Become a member!

DelphiMVCFramework 3.5.0-silicon RC6: tre host, gli stessi controller

🌐
Questo articolo è disponibile anche in altre lingue:
🇬🇧 English  •  🇪🇸 Español  •  🇩🇪 Deutsch  •  🇧🇷 Português  •  🇫🇷 Français

DelphiMVCFramework, il framework open source REST e web per Delphi

DelphiMVCFramework, il framework open source più diffuso per scrivere REST API e applicazioni web in Delphi, guadagna tre host HTTP intercambiabili, le rotte lambda e un serializzatore JSON in streaming.

DelphiMVCFramework 3.5.0-silicon RC6 è disponibile. È una release candidate, non la 3.5.0 definitiva: le funzionalità sono congelate e la matrice dei test è verde, ma pubblicarla serve a farla girare su macchine che non sono la mia prima del tag stabile.

Le tre cose che distinguono la 3.5 dalla 3.4.x sono gli host server intercambiabili, la Minimal API e un serializzatore JSON in streaming sul percorso caldo della risposta. Ci sono anche tre breaking change, tutte piccole, e per ognuna trovi sotto la modifica esatta che ti costa.

Scaricala da GitHub.


Tre host per il server, un solo stack di controller

Fino alla 3.4.x c’era un solo modo per mettere un’applicazione DMVCFramework su un socket: WebBroker, con un WebModule e TIdHTTPWebBrokerBridge sotto. Funziona, funziona da anni, e per i deploy ISAPI e Apache resta la risposta giusta. Per tutto il resto adesso puoi scegliere.

La 3.5 introduce un’interfaccia IMVCServer (MVCFramework.Server.Intf) con tre implementazioni, scelte tramite TMVCServerFactory:

Host Costruttore A cosa serve
Indy Direct TMVCServerFactory.CreateIndyDirect(LEngine) Il nuovo default quando parti da zero. Un TIdHTTPServer diretto, niente WebModule, niente strato WebBroker.
HTTP.sys TMVCServerFactory.CreateHttpSys(LEngine) HTTP in kernel mode di Windows. Richiede i diritti di amministratore, oppure un netsh http add urlacl che sulla prossima macchina ti dimenticherai di lanciare.
WebBroker TMVCServerFactory.CreateWebBroker(AConfigAction, AEngineConfig) ISAPI, moduli Apache e applicazioni già costruite su un WebModule.

Qualunque host tu scelga, il tuo codice resta lo stesso. Controller, action, entità e middleware si comportano in modo identico, e cambiare host vuol dire modificare il .dpr:

// Indy Direct: il default per un nuovo server console
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);

// HTTP.sys: stesso engine, stessi controller
LServer := TMVCServerFactory.CreateHttpSys(LEngine);

// WebBroker: idem, quando fai deploy in ISAPI o Apache
LServer := TMVCServerFactory.CreateWebBroker(nil, ConfigureEngine);

IMVCServer espone Listen, Stop, IsRunning e RunAndWait. L’ultimo è la scorciatoia per la console: chiama Listen, si blocca sul segnale di terminazione, poi chiama Stop. Non chiamarlo da una form VCL o FMX: lì il main thread ha già un message loop suo, e RunAndWait se lo prende volentieri in ostaggio finché non chiudi il processo dal Task Manager. Su una form usi Listen e Stop, e lasci decidere alla form quando eseguirli.

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

L’HTTPS adesso si configura sull’oggetto server invece che sul componente Indy:

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

Ogni backend gestisce il TLS a modo suo dietro la stessa API: Indy Direct e WebBroker usano TaurusTLS con le proprietà del certificato qui sopra, mentre HTTP.sys prende il certificato da netsh http add sslcert e UseHTTPS si limita a portare il prefisso registrato su https://.

Il sample samples/server_types è la dimostrazione di quello che ho appena scritto: una sola unit di controller in commons, sei progetti intorno (Indy Direct, HTTP.sys, WebBroker standalone, WebBroker attraverso IMVCServer, ISAPI, modulo Apache). Il file del controller è condiviso, non copiato.

