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, поэтому мы стараемся сделать маппинг на внутренние типы как можно раньше.
Полный путь апдейта выглядит так:
-
Middleware маппит Telegram-апдейт в
SaveTextMessageTelegramRequestи передаёт его сервису сохранения сообщений уровня хоста. -
Сервис сообщений уровня хоста открывает транзакцию, сохраняет
TelegramMessage, вызывает сохранение issue в Core сервисе, коммитит транзакцию. -
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 и что из этого получилось.