Frontend System Design Interview: Designing a Kanban Board
Сегодня мы разберём расшифровку обучающего видео, в котором интервьюер-эксперт демонстрирует подход к проектированию приложения с доской (Trello/Jira-подобной) на фронтенд-системном интервью, используя фреймворк CCD (Collect, Component, Data). В ходе объяснения подробно рассматриваются ключевые этапы: сбор информации и формулировка требований (включая мультипользовательскую совместную работу, обновления в реальном времени, шаблоны и масштабируемость), декомпозиция UI на компоненты, проектирование нормализованной модели данных, выбор между REST и GraphQL с учётом пагинации и синхронизации, а также стратегии оптимизации — производительность, надёжность, доступность и интернационализация. Особое внимание уделяется тому, как ранние решения (например, введение понятий «пользователи» и «совместная работа») влияют на последующие этапы проектирования, формируя целостную и масштабируемую архитектуру в рамках ограниченного времени собеседования.
Вопрос 1. Какие ключевые аспекты нужно учитывать при сборе информации для проектирования борд-приложения (доски задач)?
Таймкод: 00:03:42
Ответ собеседника: Правильный. На этапе сбора информации необходимо задавать два типа вопросов: функциональные (предметные) и кросс-функциональные (нефункциональные). К функциональным относятся: поддержка многопользовательской работы (коллаборация), координация действий нескольких пользователей на одной доске, поддержка обновлений в реальном времени, способ уведомления пользователей об изменениях (визуальное обновление на доске, нотификации в топ-баре, email-уведомления), способ создания доски (с нуля или из шаблона), возможность кастомизации шаблонов и сохранения доски как шаблона, возможность изменения колонок (переименование, добавление новых), а также примерное количество карточек на доске (влияет на решение о пагинации и виртуализации). К кросс-функциональным относятся: требования к производительности, безопасности, доступности (accessibility) и интернационализации (поддержка нескольких языков).
Правильный вет:
Ответ собеседника полный и структурированный, затрагивает ключевые аспекты сбора требований. Дополнительно можно раскрыть несколько важных моментов, которые стоит учитывать при проектировании борд-приложения на уровне архитектурных решений.
Функциональные требования — углубление
Коллаборация и конкурентный доступ
При проектировании многопользовательской работы с доской критически важно заранее определить стратегию разрешения конфликтов. Если два пользователя одновременно перемещают одну и ту же карточку или редактируют её содержимое, система должна иметь чёткий механизм обработки таких ситуаций.
Основные подходы:
-
Optimistic Locking (оптимистичная блокировка) — каждый объект имеет версию. При сохранении проверяется, не изменилась ли версия. Если изменилась — конфликт, пользователю предлагается разрешить его. Подходит для сценариев с низкой вероятностью конфликтов.
-
Operational Transformation (OT) — алгоритм, используемый в Google Docs. Каждое изменение представляется как операция, и сервер трансформирует параллельные операции так, чтобы они были применимы в любом порядке. Сложен в реализации, но обеспечивает плавную коллаборацию.
-
CRDT (Conflict-free Replicated Data Types) — математически гарантируют конвергенцию без координации. Подходят для простых структур (счётчики, множества), но для сложных структур данных карточки могут быть избыточны.
Обновления в реальном времени
Для реализации real-time обновлений нужно выбрать транспорт:
- WebSocket — двунаправленное соединение, идеально подходит для частых обновлений. В Go удобно использовать библиотеку
gorilla/websocket:
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
-
Server-Sent Events (SSE) — однонаправленный канал от сервера к клиенту. Проще в реализации, но не подходит, если клиент тоже должен часто отправлять данные.
-
Long Polling — fallback для сред, где WebSocket недоступен.
Уведомления
Система уведомлений должна быть многоуровневой:
- In-app уведомления — счётчик непрочитанных в топ-баре, центр уведомлений внутри приложения.
- Real-time визуальные обновления — карточка перемещается на глазах у пользователя, появляется курсор другого пользователя.
- Email/push уведомления — для событий, которые произошли, пока пользователь был оффлайн. Здесь важно предусмотреть частоту (digest vs мгновенные) и возможность отключения.
Масштабирование карточек — пагинация и виртуализация
Оценка количества карточек на доске напрямую влияет на клиентскую архитектуру:
- До 100 карточек — можно загружать все сразу, рендерить в DOM без оптимизаций.
- 100–1000 карточек — нужна виртуализация (отрисовка только видимых элементов). Библиотеки типа
react-windowилиreact-virtuoso. - 1000+ карточек — обязательна серверная пагинация или infinite scroll, а также виртуализация на клиенте.
На бэкенде пагинация в Go с PostgreSQL:
type CardFilter struct {
BoardID int64
ColumnID *int64
Limit int
Offset int
}
func (r *CardRepo) List(ctx context.Context, filter CardFilter) ([]Card, error) {
query := `
SELECT id, title, description, column_id, position, created_at, updated_at
FROM cards
WHERE board_id = $1
`
args := []any{filter.BoardID}
argIdx := 2
if filter.ColumnID != nil {
query += fmt.Sprintf(" AND column_id = $%d", argIdx)
args = append(args, *filter.ColumnID)
argIdx++
}
query += fmt.Sprintf(" ORDER BY position ASC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, filter.Limit, filter.Offset)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query cards: %w", err)
}
defer rows.Close()
var cards []Card
for rows.Next() {
var c Card
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.ColumnID, &c.Position, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan card: %w", err)
}
cards = append(cards, c)
}
return cards, rows.Err()
}
Кросс-функциональные требования — углубление
Производительность
- Время первой загрузки доски — целевое значение (например, < 2 секунд). Влияет на решение о денормализации данных, использовании кэширования (Redis), предзагрузке связанных сущностей.
- Latency real-time обновлений — целевое значение (например, < 200мс от момента действия пользователя до отображения у других). Влияет на выбор инфраструктуры (региональные дата-центры, edge computing).
- RPS (запросов в секунду) — оценка нагрузки влияет на выбор между монолитом и микросервисами, необходимостью горизонтального масштабирования.
Безопасность
- Авторизация на уровне досок — кто может просматривать, редактировать, администрировать. Модель RBAC или ABAC.
- Авторизация на уровне операций — например, только автор карточки может её удалить, а перемещать может любой участник.
- Защита от CSRF/XSS — особенно важно, если карточки поддерживают форматированный текст (Markdown, HTML).
- Аудит действий — логирование всех операций для возможности отката и анализа инцидентов.
Доступность (Accessibility)
- Поддержка навигации с клавиатуры (Tab, Enter, стрелки).
- Совместимость с скринридерами (ARIA-атрибуты).
- Контрастность цветов соответствует WCAG 2.1 AA.
- Это особенно важно для карточек с цветовыми метками — цвет не должен быть единственным носителем информации.
Интернационализация (i18n)
- Все пользовательские строки должны быть вынесены в локализационные файлы.
- Поддержка RTL (right-to-left) языков влияет на layout доски.
- Форматы дат и чисел должны локализоваться.
Дополнительные аспекты, которые стоит выяснить на этапе сбора информации
- Интеграции — нужна ли интеграция с внешними системами (Slack, Jira, GitHub)? Это влияет на выбор API-архитектуры (REST, GraphQL, gRPC).
- Офлайн-режим — должен ли пользователь иметь возможность работать с доской без интернета? Это требует локального хранилища и механизма синхронизации при восстановлении соединения.
- Мобильные клиенты — будет ли мобильное приложение или достаточно responsive веб-версии? Влияет на API-дизайн (оптимизация объёма передаваемых данных).
- Хранение истории изменений — нужен ли undo/redo, история перемещений карточки, лог комментариев? Влияет на модель хранения данных (event sourcing vs текущее состояние).
- Экспорт данных — возможность экспорта доски в PDF, CSV, JSON. Влияет на необходимость сервисов генерации документов.
Вопрос 2. Как выглядит компонентная структура борд-приложения?
Таймкод: 00:06:14
Ответ собеседника: Правильный. Борд-приложение естественным образом содержит колонки, внутри каждой колонки находятся карточки. Помимо этого, страница доски обычно включает секцию пользователей, назначенных на доску, фильтры для карточек (если их много) и настройки доски (тип доски, права доступа). Компонентная структура может включать: компонент Board (доска), ColumnList (список колонок), отдельные Column (колонки) и Card (карточки) внутри каждой колонки. Это демонстрирует ясную и структурированную ментальную модель UI.
Правильный ответ:
Ответ собеседника верно описывает базовую компонентную иерархию. Дополнительно можно детализировать структуру, выделив контейнерные и презентационные компоненты, а также описать ответственность каждого уровня.
Иерархия компонентов верхнего уровня
Страница борд-приложения можно разбить на следующие крупные блоки:
- BoardLayout — корневой layout страницы, включающий навигацию, хедер с названием доски и основную область.
- BoardHeader — название доски, переключатель между досками, настройки доски, кнопка приглашения участников.
- BoardFilters — панель фильтрации (по участникам, меткам, дате, текстовый поиск).
- BoardCanvas — горизонтально прокручиваемая область со списком колонок.
- BoardSidebar — боковая панель с информацией о доске, списком участников, активностью.
Иерархия внутри BoardCanvas
BoardCanvas
└── ColumnList
├── Column
│ ├── ColumnHeader (название, счётчик карточек, меню колонки)
│ ├── CardList
│ │ ├── Card
│ │ │ ├── CardCover (цветная полоса или изображение)
│ │ │ ├── CardTitle
│ │ │ ├── CardBadges (метки, дата, чеклист, комментарии)
│ │ │ └── CardMembers (аватары назначенных)
│ │ ├── Card (...)
│ │ └── AddCardButton
│ └── ColumnFooter (кнопка "Добавить карточку")
├── Column (...)
└── AddColumnButton
Разделение на контейнерные и презентационные компоненты
Хорошей практикой является разделение логики и отображения:
Контейнерные компоненты (smart):
BoardContainer— загружает данные доски, управляет состоянием, обрабатывает WebSocket-события.ColumnContainer— управляет состоянием колонки, обрабатывает drag-and-drop операций.CardContainer— управляет состоянием карточки, открытием/закрытием детального вида.
Презентационные компоненты (dumb):
ColumnView— отрисовывает колонку, принимает callbacks для действий.CardView— отрисовывает карточку, принимает callbacks для перетаскивания и клика.CardBadge— отдельный компонент для бейджей (метки, даты, прогресс).
Модальные окна и оверлеи
Отдельную категорию составляют модальные компоненты:
CardDetailModal— детальный вид карточки с описанием, комментариями, чеклистом, прикреплёнными файлами.BoardSettingsModal— настройки доски (название, фон, права доступа, архивирование).InviteModal— приглашение участников по email или ссылке.ConfirmDialog— подтверждение удаления карточки, колонки, доски.
Компоненты для коллаборации
UserCursors— отображение курсоров других пользователей в реальном времени.UserPresence— индикатор онлайн-статуса участников.ActivityFeed— лента активности (кто, что и когда изменил).
Компоненты drag-and-drop
Для реализации перетаскивания карточек между колонками используются специализированные компоненты:
DragProvider— контекст, оборачивающий область с drag-and-drop.DraggableCard— обёртка карточки, делающая её перетаскиваемой.DroppableColumn— обёртка колонки, принимающая перетаскиваемые элементы.DragPreview— визуальный образ карточки при перетаскивании.
Типичные пропсы ключевых компонентов
Для понимания контрактов между компонентами:
// Презентационный компонент карточки
interface CardViewProps {
id: string;
title: string;
labels: Label[];
members: User[];
dueDate?: Date;
checklistProgress?: { done: number; total: number };
coverColor?: string;
onClick: (id: string) => void;
onDragStart: (id: string) => void;
}
// Контейнерный компонент колонки
interface ColumnContainerProps {
column: Column;
cards: Card[];
onCardMove: (cardId: string, targetColumnId: string, position: number) => void;
onCardAdd: (columnId: string, title: string) => void;
onColumnUpdate: (columnId: string, updates: Partial<Column>) => void;
}
Адаптивная структура
Для мобильных устройств компонентная структура может отличаться:
- Вместо горизонтального скролла колонок — вертикальный аккордеон или переключение между колонками.
- Вместо hover-действий — долгое нажатие (long press) для контекстного меню.
- Фиксированная нижняя панель с быстрыми действиями (добавить карточку, поиск).
Такая декомпозиция позволяет каждому компоненту иметь единственную ответственность, упрощает тестирование и переиспользование, а также делает код поддерживаемым по мере роста приложения.
Вопрос 3. Почему нормализация данных важна при проектировании модели данных для борд-приложения?
Таймкод: 00:07:33
Ответ собеседника: Правильный. Интуитивный подход — вложенная структура (board содержит columns, каждая column содержит cards, каждая card содержит данные пользователя). Однако у такого подхода есть серьёзная проблема: данные пользователя дублируются во многих карточках. Если пользователь меняет имя через отдельный API, обновление становится сложным и подверженным ошибкам — нужно найти и обновить все дубликаты. Если данные пользователя используются в других частях UI (например, топ-баре), проблема усугубляется. Нормализация решает эту проблему: данные хранятся по ссылкам через ID (board → columns → cards → users), пользователи живут в отдельном месте. При изменении имени обновляется только одно место, что обеспечивает единственный источник истины, упрощает обновления, делает вычисление производных данных проще и позволяет системе лучше масштабироваться.
Правильный ответ:
Ответ собеседника отлично описывает ключевую проблему денормализации и преимущества нормализации. Дополнительно можно раскрыть тему с точки зрения конкретных паттернов, компромиссов и практических примеров.
Проблемы денормализованного подхода
Когда данные пользователя встроены в каждую карточку, возникает класс проблем, известный как аномалии обновления:
- Аномалия обновления — пользователь изменил аватар, нужно обновить N карточек. При высокой конкурентности часть карточек может обновиться, а часть — нет, что приводит к непоследовательному состоянию.
- Аномалия удаления — если пользователь удалён из всех карточек, но забыт в одной, появляется висячая ссылка на несуществующего пользователя.
- Аномалия вставки — невозможно создать пользователя без хотя бы одной карточки, если данные пользователя встроены в карточку.
Нормализованная модель данных
Для борд-приложения нормализованная модель на уровне базы данных выглядит следующим образом:
CREATE TABLE boards (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
owner_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE columns (
id BIGSERIAL PRIMARY KEY,
board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
position INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE cards (
id BIGSERIAL PRIMARY KEY,
column_id BIGINT NOT NULL REFERENCES columns(id) ON DELETE COLUMN,
board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
position INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE card_members (
card_id BIGINT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (card_id, user_id)
);
CREATE TABLE card_labels (
card_id BIGINT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (card_id, label_id)
);
CREATE TABLE labels (
id BIGSERIAL PRIMARY KEY,
board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL -- hex цвет
);
Обратите внимание на связующую таблицу card_members — она реализует связь многие-ко-многим между карточками и пользователями, что является классическим паттерном нормализации.
Нормализация на клиенте (state management)
На клиенте нормализация состояния — это отдельный важный аспект. Библиотека normalizr (или аналогичные подходы в Redux Toolkit) превращает вложенный JSON в плоскую структуру:
// Денормализованный ответ API
{
"board": {
"id": 1,
"columns": [
{
"id": 10,
"cards": [
{ "id": 100, "title": "Task 1", "members": [{ "id": 1, "name": "Alice" }] },
{ "id": 101, "title": "Task 2", "members": [{ "id": 1, "name": "Alice" }] }
]
}
]
}
}
// После нормализации
{
entities: {
users: {
1: { id: 1, name: "Alice" }
},
cards: {
100: { id: 100, title: "Task 1", members: [1] },
101: { id: 101, title: "Task 2", members: [1] }
},
columns: {
10: { id: 10, cards: [100, 101] }
},
boards: {
1: { id: 1, columns: [10] }
}
},
result: { board: 1 }
}
Теперь при изменении имени пользователя с ID 1 обновляется ровно одна запись в entities.users[1], и все компоненты, которые ссылаются на этого пользователя, автоматически получают актуальные данные.
Когда денормализация оправдана
Полная нормализация не всегда является оптимальным решением. В некоторых случаях контролируемая денормализация улучшает производительность:
-
Кэширование имён пользователей в карточке — если карточка отображает имя автора и список участников, можно хранить
author_nameпрямо в таблицеcardsкак денормализованное поле, обновляемое через триггер или приложение при изменении имени. Это убирает JOIN при выборке карточек, но требует поддержки консистентности. -
Материализованные представления — для отображения доски с карточками, участниками и метками можно создать materialized view, которая агрегирует данные из нескольких таблиц и обновляется по расписанию или по событию.
-
Кэширование в Redis — часто запрашиваемые данные (например, состав доски с карточками) можно кэшировать в денормализованном виде и инвалидировать при изменениях.
Паттерн «единственный источник истины» в Go
На бэкенде в Go нормализация означает, что каждый доменный сервис владеет своими данными:
// Сервис пользователей — единственный источник истины о данных пользователя
type UserService struct {
repo UserRepository
}
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, name string) error {
return s.repo.UpdateName(ctx, userID, name)
}
// Сервис карточек хранит только user_id, а не имя пользователя
type CardService struct {
repo CardRepository
}
func (s *CardService) GetCardWithMembers(ctx context.Context, cardID int64) (*CardDetail, error) {
card, err := s.repo.GetByID(ctx, cardID)
if err != nil {
return nil, err
}
// Имена пользователей подтягиваются через JOIN или отдельный запрос
members, err := s.repo.GetMembers(ctx, cardID)
if err != nil {
return nil, err
}
return &CardDetail{Card: card, Members: members}, nil
}
Такой подход гарантирует, что логика обновления имени пользователя находится в одном месте, и нет риска забыть обновить дубликат в другом сервисе.
Итого
Нормализация данных — это фундаментальный принцип, который обеспечивает консистентность, упрощает поддержку и масштабирование. В борд-приложении она особенно важна, потому что одни и те же сущности (пользователи, метки) используются во множестве контекстах. Контролируемая денормализация допустима как оптимизация производительности, но только при наличии чёткого механизма поддержания консистентности.
Вопрос 4. Какой подход к проектированию API рекомендуется для борд-приложения и какие вопросы нужно учитывать?
Таймкод: 00:10:28
Ответ собеседника: Правильный. Для борд-приложения рекомендуется начать с RESTful API — он простой, широко поддерживается и легко интегрируется на фронтенде без сторонних библиотек. Альтернатива — GraphQL для большей гибкости при получении данных. Важные вопросы при проектировании API: сколько данных загружать за раз (пагинация — offset-based, cursor-based или инкрементальная загрузка по колонкам), так как пагинация влияет не только на UI, но и на API-дизайн, модель данных и обработку обновлений на клиенте. Также нужно продумать механизм распространения обновлений между пользователями: polling, server-sent events или WebSocket.
Правильный ответ:
Ответ собеседника корректно обозначает основные подходы и вопросы. Дополнительно можно детализировать архитектурные решения, паттерны и практические примеры для каждого аспекта.
Выбор между REST, GraphQL и gRPC
REST — оптимальный старт для большинства борд-приложений:
- Простота реализации и отладки.
- Нативная поддержка HTTP-кэширования (ETags, Cache-Control).
- Широкая экосистема инструментов (Swagger/OpenAPI, генерация клиентов).
Пример REST-маршрутов для борд-приложения:
GET /api/v1/boards — список досок пользователя
POST /api/v1/boards — создать доску
GET /api/v1/boards/{id} — получить доску с колонками
PATCH /api/v1/boards/{id} — обновить доску
DELETE /api/v1/boards/{id} — удалить доску
GET /api/v1/boards/{id}/columns — колонки доски
POST /api/v1/boards/{id}/columns — создать колонку
PATCH /api/v1/columns/{id} — обновить колонку
DELETE /api/v1/columns/{id} — удалить колонку
GET /api/v1/columns/{id}/cards — карточки колонки (с пагинацией)
POST /api/v1/columns/{id}/cards — создать карточку
PATCH /api/v1/cards/{id} — обновить карточку
DELETE /api/v1/cards/{id} — удалить карточку
POST /api/v1/cards/{id}/move — переместить карточку
GraphQL имеет смысл, когда:
- Клиенту нужны разные проекции данных в разных контекстах (виджет карточки в колонке vs модальное окно детального просмотра vs дашборд со статистикой).
- Есть множество клиентов (веб, мобильное приложение, интеграции) с разными требованиями к данным.
- Важно минимизировать количество запросов (один GraphQL-запрос может заменить несколько REST-вызовов).
Пример GraphQL-запроса:
query GetBoard($id: ID!) {
board(id: $id) {
id
title
columns {
id
title
cards(first: 50, after: $cursor) {
edges {
node {
id
title
members { id name avatarUrl }
labels { id name color }
dueDate
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
gRPC подходит для внутренней коммуникации между микросервисами (например, сервис уведомлений подписывается на события сервиса карточек через gRPC streaming).
Стратегии пагинации
Offset-based пагинация
Простая в реализации, но имеет проблемы с производительностью при больших смещениях и нестабильностью при параллельных вставках:
func (s *CardService) ListCards(ctx context.Context, columnID int64, page, pageSize int) (*CardList, error) {
offset := (page - 1) * pageSize
cards, err := s.repo.ListByColumn(ctx, columnID, pageSize, offset)
if err != nil {
return nil, err
}
total, err := s.repo.CountByColumn(ctx, columnID)
if err != nil {
return nil, err
}
return &CardList{
Cards: cards,
TotalCount: total,
Page: page,
PageSize: pageSize,
}, nil
}
Cursor-based пагинация
Стабильна при параллельных изменениях, лучше масштабируется. Используется идентификатор последнего элемента как курсор:
func (s *CardService) ListCardsCursor(ctx context.Context, columnID int64, cursor string, limit int) (*CardConnection, error) {
var afterID int64
if cursor != "" {
decoded, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
afterID = parseInt64(decoded)
}
cards, err := s.repo.ListAfterID(ctx, columnID, afterID, limit+1)
if err != nil {
return nil, err
}
hasNext := len(cards) > limit
if hasNext {
cards = cards[:limit]
}
var endCursor string
if len(cards) > 0 {
lastCard := cards[len(cards)-1]
endCursor = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", lastCard.ID)))
}
return &CardConnection{
Edges: cards,
PageInfo: PageInfo{
HasNextPage: hasNext,
EndCursor: endCursor,
},
}, nil
}
SQL-запрос для cursor-based пагинации:
SELECT id, title, description, position, created_at, updated_at
FROM cards
WHERE column_id = $1 AND id > $2
ORDER BY id ASC
LIMIT $3;
Инкрементальная загрузка по колонкам
Для борд-приложения часто оптимально загружать данные по колонкам — пользователь видит первые колонки сразу, остальные подгружаются по мере горизонтальной прокрутки:
GET /api/v1/boards/{id}/columns?limit=5&offset=0
GET /api/v1/columns/{id}/cards?limit=20
Механизмы распространения обновлений
Polling (Long Polling)
Самый простой подход — клиент периодически опрашивает сервер об изменениях:
func (s *BoardService) GetUpdates(ctx context.Context, boardID int64, since time.Time) ([]BoardEvent, error) {
return s.repo.GetEventsSince(ctx, boardID, since)
}
Недостатки: задержка равна интервалу опроса, избыточная нагрузка на сервер при частом опросе.
Server-Sent Events (SSE)
Однонаправленный канал от сервера к клиенту, работает поверх HTTP:
func (h *BoardHub) HandleSSE(w http.ResponseWriter, r *http.Request, boardID int64) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
ch := h.subscribe(boardID)
defer h.unsubscribe(boardID, ch)
for {
select {
case event := <-ch:
fmt.Fprintf(w, "data: %s\n\n", event)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
WebSocket
Двунаправленное соединение, подходит для коллаборации в реальном времени (курсоры, совместное редактирование):
func (h *BoardHub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &Client{hub: h, conn: conn, send: make(chan []byte, 256)}
h.register <- client
go client.writePump()
go client.readPump()
}
Гибридный подход
На практике часто используется комбинация:
- REST API для CRUD-операций.
- WebSocket для real-time обновлений и присутствия пользователей.
- SSE как fallback для уведомлений, если WebSocket недоступен.
Версионирование API
Важно заложить версионирование с самого начала:
- В URL:
/api/v1/boards— просто и прозрачно. - В заголовке:
Accept: application/vnd.board.v1+json— чище URL, но сложнее в отладке.
Идемпотентность и безопасность операций
Для операций, которые могут быть повторены (например, при потере соединения клиент повторяет запрос), важна идемпотентность:
// Идемпотентное перемещение карточки через уникальный ключ запроса
func (s *CardService) MoveCard(ctx context.Context, req MoveCardRequest) error {
// Проверяем, не была ли операция уже выполнена
if processed, err := s.idempotencyStore.IsProcessed(ctx, req.IdempotencyKey); err == nil && processed {
return nil // уже обработано, возвращаем успех
}
err := s.repo.MoveCard(ctx, req.CardID, req.TargetColumnID, req.Position)
if err != nil {
return err
}
// Помечаем операцию как обработанную
s.idempotencyStore.MarkProcessed(ctx, req.IdempotencyKey, 24*time.Hour)
return nil
}
Такой подход обеспечивает надёжную работу даже в условиях нестабильного соединения.
Вопрос 5. Какие стратегии оптимизации и кросс-функциональные требования следует учитывать при проектировании борд-приложения?
Таймкод: 00:12:16
Ответ собеседника: Правильный. Оптимизация включает не только производительность, но и нефункциональные требования. Производительность: воспринимаемая производительность (скелетоны, состояния загрузки), кэширование (CDN, HTTP-заголовки), избегание лишних ре-рендеров. Надёжность: обработка ошибок запросов, retry-механизмы, отображение состояния ошибки. Масштабируемость: поведение системы при росте числа досок, карточек и пользователей. Доступность: навигация с клавиатуры, поддержка screen reader, тестирование accessibility. Также важны: интернационализация, безопасность, поддержка офлайн-режима и синхронизации, а также вопросы сборки (тестирование, code splitting, tree shaking). Не нужно углубляться во все аспекты, но нужно показать осведомлённость и умение расставлять приоритеты.
Правильный ответ:
Ответ собеседника покрывает основные категории кросс-функциональных требований. Дополнительно можно раскрыть практические стратегии и паттерны реализации для каждого аспекта.
Производительность
Воспринимаемая производительность (perceived performance)
Скелетоны и оптимистичные обновления критически важны для борд-приложения, потому что пользователь ожидает мгновенной реакции при перетаскивании карточек:
// Оптимистичное обновление: клиент сразу отображает результат,
// а сервер обрабатывает запрос асинхронно
func (s *CardService) MoveCardOptimistic(ctx context.Context, cardID, targetColumnID int64, position int) (*Card, error) {
// 1. Возвращаем клиенту ожидаемое состояние сразу
card, err := s.repo.GetByID(ctx, cardID)
if err != nil {
return nil, err
}
card.ColumnID = targetColumnID
card.Position = position
// 2. Асинхронно выполняем фактическое перемещение
go func() {
bgCtx := context.Background()
if err := s.repo.MoveCard(bgCtx, cardID, targetColumnID, position); err != nil {
// Публикуем событие об ошибке — клиент откатит изменение
s.eventBus.Publish(bgCtx, CardMoveFailed{CardID: cardID, OriginalColumnID: card.ColumnID})
}
}()
return card, nil
}
Кэширование на нескольких уровнях
- CDN — статика (JS, CSS, изображения) отдаётся через CDN с долгим cache TTL.
- HTTP-кэширование — ETags и
Cache-Controlдля GET-запросов досок и карточек. - Redis — кэш часто запрашиваемых данных (состав доски, профили пользователей).
func (s *BoardService) GetBoard(ctx context.Context, boardID int64) (*Board, error) {
// Пробуем кэш
cacheKey := fmt.Sprintf("board:%d", boardID)
var board Board
if err := s.cache.Get(ctx, cacheKey, &board); err == nil {
return &board, nil
}
// Кэш пуст — идём в БД
boardPtr, err := s.repo.GetByID(ctx, boardID)
if err != nil {
return nil, err
}
// Сохраняем в кэш на 5 минут
s.cache.Set(ctx, cacheKey, boardPtr, 5*time.Minute)
return boardPtr, nil
}
Инвалидация кэша при изменениях:
func (s *BoardService) UpdateBoard(ctx context.Context, boardID int64, updates BoardUpdates) (*Board, error) {
board, err := s.repo.Update(ctx, boardID, updates)
if err != nil {
return nil, err
}
// Инвалидируем кэш
s.cache.Delete(ctx, fmt.Sprintf("board:%d", boardID))
return board, nil
}
Оптимистичная загрузка данных (prefetching)
При открытии доски можно предзагружать данные для соседних колонок, чтобы скролл был мгновенным:
// Загружаем первые 3 колонки полностью, остальные — только заголовки
func (s *BoardService) GetBoardWithPrefetch(ctx context.Context, boardID int64) (*BoardDetail, error) {
columns, err := s.columnRepo.ListByBoard(ctx, boardID)
if err != nil {
return nil, err
}
var wg sync.WaitGroup
var mu sync.Mutex
columnCards := make(map[int64][]Card)
// Загружаем карточки для первых 3 колонок параллельно
limit := 3
if len(columns) < limit {
limit = len(columns)
}
for i := 0; i < limit; i++ {
wg.Add(1)
go func(colID int64) {
defer wg.Done()
cards, _ := s.cardRepo.ListByColumn(ctx, colID, 50)
mu.Lock()
columnCards[colID] = cards
mu.Unlock()
}(columns[i].ID)
}
wg.Wait()
return &BoardDetail{Columns: columns, Cards: columnCards}, nil
}
Надёжность
Retry с exponential backoff
Для сетевых запросов, которые могут временно падать:
func WithRetry(ctx context.Context, maxRetries int, fn func() error) error {
var err error
for attempt := 0; attempt <= maxRetries; attempt++ {
if err = fn(); err == nil {
return nil
}
if !isRetryable(err) {
return err
}
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("after %d retries: %w", maxRetries, err)
}
Circuit Breaker
Защищает систему от каскадных отказов при недоступности зависимого сервиса:
type CircuitBreaker struct {
failures int
threshold int
lastFailure time.Time
timeout time.Duration
mu sync.Mutex
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
if cb.failures >= cb.threshold && time.Since(cb.lastFailure) < cb.timeout {
cb.mu.Unlock()
return ErrCircuitOpen
}
cb.mu.Unlock()
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
return err
}
cb.failures = 0
return nil
}
Грейсфул деградация
При падении real-time функциональности приложение продолжает работать в режиме long polling:
func (c *Client) Connect(boardID int64) {
wsConn, err := c.tryWebSocket(boardID)
if err != nil {
log.Warn("WebSocket unavailable, falling back to SSE")
sseConn, err := c.trySSE(boardID)
if err != nil {
log.Warn("SSE unavailable, falling back to polling")
c.startPolling(boardID)
}
}
}
Масштабируемость
Горизонтальное масштабирование WebSocket
При горизонтальном масштабировании WebSocket-соединения распределяются между несколькими инстансами. Для доставки сообщений всем подписчикам нужна шина сообщений:
// Каждый инстанс подписывается на Redis Pub/Sub для своей доски
type BoardHub struct {
redisSub *redis.PubSub
clients map[int64]map[*Client]bool
}
func (h *BoardHub) SubscribeToRedis(ctx context.Context, boardID int64) {
sub := h.redisClient.Subscribe(ctx, fmt.Sprintf("board:%d", boardID))
ch := sub.Channel()
for msg := range ch {
h.broadcastToLocalClients(boardID, []byte(msg.Payload))
}
}
Шардирование данных
При большом количестве досок можно шардировать по board_id:
-- Шардирование: данные доски всегда на одном шарде
-- Определяем шард как board_id % num_shards
Доступность (Accessibility)
- Все интерактивные элементы должны быть доступны с клавиатуры (Tab, Enter, Space, стрелки).
- Drag-and-drop должен иметь альтернативу через меню действий (переместить в колонку X).
- ARIA-атрибуты для динамически обновляющихся областей:
<div role="status" aria-live="polite">
Карточка "Задача" перемещена в колонку "В работе"
</div>
Безопасность
- CORS — строгая настройка разрешённых источников.
- Rate limiting — защита от DDoS и злоупотреблений:
func RateLimitMiddleware(limiter *rate.Limiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
- Валидация входных данных — санитизация содержимого карточек (XSS-защита).
- Авторизация — проверка прав доступа на уровне middleware:
func BoardAccessMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
boardID := extractBoardID(r)
userID := extractUserID(r)
if !authz.CanAccess(userID, boardID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Офлайн-режим и синхронизация
Для поддержки офлайн-работы на клиенте используется локальное хранилище (IndexedDB) и очередь операций:
// При офлайне операции сохраняются в очередь
class OfflineQueue {
async enqueue(operation) {
await db.operations.add({ ...operation, timestamp: Date.now() });
}
async flush() {
const operations = await db.operations.orderBy('timestamp').toArray();
for (const op of operations) {
await api.execute(op);
await db.operations.delete(op.id);
}
}
}
При восстановлении соединения очередь воспроизводится, а сервер разрешает конфликты через версионирование операций.
Расстановка приоритетов
На практике нельзя реализовать всё сразу. Приоритизация зависит от стадии продукта:
- MVP: базовый CRUD REST API, простой polling для обновлений, минимальная валидация.
- Рост: WebSocket для real-time, кэширование, retry-механизмы, базовая accessibility.
- Зрелость: офлайн-режим, circuit breaker, грейсфул деградация, полная i18n, шардирование.
Ключевое — показать, что кандидат понимает весь спектр требований и способен принимать обоснованные компромиссы между идеальным решением и ограничениями по времени и ресурсам.
