Изучение кодовой базы etcd — обложка

Есть распространённый совет: «Хочешь писать лучше? Читай хороший код». Звучит очевидно. Делается редко.

Проблема в том, что большинство open-source проектов — лабиринты. Открываешь репу, видишь 200 директорий и закрываешь вкладку. Kubernetes — два миллиона строк. Linux kernel — даже не думай. С чего начать?

Мой ответ: etcd.

Для тех, кто не знаком: etcd — это распределённое key-value хранилище на Go. Костяк Kubernetes — там лежит всё состояние кластера. Но мне etcd интересен не как продукт. Мне он интересен как пример архитектуры, которую реально можно прочитать от начала до конца.

Что меня удивило: принципы, заложенные в etcd, — это не про Go. Это про дизайн ПО в целом. Я каждый день работаю с PHP и Symfony, и почти всё, что нашёл в etcd, переложилось напрямую на мои проекты.

Семь принципов, конкретные примеры, без воды.


1. Один источник правды для API

В etcd любой API определён в .proto-файлах. Открываешь rpc.proto и видишь все операции: Range, Put, DeleteRange, Txn. Каждое поле типизировано. Нет места для «погоди, мы здесь принимаем строку или число?».

В PHP вместо protobuf у нас строго типизированные DTO:

final readonly class CreateOrderRequest
{
    public function __construct(
        public string $customerId,
        /** @var OrderItemDto[] */
        public array $items,
        public ?string $promoCode = null,
    ) {}
}

Один класс — и все знают, что эндпоинт принимает. Фронтендер смотрит на DTO, бэкендер пишет логику против него, OpenAPI-схема генерируется автоматически через NelmioApiDocBundle.

Сравни с тем, что я видел (и сам писал) в реальных проектах:

$data = json_decode($request->getContent(), true);
$customerId = $data['customer_id'] ?? null;
$items = $data['items'] ?? [];
// Какой формат items? promoCode вообще есть? Хрен его знает.

Когда твой контракт — это «ну, какой-то массив прилетает», любое изменение что-то ломает неожиданно. Когда контракт — это DTO с типами, PHPStan ловит проблему до прода.


2. Каждый сервис делает одну вещь

В etcd чётко разделены gRPC-сервисы: KV (чтение-запись), Watch (подписка на изменения), Lease (TTL для ключей), Auth (авторизация). Каждый — отдельный интерфейс. Watch не трогает запись. KV не проверяет токены.

В Symfony — та же идея, другие инструменты:

class OrderController
{
    #[Route('/orders', methods: ['POST'])]
    public function create(
        CreateOrderRequest $request,
        OrderService $orderService,
    ): JsonResponse {
        return new JsonResponse(
            $orderService->create($request)
        );
    }
}

OrderService создаёт заказы. Не отправляет письма — это NotificationService, слушающий OrderCreatedEvent. Не обрабатывает платежи — это PaymentService.

И альтернатива, которую я регулярно вижу:

class OrderController
{
    public function create(Request $request)
    {
        // 40 строк валидации
        // 20 строк авторизации
        // 60 строк бизнес-логики
        // 15 строк отправки email
        // 10 строк логирования
        // Итого: 150 строк, не тестируется
    }
}

500-строчный god-controller. Все там были. etcd помог мне наконец сформулировать почему это плохо: не потому что «паттерн неправильный», а потому что ты не можешь проследить, что система делает.


3. Middleware собирается как Lego

Каждый gRPC-запрос в etcd проходит через цепочку interceptor’ов: logging → auth → metrics → handler → metrics → response. Каждый interceptor маленький, single-purpose. Сила — в композиции.

В Symfony это ложится на Event Listeners и Messenger Middleware:

class MetricsMiddleware implements MiddlewareInterface
{
    public function __construct(
        private PrometheusCollector $metrics,
    ) {}

    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $start = microtime(true);

        try {
            $result = $stack->next()->handle($envelope, $stack);
            $this->metrics->increment('messages_processed_total', [
                'type' => $envelope->getMessage()::class,
                'status' => 'success',
            ]);
            return $result;
        } catch (\Throwable $e) {
            $this->metrics->increment('messages_processed_total', [
                'type' => $envelope->getMessage()::class,
                'status' => 'error',
            ]);
            throw $e;
        } finally {
            $this->metrics->histogram(
                'message_duration_seconds',
                microtime(true) - $start,
                [$envelope->getMessage()::class]
            );
        }
    }
}

Один middleware — одна задача. Метрики здесь, логи там, retry где-то ещё. Цепочка собирается в messenger.yaml.

Антипаттерн — когда у каждого хендлера это руками:

public function handle(CreateOrderCommand $command): void
{
    $this->logger->info('Starting order creation...');
    $start = microtime(true);

    // ... actual logic ...

    $this->metrics->record(microtime(true) - $start);
    $this->logger->info('Order created');
}

50 хендлеров, 50 копий одного и того же boilerplate. Забыл один — нет метрик. Поменял формат лога — меняй в 50 местах.


4. Observability — это архитектура, а не дополнение

В etcd Prometheus вшит в gRPC-слой с первого дня. Не «добавили через полгода после релиза». Код не считается готовым без метрик.

В PHP:

