Back to "Un servicio de suscripción de boletín de noticias Parte 2 - Refactorización de los servicios (y un poco de Hangfire)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET Email Newsletter Hangfire

Un servicio de suscripción de boletín de noticias Parte 2 - Refactorización de los servicios (y un poco de Hangfire)

Monday, 23 September 2024

Introducción

In Parte 1 de esta serie Te mostré cómo creé una nueva página de suscripción al boletín. En esta parte cubriré cómo reestructuré la solución para permitir el intercambio de Servicios y Modelos entre el proyecto Web (Mostlylucid) y el proyecto Scheduler Service (Mostlylucid.SchedulerService).

Los proyectos

Originalmente sólo tenía un único proyecto monolítico que contenía todo el código para el sitio web. Este es un enfoque decente para aplicaciones más pequeñas, le da una solución simple de navegar, construir e implementar; todas las cuales son consideraciones razonables. Sin embargo, a medida que amplíes una solución, querrás empezar a dividir tu proyecto para permitir el aislamiento de las preocupaciones y permitir pruebas más fáciles, la navegación (un gran proyecto con un montón de bits puede ser difícil de navegar). Además, dividir el servicio de programador tiene sentido para mí, ya que puedo implementar esto como un contenedor de docker separado que me permite actualizar el sitio web sin hacer que el programador para reiniciar.

Para ello agrupé lógicamente las preocupaciones en 5 proyectos. Este es un enfoque común en las aplicaciones ASP.NET.

Ahora puedo añadir proyectos de prueba para cada uno de estos y probarlos en aislamiento. Esta es una gran ventaja, ya que me permite probar los servicios sin necesidad de girar toda la aplicación.

Principalmente lúcido

Este proyecto es un proyecto web ASP.NET Core (8), tiene todos los controladores y vistas para mostrar páginas al usuario.

Principalmente lúcido.DbContexto

Esto contiene mi definición de contexto principal utilizado para interactuar con la base de datos. Incluyendo el EF Core DbContext.

Mayormente lúcido.Servicios

Este es el proyecto principal de biblioteca de clase que contiene todos los servicios que interactúan con la base de datos / Archivos Markdown / Servicios de correo electrónico, etc.

Mayormente lúcido.Compartido

Este es un proyecto de biblioteca de clase que contiene todos los modelos compartidos que son utilizados por los proyectos Mostlylucid y Mostlylucid.SchedulerService.

Mayormentelucid.SchedulerService

Esta es una aplicación web que controla el servicio Hangfire que ejecuta las tareas programadas, así como los puntos finales para manejar el envío de correos electrónicos.

La estructura de esto se muestra a continuación. Puede ver que SchedulerService y el principal Mostlylucid solo usan la clase DbContext para la configuración inicial.

graph LR Mostlylucid[Mostlylucid] --> |Intialize| Mostlylucid_DbContext[Mostlylucid.DbContext] Mostlylucid[Mostlylucid] --> Mostlylucid_Shared[Mostlylucid.Shared] Mostlylucid[Mostlylucid] --> Mostlylucid_Services[Mostlylucid.Services] Mostlylucid_DbContext[Mostlylucid.DbContext] --> Mostlylucid_Shared[Mostlylucid.Shared] Mostlylucid_Services[Mostlylucid.Services] --> Mostlylucid_DbContext[Mostlylucid.DbContext] Mostlylucid_Services[Mostlylucid.Services] --> Mostlylucid_Shared[Mostlylucid.Shared] Mostlylucid_SchedulerService[Mostlylucid.SchedulerService] --> Mostlylucid_Shared[Mostlylucid.Shared] Mostlylucid_SchedulerService[Mostlylucid.SchedulerService] --> |Intialize|Mostlylucid_DbContext[Mostlylucid.DbContext] Mostlylucid_SchedulerService[Mostlylucid.SchedulerService] --> Mostlylucid_Services[Mostlylucid.Services]

Como de costumbre, utilizo un método de extensión como punto de entrada para configurar la base de datos.

