Назад к articles
article

Чистая архитектура Telegram-бота на .NET — контроллеры вместо гигантского switch

Architecture First: как в одиночку с ИИ сделать альтернативу Jira — Часть 5.
Первые четыре статьи были теоретическими: зачем и что делаем, путь пользователя, стек проекта. В этой статье пишем бэкенд реального Telegram бота.

Цель первой итерации разработки минимальна: разработать бэкенд, который сможет обрабатывать сообщения от пользователей в Telegram и сохранять их. Пока никакого web API и фронтенда — только принятие сообщений от пользователя и складывание их в базу.

Telegram Host

На этом этапе мы будем работать только с одним запускаемым проектом (хостом): TelegramHost. Его задача проста — подключаться к Telegram, забирать новые апдейты и класть их в базу.

Когда продукт уже целиком в голове, велик соблазн сделать всю структуру проекта заранее — web API, фоновые воркеры, сервисы под будущие фичи. Мы хотим двигаться последовательно. Перекладывание сообщений из чата в базу — минимальная функциональность, с которой можно будет задеплоить проекты. Именно на ней мы и остановимся.

Структура solution

Несмотря на то, что мы работаем только над одним хостом, структуру solution стараемся делать максимально похожей на ту, что хотим получить в итоге. От проекта к проекту она почти не отличается: в папке src лежат все проекты, в папке tests — тесты. Исходный код можно посмотреть в бэкенд-репозитории, ниже — разбор почему структура именно такая.

Сами проекты мы создаём вручную — добавляя каждый .csproj через контекстное меню редактора кода. В крупных компаниях этот процесс обычно автоматизирован, сервис с правильной структурой и настройками генерируется из шаблона. Но такая автоматизация окупается, когда новые сервисы создаются каждый день. У нас же проектов немного, и их создание отнимает очень мало времени относительно других активностей.

Проекты в solution и соглашения об именовании

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

  • Laraue.Apps.Boards.DataAccess — модели EF Core, enums, связанные с ними, и контекст базы данных. Бизнес-логики здесь нет — только классы, связанные с базой и их настройками.
  • Laraue.Apps.Boards.Services — бизнес-логика, общая для всех хостов. Создание issue, например, может добавлять запись в базу, писать в историю изменений и в аудит-лог. А так как создание может выполняться из разных хостов — то нет смысла дублировать такую логику.
  • Laraue.Apps.Boards.TelegramServices — сервисы, специфичные для Telegram-хоста. Они оборачивают вызовы общих сервисов специфичной для хоста логикой. Например, Telegram-сервис, помимо создания issue через общий сервис также должен привязать к нему ссылку на TelegramMessage.
  • Laraue.Apps.Boards.TelegramHost — запускаемый проект (хост), общающийся с Telegram.

Единый префикс вида Laraue.Apps.Boards.* позволяет иметь уникальное имя у каждого проекта в компании, что делает очевидным его назначение и позволяет избежать ошибок в конфигурировании.

Один хост на одну функцию

Хост называется по функции, которую он выполняет: TelegramHost обрабатывает запросы Telegram-бота, WebApiHost будет обслуживать HTTP API запросы, в других проектах может находиться RabbitWorkerHost, потребляющий очередь. Имя должно однозначно отвечать на вопрос, что делает процесс.

Это осознанное решение, упрощающее управление хостами. Каждому можно задать свои лимиты ресурсов (у бота и тяжёлого фонового воркера может быть разное потребление памяти и CPU), свои сетевые правила (web API выставлен наружу, воркеру такой доступ не нужен) и своё поведение при перезапуске. Один хост на все функции не дал бы таких возможностей.

Telegram-хост, например, использует long polling — сам обращается к Telegram за апдейтами, а не работает с вебхуками. Поэтому публичный доступ к нему не нужен. Его основные задачи — перекладывание новых сообщений в очередь на обработку и последовательная обработка этой очереди, по одной записи за раз. То есть лимиты по памяти для него можно держать на небольшом уровне.

Core и Host сервисы

Одно из наших правил: хост может взаимодействовать только со своими сервисами. Использовать напрямую Core сервисы в контроллерах нельзя. То есть, Telegram-хост может инжектить классы из TelegramServices, а те уже могут работать с классами из Services.

Это помогает быстро добавлять новые точки входа в приложение. Когда появится web API, он реализует свои сервисы уровня хоста. В них будут свои проверки прав, валидация, но вызываться будут те же самые Core сервисы, что вызываются и из бота.

Такое разделение несомненно отнимает ресурсы у разработчика и не всегда стоит потраченных усилий. Если бы solution содержал только код для Telegram-бота, и мы понимали бы, что новых хостов не предвидится — то не стали бы тратить на это силы.

Первые модели в базе данных

Схема базы строится исходя из пути пользователя, определённого ранее: пользователь пишет сообщение боту и оно сохраняется, чтобы позже быть показанным на доске. Первые таблицы этой итерации — пользователь, сообщение (карточка на доске), сообщение Telegram.

