Organisation von Code in Microservices und mein Ansatz zur Verwendung von hexagonaler Architektur und DDD


Hallo Habr! Im Monolith sollte der gesamte Code im gleichen Stil sein, und in verschiedenen Microservices können Sie verschiedene Ansätze, Programmiersprachen und Frameworks verwenden. Für einfache Mikrodienste mit 1 bis 2 Controllern und 1 bis 10 Aktionen ist es nicht besonders sinnvoll, Abstraktionsebenen zu blockieren. Für komplexe Mikrodienste mit unterschiedlichen Zuständen und der Logik des Übergangs zwischen ihnen ist es im Gegenteil besser, anfangs nicht faul zu sein. Ich möchte über meine Erfahrungen mit der Organisation von Code und der Verwendung der DDD-, Ports- und Adapter-Ansätze für beide Fälle sprechen. Es gibt eine kurze Zusammenfassung des Artikels: Juni - schreibt Code in die Steuerung. Mitte - schreibt eine Reihe von Abstraktionen. Senior - weiß, wann Code in den Controller geschrieben werden muss und wann Abstraktionen erforderlich sind.Hier müssen Sie I - Ports in C # sofort beenden, die Schnittstelle oder abstrakt sind, und Adapter sind konkrete Implementierungen (Klasse, Struktur). Als Referenz empfehle ich, diese Übersetzung von DDD, Hexagonal, Onion, Clean, CQRS zu lesen ... wie ich alles zusammenstelle und diesen Artikel über Missverständnisse in Bezug auf saubere Architektur . Denken Sie hier daran, dass der Ansatz für einen großen und komplexen Monolithen beschrieben wird. Ich möchte über die Variationen dieses Ansatzes in Microservices sprechen, von denen 80 Prozent einfach und 20 mittel oder hoch komplex sind.

Terminologie


1) Primäradapter


Einführung einer benutzerfreundlichen Oberfläche für ein externes Anrufsystem . Rufen Sie SecondaryApdapters und Logic auf und verwenden Sie sie. Sie fordern die Anwendung auf, etwas zu tun. Einstiegspunkte zu Ihrem Code. Typische Vertreter: ItemsService, CreateItemCommandHandler. Verwenden Sie Logic und SecondaryAdapters für ihre Arbeit.

2) Sekundäradapter


Bietet eine Schnittstelle zu einem externen angerufenen System, das für die Verwendung durch unser System bequem ist. Er erhält Befehle von der Anwendung. Typische Vertreter: ItemsRepository, EmailSender, Logger, DistributedCache. Sie verwenden Bibliotheken und Frameworks wie EntityFramework für ihre Arbeit.

3) Logik


3.1) Unternehmen


Daten und Logik kombinieren. Typische OOP-Objekte. Gegenstand, Geld, Benutzer. Es gibt auch ValueObjects und Events. Es werden nur andere Objekte aus der Ebene "Entitäten" verwendet. Theoretisch ist diese Schicht der Kern der Anwendung, der von niemandem abhängt. Alle hängen von ihm ab.

3.2) DomainServices


Naked Computing. Sie werden für Logik benötigt, die nicht an eine bestimmte Entität angehängt werden kann. Verwenden Sie Entitäten oder andere DomainServices. Verwenden Sie keine PrimaryAdapters (sie verwenden immer alle selbst) und SecondaryAdapters. Normalerweise sind dies alle Arten von AmountCalculator, Validator und mehr.

Der Code-Aufruffluss sollte immer in eine Richtung erfolgen. PrimaryAdapters -> Logic und SecondaryAdapters.

Domain


Dies ist der Themenbereich, für den das System entwickelt wird. Für den Domain Online Store ist dies beispielsweise E-Commerce.

Begrenzter Kontext


