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
}
Подводные камни¶
- Не клади в payload секреты — payload только base64, не шифр.
- Храни revocation list для отозванных refresh.
- Подпись HS256 с длинным секретом или RS256 с парой ключей.
- 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 формула¶
Интуиция: 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.