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

Currency Service — end-to-end

Архитектура

graph LR
    Client -->|REST| GW[gateway]
    GW -->|gRPC| CS[currency-service]
    CS --> DB[(PostgreSQL)]
    CS --> R[(Redis cache)]
    CRON[cron worker] -->|fetch CBR| EXT[CBR API]
    CRON --> DB
    CS --> Prom[Prometheus]
    Prom --> Grafana

Структура репозитория

currency-service/
├── cmd/
│   ├── server/main.go         # gRPC server
│   ├── cron/main.go           # фоновый воркер: загрузка CBR
│   └── migrator/main.go       # миграции
├── internal/
│   ├── config/                # viper + ENV
│   ├── domain/                # модели + ошибки
│   ├── grpcserver/            # хэндлеры gRPC
│   ├── repository/            # DAO к Postgres
│   ├── service/               # бизнес-логика
│   ├── cron/                  # job scheduler
│   ├── cache/                 # Redis-обёртка
│   └── observability/         # Prometheus, log
├── pkg/proto/                 # .proto + сгенерированные _grpc.pb.go
├── migrations/
│   └── 0001_init.up.sql
├── deployment/
│   ├── docker/Dockerfile
│   └── compose.yml
└── README.md

gateway/
├── cmd/gateway/main.go        # chi REST
├── internal/handlers/         # REST хэндлеры → gRPC
└── deployment/...

Этапы разработки (~14 шагов)

M0 — Скелет

  • go mod init
  • Standard layout (см. выше)
  • Makefile с базовыми таргетами: run, build, test, lint, proto

M1 — gRPC контракт

service Currency {
    rpc GetRate(GetRateRequest) returns (GetRateResponse);
    rpc ListRates(ListRatesRequest) returns (ListRatesResponse);
}
  • protoc через buf (рекомендуется) или вручную
  • сгенерированный код в pkg/proto/currency_grpc.pb.go

M2 — Postgres + миграции

CREATE TABLE rates (
    id BIGSERIAL PRIMARY KEY,
    base TEXT NOT NULL,
    quote TEXT NOT NULL,
    rate NUMERIC(18, 6) NOT NULL,
    fetched_at TIMESTAMPTZ NOT NULL,
    UNIQUE (base, quote, fetched_at)
);
CREATE INDEX idx_rates_pair ON rates(base, quote, fetched_at DESC);
  • golang-migrate через CLI и через embed
  • pgxpool.New для коннекшен пула

M3 — Repository слой

type RatesRepo interface {
    GetLatest(ctx context.Context, base, quote string) (*Rate, error)
    Save(ctx context.Context, r *Rate) error
    ListLatest(ctx context.Context, base string) ([]*Rate, error)
}

M4 — Service слой

Бизнес-логика: запросить cache → если нет, идём в БД → populate cache.

func (s *Service) GetRate(ctx context.Context, base, quote string) (*Rate, error) {
    if r, ok := s.cache.Get(base + ":" + quote); ok {
        return r, nil
    }
    r, err := s.repo.GetLatest(ctx, base, quote)
    if err != nil { return nil, err }
    s.cache.Set(base+":"+quote, r, 5*time.Minute)
    return r, nil
}

M5 — gRPC handler

Тонкий слой: парсит request, вызывает service, мапит ошибки в status.Error.

// internal/grpcserver/handlers.go
package grpcserver

import (
    "context"
    "errors"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    "github.com/yourorg/currency/internal/domain"
    pb "github.com/yourorg/currency/pkg/proto"
)

type CurrencyHandler struct {
    pb.UnimplementedCurrencyServer
    svc Service
}

type Service interface {
    GetRate(ctx context.Context, base, quote string) (*domain.Rate, error)
}