WebBroker resta supportato, a tempo indeterminato. È una delle tre opzioni. I deploy ISAPI e Apache passano di lì, le applicazioni esistenti continuano a compilare senza toccare niente, e il costruttore TMVCEngine.Create(AWebModule) funziona ancora (è marcato deprecated a favore di TMVCEngine.CreateForWebBroker, che è una rinomina, non una rimozione).


Minimal API

La seconda novità è uno stile di routing che funziona senza una classe controller. MVCFramework.MinimalAPI ti fa registrare un handler direttamente su un gruppo di rotte:

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

  // un argomento interfaccia viene risolto dal service container
  lPeople.MapGet<IPeopleService>('',
    function (Svc: IPeopleService): IMVCResponse
    begin
      Result := Ok(Svc.GetAll);
    end);

  // un argomento primitivo viene legato al segmento di rotta successivo
  lPeople.MapGet<Integer>('/($id:int)',
    function (ID: Integer): IMVCResponse
    begin
      Result := Ok(TPerson.Create(ID, 'Daniele', 'Teti', EncodeDate(1979, 11, 4)));
    end);

  // un argomento classe arriva dal body, e viene validato prima che parta l'handler
  lPeople.MapPost<TPersonInput>('',
    function (Input: TPersonInput): IMVCResponse
    begin
      Result := Created('', 'Person created');
    end).WithSummary('Create a new person (validated)');
end;

MapGet, MapPost, MapPut, MapDelete e MapPatch coprono i verbi singoli; MapMethods prende un array di verbi, per i casi in cui un solo handler ne gestisce più di uno:

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

Gli handler sono function(...): IMVCResponse con al massimo quattro argomenti tipizzati, e il binding è per tipo, non per nome né per una posizione da tenere a mente. Un argomento interfaccia viene risolto dal service container. Un primitivo (Integer, Int64, string, Boolean, Double, TGUID, TDateTime) viene legato al primo segmento di rotta non ancora consumato, nell’ordine di dichiarazione. Una classe o un record arrivano dal body, e un record può dichiarare la sorgente campo per campo con [MVCFromQueryString], [MVCFromHeader], [MVCFromCookie], [MVCFromContentField] e [MVCFromBody]. Un argomento TMVCFormFile viene legato al primo file multipart caricato. Le classi che discendono da TMVCValidatable vengono validate prima che l’handler venga eseguito, quindi un payload non valido viene respinto subito con un 400 e un body ProblemDetails. I vincoli di rotta come ($id:int) rifiutano un id non numerico con un 404 prima che parta il tuo codice.

Due dettagli falliscono in silenzio invece che a voce alta.

TMVCRouteGroup<T> è un record: Use, Prefix e AsWeb restituiscono un gruppo nuovo invece di modificare quello su cui li hai chiamati. Se butti via il risultato ottieni codice che compila pulito, gira pulito e ignora il tuo filtro:

// sbagliato: il gruppo restituito viene buttato via, LogFilter non gira mai
ARoot.Prefix('/people').Use(LogFilter());

// giusto: tieni il gruppo e registraci sopra le rotte
lPeople := ARoot.Prefix('/people').Use(LogFilter());
lPeople.MapGet<IPeopleService>('', ...);

Il middleware classico va registrato prima del primo MapXxx. Il dispatcher minimale viene installato in modo lazy alla prima chiamata Map e cortocircuita le richieste che intercetta, quindi tutto quello che aggiungi dopo con AddMiddleware non le vedrà.

Due sample completi e compilabili stanno in samples/wizard_showcase/rest/ (REST) e samples/wizard_showcase/web/ (TemplatePro e HTMX tramite .AsWeb). Sono molto commentati, e sono il modo più veloce per vedere tutte le modalità di binding in una schermata. C’è un’introduzione più lunga alla Minimal API, con il ragionamento dietro le regole di binding, in Delphi Minimal API: REST API semplici e veloci con DMVCFramework.


