Organisation du code dans les microservices et mon approche de l'utilisation de l'architecture hexagonale et du DDD


Bonjour, Habr! Dans le Monolith, tout le code doit être dans le même style, et dans différents microservices, vous pouvez utiliser différentes approches, langages de programmation et frameworks. Pour les microservices simples avec 1 à 2 contrôleurs et 1 à 10 actions, il n'y a aucun sens particulier à bloquer les couches d'abstractions. Pour les microservices complexes avec des états différents et la logique de transition entre eux, au contraire, il vaut mieux ne pas être paresseux au départ. Je veux parler de mon expérience de l'organisation du code et de l'utilisation des approches DDD, Ports et Adapters dans les deux cas. Il y a un bref résumé de l'article: Juin - écrit le code dans le contrôleur. Milieu - écrit un tas d'abstractions. Senior - sait quand écrire du code dans le contrôleur et quand des abstractions sont nécessaires.Ici, vous devez immédiatement mettre fin aux I - Les ports en C # sont des interfaces ou des résumés, et les adaptateurs sont des implémentations concrètes (classe, struct). Pour référence, je vous recommande de lire cette traduction DDD, Hexagonal, Onion, Clean, CQRS ... comment je mets tout cela ensemble et cet article sur la mauvaise conception de Clean Architecture . Ici, gardez à l'esprit que l'approche est décrite pour un monolithe grand et complexe. Mais je veux parler des variations de cette approche dans les microservices dont 80% sont simples et 20 de complexité moyenne ou élevée.

Terminologie


1) Adaptateurs principaux


Introduisez une interface conviviale pour un système d' appel externe . Appelez et utilisez les adaptateurs secondaires et la logique. Ils disent à l'application de faire quelque chose. L'entrée pointe vers votre code. Représentants typiques: ItemsService, CreateItemCommandHandler. Utilisez Logic et SecondaryAdapters pour leur travail.

2) Adaptateurs secondaires


Fournit une interface à un système appelé externe pratique pour une utilisation par notre système. Il reçoit des commandes de l'application. Représentants typiques: ItemsRepository, EmailSender, Logger, DistributedCache. Ils utilisent des bibliothèques et des cadres comme EntityFramework pour leur travail.

3) Logique


3.1) Entités


Combinez données et logique. Objets POO typiques. Article, argent, utilisateur. Il existe également des ValueObjects et des événements. Seuls les autres objets de la couche Entités sont utilisés. En théorie, cette couche est le cœur de l'application qui ne dépend de personne. Tout dépend de lui.

3.2) DomainServices


Informatique nue. Ils sont nécessaires pour une logique qui ne peut pas être attachée à une entité spécifique. Utilisez des entités ou d'autres services de domaine. N'utilisez pas d'adaptateurs primaires (ils utilisent toujours eux-mêmes tous) et d'adaptateurs secondaires. Il s'agit généralement de toutes sortes d'AmountCalculator, Validator et plus encore.

Le flux d'appels de code doit toujours être dans une direction PrimaryAdapters -> Logic et SecondaryAdapters.

Domaine


C'est le domaine pour lequel le système est développé. Par exemple, pour le domaine de la boutique Internet est le commerce électronique.

Contexte borné


BoundedContext est utilisé pour diviser le système en parties isolées avec une certaine responsabilité. L'un des moyens de réussir la conception d'un système consiste à y trouver et à mettre en évidence tous ses Contextes Limités. Dans une architecture de microservice, 1 microservice = 1 BoundedContext. Par exemple: une boutique en ligne peut avoir un panier d'achat BoundedContext et BoundedContext travaillant avec des commandes, BoundedContext travaillant avec des fichiers. Et un grand BoundedContext peut être divisé en petits morceaux à l'intérieur. Par exemple, pour le contexte délimité du panier de marchandises, vous pouvez effectuer une division dans le contexte de l'ajout de marchandises au panier et dans le contexte de la suppression de marchandises du panier. Dans le monolithe 1, un grand BoundedContext est un module. Ici, il est nécessaire de faire remarquer que toutes les entités vivent dans un contexte spécifique, c'est-à-direArticle du contexte du panier de marchandises et Article du contexte de la vitrine des marchandises, il peut s'agir de classes différentes avec des champs et des méthodes différents. Dans un monolithe, ils se mappent simplement les uns dans les autres et utilisent certains ItemDto que EF passe pour travailler avec la base de données et qui ont des champs communs à tous, c'est-à-dire si Item du contexte (module) du panier a la propriété Property1 et Item du contexte de la vitrine des biens ont la propriété Property2, alors ItemDto aura à la fois Property1 et Property2. Dans chaque contexte, il y aura son propre référentiel qui extraira l'élément, qui est déjà caractéristique de ce contexte, de la base de données. Dans les microservices, c'est facile. Chacun a sa propre base de données et sa propre essence. Chaque service mondial a ses propres classes. SimplementDans un monolithe, ils se mappent simplement les uns dans les autres et utilisent certains ItemDto que EF passe pour travailler avec la base de données et qui ont des champs communs à tous, c'est-à-dire si Item du contexte (module) du panier a la propriété Property1 et Item du contexte de la vitrine des biens ont la propriété Property2, alors ItemDto aura à la fois Property1 et Property2. Dans chaque contexte, il y aura son propre référentiel qui extraira de la base de données l'élément qui est déjà caractéristique de ce contexte. Dans les microservices, c'est facile. Chacun a sa propre base de données et sa propre essence. Chaque service mondial a ses propres classes. SimplementDans un monolithe, ils se mappent simplement les uns dans les autres et utilisent certains ItemDto que EF passe pour travailler avec la base de données et qui ont des champs communs à tous, c'est-à-dire si Item du contexte (module) du panier a la propriété Property1 et Item du contexte de la vitrine des biens ont la propriété Property2, alors ItemDto aura à la fois Property1 et Property2. Dans chaque contexte, il y aura son propre référentiel qui extraira l'élément, qui est déjà caractéristique de ce contexte, de la base de données. Dans les microservices, c'est facile. Chacun a sa propre base de données et sa propre essence. Chaque service mondial a ses propres classes. SimplementDans chaque contexte, il y aura son propre référentiel qui extraira l'élément, qui est déjà caractéristique de ce contexte, de la base de données. Dans les microservices, c'est facile. Chacun a sa propre base de données et sa propre essence. Chaque service mondial a ses propres classes. SimplementDans chaque contexte, il y aura son propre référentiel qui extraira l'élément, qui est déjà caractéristique de ce contexte, de la base de données. Dans les microservices, c'est facile. Chacun a sa propre base de données et sa propre essence. Chaque service mondial a ses propres classes. Simplementrappelez - vous que Item et ItemsRepository de différents BoundedContext peuvent être différents objets avec différentes méthodes et propriétés. Souvent, BoundedContext dans les microservices est violé par l'introduction de certaines bibliothèques communes. Le résultat est une classe qui a plusieurs dizaines de méthodes ou propriétés et chaque microservice utilise 1 à 2 uniquement dont il a besoin, vous devez donc être prudent avec les bibliothèques partagées dans les microservices.

