Minimal Z .NET 6 pojawił się nowy styl pisania aplikacji ASP.NET Core. Tak możesz pisać aplikację bez pisania kontrolerów. 

Oczywiście rodzi to wiele pytań.  Kiedy ten styl ma sens? Kiedy moja aplikacja jest już zbyt skomplikowana, aby nie pisać już aplikacji w stylu minimalnym

Słuchaj, nawet jeśli twoja aplikacja jest złożona to wciąż możesz napisać aplikacje bez kontrolerów. Pokaże Ci zaraz jak ja to zrobiłem.

Oto na projekt, który obrazuje problem.

Projekt minimalnej aplikacji ASP.NET CORE pokazujący problem

Cała logika mojej aplikacji znajduje się w jednym pliku.

using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IEmployeeService, EmployeeService>();
builder.Services.AddSingleton<IHelloWorldService, HelloWorldService>();

var app = builder.Build();

app.MapGet("/", () => "Daj Like");

app.MapGet("/quote/", async () =>
    await new HttpClient().GetStringAsync
        ("https://ron-swanson-quotes.herokuapp.com/v2/quotes"));

app.MapPost("/employee",
    (Employee e, IEmployeeService ser) =>
    {
        return ser.ShowEmployee(e);
    });

app.MapGet("/hello",
    (HttpContext context, IHelloWorldService service)
    =>
    {
        return service.SayHello
                    (context.Request.Query["name"].ToString());
    });

app.Run();

Mam tu różne wstrzykiwania zależności, klasy serwisowe i obsługę adresów HTTP. Na tym etapie możesz stwierdzić, że jeszcze nie ma bałaganu, ale ten problem oczywiście będzie rósł wraz ze wzrostem liczby punktów HTTP, na które twoja aplikacja jest wystawiona przez sieć. 

Oczywiście możesz powiedzieć. Czarek, jak jest problem to tworzymy kontroler i robimy aplikację w stylu MVC.

Jednakże największą zaletą podejścia minimalnych aplikacji jest szybkość. Co prawda nie mogę zagwarantować, że zawsze tak jest w każdym przypadku, ale warto być otwartym na alternatywę. 

public class HelloWorldService : IHelloWorldService
{
    public string SayHello(string user)
    {
        return $"Hello {user}";
    }
}

public interface IHelloWorldService
{
    public string SayHello(string user);
}

public record Employee(string FirstName, [Required] string LastName);

public interface IEmployeeService
{
    public string ShowEmployee(Employee employee);
}

public class EmployeeService : IEmployeeService
{
    public string ShowEmployee(Employee e)
    {
        return "We have a new employee:" +
            $" {e.FirstName}  {e.LastName}";
    }
}

Przejdźmy do rozwiązania. Chcielibyśmy wydzielić logikę każdego wstawionego punktu do osobnych projektów. 

Zanim to zrobimy musimy mieć jakiś system, który nam to wszystko uporządkuje.

Projekt minimalnej aplikacji ASP.NET CORE pokazujący rozwiązanie

Technicznie każdy projekt do obsługi punktu HTTP potrzebuje dwóch informacji:

  • Pod jakim adresem strony co ma się wydarzyć dla tego konkretnego projektu
  • Jakie obiekty mają być wstrzyknięte, aby ten wstawiony punkt obsłużyć

Mam więc interfejs, który nam zdefiniuje te dwie rzeczy. Za komentowany kod symbolizuje moją próbę użycia któregoś z tych interfejsów, ale niestety przy definiowaniu endpoint-a potrzebujemy definicji "WebApplication".

public interface IEndpointDefinition
{
    void DefineServices(IServiceCollection
        services);

    //void DefineEndpoints(IHost app)
    //void DefineEndpoints(IEndpointRouteBuilder app)
    void DefineEndpoints(WebApplication app);
}

Tworzymy teraz metodę pomocniczą , która nam doda te wszystkie wstawione punkty HTTP. 

Na początku przed zbudowaniem definicji aplikacji ASP.NET Core chcemy użyć metody "AddEndpointDefinitions"

W naszej metodzie podamy typ klasy, który istnieje w dany projekcie.

Wewnątrz metody zeskanujemy cały dany projekt poszukując wszystkich klas, które implementują interfejs "IEndpointDefinition" i potem po kolei zarejestrujemy potrzebne klasy,interfejsy do kontenera wstrzykiwania zależności potrzebne dla tego projektu.

Na koniec zarejestrujemy wszystkie klas implementujące interfejs "IEndpointDefinition" 

public static class EndPointDefinitionExtension
{
    public static void AddEndpointDefinitions(
        this IServiceCollection services, params Type[] scanMarkers)
    {
        var endpoints = new List<IEndpointDefinition>();

        foreach (var scanMarker in scanMarkers)
        {
            endpoints.AddRange(
                scanMarker.Assembly.ExportedTypes
                .Where(x => typeof(IEndpointDefinition)
                .IsAssignableFrom(x) && !x.IsInterface && !x.IsAbstract)
                .Select(Activator.CreateInstance)
                .Cast<IEndpointDefinition>()
            );
        }

        foreach (var endpoint in endpoints)
        {
            endpoint.DefineServices(services);
        }

        services.AddSingleton
            (endpoints as IReadOnlyCollection<IEndpointDefinition>);
    }