Filtri

MVCFramework.Filters è la superficie moderna accanto al middleware che già conosci. Ce ne sono di due tipi.

TMVCEndpointFilter si attacca a un gruppo di rotte e gira solo quando una rotta di quel gruppo fa match. È una closure che riceve il context e una continuazione Next, quindi avvolge l’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 vale per tutto l’engine e avvolge il routing stesso, che è quello che ti serve per tutto ciò che deve valere prima ancora che una rotta venga scelta:

lEngine
  .UseHTTPFilter(SecurityHeaders)
  .UseHTTPFilter(RateLimit(100, 60))   // 100 richieste al minuto per IP
  .UseHTTPFilter(Compression(1024))
  .UseHTTPFilter(StaticFiles('/static', 'www'));

18 dei 19 middleware helper classici hanno un equivalente filtro (MemorySession, CORS, JWT, ActiveRecord, ETag, Analytics, Trace, Redirect, Swagger e gli altri); per OIDC resta disponibile solo il middleware. C’è anche RangeMedia, che serve i file con supporto HTTP Range (RFC 7233) in modo che gli elementi HTML5 <audio> e <video> possano fare seek, e un RateLimitRedis basato su Redis nella unit dedicata MVCFramework.Filters.Redis per i deploy sotto load balancer.


Serializzatore JSON in streaming

OKResponse(TObject) e OKResponse(TObjectList<T>) adesso hanno un percorso veloce (MVCFramework.Serializer.Streaming). Invece di costruire un albero TJDOJsonObject, convertirlo in una stringa Delphi UTF-16 e ricodificarla in UTF-8, scrive il JSON direttamente nello stream della risposta attraverso System.JSON.Writers.TJsonTextWriter, usando un piano di emissione tenuto in cache per classe. Niente albero intermedio, niente stringa intermedia.

Richiede Delphi 10.3 Rio o superiore. Sui compilatori più vecchi la nuova unit è uno stub e viene usato il serializzatore legacy, invariato.

Il percorso streaming ha piena parità di funzionalità con il serializzatore legacy, e parità qui vuol dire output identico byte per byte, verificato su 50 scenari da un harness dedicato (performancetest/parity/ParityCheck.exe): ogni tipo primitivo, ogni record NullableXxx, oggetti annidati con rilevamento dei cicli quando il piano viene costruito, TObjectList<T> e TList<T> con risoluzione polimorfica elemento per elemento, TArray<T>, gli stream come base64, le proprietà TDataSet (delegate al serializzatore dataset legacy, quindi maiuscole e minuscole dei nomi, campi ignorati, dataset annidati e gestione dei blob si comportano esattamente come prima) e gli attributi MVCNameAs, MVCNameCase e MVCDoNotSerialize.

Due forme restano sul serializzatore legacy per scelta: le classi marcate [MVCSerialize(stFields)] e le proprietà il cui tipo ha un IMVCTypeSerializer custom registrato. Anche lì l’output è identico byte per byte.

Se in mezzo a un’emissione salta fuori qualcosa di non supportato, per esempio su un elemento polimorfico di una lista risolto a runtime, il writer streaming riavvolge lo stream di output fino al segno che aveva preso prima della prima scrittura, scarta il suo stato thread-local e restituisce False, così il chiamante riserializza tutta la risposta attraverso il percorso legacy. Al client non arriva mai un byte parziale.

Argomento collegato ma diverso: un dataset forward-only adesso può essere mandato al client record per record, con il consumo di memoria del server che resta piatto, invece di essere materializzato per intero:

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

Lo streaming chunked ha bisogno di un backend che sappia cedere il socket, quindi questo funziona su Indy Direct e HTTP.sys; su WebBroker fallisce in modo pulito con un 501 prima che parta un solo byte.


ActiveRecord

