Become a member!

DelphiMVCFramework 3.5.0-silicon RC6: drei Hosts, dieselben Controller

🌐
Dieser Artikel ist auch in anderen Sprachen verfügbar:
🇬🇧 English  •  🇮🇹 Italiano  •  🇪🇸 Español  •  🇧🇷 Português  •  🇫🇷 Français

DelphiMVCFramework, das Open-Source-Framework für REST und Web in Delphi

DelphiMVCFramework, das verbreitetste Open-Source-Framework für REST-APIs und Web-Anwendungen in Delphi, bekommt drei austauschbare HTTP-Hosts, Lambda-Routen und einen Streaming-JSON-Serializer.

DelphiMVCFramework 3.5.0-silicon RC6 ist verfügbar. Das ist ein Release Candidate, nicht die fertige 3.5.0: der Funktionsumfang steht, die Testmatrix ist grün, aber der Sinn der Veröffentlichung ist, dass die Version auf Maschinen läuft, die nicht meine sind, bevor das stabile Tag kommt.

Die drei Dinge, die 3.5 von 3.4.x unterscheiden, sind die austauschbaren Server-Hosts, die Minimal API und ein Streaming-JSON-Serializer auf dem heißen Pfad der Antwort. Dazu kommen drei Breaking Changes, alle klein, und unten steht zu jedem genau die Änderung, die er dich kostet.

Den Download gibt es auf GitHub.


Drei Server-Hosts, ein Controller-Stack

Bis 3.4.x gab es genau einen Weg, eine DMVCFramework-Anwendung auf einen Socket zu bringen: WebBroker, mit einem WebModule und TIdHTTPWebBrokerBridge darunter. Das funktioniert, es funktioniert seit Jahren, und für ISAPI- und Apache-Deployments ist es weiterhin die richtige Antwort. Für alles andere hast du jetzt die Wahl.

3.5 führt ein Interface IMVCServer (MVCFramework.Server.Intf) mit drei Implementierungen ein, ausgewählt über TMVCServerFactory:

Host Konstruktor Wofür er da ist
Indy Direct TMVCServerFactory.CreateIndyDirect(LEngine) Der neue Standard für neue Projekte. Ein direkter TIdHTTPServer, kein WebModule, keine WebBroker-Schicht.
HTTP.sys TMVCServerFactory.CreateHttpSys(LEngine) HTTP im Windows-Kernel-Mode. Braucht Administratorrechte oder ein netsh http add urlacl, das du auf der nächsten Maschine vergessen haben wirst.
WebBroker TMVCServerFactory.CreateWebBroker(AConfigAction, AEngineConfig) ISAPI, Apache-Module und Anwendungen, die schon auf einem WebModule aufbauen.

Egal welchen Host du wählst, dein eigener Code bleibt gleich. Controller, Actions, Entities und Middleware verhalten sich identisch, und der Wechsel bedeutet, die .dpr zu bearbeiten:

// Indy Direct: der Standard für einen neuen Konsolen-Server
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);

// HTTP.sys: gleiche Engine, gleiche Controller
LServer := TMVCServerFactory.CreateHttpSys(LEngine);

// WebBroker: dasselbe noch einmal, beim Deployment nach ISAPI oder Apache
LServer := TMVCServerFactory.CreateWebBroker(nil, ConfigureEngine);

IMVCServer bietet Listen, Stop, IsRunning und RunAndWait. Die letzte Methode ist die Abkürzung für Konsolenprogramme: sie ruft Listen auf, blockiert bis zum Terminierungssignal und ruft dann Stop auf. Ruf sie nicht aus einem VCL- oder FMX-Formular auf: dort besitzt der Haupt-Thread bereits eine Message Loop, und RunAndWait nimmt sie bereitwillig als Geisel, bis du den Prozess im Task-Manager abschießt. In einem Formular rufst du Listen und Stop selbst auf und lässt das Formular entscheiden, wann was passiert.

LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
LServer.RunAndWait(8080);

HTTPS wird jetzt am Server-Objekt konfiguriert statt an der Indy-Komponente:

uses
  MVCFramework.Server.HTTPS.TaurusTLS;