Dépendances entre couches




Option pour des microservices simples dont la loi de Pareto 80 pour cent


Jetez les calques PrimaryAdatapters et SecondaryAdapters. Ne laissez que la couche Logic. Plus précisément, dans une application ASP.NET typique que nous utilisons - PimaryAdater est Controller et SecondaryAdapter est DbContext d'EF. Si le contrôleur devient soudainement trop grand, nous le coupons en morceaux en utilisant partiel. C'est bien mieux que d'élever des abstractions inutiles et de passer du temps dessus. Par exemple, Microsoft l'a fait pour le BasketMicrotservice dans l'exemple de son application EShop sur les conteneurs Docker. Oui, écrivez simplement le code sur le contrôleur. N'écrivez simplement pas de code spaghetti. Importantrappelez-vous que la couche Logic reste avec nous et nous utilisons toujours des entités avec leur logique et DomainServices avec leurs calculs et pas seulement écrire un mur de code spaghetti dans le contrôleur. Nous jetons simplement le ItemService et le ItemRepository typiques. Le bon vieux ItemValidator, ItemMapper, AmountCalculator, etc. dans cet esprit reste toujours avec nous. Puisque nous avons toujours la couche Logic, nous pouvons basculer vers une option plus complexe à tout moment en encapsulant des abstractions supplémentaires avec ItemsService, ItemsRepository.

Exemple


Un service qui calcule le prix final d'un produit avec une remise. En théorie, il fait partie de la boutique en ligne de domaine et représente son catalogue de produits BoundedContext. Par souci de simplicité, j'ai sauté toute la logique de validation. Il peut être décrit dans une sorte de ItemValidator et DiscountValidator.

1) Dossier Logique:
1.1) Dossier Entités:

Remise:

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

        public decimal Value { get; set; }
    }

Argent:

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

Produit:

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

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

1.2) Dossier DomainServices

Calculateur de prix du produit , y compris les remises:

    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) Contrôleur

    [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 pour les microservices complexes dont 20% et pour la plupart des monolithes


Ici, nous utilisons une approche standard avec une isolation maximale des couches les unes des autres. Ajoutez une classe pour PrimaryAdapter (ItemService) et SecondaryAdapter (ItemRepository). Dans le dossier Logic, tout reste comme avant.

Exemple


1) Dossier 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) Dossier PrimaryAdapters

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

Notre contrôleur utilise maintenant notre 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;
        }
    }

Ajout de beaucoup de code supplémentaire. Au lieu de cela, une flexibilité accrue du système. Il est désormais plus facile de changer le contrôleur et généralement ASP.NET Core en autre chose ou de changer DbContext et EntityFramework Core en autre chose ou d'ajouter la mise en cache à Redis. La première approche l'emporte dans les microservices simples et les monolithes très simples et petits. Le second dans tous les autres cas, lorsque vous devrez ajouter et terminer ce code pendant plus d'un an. Eh bien, dans la deuxième approche, en plus de l'élément d'entité et de la remise, il est utile de créer des DTO distincts qui utiliseront DbContex EF et des DTO séparés (modèles) qu'ASP.NET Controller utilisera, c'est-à-dire ItemDto et ItemModel. Cela rendra les modèles de domaine encore plus indépendants. Et enfin, un exemple d'une application simple que j'ai écrite en utilisant la 2ème approche TestRuvds. En fait, il est redondant ici, et toutes ces abstractions sont ici pour un exemple.

Exemple d'implémentation


VirtualServersModule

Remerciements


remercier cannes et AndrewDemb pour les erreurs grammaticales trouvées.

All Articles