BoundedContext wird verwendet, um das System mit einer bestimmten Verantwortung in isolierte Teile aufzuteilen. Eine Möglichkeit zum Erfolg beim Systemdesign besteht darin, alle darin enthaltenen BoundedContexts zu finden und hervorzuheben. In einer Microservice-Architektur ist 1 Microservice = 1 BoundedContext. Beispiel: In einem Online-Shop können ein BoundedContext-Warenkorb und BoundedContext mit Bestellungen und BoundedContext mit Dateien arbeiten. Und ein großer BoundedContext kann im Inneren in kleine Teile zerlegt werden. Für den BoundedContext des Warenkorbs können Sie beispielsweise eine Unterteilung in den Kontext des Hinzufügens von Waren zum Warenkorb und den Kontext des Entfernens von Waren aus dem Warenkorb vornehmen. In Monolith 1 ist ein großer BoundedContext ein Modul. Hier muss angemerkt werden, dass alle Entitäten in einem bestimmten Kontext leben, d.h.Artikel aus dem Kontext des Warenkorbs und Artikel aus dem Kontext der Warenausstellung können unterschiedliche Klassen mit unterschiedlichen Feldern und Methoden sein. In einem Monolithen ordnen sie sich einfach zu und verwenden ein ItemDto, das EF für die Arbeit mit der Datenbank übergibt und das Felder enthält, die allen gemeinsam sind, dh wenn das Item aus dem Kontext (Modul) des Basket die Eigenschaft Property1 und das Item aus dem Showcase-Kontext hat von Waren hat Eigentum Property2, dann hat ItemDto sowohl Property1 als auch Property2. In jedem Kontext gibt es ein eigenes Repository, das das für diesen Kontext bereits charakteristische Element aus der Datenbank herausholt. In Microservices ist dies einfach. Jeder hat seine eigene Datenbank und sein eigenes Wesen. Jeder Weltdienst hat seine eigenen Klassen. EinfachIn einem Monolithen ordnen sie sich einfach zu und verwenden ein ItemDto, das EF für die Arbeit mit der Datenbank übergibt und das Felder enthält, die allen gemeinsam sind, dh wenn das Item aus dem Kontext (Modul) des Basket die Eigenschaft Property1 und das Item aus dem Showcase-Kontext hat von Waren hat Eigentum Property2, dann hat ItemDto sowohl Property1 als auch Property2. In jedem Kontext gibt es ein eigenes Repository, das das für diesen Kontext bereits charakteristische Element aus der Datenbank herausholt. In Microservices ist dies einfach. Jeder hat seine eigene Datenbank und sein eigenes Wesen. Jeder Weltdienst hat seine eigenen Klassen. EinfachIn einem Monolithen ordnen sie sich einfach zu und verwenden ein ItemDto, das EF für die Arbeit mit der Datenbank übergibt und das Felder enthält, die allen gemeinsam sind, dh wenn das Item aus dem Kontext (Modul) des Basket die Eigenschaft Property1 und das Item aus dem Showcase-Kontext hat von Waren hat Eigentum Property2, dann hat ItemDto sowohl Property1 als auch Property2. In jedem Kontext gibt es ein eigenes Repository, das das für diesen Kontext bereits charakteristische Element aus der Datenbank herausholt. In Microservices ist dies einfach. Jeder hat seine eigene Datenbank und sein eigenes Wesen. Jeder Weltdienst hat seine eigenen Klassen. EinfachIn jedem Kontext gibt es ein eigenes Repository, das das Element, das bereits für diesen Kontext charakteristisch ist, aus der Datenbank abruft. In Microservices ist dies einfach. Jeder hat seine eigene Datenbank und sein eigenes Wesen. Jeder Weltdienst hat seine eigenen Klassen. EinfachIn jedem Kontext gibt es ein eigenes Repository, das das für diesen Kontext bereits charakteristische Element aus der Datenbank herausholt. In Microservices ist dies einfach. Jeder hat seine eigene Datenbank und sein eigenes Wesen. Jeder Weltdienst hat seine eigenen Klassen. EinfachDenken Sie daran, dass Item und ItemsRepository aus verschiedenen BoundedContext verschiedene Objekte mit unterschiedlichen Methoden und Eigenschaften sein können. BoundedContext in Microservices wird häufig durch die Einführung einiger gängiger Bibliotheken verletzt. Das Ergebnis ist eine Klasse mit mehreren Dutzend Methoden oder Eigenschaften, und jeder Mikrodienst verwendet nur 1 - 2, die er benötigt. Daher müssen Sie mit gemeinsam genutzten Bibliotheken in Mikrodiensten vorsichtig sein.