...
LServer.HTTPSConfigurator := TaurusTLSIndyConfigurator();
LServer.UseHTTPS := True;
LServer.CertFile := 'certificates\localhost.crt';
LServer.KeyFile := 'certificates\localhost.key';

Jedes Backend behandelt TLS hinter derselben API auf seine Art: Indy Direct und WebBroker nutzen TaurusTLS mit den Zertifikats-Properties von oben, während HTTP.sys sein Zertifikat aus netsh http add sslcert bezieht und UseHTTPS nur das registrierte Präfix auf https:// umstellt.

Das Sample samples/server_types ist der Beleg für das, was ich gerade behauptet habe: eine einzige Controller-Unit in commons, sechs Projekte darum herum (Indy Direct, HTTP.sys, WebBroker standalone, WebBroker über IMVCServer, ISAPI, Apache-Modul). Die Controller-Datei ist geteilt, nicht kopiert.

WebBroker bleibt unterstützt, unbefristet. Er ist eine von drei Optionen. ISAPI- und Apache-Deployments laufen darüber, bestehende Anwendungen kompilieren unverändert weiter, und der Konstruktor TMVCEngine.Create(AWebModule) funktioniert nach wie vor (er ist als deprecated markiert zugunsten von TMVCEngine.CreateForWebBroker, was eine Umbenennung ist, keine Entfernung).


Minimal API

Die zweite Neuerung ist ein Routing-Stil, der ohne Controller-Klasse auskommt. MVCFramework.MinimalAPI lässt dich einen Handler direkt an einer Routengruppe registrieren:

procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
var
  lPeople: TMVCRouteGroup<TObject>;
begin
  lPeople := ARoot.Prefix('/people').Use(LogFilter());

  // ein Interface-Argument wird aus dem Service Container aufgelöst
  lPeople.MapGet<IPeopleService>('',
    function (Svc: IPeopleService): IMVCResponse
    begin
      Result := Ok(Svc.GetAll);
    end);

  // ein primitives Argument wird an das nächste Routensegment gebunden
  lPeople.MapGet<Integer>('/($id:int)',
    function (ID: Integer): IMVCResponse
    begin
      Result := Ok(TPerson.Create(ID, 'Daniele', 'Teti', EncodeDate(1979, 11, 4)));
    end);

  // ein Klassen-Argument kommt aus dem Body und wird vor dem Handler validiert
  lPeople.MapPost<TPersonInput>('',
    function (Input: TPersonInput): IMVCResponse
    begin
      Result := Created('', 'Person created');
    end).WithSummary('Create a new person (validated)');
end;

MapGet, MapPost, MapPut, MapDelete und MapPatch decken die einzelnen Verben ab; MapMethods nimmt ein Array davon, für die Fälle, in denen ein Handler mehrere beantwortet:

lPeople.MapMethods<Integer>([httpPUT, httpPATCH], '/($id:int)',
  function (ID: Integer): IMVCResponse
  begin
    Result := Ok('updated ' + ID.ToString);
  end);

Handler sind function(...): IMVCResponse mit höchstens vier typisierten Argumenten, und gebunden wird per Typ, nicht per Name und nicht über eine Position, die du dir merken musst. Ein Interface-Argument wird aus dem Service Container aufgelöst. Ein primitives Argument (Integer, Int64, string, Boolean, Double, TGUID, TDateTime) wird an das nächste noch nicht verbrauchte Routensegment gebunden, in Deklarationsreihenfolge. Eine Klasse oder ein Record kommt aus dem Body, und ein Record kann pro Feld eine Quelle deklarieren, mit [MVCFromQueryString], [MVCFromHeader], [MVCFromCookie], [MVCFromContentField] und [MVCFromBody]. Ein TMVCFormFile-Argument bindet die erste hochgeladene Multipart-Datei. Klassen, die von TMVCValidatable abstammen, werden vor dem Betreten des Handlers validiert, eine ungültige Payload bricht also sofort mit einem 400 und einem ProblemDetails-Body ab. Routen-Constraints wie ($id:int) weisen eine nicht numerische ID mit einem 404 ab, bevor dein Code startet.

Zwei Details scheitern leise statt laut.

