Ders 02 / 16
İstemci ve Sunucu Sorumluluk Sınırı
Aynı hesabın iki tarafta da yapılabildiği durumlarda seçimi belirleyen ölçütler; taşınan bayt, ağ turu sayısı ve kararın bağlayıcılığı ölçülerek karşılaştırılır.
İçindekiler
Önceki ders doğruluk kaynağının sunucu olduğunu üç sorumluluk üzerinden kurdu: kalıcılık, iş kuralları ve tümleştirme. Bu, her hesabın sunucuda yapılacağı anlamına gelmez. Kütüphane uygulamasında pek çok işlem — bir listenin süzülmesi, bir tutarın hesaplanması, bir arama teriminin düzeltilmesi — iki tarafta da yazılabilir ve ikisi de doğru çalışır.
Bu ders sınırın nereden geçtiğini üç ölçüyle inceler: taşınan bayt, ağ turu sayısı ve kararın bağlayıcılığı. İlk ikisi ölçülür, üçüncüsü ölçülemez ama en belirleyicisidir.
Aynı Sonuç, İki Yol: Taşınan Veri
Kütüphanenin kataloğu beş bin kitap içeriyor. Kullanıcı “ağ” konusundaki kitapların ilk sayfasını görmek istiyor. İki yol vardır: sunucu kataloğun tamamını gönderir ve istemci süzer; ya da sunucu süzer, sayfalar ve yalnız görüntülenecek dilimi gönderir. Aşağıdaki sunucu ikisini de sunar.
// katalog-sunucusu.mjs — ayni katalogu iki bicimde sunar: tamami ve suzulmus sayfa import { createServer } from "node:http"; const KONULAR = ["algoritma", "veritabani", "ag", "isletim-sistemi", "derleyici"]; const KATALOG = Array.from({ length: 5000 }, (_, i) => ({ isbn: `978-0${(1000000 + i).toString()}`, ad: `Kitap ${i + 1}: ${KONULAR[i % 5]} uzerine inceleme`, konu: KONULAR[i % 5], yil: 1970 + (i % 55), raf: `R-${Math.floor(i / 50) + 1}`, })); createServer((istek, yanit) => { yanit.sendDate = false; const adres = new URL(istek.url, "http://yerel"); yanit.setHeader("content-type", "application/json; charset=utf-8"); if (adres.pathname === "/kitaplar/tumu") { return yanit.writeHead(200).end(JSON.stringify(KATALOG)); } if (adres.pathname === "/kitaplar") { const konu = adres.searchParams.get("konu"); const sayfa = Number(adres.searchParams.get("sayfa") ?? 1); const boy = 20; const suzulmus = konu ? KATALOG.filter((k) => k.konu === konu) : KATALOG; const dilim = suzulmus.slice((sayfa - 1) * boy, sayfa * boy); return yanit.writeHead(200).end(JSON.stringify({ toplam: suzulmus.length, sayfa, kitaplar: dilim })); } yanit.writeHead(404).end(JSON.stringify({ hata: "yol_yok" })); }).listen(8424, "127.0.0.1", () => console.log("katalog 127.0.0.1:8424"));
#!/usr/bin/env bash # Ayni sonucu ureten iki yaklasimin tasidigi baytlari ve sureyi olcer. node katalog-sunucusu.mjs & sunucu=$! sleep 0.5 curl -sS -o /dev/null 'http://127.0.0.1:8424/kitaplar?konu=ag' # isinma BICIM=' govde %{size_download} B ilk bayt %{time_starttransfer} s\n' echo "--- tum katalog istemciye tasiniyor (suzme istemcide) ---" curl -sS -o /dev/null -w "$BICIM" 'http://127.0.0.1:8424/kitaplar/tumu' echo "--- suzme ve sayfalama sunucuda ---" curl -sS -o /dev/null -w "$BICIM" 'http://127.0.0.1:8424/kitaplar?konu=ag&sayfa=1' echo "--- iki yanitin ilk satirlari ---" curl -sS 'http://127.0.0.1:8424/kitaplar?konu=ag&sayfa=1' | head -c 220; echo kill "$sunucu"
katalog 127.0.0.1:8424
--- tum katalog istemciye tasiniyor (suzme istemcide) ---
govde 563494 B ilk bayt 0.001898 s
--- suzme ve sayfalama sunucuda ---
govde 1956 B ilk bayt 0.000569 s
--- iki yanitin ilk satirlari ---
{"toplam":1000,"sayfa":1,"kitaplar":[{"isbn":"978-01000002","ad":"Kitap 3: ag uzerine inceleme","konu":"ag","yil":1972,"raf":"R-1"},{"isbn":"978-01000007","ad":"Kitap 8: ag uzerine inceleme","konu":"ag","yil":1977,"raf":
Süre alanları makineye ve o andaki yüke bağlıdır; her çalıştırmada değişir. Sabit olan bayt oranıdır: 563 494 bayta karşılık 1 956 bayt, yaklaşık 288 kat. Kullanıcı ekranda yirmi satır görecektir; ilk yolda bu yirmi satır için beş bin kaydın tamamı taşınmıştır.
Fark yalnız bant genişliği değildir. Kataloğun tamamı istemciye indiğinde bellekte de tutulur, ayrıştırılır ve her süzme işleminde yeniden gezilir; katalog büyüdükçe bu maliyet istemcinin en zayıf aygıtında ödenir. Buna karşılık ikinci yolda her süzme değişikliği yeni bir istek demektir: kullanıcı konu değiştirdiğinde ağa çıkılır, sonuç beklenir.
Ölçüt bu iki maliyet arasındaki orandır. Veri kümesi ekrana sığmayacak kadar büyükse süzme sunucuya aittir. Veri kümesi küçük ve zaten indirilmişse — örneğin bir üyenin altı açık ödüncü — aynı listeyi tekrar tekrar sunucudan istemek gereksiz turlar üretir.
Ölçümün Karar Veremediği Kalem: Bağlayıcılık
Yukarıdaki karar başarım üzerinden verilebilir; her iki seçenek de doğru sonucu üretir. Bazı hesaplarda ise seçenek yoktur ve gerekçe ölçüme dayanmaz.
Gecikme cezası böyle bir hesaptır. Ödünç süresi on dört gündür, gecikilen her gün için birim ücret alınır. Hesap iki çarpma ve bir çıkarmadır; istemci de yapabilir. Aşağıdaki sunucu istemcinin gönderdiği tutarı ve istemcinin bildirdiği saati okumaz.
// iade-sunucusu.mjs — gecikme cezasini istemcinin bildirdigine bakmadan kendisi hesaplar import { createServer } from "node:http"; const GUN = 86_400_000; const SURE_GUN = 14; // odunc suresi const GUNLUK_CEZA = 2.5; // gecikme basina birim ucret // Tohum veri: sunucu ayaga kalkarken 20 gun once verilmis bir odunc olusturulur. const ODUNCLER = new Map([["U-1002:978-0201896831", { verilis: Date.now() - 20 * GUN }]]); const govdeOku = (istek) => new Promise((coz, red) => { let veri = ""; istek.on("data", (parca) => (veri += parca)); istek.on("end", () => { try { coz(veri ? JSON.parse(veri) : {}); } catch (h) { red(h); } }); }); createServer(async (istek, yanit) => { yanit.sendDate = false; yanit.setHeader("content-type", "application/json; charset=utf-8"); const govde = await govdeOku(istek); // govde.tutar ve govde.simdi okunmaz const kayit = ODUNCLER.get(`${govde.uye}:${govde.isbn}`); if (!kayit) return yanit.writeHead(404).end(JSON.stringify({ hata: "odunc_yok" })); const sonGun = kayit.verilis + SURE_GUN * GUN; const gecikme = Math.max(0, Math.floor((Date.now() - sonGun) / GUN)); const tutar = Number((gecikme * GUNLUK_CEZA).toFixed(2)); yanit.writeHead(200).end(JSON.stringify({ uye: govde.uye, gecikmeGun: gecikme, tutar, istemcininBildirdigi: govde.tutar ?? null, })); }).listen(8425, "127.0.0.1", () => console.log("iade 127.0.0.1:8425"));
#!/usr/bin/env bash # Istemcinin bildirdigi tutar ve saat degistiginde sunucunun yaniti degisiyor mu? node iade-sunucusu.mjs & sunucu=$! sleep 0.5 gonder() { printf '%-62s\n -> ' "$1" curl -sS -w '\n' -X POST -H 'content-type: application/json' -d "$1" http://127.0.0.1:8425/iade } gonder '{"uye":"U-1002","isbn":"978-0201896831"}' gonder '{"uye":"U-1002","isbn":"978-0201896831","tutar":0}' gonder '{"uye":"U-1002","isbn":"978-0201896831","tutar":0,"simdi":"2020-01-01T00:00:00Z"}' kill "$sunucu"
iade 127.0.0.1:8425
{"uye":"U-1002","isbn":"978-0201896831"}
-> {"uye":"U-1002","gecikmeGun":6,"tutar":15,"istemcininBildirdigi":null}
{"uye":"U-1002","isbn":"978-0201896831","tutar":0}
-> {"uye":"U-1002","gecikmeGun":6,"tutar":15,"istemcininBildirdigi":0}
{"uye":"U-1002","isbn":"978-0201896831","tutar":0,"simdi":"2020-01-01T00:00:00Z"}
-> {"uye":"U-1002","gecikmeGun":6,"tutar":15,"istemcininBildirdigi":0}
Üç istek de aynı sonucu almıştır: altı gün gecikme, on beş birim ücret. İkinci istek sıfır tutar bildirmiştir, üçüncüsü ayrıca kendi saatini altı yıl geriye almıştır. Sunucunun yanıtı değişmemiştir, çünkü sunucu iki girdiyi de yalnız kayda geçirmiş, hesabı kendi verisinden ve kendi saatinden yapmıştır.
Buradaki ilke bir başarım ilkesi değil, bir güven sınırı (trust boundary) ilkesidir: sunucu, kendi denetimindeki verilerden türetemediği hiçbir değeri bağlayıcı saymaz. Bu listeye istemciden gelen tutarlar, saat bilgisi, kullanıcı kimliği iddiaları, fiyatlar ve yetki bilgileri girer. İstemci bunların hepsini gönderebilir; sunucu hepsini girdi olarak alır, hiçbirini karar olarak almaz.
İstemcinin aynı hesabı yapması yine de yararlıdır. Üye iade ekranını açtığında tutarı anında görür, sürpriz yaşamaz. Bu hesap bir tahmindir ve sunucunun hesabıyla çeliştiğinde sunucununki geçerlidir. Aynı kural birinci derste ödünç sınırında görülmüştü: istemci gösterir, sunucu bağlar.
Ters Yön: Sunucuya Taşınmaması Gereken Hesap
Sınır tek yönlü değildir. Bir hesabı gereksiz yere sunucuya taşımak da bir tasarım hatasıdır ve bedeli tur sayısıyla ölçülür.
Katalog arama kutusunda kullanıcı yazdıkça arama terimi düzeltilir: baştaki ve sondaki boşluklar atılır, küçük harfe çevrilir, iç boşluklar teke indirilir. Türkçe’de küçük harfe çevirme yerel ayara duyarlıdır ve bu düzeltmenin sunucuda yapılması için bir gerekçe gibi görünebilir. Aşağıdaki ölçüm bedeli gösterir.
// tur-maliyeti.mjs — arama kutusundaki her tus icin sunucuya gitmenin bedeli import { createServer } from "node:http"; const TUSLAR = ["a", "al", "alg", "algo", "algor", "algori", "algorit", "algoritma"]; const duzelt = (metin) => metin.trim().toLocaleLowerCase("tr").replace(/\s+/g, " "); const sunucu = createServer((istek, yanit) => { yanit.sendDate = false; const q = new URL(istek.url, "http://yerel").searchParams.get("q") ?? ""; yanit.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ q: duzelt(q) })); }); await new Promise((coz) => sunucu.listen(8426, "127.0.0.1", coz)); await fetch("http://127.0.0.1:8426/duzelt?q=isinma"); // isinma turu const t0 = performance.now(); for (const tus of TUSLAR) await (await fetch(`http://127.0.0.1:8426/duzelt?q=${tus}`)).json(); const sunucuda = performance.now() - t0; const t1 = performance.now(); for (const tus of TUSLAR) duzelt(tus); const istemcide = performance.now() - t1; sunucu.close(); console.log(`tus sayisi: ${TUSLAR.length}`); console.log(`istemcide duzeltme : ${istemcide.toFixed(3)} ms`); console.log(`sunucuda duzeltme : ${sunucuda.toFixed(3)} ms (geri dongu arayuzunde)`); console.log(""); console.log("ag turu eklendiginde toplam bekleme (tus basina bir tur):"); console.log(" " + "tur".padEnd(10) + "sunucuda".padStart(12) + "istemcide".padStart(12)); for (const tur of [0, 20, 60, 150]) { const s = sunucuda + TUSLAR.length * tur; console.log(" " + `${tur} ms`.padEnd(10) + `${s.toFixed(1)} ms`.padStart(12) + `${istemcide.toFixed(1)} ms`.padStart(12)); }
tus sayisi: 8 istemcide duzeltme : 0.007 ms sunucuda duzeltme : 12.076 ms (geri dongu arayuzunde) ag turu eklendiginde toplam bekleme (tus basina bir tur): tur sunucuda istemcide 0 ms 12.1 ms 0.0 ms 20 ms 172.1 ms 0.0 ms 60 ms 492.1 ms 0.0 ms 150 ms 1212.1 ms 0.0 ms
Süre alanları makineye bağlıdır; oran korunur. Geri döngü arayüzünde bile fark üç mertebedir, çünkü yerel bir fonksiyon çağrısı ile bir HTTP isteği aynı büyüklükte işler değildir. Tablo, ağ turu eklendiğinde farkın nasıl büyüdüğünü gösterir: seksen milisaniyelik bir turda sekiz tuş vuruşu neredeyse bir saniye bekleme üretir.
Bu hesabın sunucuya taşınmasında bir kazanç yoktur, çünkü sonuç bağlayıcı değildir: arama terimini düzeltmek yanlış yapıldığında veriyi bozmaz, yalnız arama sonucunu değiştirir. Ve sunucu zaten kendi tarafında aynı düzeltmeyi yapmak zorundadır — gelen terimi olduğu gibi sorguya vermeyecektir. Yani düzeltme iki tarafta da vardır; istemcideki tepkiyi hızlandırır, sunucudaki doğruluğu güvence altına alır. Aynı hesabın iki tarafta da bulunması bir yineleme değil, iki farklı görevin gereğidir.
Sınırı Çizen Üç Soru
Yukarıdaki üç ölçüm tek bir karar akışına indirgenebilir. Bir hesabın hangi tarafa ait olduğu şu sırayla sorulur:
Hesap bağlayıcı mı? Yanlış sonuç veriye ya da paraya yazılıyorsa hesap sunucudadır ve tartışma biter. İstemcide aynı hesabın bir kopyası bulunabilir, ama yalnız gösterim için.
Girdi nerede duruyor? Hesabın ihtiyaç duyduğu veri sunucudaysa ve büyükse, hesabı veriye götürmek veriyi hesaba götürmekten ucuzdur — kataloğun süzülmesi budur. Veri zaten istemciye indirilmişse tersi geçerlidir.
Kaç kez yapılıyor? Kullanıcı etkileşimi başına birden çok kez tekrarlanan bir hesap sunucuya taşındığında maliyet tur sayısıyla çarpılır. Tuş başına bir istek, saniyede birkaç tur demektir.
Üç soru çelişebilir. Bir arama kutusu hem sık tekrarlanır hem de girdisi sunucudadır: bu durumda hesabın kendisi sunucuda kalır, ama istek sıklığı gecikmeli tetikleme ile düşürülür. Çelişki, sorulardan birinin iptal edilmesiyle değil, iki maliyetin birlikte azaltılmasıyla çözülür.
Özet
- Aynı sonucu üreten iki yerleşim arasındaki seçim ölçülebilir: beş bin kayıtlık katalogda süzmenin sunucuya alınması taşınan gövdeyi 563 494 bayttan 1 956 bayta indirmiştir.
- Süzmenin yeri veri kümesinin büyüklüğüne bağlıdır; küçük ve zaten indirilmiş bir liste için her etkileşimde sunucuya gitmek gereksiz tur üretir.
- Bağlayıcı hesaplar ölçümle seçilmez: sunucu, kendi verisinden ve kendi saatinden türetemediği hiçbir değeri karar olarak almaz; istemcinin bildirdiği tutar ve saat yanıtı değiştirmemiştir.
- Sunum hesabını sunucuya taşımanın bedeli tur sayısıyla çarpılır; sekiz tuş vuruşu için ölçülen fark geri döngüde üç mertebedir, gerçek bir turda saniyelere çıkar.
- Karar üç soruyla verilir: hesap bağlayıcı mı, girdi nerede duruyor, kaç kez tekrarlanıyor.
Sonraki Adım
Buraya kadar “sunucu” tek bir program gibi anıldı: bağlantı noktasını dinleyen, isteği okuyan, kuralı uygulayan ve yanıtı yazan tek bir süreç. Gerçek dağıtımlarda bu iş birden çok bileşene bölünür. Kütüphane uygulamasının kapak görselleri ile arayüz dosyalarını sunan bileşenin, ödünç kuralını çalıştıran bileşenle aynı olması gerekmez; hatta bir bileşenin öndeki tek görevi isteği doğru yere iletmek olabilir. Sonraki ders bu iş bölümünü kurar: statik dosyaları sunmak, istekleri yola göre ayırmak ve bir isteği başka bir sunucuya devretmek ayrı ayrı çalıştırılarak gösterilir.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.