Skip to content

Как мы пишем код?

Vladislav Quilin edited this page Jan 15, 2022 · 4 revisions

Дисклеймер: да пишите как хотите, конечно! :)

API

Мы стараемся придерживаться следующих принципов при разработке API:

  • REST
  • обратная совместимость

Контроллер

Каждый ресурс API должен обладать своей моделью. Даже если модель дословно совпадает с какой-то сущностью из БД, ни в коем разе не нужно лениться и возвращать из API прямо эти модели. Для них специально указаны Private Assets и добавлять зависимость от проекта DM.Services.DataAccess нельзя в API ни в коем случае.

Не забывайте именовать все эндпоинты, чтобы была возможность возвращать их хотя в кодах ответа 201.

Контроллеры должны быть максимально худыми и лишенными логики. Но поскольку им как минимум нужны маппинги, а иногда и что-то сложнее, то мы используем вот что:

API Service

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

Как правило, API Service получает на вход модель ресурса API или набор параметров запроса, преобразует их в бизнесовую модель (с помощью AutoMapper), затем вызывает бизнесовый сервис, маппит результат на модель ресурса API и возвращает значение.

Business Process Service

Как правило, API Service необходимо дергать ручки бизнесовых сервисов. Этим не надо пренебрегать, поскольку это позволит в будущем легко перейти на сервисную архитектуру и начать обращаться к внешним системам по gRPC/HTTP/etc без изменения кода.

Бизнесовые сервисы отвечают за бизнес-логику. В отличие от худющих контроллеров и относительно худых API сервисов, в них ожидается больше количество важного кода. Поэтому используется принципиальное деление - бизнесовый сервис отвечает за одну и только одну операцию. Если в интерфейсе вашего бизнес-сервиса более одного метода, вы скорее всего что-то делаете не так. Исключение: если бизнес-сервис отвечает за процессы чтения. В пресловутом CQRS ваши запросы (чтение) всегда отделено от команд (изменение), и чтение почти всегда тощее. Поэтому в порыве малодушия мы иногда забиваем на SRP в сервисах чтения.

Более того, поскольку бизнес-сервисам часто нужны собственные зависимости, каждый бизнес-сервис обладает собственным пространством имен.

Бизнес-сервис проще всего понимать как бизнес-процесс. Каждый сервис отвечает за один и только один процесс. Если пользоваться терминологией CQRS, каждая команда должна представлять собой бизнес-сервис. Если пользоваться терминологией DDD - каждый бизнес-сервис обеспечивает юзкейс.

Ваш типичный бизнес-сервис скорее всего будет выглядеть как-то так:

public async Task<Character> Create(CreateCharacter createCharacter)
{
    await validator.ValidateAndThrowAsync(createCharacter);                 // Сервисы всегда должны валидировать вводимые параметры
    var game = await gameReadingService.GetGame(createCharacter.GameId);    // Сервисы изменения могут инъектить сервисы чтения, но не наоборот
    intentionManager.ThrowIfForbidden(GameIntention.CreateCharacter, game); // Для авторизации сущности часто нужно прочитать ее из БД

    var currentUserId = identityProvider.Current.User.UserId;
    var gameParticipation = game.Participation(currentUserId);

    // Master and assistant characters should be created in Active status
    var initialStatus = gameParticipation.HasFlag(GameParticipation.Authority)
        ? CharacterStatus.Active
        : CharacterStatus.Registration;

    // Only master and assistant are allowed to create NPCs
    createCharacter.IsNpc = createCharacter.IsNpc && gameParticipation.HasFlag(GameParticipation.Authority);

    var (character, attributes) = factory.Create(createCharacter, currentUserId, initialStatus);
    var createdCharacter = await creatingRepository.Create(character, attributes);

    await unreadCountersRepository.Increment(createCharacter.GameId, UnreadEntryType.Character);
    await producer.Send(EventType.NewCharacter, createdCharacter.Id);

    return createdCharacter;
}

Repository

Почти каждый бизнес-сервис так или иначе взаимодействует с БД. Типичный подход при разработке таких приложений и первый соблаз любого разработчика - создавать репозитории по таблице в БД. Мы от этого убегаем со всех ног, и причин тому несколько:

  1. Разные процессы нуждаются в разных фильтрах, проекциях и прочем при общении с БД. Если обслуживание запросов к БД разных процессов лежит в одной зависимости, она сталкивается с выбором - или предоставлять разным процессам одну общую модель, что может сильно ударять по производительности, либо разделять методы для разных процессов, что нарушает принцип Interface Segregation.
  2. Многие процессы нуждаются в данных из нескольких таблиц, и это моментально делает неочевидным ответственность сервиса. RoomRepository, который внутри джойнит Room, RoomClaims и Game - это не очень легально, даже хотя снаружи это может быть и незаметно.
  3. EF уже реализует паттерн репозитория для каждой таблицы в БД - сам DbSet<Table> - это репозиторий. Нет никакого смысла оборачивать их в абстракции, основанные на том же разделении. Если уж мы это делаем - то абстракции должны быть уровнем выше, и не должны заботиться о структуре БД.

Таким образом, мы стараемся выделять на каждый бизнес-процесс свои репозитории. Это позволяет делать достаточно элегантные и оптимальные вещи. Например, у бизнес-сервиса, отвечающего за удаление комментариев на форуме, репозиторий отвечает особенной моделью CommentToDelete, которая содержит минимальную проекцию данных из таблицы ForumComments, необходимую для авторизации и для обновления денормализованных данных в таблице ForumTopics. Если такую логику держать в общем репозитории ForumCommentRepository, он достаточно быстро разбухнет и станет пахнуть.

