İçeriğe geç
academia.sh

Ders 23 / 25

WebSocket Sunucusu

Çift yönlü bağlantının sıfırdan kurulması: HTTP üzerinden yükseltme el sıkışması ve kabul anahtarının üretimi, çerçeve biçiminin yazılması ve çözülmesi, yoklama–karşılık ile kapanış el sıkışması ve bağlantı durumunun yatay ölçeklemeye etkisi.

İçindekiler

Sunucu gönderimli olaylar tek yönlüydü: sunucu konuşur, istemci dinler. Kütüphane servisinde iki tarafın da konuştuğu durumlar var — görevlinin şube stok sayacını düzeltmesi, üyenin rezervasyon sırasından çekilmesi. Bunların her biri için ayrı bir HTTP isteği açmak, akışın kazandırdığı gecikmeyi geri verir.

WebSocket, bu ihtiyaca karşılık gelen aktarımdır: HTTP üzerinden başlayan, bir el sıkışmadan sonra kendi çerçeve biçimine geçen ve iki yönde de mesaj taşıyan tek bir bağlantı. Bu ders onu hazır bir paket kullanmadan kurar; çünkü kurmadan öğrenilmeyen tek şey, bağlantının neyi tuttuğu ve neden ölçeklemede sorun çıkardığıdır.

Yükseltme El Sıkışması

Bağlantı olağan bir HTTP isteğiyle başlar. İstemci Upgrade: websocket ve Connection: Upgrade başlıklarını, ayrıca rastgele üretilmiş on altı baytlık bir değeri base64 ile kodlayıp Sec-WebSocket-Key başlığında gönderir.

Sunucu bu anahtarı belirtimde sabitlenmiş bir GUID ile birleştirir, SHA-1 özetini alır, base64 ile kodlar ve sonucu Sec-WebSocket-Accept başlığında geri verir. Yanıtın durum kodu 101 Switching Protocols’tür ve bu noktadan sonra bağlantı üzerinde HTTP yoktur.

// kabul-anahtari.mjs — el sikismadaki kabul degerinin uretimi ve dogrulanmasi
import { createHash } from "node:crypto";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  // protokolde sabitlenmis deger
const anahtar = "dGhlIHNhbXBsZSBub25jZQ==";           // belirtimdeki ornek istemci anahtari
const kabul = createHash("sha1").update(anahtar + GUID).digest("base64");

