Диалоги — одна абстракция для P2P и групп
Перед началом: бойлерплейт развёрнут, проверки зелёные (Модуль 1). Инструменты: PostgreSQL, миграции, Go. Трудоёмкость: ~6 ч.
🔥 Инцидент: «Почему у Боба пять диалогов со мной?»
Фичу личных сообщений в v1 сделали «быстро»: диалог — это комната с двумя участниками, создаётся при первом открытии переписки. Через месяц аналитика показала: у активных пользователей до десятка «диалогов» с одним человеком. Причина — гонка: пользователь открыл переписку в двух вкладках, оба запроса «создай комнату, если нет» прошли проверку существования одновременно, и таблица получила две строки. А ещё в этих «диалогах» разные истории сообщений. В этом модуле делаем модель диалогов, в которой такая гонка невозможна по устройству базы.
📖 Теория
2.1 Одна таблица на два типа
Личный диалог и группа — разные продукты, но у них идентичны: лента
сообщений, участники, read-состояние, доставка, списки и непрочитанные.
Поэтому — одна сущность conversations с полем type ∈ {direct, group}:
sqlCREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), type TEXT NOT NULL CHECK (type IN ('direct', 'group')), title TEXT NOT NULL DEFAULT '', -- пусто у direct direct_key TEXT UNIQUE, -- только у direct created_by UUID NOT NULL REFERENCES users (id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (type <> 'direct' OR direct_key IS NOT NULL), CHECK (type <> 'group' OR length(title) BETWEEN 1 AND 128) );
Что покупаем: один код на ленту, один на доставку, один на непрочитанные.
Что платим: nullable-поля и инварианты, которые защищают CHECK-и и код.
Trade-off честный: две таблицы дублировали бы ~80% логики ради 20% различий.
2.2 Канонический ключ пары: гонка лечится базой
Для direct заводим direct_key — ключ пары пользователей, не зависящий
от порядка:
go// DirectKey: Алиса+Боб и Боб+Алиса → один и тот же ключ. func DirectKey(a, b uuid.UUID) string { if a.String() > b.String() { a, b = b, a } return a.String() + ":" + b.String() }
Создание — идемпотентным UPSERT'ом. Приём DO UPDATE с возвратом
существующей строки — классика, которая часто удивляет:
sqlINSERT INTO conversations (type, direct_key, created_by) VALUES ('direct', $1, $2) ON CONFLICT (direct_key) DO UPDATE SET type = 'direct' -- no-op, чтобы RETURNING сработал RETURNING id, created_at;
Оба конкурентных запроса вернут одну и ту же строку: один создал, второй получил её через конфликт. Никаких «пять диалогов с Бобом».
2.3 Миграция без остановки: expand → contract
Мы меняем rooms → conversations, но не удаляем старое в том же релизе.
Правило продакшена: миграция сначала расширяет схему (новое рядом со
старым), затем данные переносятся, и только отдельным релизом старое
сжимается. Так старый образ, оставшийся на откате, продолжает
работать.
sql-- 000004_conversations.up.sql (expand) CREATE TABLE conversations ( ... ); INSERT INTO conversations (id, type, title, created_by, created_at) SELECT id, 'group', title, created_by, created_at FROM rooms; CREATE TABLE conversation_participants ( conversation_id UUID NOT NULL REFERENCES conversations (id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')), last_read_seq BIGINT NOT NULL DEFAULT 0, muted_until TIMESTAMPTZ, joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (conversation_id, user_id) ); INSERT INTO conversation_participants (conversation_id, user_id) SELECT room_id, user_id FROM room_participants; -- messages: добавляем новую колонку, старую (`room_id`) уберём в 000006 ALTER TABLE messages ADD COLUMN conversation_id UUID REFERENCES conversations (id); UPDATE messages SET conversation_id = room_id;
Дробим на шаги осознанно: в этом модуле — создать новое и перенести,
следующие модули пишут только в conversation_id, а уборку room_id
сделаем в модуле про группы, когда новая модель уже обкатана.
Номера начинаются с 000004 не просто так: 000002 и 000003 в
бойлерплейте уже заняты — там реакции и стикеры (см. §1.2), живой пример
фичи поверх сообщений.
💻 Практика
-
Напишите миграции
000004_conversations.{up,down}.sqlпо образцу выше и накатите:make migrate-up. -
Добавьте модели
Conversation,ConversationType,ParticipantRoleвinternal/models. Инварианты — в конструкторы/валидацию юзкейса, а не только в БД: БД — последний рубеж, не первый. -
Реализуйте репозиторий
Conversations:GetOrCreateDirect(ctx, a, b uuid.UUID)— UPSERT поdirect_key+ вставка двух участников (ON CONFLICT DO NOTHING);CreateGroup(ctx, title, creator)— группа + участник-владелец в одной транзакции.
-
Юзкейс и хендлер
POST /api/v1/conversations:
jsonc// type=direct { "type": "direct", "peer_id": "uuid" } // type=group { "type": "group", "title": "Команда", "member_ids": ["uuid", "uuid"] }
-
Допишите пути в
api/openapi.yamlи прогонитеmake validate-api— тест напомнит, если хендлер есть, а контракта нет (и наоборот). -
Тест на гонку — главный в модуле:
gofunc TestGetOrCreateDirect_Concurrent(t *testing.T) { // 20 горутин создают диалог одной и той же пары; // все обязаны получить один id, в таблице — одна строка. }
Чек-лист модуля: два одновременных запроса на диалог дают один
conversation_id; группы и участники создаются транзакционно; validate-api
зелёный; INSERT в conversation_participants не зависит от порядка
аргументов (канонический ключ).
🧩 Quiz
ON CONFLICT (direct_key) DO UPDATE SET type = 'direct' в этом UPSERT'е?direct_key, если можно проверить «есть ли диалог с этим пользователем» запросом?✅ Итоги модуля
conversations— единая модель:directиgroupс инвариантами в схеме.- Гонка дублей диалога закрыта каноническим
direct_key+ UPSERT — база, а не код. - Миграция показана в стиле expand→contract: старое живёт до отдельного релиза.
participantsнесут роль, read-позицию и mute — всё, что понадобится дальше.make validate-apiдержит контракт синхронным после каждого изменения.
🔗 Мостик к Модулю 3
Диалоги есть, участники есть — но сообщения в них всё ещё упорядочены по
created_at, и это мина: два сообщения в одну миллисекунду, пагинация на
границе курсора теряет строки, а «последнее прочитанное» невыразимо одним
временем. В следующем модуле вводим seq — строгий порядок внутри
диалога, который затем станет фундаментом и синхронизации, и
непрочитанных.