Всем привет! Это мой первый пост на Хабре, в котором я бы хотел поделиться процессов разработки своего сервиса для публикации препринтов ScienceArchive.

Меня зовут Гаврилов Антон и я .NET бэкенд разработчик. Мне интересно изучать новые технологии и подходы к проектированию и разработке систем и приложений и относительно недавно я решил испытать свои навыки в полном цикле создания собственного веб-сервиса, которым и стал ScienceArchive.

Также вы можете ознакомиться с исходниками проекта на GitHub.

Идея проекта

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

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

Архитектурный подход

За последние несколько месяцев я погрузился в изучение архитектуры приложений. За это время прочитал "Чистую архитектуру", а также две книги по предметно-ориентированному проектированию: т.н. "синюю" от Эрика Эванса и "красную" от Вона Вернона. Так как теория без практики, на мой взгляд - бесполезная трата времени, то я принялся за создание схем и набросков того, как будет выглядеть система.

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

Итак, получилось следующее:

Примерный план реализации
Примерный план реализации

Подход чистой архитектуры был использован с целью создания каркаса для всей системы. Он традиционно включает в себя несколько слоев, на которые делится всё приложение, а так как я .NET разработчик, то, соответственно, решение делится на несколько проектов, которыми получились:

  • Слой предметной области (Core) - ядро системы, в котором заключены все сущности, участвующие в рамках системы, а также необходимые интерфейсы репозиторием, сервисов и пр. Иными словами, модель предметной области, ;

  • Прикладной слой (Application) - проект, предоставляющий прикладные сервисы и другие вспомогательные инструменты для взаимодействия с системой. Он выступает в роли оркестратора между предметной логикой и интерфейсами инфраструктуры, управляя транзакциями и общим поведением системы;

  • Инфраструктурный слой (Presentation и Infrastructure) - данный слой представляет собой два проекта, оба из которых отвечают за взаимодействие с внешними системами. Разница лишь в том, что Presentation отвечает за входящие сообщения (REST API, gPRC, RabbitMQ Consumers), а Infrastructure - за отправляемые запросы (взаимодействие с базами данных, отправка запросов к внешним системам).

Модель предметной области

На данном слое как раз и применялся подход предметно-ориентированного проектирования (DDD, Domain Driven Design). Здесь определены основные сущности, которые инкапсулируют в себе логику сервиса. Например, в системе есть такое понятие, как статьи. Помимо своих внутренних данных, статья хранит в себе логику проверки входящих значений, а также некоторые операции бизнес-логики:

public class Article : AggregateRoot<ArticleId>
{
    private string _title;

    private ArticleStatus _status;
    
    internal Article(ArticleId? id) : base(id ?? ArticleId.CreateNew())
    {
        _title = string.Empty;
        _status = ArticleStatus.ToVerify;
    }
    
    public required ArticleCategory Category { get; init; }

    public required string Title
    {
        get => _title;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                throw new InvalidFieldValueException(nameof(Title));
            }

            _title = value.Trim();
        }
    }

    public required List<ArticleAuthor> Authors { get; init; }

    public required ArticleStatus Status
    {
        get => _status;
        init => _status = value;
    }

    public required DateTime CreationDate { get; init; }

    public required List<ArticleDocument> Documents { get; init; }

    public void Approve()
    {
        // Логика принятия статьи
    }

    public void Decline()
    {
        // Логика отклонения статьи
    }
}

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

public class ArticleStatusChangedEvent : DomainEvent
{
    public required ArticleId ArticleId { get; set; }
    public required ArticleStatus Status { get; set; }
    public string? Message { get; set; } 
}

Обработка данного события происходит на прикладном слое, про который я сейчас также расскажу.

Прикладной слой

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

Публично доступны в слое только DTO (Data Transfer Objects или объекты передачи данных), которые формируют контракт взаимодействия с системой, а также интерфейсы прикладных сервисов. Например, также возьмем набор прикладных операций, связанных со статьями:

public interface IArticleApplicationService
{
    Task<GetArticleByIdResponseDto> GetArticleById(GetArticleByIdRequestDto dto);
    Task<CreateArticleResponseDto> CreateArticle(CreateArticleRequestDto dto);
    Task<UpdateArticleResponseDto> UpdateArticle(UpdateArticleRequestDto dto);
    Task<ApproveArticleResponseDto> ApproveArticle(ApproveArticleRequestDto dto);
    Task<DeclineArticleResponseDto> DeclineArticle(DeclineArticleRequestDto dto);
}

DTO представлены в виде простых типов record , которые позволяют нам создавать иммутабельные объекты, идеальные для представления простых структур данных:

public record ApproveArticleResponseDto(ArticleDto Article);
public record ApproveArticleRequestDto(string ArticleId) : IRequest<ApproveArticleResponseDto>;
Hidden text

В данном примере используется аргумент типа ArticleDto. Данный тип представляет собой простое представление сложной бизнес-сущности в виде простой структуры данных для передачи её клиенту.

Также ApproveArticleRequestDto реализует интерфейс IRequest из библиотеки MediatR. Её применение я также объясню далее

Реализация интерфейса прикладного сервиса доступна только изнутри проекта для регистрации в контейнере внедрения зависимостей .NET. Типовая реализация прикладной службы выглядит следующим образом:

internal class ArticleApplicationService : BaseApplicationService, IArticleApplicationService
{ 
    public ArticleApplicationService(IMediator mediator, IDbUnitOfWork dbUnitOfWork, IEventBus eventBus) 
        : base(mediator, dbUnitOfWork, eventBus) { }

    /// <inheritdoc/>
    public Task<GetArticleByIdResponseDto> GetArticleById(GetArticleByIdRequestDto dto)
    {
        return ExecuteUseCase<GetArticleByIdRequestDto, GetArticleByIdResponseDto>(dto);
    }
    
    public Task<CreateArticleResponseDto> CreateArticle(CreateArticleRequestDto dto)
    {
        return ExecuteTransactionalUseCase<CreateArticleRequestDto, CreateArticleResponseDto>(dto);
    }

    /// <inheritdoc/>
    public Task<DeleteArticleResponseDto> DeleteArticle(DeleteArticleRequestDto dto)
    {
        return ExecuteTransactionalUseCase<DeleteArticleRequestDto, DeleteArticleResponseDto>(dto);   
    }

    /// <inheritdoc/>
    public Task<ApproveArticleResponseDto> ApproveArticle(ApproveArticleRequestDto dto)
    {
        return ExecuteTransactionalUseCase<ApproveArticleRequestDto, ApproveArticleResponseDto>(dto);
    }

    /// <inheritdoc/>
    public Task<DeclineArticleResponseDto> DeclineArticle(DeclineArticleRequestDto dto)
    {
        return ExecuteTransactionalUseCase<DeclineArticleRequestDto, DeclineArticleResponseDto>(dto);
    }

    /// <inheritdoc/>
    public Task<UpdateArticleResponseDto> UpdateArticle(UpdateArticleRequestDto dto)
    {
        return ExecuteTransactionalUseCase<UpdateArticleRequestDto, UpdateArticleResponseDto>(dto);
    }
}

Каждый метод делегирует свое выполнение через вызов метода базового класса ExecuteUseCase или ExecuteTransactionalUseCase. Здесь как раз и пригодилось использование библиотеки MediatR. Давайте рассмотрим данные методы глубже и поймем для чего именно применяется такой подход:

protected async Task<TResponse> ExecuteTransactionalUseCase<TRequest, TResponse>(TRequest contract) 
        where TRequest : IRequest<TResponse>
{
    try
    {
        await _dbUnitOfWork.StartTransactionAsync();
        
        var result = await _mediator.Send(contract);
        
        await _eventBus.HandleEvents();
        await _dbUnitOfWork.SaveAsync();
        
        return result;
    }
    catch (Exception)
    {
        await _eventBus.ClearEvents();
        await _dbUnitOfWork.RollbackAsync();
        throw;
    }
}

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

Hidden text

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

В данном примере мы видим, что:

  • В начале операции открывается транзакция для соблюдения непротиворечивости данных после выполнения;

  • Далее посредством MediatR мы отсылаем запрос к обработчику операции. Этот пункт будет также рассмотрен далее;

  • После чего вызываем обработку всех опубликованных событий предметной области и закрываем транзакцию.

Теперь давайте рассмотрим обработчик запросов. Он представляет собой реализацию паттерная "стратегия", то есть хранит в себе всего один публичный метод Handle для исполнения одной единственной операции:

internal class ApproveArticleUseCase : IUseCase<ApproveArticleRequestDto, ApproveArticleResponseDto>
{
    private readonly IDbUnitOfWork _dbUnitOfWork;
    private readonly IEventBus _eventBus;
    private readonly IApplicationMapper<Article, ArticleDto> _articleMapper;
    