console.log("Sec-WebSocket-Key   :", anahtar);
console.log("Sec-WebSocket-Accept:", kabul);
console.log("belirtimdeki deger  :", kabul === "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
Sec-WebSocket-Key   : dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
belirtimdeki deger  : true

Bu hesabın hiçbir güvenlik işlevi yoktur. SHA-1 burada bir imza değil, bir doğrulamadır: sunucunun isteği gerçekten WebSocket olarak anladığını ve aradaki bir önbelleğin eski bir yanıtı tekrar oynatmadığını gösterir. Kimlik doğrulama, yükseltme isteğinin kendisinde — çerezde, başlıkta ya da ilk mesajda — ayrıca yapılmalıdır.

Çerçeve Biçimi

El sıkışmadan sonra bağlantı üzerinde çerçeveler (frame) taşınır. Bir çerçevenin başlığı en az iki bayttır. İlk baytın en yüksek biti FIN, mesajın bu çerçeveyle bitip bitmediğini söyler; alt dört bit işlem kodudur: metin, ikili, kapat, yokla, karşılık.

İkinci baytın en yüksek biti maske bitidir, kalan yedi bit uzunluktur. Uzunluk 125’e kadar doğrudan yazılır; 126 değeri sonraki iki baytın, 127 değeri sonraki sekiz baytın gerçek uzunluğu taşıdığını bildirir.

Maske kuralı tek yönlüdür: istemciden sunucuya giden her çerçeve maskelenmek zorundadır, sunucudan istemciye giden hiçbiri maskelenmemelidir. Maskeleme gizlilik sağlamaz — anahtar çerçevenin içindedir. Amacı, aradaki eski bir aracının çerçeve gövdesini geçerli bir HTTP isteği sanıp önbelleğini zehirlemesini engellemektir.

// cerceve.mjs — WebSocket cerceve bicimini yazar ve cozer
import { randomBytes } from "node:crypto";

export const ISLEM = { metin: 0x1, ikili: 0x2, kapat: 0x8, yokla: 0x9, karsilik: 0xa };

// Cerceve: [FIN + islem kodu][maske biti + uzunluk][genisletilmis uzunluk?]
//          [maske anahtari?][govde].  Istemciden gelen her cerceve maskeli olmak zorunda.
export function cerceveYaz(islem, govde, maskeli = false) {
  const veri = Buffer.isBuffer(govde) ? govde : Buffer.from(String(govde), "utf8");
  const n = veri.length;
  const ek = n < 126 ? 0 : n < 65536 ? 2 : 8;
  const baslik = Buffer.alloc(2 + ek + (maskeli ? 4 : 0));
  baslik[0] = 0x80 | islem;                        // FIN = 1: cerceve tek parca
  baslik[1] = (maskeli ? 0x80 : 0) | (ek === 0 ? n : ek === 2 ? 126 : 127);
  if (ek === 2) baslik.writeUInt16BE(n, 2);
  if (ek === 8) baslik.writeBigUInt64BE(BigInt(n), 2);
  if (!maskeli) return Buffer.concat([baslik, veri]);
  const anahtar = randomBytes(4);
  anahtar.copy(baslik, 2 + ek);
  const maskelenmis = Buffer.from(veri);
  for (let i = 0; i < n; i++) maskelenmis[i] ^= anahtar[i % 4];
  return Buffer.concat([baslik, maskelenmis]);
}

// Tampondan tam bir cerceve cozer; cerceve tamamlanmamissa null doner.
export function cerceveCoz(tampon) {
  if (tampon.length < 2) return null;
  const islem = tampon[0] & 0x0f, son = (tampon[0] & 0x80) !== 0;
  const maskeli = (tampon[1] & 0x80) !== 0;
  let n = tampon[1] & 0x7f, konum = 2;
  if (n === 126) { if (tampon.length < 4) return null; n = tampon.readUInt16BE(2); konum = 4; }
  else if (n === 127) {
    if (tampon.length < 10) return null;
    n = Number(tampon.readBigUInt64BE(2)); konum = 10;
  }
  const anahtar = maskeli ? tampon.subarray(konum, konum + 4) : null;
  if (maskeli) konum += 4;
  if (tampon.length < konum + n) return null;
  const govde = Buffer.from(tampon.subarray(konum, konum + n));
  if (anahtar) for (let i = 0; i < n; i++) govde[i] ^= anahtar[i % 4];
  return { islem, son, maskeli, govde, tuketilen: konum + n };
}

cerceveCoz işlevinin tamamlanmamış çerçevede null döndürmesi bir ayrıntı değil, zorunluluktur. Alt katman TCP’dir ve TCP bir bayt akışıdır: gönderilen bir çerçeve iki parça hâlinde gelebilir, iki çerçeve tek parçada gelebilir. Bu yüzden okuyan taraf gelen baytları biriktirir ve tampondan çözebildiği kadar çerçeve çeker.

Sunucu

Sunucu, HTTP sunucusunun upgrade olayına bağlanır. Bu olay, yükseltme isteği geldiğinde ham yuvayı (socket) verir; yanıt satırını ve başlıklarını sunucunun kendisi yazar.

// ws-sunucu.mjs — el sikismayi ve cerceve cozumlemesini kendisi yapan WebSocket sunucusu
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { ISLEM, cerceveYaz, cerceveCoz } from "./cerceve.mjs";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const kabulUret = (anahtar) => createHash("sha1").update(anahtar + GUID).digest("base64");

const musteri = new Set();
const kutuk = (...s) => console.log(...s);

const sunucu = createServer((istek, yanit) => {
  yanit.sendDate = false;
  yanit.writeHead(426, { "content-type": "text/plain", upgrade: "websocket" });
  yanit.end("bu uc yalniz WebSocket baglantisi kabul eder\n");
});

sunucu.on("upgrade", (istek, yuva) => {
  const anahtar = istek.headers["sec-websocket-key"];
  if (istek.headers.upgrade?.toLowerCase() !== "websocket" || !anahtar)
    return yuva.destroy();

  yuva.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" +
             `Connection: Upgrade\r\nSec-WebSocket-Accept: ${kabulUret(anahtar)}\r\n\r\n`);
  musteri.add(yuva);
  kutuk(`baglanti acildi   (acik=${musteri.size})`);

  const gonder = (islem, govde) => yuva.write(cerceveYaz(islem, govde));  // sunucu maskelemez
  let tampon = Buffer.alloc(0);

  yuva.on("data", (parca) => {
    tampon = Buffer.concat([tampon, parca]);
    let c;
    while ((c = cerceveCoz(tampon))) {
      tampon = tampon.subarray(c.tuketilen);
      kutuk(`cerceve alindi    islem=0x${c.islem.toString(16)}` +
            ` maskeli=${c.maskeli} uzunluk=${c.govde.length}`);

      if (c.islem === ISLEM.metin) {
        const istem = JSON.parse(c.govde.toString());
        gonder(ISLEM.metin, JSON.stringify({ tur: "onay", sube: istem.sube, yeniStok: 41 }));
      } else if (c.islem === ISLEM.yokla) {
        gonder(ISLEM.karsilik, c.govde);            // yoklamaya ayni govdeyle karsilik
      } else if (c.islem === ISLEM.kapat) {
        const kod = c.govde.length >= 2 ? c.govde.readUInt16BE(0) : 1005;
        kutuk(`kapanis istegi    kod=${kod} neden=${c.govde.subarray(2).toString()}`);
        gonder(ISLEM.kapat, c.govde);               // kapanis el sikismasi: ayni kodla don
        yuva.end();
      }
    }
  });

  yuva.on("close", () => {
    musteri.delete(yuva);
    kutuk(`baglanti kapandi  (acik=${musteri.size})`);
  });
});