TMVCRouteGroup<T> ist ein Record: Use, Prefix und AsWeb geben eine neue Gruppe zurück, statt die Gruppe zu ändern, auf der du sie aufgerufen hast. Wirfst du das Ergebnis weg, bekommst du Code, der sauber kompiliert, sauber läuft und deinen Filter ignoriert:

// falsch: die zurückgegebene Gruppe wird weggeworfen, LogFilter läuft nie
ARoot.Prefix('/people').Use(LogFilter());

// richtig: die Gruppe behalten und die Routen darauf registrieren
lPeople := ARoot.Prefix('/people').Use(LogFilter());
lPeople.MapGet<IPeopleService>('', ...);

Klassische Middleware muss vor dem ersten MapXxx registriert werden. Der minimale Dispatcher wird beim ersten Map-Aufruf lazy installiert und beantwortet passende Requests sofort selbst. Was danach mit AddMiddleware dazukommt, bekommt diese Requests nicht mehr zu sehen.

Zwei vollständige, kompilierbare Samples liegen in samples/wizard_showcase/rest/ (REST) und samples/wizard_showcase/web/ (TemplatePro und HTMX über .AsWeb). Beide sind ausführlich kommentiert und der schnellste Weg, alle Bindungsarten auf einem Bildschirm zu sehen. Eine längere Einführung in die Minimal API, mit den Überlegungen hinter den Bindungsregeln, steht in Delphi Minimal API: schnelle REST-APIs mit DMVCFramework.


Filter

MVCFramework.Filters ist die moderne API neben der Middleware, die du schon kennst. Es gibt zwei Sorten.

TMVCEndpointFilter hängt an einer Routengruppe und läuft nur, wenn eine Route dieser Gruppe trifft. Er ist eine Closure, die den Context und eine Next-Fortsetzung bekommt und den Handler damit umschließt:

function LogFilter: TMVCEndpointFilter;
begin
  Result := function (const Ctx: TWebContext;
                      const Next: TMVCEndpointFilterNext): IMVCResponse
    begin
      LogI('-> ' + Ctx.Request.PathInfo);
      Result := Next();
      LogI('<- status ' + Result.StatusCode.ToString);
    end;
end;

TMVCHTTPFilter gilt engine-weit und umschließt das Routing selbst, was du für alles brauchst, was greift, bevor überhaupt eine Route gewählt wird:

lEngine
  .UseHTTPFilter(SecurityHeaders)
  .UseHTTPFilter(RateLimit(100, 60))   // 100 Requests pro Minute und IP
  .UseHTTPFilter(Compression(1024))
  .UseHTTPFilter(StaticFiles('/static', 'www'));

18 der 19 klassischen Middleware-Helper haben ein Filter-Äquivalent (MemorySession, CORS, JWT, ActiveRecord, ETag, Analytics, Trace, Redirect, Swagger und die übrigen); nur OIDC gibt es weiterhin ausschließlich als Middleware. Dazu kommt RangeMedia, das Dateien mit HTTP-Range-Unterstützung ausliefert (RFC 7233), damit HTML5-<audio>- und <video>-Elemente springen können, und ein Redis-gestütztes RateLimitRedis in der Begleit-Unit MVCFramework.Filters.Redis für Deployments hinter einem Load Balancer.


Streaming-JSON-Serializer

OKResponse(TObject) und OKResponse(TObjectList<T>) haben jetzt einen schnellen Pfad (MVCFramework.Serializer.Streaming). Statt einen TJDOJsonObject-Baum zu bauen, ihn in einen UTF-16-Delphi-String zu wandeln und den wieder nach UTF-8 zu kodieren, schreibt er das JSON über System.JSON.Writers.TJsonTextWriter direkt in den Response-Stream, mit einem pro Klasse gecachten Emissionsplan. Kein Zwischenbaum, kein Zwischenstring.

Er braucht Delphi 10.3 Rio oder neuer. Auf älteren Compilern ist die neue Unit ein Stub, und es wird unverändert der bisherige Serializer verwendet.

