DelphiMVCFramework 3.5.0-silicon RC6: tres hosts, los mismos controladores
🇬🇧 English • 🇮🇹 Italiano • 🇩🇪 Deutsch • 🇧🇷 Português • 🇫🇷 Français

DelphiMVCFramework, el framework open source más usado para escribir APIs REST y aplicaciones web en Delphi, gana tres hosts HTTP intercambiables, rutas lambda y un serializador JSON en streaming.
DelphiMVCFramework 3.5.0-silicon RC6 ya está disponible. Es una release candidate, no la 3.5.0 definitiva: las funcionalidades están cerradas y la matriz de tests está en verde, pero publicarla sirve para que corra en máquinas que no son la mía antes de la etiqueta estable.
Las tres cosas que separan la 3.5 de la 3.4.x son los hosts de servidor intercambiables, la Minimal API y un serializador JSON en streaming en el camino caliente de la respuesta. También hay tres cambios que rompen compatibilidad, todos pequeños, y abajo tienes para cada uno la modificación exacta que te va a costar.
Descárgala desde GitHub.
Tres hosts de servidor, un solo stack de controladores
Hasta la 3.4.x había una sola forma de poner una aplicación DMVCFramework sobre un socket: WebBroker, con un WebModule y TIdHTTPWebBrokerBridge debajo. Funciona, lleva años funcionando, y para despliegues ISAPI y Apache sigue siendo la respuesta correcta. Para todo lo demás ahora puedes elegir.
La 3.5 introduce una interfaz IMVCServer (MVCFramework.Server.Intf) con tres implementaciones, elegidas mediante TMVCServerFactory:
| Host | Constructor | Para qué sirve |
|---|---|---|
| Indy Direct | TMVCServerFactory.CreateIndyDirect(LEngine) |
El nuevo valor por defecto para proyectos nuevos. Un TIdHTTPServer directo, sin WebModule, sin capa WebBroker. |
| HTTP.sys | TMVCServerFactory.CreateHttpSys(LEngine) |
HTTP en kernel mode de Windows. Necesita permisos de administrador, o un netsh http add urlacl del que en la siguiente máquina ya te habrás olvidado. |
| WebBroker | TMVCServerFactory.CreateWebBroker(AConfigAction, AEngineConfig) |
ISAPI, módulos Apache y aplicaciones ya construidas sobre un WebModule. |
Elijas el host que elijas, tu código es el mismo. Controladores, actions, entidades y middleware se comportan igual, y cambiar de host significa editar el .dpr:
// Indy Direct: el valor por defecto para un servidor de consola nuevo
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
// HTTP.sys: mismo engine, mismos controladores
LServer := TMVCServerFactory.CreateHttpSys(LEngine);
// WebBroker: lo mismo, cuando despliegas en ISAPI o Apache
LServer := TMVCServerFactory.CreateWebBroker(nil, ConfigureEngine);
IMVCServer expone Listen, Stop, IsRunning y RunAndWait. El último es el atajo para consola: llama a Listen, se bloquea en la señal de terminación y después llama a Stop. No lo llames desde un formulario VCL o FMX: ahí el hilo principal ya tiene su bucle de mensajes, y RunAndWait se lo queda de rehén con mucho gusto hasta que cierres el proceso desde el Administrador de tareas. En un formulario usas Listen y Stop, y dejas que el formulario decida cuándo va cada uno.
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
LServer.RunAndWait(8080);
HTTPS ahora se configura en el objeto servidor en lugar de en el 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 gestiona TLS a su manera detrás de esa misma API: Indy Direct y WebBroker usan TaurusTLS con las propiedades del certificado de arriba, mientras que HTTP.sys toma su certificado de netsh http add sslcert y UseHTTPS se limita a pasar el prefijo registrado a https://.
El ejemplo samples/server_types es la demostración de lo que acabo de decir: una sola unit de controlador en commons, seis proyectos alrededor (Indy Direct, HTTP.sys, WebBroker standalone, WebBroker a través de IMVCServer, ISAPI, módulo Apache). El fichero del controlador está compartido, no copiado.
WebBroker sigue soportado, indefinidamente. Es una opción de tres. Los despliegues ISAPI y Apache pasan por ahí, las aplicaciones existentes siguen compilando sin tocar nada, y el constructor TMVCEngine.Create(AWebModule) sigue funcionando (está marcado como deprecated en favor de TMVCEngine.CreateForWebBroker, que es un renombrado, no una eliminación).
Minimal API
La segunda novedad es un estilo de enrutamiento que funciona sin clase de controlador. MVCFramework.MinimalAPI te deja registrar un handler directamente sobre un grupo de rutas:
procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
var
lPeople: TMVCRouteGroup<TObject>;
begin
lPeople := ARoot.Prefix('/people').Use(LogFilter());
// un argumento de tipo interfaz se resuelve desde el service container
lPeople.MapGet<IPeopleService>('',
function (Svc: IPeopleService): IMVCResponse
begin
Result := Ok(Svc.GetAll);
end);
// un argumento primitivo se enlaza al siguiente segmento de la ruta
lPeople.MapGet<Integer>('/($id:int)',
function (ID: Integer): IMVCResponse
begin
Result := Ok(TPerson.Create(ID, 'Daniele', 'Teti', EncodeDate(1979, 11, 4)));
end);
// un argumento de clase viene del body, y se valida antes de entrar en el 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 y MapPatch cubren los verbos sueltos; MapMethods acepta un array de verbos, para los casos en que un mismo handler responde a varios:
lPeople.MapMethods<Integer>([httpPUT, httpPATCH], '/($id:int)',
function (ID: Integer): IMVCResponse
begin
Result := Ok('updated ' + ID.ToString);
end);
Los handlers son function(...): IMVCResponse con un máximo de cuatro argumentos tipados, y el enlace es por tipo, no por nombre ni por una posición que tengas que recordar. Un argumento de tipo interfaz se resuelve desde el service container. Un primitivo (Integer, Int64, string, Boolean, Double, TGUID, TDateTime) se enlaza al primer segmento de ruta aún sin consumir, en orden de declaración. Una clase o un record vienen del body, y un record puede declarar el origen campo a campo con [MVCFromQueryString], [MVCFromHeader], [MVCFromCookie], [MVCFromContentField] y [MVCFromBody]. Un argumento TMVCFormFile se enlaza al primer fichero multipart subido. Las clases que descienden de TMVCValidatable se validan antes de entrar en el handler, así que un payload no válido se corta enseguida con un 400 y un body ProblemDetails. Las restricciones de ruta como ($id:int) rechazan un id no numérico con un 404 antes de que arranque tu código.
Dos detalles fallan en silencio en lugar de fallar a gritos.
TMVCRouteGroup<T> es un record: Use, Prefix y AsWeb devuelven un grupo nuevo en lugar de modificar el grupo sobre el que los llamaste. Si tiras el resultado te queda código que compila limpio, corre limpio e ignora tu filtro:
// mal: el grupo devuelto se tira, LogFilter nunca corre
ARoot.Prefix('/people').Use(LogFilter());
// bien: te quedas el grupo y registras las rutas sobre él
lPeople := ARoot.Prefix('/people').Use(LogFilter());
lPeople.MapGet<IPeopleService>('', ...);
El middleware clásico hay que registrarlo antes del primer MapXxx. El dispatcher mínimo se instala de forma perezosa en la primera llamada a Map y cortocircuita las peticiones que intercepta, así que todo lo que añadas después con AddMiddleware no las verá.
Dos ejemplos completos y compilables viven en samples/wizard_showcase/rest/ (REST) y samples/wizard_showcase/web/ (TemplatePro y HTMX mediante .AsWeb). Están muy comentados, y son la forma más rápida de ver todos los modos de enlace en una pantalla. Hay una introducción más larga a la Minimal API, con el razonamiento detrás de las reglas de enlace, en Delphi Minimal API: APIs REST simples y rápidas con DMVCFramework.
Filtros
MVCFramework.Filters es la superficie moderna junto al middleware que ya conoces. Hay de dos tipos.
TMVCEndpointFilter se engancha a un grupo de rutas y corre solo cuando una ruta de ese grupo hace match. Es un closure que recibe el context y una continuación Next, así que envuelve al 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 el engine y envuelve el enrutamiento mismo, que es justo lo que necesitas para lo que se aplica antes incluso de que se elija una ruta:
lEngine
.UseHTTPFilter(SecurityHeaders)
.UseHTTPFilter(RateLimit(100, 60)) // 100 peticiones por minuto y por IP
.UseHTTPFilter(Compression(1024))
.UseHTTPFilter(StaticFiles('/static', 'www'));
18 de los 19 helpers de middleware clásicos tienen equivalente en filtro (MemorySession, CORS, JWT, ActiveRecord, ETag, Analytics, Trace, Redirect, Swagger y el resto); solo OIDC sigue existiendo únicamente como middleware. También está RangeMedia, que sirve ficheros con soporte HTTP Range (RFC 7233) para que los elementos HTML5 <audio> y <video> puedan hacer seek, y un RateLimitRedis apoyado en Redis en la unit compañera MVCFramework.Filters.Redis para despliegues con balanceo de carga.
Serializador JSON en streaming
OKResponse(TObject) y OKResponse(TObjectList<T>) ahora tienen un camino rápido (MVCFramework.Serializer.Streaming). En vez de construir un árbol TJDOJsonObject, convertirlo a una cadena Delphi UTF-16 y recodificarla a UTF-8, escribe el JSON directamente en el stream de la respuesta a través de System.JSON.Writers.TJsonTextWriter, usando un plan de emisión cacheado por clase. Sin árbol intermedio, sin cadena intermedia.
Requiere Delphi 10.3 Rio o superior. En compiladores más antiguos la unit nueva es un stub y se usa el serializador legacy, sin cambios.
El camino en streaming tiene paridad completa de funcionalidad con el serializador legacy, y paridad aquí significa salida idéntica byte a byte, verificada en 50 escenarios por un harness dedicado (performancetest/parity/ParityCheck.exe): todos los tipos primitivos, todos los records NullableXxx, objetos anidados con detección de ciclos al construir el plan, TObjectList<T> y TList<T> con resolución polimórfica elemento a elemento, TArray<T>, streams como base64, propiedades TDataSet (delegadas al serializador de datasets legacy, así que mayúsculas y minúsculas de los nombres, campos ignorados, datasets anidados y tratamiento de blobs se comportan exactamente igual que antes) y los atributos MVCNameAs, MVCNameCase y MVCDoNotSerialize.
Dos formas se quedan en el serializador legacy por diseño: las clases marcadas [MVCSerialize(stFields)] y las propiedades cuyo tipo tiene registrado un IMVCTypeSerializer propio. También ahí la salida es idéntica byte a byte.
Si en mitad de una emisión aparece algo no soportado, por ejemplo en un elemento polimórfico de una lista resuelto en tiempo de ejecución, el writer en streaming rebobina el stream de salida hasta la marca que tomó antes de la primera escritura, descarta su estado thread-local y devuelve False, de modo que quien llama reserializa la respuesta entera por el camino legacy. Nunca llega un byte parcial al cable.
Relacionado pero distinto: un dataset forward-only ahora puede enviarse al cliente registro a registro, con memoria del servidor plana, en lugar de materializarse entero:
[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
function GetCustomers: TMVCStreamedResponse;
begin
Result := StreamDataSet(qry);
end;
El streaming chunked necesita un backend capaz de ceder el socket, así que esto funciona en Indy Direct y HTTP.sys; en WebBroker falla de forma limpia con un 501 antes de enviar un solo byte.
ActiveRecord
El cambio principal son las claves primarias compuestas. Durante años TMVCActiveRecord mantuvo las claves primarias deliberadamente simples: exactamente una columna foPrimaryKey, lo que cubre la gran mayoría de tablas y mantiene predecible el SQL generado. Las tablas de unión y las claves naturalmente multicolumna ((order_id, line_no), (tenant, code)) recibían un id sustituto más una restricción UNIQUE sobre la clave real. Funciona, al precio de cargar filas por una columna que nadie consulta. Desde la 3.5 marcas cada columna de la clave igual que ya marcabas una:
[MVCTable('user_roles')]
TUserRole = class(TMVCActiveRecord)
private
[MVCTableField('user_id', [foPrimaryKey])]
fUserID: Integer;
[MVCTableField('role_id', [foPrimaryKey])]
fRoleID: Integer;
// ...
end;
Los métodos por clave ganaron contrapartes en plural, LoadByPKs, GetByPKs, GetPKs, SetPKs, más HasCompositePK para cuando necesitas preguntarlo:
lRole := TMVCActiveRecord.GetByPKs<TUserRole>([1, 42]);
Load y Refresh fallan a gritos: una clave que no encuentra ninguna fila lanza una excepción, así que lo que tienes en la mano después de la llamada es siempre una fila real. En TMVCActiveRecordController una clave compuesta viaja como array JSON en el segmento de URL, GET /user_roles/[1,42], mientras que las entidades de clave simple conservan el familiar /customers/1. Las entidades de clave simple también generan SQL idéntico byte a byte al de la 3.4.x: el camino compuesto solo se activa cuando declaras una segunda foPrimaryKey.
Junto a los métodos de clase está IMVCRepository<T> (MVCFramework.Repository) sobre las mismas entidades. Al ser una interfaz se puede registrar en el container e inyectar en controladores y servicios con [MVCInject], que es la diferencia que cuenta cuando quieres sustituirlo en un test.
La suite compartida de ActiveRecord ahora corre contra SQLite, Firebird, PostgreSQL, MySQL/MariaDB, InterBase y Oracle.
Hay un recorrido más largo sobre claves compuestas, incluidas las preguntas que esta sección se salta, en Claves primarias compuestas en Delphi MVC Framework ActiveRecord.
Cambios que rompen compatibilidad
Tres, y cada uno es un cambio pequeño o nada en absoluto. Si actualizas desde la 3.4.x, esta es la sección que hay que leer con calma.
1. TGUID se serializa sin llaves
Antes:
{ "id": "{550E8400-E29B-41D4-A716-446655440000}" }
Después:
{ "id": "550e8400-e29b-41d4-a716-446655440000" }
El nuevo valor por defecto es RFC 4122, que es lo que esperan JavaScript, Java, Python, .NET y los clientes de bases de datos. Solo muerde a los clientes Delphi que parsean las respuestas con una regex que da por sentadas las llaves. Para recuperar el formato antiguo de forma global, al arrancar:
uses MVCFramework.Serializer.Commons;
...
MVCGuidSerializationTypeDefault := gstBraces;
o campo a campo con [MVCGuidSerialization(gstBraces)].
2. Un TDate / TDateTime / TTime a cero ya no se serializa como null
Antes un TDateTime a cero emitía null, porque el framework usaba el cero como centinela de “sin valor”, de una época en la que NullableDateTime no existía. Ahora el cero es lo que realmente es, un instante válido:
{ "when": "1899-12-30T00:00:00.000+00:00" }
Aquí no hay flag para recuperar el comportamiento anterior, y es a propósito: el centinela perdía información y rompía los round-trips. Si un campo de verdad puede faltar, decláralo NullableTDateTime, que serializa HasValue = False como null y deja que el cero siga significando cero.
3. TMVCListener ahora es un servidor Indy Direct, y queda obsoleto
TMVCListener y TMVCListenerProperties (MVCFramework.Server) exigían una TWebModuleClass y corrían sobre TIdHTTPWebBrokerBridge. Ahora alojan un TMVCEngine directamente sobre TMVCIndyServer, sin capa WebBroker, así que la API de configuración cambió: SetWebModuleClass y SetSSLOptions ya no están, sustituidos por SetConfigAction (claves de configuración del engine, aplicadas mientras se crea el engine) y SetEngineConfig (controladores y middleware, aplicados después).
Antes:
TMVCListener.Create(TMVCListenerProperties.New
.SetName('App').SetPort(8080)
.SetWebModuleClass(TMyWebModule));
Después:
TMVCListener.Create(TMVCListenerProperties.New
.SetName('App').SetPort(8080)
.SetEngineConfig(
procedure(AEngine: TMVCEngine)
begin
AEngine.AddController(TMyController);
AEngine.AddMiddleware(UseMemorySessionMiddleware(0));
end));
La migración es mecánica: el cuerpo del viejo WebModuleCreate, es decir las llamadas a AddController y AddMiddleware, se mueve dentro del procedimiento que se pasa a SetEngineConfig, y las asignaciones a TMVCConfig se mueven a SetConfigAction.
TMVCListener además queda obsoleto, y se retirará en la 4.0. Después de la conversión es un envoltorio fino sobre IMVCServer que expone estrictamente menos: solo Indy, sin HTTPS, solo MaxConnections. Construye los servidores con TMVCServerFactory, que es el mismo ciclo de vida más los otros dos backends y el TLS integrado. Mientras tanto, el código existente sigue compilando con un warning de deprecación.
Rendimiento
Todos los números de abajo son la mediana de 3 ejecuciones de 30 segundos a c=100, en un bench HTTP.sys en loopback: i9-13980HX, Windows 11, Release Win64. Ese contexto va con los números: una cifra de throughput sin la máquina, la concurrencia y el transporte que hay detrás no dice nada.
| Escenario | Antes | Despué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 (*) | nuevo | 3132 | +18,6% sobre legacy |
| pods/large (*) | nuevo | 438 | +74,6% sobre legacy |
(*) escenarios de benchmark nuevos, introducidos en la 3.5.x para poner a prueba el serializador en streaming.
Las mejoras se dividen en dos tipos. La tabla de rutas (calculada una sola vez en el momento de AddController e indexada por método y luego por path, en lugar del escaneo RTTI por petición) y el camino rápido de render para OKResponse(TJsonBaseObject) son optimizaciones transversales: ayudan a todos los backends, entre el 20% y el 70% con esta carga.
La fila de la subida de 1 MB es otro animal. El listener de HTTP.sys leía el body y ejecutaba todo el pipeline en el hilo del listener, de una petición cada vez: en un health check no se nota, en un megabyte se nota muchísimo. La RC6 manda las dos cosas al task pool por defecto, y cuando Content-Length se conoce escribe el body directamente en un TBytes ya dimensionado en lugar de un TMemoryStream seguido de un SetLength y un Move. Así que lee los 892 rps como el HTTP.sys al que por fin se le hace la pregunta correcta, no como un truco nuevo.
Una fila va en la dirección contraria: heavy sobre Indy Direct midió -9%. En una máquina de bench con una varianza entre ejecuciones cercana al 20% eso se lee como neutro, no como una regresión. Las diferencias por debajo de aproximadamente el 15% en este equipo son ruido. La comparación entre backends, y las ejecuciones de WebBroker (no comparables a c=100 en esta máquina, donde el servidor no se mantiene en pie durante toda la ejecución), están en performancetest/results/BASELINE_AFTER.md.
Probarla
Dos caminos.
Descarga el zip desde la página de release, añade sources al library path, y para un proyecto existente ahí termina la instalación.
O instalas el asistente para el IDE y dejas que te genere uno. Los presets aparecen en el diálogo New Items del IDE, bajo Delphi > DelphiMVCFramework:

Trae 8 presets de proyecto: RESTful API, Minimal API RESTful, Web Application, Minimal API WebApp, JSON-RPC Service, Real-Time Application (WebSocket), Full-Stack Application y Custom Project con todas las opciones a la vista. Cada preset rellena el mismo formulario del asistente con valores por defecto distintos, así que puedes aceptarlos o cambiar lo que quieras antes de generar. El host por defecto en todos los presets es Indy Direct.
Si algo se rompe, o si una actualización desde la 3.4.x necesita un paso que no está en la sección de cambios de arriba, abre un issue en GitHub antes de la etiqueta estable. Para eso sirve una release candidate. Un bug encontrado ahora es un arreglo en la 3.5.0; el mismo bug encontrado más tarde es un arreglo en la 3.5.1 y una tarde de la vida de alguien.
Recursos
- Página de release - descarga y changelog completo
- Repositorio GitHub - código fuente y más de 40 ejemplos
- La guía oficial, segunda edición - 30 capítulos escritos sobre esta release, en Leanpub
- Comunidad PATREON - tutoriales, vídeos y soporte prioritario
Enjoy!
– Daniele Teti

Comments
comments powered by Disqus