Back to "Ett nyhetsbrev Prenumerationstjänst Del 2 - Refaktor för tjänsterna (och lite 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

Ett nyhetsbrev Prenumerationstjänst Del 2 - Refaktor för tjänsterna (och lite Hangfire)

Monday, 23 September 2024

Inledning

Ingångspunkt Del 1 i denna serie Jag visade dig hur jag skapade en ny sida för nyhetsbrevet. I den här delen ska jag ta upp hur jag omstrukturerade lösningen för att möjliggöra delning av tjänster och modeller mellan Websiteprojektet (Mostlylucid) och Schedule Service-projektet (Mostlylucid.SchedulerService).

Projekten

Ursprungligen hade jag bara ett enda, monolitiskt projekt som innehöll alla koden för webbplatsen. Detta är en anständig strategi för mindre applikationer, det ger dig en enkel att navigera, bygga och distribuera lösning. Allt detta är rimliga överväganden. Men när du skalar upp en lösning vill du börja dela upp ditt projekt för att möjliggöra isolering av problem och för att möjliggöra enklare testning, navigering (stort projekt med massor av bitar kan vara svårt att navigera). Dessutom dela ut schemaläggaren tjänsten är vettigt för mig som jag kan distribuera detta som en separat docker behållare så att jag kan uppdatera webbplatsen utan att orsaka schemaläggaren att starta om.

För att göra detta grupperade jag frågorna logiskt sett i fem projekt. Detta är ett gemensamt tillvägagångssätt i ASP.NET-tillämpningar.

Mest lucid

Detta projekt är ett ASP.NET Core (8) webbprojekt, det håller alla Controllers & Views för att visa sidor för användaren.

Mestlylucid.DbContext

Detta innehåller min huvudsakliga kontextdefinition som används för att interagera med databasen. Inklusive EF Core DbContext.

Mestlylucid.Services

Detta är huvudklassen biblioteksprojekt som håller alla tjänster som interagerar med databasen / Markdown filer / E-tjänster etc.

Mest lucid.Delad

Detta är ett klassbiblioteksprojekt som innehåller alla delade modeller som används av både Mostlylucid och Mostlylucid.SchedulerService-projekt.

Mestlylucid.SchedulerService

Detta är ett webbprogram som styr Hangfire-tjänsten som kör de schemalagda uppgifterna samt slutpunkterna för att hantera att skicka e-postmeddelanden.

Strukturen för detta visas nedan. Du kan se att SchedulerService och den huvudsakliga Mostlylucid endast använder DbContext klassen för inledande inställning.

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]

Som vanligt använder jag en förlängningsmetod som entryppoint för att skapa databasen.

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);
        });
    }
}

Förutom att kalla denna metod har inget projekt på toppnivå några beroenden av DbContext-projektet.

OBS: Det är viktigt att undvika ett tävlingstillstånd vid initiering av DbContext (särskilt om du kör migreringar) för att göra detta helt enkelt här Jag lade bara till ett beroende i min Docker Kompositfil för att säkerställa att huvudprojektet Mostlylucid är igång innan du startar min ShcedulerService. Detta läggs till i tjänstedefinitionen för min SchedulerService i Docker-compose-filen.

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

Du kan också åstadkomma detta på andra sätt, till exempel i det förflutna när du kör flera instanser av samma app jag har använt Redis för att ställa in en lås flagga som varje instans kontrollerar innan du kör migreringar / andra uppgifter bara en instans bör köras i taget.

Inställning av hangfire

Jag valde att använda Hangfire för att hantera mitt schema eftersom det har bekväm integration med ASP.NET Core och är lätt att använda.

För detta i SchedulerService-projektet lade jag till följande NuGet-paket.

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

Detta innebär att jag kan använda Postgres som min butik för de schemalagda uppgifterna.

Nu har jag Hangfire seetup Jag kan börja lägga till mina schemalagda jobb.

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 * * *");
    }
}

Här ser du Jag har ett jobb för varje SubscriptionType vilket är en flagga som avgör hur ofta nyhetsbrevet skickas. Jag använder Cron Klass för att bestämma hur ofta jobbet ska utföras.

Eftersom jag har möjlighet till dag för både månads- och veckoabonnemang sätter jag tiden till 17:00 (5pm) eftersom detta är ett bra tillfälle att skicka nyhetsbrev (slutet av dagen i Storbritannien och börja i USA). Jag har också ett jobb som körs varje timme för att skicka nyhetsbrevet till dem som har prenumererat på varje inlägg.

Detta kräver sedan en SendNewsletter metoden i NewsletterSendingService Klassen. Vilket jag ska gå närmare in på i ett senare inlägg.

Dtos och kartläggning

Jag använder DTO:er föra data mellan skikten i min applikation. Detta DOES lägga komplexitet som jag behöver för att kartlägga (ofta två gånger) enheterna till Dtos och sedan Dtos till ViewModeller (och tillbaka). Jag anser dock att denna åtskillnad av farhågor är värt det eftersom det gör det möjligt för mig att ändra den underliggande datastrukturen utan att påverka UI.

Du kan använda tillvägagångssätt som AutoMapper / Mapster etc för att göra denna kartläggning. Använda dessa kan också ha betydande prestandafördelar för.AsNoTracking () frågor som du kan kartlägga direkt till Dto och undvika overhead av spårning förändringar. Till exempel AutoMapper har en IQueryable förlängning metod som gör att du kan kartlägga direkt till Dto i frågan.

Men i det här fallet bestämde jag mig för att bara lägga till mapper förlängningar där jag behövde. Detta gör att jag kan ha mer kontroll över kartläggningen för varje nivå (men är mer arbete, särskilt om du har en hel del enheter).

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>()
        };
    }
}

Här ser du att jag kartlägger BlogPostEntity till min huvud BlogPostDto Vilket är mitt överföringsobjekt.
Syftet är att front-end-tjänsterna inte vet något om objektet Entitet och är "abstraherade" från den underliggande datastrukturen.

I dessa toppnivåtjänster har jag sedan kod för att kartlägga dessa Dtos till ViewModeller som sedan används i Controllers.

    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
        };
    }

Återigen, med hjälp av en Mapper verktyg kan undvika denna pannplate kod (och minska fel) men jag tycker att detta tillvägagångssätt fungerar bra för mig i detta fall.

Nu har jag alla dessa modeller som jag kan börja lägga till controller metoder för att använda dem. Jag täcker det här flödet i nästa del.

Slutsatser

Vi har nu strukturen som gör att vi kan börja erbjuda nyhetsbrev abonnemang till våra användare. Denna refaktoring var något smärtsam, men det är ett faktum för de flesta projekt. Starta enkelt och lägg till arkitektonisk komplikation vid behov. Framtida inlägg kommer att täcka resten av detta, inklusive en uppföljning av min användning av [Flytande brev] för att skicka e-post och värdtjänst... och kanske lite mer Hangfire.

logo

©2024 Scott Galloway