func (h *CurrencyHandler) GetRate(ctx context.Context, in *pb.GetRateRequest) (*pb.GetRateResponse, error) {
    if in.Base == "" || in.Quote == "" {
        return nil, status.Error(codes.InvalidArgument, "base and quote required")
    }
    r, err := h.svc.GetRate(ctx, in.Base, in.Quote)
    switch {
    case errors.Is(err, domain.ErrNotFound):
        return nil, status.Error(codes.NotFound, "rate not found")
    case err != nil:
        return nil, status.Error(codes.Internal, "internal error")
    }
    return &pb.GetRateResponse{
        Base:      r.Base,
        Quote:     r.Quote,
        Rate:      r.Rate,
        FetchedAt: r.FetchedAt.Unix(),
    }, nil
}

Идиома: handler не делает бизнес-логику, только трансляция (proto ↔ domain) и маппинг ошибок в коды gRPC. Это упрощает тесты — service слой проверяется без запуска gRPC сервера.

M6 — Cron worker

Отдельный бинарь cmd/cron/main.go:

ticker := time.NewTicker(time.Hour)
for {
    select {
    case <-ticker.C:
        rates, err := fetchCBR(ctx)
        for _, r := range rates {
            s.repo.Save(ctx, r)
        }
    case <-ctx.Done():
        return
    }
}

M7 — Gateway (REST → gRPC)

r := chi.NewRouter()
r.Get("/api/rate", func(w http.ResponseWriter, req *http.Request) {
    base := req.URL.Query().Get("base")
    quote := req.URL.Query().Get("quote")
    rate, err := grpcClient.GetRate(req.Context(), &pb.GetRateRequest{Base: base, Quote: quote})
    json.NewEncoder(w).Encode(rate)
})

M8 — Конфиг

internal/config/config.go через viper:

grpc_addr: ":50051"
postgres_dsn: "${POSTGRES_DSN}"
redis_addr: "${REDIS_ADDR}"
cache_ttl: 5m
log_level: info

ENV переопределяет YAML.

M9 — Observability

  • Prometheus: /metrics endpoint, гистограммы grpc_request_duration_seconds, счётчики cbr_fetch_total{status="..."}.
  • Логи: slog или zap с request_id middleware.
  • Trace ID через context.

M10 — Тесты

  • Unit: service слой через mock'и (gomock).
  • Integration: testcontainers-go поднимает Postgres+Redis в Docker.
  • E2E: docker-compose + curl/grpcurl.

Минимальный пример unit-теста через generated мок:

func TestService_GetRate_CacheHit(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    repo := mocks.NewMockRatesRepo(ctrl)
    cache := mocks.NewMockCache(ctrl)

    cached := &domain.Rate{Base: "USD", Quote: "RUB", Rate: 95.5, FetchedAt: time.Now()}
    cache.EXPECT().Get("USD:RUB").Return(cached, true)
    // На cache hit в репозиторий идти не должны.
    repo.EXPECT().GetLatest(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)

    svc := service.New(repo, cache, slog.Default())
    got, err := svc.GetRate(context.Background(), "USD", "RUB")
    require.NoError(t, err)
    require.Equal(t, 95.5, got.Rate)
}

Интеграционный тест с testcontainers-go:

func TestRepo_SaveAndGet(t *testing.T) {
    ctx := context.Background()
    pgC, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("test"),
        postgres.WithUsername("u"), postgres.WithPassword("p"),
        testcontainers.WithWaitStrategy(wait.ForLog("ready to accept connections").WithOccurrence(2)),
    )
    require.NoError(t, err)
    defer pgC.Terminate(ctx)

    dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
    pool, _ := pgxpool.New(ctx, dsn)
    require.NoError(t, repository.MigrateUp(ctx, dsn))

    r := repository.NewRatesRepo(pool)
    require.NoError(t, r.Save(ctx, &domain.Rate{Base: "USD", Quote: "RUB", Rate: 95.5, FetchedAt: time.Now()}))

    got, err := r.GetLatest(ctx, "USD", "RUB")
    require.NoError(t, err)
    require.Equal(t, 95.5, got.Rate)
}

Тесты гоняем с -race, в CI блокирующее условие — все зелёные.

M11 — Outbox (опционально, +зачётка)

