Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 5.
Первые четыре статьи были о подготовке: зачем мы это делаем, как выглядит путь пользователя, какой стек выбран. Здесь мы наконец пишем бэкенд настоящего Telegram-бота.
Цель первой итерации скромная: бэкенд, который принимает сообщения пользователей в Telegram и сохраняет их. Пока без web API и фронтенда: бот только принимает сообщение и кладёт его в базу.
Что в итоге получилось:
- один запускаемый проект,
TelegramHost, и слои вокруг него:DataAccess,Services,TelegramServices; - команды бота разбираются контроллерами, как в ASP.NET, а все остальные сообщения попадают в middleware, который их сохраняет;
- база PostgreSQL с миграциями, которые применяются при старте хоста, и EF Core, к которому для части запросов подключён linq2db;
- health-check
/_healthс первого коммита.
Telegram Host
На этом этапе мы работаем только с одним запускаемым проектом, хостом TelegramHost. Его задача проста: подключиться к Telegram, получать новые апдейты и сохранять их в базу.
Когда продукт уже целиком в голове, велик соблазн сразу нарисовать всю структуру: web API, фоновые воркеры, сервисы под будущие функции. Мы идём последовательно. Переложить сообщения из чата в базу — это минимум, с которым уже можно выкладывать проект на сервер. На нём и остановимся.
Структура solution
Хотя сейчас мы делаем один хост, структуру solution строим максимально похожей на ту, к которой придём. От проекта к проекту она почти не меняется: в папке src лежат все проекты, в папке tests — тесты. Исходный код можно посмотреть в репозитории бэкенда, а ниже объясняем, почему структура именно такая.
Проекты мы создаём вручную, добавляя каждый .csproj через контекстное меню редактора. В крупных компаниях это обычно автоматизируют: сервис с правильной структурой и настройками генерируется из шаблона. Но автоматизация окупается, когда новые сервисы появляются каждый день. У нас проектов немного, и их создание занимает очень мало времени по сравнению с остальной работой.
Проекты в solution и именование
Бэкенд разбит на проекты, и каждый отвечает за свой слой. Названия следуют правилу, которое мы применяем во всех бэкенд-репозиториях Laraue: у каждого проекта префикс из неймспейса.
Laraue.Apps.Boards.DataAccess— модели EF Core, связанные с ними enum и контекст базы данных. Бизнес-логики здесь нет, только классы, относящиеся к базе, и их настройки.Laraue.Apps.Boards.Services— бизнес-логика, общая для всех хостов. Например, создание issue может добавлять запись в базу, писать в историю изменений и в аудит-лог. Создавать issue можно из разных хостов, поэтому дублировать эту логику не нужно.Laraue.Apps.Boards.TelegramServices— сервисы, специфичные для Telegram-хоста. Они оборачивают вызовы общих сервисов логикой хоста. Например, Telegram-сервис помимо создания issue через общий сервис должен ещё привязать к немуTelegramMessage.Laraue.Apps.Boards.TelegramHost— запускаемый проект, который общается с Telegram.
Единый префикс Laraue.Apps.Boards.* делает имя проекта уникальным в компании, из него сразу ясно назначение проекта, и это помогает не ошибиться в настройках.
Один хост — одна функция
Хост называется по тому, что он делает. TelegramHost обрабатывает запросы Telegram-бота, WebApiHost будет обслуживать HTTP API, в другом проекте может быть RabbitWorkerHost, который читает очередь. По имени должно быть сразу понятно, чем занят процесс.
Это сознательное решение: так проще управлять хостами. Каждому можно задать свои лимиты ресурсов (бот и тяжёлый фоновый воркер потребляют память и процессор по-разному), свои сетевые правила (web API смотрит наружу, воркеру это не нужно) и своё поведение при перезапуске. Один хост на все функции такой гибкости не дал бы.
Telegram-хост, например, работает через long polling: он сам запрашивает у Telegram обновления, а не принимает вебхуки, поэтому публичный адрес ему не нужен. Он кладёт полученные апдейты в очередь в базе и обрабатывает её. Апдейты одного чата обрабатываются строго по одному, апдейты разных чатов могут идти параллельно (подробнее об этом в статье про медиагруппы). Нагрузка на память невелика, и лимиты для хоста можно держать небольшими.
Core- и Host-сервисы
Одно из наших правил: хост работает только со своими сервисами. Вызывать Core-сервисы прямо из контроллеров нельзя. То есть Telegram-хост может внедрять классы из TelegramServices, а те уже работают с классами из Services.
Благодаря этому легко добавлять новые точки входа. Когда появится web API, у него будут свои сервисы уровня хоста, со своими проверками прав и валидацией, а вызывать они будут те же Core-сервисы, что и бот.
Такое разделение отнимает время, и оно оправдано не всегда. Если бы в solution был только код Telegram-бота и мы точно знали, что новых хостов не будет, мы бы на это время не тратили.
Первые модели в базе
Схема базы выводится из пути пользователя, который мы определили раньше: человек пишет боту, сообщение сохраняется и позже показывается на доске. Поэтому первые таблицы этой итерации — пользователь, сообщение на доске (карточка) и сообщение Telegram.
Основная таблица — Message, то есть одно сообщение на доске. Здесь она показана в упрощённом виде. В первых коммитах поля выглядели чуть иначе: категория называлась MessageCategory, а идентификатор сообщения 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 допускает null. Уже в первой итерации модель допускает, что карточку на доске можно создать и без мессенджера.
Модель пользователя User хранит идентификаторы, включая TelegramId. Пользователя создаёт библиотека Laraue.Telegram.NET при первом обращении к боту. Для этого хост подключает свой сервис, который отвечает на два вопроса: найти пользователя по Telegram ID и создать нового.
public interface ITelegramUserQueryService<TUserKey>
{
Task<TelegramUserId<TUserKey>?> FindUserIdAsync(long telegramId, CancellationToken cancellationToken = default);
Task<TUserKey> CreateAsync(TelegramData telegramData, CancellationToken cancellationToken = default);
}
В хосте он подключается строкой AddTelegramAuthentication<Guid, TelegramUserQueryService, RequestContext>(). Когда мы писали эту часть, библиотека ещё требовала, чтобы сама модель User реализовывала интерфейс ITelegramUser<Guid>. Позже мы убрали это требование: модель базы не должна зависеть от библиотеки, а нужные данные библиотека получает через сервис.
Модель TelegramMessage содержит идентификаторы сообщения в Telegram. С её помощью мы не пускаем Telegram-специфичные идентификаторы в основную бизнес-логику.
Ограничение длины строк
У каждой строки в моделях максимальная длина задаётся явно: Content — 4096, Name — 128, Color — 7. Строковый столбец без ограничения превращается в text/varchar(max), что создаёт проблемы с индексами и мешает базе работать оптимально. Лимит эти проблемы снимает.
Кроме того, выбор лимита заставляет подумать, какие данные будут лежать в столбце. 4096 для Content выбрано не случайно: столько символов Telegram разрешает в одном сообщении. А семи символов для Color ровно хватает на hex-код вроде #1D9E75.
Ошибка: поспешное название сущности
Сначала мы назвали модель карточки на доске Message. Позже, когда карточки стали создаваться и через web API, возникла путаница: в интерфейсе появились подписи вроде «создать сообщение» и «редактировать сообщение», и это выглядело странно. Мы переименовали сущность в Card. Но это название плохо сочеталось с ботом: пользователь пишет заметку, а не карточку. В итоге остановились на Issue, с которым живём до сих пор.
Каждое такое переименование — не просто поиск с заменой: имя вплетено в модели, названия сервисов и методов, DTO. Оно же показывается пользователю в интерфейсе.
Как этого избежать, мы так и не придумали. Правильное имя в первой итерации было неизвестно, а Message выглядело вполне подходящим. Вывод один: переименовывать модели долго, поэтому стоит дважды подумать, прежде чем закреплять название. Впрочем, ошибка не уникальна: Atlassian в процессе развития Jira тоже переименовывала сущности, например «projects» в «spaces», а «issues» в «work items».
Название проекта тоже менялось
Мы долго не могли выбрать имя проекта и начали с Laraue.Apps.StructuredMessages. Когда название Laraue Boards закрепилось, мы переименовали репозиторий, solution, все неймспейсы и файлы деплоя (на самом деле не всё, время от времени находим что-то забытое). Для таких изменений хорошо подходит отдельный PR без других правок: его проще проверить и при необходимости откатить.
Чистая архитектура обработки сообщений
База — это место, где сообщение оказывается. Интереснее, как оно туда попадает.
Бот обрабатывает два вида сообщений. Первый — команды: пользователь просит что-то сделать, бот делает. Например, /start. Второй — всё остальное: если пользователь написал текст, который не является командой, сообщение просто сохраняется в базу, чтобы потом появиться на доске.
Команды направляются в контроллеры, как в ASP.NET, с помощью нашей библиотеки Laraue.Telegram.NET (исходный код на GitHub). Команда /start выглядит так:
public class CommandsController(ITelegramCommandsService commandsService)
: TelegramController
{
[TelegramMessageRoute("/start")]
public Task HandleStart(
RequestContext requestContext,
CancellationToken cancellationToken)
{
return commandsService.HandleStart(
ReplyData.FromMessageRequest(requestContext),
cancellationToken);
}
}
Среди ботов на C# такой подход встречается нечасто, поэтому библиотеку мы сделали сами. В большинстве примеров бот на C# — это один метод со switch, который обрабатывает все сообщения. В маленьком боте так можно жить, а в большом это тяжело поддерживать. Поэтому мы взяли за основу MVC и перенесли его в библиотеку.
Если пользователь прислал не команду, ни один маршрут не подходит, и сообщение попадает в HandleAllMessagesMiddleware, который работает как запасной вариант. Это не 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-сервис issue добавляет в базу запись
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
Мы придерживаемся правила «один pull request — одна миграция». Пока PR в черновике, миграций в нём становится много: что-то добавляется, меняется, удаляется. Перед тем как PR будет готов, мы их объединяем. Можно слить вручную, перенеся сгенерированный код из одного файла в другой. Можно удалить все миграции этого PR, откатить DatabaseContextSnapshot и сгенерировать одну новую со всеми изменениями.
Причина — удобная история. Проект, который живёт годами, накапливает много миграций, и бесконечный список мелких изменений трудно читать, когда пытаешься понять, как развивалась таблица. К тому же свежая база накатывает все миграции по порядку, и это может занять заметное время.
EF Core и linq2db на одних моделях
По умолчанию проект использует EF Core как ORM. Но некоторые запросы EF Core не поддерживает, и тогда выручает linq2db. Обе ORM работают с одними и теми же моделями, подключение выполняется строкой app.Services.UseLinq2Db(). UseLinq2Db() — наша обёртка над официальным адаптером LinqToDB.EntityFrameworkCore.
На практике выбор между ними виден прямо в коде запроса. Адаптер даёт методы с суффиксами, поэтому всегда понятно, какая ORM выполнит запрос: например, ToListAsyncEF исполняет его через EF Core, а ToListAsyncLinqToDB — через linq2db. Так, постраничный список issue в Boards читается через linq2db, а большинство остальных запросов идут через EF Core.
Почему не использовать linq2db всегда? Причин две. Во-первых, бывают запросы, с которыми справляется EF Core, а linq2db нет. Во-вторых, в EF Core удобные отслеживание изменений и работа с миграциями, а в linq2db этого нет.
Health-check с первого дня
В Program.cs есть строки AddHealthChecks() и MapHealthChecks("/_health"). Они появились в каждом хосте с первого коммита. Запрос к /_health показывает, жив ли хост и может ли он обслуживать запросы. Эндпоинт пригодится при настройке инфраструктуры, и мы увидим это в следующей статье.
Итоги
В итоге получился .NET-бэкенд, разбитый на слои, с одним Telegram-хостом. Он принимает сообщения из Telegram, команды направляет в контроллеры, а обычные сообщения через запасной middleware превращает в запросы и передаёт сервису уровня хоста, затем Core-сервису, и в конце они оказываются в PostgreSQL. При старте хост применяет миграции, а ещё у него есть health-check.
Пока ничего из этого не развёрнуто: код собирается и работает локально, но на сервер ещё не попал.
Что изменилось с тех пор. Код в статье показан на момент этой части. Позднее единый middleware разделился на два: для личных и для групповых чатов. В хост добавились метрики (OpenTelemetry и эндпоинт
/_metricsдля Prometheus), у некоторых команд появилось ограничение на тип чата, аUserперестал реализовывать интерфейс библиотеки. Структура решения и принципы остались прежними.
Что дальше
Следующая статья — о выкладке хоста на настоящий сервер. Расскажем про VPS с PostgreSQL на том же сервере, про пайплайн сборки и доставки артефактов, про файлы Dockerfile и Docker Compose и про то, что из этого получилось.