Proučavanje etcd codebase-a — naslovna

Postoji uobičajen savet: „Hoćeš da pišeš bolji kod? Čitaj dobar kod.” Zvuči očigledno. Retko se primenjuje.

Problem je što su većina open-source projekata lavirinti. Otvoriš repo, vidiš 200 direktorijuma i zatvoriš tab. Kubernetes je dva miliona linija. Linux kernel — ne razmišljaj o tome. Odakle početi?

Moj odgovor: etcd.

Za one koji nisu upoznati: etcd je distribuirani key-value store napisan u Go-u. Kičma Kubernetes-a — svaki deo stanja klastera tu živi. Ali mene etcd ne zanima kao proizvod. Zanima me kao primer arhitekture koju zaista možeš pročitati od početka do kraja.

Evo šta me je iznenadilo: principi ugrađeni u etcd nisu o Go-u. Oni su o dizajnu softvera uopšte. Radim sa PHP-om i Symfony-jem svakodnevno, i skoro sve što sam našao u etcd-u prevelo se direktno u moje projekte.

Sedam principa, konkretni primeri, bez praznog hoda.


1. Jedan izvor istine za tvoj API

U etcd-u, svaki API je definisan u .proto fajlovima. Otvoriš rpc.proto i vidiš sve operacije: Range, Put, DeleteRange, Txn. Svako polje je tipizirano. Nema mesta za „čekaj, prihvatamo li ovde string ili integer?”.

U PHP-u, umesto protobuf-a, imamo strogo tipizirane DTO-e:

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

Jedna klasa — i svi znaju šta endpoint prihvata. Frontend developer gleda DTO, backend developer piše logiku protiv njega, OpenAPI shema se generiše automatski kroz NelmioApiDocBundle.

Uporedi to sa onim što sam video (i pisao) u realnim projektima:

$data = json_decode($request->getContent(), true);
$customerId = $data['customer_id'] ?? null;
$items = $data['items'] ?? [];
// Koji format ima items? Da li je promoCode stvar? Ko zna.

Kad ti je ugovor „pa, neki niz dolazi”, bilo koja izmena nešto polomi neočekivano. Kad ti je ugovor DTO sa tipovima, PHPStan hvata problem pre nego što produkcija to uradi.


2. Svaki servis radi jednu stvar

etcd ima jasno razdvojene gRPC servise: KV (čitanje-pisanje), Watch (pretplata na promene), Lease (TTL-ovi za ključeve), Auth (autorizacija). Svaki je odvojen interfejs. Watch ne dira pisanja. KV ne proverava tokene.

U Symfony-ju — ista ideja, drugačiji alati:

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

OrderService kreira narudžbine. Ne šalje email-ove — to je NotificationService koji sluša OrderCreatedEvent. Ne procesuira plaćanja — to je PaymentService.

A onda postoji alternativa koju redovno viđam:

class OrderController
{
    public function create(Request $request)
    {
        // 40 linija validacije
        // 20 linija autorizacije
        // 60 linija biznis logike
        // 15 linija slanja email-a
        // 10 linija logovanja
        // Ukupno: 150 linija, ne može da se testira
    }
}

500-linijski god controller. Svi smo bili tu. etcd mi je pomogao da konačno artikulišem zašto je to loše: ne zato što je „pattern pogrešan”, već zato što ne možeš da pratiš šta sistem radi.


3. Middleware se sastavlja kao Lego

Svaki gRPC zahtev u etcd-u prolazi kroz lanac interceptor-a: logging → auth → metrics → handler → metrics → response. Svaki interceptor je mali, jednonamenski. Snaga dolazi iz kompozicije.

U Symfony-ju, ovo se mapira na Event Listenere i 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]
            );
        }
    }
}

Jedan middleware, jedan posao. Metrike ovde, logovi tamo, retry negde drugde. Sastavi lanac u messenger.yaml.

Antipattern — kad svaki handler ima ovo ručno:

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 handler-a, 50 kopija istog boilerplate-a. Zaboraviš jedan — nema metrika. Promeniš format loga — promeni ga na 50 mesta.


4. Observability je arhitektura, ne dodatak

U etcd-u, Prometheus je ugrađen u gRPC sloj od prvog dana. Ne „dodato šest meseci posle launch-a”. Kod se ne smatra završenim bez metrika.

U PHP-u:

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();
        }
    }
}

Svako plaćanje — u metrikama. Koliko je uspelo, koliko je timeout-ovalo, koji provider je spor. Ne zato što je neko tražio, već zato što bez toga letiš slepo.

