Хочу поделиться своим опытом реализации микросервисной архитектуры поверх actor-model фреймоворков, таких как AKKA и Microsoft Orleans.



Моя гипотеза: если использовать один стек для реализации микросервисов, то можно:


  • Существенно снизить сложность разработки и объем кода
  • Упростить отладку и поиск ошибок
  • Упростить деплой
  • Упростить задачу определения границ сервисов
  • Избавится от ограничения применения stateful-сервисов
  • Упростит работу с распределенными транзакциями.

О… Похоже на «серебряную пулю»! Давайте разберемся, так ли это.


Зачем это нужно?


Всем знакомы ключевые преимущества микросервисной архитектуры:


  • сервисы можно легко заменить в любое время: акцент на простоту, независимость развёртывания и обновления каждого из микросервисов;
  • сервисы организованы вокруг функций: микросервис по возможности выполняет только одну достаточно элементарную функцию;
  • сервисы могут быть реализованы с использованием различных языков программирования, фреймворков, связующего программного обеспечения и пр.;
  • архитектура симметричная, а не иерархическая: зависимости между микросервисами одноранговые

Не являются секретом и сложности при использование этого подхода:


  • Возросшая сложность разработки: сложность работы над задачей, затрагивающей множество сервисов, сложности отладки.
  • Возросшая сложность эксплуатации и DevOps: чем больше сервисов, больше способов их взаимодействия и больше возможностей для потенциальных проблем.
  • Реальные системы обычно не имеют чётко определённых границ: сложно провести границы между сервисами, определить зависимости между ними, необходимость одновременного выката множества версий множества сервисов при деплое фичи.
  • Ограничения реализации stateful-сервисов: реализация stateful-сервисов существенно усложняет деплой.
  • Распределенные транзакции и итоговая согласованность: распределённых системы требуют механизма распределенных транзакций.

Анализируя плюсы и минусы, появилась идея: а что, если избавиться о части проблем за счет сокращения некоторых невостребованных преимуществ?


На практике, во всех проектах стараются избежать «зоопарка технологий» и стек на котором происходит разработка сервисов практически всегда один. Так что, достаточно часто можно пожертвовать таким преимуществом как «сервисы могут быть реализованы с использованием различных языков программирования, фреймворков, связующего программного обеспечения и пр.». Если мы можем использовать один стек для разработки сервисов, то это открывает нам новые возможности!


Единый стек для сервисов


Возьмём за основу DDD подход, который рекомендует нам разбивать предметную обла��ть на ограниченные контексты (Bounded context).


Ограниченный контекст – это явная граница, внутри которой существует модель предметной области, которая отображает единый язык в модель программного обеспечения. Очевидно, что ограниченный контекст это и есть микросервис со своей моделью данных.


В рамках каждого ограниченного контекста, в соответствии с DDD, свой набор сущностей (entities), состоящих из других сущностей и объектов-значений (value-objects). Сущности объединены в агрегаты (aggregation roots). Доступ к сущностям возможен только через агрегаты, которые имеют уникальный идентификатор агрегата (aggregate identity) в рамках системы.
Вся логика, которая относится к конкретным сущностям/агрегатам должна быть определена в них. Логика, затрагивающая несколько агрегатов, должна быть реализована в рамках доменных сервисов (domain services).
Агрегат может оповещать другие агрегаты об изменении своего состояния через единую шину событий (event bus), выбрасывая доменные события (domain events).


Добавим к этомy походу CQRS – как «протокол» для общения с агрегатами:


  • Команды (commands) – это примитивы для вызова методов агрегата.
  • Запросы (queries) – это примитивы для получения состояния агрегатов или даже группы агрегатов. Согласно CQRS обработка запросов должна быть реализована вне агрегатов в рамках отдельных сущностей – обработчиков запросов (command handlers).

Нам нужны будут шина команд (command bus) и шина запросов (query bus) для доставки этих примитивов агрегатам и обработчикам и получения результатов вызова.


Таким образом, любой наш сервис строится из следующих компонентов:


  • объекты-значений (value-objects)
  • сущности (entities)
  • агрегаты (aggregation roots)
  • доменные сервисы (domain services)
  • идентификаторы агрегата (aggregate identity)
  • доменные события (domain events)
  • команды (commands)
  • запросы (queries)
  • обработчики запросов (command handlers).

