Ürün

Shuffly

Gerçek zamanlı sesli sohbet platformunun arka ucu. İki pazar bu servis üzerinde çalışıyor, uygulamaları ayrı bir ekip geliştiriyor.

Yayınlanmış API referansı. Üst navigasyonda üç yüzey: REST, WebSocket protokolü ve sunucular arası API. Solda endpoint'ler alan alan gruplanmış: kimlik, sesli odalar, oyunlar, ekonomi. Açık olan sayfa token yenileme endpoint'i. Hangi kimlik bilgisinin öncelikli olduğunu ve nedenini, istek gövdesi şemasını, durum kodlarıyla yanıtları gösteriyor.

Sayfanın devamı için kaydırın

Refresh endpoint'i header yerine gövdenin geçerli olduğunu ve nedenini yazıyor: mobil istemciler Bearer header'ı kendiliğinden ekliyor. Entegrasyonu yapan mühendisler için yazılmış.

Sistem

İki pazarda gerçek zamanlı sesli odalar çalışıyor, üçüncüsü yolda. Mobil uygulamaları dört kişilik ayrı bir ekip geliştiriyor. Onların çağırdığı API’nin arkasındaki her şey bende: HTTP uygulaması, oda durumunu tutan WebSocket daemon’ları, moderasyon ve ban, satın alma, push ve deploy hattı. Operasyon ekibinin kullandığı operasyon panelini de ben yazıyorum.

Her pazar bir config dosyası, fork değil. Yerelleştirme istemciye kadar iniyor. Yeni bir pazar açmak bir deploy demek.

Göstergeİstemci ekibi geliştiriyorBenim sunucumda çalışıyorDoğrudan yol
  1. İstemciler

    • Pazara özel mobil istemciler

      Flutter · Dart

      İstemci ekibi geliştiriyor

    • Operasyon paneli

      Next.js · TypeScript

  2. Sözleşme

    • REST ve WebSocket API

      OpenAPI · WebSocket

      İstemci ekibi bu yüzeyin üzerine geliştiriyor ve arkasını göremiyor. Sistemde sessizce bozulamayacak tek parça burası.

  3. Uygulama

    • HTTP uygulaması

      PHP · Slim

    • Oda daemon'ı

      Workerman

      Neden

      Tick tabanlı oda durumunu tutuyor: kim hangi koltukta ve ne kadar süredir. REST bu servisle aynı süreci paylaşmıyor, Redis listeleri üzerinden konuşuyor.

    • Birebir mesaj daemon'ı

      Ratchet

      Neden

      Birebir mesajlaşmayı Ratchet yürütüyor. Koltuk durumunu tutan tick tabanlı oda daemon'ından ayrı duruyor.

  4. Servisler

    • Sesli odalar

      LiveKit

      Neden

      Yayında kullanıcıya hizmet veren bir sistemde, önceki sağlayıcıdan buraya geçtik. Oturumu, token'ı ve oda durumunu istemciler tuttuğu için uygulamalar da aynı sürümde çıktı.

    • Moderasyon ve ban

      Neden

      Baştan sona benim. Raporlama hattı, otomatik kontroller ve ban sistemi; hiçbiri satın alınmadı.

    • Cüzdan ve satın alma

      RevenueCat

      Neden

      Üçüncü parti. Maaşlı bir ekipte fiş doğrulamasının ve mağaza uç durumlarının bakımını satın almak mantıklı; kendi oyunumda aynı işi kendim yazıyorum, çünkü orada kullanıcı başına fatura bana çıkıyor.

    • Push bildirimleri

      Firebase Cloud Messaging

  5. Durum

    • İlişkisel veritabanı

      MySQL

    • Önbellek ve mesaj yolu

      Redis

