Løst koblede features med ASP.NET Core

Sidst skrev jeg om opdeling af arkitektur i features frem for lag. Nu vil jeg se på et konkret eksempel – hvordan vi opnår opdeling i features med ASP.NET Core 3.

Har man først sat sig som mål at opdele ens arkitektur i features, kan det godt nogen gange føles, som om at værktøjer og frameworks kæmper imod én. Tag for eksempel det der kommer ud, når man vælger at starte på et ASP.NET Core website (dotnet new mvc):

Screenshot fra Visual Studio.

Med foldere i roden som Controllers, Models og Views ligger standardskabelonen op til en opdeling efter funktion frem for feature. Så der skal arbejdes lidt for sagen.

Jeg kommer tilbage til features, men først en lille detour.

Dependency Injection

For snart 12 år siden sad jeg i et firma og kæmpede med en stor XML fil. Filen skulle sørge for at det system jeg arbejde på blev konfigureret korrekt, og enhver lille fejl betød at hele systemet blev påvirket.

Det var konfiguration af dependency injection (DI) frameworket Castle Windsor (som i daglig tale blev til Carsten Vinsor) og det som filen beskrev, var forholdet mellem abstraktioner (basisklasser og interfaces) og deres konkrete implementationer samt hvad deres livscyklus skulle være:

<configuration>
    <configSections>
        <section name="castle"
        type="Castle.Windsor.Configuration.AppDomain.CastleSectionHandler, Castle.Windsor" />
    </configSections>

    <castle>
        <components>

            <component id="di.windsorExample.lineWriter"
            service="DI.WindsorExample.ILineWriter, DI"
            type="DI.WindsorExample.LineWriter, DI" />

            <component id="di.windsorExample.fileReader"
            service="DI.WindsorExample.IFileReader, DI"
            type="DI.WindsorExample.FileReader, DI" />

        </components>
    </castle>
</configuration>

(Eksempel venligst lånt fra min ex-kollegas blog. Sjovt som trends fungerer i branchen – det indlæg er ca samme alder som denne historie).

Det var på mange måder en problematisk måde at gøre tingene på – for det første skulle vi holde den store XML fil i sync med alle kodeændringer. Omdøbte man et namespace, et interface eller en klasse, skulle man ligeledes rundt og lave ændringer i filen.

Det største problem var dog at fejl først viste sig sent. Kompilering kunne gå fint, men så ville applikationen kaste fejl under opstart. Eller opstart ville gå fint, men så ville applikationen kaste fejl senere fordi den bare var wired forkert.

Siden da har jeg haft et lidt anstrengt forhold til DI-frameworks; hellere tydelige statiske bindinger mellem klasser, end bindinger skjult af konfiguration. Samtidig vil jeg dog sige at jeg er glad for testdrevet udvikling, og det har fået mig til at dyrke hvad jeg kalder fattigmands-DI i mange år:

OrderController(IProductCatalog productCatalog, ILogger logger = null)
{
    _logger = logger ?? new Logger();
    _productCatalog = productCatalog ?? new ProductCatalog();
}

Her wirer systemet sig selv op, afhængighederne er statiske, og vi kan stadig erstatte produktkataloget med en mock eller stub, når vi skal unitteste.

Men hvor vi får mulighed for at stadig teste med fattigmands-DI, så skaber det også tæt kobling mellem abstraktioner og implementationer. I ovenstående eksempel har vi en binding til en konkret implementation af IProductCatalog som kunne høre til i en helt anden feature, og som måske ikke skal have samme livscyklus som OrderController. Endnu værre hvis vi skal have en liste af IProductCatalog, et scenarie jeg har været i mange gange uden dependency injection, og som kræver at vi bruger reflection til at opdage typer og kan risikere at afvikle assemblies som vi ikke skulle have afviklet.

Microsoft har med ASP.NET Core taget skridtet videre og bygget hele systemet op omkring deres eget dependency injection framework. Og hvor man sikkert i store træk godt kan skrive et system uden at berøre deres DI-framework, tror jeg ikke at det er kampen værd.

Konfigurationen foregår nu, heldigvis, som udgangspunkt i kode og i et separat trin under opstart og nogle ting såsom registrering af controllere foregår automatisk.

Lige som mange andre DI-frameworks kan man, når man registrerer en type, vælge hvilken livscyklus den skal have, transient (altid ny instans), singleton (altid samme instans) og scoped (kort sagt samme instans inden for behandling af ét top-down kald).

Selve konkretisering af instanser og deres afhængighedsgraf foregår med de rette registreringer automatisk og dermed kan det spare os for noget indtastning:

ProductCatalog(IDBConnection dbConnection, ILogger logger)
{
    // Magisk dbConnection og logger
}