La novità principale sono le chiavi primarie composte. Per anni TMVCActiveRecord ha tenuto le chiavi primarie deliberatamente semplici: esattamente una colonna foPrimaryKey, che copre la stragrande maggioranza delle tabelle e mantiene prevedibile l’SQL generato. Le tabelle di join e le chiavi naturalmente multi-colonna ((order_id, line_no), (tenant, code)) prendevano un id surrogato più un vincolo UNIQUE sulla chiave vera. Funziona, al prezzo di caricare le righe da una colonna su cui non cerca nessuno. Dalla 3.5 marchi ogni colonna della chiave nello stesso modo in cui già ne marcavi una:

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

I metodi per chiave hanno acquisito le controparti al plurale, LoadByPKs, GetByPKs, GetPKs, SetPKs, più HasCompositePK per quando devi chiederlo:

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

Load e Refresh falliscono a voce alta: una chiave che non trova nessuna riga solleva un’eccezione, quindi quello che hai in mano dopo la chiamata è sempre una riga vera. In TMVCActiveRecordController una chiave composta viaggia come array JSON nel segmento di URL, GET /user_roles/[1,42], mentre le entità a chiave singola mantengono il familiare /customers/1. Le entità a chiave singola generano anche SQL identico byte per byte a quello della 3.4.x: il percorso composto si attiva solo quando dichiari una seconda foPrimaryKey.

Accanto ai metodi di classe c’è IMVCRepository<T> (MVCFramework.Repository) sulle stesse entità. Essendo un’interfaccia si può registrare nel container e iniettare in controller e servizi con [MVCInject], che è la differenza che conta quando in un test lo vuoi sostituire.

La suite condivisa di ActiveRecord adesso gira su SQLite, Firebird, PostgreSQL, MySQL/MariaDB, InterBase e Oracle.

C’è un approfondimento più lungo sulle chiavi composte, comprese le domande che questa sezione salta, in Chiavi primarie composte in Delphi MVC Framework ActiveRecord.


Breaking change

Tre, e ognuna è una piccola modifica o niente del tutto. Se stai aggiornando dalla 3.4.x, questa è la sezione da leggere con attenzione.

1. TGUID si serializza senza graffe

Prima:

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

Dopo:

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

Il nuovo default è RFC 4122, che è quello che si aspettano JavaScript, Java, Python, .NET e i client dei database. Crea problemi solo ai chiamanti Delphi che fanno il parsing delle risposte con una regex che dà per scontate le graffe. Per ripristinare il vecchio formato in modo globale, allo startup:

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

oppure campo per campo con [MVCGuidSerialization(gstBraces)].

2. Un TDate / TDateTime / TTime a zero non si serializza più come null

Prima un TDateTime a zero emetteva null, perché il framework usava lo zero come sentinella di “non impostato”, da un’epoca in cui NullableDateTime non esisteva. Adesso lo zero è quello che è davvero, un istante valido:

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

Qui non c’è un flag per ripristinare il comportamento precedente, ed è voluto: la sentinella perdeva informazione e rompeva i round-trip. Se un campo può davvero essere assente, dichiaralo NullableTDateTime, che serializza HasValue = False come null e lascia che lo zero continui a significare zero.

3. TMVCListener adesso è un server Indy Direct, ed è deprecato

TMVCListener e TMVCListenerProperties (MVCFramework.Server) richiedevano una TWebModuleClass e giravano su TIdHTTPWebBrokerBridge. Adesso ospitano un TMVCEngine direttamente su TMVCIndyServer, senza strato WebBroker, quindi l’API di configurazione è cambiata: SetWebModuleClass e SetSSLOptions non ci sono più, sostituiti da SetConfigAction (chiavi di configurazione dell’engine, applicate mentre l’engine viene creato) e SetEngineConfig (controller e middleware, applicati dopo).

Prima:

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

Dopo:

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

La migrazione è meccanica: il corpo del vecchio WebModuleCreate, cioè le chiamate AddController e AddMiddleware, si sposta dentro la procedure passata a SetEngineConfig, e le assegnazioni a TMVCConfig si spostano in SetConfigAction.