Pamtim projekat gde je produkcija bila pala 40 minuta i jedini način da razumem šta se dešava bio je tail -f /var/log/symfony.log | grep ERROR. Nikad više.

Paket: promphp/prometheus_client_php. Pet minuta za instalaciju, petnaest za povezivanje Grafane.


5. Spolja jednostavno, iznutra raketna nauka

clientv3 u etcd-u je masterclass facade pattern-a:

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

Jedna linija. Pod haubom: izbor čvora, reconnect pri padu, retry sa exponential backoff-om, protobuf serijalizacija, Raft consensus, upis na disk, potvrda kvoruma.

Isti princip u PHP-u:

// Calling code. Jednostavan i jasan.
$paymentService->charge($order);

Unutar 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;
}

Kontroler koji zove charge() ne zna ništa o fiskalnim računima, retry-ima ili izboru provider-a. I ne treba da zna.

Znak dobrog servisa: možeš objasniti šta radi u jednoj rečenici — „naplaćuje kupcu narudžbinu” — dok je implementacija 200 linija pažljive logike.


6. Možeš pratiti zahtev prstom

U etcd-u, putanja zahteva se čita linearno:

gRPC handler → EtcdServer.Put() → Raft → apply → bbolt (disk)

Bez magije. Bez skrivenih poziva. Bez „odakle se ovo uopšte triggeruje?”.

U Symfony-ju — ista stvar, ako ne zloupotrebljavaš sistem događaja:

Request
  → Controller (raspakivanje DTO-a)
    → Service (biznis logika)
      → Repository (baza)
      → EventDispatcher (sporedni efekti)
  → Response

Otvoriš kontroler — vidiš koji se servis poziva. Otvoriš servis — vidiš šta radi. Otvoriš repository — vidiš upit.

Šta ubija praćenje:

  • @PostPersist na entitetu koji tiho šalje SMS
  • prePersist listeneri koji menjaju podatke pre upisa — i provedeš 30 minuta tražeći ko dira polje updatedAt
  • Deset EventSubscriber-a na istom događaju sa nejasnim redosledom izvršavanja

Event-driven je odličan. Ali ako novi developer ne može da objasni „zahtev ulazi ovde, odgovor izlazi tamo” za 2 minuta — imaš problem.


7. Bez skrivenih zavisnosti

U etcd-u, sve zavisnosti se prosleđuju eksplicitno:

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

Vidiš konstruktor — vidiš sve što klasa treba.

U Symfony-ju — constructor injection, ista stvar:

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

Četiri zavisnosti. Sve vidljive. Hoćeš da testiraš? Ubaci mock-ove. Hoćeš da razumeš klasu? Pogledaj konstruktor.

Antipatterni koji još uvek preživljavaju:

// Service locator: odakle ovo dolazi?
$payment = $this->container->get('payment.gateway');

// Statički pozivi: ne može se testirati
Cache::put('key', $value);

// new SomeService() unutar drugog servisa: nevidljivo spajanje
$validator = new OrderValidator();

Symfony autowiring nije magija u lošem smislu. Container povezuje zavisnosti po tipu, ali ih i dalje vidiš u konstruktoru. To je pogodnost, ne skriveno ponašanje.


Moja checklist

Posle proučavanja etcd-a, izvukao sam checklist koji sad primenjujem na svaki novi servis:

  1. Ugovor definisan? DTO-ovi postoje, tipovi su zadati, OpenAPI se generiše iz njih
  2. Kontroler tanak? Najviše 10 linija, sva logika u service sloju
  3. Cross-cutting concerns izvučeni? Logovanje, metrike, retry — kroz middleware, ne copy-paste
  4. Metrike prisutne? Ako ne, servis nije production-ready
  5. API jednostavan spolja? Pozivajući kod ne zna o internoj kompleksnosti
  6. Putanja zahteva sledljiva? Novi developer nalazi handler za 2 minuta
  7. Zavisnosti eksplicitne? Sve u konstruktoru, ništa iz vazduha

Ništa od ovoga nije revolucionarno. Osnovna higijena koju je lako zaboraviti pod pritiskom rokova.

etcd me je samo podsetio kako izgleda codebase kad ta higijena nije preskočena. I da je moguće čak i u velikom produkcijskom sistemu.


Koji open-source codebase je promenio kako pišeš kod? Voleo bih da napravim reading listu — bacaj svoje u komentare.