public static class Setup
{
    public static void SetupDatabase(this IServiceCollection services, IConfiguration configuration,
        IWebHostEnvironment env, string applicationName="mostlylucid")
    {
        services.AddDbContext<IMostlylucidDBContext, MostlylucidDbContext>(options =>
        {
            if (env.IsDevelopment())
            {
                options.EnableDetailedErrors(true);
                options.EnableSensitiveDataLogging(true);
            }
            var connectionString = configuration.GetConnectionString("DefaultConnection");
            var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString)
            {
                ApplicationName = applicationName
            };
            options.UseNpgsql(connectionStringBuilder.ConnectionString);
        });
    }
}

Aparte de llamar a este método, ningún proyecto de nivel superior depende del proyecto DbContext.

NOTA: Es importante evitar una condición de carrera al inicializar DbContext (especialmente si ejecuta migraciones) para hacer esto simplemente aquí acabo de añadir una dependencia en mi archivo Docker Compose para asegurar que el proyecto principal Mostlylucid está en funcionamiento antes de iniciar mi ShcedulerService. Esto se añade a la definición de servicio para mi SchedulerService en el archivo docker-compose.

  depends_on:
      - mostlylucid 
  healthcheck:
      test: ["CMD", "curl", "-f", "http://mostlylucid:80/healthy"]
      interval: 30s
      timeout: 10s
      retries: 5

También puede lograr esto de otras maneras, por ejemplo en el pasado cuando se ejecutan múltiples instancias de la misma aplicación que he utilizado Redis para establecer una bandera de bloqueo que cada instancia comprueba antes de ejecutar migraciones / otras tareas sólo una instancia debe ejecutarse a la vez.

Configuración del fuego del hangfire

Elegí utilizar Hangfire para manejar mi programación, ya que tiene una integración conveniente con ASP.NET Core y es fácil de usar.

Para esto en el proyecto SchedulerService agregué los siguientes paquetes NuGet.

dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.PostgreSql

Esto significa que puedo usar Postgres como mi tienda para las tareas programadas.

Ahora tengo Hangfire betup que puedo empezar a añadir mis trabajos programados.

public static class JobInitializer
{
    private const string AutoNewsletterJob = "AutoNewsletterJob";
    private const string DailyNewsletterJob = "DailyNewsletterJob";
    private const string WeeklyNewsletterJob = "WeeklyNewsletterJob";
    private const string MonthlyNewsletterJob = "MonthlyNewsletterJob";
    public static void InitializeJobs(this IApplicationBuilder app)
    {
      var scope=  app.ApplicationServices.CreateScope();
        var recurringJobManager = scope.ServiceProvider.GetRequiredService<RecurringJobManager>();
      
        recurringJobManager.AddOrUpdate<NewsletterSendingService>(AutoNewsletterJob,  x =>  x.SendNewsletter(SubscriptionType.EveryPost), Cron.Hourly);
        recurringJobManager.AddOrUpdate<NewsletterSendingService>(DailyNewsletterJob,  x =>  x.SendNewsletter(SubscriptionType.Daily), "0 17 * * *");
        recurringJobManager.AddOrUpdate<NewsletterSendingService>(WeeklyNewsletterJob,  x =>  x.SendNewsletter(SubscriptionType.Weekly), "0 17 * * *");
        recurringJobManager.AddOrUpdate<NewsletterSendingService>(MonthlyNewsletterJob,  x => x.SendNewsletter(SubscriptionType.Monthly), "0 17 * * *");
    }
}

Aquí ves que tengo un trabajo para cada uno. SubscriptionType que es una bandera que determina con qué frecuencia se envía el boletín de noticias. Yo uso el Cron clase para establecer la frecuencia del trabajo.

Como tengo la opción para el día para las suscripciones mensuales y semanales, he fijado la hora a las 17:00 (5pm) ya que este es un buen momento para enviar boletines de noticias (final del día en el Reino Unido y empezar en los EE.UU.). También tengo un trabajo que funciona cada hora para enviar el boletín de noticias a aquellos que se han suscrito a cada puesto.