Arkadaş grafı taramasıPHPHTTP uygulaması düğümünden
/** * Pure breadth-first search over the friend graph. No database access: the * caller injects a neighbor provider, so this class can be unit-tested with an * in-memory graph. Produces distance-2..maxDepth candidates ranked by BFS * distance (closer first), then by bridge count (more mutual paths first). * * Performance guards are class constants; the constructor accepts overrides so * tests can trip the caps cheaply without changing production behaviour. */class FriendGraphTraversal{    public const HARD_MAX_DEPTH = 4;    public const MAX_VISITED_NODES = 5000;    public const MAX_FRONTIER = 2000;    public function __construct(        private int $maxVisited = self::MAX_VISITED_NODES,        private int $maxFrontier = self::MAX_FRONTIER,    ) {    }    public function clampDepth(int $requested): int    {        if ($requested < 2) {            return 2;        }        return min($requested, self::HARD_MAX_DEPTH);    }    /**     * @param list<string>                                          $level1Keys     * @param callable(list<string>): array<string, list<string>>  $neighborProvider     * @param array<string, true>                                   $excludedKeys     *     * @return array{     *   candidates: list<array{userKey: string, distance: int, bridgeCount: int, bridgeKeys: list<string>}>,     *   truncated: bool     * }     */    public function run(        string $seedKey,        array $level1Keys,        callable $neighborProvider,        array $excludedKeys,        int $maxDepth,    ): array {        $maxDepth = $this->clampDepth($maxDepth);        // distance[key] = shortest hop count from seed. seed=0, direct friends=1.        $distance = [$seedKey => 0];        foreach ($level1Keys as $k) {            $distance[$k] = 1;        }        /** @var array<string, array<string, true>> $bridges candidateKey => set of level-1 bridge keys */        $bridges = [];        $truncated = false;        $visited = count($distance);        $frontier = array_values(array_unique($level1Keys));        $d = 1;        while ($d < $maxDepth && $frontier !== [] && $visited < $this->maxVisited) {            if (count($frontier) > $this->maxFrontier) {                $frontier = array_slice($frontier, 0, $this->maxFrontier);                $truncated = true;            }            $neighbors = $neighborProvider($frontier);            /** @var array<string, array<string, true>> $nextLayer */            $nextLayer = [];            foreach ($frontier as $node) {                // Bridges that reach $node on a shortest path. A level-1 node is                // its own bridge; deeper nodes carry the accumulated set.                $nodeBridges = $d === 1 ? [(string) $node => true] : ($bridges[$node] ?? []);                foreach (($neighbors[$node] ?? []) as $nb) {                    if (isset($distance[$nb]) && $distance[$nb] <= $d) {                        continue; // reached earlier/at this layer — not via $node's next hop                    }                    if (!isset($nextLayer[$nb])) {                        $nextLayer[$nb] = [];                    }                    foreach ($nodeBridges as $b => $_) {                        $nextLayer[$nb][$b] = true; // union accumulates distinct paths                    }                }            }            $nextFrontier = [];            foreach ($nextLayer as $nb => $brs) {                if (isset($distance[$nb])) {                    continue; // committed in an earlier (shorter) layer                }                $distance[$nb] = $d + 1;                $bridges[$nb] = $brs;                $nextFrontier[] = (string) $nb;                $visited++;                if ($visited >= $this->maxVisited) {                    $truncated = true;                    break;                }            }            $frontier = $nextFrontier;            $d++;        }        $candidates = [];        foreach ($bridges as $key => $brs) {            if (isset($excludedKeys[$key])) {                continue;            }            $bridgeKeys = array_map('strval', array_keys($brs));            sort($bridgeKeys);            $candidates[] = [                'userKey' => (string) $key,                'distance' => $distance[$key],                'bridgeCount' => count($brs),                'bridgeKeys' => $bridgeKeys,            ];        }        usort($candidates, static function (array $a, array $b): int {            return [$a['distance'], -$a['bridgeCount'], $a['userKey']]                <=> [$b['distance'], -$b['bridgeCount'], $b['userKey']];        });        return ['candidates' => $candidates, 'truncated' => $truncated];    }}
Aramanın içinde veritabanı yok. Komşu sorgusunu çağıran taraf veriyor; böylece aynı arama testte bellekteki bir grafın, üretimde deponun üzerinde çalışıyor ve tarama hangisi olduğunu hiç öğrenmiyor. Adaylar önce uzaklığa, sonra kaç ayrı yolun onlara ulaştığına göre sıralanıyor. Gezilen düğüm ve sınır boyutu sınırları sabit, ama kurucu onları ezebiliyor; test bu sayede sınırı ucuza zorluyor.

shuffly-serverb7006f04app/Services/Recommendation/FriendGraphTraversal.phpSatır 6 – 136131 satır

/**
 * Pure breadth-first search over the friend graph. No database access: the
 * caller injects a neighbor provider, so this class can be unit-tested with an
 * in-memory graph. Produces distance-2..maxDepth candidates ranked by BFS
 * distance (closer first), then by bridge count (more mutual paths first).
 *
 * Performance guards are class constants; the constructor accepts overrides so
 * tests can trip the caps cheaply without changing production behaviour.
 */
class FriendGraphTraversal
{
    public const HARD_MAX_DEPTH = 4;
    public const MAX_VISITED_NODES = 5000;
    public const MAX_FRONTIER = 2000;

    public function __construct(
        private int $maxVisited = self::MAX_VISITED_NODES,
        private int $maxFrontier = self::MAX_FRONTIER,
    ) {
    }

    public function clampDepth(int $requested): int
    {
        if ($requested < 2) {
            return 2;
        }
        return min($requested, self::HARD_MAX_DEPTH);
    }

    /**
     * @param list<string>                                          $level1Keys
     * @param callable(list<string>): array<string, list<string>>  $neighborProvider
     * @param array<string, true>                                   $excludedKeys
     *
     * @return array{
     *   candidates: list<array{userKey: string, distance: int, bridgeCount: int, bridgeKeys: list<string>}>,
     *   truncated: bool
     * }
     */
    public function run(
        string $seedKey,
        array $level1Keys,
        callable $neighborProvider,
        array $excludedKeys,
        int $maxDepth,
    ): array {
        $maxDepth = $this->clampDepth($maxDepth);

        // distance[key] = shortest hop count from seed. seed=0, direct friends=1.
        $distance = [$seedKey => 0];
        foreach ($level1Keys as $k) {
            $distance[$k] = 1;
        }

        /** @var array<string, array<string, true>> $bridges candidateKey => set of level-1 bridge keys */
        $bridges = [];
        $truncated = false;
        $visited = count($distance);

        $frontier = array_values(array_unique($level1Keys));
        $d = 1;

        while ($d < $maxDepth && $frontier !== [] && $visited < $this->maxVisited) {
            if (count($frontier) > $this->maxFrontier) {
                $frontier = array_slice($frontier, 0, $this->maxFrontier);
                $truncated = true;
            }

            $neighbors = $neighborProvider($frontier);

            /** @var array<string, array<string, true>> $nextLayer */
            $nextLayer = [];
            foreach ($frontier as $node) {
                // Bridges that reach $node on a shortest path. A level-1 node is
                // its own bridge; deeper nodes carry the accumulated set.
                $nodeBridges = $d === 1 ? [(string) $node => true] : ($bridges[$node] ?? []);
                foreach (($neighbors[$node] ?? []) as $nb) {
                    if (isset($distance[$nb]) && $distance[$nb] <= $d) {
                        continue; // reached earlier/at this layer — not via $node's next hop
                    }
                    if (!isset($nextLayer[$nb])) {
                        $nextLayer[$nb] = [];
                    }
                    foreach ($nodeBridges as $b => $_) {
                        $nextLayer[$nb][$b] = true; // union accumulates distinct paths
                    }
                }
            }

            $nextFrontier = [];
            foreach ($nextLayer as $nb => $brs) {
                if (isset($distance[$nb])) {
                    continue; // committed in an earlier (shorter) layer
                }
                $distance[$nb] = $d + 1;
                $bridges[$nb] = $brs;
                $nextFrontier[] = (string) $nb;
                $visited++;
                if ($visited >= $this->maxVisited) {
                    $truncated = true;
                    break;
                }
            }

            $frontier = $nextFrontier;
            $d++;
        }

        $candidates = [];
        foreach ($bridges as $key => $brs) {
            if (isset($excludedKeys[$key])) {
                continue;
            }
            $bridgeKeys = array_map('strval', array_keys($brs));
            sort($bridgeKeys);
            $candidates[] = [
                'userKey' => (string) $key,
                'distance' => $distance[$key],
                'bridgeCount' => count($brs),
                'bridgeKeys' => $bridgeKeys,
            ];
        }

        usort($candidates, static function (array $a, array $b): int {
            return [$a['distance'], -$a['bridgeCount'], $a['userKey']]
                <=> [$b['distance'], -$b['bridgeCount'], $b['userKey']];
        });

        return ['candidates' => $candidates, 'truncated' => $truncated];
    }
}
  • Analitik sorgular API üzerinden değil doğrudan veritabanından okuyor, böylece raporlama yükü uygulama sunucusuna hiç ulaşmıyor.

Ses motorunu çalışırken değiştirmek

En büyük iş, kullanıcılara hizmet veren bir sistemde gerçek zamanlı ses sağlayıcısını değiştirmekti.

Bu değişiklik arka uçta duramazdı. Oturumu, token’ı ve oda durumunu istemciler tutuyor, yani uygulamaların da aynı sürümde çıkması gerekiyordu. O migration boyunca istemci ekibinin deposuna, onların dalına commit attım. Changelog gönderip beklemedim.

Katkı

Yazılan commit
2.074
Başka bir ekibin kod tabanında
175
Kaydedilen karar
102

23 haftanın 22 tanesi aktif · 6 Nis 2026 – 13 Eyl 2026

En uzun seri · 15 hafta · Haz – Eyl

Dört istemci mühendisinin üzerine geliştirdiği servisi ben sürdürüyorum. Ses sağlayıcısı değişirken onların deposunda, onların dalında, yanlarında çalıştım.

Ölçüm 2026-09-08

Aynı anda platformun neyi sakladığına da karar verdik:

  • Birebir mesajlar: saklanıyor. Teslimat garantisi, kapladığı yere değiyor.
  • Sesli oda sohbeti: saklanmıyor. Mesaj gövdeleri hiçbir yere yazılmıyor, sistemin en büyük tablosu böylece hiç oluşmuyor.

API’nin kendisi ürün

Dört mühendis bunun üzerine geliştiriyor ve arkasındaki kodu göremiyor. O yüzden API’ye ürün gibi davranıyorum.

  • Versiyonlanıyor ve yayınlanıyor; path, şema ve kanal olarak bölünmüş. Merge’de pipeline yayınlıyor, yani okudukları doküman koddan eski olmuyor.
  • Her endpoint kendi yan etkilerini yazıyor: iki kez çağrılması güvenli mi, hangi yanıtta ne yapılmalı, hangi hata tekrar denenmeli.
  • Entegrasyon rehberleri, tek tek endpoint’leri değil komple akışları anlatıyor.
Operasyon panelinin coin ekonomi raporu. Üstte tarih aralığı, kullanıcı tipi ve cinsiyet filtreleri, altlarında beş özet rakam: basılan, harcanan, dolaşımdaki net değişim, dönemdeki işlem sayısı ve giriş çıkış oranı. Aşağıda coin'in nereden girip nereden çıktığını sıralayan iki döküm ve kümülatif arz grafiği.

Sayfanın devamı için kaydırın

Panel bu sözleşmenin diğer istemcisi ve onu ben geliştiriyorum. Bunun gibi toplu sorgular, API üzerinden değil doğrudan veritabanından okumasının sebebi.

Devraldığım stack

PHP 8, Slim 4, MySQL, Redis. WebSocket daemon’ları HTTP uygulamasının yanında duruyor, içinde değil.

Değiştirmeyi erken, hâlâ ucuzken savundum. Karar başka yönde çıktı, iş de şuna döndü: başkalarının rahat çalışabileceği hale getirmek.

53

Alt sistem dokümanı

Hangi parça nasıl davranıyor.

102

Karar kaydı

Neden öyle davranıyor.

4

Test suite

Gerçek MySQL ve Redis üzerinde.

Buradaki asıl hataları mock’lar gizliyor, o yüzden testler gerçeğin üzerinde koşuyor.

Koddan sonrası

  • Deploy ve rollback. Dört dal. Prod’a yalnızca testleri geçmiş commit çıkabiliyor, bunu bir tag zorunlu kılıyor.
  • Deploy kendini doğruluyor. Reload sonrası WebSocket proxy’sinden gerçek yanıt bekliyor. Bu sunucuda sağlık raporu güvenilir değil.
  • Şunları da ben yürütüyorum: moderasyon ve ban, push dağıtımı, staging, CI.