Abhängigkeiten zwischen Ebenen




Option für einfache Mikrodienste, von denen das Pareto-Gesetz 80 Prozent beträgt


Werfen Sie die Ebenen PrimaryAdatapters und SecondaryAdapters weg. Lassen Sie nur die Logikebene. Genauer gesagt verwenden wir in einer typischen ASP.NET-Anwendung: PimaryAdater ist Controller und SecondaryAdapter ist DbContext von EF. Wenn der Controller plötzlich zu groß wird, schneiden wir ihn mit Partial in Stücke. Dies ist viel besser, als unnötige Abstraktionen zu züchten und Zeit damit zu verbringen. Microsoft hat dies beispielsweise für den BasketMicrotservice im Beispiel seiner EShop-Anwendung für Docker-Container durchgeführt. Ja, schreiben Sie einfach den Code in die Steuerung. Schreiben Sie einfach keinen Spaghetti-Code. WichtigDenken Sie daran, dass die Logikschicht bei uns bleibt und wir weiterhin Entitäten mit ihrer Logik und DomainServices mit ihren Berechnungen verwenden und nicht nur eine Wand aus Spaghetti-Code in den Controller schreiben. Wir werfen einfach den typischen ItemService und ItemRepository weg. Der gute alte ItemValidator, ItemMapper, AmountCalculator usw. in diesem Sinne bleibt bei uns. Da wir immer noch über die Logikschicht verfügen, können wir jederzeit zu einer komplexeren Option wechseln, indem wir zusätzliche Abstraktionen mit ItemsService, ItemsRepository, umschließen.

Beispiel


Ein Service, der den Endpreis eines Produkts mit einem Rabatt berechnet. Theoretisch ist es Teil des Domain Online Store und repräsentiert seinen BoundedContext-Produktkatalog. Der Einfachheit halber habe ich die gesamte Validierungslogik übersprungen. Es kann in einer Art ItemValidator und DiscountValidator beschrieben werden.

1) Logikordner:
1.1) Entitätsordner:

Rabatt:

    public class Discount
    {
        [Key]
        public Guid Id { get; set; }

        public decimal Value { get; set; }
    }

Geld:

    [Owned]
    public class Money
    {
        public decimal Amount { get; set; }

        public Money Apply(Discount discount)
        {
            var amount = Amount * (1 - discount.Value);
            return new Money()
            {
                Amount = amount
            };
        }
    }

Produkt:

    public class Item
    {
        [Key]
        public Guid Id { get; set; }

        public Money Price { get; set; } = new Money();
    }

1.2) DomainServices-Ordner Produktpreisrechner

, einschließlich Rabatte:

    public interface IPriceCalculator
    {
        Money WithDiscounts(Item item, IEnumerable<Discount> discounts);
    }

     public sealed class PriceCalculator:IPriceCalculator
    {
        public Money WithDiscounts(Item item, IEnumerable<Discount> discounts)
        {
            return discounts.Aggregate(item.Price, (m, d) => m.Apply(d));
        }
    }

2) DbContext

    public class ItemsDbContext : DbContext
    {
        public ItemsDbContext(DbContextOptions<ItemsDbContext> options) : base(options)
        {

        }

        public DbSet<Item> Items { get; set; }

        public DbSet<Discount> Discounts { get; set; }
    }

3) Controller

    [ApiController]
    [Route("api/v1/items")]
    public class ItemsController : ControllerBase
    {
        private readonly ItemsDbContext _context;
        private readonly IPriceCalculator _priceCalculator;

        public ItemsController(ItemsDbContext context, IPriceCalculator priceCalculator)
        {
            _context = context;
            _priceCalculator = priceCalculator;
        }

        [HttpGet("{id}/price-with-discounts")]
        public async Task<decimal> GetPriceWithDiscount(Guid id)
        {
            var item = await _context.Items.FindAsync(id);
            var discounts = await _context.Discounts.ToListAsync();
            return _priceCalculator.WithDiscounts(item, discounts).Amount;
        }
    }


