Frontend собеседование в Okko: System Design
Сегодня мы разберём второй этап собеседования на позицию фронтенд-разработчика, посвящённый проектированию архитектуры полноценного веб-приложения — агрегатора новостей с мобильной ориентацией. Кандидат совместно с интервьюером детально прорабатывает как клиентскую, так и серверную части системы: выбор стека (React/Next.js для SSR и SEO, Node.js на бэкенде, MongoDB как документоориентированная СУБД), организация хранения статики через S3-совместимое хранилище, кэширование с помощью CDN для геораспределённой аудитории СНГ, а также проектирование API, оптимизацию загрузки контента и реализацию ленивой подгрузки новостей. Особое внимание уделяется нефункциональным требованиям — производительности (метрики Web Vitals, FPS, время открытия страниц), безопасности (CORS, CSP, защита от XSS), масштабируемости, мониторингу ошибок и пользовательскому опыту, включая accessibility и A/B-тестирование через feature flags.
Вопрос 1. Есть ли у вас опыт проектирования систем (system design)?
Таймкод: 00:01:13
Ответ собеседника: Правильный. Опыт проектирования систем имеется.
Правильный ответ:
Опыт проектирования систем — это одна из ключевых компетенций для разработчика высокого уровня. Полный ответ на этот вопрос должен включать не просто подтверждение наличия опыта, а демонстрацию глубины и широты этого опыта.
Что ожидает услышать интервьюер:
1. Типы спроектированных систем
Следует описать конкретные системы: высоконагруженные сервисы, микросервисная архитектура, распределённые системы, системы обработки данных, API-шлюзы, системы очередей сообщений, CDN, системы кэширования, базы данных и так далее. Важно указать масштаб — количество пользователей, RPS, объём данных.
2. Архитектурные решения
Должны быть описаны принятые архитектурные решения и их обоснование. Например:
- Выбор между монолитом и микросервисами — почему был выбран тот или иной подход, какие компромиссы были приняты.
- Выбор СУБД — реляционная (PostgreSQL, MySQL) или NoSQL (MongoDB, Cassandra, Redis), и почему.
- Стратегия кэширования — Redis, Memcached, многоуровневое кэширование.
- Очереди сообщений — Kafka, RabbitMQ, NATS — выбор в зависимости от требований к гарантиям доставки, пропускной способности, порядку сообщений.
3. Масштабирование
Горизонтальное и вертикальное масштабирование, шардирование, репликация, партиционирование данных, балансировка нагрузки (L4/L7 балансировщики, service mesh).
4. Отказоустойчивость
- Circuit breaker pattern (например, через библиотеку
sony/gobreakerилиhystrix-go). - Retry с экспоненциальным бэкоффом.
- Graceful degradation.
- Мониторинг и алертинг (Prometheus, Grafana, OpenTelemetry).
5. Конкретный пример на Go
Вот пример проектирования простого rate limiter для API-шлюза — типичная задача при проектировании систем:
package ratelimit
import (
"context"
"fmt"
"sync"
"time"
)
// TokenBucket реализует алгоритм токен-бакета для rate limiting
type TokenBucket struct {
mu sync.Mutex
capacity int // максимальное количество токенов
tokens int // текущее количество токенов
refillRate int // количество токенов, добавляемых за интервал
refillInterval time.Duration // интервал пополнения
lastRefill time.Time // время последнего пополнения
}
func NewTokenBucket(capacity, refillRate int, refillInterval time.Duration) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
refillInterval: refillInterval,
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.refill()
if tb.tokens > 0 {
tb.tokens--
return true
}
return false
}
func (tb *TokenBucket) refill() {
now := time.Now()
elapsed := now.Sub(tb.lastRefill)
intervals := int(elapsed / tb.refillInterval)
if intervals > 0 {
tb.tokens += intervals * tb.refillRate
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity
}
tb.lastRefill = tb.lastRefill.Add(time.Duration(intervals) * tb.refillInterval)
}
}
// SlidingWindowRateLimiter — более продвинутая реализация на основе скользящего окна
type SlidingWindowRateLimiter struct {
mu sync.Mutex
window time.Duration
maxReq int
requests []time.Time
}
func NewSlidingWindowRateLimiter(window time.Duration, maxReq int) *SlidingWindowRateLimiter {
return &SlidingWindowRateLimiter{
window: window,
maxReq: maxReq,
requests: make([]time.Time, 0, maxReq),
}
}
func (sw *SlidingWindowRateLimiter) Allow(ctx context.Context) error {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
cutoff := now.Add(-sw.window)
// Удаляем устаревшие записи
idx := 0
for idx < len(sw.requests) && sw.requests[idx].Before(cutoff) {
idx++
}
sw.requests = sw.requests[idx:]
if len(sw.requests) >= sw.maxReq {
return fmt.Errorf("rate limit exceeded: %d requests in %v", sw.maxReq, sw.window)
}
sw.requests = append(sw.requests, now)
return nil
}
6. Распределённый rate limiter на Redis
В реальных системах rate limiter должен быть распределённым:
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type RedisRateLimiter struct {
client *redis.Client
window time.Duration
maxReq int
}
func NewRedisRateLimiter(client *redis.Client, window time.Duration, maxReq int) *RedisRateLimiter {
return &RedisRateLimiter{
client: client,
window: window,
maxReq: maxReq,
}
}
func (rl *RedisRateLimiter) Allow(ctx context.Context, key string) (bool, error) {
now := time.Now().UnixNano()
windowStart := now - rl.window.Nanoseconds()
pipe := rl.client.TxPipeline()
// Удаляем старые записи вне окна
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
// Считаем количество запросов в текущем окне
pipe.ZCard(ctx, key)
// Добавлятекущий запрос
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
// Устанавливаем TTL на ключ
pipe.Expire(ctx, key, rl.window)
cmds, err := pipe.Exec(ctx)
if err != nil {
return false, fmt.Errorf("redis pipeline error: %w", err)
}
count := cmds[1].(*redis.IntCmd).Val()
return count < int64(rl.maxReq), nil
}
7. Ключевые принципы проектирования, которые стоит упомянуть
- Separation of Concerns — разделение ответственности между сервисами.
- Single Responsibility Principle — каждый сервис решает одну задачу.
- CAP-теорема — понимание компромиссов между согласованностью, доступностью и устойчивостью к разделению.
- Event-driven архитектура — асинхронное взаимодействие через события.
- Observability — логирование, метрики, трейсинг.
- Graceful shutdown — корректное завершение работы сервиса.
Рекомендуемый формат ответа на интервью:
«Да, у меня есть опыт проектирования распределённых систем. В частности, я проектировал [тип системы] с нагрузкой до [RPS]/[пользователей], используя [технологии]. Ключевые решения: [архитектурные решения с обоснованием]. Например, для [конкретный компонент] я выбрал [решение], потому что [обоснование]. При проектировании я учитывал [масштабируемость / отказоустойчивость / наблюдаемость] и применял паттерны [circuit breaker / retry / graceful degradation].»
Такой ответ демонстрирует не просто наличие опыта, а системное мышление и способность принимать обоснованные инженерные решения.
Вопрос 2. Как рассчитывается суммарная оценка новости на карточке — это сумма лайков и дизлайков?
Таймкод: 00:02:33
Ответ собеседника: Правильный. Да, это суммарная оценка, которая может уходить в минус (лайки минус дизлайков), аналогично карме.
Правильный ответ:
Суммарная оценка (score) — это разница между количеством лайков и дизлайков, а не их сумма. Формула:
score = likes - dislikes
Это означает, что оценка может быть отрицательной, если дизлайков больше, чем лайков. Такой подход используется на Reddit, Stack Overflow и многих других платформах.
Почему именно разница, а не сумма:
Если бы использовалась сумма (likes + dislikes), то статья с 1000 лайками и 990 дизлайками имела бы высокий рейтинг 1990, что не отражает реальную картину — контент фактически спорный. Разница (likes - dislikes) даёт значение 10, что честнее отражает ситуацию.
Модель данных в Go:
package model
import "time"
type Article struct {
ID int64 `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
AuthorID int64 `json:"author_id" db:"author_id"`
Likes int64 `json:"likes" db:"likes"`
Dislikes int64 `json:"dislikes" db:"dislikes"`
Score int64 `json:"score" db:"score"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// RecalculateScore пересчитывает суммарную оценку
func (a *Article) RecalculateScore() {
a.Score = a.Likes - a.Dislikes
}
Схема базы данных:
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
author_id BIGINT NOT NULL REFERENCES users(id),
likes BIGINT NOT NULL DEFAULT 0,
dislikes BIGINT NOT NULL DEFAULT 0,
score BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Индекс для сортировки по рейтингу
CREATE INDEX idx_articles_score ON articles(score DESC, created_at DESC);
-- Триггер для автоматического пересчёта score
CREATE OR REPLACE FUNCTION update_article_score()
RETURNS TRIGGER AS $$
BEGIN
NEW.score := NEW.likes - NEW.dislikes;
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_article_score
BEFORE INSERT OR UPDATE OF likes, dislikes ON articles
FOR EACH ROW
EXECUTE FUNCTION update_article_score();
Атомарное обновление счётчиков в PostgreSQL:
-- Безопасное увеличение счётчика лайков (без гонки данных)
UPDATE articles
SET likes = likes + 1,
score = score + 1
WHERE id = $1
RETURNING score;
-- Безопасное увеличение счётчика дизлайков
UPDATE articles
SET dislikes = dislikes + 1,
score = score - 1
WHERE id = $1
RETURNING score;
Обработка голосования на уровне сервиса:
package service
import (
"context"
"database/sql"
"errors"
"fmt"
)
var (
ErrArticleNotFound = errors.New("article not found")
ErrAlreadyVoted = errors.New("user already voted")
ErrSelfVote = errors.New("cannot vote for own article")
)
type VoteType int
const (
VoteLike VoteType = 1
VoteDislike VoteType = -1
VoteRemove VoteType = 0
)
type ArticleRepository interface {
GetArticle(ctx context.Context, id int64) (*Article, error)
UpdateScore(ctx context.Context, articleID int64, delta int64) (int64, error)
GetUserVote(ctx context.Context, articleID, userID int64) (VoteType, error)
UpsertVote(ctx context.Context, articleID, userID int64, vote VoteType) error
}
type ArticleService struct {
repo ArticleRepository
}
func NewArticleService(repo ArticleRepository) *ArticleService {
return &ArticleService{repo: repo}
}
// Vote обрабатывает голосование пользователя с учётом идемпотентности
func (s *ArticleService) Vote(ctx context.Context, articleID, userID int64, vote VoteType) (int64, error) {
article, err := s.repo.GetArticle(ctx, articleID)
if err != nil {
return 0, fmt.Errorf("get article: %w", err)
}
if article.AuthorID == userID {
return 0, ErrSelfVote
}
existingVote, err := s.repo.GetUserVote(ctx, articleID, userID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("get user vote: %w", err)
}
var delta int64
switch {
// Пользователь убирает голос
case vote == VoteRemove:
if existingVote == VoteLike {
delta = -1
} else if existingVote == VoteDislike {
delta = 1
} else {
return article.Score, nil // голоса не было
}
if err := s.repo.UpsertVote(ctx, articleID, userID, VoteRemove); err != nil {
return 0, fmt.Errorf("remove vote: %w", err)
}
// Пользователь ставит лайк
case vote == VoteLike:
if existingVote == VoteLike {
return article.Score, nil // уже поставил лайк — идемпотентность
}
if existingVote == VoteDislike {
delta = 2 // убрать дизлайк (+1) и добавить лайк (+1)
} else {
delta = 1
}
if err := s.repo.UpsertVote(ctx, articleID, userID, VoteLike); err != nil {
return 0, fmt.Errorf("set like: %w", err)
}
// Пользователь ставит дизлайк
case vote == VoteDislike:
if existingVote == VoteDislike {
return article.Score, nil // уже поставил дизлайк
}
if existingVote == VoteLike {
delta = -2 // убрать лайк (-1) и добавить дизлайк (-1)
} else {
delta = -1
}
if err := s.repo.UpsertVote(ctx, articleID, userID, VoteDislike); err != nil {
return 0, fmt.Errorf("set dislike: %w", err)
}
}
newScore, err := s.repo.UpdateScore(ctx, articleID, delta)
if err != nil {
return 0, fmt.Errorf("update score: %w", err)
}
return newScore, nil
}
Таблица голосов пользователей:
CREATE TABLE article_votes (
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vote_type SMALLINT NOT NULL CHECK (vote_type IN (-1, 0, 1)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (article_id, user_id)
);
CREATE INDEX idx_article_votes_user ON article_votes(user_id);
Потенциальные проблемы и решения:
- Гонки данных при одновременном голосовании — решаются через атомарные
UPDATE ... SET likes = likes + 1в PostgreSQL или черезSELECT FOR UPDATEв транзакции. - Накрутка рейтинга — требуется rate limiting, проверка на ботов, ограничение на голоса с одного IP.
- Пересчёт score при миграции — если ранее поле
scoreвычислялось некорректно, нужен скрипт пересчёта:
UPDATE articles SET score = likes - dislikes;
Таким образом, суммарная оценка — это разница между лайками и дизлайками (likes - dislikes), что позволяет рейтингу уходить в отрицательные значения и корректно отражать общую реакцию аудитории.
Вопрос 3. Нужна ли SEO-оптимизация для приложения-агрегатора новостей?
Таймкод: 00:05:03
Ответ собеседника: Правильный. Да, SEO-оптимизация требуется.
Правильный ответ:
Для агрегатора новостей SEO-оптимизация — это критически важный компонент, поскольку основной источник трафика — органическая выдача поисковых систем. Без SEO приложение не сможет привлекать пользователей бесплатно и будет зависеть исключительно от платного привлечения.
Ключевые аспекты SEO-оптимизации для агрегатора новостей:
1. Серверный рендеринг (SSR) или предварительный рендеринг (Pre-rendering)
Поисковые роботы должны получать полностью отрендеренный HTML. Если приложение — SPA на React/Vue, поисковик увидит пустую страницу. Решения:
- Next.js / Nuxt.js — фреймворки с встроенным SSR.
- Prerender.io — сервис предварительного рендеринга для SPA.
- Отдельный Go-сервис для генерации статического HTML для ботов.
Пример middleware на Go для определения ботов и отдачи пререндеренного контента:
package middleware
import (
"net/http"
"regexp"
"strings"
)
var botPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)googlebot`),
regexp.MustCompile(`(?i)yandexbot`),
regexp.MustCompile(`(?i)bingbot`),
regexp.MustCompile(`(?i)slurp`),
regexp.MustCompile(`(?i)duckduckbot`),
regexp.MustCompile(?i)baiduspider`),
}
func isBot(userAgent string) bool {
for _, pattern := range botPatterns {
if pattern.MatchString(userAgent) {
return true
}
}
return false
}
func SEOPrerenderMiddleware(prerenderURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isBot(r.UserAgent()) && !strings.HasPrefix(r.URL.Path, "/api/") {
// Отдаём пререндеренный HTML для ботов
prerenderReq, _ := http.NewRequest("GET", prerenderURL+r.URL.String(), nil)
prerenderReq.Header.Set("User-Agent", r.UserAgent())
resp, err := http.DefaultClient.Do(prerenderReq)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
for key, values := range resp.Header {
for _, v := range values {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
return
}
}
next.ServeHTTP(w, r)
})
}
}
2. Семантическая разметка (Semantic HTML)
Каждая новость должна содержать структурированные данные по стандарту Schema.org:
<article itemscope itemtype="https://schema.org/NewsArticle">
<h1 itemprop="headline">Заголовок новости</h1>
<time itemprop="datePublished" datetime="2024-01-15T10:30:00Z">
15 января 2024, 10:30
</time>
<span itemprop="author" itemscope itemtype="https://schema.org/Person">
<span itemprop="name">Имя автора</span>
</span>
<div itemprop="articleBody">
Содержание новости...
</div>
<meta itemprop="image" content="https://example.com/image.jpg">
</article>
3. Мета-теги и Open Graph
Генерация динамических мета-тегов для каждой страницы:
package seo
type MetaTags struct {
Title string
Description string
Keywords string
Author string
Image string
URL string
Type string
PublishedAt string
ModifiedAt string
}
func (m MetaTags) Render() map[string]string {
return map[string]string{
"title": m.Title,
"description": m.Description,
"keywords": m.Keywords,
"author": m.Author,
"og:title": m.Title,
"og:description": m.Description,
"og:image": m.Image,
"og:url": m.URL,
"og:type": m.Type,
"og:site_name": "News Aggregator",
"article:published_time": m.PublishedAt,
"article:modified_time": m.ModifiedAt,
"twitter:card": "summary_large_image",
"twitter:title": m.Title,
"twitter:description": m.Description,
"twitter:image": m.Image,
}
}
4. Sitemap.xml и robots.txt
Динамическая генерация sitemap для индексации тысяч новостей:
package seo
import (
"encoding/xml"
"fmt"
"net/http"
"time"
)
type URLSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []URLLoc `xml:"url"`
}
type URLLoc struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
ChangeFreq string `xml:"changefreq,omitempty"`
Priority string `xml:"priority,omitempty"`
}
type SitemapService struct {
baseURL string
repo ArticleRepository
}
func (s *SitemapService) GenerateSitemap(w http.ResponseWriter, r *http.Request) {
articles, _ := s.repo.GetRecentArticles(r.Context(), 50000)
urls := make([]URLLoc, 0, len(articles)+1)
urls = append(urls, URLLoc{
Loc: s.baseURL,
ChangeFreq: "always",
Priority: "1.0",
})
for _, a := range articles {
urls = append(urls, URLLoc{
Loc: fmt.Sprintf("%s/news/%d", s.baseURL, a.ID),
LastMod: a.UpdatedAt.Format(time.RFC3339),
ChangeFreq: "daily",
Priority: "0.8",
})
}
urlSet := URLSet{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
xml.NewEncoder(w).Encode(urlSet)
}
// robots.txt
func RobotsHandler(baseURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
content := fmt.Sprintf(`User-agent: *
Allow: /
Disallow: /api/
Disallow: /admin/
Disallow: /search?
Sitemap: %s/sitemap.xml
`, baseURL)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(content))
}
}
5. Канонические URL и дедупликация
Агрегатор собирает новости из разных источников, поэтому важно избежать дублей:
type CanonicalURLService struct{}
// ResolveCanonical определяет канонический URL для новости,
// отдавая приоритет первоисточнику
func (s *CanonicalURLService) ResolveCanonical(articles []Article) []CanonicalArticle {
seen := make(map[string]CanonicalArticle)
for _, article := range articles {
// Нормализуем URL для группировки
normalized := normalizeURL(article.SourceURL)
existing, found := seen[normalized]
if !found || article.IsOriginal {
seen[normalized] = CanonicalArticle{
Article: article,
CanonicalURL: article.SourceURL,
Duplicates: []int64{},
}
} else {
existing.Duplicates = append(existing.Duplicates, article.ID)
seen[normalized] = existing
}
}
result := make([]CanonicalArticle, 0, len(seen))
for _, v := range seen {
result = append(result, v)
}
return result
}
6. Производительность и Core Web Vitals
Google учитывает скорость загрузки в ранжировании:
- LCP (Largest Contentful Paint) < 2.5 сек — оптимизация изображений, lazy loading.
- FID (First Input Delay) < 100 мс — минификация JS, code splitting.
- CLS (Cumulative Layout Shift) < 0.1 — заранее резервировать место для изображений и рекламы.
7. ЧПУ (человеко-понятные URL)
Плохо: /news?id=12345&source=ria
Хорошо: /news/2024/01/15/rossiya-dostigla-rekorda
Генерация slug в Go:
import (
"github.com/gosimple/slug"
"strings"
)
func GenerateSlug(title string, date time.Time, id int64) string {
s := slug.Make(title)
datePrefix := date.Format("2006/01/02")
return fmt.Sprintf("%s/%s-%d", datePrefix, s, id)
}
Итого: SEO для агрегатора новостей включает SSR/пререндеринг, структурированные данные Schema.org, динамические мета-теги, sitemap.xml, robots.txt, канонические URL, оптимизацию Core Web Vitals и ЧПУ. Без этих мер агрегатор не сможет конкурировать за органический трафик в поисковой выдаче.
Вопрос 4. Какие метрики скорости загрузки страницы являются приоритетными?
Таймкод: 00:05:20
Ответ собеседника: Правильный. Приоритетна быстрая загрузка первой страницы (Time to First Paint), а Time to Interactive (TTI) можно пожертвовать ради SEO.
Правильный ответ:
Для агрегатора новостей приоритетными являются метрики, которые непосредственно влияют на SEO-ранжирование и пользовательский опыт. Google использует Core Web Vitals как фактор ранжирования, поэтому оптимизация этих метрик — не опциональное улучшение, а бизнес-необходимость.
Core Web Vitals — три ключевые метрики Google:
1. LCP (Largest Contentful Paint) — целевое значение ≤ 2.5 сек
Измеряет время загрузки самого крупного видимого элемента (обычно это главное изображение или заголовок). Для новостной страницы это обычно hero-изображение статьи.
Способы оптимизации:
- Использование форматов WebP/AVIF для изображений.
- Предзагрузка критических ресурсов через
<link rel="preload">. - CDN для статики.
- Серверный рендеринг HTML без ожидания JavaScript.
<!-- Предзагрузка главного изображения -->
<link rel="preload" as="image" href="/images/hero.webp" type="image/webp">
<!-- Адаптивные изображения -->
<picture>
<source srcset="/images/hero.avif" type="image/avif">
<source srcset="/images/hero.webp" type="image/webp">
<img src="/images/hero.jpg" alt="Заголовок новости" loading="eager" width="1200" height="630">
</picture>
2. FID (First Input Delay) / INP (Interaction to Next Paint) — целевое значение ≤ 100 мс (FID) / ≤ 200 мс (INP)
FID измеряет время от первого взаимодействия пользователя (клик, тап) до момента, когда браузер начинает обработку события. С марта 2024 года Google заменил FID на INP — метрику, которая учитывает все взаимодействия на странице, а не только первое.
Способы оптимизации:
- Разбиение длинных задач (long tasks > 50 мс) на мелкие через
requestIdleCallbackилиsetTimeout. - Code splitting — загрузка только необходимого JavaScript.
- Отложенная загрузка некритичных скриптов (аналитика, реклама, виджеты).
// Go-сервер может внедрять async/defer для скриптов динамически
type ScriptLoader struct {
CriticalScripts []string
DeferredScripts []string
}
func (sl *ScriptLoader) Render() string {
var sb strings.Builder
// Критичные скрипты — загружаются сразу
for _, src := range sl.CriticalScripts {
sb.WriteString(fmt.Sprintf(`<script src="%s"></script>`, src))
}
// Некритичные — с defer
for _, src := range sl.DeferredScripts {
sb.WriteString(fmt.Sprintf(`<script src="%s" defer></script>`, src))
}
return sb.String()
}
3. CLS (Cumulative Layout Shift) — целевое значение ≤ 0.1
Измеряет визуальную стабильность — насколько элементы страницы «прыгают» во время загрузки. Для новостного сайта это особенно критично, так как рекламные блоки, изображения и виджеты голосования часто вызывают сдвиги.
Способы оптимизации:
- Зарезервировать размеры для изображений и рекламных блоков.
- Не вставлять контент над существующим (кроме уведомлений по действию пользователя).
- Предзагрузка шрифтов и использование
font-display: swap.
/* Резервирование места для изображений */
.article-image {
aspect-ratio: 16 / 9;
background-color: #f0f0f0;
width: 100%;
height: auto;
}
/* Резервирование места для рекламного блока */
.ad-banner {
min-height: 250px;
width: 100%;
}
/* Предзагрузка шрифта */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
Дополнительные важные метрики:
4. TTFB (Time to First Byte) — целевое значение ≤ 800 мс
Время от запроса до первого байта ответа сервера. Для агрегатора новостей это критично, так как страницы генерируются динамически.
// Middleware для мониторинга TTFB
func TTFBMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
ttfb := time.Since(start)
if ttfb > 800*time.Millisecond {
log.Warn().
Str("path", r.URL.Path).
Dur("ttfb", ttfb).
Msg("TTFB exceeds threshold")
}
// Отправка метрики в Prometheus
ttfbHistogram.WithLabelValues(r.URL.Path).Observe(ttfb.Seconds())
})
}
5. FCP (First Contentful Paint) — целевое значение ≤ 1.8 сек
Время до отображения первого элемента контента (текст, изображение). Тесно связан с LCP, но измеряет именно первый элемент, а не самый крупный.
6. TBT (Total Blocking Time)
Суммарное время, когда основной поток был заблокирован задачами дольше 50 мс. Влияет на FID/INP. Целевое значение — ≤ 200 мс.
Приоритизация метрик для агрегатора новостей:
| Метрика | Приоритет | Целевое значение | Почему важно |
|---|---|---|---|
| LCP | Высший | ≤ 2.5 сек | Фактор ранжирования Google, пользователь видит контент |
| CLS | Высший | ≤ 0.1 | Фактор ранжирования, «прыгающая» страница раздражает |
| INP | Высокий | ≤ 200 мс | Заменил FID в Core Web Vitals, влияет на UX |
| TTFB | Высокий | ≤ 800 мс | Базовая производительность сервера |
| FCP | Средний | ≤ 1.8 сек | Пользователь быстро видит, что страница загружается |
| TTI | Средний | ≤ 3.8 сек | Менее критично для новостей (контент читают, а не взаимодействуют) |
Инструменты измерения:
- Lighthouse — аудит в Chrome DevTools.
- PageSpeed Insights — онлайн-проверка с реальными данными от Google.
- Chrome User Experience Report (CrUX) — реальные данные от пользователей Chrome.
- Web Vitals JS — библиотека для сбора метрик в production:
import {onLCP, onINP, onCLS} from 'web-vitals';
function sendToAnalytics(metric) {
navigator.sendBeacon('/api/metrics', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
url: window.location.href,
}));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Итого: для агрегатора новостей приоритетны LCP, CLS и INP как Core Web Vitals, плюс TTFB как базовая метрика серверной производительности. TTI действительно можно считать второстепенным, поскольку пользователи новостей в первую очередь потребляют контент, а не взаимодействуют с интерактивными элементами.
Вопрос 5. Достаточно ли проектировать только фронтенд или нужно детально прорабатывать и бэкенд?
Таймкод: 00:06:16
Ответ собеседника: Правильный. Необходимо рассмотреть и фронтенд, и бэкенд, а также общее взаимодействие приложения в целом.
Правильный ответ:
Полноценное проектирование системы требует детальной проработки всех слоёв — от фронтенда до бэкенда, базы данных, инфраструктуры и их взаимодействия. Проектирование только одного слоя неизбежно приводит к проблемам, поскольку архитектурные решения на одном уровне определяют ограничения и возможности на другом.
Почему нельзя проектировать только фронтенд:
- API-контракт определяет, какие данные и в каком формате получит фронтенд. Без проектирования бэкенда невозможно определить эндпоинты, структуру ответов, стратегию пагинации, подход к кэшированию.
- Производительность фронтенда напрямую зависит от скорости ответа бэкенда (TTFB, объём передаваемых данных, количество запросов).
- SEO требует серверного рендеринга, что архитектурное решение бэкенда.
- Безопасность — авторизация, rate limiting, валидация данных — реализуются на бэкенде.
Компоненты полного проектирования:
1. Фронтенд-архитектура
- Выбор фреймворка (Next.js для SSR, React/Vue для SPA).
- Стейт-менеджмент (Redux, Zustand, Pinia).
- Стратегия рендеринга: SSR для поисковых роботов, CSR для пользовательского интерфейса.
- Оптимизация: code splitting, lazy loading, оптимизация изображений.
2. API-слой (Gateway)
- REST vs GraphQL vs gRPC.
- Версионирование API.
- Rate limiting и аутентификация.
- API Gateway как единая точка входа.
Пример проектирования API Gateway на Go:
package gateway
import (
"context"
"net/http"
"net/http/httputil"
"strings"
"time"
)
type ServiceConfig struct {
Name string
BaseURL string
Prefix string
Timeout time.Duration
RateLimit int
}
type Gateway struct {
services map[string]*httputil.ReverseProxy
config []ServiceConfig
rateLimiter *RateLimiter
}
func NewGateway(config []ServiceConfig) *Gateway {
gw := &Gateway{
services: make(map[string]*httputil.ReverseProxy),
config: config,
rateLimiter: NewRateLimiter(100, time.Minute),
}
for _, svc := range config {
proxy := httputil.NewSingleHostReverseProxy(mustParseURL(svc.BaseURL))
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, `{"error": "service unavailable"}`, http.StatusBadGateway)
}
gw.services[svc.Name] = proxy
}
return gw
}
func (gw *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Rate limiting
clientIP := getClientIP(r)
if !gw.rateLimiter.Allow(clientIP) {
http.Error(w, `{"error": "rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
// Маршрутизация по префиксу пути
for _, svc := range gw.config {
if strings.HasPrefix(r.URL.Path, svc.prefix) {
ctx, cancel := context.WithTimeout(r.Context(), svc.timeout)
defer cancel()
gw.services[svc.name].ServeHTTP(w, r.WithContext(ctx))
return
}
}
http.NotFound(w, r)
}
3. Микросервисы бэкенда
Разделение ответственности:
- News Service — управление новостями, CRUD, поиск.
- Crawler/Parser Service — сбор новостей из источников.
- Rating Service — подсчёт рейтингов, лайки/дизлайки.
- User Service — аутентификация, профили, подписки.
- Notification Service — уведомления о новых новостях.
- Analytics Service — сбор метрик, статистика просмотров.
4. База данных и хранение
Выбор СУБД и стратегия хранения:
// Стратегия выбора хранилища в зависимости от типа данных
type StorageStrategy struct{}
func (s *StorageStrategy) GetStore(dataType string) string {
switch dataType {
case "articles", "users", "votes":
return "postgresql" // Структурированные данные, транзакции
case "sessions", "rate_limits":
return "redis" // Быстрый доступ, TTL
case "search_index":
return "elasticsearch" // Полнотекстовый поиск
case "analytics", "clickstream":
return "clickhouse" // Аналитика, колоночное хранение
case "images", "static":
return "s3" // Объектное хранение
default:
return "postgresql"
}
}
5. Очереди сообщений и асинхронная обработка
package crawler
import (
"context"
"encoding/json"
"fmt"
"github.com/segmentio/kafka-go"
)
type CrawlerService struct {
kafkaWriter *kafka.Writer
parser *NewsParser
repo ArticleRepository
}
func (c *CrawlerService) PublishRawNews(ctx context.Context, sourceURL string, rawHTML []byte) error {
msg := RawNewsMessage{
SourceURL: sourceURL,
RawHTML: string(rawHTML),
Timestamp: time.Now(),
}
data, _ := json.Marshal(msg)
return c.kafkaWriter.WriteMessages(ctx, kafka.Message{
Key: []byte(sourceURL),
Value: data,
Topic: "raw-news",
})
}
// Consumer обрабатывает сырые новости
type NewsProcessor struct {
kafkaReader *kafka.Reader
parser *NewsParser
repo ArticleRepository
}
func (np *NewsProcessor) Start(ctx context.Context) error {
for {
msg, err := np.kafkaReader.ReadMessage(ctx)
if err != nil {
return fmt.Errorf("read message: %w", err)
}
var raw RawNewsMessage
if err := json.Unmarshal(msg.Value, &raw); err != nil {
log.Error().Err(err).Msg("failed to unmarshal raw news")
continue
}
article, err := np.parser.Parse(raw.SourceURL, []byte(raw.RawHTML))
if err != nil {
log.Error().Err(err).Str("url", raw.SourceURL).Msg("parse failed")
continue
}
if err := np.repo.SaveArticle(ctx, article); err != nil {
log.Error().Err(err).Msg("save article failed")
continue
}
}
}
6. Кэширование на всех уровнях
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type MultiLayerCache struct {
local *ristretto.Cache // L1 — локальный кэш в памяти процесса
redis *redis.Client // L2 — распределённый кэш
repo ArticleRepository // L3 — база данных
}
func (c *MultiLayerCache) GetArticle(ctx context.Context, id int64) (*Article, error) {
cacheKey := fmt.Sprintf("article:%d", id)
// L1: Локальный кэш (микросекунды)
if val, found := c.local.Get(cacheKey); found {
return val.(*Article), nil
}
// L2: Redis (миллисекунды)
data, err := c.redis.Get(ctx, cacheKey).Bytes()
if err == nil {
var article Article
if err := json.Unmarshal(data, &article); err == nil {
c.local.Set(cacheKey, &article, 1)
return &article, nil
}
}
// L3: База данных
article, err := c.repo.GetArticle(ctx, id)
if err != nil {
return nil, err
}
// Заполняем кэши
data, _ = json.Marshal(article)
c.redis.Set(ctx, cacheKey, data, 5*time.Minute)
c.local.Set(cacheKey, article, 1)
return article, nil
}
7. Инфраструктура и деплой
- Docker контейнеризация каждого сервиса.
- Kubernetes для оркестрации.
- CI/CD — GitHub Actions, GitLab CI.
- Мониторинг — Prometheus + Grafana.
- Логирование — ELK stack или Loki.
- Трейсинг — Jaeger или Zipkin.
Диаграмма взаимодействия:
[Браузер] → [CDN] → [API Gateway (Go)]
↓
┌──────────────┼──────────────┐
↓ ↓ ↓
[News Service] [User Service] [Rating Service]
↓ ↓ ↓
[PostgreSQL] [PostgreSQL] [Redis]
↓
[Elasticsearch] ← [Crawler Service]
↓
[Kafka] → [Analytics Service] → [ClickHouse]
Итого: проектирование только фронтенда или только бэкенда — это неполное решение. Для агрегатора новостей необходимо спроектировать все слои: фронтенд (SSR/CSR), API Gateway, микросервисы, базы данных, кэширование, очереди сообщений, мониторинг и инфраструктуру. Архитектурные решения на каждом уровне влияют на все остальные, поэтому только комплексный подход обеспечивает работоспособную, масштабируемую и поддерживаемую систему.
Вопрос 6. Нужна ли локализация приложения-агрегатора новостей?
Таймкод: 00:06:46
Ответ собеседника: Правильный. Локализация не требуется, приложение ориентировано на СНГ.
Правильный ответ:
Если приложение агрегатора новостей ориентировано на аудиторию СНГ, то полноценная локализация (i18n) с поддержкой множества языков действительно не является приоритетом. Однако даже в этом контексте стоит учитывать несколько нюансов.
Почему локализация не критична для СНГ-проекта:
- Основная аудитория СНГ свободно читает на русском языке, который является лингва франка региона.
- Рынок русскоязычных новостей достаточно велик для монетизации без расширения на другие языки.
- Локализация — это значительные затраты на перевод, поддержку и тестирование интерфейса.
Что всё же стоит предусмотреть:
1. Многоязычность контента
Агрегатор может собирать новости из источников на разных языках СНГ (русский, украинский, казахский, узбекский). Даже если интерфейс на русском, контент может быть на нескольких языках. Необходимо определять язык статьи и корректно его отображать:
package lang
import (
"github.com/pemistahl/lingua-go"
)
type LanguageDetector struct {
detector lingua.LanguageDetector
}
func NewLanguageDetector() *LanguageDetector {
languages := []lingua.Language{
lingua.Russian,
lingua.Ukrainian,
lingua.Kazakh,
lingua.Uzbek,
lingua.English,
}
return &LanguageDetector{
detector: lingua.NewLanguageDetectorBuilder().
FromLanguages(languages...).
Build(),
}
}
func (ld *LanguageDetector) Detect(text string) (string, float64) {
language, exists := ld.detector.DetectLanguageOf(text)
if !exists {
return "unknown", 0
}
return language.IsoCode639_1().String(), ld.detector.ComputeLanguageConfidence(text, language)
}
func (ld *LanguageDetector) DetectWithFallback(text string) string {
lang, confidence := ld.Detect(text)
if confidence < 0.5 {
return "ru" // fallback на русский
}
return lang
}
2. Правильная обработка дат и чисел
Даже в рамках СНГ существуют различия в форматах дат:
package i18n
import (
"fmt"
"time"
)
type LocaleFormatter struct {
locale string
}
func NewLocaleFormatter(locale string) *LocaleFormatter {
return &LocaleFormatter{locale: locale}
}
func (f *LocaleFormatter) FormatDate(t time.Time) string {
switch f.locale {
case "uk":
// Украинский: 15 січня 2024
months := []string{
"", "січня", "лютого", "березня", "квітня", "травня", "червня",
"липня", "серпня", "вересня", "жовтня", "листопада", "грудня",
}
return fmt.Sprintf("%d %s %d", t.Day(), months[t.Month()], t.Year())
case "kk":
// Казахский: 2024 жылға 15 қаңтар
months := []string{
"", "қаңтар", "ақпан", "наурыз", "сәуір", "мамыр", "маусым",
"шілде", "тамыз", "қыркүйек", "қазан", "қараша", "желтоқсан",
}
return fmt.Sprintf("%d жылға %d %s", t.Year(), t.Day(), months[t.Month()])
default:
// Русский: 15 января 2024
months := []string{
"", "января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
}
return fmt.Sprintf("%d %s %d", t.Day(), months[t.Month()], t.Year())
}
}
func (f *LocaleFormatter) FormatNumber(n int64) string {
// Русский формат: пробел как разделитель тысяч
// 1 234 567
s := fmt.Sprintf("%d", n)
if n < 0 {
return "-" + f.FormatNumber(-n)
}
if n < 1000 {
return s
}
var parts []string
for len(s) > 3 {
parts = append([]string{s[len(s)-3:]}, parts...)
s = s[:len(s)-3]
}
parts = append([]string{s}, parts...)
result := parts[0]
for i := 1; i < len(parts); i++ {
result += " " + parts[i]
}
return result
}
3. Поддержка Unicode и кодировок
Все сервисы должны корректно работать с UTF-8:
-- При создании базы данных указываем правильную кодировку
CREATE DATABASE news_aggregator
WITH ENCODING = 'UTF8'
LC_COLLATE = 'ru_RU.UTF-8'
LC_CTYPE = 'ru_RU.UTF-8';
4. Архитектурный задел на будущее
Даже если локализация не нужна сейчас, стоит заложить возможность её добавления в будущем:
package i18n
type Translator struct {
translations map[string]map[string]string // locale -> key -> translation
fallback string
}
func NewTranslator(fallback string) *Translator {
return &Translator{
translations: make(map[string]map[string]string),
fallback: fallback,
}
}
func (t *Translator) Load(locale string, translations map[string]string) {
t.translations[locale] = translations
}
func (t *Translator) T(locale, key string) string {
if translations, ok := t.translations[locale]; ok {
if val, ok := translations[key]; ok {
return val
}
}
// Fallback
if translations, ok := t.translations[t.fallback]; ok {
if val, ok := translations[key]; ok {
return val
}
}
return key
}
Итого: для агрегатора новостей, ориентированного на СНГ, полноценная локализация интерфейса на десятки языков не требуется. Русский язык покрывает основную аудиторию. Однако стоит предусмотреть корректную обработку дат, чисел, определение языка контента и архитектурный задел для возможного расширения в будущем.
Вопрос 7. Нужна ли геораспределённость сервисов по СНГ или достаточно одного региона?
Таймкод: 00:07:21
Ответ собеседника: Правильный. Геораспределённость требуется, необходимо предусмотреть возможность масштабирования с учётом географии.
Правильный ответ:
Для агрегатора новостей, ориентированного на аудиторию СНГ, геораспределённость — это архитектурное решение, которое необходимо заложить на этапе проектирования, даже если на старте все сервисы работают в одном регионе. Территория СНГ охватывает 11 часовых поясов — от Калининграда (UTC+2) до Камчатки (UTC+12), что создаёт значительные задержки при обращении к единому дата-центру.
Аргументы в пользу геораспределённости:
1. Задержки (Latency)
Пользователь из Владивостока, обращающийся к серверу в Москве, получает задержку порядка 100–150 мс только на сетевом уровне. При множественных API-запросах для формирования страницы это складывается в ощутимое замедление. LCP напрямую зависит от TTFB, а TTFB — от физического расстояния.
| Откуда | Куда | Примерная задержка |
|---|---|---|
| Москва | Москва | 5–15 мс |
| Алматы | Москва | 40–70 мс |
| Владивосток | Москва | 100–150 мс |
| Ташкент | Москва | 60–90 мс |
2. Нагрузка и отказоустойчивость
Геораспределённость позволяет распределять нагрузку и обеспечивать отказоустойчивость — при выходе из строя одного региона трафик перенаправляется в другой.
3. Юридические требования
Некоторые страны СНГ (Казахстан, Узбекистан) имеют законодательные требования о хранении данных пользователей на территории страны.
Архитектура геораспределённой системы:
1. Multi-region deployment
┌─────────────────────────────────────────────────────────┐
│ Global DNS (Route 53 / Cloudflare) │
│ Geo-based routing │
└──────────┬──────────────────┬──────────────────┬────────┘
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Region: EU │ │ Region: KZ │ │ Region: RU │
│ (Frankfurt) │ │ (Almaty) │ │ (Vladivostok)│
│ │ │ │ │ │
│ API Gateway │ │ API Gateway │ │ API Gateway │
│ News Service │ │ News Service │ │ News Service │
│ Local Cache │ │ Local Cache │ │ Local Cache │
│ Read Replica │ │ Read Replica │ │ Read Replica │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
↓ ↓ ↓
└──────────────────────────────────────────────────┐
│ Primary DB (Moscow) + Async Replicas │
│ Kafka Cluster (cross-region) │
│ Object Storage (S3 + CDN) │
└──────────────────────────────────────────────────┘
2. Geo-based routing через DNS
package geo
import (
"net"
"net/http"
"github.com/oschwald/geoip2-golang"
)
type GeoRouter struct {
db *geoip2.Reader
endpoints map[string]string // region -> endpoint URL
}
func NewGeoRouter(dbPath string) (*GeoRouter, error) {
db, err := geoip2.Open(dbPath)
if err != nil {
return nil, err
}
return &GeoRouter{
db: db,
endpoints: map[string]string{
"EU": "https://eu.news-aggregator.com",
"KZ": "https://kz.news-aggregator.com",
"RU-EU": "https://ru.news-aggregator.com",
"RU-ASIA": "https://asia.news-aggregator.com",
},
}, nil
}
func (gr *GeoRouter) ResolveEndpoint(clientIP string) string {
ip := net.ParseIP(clientIP)
if ip == nil {
return gr.endpoints["EU"] // fallback
}
record, err := gr.db.Country(ip)
if err != nil {
return gr.endpoints["EU"]
}
switch record.Country.IsoCode {
case "KZ", "UZ", "KG", "TJ", "TM":
return gr.endpoints["KZ"]
case "RU":
// Для России определяем по городу — европейская или азиатская часть
cityRecord, err := gr.db.City(ip)
if err == nil && cityRecord.Location.Longitude > 60.0 {
return gr.endpoints["RU-ASIA"]
}
return gr.endpoints["RU-EU"]
case "UA", "BY", "MD":
return gr.endpoints["EU"]
default:
return gr.endpoints["EU"]
}
}
3. Репликация данных между регионами
package replication
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/segmentio/kafka-go"
)
// Event представляет событие изменения данных
type Event struct {
ID string `json:"id"`
Type string `json:"type"` // "article_created", "article_updated", "vote_changed"
Region string `json:"region"`
Payload []byte `json:"payload"`
Timestamp time.Time `json:"timestamp"`
}
// CrossRegionReplicator синхронизирует данные между регионами через Kafka
type CrossRegionReplicator struct {
producer *kafka.Writer
region string
}
func NewCrossRegionReplicator(brokers []string, region string) *CrossRegionReplicator {
return &CrossRegionReplicator{
producer: &kafka.Writer{
Addr: kafka.TCP(brokers...),
Topic: "cross-region-events",
Balancer: &kafka.Murmur2Balancer{},
BatchTimeout: 10 * time.Millisecond,
Async: false, // гарантия доставки
},
region: region,
}
}
func (r *CrossRegionReplicator) Publish(ctx context.Context, eventType string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
event := Event{
Type: eventType,
Region: r.region,
Payload: data,
Timestamp: time.Now(),
}
eventData, _ := json.Marshal(event)
return r.producer.WriteMessages(ctx, kafka.Message{
Key: []byte(eventType),
Value: eventData,
Headers: []kafka.Header{
{Key: "source-region", Value: []byte(r.region)},
},
})
}
// Consumer обрабатывает события из других регионов
type CrossRegionConsumer struct {
consumer *kafka.Reader
handler func(context.Context, Event) error
}
func (c *CrossRegionConsumer) Start(ctx context.Context) error {
for {
msg, err := c.consumer.ReadMessage(ctx)
if err != nil {
return fmt.Errorf("read message: %w", err)
}
// Пропускаем события из своего региона
sourceRegion := ""
for _, h := range msg.Headers {
if h.Key == "source-region" {
sourceRegion = string(h.Value)
}
}
var event Event
if err := json.Unmarshal(msg.Value, &event); err != nil {
continue
}
if sourceRegion == "" || sourceRegion == c.ownRegion() {
continue
}
if err := c.handler(ctx, event); err != nil {
log.Error().Err(err).Str("type", event.Type).Msg("handle cross-region event failed")
}
}
}
4. Стратегия кэширования с учётом географии
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type GeoDistributedCache struct {
localRedis *redis.Client // Redis в текущем регионе
remoteRedis *redis.Client // Redis в центральном регионе
region string
}
func (c *GeoDistributedCache) Get(ctx context.Context, key string) (string, error) {
// Сначала проверяем локальный Redis
val, err := c.localRedis.Get(ctx, key).Result()
if err == nil {
return val, nil
}
// Если нет локально — проверяем центральный
val, err = c.remoteRedis.Get(ctx, key).Result()
if err == nil {
// Кэшируем локально на короткое время
c.localRedis.Set(ctx, key, val, 30*time.Second)
return val, nil
}
return "", fmt.Errorf("cache miss")
}
func (c *GeoDistributedCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
// Записываем в локальный Redis
if err := c.localRedis.Set(ctx, key, value, ttl).Err(); err != nil {
return err
}
// Публикуем инвалидацию в другие регионы
c.localRedis.Publish(ctx, "cache-invalidation", fmt.Sprintf("%s:%s", c.region, key))
return nil
}
5. Стратегия развёртывания — от простого к сложному
На старте можно начать с одного региона и заложить возможность расширения:
# docker-compose.yml — стартовая конфигурация (один регион)
version: '3.8'
services:
api-gateway:
build: ./gateway
environment:
- REGION=ru-central
- DB_HOST=postgres
- REDIS_HOST=redis
- KAFKA_BROKERS=kafka:9092
ports:
- "8080:8080"
news-service:
build: ./news-service
environment:
- REGION=ru-central
- DB_HOST=postgres
- REDIS_HOST=redis
crawler-service:
build: ./crawler-service
environment:
- KAFKA_BROKERS=kafka:9092
postgres:
image: postgres:16
environment:
POSTGRES_DB: news_aggregator
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
kafka:
image: confluentinc/cp-kafka:7.5.0
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
volumes:
pgdata:
6. Мониторинг латентности по регионам
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
requestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration by region and endpoint",
Buckets: prometheus.DefBuckets,
},
[]string{"region", "endpoint", "method"},
)
crossRegionReplicationLag = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cross_region_replication_lag_seconds",
Help: "Replication lag between regions",
},
[]string{"source_region", "target_region"},
)
)
func RecordRequestDuration(region, endpoint, method string, duration float64) {
requestDuration.WithLabelValues(region, endpoint, method).Observe(duration)
}
func RecordReplicationLag(sourceRegion, targetRegion string, lag float64) {
crossRegionReplicationLag.WithLabelValues(sourceRegion, targetRegion).Set(lag)
}
Итого: для агрегатора новостей СНГ геораспределённость — это необходимость, обусловленная масштабами территории (11 часовых поясов), требованиями к латентности (LCP напрямую зависит от TTFB), отказоустойчивостью и потенциальными юридическими требованиями. Архитектура должна включать geo-based routing через DNS, асинхронную репликацию данных через Kafka, многоуровневое кэширование с инвалидацией между регионами и мониторинг латентности. На старте можно развернуть систему в одном регионе, но архитектура должна позволять добавление новых регионов без переписывания кодовой базы.
Вопрос 8. Какой стек технологий у команды и какие сроки разработки агрегатора новостей?
Таймкод: 00:08:39
Ответ собеседника: Правильный. Фронтенд на React, бэкенд на Node.js, сроки — 3–5 месяцев.
Правильный ответ:
Для агрегатора новостей с учётом требований SEO, масштабируемости по СНГ и высокой нагрузки стек технологий должен быть выбран с учётом конкретных задач каждого компонента. Сроки 3–5 месяцев для MVP реалистичны при команде 4–6 человек, но зависят от объёма функциональности.
Рекомендуемый стек технологий:
Фронтенд:
| Технология | Выбор | Обоснование |
|---|---|---|
| Фреймворк | Next.js (React) | SSR для SEO, SSG для статических страниц, ISR для обновления контента без пересборки |
| Стейт-менеджмент | Zustand или TanStack Query | Простота, минимальный boilerplate, встроенное кэширование серверных данных |
| Стилизация | Tailwind CSS | Быстрая разработка, малый размер бандла, консистентность дизайна |
| Дизайн-система | shadcn/ui или Ant Design | Готовые компоненты, доступность (a11y), кастомизация |
| Мониторинг | Sentry, web-vitals | Отслеживание ошибок и Core Web Vitals |
Бэкенд:
| Технология | Выбор | Обоснование |
|---|---|---|
| Язык | Go | Высокая производительность, нативная конкурентность, быстрый старт, низкое потребление памяти |
| Фреймворк | Chi или Fiber | Минималистичный роутинг (Chi) или высокопроизводительный (Fiber на fasthttp) |
| API | gRPC между сервисами, REST/GraphQL для внешних клиентов | gRPC для эффективного межсервисного взаимодействия, GraphQL для гибких запросов фронтенда |
| Аутентификация | JWT + OAuth 2.0 | Стандарт индустрии, поддержка социальных логинов |
Хранение данных:
| Тип данных | Технология | Обоснование |
|---|---|---|
| Основные данные | PostgreSQL | ACID, полнотекстовый поиск, JSONB, надёжность |
| Кэширование | Redis Cluster | Быстрый доступ, TTL, pub/sub для инвалидации |
| Поиск | Elasticsearch | Полнотекстовый поиск, фильтрация, агрегации, ранжирование |
| Аналитика | ClickHouse | Колоночное хранение, быстрая агрегация больших объёмов |
| Очереди | Kafka | Гарантия доставки, порядок сообщений, высокая пропускная способность |
| Статика | S3-совместимое (MinIO / AWS S3) + CDN | Изображения, медиа, быстрая доставка |
Инфраструктура:
| Компонент | Технология |
|---|---|
| Контейнеризация | Docker |
| Оркестрация | Kubernetes (k3s для небольших кластеров) |
| CI/CD | GitHub Actions или GitLab CI |
| Мониторинг | Prometheus + Grafana |
| Логирование | Loki или ELK |
| Трейсинг | Jaeger (OpenTelemetry) |
| DNS / CDN | Cloudflare |
Архитектура на языке Go — пример структуры проекта:
news-aggregator/
├── cmd/
│ ├── api-gateway/
│ │ └── main.go
│ ├── news-service/
│ │ └── main.go
│ ├── crawler-service/
│ │ └── main.go
│ └── rating-service/
│ └── main.go
├── internal/
│ ├── news/
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── repository.go
│ │ └── model.go
│ ├── crawler/
│ │ ├── parser.go
│ │ ├── scheduler.go
│ │ └── source.go
│ └── rating/
│ ├── service.go
│ └── repository.go
├── pkg/
│ ├── database/
│ │ ├── postgres.go
│ │ └── redis.go
│ ├── kafka/
│ │ ├── producer.go
│ │ └── consumer.go
│ └── middleware/
│ ├── auth.go
│ ├── ratelimit.go
│ └── logging.go
├── migrations/
├── deployments/
│ ├── docker-compose.yml
│ └── k8s/
├── go.mod
└── go.sum
Пример базовой структуры сервиса на Go:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
cfg := loadConfig()
db, err := database.NewPostgres(cfg.DatabaseURL)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
defer db.Close()
redisClient := database.NewRedis(cfg.RedisURL)
defer redisClient.Close()
kafkaProducer, err := kafka.NewProducer(cfg.KafkaBrokers)
if err != nil {
log.Fatalf("failed to create kafka producer: %v", err)
}
defer kafkaProducer.Close()
// Инициализация слоёв
articleRepo := repository.NewArticleRepository(db)
articleCache := cache.NewArticleCache(redisClient)
articleService := service.NewArticleService(articleRepo, articleCache, kafkaProducer)
articleHandler := handler.NewArticleHandler(articleService)
// Роутинг
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
r.Route("/api/v1", func(r chi.Router) {
r.Get("/articles", articleHandler.List)
r.Get("/articles/{id}", articleHandler.Get)
r.Get("/feed", articleHandler.Feed)
})
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown error: %v", err)
}
}}()
log.Printf("server starting on port %s", cfg.Port)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
Планирование сроков — 3–5 месяцев на MVP:
| Этап | Срок | Что делаем |
|---|---|---|
| Проектирование | Неделя 1–2 | Архитектура, API-контракты, модель данных, макеты |
| Бэкенд-фундамент | Неделя 3–5 | Аутентификация, CRUD новостей, базовая интеграция с БД |
| Crawler/Parser | Неделя 4–7 | Парсеры 5–10 источников, Kafka pipeline |
| Фронтенд | Неделя 3–8 | Next.js, главная страница, карточки новостей, SSR |
| Поиск | Неделя 6–9 | Elasticsearch индексация, полнотекстовый поиск |
| Рейтинги и голосование | Неделя 8–10 | Лайки/дизлайки, score, антифрод |
| SEO и оптимизация | Неделя 9–12 | Sitemap, мета-теги, Core Web Vitals, пререндеринг |
| Тестирование и деплой | Неделя 13–16 | Нагрузочное тестирование, CI/CD, мониторинг |
Состав команды для 3–5 месяцев:
| Роль | Количество |
|---|---|
| Backend-разработчик (Go) | 2 |
| Frontend-разработчик (React/Next.js) | 1 |
| DevOps-инженер | 1 |
| QA-инженер | 1 |
| Технический лид / Архитектор | 1 (может совмещать с backend) |
Почему Go, а не Node.js для бэкенда:
- Производительность: Go потребляет значительно меньше памяти и быстрее обрабатывает CPU-bound задачи (парсинг HTML, обработка JSON).
- Конкурентность: Горутины и каналы — нативный механизм для одновременного парсинга сотен источников.
- Статическая типизация: Меньше runtime-ошибок в production.
- Простота деплоя: Один бинарный файл без зависимостей.
Node.js может быть оправдан для BFF-слоя (Backend for Frontend), если фронтенд-команда хочет писать на одном языке, но для основных сервисов Go предпочтительнее.
Итого: рекомендуемый стек — Next.js на фронтенде, Go на бэкенде, PostgreSQL + Redis + Elasticsearch + Kafka для хранения и обработки данных, Docker + Kubernetes для инфраструктуры. Сроки MVP — 3–5 месяцев при команде из 5–6 человек. Go предпочтительнее Node.js для бэкенда благодаря производительности, нативной конкурентности и статической типизации.
Вопрос 9. Какой фреймворк лучше использовать для серверного рендеринга с учётом требований к SEO?
Таймкод: 00:10:41
Ответ собеседника: Правильный. Рекомендуется Next.js с поддержкой SSR из коробки. Альтернатива — отдельные пререндеренные страницы для роботов и SPA для клиентов, но это дублирование.
Правильный ответ:
Для агрегатора новостей с жёсткими требованиями к SEO Next.js является оптимальным выбором. Он обеспечивает гибкость в выборе стратегии рендеринга для каждой страницы и решает ключевые проблемы SEO из коробки.
Стратегии рендеринга в Next.js и их применение:
1. SSR (Server-Side Rendering) — для динамического контента
Используется для страниц, где контент часто меняется и должен быть актуальным на момент запроса поискового робота.
// app/articles/[id]/page.tsx
// Серверный компонент с SSR по умолчанию (App Router)
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface ArticlePageProps {
params: { id: string };
}
// Динамическая генерация мета-тегов для SEO
export async function generateMetadata({ params }: ArticlePageProps): Promise<Metadata> {
const article = await getArticle(params.id);
if (!article) {
return { title: 'Статья не найдена' };
}
return {
title: article.title,
description: article.summary,
openGraph: {
title: article.title,
description: article.summary,
type: 'article',
publishedTime: article.publishedAt,
modifiedTime: article.updatedAt,
authors: [article.author.name],
images: [{
url: article.imageUrl,
width: 1200,
height: 630,
alt: article.title,
}],
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.summary,
images: [article.imageUrl],
},
};
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const article = await getArticle(params.id);
if (!article) {
notFound();
}
return (
<article itemScope itemType="https://schema.org/NewsArticle">
<meta itemProp="datePublished" content={article.publishedAt} />
<meta itemProp="dateModified" content={article.updatedAt} />
<header>
<h1 itemProp="headline">{article.title}</h1>
<div className="article-meta">
<span itemProp="author" itemScope itemType="https://schema.org/Person">
<span itemProp="name">{article.author.name}</span>
</span>
<time dateTime={article.publishedAt}>
{formatDate(article.publishedAt, 'ru')}
</time>
<span className="score">Score: {article.score}</span>
</div>
</header>
{article.imageUrl && (
<img
src={article.imageUrl}
alt={article.title}
width={1200}
height={630}
itemProp="image"
/>
)}
<div itemProp="articleBody">
{article.content}
</div>
<VoteWidget articleId={article.id} initialScore={article.score} />
</article>
);
}
async function getArticle(id: string) {
const res = await fetch(`${process.env.API_URL}/api/v1/articles/${id}`, {
next: { revalidate: 60 }, // ISR: перевалидация каждые 60 секунд
});
if (!res.ok) return null;
return res.json();
}
2. ISR (Incremental Static Regeneration) — для главной страницы и категорий
Контент генерируется при сборке, но обновляется по расписанию без пересборки всего сайта. Идеально для ленты новостей.
// app/page.tsx — Главная страница с ISR
export const revalidate = 30; // Перевалидация каждые 30 секунд
export default async function HomePage() {
const [topNews, latestNews, trendingNews] = await Promise.all([
getTopNews(),
getLatestNews(),
getTrendingNews(),
]);
return (
<main>
<section aria-label="Главные новости">
<h2>Главные новости</h2>
<div className="news-grid">
{topNews.map(article => (
<ArticleCard key={article.id} article={article} priority={true} />
))}
</div>
</section>
<section aria-label="Последние новости">
<h2>Последние новости</h2>
<div className="news-list">
{latestNews.map(article => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</section>
<section aria-label="В тренде">
<h2>В тренде</h2>
<TrendingList articles={trendingNews} />
</section>
</main>
);
}
async function getTopNews() {
const res = await fetch(`${process.env.API_URL}/api/v1/articles?sort=score&limit=10`, {
next: { revalidate: 30 },
});
return res.json();
}
async function getLatestNews() {
const res = await fetch(`${process.env.API_URL}/api/v1/articles?sort=latest&limit=50`, {
next: { revalidate: 15 },
});
return res.json();
}
async function getTrendingNews() {
const res = await fetch(`${process.env.API_URL}/api/v1/articles?sort=trending&limit=10`, {
next: { revalidate: 60 },
});
return res.json();
}
3. SSG (Static Site Generation) — для редко меняющихся страниц
// app/about/page.tsx — статическая страница
export default function AboutPage() {
return (
<main>
<h1>О проекте</h1>
<p>Агрегатор новостей — это платформа для сбора...</p>
</main>
);
}
4. Streaming SSR — для постепенной отдачи контента
Позволяет отдать каркас страницы сразу, а динамические блоки подгрузить по мере готовности данных. Это улучшает TTFB и FCP.
// app/page.tsx с использованием Suspense для потокового рендеринга
import { Suspense } from 'react';
export default function HomePage() {
return (
<main>
{/* Статический контент — отдаётся мгновенно */}
<header className="site-header">
<h1>Новости</h1>
<nav>{/* Навигация */}</nav>
</header>
{/* Главные новости — загружаются с приоритетом */}
<Suspense fallback={<NewsSkeleton count={5} />}>
<TopNewsSection />
</Suspense>
{/* Последние новости — загружаются вторым приоритетом */}
<Suspense fallback={<NewsSkeleton count={20} />}>
<LatestNewsSection />
</Suspense>
{/* Тренды — загружаются последними */}
<Suspense fallback={<TrendingSkeleton />}>
<TrendingSection />
</Suspense>
</main>
);
}
async function TopNewsSection() {
const articles = await getTopNews();
return (
<section>
{articles.map(a => <ArticleCard key={a.id} article={a} />)}
</section>
);
}
function NewsSkeleton({ count }: { count: number }) {
return (
<div className="skeleton-container">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-image" />
<div className="skeleton-title" />
<div className="skeleton-text" />
</div>
))}
</div>
);
}
5. Оптимизация изображений для LCP
import Image from 'next/image';
function ArticleCard({ article }: { article: Article }) {
return (
<article className="card">
<Image
src={article.imageUrl}
alt={article.title}
width={400}
height={225}
priority={article.isTop} // Предзагрузка для hero-изображений
placeholder="blur"
blurDataURL={article.blurHash} // LQIP через blurhash
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
<h3>{article.title}</h3>
<p>{article.summary}</p>
<time dateTime={article.publishedAt}>
{formatDate(article.publishedAt, 'ru')}
</time>
</article>
);
}
6. Генерация структурированных данных (JSON-LD)
// components/StructuredData.tsx
import { Article, NewsArticle } from 'schema-dts';
import { JsonLd } from 'react-schemaorg';
export function ArticleJsonLd({ article }: { article: ArticleType }) {
return (
<JsonLd<NewsArticle>
item={{
'@context': 'https://schema.org',
'@type': 'NewsArticle',
headline: article.title,
description: article.summary,
image: [article.imageUrl],
datePublished: article.publishedAt,
dateModified: article.updatedAt,
author: [{
'@type': 'Person',
name: article.author.name,
}],
publisher: {
'@type': 'Organization',
name: 'News Aggregator',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
`@id': `https://example.com/articles/${article.id}`,
},
}}
/>
);
}
7. Breadcrumbs для навигации и SEO
// components/Breadcrumbs.tsx
import { BreadcrumbList } from 'schema-dts';
import { JsonLd } from 'react-schemaorg';
import Link from 'next/link';
interface BreadcrumbItem {
name: string;
href: string;
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
const structuredData: BreadcrumbList = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `https://example.com${item.href}`,
})),
};
return (
<>
<JsonLd<BreadcrumbList> item={structuredData} />
<nav aria-label="Breadcrumb">
<ol className="breadcrumbs">
{items.map((item, index) => (
<li key={item.href}>
{index < items.length - 1 ? (
<Link href={item.href}>{item.name}</Link>
) : (
<span aria-current="page">{item.name}</span>
)}
</li>
))}
</ol>
</nav>
</>
);
}
Сравнение стратегий рендеринга для разных страниц:
| Страница | Стратегия | Обоснование |
|---|---|---|
| Главная | ISR (revalidate: 30s) | Часто обновляется, нужен свежий контент |
| Карточка статьи | SSR + ISR (revalidate: 60s) | Динамический контент, голосование |
| Категория | ISR (revalidate: 60s) | Список обновляется реже |
| Поиск | CSR | Персонализированный результат |
| О проекте, Контакты | SSG | Статический контент |
| Профиль пользователя | CSR + SSR | Приватные данные, требует авторизации |
Итого: Next.js — оптимальный выбор для SEO-ориентированного агрегатора новостей. App Router позволяет комбинировать SSR, ISR и SSG для каждой страницы отдельно. Streaming SSR через Suspense улучшает TTFB и FCP. Встроенные компоненты Image, Script, Link решают проблемы производительности из коробки. Структурированные данные Schema.org через JSON-LD обеспечивают расширенные сниппеты в поисковой выдаче.
Вопрос 10. Как организовать хранение изображений и статики для оптимизации загрузки?
Таймкод: 00:13:50
Ответ собеседника: Правильный. Использовать S3-совместимое хранилище для картинок и статики, бэкенд отдаёт ссылки, клиент загружает по этим ссылкам.
Правильный ответ:
Хранение и отдача изображений — это критический компонент производительности агрегатора новостей, напрямую влияющий на LCP и, как следствие, на SEO-ранжирование. Правильная архитектура включает S3-хранилище, CDN, автоматическую оптимизацию изображений и адаптивную загрузку.
Полная архитектура хранения и отдачи:
[Загрузка] [Хранение] [Отдача]
Клиент → API Gateway → Image Service → S3 Bucket → CDN (Cloudflare/CloudFront)
↓ ↓
PostgreSQL Браузер клиента
(метаданные) (оптимизированные изображения)
1. Сервис обработки изображений на Go:
package image
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
)
type ImageSize struct {
Width int
Height int
Suffix string
}
var PresetSizes = []ImageSize{
{Width: 320, Height: 180, Suffix: "thumb"},
{Width: 640, Height: 360, Suffix: "medium"},
{Width: 1280, Height: 720, Suffix: "large"},
{Width: 1920, Height: 1080, Suffix: "xlarge"},
}
type ProcessedImage struct {
OriginalURL string `json:"original"`
Variants map[string]string `json:"variants"` // size_name -> url
Width int `json:"width"`
Height int `json:"height"`
BlurHash string `json:"blurHash"`
Format string `json:"format"`
}
type ImageService struct {
minioClient *minio.Client
bucket string
cdnBaseURL string
}
func NewImageService(minioClient *minio.Client, bucket, cdnBaseURL string) *ImageService {
return &ImageService{
minioClient: minioClient,
bucket: bucket,
cdnBaseURL: cdnBaseURL,
}
}
func (s *ImageService) UploadAndProcess(ctx context.Context, reader io.Reader, contentType string) (*ProcessedImage, error) {
// Читаем весь файл в память
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read image data: %w", err)
}
// Декодируем изображение
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
bounds := img.Bounds()
originalWidth := bounds.Dx()
originalHeight := bounds.Dy()
// Генерируем уникальный идентификатор
imageID := uuid.New().String()
ext := format
if ext == "jpeg" {
ext = "jpg"
}
// Вычисляем BlurHash для placeholder
blurHash := computeBlurHash(img)
result := &ProcessedImage{
Variants: make(map[string]string),
Width: originalWidth,
Height: originalHeight,
BlurHash: blurHash,
Format: ext,
}
// Загружаем оригинал в S3
originalKey := fmt.Sprintf("originals/%s.%s", imageID, ext)
if err := s.uploadToS3(ctx, originalKey, data, contentType); err != nil {
return nil, fmt.Errorf("upload original: %w", err)
}
result.OriginalURL = s.cdnBaseURL + "/" + originalKey
// Генерируем варианты разных размеров
for _, size := range PresetSizes {
// Не создаём вариант больше оригинала
if size.Width > originalWidth && size.Height > originalHeight {
result.Variants[size.Suffix] = result.OriginalURL
continue
}
resized := imaging.Fit(img, size.Width, size.Height, imaging.Lanczos)
var buf bytes.Buffer
var resContentType string
// Конвертируем в WebP для лучшего сжатия
if err := imaging.Encode(&buf, resized, imaging.WEBP, imaging.WebPQuality(80)); err != nil {
continue
}
resContentType = "image/webp"
resizedKey := fmt.Sprintf("resized/%s/%s.webp", size.Suffix, imageID)
if err := s.uploadToS3(ctx, resizedKey, buf.Bytes(), resContentType); err != nil {
continue
}
result.Variants[size.Suffix] = s.cdnBaseURL + "/" + resizedKey
}
return result, nil
}
func (s *ImageService) uploadToS3(ctx context.Context, key string, data []byte, contentType string) error {
_, err := s.minioClient.PutObject(
ctx,
s.bucket,
key,
bytes.NewReader(data),
int64(len(data)),
minio.PutObjectOptions{
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", // год кэширования
},
)
return err
}
2. Модель метаданных изображения в базе данных:
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id BIGINT REFERENCES articles(id) ON DELETE SET NULL,
original_key VARCHAR(500) NOT NULL, -- ключ в S3
original_url VARCHAR(1000) NOT NULL, -- URL через CDN
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format VARCHAR(10) NOT NULL, -- jpeg, png, webp, avif
blur_hash VARCHAR(100), -- blurhash для placeholder
file_size_bytes INTEGER NOT NULL,
variants JSONB NOT NULL DEFAULT '{}', -- {"thumb": "url", "medium": "url", ...}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_images_article ON images(article_id);
-- Пример данных в variants:
-- {"thumb": "https://cdn.example.com/resized/thumb/abc-123.webp",
-- "medium": "https://cdn.example.com/resized/medium/abc-123.webp",
-- "large": "https://cdn.example.com/resized/large/abc-123.webp"}
3. Генерация srcset для адаптивных изображений:
package image
import (
"encoding/json"
"fmt"
"strings"
)
type ImageVariant struct {
URL string `json:"url"`
Width int `json:"width"`
}
type ResponsiveImage struct {
OriginalURL string `json:"originalUrl"`
Variants map[string]ImageVariant `json:"variants"`
BlurHash string `json:"blurHash"`
Width int `json:"width"`
Height int `json:"height"`
}
// GenerateSrcSet формирует атрибут srcset для тега <img>
func (ri *ResponsiveImage) GenerateSrcSet() string {
var parts []string
sizeWidths := map[string]int{
"thumb": 320,
"medium": 640,
"large": 1280,
"xlarge": 1920,
}
for name, variant := range ri.Variants {
if w, ok := sizeWidths[name]; ok {
parts = append(parts, fmt.Sprintf("%s %dw", variant.URL, w))
}
}
return strings.Join(parts, ", ")
}
// GenerateSizes формирует атрибут sizes для responsive images
func (ri *ResponsiveImage) GenerateSizes() string {
return "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
}
4. Интеграция с Next.js Image компонентом:
// components/ResponsiveImage.tsx
import Image from 'next/image';
interface ResponsiveImageProps {
src: string;
alt: string;
blurHash: string;
width: number;
height: number;
variants: Record<string, string>;
priority?: boolean;
className?: string;
}
export function ResponsiveImage({
src,
alt,
blurHash,
width,
height,
variants,
priority = false,
className,
}: ResponsiveImageProps) {
// Формируем srcSet из вариантов
const srcSet = Object.entries(variants)
.map(([size, url]) => {
const widths: Record<string, number> = {
thumb: 320,
medium: 640,
large: 1280,
xlarge: 1920,
};
return `${url} ${widths[size] || 640}w`;
})
.join(', ');
return (
<div className="image-container" style={{ aspectRatio: `${width}/${height}` }}>
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
placeholder="blur"
blurDataURL={blurHashToDataURL(blurHash)}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className={className}
quality={80}
/>
</div>
);
}
// Конвертация blurhash в data URL для placeholder
function blurDataURL(hash: string): string {
// Используем библиотеку blurhash для декодирования
// Возвращаем data URI для использования в Next.js Image
const pixels = decode(hash, 32, 32);
const dataURL = toDataURL(pixels, 32, 32);
return dataURL;
}
5. Конфигурация CDN (Cloudflare) для оптимизации:
# Cloudflare Page Rules или Configuration Rules
# Кэширование изображений на 1 год
# URL Pattern: *cdn.example.com/resized/*
# Settings:
# Cache Level: Cache Everything
# Edge Cache TTL: 1 year
# Browser Cache TTL: 1 year
# Автоматическая конвертация в WebP/AVIF
# URL Pattern: *cdn.example.com/resized/*
# Settings:
# Polish: Lossy
# Mirage: On (lazy loading для изображений)
# Оригиналы — кэш на 1 месяц
# URL Pattern: *cdn.example.com/originals/*
# Settings:
# Cache Level: Cache Everything
# Edge Cache TTL: 1 month
6. Загрузка изображений с клиента:
// hooks/useImageUpload.ts
import { useState, useCallback } from 'react';
interface UploadResult {
originalUrl: string;
variants: Record<string, string>;
blurHash: string;
width: number;
height: number;
}
export function useImageUpload() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const upload = useCallback(async (file: File): Promise<UploadResult> => {
setUploading(true);
setProgress(0);
try {
// Валидация
const maxSize = 10 * 1024 * 1024; // 10 MB
if (file.size > maxSize) {
throw new Error('Файл слишком большой (макс. 10 МБ)');
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
throw new Error('Неподдерживаемый формат файла');
}
// Создаём FormData
const formData = new FormData();
formData.append('image', file);
// Загрузка с отслеживанием прогресса
const result = await fetch('/api/v1/images/upload', {
method: 'POST',
body: formData,
});
if (!result.ok) {
throw new Error('Ошибка загрузки');
}
return result.json();
} finally {
setUploading(false);
}
}, []);
return { upload, uploading, progress };
}
7. Ленивая загрузка (Lazy Loading) изображений за пределами viewport:
// components/LazyImage.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
interface LazyImageProps {
src: string;
alt: string;
width: number;
height: number;
blurHash: string;
}
export function LazyImage({ src, alt, width, height, blurHash }: LazyImageProps) {
const imgRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' } // Начинаем загрузку за 200px до появления
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div
ref={imgRef}
style={{
aspectRatio: `${width}/${height}`,
backgroundColor: '#f0f0f0',
}}
>
{isVisible ? (
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
/>
) : (
<BlurHashCanvas hash={blurHash} width={width} height={height} />
)}
</div>
);
}
8. Мониторинг метрик загрузки изображений:
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
imageUploadDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "image_upload_duration_seconds",
Help: "Image upload and processing duration",
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30},
},
[]string{"format"},
)
imageUploadSize = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "image_upload_size_bytes",
Help: "Original image upload size",
Buckets: prometheus.ExponentialBuckets(1024, 2, 15), // 1KB to 32MB
},
[]string{"format"},
)
imageVariantsCreated = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "image_variants_created_total",
Help: "Total number of image variants created",
},
[]string{"size"},
)
)
Итого: архитектура хранения изображений включает S3-совместимое хранилище (MinIO/AWS S3) для оригиналов, автоматическую генерацию вариантов разных размеров в формате WebP, CDN (Cloudflare) для быстрой доставки с edge-кэшированием на 1 год, BlurHash для placeholder'ов, адаптивную загрузку через srcset и sizes, lazy loading для изображений за пределами viewport. Все метаданные хранятся в PostgreSQL в формате JSONB. Это обеспечивает оптимальный LCP и Core Web Vitals для всей территории СНГ.
Вопрос 11. Нужна ли CDN для геораспределённой системы и как она улучшит производительность?
Таймкод: 00:14:59
Ответ собеседника: Правильный. CDN необходима для кэширования статических ресурсов и HTML-страниц в зависимости от региона пользователя, что ускорит загрузку по всему СНГ.
Правильный ответ:
CDN — это обязательный компонент для агрегатора новостей с географией СНГ. Без CDN пользователи из удалённых регионов будут получать неприемлемо высокий TTFB, что напрямую ухудшит LCP, Core Web Vitals и SEO-ранжирование.
Что решает CDN:
1. Снижение TTFB для статического контента
CDN размещает копии контента на edge-серверах, расположенных близко к пользователю. Запрос не идёт через всю страну до центрального дата-центра, а обрабатывается ближайшим узлом.
| Пользователь | Без CDN (до Москвы) | С CDN (до ближайшего PoP) |
|---|---|---|
| Москва | 10 мс | 5 мс |
| Алматы | 60 мс | 5–10 мс (PoP в Алматы) |
| Владивосток | 120 мс | 5–15 мс (PoP в Владивостоке) |
| Ташкент | 80 мс | 5–10 мс (PoP в Ташкенте) |
2. Кэширование динамического контента
Современные CDN (Cloudflare, AWS CloudFront) умеют кэшировать не только статику, но и HTML-страницы с настраиваемым TTL. Для новостей это критично — контент обновляется каждые 30–60 секунд, и CDN может кэшировать его на это время.
Архитектура CDN для агрегатора новостей:
[Пользователь из Алматы]
↓
[Cloudflare PoP Алматы] ← кэш HIT → Отдаёт страницу за 5 мс
↓ (cache MISS)
[Origin: API Gateway (Москва)] → Генерирует страницу → Кэширует в PoP
Конфигурация CDN-кэширования на уровне Go-бэкенда:
package cdn
import (
"fmt"
"net/http"
"time"
)
// CachePolicy определяет правила кэширования для разных типов контента
type CachePolicy struct {
StaticTTL time.Duration // Для CSS, JS, изображений
PageTTL time.Duration // Для HTML-страниц
APIResponseTTL time.Duration // Для API-ответов
FeedTTL time.Duration // Для RSS/Atom лент
}
var DefaultPolicy = CachePolicy{
StaticTTL: 365 * 24 * time.Hour, // 1 год
PageTTL: 30 * time.Second, // 30 секунд для новостей
APIResponseTTL: 15 * time.Second, // 15 секунд для API
FeedTTL: 60 * time.Second, // минута для лент
}
// SetCacheHeaders устанавливает заголовки кэширования в зависимости от типа контента
func SetCacheHeaders(w http.ResponseWriter, contentType string, policy CachePolicy) {
switch {
// Статические ресурсы — долгий кэш с иммутабельностью
case isStatic(contentType):
w.Header().Set("Cache-Control",
fmt.Sprintf("public, max-age=%d, immutable", int(policy.StaticTTL.Seconds())))
w.Header().Set("CDN-Cache-Control",
fmt.Sprintf("public, max-age=%d, immutable", int(policy.StaticTTL.Seconds())))
// HTML-страницы — короткий кэш через CDN
case contentType == "text/html":
w.Header().Set("Cache-Control",
fmt.Sprintf("public, max-age=%d, s-maxage=%d, stale-while-revalidate=%d",
0, // браузер не кэширует
int(policy.PageTTL.Seconds()), // CDN кэширует на 30 сек
300)) // при истечении кэша отдаём старое и обновляем
w.Header().Set("CDN-Cache-Control",
fmt.Sprintf("public, max-age=%d, stale-while-revalidate=300",
int(policy.PageTTL.Seconds())))
// Vary для корректного кэширования в зависимости от заголовков
w.Header().Set("Vary", "Accept-Encoding, Accept-Language, Authorization")
// API-ответы — очень короткий кэш
case contentType == "application/json":
w.Header().Set("Cache-Control",
fmt.Sprintf("public, max-age=%d, s-maxage=%d",
0, int(policy.APIResponseTTL.Seconds())))
}
}
func isStatic(contentType string) bool {
staticTypes := []string{
"text/css",
"application/javascript",
"image/", "font/", "video/", "audio/",
}
for _, t := range staticTypes {
if len(contentType) >= len(t) && contentType[:len(t)] == t {
return true
}
}
return false
}
// InvalidateCache отправляет запрос на очистку кэша CDN при обновлении контента
func InvalidateCache(cloudflareAPI *CloudflareAPI, urls []string) error {
return cloudflareAPI.PurgeCache(urls)
}
Интеграция с Cloudflare API для инвалидации кэша:
package cdn
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type CloudflareAPI struct {
apiToken string
zoneID string
baseURL string
client *http.Client
}
func NewCloudflareAPI(apiToken, zoneID string) *CloudflareAPI {
return &CloudflareAPI{
apiToken: apiToken,
zoneID: zoneID,
baseURL: "https://api.cloudflare.com/client/v4",
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *CloudflareAPI) PurgeCache(ctx context.Context, urls []string) error {
payload := map[string]interface{}{
"files": urls,
}
data, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST",
fmt.Sprintf("%s/zones/%s/purge_cache", c.baseURL, c.zoneID),
bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cloudflare API returned status %d", resp.StatusCode)
}
return nil
}
// Инвалидация кэша при публикации новой статьи
type CacheInvalidator struct {
cfAPI *CloudflareAPI
baseURL string
}
func (ci *CacheInvalidator) OnArticlePublished(ctx context.Context, articleID int64) error {
urls := []string{
fmt.Sprintf("%s/articles/%d", ci.baseURL, articleID),
fmt.Sprintf("%s/", ci.baseURL), // Главная
fmt.Sprintf("%s/api/v1/articles/%d", ci.baseURL, articleID),
fmt.Sprintf("%s/api/v1/articles?sort=latest", ci.baseURL), // Лента
}
return cfAPI.PurgeCache(ctx, urls)
}
Стратегия stale-while-revalidate:
// stale-while-revalidate позволяет отдавать устаревший кэш,
// пока в фоне запрашивается свежая версия.
// Это критично для новостей — пользователь никогда не ждёт.
// Заголовок ответа:
// Cache-Control: public, max-age=30, s-maxage=30, stale-while-revalidate=300
//
// Логика:
// 0-30 сек: отдаём из кэша (fresh)
// 30-330 сек: отдаём из кэша (stale) + фоновое обновление
// 330+ сек: ждём обновления (edge идёт в origin)
Кэширование на уровне Next.js с заголовками для CDN:
// next.config.js — конфигурация CDN-заголовков
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'CDN-Cache-Control',
value: 'public, max-age=30, stale-while-revalidate=300',
},
],
},
{
// Статика — долгий кэш (хэши в именах файлов)
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// Изображения через CDN
source: '/images/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=86400, stale-while-revalidate=604800',
},
],
},
];
},
};
module.exports = nextConfig;
Мониторинг эффективности CDN:
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
cdnCacheHitRatio = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cdn_cache_hit_ratio",
Help: "CDN cache hit ratio by PoP location",
},
[]string{"pop_location"},
)
cdnEdgeResponseTime = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "cdn_edge_response_time_seconds",
Help: "Response time from CDN edge",
Buckets: prometheus.DefBuckets,
},
[]string{"pop_location", "cache_status"}, // HIT or MISS
)
cdnOriginResponseTime = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "cdn_origin_response_time_seconds",
Help: "Response time from origin (cache miss)",
Buckets: prometheus.DefBuckets,
},
)
)
// Middleware для сбора метрик CDN из заголовков ответа
func CDNMetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Cloudflare добавляет заголовок CF-Cache-Status: HIT | MISS | EXPIRED | DYNAMIC
cacheStatus := r.Header.Get("CF-Cache-Status")
popLocation := r.Header.Get("CF-Ray") // содержит код PoP
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start).Seconds()
cdnEdgeResponseTime.WithLabelValues(popLocation, cacheStatus).Observe(duration)
})
}
Выбор CDN-провайдера для СНГ:
| Провайдер | PoP в СНГ | Особенности | Стоимость |
|---|---|---|---|
| Cloudflare | Москва, Алматы, Ташкент, Тбилиси, Баку, Ереван | Лучшее покрытие СНГ, бесплатный тариф, Workers для edge-логики | Бесплатный Pro $20/мес |
| AWS CloudFront | Москва (через партнёров), ограниченно | Интеграция с AWS, но слабое покрытие СНГ | Pay-as-you-go |
| Gcore | Москва, Алматы, Ташкент, Кишинёв | Специализация на СНГ, хорошая цена | Конкурентные цены |
Итого: CDN — обязательный компонент для агрегатора новостей в СНГ. Cloudflare является оптимальным выбором благодаря наличию PoP в ключевых городах СНГ и бесплатному тарифу. CDN снижает TTFB с 100–150 мс до 5–15 мс для удалённых регионов, обеспечивает кэширование HTML-с
Вопрос 12. Как масштабировать систему и какие компоненты можно горизонтально масштабировать?
Таймкод: 00:15:48
Ответ собеседника: Правильный. Инстансы серверов в Docker-контейнерах масштабируются горизонтально, базы данных сложнее из-за нормализации, CDN и S3 выносятся отдельно.
Правильный ответ:
Масштабирование агрегатора новостей требует дифференцированного подхода к каждому компоненту системы. Разные слои архитектуры имеют разную природу и масштабируются по-разному — от простого горизонтального добавления реплик до сложного шардирования данных.
Уровни масштабирования:
1. Горизонтальное масштабирование stateless-сервисов (самый простой уровень)
API Gateway, BFF, микросвисы не хранят состояние — их можно реплицировать без ограничений.
# kubernetes/deployment/api-gateway.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
spec:
replicas: 3 # Начинаем с 3, автомасштабирование по CPU/RPS
selector:
matchLabels:
app: api-gateway
template:
metadata:
labels:
app: api-gateway
spec:
containers:
- name: api-gateway
image: news-aggregator/api-gateway:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-gateway-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-gateway
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "1000" # Масштабируемся при >1000 RPS на под
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 60 # Удваиваем количество подов за минуту
scaleDown:
stabilizationWindowSeconds: 300 # 5 минут стабилизации перед уменьшением
policies:
- type: Percent
value: 25
periodSeconds: 60 # Уменьшаем медленно
2. Масштабирование базы данных (read replicas + шардирование)
PostgreSQL масштабируется на чтение через реплики, на запись — через шардирование.
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// DBCluster управляет подключениями к primary и read-репликам
type DBCluster struct {
primary *pgxpool.Pool
replicas []*pgxpool.Pool
replicaIdx uint32 // для round-robin
}
func NewDBCluster(primaryURL string, replicaURLs []string) (*DBCluster, error) {
primary, err := pgxpool.New(context.Background(), primaryURL)
if err != nil {
return nil, fmt.Errorf("connect to primary: %w", err)
}
replicas := make([]*pgxpool.Pool, 0, len(replicaURLs))
for _, url := range replicaURLs {
pool, err := pgxpool.New(context.Background(), url)
if err != nil {
return nil, fmt.Errorf("connect to replica: %w", err)
}
replicas = append(replicas, pool)
}
return &DBCluster{
primary: primary,
replicas: replicas,
}, nil
}
// Primary возвращает подключение к основной БД (для записи)
func (c *DBCluster) Primary() *pgxpool.Pool {
return c.primary
}
// Replica возвращает подключение к реплике (для чтения) с round-robin
func (c *DBCluster) Replica() *pgxpool.Pool {
if len(c.replicas) == 0 {
return c.primary
}
idx := (c.replicaIdx + 1) % uint32(len(c.replicas))
return c.replicas[idx]
}
// Close закрывает все подключения
func (c *DBCluster) Close() {
c.primary.Close()
for _, r := range c.replicas {
r.Close()
}
}
Разделение запросов на чтение и запись в репозитории:
package repository
type ArticleRepository struct {
cluster *database.DBCluster
}
// GetArticle — операция чтения, идёт в реплику
func (r *ArticleRepository) GetArticle(ctx context.Context, id int64) (*model.Article, error) {
db := r.cluster.Replica()
var article model.Article
err := db.QueryRow(ctx, `
SELECT id, title, content, author_id, likes, dislikes, score, created_at, updated_at
FROM articles
WHERE id = $1
`, id).Scan(
&article.ID, &article.Title, &article.Content, &article.AuthorID,
&article.Likes, &article.Dislikes, &article.Score,
&article.CreatedAt, &article.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get article: %w", err)
}
return &article, nil
}
// CreateArticle — операция записи, идёт в primary
func (r *ArticleRepository) CreateArticle(ctx context.Context, article *model.Article) error {
db := r.cluster.Primary()
err := db.QueryRow(ctx, `
INSERT INTO articles (title, content, author_id, likes, dislikes, score)
VALUES ($1, $2, $3, 0, 0, 0)
RETURNING id, created_at, updated_at
`, article.Title, article.Content, article.AuthorID).Scan(
&article.ID, &article.CreatedAt, &article.UpdatedAt,
)
if err != nil {
return fmt.Errorf("create article: %w", err)
}
return nil
}
Конфигурация PostgreSQL репликации:
-- На primary сервере (postgresql.conf):
-- wal_level = replica
-- max_wal_senders = 10
-- wal_keep_size = 1GB
-- Создание пользователя для репликации:
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'secret';
-- На реплике (pg_basebackup для начальной настройки):
-- pg_basebackup -h primary-host -U replicator -D /var/lib/postgresql/data -Fp -Xs -P
-- Мониторинг лага репликации:
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replication_lag
FROM pg_stat_replication;
3. Шардирование для масштабирования записи
Когда одна PostgreSQL не справляется с записью, применяется шардирование. Для агрегатора новостей логично шардировать по категории или временному диапазону.
package sharding
import (
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5/pgxpool"
)
// ShardRouter определяет, на какой шард направить запрос
type ShardRouter struct {
shards []*pgxpool.Pool
}
func NewShardRouter(shardURLs []string) (*ShardRouter, error) {
shards := make([]*pgxpool.Pool, len(shardURLs))
for i, url := range shardURLs {
pool, err := pgxpool.New(context.Background(), url)
if err != nil {
return nil, fmt.Errorf("connect to shard %d: %w", i, err)
}
shards[i] = pool
}
return &ShardRouter{shards: shards}, nil
}
// GetShardByID определяет шард по ID статьи (consistent hashing)
func (sr *ShardRouter) GetShardByID(id int64) *pgxpool.Pool {
shardIdx := id % int64(len(sr.shards))
return sr.shards[shardIdx]
}
// GetShardByCategory определяет шард по категории
func (sr *ShardRouter) GetShardByCategory(category string) *pgxpool.Pool {
h := fnv.New32a()
h.Write([]byte(category))
shardIdx := h.Sum32() % uint32(len(sr.shards))
return sr.shards[shardIdx]
}
// GetShardCount возвращает количество шардов
func (sr *ShardRouter) GetShardCount() int {
return len(sr.shards)
}
4. Масштабирование Redis (Cluster Mode)
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type RedisCluster struct {
client *redis.ClusterClient
}
func NewRedisCluster(addrs []string) *RedisCluster {
return &RedisCluster{
client: redis.NewClusterClient(&redis.ClusterOptions{
Addrs: addrs,
Password: "",
PoolSize: 100,
MinIdleConns: 10,
MaxRetries: 3,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
PoolTimeout: 5 * time.Second,
}),
}
}
func (rc *RedisCluster) Get(ctx context.Context, key string) (string, error) {
return rc.client.Get(ctx, key).Result()
}
func (rc *RedisCluster) Set(ctx context.Context, key, value string, ttl time.Duration) error {
return rc.client.Set(ctx, key, value, ttl).Err()
}
// Пример: распределённый rate limiter через Redis Cluster
func (rc *RedisCluster) RateLimit(ctx context.Context, key string, maxReq int, window time.Duration) (bool, error) {
now := time.Now().UnixNano()
windowStart := now - window.Nanoseconds()
pipe := rc.client.TxPipeline()
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
pipe.ZCard(ctx, key)
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
pipe.Expire(ctx, key, window)
cmds, err := pipe.Exec(ctx)
if err != nil {
return false, err
}
count := cmds[1].(*redis.IntCmd).Val()
return count < int64(maxReq), nil
}
5. Масштабирование Elasticsearch:
package search
import (
"context"
"encoding/json"
"fmt"
"github.com/elastic/go-elasticsearch/v8"
)
type SearchService struct {
client *elasticsearch.Client
}
func NewSearchService(addresses []string) (*SearchService, error) {
cfg := elasticsearch.Config{
Addresses: addresses,
}
client, err := elasticsearch.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("create ES client: %w", err)
}
return &SearchService{client: client}, nil
}
// IndexArticle индексирует статью в Elasticsearch
func (s *SearchService) IndexArticle(ctx context.Context, article *Article) error {
data, _ := json.Marshal(article)
_, err := s.client.Index(
"articles",
bytes.NewReader(data),
s.client.Index.WithDocumentID(fmt.Sprintf("%d", article.ID)),
s.client.Index.WithContext(ctx),
s.client.Index.WithRefresh("wait_for"), // Для тестов; в production убрать
)
return err
}
// Search полнотекстовый поиск с фильтрацией
func (s *SearchService) Search(ctx context.Context, query string, category string, from, size int) (*SearchResult, error) {
var buf bytes.Buffer
searchQuery := map[string]interface{}{
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []map[string]interface{}{
{
"multi_match": map[string]interface{}{
"query": query,
"fields": []string{"title^3", "content", "summary^2"},
"type": "best_fields",
},
},
},
"filter": []map[string]interface{}{},
},
},
"sort": []map[string]interface{}{
{"_score": map[string]string{"order": "desc"}},
{"published_at": map[string]string{"order": "desc"}},
},
"from": from,
"size": size,
"highlight": map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content": map[string]interface{}{"fragment_size": 200},
},
},
}
if category != "" {
filters := searchQuery["query"].(map[string]interface{})["bool"].(map[string]interface{})["filter"].([]map[string]interface{})
filters = append(filters, map[string]interface{}{
"term": map[string]interface{}{"category": category},
})
searchQuery["query"].(map[string]interface{})["bool"].(map[string]interface{})["filter"] = filters
}
json.NewEncoder(&buf).Encode(searchQuery)
res, err := s.client.Search(
s.client.Search.WithContext(ctx),
s.client.Search.WithIndex("articles"),
s.client.Search.WithBody(&buf),
)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
defer res.Body.Close()
// Парсинг результата...
return parseSearchResult(res)
}
6. Масштабирование Kafka (partition-based):
package kafka
import (
"context"
"fmt"
"github.com/segmentio/kafka-go"
)
// KafkaCluster управляет producer и consumer с учётом партиций
type KafkaCluster struct {
brokers []string
topic string
}
func (kc *KafkaCluster) CreateTopicIfNotExists(partitions int, replicationFactor int) error {
conn, err := kafka.Dial("tcp", kc.brokers[0])
if err != nil {
return fmt.Errorf("dial kafka: %w", err)
}
defer conn.Close()
topicConfig := kafka.TopicConfig{
Topic: kc.topic,
NumPartitions: partitions, // Больше партиций = больше параллелизм
ReplicationFactor: replicationFactor, // Минимум 3 для отказоустойчивости
}
return conn.CreateTopics(topicConfig)
}
// Количество consumer'ов в группе не может превышать количество партиций.
// Для масштабирования обработки — увеличиваем партиции.
Сводная таблица масштабируемости компонентов:
| Компонент | Стратегия | Сложность | Лимит |
|---|---|---|---|
| API Gateway | HPA (Horizontal Pod Autoscaler) | Низкая | Практически не ограничен |
| BFF (Next.js) | HPA + CDN | Низкая | Не ограничен |
| News Service | HPA | Низкая | Не ограничен |
| Crawler Service | Partition-based scaling | Средняя | По количеству источников |
| PostgreSQL (чтение) | Read Replicas | Средняя | До 10–20 реплик |
| PostgreSQL (запись) | Шардирование | Высокая | Зависит от схемы |
| Redis | Cluster Mode | Средняя | До 1000 нод |
| Elasticsearch | Добавление нод | Средняя | До 100 нод |
| Kafka | Увеличение партиций | Средняя | По партициям |
| S3/MinIO | Erasure Coding | Низкая | Почти не ограничен |
| CDN | Автоматическое | Низкая | Не ограничен |
Итого: stateless-сервисы (API Gateway, BFF, микросервисы) масштабируются горизонтально через Kubernetes HPA без ограничений. Базы данных масштабируются на чтение через реплики, на запись — через шардирование. Redis и Elasticsearch масштабируются кластерно. Kafka масштабируется увеличением партиций. S3 и CDN масштабируются автоматически. Ключевой принцип — разделять stateful и stateless компоненты, применяя для каждого свою стратегию масштабирования.
Вопрос 13. Какой тип базы данных выбрать для хранения новостей и почему?
Таймкод: 00:17:00
Ответ собеседника: Правильный. Документоориентированная база данных (MongoDB) подойдёт лучше всего для работы с Node.js, гибкая схема и хорошее масштабирование.
Правильный ответ:
Выбор базы данных для агрегатора новостей — это не вопрос «MongoDB vs PostgreSQL» в чистом виде, а вопрос полифокального хранения (polyglot persistence), где разные типы данных хранятся в наиболее подходящем хранилище. Однако если выбирать одну основную СУБД для ключевых данных (новости, пользователи, голоса), то PostgreSQL является более обоснованным выбором, чем MongoDB.
Почему PostgreSQL лучше подходит как основная БД:
1. Структурированность данных новостей
Новости имеют чётко определённую структуру: заголовок, содержание, автор, дата публикации, категория, теги, рейтинг. Это реляционные данные с предсказуемой схемой, для которых реляционная модель оптимальна.
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(600) NOT NULL UNIQUE,
summary TEXT,
content TEXT NOT NULL,
content_html TEXT, -- Очищенный HTML
source_url VARCHAR(2000) NOT NULL,
source_name VARCHAR(200) NOT NULL,
author_id BIGINT REFERENCES users(id),
category_id INTEGER REFERENCES categories(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
likes BIGINT NOT NULL DEFAULT 0,
dislikes BIGINT NOT NULL DEFAULT 0,
score BIGINT NOT NULL DEFAULT 0,
views BIGINT NOT NULL DEFAULT 0,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
language VARCHAR(5) NOT NULL DEFAULT 'ru',
meta_title VARCHAR(60), -- SEO
meta_description VARCHAR(160), -- SEO
featured_image_id UUID REFERENCES images(id)
);
CREATE UNIQUE INDEX idx_articles_slug ON articles(slug);
CREATE INDEX idx_articles_status_published ON articles(status, published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_articles_category ON articles(category_id, published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_articles_score ON articles(score DESC, published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_articles_source ON articles(source_name, published_at DESC);
2. ACID-транзакции для критичных операций
Голосование (лайк/дизлайк) требует атомарности: нельзя одновременно добавить лайк и не обновить score. PostgreSQL гарантирует это на уровне транзакций.
-- Атомарное голосование с проверкой на повторное голосование
BEGIN;
-- Блокируем строку статьи для предотвращения гонок
SELECT score, likes, dislikes
FROM articles
WHERE id = $1
FOR UPDATE;
-- Проверяем, голосовал ли уже пользователь
SELECT vote_type FROM article_votes
WHERE article_id = $1 AND user_id = $2;
-- Если голоса нет — добавляем
INSERT INTO article_votes (article_id, user_id, vote_type)
VALUES ($1, $2, $3)
ON CONFLICT (article_id, user_id)
DO UPDATE SET vote_type = $3, updated_at = NOW();
-- Атомарно обновляем счётчики
UPDATE articles
SET likes = likes + $4,
dislikes = $5,
score = score + $6,
updated_at = NOW()
WHERE id = $1;
COMMIT;
// Реализация голосования в Go с транзакциями
func (r *ArticleRepository) Vote(ctx context.Context, articleID, userID int64, voteType model.VoteType) (int64, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// Получаем текущий голос пользователя
var existingVote sql.NullInt16
err = tx.QueryRow(ctx, `
SELECT vote_type FROM article_votes
WHERE article_id = $1 AND user_id = $2
FOR UPDATE
`, articleID, userID).Scan(&existingVote)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("get existing vote: %w", err)
}
// Вычисляем дельту для счётчиков
var likeDelta, dislikeDelta, scoreDelta int64
oldVote := model.VoteType(0)
if existingVote.Valid {
oldVote = model.VoteType(existingVote.Int16)
}
switch {
case oldVote == voteType:
return 0, ErrAlreadyVoted // Уже проголосовал таким образом
case oldVote == model.VoteLike && voteType == model.VoteDislike:
likeDelta, dislikeDelta, scoreDelta = -1, 1, -2
case oldVote == model.VoteDislike && voteType == model.VoteLike:
likeDelta, dislikeDelta, scoreDelta = 1, -1, 2
case oldVote == model.VoteNone && voteType == model.VoteLike:
likeDelta, dislikeDelta, scoreDelta = 1, 0, 1
case oldVote == model.VoteNone && voteType == model.VoteDislike:
likeDelta, dislikeDelta, scoreDelta = 0, 1, -1
}
// Обновляем или создаём голос
_, err = tx.Exec(ctx, `
INSERT INTO article_votes (article_id, user_id, vote_type, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (article_id, user_id)
DO UPDATE SET vote_type = $3, updated_at = NOW()
`, articleID, userID, voteType)
if err != nil {
return 0, fmt.Errorf("upsert vote: %w", err)
}
// Атомарно обновляем счётчики
var newScore int64
err = tx.QueryRow(ctx, `
UPDATE articles
SET likes = likes + $2,
dislikes = dislikes + $3,
score = score + $4,
updated_at = NOW()
WHERE id = $1
RETURNING score
`, articleID, likeDelta, dislikeDelta, scoreDelta).Scan(&newScore)
if err != nil {
return 0, fmt.Errorf("update score: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit transaction: %w", err)
}
return newScore, nil
}
3. Полнотекстовый поиск из коробки
PostgreSQL имеет встроенный полнотекстовый поиск, который покрывает базовые потребности без отдельного Elasticsearch.
-- Добавляем столец с поисковым вектором
ALTER TABLE articles ADD COLUMN search_vector TSVECTOR;
-- Создаём GIN-индекс для быстрого поиска
CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);
-- Функция автоматического обновления поискового вектора
CREATE OR REPLACE FUNCTION articles_search_vector_update()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('russian', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('russian', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('russian', COALESCE(NEW.content, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_articles_search_vector
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW
EXECUTE FUNCTION articles_search_vector_update();
-- Поиск с ранжированием
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('russian', 'путин & заявление') AS query
WHERE search_vector @@ query
AND status = 'published'
ORDER BY rank DESC
LIMIT 20;
4. JSONB для гибких полей
PostgreSQL поддерживает JSONB для полей с переменной структурой, что даёт гибкость MongoDB в рамках реляционной БД.
-- Метаданные источника могут различаться для разных парсеров
ALTER TABLE articles ADD COLUMN source_metadata JSONB DEFAULT '{}';
-- Пример данных:
-- {"parser_version": "2.1", "original_id": "ria_12345", "tags": ["политика"]}
-- Индекс на JSONB для быстрого поиска
CREATE INDEX idx_articles_source_metadata ON articles USING GIN(source_metadata);
-- Поиск по JSONB-полю
SELECT * FROM articles
WHERE source_metadata @> '{"tags": ["политика"]}';
5. Партиционирование для больших объёмов данных
Новостные данные имеют естественный временной порядок. Партиционирование по месяцам позволяет эффективно удалять старые данные и ускорять запросы.
-- Создаём партиционированную таблицу
CREATE TABLE articles_partitioned (
id BIGSERIAL,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
source_name VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'published',
score BIGINT NOT NULL DEFAULT 0,
published_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (published_at);
-- Создаём партиции по месяцам
CREATE TABLE articles_2024_01 PARTITION OF articles_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE articles_2024_02 PARTITION OF articles_partitioned
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE articles_2024_03 PARTITION OF articles_partitioned
FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- Автоматическое создание партиций (через pg_partman или cron)
-- Удаление старых данных — DROP TABLE вместо DELETE (мгновенно)
DROP TABLE articles_2023_01;
Полифокальное хранение — рекомендуемая архитектура:
| Тип данных | Хранилище | Обоснование |
|---|---|---|
| Новости, пользователи, голоса | PostgreSQL | ACID, структурированные данные, полнотекстовый поиск |
| Сессии, rate limiting, кэш | Redis | Быстрый доступ, TTL, атомарные операции |
| Полнотекстовый поиск (продвинутый) | Elasticsearch | Морфология, синонимы, фасеты, подсветка |
| Аналитика, клики, просмотры | ClickHouse | Колоночное хранение, быстрая агрегация |
| Изображения, медиа | S3 (MinIO) | Объектное хранение, CDN-интеграция |
| Очереди событий | Kafka | Гарантия доставки, порядок сообщений |
Итого: PostgreSQL — оптимальный выбор как основная СУБД для агрегатора новостей. Он обеспечивает ACID-транзакции для голосования, полнотекстовый поиск из коробки, JSONB для гибких полей, партиционирование по времени и масштабирование через read-реплики. MongoDB может быть полезна для хранения сырых данных парсеров (HTML, метаданные источников), но не как основная БД. Для production-системы рекомендуется полифокальный подход: PostgreSQL + Redis + Elasticsearch + ClickHouse + S3.
Вопрос 14. Какие сущности данных необходимо спроектировать для агрегатора новостей?
Таймкод: 00:18:39
Ответ собеседника: Правильный. Основные сущности: User (с ролями reader/author) и Post (новость), с возможностью масштабирования ролей.
Правильный ответ:
Для агрегатора новостей необходимо спроектировать полную модель данных, покрывающую не только пользователей и новости, но и все связанные сущности: категории, теги, голоса, источники, комментарии, подписки, изображения, сессии и аналитика. Ниже приведена полная ER-модель с описанием каждой сущности.
Полная модель данных:
-- =============================================
-- 1. ПОЛЬЗОВАТЕЛИ И АУТЕНТИФИКАЦИЯ
-- =============================================
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE, -- 'reader', 'author', 'editor', 'admin', 'moderator'
description TEXT,
permissions JSONB NOT NULL DEFAULT '[]', -- ["read", "write", "moderate", "admin"]
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255), -- NULL для OAuth-пользователей
display_name VARCHAR(200),
avatar_image_id UUID REFERENCES images(id),
bio TEXT,
role_id INTEGER NOT NULL DEFAULT 1 REFERENCES roles(id),
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_role ON users(role_id);
-- OAuth-провайдеры (Google, VK, Yandex, Telegram)
CREATE TABLE user_oauth_accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- 'google', 'vk', 'yandex', 'telegram'
provider_uid VARCHAR(255) NOT NULL,
access_token TEXT,
refresh_token TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_uid)
);
-- Сессии пользователей
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at);
-- =============================================
-- 2. КАТЕГОРИИ И ТЕГИ
-- =============================================
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
description TEXT,
parent_id INTEGER REFERENCES categories(id), -- иерархия
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
meta_title VARCHAR(60),
meta_description VARCHAR(160),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_categories_slug ON categories(slug);
CREATE INDEX idx_categories_parent ON categories(parent_id);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(120) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tags_slug ON tags(slug);
-- =============================================
-- 3. ИСТОЧНИКИ НОВОСТЕЙ
-- =============================================
CREATE TABLE news_sources (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL UNIQUE,
base_url VARCHAR(500) NOT NULL,
rss_url VARCHAR(500),
description TEXT,
logo_image_id UUID REFERENCES images(id),
language VARCHAR(5) NOT NULL DEFAULT 'ru',
country VARCHAR(2), -- ISO 3166-1 alpha-2
reliability SMALLINT DEFAULT 50 CHECK (reliability BETWEEN 0 AND 100),
crawl_interval INTERVAL NOT NULL DEFAULT '5 minutes',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_crawled_at TIMESTAMPTZ,
last_error TEXT,
parser_config JSONB DEFAULT '{}', -- Настройки парсера для источника
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sources_active ON news_sources(is_active) WHERE is_active = TRUE;
-- =============================================
-- 4. НОВОСТИ (СТАТЬИ)
-- =============================================
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(600) NOT NULL UNIQUE,
summary TEXT,
content TEXT NOT NULL,
content_html TEXT,
source_id INTEGER REFERENCES news_sources(id),
source_url VARCHAR(2000) NOT NULL,
author_id BIGINT REFERENCES users(id),
category_id INTEGER REFERENCES categories(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived', 'moderation')),
likes BIGINT NOT NULL DEFAULT 0,
dislikes BIGINT NOT NULL DEFAULT 0,
score BIGINT NOT NULL DEFAULT 0,
views BIGINT NOT NULL DEFAULT 0,
comments_count INTEGER NOT NULL DEFAULT 0,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
language VARCHAR(5) NOT NULL DEFAULT 'ru',
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
meta_title VARCHAR(60),
meta_description VARCHAR(160),
featured_image_id UUID REFERENCES images(id),
search_vector TSVECTOR,
source_metadata JSONB DEFAULT '{}'
);
CREATE UNIQUE INDEX idx_articles_slug ON articles(slug);
CREATE INDEX idx_articles_status_published ON articles(status, published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_articles_category ON articles(category_id, published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_articles_score ON articles(score DESC, published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_articles_source ON articles(source_id, published_at DESC);
CREATE INDEX idx_articles_author ON articles(author_id, published_at DESC);
CREATE INDEX idx_articles_featured ON articles(is_featured, published_at DESC)
WHERE is_featured = TRUE AND status = 'published';
CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);
CREATE INDEX idx_articles_language ON articles(language, published_at DESC)
WHERE status = 'published';
-- Связь статьи и тегов (many-to-many)
CREATE TABLE article_tags (
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (article_id, tag_id)
);
CREATE INDEX idx_article_tags_tag ON article_tags(tag_id);
-- =============================================
-- 5. ИЗОБРАЖЕНИЯ
-- =============================================
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id BIGINT REFERENCES articles(id) ON DELETE SET NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
original_key VARCHAR(500) NOT NULL,
original_url VARCHAR(1000) NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format VARCHAR(10) NOT NULL,
blur_hash VARCHAR(100),
file_size_bytes INTEGER NOT NULL,
variants JSONB NOT NULL DEFAULT '{}',
alt_text VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_images_article ON images(article_id);
CREATE INDEX idx_images_user ON images(user_id);
-- =============================================
-- 6. ГОЛОСОВАНИЕ (ЛАЙКИ / ДИЗЛАЙКИ)
-- =============================================
CREATE TABLE article_votes (
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vote_type SMALLINT NOT NULL CHECK (vote_type IN (-1, 1)), -- -1 dislike, 1 like
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (article_id, user_id)
);
CREATE INDEX idx_article_votes_user ON article_votes(user_id);
CREATE INDEX idx_article_votes_article ON article_votes(article_id);
-- =============================================
-- 7. КОММЕНТАРИИ (древовидная структура)
-- =============================================
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id BIGINT REFERENCES comments(id) ON DELETE CASCADE, -- ответ на комментарий
content TEXT NOT NULL,
likes INTEGER NOT NULL DEFAULT 0,
dislikes INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE, -- soft delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_comments_article ON comments(article_id, created_at DESC);
CREATE INDEX idx_comments_user ON comments(user_id);
CREATE INDEX idx_comments_parent ON comments(parent_id) WHERE parent_id IS NOT NULL;
-- =============================================
-- 8. ПОДПИСКИ
-- =============================================
CREATE TABLE user_subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_type VARCHAR(50) NOT NULL, -- 'category', 'tag', 'author', 'source'
target_id BIGINT NOT NULL, -- ID соответствующей сущности
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, target_type, target_id)
);
CREATE INDEX idx_subscriptions_user ON user_subscriptions(user_id);
-- =============================================
-- 9. ЗАКЛАДКИ
-- =============================================
CREATE TABLE user_bookmarks (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, article_id)
);
CREATE INDEX idx_bookmarks_user ON user_bookmarks(user_id, created_at DESC);
-- =============================================
-- 10. ЧТЕНИЕ ИСТОРИИ
-- =============================================
CREATE TABLE reading_history (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
duration_seconds INTEGER, -- сколько секунд читал
scroll_depth SMALLINT, -- процент прокрутки (0-100)
UNIQUE(user_id, article_id)
);
CREATE INDEX idx_reading_history_user ON reading_history(user_id, read_at DESC);
-- =============================================
-- 11. ЖУРНАЛ МОДЕРАЦИИ
-- =============================================
CREATE TABLE moderation_log (
id BIGSERIAL PRIMARY KEY,
article_id BIGINT REFERENCES articles(id) ON DELETE SET NULL,
moderator_id BIGINT NOT NULL REFERENCES users(id),
action VARCHAR(50) NOT NULL, -- 'approve', 'reject', 'edit', 'delete'
reason TEXT,
previous_status VARCHAR(20),
new_status VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_moderation_article ON moderation_log(article_id);
CREATE INDEX idx_moderation_moderator ON moderation_log(moderator_id);
-- =============================================
-- 12. НАСТРОЙКИ ПОЛЬЗОВАТЕЛЯ
-- =============================================
CREATE TABLE user_preferences (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferred_language VARCHAR(5) NOT NULL DEFAULT 'ru',
preferred_categories INTEGER[] DEFAULT '{}',
email_notifications BOOLEAN NOT NULL DEFAULT TRUE,
push_notifications BOOLEAN NOT NULL DEFAULT FALSE,
theme VARCHAR(20) NOT NULL DEFAULT 'system', -- 'light', 'dark', 'system'
items_per_page INTEGER NOT NULL DEFAULT 20 CHECK (items_per_page BETWEEN 5 AND 100),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Go-модели для основных сущностей:
package model
import "time"
type Role struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Permissions []string `json:"permissions" db:"permissions"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type User struct {
ID int64 `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
DisplayName string `json:"display_name" db:"display_name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
Bio string `json:"bio" db:"bio"`
RoleID int `json:"role_id" db:"role_id"`
Role *Role `json:"role,omitempty"`
EmailVerified bool `json:"email_verified" db:"email_verified"`
IsActive bool `json:"is_active" db:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type Article struct {
ID int64 `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Slug string `json:"slug" db:"slug"`
Summary string `json:"summary" db:"summary"`
Content string `json:"content" db:"content"`
ContentHTML string `json:"content_html,omitempty" db:"content_html"`
SourceID *int `json:"source_id,omitempty" db:"source_id"`
SourceURL string `json:"source_url" db:"source_url"`
AuthorID *int64 `json:"author_id,omitempty" db:"author_id"`
Author *User `json:"author,omitempty"`
CategoryID *int `json:"category_id,omitempty" db:"category_id"`
Category *Category `json:"category,omitempty"`
Tags []Tag `json:"tags,omitempty"`
Status string `json:"status" db:"status"`
Likes int64 `json:"likes" db:"likes"`
Dislikes int64 `json:"dislikes" db:"dislikes"`
Score int64 `json:"score" db:"score"`
Views int64 `json:"views" db:"views"`
CommentsCount int `json:"comments_count" db:"comments_count"`
PublishedAt *time.Time `json:"published_at,omitempty" db:"published_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Language string `json:"language" db:"language"`
IsFeatured bool `json:"is_featured" db:"is_featured"`
FeaturedImage *Image `json:"featured_image,omitempty"`
}
type Category struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
ParentID *int `json:"parent_id,omitempty" db:"parent_id"`
Children []Category `json:"children,omitempty"`
SortOrder int `json:"sort_order" db:"sort_order"`
IsActive bool `json:"is_active" db:"is_active"`
}
type Tag struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
}
type NewsSource struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
BaseURL string `json:"base_url" db:"base_url"`
RSSURL string `json:"rss_url,omitempty" db:"rss_url"`
Description string `json:"description" db:"description"`
Language string `json:"language" db:"language"`
Country string `json:"country" db:"country"`
Reliability int `json:"reliability" db:"reliability"`
CrawlInterval string `json:"crawl_interval" db:"crawl_interval"`
IsActive bool `json:"is_active" db:"is_active"`
LastCrawledAt *time.Time `json:"last_crawled_at,omitempty" db:"last_crawled_at"`
}
type Vote struct {
ArticleID int64 `json:"article_id" db:"article_id"`
UserID int64 `json:"user_id" db:"user_id"`
VoteType int8 `json:"vote_type" db:"vote_type"` // 1 = like, -1 = dislike
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type Comment struct {
ID int64 `json:"id" db:"id"`
ArticleID int64 `json:"article_id" db:"article_id"`
UserID int64 `json:"user_id" db:"user_id"`
ParentID *int64 `json:"parent_id,omitempty" db:"parent_id"`
Content string `json:"content" db:"content"`
Likes int `json:"likes" db:"likes"`
Dislikes int `json:"dislikes" db:"dislikes"`
Score int `json:"score" db:"score"`
IsDeleted bool `json:"is_deleted" db:"is_deleted"`
User *User `json:"user,omitempty"`
Replies []Comment `json:"replies,omitempty"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
Диаграмма связей основных сущностей:
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ roles │────<│ users │>────│ images │
└──────────┘ └──────┬───────┘ └──────────────┘
│
┌─────────┼─────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────┐ ┌──────────────────┐
│ comments │ │ votes │ │ user_subscriptions│
└─────┬──────┘ └───┬────┘ └──────────────────┘
│ │
└─────┬─────┘
▼
┌───────────────┐ ┌──────────────┐
│ articles │>────│ categories │
└───┬───────┬───┘ └──────────────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│article_ │ │ news_sources │
│ tags │ └──────────────┘
└────┬─────┘
│
▼
┌──────────┐
│ tags │
└──────────┘
Итого: полная модель данных агрегатора новостей включает 12+ основных сущностей: users, roles, categories, tags, news_sources, articles, images, votes, comments, subscriptions, bookmarks, reading_history, moderation_log, user_preferences. Каждая сущность спроектирована с учётом индексов для частых запросов, внешних ключей для целостности и полей для SEO-оптимизации. Модель поддерживает иерархические категории, древовидные комментарии, гибкие подписки и полнотекстовый поиск.
Вопрос 15. Как хранить список понравившихся постов пользователя и для чего это нужно?
Таймкод: 00:21:34
Ответ собеседника: Правильный. Хранить список ID понравившихся постов (likedPosts) в сущности User для рекомендательной системы.
Правильный ответ:
Хранение списка понравившихся постов — это отдельная задача, которая решается через реляционную связь между пользователем и статьями, а не через массив в поле пользователя. Такой подход обеспечивает нормализацию, эффективные запросы и масштабируемость.
Почему не хранить массив ID в поле User:
Хранение массива likedPosts: [1, 5, 123, 4567] в JSONB-поле таблицы users — антипаттерн для реляционной БД. Это приводит к невозможности эффективного поиска («какие пользователи лайкнули статью X»), нарушению ссылочной целостности, проблемам с производительностью при большом количестве лайков и сложностям с дедупликацией.
Правильное решение — отдельная таблица голосов:
-- Таблица голосов (уже спроектирована в модели данных)
CREATE TABLE article_votes (
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vote_type SMALLINT NOT NULL CHECK (vote_type IN (-1, 1)), -- 1 = like, -1 = dislike
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (article_id, user_id)
);
CREATE INDEX idx_votes_user ON article_votes(user_id, created_at DESC);
CREATE INDEX idx_votes_article ON article_votes(article_id);
CREATE INDEX idx_votes_user_likes ON article_votes(user_id, created_at DESC)
WHERE vote_type = 1;
Для чего нужен список понравившихся постов:
1. Персонализированная лента и рекомендации
На основе лайков строится профиль интересов пользователя. Анализируются категории, теги, источники и авторы понравившихся статей, после чего подбираются похожие.
package recommendation
type UserProfile struct {
UserID int64 `json:"user_id"`
CategoryWeights map[int]float64 `json:"category_weights"` // category_id -> weight
TagWeights map[int]float64 `json:"tag_weights"` // tag_id -> weight
SourceWeights map[int]float64 `json:"source_weights"` // source_id -> weight
UpdatedAt time.Time `json:"updated_at"`
}
type RecommendationService struct {
voteRepo VoteRepository
articleRepo ArticleRepository
userRepo UserRepository
cache *cache.RedisCluster
}
// BuildUserProfile строит профиль интересов пользователя на основе лайков
func (rs *RecommendationService) BuildUserProfile(ctx context.Context, userID int64) (*UserProfile, error) {
// Получаем все лайки пользователя за последние 90 дней
since := time.Now().Add(-90 * 24 * time.Hour)
likedArticles, err := rs.voteRepo.GetLikedArticles(ctx, userID, since)
if err != nil {
return nil, fmt.Errorf("get liked articles: %w", err)
}
profile := &UserProfile{
UserID: userID,
CategoryWeights: make(map[int]float64),
TagWeights: make(map[int]float64),
SourceWeights: make(map[int]float64),
}
// Временной декэй — свежие лайки важнее
for _, article := range likedArticles {
daysAgo := time.Since(article.PublishedAt).Hours() / 24
timeWeight := math.Exp(-0.01 * daysAgo) // экспоненциальный спад
if article.CategoryID != nil {
profile.CategoryWeights[*article.CategoryID] += timeWeight
}
for _, tag := range article.Tags {
profile.TagWeights[tag.ID] += timeWeight * 0.5
}
if article.SourceID != nil {
profile.SourceWeights[*article.SourceID] += timeWeight * 0.3
}
}
// Нормализация весов
normalizeWeights(profile.CategoryWeights)
normalizeWeights(profile.TagWeights)
normalizeWeights(profile.SourceWeights)
profile.UpdatedAt = time.Now()
// Кэшируем профиль
rs.cache.Set(ctx, fmt.Sprintf("user_profile:%d", userID), profile, 1*time.Hour)
return profile, nil
}
// GetRecommendations возвращает рекомендованные статьи
func (rs *RecommendationService) GetRecommendations(ctx context.Context, userID int64, limit int) ([]*model.Article, error) {
// Проверяем кэш
cacheKey := fmt.Sprintf("recommendations:%d", userID)
cached, err := rs.cache.Get(ctx, cacheKey)
if err == nil {
return deserializeArticles(cached), nil
}
profile, err := rs.BuildUserProfile(ctx, userID)
if err != nil {
return nil, err
}
// Формируем запрос с учётом весов категорий и тегов
topCategories := getTopN(profile.CategoryWeights, 5)
topTags := getTopN(profile.TagWeights, 10)
topSources := getTopN(profile.SourceWeights, 3)
// Ищем статьи, похожие на профиль, исключая уже просмотренные
articles, err := rs.articleRepo.FindSimilar(ctx, topCategories, topTags, topSources, userID, limit)
if err != nil {
return nil, fmt.Errorf("find similar articles: %w", err)
}
// Кэшируем рекомендации на 15 минут
rs.cache.Set(ctx, cacheKey, serializeArticles(articles), 15*time.Minute)
return articles, nil
}
2. Отображение истории лайков в профиле пользователя
// Получить список понравившихся статей с пагинацией
func (r *VoteRepository) GetUserLikedArticles(ctx context.Context, userID int64, page, pageSize int) ([]*model.Article, int64, error) {
offset := (page - 1) * pageSize
// Общее количество
var total int64
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM article_votes
WHERE user_id = $1 AND vote_type = 1
`, userID).Scan(&total)
if err != nil {
return nil, 0, err
}
// Статьи с пагинацией
rows, err := r.db.Query(ctx, `
SELECT a.id, a.title, a.slug, a.summary, a.score, a.published_at,
a.featured_image_id, c.name as category_name
FROM article_votes av
JOIN articles a ON a.id = av.article_id
LEFT JOIN categories c ON c.id = a.category_id
WHERE av.user_id = $1 AND av.vote_type = 1 AND a.status = 'published'
ORDER BY av.created_at DESC
LIMIT $2 OFFSET $3
`, userID, pageSize, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var articles []*model.Article
for rows.Next() {
var a model.Article
err := rows.Scan(&a.ID, &a.Title, &a.Slug, &a.Summary, &a.Score,
&a.PublishedAt, &a.FeaturedImageID, &a.Category.Name)
if err != nil {
continue
}
articles = append(articles, &a)
}
return articles, total, nil
}
3. Аналитика и статистика для авторов
Авторы видят, какие их статьи получили больше всего лайков, в какое время и от какой аудитории.
-- Статистика лайков по авторам за последние 30 дней
SELECT
u.id AS author_id,
u.display_name,
COUNT(av.article_id) AS total_likes,
COUNT(DISTINCT av.user_id) AS unique_likers,
AVG(a.score) AS avg_article_score
FROM article_votes av
JOIN articles a ON a.id = av.article_id
JOIN users u ON u.id = a.author_id
WHERE av.vote_type = 1
AND av.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.display_name
ORDER BY total_likes DESC
LIMIT 20;
4. Антифрод и выявление накрутки
Паттерн голосования вызывает подозрение: множество лайков с новых аккаунтов, голосование в короткий промежуток времени, одинаковые IP-адреса.
type AntiFraudService struct {
voteRepo VoteRepository
cache *cache.RedisCluster
}
func (af *AntiFraudService) DetectVoteManipulation(ctx context.Context, articleID int64) (*FraudReport, error) {
// Проверка 1: Слишком много лайков за короткое время
recentVotes, err := af.voteRepo.GetRecentVotes(ctx, articleID, 10*time.Minute)
if err != nil {
return nil, err
}
report := &FraudReport{ArticleID: articleID}
if len(recentVotes) > 100 {
report.AddSuspicion("too_many_votes", "%d votes in 10 minutes", len(recentVotes))
}
// Проверка 2: Голоса от новых аккаунтов (< 24 часов)
newAccountVotes := 0
for _, vote := range recentVotes {
userAge, _ := af.getUserAge(ctx, vote.UserID)
if userAge < 24*time.Hour {
newAccountVotes++
}
}
if float64(newAccountVotes)/float64(len(recentVotes)) > 0.5 {
report.AddSuspicion("new_accounts", "%d%% votes from accounts < 24h old",
newAccountVotes*100/len(recentVotes))
}
// Проверка 3: Повторяющиеся IP-адреса
ipCounts := make(map[string]int)
for _, vote := range recentVotes {
ipCounts[vote.IPAddress]++
}
for ip, count := range ipCounts {
if count > 10 {
report.AddSuspicion("duplicate_ip", "IP %s voted %d times", ip, count)
}
}
return report, nil
}
5. Коллаборативная фильтрация («пользователям, которым понравилось это, также понравилось»)
-- Находим статьи, которые лайкали те же пользователи,
-- что лайкали текущую статью
WITH article_likers AS (
SELECT user_id
FROM article_votes
WHERE article_id = $1 AND vote_type = 1
),
similar_articles AS (
SELECT
av.article_id,
COUNT(DISTINCT av.user_id) AS common_likers
FROM article_votes av
JOIN article_likers al ON al.user_id = av.user_id
WHERE av.article_id != $1
AND av.vote_type = 1
GROUP BY av.article_id
HAVING COUNT(DISTINCT av.user_id) >= 3 -- минимум 3 общих лайка
)
SELECT
sa.article_id,
sa.common_likers,
a.title,
a.score,
a.published_at
FROM similar_articles sa
JOIN articles a ON a.id = sa.article_id
WHERE a.status = 'published'
ORDER BY sa.common_likers DESC, a.score DESC
LIMIT 10;
6. Кэширование списка лайков в Redis для быстрой проверки
// При загрузке ленты новостей нужно быстро определить,
// какие статьи лайкнуты текущим пользователем
func (af *AntiFraudService) DetectVoteManipulation(ctx context.Context, articleID int64) (*FraudReport, error) {
// Проверка 1: Слишком много лайков за короткое время
recentVotes, err := af.voteRepo.GetRecentVotes(ctx, articleID, 10*time.Minute)
if err != nil {
return nil, err
}
report := &FraudReport{ArticleID: articleID}
if len(recentVotes) > 100 {
report.AddSuspicion("too_many_votes", "%d votes in 10 minutes", len(recentVotes))
}
// Проверка 2: Голоса от новых аккаунтов (< 24 часов)
newAccountVotes := 0
for _, vote := range recentVotes {
userAge, _ := af.getUserAge(ctx, vote.UserID)
if userAge < 24*time.Hour {
newAccountVotes++
}
}
if float64(newAccountVotes)/float64(len(recentVotes)) > 0.5 {
report.AddSuspicion("new_accounts", "%d%% votes from accounts < 24h old",
newAccountVotes*100/len(recentVotes))
}
// Проверка 3: Повторяющиеся IP-адреса
ipCounts := make(map[string]int)
for _, vote := range recentVotes {
ipCounts[vote.IPAddress]++
}
for ip, count := range ipCounts {
if count > 10 {
report.AddSuspicion("duplicate_ip", "IP %s voted %d times", ip, count)
}
}
return report, nil
}
Итого: список понравившихся постов хранится в отдельной таблице article_votes с составным первичным ключом (article_id, user_id), а не в поле пользователя. Это обеспечивает ссылочную целостность, эффективные запросы в обоих направлениях и масштабируемость. Данные о голосах используются для рекомендательной системы (коллаборативная фильтрация, профиль интересов), аналитики для авторов, антифрод-системы и быстрой проверки состояния голоса при отображении ленты.
Вопрос 16. Как хранить аватар/логотип пользователя в базе данных?
Таймкод: 00:22:17
Ответ собеседника: Правильный. Хранить как объект с разными размерами изображений (XS, S, M, L) для адаптивности, либо как строку со ссылкой на S3-хранилище.
Правильный ответ:
Аватары пользователей хранятся по тому же принципу, что и изображения к новостям — в S3-совместимом хранилище, а в базе данных сохраняются только метаданные и ссылки. Для аватаров особенно важна генерация вариантов разных размеров, поскольку одно и то же изображение отображается в разных контекстах.
Модель хранения аватаров:
-- Таблица изображений (универсальная — и для аватаров, и для контента)
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
article_id BIGINT REFERENCES articles(id) ON DELETE SET NULL,
source VARCHAR(20) NOT NULL DEFAULT 'upload' CHECK (source IN ('upload', 'oauth', 'generated')),
-- Оригинал
original_key VARCHAR(500) NOT NULL, -- ключ в S3
original_url VARCHAR(1000) NOT NULL, -- URL через CDN
-- Метаданные
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format VARCHAR(10) NOT NULL, -- jpeg, png, webp
file_size_bytes INTEGER NOT NULL,
blur_hash VARCHAR(100), -- для placeholder
-- Варианты размеров (генерируются автоматически)
variants JSONB NOT NULL DEFAULT '{}',
-- Контекст использования
image_type VARCHAR(20) NOT NULL DEFAULT 'general'
CHECK (image_type IN ('avatar', 'featured', 'content', 'thumbnail')),
alt_text VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Либо user_id, либо article_id должен быть заполнен
CONSTRAINT chk_image_owner CHECK (
(user_id IS NOT NULL AND article_id IS NULL) OR
(user_id IS NULL AND article_id IS NOT NULL)
)
);
CREATE INDEX idx_images_user ON images(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_images_article ON images(article_id) WHERE article_id IS NOT NULL;
CREATE INDEX idx_images_type ON images(image_type);
-- В таблице users хранится только ссылка на текущий аватар
ALTER TABLE users ADD COLUMN avatar_image_id UUID REFERENCES images(id) ON DELETE SET NULL;
Варианты размеров для аватаров:
package image
// AvatarSizes — пресеты размеров для аватаров
var AvatarSizes = []ImageSize{
{Width: 32, Height: 32, Suffix: "xs"}, -- Комментарии, списки
{Width: 64, Height: 64, Suffix: "s"}, -- Карточки, уведомления
{Width: 128, Height: 128, Suffix: "m"}, -- Профиль, шапка
{Width: 256, Height: 256, Suffix: "l"}, -- Страница профиля
{Width: 512, Height: 512, Suffix: "xl"}, -- Модальное окно, превью
}
type AvatarService struct {
imageService *ImageService
repo ImageRepository
}
func (as *AvatarService) UploadAvatar(ctx context.Context, userID int64, fileReader io.Reader, contentType string, fileName string) (*model.Image, error) {
// 1. Валидация
if err := validateAvatarFile(fileReader, contentType); err != nil {
return nil, err
}
// 2. Обработка изображения
processed, err := as.imageService.UploadAndProcessWithType(
ctx, fileReader, contentType, fileName, "avatar", AvatarSizes)
if err != nil {
return nil, fmt.Errorf("process avatar: %w", err)
}
// 3. Сохраняем метаданные
image := &model.Image{
UserID: &userID,
Source: "upload",
ImageType: "avatar",
OriginalKey: processed.OriginalKey,
OriginalURL: processed.OriginalURL,
Width: processed.Width,
Height: processed.Height,
Format: processed.Format,
BlurHash: processed.BlurHash,
Variants: processed.Variants,
}
if err := as.repo.Save(ctx, image); err != nil {
return nil, fmt.Errorf("save image metadata: %w", err)
}
// 4. Обновляем ссылку в таблице users
if err := as.repo.UpdateUserAvatar(ctx, userID, image.ID); err != nil {
return nil, fmt.Errorf("update user avatar: %w", err)
}
// 5. Удаляем старый аватар из S3 (асинхронно)
go as.cleanupOldAvatar(ctx, userID, image.ID)
return image, nil
}
func validateAvatarFile(reader io.Reader, contentType string) error {
// Проверяем формат
allowedTypes := map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
if !allowedTypes[contentType] {
return fmt.Errorf("unsupported format: %s", contentType)
}
// Проверяем размер (максимум 5 МБ)
data, err := io.ReadAll(io.LimitReader(reader, 6*1024*1024))
if err != nil {
return fmt.Errorf("read file: %w", err)
}
if len(data) > 5*1024*1024 {
return fmt.Errorf("file too large: max 5MB")
}
// Проверяем минимальное разрешение
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("decode image: %w", err)
}
bounds := img.Bounds()
if bounds.Dx() < 100 || bounds.Dy() < 100 {
return fmt.Errorf("image too small: minimum 100x100px")
}
return nil
}
Генерация круглого аватара с обрезкой по центру:
package image
import (
"bytes"
"image"
"image/color"
"github.com/disintegration/imaging"
)
// GenerateAvatarVariants создаёт варианты аватара с круглой обрезкой
func GenerateAvatarVariants(img image.Image, sizes []ImageSize) (map[string]string, error) {
variants := make(map[string]string)
// Сначала делаем квадратную обрезку по центру
bounds := img.Bounds()
side := bounds.Dx()
if bounds.Dy() < side {
side = bounds.Dy()
}
square := imaging.CropCenter(img, side, side)
for _, size := range sizes {
// Масштабируем до нужного размера
resized := imaging.Fill(square, size.Width, size.Height, imaging.Center, imaging.Lanczos)
var buf bytes.Buffer
if err := imaging.Encode(&buf, resized, imaging.WEBP, imaging.WebPQuality(85)); err != nil {
continue
}
// Загружаем в S3 и получаем URL
key := fmt.Sprintf("avatars/%s/%s.webp", size.Suffix, uuid.New().String())
url, err := uploadToS3(key, buf.Bytes(), "image/webp")
if err != nil {
continue
}
variants[size.Suffix] = url
}
return variants, nil
}
Структура данных вариантов в JSONB:
{
"xs": "https://cdn.example.com/avatars/xs/abc-123.webp",
"s": "https://cdn.example.com/avatars/s/abc-123.webp",
"m": "https://cdn.example.com/avatars/m/abc-123.webp",
"l": "https://cdn.example.com/avatars/l/abc-123.webp",
"xl": "https://cdn.example.com/avatars/xl/abc-123.webp"
}
Использование в шаблоне (адаптивный аватар):
// components/UserAvatar.tsx
import Image from 'next/image';
interface UserAvatarProps {
user: {
displayName: string;
avatar: {
variants: Record<string, string>;
blurHash: string;
} | null;
};
size?: 'xs' | 's' | 'm' | 'l' | 'xl';
className?: string;
}
const sizeMap = {
xs: 32,
s: 64,
m: 128,
l: 256,
xl: 512,
};
export function UserAvatar({ user, size = 'm', className }: UserAvatarProps) {
const dimension = sizeMap[size];
const avatarUrl = user.avatar?.variants?.[size] || user.avatar?.variants?.['m'];
if (!avatarUrl) {
// Fallback: генерируем инициалы
return (
<div
className={`avatar-placeholder ${className}`}
style={{ width: dimension, height: dimension }}
aria-label={user.displayName}
>
{getInitials(user.displayName)}
</div>
);
}
return (
<Image
src={avatarUrl}
alt={user.displayName}
width={dimension}
height={dimension}
className={`avatar rounded-full ${className}`}
placeholder="blur"
blurDataURL={user.avatar.blurHash ? blurHashToDataURL(user.avatar.blurHash) : undefined}
loading="lazy"
/>
);
}
// Использование srcset для автоматического выбора размера
export function ResponsiveAvatar({ user }: UserAvatarProps) {
const variants = user.avatar?.variants;
if (!variants) return <AvatarPlaceholder name={user.displayName} />;
return (
<picture>
{/* WebP варианты */}
<source
type="image/webp"
srcSet={`
${variants.xs} 32w,
${variants.s} 64w,
${variants.m} 128w,
${variants.l} 256w
`}
sizes="(max-width: 640px) 32px, (max-width: 1024px) 64px, 128px"
/>
<Image
src={variants.m}
alt={user.displayName}
width={128}
height={128}
className="avatar rounded-full"
/>
</picture>
);
}
Обработка аватаров из OAuth-провайдеров:
// При регистрации через Google/VK/Telegram — скачиваем и обрабатываем аватар
func (as *AvatarService) ImportOAuthAvatar(ctx context.Context, userID int64, avatarURL string, provider string) (*model.Image, error) {
// Скачиваем изображение
resp, err := http.Get(avatarURL)
if err != nil {
return nil, fmt.Errorf("download avatar: %w", err)
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
// Обрабатываем как обычный аватар
processed, err := as.imageService.UploadAndProcessWithType(
ctx, resp.Body, contentType, "avatar", "avatar", AvatarSizes)
if err != nil {
return nil, fmt.Errorf("process oauth avatar: %w", err)
}
image := &model.Image{
UserID: &userID,
Source: "oauth",
ImageType: "avatar",
OriginalKey: processed.OriginalKey,
OriginalURL: processed.OriginalURL,
Width: processed.Width,
Height: processed.Height,
Format: processed.Format,
BlurHash: processed.BlurHash,
Variants: processed.Variants,
}
if err := as.repo.Save(ctx, image); err != nil {
return nil, err
}
return image, nil
}
Очистка старых аватаров:
func (as *AvatarService) cleanupOldAvatar(ctx context.Context, userID int64, newImageID uuid.UUID) {
oldImages, err := as.repo.GetOldUserAvatars(ctx, userID, newImageID)
if err != nil {
log.Error().Err(err).Msg("failed to get old avatars")
return
}
for _, oldImage := range oldImages {
// Удаляем из S3
keys := []string{oldImage.OriginalKey}
for _, variantURL := range oldImage.Variants {
key := extractKeyFromURL(variantURL)
keys = append(keys, key)
}
if err := as.imageService.DeleteFromS3(ctx, keys); err != nil {
log.Error().Err(err).Str("imageID", oldImage.ID.String()).Msg("failed to delete old avatar from S3")
}
// Помечаем удалённым в БД
if err := as.repo.MarkDeleted(ctx, oldImage.ID); err != nil {
log.Error().Err(err).Msg("failed to mark old avatar as deleted")
}
}
}
Итого: аватары хранятся в S3-хранилище с автоматической генерацией 5 вариантов размеров (32, 64, 128, 256, 512 px) в формате WebP. В PostgreSQL сохраняются только метаданные в таблице images с JSONB-полем variants, содержащим ссылки на каждый размер. В таблице users — только ссылка avatar_image_id на текущий аватар. Поддерживаются загрузка вручную, импорт из OAuth-провайдеров и автоматическая очистка старых файлов.
Вопрос 17. Какие поля должна содержать сущность Post (новость)?
Таймкод: 00:23:13
Ответ собеседника: Правильный. Содержит: id, background (ссылка на S3), rating (число), title (строка), content (строка в формате Markdown), author (user ID). Картинка и аватар могут быть необязательными.
Правильный ответ:
Сущность Article (новость) — это центральная сущность агрегатора новостей. Её структура должна покрывать потребности хранения контента, SEO, рейтинговой системы, модерации, аналитики и интеграции с внешними источниками. Ниже приведена полная и детальная модель.
Полная схема таблицы articles:
CREATE TABLE articles (
-- =========================================
-- ИДЕНТИФИКАЦИЯ
-- =========================================
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(600) NOT NULL UNIQUE, -- ЧПУ для SEO: "putin-zayavil-o-novom-soglashenii"
external_id VARCHAR(500), -- ID из источника (для дедупликации)
-- =========================================
-- КОНТЕНТ
-- =========================================
title VARCHAR(500) NOT NULL, -- Заголовок новости
summary TEXT, -- Краткое описание (для карточек, meta description)
content TEXT NOT NULL, -- Основной контент (Markdown)
content_html TEXT, -- Очищенный HTML (для рендеринг
Вопрос 17. Как организовать хранение контента новости и в каком формате?
Таймкод: 00:25:19
Ответ собеседника: Правильный. Контент хранится в формате Markdown как единая строка, на фронтенде парсится Markdown-парсером и разбивается на блоки.
Правильный ответ:
Хранение контента новости — это архитектурное решение, которое влияет на гибкость отображения, SEO, производительность и удобство работы редакторов. Оптимальный подход — хранить контент в формате Markdown с автоматической генерацией HTML, а для сложных макетов использовать структурированный формат на основе JSON.
Рекомендуемая схема хранения:
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(600) NOT NULL UNIQUE,
-- Основной контент
content_md TEXT NOT NULL, -- Markdown-контент (источник истины)
content_html TEXT, -- Предрендеренный HTML (для быстрой отдачи)
-- Структурированный контент (для сложных макетов)
content_blocks JSONB DEFAULT '[]', -- Блочная структура
-- Мета-информация о контенте
word_count INTEGER NOT NULL DEFAULT 0,
reading_time INTEGER NOT NULL DEFAULT 0, -- Время чтения в минутах
language VARCHAR(5) NOT NULL DEFAULT 'ru',
-- Статус и модерация
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived', 'moderation')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Индекс для поиска по статусу и дате
CREATE INDEX idx_articles_status_date ON articles(status, created_at DESC);
Формат Markdown как основной источник:
package article
import (
"bytes"
"html/template"
"regexp"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
// ContentService обрабатывает контент новостей
type ContentService struct {
md goldmark.Markdown
}
func NewContentService() *ContentService {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM, -- GitHub Flavored Markdown (таблицы, strikethrough)
extension.Typographer, -- Типографские замены (кавычки, тире)
extension.Linkify, -- Автоматические ссылки
),
goldmark.WithRendererOptions(
html.WithUnsafe(), -- Разрешаем HTML в Markdown (для видео, iframe)
),
)
return &ContentService{md: md}
}
// RenderMarkdown конвертирует Markdown в HTML
func (cs *ContentService) RenderMarkdown(markdown string) (string, error) {
var buf bytes.Buffer
if err := cs.md.Convert([]byte(markdown), &buf); err != nil {
return "", fmt.Errorf("render markdown: %w", err)
}
return buf.String(), nil
}
// Article с методами обработки контента
type Article struct {
ID int64 `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Slug string `json:"slug" db:"slug"`
ContentMD string `json:"-" db:"content_md"`
ContentHTML string `json:"content_html,omitempty" db:"content_html"`
ContentBlocks []Block `json:"content_blocks,omitempty" db:"content_blocks"`
Summary string `json:"summary" db:"summary"`
WordCount int `json:"word_count" db:"word_count"`
ReadingTime int `json:"reading_time" db:"reading_time"`
Language string `json:"language" db:"language"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Block представляет структурированный блок контента
type Block struct {
Type string `json:"type"` // "paragraph", "heading", "image", "quote", "list", "video"
Content string `json:"content"` // текст или URL
Meta map[string]interface{} `json:"meta"` // дополнительные данные
}
// ProcessContent обрабатывает контент при сохранении статьи
func (a *Article) ProcessContent(cs *ContentService) error {
// 1. Рендерим Markdown в HTML
html, err := cs.RenderMarkdown(a.ContentMD)
if err != nil {
return fmt.Errorf("render markdown: %w", err)
}
a.ContentHTML = html
// 2. Генерируем summary из первого абзаца, если не указан
if a.Summary == "" {
a.Summary = extractSummary(a.ContentMD, 200)
}
// 3. Считаем слова и время чтения
a.WordCount = countWords(a.ContentMD)
a.ReadingTime = calculateReadingTime(a.WordCount)
// 4. Парсим структурированные блоки
a.ContentBlocks = parseBlocks(a.ContentMD)
return nil
}
// extractSummary извлекает краткое описание из Markdown
func extractSummary(markdown string, maxLen int) string {
// Убираем Markdown-разметку
re := regexp.MustCompile(`[#*_~\[\]()` + "`" + `]`)
clean := re.ReplaceAllString(markdown, " ")
clean = strings.TrimSpace(clean)
if len(clean) <= maxLen {
return clean
}
// Обрезаем по границе слова
truncated := clean[:maxLen]
lastSpace := strings.LastIndex(truncated, " ")
if lastSpace > 0 {
truncated = truncated[:lastSpace]
}
return truncated + "..."
}
// countWords считает слова в тексте
func countWords(text string) int {
words := strings.Fields(text)
return len(words)
}
// calculateReadingTime оценивает время чтения (средняя скорость ~200 слов/мин)
func calculateReadingTime(wordCount int) int {
minutes := wordCount / 200
if minutes < 1 {
return 1
}
return minutes
}
Структурированные блоки контента (JSONB):
// ContentBlock представляет блок контента для гибкого рендеринга
type ContentBlock struct {
Type string `json:"type"`
Content string `json:"content"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
// parseBlocks парсит Markdown в структурированные блоки
func parseBlocks(markdown string) []ContentBlock {
var blocks []ContentBlock
lines := strings.Split(markdown, "\n")
var currentList []string
inList := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Заголовки
if strings.HasPrefix(trimmed, "#") {
if inList {
blocks = append(blocks, ContentBlock{Type: "list", Content: strings.Join(currentList, "\n")})
currentList = nil
inList = false
}
level := 0
for _, ch := range trimmed {
if ch == '#' {
level++
} else {
break
}
}
content := strings.TrimSpace(trimmed[level:])
blocks = append(blocks, ContentBlock{
Type: "heading",
Content: content,
Meta: map[string]interface{}{"level": level},
})
continue
}
// Изображения
if matched, _ := regexp.MatchString(`^!\[.*\]\(.*\)$`, trimmed); matched {
if inList {
blocks = append(blocks, ContentBlock{Type: "list", Content: strings.Join(currentList, "\n")})
currentList = nil
inList = false
}
re := regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
matches := re.FindStringSubmatch(trimmed)
if len(matches) == 3 {
blocks = append(blocks, ContentBlock{
Type: "image",
Content: matches[2],
Meta: map[string]interface{}{"alt": matches[1]},
})
}
continue
}
// Цитаты
if strings.HasPrefix(trimmed, ">") {
if inList {
blocks = append(blocks, ContentBlock{Type: "list", Content: strings.Join(currentList, "\n")})
currentList = nil
inList = false
}
content := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
blocks = append(blocks, ContentBlock{
Type: "quote",
Content: content,
})
continue
}
-- Списки
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || regexp.MustCompile(`^\d+\.`).MatchString(trimmed) {
currentList = append(currentList, trimmed)
inList = true
continue
}
-- Пустая строка — конец списка
if trimmed == "" {
if inList {
blocks = append(blocks, ContentBlock{
Type: "list",
Content: strings.Join(currentList, "\n"),
})
currentList = nil
inList = false
}
continue
}
-- Обычный параграф
if inList {
blocks = append(blocks, ContentBlock{
Type: "list",
Content: strings.Join(currentList, "\n"),
})
currentList = nil
inList = false
}
blocks = append(blocks, ContentBlock{
Type: "paragraph",
Content: trimmed,
})
}
-- Не забываем добавить последний список
if inList {
blocks = append(blocks, ContentBlock{
Type: "list",
Content: strings.Join(currentList, "\n"),
})
}
return blocks
}
Пример контента в базе данных:
# Заголовок новости
Это вводный абзац новости, который будет использоваться как summary.
## Подзаголовок
Текст с **жирным** и *курсивом*.

> Важная цитата из источника
- Первый пункт списка
- Второй пункт
- Третий пункт
Соответствующий JSONB content_blocks:
[
{"type": "heading", "content": "Заголовок новости", "meta": {"level": 1}},
{"type": "paragraph", "content": "Это вводный абзац новости..."},
{"type": "heading", "content": "Подзаголовок", "meta": {"level": 2}},
{"type": "paragraph", "content": "Текст с **жирным** и *курсивом*."},
{"type": "image", "content": "https://cdn.example.com/images/photo.webp", "meta": {"alt": "Описание"}},
{"type": "quote", "content": "Важная цитата из источника"},
{"type": "list", "content": "- Первый пункт\n- Второй пункт\n- Третий пункт"}
Рендеринг на фронтенде:
// components/ArticleContent.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import Image from 'next/image';
interface ArticleContentProps {
contentHtml: string;
contentBlocks: ContentBlock[];
useBlocks?: boolean;
}
export function ArticleContent({ contentHtml, contentBlocks, useBlocks = false }: ArticleContentProps) {
if (useBlocks) {
return (
<article className="article-content">
{contentBlocks.map((block, index) => (
<ContentBlockRenderer key={index} block={block} />
))}
</article>
);
}
// Простой рендеринг HTML
return (
<article
className="article-content prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
);
}
function ContentBlockRenderer({ block }: { block: ContentBlock }) {
switch (block.type) {
case 'heading':
const level = block.meta?.level as number;
if (level === 1) return <h1>{block.content}</h1>;
if (level === 2) return <h2>{block.content}</h2>;
if (level === 3) return <h3>{block.content}</h3>;
return <h4>{block.content}</h4>;
case 'paragraph':
return (
<div className="prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{block.content}
</ReactMarkdown>
</div>
);
case 'image':
return (
<figure className="article-image">
<Image
src={block.content}
alt={block.meta?.alt as string || ''}
width={800}
height={450}
className="rounded-lg"
/>
{block.meta?.alt && <figcaption>{block.meta.alt}</figcaption>}
</figure>
);
case 'quote':
return <blockquote className="border-l-4 border-blue-500 pl-4 italic">{block.content}</blockquote>;
case 'list':
const items = block.content.split('\n').map(item => item.replace(/^[-*]\s+|^\d+\.\s+/, ''));
return (
<ul className="list-disc pl-6">
{items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
);
default:
return null;
}
}
Триггер для автоматического обновления HTML при изменении Markdown:
CREATE OR REPLACE FUNCTION update_article_content()
RETURNS TRIGGER AS $$
BEGIN
-- Обновляем HTML при изменении Markdown
IF NEW.content_md != OLD.content_md THEN
-- HTML будет сгенерирован на уровне приложения
-- Здесь можно обновить вспомогательные поля
NEW.word_count := array_length(regexp_split_to_array(NEW.content_md, '\s+'), 1);
NEW.reading_time := GREATEST(1, NEW.word_count / 200);
NEW.updated_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_article_content_update
BEFORE UPDATE ON articles
FOR EACH ROW
EXECUTE FUNCTION update_article_content();
Итого: контент новости хранится в формате Markdown как источник истины (content_md), с автоматически генерируемым HTML (content_html) для быстрой отдачи и структурированными блоками (content_blocks в JSONB) для гибкого рендеринга на фронтенде. Markdown выбран как баланс между читаемостью для редакторов, простотой хранения и гибкостью отображения. Предрендеренный HTML устраняет необходимость парсить Markdown на каждый запрос, а блочная структура позволяет создавать сложные макеты с разными типами контента.
Вопрос 18. Какие API-эндпоинты необходимо спроектировать для приложения?
Таймкод: 00:27:46
Ответ собеседника: Правильный. GET posts (список с пагинацией), GET user/:id (информация о пользователе), POST rate (оценка поста с возвратом нового рейтинга).
Правильный ответ:
Полный API агрегатора новостей должен покрывать все сценарии использования: просмотр ленты, поиск, голосование, комментарии, управление профилем, администрирование и аналитику. Ниже приведён полный список эндпоинтов с детализацией.
Полная спецификация API:
1. Аутентификация и пользователи:
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| POST | /api/v1/auth/register | Регистрация нового пользователя | Нет |
| POST | /api/v1/auth/login | Вход (email + пароль) | Нет |
| POST | /api/v1/auth/refresh | Обновление access-токена | Refresh token |
| POST | /api/v1/auth/logout | Выход (инвалидация токена) | Access token |
| POST | /api/v1/auth/oauth/{provider} | OAuth авторизация (google, vk, yandex) | Нет |
| GET | /api/v1/users/me | Текущий пользователь | Access token |
| PUT | /api/v1/users/me | Обновление профиля | Access token |
| POST | /api/v1/users/me/avatar | Загрузка аватара | Access token |
| GET | /api/v1/users/{id} | Профиль пользователя | Нет |
| GET | /api/v1/users/{id}/posts | Посты пользователя | Нет |
| GET | /api/v1/users/{id}/liked | Лайкнутые посты | Access token |
2. Новости (статьи):
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| GET | /api/v1/articles | Лента новостей (с фильтрами и пагинацией) | Нет |
| GET | /api/v1/articles/feed | Персонализированная лента | Access token |
| GET | /api/v1/articles/{id} | Статья по ID | Нет |
| GET | /api/v1/articles/slug/{slug} | Статья по slug (SEO) | Нет |
| GET | /api/v1/articles/search | Поиск статей | Нет |
| GET | /api/v1/articles/trending | Трендовые статьи | Нет |
| GET | /api/v1/articles/featured | Рекомендуемые статьи | Нет |
| POST | /api/v1/articles | Создание статьи | Access token (author) |
| PUT | /api/v1/articles/{id} | Редактирование статьи | Access token (author) |
| DELETE | /api/v1/articles/{id} | Удаление статьи | Access token (author) |
| POST | /api/v1/articles/{id}/publish | Публикация статьи | Access token (author) |
3. Голосование:
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| POST | /api/v1/articles/{id}/vote | Лайк/дизлайк статьи | Access token |
| DELETE | /api/v1/articles/{id}/vote | Убрать голос | Access token |
| GET | /api/v1/articles/{id}/vote | Голос текущего пользователя | Access token |
4. Комментарии:
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| GET | /api/v1/articles/{id}/comments | Комментарии к статье | Нет |
| POST | /api/v1/articles/{id}/comments | Написать комментарий | Access token |
| PUT | /api/v1/comments/{id} | Редактирование комментария | Access token |
| DELETE | /api/v1/comments/{id} | Удаление комментария | Access token |
| POST | /api/v1/comments/{id}/vote | Лайк/дизлайк комментария | Access token |
5. Категории и теги:
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| GET | /api/v1/categories | Список категорий | Нет |
| GET | /api/v1/categories/{slug} | Категория по slug | Нет |
| GET | /api/v1/categories/{slug}/articles | Статьи категории | Нет |
| GET | /api/v1/tags | Список тегов | Нет |
| GET | /api/v1/tags/{slug} | Тег по slug | Нет |
| GET | /api/v1/tags/{slug}/articles | Статьи по тегу | Нет |
6. Закладки и подписки:
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| GET | /api/v1/bookmarks | Закладки пользователя | Access token |
| POST | /api/v1/bookmarks | Добавить в закладки | Access token |
| DELETE | /api/v1/bookmarks/{articleId} | Убрать из закладок | Access token |
| GET | /api/v1/subscriptions | Подписки пользователя | Access token |
| POST | /api/v1/subscriptions | Подписаться | Access token |
| DELETE | /api/v1/subscriptions/{id} | Отписаться | Access token |
7. Администрирование:
| Метод | Путь | Описание | Авторизация |
|---|---|---|---|
| GET | /api/v1/admin/articles/pending | Статьи на модерации | Admin |
| POST | /api/v1/admin/articles/{id}/moderate | Модерация статьи | Admin |
| GET | /api/v1/admin/users | Список пользователей | Admin |
| PUT | /api/v1/admin/users/{id}/role | Изменение роли | Admin |
| GET | /api/v1/admin/sources | Управление источниками | Admin |
| POST | /api/v1/admin/sources | Добавить источник | Admin |
Реализация на Go с использованием Chi:
package handler
// ArticleHandler обрабатывает запросы к статьям
type ArticleHandler struct {
articleService *service.ArticleService
voteService *service.VoteService
commentService *service.CommentService
}
func (h *ArticleHandler) RegisterRoutes(r chi.Router) {
r.Route("/api/v1", func(r chi.Router) {
-- Публичные маршруты
r.Group(func(r chi.Router) {
r.Get("/articles", h.ListArticles)
r.Get("/articles/search", h.SearchArticles)
r.Get("/articles/trending", h.TrendingArticles)
r.Get("/articles/featured", h.FeaturedArticles)
r.Get("/articles/{id}", h.GetArticle)
r.Get("/articles/slug/{slug}", h.GetArticleBySlug)
r.Get("/articles/{id}/comments", h.GetComments)
r.Get("/categories", h.ListCategories)
r.Get("/categories/{slug}", h.GetCategory)
r.Get("/categories/{slug}/articles", h.GetCategoryArticles)
r.Get("/tags", h.ListTags)
r.Get("/users/{id}", h.GetUser)
r.Get("/users/{id}/posts", h.GetUserPosts)
})
-- Защищённые маршруты
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
-- Профиль
r.Get("/users/me", h.GetCurrentUser)
r.Put("/users/me", h.UpdateProfile)
r.Post("/users/me/avatar", h.UploadAvatar)
-- Статьи
r.Post("/articles", h.CreateArticle)
r.Put("/articles/{id}", h.UpdateArticle)
r.Delete("/articles/{id}", h.DeleteArticle)
r.Post("/articles/{id}/publish", h.PublishArticle)
-- Голосование
r.Post("/articles/{id}/vote", h.VoteArticle)
r.Delete("/articles/{id}/vote", h.RemoveVote)
r.Get("/articles/{id}/vote", h.GetMyVote)
-- Комментарии
r.Post("/articles/{id}/comments", h.CreateComment)
r.Put("/comments/{id}", h.UpdateComment)
r.Delete("/comments/{id}", h.DeleteComment)
r.Post("/comments/{id}/vote", h.VoteComment)
-- Закладки и подписки
r.Get("/bookmarks", h.GetBookmarks)
r.Post("/bookmarks", h.AddBookmark)
r.Delete("/bookmarks/{articleId}", h.RemoveBookmark)
r.Get("/subscriptions", h.GetSubscriptions)
r.Post("/subscriptions", h.AddSubscription)
r.Delete("/subscriptions/{id}", h.RemoveSubscription)
-- Персонализированная лента
r.Get("/articles/feed", h.GetPersonalizedFeed)
})
})
}
// ListArticles возвращает список статей с фильтрацией и пагинацией
func (h *ArticleHandler) ListArticles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
-- Парсим параметры запроса
params := &model.ListArticlesParams{
Category: r.URL.Query().Get("category"),
Tag: r.URL.Query().Get("tag"),
Source: r.URL.Query().Get("source"),
Sort: r.URL.Query().Get("sort"), -- "latest", "popular", "trending"
Language: r.URL.Query().Get("lang"),
Page: parseIntOrDefault(r.URL.Query().Get("page"), 1),
PageSize: parseIntOrDefault(r.URL.Query().Get("per_page"), 20),
}
-- Валидация
if params.PageSize > 100 {
params.PageSize = 100
}
result, err := h.articleService.ListArticles(ctx, params)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to fetch articles")
return
}
respondJSON(w, http.StatusOK, result)
}
// VoteArticle обрабатывает голосование
func (h *ArticleHandler) VoteArticle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
articleID, err := parseID(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid article ID")
return
}
var req struct {
VoteType int `json:"vote_type"` // 1 = like, -1 = dislike
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.VoteType != 1 && req.VoteType != -1 {
respondError(w, http.StatusBadRequest, "vote_type must be 1 (like) or -1 (dislike)")
return
}
userID := middleware.GetUserID(ctx)
result, err := h.voteService.Vote(ctx, articleID, userID, model.VoteType(req.VoteType))
if err != nil {
switch {
case errors.Is(err, service.ErrArticleNotFound):
respondError(w, http.StatusNotFound, "article not found")
case errors.Is(err, service.ErrSelfVote):
respondError(w, http.StatusForbidden, "cannot vote for your own article")
default:
respondError(w, http.StatusInternalServerError, "failed to vote")
}
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"article_id": articleID,
"score": result.NewScore,
"user_vote": req.VoteType,
})
}
// SearchArticles полнотекстовый поиск
func (h *ArticleHandler) SearchArticles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query().Get("q")
if query == "" {
respondError(w, http.StatusBadRequest, "query parameter 'q' is required")
return
}
params := &model.SearchParams{
Query: query,
Category: r.URL.Query().Get("category"),
Sort: r.URL.Query().Get("sort"), -- "relevance", "date"
Page: parseIntOrDefault(r.URL.Query().Get("page"), 1),
PageSize: parseIntOrDefault(r.URL.Query().Get("per_page"), 20),
}
result, err := h.articleService.Search(ctx, params)
if err != nil {
respondError(w, http.StatusInternalServerError, "search failed")
return
}
respondJSON(w, http.StatusOK, result)
}
Модели запросов и ответов:
package model
-- Параметры списка статей
type ListArticlesParams struct {
Category string `json:"category"`
Tag string `json:"tag"`
Source string `json:"source"`
Sort string `json:"sort"` // "latest", "popular", "trending"
Language string `json:"language"`
Page int `json:"page"`
PageSize int `json:"per_page"`
}
-- Ответ со списком статей (с пагинацией)
type ArticleListResponse struct {
Articles []ArticleSummary `json:"articles"`
Pagination Pagination `json:"pagination"`
}
type ArticleSummary struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Score int64 `json:"score"`
CommentsCount int `json:"comments_count"`
Views int64 `json:"views"`
PublishedAt *time.Time `json:"published_at"`
Category *Category `json:"category,omitempty"`
FeaturedImage *Image `json:"featured_image,omitempty"`
Author *AuthorSummary `json:"author,omitempty"`
}
type AuthorSummary struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url,omitempty"`
}
type Pagination struct {
CurrentPage int `json:"current_page"`
PageSize int `json:"per_page"`
TotalItems int64 `json:"total_items"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
-- Параметры поиска
type SearchParams struct {
Query string `json:"q"`
Category string `json:"category"`
Sort string `json:"sort"`
Page int `json:"page"`
PageSize int `json:"per_page"`
}
-- Результат поиска
type SearchResponse struct
Results []SearchResult `json:"results"`
Pagination Pagination `json:"pagination"`
Facets SearchFacets `json:"facets"`
}
type SearchResult struct {
Article ArticleSummary `json:"article"`
Highlights []string `json:"highlights"` -- Подсвеченные фрагменты
Score float64 `json:"score"` -- Релевантность
}
type SearchFacets struct {
Categories []FacetCount `json:"categories"`
Sources []FacetCount `json:"sources"`
Dates []FacetCount `json:"dates"`
}
type FacetCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
Вспомогательные функции:
package handler
import (
"encoding/json"
"net/http"
"strconv"
)
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Error().Err(err).Msg("failed to encode response")
}
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
func parseIntOrDefault(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
val, err := strconv.Atoi(s)
if err != nil || val < 1 {
return defaultVal
}
return val
}
Rate limiting для API:
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
type RateLimiter struct {
visitors map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit
burst int
}
func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
return &RateLimiter{
visitors: make(map[string]*rate.Limiter),
rate: r,
burst: burst,
}
}
func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
limiter, exists := rl.visitors[ip]
if !exists {
limiter = rate.NewLimiter(rl.rate, rl.burst)
rl.visitors[ip] = limiter
}
return limiter
}
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
limiter := rl.getLimiter(ip)
if !limiter.Allow() {
w.Header().Set("Retry-After", "60")
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(rl.burst))
respondError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
-- Использование: 100 запросов в минуту с burst 150
rateLimiter := middleware.NewRateLimiter(rate.Limit(100)/60, 150)
r.Use(rateLimiter.Middleware)
Итого: API агрегатора новостей включает 50+ эндпоинтов, сгруппированных по доменам: аутентификация, статьи, голосование, комментарии, категории/теги, закладки/подписки, администрирование. Все эндпоинты следуют REST-конвенциям, поддерживают пагинацию, фильтрацию и сортировку. Защищённые маршруты используют JWT-аутентификацию. Rate limiting предотвращает злоупотребления. Ответы содержат метаданные пагинации для удобной работы фронтенда.
Вопрос 19. Как реализовать ограничение на повторные лайки и отображение состояния кнопок?
Таймкод: 00:31:04
Ответ собеседника: Правильный. На бэкенде проверять, не оценивал ли пользователь пост ранее. На фронтенде — визуально выделять активную кнопку на основе данных о лайкнутых постах.
Правильный ответ:
Система голосования должна быть идемпотентной, защищённой от накрутки и обеспечивать мгновенную обратную связь на фронтенде. Это требует согласованной работы бэкенда, базы данных и клиентского приложения.
Серверная реализация:
package service
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"news-aggregator/internal/model"
)
var (
ErrArticleNotFound = errors.New("article not found")
ErrSelfVote = errors.New("cannot vote for your own article")
ErrAlreadyVoted = errors.New("already voted with the same type")
)
type VoteService struct {
voteRepo VoteRepository
articleRepo ArticleRepository
cache *CacheService
eventBus *EventBus
}
// VoteResult результат голосования
type VoteResult struct {
ArticleID int64 `json:"article_id"`
NewScore int64 `json:"new_score"`
UserVote int8 `json:"user_vote"` // 1 = like, -1 = dislike, 0 = removed
}
// Vote обрабатывает голосование с полной идемпотентностью
func (vs *VoteService) Vote(ctx context.Context, articleID, userID int64, voteType model.VoteType) (*VoteResult, error) {
// 1. Проверяем существование статьи и права
article, err := vs.articleRepo.GetArticle(ctx, articleID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrArticleNotFound
}
return nil, fmt.Errorf("get article: %w", err)
}
// 2. Запрещаем голосовать за свои статьи
if article.AuthorID != nil && *article.AuthorID == userID {
return nil, ErrSelfVote
}
// 3. Получаем текущий голос пользователя
existingVote, err := vs.voteRepo.GetUserVote(ctx, articleID, userID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user vote: %w", err)
}
// 4. Определяем действие
if existingVote != nil {
if existingVote.VoteType == voteType {
// Повторный голос того же типа — идемпотентность
return &VoteResult{
ArticleID: articleID,
NewScore: article.Score,
UserVote: int8(voteType),
}, nil
}
}
// 5. Выполняем голосование в транзакции
result, err := vs.executeVoteInTx(ctx, articleID, userID, voteType, existingVote)
if err != nil {
return nil, err
}
// 6. Инвалидируем кэш
vs.cache.Delete(ctx, fmt.Sprintf("article:%d", articleID))
vs.cache.Delete(ctx, fmt.Sprintf("user_vote:%d:%d", articleID, userID))
// 7. Публикуем событие для аналитики
vs.eventBus.Publish(ctx, "vote.created", map[string]interface{}{
"article_id": articleID,
"user_id": userID,
"vote_type": voteType,
"timestamp": time.Now(),
})
return result, nil
}
func (vs *VoteService) executeVoteInTx(ctx context.Context, articleID, userID int64, voteType model.VoteType, existingVote *model.Vote) (*VoteResult, error) {
tx, err := vs.voteRepo.BeginTx(ctx)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
// Вычисляем дельты
var likeDelta, dislikeDelta, scoreDelta int64
if existingVote == nil {
// Новый голос
if voteType == model.VoteLike {
likeDelta = 1
scoreDelta = 1
} else {
dislikeDelta = 1
scoreDelta = -1
}
} else {
// Смена голоса
if voteType == model.VoteLike {
likeDelta = 1
dislikeDelta = -1
scoreDelta = 2
} else {
likeDelta = -1
dislikeDelta = 1
scoreDelta = -2
}
}
// Обновляем или создаём голос
if existingVote == nil {
if err := tx.Exec(ctx, `
INSERT INTO article_votes (article_id, user_id, vote_type, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
`, articleID, userID, voteType).Err(); err != nil {
return nil, fmt.Errorf("insert vote: %w", err)
}
} else {
if err := tx.Exec(ctx, `
UPDATE article_votes
SET vote_type = $3, updated_at = NOW()
WHERE article_id = $1 AND user_id = $2
`, articleID, userID, voteType).Err(); err != nil {
return nil, fmt.Errorf("update vote: %w", err)
}
}
// Атомарно обновляем счётчики в articles
var newScore int64
err = tx.QueryRow(ctx, `
UPDATE articles
SET likes = likes + $2,
dislikes = dislikes + $3,
score = score + $4,
updated_at = NOW()
WHERE id = $1
RETURNING score
`, articleID, likeDelta, dislikeDelta, scoreDelta).Scan(&newScore)
if err != nil {
return nil, fmt.Errorf("update article score: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
return &VoteResult{
ArticleID: articleID,
NewScore: newScore,
UserVote: int8(voteType),
}, nil
}
// RemoveVote убирает голос пользователя
func (vs *VoteService) RemoveVote(ctx context.Context, articleID, userID int64) (*VoteResult, error) {
existingVote, err := vs.voteRepo.GetUserVote(ctx, articleID, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("vote not found")
}
return nil, fmt.Errorf("get user vote: %w", err)
}
if existingVote == nil {
return nil, errors.New("no vote to remove")
}
tx, err := vs.voteRepo.BeginTx(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
var scoreDelta int64
if existingVote.VoteType == model.VoteLike {
scoreDelta = -1
} else {
scoreDelta = 1
}
// Удаляем голос
if err := tx.Exec(ctx, `
DELETE FROM article_votes WHERE article_id = $1 AND user_id = $2
`, articleID, userID).Err(); err != nil {
return nil, fmt.Errorf("delete vote: %w", err)
}
var newScore int64
err = tx.QueryRow(ctx, `
UPDATE articles
SET score = score + $2,
updated_at = NOW()
WHERE id = $1
RETURNING score
`, articleID, scoreDelta).Scan(&newScore)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
vs.cache.Delete(ctx, fmt.Sprintf("article:%d", articleID))
return &VoteResult{
ArticleID: articleID,
NewScore: newScore,
UserVote: 0,
}, nil
}
// GetUserVotesBatch получает голоса пользователя для списка статей
func (vs *VoteService) GetUserVotesBatch(ctx context.Context, userID int64, articleIDs []int64) (map[int64]int8, error) {
if len(articleIDs) == 0 {
return make(map[int64]int8), nil
}
votes, err := vs.voteRepo.GetUserVotesForArticles(ctx, userID, articleIDs)
if err != nil {
return nil, err
}
result := make(map[int64]int8, len(votes))
for _, vote := range votes {
result[vote.ArticleID] = int8(vote.VoteType)
}
return result, nil
}
Оптимизация: батчевая загрузка голосов для ленты:
// В ответе списка статей включаем голос текущего пользователя
type ArticleListResponse struct {
Articles []ArticleSummary `json:"articles"`
Pagination Pagination `json:"pagination"`
UserVotes map[int64]int8 `json:"user_votes"` // article_id -> vote_type
}
func (h *ArticleHandler) ListArticles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
params := parseListParams(r)
result, err := h.articleService.ListArticles(ctx, params)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to fetch articles")
return
}
// Получаем голоса пользователя одним запросом
var userVotes map[int64]int8
userID := middleware.GetUserID(ctx) // 0 если не авторизован
if userID > 0 {
articleIDs := extractArticleIDs(result.Articles)
userVotes, _ = h.voteService.GetUserVotesBatch(ctx, userID, articleIDs)
}
respondJSON(w, http.StatusOK, ArticleListResponse{
Articles: result.Articles,
Pagination: result.Pagination,
UserVotes: userVotes,
})
}
Кэширование голосов в Redis:
package cache
func (c *CacheService) GetUserVote(ctx context.Context, articleID, userID int64) (*int8, error) {
key := fmt.Sprintf("user_vote:%d:%d", articleID, userID)
val, err := c.redis.Get(ctx, key).Result()
if err == redis.Nil {
return nil, nil // голоса нет
}
if err != nil {
return nil, err
}
voteType := int8(atoi(val))
return &voteType, nil
}
func (c *CacheService) SetUserVote(ctx context.Context, articleID, userID int64, voteType int8) error {
key := fmt.Sprintf("user_vote:%d:%d", articleID, userID)
if voteType == 0 {
return c.redis.Del(ctx, key).Err()
}
return c.redis.Set(ctx, key, int(voteType), 24*time.Hour).Err()
}
Клиентская реализация (React + TanStack Query):
// hooks/useVote.ts
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface VoteState {
score: number;
userVote: 1 | -1 | 0;
}
export function useVote(articleId: number) {
const queryClient = useQueryClient();
const { data: voteState } = useQuery<VoteState>({
queryKey: ['vote', articleId],
queryFn: () => api.get(`/api/v1/articles/${articleId}/vote`).then(r => r.data),
staleTime: 30000,
});
const voteMutation = useMutation({
mutationFn: (voteType: 1 | -1) =>
api.post(`/api/v1/articles/${articleId}/vote`, { vote_type: voteType }),
// Оптимистичное обновление UI
onMutate: async (voteType) => {
// Отменяем текущие запросы
await queryClient.cancelQueries({ queryKey: ['vote', articleId] });
await queryClient.cancelQueries({ queryKey: ['articles'] });
// Сохраняем предыдущее состояние
const previousVote = queryClient.getQueryData<VoteState>(['vote', articleId]);
const previousArticles = queryClient.getQueryData(['articles']);
// Оптимистично обновляем
queryClient.setQueryData<VoteState>(['vote', articleId], (old) => {
if (!old) return { score: 0, userVote: voteType };
let newScore = old.score;
let newVote = voteType;
if (old.userVote === voteType) {
// Повторный клик — убираем голос
newVote = 0;
newScore = voteType === 1 ? old.score - 1 : old.score + 1;
} else if (old.userVote === 0) {
// Новый голос
newScore = voteType === 1 ? old.score + 1 : old.score - 1;
} else {
// Смена голоса
newScore = voteType === 1 ? old.score + 2 : old.score - 2;
}
return { score: newScore, userVote: newVote };
});
return { previousVote, previousArticles };
},
// Откат при ошибке
onError: (_err, _voteType, context) => {
if (context?.previousVote) {
queryClient.setQueryData(['vote', articleId], context.previousVote);
}
if (context?.previousArticles) {
queryClient.setQueryData(['articles'], context.previousArticles);
}
},
// После завершения — обновляем из ответа сервера
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['vote', articleId] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
return {
voteState,
vote: voteMutation.mutate,
isLoading: voteMutation.isPending,
};
}
Компонент кнопок голосования:
// components/VoteButtons.tsx
import { useVote } from '@/hooks/useVote';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface VoteButtonsProps {
articleId: number;
initialScore: number;
initialUserVote: 1 | -1 | 0;
}
export function VoteButtons({ articleId, initialScore, initialUserVote }: VoteButtonsProps) {
const { voteState, vote, isLoading } = useVote(articleId);
const score = voteState?.score ?? initialScore;
const userVote = voteState?.userVote ?? initialUserVote;
const handleLike = () => {
if (isLoading) return;
vote(userVote === 1 ? -1 : 1); // Если уже лайкнут — убираем
};
const handleDislike = () => {
if (isLoading) return;
vote(userVote === -1 ? 1 : -1); // Если уже дизлайкнут — убираем
};
return (
<div className="vote-buttons flex items-center gap-2">
<button
onClick={handleLike}
disabled={isLoading}
className={`
vote-btn vote-like flex items-center gap-1 px-3 py-1 rounded-full
transition-all duration-200
${userVote === 1
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-blue-100 hover:text-blue-600'
}
${isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
aria-label="Лайк"
aria-pressed={userVote === 1}
>
<ThumbsUp size={18} className={userVote === 1 ? 'fill-current' : ''} />
</button>
<span
className={`
score font-semibold min-w-[3ch] text-center
${score > 0 ? 'text-blue-600' : score < 0 ? 'text-red-600' : 'text-gray-500'}
`}
aria-label={`Рейтинг: ${score}`}
>
{score > 0 ? `+${score}` : score}
</span>
<button
onClick={handleDislike}
disabled={isLoading}
className={`
vote-btn vote-dislike flex items-center gap-1 px-3 py-1 rounded-full
transition-all duration-200
${userVote === -1
? 'bg-red-500 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-red-100 hover:text-red-600'
}
${isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
aria-label="Дизлайк"
aria-pressed={userVote === -1}
>
<ThumbsDown size={18} className={userVote === -1 ? 'fill-current' : ''} />
</button>
</div>
);
}
Rate limiting для голосования:
// middleware/vote_ratelimit.go
package middleware
import (
"fmt"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
type VoteRateLimiter struct {
redis *redis.Client
maxVotes int // Максимум голосов
window time.Duration // За какой период
}
func NewVoteRateLimiter(redis *redis.Client, maxVotes int, window time.Duration) *VoteRateLimiter {
return &VoteRateLimiter{
redis: redis,
maxVotes: maxVotes,
window: window,
}
}
func (vrl *VoteRateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == 0 {
next.ServeHTTP(w, r)
return
}
key := fmt.Sprintf("vote_limit:%d", userID)
ctx := r.Context()
// Используем Redis INCR с TTL
pipe := vrl.redis.Pipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, vrl.window)
_, _ = pipe.Exec(ctx)
if incr.Val() > int64(vrl.maxVotes) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(vrl.window.Seconds())))
respondError(w, http.StatusTooManyRequests,
fmt.Sprintf("vote limit exceeded: %d votes per %v", vrl.maxVotes, vrl.window))
return
}
next.ServeHTTP(w, r)
})
}
-- Использование: максимум 60 голосов в минуту
voteLimiter := middleware.NewVoteRateLimiter(redisClient, 60, time.Minute)
r.Post("/articles/{id}/vote", voteLimiter.Middleware(http.HandlerFunc(h.VoteArticle)))
Итого: ограничение повторных лайков реализуется на уровне базы данных (UNIQUE constraint на (article_id, user_id)) и бизнес-логики (проверка существующего голоса перед записью). На фронтенде используется оптимистичное обновление UI через TanStack Query для мгновенной обратной связи, с откатом при ошибке. Rate limiting в Redis предотвращает накрутку. Батчевая загрузка голосов (GetUserVotesBatch) позволяет отображать состояние кнопок для всех статей ленты одним запросом.
Вопрос 20. Как реализовать лайки/дизлайки для неавторизованных пользователей и синхронизировать их после авторизации?
Таймкод: 00:32:46
Ответ собеседника: Правильный. Использовать Local Storage для временного хранения лайков неавторизованных пользователей, при авторизации — переносить в базу данных.
Правильный ответ:
Голосование без авторизации — это компромисс между вовлечением пользователей и целостностью данных. Неавторизованные голоса не должны влиять на глобальный рейтинг, но должны сохранять состояние UI для пользователя. При авторизации локальные голоса мигрируются на сервер.
Архитектура решения:
[Неавторизованный пользователь]
↓
[LocalStorage / IndexedDB] ← Хранит локальные голоса
↓ (при авторизации)
[API: POST /api/v1/votes/sync] → [Сервер] → [База данных]
↓
[Обновление глобального рейтинга]
Клиентская реализация:
// services/localVoteService.ts
interface LocalVote {
articleId: number;
voteType: 1 | -1;
timestamp: number; // Unix timestamp
}
interface LocalVotes {
votes: LocalVote[];
lastSyncedAt: number | null;
}
const STORAGE_KEY = 'news_aggregator_local_votes';
const MAX_LOCAL_VOTES = 1000; // Ограничение размера
class LocalVoteService {
// Получить все локальные голоса
getLocalVotes(): LocalVotes {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return { votes: [], lastSyncedAt: null };
return JSON.parse(data);
} catch {
return { votes: [], lastSyncedAt: null };
}
}
// Сохранить локальный голос
addLocalVote(articleId: number, voteType: 1 | -1): void {
const data = this.getLocalVotes();
// Удаляем предыдущий голос для этой статьи (если есть)
data.votes = data.votes.filter(v => v.articleId !== articleId);
// Если голос не нейтральный — добавляем
if (voteType !== 0) {
data.votes.push({
articleId,
voteType,
timestamp: Date.now(),
});
}
// Ограничиваем размер хранилища
if (data.votes.length > MAX_LOCAL_VOTES) {
data.votes.sort((a, b) => a.timestamp - b.timestamp);
data.votes = data.votes.slice(-MAX_LOCAL_VOTES);
}
this.saveLocalVotes(data);
}
// Получить голос для конкретной статьи
getLocalVote(articleId: number): 1 | -1 | 0 {
const data = this.getLocalVotes();
const vote = data.votes.find(v => v.articleId === articleId);
return vote ? vote.voteType : 0;
}
// Очистить локальные голоса после успешной синхронизации
clearLocalVotes(): void {
this.saveLocalVotes({ votes: [], lastSyncedAt: Date.now() });
}
// Получить несинхронизированные голоса
getUnsyncedVotes(): LocalVote[] {
const data = this.getLocalVotes();
if (!data.lastSyncedAt) {
return data.votes;
}
return data.votes.filter(v => v.timestamp > data.lastSyncedAt!);
}
private saveLocalVotes(data: LocalVotes): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
// Хранилище переполнено — очищаем старые записи
if (e.name === 'QuotaExceededError') {
data.votes = data.votes.slice(-100);
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
}
}
}
export const localVoteService = new LocalVoteService();
Сервис синхронизации при авторизации:
// services/voteSyncService.ts
import { localVoteService } from './localVoteService';
import { api } from '@/lib/api';
class VoteSyncService {
// Синхронизировать локальные голоса с сервером
async syncVotes(): Promise<SyncResult> {
const unsyncedVotes = localVoteService.getUnsyncedVotes();
if (unsyncedVotes.length === 0) {
return { synced: 0, failed: 0 };
}
try {
const response = await api.post('/api/v1/votes/sync', {
votes: unsyncedVotes.map(v => ({
article_id: v.articleId,
vote_type: v.voteType,
created_at: new Date(v.timestamp).toISOString(),
})),
});
const result: SyncResult = response.data;
// Очищаем локальные голоса только при полном успехе
if (result.failed === 0) {
localVoteService.clearLocalVotes();
} else {
// Частичная синхронизация — очищаем только успешные
const failedIds = new Set(result.failedArticleIds || []);
const remainingVotes = unsyncedVotes.filter(v => failedIds.has(v.articleId));
// Сохраняем только неудачные для повторной попытки
localStorage.setItem('news_aggregator_local_votes', JSON.stringify({
votes: remainingVotes,
lastSyncedAt: null,
}));
}
return result;
} catch (error) {
console.error('Vote sync failed:', error);
return { synced: 0, failed: unsyncedVotes.length };
}
}
}
interface SyncResult {
synced: number;
failed: number;
failedArticleIds?: number[];
}
export const voteSyncService = new VoteSyncService();
Хук для голосования с поддержкой неавторизованных пользователей:
// hooks/useVote.ts
import { useCallback } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useAuth } from '@/hooks/useAuth';
import { localVoteService } from '@/services/localVoteService';
import { voteSyncService } from '@/services/voteSyncService';
import { api } from '@/lib/api';
export function useVote(articleId: number) {
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
// Получаем голос: из сервера для авторизованных, из localStorage для остальных
const { data: serverVote } = useQuery({
queryKey: ['vote', articleId],
queryFn: () => api.get(`/api/v1/articles/${articleId}/vote`).then(r => r.data),
enabled: isAuthenticated,
});
const localVote = localVoteService.getLocalVote(articleId);
const userVote = isAuthenticated ? (serverVote?.user_vote ?? 0) : localVote;
// Мутация для авторизованных пользователей
const serverVoteMutation = useMutation({
mutationFn: (voteType: 1 | -1) =>
api.post(`/api/v1/articles/${articleId}/vote`, { vote_type: voteType }),
onSuccess: (data) => {
queryClient.setQueryData(['vote', articleId], data);
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
// Основная функция голосования
const vote = useCallback((voteType: 1 | -1) => {
if (isAuthenticated) {
// Авторизованный — отправляем на сервер
serverVoteMutation.mutate(voteType);
} else {
// Неавторизованный — сохраняем локально
localVoteService.addLocalVote(articleId, voteType);
// Обновляем кэш для немедленного отображения
queryClient.setQueryData(['vote', articleId], (old: any) => {
const currentScore = old?.score ?? 0;
const currentUserVote = old?.userVote ?? 0;
let newScore = currentScore;
let newVote = voteType;
if (currentUserVote === voteType) {
// Повторный клик — убираем голос
newVote = 0;
newScore = voteType === 1 ? currentScore - 1 : currentScore + 1;
} else if (currentUserVote === 0) {
// Новый голос
newScore = voteType === 1 ? currentScore + 1 : currentScore - 1;
} else {
// Смена голоса
newScore = voteType === 1 ? currentScore + 2 : currentScore - 2;
}
return { score: newScore, userVote: newVote };
});
}
}, [isAuthenticated, articleId, serverVoteMutation, queryClient]);
return {
userVote,
isLoading: serverVoteMutation.isPending,
vote,
};
}
Хук авторизации с синхронизацией голосов:
// hooks/useAuth.ts
import { useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import { voteSyncService } from '@/services/voteSyncService';
import { api } from '@/lib/api';
export function useAuth() {
// ... другие методы аутентификации
const loginMutation = useMutation({
mutationFn: async (credentials: { email: string; password: string }) => {
const response = await api.post('/api/v1/auth/login', credentials);
const { token, refresh_token, user } = response.data;
// Сохраняем токены
localStorage.setItem('access_token', token);
localStorage.setItem('refresh_token', refresh_token);
return user;
},
onSuccess: async (user) => {
try {
// Синхронизируем локальные голоса
const syncResult = await voteSyncService.syncVotes();
if (syncResult.synced > 0) {
// Обновляем UI с актуальными данными с сервера
queryClient.invalidateQueries({ queryKey: ['vote'] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
// Показываем уведомление
toast.success(`Синхронизировано голосов: ${syncResult.synced}`);
}
} catch (error) {
console.error('Vote sync after login failed:', error);
// Не блокируем авторизацию из-за ошибки синхронизации
}
// Обновляем состояние пользователя
queryClient.setQueryData(['currentUser'], user);
},
});
const registerMutation = useMutation({
mutationFn: async (data: RegisterData) => {
const response = await api.post('/api/v1/auth/register', data);
return response.data;
},
onSuccess: async (data) => {
// Аналогичная синхронизация после регистрации
await voteSyncService.syncVotes();
queryClient.invalidateQueries({ queryKey: ['vote'] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
return {
login: loginMutation.mutate,
register: registerMutation.mutate,
isAuthenticated: !!localStorage.getItem('access_token'),
};
}
Серверный endpoint для синхронизации:
package handler
// SyncVotesRequest запрос на синхронизацию голосов
type SyncVotesRequest struct {
Votes []SyncedVote `json:"votes" validate:"required,min=1,max=100"`
}
type SyncedVote struct {
ArticleID int64 `json:"article_id" validate:"required"`
VoteType int8 `json:"vote_type" validate:"oneof=1 -1"`
CreatedAt time.Time `json:"created_at" validate:"required"`
}
// SyncVotesResponse ответ синхронизации
type SyncVotesResponse struct {
Synced int64 `json:"synced"`
Failed int64 `json:"failed"`
FailedArticleIDs []int64 `json:"failed_article_ids,omitempty"`
}
// SyncVotes синхронизирует локальные голоса неавторизованного пользователя
func (h *VoteHandler) SyncVotes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req SyncVotesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := validate.Struct(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
userID := middleware.GetUserID(ctx)
if userID == 0 {
respondError(w, http.StatusUnauthorized, "authentication required")
return
}
var synced, failed int64
var failedIDs []int64
for _, vote := range req.Votes {
_, err := h.voteService.Vote(ctx, vote.ArticleID, userID, model.VoteType(vote.VoteType))
if err != nil {
failed++
failedIDs = append(failedIDs, vote.ArticleID)
log.Warn().
Err(err).
Int64("article_id", vote.ArticleID).
Int64("user_id", userID).
Msg("failed to sync vote")
} else {
synced++
}
}
respondJSON(w, http.StatusOK, SyncVotesResponse{
Synced: synced,
Failed: failed,
FailedArticleIDs: failedIDs,
})
}
Альтернатива: IndexedDB для большего объёма данных:
// services/indexedDbVoteService.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface VoteDB extends DBSchema {
votes: {
key: number; // articleId
value: {
articleId: number;
voteType: 1 | -1;
timestamp: number;
};
};
}
class IndexedDbVoteService {
private db: IDBPDatabase<VoteDB> | null = null;
async init(): Promise<void> {
this.db = await openDB<VoteDB>('news-aggregator-votes', 1, {
upgrade(db) {
db.createObjectStore('votes', { keyPath: 'articleId' });
},
});
}
async addVote(articleId: number, voteType: 1 | -1): Promise<void> {
if (!this.db) await this.init();
await this.db!.put('votes', {
articleId,
voteType,
timestamp: Date.now(),
});
}
async getVote(articleId: number): Promise<1 | -1 | 0> {
if (!this.db) await this.init();
const vote = await this.db!.get('votes', articleId);
return vote?.voteType ?? 0;
}
async getAllVotes(): Promise<Array<{ articleId: number; voteType: 1 | -1; timestamp: number }>> {
if (!this.db) await this.init();
return this.db!.getAll('votes');
}
async clearVotes(): Promise<void> {
if (!this.db) await this.init();
await this.db!.clear('votes');
}
}
export const indexedDbVoteService = new IndexedDbVoteService();
Итого: для неавторизованных пользователей голоса хранятся в LocalStorage (или IndexedDB для больших объёмов) в формате { articleId, voteType, timestamp }. При авторизации вызывается POST /api/v1/votes/sync, который пакетно переносит локальные голоса в базу данных. После успешной синхронизации локальные данные очищаются. Ошибки синхронизации не блокируют авторизацию — неудачные голоса остаются в локальном хранилище для повторной попытки. Это обеспечивает плавный пользовательский опыт с постепенным вовлечением через голосование до регистрации.
Вопрос 21. Как оптимизировать передачу данных о постах для ленты и страницы новости?
Таймкод: 00:34:08
Ответ собеседника: Правильный. При загрузке ленты получать урезанную версию постов без контента, контент загружать отдельным запросом, кэшировать на клиенте.
Правильный ответ:
Оптимизация передачи данных между бэкендом и фронтендом — это ключевой фактор производительности. Неправильный подход к формированию ответов API может привести к передаче мегабайтов ненужных данных на каждый запрос ленты.
Проблема: что происходит без оптимизации:
// GET /api/v1/articles?page=1&per_page=20
// Без оптимизации — каждая статья содержит ПОЛНЫЙ контент (2000-5000 слов)
// Размер ответа: ~500KB-1MB на одну страницу ленты
{
"articles": [
{
"id": 1,
"title": "Заголовок",
"content": "Полный текст статьи в Markdown (5000 символов)...",
"content_html": "<p>Полный HTML контент (8000 символов)...</p>",
"summary": "Краткое описание",
// ... ещё 19 статей с полным контентом
}
]
}
Решение: разделение моделей ответа:
package model
// ArticleSummary — для ленты (минимальный набор полей)
type ArticleSummary struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Score int64 `json:"score"`
Views int64 `json:"views"`
CommentsCount int `json:"comments_count"`
PublishedAt *time.Time `json:"published_at"`
Language string `json:"language"`
IsFeatured bool `json:"is_featured"`
Category *CategorySummary `json:"category,omitempty"`
Author *AuthorSummary `json:"author,omitempty"`
FeaturedImage *ImageSummary `json:"featured_image,omitempty"`
UserVote int8 `json:"user_vote,omitempty"`
}
// ArticleDetail — для страницы статьи (полный набор полей)
type ArticleDetail struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
ContentMD string `json:"content_md"` // Markdown контент
ContentHTML string `json:"content_html"` // HTML контент
ContentBlocks []ContentBlock `json:"content_blocks"` // Структурированные блоки
Score int64 `json:"score"`
Likes int64 `json:"likes"`
Dislikes int64 `json:"dislikes"`
Views int64 `json:"views"`
CommentsCount int `json:"comments_count"`
WordCount int `json:"word_count"`
ReadingTime int `json:"reading_time"`
PublishedAt *time.Time `json:"published_at"`
UpdatedAt time.Time `json:"updated_at"`
Language string `json:"language"`
IsFeatured bool `json:"is_featured"`
SourceURL string `json:"source_url"`
SourceName string `json:"source_name"`
MetaTitle string `json:"meta_title"`
MetaDescription string `json:"meta_description"`
Category *CategorySummary `json:"category"`
Author *AuthorDetail `json:"author"`
Tags []TagSummary `json:"tags"`
FeaturedImage *ImageDetail `json:"featured_image"`
UserVote int8 `json:"user_vote"`
RelatedArticles []ArticleSummary `json:"related_articles"`
}
// CategorySummary — краткая информация о категории
type CategorySummary struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
// AuthorSummary — краткая информация об авторе
type AuthorSummary struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// AuthorDetail — полная информация об авторе
type AuthorDetail struct {
ID int64 `json:"id"`
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// ImageSummary — краткая информация об изображении
type ImageSummary struct {
ThumbnailURL string `json:"thumbnail_url"`
MediumURL string `json:"medium_url"`
BlurHash string `json:"blur_hash"`
}
// ImageDetail — полная информация об изображении
type ImageDetail struct {
ID string `json:"id"`
OriginalURL string `json:"original_url"`
Variants map[string]string `json:"variants"`
Width int `json:"width"`
Height int `json:"height"`
AltText string `json:"alt_text"`
BlurHash string `json:"blur_hash"`
}
Оптимизированные SQL-запросы:
package repository
// ListArticles — запрос для ленты (без контента)
func (r *ArticleRepository) ListArticles(ctx context.Context, params ListParams) ([]*model.ArticleSummary, int64, error) {
// Подсчёт общего количества (для пагинации)
var total int64
countQuery := `
SELECT COUNT(*) FROM articles
WHERE status = 'published'
`
if params.CategoryID != nil {
countQuery += " AND category_id = $1"
}
err := r.db.QueryRow(ctx, countQuery).Scan(&total)
if err != nil {
return nil, 0, err
}
// Основной запрос — БЕЗ полей content_md, content_html, content_blocks
query := `
SELECT
a.id, a.title, a.slug, a.summary,
a.score, a.views, a.comments_count,
a.published_at, a.language, a.is_featured,
a.source_url, a.source_name,
-- Категория
c.id, c.name, c.slug,
-- Автор
u.id, u.display_name,
-- Изображение
i.id, i.variants, i.blur_hash
FROM articles a
LEFT JOIN categories c ON c.id = a.category_id
LEFT JOIN users u ON u.id = a.author_id
LEFT JOIN images i ON i.id = a.featured_image_id
WHERE a.status = 'published'
`
args := []interface{}{}
argIdx := 1
if params.CategoryID != nil {
query += fmt.Sprintf(" AND a.category_id = $%d", argIdx)
args = append(args, *params.CategoryID)
argIdx++
}
if params.TagID != nil {
query += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id AND at.tag_id = $%d)", argIdx)
args = append(args, *params.TagID)
argIdx++
}
// Сортировка
switch params.Sort {
case "popular":
query += " ORDER BY a.score DESC, a.published_at DESC"
case "trending":
query += " ORDER BY (a.views / EXTRACT(EPOCH FROM NOW() - a.published_at)) DESC"
default: // "latest"
query += " ORDER BY a.published_at DESC"
}
query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, params.PageSize, (params.Page-1)*params.PageSize)
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var articles []*model.ArticleSummary
for rows.Next() {
var a model.ArticleSummary
var cat model.CategorySummary
var author model.AuthorSummary
var img model.ImageSummary
err := rows.Scan(
&a.ID, &a.Title, &a.Slug, &a.Summary,
&a.Score, &a.Views, &a.CommentsCount,
&a.PublishedAt, &a.Language, &a.IsFeatured,
&a.SourceURL, &a.SourceName,
&cat.ID, &cat.Name, &cat.Slug,
&author.ID, &author.DisplayName,
&img.ID, &img.Variants, &img.BlurHash,
)
if err != nil {
continue
}
a.Category = &cat
a.Author = &author
if img.ID != "" {
a.FeaturedImage = &img
}
articles = append(articles, &a)
}
return articles, total, nil
}
// GetArticleDetail — запрос для страницы статьи (с полным контентом)
func (r *ArticleRepository) GetArticleDetail(ctx context.Context, id int64) (*model.ArticleDetail, error) {
query := `
SELECT
a.id, a.title, a.slug, a.summary,
a.content_md, a.content_html, a.content_blocks,
a.score, a.likes, a.dislikes, a.views, a.comments_count,
a.word_count, a.reading_time,
a.published_at, a.updated_at, a.language, a.is_featured,
a.source_url, a.source_name,
a.meta_title, a.meta_description,
-- Категория
c.id, c.name, c.slug,
-- Автор
u.id, u.display_name, u.bio,
-- Изображение
i.id, i.original_url, i.variants, i.width, i.height, i.alt_text, i.blur_hash
FROM articles a
LEFT JOIN categories c ON c.id = a.category_id
LEFT JOIN users u ON u.id = a.author_id
LEFT JOIN images i ON i.id = a.featured_image_id
WHERE a.id = $1 AND a.status = 'published'
`
var article model.ArticleDetail
err := r.db.QueryRow(ctx, query, id).Scan(
&article.ID, &article.Title, &article.Slug, &article.Summary,
&article.ContentMD, &article.ContentHTML, &article.ContentBlocks,
&article.Score, &article.Likes, &article.Dislikes, &article.Views, &article.CommentsCount,
&article.WordCount, &article.ReadingTime,
&article.PublishedAt, &article.UpdatedAt, &article.Language, &article.IsFeatured,
&article.SourceURL, &article.SourceName,
&article.MetaTitle, &article.MetaDescription,
// ... остальные поля
)
if err != nil {
return nil, err
}
// Загружаем теги отдельным запросом
tags, err := r.GetArticleTags(ctx, id)
if err == nil {
article.Tags = tags
}
// Загружаем связанные статьи
related, err := r.GetRelatedArticles(ctx, id, 5)
if err == nil {
article.RelatedArticles = related
}
// Инкрементируем счётчик просмотров (асинхронно)
go r.IncrementViews(context.Background(), id)
return &article, nil
}
Кэширование на бэкенде:
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
)
type ArticleCache struct {
redis *redis.Client
}
// GetArticleDetail получает статью из кэша или возвращает nil
func (ac *ArticleCache) GetArticleDetail(ctx context.Context, id int64) (*model.ArticleDetail, error) {
key := fmt.Sprintf("article:detail:%d", id)
data, err := ac.redis.Get(ctx, key).Bytes()
if err != nil {
return nil, err
}
var article model.ArticleDetail
if err := json.Unmarshal(data, &article); err != nil {
return nil, err
}
return &article, nil
}
// SetArticleDetail сохраняет статью в кэш
func (ac *ArticleCache) SetArticleDetail(ctx context.Context, article *model.ArticleDetail) error {
key := fmt.Sprintf("article:detail:%d", article.ID)
data, err := json.Marshal(article)
if err != nil {
return err
}
// Кэшируем на 5 минут
return ac.redis.Set(ctx, key, data, 5*time.Minute).Err()
}
// InvalidateArticle удаляет статью из кэша при обновлении
func (ac *ArticleCache) InvalidateArticle(ctx context.Context, id int64) error {
key := fmt.Sprintf("article:detail:%d", id)
return ac.redis.Del(ctx, key).Err()
}
// GetArticleList получает кэшированную ленту
func (ac *ArticleCache) GetArticleList(ctx context.Context, cacheKey string) ([]*model.ArticleSummary, error) {
data, err := ac.redis.Get(ctx, cacheKey).Bytes()
if err != nil {
return nil, err
}
var articles []*model.ArticleSummary
if err := json.Unmarshal(data, &articles); err != nil {
return nil, err
}
return articles, nil
}
// SetArticleList кэширует ленту
func (ac *ArticleCache) SetArticleList(ctx context.Context, cacheKey string, articles []*model.ArticleSummary) error {
data, err := json.Marshal(articles)
if err != nil {
return err
}
// Ленту кэшируем на 30 секунд
return ac.redis.Set(ctx, cacheKey, data, 30*time.Second).Err()
}
Клиентское кэширование с TanStack Query:
// hooks/useArticle.ts
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
// Ключи кэша
export const articleKeys = {
all: ['articles'] as const,
lists: () => [...articleKeys.all, 'list'] as const,
list: (filters: ArticleFilters) => [...articleKeys.lists(), filters] as const,
details: () => [...articleKeys.all, 'detail'] as const,
detail: (id: number) => [...articleKeys.details(), id] as const,
};
// Загрузка ленты (список статей без контента)
export function useArticleList(filters: ArticleFilters) {
return useQuery({
queryKey: articleKeys.list(filters),
queryFn: async () => {
const { data } = await api.get('/api/v1/articles', {
params: {
page: filters.page || 1,
per_page: filters.perPage || 20,
category: filters.category,
tag: filters.tag,
sort: filters.sort || 'latest',
},
});
return data;
},
staleTime: 30000, // 30 секунд — данные считаются свежими
gcTime: 5 * 60 * 1000, // 5 минут в кэше
});
}
// Загрузка полной статьи (с контентом)
export function useArticleDetail(id: number) {
const queryClient = useQueryClient();
return useQuery({
queryKey: articleKeys.detail(id),
queryFn: async () => {
const { data } = await api.get(`/api/v1/articles/${id}`);
return data;
},
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 30 * 60 * 1000, // 30 минут в кэше
// Предзаполняем данными из ленты, если есть
initialData: () => {
// Ищем статью в кэше лент
const lists = queryClient.getQueriesData<ArticleListResponse>({
queryKey: articleKeys.lists(),
});
for (const [, response] of lists) {
const article = response?.articles?.find(a => a.id === id);
if (article) {
return article as any;
}
}
return undefined;
},
});
}
// Предзагрузка статьи при наведении на ссылку
export function usePrefetchArticle() {
const queryClient = useQueryClient();
return (id: number) => {
queryClient.prefetchQuery({
queryKey: articleKeys.detail(id),
queryFn: async () => {
const { data } = await api.get(`/api/v1/articles/${id}`);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
}
Использование в компонентах:
// components/ArticleCard.tsx
import Link from 'next/link';
import { usePrefetchArticle } from '@/hooks/useArticle';
interface ArticleCardProps {
article: ArticleSummary;
}
export function ArticleCard({ article }: ArticleCardProps) {
const prefetchArticle = usePrefetchArticle();
return (
<article className="article-card">
<Link
href={`/articles/${article.slug}`}
onMouseEnter={() => prefetchArticle(article.id)} // Предзагрузка при наведении
onFocus={() => prefetchArticle(article.id)} // Для accessibility
>
{article.featured_image && (
<img
src={article.featured_image.thumbnail_url}
alt={article.title}
loading="lazy"
width={400}
height={225}
/>
)}
<h2>{article.title}</h2>
<p>{article.summary}</p>
<div className="meta">
<span>{article.category?.name}</span>
<span>{article.score > 0 ? `+${article.score}` : article.score}</span>
<time>{formatDate(article.published_at)}</time>
</div>
</Link>
</article>
);
}
// app/articles/[slug]/page.tsx
export default function ArticlePage({ params }: { params: { slug: string } }) {
// Получаем ID из slug или из параметров
const { data: article, isLoading } = useArticleDetail(parseInt(params.slug));
if (isLoading) return <ArticleSkeleton />;
if (!article) return <NotFound />;
return (
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.content_html }} />
{/* Связанные статьи — предзагружаем при наведении */}
<aside className="related-articles">
<h3>Читайте также</h3>
{article.related_articles.map(related => (
<ArticleCard key={related.id} article={related} />
))}
</aside>
</article>
);
}
Сравнение объёмов данных:
| Сценарий | Без оптимизации | С оптимизацией | Экономия |
|---|---|---|---|
| Лента (20 статей) | ~500 KB | ~15 KB | 97% |
| Страница статьи | ~500 KB (лента) + ~50 KB (статья) | ~50 KB | — |
| Повторное открытие | ~50 KB (с сервера) | 0 KB (из кэша) | 100% |
Итого: оптимизация достигается за счёт разделения моделей ответа (ArticleSummary для ленты, ArticleDetail для страницы), исключения полей контента из запросов ленты, кэширования на сервере (Redis) и клиенте (TanStack Query), предзагрузки при наведении на ссылку. Это снижает объём передаваемых данных на 97% для ленты и обеспечивает мгновенную загрузку ранее просмотренных статей.
Вопрос 22. Какие меры безопасности необходимо предусмотреть для приложения?
Таймкод: 00:41:45
Ответ собеседника: Правильный. Валидировать входные данные на XSS, использовать белый список символов, санитизировать на бэкенде.
Правильный ответ:
Безопасность веб-приложения — это многослойная система, охватывающая все уровни: от сетевого до прикладного. Ниже приведён полный перечень мер с реализацией.
1. Защита от XSS (Cross-Site Scripting):
package middleware
import (
"html"
"net/http"
"strings"
"github.com/microcosm-cc/bluemonday"
)
// XSSProtection middleware для санитизации входных данных
func XSSProtection(next http.Handler) http.Handler {
// bluemonday — строгий политика для текста
strictPolicy := bluemonday.StrictPolicy()
// Более мягкая политика для Markdown (разрешаем базовое форматирование)
ugcPolicy := bluemonday.UGCPolicy()
ugcPolicy.AllowAttrs("class").OnElements("pre", "code")
ugcPolicy.AllowAttrs("alt", "src", "title").OnElements("img")
ugcPolicy.AllowAttrs("href", "title").OnElements("a")
ugcPolicy.AllowElements("p", "br", "strong", "em", "ul", "ol", "li", "blockquote", "code", "pre")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Санитизируем query параметры
if r.URL.RawQuery != "" {
q := r.URL.Query()
for key, values := range q {
for i, v := range values {
q[key][i] = strictPolicy.Sanitize(v)
}
}
r.URL.RawQuery = q.Encode()
}
next.ServeHTTP(w, r)
})
}
// SanitizeString очищает строку от HTML-тегов
func SanitizeString(input string) string {
// HTML-экранирование
escaped := html.EscapeString(input)
return strings.TrimSpace(escaped)
}
// SanitizeHTML очищает HTML, оставляя только разрешённые теги
func SanitizeHTML(input string) string {
p := bluemonday.UGCPolicy()
return p.Sanitize(input)
}
Валидация входных данных в обработчиках:
package handler
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
var validate *validator.Validate
func init() {
validate = validator.New()
// Регистрируем кастомные валидаторы
validate.RegisterValidation("no_html", func(fl validator.FieldLevel) bool {
value := fl.Field().String()
// Проверяем отсутствие HTML-тегов
matched, _ := regexp.MatchString(`<[^>]+>`, value)
return !matched
})
validate.RegisterValidation("safe_markdown", func(fl validator.FieldLevel) bool {
value := fl.Field().String()
// Запрещаем потенциально опасные конструкции в Markdown
dangerous := []string{"<script", "javascript:", "onerror=", "onload="}
for _, d := range dangerous {
if strings.Contains(strings.ToLower(value), d) {
return false
}
}
return true
})
}
// CreateArticleRequest запрос на создание статьи с валидацией
type CreateArticleRequest struct {
Title string `json:"title" validate:"required,min=10,max=500,no_html"`
Summary string `json:"summary" validate:"omitempty,max=1000,no_html"`
ContentMD string `json:"content_md" validate:"required,min=100,safe_markdown"`
CategoryID *int `json:"category_id" validate:"omitempty,min=1"`
Tags []string `json:"tags" validate:"omitempty,max=10,dive,min=1,max=50,no_html"`
Language string `json:"language" validate:"omitempty,oneof=ru en"`
}
func (h *ArticleHandler) CreateArticle(w http.ResponseWriter, r *http.Request) {
var req CreateArticleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
// Валидация
if err := validate.Struct(req); err != nil {
var errors []string
for _, e := range err.(validator.ValidationErrors) {
errors = append(errors, fmt.Sprintf("field %s: %s", e.Field(), e.Tag()))
}
respondError(w, http.StatusBadRequest, strings.Join(errors, "; "))
return
}
// Санитизация текстовых полей
req.Title = SanitizeString(req.Title)
req.Summary = SanitizeString(req.Summary)
// ... обработка
}
2. Защита от SQL-инъекций:
package repository
// ПРАВИЛЬНО: используем параметризованные запросы
func (r *ArticleRepository) GetByCategory(ctx context.Context, categorySlug string) ([]*model.Article, error) {
// pgx автоматически экранирует параметры
rows, err := r.db.Query(ctx, `
SELECT a.id, a.title, a.slug, a.summary, a.score
FROM articles a
JOIN categories c ON c.id = a.category_id
WHERE c.slug = $1 AND a.status = 'published'
ORDER BY a.published_at DESC
`, categorySlug) // $1 — безопасный параметр
// ...
}
// НЕПРАВИЛЬНО: никогда не делайте так!
func (r *ArticleRepository) GetByCategoryUnsafe(ctx context.Context, categorySlug string) ([]*model.Article, error) {
// УЯЗВИМОСТЬ: SQL-инъекция!
query := fmt.Sprintf(`
SELECT * FROM articles
WHERE category_id = (SELECT id FROM categories WHERE slug = '%s')
`, categorySlug)
// Злоумышленник может передать: ' OR 1=1; DROP TABLE articles; --
}
3. CSRF-защита:
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
)
type CSRFConfig struct {
HeaderName string
CookieName string
Secret []byte
}
func CSRFProtection(config CSRFConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Пропускаем безопасные методы
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Проверяем CSRF-токен из заголовка
token := r.Header.Get(config.HeaderName)
if token == "" {
respondError(w, http.StatusForbidden, "CSRF token missing")
return
}
// Валидируем токен (сравниваем с токеном из cookie)
cookie, err := r.Cookie(config.CookieName)
if err != nil || cookie.Value != token {
respondError(w, http.StatusForbidden, "CSRF token invalid")
return
}
next.ServeHTTP(w, r)
})
}
}
// Генерация CSRF-токена
func GenerateCSRFToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
4. Rate Limiting:
package middleware
import (
"fmt"
"net/http"
"time"
"golang.org/x/time/rate"
)
type IPRateLimiter struct {
ips map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit
burst int
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{
ips: make(map[string]*rate.Limiter),
rate: r,
burst: b,
}
}
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter, exists := i.ips[ip]
if !exists {
limiter = rate.NewLimiter(i.rate, i.burst)
i.ips[ip] = limiter
}
return limiter
}
func (i *IPRateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
limiter := i.GetLimiter(ip)
if !limiter.Allow() {
w.Header().Set("Retry-After", "60")
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", i.burst))
w.Header().Set("X-RateLimit-Remaining", "0")
respondError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
5. CORS-настройки:
package middleware
import (
"net/http"
"strings"
)
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
MaxAge int
}
func CORS(config CORSConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Проверяем origin по белому списку
allowed := false
for _, o := range config.AllowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
// Для авторизованных запросов
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
// Preflight запросы
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// Использование
corsConfig := middleware.CORSConfig{
AllowedOrigins: []string{"https://news.example.com", "https://admin.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-CSRF-Token"},
MaxAge: 86400,
}
r.Use(middleware.CORS(corsConfig))
6. Безопасные заголовки:
package middleware
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Защита от clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Защита от MIME-sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// XSS Protection (для старых браузеров)
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ // В продакшене убрать unsafe-*
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https://cdn.example.com; "+
"font-src 'self'; "+
"connect-src 'self' https://api.example.com; "+
"frame-ancestors 'none';")
// Strict Transport Security (только для HTTPS)
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Permissions Policy
w.Header().Set("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
7. Валидация загрузки файлов:
package handler
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
)
type FileValidationConfig struct {
MaxSize int64
AllowedTypes map[string]string // MIME type -> extension
AllowedExts []string
}
var ImageConfig = FileValidationConfig{
MaxSize: 5 * 1024 * 1024, // 5 MB
AllowedTypes: map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
},
AllowedExts: []string{".jpg", ".jpeg", ".png", ".webp"},
}
func (h *UserHandler) UploadAvatar(w http.ResponseWriter, r *http.Request) {
// Ограничиваем размер запроса
r.ParseMultipartForm(ImageConfig.MaxSize)
file, header, err := r.FormFile("avatar")
if err != nil {
respondError(w, http.StatusBadRequest, "file required")
return
}
defer file.Close()
// Проверяем размер
if header.Size > ImageConfig.MaxSize {
respondError(w, http.StatusBadRequest,
fmt.Sprintf("file too large: max %d MB", ImageConfig.MaxSize/1024/1024))
return
}
// Определяем реальный MIME-тип (не доверяем заголовку)
buffer := make([]byte, 512)
n, _ := file.Read(buffer)
file.Seek(0, 0)
contentType := http.DetectContentType(buffer[:n])
// Проверяем разрешённые типы
expectedExt, allowed := ImageConfig.AllowedTypes[contentType]
if !allowed {
respondError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType))
return
}
// Проверяем расширение
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != expectedExt {
respondError(w, http.StatusBadRequest, "file extension does not match content type")
return
}
// Генерируем безопасное имя файла
hash := sha256.New()
io.Copy(hash, file)
file.Seek(0, 0)
safeFilename := fmt.Sprintf("%x%s", hash.Sum(nil)[:16], expectedExt)
// Загружаем в S3
url, err := h.imageService.Upload(r.Context(), file, safeFilename, contentType)
if err != nil {
respondError(w, http.StatusInternalServerError, "upload failed")
return
}
respondJSON(w, http.StatusOK, map[string]string{"url": url})
}
8. Аутентификация и авторизация:
package middleware
import (
"context"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTConfig struct {
Secret []byte
ExpireTime time.Duration
}
type Claims struct {
UserID int64 `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func (jc *JWTConfig) GenerateToken(userID int64, role string) (string, error) {
claims := &Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(jc.ExpireTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jc.Secret)
}
func (jc *JWTConfig) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
respondError(w, http.StatusUnauthorized, "authorization header required")
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
respondError(w, http.StatusUnauthorized, "invalid authorization format")
return
}
tokenString := parts[1]
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
// Проверяем метод подписи
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jc.Secret, nil
})
if err != nil || !token.Valid {
respondError(w, http.StatusUnauthorized, "invalid token")
return
}
// Добавляем данные пользователя в контекст
ctx := context.WithValue(r.Context(), "userID", claims.UserID)
ctx = context.WithValue(ctx, "userRole", claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RoleMiddleware проверяет роль пользователя
func RoleMiddleware(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole := r.Context().Value("userRole").(string)
allowed := false
for _, role := range roles {
if role == userRole {
allowed = true
break
}
}
if !allowed {
respondError(w, http.StatusForbidden, "insufficient permissions")
return
}
next.ServeHTTP(w, r)
})
}
}
9. Валидация входных данных на фронтенде:
// utils/validation.ts
import { z } from 'zod';
// Схема валидации статьи
export const articleSchema = z.object({
title: z.string()
.min(10, 'Заголовок должен быть не менее 10 символов')
.max(500, 'Заголовок не более 500 символов')
.regex(/^[^<>]*$/, 'HTML-теги запрещены'),
summary: z.string()
.max(1000, 'Описание не более 1000 символов')
.regex(/^[^<>]*$/, 'HTML-теги запрещены')
.optional(),
content_md: z.string()
.min(100, 'Контент должен быть не менее 100 символов')
.max(50000, 'Контент не более 50000 символов')
.refine(
(val) => !/<script/i.test(val),
'Скрипты запрещены'
)
.refine(
(val) => !/javascript:/i.test(val),
'JavaScript-ссылки запрещены'
),
tags: z.array(
z.string()
.min(1, 'Тег не может быть пустым')
.max(50, 'Тег не более 50 символов')
.regex(/^[a-zA-Zа-яА-ЯёЁ0-9-_]+$/, 'Только буквы, цифры, дефис и подчёркивание')
).max(10, 'Не более 10 тегов').optional(),
});
// Санитизация строки
export function sanitizeString(input: string): string {
return input
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/')
.trim();
}
Итого: безопасность приложения включает XSS-защиту (санитизация, CSP), SQL-инъекции (параметризованные запросы), CSRF-токены, rate limiting, CORS, безопасные заголовки, валидацию файлов, JWT-аутентификацию, ролевую авторизацию и клиентскую валидацию. Каждый уровень добавляет слой защиты, создавая эшелонированную оборону.
Вопрос 23. Какие угрозы безопасности связаны с встраиванием приложения через iframe на сторонние сайты?
Таймкод: 00:43:01
Ответ собеседника: Правильный. Сторонние сайты могут выполнять скрипты из нашего приложения, нужно настроить CORS с белым списком и использовать CSRF-токены.
Правильный ответ:
Встраивание приложения через iframe создаёт ряд специфических угроз безопасности, которые требуют комплексной защиты.
Основные угрозы:
1. Clickjacking (UI Redressing)
Злоумышленник размещает прозрачный iframe поверх легитимного интерфейса, заставляя пользователя совершать нежелательные действия.
// Защита: запрет встраивания в iframe
func FrameProtection(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Вариант 1: Запрет встраивания полностью
w.Header().Set("X-Frame-Options", "DENY")
// Вариант 2: Разрешение только для того же источника
// w.Header().Set("X-Frame-Options", "SAMEORIGIN")
// Вариант 3: CSP frame-ancestors (более гибкий)
w.Header().Set("Content-Security-Policy",
"frame-ancestors 'self' https://trusted-partner.com;")
next.ServeHTTP(w, r)
})
}
2. Clickjacking с несколькими слоями:
<!-- Атака: пользователь думает, что нажимает кнопку на легитимном сайте -->
<div style="position: relative;">
<!-- Легитимная кнопка -->
<button>Получить скидку</button>
<!-- Прозрачный iframe поверх -->
<iframe
src="https://bank.com/transfer"
style="position: absolute; top: 0; left: 0; opacity: 0; width: 100%; height: 100%;"
></iframe>
</div>
Защита на фронтенде (frame-busting):
// utils/frameProtection.ts
// Проверяем, загружено ли приложение в iframe
export function isInIframe(): boolean {
try {
return window.self !== window.top;
} catch {
// Если не удалось получить доступ к window.top (cross-origin)
return true;
}
}
// Политика встраивания
type EmbedPolicy = 'deny' | 'allow-same-origin' | 'allow-specific';
interface EmbedConfig {
policy: EmbedPolicy;
allowedOrigins?: string[];
}
const embedConfig: EmbedConfig = {
policy: 'allow-specific',
allowedOrigins: [
'https://trusted-partner.com',
'https://embed.trusted-partner.com',
],
};
export function checkEmbedAllowed(): boolean {
if (embedConfig.policy === 'deny') {
return !isInIframe();
}
if (embedConfig.policy === 'allow-same-origin') {
try {
// Проверяем, что родительский фрейм — тот же источник
return window.location.origin === window.parent.location.origin;
} catch {
return false; // Cross-origin — запрещаем
}
}
if (embedConfig.policy === 'allow-specific') {
if (!isInIframe()) return true; // Не в iframe — разрешаем
try {
const parentOrigin = document.referrer;
return embedConfig.allowedOrigins?.some(
origin => parentOrigin.startsWith(origin)
) ?? false;
} catch {
return false;
}
}
return false;
}
// Использование в компоненте
export function AppWrapper({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (!checkEmbedAllowed()) {
// Перенаправляем на основной сайт
window.top.location.href = window.location.href;
}
}, []);
if (!checkEmbedAllowed()) {
return <div>Приложение не может быть встроено в iframe</div>;
}
return <>{children}</>;
}
3. PostMessage безопасность:
// services/securePostMessage.ts
interface EmbedMessage {
type: string;
payload: unknown;
nonce: string;
timestamp: number;
}
class SecurePostMessage {
private allowedOrigins: string[];
private nonce: string;
constructor(allowedOrigins: string[]) {
this.allowedOrigins = allowedOrigins;
this.nonce = this.generateNonce();
// Слушаем сообщения от родительского окна
window.addEventListener('message', this.handleMessage.bind(this));
}
private generateNonce(): string {
return Math.random().toString(36).substring(2, 15);
}
// Отправка сообщений родительскому окну
postToParent(type: string, payload: unknown, targetOrigin?: string): void {
const message: EmbedMessage = {
type,
payload,
nonce: this.nonce,
timestamp: Date.now(),
};
// Отправляем конкретному origin, а не *
const origin = targetOrigin || this.allowedOrigins[0];
window.parent.postMessage(message, origin);
}
// Обработка входящих сообщений
private handleMessage(event: MessageEvent): void {
// Проверяем origin
if (!this.allowedOrigins.includes(event.origin)) {
console.warn('Blocked message from untrusted origin:', event.origin);
return;
}
const message = event.data as EmbedMessage;
// Проверяем nonce
if (message.nonce !== this.nonce) {
console.warn('Invalid nonce');
return;
}
// Проверяем timestamp (сообщение не старше 30 секунд)
if (Date.now() - message.timestamp > 30000) {
console.warn('Stale message');
return;
}
// Обрабатываем сообщение
this.processMessage(message);
}
private processMessage(message: EmbedMessage): void {
switch (message.type) {
case 'resize':
// Изменяем размер iframe
break;
case 'navigate':
// Навигация внутри приложения
break;
default:
console.warn('Unknown message type:', message.type);
}
}
}
export const embedMessenger = new SecurePostMessage([
'https://trusted-partner.com',
]);
4. Политика для партнёрских iframe:
package middleware
import (
"fmt"
"net/http"
"strings"
)
type IFrameConfig struct {
// Разрешённые домены для встраивания
AllowedEmbedders []string
// Разрешённые пути для встраивания
AllowedPaths map[string][]string // path -> allowed origins
}
func IFrameProtection(config IFrameConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
referer := r.Header.Get("Referer")
origin := r.Header.Get("Origin")
// Определяем источник запроса
source := origin
if source == "" {
source = referer
}
// Проверяем, является ли запрос iframe-встраиванием
if r.Header.Get("Sec-Fetch-Dest") == "iframe" || isIframeRequest(source) {
allowed := false
// Проверяем белый список
for _, allowedOrigin := range config.AllowedEmbedders {
if strings.HasPrefix(source, allowedOrigin) {
allowed = true
break
}
}
// Проверяем специфичные пути
if pathOrigins, exists := config.AllowedPaths[r.URL.Path]; exists {
allowed = false
for _, po := range pathOrigins {
if strings.HasPrefix(source, po) {
allowed = true
break
}
}
}
if !allowed {
// Запрещаем встраивание
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
respondError(w, http.StatusForbidden, "embedding not allowed")
return
}
// Разрешаем встраивание только для этого источника
w.Header().Set("X-Frame-Options", fmt.Sprintf("ALLOW-FROM %s", getOriginHost(source)))
w.Header().Set("Content-Security-Policy",
fmt.Sprintf("frame-ancestors %s", strings.Join(config.AllowedEmbedders, " ")))
}
next.ServeHTTP(w, r)
})
}
}
func isIframeRequest(source string) bool {
// Эвристика: если Origin/Referer отличается от нашего домена
return source != "" && !strings.Contains(source, "our-domain.com")
}
func getOriginHost(source string) string {
// Извлекаем хост из URL
if u, err := url.Parse(source); err == nil {
return u.Scheme + "://" + u.Host
}
return source
}
5. Защита от кражи данных через iframe:
// hooks/useEmbedProtection.ts
import { useEffect, useState } from 'react';
interface EmbedState {
isEmbedded: boolean;
isAllowed: boolean;
parentOrigin: string | null;
}
export function useEmbedProtection(allowedOrigins: string[]): EmbedState {
const [state, setState] = useState<EmbedState>({
isEmbedded: false,
isAllowed: true,
parentOrigin: null,
});
useEffect(() => {
const isEmbedded = window.self !== window.top;
let parentOrigin: string | null = null;
try {
parentOrigin = document.referrer ? new URL(document.referrer).origin : null;
} catch {
parentOrigin = null;
}
const isAllowed = !isEmbedded || (
parentOrigin !== null &&
allowedOrigins.includes(parentOrigin)
);
setState({ isEmbedded, isAllowed, parentOrigin });
// Если встроено в недоверенный сайт — блокируем функциональность
if (isEmbedded && !isAllowed) {
// Отключаем формы
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => e.preventDefault());
});
// Отключаем кнопки
document.querySelectorAll('button').forEach(button => {
button.disabled = true;
});
}
}, [allowedOrigins]);
return state;
}
6. CSP для встраиваемых компонентов:
// Для страниц, которые могут быть встроены в iframe
func EmbeddableCSP(allowedEmbedders []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// frame-ancestors контролирует, кто может встроить страницу
csp := fmt.Sprintf(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors %s",
strings.Join(allowedEmbedders, " "),
)
w.Header().Set("Content-Security-Policy", csp)
next.ServeHTTP(w, r)
})
}
}
Итого: при встраивании через iframe основные угрозы — clickjacking (защита: X-Frame-Options, CSP frame-ancestors), утечка данных (защита: проверка origin, nonce в postMessage), CSRF-атаки (защита: токены, SameSite cookies). Необходимо вести белый список доверенных доменов, валидировать все входящие сообщения, ограничивать функциональность при недоверенном встраивании.
Вопрос 24. Как настроить мониторинг ошибок и производительности для фронтенда и бэкенда?
Таймкод: 00:46:55
Ответ собеседника: Правильный. Sentry/PostHog для фронтенда, Prometheus + Grafana для бэкенда, trace ID для сквозного отслеживания.
Правильный ответ:
Комплексный мониторинг включает сбор метрик, логов, трейсов и ошибок на всех уровнях системы. Рассмотрим настройку для фронтенда и бэкенда.
1. Мониторинг фронтенда — Sentry:
// sentry.config.ts
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.REACT_APP_VERSION,
// Производительность
integrations: [
new BrowserTracing({
tracePropagationTargets: [
'localhost',
/^https:\/\/api\.example\.com/,
],
}),
],
// Выборка трейсов (в продакшене не все 100%)
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
// Ошибки
beforeSend(event, hint) {
// Фильтруем неважные ошибки
const ignoreErrors = [
'ResizeObserver loop limit exceeded',
'Network Error',
'Loading chunk',
];
if (event.exception?.values?.some(e =>
ignoreErrors.some(ignore => e.value?.includes(ignore))
)) {
return null;
}
return event;
},
// Пользовательский контекст
initialScope: {
tags: {
component: 'frontend',
},
},
});
// Оборачиваем приложение
function App() {
return (
<Sentry.ErrorBoundary
fallback={({ error }) => (
<ErrorPage error={error} />
)}
onError={(error, componentStack) => {
Sentry.captureException(error, {
contexts: {
react: { componentStack },
},
});
}}
>
<MainApp />
</Sentry.ErrorBoundary>
);
}
// Мониторинг API-запросов
class ApiClient {
async request(url: string, options: RequestInit = {}) {
const transaction = Sentry.startTransaction({
name: `API ${options.method || 'GET'} ${url}`,
op: 'http.client',
});
const span = transaction.startChild({
op: 'http',
description: `${options.method || 'GET'} ${url}`,
});
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
// Пробрасываем trace ID для сквозного мониторинга
'X-Trace-ID': Sentry.getCurrentScope()?.getPropagationContext()?.traceId || '',
},
});
span.setData('http.status_code', response.status);
span.setStatus(response.ok ? 'ok' : 'internal_error');
if (!response.ok) {
Sentry.captureException(new Error(`API Error: ${response.status}`), {
tags: { url, status: response.status },
});
}
return response;
} catch (error) {
span.setStatus('internal_error');
Sentry.captureException(error, {
tags: { url },
});
throw error;
} finally {
span.finish();
transaction.finish();
}
}
}
2. Мониторинг фронтенда — Web Vitals:
// monitoring/webVitals.ts
import { onCLS, onFID, onLCP, onFCP, onTTFB, Metric } from 'web-vitals';
type ReportHandler = (metric: Metric) => void;
const reportWebVitals: ReportHandler = (metric) => {
// Отправляем в Sentry
Sentry.setMeasurement(metric.name, metric.value, metric.unit);
// Отправляем в аналитику
if (window.gtag) {
window.gtag('event', metric.name, {
event_category: 'Web Vitals',
event_label: metric.id,
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
non_interaction: true,
});
}
// Отправляем в собственный endpoint
navigator.sendBeacon('/api/metrics/web-vitals', JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
delta: metric.delta,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
navigationType: metric.navigationType,
url: window.location.href,
timestamp: Date.now(),
}));
};
// Инициализация мониторинга
export function initWebVitalsMonitoring(): void {
onCLS(reportWebVitals); // Cumulative Layout Shift
onFID(reportWebVitals); // First Input Delay
onLCP(reportWebVitals); // Largest Contentful Paint
onFCP(reportWebVitals); // First Contentful Paint
onTTFB(reportWebVitals); // Time to First Byte
}
3. Мониторинг бэкенда — Prometheus метрики:
// metrics/metrics.go
package metrics
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
// HTTP метрики
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "endpoint", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Duration of HTTP requests in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint"},
)
httpRequestSize = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_size_bytes",
Help: "Size of HTTP requests in bytes",
Buckets: prometheus.ExponentialBuckets(100, 10, 8),
},
[]string{"method", "endpoint"},
)
// Бизнес-метрики
articlesPublished = promauto.NewCounter(
prometheus.CounterOpts{
Name: "articles_published_total",
Help: "Total number of published articles",
},
)
activeUsers = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "active_users",
Help: "Number of active users",
},
)
// Метрики базы данных
dbQueryDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "db_query_duration_seconds",
Help: "Duration of database queries",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 15),
},
[]string{"query_type", "table"},
)
dbConnections = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "db_connections",
Help: "Number of database connections",
},
[]string{"state"}, // active, idle, waiting
)
)
// MetricsMiddleware собирает HTTP метрики
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Оборачиваем ResponseWriter для получения статуса
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start).Seconds()
status := strconv.Itoa(wrapped.statusCode)
httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, status).Inc()
httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Экспорт метрик
func RegisterMetricsHandler(mux *http.ServeMux) {
mux.Handle("/metrics", promhttp.Handler())
}
4. Мониторинг бэкенда — структурированные логи:
// logging/logger.go
package logging
import (
"context"
"os"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type Logger struct {
*zap.SugaredLogger
}
type contextKey string
const (
traceIDKey contextKey = "trace_id"
userIDKey contextKey = "user_id"
)
func NewLogger(environment string) *Logger {
var config zap.Config
if environment == "production" {
config = zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
} else {
config = zap.NewDevelopmentConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
logger, _ := config.Build(
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
)
return &Logger{logger.Sugar()}
}
// WithTraceID добавляет trace ID в контекст логгера
func (l *Logger) WithTraceID(ctx context.Context) *Logger {
if traceID, ok := ctx.Value(traceIDKey).(string); ok {
return &Logger{l.SugaredLogger.With("trace_id", traceID)}
}
return l
}
// WithUserID добавляет user ID в контекст логгера
func (l *Logger) WithUserID(ctx context.Context) *Logger {
if userID, ok := ctx.Value(userIDKey).(int64); ok {
return &Logger{l.SugaredLogger.With("user_id", userID)}
}
return l
}
// LogHTTPRequest логирует HTTP запрос
func (l *Logger) LogHTTPRequest(ctx context.Context, r *http.Request, statusCode int, duration time.Duration) {
l.WithTraceID(ctx).WithUserID(ctx).Infow("HTTP Request",
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery,
"status", statusCode,
"duration_ms", duration.Milliseconds(),
"user_agent", r.UserAgent(),
"remote_addr", r.RemoteAddr,
"content_length", r.ContentLength,
)
}
// LogError логирует ошибку
func (l *Logger) LogError(ctx context.Context, err error, msg string, fields ...interface{}) {
allFields := append([]interface{}{"error", err.Error()}, fields...)
l.WithTraceID(ctx).WithUserID(ctx).Errorw(msg, allFields...)
}
// LogDBQuery логирует запрос к базе данных
func (l *Logger) LogDBQuery(ctx context.Context, query string, duration time.Duration, err error) {
logger := l.WithTraceID(ctx)
fields := []interface{}{
"query", query,
"duration_ms", duration.Milliseconds(),
}
if err != nil {
logger.Errorw("Database query failed", append(fields, "error", err.Error())...)
} else if duration > 100*time.Millisecond {
logger.Warnw("Slow database query", fields...)
} else {
logger.Debugw("Database query", fields...)
}
}
5. Мониторинг бэкенда — Distributed Tracing:
// tracing/tracing.go
package tracing
import (
"context"
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
func InitTracer(serviceName string, jaegerEndpoint string) (*sdktrace.TracerProvider, error) {
exporter, err := jaeger.New(
jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(jaegerEndpoint)),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
"service.name", serviceName,
"environment", "production",
)),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% выборка
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp, nil
}
// TracingMiddleware извлекает и создаёт спаны
func TracingMiddleware(next http.Handler) http.Handler {
tracer := otel.Tracer("http-server")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := otel.GetTextMapPropagator().Extract(
r.Context(),
propagation.HeaderCarrier(r.Header),
)
ctx, span := tracer.Start(ctx,
r.Method+" "+r.URL.Path,
trace.WithAttributes(
attribute.String("http.method", r.Method),
attribute.String("http.url", r.URL.String()),
attribute.String("http.user_agent", r.UserAgent()),
attribute.String("http.remote_addr", r.RemoteAddr),
),
)
defer span.End()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r.WithContext(ctx))
span.SetAttributes(
attribute.Int("http.status_code", wrapped.statusCode),
)
if wrapped.statusCode >= 500 {
span.SetStatus(codes.Error, "server error")
}
})
}
// DBTracer для трейсинга запросов к БД
type DBTracer struct {
tracer trace.Tracer
}
func (t *DBTracer) TraceQuery(ctx context.Context, query string, args ...interface{}) (context.Context, trace.Span) {
ctx, span := t.tracer.Start(ctx, "db.query",
trace.WithAttributes(
attribute.String("db.statement", query),
attribute.String("db.system", "postgresql"),
),
)
return ctx, span
}
6. Мониторинг бэкенда — Health Checks:
// health/health.go
package health
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
type HealthChecker struct {
checks map[string]Check
mu sync.RWMutex
}
type Check func(ctx context.Context) error
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Checks map[string]string `json:"checks,omitempty"`
}
func NewHealthChecker() *HealthChecker {
return &HealthChecker{
checks: make(map[string]Check),
}
}
func (hc *HealthChecker) Register(name string, check Check) {
hc.mu.Lock()
defer hc.mu.Unlock()
hc.checks[name] = check
}
func (hc *HealthChecker) Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
hc.mu.RLock()
checks := make(map[string]Check, len(hc.checks))
for k, v := range hc.checks {
checks[k] = v
}
hc.mu.RUnlock()
results := make(map[string]string)
allHealthy := true
for name, check := range checks {
if err := check(ctx); err != nil {
results[name] = "unhealthy: " + err.Error()
allHealthy = false
} else {
results[name] = "healthy"
}
}
response := HealthResponse{
Status: map[bool]string{true: "healthy", false: "unhealthy"}[allHealthy],
Timestamp: time.Now(),
Checks: results,
}
if !allHealthy {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(response)
}
// Пример использования
func SetupHealthChecks(db *sql.DB, redis *redis.Client) *HealthChecker {
hc := NewHealthChecker()
hc.Register("database", func(ctx context.Context) error {
return db.PingContext(ctx)
})
hc.Register("redis", func(ctx context.Context) error {
return redis.Ping(ctx).Err()
})
hc.Register("disk", func(ctx context.Context) error {
// Проверяем свободное место на диске
return nil
})
return hc
}
7. Мониторинг бэкенда — профилирование:
// profiling/profiling.go
package profiling
import (
"net/http"
_ "net/http/pprof" // Импортируем для регистрации handlers
"github.com/felixge/fgprof"
)
func RegisterProfilingHandlers(mux *http.ServeMux) {
// Стандартный pprof
mux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP)
mux.HandleFunc("/debug/pprof/cmdline", http.DefaultServeMux.ServeHTTP)
mux.HandleFunc("/debug/pprof/profile", http.DefaultServeMux.ServeHTTP)
mux.HandleFunc("/debug/pprof/symbol", http.DefaultServeMux.ServeHTTP)
mux.HandleFunc("/debug/pprof/trace", http.DefaultServeMux.ServeHTTP)
// fgprof — профилирование горутин
mux.Handle("/debug/fgprof", fgprof.Handler())
}
8. Grafana дашборд (конфигурация):
{
"dashboard": {
"title": "API Monitoring",
"panels": [
{
"title": "Request Rate",
"targets": [
{
"expr": "rate(http_requests_total[5m])",
"legendFormat": "{{method}} {{endpoint}} {{status}}"
}
],
"type": "graph"
},
{
"title": "Latency P95",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "{{method}} {{endpoint}}"
}
],
"type": "graph"
},
{
"title": "Error Rate",
"targets": [
{
"expr": "rate(http_requests_total{status=~'5..'}[5m]) / rate(http_requests_total[5m]) * 100",
"legendFormat": "Error %"
}
],
"type": "gauge",
"thresholds": [1, 5, 10]
},
{
"title": "Active Users",
"targets": [
{
"expr": "active_users"
}
],
"type": "stat"
}
]
}
}
9. Алерты (Prometheus Alertmanager):
# alerts.yml
groups:
- name: api_alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate detected"
description: "Error rate is {{ $value | humanizePercentage }}"
- alert: HighLatency
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 2
for: 10m
labels:
severity: warning
annotations:
summary: "High P95 latency"
description: "P95 latency is {{ $value }}s"
- alert: ServiceDown
expr: up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Service {{ $labels.instance }} is down"
- alert: HighDBQueryTime
expr: histogram_quantile(0.99, rate(db_query_duration_seconds_bucket[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "Slow database queries"
Итого: мониторинг фронтенда включает Sentry (ошибки + трейсинг), Web Vitals (производительность), PostHog (аналитика). Мониторинг бэкенда — Prometheus (метрики), Grafana (визуализация), Jaeger (трейсинг), ELK (логи), pprof (профилирование). Критически важен сквозной trace ID для связи фронтенд-запросов с бэкенд-обработкой.
Вопрос 25. Как обеспечить качество работы приложения на мобильных устройствах?
Таймкод: 00:51:19
Ответ собеседника: Правильный. Тестировать на реальных слабых устройствах, особенно на Mobile Safari. Использовать аналитику для записи сессий и отслеживания проблем.
Правильный ответ:
Качество на мобильных устройствах требует комплексного подхода: от тестирования на реальных устройствах до оптимизации производительности и специфических исправлений для мобильных браузеров.
1. Тестирование на реальных устройствах:
// utils/deviceDetection.ts
interface DeviceInfo {
isMobile: boolean;
isTablet: boolean;
isIOS: boolean;
isAndroid: boolean;
isSafari: boolean;
isChrome: boolean;
screenWidth: number;
screenHeight: number;
devicePixelRatio: number;
memoryLimit: 'low' | 'medium' | 'high';
}
export function getDeviceInfo(): DeviceInfo {
const ua = navigator.userAgent;
const width = window.innerWidth;
const isIOS = /iPad|iPhone|iPod/.test(ua);
const isAndroid = /Android/.test(ua);
const isMobile = /Mobile|Android|iPhone|iPad|iPod/.test(ua);
const isTablet = /iPad|Tablet|Android(?!.*Mobile)/.test(ua);
const isSafari = /^((?!chrome|android).)*safari/i.test(ua) && !isAndroid;
// Определяем лимит памяти устройства
const memory = (navigator as any).deviceMemory || 4;
const memoryLimit: DeviceInfo['memoryLimit'] =
memory <= 2 ? 'low' : memory <= 4 ? 'medium' : 'high';
return {
isMobile,
isTablet,
isIOS,
isAndroid,
isSafari,
isChrome: /Chrome/.test(ua) && !isAndroid,
screenWidth: width,
screenHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
memoryLimit,
};
}
// Адаптивная загрузка контента в зависимости от устройства
export function shouldLoadHeavyContent(): boolean {
const device = getDeviceInfo();
// На слабых устройствах не загружаем тяжёлый контент
if (device.memoryLimit === 'low') return false;
// На мобильных с маленьким экраном — упрощённая версия
if (device.isMobile && device.screenWidth < 375) return false;
return true;
}
2. Исправления для Mobile Safari:
// utils/safariFixes.ts
// Исправление проблемы с 100vh в Safari
export function fixSafariViewport(): void {
// Safari не учитывает адресную строку в 100vh
const setVH = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVH();
window.addEventListener('resize', setVH);
window.addEventListener('orientationchange', setVH);
}
// CSS переменная вместо 100vh
// .container { height: calc(var(--vh, 1vh) * 100); }
// Исправление проблемы с sticky элементами в Safari
export function fixSafariSticky(): void {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Safari имеет баги с position: sticky внутри overflow контейнеров
document.querySelectorAll('.sticky-element').forEach(el => {
(el as HTMLElement).style.webkitOverflowScrolling = 'touch';
});
}
}
// Исправление проблемы с input фокусом в iOS
export function fixIOSInputFocus(): void {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
// При фокусе на input Safari может неправильно скроллить
document.addEventListener('focusin', (e) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Даём Safari время на открытие клавиатуры
setTimeout(() => {
target.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 300);
}
});
// Скрытие клавиатуры при тапе вне input
document.addEventListener('touchstart', (e) => {
const target = e.target as HTMLElement;
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
(document.activeElement as HTMLElement)?.blur();
}
});
}
}
// Исправление проблемы с momentum scrolling в iOS
export function fixIOSScroll(): void {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
document.querySelectorAll('.scrollable').forEach(el => {
(el as HTMLElement).style.webkitOverflowScrolling = 'touch';
});
}
}
3. Оптимизация производительности для мобильных:
// utils/performanceOptimizations.ts
// Ленивая загрузка изображений с учётом устройства
export function setupLazyLoading(): void {
const device = getDeviceInfo();
// На слабых устройствах загружаем изображения ближе к viewport
const rootMargin = device.memoryLimit === 'low' ? '50px' : '200px';
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.dataset.src;
const srcset = img.dataset.srcset;
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
if (srcset) {
img.srcset = srcset;
img.removeAttribute('data-srcset');
}
observer.unobserve(img);
}
});
},
{ rootMargin }
);
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
// Адаптивные изображения
export function getResponsiveImageUrl(
baseUrl: string,
width: number,
device: DeviceInfo
): string {
// На Retina дисплеях загружаем изображения в 2x
const dpr = device.devicePixelRatio;
const targetWidth = Math.min(width * dpr, 1200); // Максимум 1200px
// Для Safari используем WebP с fallback
const format = device.isSafari && !supportsWebP() ? 'jpg' : 'webp';
return `${baseUrl}?w=${targetWidth}&f=${format}&q=80`;
}
function supportsWebP(): boolean {
const canvas = document.createElement('canvas');
if (canvas.getContext && canvas.getContext('2d')) {
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
}
return false;
}
// Отложенная загрузка некритичного контента
export function deferNonCriticalContent(): void {
const device = getDeviceInfo();
// На мобильных с плохим соединением откладываем загрузь
const connection = (navigator as any).connection;
const isSlowConnection = connection &&
(connection.effectiveType === 'slow-2g' ||
connection.effectiveType === '2g' ||
connection.saveData);
if (device.isMobile || isSlowConnection) {
// Откладываем загрузку виджетов, комментариев и т.д.
setTimeout(() => {
loadDeferredContent();
}, 3000);
} else {
loadDeferredContent();
}
}
4. Мониторинг производительности на мобильных:
// monitoring/mobilePerformance.ts
interface MobilePerformanceMetrics {
deviceInfo: DeviceInfo;
ttfb: number; // Time to First Byte
fcp: number; // First Contentful Paint
lcp: number; // Largest Contentful Paint
cls: number; // Cumulative Layout Shift
fid: number; // First Input Delay
jsHeapSize: number; // Использование памяти
networkType: string; // Тип соединения
}
export function trackMobilePerformance(): void {
const device = getDeviceInfo();
const connection = (navigator as any).connection;
// Собираем метрики производительности
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
sendMobileMetric('lcp', {
value: entry.startTime,
device,
url: window.location.href,
});
}
if (entry.entryType === 'layout-shift') {
sendMobileMetric('cls', {
value: (entry as any).value,
device,
url: window.location.href,
});
}
if (entry.entryType === 'first-input') {
sendMobileMetric('fid', {
value: (entry as any).processingStart - entry.startTime,
device,
url: window.location.href,
});
}
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'layout-shift', buffered: true });
observer.observe({ type: 'first-input', buffered: true });
// Мониторинг использования памяти
if ((performance as any).memory) {
setInterval(() => {
const memory = (performance as any).memory;
const usedPercent = memory.usedJSHeapSize / memory.jsHeapSizeLimit;
// Если использование памяти > 80% — отправляем предупреждение
if (usedPercent > 0.8) {
sendMobileMetric('memory_warning', {
used: memory.usedJSHeapSize,
limit: memory.jsHeapSizeLimit,
percent: usedPercent,
device,
});
}
}, 30000);
}
// Мониторинг типа соединения
if (connection) {
connection.addEventListener('change', () => {
sendMobileMetric('connection_change', {
effectiveType: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt,
saveData: connection.saveData,
device,
});
});
}
}
function sendMobileMetric(metric: string, data: any): void {
// Отправляем в аналитику
navigator.sendBeacon('/api/metrics/mobile', JSON.stringify({
metric,
data,
timestamp: Date.now(),
}));
}
5. Запись сессий пользователей:
// monitoring/sessionRecording.ts
interface SessionConfig {
sampleRate: number; // Процент сессий для записи
maxDuration: number; // Максимальная длительность записи
maskSensitiveData: boolean; // Маскирование чувствительных данных
recordOnMobileOnly: boolean; // Запись только на мобильных
}
const defaultConfig: SessionConfig = {
sampleRate: 0.01, // 1% сессий
maxDuration: 5 * 60 * 1000, // 5 минут
maskSensitiveData: true,
recordOnMobileOnly: true,
};
export function initSessionRecording(config: Partial<SessionConfig> = {}): void {
const mergedConfig = { ...defaultConfig, ...config };
const device = getDeviceInfo();
// Проверяем, нужно ли записывать эту сессию
if (mergedConfig.recordOnMobileOnly && !device.isMobile) return;
if (Math.random() > mergedConfig.sampleRate) return;
// Инициализация Sentry Session Replay
Sentry.init({
integrations: [
Sentry.replayIntegration({
maskAllText: mergedConfig.maskSensitiveData,
maskAllInputs: true,
blockClass: 'sentry-block',
blockSelector: '[data-sentry-block]',
maskClass: 'sentry-mask',
maskTextSelector: '[data-sentry-mask]',
}),
],
replaysSessionSampleRate: mergedConfig.sampleRate,
replaysOnErrorSampleRate: 1.0, // Записываем все сессии с ошибками
});
// Ограничиваем длительность записи
setTimeout(() => {
Sentry.getClient()?.getIntegration(Sentry.Replay)?.stop();
}, mergedConfig.maxDuration);
}
6. Адаптивный интерфейс для мобильных:
// hooks/useResponsive.ts
import { useState, useEffect } from 'react';
interface Breakpoints {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isSmallMobile: boolean; // < 375px
}
export function useResponsive(): Breakpoints {
const [breakpoints, setBreakpoints] = useState<Breakpoints>({
isMobile: false,
isTablet: false,
isDesktop: true,
isSmallMobile: false,
});
useEffect(() => {
const updateBreakpoints = () => {
const width = window.innerWidth;
setBreakpoints({
isMobile: width < 768,
isTablet: width >= 768 && width < 1024,
isDesktop: width >= 1024,
isSmallMobile: width < 375,
});
};
updateBreakpoints();
window.addEventListener('resize', updateBreakpoints);
return () => window.removeEventListener('resize', updateBreakpoints);
}, []);
return breakpoints;
}
// Компонент с адаптивным рендерингом
export function ArticleCard({ article }: { article: Article }) {
const { isMobile, isSmallMobile } = useResponsive();
const device = getDeviceInfo();
// На маленьких экранах — упрощённая карточка
if (isSmallMobile) {
return (
<div className="article-card-compact">
<h3>{article.title}</h3>
<span>{article.readTime} мин</span>
</div>
);
}
// На мобильных — без тяжёлых изображений на слабых устройствах
if (isMobile && device.memoryLimit === 'low') {
return (
<div className="article-card-mobile">
<h3>{article.title}</h3>
<p>{article.summary}</p>
{/* Без изображения */}
</div>
);
}
// Полная версия
return (
<div className="article-card">
<img src={article.imageUrl} alt={article.title} loading="lazy" />
<h3>{article.title}</h3>
<p>{article.summary}</p>
<div className="meta">
<span>{article.author}</span>
<span>{article.readTime} мин</span>
</div>
</div>
);
}
7. Тестирование с BrowserStack:
// e2e/mobile.spec.ts
import { test, expect, devices } from '@playwright/test';
// Тестирование на различных устройствах
const mobileDevices = [
{ name: 'iPhone 13', viewport: { width: 390, height: 844 }, userAgent: 'iPhone' },
{ name: 'iPhone SE', viewport: { width: 375, height: 667 }, userAgent: 'iPhone' },
{ name: 'Samsung Galaxy S21', viewport: { width: 360, height: 800 }, userAgent: 'Android' },
{ name: 'iPad Air', viewport: { width: 820, height: 1180 }, userAgent: 'iPad' },
];
mobileDevices.forEach(device => {
test(`article page on ${device.name}`, async ({ browser }) => {
const context = await browser.newContext({
viewport: device.viewport,
userAgent: device.userAgent,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
// Эмуляция медленного соединения
await page.route('**/*', async route => {
await new Promise(r => setTimeout(r, 100));
await route.continue();
});
await page.goto('/article/test-article');
// Проверяем, что контент загрузился
await expect(page.locator('h1')).toBeVisible();
// Проверяем, что изображения загрузились
const images = await page.locator('img').all();
for (const img of images) {
const naturalWidth = await img.evaluate(el => (el as HTMLImageElement).naturalWidth);
expect(naturalWidth).toBeGreaterThan(0);
}
// Проверяем отсутствие горизонтального скролла
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await context.close();
});
});
Итого: качество на мобильных требует тестирования на реальных устройствах (особенно слабых), исправления Safari-специфичных багов (100vh, sticky, input focus), адаптивной загрузки контента в зависимости от памяти и соединения, мониторинга производительности с разделением по типам устройств, записи сессий для воспроизведения проблем.
Вопрос 25. Как обеспечить качество работы приложения на мобильных устройствах?
Таймкод: 00:51:19
Ответ собеседника: Правильный. Тестировать на реальных слабых устройствах, особенно на Mobile Safari. Использовать аналитику для записи сессий и отслеживания проблем.
Правильный ответ:
Качество на мобильных устройствах требует комплексного подхода: от тестирования на реальных устройствах до оптимизации производительности и специфических исправлений для мобильных браузеров.
1. Тестирование на реальных устройствах:
// utils/deviceDetection.ts
interface DeviceInfo {
isMobile: boolean;
isTablet: boolean;
isIOS: boolean;
isAndroid: boolean;
isSafari: boolean;
isChrome: boolean;
screenWidth: number;
screenHeight: number;
devicePixelRatio: number;
memoryLimit: 'low' | 'medium' | 'high';
}
export function getDeviceInfo(): DeviceInfo {
const ua = navigator.userAgent;
const width = window.innerWidth;
const isIOS = /iPad|iPhone|iPod/.test(ua);
const isAndroid = /Android/.test(ua);
const isMobile = /Mobile|Android|iPhone|iPad|iPod/.test(ua);
const isTablet = /iPad|Tablet|Android(?!.*Mobile)/.test(ua);
const isSafari = /^((?!chrome|android).)*safari/i.test(ua) && !isAndroid;
// Определяем лимит памяти устройства
const memory = (navigator as any).deviceMemory || 4;
const memoryLimit: DeviceInfo['memoryLimit'] =
memory <= 2 ? 'low' : memory <= 4 ? 'medium' : 'high';
return {
isMobile,
isTablet,
isIOS,
isAndroid,
isSafari,
isChrome: /Chrome/.test(ua) && !isAndroid,
screenWidth: width,
screenHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
memoryLimit,
};
}
// Адаптивная загрузка контента в зависимости от устройства
export function shouldLoadHeavyContent(): boolean {
const device = getDeviceInfo();
// На слабых устройствах не загружаем тяжёлый контент
if (device.memoryLimit === 'low') return false;
// На мобильных с маленьким экраном — упрощённая версия
if (device.isMobile && device.screenWidth < 375) return false;
return true;
}
2. Исправления для Mobile Safari:
// utils/safariFixes.ts
// Исправление проблемы с 100vh в Safari
export function fixSafariViewport(): void {
// Safari не учитывает адресную строку в 100vh
const setVH = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVH();
window.addEventListener('resize', setVH);
window.addEventListener('orientationchange', setVH);
}
// CSS переменная вместо 100vh
// .container { height: calc(var(--vh, 1vh) * 100); }
// Исправление проблемы с sticky элементами в Safari
export function fixSafariSticky(): void {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Safari имеет баги с position: sticky внутри overflow контейнеров
document.querySelectorAll('.sticky-element').forEach(el => {
(el as HTMLElement).style.webkitOverflowScrolling = 'touch';
});
}
}
// Исправление проблемы с input фокусом в iOS
export function fixIOSInputFocus(): void {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
// При фокусе на input Safari может неправильно скроллить
document.addEventListener('focusin', (e) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Даём Safari время на открытие клавиатуры
setTimeout(() => {
target.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 300);
}
});
// Скрытие клавиатуры при тапе вне input
document.addEventListener('touchstart', (e) => {
const target = e.target as HTMLElement;
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
(document.activeElement as HTMLElement)?.blur();
}
});
}
}
// Исправление проблемы с momentum scrolling в iOS
export function fixIOSScroll(): void {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
document.querySelectorAll('.scrollable').forEach(el => {
(el as HTMLElement).style.webkitOverflowScrolling = 'touch';
});
}
}
3. Оптимизация производительности для мобильных:
// utils/performanceOptimizations.ts
// Ленивая загрузка изображений с учётом устройства
export function setupLazyLoading(): void {
const device = getDeviceInfo();
// На слабых устройствах загружаем изображения ближе к viewport
const rootMargin = device.memoryLimit === 'low' ? '50px' : '200px';
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.dataset.src;
const srcset = img.dataset.srcset;
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
if (srcset) {
img.srcset = srcset;
img.removeAttribute('data-srcset');
}
observer.unobserve(img);
}
});
},
{ rootMargin }
);
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
// Адаптивные изображения
export function getResponsiveImageUrl(
baseUrl: string,
width: number,
device: DeviceInfo
): string {
// На Retina дисплеях загружаем изображения в 2x
const dpr = device.devicePixelRatio;
const targetWidth = Math.min(width * dpr, 1200); // Максимум 1200px
// Для Safari используем WebP с fallback
const format = device.isSafari && !supportsWebP() ? 'jpg' : 'webp';
return `${baseUrl}?w=${targetWidth}&f=${format}&q=80`;
}
function supportsWebP(): boolean {
const canvas = document.createElement('canvas');
if (canvas.getContext && canvas.getContext('2d')) {
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
}
return false;
}
// Отложенная загрузка некритичного контента
export function deferNonCriticalContent(): void {
const device = getDeviceInfo();
// На мобильных с плохим соединением откладываем загрузку
const connection = (navigator as any).connection;
const isSlowConnection = connection &&
(connection.effectiveType === 'slow-2g' ||
connection.effectiveType === '2g' ||
connection.saveData);
if (device.isMobile || isSlowConnection) {
// Откладываем загрузку виджетов, комментариев и т.д.
setTimeout(() => {
loadDeferredContent();
}, 3000);
} else {
loadDeferredContent();
}
}
4. Мониторинг производительности на мобильных:
// monitoring/mobilePerformance.ts
interface MobilePerformanceMetrics {
deviceInfo: DeviceInfo;
ttfb: number;
fcp: number;
lcp: number;
cls: number;
fid: number;
jsHeapSize: number;
networkType: string;
}
export function trackMobilePerformance(): void {
const device = getDeviceInfo();
const connection = (navigator as any).connection;
// Собираем метрики производительности
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
sendMobileMetric('lcp', {
value: entry.startTime,
device,
url: window.location.href,
});
}
if (entry.entryType === 'layout-shift') {
sendMobileMetric('cls', {
value: (entry as any).value,
device,
url: window.location.href,
});
}
if (entry.entryType === 'first-input') {
sendMobileMetric('fid', {
value: (entry as any).processingStart - entry.startTime,
device,
url: window.location.href,
});
}
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'layout-shift', buffered: true });
observer.observe({ type: 'first-input', buffered: true });
// Мониторинг использования памяти
if ((performance as any).memory) {
setInterval(() => {
const memory = (performance as any).memory;
const usedPercent = memory.usedJSHeapSize / memory.jsHeapSizeLimit;
// Если использование памяти > 80% — отправляем предупреждение
if (usedPercent > 0.8) {
sendMobileMetric('memory_warning', {
used: memory.usedJSHeapSize,
limit: memory.jsHeapSizeLimit,
percent: usedPercent,
device,
});
}
}, 30000);
}
// Мониторинг типа соединения
if (connection) {
connection.addEventListener('change', () => {
sendMobileMetric('connection_change', {
effectiveType: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt,
saveData: connection.saveData,
device,
});
});
}
}
function sendMobileMetric(metric: string, data: any): void {
navigator.sendBeacon('/api/metrics/mobile', JSON.stringify({
metric,
data,
timestamp: Date.now(),
}));
}
5. Запись сессий пользователей:
// monitoring/sessionRecording.ts
interface SessionConfig {
sampleRate: number;
maxDuration: number;
maskSensitiveData: boolean;
recordOnMobileOnly: boolean;
}
const defaultConfig: SessionConfig = {
sampleRate: 0.01, // 1% сессий
maxDuration: 5 * 60 * 1000, // 5 минут
maskSensitiveData: true,
recordOnMobileOnly: true,
};
export function initSessionRecording(config: Partial<SessionConfig> = {}): void {
const mergedConfig = { ...defaultConfig, ...config };
const device = getDeviceInfo();
if (mergedConfig.recordOnMobileOnly && !device.isMobile) return;
if (Math.random() > mergedConfig.sampleRate) return;
// Инициализация Sentry Session Replay
Sentry.init({
integrations: [
Sentry.replayIntegration({
maskAllText: mergedConfig.maskSensitiveData,
maskAllInputs: true,
blockClass: 'sentry-block',
blockSelector: '[data-sentry-block]',
maskClass: 'sentry-mask',
maskTextSelector: '[data-sentry-mask]',
}),
],
replaysSessionSampleRate: mergedConfig.sampleRate,
replaysOnErrorSampleRate: 1.0,
});
setTimeout(() => {
Sentry.getClient()?.getIntegration(Sentry.Replay)?.stop();
}, mergedConfig.maxDuration);
}
6. Адаптивный интерфейс для мобильных:
// hooks/useResponsive.ts
import { useState, useEffect } from 'react';
interface Breakpoints {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isSmallMobile: boolean;
}
export function useResponsive(): Breakpoints {
const [breakpoints, setBreakpoints] = useState<Breakpoints>({
isMobile: false,
isTablet: false,
isDesktop: true,
isSmallMobile: false,
});
useEffect(() => {
const updateBreakpoints = () => {
const width = window.innerWidth;
setBreakpoints({
isMobile: width < 768,
isTablet: width >= 768 && width < 1024,
isDesktop: width >= 1024,
isSmallMobile: width < 375,
});
};
updateBreakpoints();
window.addEventListener('resize', updateBreakpoints);
return () => window.removeEventListener('resize', updateBreakpoints);
}, []);
return breakpoints;
}
// Компонент с адаптивным рендерингом
export function ArticleCard({ article }: { article: Article }) {
const { isMobile, isSmallMobile } = useResponsive();
const device = getDeviceInfo();
// На маленьких экранах — упрощённая карточка
if (isSmallMobile) {
return (
<div className="article-card-compact">
<h3>{article.title}</h3>
<span>{article.readTime} мин</span>
</div>
);
}
// На мобильных — без тяжёлых изображений на слабых устройствах
if (isMobile && device.memoryLimit === 'low') {
return (
<div className="article-card-mobile">
<h3>{article.title}</h3>
<p>{article.summary}</p>
</div>
);
}
// Полная версия
return (
<div className="article-card">
<img src={article.imageUrl} alt={article.title} loading="lazy" />
<h3>{article.title}</h3>
<p>{article.summary}</p>
<div className="meta">
<span>{article.author}</span>
<span>{article.readTime} мин</span>
</div>
</div>
);
}
7. Тестирование с Playwright на мобильных устройствах:
// e2e/mobile.spec.ts
import { test, expect, devices } from '@playwright/test';
const mobileDevices = [
{ name: 'iPhone 13', viewport: { width: 390, height: 844 }, userAgent: 'iPhone' },
{ name: 'iPhone SE', viewport: { width: 375, height: 667 }, userAgent: 'iPhone' },
{ name: 'Samsung Galaxy S21', viewport: { width: 360, height: 800 }, userAgent: 'Android' },
{ name: 'iPad Air', viewport: { width: 820, height: 1180 }, userAgent: 'iPad' },
];
mobileDevices.forEach(device => {
test(`article page on ${device.name}`, async ({ browser }) => {
const context = await browser.newContext({
viewport: device.viewport,
userAgent: device.userAgent,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
// Эмуляция медленного соединения
await page.route('**/*', async route => {
await new Promise(r => setTimeout(r, 100));
await route.continue();
});
await page.goto('/article/test-article');
// Проверяем, что контент загрузился
await expect(page.locator('h1')).toBeVisible();
// Проверяем, что изображения загрузились
const images = await page.locator('img').all();
for (const img of images) {
const naturalWidth = await img.evaluate(el => (el as HTMLImageElement).naturalWidth);
expect(naturalWidth).toBeGreaterThan(0);
}
// Проверяем отсутствие горизонтального скролла
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await context.close();
});
});
Итого: качество на мобильных требует тестирования на реальных устройствах (особенно слабых), исправления Safari-специфичных багов (100vh, sticky, input focus), адаптивной загрузки контента в зависимости от памяти и соединения, мониторинга производительности с разделением по типам устройств, записи сессий для воспроизведения проблем.
Вопрос 26. Как оценить эффективность оптимизаций и влияние изменений на пользовательский опыт?
Таймкод: 00:59:41
Ответ собеседника: Правильный. Использовать A/B-тестирование с фича-флагами. Разделить пользователей на группы, показать разные версии и сравнить метрики. Фича-флаги хранятся в отдельном сервисе/конфиге, клиент получает их и включает/выключает функциональность. Важно заводить тикеты на удаление старых флагов.
Правильный ответ:
Оценка эффективности оптимизаций — это многоуровневый процесс, который включает A/B-тестирование, мониторинг метрик, анализ пользовательского поведения и постоянную итерацию.
1. A/B-тестирование с фича-флагами:
// services/featureFlags.ts
interface FeatureFlags {
newArticleLayout: boolean;
lazyLoadImages: boolean;
newCommentSystem: boolean;
experimentalSearch: boolean;
}
class FeatureFlagService {
private flags: FeatureFlags = {};
private userId: string;
constructor(userId: string) {
this.userId = userId;
}
async initialize(): Promise<void> {
// Получаем флаги при загрузке приложения
const response = await fetch(`/api/feature-flags?userId=${this.userId}`);
this.flags = await response.json();
}
isEnabled(flagName: keyof FeatureFlags): boolean {
return this.flags[flagName] ?? false;
}
// Определяем группу пользователя на основе хеша
static getUserGroup(userId: string): 'control' | 'variant_a' | 'variant_b' {
const hash = this.hashString(userId);
const groupNumber = hash % 3;
if (groupNumber === 0) return 'control';
if (groupNumber === 1) return 'variant_a';
return 'variant_b';
}
private static hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
}
// Использование в компонентах
function ArticlePage() {
const flags = useFeatureFlags();
const { user } = useAuth();
// Разный лейаут в зависимости от флага
if (flags.isEnabled('newArticleLayout')) {
return <NewArticleLayout />;
}
return <OldArticleLayout />;
}
2. Серверная реализация фича-флагов:
// feature_flags/service.go
package featureflags
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
type Flag struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Percentage int `json:"percentage"` // 0-100
Groups []string `json:"groups"` // Группы пользователей
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
}
type Service struct {
flags map[string]Flag
mu sync.RWMutex
}
func NewService() *Service {
return &Service{
flags: make(map[string]Flag),
}
}
func (s *Service) GetFlagsForUser(userID string) map[string]bool {
s.mu.RLock()
defer s.mu.RUnlock()
userGroup := s.getUserGroup(userID)
result := make(map[string]bool)
for name, flag := range s.flags {
// Проверяем временные ограничения
now := time.Now()
if flag.StartDate != nil && now.Before(*flag.StartDate) {
result[name] = false
continue
}
if flag.EndDate != nil && now.After(*flag.EndDate) {
result[name] = false
continue
}
// Проверяем группы
if len(flag.Groups) > 0 {
result[name] = s.isInGroup(userGroup, flag.Groups)
continue
}
// Процентное распределение
if flag.Percentage > 0 {
userHash := s.hashUserID(userID, name)
result[name] = userHash%100 < flag.Percentage
continue
}
result[name] = flag.Enabled
}
return result
}
func (s *Service) getUserGroup(userID string) string {
hash := s.hashUserID(userID, "group")
groups := []string{"control", "variant_a", "variant_b"}
return groups[hash%len(groups)]
}
func (s *Service) hashUserID(userID, salt string) int {
data := fmt.Sprintf("%s:%s", userID, salt)
hash := sha256.Sum256([]byte(data))
return int(hash[0])<<24 | int(hash[1])<<16 | int(hash[2])<<8 | int(hash[3])
}
func (s *Service) isInGroup(userGroup string, allowedGroups []string) bool {
for _, g := range allowedGroups {
if g == userGroup {
return true
}
}
return false
}
func (s *Service) UpdateFlag(flag Flag) {
s.mu.Lock()
defer s.mu.Unlock()
s.flags[flag.Name] = flag
}
// HTTP handler
func (s *Service) HandleGetFlags(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("userId")
if userID == "" {
http.Error(w, "userId is required", http.StatusBadRequest)
return
}
flags := s.GetFlagsForUser(userID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags)
}
3. Отслеживание метрик A/B-теста:
// analytics/abTesting.ts
interface ABTestMetric {
testName: string;
variant: 'control' | 'variant_a' | 'variant_b';
metric: string;
value: number;
timestamp: number;
userId: string;
}
class ABTestTracker {
private static instance: ABTestTracker;
private metrics: ABTestMetric[] = [];
static getInstance(): ABTestTracker {
if (!ABTestTracker.instance) {
ABTestTracker.instance = new ABTestTracker();
}
return ABTestTracker.instance;
}
// Отслеживаем метрику
track(testName: string, metric: string, value: number): void {
const userId = this.getUserId();
const variant = this.getUserVariant(testName);
const metricData: ABTestMetric = {
testName,
variant,
metric,
value,
timestamp: Date.now(),
userId,
};
this.metrics.push(metricData);
// Отправляем в аналитику
this.sendToAnalytics(metricData);
// Логируем для отладки
console.log(`[AB Test] ${testName}/${variant}: ${metric} = ${value}`);
}
// Время загрузки страницы
trackPageLoad(testName: string, loadTime: number): void {
this.track(testName, 'page_load_time', loadTime);
}
// Время до первого взаимодействия
trackTimeToInteractive(testName: string, time: number): void {
this.track(testName, 'time_to_interactive', time);
}
// Клики по CTA
trackCTAClick(testName: string, ctaName: string): void {
this.track(testName, `cta_click_${ctaName}`, 1);
}
// Конверсия
trackConversion(testName: string, conversionName: string, value: number = 1): void {
this.track(testName, `conversion_${conversionName}`, value);
}
// Отскок (bounce rate)
trackBounce(testName: string, bounced: boolean): void {
this.track(testName, 'bounce', bounced ? 1 : 0);
}
// Глубина скролла
trackScrollDepth(testName: string, depth: number): void {
this.track(testName, 'scroll_depth', depth);
}
private sendToAnalytics(metric: ABTestMetric): void {
// Отправляем через sendBeacon для надёжности
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/ab-test', JSON.stringify(metric));
} else {
fetch('/api/analytics/ab-test', {
method: 'POST',
body: JSON.stringify(metric),
keepalive: true,
});
}
}
private getUserId(): string {
return localStorage.getItem('userId') || 'anonymous';
}
private getUserVariant(testName: string): 'control' | 'variant_a' | 'variant_b' {
const stored = localStorage.getItem(`ab_test_${testName}`);
if (stored) return stored as any;
// Определяем вариант на основе хеша
const hash = this.hashString(this.getUserId() + testName);
const variants = ['control', 'variant_a', 'variant_b'] as const;
const variant = variants[hash % 3];
localStorage.setItem(`ab_test_${testName}`, variant);
return variant;
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
}
export const abTestTracker = ABTestTracker.getInstance();
4. Хук для A/B-тестирования:
// hooks/useABTest.ts
import { useEffect, useState } from 'react';
import { abTestTracker } from '../analytics/abTesting';
interface UseABTestOptions {
testName: string;
trackPageView?: boolean;
trackInteractions?: boolean;
}
export function useABTest(options: UseABTestOptions) {
const { testName, trackPageView = true, trackInteractions = true } = options;
const [variant, setVariant] = useState<'control' | 'variant_a' | 'variant_b'>('control');
useEffect(() => {
// Определяем вариант при монтировании
const userVariant = abTestTracker.getUserVariant(testName);
setVariant(userVariant);
// Отслеживаем просмотр страницы
if (trackPageView) {
abTestTracker.track(testName, 'page_view', 1);
}
// Отслеживаем время загрузки
const startTime = performance.now();
const handleLoad = () => {
const loadTime = performance.now() - startTime;
abTestTracker.trackPageLoad(testName, loadTime);
};
window.addEventListener('load', handleLoad);
return () => {
window.removeEventListener('load', handleLoad);
};
}, [testName, trackPageView]);
const trackMetric = (metric: string, value: number) => {
abTestTracker.track(testName, metric, value);
};
const trackClick = (elementName: string) => {
if (trackInteractions) {
abTestTracker.track(testName, `click_${elementName}`, 1);
}
};
const trackConversion = (conversionName: string, value: number = 1) => {
abTestTracker.trackConversion(testName, conversionName, value);
};
return {
variant,
isControl: variant === 'control',
isVariantA: variant === 'variant_a',
isVariantB: variant === 'variant_b',
trackMetric,
trackClick,
trackConversion,
};
}
// Использование
function ArticlePage() {
const { variant, trackClick, trackConversion } = useABTest({
testName: 'new_article_layout',
});
const handleReadMore = () => {
trackClick('read_more');
trackConversion('read_more_click');
};
return (
<div>
{variant === 'control' ? <OldLayout /> : <NewLayout />}
<button onClick={handleReadMore}>Читать далее</button>
</div>
);
}
5. Анализ результатов A/B-теста:
// analytics/ab_analysis.go
package analytics
import (
"math"
"sort"
"time"
)
type ABTestResult struct {
TestName string `json:"test_name"`
Variant string `json:"variant"`
SampleSize int `json:"sample_size"`
Mean float64 `json:"mean"`
StdDev float64 `json:"std_dev"`
Confidence float64 `json:"confidence"` // Доверительный интервал
PValue float64 `json:"p_value"`
IsSignificant bool `json:"is_significant"`
Improvement float64 `json:"improvement"` // Улучшение в процентах
}
type ABTestAnalyzer struct {
metrics []ABTestMetric
}
func NewABTestAnalyzer(metrics []ABTestMetric) *ABTestAnalyzer {
return &ABTestAnalyzer{metrics: metrics}
}
func (a *ABTestAnalyzer) Analyze(testName, metricName string) map[string]ABTestResult {
// Группируем метрики по вариантам
variantMetrics := make(map[string][]float64)
for _, m := range a.metrics {
if m.TestName == testName && m.Metric == metricName {
variantMetrics[m.Variant] = append(variantMetrics[m.Variant], m.Value)
}
}
results := make(map[string]ABTestResult)
for variant, values := range variantMetrics {
if len(values) == 0 {
continue
}
result := ABTestResult{
TestName: testName,
Variant: variant,
SampleSize: len(values),
Mean: calculateMean(values),
StdDev: calculateStdDev(values),
}
results[variant] = result
}
// Сравниваем с контрольной группой
if control, ok := results["control"]; ok {
for variant, result := range results {
if variant == "control" {
continue
}
// Вычисляем улучшение
if control.Mean != 0 {
result.Improvement = ((result.Mean - control.Mean) / control.Mean) * 100
}
// Вычисляем p-value (t-тест Стьюдента)
controlValues := variantMetrics["control"]
variantValues := variantMetrics[variant]
result.PValue = calculateTTest(controlValues, variantValues)
// Проверяем статистическую значимость (p < 0.05)
result.IsSignificant = result.PValue < 0.05
// Вычисляем доверительный интервал (95%)
result.Confidence = 1.96 * result.StdDev / math.Sqrt(float64(result.SampleSize))
results[variant] = result
}
}
return results
}
func calculateMean(values []float64) float64 {
if len(values) == 0 {
return 0
}
sum := 0.0
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
func calculateStdDev(values []float64) float64 {
if len(values) < 2 {
return 0
}
mean := calculateMean(values)
sum := 0.0
for _, v := range values {
sum += math.Pow(v-mean, 2)
}
return math.Sqrt(sum / float64(len(values)-1))
}
func calculateTTest(group1, group2 []float64) float64 {
if len(group1) < 2 || len(group2) < 2 {
return 1.0
}
mean1 := calculateMean(group1)
mean2 := calculateMean(group2)
std1 := calculateStdDev(group1)
std2 := calculateStdDev(group2)
n1 := float64(len(group1))
n2 := float64(len(group2))
// Объединённая дисперсия
pooledVar := ((n1-1)*std1*std1 + (n2-1)*std2*std2) / (n1 + n2 - 2)
// t-статистика
t := (mean1 - mean2) / math.Sqrt(pooledVar*(1/n1+1/n2))
// Упрощённый расчёт p-value (для больших выборок)
// Для точного расчёта нужна функция распределения Стьюдента
df := n1 + n2 - 2
p := 2 * (1 - normalCDF(math.Abs(t)))
return p
}
// Упрощённая функция нормального распределения
func normalCDF(x float64) float64 {
return 0.5 * (1 + math.Erf(x/math.Sqrt(2)))
}
// Определяем минимальный размер выборки для теста
func CalculateSampleSize(baselineRate, minimumDetectableEffect float64, power float64) int {
// Для двустороннего теста с alpha = 0.05, power = 0.8
zAlpha := 1.96
zBeta := 0.84
p1 := baselineRate
p2 := baselineRate * (1 + minimumDetectableEffect)
numerator := (zAlpha*math.Sqrt(2*p1*(1-p1)) + zBeta*math.Sqrt(p1*(1-p1)+p2*(1-p2)))
denominator := math.Abs(p2 - p1)
return int(math.Pow(numerator/denominator, 2)) + 1
}
6. Управление фича-флагами и очистка:
// admin/FeatureFlagManager.tsx
import React, { useState, useEffect } from 'react';
interface FlagWithStats extends Flag {
createdAt: Date;
lastUsedAt: Date;
usageCount: number;
isStale: boolean;
}
function FeatureFlagManager() {
const [flags, setFlags] = useState<FlagWithStats[]>([]);
const [showStaleOnly, setShowStaleOnly] = useState(false);
useEffect(() => {
loadFlags();
}, []);
const loadFlags = async () => {
const response = await fetch('/api/admin/feature-flags');
const data = await response.json();
setFlags(data);
};
const createCleanupTicket = async (flagName: string) => {
// Создаём тикет на удаление флага
await fetch('/api/admin/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `Remove stale feature flag: ${flagName}`,
description: `Feature flag "${flagName}" has been active for more than 30 days. Please review and remove if no longer needed.`,
type: 'tech_debt',
priority: 'medium',
}),
});
};
const filteredFlags = showStaleOnly
? flags.filter(f => f.isStale)
: flags;
return (
<div>
<h1>Feature Flags</h1>
<label>
<input
type="checkbox"
checked={showStaleOnly}
onChange={e => setShowStaleOnly(e.target.checked)}
/>
Показать только устаревшие
</label>
<table>
<thead>
<tr>
<th>Название</th>
<th>Статус</th>
<th>Включён</th>
<th>Создан</th>
<th>Последнее использование</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{filteredFlags.map(flag => (
<tr key={flag.name} className={flag.isStale ? 'stale' : ''}>
<td>{flag.name}</td>
<td>
{flag.isStale && (
<span className="badge stale">Устаревший</span>
)}
</td>
<td>
<input
type="checkbox"
checked={flag.enabled}
onChange={() => toggleFlag(flag.name)}
/>
</td>
<td>{flag.createdAt.toLocaleDateString()}</td>
<td>{flag.lastUsedAt.toLocaleDateString()}</td>
<td>
{flag.isStale && (
<button onClick={() => createCleanupTicket(flag.name)}>
Создать тикет на удаление
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
7. Метрики для оценки пользовательского опыта:
// analytics/uxMetrics.ts
class UXMetricsCollector {
// Core Web Vitals
collectWebVitals(): void {
// LCP (Largest Contentful Paint)
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.sendMetric('lcp', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// FID (First Input Delay)
new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
this.sendMetric('fid', (entry as any).processingStart - entry.startTime);
}).observe({ type: 'first-input', buffered: true });
// CLS (Cumulative Layout Shift)
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value;
}
}
this.sendMetric('cls', clsValue);
}).observe({ type: 'layout-shift', buffered: true });
}
// Кастомные UX-метрики
collectCustomMetrics(): void {
// Время до интерактивности
this.trackTimeToInteractive();
// Глубина скролла
this.trackScrollDepth();
// Ошибки взаимодействия
this.trackInteractionErrors();
// Скорость анимаций
this.trackAnimationPerformance();
}
private trackTimeToInteractive(): void {
let tti = 0;
const checkTTI = () => {
// Проверяем, что основной контент загружен и страница интерактивна
const images = document.querySelectorAll('img');
const allLoaded = Array.from(images).every(img => img.complete);
if (allLoaded && document.readyState === 'complete') {
tti = performance.now();
this.sendMetric('tti', tti);
} else {
setTimeout(checkTTI, 100);
}
};
setTimeout(checkTTI, 1000);
}
private trackScrollDepth(): void {
let maxDepth = 0;
window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
const depth = Math.round((scrollTop / (docHeight - winHeight)) * 100);
if (depth > maxDepth) {
maxDepth = depth;
this.sendMetric('scroll_depth', maxDepth);
}
}, { passive: true });
}
private trackInteractionErrors(): void {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// Проверяем, был ли клик обработан
setTimeout(() => {
if (document.activeElement === target) {
// Элемент получил фокус, но ничего не произошло
this.sendMetric('interaction_error', 1);
}
}, 100);
});
}
private trackAnimationPerformance(): void {
let frameCount = 0;
let lastTime = performance.now();
const checkFPS = () => {
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
const fps = Math.round((frameCount * 1000) / (now - lastTime));
this.sendMetric('fps', fps);
frameCount = 0;
lastTime = now;
}
requestAnimationFrame(checkFPS);
};
requestAnimationFrame(checkFPS);
}
private sendMetric(name: string, value: number): void {
navigator.sendBeacon('/api/analytics/ux-metrics', JSON.stringify({
name,
value,
url: window.location.href,
timestamp: Date.now(),
}));
}
}
// Инициализация
const uxMetrics = new UXMetricsCollector();
uxMetrics.collectWebVitals();
uxMetrics.collectCustomMetrics();
Итого: оценка эффективности оптимизаций включает A/B-тестирование с фича-флагами (серверная и клиентская реализация), отслеживание метрик (Core Web Vitals, кастомные UX-метрики), статистический анализ результатов (t-тест, p-value, доверительные интервалы), управление флагами и создание тикетов на очистку. Важно не только запускать тесты, но и удалять устаревшие флаги, чтобы не засорять код.
Вопрос 27. Как реализовать ленивую загрузку (lazy loading) для бесконечной ленты новостей?
Таймкод: 01:03:47
Ответ собеседника: Правильный. Использовать Intersection Observer для отслеживания появления якорного элемента (последней карточки) в зоне видимости. При появлении якоря загружать следующую партию новостей и переставлять атрибут якоря на следующий элемент.
Правильный ответ:
Ленивая загрузка для бесконечной ленты — это комплексная задача, включающая Intersection Observer, управление состоянием загрузки, виртуализацию и оптимизацию производительности.
1. Базовая реализация с Intersection Observer:
// hooks/useInfiniteScroll.ts
import { useEffect, useRef, useCallback, useState } from 'react';
interface UseInfiniteScrollOptions {
threshold?: number;
rootMargin?: string;
onLoadMore: () => Promise<void>;
hasMore: boolean;
isLoading: boolean;
}
export function useInfiniteScroll({
threshold = 0.1,
rootMargin = '200px',
onLoadMore,
hasMore,
isLoading,
}: UseInfiniteScrollOptions) {
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const handleIntersect = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !isLoading) {
onLoadMore();
}
},
[hasMore, isLoading, onLoadMore]
);
useEffect(() => {
// Создаём observer
observerRef.current = new IntersectionObserver(handleIntersect, {
threshold,
rootMargin,
});
// Наблюдаем за sentinel-элементом
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [handleIntersect, threshold, rootMargin]);
return { sentinelRef };
}
// Компонент бесконечной ленты
function NewsFeed() {
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const response = await fetch(`/api/articles?page=${page}&limit=20`);
const data = await response.json();
setArticles(prev => [...prev, ...data.articles]);
setPage(prev => prev + 1);
setHasMore(data.hasMore);
} catch (error) {
console.error('Failed to load articles:', error);
} finally {
setIsLoading(false);
}
};
const { sentinelRef } = useInfiniteScroll({
onLoadMore: loadMore,
hasMore,
isLoading,
rootMargin: '300px', // Начинаем загрузку за 300px до конца
});
return (
<div className="news-feed">
{articles.map((article, index) => (
<ArticleCard key={article.id} article={article} />
))}
{/* Sentinel-элемент для Intersection Observer */}
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
{isLoading && <LoadingSpinner />}
{!hasMore && <EndOfFeedMessage />}
</div>
);
}
2. Оптимизированная версия с виртуализацией:
// components/VirtualizedNewsFeed.tsx
import React, { useCallback, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
interface VirtualizedNewsFeedProps {
articles: Article[];
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => Promise<void>;
}
function VirtualizedNewsFeed({
articles,
hasMore,
isLoading,
onLoadMore,
}: VirtualizedNewsFeedProps) {
const parentRef = useRef<HTMLDivElement>(null);
// Виртуализация списка
const virtualizer = useVirtualizer({
count: articles.length + (hasMore ? 1 : 0), // +1 для sentinel
getScrollElement: () => parentRef.current,
estimateSize: () => 200, // Ориентировочная высота карточки
overscan: 5, // Рендерим на 5 элементов больше
});
// Загрузка при достижении конца виртуального списка
const virtualItems = virtualizer.getVirtualItems();
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index >= articles.length - 1 && hasMore && !isLoading) {
onLoadMore();
}
}, [virtualItems, articles.length, hasMore, isLoading, onLoadMore]);
return (
<div
ref={parentRef}
className="news-feed-container"
style={{ height: '100vh', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map(virtualItem => {
const article = articles[virtualItem.index];
const isLoader = virtualItem.index >= articles.length;
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoader ? (
<div className="loading-trigger">
{isLoading ? <LoadingSpinner /> : null}
</div>
) : (
<ArticleCard article={article} />
)}
</div>
);
})}
</div>
</div>
);
}
3. Продвинутая реализация с управлением очередью загрузок:
// services/ArticleLoader.ts
interface LoadQueueItem {
page: number;
priority: 'high' | 'normal' | 'low';
resolve: (articles: Article[]) => void;
reject: (error: Error) => void;
}
class ArticleLoader {
private queue: LoadQueueItem[] = [];
private loadingPages: Set<number> = new Set();
private cache: Map<number, Article[]> = new Map();
private maxConcurrent = 2;
async loadPage(page: number, priority: 'high' | 'normal' | 'low' = 'normal'): Promise<Article[]> {
// Проверяем кэш
if (this.cache.has(page)) {
return this.cache.get(page)!;
}
// Проверяем, не загружается ли уже
if (this.loadingPages.has(page)) {
return new Promise((resolve, reject) => {
this.queue.push({ page, priority, resolve, reject });
});
}
// Добавляем в очередь загрузки
this.loadingPages.add(page);
try {
const articles = await this.fetchPage(page);
this.cache.set(page, articles);
// Обрабатываем очередь
this.processQueue();
return articles;
} catch (error) {
this.loadingPages.delete(page);
throw error;
}
}
private async fetchPage(page: number): Promise<Article[]> {
const response = await fetch(`/api/articles?page=${page}&limit=20`);
if (!response.ok) {
throw new Error(`Failed to load page ${page}`);
}
const data = await response.json();
return data.articles;
}
private processQueue(): void {
// Сортируем очередь по приоритету
this.queue.sort((a, b) => {
const priorityOrder = { high: 0, normal: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
});
// Загружаем следующие страницы
while (this.queue.length > 0 && this.loadingPages.size < this.maxConcurrent) {
const item = this.queue.shift()!;
if (this.cache.has(item.page)) {
item.resolve(this.cache.get(item.page)!);
continue;
}
this.loadingPages.add(item.page);
this.fetchPage(item.page)
.then(articles => {
this.cache.set(item.page, articles);
item.resolve(articles);
})
.catch(item.reject)
.finally(() => {
this.loadingPages.delete(item.page);
this.processQueue();
});
}
}
// Предзагрузка следующей страницы
prefetchNextPage(currentPage: number): void {
const nextPage = currentPage + 1;
if (!this.cache.has(nextPage) && !this.loadingPages.has(nextPage)) {
this.loadPage(nextPage, 'low').catch(() => {
// Игнорируем ошибки предзагрузки
});
}
}
clearCache(): void {
this.cache.clear();
}
}
export const articleLoader = new ArticleLoader();
4. React Query интеграция:
// hooks/useInfiniteArticles.ts
import { useInfiniteQuery } from '@tanstack/react-query';
interface ArticlesResponse {
articles: Article[];
nextCursor: string | null;
hasMore: boolean;
}
async function fetchArticles({ pageParam }: { pageParam?: string }): Promise<ArticlesResponse> {
const url = pageParam
? `/api/articles?cursor=${pageParam}&limit=20`
: '/api/articles?limit=20';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch articles');
}
return response.json();
}
export function useInfiniteArticles() {
return useInfiniteQuery({
queryKey: ['articles'],
queryFn: fetchArticles,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined as string | undefined,
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 10 * 60 * 1000, // 10 минут
});
}
// Компонент с React Query
function NewsFeedWithReactQuery() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
} = useInfiniteArticles();
const { sentinelRef } = useInfiniteScroll({
onLoadMore: fetchNextPage,
hasMore: !!hasNextPage,
isLoading: isFetchingNextPage,
});
if (isLoading) return <LoadingSkeleton />;
if (error) return <ErrorMessage error={error} />;
const allArticles = data?.pages.flatMap(page => page.articles) ?? [];
return (
<div className="news-feed">
{allArticles.map((article, index) => (
<ArticleCard
key={article.id}
article={article}
priority={index < 4} // Приоритетная загрузка первых 4
/>
))}
<div ref={sentinelRef} className="sentinel" />
{isFetchingNextPage && <LoadingSpinner />}
</div>
);
}
5. Оптимизация производительности:
// components/OptimizedArticleCard.tsx
import React, { memo, useCallback } from 'react';
interface ArticleCardProps {
article: Article;
priority?: boolean;
onVisible?: () => void;
}
const ArticleCard = memo(function ArticleCard({
article,
priority = false,
onVisible,
}: ArticleCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
// Отслеживаем видимость карточки
useEffect(() => {
if (!onVisible) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onVisible();
observer.disconnect();
}
},
{ threshold: 0.5 }
);
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => observer.disconnect();
}, [onVisible]);
const handleClick = useCallback(() => {
// Навигация к статье
navigate(`/article/${article.id}`);
}, [article.id]);
return (
<article ref={cardRef} className="article-card" onClick={handleClick}>
<OptimizedImage
src={article.imageUrl}
alt={article.title}
priority={priority}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
<div className="article-content">
<h2 className="article-title">{article.title}</h2>
<p className="article-summary">{article.summary}</p>
<div className="article-meta">
<span className="author">{article.author}</span>
<time dateTime={article.publishedAt}>
{formatDate(article.publishedAt)}
</time>
</div>
</div>
</article>
);
});
// Оптимизированный компонент изображения
const OptimizedImage = memo(function OptimizedImage({
src,
alt,
priority = false,
sizes,
}: {
src: string;
alt: string;
priority?: boolean;
sizes: string;
}) {
return (
<div className="article-image-container">
<img
src={src}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding={priority ? 'sync' : 'async'}
fetchpriority={priority ? 'high' : 'auto'}
sizes={sizes}
style={{
width: '100%',
height: 'auto',
objectFit: 'cover',
}}
/>
</div>
);
});
6. Серверная пагинация с курсором:
// handlers/articles.go
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Cursor struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
}
type ArticlesResponse struct {
Articles []Article `json:"articles"`
NextCursor *string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
func (h *Handler) GetArticles(w http.ResponseWriter, r *http.Request) {
// Параметры запроса
cursorParam := r.URL.Query().Get("cursor")
limitParam := r.URL.Query().Get("limit")
limit := 20
if limitParam != "" {
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
limit = l
}
}
var cursor *Cursor
if cursorParam != "" {
var err error
cursor, err = decodeCursor(cursorParam)
if err != nil {
http.Error(w, "Invalid cursor", http.StatusBadRequest)
return
}
}
// Загружаем статьи
articles, nextCursor, err := h.articleService.GetArticles(r.Context(), cursor, limit+1)
if err != nil {
http.Error(w, "Failed to load articles", http.StatusInternalServerError)
return
}
// Проверяем, есть ли ещё статьи
hasMore := len(articles) > limit
if hasMore {
articles = articles[:limit]
}
// Кодируем следующий курсор
var nextCursorStr *string
if nextCursor != nil && hasMore {
encoded := encodeCursor(nextCursor)
nextCursorStr = &encoded
}
response := ArticlesResponse{
Articles: articles,
NextCursor: nextCursorStr,
HasMore: hasMore,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func encodeCursor(cursor *Cursor) string {
data, _ := json.Marshal(cursor)
return base64.URLEncoding.EncodeToString(data)
}
func decodeCursor(encoded string) (*Cursor, error) {
data, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
var cursor Cursor
if err := json.Unmarshal(data, &cursor); err != nil {
return nil, err
}
return &cursor, nil
}
// Сервис для загрузки статей
func (s *ArticleService) GetArticles(ctx context.Context, cursor *Cursor, limit int) ([]Article, *Cursor, error) {
query := `SELECT id, title, summary, image_url, author, created_at
FROM articles
WHERE ($1::timestamp IS NULL OR (created_at, id) < ($1, $2))
ORDER BY created_at DESC, id DESC
LIMIT $3`
var createdAt *time.Time
var id *string
if cursor != nil {
createdAt = &cursor.CreatedAt
id = &cursor.ID
}
rows, err := s.db.QueryContext(ctx, query, createdAt, id, limit)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var article Article
if err := rows.Scan(
&article.ID,
&article.Title,
&article.Summary,
&article.ImageURL,
&article.Author,
&article.CreatedAt,
); err != nil {
return nil, nil, err
}
articles = append(articles, article)
}
// Формируем курсор для следующей страницы
var nextCursor *Cursor
if len(articles) > 0 {
lastArticle := articles[len(articles)-1]
nextCursor = &Cursor{
ID: lastArticle.ID,
CreatedAt: lastArticle.CreatedAt,
}
}
return articles, nextCursor, nil
}
7. Обработка ошибок и retry-логика:
// hooks/useInfiniteScrollWithRetry.ts
import { useState, useCallback } from 'react';
interface RetryOptions {
maxRetries?: number;
retryDelay?: number;
backoffMultiplier?: number;
}
export function useInfiniteScrollWithRetry<T>(
fetchFn: () => Promise<T>,
options: RetryOptions = {}
) {
const {
maxRetries = 3,
retryDelay = 1000,
backoffMultiplier = 2,
} = options;
const [retryCount, setRetryCount] = useState(0);
const [error, setError] = useState<Error | null>(null);
const executeWithRetry = useCallback(async (): Promise<T | null> => {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await fetchFn();
setRetryCount(0);
setError(null);
return result;
} catch (err) {
const error = err as Error;
if (attempt === maxRetries) {
setError(error);
throw error;
}
// Экспоненциальная задержка
const delay = retryDelay * Math.pow(backoffMultiplier, attempt);
setRetryCount(attempt + 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
return null;
}, [fetchFn, maxRetries, retryDelay, backoffMultiplier]);
const reset = useCallback(() => {
setRetryCount(0);
setError(null);
}, []);
return {
executeWithRetry,
retryCount,
error,
reset,
canRetry: retryCount < maxRetries,
};
}
// Использование
function NewsFeedWithRetry() {
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const fetchArticles = useCallback(async () => {
const response = await fetch(`/api/articles?page=${page}&limit=20`);
const data = await response.json();
setArticles(prev => [...prev, ...data.articles]);
setPage(prev => prev + 1);
setHasMore(data.hasMore);
return data;
}, [page]);
const { executeWithRetry, retryCount, error, reset, canRetry } =
useInfiniteScrollWithRetry(fetchArticles);
const { sentinelRef } = useInfiniteScroll({
onLoadMore: executeWithRetry,
hasMore,
isLoading: false,
});
return (
<div className="news-feed">
{articles.map(article => (
<ArticleCard key={article.id} article={article} />
))}
<div ref={sentinelRef} className="sentinel" />
{error && (
<div className="error-container">
<p>Ошибка загрузки: {error.message}</p>
{canRetry && (
<button onClick={() => reset()}>
Повторить попытку ({retryCount}/3)
</button>
)}
</div>
)}
</div>
);
}
Итого: реализация ленивой загрузки включает Intersection Observer для отслеживания скролла, виртуализацию для больших списков, кэширование и предзагрузку, cursor-based пагинацию на сервере, retry-логику для ошибок, React Query для управления состоянием. Важно использовать rootMargin для начала загрузки до того, как пользователь дойдёт до конца списка.
Вопрос 28. Как организовать CI/CD процесс для проекта?
Таймкод: 01:05:31
Ответ собеседника: Правильный. CI запускается при merge request: lint, type check, unit tests, build. CD для релиза: проверки в релизной ветке, деплой на staging, ручной деплой на production с поэтапным развёртыванием. Git-flow: фича-ветки мержатся в master, релизные ветки отводятся от master.
Правильный ответ:
CI/CD — это комплексная система, которая включает автоматизацию сборки, тестирования, развёртывания и мониторинга. Правильная организация процесса обеспечивает быструю обратную связь разработчикам и стабильные релизы.
1. Структура Git-ветвления (Git Flow / Trunk-Based):
# .github/branch-protection.yml
# Защита веток в GitHub
branches:
- name: main
protection:
required_pull_request_reviews:
required_approving_review_count: 2
dismiss_stale_reviews: true
required_status_checks:
strict: true
contexts:
- "lint"
- "type-check"
- "unit-tests"
- "build"
- "security-scan"
enforce_admins: true
required_linear_history: true
- name: staging
protection:
required_pull_request_reviews:
required_approving_review_count: 1
required_status_checks:
contexts:
- "integration-tests"
- "e2e-tests"
2. Полный CI-пайплайн (GitHub Actions):
# .github/workflows/ci.yml
name: CI Pipeline
on:
pull_request:
branches: [main, staging]
push:
branches: [main]
env:
NODE_VERSION: '18'
GO_VERSION: '1.21'
jobs:
# Этап 1: Линтинг и проверка типов
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript type check
run: npm run type-check
- name: Run Prettier check
run: npm run format:check
# Этап 2: Unit тесты
unit-tests:
runs-on: ubuntu-latest
needs: lint-and-typecheck
strategy:
matrix:
shard: [1, 2, 3, 4] # Параллельное выполнение тестов
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests (shard ${{ matrix.shard }}/4)
run: npm run test:unit -- --shard=${{ matrix.shard }}/4
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.shard }}
path: coverage/
# Этап 3: Сборка
build:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build
path: dist/
# Этап 4: Сканирование безопасности
security-scan:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
# Этап 5: Проверка Docker-образа
docker-build:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan Docker image
uses: aquasecurity/trivy-action@master
with:
image-ref: 'app:${{ github.sha }}'
severity: 'CRITICAL,HIGH'
# Этап 6: Интеграционные тесты
integration-tests:
runs-on: ubuntu-latest
needs: docker-build
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm ci
- name: Run database migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
# Этап 7: E2E тесты
e2e-tests:
runs-on: ubuntu-latest
needs: integration-tests
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Start application
run: npm run start:test &
env:
NODE_ENV: test
- name: Wait for application
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
3. CD-пайплайн (развёртывание):
# .github/workflows/cd.yml
name: CD Pipeline
on:
push:
branches: [main, staging]
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy'
required: true
type: choice
options:
- staging
- production
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Определяем окружение
setup:
runs-on: ubuntu-latest
outputs:
environment: ${{ steps.set-env.outputs.environment }}
version: ${{ steps.set-env.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set environment and version
id: set-env
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "environment=staging" >> $GITHUB_OUTPUT
else
echo "environment=staging" >> $GITHUB_OUTPUT
fi
# Генерируем версию
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-$(git rev-parse --short HEAD)")
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Сборка и публикация Docker-образа
build-and-push:
runs-on: ubuntu-latest
needs: setup
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ needs.setup.outputs.version }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
# Деплой на Staging
deploy-staging:
runs-on: ubuntu-latest
needs: [setup, build-and-push]
if: needs.setup.outputs.environment == 'staging'
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v3
- name: Set Kubernetes context
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG_STAGING }}
- name: Deploy to staging
run: |
# Обновляем образ в манифесте
kubectl set image deployment/app \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-n staging
# Ждём завершения деплоя
kubectl rollout status deployment/app -n staging --timeout=300s
- name: Run smoke tests
run: |
npm run test:smoke -- --url https://staging.example.com
- name: Notify Slack
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Staging deployment ${{ job.status }}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
# Деплой на Production (с ручным подтверждением)
deploy-production:
runs-on: ubuntu-latest
needs: [setup, build-and-push, deploy-staging]
if: needs.setup.outputs.environment == 'production'
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v3
- name: Set Kubernetes context
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }}
- name: Canary deployment (10%)
run: |
# Деплоим canary-версию
kubectl apply -f k8s/production/canary/
kubectl set image deployment/app-canary \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-n production
# Ждём готовности
kubectl rollout status deployment/app-canary -n production --timeout=300s
- name: Monitor canary (5 minutes)
run: |
# Мониторим метрики canary
for i in {1..30}; do
ERROR_RATE=$(curl -s "https://prometheus/api/v1/query?query=rate(http_requests_total{status=~'5..',deployment='canary'}[5m])" | jq '.data.result[0].value[1]')
if (( $(echo "$ERROR_RATE > 0.01" | bc -l) )); then
echo "Error rate too high: $ERROR_RATE"
exit 1
fi
sleep 10
done
- name: Full deployment
run: |
# Обновляем основной deployment
kubectl set image deployment/app \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-n production
# Ждём завершения
kubectl rollout status deployment/app -n production --timeout=600s
# Убираем canary
kubectl delete deployment app-canary -n production
- name: Run production smoke tests
run: |
npm run test:smoke -- --url https://example.com
- name: Notify Slack
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Production deployment ${{ job.status }}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
4. Kubernetes манифесты:
# k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
labels:
app: app
spec:
replicas: 3
selector:
matchLabels:
app: app
template:
metadata:
labels:
app: app
spec:
containers:
- name: app
image: app:latest
ports:
- containerPort: 3000
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
env:
- name: NODE_ENV
valueFrom:
configMapKeyRef:
name: app-config
key: node-env
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
---
# k8s/base/service.yaml
apiVersion: v1
kind: Service
metadata:
name: app
spec:
selector:
app: app
ports:
- port: 80
targetPort: 3000
type: ClusterIP
---
# k8s/base/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: app
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
5. Мониторинг и откат:
# .github/workflows/rollback.yml
name: Rollback
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to rollback'
required: true
type: choice
options:
- staging
- production
revision:
description: 'Revision to rollback to (leave empty for previous)'
required: false
type: string
jobs:
rollback:
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment }}
steps:
- name: Setup kubectl
uses: azure/setup-kubectl@v3
- name: Set Kubernetes context
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets[format('KUBE_CONFIG_{0}', github.event.inputs.environment | upper)] }}
- name: Get rollback revision
id: revision
run: |
if [ -n "${{ github.event.inputs.revision }}" ]; then
echo "REVISION=${{ github.event.inputs.revision }}" >> $GITHUB_ENV
else
# Получаем предыдущую ревизию
PREV_REVISION=$(kubectl rollout history deployment/app -n ${{ github.event.inputs.environment }} | tail -2 | head -1 | awk '{print $1}')
echo "REVISION=$PREV_REVISION" >> $GITHUB_ENV
fi
- name: Perform rollback
run: |
kubectl rollout undo deployment/app -n ${{ github.event.inputs.environment }} --to-revision=$REVISION
kubectl rollout status deployment/app -n ${{ github.event.inputs.environment }} --timeout=300s
- name: Verify rollback
run: |
kubectl get pods -n ${{ github.event.inputs.environment }} -l app=app
npm run test:smoke -- --url https://${{ github.event.inputs.environment }}.example.com
- name: Notify Slack
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Rollback to revision ${{ env.REVISION }} ${{ job.status }}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
6. Скрипты для локальной разработки:
# Makefile
.PHONY: ci cd deploy-staging deploy-production rollback
# Запуск всех CI-проверок локально
ci:
npm run lint
npm run type-check
npm run format:check
npm run test:unit
npm run build
npm run test:integration
# Запуск в Docker
ci-docker:
docker compose -f docker-compose.ci.yml up --build --abort-on-container-exit
# Деплой на staging
deploy-staging:
gh workflow run cd.yml -f environment=staging
# Деплой на production (требует подтверждения)
deploy-production:
@echo "⚠️ Deploying to production!"
@read -p "Are you sure? [y/N] " confirm && [ confirm = y ]
gh workflow run cd.yml -f environment=production
# Откат
rollback:
@read -p "Environment (staging/production): " env
@read -p "Revision (empty for previous): " rev
gh workflow run rollback.yml -f environment=$$env -f revision=$$rev
Итого: организация CI/CD включает многоэтапный CI-пайплайн (lint, type check, unit tests, build, security scan, integration tests, E2E), CD-пайплайн с canary-деплоем, Kubernetes манифесты с HPA, автоматический откат, уведомления в Slack, защиту веток. Важно использовать параллельное выполнение тестов, кэширование, ручное подтверждение для production и мониторинг после деплоя.
Вопрос 29. Как разработчики могут помочь QA-команде в тестировании?
Таймкод: 01:11:27
Ответ собеседника: Правильный. Писать автоматизированные тесты: unit-тесты для покрытия кода с метриками покрытия и E2E-тесты для автоматизации пользовательских сценариев из тест-кейсов тестировщиков. Это ускоряет регрессионное тестирование и снижает нагрузку на QA.
Правильный ответ:
Разработчики могут значительно помочь QA-команде через автоматизацию тестов, улучшение качества кода, создание инструментов для тестирования и тесное сотрудничество на всех этапах разработки.
1. Автоматизированные тесты:
// __tests__/unit/userService.test.ts
import { UserService } from '../services/UserService';
import { UserRepository } from '../repositories/UserRepository';
import { EmailService } from '../services/EmailService';
describe('UserService', () => {
let userService: UserService;
let userRepository: jest.Mocked<UserRepository>;
let emailService: jest.Mocked<EmailService>;
beforeEach(() => {
userRepository = {
findById: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
} as any;
emailService = {
sendWelcomeEmail: jest.fn(),
sendPasswordReset: jest.fn(),
} as any;
userService = new UserService(userRepository, emailService);
});
describe('createUser', () => {
it('should create user successfully', async () => {
const userData = {
email: 'test@example.com',
name: 'Test User',
password: 'securePassword123',
};
userRepository.findByEmail.mockResolvedValue(null);
userRepository.create.mockResolvedValue({
id: '123',
...userData,
password: 'hashedPassword',
createdAt: new Date(),
});
const result = await userService.createUser(userData);
expect(result).toHaveProperty('id');
expect(result.email).toBe(userData.email);
expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(
userData.email,
userData.name
);
});
it('should throw error if email already exists', async () => {
const userData = {
email: 'existing@example.com',
name: 'Test User',
password: 'securePassword123',
};
userRepository.findByEmail.mockResolvedValue({
id: '456',
...userData,
});
await expect(userService.createUser(userData)).rejects.toThrow(
'Email already registered'
);
expect(userRepository.create).not.toHaveBeenCalled();
});
it('should validate password strength', async () => {
const userData = {
email: 'test@example.com',
name: 'Test User',
password: 'weak',
};
await expect(userService.createUser(userData)).rejects.toThrow(
'Password must be at least 8 characters'
);
});
});
describe('updateUser', () => {
it('should update user and invalidate cache', async () => {
const userId = '123';
const updateData = { name: 'Updated Name' };
const existingUser = {
id: userId,
email: 'test@example.com',
name: 'Old Name',
};
userRepository.findById.mockResolvedValue(existingUser);
userRepository.update.mockResolvedValue({
...existingUser,
...updateData,
});
const result = await userService.updateUser(userId, updateData);
expect(result.name).toBe('Updated Name');
expect(userRepository.update).toHaveBeenCalledWith(userId, updateData);
});
});
});
// __tests__/integration/userApi.test.ts
import request from 'supertest';
import { app } from '../../app';
import { db } from '../../database';
describe('User API Integration Tests', () => {
beforeAll(async () => {
await db.migrate.latest();
});
afterAll(async () => {
await db.destroy();
});
afterEach(async () => {
await db('users').truncate();
});
describe('POST /api/users', () => {
it('should create user and return 201', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'securePassword123',
})
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.email).toBe('test@example.com');
expect(response.body).not.toHaveProperty('password');
});
it('should return 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
name: 'Test User',
password: 'securePassword123',
})
.expect(400);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' })
);
});
it('should return 409 for duplicate email', async () => {
// Создаём первого пользователя
await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'securePassword123',
});
// Пытаемся создать второго с тем же email
await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Another User',
password: 'anotherPassword123',
})
.expect(409);
});
});
});
2. E2E тесты с Playwright:
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('user can register with valid credentials', async ({ page }) => {
// Переходим на страницу регистрации
await page.click('[data-testid="register-link"]');
await expect(page).toHaveURL('/register');
// Заполняем форму
await page.fill('[data-testid="email-input"]', 'newuser@example.com');
await page.fill('[data-testid="name-input"]', 'New User');
await page.fill('[data-testid="password-input"]', 'SecurePass123!');
await page.fill('[data-testid="confirm-password-input"]', 'SecurePass123!');
// Отправляем форму
await page.click('[data-testid="submit-button"]');
// Проверяем редирект на dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="welcome-message"]')).toContainText(
'Welcome, New User'
);
});
test('shows error for weak password', async ({ page }) => {
await page.click('[data-testid="register-link"]');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="name-input"]', 'Test User');
await page.fill('[data-testid="password-input"]', 'weak');
await page.fill('[data-testid="confirm-password-input"]', 'weak');
await page.click('[data-testid="submit-button"]');
// Проверяем сообщение об ошибке
await expect(page.locator('[data-testid="password-error"]')).toContainText(
'Password must be at least 8 characters'
);
});
test('user can login and logout', async ({ page }) => {
// Логинимся
await page.click('[data-testid="login-link"]');
await page.fill('[data-testid="email-input"]', 'existing@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
// Проверяем что залогинены
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
// Разлогиниваемся
await page.click('[data-testid="user-menu"]');
await page.click('[data-testid="logout-button"]');
// Проверяем редирект на главную
await expect(page).toHaveURL('/');
await expect(page.locator('[data-testid="login-link"]')).toBeVisible();
});
test('handles network errors gracefully', async ({ page }) => {
// Симулируем сетевую ошибку
await page.route('**/api/auth/login', (route) => {
route.abort('failed');
});
await page.click('[data-testid="login-link"]');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
// Проверяем сообщение об ошибке
await expect(page.locator('[data-testid="error-message"]')).toContainText(
'Network error. Please try again.'
);
});
});
// e2e/news-feed.spec.ts
test.describe('News Feed', () => {
test.beforeEach(async ({ page }) => {
// Логинимся перед каждым тестом
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
});
test('infinite scroll loads more articles', async ({ page }) => {
await page.goto('/feed');
// Ждём загрузки первой страницы
await page.waitForSelector('[data-testid="article-card"]');
const initialCount = await page.locator('[data-testid="article-card"]').count();
expect(initialCount).toBe(20);
// Скроллим вниз
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(1000);
// Проверяем что загрузились новые статьи
const newCount = await page.locator('[data-testid="article-card"]').count();
expect(newCount).toBeGreaterThan(initialCount);
});
test('article search works correctly', async ({ page }) => {
await page.goto('/feed');
// Вводим поисковый запрос
await page.fill('[data-testid="search-input"]', 'TypeScript');
await page.click('[data-testid="search-button"]');
// Ждём результатов
await page.waitForSelector('[data-testid="article-card"]');
// Проверяем что все результаты содержат запрос
const articles = await page.locator('[data-testid="article-card"]').all();
for (const article of articles) {
const title = await article.locator('[data-testid="article-title"]').textContent();
expect(title?.toLowerCase()).toContain('typescript');
}
});
});
3. Тестовые утилиты и хелперы:
// testing/testUtils.ts
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '../contexts/AuthContext';
// Создаём QueryClient для тестов
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
// Wrapper для рендеринга компонентов с провайдерами
export function renderWithProviders(
ui: React.ReactElement,
{
queryClient = createTestQueryClient(),
route = '/',
...renderOptions
} = {}
) {
window.history.pushState({}, 'Test page', route);
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>{children}</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
}
// Фабрики тестовых данных
export const factories = {
user: (overrides = {}) => ({
id: `user-${Math.random().toString(36).substr(2, 9)}`,
email: 'test@example.com',
name: 'Test User',
avatar: 'https://example.com/avatar.jpg',
createdAt: new Date().toISOString(),
...overrides,
}),
article: (overrides = {}) => ({
id: `article-${Math.random().toString(36).substr(2, 9)}`,
title: 'Test Article',
summary: 'This is a test article summary',
content: 'Full article content...',
author: factories.user(),
imageUrl: 'https://example.com/image.jpg',
publishedAt: new Date().toISOString(),
tags: ['test', 'example'],
...overrides,
}),
paginatedResponse: <T>(items: T[], { page = 1, limit = 20, total = items.length } = {}) => ({
data: items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1,
},
}),
};
// Моки API
export const mockApi = {
get: (url: string, response: any, delay = 0) => {
return {
url: expect.stringContaining(url),
method: 'GET',
response,
delay,
};
},
post: (url: string, response: any, delay = 0) => {
return {
url: expect.stringContaining(url),
method: 'POST',
response,
delay,
};
},
};
// Хелперы для ожидания
export const waitForLoadingToFinish = () => {
return waitFor(() => {
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
};
export const waitForError = (errorMessage: string) => {
return waitFor(() => {
expect(screen.getByTestId('error-message')).toHaveTextContent(errorMessage);
});
};
4. Контрактное тестирование (Pact):
// __tests__/contracts/userService.contract.test.ts
import { Pact } from '@pact-foundation/pact';
import { UserService } from '../../services/UserService';
import { like, regex } from '@pact-foundation/pact/src/dsl/matchers';
const provider = new Pact({
consumer: 'WebApp',
provider: 'UserService',
port: 1234,
log: './logs/pact.log',
dir: './pacts',
});
describe('UserService Contract Tests', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe('GET /users/:id', () => {
it('returns user by id', async () => {
// Определяем ожидаемый контракт
await provider.addInteraction({
state: 'user with id 123 exists',
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: {
Authorization: like('Bearer token'),
},
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
id: '123',
email: regex('test@example.com', '^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$'),
name: like('Test User'),
createdAt: regex('2024-01-01T00:00:00Z', '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$'),
},
},
});
// Выполняем запрос
const userService = new UserService(provider.mockService.baseUrl);
const user = await userService.getUser('123');
// Проверяем ответ
expect(user).toEqual({
id: '123',
email: 'test@example.com',
name: 'Test User',
createdAt: expect.any(String),
});
});
});
describe('POST /users', () => {
it('creates a new user', async () => {
await provider.addInteraction({
uponReceiving: 'a request to create a user',
withRequest: {
method: 'POST',
path: '/users',
headers: {
'Content-Type': 'application/json',
Authorization: like('Bearer token'),
},
body: {
email: 'newuser@example.com',
name: 'New User',
password: like('password123'),
},
},
willRespondWith: {
status: 201,
headers: {
'Content-Type': 'application/json',
},
body: {
id: like('uuid-string'),
email: 'newuser@example.com',
name: 'New User',
createdAt: regex('2024-01-01T00:00:00Z', '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$'),
},
},
});
const userService = new UserService(provider.mockService.baseUrl);
const user = await userService.createUser({
email: 'newuser@example.com',
name: 'New User',
password: 'password123',
});
expect(user.id).toBeDefined();
expect(user.email).toBe('newuser@example.com');
});
});
});
5. Инструменты для QA (тестовые данные, feature flags):
// testing/qaHelpers.ts
import { Factory } from 'rosie';
import { faker } from '@faker-js/faker';
// Фабрики для генерации тестовых данных
export const UserFactory = new Factory()
.attr('id', () => faker.string.uuid())
.attr('email', () => faker.internet.email())
.attr('name', () => faker.person.fullName())
.attr('role', 'user')
.attr('status', 'active');
export const ArticleFactory = new Factory()
.attr('id', () => faker.string.uuid())
.attr('title', () => faker.lorem.sentence())
.attr('content', () => faker.lorem.paragraphs(3))
.attr('status', 'published')
.attr('publishedAt', () => faker.date.past().toISOString());
// Feature flags для тестирования
export const testFeatures = {
enableBetaFeatures: () => {
window.localStorage.setItem('beta_features', 'true');
},
disableBetaFeatures: () => {
window.localStorage.removeItem('beta_features');
},
setFeatureFlag: (flag: string, value: boolean) => {
const flags = JSON.parse(window.localStorage.getItem('feature_flags') || '{}');
flags[flag] = value;
window.localStorage.setItem('feature_flags', JSON.stringify(flags));
},
};
// Хелперы для E2E тестов
export const qaHelpers = {
// Создаём тестового пользователя через API
createTestUser: async (overrides = {}) => {
const response = await fetch('/api/test/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(UserFactory.build(overrides)),
});
return response.json();
},
// Создаём тестовые данные
seedTestData: async () => {
const users = UserFactory.buildList(10);
const articles = ArticleFactory.buildList(50);
await fetch('/api/test/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ users, articles }),
});
},
// Очищаем тестовые данные
cleanupTestData: async () => {
await fetch('/api/test/cleanup', { method: 'POST' });
},
// Получаем состояние приложения для отладки
getAppState: async () => {
const response = await fetch('/api/test/state');
return response.json();
},
// Симулируем ошибку сети
simulateNetworkError: async (endpoint: string) => {
await fetch('/api/test/simulate-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint, type: 'network' }),
});
},
// Симулируем медленную сеть
simulateSlowNetwork: async (endpoint: string, delay: number) => {
await fetch('/api/test/simulate-slow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint, delay }),
});
},
};
// Компонент для QA-панели (только в development)
export function QAPanel() {
if (process.env.NODE_ENV !== 'development') {
return null;
}
return (
<div
data-testid="qa-panel"
style={{
position: 'fixed',
bottom: 10,
right: 10,
background: '#f0f0f0',
padding: 10,
borderRadius: 5,
zIndex: 9999,
}}
>
<h4>QA Tools</h4>
<button onClick={() => qaHelpers.seedTestData()}>
Seed Test Data
</button>
<button onClick={() => qaHelpers.cleanupTestData()}>
Cleanup
</button>
<button onClick={() => testFeatures.enableBetaFeatures()}>
Enable Beta
</button>
<button onClick={() => testFeatures.disableBetaFeatures()}>
Disable Beta
</button>
</div>
);
}
6. Метрики покрытия и отчёты:
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/services/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
'!src/testing/**',
],
};
# .github/workflows/coverage.yml
name: Coverage Report
on:
pull_request:
branches: [main]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests with coverage
run: |
npm ci
npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
flags: unittests
fail_ci_if_error: true
- name: Comment PR with coverage
uses: romeovs/lcov-reporter-action@v0.3.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
lcov-file: ./coverage/lcov.info
7. Документация для QA:
# QA Testing Guide
## Test Environments
| Environment | URL | Purpose |
|-------------|-----|---------|
| Development | https://dev.example.com | Feature testing |
| Staging | https://staging.example.com | Integration testing |
| Production | https://example.com | Smoke tests |
## Test Data
### Test Accounts
| Role | Email | Password |
|------|-------|----------|
| Admin | admin@test.com | admin123 |
| User | user@test.com | user123 |
| Premium | premium@test.com | premium123 |
### API Endpoints for Testing
- `POST /api/test/seed` - Seed test data
- `POST /api/test/cleanup` - Clean test data
- `GET /api/test/state` - Get application state
## Feature Flags
Enable beta features:
```javascript
localStorage.setItem('beta_features', 'true');
Common Test Scenarios
Authentication
- Register with valid credentials
- Login with existing account
- Reset password
- Logout
News Feed
- Load initial articles
- Infinite scroll
- Search articles
- Filter by category
Reporting Bugs
Include:
- Steps to reproduce
- Expected behavior
- Actual behavior
- Screenshots/videos
- Browser/OS info
- Console errors
**Итого:** разработчики помогают QA через unit/integration/E2E тесты, контрактное тестирование, тестовые утилиты и фабрики данных, feature flags для изоляции функциональности, QA-панели для отладки, метрики покрытия, документацию. Это ускоряет регрессионное тестирование, снижает нагрузку на QA и повышает качество продукта.