class PaymentService
{
    public function charge(Order $order): PaymentResult
    {
        $timer = $this->metrics->startTimer('payment_charge_duration');

        try {
            $result = $this->gateway->process($order);

            $this->metrics->increment('payments_total', [
                'provider' => $result->provider,
                'status' => $result->isSuccess() ? 'success' : 'declined',
            ]);

            return $result;
        } catch (GatewayTimeoutException $e) {
            $this->metrics->increment('payments_total', [
                'provider' => $order->paymentMethod,
                'status' => 'timeout',
            ]);
            throw $e;
        } finally {
            $timer->observe();
        }
    }
}

Каждый платёж — в метриках. Сколько прошло, сколько таймаутнулось, какой провайдер тормозит. Не потому что кто-то попросил, а потому что без этого ты летишь вслепую.

Помню проект, где прод лежал 40 минут, и единственный способ понять, что происходит, был tail -f /var/log/symfony.log | grep ERROR. Никогда больше.

Пакет: promphp/prometheus_client_php. Пять минут на установку, пятнадцать на подключение Grafana.


5. Снаружи просто, внутри ракетная наука

clientv3 в etcd — это мастер-класс паттерна facade:

client.Put(ctx, "name", "value")

Одна строка. Под капотом: выбор ноды, реконнект при падении, retry с exponential backoff, protobuf-сериализация, Raft-консенсус, запись на диск, подтверждение кворума.

Тот же принцип в PHP:

// Calling code. Простой и понятный.
$paymentService->charge($order);

Внутри charge():

public function charge(Order $order): PaymentResult
{
    if ($existing = $this->findExistingPayment($order)) {
        return $existing; // idempotency
    }

    $provider = $this->providerResolver->resolve($order);

    $result = $this->withRetry(
        fn () => $provider->process($order),
        maxAttempts: 3,
        backoff: 'exponential',
    );

    if ($result->isSuccess()) {
        $this->fiscalService->createReceipt($order, $result);
    }

    $this->events->dispatch(new PaymentProcessed($order, $result));

    return $result;
}

Контроллер, вызывающий charge(), ничего не знает про фискальные чеки, retry или выбор провайдера. И не должен.

Признак хорошего сервиса: ты можешь объяснить, что он делает, одним предложением — «снимает деньги с клиента за заказ» — а внутри 200 строк аккуратной логики.


6. Путь запроса можно проследить пальцем

В etcd путь запроса читается линейно:

gRPC handler → EtcdServer.Put() → Raft → apply → bbolt (диск)

Никакой магии. Никаких скрытых вызовов. Никаких «откуда это вообще триггерится?».

В Symfony — то же, если не злоупотреблять системой событий:

Request
  → Controller (распаковка DTO)
    → Service (бизнес-логика)
      → Repository (БД)
      → EventDispatcher (побочные эффекты)
  → Response

Открываешь контроллер — видишь, какой сервис зовёт. Открываешь сервис — видишь, что он делает. Открываешь репозиторий — видишь запрос.

Что убивает прозрачность:

  • @PostPersist на сущности, который втихую отправляет SMS
  • prePersist-листенеры, изменяющие данные до записи, — и ты 30 минут ищешь, кто трогает поле updatedAt
  • Десять EventSubscriber’ов на одном событии с непонятным порядком выполнения

Event-driven — это здорово. Но если новый разработчик не может объяснить «запрос приходит сюда, ответ уходит туда» за 2 минуты — у тебя проблема.


7. Никаких скрытых зависимостей

В etcd все зависимости передаются явно:

func NewKVServer(s *EtcdServer) KVServer { ... }

Видишь конструктор — видишь всё, что нужно классу.

В Symfony — constructor injection, то же самое:

class OrderService
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway $payment,
        private EventDispatcherInterface $events,
        private LoggerInterface $logger,
    ) {}
}

Четыре зависимости. Все на виду. Хочешь тестировать? Подсунь моки. Хочешь понять класс? Смотри на конструктор.

Антипаттерны, которые до сих пор живут в природе:

// Service locator: откуда это взялось?
$payment = $this->container->get('payment.gateway');

// Статические вызовы: не тестируется
Cache::put('key', $value);

// new SomeService() внутри другого сервиса: невидимая связность
$validator = new OrderValidator();

Autowiring в Symfony — это не магия в плохом смысле. Контейнер связывает зависимости по типу, но они всё равно видны в конструкторе. Это удобство, а не скрытое поведение.


Мой чек-лист

Изучив etcd, я выжал чек-лист, который теперь применяю к каждому новому сервису:

  1. Контракт определён? DTO есть, типы заданы, OpenAPI генерится из них
  2. Контроллер тонкий? Максимум 10 строк, вся логика — в сервисном слое
  3. Cross-cutting concerns вытащены? Логи, метрики, retry — через middleware, а не copy-paste
  4. Метрики есть? Если нет — сервис не production-ready
  5. API снаружи простой? Вызывающий код не знает о внутренней сложности
  6. Путь запроса прозрачен? Новый разраб находит хендлер за 2 минуты
  7. Зависимости явные? Всё в конструкторе, ничего из воздуха

Ничего революционного. Базовая гигиена, которую легко забыть под прессом дедлайна.

etcd просто напомнил мне, как выглядит кодбаз, когда эту гигиену не пропустили. И что это возможно даже в большой production-системе.


Какой open-source кодбаз изменил то, как ты пишешь код? Хочется собрать reading list — кидай в комментарии.