sunucu.listen(8381, "127.0.0.1", () => kutuk("dinleniyor: 127.0.0.1:8381"));

İstemci

İstemci de aynı biçimi kullanır, iki farkla: el sıkışma isteğini kendisi yazar ve gönderdiği her çerçeveyi maskeler.

// ws-istemci.mjs — ham TCP uzerinde el sikisip cerceve gonderen kucuk WebSocket istemcisi
import { connect } from "node:net";
import { createHash, randomBytes } from "node:crypto";
import { ISLEM, cerceveYaz, cerceveCoz } from "./cerceve.mjs";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const anahtar = randomBytes(16).toString("base64");
const beklenenKabul = createHash("sha1").update(anahtar + GUID).digest("base64");

const kuyruk = [], bekleyen = [];
const cerceveAl = () => kuyruk.length
  ? Promise.resolve(kuyruk.shift())
  : new Promise((c) => bekleyen.push(c));
const cerceveGeldi = (c) => (bekleyen.length ? bekleyen.shift()(c) : kuyruk.push(c));

let tampon = Buffer.alloc(0), elSikisildi = false;
const yuva = connect(8381, "127.0.0.1", () => {
  yuva.write("GET /kanal HTTP/1.1\r\nHost: 127.0.0.1:8381\r\n" +
             "Upgrade: websocket\r\nConnection: Upgrade\r\n" +
             `Sec-WebSocket-Key: ${anahtar}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
});

yuva.on("error", (h) => {                        // sunucu ayakta degilse sessizce cik
  console.log("sunucuya baglanilamadi:", h.code);
  process.exit(0);
});

yuva.on("data", (parca) => {
  tampon = Buffer.concat([tampon, parca]);
  if (!elSikisildi) {
    const son = tampon.indexOf("\r\n\r\n");
    if (son < 0) return;
    const basliklar = tampon.subarray(0, son).toString();
    tampon = tampon.subarray(son + 4);
    elSikisildi = true;
    console.log("durum satiri      :", basliklar.split("\r\n")[0]);
    console.log("kabul anahtari    :",
      /sec-websocket-accept: (.+)/i.exec(basliklar)?.[1] === beklenenKabul ? "dogru" : "yanlis");
    konus();
  }
  let c;
  while ((c = cerceveCoz(tampon))) { tampon = tampon.subarray(c.tuketilen); cerceveGeldi(c); }
});

async function konus() {
  const gonder = (islem, govde) => yuva.write(cerceveYaz(islem, govde, true));

  gonder(ISLEM.metin, JSON.stringify({ tur: "stok", sube: 3, delta: -1 }));
  const y1 = await cerceveAl();
  console.log("metin karsiligi   :", y1.govde.toString(), `(maskeli=${y1.maskeli})`);

  gonder(ISLEM.yokla, "canli-mi");
  const y2 = await cerceveAl();
  console.log("yoklama karsiligi : islem=0x" + y2.islem.toString(16), y2.govde.toString());

  const neden = Buffer.concat([Buffer.from([0x03, 0xe8]), Buffer.from("is bitti")]);
  gonder(ISLEM.kapat, neden);                     // 0x03e8 = 1000, olagan kapanis
  const y3 = await cerceveAl();
  console.log("kapanis karsiligi : islem=0x" + y3.islem.toString(16),
              "kod=" + y3.govde.readUInt16BE(0));
  yuva.end();
}

Yaşam Döngüsü

Aşağıdaki betik önce yükseltmesiz bir HTTP isteği gönderiyor, sonra istemciyi koşturuyor. Bağlantı noktası 8381 keyfîdir ve boş olmalıdır.

#!/usr/bin/env bash
# ws-sunucu.mjs'yi baslatir, once duz HTTP istegini, sonra istemciyi kosturur, durdurur.
node ws-sunucu.mjs > sunucu.log & sunucu=$!
sleep 1

echo "--- yukseltmesiz duz HTTP istegi ---"
curl -sS -i http://127.0.0.1:8381/kanal | head -3
echo "--- istemcinin gordugu ---"
node ws-istemci.mjs
sleep 0.3
echo "--- sunucunun kutugu ---"
cat sunucu.log
kill "$sunucu"
--- yukseltmesiz duz HTTP istegi ---
HTTP/1.1 426 Upgrade Required
content-type: text/plain
upgrade: websocket
--- istemcinin gordugu ---
durum satiri      : HTTP/1.1 101 Switching Protocols
kabul anahtari    : dogru
metin karsiligi   : {"tur":"onay","sube":3,"yeniStok":41} (maskeli=false)
yoklama karsiligi : islem=0xa canli-mi
kapanis karsiligi : islem=0x8 kod=1000
--- sunucunun kutugu ---
dinleniyor: 127.0.0.1:8381
baglanti acildi   (acik=1)
cerceve alindi    islem=0x1 maskeli=true uzunluk=34
cerceve alindi    islem=0x9 maskeli=true uzunluk=8
cerceve alindi    islem=0x8 maskeli=true uzunluk=10
kapanis istegi    kod=1000 neden=is bitti
baglanti kapandi  (acik=0)

Kütük yaşam döngüsünün dört evresini gösteriyor. Açılış: yükseltme kabul edilir, bağlantı sayacı artar. Veri: istemciden gelen metin çerçevesi maskelidir, dönen karşılık değildir. Denetim: yoklama (0x9) çerçevesine aynı gövdeyle karşılık (0xa) verilir. Kapanış: 0x8 çerçevesi bir kapanış isteğidir, emir değil; karşı taraf kendi kapanış çerçevesini gönderdikten sonra yuva kapanır.

Yoklama–karşılık çifti gerçek işlerde vazgeçilmezdir. TCP bağlantısı, karşı taraf sessizce kaybolduğunda uzun süre açık görünebilir; düzenli aralıklarla gönderilen yoklamaya karşılık gelmemesi, bağlantının ölü olduğunun tek güvenilir işaretidir. Aynı trafik, ara katmanların boşta duran bağlantıyı kapatmasını da engeller — sunucu gönderimli olaylardaki kalp atışı satırının burada karşılık gelen biçimidir.

Ölçekleme

WebSocket ile HTTP arasındaki asıl fark başlıklarda değil, durumdadır. Olağan bir HTTP isteği durumsuzdur; hangi sunucu örneğine düşerse düşsün aynı sonucu verir. WebSocket bağlantısı ise bir örneğe bağlanır ve bağlantı süresince orada kalır.

Bunun üç sonucu vardır. Ölçekleme birimi artık istek değil bağlantıdır; sunucunun kapasitesi saniyedeki istek sayısıyla değil, aynı anda tutabildiği yuva ve bellek miktarıyla ölçülür. Yeniden dağıtım her açık bağlantıyı koparır; istemci tarafında yeniden bağlanma ve durum tazeleme zorunludur. Ve en önemlisi: aynı kanalı dinleyen iki istemci farklı örneklere düşmüş olabilir. Bir örneğe gelen mesaj, diğerinin istemcisine kendiliğinden ulaşmaz.

Bu üçüncü sonuç, çok örnekli her gerçek zamanlı kurulumun temel sorunudur.

Özet

  • WebSocket bağlantısı HTTP isteğiyle başlar; sunucu istemci anahtarını sabit bir GUID ile birleştirip SHA-1 özetini base64 olarak geri verir ve 101 ile protokolü değiştirir.
  • Kabul anahtarı bir güvenlik önlemi değil, isteğin WebSocket olarak anlaşıldığının ve eski bir yanıtın tekrarlanmadığının doğrulamasıdır.
  • Çerçeve başlığı FIN bitini, işlem kodunu, maske bitini ve uzunluğu taşır; istemciden gelen her çerçeve maskeli, sunucudan gidenler maskesizdir.
  • Alt katman bayt akışı olduğu için çözücü tamamlanmamış çerçevede beklemeli, tampondan çözebildiği kadar çerçeve çekmelidir.
  • Yaşam döngüsü açılış, veri, yoklama–karşılık ve iki taraflı kapanış el sıkışmasıdır; bağlantının bir örneğe bağlanması, ölçekleme biriminin istek değil bağlantı olmasına yol açar.

Sonraki Adım

Bu derste tek bir sunucu örneği ve tek bir istemci vardı; mesajın gideceği yer belliydi. Gerçek kurulumda ise aynı kanalı dinleyen istemciler farklı örneklere dağılır: şube stok sayacını izleyen üç görevliden ikisi birinci örneğe, biri ikinciye bağlanmış olabilir. Birinci örneğe gelen bir güncelleme, ikinci örnekteki görevliye ulaşmaz — çünkü o mesajı kimse ona iletmez. Sonraki ders bu boşluğu kapatan yapıyı kurar ve mesajın bir örnekten diğerinin abonesine gerçekten ulaştığını koşturarak gösterir.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat