Hosting — Guía de uso
🌍 Idioma: English | Español
Contenido
- Visión general de la arquitectura
- Construir un host
- Pipeline de configuración
- Compartir datos entre piezas del arranque
- Registrar servicios
- Hosted services (workers en segundo plano)
- Tareas de arranque
- Entorno
- Ciclo de vida de la aplicación
- Acceder a servicios tras el Build
- Apagado elegante
- Patrón AppModule con Hosting
Visión general de la arquitectura
THost.CreateBuilder ──► IGenericHostBuilder
├── Configuration: IConfigurationManager ← las fuentes, y su lectura
├── Environment: IHostEnvironment ← resuelto al leerlo, derivado de la config de host
├── Services: IServiceCollection ← registros DI
├── Logging: ILoggingBuilder ← solo se compone si lo tocas
├── Properties ← datos que comparten las piezas del arranque
└── Build ──► IHost
├── Services: IServiceProvider
├── IHostEnvironment
├── IHostApplicationLifetime
└── [IHostedService, ...]
El host se construye una vez, se arranca una vez y se para una vez. Todos los servicios están gestionados por el contenedor DI.
Construir un host
uses Daf.Hosting, Daf.Extensions.Hosting, Daf.Extensions.DependencyInjection;
var Builder := THost.CreateBuilder;
Builder.Services.AddSingleton<IMyService, TMyService>;
var Host := Builder.Build;
try
Host.Start;
Host.WaitForShutdown; // bloquea hasta la señal de parada
finally
StopAndNil(Host);
end;THost.CreateBuilder entrega un IGenericHostBuilder, así que una sola variable posee el builder y llega a Build. Cada builder es independiente: puede haber varios builders y varios hosts vivos a la vez en un mismo proceso.
Build solo puede llamarse una vez por builder; congela la configuración y crea el contenedor DI.
StopAndNil(Host) para el host y deja la variable vacía, como FreeAndNil con un objeto: parar libera el host, y una variable que sigue apuntando a él toca memoria devuelta al salir de su ámbito.
Pipeline de configuración
Hay una configuración, y se puebla la primera vez que se lee. Builder.Configuration es un IConfigurationManager —constructor y lector a la vez—, así que una fuente se puede elegir a partir de lo ya configurado.
1. Lo que siembra el builder
La primera vez que la composición lee Configuration o Environment —o en el Build, si no lee ninguna—, el builder carga las fuentes que carga Host.CreateApplicationBuilder, en este orden, y cada una gana a las anteriores:
- las variables de entorno del proceso con prefijo
DAF_, sin el prefijo —DAF_ENVIRONMENTfijaenvironment— y, si se le dieron argumentos, la línea de comandos; appsettings.json,appsettings.{Entorno}.json,{ApplicationName}.settings.jsony{ApplicationName}.settings.{Entorno}.jsondel content root, todos opcionales; un separador de ruta en el nombre de la aplicación pasa a_;- todas las variables de entorno, con
__como separador de sección:Db__ServeresDb:Server; - otra vez la línea de comandos.
var Builder := THost.CreateBuilder(CommandLineConfig.ProcessArgs);De esas primeras fuentes sale el entorno, por las claves que usa .NET y que nombra THostDefaults: environment, applicationName y contentRoot. Así que --environment Staging y DAF_ENVIRONMENT=Staging dicen lo mismo, y gana el argumento.
El content root es el directorio actual, como en .NET, salvo que contentRoot diga otro; uno relativo se resuelve contra la carpeta del ejecutable. La excepción es el directorio del sistema, que es donde arranca un servicio de Windows: ahí el content root es la carpeta del ejecutable. Environment.BinPath es siempre esa carpeta, sea cual sea el directorio de trabajo.
Los ficheros se leen una vez, en esa primera lectura: un cambio en ellos no se nota mientras el proceso sigue vivo, y crear el builder no abre ninguno.
Un fichero por defecto que no se puede leer —un appsettings.json mal formado— se informa por el canal de arranque al abrirse, y la lectura sigue con las demás fuentes; el Build falla después con esa misma excepción. Una fuente que añades tú falla al añadirla, en tu código.
2. El entorno, ya resuelto
Builder.Environment se deriva de esa siembra al cargarla. Eso es lo que permite que el paso siguiente dependa de él: no hay ninguna fase que esperar.
3. Tus propias fuentes
Se añaden donde estás, entre el builder y Build. Van detrás de las de por defecto, así que les ganan, línea de comandos incluida, como en .NET:
JsonConfig.AddFile(Builder.Configuration,
TPath.Combine(Builder.Environment.ContentRootPath, 'features.json'), [csoOptional]);Y como el manager lee además de construir, un registro consume configuración en el sitio:
var Db := Builder.Configuration['Database:ConnectionString'];
Builder.Services.AddSingleton<IDatabase>(TDatabase.Create(Db));Para eso existían los callbacks diferidos, y por eso no hay ninguno: con una configuración que se lee mientras se compone, no queda nada que diferir.
Build la congela. A partir de ahí, esa misma configuración es la que entrega IHostBuilderContext.Configuration.
Compartir datos entre piezas del arranque
Properties es un TDictionary<string, TValue> que lleva el builder, para los datos que las piezas que componen el arranque necesitan pasarse entre sí. Está en IHostApplicationBuilder, y el mismo diccionario se lee desde el otro lado por IHostBuilderContext.Properties, que está registrado en el contenedor.
Builder.Properties.AddOrSetValue('tenant', 'acme');
// ...en cualquier sitio que tenga el contexto:
var Tenant := Context.Properties['tenant'].AsString;Registrar servicios
La API completa de IServiceCollection está disponible en Builder.Services:
var Services := Builder.Services;
// Tiempos de vida
Services.AddSingleton<ICache, TMemoryCache>;
Services.AddScoped<IUnitOfWork, TDbUnitOfWork>;
Services.AddTransient<IEmailSender, TSmtpSender>;
// Hosted service (arranca/para con el host)
Services.AddHostedService<TBackgroundIndexer>;Consulta DependencyInjection para la referencia completa de registro.
Hosted services (workers en segundo plano)
Implementa IHostedService para cualquier trabajo que deba ejecutarse junto al host:
type
TOrderProcessor = class(TInterfacedObject, IHostedService)
private
FTimer: TTimer;
public
procedure Start;
procedure Stop;
end;
procedure TOrderProcessor.Start;
begin
// configurar trabajo periódico, abrir conexiones, etc.
end;
procedure TOrderProcessor.Stop;
begin
// vaciar, cerrar, limpiar
end;Registra el servicio:
Services.AddHostedService<TOrderProcessor>;Startse llama para cada hosted service cuando se ejecutaHost.Start, en orden de registro.Stopse llama en el orden inverso durante el apagado —el mismo en que el contenedor libera—, así que un servicio para antes que los registrados delante de él, que puede estar usando.AddHostedService<T>registraTbajoIHostedServicey nada más. Para llegar a esa misma instancia comoIOrderProcessor, regístralo también y que el registro del hosted service lo resuelva.
Tareas de arranque
Un IStartupTask corre una vez al arrancar, con el proveedor ya construido. Tiene un Run y nada que parar: es inicialización, no un servicio con ciclo de vida.
type
TWarmUpCache = class(TInterfacedObject, IStartupTask)
public
procedure Run;
end;
procedure TWarmUpCache.Run;
begin
// leer lo que la aplicación necesita antes de atender nada
end;Se registra en la colección:
Services.AddStartupTask<TWarmUpCache>;THost.Start ejecuta las tareas en orden de registro, antes de todos los IHostedService y antes de ApplicationStarted — un servicio alojado puede depender del estado que prepara una tarea, así que el orden es el punto y no una casualidad.
Una tarea que lanza aborta el arranque. La excepción se informa y se vuelve a lanzar, así que no arranca ningún servicio alojado y la aplicación nunca se declara arrancada. Eso es lo que hace de una tarea de arranque el sitio de una precondición: una caché que no se puede llenar, una carpeta que no es escribible, un esquema que no es el esperado.
Cuando el helper queda ensombrecido
En Delphi solo hay un record helper visible por tipo, así que una unit que traiga un segundo helper para IServiceCollection esconde a este. La fachada de clase es la vía, y toma la implementación como metaclase:
Hosting.AddStartupTask(Services, TWarmUpCache);Es el mismo registro: con ella un paquete aporta su propia inicialización sin que el .dpr nombre la tarea — ver PackageInit.
Elegir entre un IStartupTask y un IHostedService se decide en Composición del arranque.
Entorno
IHostEnvironment está pre-registrado como Singleton. Accede a él desde cualquier parte del contenedor DI:
var Env := Host.Services.GetRequiredService<IHostEnvironment>;
Env.ApplicationName // p. ej. 'MyApp'
Env.EnvironmentName // p. ej. 'Development'
Env.ContentRootPath // p. ej. 'C:\MyApp\'
Env.BinPath // siempre el directorio del binario
Env.IsDevelopment // True en Development
Env.IsProduction // True en Production
Env.IsTesting // True en Testing (útil para test runners)Establece el entorno mediante variable de OS antes de lanzar:
DAF_ENVIRONMENT=Development
Cualquier otro nombre, como QA, es un entorno más, con su propio appsettings.QA.json. Las preguntas Is… comparan el nombre sin distinguir mayúsculas.
En Development el host además valida el contenedor al construirlo: un registro cuyas dependencias no se pueden satisfacer, o un Singleton que captura un Scoped, detiene Build con todos los hallazgos juntos, y un Scoped pedido al proveedor raíz se rechaza al resolverlo. ServiceProviderOptions en el builder fija esa elección sea cual sea el entorno — ver Validación del contenedor en el README del módulo.
También puedes leer variables de entorno arbitrarias:
var DbHost := Env['MY_DB_HOST'];Ciclo de vida de la aplicación
IHostApplicationLifetime publica tres cancellation tokens a los que puedes reaccionar:
var Lifetime := Services.GetRequiredService<IHostApplicationLifetime>;
// Se dispara cuando todos los hosted services han arrancado
Lifetime.ApplicationStarted.Register(procedure begin
Logger.LogInformation('Aplicación lista');
end);
// Se dispara cuando se solicita un apagado (antes de que paren los servicios)
Lifetime.ApplicationStopping.Register(procedure begin
Logger.LogInformation('Apagado iniciado');
end);
// Se dispara cuando todos los hosted services han parado
Lifetime.ApplicationStopped.Register(procedure begin
Logger.LogInformation('Aplicación detenida');
end);
// Solicitar apagado elegante por programa
Lifetime.StopApplication;StopApplication desencadena la misma secuencia de apagado que Ctrl+C / SIGTERM.
Acceder a servicios tras el Build
var Host := Builder.Build;
try
Host.Start;
// Acceder a servicios desde fuera del grafo DI
var Svc := Host.Services.GetRequiredService<IMyService>;
Svc.Execute;
Host.WaitForShutdown;
finally
StopAndNil(Host);
end;Para operaciones con scope (p. ej. un job en segundo plano que necesita una unidad de trabajo):
var Scope := Host.Services.CreateScope;
try
var UoW := Scope.ServiceProvider.GetRequiredService<IUnitOfWork>;
UoW.DoWork;
UoW.Commit;
finally
Scope := nil;
end;Apagado elegante
Host.WaitForShutdown bloquea hasta que el proceso recibe una señal de apagado (Ctrl+C, SIGTERM o Lifetime.StopApplication). Host.Stop ejecuta la secuencia de apagado:
- Se disparan los callbacks de
IHostApplicationLifetime.ApplicationStopping - Se llama a
IHostedService.Stoppara cada servicio, en orden inverso al de registro - Se disparan los callbacks de
IHostApplicationLifetime.ApplicationStopped - El contenedor DI suelta cada Singleton cuando ya no lo sostiene nadie más que él, así que un servicio cae después de todo lo que depende de él
Un paso que falla no impide los siguientes. Un servicio alojado cuyo Stop lanza se informa por el logger cuando ocurre, y el resto de la secuencia se ejecuta igual. Host.Stop lanza lo que falló al final, con el host ya liberado:
- un fallo: esa misma excepción, sin envolver;
- varios: una
EAggregateException(unitSystem.Threading) que los contiene todos, y cuyo mensaje dice cuántos pasos fallaron.
StopAndNil(Host) deja la variable vacía también cuando Stop lanza, y lanza lo mismo. Solo vacía esa variable: una copia de la referencia guardada aparte sigue apuntando al host liberado. La copia se suelta en ApplicationStopped, que llega con el host todavía vivo — el sample HostLifetime guarda una y la suelta ahí.
Liberar un host que no se paró ejecuta la misma secuencia una vez. Lo que falle ahí se informa, y el destructor no lo lanza.
Stop llega a todos los servicios alojados de un host cuyo Start empezó, también a los que no llegaron a arrancar o cuyo Start lanzó, así que el Stop de un servicio tolera que su Start no corriera. Un host que no llegó a arrancar notifica la parada y cierra el contenedor sin llamar al Stop de ningún servicio, así que Host.Start va dentro del try, como en los ejemplos de arriba.
WaitForShutdown se integra con el manejador de señales del SO mediante TShutdownHook.
Patrón AppModule con Hosting
Divide los registros en módulos para apps grandes:
uses Daf.AppModule, Daf.Extensions.DependencyInjection;
type
TInfraModule = class(TInterfacedObject, IAppModule)
public
procedure AddServices(const Services: IServiceCollection);
end;
procedure TInfraModule.AddServices(const Services: IServiceCollection);
begin
Services.AddSingleton<IDatabase, TPostgresDatabase>;
Services.AddHostedService<TBackgroundWorker>;
end;Carga los módulos en Builder.Services, un AddModule<T> por módulo:
Builder.Services
.AddModule<TInfraModule>
.AddModule<TDomainModule>;AddModule<T> instancia el módulo, llama a su AddServices en el sitio y se hace cargo de su vida. No llames a TInfraModule.Create.AddServices(Services) a mano: el módulo es un TInterfacedObject creado con contador de referencias a cero, así que nadie lo libera.
Cuando un módulo necesita configuración, constrúyelo tú y pasa la instancia: Services.AddModule(TInfraModule.Create(...)). Entregarlo como IAppModule es lo que le da un dueño, y es la única vía de pasarle configuración.
No hay descubrimiento automático. AddModules(Package), el escaneo por RTTI de los IAppModule, se retiró: escondía la declaración en un uses, dejaba el arranque ciego en silencio ante una RTTI recortada y ponía el orden de registro en manos de la reflexión. Ninguna variante de la familia registra módulos por su cuenta — los suyos se declaran donde se compongan los servicios, igual que arriba. Las dos fases y las reglas de orden, en Composición del arranque.