Основная таблица — Message. Это одно из сообщений на доске:

public class Message
{
    public long Id { get; set; }

    [MaxLength(4096)]
    public required string? Content { get; set; }

    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }

    // The user the message belongs to.
    public Guid UserId { get; set; }
    public User? User { get; set; }

    // The message's current status.
    public long StatusId { get; set; }
    public Status Status { get; set; }

    // Category it belongs to. Null for backlog.
    public long? CategoryId { get; set; }
    public Category? Category { get; set; }

    // Linked Telegram message. Null means it was not created through Telegram.
    public TelegramMessage? TelegramMessage { get; set; }
    public long? TelegramMessageId { get; set; }
}

Связь с идентификатором сообщения Telegram необязательна: поле TelegramMessageId — nullable. С первой итерации модель допускает, что карточка на доске может быть создана и без использования мессенджера.

Модель пользователя User реализует ITelegramUser<Guid> — интерфейс из нашей библиотеки Laraue.Telegram.NET. Пользователь сохраняется библиотекой автоматически при первом взаимодействии с ботом, поэтому обязан иметь этот интерфейс.

Модель TelegramMessage содержит ссылки на различные идентификаторы сообщения в Telegram. С её помощью мы стараемся уйти от хранения специфичных Telegram-идентификаторов в основной бизнес-логике приложения.

Ограничение длины строковых столбцов

У каждой строки в моделях максимальная длина задается явно: Content — 4096, Name — 128, Color — 7. Неограниченный по длине строковый столбец неявно превращается в text/varchar(max), что ведет к проблемам с индексацией и неоптимальной работе базы данных. Лимит позволяет нивелировать эти проблемы.

Выбор лимита заставляет подумать, что за данные собираются храниться в столбце. Например, 4096 для Content не случаен — столбец соответствует реальному ограничению длины сообщения в Telegram. Ограничение Color семью символами оптимально для хранения hex-кода вроде #1D9E75.

Ошибка: необдуманный выбор названия для сущности

Изначально, модель для карточки, которая есть на доске, мы назвали Message. На более поздних этапах, когда было добавлено создание карточек через web API, появилась неоднозначность. В интерфейсе мы выводили надписи вроде «создать сообщение» / «редактировать сообщение», что казалось нелогичным. Мы переименовали сущность в Card. Но такое название уже плохо сочеталось с ботом — пользователь создает заметку, а не карточку. В итоге, мы остановились на финальном варианте Issue, с которым живем и по сей день.

Каждое такое переименование — это не просто операция find-and-replace: имя вплетено в модели, названия сервисов, их методов и DTO. Названия отображаются и в пользовательских интерфейсах.

Идей, как можно было бы этого избежать, так и не появилось. Правильное имя не было известно в первой итерации и Message выглядел корректным названием. Единственный вывод: переименование моделей может отнимать много времени, поэтому стоит дважды подумать перед выбором окончательного названия. А вообще наша ошибка неуникальна: Atlassian переименовывала в Jira «projects» в «spaces», а «issues» в «work» в процессе эволюции продукта.

Нейминг проекта тоже изменился

Мы долго не могли определиться с именем проекта, поэтому начинали с Laraue.Apps.StructuredMessages. Когда название Laraue Boards устоялось, был переименован репозиторий, solution, все неймспейсы и файлы деплоя (на самом деле не все, периодически находим что-то, что забыли поменять). Хорошая практика для подобных изменений — один PR без каких-либо других изменений, для облегчения ревью и возможности простого отката.

Чистая архитектура для обработки Telegram сообщений

База данных — то место, где сообщение оказывается. Но интереснее, как оно туда попадает.

В бот закладывается обработка двух видов сообщений. Первый — команды: пользователь просит выполнить что-то, бот выполняет. Например, команда /start. Второй — все остальные сообщения: если пользователь ввел что-то и это не является командой, то сообщение просто сохраняется в базу, чтобы потом отобразиться на доске.

Команды маршрутизируются на контроллеры, в стиле ASP.NET, с помощью нашей библиотеки Laraue.Telegram.NET. Команда /start, например, выглядит так:

public class CommandsController(ITelegramCommandsService commandsService)
    : TelegramController
{
    [TelegramMessageRoute("/start")]
    public Task HandleStart(
        RequestContext requestContext,
        CancellationToken cancellationToken)
    {
        return commandsService.HandleStart(
            ReplyData.FromMessageRequest(requestContext),
            cancellationToken);
    }
}

В .NET-ботах такой паттерн почти не используется, мы сделали эту библиотеку самостоятельно. Большинство примеров Telegram-ботов на C# выглядят как один метод для обработки всех сообщений с большим набором switch. Это работает в небольших ботах, но становится трудно поддерживаемым в больших. Поэтому мы решили взять архитектуру MVC и перенести ее в эту библиотеку.

Когда пользователь отправил не команду — ни один из роутов не подойдет и сообщение будет обработано через HandleAllMessagesMiddleware, работающим как fallback. Это не ASP.NET Middleware, оно добавляется в контейнер через AddTelegramMiddleware<HandleAllMessagesMiddleware>() — это метод расширения из Laraue.Telegram.NET.

