Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 20.
В предыдущей статье у задач появились атрибуты. Когда задачу могут менять несколько человек, рано или поздно возникает вопрос: кто и когда это поменял?
Краткое содержание
Как мы сделали историю изменений задач:
- История хранится в двух таблицах: запись о действии (кто, когда, с какой сущностью, что сделал) и список изменённых полей со старым и новым значением.
- Записи пишет сам сервис, который меняет задачу, в той же транзакции. Если изменение не сохранилось, записи в истории тоже не будет, и наоборот.
- Интерсепторы EF Core и темпоральные таблицы не подошли: решать, что считать изменением и как его показать, — это бизнес-логика, а ей не место ни в интерсепторе, ни в базе.
- Event sourcing тоже не подошёл. Ему нужна шина событий, которой у нас нет, а данные при нём какое-то время могут не совпадать с историей. Нам же было важно, чтобы история всегда точно отражала то, что произошло.
- В истории хранятся значения в том виде, в каком их показывать, — так она остаётся читаемой, даже если атрибут потом переименуют или удалят.
- Таблица называется
OrganizationLog, а не «история задач», потому что позже мы хотим хранить в ней изменения не только задач, но и других сущностей.
Зачем задаче история
Пока задачами пользуется один человек, вопрос «кто это поменял» не возникает. В организации над одной задачей работают несколько участников: кто-то переносит её в другой статус, кто-то меняет исполнителя или атрибут, кто-то удаляет вложение. Без истории нельзя понять, как задача пришла к текущему состоянию.
Мы хотели, чтобы история отвечала на этот вопрос честно: что в ней записано, то и произошло на самом деле.
Модель: запись о действии и изменённые поля
Одно действие пользователя — одна запись в OrganizationLog:
public class OrganizationLog
{
public long Id { get; set; }
public long? EntityId { get; set; }
public LogEntityType EntityType { get; set; }
public long OrganizationId { get; set; }
public Organization? Organization { get; set; }
public Guid OwnerId { get; set; }
public User? Owner { get; set; }
public DateTime CreatedAt { get; set; }
public LogAction Action { get; set; }
public List<OrganizationLogItem>? Items { get; set; }
}
public enum LogEntityType
{
Issue,
Comment,
}
public enum LogAction
{
Create,
Update,
Delete,
}
Если за одно действие поменялось несколько полей — текст, исполнитель и два атрибута, — у записи будет несколько элементов OrganizationLogItem:
public class OrganizationLogItem
{
public long Id { get; set; }
public long OrganizationLogId { get; set; }
public OrganizationLog? OrganizationLog { get; set; }
public PropertyType PropertyType { get; set; }
[MaxLength(36)]
public string? ParentId { get; set; }
[MaxLength(36)]
public string? OldValueId { get; set; }
[MaxLength(36)]
public string? NewValueId { get; set; }
[MaxLength(255)]
public string? PropertyName { get; set; }
[MaxLength(4096)]
public string? OldDisplayValue { get; set; }
[MaxLength(4096)]
public string? NewDisplayValue { get; set; }
}
PropertyType определяет, что именно поменялось: текст, статус, исполнитель, атрибут, вложение, доска или спейс. Для ссылок на другие сущности сохраняется их идентификатор (OldValueId, NewValueId), а рядом — значение в том виде, в каком его видел пользователь (OldDisplayValue, NewDisplayValue).
Одних идентификаторов недостаточно: история должна показывать то, что видел пользователь в момент изменения. Если бы в ней хранился только идентификатор варианта списка, то после переименования варианта старые записи показывали бы новое название, а после удаления — пустое место. Поэтому вместе с идентификатором сохраняется и текст значения, а в PropertyName — название атрибута на момент изменения.
Мы назвали таблицу OrganizationLog, а не IssueHistory, потому что она не привязана к задачам: тип сущности хранится в каждой записи. Сейчас туда пишутся изменения задач и комментариев, а когда понадобится история других сущностей, она будет храниться там же.
Как мы записываем историю
Чтобы история совпадала с тем, что произошло, она должна сохраняться вместе с самим изменением, в одной транзакции. Если изменение откатилось, записи в истории не должно быть.
Есть несколько распространённых способов вести историю. Вот почему они нам не подошли.
Интерсептор EF Core. Можно перехватывать SaveChanges, смотреть в ChangeTracker, какие свойства поменялись, и записывать их. Но интерсептор видит только колонки. Он не знает, что смена StatusId — это перенос задачи в другой статус, который нужно показать названием статуса, что изменение атрибута лежит в отдельной таблице значений и что у него есть название. Решать, что считать изменением и как его показать, — это бизнес-логика, и в интерсепторе ей не место.
Темпоральные таблицы. База сама хранит предыдущие версии строк. Это та же проблема, только ещё ниже: вместо истории изменений получаются копии значений, а логика того, что именно произошло, оказывается на уровне базы.
Event sourcing. Изменения записываются как события, и из них строится и текущее состояние, и история. Этот подход хорош, когда в приложении уже есть шина событий и когда допустимо, что история и данные какое-то время не совпадают. Нам было важно, чтобы история всегда совпадала с данными, поэтому она должна сохраняться в той же транзакции, что и само изменение.
Поэтому историю пишет сам сервис, который меняет данные.
Как это выглядит в коде
Работа разделена так же, как и во всём бэкенде: сервис хоста проверяет права и управляет транзакцией, а Core-сервис меняет данные (подробнее — в статье о наших инженерных принципах). Там же мы писали, что создание issue должно добавлять запись в историю независимо от того, откуда пришёл запрос — из Telegram-бота или из веб-API. Поэтому история пишется в Core-сервисе.
Сервис хоста открывает транзакцию и коммитит её, когда всё готово:
await using var transaction = await context.Database.BeginTransactionAsync(ct);
await issuesService.Update(
issueId,
request.AuthData.UserId,
request.Content,
request.AssigneeId,
attributeUpdateRequests,
uploadedFiles,
request.RemoveAttachmentIds,
ct);
await transaction.CommitAsync(ct);
Core-сервис сравнивает старые и новые значения, меняет только то, что изменилось, и для каждого изменения добавляет элемент истории:
var change = new OrganizationLog
{
EntityId = issueId,
EntityType = LogEntityType.Issue,
Action = LogAction.Update,
OwnerId = updaterId,
CreatedAt = date,
Items = [],
};
if (oldContent != content)
{
settersBuilder += builder => builder.SetProperty(x => x.Content, content);
change.Items.Add(GetContentLogItem(oldContent, content));
}
// остальные изменения (исполнитель, вложения) обрабатываются так же
change.Items.AddRange(await UpdateAttributes(issueId, issueData.OrganizationId, attributes, cancellationToken));
if (change.Items.Count != 0)
{
context.Add(change);
await context.SaveChangesAsync(cancellationToken);
}
Методы, которые меняют вложения и атрибуты, возвращают свои элементы истории, а запись сохраняется, только если что-то действительно поменялось. Сохранение задачи без изменений не оставляет в истории пустых записей.
Что видит пользователь
В веб-приложении в карточке задачи рядом с комментариями появилась вкладка «История». Каждая запись показывает, кто и когда что сделал, а внутри — изменённые поля со старым и новым значением. Создание и удаление задачи тоже попадают в историю, как и изменения комментариев. Интерфейс истории сделал Николай, фронтенд-разработчик, который к тому времени присоединился к проекту.
Итоги
- История — то, что действительно произошло. Запись в истории и само изменение сохраняются в одной транзакции.
- Историю пишет сервис, а не инфраструктура. Что считать изменением и как его показать — это бизнес-логика, поэтому ни интерсепторы EF Core, ни темпоральные таблицы не подошли.
- Без event sourcing. Шины событий у нас нет, а расхождение истории и данных было бы неприемлемо.
- Значения для показа сохраняются в истории. Поэтому она остаётся читаемой после переименования и удаления атрибутов.
- Общая таблица для всех сущностей. Сейчас в неё пишутся задачи и комментарии, позже — и другие сущности.
Что дальше
Незадолго до истории к задачам в веб-приложении стало можно прикреплять картинки. Об этом — следующая статья.