    public ApproveArticleUseCase(
        IDbUnitOfWork dbUnitOfWork, 
        IEventBus eventBus,
        IApplicationMapper<Article, ArticleDto> articleMapper) 
    {
        _dbUnitOfWork = dbUnitOfWork;
        _articleMapper = articleMapper;
        _eventBus = eventBus;
    }

    public async Task<ApproveArticleResponseDto> Handle(ApproveArticleRequestDto request, CancellationToken cancellationToken)
    {
        var articleId = ArticleId.CreateFromString(request.ArticleId);
        var article = await _dbUnitOfWork.ArticleRepository.GetById(articleId);

        if (article is null)
        {
            throw new EntityNotFoundException(nameof(Article));
        }
        
        article.Approve();

        var updatedArticle = await _dbUnitOfWork.ArticleRepository.Update(articleId, article);

        await _eventBus.AddEventAsync(new ArticleStatusChangedEvent
        {
            ArticleId = articleId,
            Status = updatedArticle.Status
        });
        
        return new ApproveArticleResponseDto(_articleMapper.MapToDto(updatedArticle));
    }
}

Как и было сказано выше - выполнение происходит через оркестрацию внешних сервисов и внутренних бизнес-правил и логики.

Hidden text

В данном примере публикация события предметной области происходит на прикладном слое. Это не совсем верное решение, но оно было сделано для наглядности того, как происходит данное действие.

При правильном подходе, события публикуются либо из сущностей предметной области, либо из сервисов предметной области.

После выполнения всех бизнес-операций, в шину событий добавляется новое событие предметной области, которое будет обработано далее другим обработчиком, оставляя наш метод UseCase.Handle свободным от производных операций, выражая чистую логику приложения.

Hidden text

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

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

Теперь рассмотрим обработчик событий предметной области:

internal class UserRegisteredEmailEventHandler : IEventHandler<UserRegisteredEvent>
{
    private readonly IDbUnitOfWork _dbUnitOfWork;
    private readonly INotificationGateway _notificationGateway;
    private readonly ITemplateService _templateService;
    
    public UserRegisteredEmailEventHandler(
        IDbUnitOfWork dbUnitOfWork, 
        INotificationGateway notificationGateway, 
        ITemplateService templateService)
    {
        _dbUnitOfWork = dbUnitOfWork;
        _notificationGateway = notificationGateway;
        _templateService = templateService;
    }

    public async Task Handle(UserRegisteredEventWrapper eventWrapper, CancellationToken cancellationToken)
    {
        var populatedEmail = await _templateService.GetPopulatedOtpEmailTemplate(eventWrapper.Event.Name, eventWrapper.Event.ConfirmationCode);
        
        var notification = new Notification(NotificationId.CreateNew())
        {
            Message = populatedEmail,
            Receiver = eventWrapper.Event.Email
        };

        await _notificationGateway.SendNotification(notification);
    }
}

Здесь я для примера взял событие регистрации нового пользователя. По логике системы, нам нужно отправить электронное письмо пользователю для подтверждения регистрации через OTP код.

Инфраструктуры слой

Как было сказано, данный слой представлен в виде двух проектов: Infrastructure и Presentation. Первый отвечает за отправку уведомлений (то есть за исходящие сообщения), а второй - за принятие сообщений от внешних систем или клиента.

Проект Infrastructure сам по себе представляет просто склад с набором реализаций интерфейсов, требуемых в других проектах для реализации их логики. В моем случае, таковыми являются репозитория (для доступа к хранилищам данных), а также Gateways (специальными сервисами для взаимодействия с внешними системами). Пример репозитория можете увидеть ниже:

internal class PostgresArticleRepository : IArticleRepository
{
    private readonly PostgresDbContext _dbContext;
    
    public PostgresArticleRepository(PostgresDbContext dbContext)
    {
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
    }

    public async Task<Article?> GetById(ArticleId id)
    {
        var article = await _dbContext
            .Articles
            .Where(a => a.Id == id.Value)
            .Include(a => a.UsersArticles)
            .ThenInclude(ua => ua.User)
            .Include(a => a.Category)
            .Include(a => a.ArticlesDocuments)
            .FirstOrDefaultAsync();

        if (article is null)
        {
            return null;
        }
        
        var builder = new ArticleBuilder(article.Id);

        foreach (var userArticle in article.UsersArticles)
        {
            builder.AddAuthor(userArticle.UserId, userArticle.User.Name, userArticle.Role);
        }

        foreach (var articleDocument in article.ArticlesDocuments)
        {
            builder.AddDocument(articleDocument.Id, articleDocument.Name, articleDocument.Filepath);
        }
                
        return builder
            .AddTitle(article.Title)
            .AddStatus(article.Status)
            .AddCategory(article.CategoryId, article.Category.Name)
            .AddCreationDate(article.CreationDate)
            .AddDescription(article.Description)
            .Build();
    }
}