if (context.GetExecutedRoute() is null && AllowedUpdates.Contains(context.Update.Type))
{
    var message = context.Update.Message ?? context.Update.EditedMessage;

    SaveMessageTelegramRequest? request = message.Type switch
    {
        MessageType.Text => GetMessageRequest(message),
        // photo, video, animation handled here too, later in the series
        _ => null
    };

    if (request is not null)
        await telegramMessageService.HandleSaveMessage(request, ct);
}

При получении текстового апдейта, он маппится в SaveTextMessageTelegramRequest и передается в сервис уровня хоста для сохранения. Бизнес-логике необязательно знать о типах апдейтов Telegram, поэтому мы стараемся сделать маппинг на внутренние типы как можно раньше.

Полный путь апдейта выглядит так:

  1. Middleware маппит Telegram-апдейт в SaveTextMessageTelegramRequest и передаёт его сервису сохранения сообщений уровня хоста.
  2. Сервис сообщений уровня хоста открывает транзакцию, сохраняет TelegramMessage, вызывает сохранение issue в Core сервисе, коммитит транзакцию.
  3. Core сервис issues добавляет запись Message (в поздних итерациях Issue) в базу данных.

Настройка Telegram хоста

Хост настраивается и конфигурируется в Program.cs:

var builder = WebApplication.CreateBuilder(args);

const string dbConnectionStringName = "Postgre";

builder
    .AddTelegramOptions("Telegram")
    .AddApplicationServices()
    .AddDatabaseServices(dbConnectionStringName);

builder.Services.AddHealthChecks();

var app = builder.Build();

app.Services.UseLinq2Db();

using (var scope = app.Services.CreateScope())
{
    await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
    await db.Database.MigrateAsync();
    app.MapTelegramRequests();
}

app.MapHealthChecks("/_health");

app.Run();

Регистрация сгруппирована в методы-расширения — AddTelegramOptions, AddApplicationServices, AddDatabaseServices — для легкого понимания зависимостей сервиса. Мы стараемся избегать больших цепочек вызовов services.Add().

Перед каждым запуском сервиса мы запускаем выполнение миграций через await db.Database.MigrateAsync(). Такой подход редко встретишь в корпоративных приложениях — компании предпочитают максимальный контроль и запускают шаг с миграциями самостоятельно. Для небольших приложений это сознательное упрощение.

Создание миграций

Для создания миграций в папке src можно выполнить команду:

dotnet ef migrations add MigrationName \
  -p Laraue.Apps.Boards.DataAccess \
  -s Laraue.Apps.Boards.TelegramHost \
  -v

Мы стараемся придерживаться подхода: один пул-реквест = одна миграция. Пока PR в драфте, в нем постоянно появляются новые миграции — что-то добавляется, меняется, удаляется. Но перед тем как PR готов, мы делаем объединение. Миграции можно слить руками, перенося сгенерированный код из одного файла в другой; либо удалить все, сделанные в этом PR, откатить DatabaseContextSnapshot и сгенерировать одну новую, включающую все изменения.

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

Совмещение EF Core и linq2db в одном проекте

Проект использует EF Core как ORM для запросов по умолчанию. Однако некоторые кейсы не поддерживаются этим фреймворком и здесь на помощь приходит linq2db. Обе ORM работают с одними и теми же моделями — регистрация осуществляется строкой app.Services.UseLinq2Db(). UseLinq2Db() — это всего лишь наша обёртка для работы с официальным адаптером LinqToDB.EntityFrameworkCore.

Почему бы не использовать linq2db всегда? Две причины. Первая: есть случаи, когда EF Core работает с теми запросами, с которыми не работает linq2db. Вторая: в EF Core очень удобный change-tracking и работа с миграциями, которых не хватает linq2db.

Health-checks с первого дня

В Program.cs можно увидеть строки — AddHealthChecks() и MapHealthChecks("/_health"). Они добавляются в каждый хост с первого коммита. Запрос к /_health возвращает, жив ли хост и способен ли обслуживать запросы. Health-checks пригодятся в дальнейшем при настройке инфраструктуры.

Итоги

Результатом итерации стал .NET-бэкенд с архитектурой, разделенной на слои и одним Telegram-хостом. Он принимает сообщения Telegram, маршрутизирует команды через контроллеры. Обычные сообщения через fallback-middleware маппятся и передаются в сервис уровня Host, оттуда уходят в Core сервис и, наконец, оказываются в базе PostgreSQL. Хост запускает миграции при старте и имеет health-check эндпоинт.

Ничего из этого пока не развёрнуто: код собирается и работает локально, но пока не оказался на сервере.

Что дальше

Следующая статья о деплое хоста на реальный сервер. Расскажем о VPS с self-hosted PostgreSQL, как настраивали пайплайн сборки и доставки артефактов, что находится в файлах Dockerfile и Docker Compose и что из этого получилось.