AtomicUpdate

EF реализует три паттерна: UnitOfWork (DbContext), Repository (DbSet<>) и Active Record (Entity<>). И хотя все они просто топчик, проверять корректность работы с БД с его помощью бывает крайне неудобно, требует поднятия настоящей БД, а вдобавок Active Record всегда добавляет риска лишних сайдэффектов. Не говоря уже о том, что ChangeTracker в EF - это далеко не самая производительная вещь.

Поэтому на ДМ мы пользуемся самописным паттерном IUpdateBuilder. Команды обновления данных в указанных таблицах не требуют вычитывать из БД всю запись целиком, для того чтобы поменять в ней одно единственное поле (да нам может быть и его-то читать не надо), при этом гарантируя type safety. Обычно это выглядит как-то так:

// DoThingService.cs, местами псевдокод
private readonly IUpdateBuilderFactory updateBuilderFactory;
private readonly IDoThingRepository repository;

public Task DoThing(Guid thingId, CancellationToken ct)
{
    var updateBuilder = updateBuilderFactory.Create<DbThing>(thingId)
        .Field(thing => thing.Status, ThingStatus.Done)
        .Field(thing => thing.UpdatedAt, DateTimeOffset.UtcNow);
    return repository.UpdateThing(updateBuilder, ct);
}

// DoThingRepository.cs
private readonly DbContext dbContext;

public Task UpdateThing(IUpdateBuilder<DbThing> updates)
{
    updates.AttachTo(dbContext);
    return dbContext.SaveChangesAsync();
}

UpdateBuilder гарантирует, что обновятся только те поля, которые вы явно вызвали. UpdateBuilderFactory гарантирует, что вы обладаете полным контролем над обновляемыми правилами, и что никто не помешает вам внести только те изменения, которые явно указаны в построителе. Great success!

Также UpdateBuilder работает не только с EF, но и с MongoC#Driver. Его реализация для ElasticSearch пока не подъехала, но там она пока и не нужна.

Для IUpdateBuilder есть удобные расширения, которые связаны с тем, что мы часто используем PATCH запросы. Например, если пользователь передает в API ресурс для изменения, игнорируя все поля, кроме одного, мы ожидаем, что пользователь хочет заменить значение только одного поля, а остальные не трогать - и писать каждый раз if (input.Field is not null) builder = builder.Field(e => e.Field, input.Field) - достаточно неудобно, поэтому можно пользоваться расширением MaybeField. В запущенных случаях это может выглядеть вот так:

var changes = updateBuilderFactory.Create<DbCharacter>(updateCharacter.CharacterId)
    .MaybeField(c => c.Name, updateCharacter.Name?.Trim())
    .MaybeField(c => c.Race, updateCharacter.Race?.Trim())
    .MaybeField(c => c.Class, updateCharacter.Class?.Trim())
    .MaybeField(c => c.Appearance, updateCharacter.Appearance)
    .MaybeField(c => c.Temper, updateCharacter.Temper)
    .MaybeField(c => c.Story, updateCharacter.Story)
    .MaybeField(c => c.Skills, updateCharacter.Skills)
    .MaybeField(c => c.Inventory, updateCharacter.Inventory);

Разумеется, к одному DbContext'у можно привязывать несколько разных изменений, даже в разных таблицах.

Финальный бонус - тестабильность. Теперь вы можете написать тест, который проверит, что если выполняется какое-то условие, то у сущности с таким-то идентификатором будет обновлено такое-то поле таким-то значением, вообще не трогая EF в тестах и гарантируя, что больше никаких изменений в сущности не было. Это выглядит примерно так:

[Fact]
public async Task SaveWithRemovedFlag()
{
    var commentId = Guid.NewGuid();
    var comment = new CommentToDelete();
    getCommentSetup.ReturnsAsync(comment);

    await service.Delete(commentId);

    commentaryRepository.Verify(r =>
        r.Delete(commentUpdateBuilder.Object, topicUpdateBuilder.Object), Times.Once);
    commentUpdateBuilder.Verify(b => b.Field(c => c.IsRemoved, true));
    commentUpdateBuilder.VerifyNoOtherCalls();
}

Этот тест проверяет много вещей, и наверное стоит сделать одно общее расширение для FluentAssertions, чтобы проверять такие вещи одной командой, но по сути мы проверяем, что

  • в репозиторий был передан именно наш набор обновлений и никакой другой
  • набор обновлений содержит обновление поля IsRemoved в значение true
  • и больше никаких обновлений.

По-моему, очень круто :)

Automapper

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

Authorization

Для авторизации мы не пользуемся аттрибутами ролей - в случае с ДМчиком это крайне редко полностью определяет права пользователя. Гораздо чаще мы вынуждены смотреть на клейм пользователя под названием UserId - авторская модель.

Для авторизации мы пользуемся абстракцией IIntentionManager. У него есть обобщенный метод, который принимает тип проверяемой сущности, намерение пользователя и саму сущность. Пользователь определяется внутри, а на выходе можно получить один из двух результатов: булево значение для метода IsAllowed и void для метода ThrowIfForbidden.

Это расширяемая точка, и если вы хотите ввести новую систему правил авторизации, когда намерение пользователя выполнить команду (снова термины CQRS) рискует натолкнуться на суровую реальность, вы должны реализовать интерфейс IIntentionResolver<>. У него много разных обобщенных типов на вход, а на выходе от него ожидается простой bool.

Не нужно ходить в БД из реализаций этой абстракции, это нарушит его зону ответственности, которая состоит в том, чтобы ответить, какие действия допустимы для указанного пользователя с указанной сущностью (а не с ее клеймами).