Теперь можно взаимодействовать с любым микросервисом по стандартному «протоколу»:


  • Посылать команды агрегатам миросервиса для изменения их состояния
  • Подписываться и получать события, как результат изменения состояния агрегата
  • Отправлять запросы и получать в ответ данные о состоянии групп агрегатов

Ну и нельзя забывать о логике уровня приложения, которая оперирует агрегатами различных сервисов (оркеструет их работу), управляет распределенными транзакциями. Это саги (saga) –конечные автоматы с состоянием, которые подписываются на доменные события и вызывают команды различных агрегатов, реализуя логику уровня приложения.


Stateful против Stateless


Пришло время поговорить о модели акторов (actor model). Акторы — альтернативный подход к организации параллельных вычислений и распределенных систем.


В упрощенном виде актор (actor) – это, обычно, stateful сущность, которая принимает сообщения, производит вычисления на основе этих сообщений и изменяет свое состояние. Акторы изолированы друг от друга и их внутреннее состояние не может быть изменено извне. Актор получает сообщения асинхронно. Но обрабатывает их по очереди (синхронно, в рамках одного потока).


Существуют фреймворки, которые упрощают разработку акторов:


  • АККА (АККА.Net) – модель акторов для Java и Scala (C#)
  • Microsoft Orleans – модель виртуальных акторов для .NET Core (C#)

Все эти фреймворки позволяют создавать акторы, управлять их жизненным циклом, доставкой и обменом сообщении. Они позволяют элегантно решить задачу создания stateful-сервисов и обеспечивают их отказоустойчивую работу в кластере.


Очевидно, что акторы в нашем подходе очень хорошо лежаться на реализацию stateful-объектов: агрегатов и саг. Их так же можно использовать для кэширования результатов выполнения запросов.


Ох, но Stateful – это плохо! Или нет?


Понятно, что нет универсального ответа на это вопрос. Также очевидно, что тенденции последних лет были направлены в сторону разработки stateless-сервисов, обеспечивающих хорошее горизонтальное масштабирование.


Но при настоящем highload становится очевидным, что stateless-сервисы – это не панацея. Они либо дают серьёзную нагрузку на базу, которую становится сложно решить или приводят к проблемам с кэшированием (когда обновлять кэш?).


Модель акторов, которая позволяет хранить состояние сервиса в памяти кластера и обращаться к нему без восстановления состояния из базы или кэша – это более продуктивное решение, чем stateless-сервис. Причем, фреймворк модели акторов берет на себя задачу выгрузки из памяти неиспользуемых объектов, а также задачи отказоустойчивости.


Именно такой поход мы применим для работы с нашими агрегатами и сагами.
Также, стоит отметить, что фреймворки модели акторов «из коробки» дают нам реактивные потоки (reactive streams), которые идеально подходят для реализации шин событий, команд и запросов. Это позволяет нашим сервисам взаимодействовать друг с другом единым, универсальным образом через реактивные потоки.


А как же взаимодействие с микросервисов с клиентскими приложениями?


Универсальный API-proxy


Очевидно, что если все взаимодействие с микросервисами строится с помощью команд, событий и запросов, то мы можем сделать универсальный API-proxy, который будет строиться автоматически на основании семантики этих примитивов.


В свои проектах я реализовывал такой подход для автоматической генерации «налету» WebAPI для вызова команд и запросов, swagger документации к этим вызовам, graphql для вызова запросов, signalR подписку и получение любых доменных событий.


Среда исполнения


В описываемом подходе акторы (агрегаты, саги, обработчики запросов) материализуются в кластере под управлением фреймворка акторов – это наша единая среди исполнения всех микросервисов.


Это позволяет гибко управлять структурой кластера, запуском микросервисов в нем. Например, мы можем поднять все сервисы на одном сервере в рамках одного процесса, что очень удобно для отладки. Или в режиме реального времени добавлять новые серверы в кластер, на которых поднимать интересующие нас сервисы. У нас есть возможность управлять правилами материализации акторов в кластере, что позволяет нам поднимать связанные сущности в одном процессе, чтобы существенно сократить затраты на взаимодействие между ними.


Имея такой универсальный набор компонентов для создания сервисов и единую среду для их исполнения, мы можем создать свой единый фреймворк, на основе которого будем строить все наши микросервисы.


Единый фреймворк


Я на практике использовал для реализации описанного выше подхода АККА.Net и Microsoft Orleans. По моему мнению Microsoft Orleans лучше подходит для решения этой задачи, т.к. виртуальная модель акторов существенно упрощает управление жизненным циклом акторов и обработку исключений.


Как устроен единый фреймворк:


  1. Сборка с примитивами Framework.Primitives
    Содержит базовые классы для создания публичных примитивов миросервисов, таких как:

  • объекты-значений (value-objects)
  • идентификаторы агрегата (aggregate identity)
  • доменные события (domain events)
  • команды (commands)
  • запросы (queries)

  1. Сборка с базовыми объектами компонентами миросервиса Framework.Domain
    Содержит базовые классы для создания приватных объектов миросервисов, таких как:
    • сущности (entities)
    • агрегаты (aggregation roots)
    • доменные сервисы (domain services)
    • обработчики запросов (command handlers)
  2. Сборка Framework.Application содержит базовые классы для саг.
  3. Сборка Framework.Infrastructure содержит базовые классы для сохранения состояния агрегатов и саг в различные базы данных
  4. Сборка Framework.Host содержит реализацию хоста, среды исполнения микросервисов, построения и управления кластером, работой с конфигурацией.

Как с помощью единого фреймворка создается микросервис:


  1. Определяется набор сущностей, объектов значений и агрегатов. Определяется модель состояния каждого агрегата.
  2. Создаются классы для хранения состояния агрегатов и классы сущностей, объектов значений и агрегатов.
  3. Определяется набор команд, меняющих состояние агрегатов и события, информирующие об изменении состояния агрегата.
  4. Создаются классы команд и обработчики этих команд в агрегате, меняющие состояние и выбрасывающие события. При необходимости создаются доменные сервисы (stateless-сервисы), содержащие логику, которая выходит за рамки одного агрегата.
  5. Создаются провайдеры для чтения и сохранения состояния агрегатов �� базы данных.

Таким образом, каждый микросервис, обычно сдержит следующие сборки:


  • Service1.Primitives – публичные примитивы (события, команды, идентификаторы агрегатов)
  • Service1.Domain – приватная реализация доменной логики (сущности, агрегаты, доменные сервисы)
  • Service1.Infrastructure – провайдеры чтения и сохранения состояния агрегатов в базы данных
    В отдельных сборках уровня приложения (Application) создаются саги и обработчики запросов.

Для примера, добавлю немного кода, а то "многобукв" получилось :)
Ниже пример реализации агрегата, как видно код простой и очевидный.


public class UserId : Identity<UserId>
{
    public UserId(System.String value) : base(value) { }
}
[Validator(typeof(BirthValidator))]
public class Birth : SingleValueObject<DateTime>
{
    public Birth(DateTime value) : base(value) { }
}
public class BirthValidator : AbstractValidator<Birth>
{
    public BirthValidator()
    {
        RuleFor(p => p.Value).NotNull();
        RuleFor(p => p.Value).LessThan(DateTime.Now);
    }
}

[Description("Создание нового пользователя")]
[HasPermissions(DemoContext.CreateUser, DemoContext.ChangeUser)]
[Validator(typeof(CreateUserCommandValidator))]
public class CreateUserCommand : Command<UserId>
{
    public LocalizedString UserName { get; }
    public Birth Birth { get; }

    public CreateUserCommand(UserId aggregateId, LocalizedString userName, Birth birth) : base(aggregateId)
    {
        UserName = userName;
        Birth = birth;
    }
}

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator(IServiceProvider sp)
    {
        RuleFor(p => p.AggregateId).NotNull();
        RuleFor(p => p.UserName).SetValidator(new UserNameValidator());
        RuleFor(p => p.Birth).ApplyRegisteredValidators(sp);

    }
}
[Validator(typeof(BirthValidator))]
public class Birth : SingleValueObject<DateTime>
{
    public Birth(DateTime value) : base(value) { }
}
public class BirthValidator : AbstractValidator<Birth>
{
    public BirthValidator()
    {
        RuleFor(p => p.Value).NotNull();
        RuleFor(p => p.Value).LessThan(DateTime.Now);
    }
}
public class UserState : AggregateState<UserState, UserId>,
    IApply<UserCreatedEvent>,
    IApply<UserRenamedEvent>,
    IApply<UserNotesChangedEvent>,
    IApply<UserTouchedEvent>,
    IApply<ProjectCreatedEvent>,
    IApply<ProjectDeletedEvent>
{
    public LocalizedString Name { get; private set; }
    public Birth Birth { get; private set; }
    public System.String Notes { get; private set; } = System.String.Empty;
    public IEnumerable<Entities.Project> Projects => _projects;

    public void Apply(UserCreatedEvent e) { (Name, Birth) = (e.Name, e.Birth); }
    public void Apply(UserRenamedEvent e) { Name = e.NewName; }
    public void Apply(UserNotesChangedEvent e) { Notes = e.NewValue; }
    public void Apply(UserTouchedEvent _) { }

    public void Apply(ProjectCreatedEvent e)
    {
        _projects.Add(new Entities.Project(e.ProjectId, e.Name));
    }

    public void Apply(ProjectDeletedEvent aggregateEvent)
    {
        var projectToRemove = _projects.FirstOrDefault(i => i.Id == aggregateEvent.ProjectId);
        if (projectToRemove != null) _projects.Remove(projectToRemove);
    }

    private readonly ICollection<Entities.Project> _projects = new List<Entities.Project>();
}
public class UserAggregate : EventDrivenAggregateRoot<UserAggregate, UserId, UserState>,
    IExecute<CreateUserCommand, UserId>,
    IExecute<ChangeUserNotesCommand, UserId>,
    IExecute<TrackUserTouchingCommand, UserId>
{
    public UserAggregate(UserId id) : base(id)
    {
        Command<RenameUserCommand>();
        Command<CreateProjectCommand>();
        Command<DeleteProjectCommand>();
    }

    internal async Task CreateProject(ProjectId projectId, ProjectName projectName)
    {
        await Emit(new ProjectCreatedEvent(Id, projectId, projectName));
    }

    internal async Task DeleteProject(ProjectId projectId)
    {
        await Emit(new ProjectDeletedEvent(Id, projectId));
    }

    public async Task<IExecutionResult> Execute(CreateUserCommand cmd)
    {
        //SecurityContext.Authorized();
        //SecurityContext.HasPermissions(cmd.AggregateId, DemoContext.TestUserPermission);

        await Emit(new UserCreatedEvent(cmd.UserName, cmd.Birth));
        return ExecutionResult.Success();
    }

    public async Task<IExecutionResult> Execute(ChangeUserNotesCommand cmd)
    {
        await Emit(new UserNotesChangedEvent(Id, State.Notes, cmd.NewValue));
        return ExecutionResult.Success();
    }

    public async Task<IExecutionResult> Execute(TrackUserTouchingCommand cmd)
    {
        await Emit(new UserTouchedEvent());
        return ExecutionResult.Success();
    }

    public async Task Rename(UserName newName)
    {
        await Emit(new UserRenamedEvent(newName));
    }
}