Если нужно публиковать события «новый курс» в Kafka — outbox-таблица + worker.

M12 — Graceful shutdown

Полный канонический пример для cmd/server/main.go — лови SIGTERM, останови gRPC сервер, дождись текущих запросов, закрой пул БД:

package main

import (
    "context"
    "errors"
    "log/slog"
    "net"
    "os"
    "os/signal"
    "syscall"
    "time"

    "google.golang.org/grpc"

    "github.com/yourorg/currency/internal/config"
    "github.com/yourorg/currency/internal/grpcserver"
    "github.com/yourorg/currency/internal/repository"
    "github.com/yourorg/currency/internal/service"
    pb "github.com/yourorg/currency/pkg/proto"
)

func main() {
    log := slog.Default()

    cfg, err := config.Load()
    if err != nil {
        log.Error("load config", "err", err)
        os.Exit(1)
    }

    pool, err := repository.NewPool(context.Background(), cfg.PostgresDSN)
    if err != nil {
        log.Error("connect db", "err", err)
        os.Exit(1)
    }
    defer pool.Close()

    repo := repository.NewRatesRepo(pool)
    cache := service.NewRedisCache(cfg.RedisAddr)
    svc := service.New(repo, cache, log)

    grpcSrv := grpc.NewServer()
    pb.RegisterCurrencyServer(grpcSrv, &grpcserver.CurrencyHandler{Svc: svc})

    lis, err := net.Listen("tcp", cfg.GRPCAddr)
    if err != nil {
        log.Error("listen", "err", err)
        os.Exit(1)
    }

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    serveErr := make(chan error, 1)
    go func() {
        log.Info("grpc serving", "addr", cfg.GRPCAddr)
        if err := grpcSrv.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
            serveErr <- err
        }
    }()

    select {
    case <-ctx.Done():
        log.Info("shutdown signal received")
    case err := <-serveErr:
        log.Error("serve error", "err", err)
    }

    // 1) Перестаём принимать новые соединения, ждём активные запросы.
    done := make(chan struct{})
    go func() {
        grpcSrv.GracefulStop()
        close(done)
    }()
    select {
    case <-done:
        log.Info("grpc stopped gracefully")
    case <-time.After(20 * time.Second):
        log.Warn("graceful stop timeout, forcing")
        grpcSrv.Stop()
    }
}

Паттерн signal.NotifyContext + два select'а — самый частый каркас на собесах, учим его наизусть.

M13 — Docker compose

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: cs
      POSTGRES_PASSWORD: cs
  redis:
    image: redis:7-alpine
  currency:
    build: .
    depends_on: [postgres, redis]
    environment:
      POSTGRES_DSN: postgres://cs:cs@postgres:5432/cs
      REDIS_ADDR: redis:6379
  cron:
    build: .
    command: ["/cron"]
  gateway:
    build: ./gateway

M14 — Документация и CI

  • README с quickstart (docker-compose up)
  • .gitlab-ci.yml: lint + test + build
  • make с base-таргетами

Acceptance criteria (из RUBRIC.md)

  • Сервис стартует через docker compose up, healthcheck зелёный.
  • gRPC GetRate возвращает корректные данные.
  • REST gateway проксирует к gRPC.
  • Cron worker реально дёргает CBR раз в час.
  • Миграции применяются автоматически.
  • Prometheus метрики доступны на /metrics.
  • Race detector чист (go test -race).
  • Покрытие service слоя ≥ 60%.
  • Graceful shutdown по SIGTERM.
  • README с quickstart и описанием API.

Что говорить на собесе про этот проект

  1. «Стандартный layout, отделил cmd / internal / pkg».
  2. «gRPC контракт, через protoc генерирую».
  3. «Repository pattern для тестируемости».
  4. «Cron worker — отдельный процесс, не в основном сервере».
  5. «Cache → Postgres → CBR — три уровня хранения».
  6. «Observability: Prometheus, RED, structured logs с request_id».
  7. «Race detector, testcontainers для интеграционных».

Всё это — реальные практики, не выдумка.