A Newsletter Servizio abbonamenti Parte 2 - Refactoring the Services (and a little Hangfire) (Italiano (Italian))

A Newsletter Servizio abbonamenti Parte 2 - Refactoring the Services (and a little Hangfire)

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Monday, 23 September 2024

//

7 minute read

Introduzione

Dentro parte 1 di questa serie Vi ho mostrato come ho creato una nuova pagina di abbonamento alla Newsletter. In questa parte mi occuperò di come ho ristrutturato la soluzione per consentire la condivisione di Servizi e Modelli tra il progetto Sito Web (Mostlylucid) e il progetto Scheduler Service (Mostlylucid.SchedulerService).

I progetti

Originariamente avevo solo un singolo progetto monolitico che conteneva tutto il codice per il sito web. Questo è un approccio decente per le applicazioni più piccole, ti dà una soluzione semplice da navigare, costruire e distribuire; tutti sono considerazioni ragionevoli. Tuttavia, mentre si scala una soluzione si vuole iniziare a dividere il progetto per consentire l'isolamento delle preoccupazioni e per consentire un test più facile, la navigazione (grande progetto con un sacco di bit può essere difficile da navigare). Inoltre dividere il servizio scheduler ha senso per me come posso distribuire questo come un contenitore docker separato che mi permette di aggiornare il sito web senza causare il scheduler di riavviare.

Per fare questo ho raggruppato logicamente le preoccupazioni in 5 progetti. Questo è un approccio comune nelle applicazioni ASP.NET.

Ora posso aggiungere progetti di prova per ciascuno di questi e testarli in modo isolato. Questo è un grande vantaggio in quanto mi permette di testare i servizi senza bisogno di far girare l'intera applicazione.

Per lo più Lucid

Questo progetto è un progetto web ASP.NET Core (8), contiene tutti i Controller & Views per visualizzare le pagine all'utente.

Principalmente Lucid.DbContext

Questo contiene la mia principale definizione di contesto utilizzata per interagire con il database. Compreso l'EF Core DbContext.

Per lo più Lucid.Services

Questo è il progetto di libreria di classe principale che contiene tutti i servizi che interagiscono con il database / Markdown file / Servizi di posta elettronica ecc.

Principalmente lucido.Condiviso

Si tratta di un progetto di libreria di classe che contiene tutti i modelli condivisi utilizzati sia dai progetti Mostlylucid che Mostlylucid.SchedulerService.

Per lo più Lucid.SchedulerService

Si tratta di un'applicazione Web che controlla il servizio Hangfire che esegue le attività pianificate e gli endpoint per gestire l'invio di email.

La struttura di questo è mostrata di seguito. Puoi vedere che SchedulerService e il principale Mostlylucid usano solo la classe DbContext per la configurazione iniziale.

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]

Come al solito uso un metodo di estensione come punto di accesso per la creazione del database.

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

Oltre a chiamare questo metodo, nessun progetto di alto livello ha alcuna dipendenza dal progetto DbContext.

NOTA: È importante evitare una condizione di gara quando si inizializza DbContext (soprattutto se si esegue le migrazioni) per fare questo semplicemente qui ho appena aggiunto una dipendenza nel mio Docker Compose file per garantire che il principale progetto Mostlylucid è attivo e in esecuzione prima di avviare il mio ShcedulerService. Questo viene aggiunto alla definizione del servizio per il mio SchedulerService nel file docker-compose.

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

Puoi anche farlo in altri modi, ad esempio in passato quando ho eseguito più istanze della stessa app ho usato Redis per impostare un flag di blocco che ogni istanza controlla prima di eseguire le migrazioni / altre attività dovrebbe essere eseguito solo un'istanza alla volta.

Configurazione di Hangfire

Ho scelto di utilizzare Hangfire per gestire la mia pianificazione in quanto ha un'integrazione conveniente con ASP.NET Core ed è facile da usare.

Per questo nel progetto SchedulerService ho aggiunto i seguenti pacchetti NuGet.

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

Questo significa che posso usare Postgres come mio negozio per le attività programmate.

Ora ho Hangfire seetup posso iniziare ad aggiungere i miei lavori programmati.

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

Qui vedete che ho un lavoro per ciascuno SubscriptionType che è una bandiera che determina la frequenza di invio della newsletter. Io uso il Cron classe per impostare la frequenza del lavoro.

Poiché ho l'opzione per il giorno sia per gli abbonamenti mensili che settimanali ho fissato il tempo alle 17:00 (5pm) in quanto questo è un buon momento per inviare le newsletter (fine della giornata nel Regno Unito e iniziare negli Stati Uniti). Ho anche un lavoro che funziona ogni ora per inviare la newsletter a coloro che hanno sottoscritto ogni post.

Ciò richiede quindi un SendNewsletter metodo nel NewsletterSendingService classe. Che andro' piu' in dettaglio in un post successivo.

Dtos e Mappatura

Uso DTOsCity name (optional, probably does not need a translation) per trasportare i dati tra i livelli della mia applicazione. Questo aggiunge complessità come ho bisogno di mappare (spesso due volte) le Entità a Dtos e poi il Dtos a ViewModels (e indietro). Tuttavia, trovo che questa separazione delle preoccupazioni ne valga la pena in quanto mi permette di modificare la struttura dei dati sottostante senza influenzare l'interfaccia utente.

È possibile utilizzare approcci come AutoMapper / Mapster ecc per fare questa mappatura. Utilizzando questi può anche avere significativi vantaggi di prestazioni per.AsNoTracking() query come è possibile mappare direttamente al Dto ed evitare l'overhead delle modifiche di tracciamento. Per esempio AutoMapper ha un Estensione IQueryable metodo che consente di mappare direttamente il Dto nella query.

Tuttavia in questo caso ho deciso solo di aggiungere estensioni mapper dove avevo bisogno. Questo mi permette di avere più controllo sulla mappatura per ogni livello (ma è più lavoro, soprattutto se si dispone di un sacco di entità).

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

Qui puoi vedere la mappa BlogPostEntity al mio principale BlogPostDto che è il mio oggetto di trasferimento.
L'obiettivo è che i servizi front-end non sappiano nulla dell'oggetto Entity e siano "astratti" dalla struttura dei dati sottostante.

In questi servizi di primo livello ho poi il codice per mappare questi Dtos a ViewModels che vengono poi utilizzati nei Controller.

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

Ancora una volta, utilizzando uno strumento Mapper può evitare questo codice caldaia (e ridurre gli errori) ma trovo che questo approccio funziona bene per me in questo caso.

Ora ho tutti questi modelli impostati posso iniziare ad aggiungere i metodi del controller per usarli. Copriro' questo flusso nella prossima parte.

In conclusione

Ora abbiamo la struttura che ci permette di iniziare ad offrire le iscrizioni alla newsletter ai nostri utenti. Questo refactoring è stato leggermente doloroso, ma è un fatto di vita per la maggior parte dei progetti. Avviare semplice e aggiungere complicazioni architettoniche quando necessario. I posti futuri copriranno il resto di questo, compreso un follow-up sul mio uso di [FluentMail] per inviare le e-mail e il Servizio Ospitato... e forse un po 'più Hangfire.

logo

©2024 Scott Galloway