Что этот дает?


Что дает этот единый фреймворк для разработки микросервисов:


Существенно снизить сложность разработки и объем кода
Основной объем кода сервиса – это прикладная бизнес-логики. Разработчики определяют объекты-значений, сущности и агрегаты, и их логику, доменные сервисы. Одновременно формализуя интерфейс работы с микросервисом, определяя команды, запросы и события.
Код существенно упрощается, т.к. вся логика работы агрегата исполняется в одном потоке (нет многопоточных проблем), не нужно создавать контроллеры для обработки API, все берет на себя фреймворк. Весь код разнесен по слоям с четкой односторонней зависимостью между слоями.
Вызов других микросервисов прост и строго типизирован, для этого используются публичные сборки с примитивами (команды, события, идентификаторы, запросы).


Упростить отладку и поиск ошибок
Можно подключить и поднять рамках одного процесса откладки (одной IDE) нужное количество сервисов и легко их отладить.


Упростить деплой
При небольших объемах можно поднять систему на одном сервере ("как монолит"). Есть возможность гибко управлять структурой кластера.


Упростить задачу определения границ сервисов
Применение DDD и Event Storming при проектировании системы позволяет корректно решить задачу разбивки системы на сервисы.


Избавится от ограничения применения stateful-сервисов
Можно использовать как stateful, так и stateless подход без ограничени��. Stateful поход реализован максимально просто для разработчика, при это обеспечивает беспрецедентную производительности, масштабируемость и отказоустойчивость.


Упростить работу с распределенными транзакциями
Stateful cаги «из коробки» позволяют создавать распределенные транзакции
Некоторые пример реализации можно посмотреть здесь.