Esto entonces llama a un SendNewsletter método en la ventana NewsletterSendingService clase. Que voy a entrar en más detalles en un post posterior.

Dtos y cartografía

Utilizo DTOs para llevar los datos entre las capas de mi aplicación. Esto añade complejidad ya que necesito mapear (a menudo dos veces) las Entidades a Dtos y luego las Dtos a ViewModels (y atrás). Sin embargo, considero que esta separación de preocupaciones vale la pena, ya que me permite cambiar la estructura de datos subyacentes sin afectar a la interfaz de usuario.

Puede utilizar enfoques como AutoMapper / Mapster, etc para hacer esta asignación. El uso de estos también puede tener ventajas de rendimiento significativas para las consultas.AsNoTracking() ya que puede mapear directamente al Dto y evitar el exceso de cambios de seguimiento. Por ejemplo AutoMapper tiene un Extensión iQueryable método que le permite mapear directamente al Dto en la consulta.

Sin embargo, en este caso decidí sólo añadir extensiones de mapper donde necesitaba. Esto me permite tener más control sobre el mapeo para cada nivel (pero es más trabajo, especialmente si tienes muchas entidades).

public static class BlogPostEntityMapper
{
   public static BlogPostDto ToDto(this BlogPostEntity entity, string[] languages = null)
    {
        return new BlogPostDto
        {
            Id = entity.Id.ToString(),
            Title = entity.Title,
            Language = entity.LanguageEntity?.Name,
            Markdown = entity.Markdown,
            UpdatedDate = entity.UpdatedDate.DateTime,
            HtmlContent = entity.HtmlContent,
            PlainTextContent = entity.PlainTextContent,
            Slug = entity.Slug,
            WordCount = entity.WordCount,
            PublishedDate = entity.PublishedDate.DateTime,
            Languages = languages ?? Array.Empty<string>()
        };
    }
}

Aquí se puede ver el mapa de la BlogPostEntity a mi principal BlogPostDto que es mi objeto de transferencia.
El objetivo es que los servicios frontales no sepan nada sobre el objeto Entidad y estén 'abstraídos' de la estructura de datos subyacente.

En estos servicios de nivel superior tengo código para asignar estos Dtos a ViewModels que luego se utilizan en los controladores.

    public static PostListModel ToPostListModel(this BlogPostDto dto)
    {
        return new PostListModel
        {
            Id = dto.Id,
            Title = dto.Title,
            Language = dto.Language,
            UpdatedDate = dto.UpdatedDate.DateTime,
            Slug = dto.Slug,
            WordCount = dto.WordCount,
            PublishedDate = dto.PublishedDate,
            Languages = dto.Languages
        };
    }
    
    public static BlogPostViewModel ToViewModel(this BlogPostDto dto)
    {
        return new BlogPostViewModel
        {
            Id = dto.Id,
            Title = dto.Title,
            Language = dto.Language,
            Markdown = dto.Markdown,
            UpdatedDate = dto.UpdatedDate.DateTime,
            HtmlContent = dto.HtmlContent,
            PlainTextContent = dto.PlainTextContent,
            Slug = dto.Slug,
            WordCount = dto.WordCount,
            PublishedDate = dto.PublishedDate,
            Languages = dto.Languages
        };
    }

Una vez más, el uso de una herramienta Mapper puede evitar este código de placa de caldera (y reducir los errores), pero me parece que este enfoque funciona bien para mí en este caso.

Ahora tengo todos estos modelos configurados puedo empezar a añadir métodos de controlador para usarlos. Cubriré este flujo en la siguiente parte.

Conclusión

Ahora tenemos la estructura que nos permite empezar a ofrecer suscripciones de newsletter a nuestros usuarios. Esta refactorización fue un poco dolorosa, pero es un hecho de la vida para la mayoría de los proyectos. Comience simple y agregue complicaciones arquitectónicas cuando sea necesario. Los futuros posts cubrirán el resto de esto, incluyendo un seguimiento de mi uso de [FluentMail] para enviar los correos electrónicos y el Servicio Hosted... y tal vez un poco más Hangfire.

logo

©2024 Scott Galloway