Option für komplexe Mikrodienste, davon 20 Prozent und für die meisten Monolithen


Hier verwenden wir einen Standardansatz mit maximaler Isolation der Schichten voneinander. Fügen Sie eine Klasse für PrimaryAdapter (ItemService) und für SecondaryAdapter (ItemRepository) hinzu. Im Logikordner bleibt alles wie zuvor.

Beispiel


1) Ordner "SecondaryAdapters"

    public interface IItemsRepository
    {
        Task<Item> Get(Guid id);
    }

    public sealed class ItemsRepository : IItemsRepository
    {
        private readonly ItemsDbContext _context;

        public ItemsRepository(ItemsDbContext context)
        {
            _context = context;
        }

        public async Task<Item> Get(Guid id)
        {
            var item = await _context.Items.FindAsync(id);
            return item;
        }
    }

    public interface IDiscountsRepository
    {
        Task<List<Discount>> Get();
    }

    public sealed class DiscountsRepository : IDiscountsRepository
    {
        private readonly ItemsDbContext _context;

        public DiscountsRepository(ItemsDbContext context)
        {
            _context = context;
        }

        public Task<List<Discount>> Get()
        {
            return _context.Discounts.ToListAsync();
        }
    }


2) PrimaryAdapters-Ordner

    public interface IItemService
    {
        Task<decimal> GetPriceWithDiscount(Guid id);
    }

    public class ItemService : IItemService
    {
        private readonly IItemsRepository _items;
        private readonly IDiscountsRepository _discounts;
        private readonly IPriceCalculator _priceCalculator;

        public ItemService(IItemsRepository items, IDiscountsRepository discounts, IPriceCalculator priceCalculator)
        {
            _items = items;
            _discounts = discounts;
            _priceCalculator = priceCalculator;
        }

        public async Task<decimal> GetPriceWithDiscount(Guid id)
        {
            var item = await _items.Get(id);
            var discounts = await _discounts.Get();
            return _priceCalculator.WithDiscounts(item, discounts).Amount;
        }
    }

Unser Controller verwendet jetzt unseren IItemService

    [ApiController]
    [Route("api/v1/items")]
    public class ItemsController : ControllerBase
    {
        private readonly IItemService _service;

        public ItemsController(IItemService service)
        {
            _service = service;
        }

        [HttpGet("{id}/price-with-discounts")]
        public async Task<decimal> GetPriceWithDiscount(Guid id)
        {
            var result = await _service.GetPriceWithDiscount(id);
            return result;
        }
    }

Es wurde viel zusätzlicher Code hinzugefügt. Stattdessen erhöhte Systemflexibilität. Jetzt ist es einfacher, den Contoller und allgemein den ASP.NET Core in etwas anderes zu ändern oder den DbContext und den EntityFramework Core in etwas anderes zu ändern oder Redis Caching hinzuzufügen. Der erste Ansatz gewinnt bei einfachen Mikrodiensten und sehr einfachen und kleinen Monolithen. Die zweite in allen anderen Fällen, wenn Sie diesen Code länger als ein Jahr hinzufügen und beenden müssen. Nun, im zweiten Ansatz ist es zusätzlich zu Entity Item und Discount nützlich, separate DTOs zu erstellen, die DbContex EF verwenden, und separate DTOs (Modelle), die ASP.NET Controller verwendet, d. H. ItemDto und ItemModel. Dadurch werden Domain-Modelle noch unabhängiger. Und schließlich ein Beispiel für eine einfache Anwendung, die ich mit dem 2. TestRuvds- Ansatz geschrieben habe. Tatsächlich ist er hier überflüssig, und all diese Abstraktionen sind hier als Beispiel.

Implementierungsbeispiel


VirtualServersModule

Danksagung


Danke canxes und AndrewDemb für grammatikalische Fehler gefunden.

All Articles