DelphiMVCFramework 3.5.0-silicon RC6 : trois hosts, les mêmes controllers
🇬🇧 English • 🇮🇹 Italiano • 🇪🇸 Español • 🇩🇪 Deutsch • 🇧🇷 Português

DelphiMVCFramework, le framework open source le plus utilisé pour écrire des API REST et des applications web en Delphi, gagne trois hosts HTTP interchangeables, les routes lambda et un sérialiseur JSON en streaming.
DelphiMVCFramework 3.5.0-silicon RC6 est disponible. C’est une release candidate, pas la 3.5.0 définitive : les fonctionnalités sont gelées et la matrice de tests est au vert, mais la publier sert à la faire tourner sur des machines qui ne sont pas la mienne avant le tag stable.
Les trois choses qui séparent la 3.5 de la 3.4.x, ce sont les hosts serveur interchangeables, la Minimal API et un sérialiseur JSON en streaming sur le chemin chaud de la réponse. Il y a aussi trois breaking changes, tous petits, et pour chacun tu trouveras plus bas la modification exacte qu’il te coûte.
Télécharge-la depuis GitHub.
Trois hosts pour le serveur, un seul stack de controllers
Jusqu’à la 3.4.x, il y avait une seule façon de poser une application DMVCFramework sur un socket : WebBroker, avec un WebModule et TIdHTTPWebBrokerBridge dessous. Ça marche, ça marche depuis des années, et pour les déploiements ISAPI et Apache c’est toujours la bonne réponse. Pour tout le reste, tu as maintenant le choix.
La 3.5 introduit une interface IMVCServer (MVCFramework.Server.Intf) avec trois implémentations, choisies via TMVCServerFactory :
| Host | Constructeur | À quoi ça sert |
|---|---|---|
| Indy Direct | TMVCServerFactory.CreateIndyDirect(LEngine) |
Le nouveau défaut pour les nouveaux projets. Un TIdHTTPServer direct, pas de WebModule, pas de couche WebBroker. |
| HTTP.sys | TMVCServerFactory.CreateHttpSys(LEngine) |
HTTP en kernel mode Windows. Demande les droits d’administrateur, ou un netsh http add urlacl que tu oublieras de lancer sur la prochaine machine. |
| WebBroker | TMVCServerFactory.CreateWebBroker(AConfigAction, AEngineConfig) |
ISAPI, modules Apache, et applications déjà construites sur un WebModule. |
Quel que soit l’host que tu choisisses, ton code à toi reste le même. Controllers, actions, entités et middleware se comportent de façon identique, et changer d’host veut dire modifier le .dpr :
// Indy Direct : le défaut pour un nouveau serveur console
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
// HTTP.sys : même engine, mêmes controllers
LServer := TMVCServerFactory.CreateHttpSys(LEngine);
// WebBroker : pareil, quand tu déploies en ISAPI ou Apache
LServer := TMVCServerFactory.CreateWebBroker(nil, ConfigureEngine);
IMVCServer expose Listen, Stop, IsRunning et RunAndWait. Le dernier est le raccourci pour la console : il appelle Listen, se bloque sur le signal de terminaison, puis appelle Stop. Ne l’appelle pas depuis une fiche VCL ou FMX : le thread principal y possède déjà une boucle de messages, et RunAndWait la prend volontiers en otage jusqu’à ce que tu ailles tuer le processus depuis le Gestionnaire des tâches. Sur une fiche, tu utilises Listen et Stop, et tu laisses la fiche décider quand chacun s’exécute.
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
LServer.RunAndWait(8080);
HTTPS se configure désormais sur l’objet serveur plutôt que sur le composant Indy :
uses
MVCFramework.Server.HTTPS.TaurusTLS;
...
LServer.HTTPSConfigurator := TaurusTLSIndyConfigurator();
LServer.UseHTTPS := True;
LServer.CertFile := 'certificates\localhost.crt';
LServer.KeyFile := 'certificates\localhost.key';
Chaque backend gère TLS à sa manière derrière cette même API : Indy Direct et WebBroker utilisent TaurusTLS avec les propriétés de certificat ci-dessus, tandis que HTTP.sys prend son certificat dans netsh http add sslcert et UseHTTPS se contente de basculer le préfixe enregistré sur https://.
Le sample samples/server_types est la démonstration de ce que je viens d’écrire : une seule unit de controller dans commons, six projets autour (Indy Direct, HTTP.sys, WebBroker standalone, WebBroker à travers IMVCServer, ISAPI, module Apache). Le fichier du controller est partagé, pas copié.
WebBroker reste supporté, pour une durée indéterminée. C’est une option sur trois. Les déploiements ISAPI et Apache passent par là, les applications existantes continuent de compiler sans qu’on y touche, et le constructeur TMVCEngine.Create(AWebModule) fonctionne toujours (il est marqué deprecated au profit de TMVCEngine.CreateForWebBroker, qui est un renommage, pas un retrait).
Minimal API
La deuxième nouveauté est un style de routing qui fonctionne sans classe controller. MVCFramework.MinimalAPI te permet d’enregistrer un handler directement sur un groupe de routes :
procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
var
lPeople: TMVCRouteGroup<TObject>;
begin
lPeople := ARoot.Prefix('/people').Use(LogFilter());
// un argument interface est résolu depuis le service container
lPeople.MapGet<IPeopleService>('',
function (Svc: IPeopleService): IMVCResponse
begin
Result := Ok(Svc.GetAll);
end);
// un argument primitif est lié au segment de route suivant
lPeople.MapGet<Integer>('/($id:int)',
function (ID: Integer): IMVCResponse
begin
Result := Ok(TPerson.Create(ID, 'Daniele', 'Teti', EncodeDate(1979, 11, 4)));
end);
// un argument classe vient du body, et il est validé avant que le handler ne s'exécute
lPeople.MapPost<TPersonInput>('',
function (Input: TPersonInput): IMVCResponse
begin
Result := Created('', 'Person created');
end).WithSummary('Create a new person (validated)');
end;
MapGet, MapPost, MapPut, MapDelete et MapPatch couvrent un verbe chacun ; MapMethods prend un tableau de verbes, pour les cas où un seul handler en sert plusieurs :
lPeople.MapMethods<Integer>([httpPUT, httpPATCH], '/($id:int)',
function (ID: Integer): IMVCResponse
begin
Result := Ok('updated ' + ID.ToString);
end);
Les handlers sont des function(...): IMVCResponse avec au plus quatre arguments typés, et le binding se fait par type, pas par nom ni par une position qu’il faut retenir. Un argument interface est résolu depuis le service container. Un primitif (Integer, Int64, string, Boolean, Double, TGUID, TDateTime) est lié au premier segment de route non encore consommé, dans l’ordre de déclaration. Une classe ou un record vient du body, et un record peut déclarer sa source champ par champ avec [MVCFromQueryString], [MVCFromHeader], [MVCFromCookie], [MVCFromContentField] et [MVCFromBody]. Un argument TMVCFormFile est lié au premier fichier multipart envoyé. Les classes qui descendent de TMVCValidatable sont validées avant d’entrer dans le handler, donc un payload invalide ressort tout de suite avec un 400 et un body ProblemDetails. Les contraintes de route comme ($id:int) refusent un id non numérique avec un 404 avant que ton code ne démarre.
Deux détails échouent en silence plutôt qu’à voix haute.
TMVCRouteGroup<T> est un record : Use, Prefix et AsWeb renvoient un nouveau groupe au lieu de modifier celui sur lequel tu les as appelés. Si tu jettes le résultat, tu obtiens du code qui compile proprement, tourne proprement, et ignore ton filtre :
// faux : le groupe renvoyé est jeté, LogFilter ne tourne jamais
ARoot.Prefix('/people').Use(LogFilter());
// juste : garde le groupe et enregistre les routes dessus
lPeople := ARoot.Prefix('/people').Use(LogFilter());
lPeople.MapGet<IPeopleService>('', ...);
Le middleware classique doit être enregistré avant le premier MapXxx. Le dispatcher minimal n’est installé qu’au premier appel Map et court-circuite les requêtes qu’il intercepte, donc tout ce que tu ajoutes ensuite avec AddMiddleware ne les verra pas.
Deux samples complets et compilables se trouvent dans samples/wizard_showcase/rest/ (REST) et samples/wizard_showcase/web/ (TemplatePro et HTMX via .AsWeb). Ils sont très commentés, et c’est le moyen le plus rapide de voir tous les modes de binding sur un seul écran. Il y a une introduction plus longue à la Minimal API, avec le raisonnement derrière les règles de binding, dans Delphi Minimal API: Simple, Fast REST APIs with DMVCFramework (en anglais).
Filtres
MVCFramework.Filters est la surface moderne à côté du middleware que tu connais déjà. Il y en a de deux sortes.
TMVCEndpointFilter s’accroche à un groupe de routes et ne tourne que quand une route de ce groupe correspond. C’est une closure qui reçoit le context et une continuation Next, donc elle enveloppe le handler :
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 vaut pour tout l’engine et enveloppe le routing lui-même, ce qui est exactement ce qu’il te faut pour les choses qui s’appliquent avant même qu’une route soit choisie :
lEngine
.UseHTTPFilter(SecurityHeaders)
.UseHTTPFilter(RateLimit(100, 60)) // 100 requêtes par minute et par IP
.UseHTTPFilter(Compression(1024))
.UseHTTPFilter(StaticFiles('/static', 'www'));
18 des 19 helpers de middleware classiques ont un équivalent filtre (MemorySession, CORS, JWT, ActiveRecord, ETag, Analytics, Trace, Redirect, Swagger et les autres) ; OIDC est le seul à rester disponible uniquement en middleware. Il y a aussi RangeMedia, qui sert les fichiers avec le support HTTP Range (RFC 7233) pour que les éléments HTML5 <audio> et <video> puissent se déplacer dans le flux, et un RateLimitRedis basé sur Redis dans l’unit compagnon MVCFramework.Filters.Redis pour les déploiements derrière un load balancer.
Sérialiseur JSON en streaming
OKResponse(TObject) et OKResponse(TObjectList<T>) ont maintenant un chemin rapide (MVCFramework.Serializer.Streaming). Au lieu de construire un arbre TJDOJsonObject, de le convertir en chaîne Delphi UTF-16 puis de réencoder celle-ci en UTF-8, il écrit le JSON directement dans le stream de la réponse via System.JSON.Writers.TJsonTextWriter, en utilisant un plan d’émission mis en cache par classe. Pas d’arbre intermédiaire, pas de chaîne intermédiaire.
Il nécessite Delphi 10.3 Rio ou plus récent. Sur les compilateurs plus anciens, la nouvelle unit est un stub et c’est le sérialiseur legacy qui est utilisé, inchangé.
Le chemin streaming a une parité de fonctionnalités complète avec le sérialiseur legacy, et parité ici veut dire sortie identique octet par octet, vérifiée sur 50 scénarios par un harness dédié (performancetest/parity/ParityCheck.exe) : chaque type primitif, chaque record NullableXxx, les objets imbriqués avec détection des cycles au moment où le plan est construit, TObjectList<T> et TList<T> avec résolution polymorphique élément par élément, TArray<T>, les streams en base64, les propriétés TDataSet (déléguées au sérialiseur dataset legacy, donc la casse des noms, les champs ignorés, les datasets imbriqués et la gestion des blobs se comportent exactement comme avant), et les attributs MVCNameAs, MVCNameCase et MVCDoNotSerialize.
Deux formes restent sur le sérialiseur legacy par choix : les classes marquées [MVCSerialize(stFields)], et les propriétés dont le type a un IMVCTypeSerializer custom enregistré. Là aussi, la sortie est identique octet par octet.
Si quelque chose de non supporté surgit au milieu d’une émission, sur un élément polymorphique de liste résolu à l’exécution par exemple, le writer streaming rembobine le stream de sortie jusqu’à la marque qu’il avait prise avant la première écriture, jette son état thread-local et renvoie False, si bien que l’appelant re-sérialise toute la réponse via le chemin legacy. Aucun octet partiel n’arrive jamais sur le fil.
Sujet lié mais différent : un dataset forward-only peut désormais être envoyé au client enregistrement par enregistrement, avec une empreinte mémoire constante côté serveur, au lieu d’être matérialisé en entier :
[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
function GetCustomers: TMVCStreamedResponse;
begin
Result := StreamDataSet(qry);
end;
Le streaming chunked a besoin d’un backend capable de céder le socket, donc celui-ci fonctionne sur Indy Direct et HTTP.sys ; sur WebBroker il échoue proprement avec un 501 avant qu’un seul octet ne parte.
ActiveRecord
La nouveauté principale, ce sont les clés primaires composées. Pendant des années, TMVCActiveRecord a gardé les clés primaires délibérément simples : exactement une colonne foPrimaryKey, ce qui couvre la grande majorité des tables et garde le SQL généré prévisible. Les tables de jointure et les clés naturellement multi-colonnes ((order_id, line_no), (tenant, code)) recevaient un id de substitution plus une contrainte UNIQUE sur la vraie clé. Ça marche, au prix de charger les lignes par une colonne sur laquelle personne ne cherche. À partir de la 3.5, tu marques chaque colonne de la clé comme tu en marquais déjà une :
[MVCTable('user_roles')]
TUserRole = class(TMVCActiveRecord)
private
[MVCTableField('user_id', [foPrimaryKey])]
fUserID: Integer;
[MVCTableField('role_id', [foPrimaryKey])]
fRoleID: Integer;
// ...
end;
Les méthodes par clé ont gagné leurs contreparties au pluriel, LoadByPKs, GetByPKs, GetPKs, SetPKs, plus HasCompositePK pour quand tu as besoin de poser la question :
lRole := TMVCActiveRecord.GetByPKs<TUserRole>([1, 42]);
Load et Refresh échouent à voix haute : une clé qui ne trouve aucune ligne lève une exception, donc ce que tu tiens en main après l’appel est toujours une vraie ligne. Dans TMVCActiveRecordController, une clé composée voyage sous forme de tableau JSON dans le segment d’URL, GET /user_roles/[1,42], tandis que les entités à clé unique gardent le familier /customers/1. Les entités à clé unique génèrent aussi un SQL identique octet par octet à celui de la 3.4.x : le chemin composé ne s’active que quand tu déclares une deuxième foPrimaryKey.
À côté des méthodes de classe, il y a IMVCRepository<T> (MVCFramework.Repository) sur les mêmes entités. Comme c’est une interface, elle peut être enregistrée dans le container et injectée dans les controllers et les services avec [MVCInject], et c’est la différence qui compte quand tu veux la remplacer dans un test.
La suite partagée d’ActiveRecord tourne maintenant sur SQLite, Firebird, PostgreSQL, MySQL/MariaDB, InterBase et Oracle.
Il y a une présentation plus détaillée des clés composées, y compris les questions que cette section saute, dans Clés primaires composées dans Delphi MVC Framework ActiveRecord (en anglais).
Breaking changes
Trois, et chacun est une petite modification, ou rien du tout. Si tu migres depuis la 3.4.x, c’est la section à lire attentivement.
1. TGUID se sérialise sans accolades
Avant :
{ "id": "{550E8400-E29B-41D4-A716-446655440000}" }
Après :
{ "id": "550e8400-e29b-41d4-a716-446655440000" }
Le nouveau défaut est RFC 4122, c’est-à-dire ce qu’attendent JavaScript, Java, Python, .NET et les clients de bases de données. Ça ne pose problème qu’aux appelants Delphi qui parsent les réponses avec une regex qui tient les accolades pour acquises. Pour retrouver l’ancien format globalement, au démarrage :
uses MVCFramework.Serializer.Commons;
...
MVCGuidSerializationTypeDefault := gstBraces;
ou champ par champ avec [MVCGuidSerialization(gstBraces)].
2. Un TDate / TDateTime / TTime à zéro ne se sérialise plus comme null
Avant, un TDateTime à zéro émettait null, parce que le framework utilisait le zéro comme sentinelle de “non renseigné”, à une époque où NullableDateTime n’existait pas. Maintenant, le zéro est ce qu’il est vraiment, un instant valide :
{ "when": "1899-12-30T00:00:00.000+00:00" }
Ici il n’y a pas de flag pour rétablir le comportement précédent, et c’est voulu : la sentinelle perdait de l’information et cassait les allers-retours. Si un champ peut vraiment être absent, déclare-le NullableTDateTime, qui sérialise HasValue = False en null et laisse le zéro continuer à signifier zéro.
3. TMVCListener est maintenant un serveur Indy Direct, et il est déprécié
TMVCListener et TMVCListenerProperties (MVCFramework.Server) demandaient une TWebModuleClass et tournaient sur TIdHTTPWebBrokerBridge. Ils hébergent désormais un TMVCEngine directement sur TMVCIndyServer, sans couche WebBroker, donc l’API de configuration a changé : SetWebModuleClass et SetSSLOptions ont disparu, remplacés par SetConfigAction (clés de configuration de l’engine, appliquées pendant que l’engine est créé) et SetEngineConfig (controllers et middleware, appliqués après).
Avant :
TMVCListener.Create(TMVCListenerProperties.New
.SetName('App').SetPort(8080)
.SetWebModuleClass(TMyWebModule));
Après :
TMVCListener.Create(TMVCListenerProperties.New
.SetName('App').SetPort(8080)
.SetEngineConfig(
procedure(AEngine: TMVCEngine)
begin
AEngine.AddController(TMyController);
AEngine.AddMiddleware(UseMemorySessionMiddleware(0));
end));
La migration est mécanique : le corps de l’ancien WebModuleCreate, c’est-à-dire les appels AddController et AddMiddleware, se déplace dans la procédure passée à SetEngineConfig, et les affectations à TMVCConfig se déplacent dans SetConfigAction.
TMVCListener est aussi déprécié, et il sera retiré en 4.0. Après la conversion, c’est une fine enveloppe autour d’IMVCServer qui expose strictement moins : Indy seulement, pas d’HTTPS, uniquement MaxConnections. Construis plutôt tes serveurs avec TMVCServerFactory, qui est le même cycle de vie avec en plus les deux autres backends et le TLS intégré. En attendant, le code existant continue de compiler, avec un warning de dépréciation.
Performances
Tous les chiffres ci-dessous sont la médiane de 3 runs de 30 secondes à c=100, sur un bench HTTP.sys en loopback : i9-13980HX, Windows 11, Release Win64. Ce contexte va avec les chiffres : un débit sans la machine, la concurrence et le transport qu’il y a derrière ne dit rien.
| Scénario | Avant | Après | 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 (*) | nouveau | 3132 | +18,6% par rapport au legacy |
| pods/large (*) | nouveau | 438 | +74,6% par rapport au legacy |
(*) nouveaux scénarios de benchmark introduits en 3.5.x pour mettre le sérialiseur streaming à l’épreuve.
Les gains se répartissent en deux catégories. La table des routes (calculée une seule fois au moment d’AddController et indexée par méthode, puis par path, à la place du scan RTTI à chaque requête) et le chemin de rendu rapide pour OKResponse(TJsonBaseObject) sont des optimisations transversales : elles aident tous les backends, entre 20% et 70% sur cette charge.
La ligne de l’upload d'1 MB, c’est une autre histoire. Le listener HTTP.sys lisait le body et exécutait tout le pipeline sur le thread du listener, une requête à la fois : sur un health check ça ne se voit pas, sur un mégaoctet ça se voit très bien. La RC6 envoie les deux sur le task pool par défaut, et quand Content-Length est connu, elle écrit le body directement dans un TBytes déjà dimensionné au lieu d’un TMemoryStream suivi d’un SetLength et d’un Move. Alors lis les 892 rps comme HTTP.sys à qui on pose enfin la bonne question, pas comme une nouvelle astuce.
Une ligne va dans l’autre sens : heavy sur Indy Direct a mesuré -9%. Sur une machine de bench avec une variance d’un run à l’autre autour de 20%, ça se lit comme neutre, pas comme une régression. Les deltas sous 15% environ sur ce matériel sont du bruit. La comparaison entre backends, et les runs WebBroker (non comparables à c=100 sur cette machine, où le serveur ne tient pas toute la durée du run), sont dans performancetest/results/BASELINE_AFTER.md.
L’essayer
Deux façons de s’y mettre.
Télécharge le zip depuis la page de release, ajoute sources à ton library path, et pour un projet existant le setup s’arrête là.
Ou bien tu installes le wizard pour l’IDE et tu t’en fais générer un. Les presets apparaissent dans la fenêtre New Items de l’IDE, sous Delphi > DelphiMVCFramework :

Les presets de projet sont au nombre de 8 : RESTful API, Minimal API RESTful, Web Application, Minimal API WebApp, JSON-RPC Service, Real-Time Application (WebSocket), Full-Stack Application et Custom Project avec toutes les options exposées. Chaque preset remplit le même formulaire du wizard avec des valeurs par défaut différentes, donc tu peux les accepter ou changer n’importe quoi avant de générer. L’host par défaut dans chaque preset est Indy Direct.
Si quelque chose casse, ou si une migration depuis la 3.4.x demande une étape qui n’est pas dans la section des breaking changes ci-dessus, ouvre une issue sur GitHub avant le tag stable. C’est à ça que sert une release candidate. Un bug trouvé maintenant, c’est un correctif dans la 3.5.0 ; le même bug trouvé plus tard, c’est un correctif dans la 3.5.1 et un après-midi de la vie de quelqu’un.
Ressources
- Page de release - téléchargement et changelog complet
- Dépôt GitHub - code source et plus de 40 samples
- Le guide officiel, deuxième édition - 30 chapitres écrits pour cette release, sur Leanpub
- Communauté PATREON - tutoriels, vidéos et support prioritaire
Enjoy!
– Daniele Teti

Comments
comments powered by Disqus