DelphiMVCFramework 3.5.0-silicon RC6: três hosts, os mesmos controllers
🇬🇧 English • 🇮🇹 Italiano • 🇪🇸 Español • 🇩🇪 Deutsch • 🇫🇷 Français

O DelphiMVCFramework, o framework open source mais usado para escrever APIs REST e aplicações web em Delphi, ganha três hosts HTTP intercambiáveis, rotas lambda e um serializador JSON em streaming.
DelphiMVCFramework 3.5.0-silicon RC6 está disponível. É uma release candidate, não a 3.5.0 final: o conjunto de funcionalidades está fechado e a matriz de testes está verde, mas o motivo de publicá-la é fazê-la rodar em máquinas que não são as minhas antes da tag estável.
As três coisas que separam a 3.5 da 3.4.x são os hosts de servidor intercambiáveis, a Minimal API e um serializador JSON em streaming no caminho quente da resposta. Existem também três breaking changes, todas pequenas, e abaixo você encontra, para cada uma, a alteração exata que ela custa.
Baixe no GitHub.
Três hosts de servidor, uma só pilha de controllers
Até a 3.4.x havia um único jeito de colocar uma aplicação DMVCFramework em um socket: WebBroker, com um WebModule e TIdHTTPWebBrokerBridge embaixo. Funciona, funciona há anos, e para deploys ISAPI e Apache continua sendo a resposta certa. Para todo o resto agora você escolhe.
A 3.5 introduz uma interface IMVCServer (MVCFramework.Server.Intf) com três implementações, escolhidas por TMVCServerFactory:
| Host | Construtor | Para que serve |
|---|---|---|
| Indy Direct | TMVCServerFactory.CreateIndyDirect(LEngine) |
O novo padrão para projetos novos. Um TIdHTTPServer direto, sem WebModule, sem camada WebBroker. |
| HTTP.sys | TMVCServerFactory.CreateHttpSys(LEngine) |
HTTP em kernel mode do Windows. Precisa de direitos de administrador, ou de um netsh http add urlacl que você vai esquecer na próxima máquina. |
| WebBroker | TMVCServerFactory.CreateWebBroker(AConfigAction, AEngineConfig) |
ISAPI, módulos Apache e aplicações já construídas sobre um WebModule. |
Qualquer que seja o host escolhido, o seu código é o mesmo. Controllers, actions, entidades e middleware se comportam de forma idêntica, e trocar de host significa editar o .dpr:
// Indy Direct: o padrão para um novo servidor console
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
// HTTP.sys: mesmo engine, mesmos controllers
LServer := TMVCServerFactory.CreateHttpSys(LEngine);
// WebBroker: idem, quando o deploy é em ISAPI ou Apache
LServer := TMVCServerFactory.CreateWebBroker(nil, ConfigureEngine);
IMVCServer expõe Listen, Stop, IsRunning e RunAndWait. O último é o atalho para o console: chama Listen, bloqueia no sinal de término e depois chama Stop. Não chame isso de um formulário VCL ou FMX: ali a thread principal já é dona de um message loop, e o RunAndWait toma esse loop de refém com o maior prazer, até você matar o processo pelo Gerenciador de Tarefas. Num formulário você usa Listen e Stop e deixa o formulário decidir quando cada um roda.
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
LServer.RunAndWait(8080);
O HTTPS agora é configurado no objeto servidor em vez de no componente Indy:
uses
MVCFramework.Server.HTTPS.TaurusTLS;
...
LServer.HTTPSConfigurator := TaurusTLSIndyConfigurator();
LServer.UseHTTPS := True;
LServer.CertFile := 'certificates\localhost.crt';
LServer.KeyFile := 'certificates\localhost.key';
Cada backend cuida do TLS do seu jeito atrás dessa mesma API: Indy Direct e WebBroker usam TaurusTLS com as propriedades de certificado acima, enquanto o HTTP.sys pega o certificado de netsh http add sslcert e UseHTTPS apenas troca o prefixo registrado para https://.
O sample samples/server_types é a demonstração do que acabei de afirmar: uma única unit de controller em commons, seis projetos em volta (Indy Direct, HTTP.sys, WebBroker standalone, WebBroker através de IMVCServer, ISAPI, módulo Apache). O arquivo do controller é compartilhado, não copiado.
O WebBroker continua suportado, por tempo indeterminado. É uma opção entre três. Deploys ISAPI e Apache passam por ele, aplicações existentes continuam compilando sem tocar em nada, e o construtor TMVCEngine.Create(AWebModule) ainda funciona (está marcado como deprecated em favor de TMVCEngine.CreateForWebBroker, o que é uma renomeação, não uma remoção).
Minimal API
A segunda novidade é um estilo de roteamento que funciona sem classe de controller. MVCFramework.MinimalAPI deixa você registrar um handler direto em um grupo de rotas:
procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
var
lPeople: TMVCRouteGroup<TObject>;
begin
lPeople := ARoot.Prefix('/people').Use(LogFilter());
// um argumento de interface é resolvido pelo service container
lPeople.MapGet<IPeopleService>('',
function (Svc: IPeopleService): IMVCResponse
begin
Result := Ok(Svc.GetAll);
end);
// um argumento primitivo é ligado ao próximo segmento da rota
lPeople.MapGet<Integer>('/($id:int)',
function (ID: Integer): IMVCResponse
begin
Result := Ok(TPerson.Create(ID, 'Daniele', 'Teti', EncodeDate(1979, 11, 4)));
end);
// um argumento de classe vem do body, e é validado antes de o handler rodar
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 cobrem os verbos isolados; MapMethods recebe um array deles, para os casos em que um handler responde a vários:
lPeople.MapMethods<Integer>([httpPUT, httpPATCH], '/($id:int)',
function (ID: Integer): IMVCResponse
begin
Result := Ok('updated ' + ID.ToString);
end);
Os handlers são function(...): IMVCResponse com no máximo quatro argumentos tipados, e o binding é por tipo, não por nome nem por uma posição que você tenha que decorar. Um argumento de interface é resolvido pelo service container. Um primitivo (Integer, Int64, string, Boolean, Double, TGUID, TDateTime) é ligado ao primeiro segmento de rota ainda não consumido, na ordem de declaração. Uma classe ou um record vem do body, e um record pode declarar a origem campo a campo com [MVCFromQueryString], [MVCFromHeader], [MVCFromCookie], [MVCFromContentField] e [MVCFromBody]. Um argumento TMVCFormFile é ligado ao primeiro arquivo multipart enviado. Classes que descendem de TMVCValidatable são validadas antes de entrar no handler, então um payload inválido sai logo com um 400 e um body ProblemDetails. Restrições de rota como ($id:int) rejeitam um id não numérico com um 404 antes de o seu código rodar.
Dois detalhes falham em silêncio em vez de falhar em alto e bom som.
TMVCRouteGroup<T> é um record: Use, Prefix e AsWeb devolvem um grupo novo em vez de alterar aquele sobre o qual você os chamou. Se jogar fora o resultado, você fica com um código que compila limpo, roda limpo e ignora o seu filtro:
// errado: o grupo devolvido é jogado fora, LogFilter nunca roda
ARoot.Prefix('/people').Use(LogFilter());
// certo: guarde o grupo e registre as rotas nele
lPeople := ARoot.Prefix('/people').Use(LogFilter());
lPeople.MapGet<IPeopleService>('', ...);
O middleware clássico precisa ser registrado antes do primeiro MapXxx. O dispatcher mínimo é instalado de forma preguiçosa na primeira chamada a Map e curto-circuita as requisições que dão match nele, então tudo o que você acrescentar depois com AddMiddleware não vai enxergá-las.
Dois samples completos e compiláveis estão em samples/wizard_showcase/rest/ (REST) e samples/wizard_showcase/web/ (TemplatePro e HTMX via .AsWeb). Os dois são bem comentados, e são o jeito mais rápido de ver todos os modos de binding em uma tela só. Existe uma introdução mais longa à Minimal API, com o raciocínio por trás das regras de binding, em Delphi Minimal API: APIs REST simples e rápidas com DMVCFramework.
Filtros
MVCFramework.Filters é a superfície moderna ao lado do middleware que você já conhece. Há dois tipos.
TMVCEndpointFilter se prende a um grupo de rotas e roda só quando uma rota daquele grupo casa. É uma closure que recebe o context e uma continuação Next, então envolve o 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 para todo o engine e envolve o próprio roteamento, que é o que você quer para as coisas que precisam valer antes mesmo de uma rota ser escolhida:
lEngine
.UseHTTPFilter(SecurityHeaders)
.UseHTTPFilter(RateLimit(100, 60)) // 100 requisições por minuto por IP
.UseHTTPFilter(Compression(1024))
.UseHTTPFilter(StaticFiles('/static', 'www'));
18 dos 19 helpers de middleware clássicos têm equivalente em filtro (MemorySession, CORS, JWT, ActiveRecord, ETag, Analytics, Trace, Redirect, Swagger e os demais); só o OIDC continua sendo apenas middleware. Há também RangeMedia, que serve arquivos com suporte a HTTP Range (RFC 7233) para que os elementos HTML5 <audio> e <video> possam fazer seek no arquivo, e um RateLimitRedis apoiado em Redis na unit companheira MVCFramework.Filters.Redis para deploys com balanceamento de carga.
Serializador JSON em streaming
OKResponse(TObject) e OKResponse(TObjectList<T>) agora têm um caminho rápido (MVCFramework.Serializer.Streaming). Em vez de montar uma árvore TJDOJsonObject, convertê-la em uma string Delphi UTF-16 e recodificar isso para UTF-8, ele escreve o JSON direto no stream da resposta através de System.JSON.Writers.TJsonTextWriter, usando um plano de emissão em cache por classe. Sem árvore intermediária, sem string intermediária.
Requer Delphi 10.3 Rio ou mais novo. Em compiladores mais antigos a nova unit é um stub e o serializador legado é usado, sem alterações.
O caminho em streaming tem paridade completa de funcionalidades com o serializador legado, e paridade aqui quer dizer saída idêntica byte a byte, verificada em 50 cenários por um harness dedicado (performancetest/parity/ParityCheck.exe): todo tipo primitivo, todo record NullableXxx, objetos aninhados com detecção de ciclos na hora de montar o plano, TObjectList<T> e TList<T> com resolução polimórfica item a item, TArray<T>, streams como base64, propriedades TDataSet (delegadas ao serializador de dataset legado, então maiúsculas e minúsculas dos nomes, campos ignorados, datasets aninhados e tratamento de blobs se comportam exatamente como antes) e os atributos MVCNameAs, MVCNameCase e MVCDoNotSerialize.
Duas formas ficam no serializador legado de propósito: as classes marcadas [MVCSerialize(stFields)] e as propriedades cujo tipo tem um IMVCTypeSerializer próprio registrado. Ali a saída também é idêntica byte a byte.
Se algo não suportado aparecer no meio de uma emissão, por exemplo num item polimórfico de lista resolvido em tempo de execução, o writer em streaming rebobina o stream de saída até a marca que registrou antes da primeira escrita, descarta seu estado thread-local e devolve False, de forma que quem chamou reserializa a resposta inteira pelo caminho legado. Nenhum byte parcial chega à rede.
Assunto vizinho, mas diferente: um dataset forward-only agora pode ser enviado ao cliente registro a registro, com uso de memória constante no servidor, em vez de ser materializado por inteiro:
[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
function GetCustomers: TMVCStreamedResponse;
begin
Result := StreamDataSet(qry);
end;
O streaming chunked precisa de um backend capaz de entregar o socket, então isso funciona em Indy Direct e HTTP.sys; no WebBroker falha de forma limpa com um 501 antes de qualquer byte sair.
ActiveRecord
A mudança principal são as chaves primárias compostas. Por anos o TMVCActiveRecord manteve as chaves primárias deliberadamente simples: exatamente uma coluna foPrimaryKey, o que cobre a grande maioria das tabelas e mantém o SQL gerado previsível. Tabelas de junção e chaves naturalmente multicoluna ((order_id, line_no), (tenant, code)) ganhavam um id substituto mais uma constraint UNIQUE sobre a chave de verdade. Funciona, ao preço de carregar linhas por uma coluna que ninguém consulta. A partir da 3.5 você marca cada coluna da chave do mesmo jeito que já marcava uma:
[MVCTable('user_roles')]
TUserRole = class(TMVCActiveRecord)
private
[MVCTableField('user_id', [foPrimaryKey])]
fUserID: Integer;
[MVCTableField('role_id', [foPrimaryKey])]
fRoleID: Integer;
// ...
end;
Os métodos por chave ganharam contrapartes no plural, LoadByPKs, GetByPKs, GetPKs, SetPKs, mais HasCompositePK para quando você precisa perguntar:
lRole := TMVCActiveRecord.GetByPKs<TUserRole>([1, 42]);
Load e Refresh falham em alto e bom som: uma chave que não encontra nenhuma linha lança uma exceção, então o que você tem na mão depois da chamada é sempre uma linha de verdade. No TMVCActiveRecordController uma chave composta viaja como array JSON no segmento da URL, GET /user_roles/[1,42], enquanto as entidades de chave única mantêm o familiar /customers/1. Entidades de chave única também geram SQL idêntico byte a byte ao da 3.4.x: o caminho composto só é ativado quando uma segunda foPrimaryKey é declarada.
Ao lado dos métodos de classe existe IMVCRepository<T> (MVCFramework.Repository) sobre as mesmas entidades. Por ser uma interface, dá para registrá-la no container e injetá-la em controllers e serviços com [MVCInject], que é a diferença que importa quando você quer substituí-la em um teste.
A suíte compartilhada do ActiveRecord agora roda contra SQLite, Firebird, PostgreSQL, MySQL/MariaDB, InterBase e Oracle.
Existe um passo a passo mais longo sobre as chaves compostas, incluindo as perguntas que esta seção pula, em Chaves primárias compostas no Delphi MVC Framework ActiveRecord.
Breaking changes
Três, e cada uma é uma alteração pequena ou nada. Se você está atualizando a partir da 3.4.x, esta é a seção para ler com calma.
1. TGUID serializa sem chaves
Antes:
{ "id": "{550E8400-E29B-41D4-A716-446655440000}" }
Depois:
{ "id": "550e8400-e29b-41d4-a716-446655440000" }
O novo padrão é RFC 4122, que é o que JavaScript, Java, Python, .NET e clientes de banco esperam. Só morde quem chama a API em Delphi e faz parsing das respostas com uma regex que pressupõe as chaves. Para voltar ao formato antigo globalmente, no startup:
uses MVCFramework.Serializer.Commons;
...
MVCGuidSerializationTypeDefault := gstBraces;
ou campo a campo com [MVCGuidSerialization(gstBraces)].
2. Um TDate / TDateTime / TTime zerado não serializa mais como null
Antes um TDateTime zerado emitia null, porque o framework usava o zero como sentinela de “não definido”, de uma época em que NullableDateTime não existia. Agora o zero é o que ele de fato é, um instante válido:
{ "when": "1899-12-30T00:00:00.000+00:00" }
Aqui não há flag para voltar ao comportamento anterior, e isso é proposital: a sentinela perdia informação e quebrava os round-trips. Se um campo realmente pode estar ausente, declare-o NullableTDateTime, que serializa HasValue = False como null e deixa o zero continuar significando zero.
3. TMVCListener agora é um servidor Indy Direct, e está obsoleto
TMVCListener e TMVCListenerProperties (MVCFramework.Server) exigiam uma TWebModuleClass e rodavam sobre TIdHTTPWebBrokerBridge. Agora eles hospedam um TMVCEngine diretamente sobre TMVCIndyServer, sem camada WebBroker, então a API de configuração mudou: SetWebModuleClass e SetSSLOptions sumiram, substituídos por SetConfigAction (chaves de configuração do engine, aplicadas enquanto o engine é criado) e SetEngineConfig (controllers e middleware, aplicados depois).
Antes:
TMVCListener.Create(TMVCListenerProperties.New
.SetName('App').SetPort(8080)
.SetWebModuleClass(TMyWebModule));
Depois:
TMVCListener.Create(TMVCListenerProperties.New
.SetName('App').SetPort(8080)
.SetEngineConfig(
procedure(AEngine: TMVCEngine)
begin
AEngine.AddController(TMyController);
AEngine.AddMiddleware(UseMemorySessionMiddleware(0));
end));
A migração é mecânica: o corpo do antigo WebModuleCreate, ou seja, as chamadas a AddController e AddMiddleware, vai para dentro da procedure passada a SetEngineConfig, e as atribuições a TMVCConfig vão para SetConfigAction.
TMVCListener também está obsoleto, e será removido na 4.0. Depois da conversão ele é um wrapper fino sobre IMVCServer que expõe estritamente menos: só Indy, sem HTTPS, só MaxConnections. Construa os servidores com TMVCServerFactory, que é o mesmo ciclo de vida, com os outros dois backends e o TLS embutido à disposição. Enquanto isso, o código existente continua compilando, com um aviso de deprecação.
Desempenho
Todos os números abaixo são a mediana de 3 execuções de 30 segundos a c=100, em um bench HTTP.sys em loopback: i9-13980HX, Windows 11, Release Win64. Esse contexto vai junto com os números: um valor de throughput sem a máquina, a concorrência e o transporte por trás não diz nada.
| Cenário | Antes | Depois | 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 (*) | novo | 3132 | +18,6% sobre o legado |
| pods/large (*) | novo | 438 | +74,6% sobre o legado |
(*) novos cenários de benchmark introduzidos na 3.5.x para exercitar o serializador em streaming.
Os ganhos se dividem em dois tipos. A tabela de rotas (calculada uma única vez no momento do AddController e indexada por método e depois por path, no lugar da varredura RTTI a cada requisição) e o caminho rápido de render para OKResponse(TJsonBaseObject) são otimizações transversais: ajudam todos os backends, entre 20% e 70% nesta carga.
A linha do upload de 1 MB é outro animal. O listener do HTTP.sys lia o body e rodava o pipeline inteiro na thread do listener, uma requisição por vez: num health check isso não aparece, num megabyte aparece muito. A RC6 manda as duas coisas para o task pool padrão, e quando Content-Length é conhecido escreve o body direto em um TBytes já dimensionado, em vez de um TMemoryStream seguido de SetLength e Move. Então leia os 892 rps como o HTTP.sys finalmente recebendo a pergunta certa, não como um truque novo.
Uma linha vai na direção oposta: heavy no Indy Direct mediu -9%. Numa máquina de bench com variação entre execuções perto de 20% isso se lê como neutro, não como regressão. Diferenças abaixo de mais ou menos 15% nesta máquina são ruído. A comparação entre backends, e as execuções do WebBroker (não comparáveis a c=100 nesta máquina, onde o servidor não fica de pé durante toda a execução), estão em performancetest/results/BASELINE_AFTER.md.
Experimentar
Dois caminhos.
Baixe o zip da página de release, adicione sources ao library path, e para um projeto existente a instalação acaba aí.
Ou instale o wizard da IDE e deixe que ele gere um projeto para você. Os presets aparecem na janela New Items da IDE, em Delphi > DelphiMVCFramework:

São 8 presets de projeto: RESTful API, Minimal API RESTful, Web Application, Minimal API WebApp, JSON-RPC Service, Real-Time Application (WebSocket), Full-Stack Application e Custom Project com todas as opções à mostra. Cada preset preenche o mesmo formulário do wizard com padrões diferentes, então você pode aceitá-los ou mudar o que quiser antes de gerar. O host padrão em todos os presets é Indy Direct.
Se algo quebrar, ou se uma atualização a partir da 3.4.x exigir um passo que não está na seção de breaking changes acima, abra uma issue no GitHub antes da tag estável. É para isso que serve uma release candidate. Um bug encontrado agora é uma correção na 3.5.0; o mesmo bug encontrado depois é uma correção na 3.5.1 e uma tarde da vida de alguém.
Recursos
- Página de release - download e changelog completo
- Repositório GitHub - código-fonte e mais de 40 samples
- O guia oficial, segunda edição - 30 capítulos escritos para esta release, no Leanpub
- Comunidade PATREON - tutoriais, vídeos e suporte prioritário
Enjoy!
– Daniele Teti

Comments
comments powered by Disqus