Delphi Minimal API: einfache, schnelle REST-APIs mit DMVCFramework
🇬🇧 English • 🇮🇹 Italiano • 🇪🇸 Español • 🇧🇷 Português
Einfache, schnelle REST-APIs und Web-Apps in Delphi — Routen mit anonymen Methoden, ohne Controller-Klasse, mit DMVCFramework, dem beliebtesten Open-Source-Delphi-Framework.
Ein neuer Weg, APIs in Delphi mit DMVCFramework zu schreiben
DMVCFramework wurde geschaffen, um “große”, komplexe Probleme zu lösen: langlebige Serveranwendungen mit vielen Endpunkten, komplexen Geschäftsregeln und Teams, die jahrelang daran arbeiten. Genau deshalb war das Controller-Action-Modell schon immer das Herzstück des Frameworks. Es ist robust, testbar, skalierbar und erzwingt eine klare Struktur, je größer das Projekt wird: Es ist diese Solidität, die DMVCFramework zum am weitesten verbreiteten Open-Source-Projekt für Webanwendungen in Delphi gemacht hat, und das wird auch so bleiben.
In den letzten Jahren habe ich jedoch beobachtet, wie sich ein neuer Ansatz im Ökosystem etabliert hat: FastAPI in Python, Minimal API in ASP.NET Core, Hono in TypeScript. Alle bieten für bestimmte Szenarien eine direktere Alternative: Microservices, Prototypen, einfache APIs.
Ich wollte dieselbe Wahlmöglichkeit nach DMVCFramework bringen. Und es funktioniert.
Das Projekt hat inzwischen mehr als 50 Mitwirkende: eine wachsende Community, die es ermöglicht, an ambitionierten Features wie diesem zu arbeiten.
Und es geht nicht nur um Code: Die Minimal API ist bereits in den IDE-Wizard integriert, mit zwei neuen Vorlagen im Delphi Object Repository (wir sehen sie uns später im Detail an).
Hallo, Welt
Zuallererst: Wie minimal ist es wirklich?
AEngine.Root
.MapGet('/hello',
function: IMVCResponse
begin
Result := Ok('Hello, World!');
end);
Drei Zeilen. Keine Klasse, kein Attribut, keine eigene Datei.
Was gibt es zurück? Die anonyme Methode hat den Rückgabetyp IMVCResponse, das Objekt, das die gesamte
HTTP-Antwort beschreibt (Status, Header, Body). Ok(...) ist eine Factory, die eines mit dem Status
200 OK erzeugt, und der Body ist immer ein JSON-Objekt: Übergibt man ihr einen String, wird dieser
im Feld message gekapselt. Die Antwort, die beim Client ankommt, lautet also:
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
{"message":"Hello, World!"}
Übergibt man Ok(...) hingegen ein Objekt oder eine Liste, landen diese im Feld data desselben
JSON-Objekts ({"data": ...}): Es ist derselbe Mechanismus wie beim Controller-Modell, hier über eine
Funktion statt über eine Methode bereitgestellt.
So sieht es in einem echten Projekt aus
Bevor wir uns den Code ansehen, ein Wort zu den Filtern. Es gibt zwei Arten: EndpointFilter,
die sich über .Use(...) an eine bestimmte Routengruppe hängen, und HTTPFilter, die auf Engine-Ebene
registriert werden und unabhängig vom Routing auf jede Anfrage angewendet werden, wobei sie direkt auf
dem HTTP-Transport (Ctx.Request/Ctx.Response) arbeiten. Beide sind das konzeptionelle Äquivalent zur
klassischen Middleware von DMVCFramework, und wir gehen später ausführlich darauf ein. Im folgenden
Beispiel verwenden wir die ersteren: Jede Route der Gruppe durchläuft sie der Reihe nach, bevor sie den
Handler erreicht. Fürs Erste genügt es zu wissen, dass .Use() bedeutet:
“Wende dieses Verhalten auf alles Folgende an.”
Hier ist eine vollständige REST-API (JWT-Authentifizierung, Logging, CRUD-Endpunkte) in einer einzigen Datei, ohne eine einzige Controller-Klasse:
procedure RegisterRoutes(AEngine: TMVCEngine);
begin
// Öffentlicher Health-Check, keine Filter
AEngine.Root
.MapGet('/health',
function: IMVCResponse
begin
Result := Ok('OK');
end);
// /api/v1-Gruppe: Authentifizierung und Logging für alle Routen
AEngine.Prefix('/api/v1')
.Use(RequestLoggingFilter())
.Use(BearerAuthFilter())
// Per Konvention wird jedes Interface (wie IPeopleService) aus dem DI-Container aufgelöst
.MapGet<IPeopleService>('/people',
function(Svc: IPeopleService): IMVCResponse
begin
Result := Ok(Svc.GetAll);
end)
.MapGet<string, IPeopleService>('/people/($id)',
function(ID: string; Svc: IPeopleService): IMVCResponse
begin
Result := Ok(Svc.GetByID(ID));
end)
.MapPost<TPerson, IPeopleService>('/people',
function(Person: TPerson; Svc: IPeopleService): IMVCResponse
begin
Svc.Create(Person);
Result := Created('/api/v1/people/' + Person.ID.ToString, Person);
end);
end;
Das ist alles. Kein [MVCPath], kein [MVCHTTPMethod], keine Controller-Klasse zu deklarieren.
Große Anwendungen: Struktur verschwindet nicht, Sie wählen sie
Die berechtigte Frage lautet: “Was ist, wenn ich 80 Routen habe?”
Kein Minimal-Routing-Framework skaliert gut, wenn alles in einem einzigen RegisterRoutes landet.
Der Unterschied ist, dass die Struktur bei DMVC Minimal API nicht durch die Syntax aufgezwungen wird,
sondern von Ihnen, auf die für Ihre Domäne sinnvollste Weise bestimmt.
Das naheliegende Muster ist, Routen nach Fachbereich aufzuteilen, jeder in einer eigenen Prozedur (oder Unit), wobei die Gruppe als Parameter übergeben wird:
// PeopleRoutesU.pas
procedure RegisterPeopleRoutes(AGroup: TMVCRouteGroup<TObject>);
begin
AGroup
.MapGet<IPeopleService>('/people', ...)
.MapGet<string, IPeopleService>('/people/($id)', ...)
.MapPost<TPerson, IPeopleService>('/people', ...)
.MapPut<string, TPerson, IPeopleService>('/people/($id)', ...)
.MapDelete<string, IPeopleService>('/people/($id)', ...);
end;
// OrdersRoutesU.pas
procedure RegisterOrdersRoutes(AGroup: TMVCRouteGroup<TObject>);
begin
AGroup
.MapGet<IOrderService>('/orders', ...)
.MapPost<TOrder, IOrderService>('/orders', ...);
end;
// RoutesU.pas: zentraler Zusammenführungspunkt
procedure RegisterRoutes(AEngine: TMVCEngine);
var
lAPI: TMVCRouteGroup<TObject>;
begin
lAPI := AEngine.Prefix('/api/v1')
.Use(RequestLoggingFilter())
.Use(BearerAuthFilter());
RegisterPeopleRoutes(lAPI);
RegisterOrdersRoutes(lAPI);
end;
Das Ergebnis ist eine explizite Baumstruktur: Gemeinsame Filter leben an einer Stelle, jeder Fachbereich in seiner eigenen Datei, und der Zusammenführungspunkt ist eine Liste von Aufrufen, die in zehn Sekunden lesbar ist.
Beim klassischen Controller-Modell ist Struktur vorhanden, aber sie wird durch die Syntax erzwungen: Jede Ressource benötigt eine eigene Klasse. Mit Minimal API ist sie optional: Man kann mit allem in einer Datei beginnen und refaktorieren, wenn es sinnvoll ist, ohne irgendetwas neu schreiben zu müssen.
Wie die Argumentbindung funktioniert
Jeder Parameter der anonymen Methode wird nach dem Typ automatisch aufgelöst:
| Parametertyp | Quelle |
|---|---|
TWebContext |
HTTP-Kontext der aktuellen Anfrage |
| Interface im DI-Container | Injizierter Service |
| Klasse | JSON-Body (POST/PUT/PATCH) oder Query-String (GET/DELETE) |
Record mit [MVCFromBody] / [MVCFromQueryString] / [MVCFromContentField]-Attributen |
Gemischte Bindung, Feld für Feld |
string, Integer, TGUID, TDateTime, … |
Routenparameter (falls vorhanden), sonst Query-String |
Es ist nicht nötig anzugeben, woher jeder Parameter stammt: Das Framework leitet es aus dem Typ und dem Kontext ab.
Filter: zwei Ebenen, eine Pipeline
Eines der Dinge, mit denen ich bei dieser Implementierung am zufriedensten bin, ist das zweistufige Filtermodell.
EndpointFilter: pro Gruppe, wird nur ausgelöst, wenn eine Route übereinstimmt:
AEngine.Prefix('/admin')
.Use(
function(Ctx: TWebContext; Next: TMVCEndpointFilterNext): IMVCResponse
begin
if not IsAdmin(Ctx) then
Result := Status(403, 'Forbidden')
else
Result := Next(); // weiter zum nächsten Filter oder zum Handler
end);
HTTPFilter: wird auf Engine-Ebene registriert und umhüllt die gesamte Verarbeitung jeder Anfrage,
noch bevor das Routing stattfindet, wobei er direkt auf dem HTTP-Transport
(Ctx.Request/Ctx.Response) arbeitet. Ideal für Rate-Limiting, statische Dateien, Sicherheits-Header,
Komprimierung:
AEngine.UseHTTPFilter(
procedure(Ctx: TWebContext; Next: TMVCHTTPFilterNext)
begin
Ctx.Response.SetCustomHeader('X-Frame-Options', 'DENY');
Next(); // lässt die Pipeline weiterlaufen
end);
Beide Typen werden in der Reihenfolge ihrer Registrierung zusammengesetzt. Die Reihenfolge ist vorhersehbar, explizit und an einer Stelle lesbar.
Nicht nur APIs: auch Web-Apps
Minimal API ist nicht auf JSON beschränkt. Mit .AsWeb wird eine Gruppe zu einer Sammlung von
serverseitig gerenderten Routen, die HTML über TemplatePro zurückgeben. RenderView und ViewData
funktionieren wie erwartet, und die Session ist einfach ein Filter:
AEngine.Root.AsWeb
.Use(MemorySession(10))
.MapGet('/',
function: IMVCResponse
begin
ViewData['title'] := 'Home';
Result := RenderView('pages/home');
end);
Routen vom Typ rkWeb werden automatisch aus der OpenAPI-Spezifikation ausgeschlossen: Das Framework
weiß, wie es API-Endpunkte von HTML-Seiten unterscheidet.
IDE-Wizard-Unterstützung
Der Wizard sollte nicht außen vor bleiben. Im Delphi Object Repository finden Sie zwei neue Voreinstellungen:
- Minimal API RESTful: reine JSON-API, gruppenbasierte Filter, DI-Container
- Minimal API Web App:
.AsWeb-Routen, Sessions, Login/Logout, HTMX-bereit
Ein funktionsfähiges Projekt, null Konfiguration, in 30 Sekunden einsatzbereit.
Status
Das Feature ist ab Version 3.5.0-silicon-rc3 auf dem master-Branch verfügbar.
Alle 872 Integrationstests bestehen auf Win32 und Win64, gegen drei verschiedene Server-Backends
(WebBroker, Indy Direct, HTTP.sys): 5.232 Ausführungen insgesamt über alle Plattform- und Server-Kombinationen.
Als Nächstes:
- Benchmark Classic API vs. Minimal API (Spoiler: die Ergebnisse haben mich überrascht)
- OpenAPI-3.x-Integration
- Ein dedizierter Leitfaden mit praxisnahen Mustern, fortgeschrittenen Filtern und DI-Container-Integration
Daniele
Comments
comments powered by Disqus