Ничего особенного, просто реализация взаимодействия с базой данных через EntityFramework.

Теперь рассмотрим пример использования контроллера:

[Route("api/articles")]
public class ArticleController : ControllerBase
{
    private readonly IArticleApplicationService _articleService;

    public ArticleController(IArticleApplicationService articleService)
    {
        _articleService = articleService;
    }
    
    [HttpGet("by-category/{categoryId}")]
    public async Task<Response> GetVerifiedByCategoryId(string categoryId)
    {
        if (string.IsNullOrWhiteSpace(categoryId))
        {
            throw new BadHttpRequestException("Category ID was not presented");
        }
        
        var dto = new GetVerifiedArticlesByCategoryIdRequestDto(categoryId);
        var result = await _articleService.GetVerifiedArticlesByCategoryId(dto);
        return new SuccessResponse(result);
    }
    
    [Authorize]
    [HttpGet("my-articles")]
    public async Task<Response> GetUserArticles()
    {
        var userId = HttpContext.GetUserIdFromToken();

        if (string.IsNullOrWhiteSpace(userId))
        {
            throw new BadHttpRequestException("Cannot get user ID", 401);
        }

        var result = await _articleService.GetArticlesByAuthorId(new GetArticlesByAuthorIdRequestDto(userId));
        return new SuccessResponse(result);
    }

    [AuthorizeClaims(AuthClaims.ApproveArticles)]
    [HttpPost("approve")]
    public async Task<Response> Approve([FromBody] ApproveArticleRequestDto? dto)
    {
        if (dto is null)
        {
            throw new BadHttpRequestException("No data presented");
        }

        var result = await _articleService.ApproveArticle(dto);
        return new SuccessResponse(result);
    }
}

Данный слой также отвечает за валидацию токенов авторизации, реализованному через атрибуты Authorize и AuthorizeClaims. Второй из них также проверяет набор разрешений для действий пользователя. Внутри каждого из методов происходит делегация обработки и выполнения запроса прикладному слою.

Заключение

Спасибо большое, что дочитали мою первую статью на Хабре! Буду очень рад, если вы поддержите данный пост и ознакомитесь с моим сервисом.

В следующих статьях я расскажу детальнее про процесс проектирования каждого слоя.

Комментарии (6)


  1. AlexViolin
    11.06.2024 13:50
    +1

    Очень часть под use case как раз и понимают application service


    1. KrawMire Автор
      11.06.2024 13:50

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

      Фактически, получается, что операции выполняются UseCase'ами, а ApplicationService является скорее неким фасадом, который объединяет их, а также выполняет управление транзакцией в процессе выполнения UseCase'a


      1. AlexViolin
        11.06.2024 13:50

        Use case / application service являются фасадом слоя logic layer. Управление транзакциями тоже здесь находится. Фаулер это называет application logic.


        1. KrawMire Автор
          11.06.2024 13:50

          Согласен, Роберт Мартин также использует эти термины. Я скорее имею ввиду общепризнанное именование таких компонентов. Согласитесь, гораздо реже для реализации логики приложения используется понятие UseCase, нежели чем Service / ApplicationService


  1. mvv-rus
    11.06.2024 13:50

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

    Для решения поставленной задачи проект явно переусложнен и совершенно необозрим.

    Но раз вы его позиционируете как учебный, для обучения всем этим наилучшим практикам разработки из трудов теоретиков - нормально: эту задачу он решает. Главное, чтобы потом, в практической работе, все эти "наилучшие практики" не мешали. Но это обычно с опытом приходит.


    1. KrawMire Автор
      11.06.2024 13:50

      Спасибо за комментарий!

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

      Но я также рассматриваю спланированную архитектуру больше не с точки зрения разового преимущества, а скорее заложения определенного фундамента под будущие изменения. Кто знает, может быть, проект действительно заинтересует кого-то и потребуется его дальнейшая доработка с добавлением возможностей, отличных от простых публикаций/чтения статей :)

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

      Да, главное - находить некую "золотую середину" между сложными в реализации и понимании подходами и сведению всей системы к "контроллер-репозиторий".