TMVCListener è anche deprecato, e verrà rimosso nella 4.0. Dopo la conversione è un wrapper sottile su IMVCServer che espone meno di quello che c’è sotto: solo Indy, niente HTTPS, solo MaxConnections. Costruisci i server con TMVCServerFactory, che è lo stesso ciclo di vita con in più gli altri due backend e il TLS integrato. Nel frattempo il codice esistente continua a compilare, con un warning di deprecazione.


Prestazioni

Tutti i numeri qui sotto sono la mediana di 3 run da 30 secondi a c=100, su un bench HTTP.sys in loopback: i9-13980HX, Windows 11, Release Win64. Quel contesto va insieme ai numeri: una cifra di throughput senza la macchina, la concorrenza e il trasporto che ci stanno dietro non dice niente.

Scenario Prima Dopo 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 (*) nuovo 3132 +18,6% sul legacy
pods/large (*) nuovo 438 +74,6% sul legacy

(*) nuovi scenari di benchmark introdotti nella 3.5.x per mettere sotto sforzo il serializzatore streaming.

I guadagni si dividono in due tipi. La route table (calcolata una volta sola al momento di AddController e indicizzata per metodo, poi per path, al posto della scansione RTTI a ogni richiesta) e il percorso veloce di render per OKResponse(TJsonBaseObject) sono ottimizzazioni trasversali: aiutano ogni backend, tra il 20% e il 70% su questo carico.

La riga dell’upload da 1 MB è un altro paio di maniche. Il listener HTTP.sys leggeva il body ed eseguiva tutta la pipeline sul thread del listener, una richiesta alla volta: su un health check non si vede, su un megabyte si vede benissimo. La RC6 manda entrambe le cose sul task pool di default, e quando Content-Length è noto scrive il body direttamente in un TBytes già dimensionato invece che in un TMemoryStream seguito da SetLength e Move. Quindi leggi gli 892 rps come il segno che a HTTP.sys viene finalmente fatta la domanda giusta, non come un trucco nuovo.

Una riga va nella direzione opposta: heavy su Indy Direct ha misurato -9%. Su una macchina da bench con una varianza tra un run e l’altro intorno al 20% quel numero si legge come neutro, non come una regressione. I delta sotto il 15% circa su questo hardware sono rumore. Il confronto tra backend, e i run WebBroker (non confrontabili a c=100 su questa macchina, dove il server non resta su per tutta la durata del run), stanno in performancetest/results/BASELINE_AFTER.md.


Provarla

Due strade.

Scarica lo zip dalla pagina di release, aggiungi sources al library path, e per un progetto esistente il setup finisce lì.

Oppure installi il wizard per l’IDE e te ne fai generare uno. I preset compaiono nella finestra New Items dell’IDE, sotto Delphi > DelphiMVCFramework:

Gli 8 preset di progetto DelphiMVCFramework nella finestra New Items di RAD Studio

I preset di progetto sono 8: RESTful API, Minimal API RESTful, Web Application, Minimal API WebApp, JSON-RPC Service, Real-Time Application (WebSocket), Full-Stack Application e Custom Project con tutte le opzioni esposte. Ogni preset riempie lo stesso form del wizard con default diversi, quindi puoi accettarli o cambiare qualunque cosa prima di generare. L’host di default in ogni preset è Indy Direct.

Se qualcosa si rompe, o se un aggiornamento dalla 3.4.x richiede un passaggio che non sta nella sezione delle breaking change qui sopra, apri una issue su GitHub prima del tag stabile. È a questo che serve una release candidate. Un bug trovato adesso è una fix nella 3.5.0; lo stesso bug trovato dopo è una fix nella 3.5.1 e un pomeriggio della vita di qualcuno.


Risorse

PATREON Community

Enjoy!

– Daniele Teti

Comments

comments powered by Disqus