Der Streaming-Pfad hat volle Funktionsparität mit dem bisherigen Serializer, und Parität heißt hier byteidentische Ausgabe, geprüft über 50 Szenarien durch einen eigenen Prüfstand (performancetest/parity/ParityCheck.exe): jeder primitive Typ, jedes NullableXxx-Record, verschachtelte Objekte mit Zyklenerkennung beim Bau des Plans, TObjectList<T> und TList<T> mit polymorpher Auflösung pro Element, TArray<T>, Streams als base64, TDataSet-Properties (delegiert an den bisherigen Dataset-Serializer, Groß- und Kleinschreibung der Namen, ignorierte Felder, verschachtelte Datasets und Blob-Behandlung verhalten sich also genau wie zuvor) und die Attribute MVCNameAs, MVCNameCase und MVCDoNotSerialize.

Zwei Formen bleiben bewusst beim bisherigen Serializer: Klassen mit [MVCSerialize(stFields)] und Properties, deren Typ einen eigenen IMVCTypeSerializer registriert hat. Auch dort ist die Ausgabe byteidentisch.

Taucht mitten in der Ausgabe doch etwas nicht Unterstütztes auf, etwa bei einem zur Laufzeit aufgelösten polymorphen Listenelement, spult der Streaming-Writer den Ausgabestream auf die Marke zurück, die er sich vor dem ersten Schreiben gesetzt hat, verwirft seinen thread-lokalen Zustand und gibt False zurück, sodass der Aufrufer die gesamte Antwort über den bisherigen Pfad neu serialisiert. Auf die Leitung geht nie eine halb geschriebene Antwort.

Verwandt, aber eine andere Sache: ein Forward-only-Dataset kann jetzt Datensatz für Datensatz zum Client gestreamt werden, mit flachem Speicherbedarf auf dem Server, statt vollständig materialisiert zu werden:

