Как мы пишем код?
Дисклеймер: да пишите как хотите, конечно! :)
Мы стараемся придерживаться следующих принципов при разработке API:
- REST
- обратная совместимость
Каждый ресурс API должен обладать своей моделью. Даже если модель дословно совпадает с какой-то сущностью из БД, ни в коем разе не нужно лениться и возвращать из API прямо эти модели. Для них специально указаны Private Assets и добавлять зависимость от проекта DM.Services.DataAccess
нельзя в API ни в коем случае.
Не забывайте именовать все эндпоинты, чтобы была возможность возвращать их хотя в кодах ответа 201.
Контроллеры должны быть максимально худыми и лишенными логики. Но поскольку им как минимум нужны маппинги, а иногда и что-то сложнее, то мы используем вот что:
Это абстракция, которая специально обслуживает контроллеры. Обычно мы делим эти абстракции по ресурсам, таким образом у ресурса, типа комментарий форума, есть свой сервис, обладающий всеми методами, необходимыми для его работы.
Как правило, API Service получает на вход модель ресурса API или набор параметров запроса, преобразует их в бизнесовую модель (с помощью AutoMapper), затем вызывает бизнесовый сервис, маппит результат на модель ресурса API и возвращает значение.
Как правило, 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;
}
Почти каждый бизнес-сервис так или иначе взаимодействует с БД. Типичный подход при разработке таких приложений и первый соблаз любого разработчика - создавать репозитории по таблице в БД. Мы от этого убегаем со всех ног, и причин тому несколько:
- Разные процессы нуждаются в разных фильтрах, проекциях и прочем при общении с БД. Если обслуживание запросов к БД разных процессов лежит в одной зависимости, она сталкивается с выбором - или предоставлять разным процессам одну общую модель, что может сильно ударять по производительности, либо разделять методы для разных процессов, что нарушает принцип Interface Segregation.
- Многие процессы нуждаются в данных из нескольких таблиц, и это моментально делает неочевидным ответственность сервиса.
RoomRepository
, который внутри джойнитRoom
,RoomClaims
иGame
- это не очень легально, даже хотя снаружи это может быть и незаметно. - EF уже реализует паттерн репозитория для каждой таблицы в БД - сам
DbSet<Table>
- это репозиторий. Нет никакого смысла оборачивать их в абстракции, основанные на том же разделении. Если уж мы это делаем - то абстракции должны быть уровнем выше, и не должны заботиться о структуре БД.
Таким образом, мы стараемся выделять на каждый бизнес-процесс свои репозитории. Это позволяет делать достаточно элегантные и оптимальные вещи. Например, у бизнес-сервиса, отвечающего за удаление комментариев на форуме, репозиторий отвечает особенной моделью CommentToDelete
, которая содержит минимальную проекцию данных из таблицы ForumComments
, необходимую для авторизации и для обновления денормализованных данных в таблице ForumTopics
. Если такую логику держать в общем репозитории ForumCommentRepository
, он достаточно быстро разбухнет и станет пахнуть.
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
- и больше никаких обновлений.
По-моему, очень круто :)
Принцип любой библиотеки, которая начинается с Auto - от общего к частному. Поэтому если у вас есть две модели, которые совпадают всеми полями, кроме одного - не надо перечислять все поля, опишите только одно. Ну и вообще, настоятельно рекомендуется почитать гайдлайны автомаппера у них в документации.
Для авторизации мы не пользуемся аттрибутами ролей - в случае с ДМчиком это крайне редко полностью определяет права пользователя. Гораздо чаще мы вынуждены смотреть на клейм пользователя под названием UserId
- авторская модель.
Для авторизации мы пользуемся абстракцией IIntentionManager
. У него есть обобщенный метод, который принимает тип проверяемой сущности, намерение пользователя и саму сущность. Пользователь определяется внутри, а на выходе можно получить один из двух результатов: булево значение для метода IsAllowed
и void
для метода ThrowIfForbidden
.
Это расширяемая точка, и если вы хотите ввести новую систему правил авторизации, когда намерение пользователя выполнить команду (снова термины CQRS) рискует натолкнуться на суровую реальность, вы должны реализовать интерфейс IIntentionResolver<>
. У него много разных обобщенных типов на вход, а на выходе от него ожидается простой bool
.
Не нужно ходить в БД из реализаций этой абстракции, это нарушит его зону ответственности, которая состоит в том, чтобы ответить, какие действия допустимы для указанного пользователя с указанной сущностью (а не с ее клеймами).