    //public static void UseEndpointDefinitions(this IHost app)
    //public static void UseEndpointDefinitions(this IEndpointRouteBuilder app)
    public static void UseEndpointDefinitions(this WebApplication app)
    {
        var defs = app.Services.
        GetRequiredService<IReadOnlyCollection<IEndpointDefinition>>();

        foreach (var def in defs)
        {
            def.DefineEndpoints(app);
        }
    }
}

W drugiej metodzie pomocniczej "UseEndpointDefinitions" zdefiniujemy adresy tych punktów HTTP. Zrobimy to, jak już będziemy mieć obiekt reprezentujący aplikację ASP.NET Core.

W tej metodzie wyciągniemy wszystkie implementacje interfejsu "IEndpointDefinition", które zarejestrowaliśmy wcześniej i użyjemy metody "DefineEndpoints" per definicja.

Teraz gdy omówiliśmy działanie naszego narzędzia do rejestracji. Pora zobaczyć jak jest ono użyte w odseparowanych od siebie projektach.

Wszystkie projekty z punktami endpoint

Zobaczmy najpierw co się dzieje w projekcie "QuoteEndPointProject".

Projekt QuoteEndPointProject

W tym projekcie mamy tylko definicję punktu. Nie korzystamy tutaj z żadnych wstrzykiwań zależności i kontenerów. 

public class QuoteEndpoint : IEndpointDefinition
{
    //public void DefineEndpoints(IEndpointRouteBuilder app)

    public void DefineEndpoints(WebApplication app)
    {
        app.MapGet("/quote/", async () =>
        await new HttpClient().GetStringAsync
        ("https://ron-swanson-quotes.herokuapp.com/v2/quotes"));
    }

    public void DefineServices(IServiceCollection services)
    { }
}

Drugi projekt "HelloWorldEndPointProject" jest już bardziej rozbudowany.

Projekt HelloWorldEndPointProject

Mamy interfejs.

public interface IHelloWorldService
{
    public string SayHello(string user);
}

Mamy jego implementacje. 

public class HelloWorldService : IHelloWorldService
{
    public string SayHello(string user)
    {
        return $"Hello {user}";
    }
}

Teraz możemy zobaczyć jak te dwie metody są użyte. Jak widzisz w metodzie "DefineServices" określam, jaka ma być implementacja "IHelloWorldService", a w metodzie "DefineEndpoints" określam adres punktu HTTP.

Dla adresu "/hello" pobiorę parametr GET o nazwie "name" i przekaże ten parametr do metody SayHello.

public class HelloWorldEndpoint : IEndpointDefinition
{
    //public void DefineEndpoints(IEndpointRouteBuilder app)

    public void DefineEndpoints(WebApplication app)
    {
        app.MapGet("/hello",
            (HttpContext context, IHelloWorldService service)
            =>
            {
                return service.SayHello
                (context.Request.Query["name"].ToString());
            });
    }

    public void DefineServices(IServiceCollection services)
    {
        services.AddSingleton<IHelloWorldService, HelloWorldService>();  
    }
}

A co się dzieje w trzecim projekcie "EmployeeEndPointProject".

Projekt EmployeeEndPointProject

Mam znowu interfejs.

public interface IEmployeeService
{
    public string ShowEmployee(Employee employee);
}

Mam jego obsługę. 

public class EmployeeService : IEmployeeService
{
    public string ShowEmployee(Employee e)
    {
        return "We have a new employee:" +
            $" {e.FirstName}  {e.LastName}";
    }
}

Mam rekord do przetrzymywania danych.

public record Employee(string FirstName, [Required] string LastName);

A na końcu oczywiście mam definicję obsługi punktu HTTP dla tego projektu. Jak widzisz schemat działania jest.

Tym razem jednak definiuje metodę HTTP POST dla adresu "/employee"

public class EmployeeEndPoint : IEndpointDefinition
{
    //public void DefineEndpoints(IEndpointRouteBuilder app)
    public void DefineEndpoints(WebApplication app)
    {
        app.MapPost("/employee",
            (Employee e, IEmployeeService ser) =>
            {
                return ser.ShowEmployee(e);
            });
    }

    public void DefineServices(IServiceCollection services)
    {
        services.AddSingleton<IEmployeeService, EmployeeService>();
    }
}

Zobaczmy jak użyjemy ty wszystkich projektów w aplikacji ASP.NET Core.

Projekt ASP.NET Core w .NET 6

Jak widzisz, ponieważ logika jest odseparowana do osobnych projektów to w sumie w naszym pliku Program.cs nie wiele się dzieje.

Musimy tylko skorzystać z metody pomocniczych  "AddEndpointDefinitions" i "UseEndpointDefinitions", które stworzyliśmy wcześniej.

using EmployeeEndPointProject;
using EndPointsMappingTool;
using HelloWorldEndPointProject;
using QuoteEndPointProject;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointDefinitions
                        (typeof(EmployeeEndPoint),
                        typeof(HelloWorldEndpoint),
                        typeof(QuoteEndpoint));

var app = builder.Build();

app.MapGet("/", () => "Daj Like");

app.UseEndpointDefinitions();
app.Run();

To wszystko. Jak widzisz można mieć swój system na pisanie minimalnych aplikacji ASP.NET CORE. Kod jest na GitHub tutaj 👇

PanNiebieski/MinimalAspCoreAppAndEndPointsMapping (github.com)