[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
function GetCustomers: TMVCStreamedResponse;
begin
  Result := StreamDataSet(qry);
end;

Chunked Streaming braucht ein Backend, das den Socket herausgeben kann, und läuft deshalb auf Indy Direct und HTTP.sys; auf WebBroker scheitert es sauber mit einem 501, bevor ein einziges Byte gesendet wurde.


ActiveRecord

Die wichtigste Änderung sind zusammengesetzte Primärschlüssel. Jahrelang hat TMVCActiveRecord Primärschlüssel bewusst einfach gehalten: genau eine foPrimaryKey-Spalte, was die große Mehrheit der Tabellen abdeckt und das erzeugte SQL vorhersagbar hält. Verknüpfungstabellen und natürlich mehrspaltige Schlüssel ((order_id, line_no), (tenant, code)) bekamen eine künstliche id plus einen UNIQUE-Constraint auf dem echten Schlüssel. Das funktioniert, um den Preis, dass die Zeilen über eine Spalte geladen werden, nach der niemand sucht. Ab 3.5 markierst du jede Schlüsselspalte so, wie du bisher eine markiert hast:

[MVCTable('user_roles')]
TUserRole = class(TMVCActiveRecord)
private
  [MVCTableField('user_id', [foPrimaryKey])]
  fUserID: Integer;
  [MVCTableField('role_id', [foPrimaryKey])]
  fRoleID: Integer;
  // ...
end;

Die schlüsselbasierten Methoden haben Plural-Gegenstücke bekommen, LoadByPKs, GetByPKs, GetPKs, SetPKs, dazu HasCompositePK, wenn du danach fragen musst:

lRole := TMVCActiveRecord.GetByPKs<TUserRole>([1, 42]);

Load und Refresh scheitern laut: ein Schlüssel, der keine Zeile trifft, löst eine Exception aus. Was du nach dem Aufruf in der Hand hältst, ist also immer eine echte Zeile. In TMVCActiveRecordController wandert ein zusammengesetzter Schlüssel als JSON-Array im URL-Segment, GET /user_roles/[1,42], während Entities mit einem einzigen Schlüssel das vertraute /customers/1 behalten. Für sie ist das erzeugte SQL außerdem byteidentisch mit dem von 3.4.x: der zusammengesetzte Pfad wird erst aktiv, wenn eine zweite foPrimaryKey deklariert ist.

Neben den Klassenmethoden gibt es IMVCRepository<T> (MVCFramework.Repository) auf denselben Entities. Als Interface lässt es sich im Container registrieren und mit [MVCInject] in Controller und Services injizieren, und genau das ist der Unterschied, der zählt, wenn du es in einem Test ersetzen willst.

Die gemeinsame ActiveRecord-Suite läuft jetzt gegen SQLite, Firebird, PostgreSQL, MySQL/MariaDB, InterBase und Oracle.

Einen längeren Durchgang zu zusammengesetzten Schlüsseln, inklusive der Fragen, die dieser Abschnitt überspringt, gibt es in Zusammengesetzte Primärschlüssel in Delphi MVC Framework ActiveRecord.


Breaking Changes

Drei, und jeder davon ist eine kleine Änderung oder gar keine. Wenn du von 3.4.x aktualisierst, ist das der Abschnitt, den du in Ruhe lesen solltest.

1. TGUID serialisiert ohne geschweifte Klammern

Vorher:

{ "id": "{550E8400-E29B-41D4-A716-446655440000}" }

Nachher:

{ "id": "550e8400-e29b-41d4-a716-446655440000" }

Der neue Standard ist RFC 4122, und genau das erwarten JavaScript, Java, Python, .NET und Datenbank-Clients. Auf die Füße fällt das nur Delphi-Aufrufern, die Antworten mit einer Regex parsen, die die geschweiften Klammern voraussetzt. Das alte Format stellst du global beim Start wieder her:

uses MVCFramework.Serializer.Commons;
...
MVCGuidSerializationTypeDefault := gstBraces;

oder pro Feld mit [MVCGuidSerialization(gstBraces)].

2. Ein TDate / TDateTime / TTime mit dem Wert 0 serialisiert nicht mehr als null

Vorher gab ein TDateTime mit dem Wert 0 ein null aus, weil das Framework die 0 als Marker für “nicht gesetzt” nutzte, aus einer Zeit, in der es NullableDateTime noch nicht gab. Jetzt ist die 0 das, was sie tatsächlich ist, ein gültiger Zeitpunkt:

{ "when": "1899-12-30T00:00:00.000+00:00" }

Hier gibt es kein Flag, um das alte Verhalten zurückzuholen, und das ist Absicht: der Marker war verlustbehaftet und hat Round-Trips kaputt gemacht. Wenn ein Feld wirklich fehlen darf, deklariere es als NullableTDateTime, das HasValue = False als null serialisiert und die 0 weiterhin 0 bedeuten lässt.

3. TMVCListener ist jetzt ein Indy-Direct-Server und ist deprecated

TMVCListener und TMVCListenerProperties (MVCFramework.Server) brauchten früher eine TWebModuleClass und liefen auf TIdHTTPWebBrokerBridge. Sie hosten jetzt eine TMVCEngine direkt auf TMVCIndyServer, ohne WebBroker-Schicht, und damit hat sich die Konfigurations-API geändert: SetWebModuleClass und SetSSLOptions sind weg, ersetzt durch SetConfigAction (Engine-Konfigurationsschlüssel, angewendet während die Engine erzeugt wird) und SetEngineConfig (Controller und Middleware, angewendet danach).

Vorher:

TMVCListener.Create(TMVCListenerProperties.New
  .SetName('App').SetPort(8080)
  .SetWebModuleClass(TMyWebModule));

Nachher:

TMVCListener.Create(TMVCListenerProperties.New
  .SetName('App').SetPort(8080)
  .SetEngineConfig(
    procedure(AEngine: TMVCEngine)
    begin
      AEngine.AddController(TMyController);
      AEngine.AddMiddleware(UseMemorySessionMiddleware(0));
    end));

Die Migration ist mechanisch: der Rumpf des alten WebModuleCreate, also die AddController- und AddMiddleware-Aufrufe, wandert in die Prozedur von SetEngineConfig, und TMVCConfig-Zuweisungen wandern nach SetConfigAction.

TMVCListener ist außerdem deprecated und wird in 4.0 entfernt. Nach der Umstellung ist er ein dünner Wrapper über IMVCServer, der strikt weniger bietet: nur Indy, kein HTTPS, nur MaxConnections. Bau deine Server stattdessen über TMVCServerFactory, das ist derselbe Lebenszyklus, mit den anderen beiden Backends und eingebautem TLS. Bestehender Code kompiliert in der Zwischenzeit weiter, mit einer Deprecation-Warnung.


Performance

Alle Zahlen unten sind der Median aus 3 Läufen zu 30 Sekunden bei c=100, auf einem HTTP.sys-Loopback-Bench: i9-13980HX, Windows 11, Release Win64. Dieser Kontext gehört zu den Zahlen: eine Durchsatzangabe ohne Maschine, Concurrency und Transport dahinter sagt nichts.

Szenario Vorher Nachher Delta
health 2354 3380 +44%
json/small 2099 2858 +36%
json/large 735 889 +21%
heavy chain 1874 3131 +67%
upload 1 MB 95 892 +839%
pods/small (*) neu 3132 +18,6% gegenüber Legacy
pods/large (*) neu 438 +74,6% gegenüber Legacy

(*) neue Benchmark-Szenarien, in 3.5.x eingeführt, um den Streaming-Serializer zu fordern.

Die Zugewinne sind von zweierlei Art. Die Routentabelle (einmalig zur AddController-Zeit berechnet und nach Methode, dann nach Pfad indiziert, statt eines RTTI-Scans pro Request) und der schnelle Render-Pfad für OKResponse(TJsonBaseObject) sind übergreifende Optimierungen: sie helfen jedem Backend und liegen bei dieser Last im Bereich von 20% bis 70%.

Die Zeile mit dem 1-MB-Upload ist eine andere Geschichte. Der HTTP.sys-Listener las den Body und fuhr die gesamte Pipeline auf dem Listener-Thread, ein Request nach dem anderen: bei einem Health-Check sieht man das nicht, bei einem Megabyte sehr deutlich. RC6 schickt beides in den Standard-Task-Pool, und wenn Content-Length bekannt ist, schreibt es den Body direkt in ein vorab dimensioniertes TBytes statt in einen TMemoryStream mit anschließendem SetLength und Move. Lies die 892 rps also so, dass HTTP.sys endlich die richtige Frage gestellt bekommt, nicht als neuen Trick.

Eine Zeile geht in die andere Richtung: heavy auf Indy Direct wurde mit -9% gemessen. Auf einer Bench-Maschine mit rund 20% Streuung zwischen den Läufen liest sich das als neutral, nicht als Regression. Unterschiede unter etwa 15% sind auf dieser Kiste Rauschen. Der Vergleich zwischen den Backends und die WebBroker-Läufe (bei c=100 auf dieser Maschine nicht vergleichbar, dort bleibt der Server nicht über den ganzen Lauf oben) stehen in performancetest/results/BASELINE_AFTER.md.


Ausprobieren

Zwei Wege hinein.

Lade das Zip von der Release-Seite, füge sources deinem Library-Pfad hinzu, und für ein bestehendes Projekt ist das die ganze Einrichtung.

Oder du installierst den IDE-Wizard und lässt dir ein Projekt generieren. Die Presets erscheinen im New Items-Dialog der IDE, unter Delphi > DelphiMVCFramework:

Die 8 DelphiMVCFramework-Projektpresets im New-Items-Dialog von RAD Studio

Mitgeliefert werden 8 Projektpresets: RESTful API, Minimal API RESTful, Web Application, Minimal API WebApp, JSON-RPC Service, Real-Time Application (WebSocket), Full-Stack Application und Custom Project mit allen Optionen offen. Jedes Preset füllt dasselbe Wizard-Formular mit anderen Vorgaben, du kannst sie also übernehmen oder vor dem Generieren alles ändern. Der Standard-Host ist in jedem Preset Indy Direct.

Wenn etwas kaputtgeht, oder wenn ein Upgrade von 3.4.x einen Schritt braucht, der oben nicht im Abschnitt zu den Breaking Changes steht, mach bitte vor dem stabilen Tag ein Issue auf GitHub auf. Genau dafür ist ein Release Candidate da. Ein jetzt gefundener Bug ist ein Fix in 3.5.0; derselbe Bug später gefunden ist ein Fix in 3.5.1 und ein Nachmittag aus dem Leben von irgendwem.


Ressourcen

PATREON Community

Enjoy!

– Daniele Teti

Comments

comments powered by Disqus