Перейти к содержанию

Reddit Clone — end-to-end

JWT-авторизация

JWT = JSON Web Token: header.payload.signature. На сервере хранится только секрет; токены — у клиента.

Схема

  • Access-токен — короткоживущий (15–30 мин), в заголовке Authorization: Bearer <token>.
  • Refresh-токен — длинноживущий (7–30 дней), в HttpOnly cookie или БД.
  • /refresh принимает refresh, выдаёт новые access+refresh.
  • Logout = инвалидация refresh-токена.

Реализация на Go

import "github.com/golang-jwt/jwt/v5"

type Claims struct {
    UserID int64 `json:"user_id"`
    jwt.RegisteredClaims
}

func IssueAccess(userID int64, secret []byte, ttl time.Duration) (string, error) {
    claims := Claims{
        UserID: userID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

func Parse(tokenStr string, secret []byte) (*Claims, error) {
    var claims Claims
    _, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (any, error) {
        return secret, nil
    })
    return &claims, err
}

Подводные камни

  1. Не клади в payload секреты — payload только base64, не шифр.
  2. Храни revocation list для отозванных refresh.
  3. Подпись HS256 с длинным секретом или RS256 с парой ключей.
  4. CSRF-токен если refresh в HttpOnly cookie.

Middleware на gateway

Стандартный приём: auth-middleware читает Authorization, валидирует подпись, кладёт userID в context. Downstream-хендлеры потом достают его одной строкой.

type ctxKey string

const userIDKey ctxKey = "user_id"

func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            h := r.Header.Get("Authorization")
            const prefix = "Bearer "
            if !strings.HasPrefix(h, prefix) {
                http.Error(w, "missing bearer", http.StatusUnauthorized)
                return
            }
            claims, err := Parse(strings.TrimPrefix(h, prefix), secret)
            if err != nil {
                http.Error(w, "invalid token", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func UserID(ctx context.Context) (int64, bool) {
    v, ok := ctx.Value(userIDKey).(int64)
    return v, ok
}

Применение в gateway:

r := chi.NewRouter()
r.Group(func(r chi.Router) {
    r.Use(AuthMiddleware([]byte(cfg.JWTSecret)))
    r.Post("/api/posts",     proxyToPosts)
    r.Post("/api/comments",  proxyToPosts)
})
r.Post("/auth/login",     loginHandler)     // публично
r.Post("/auth/refresh",   refreshHandler)

Древовидные комментарии через ltree

Комментарии Reddit — дерево произвольной глубины. Варианты:

Подход Read Write Сложность
Adjacency list (parent_id) N+1 O(1) низкая
Materialized path O(log) O(1) средняя
PostgreSQL ltree O(log) O(1) средняя
Closure table O(1) O(N) высокая

Схема ltree

CREATE EXTENSION ltree;

CREATE TABLE comments (
    id BIGSERIAL PRIMARY KEY,
    post_id BIGINT NOT NULL REFERENCES posts(id),
    author_id BIGINT NOT NULL REFERENCES users(id),
    body TEXT NOT NULL,
    path ltree NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX comments_path_gist ON comments USING GIST (path);
CREATE INDEX comments_post ON comments(post_id);

Вставка корневого комментария

INSERT INTO comments (post_id, author_id, body, path)
VALUES ($1, $2, $3, text2ltree($4))    -- $4 = id комментария
RETURNING id;

Вставка ответа

WITH parent AS (SELECT path FROM comments WHERE id = $1)
INSERT INTO comments (post_id, author_id, body, path)
VALUES ($2, $3, $4, (SELECT path || $5::ltree FROM parent))
RETURNING id;

Запросы

-- Все потомки комментария 42:
SELECT * FROM comments WHERE path <@ '42';

-- Все предки комментария 42.7.13:
SELECT * FROM comments WHERE '42.7.13' <@ path;

-- Глубина:
SELECT *, nlevel(path) AS depth FROM comments WHERE post_id = $1;

Hot-feed Reddit формула

score = log10(max(|votes|, 1)) * sign(votes) + (created_at_unix - epoch) / 45000

Интуиция: log сжимает разницу между 100 и 100000 голосами; +время даёт свежим постам преимущество.

SQL

SELECT id, title, votes, created_at,
       log(greatest(abs(votes), 1)) * sign(votes)
       + extract(epoch FROM created_at - '2024-01-01') / 45000 AS score
FROM posts
ORDER BY score DESC
LIMIT 50;

Кэш в Redis

const feedKey = "feed:hot"
const feedTTL = 30 * time.Second

func (s *Service) Hot(ctx context.Context) ([]*Post, error) {
    if data, err := s.redis.Get(ctx, feedKey).Result(); err == nil {
        var posts []*Post
        json.Unmarshal([]byte(data), &posts)
        return posts, nil
    }
    posts, err := s.repo.HotFromDB(ctx, 50)
    if err != nil { return nil, err }
    if data, err := json.Marshal(posts); err == nil {
        s.redis.Set(ctx, feedKey, data, feedTTL)
    }
    return posts, nil
}

Realtime через WebSocket (опционально)

import "github.com/gorilla/websocket"

type Hub struct {
    mu    sync.RWMutex
    rooms map[int64]map[*websocket.Conn]struct{}
}

func (h *Hub) Subscribe(postID int64, conn *websocket.Conn) {
    h.mu.Lock()
    defer h.mu.Unlock()
    if h.rooms[postID] == nil {
        h.rooms[postID] = map[*websocket.Conn]struct{}{}
    }
    h.rooms[postID][conn] = struct{}{}
}

func (h *Hub) Broadcast(postID int64, msg []byte) {
    h.mu.RLock()
    defer h.mu.RUnlock()
    for conn := range h.rooms[postID] {
        _ = conn.WriteMessage(websocket.TextMessage, msg)
    }
}

Подводные камни: - Heartbeat ping каждые 30 секунд (Caddy/Nginx убивает idle). - Закрытие conn → удалить из map. - На горизонтальном масштабе — Redis Pub/Sub между инстансами.

Acceptance criteria

  • 4 сервиса в docker-compose, healthchecks зелёные.
  • JWT работает: register, login, refresh.
  • Комментарии — древовидные (ltree), глубина >= 3.
  • Hot-feed по формуле, кэш в Redis.
  • Gateway проверяет JWT и проксирует к downstream.
  • В каждом сервисе: миграции, конфиг через ENV, graceful shutdown.
  • Покрытие тестами posts-service >= 60%.
  • README с архитектурной диаграммой и quickstart.