Når det kommer til features, kan vi bruge Microsoft’s DI framework til vores styrke og lade hver enkelt feature bestemme hvad der skal registres og dermed eksponeres. Desuden kan hver feature registres for sig så vi får Feature Toggles med i købet.

Feature-foldere

Lad os sige at vi er ved at lave et lille system til bestilling af frugt på tværs af flere leverandører.

  • Leverandørerne *Peters Fruit* og *Fresh* leverer begge deres udgave af et katalog.
  • Brugere kan bestille produkter, ordrer bliver håndteret manuelt.

Systemet er udelukkende en service der udstiller en REST-snitflade, som en rig grænseflade kan tilgå.

I stedet for at benytte førnævnte MVC skabelon, opretter vi blot et tomt web-projekt med dotnet new web. Selv med et tomt projekt lever der nogle filer og foldere i roden af projektet, så for at holde tingene adskilt opretter vi en folder i roden, /Features.

Og der under oprettes blot en folder til hver feature:

/Features
    /Orders
    /Products
    /PetersFruit
    /Fresh

Products-featuren står for at aggregere produkter på tværs af leverandører og aflevere dem via en REST-snitflade. Ud over at indeholde model og controller, så indeholder den et interface som andre features kan implementere:

interface IProductCatalog
{
    Product[] GetProducts();
}

Hver af de to katalog-features indeholder en service der henter data fra leverandøren og oversætter det til vores interne model. Disse services implementerer begge IProductCatalog:

class PetersFruitService : IProductCatalog
{
    Product[] GetProducts()
    {
        // ...
    }
}

Fælles for alle features er at de indeholder en klasse der registerer relevante typer i den globale dependency injection container via IServiceCollection.

Her registerer PetersFruit-featuren en konkret implementation af IProductCatalog:

namespace Microsoft.Extensions.DependencyInjection
{
    public static class PetersFruitExtensions
    {
        public static IServiceCollection AddPetersFruitFeature(this IServiceCollection services)
        {
            return services.AddSingleton();
        }
    }
}

Her efter kan vi i opstarten af projektet tilføje de features vi har brug for og vi kan udelade features der enten ikke er færdige eller som af andre grunde ikke er i en tilstand der gør at de skal i drift:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddOrdersFeature()
            .AddProductsFeature()
            //.AddFreshFeature()  <-- Fresh fruit er pt. slået fra
            .AddPetersFruitFeature();
    }

    // ...
}

Med flere implementører af IProductCatalog kan vores Products-feature i sin constructor blot bede om en liste af disse. Hermed vil den få alle de registrerede typer uden at kende noget til de egentlige implementationer og hvorvidt de er slået til eller fra:

public class ProductsService
{
    public ProductsService(IEnumerable catalogs)
    {
        // ...
    }
}

Products-featuren udstiller igen de typer der skal være tilgængelige for omverdenen:

namespace Microsoft.Extensions.DependencyInjection
{
    public static class ProductsExtensions
    {
        public static IServiceCollection AddProductsFeature(this IServiceCollection services)
        {
            return services.AddSingleton();
        }
    }
}

Som vores Orders-feature kan tage en afhængighed til og grafen vil være komplet:

public class OrdersController
{
    public OrdersController(ProductsService productsService)
    {
        /// ...
    }
}

Med feature-foldere og ASP.NET Core kan vi opnå en pluggable arkitektur hvor features lever i en hvis isolation og kan slåes til og fra og hvor andre features kan håndtere dette. Fordi at hele frameworket er bygget op omkring disse principper så giver det en meget homogen løsning.

Dette her er et meget skrabet eksempel blot for at vise wiring af features. I en verden af microservices og potentielt ustabile forbindelser er der flere ting som features kan gøre for at bidrage til det samlede systems helbred fx.:

  • Tage afhængighed til den indbyggede ILogger for at logge opståede fejl og uhensigtsmæssigheder.
  • Implementere IHealthCheck der er god til fx at trykafprøve forbindelsen til en ekstern service og rapportere status tilbage til et samlet dashboard.
  • Benytte OpenTelemetry til, på en standardiseret måde, at opsamle metrikker på tværs af services.
  • Lette transition mellem versioner ved fx. at lade ikke-bagudkompatible ændringer foregå i helt nye feature-foldere.

Dette var bare ét bud på hvordan at vi opnår en ren arkitektur med ASP.NET Core 3 – jeg hører gerne flere bud. Snart skal jeg på GOTO Copenhagen ind og høre Jason Taylor’s indlæg Clean Architecture with ASP.NET Core 3.0, jeg er spændt på at høre hans bud.

Skriv et svar

Din e-mailadresse vil ikke blive publiceret. Krævede felter er markeret med *