İçeriğe geç
academia.sh

Ders 20 / 21

Alan Olayları

Yan etkilerin olay yayımıyla gevşek bağlanması: aynı yayın çağrısına birden çok dinleyicinin bağlanması, işlem içinde yayımlanan olayın geri alma sonrası ürettiği yanlış bildirimlerin sayılması ve giden kutusu düzeniyle bu sayının sıfıra indirilmesi.

İçindekiler

Ödünç verme isteği iki doğrulama katmanından geçti ve kayıt yazılabilir duruma geldi. Yazmayla birlikte başlaması gereken başka işler de var: üyeye bildirim gitmeli, günlük istatistik güncellenmeli, ilerideki raporlar için kütüğe satır düşülmeli.

Bunların hepsini ödünç verme işlevinin içine yazmak, o işlevi her yeni yan etkide değiştirmek demektir. Alan olayı (domain event), aradaki bağı gevşetir: ödünç verme işlevi olup biteni bildirir, kimin ne yapacağını bilmez. Bu dersin asıl konusu bildirimin zamanıdır — olay ne zaman yayımlanırsa doğru olur?

Olay, Yayınlayıcı, Dinleyici

Olay geçmiş zamanda bir olguyu anlatır: “ödünç verildi”. Bir istek değildir, bir emir değildir; olmuş bir şeyin kaydıdır. Yayınlayıcı, olay türüne bağlı dinleyicileri çağırır. Dersin dosyaları iki dizine yerleşir.

mkdir -p alan uygulama
// alan/olay-yayinlayici.mjs — olay turune gore dinleyici cagiran en kucuk yayinlayici
export class OlayYayinlayici {
  constructor() { this.dinleyiciler = new Map(); }
  abone(tur, dinleyici) {
    if (!this.dinleyiciler.has(tur)) this.dinleyiciler.set(tur, []);
    this.dinleyiciler.get(tur).push(dinleyici);
    return this;
  }
  yayinla(olay) {
    for (const d of this.dinleyiciler.get(olay.tur) ?? []) d(olay);
    return (this.dinleyiciler.get(olay.tur) ?? []).length;
  }
}

Dinleyiciler yan etkilerdir. Üçü de aynı olayı alır, hiçbiri diğerini bilmez.

// uygulama/dinleyiciler.mjs — olaya tepki veren uc yan etki
export const bildirimDinleyicisi = (kutu) => (olay) =>
  kutu.push({ oduncId: olay.oduncId, uyeId: olay.uyeId, kitapId: olay.kitapId,
              metin: `Kitap ${olay.kitapId} ${olay.tarih} tarihinde odunc verildi.` });

export const istatistikDinleyicisi = (sayac) => (olay) => {
  sayac[olay.tarih] = (sayac[olay.tarih] ?? 0) + 1;
};

export const kutukDinleyicisi = (satirlar) => (olay) =>
  satirlar.push(`${olay.tarih} ${olay.tur} odunc=${olay.oduncId}`);

Gevşek bağlanmanın ölçüsü şudur: dinleyici eklendiğinde üreten tarafın kodu değişmez.

// yayin.mjs — ayni yayin cagrisi, farkli sayida dinleyici
import { OlayYayinlayici } from "./alan/olay-yayinlayici.mjs";
import { bildirimDinleyicisi, istatistikDinleyicisi, kutukDinleyicisi }
  from "./uygulama/dinleyiciler.mjs";

const OLAYLAR = [
  { tur: "odunc_verildi", oduncId: 1, uyeId: 1, kitapId: 5, tarih: "2025-06-20" },
  { tur: "odunc_verildi", oduncId: 2, uyeId: 3, kitapId: 7, tarih: "2025-06-20" },
];

for (const dinleyiciSayisi of [2, 3]) {
  const kutu = [], sayac = {}, kutuk = [];
  const yayinlayici = new OlayYayinlayici()
    .abone("odunc_verildi", bildirimDinleyicisi(kutu))
    .abone("odunc_verildi", istatistikDinleyicisi(sayac));
  if (dinleyiciSayisi === 3) yayinlayici.abone("odunc_verildi", kutukDinleyicisi(kutuk));

  // Ureten taraf her iki durumda da ayni tek satiri calistirir.
  for (const olay of OLAYLAR) yayinlayici.yayinla(olay);

  console.log(`dinleyici=${dinleyiciSayisi}  bildirim=${kutu.length}  ` +
              `istatistik=${JSON.stringify(sayac)}  kutuk=${kutuk.length}`);
}
node yayin.mjs
dinleyici=2  bildirim=2  istatistik={"2025-06-20":2}  kutuk=0
dinleyici=3  bildirim=2  istatistik={"2025-06-20":2}  kutuk=2

Üçüncü yan etki, yayımlayan satıra dokunulmadan eklendi. Aboneliğin kurulduğu yer uygulama katmanıdır; alan katmanı yalnız olayın kendisini üretir ve ona kimin abone olduğunu bilmez.

Olayın İşlem Sınırıyla İlişkisi

Yayının nereye konduğu, gevşek bağlanmadan daha ince bir sorundur. Aşağıdaki veritabanında ödünç verme iki yazma yapar: kayıt eklenir ve kitabın stoğu bir azalır. Stok sıfırın altına inemez; bu kısıt bazı isteklerin ikinci adımda başarısız olmasını sağlar.

# kur.sh — dersin veritabanini sifirdan kurar
rm -f kutuphane.db
sqlite3 kutuphane.db <<'SQL'
CREATE TABLE uye (uye_id INTEGER PRIMARY KEY, ad TEXT NOT NULL);
CREATE TABLE kitap (kitap_id INTEGER PRIMARY KEY, baslik TEXT NOT NULL,
                    stok INTEGER NOT NULL CHECK (stok >= 0));
CREATE TABLE odunc (odunc_id INTEGER PRIMARY KEY, kitap_id INTEGER NOT NULL,
                    uye_id INTEGER NOT NULL, alis_tarihi TEXT NOT NULL, iade_tarihi TEXT);
CREATE TABLE giden_kutusu (ileti_id INTEGER PRIMARY KEY, govde TEXT NOT NULL,
                           durum TEXT NOT NULL DEFAULT 'bekliyor');
INSERT INTO uye VALUES (1,'Ayse Demir'),(2,'Bora Kaya');
INSERT INTO kitap VALUES (1,'Korluk',1),(2,'Tutunamayanlar',0),
                         (3,'Kum Kitabi',1),(4,'Puslu Kitalar',0);
SQL

İlk sürüm olayı kayıt eklenir eklenmez, yani işlem sınırının içinde yayımlar.

// islem-icinde.mjs — olay islem icinde yayinlanir; islem geri alinsa bile dinleyici calisti
import { DatabaseSync } from "node:sqlite";
import { OlayYayinlayici } from "./alan/olay-yayinlayici.mjs";
import { bildirimDinleyicisi, istatistikDinleyicisi } from "./uygulama/dinleyiciler.mjs";

const db = new DatabaseSync("kutuphane.db");
const kutu = [], sayac = {};
const yayinlayici = new OlayYayinlayici()
  .abone("odunc_verildi", bildirimDinleyicisi(kutu))
  .abone("odunc_verildi", istatistikDinleyicisi(sayac));

function oduncVer(uyeId, kitapId, tarih) {
  db.exec("BEGIN");
  try {
    const oduncId = Number(db.prepare(
      "INSERT INTO odunc (kitap_id, uye_id, alis_tarihi, iade_tarihi) VALUES (?,?,?,NULL)")
      .run(kitapId, uyeId, tarih).lastInsertRowid);
    yayinlayici.yayinla({ tur: "odunc_verildi", oduncId, uyeId, kitapId, tarih });
    db.prepare("UPDATE kitap SET stok = stok - 1 WHERE kitap_id = ?").run(kitapId);
    db.exec("COMMIT");
    return `verildi (odunc ${oduncId})`;
  } catch (e) {
    db.exec("ROLLBACK");
    return `geri alindi (${e.message.split("\n")[0]})`;
  }
}

const ISTEKLER = [[1, 1], [1, 2], [2, 3], [2, 4]];
for (const [uyeId, kitapId] of ISTEKLER) {
  console.log(`uye ${uyeId} kitap ${kitapId}: ${oduncVer(uyeId, kitapId, "2025-06-20")}`);
}

// Bir bildirim, veritabaninda karsiligi olmayan bir odunc icin gonderilmisse yanlistir.
const yanlis = kutu.filter((b) => db.prepare(
  "SELECT 1 FROM odunc WHERE odunc_id = ? AND kitap_id = ?").get(b.oduncId, b.kitapId) === undefined);
console.log(`odunc satiri = ${db.prepare("SELECT count(*) AS n FROM odunc").get().n}`);
console.log(`gonderilen bildirim = ${kutu.length}, yanlis bildirim = ${yanlis.length}`);
console.log(`yanlis olanlar: ${yanlis.map((b) => `odunc=${b.oduncId} kitap=${b.kitapId}`).join("  ")}`);
console.log(`istatistik = ${JSON.stringify(sayac)}`);
sh kur.sh
node islem-icinde.mjs
uye 1 kitap 1: verildi (odunc 1)
uye 1 kitap 2: geri alindi (CHECK constraint failed: stok >= 0)
uye 2 kitap 3: verildi (odunc 2)
uye 2 kitap 4: geri alindi (CHECK constraint failed: stok >= 0)
odunc satiri = 2
gonderilen bildirim = 4, yanlis bildirim = 2
yanlis olanlar: odunc=2 kitap=2  odunc=3 kitap=4
istatistik = {"2025-06-20":4}

Veritabanında iki kayıt var, gönderilen bildirim dört. İkisi hiç var olmamış ödünçler için gitti: iki üye kendilerine verilmeyen kitapların bildirimini aldı. İstatistik de dördü saydı; günlük rapor gerçeğin iki katını gösteriyor.

Yanlış olanlardan biri ayrıca dikkat çekicidir. odunc=2 kitap=2 satırındaki iki numaralı kimlik veritabanında vardır, ama üç numaralı kitaba aittir. Geri alınan işlemin kullandığı kimlik serbest kaldı ve sonraki kayıt onu aldı. Bildirimi yalnız kimliğe bakarak denetleseydik bu yanlışı göremezdik; denetim kimlik ile kitabın birlikte tutulmasına dayanıyor.

Sorunun kaynağı yayının yerindedir. Dinleyici çağrıldığında işlem henüz kesinleşmemişti; kesinleşmeyeceği de belli değildi. Bildirim gönderimi ise geri alınamaz — ileti gitmişse gitmiştir. Böylece işlemin atomikliği yalnız veritabanı yazmalarını kapsadı, yan etkileri kapsamadı.

Giden Kutusu

Çözüm, İşlemler konusunda kurulan düzenle aynıdır: olay, doğrudan dinleyiciye değil, aynı işlemin içinde bir tabloya yazılır. İşlem kesinleşirse ileti orada olur, geri alınırsa olmaz. Gönderim kesinleşmeden sonra ayrı bir adımda yapılır.

// giden-kutusu.mjs — olay ayni islemde tabloya yazilir, kesinlesince dinleyiciye gider
import { DatabaseSync } from "node:sqlite";
import { OlayYayinlayici } from "./alan/olay-yayinlayici.mjs";
import { bildirimDinleyicisi, istatistikDinleyicisi } from "./uygulama/dinleyiciler.mjs";

const db = new DatabaseSync("kutuphane.db");
const kutu = [], sayac = {};
const yayinlayici = new OlayYayinlayici()
  .abone("odunc_verildi", bildirimDinleyicisi(kutu))
  .abone("odunc_verildi", istatistikDinleyicisi(sayac));

function oduncVer(uyeId, kitapId, tarih) {
  db.exec("BEGIN");
  try {
    const oduncId = Number(db.prepare(
      "INSERT INTO odunc (kitap_id, uye_id, alis_tarihi, iade_tarihi) VALUES (?,?,?,NULL)")
      .run(kitapId, uyeId, tarih).lastInsertRowid);
    const olay = { tur: "odunc_verildi", oduncId, uyeId, kitapId, tarih };
    db.prepare("INSERT INTO giden_kutusu (govde) VALUES (?)").run(JSON.stringify(olay));
    db.prepare("UPDATE kitap SET stok = stok - 1 WHERE kitap_id = ?").run(kitapId);
    db.exec("COMMIT");
    return `verildi (odunc ${oduncId})`;
  } catch (e) {
    db.exec("ROLLBACK");
    return `geri alindi (${e.message.split("\n")[0]})`;
  }
}

function gidenKutusunuBosalt() {
  const bekleyen = db.prepare(
    "SELECT ileti_id, govde FROM giden_kutusu WHERE durum = 'bekliyor' ORDER BY ileti_id").all();
  for (const i of bekleyen) {
    yayinlayici.yayinla(JSON.parse(i.govde));
    db.prepare("UPDATE giden_kutusu SET durum = 'gonderildi' WHERE ileti_id = ?").run(i.ileti_id);
  }
  return bekleyen.length;
}

const ISTEKLER = [[1, 1], [1, 2], [2, 3], [2, 4]];
for (const [uyeId, kitapId] of ISTEKLER) {
  console.log(`uye ${uyeId} kitap ${kitapId}: ${oduncVer(uyeId, kitapId, "2025-06-20")}`);
}
console.log(`giden kutusunda bekleyen = ${db.prepare(
  "SELECT count(*) AS n FROM giden_kutusu WHERE durum = 'bekliyor'").get().n}`);
console.log(`gonderilen ileti = ${gidenKutusunuBosalt()}`);

const yanlis = kutu.filter((b) => db.prepare(
  "SELECT 1 FROM odunc WHERE odunc_id = ? AND kitap_id = ?").get(b.oduncId, b.kitapId) === undefined);
console.log(`odunc satiri = ${db.prepare("SELECT count(*) AS n FROM odunc").get().n}`);
console.log(`gonderilen bildirim = ${kutu.length}, yanlis bildirim = ${yanlis.length}`);
console.log(`istatistik = ${JSON.stringify(sayac)}`);
sh kur.sh
node giden-kutusu.mjs
uye 1 kitap 1: verildi (odunc 1)
uye 1 kitap 2: geri alindi (CHECK constraint failed: stok >= 0)
uye 2 kitap 3: verildi (odunc 2)
uye 2 kitap 4: geri alindi (CHECK constraint failed: stok >= 0)
giden kutusunda bekleyen = 2
gonderilen ileti = 2
odunc satiri = 2
gonderilen bildirim = 2, yanlis bildirim = 0
istatistik = {"2025-06-20":2}

İstekler aynı, sonuçlar aynı; yanlış bildirim sayısı ikiden sıfıra indi. Geri alınan iki işlemin giden kutusu satırı da geri alındığı için ortada gönderilecek ileti kalmadı. İstatistik de artık iki diyor.

Kazanılan şey, olayın kesinleşmiş bir olguyu anlatmasıdır. İşlem içinde yayımlanan olay bir olgu değil, bir tahmindir; giden kutusundaki olay ise veritabanına yazılmış bir gerçeğin kaydıdır.

Yan Etki Ana İşlemi Belirlememeli

Yayının yeri ikinci bir sorunu daha çözer. Dinleyici doğrudan çağrılıyorsa, dinleyicinin hatası ödünç verme işlemini de geçersiz kılar. Aşağıdaki betik bildirim servisinin bir kitap için yanıt vermediği durumu iki düzende de dener.

// dinleyici-hatasi.mjs — bildirim gonderimi hata verirse odunc kaydinin akibeti ne olur
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";
import { OlayYayinlayici } from "./alan/olay-yayinlayici.mjs";

function kur(dosya) {
  rmSync(dosya, { force: true });
  const db = new DatabaseSync(dosya);
  db.exec(`CREATE TABLE odunc (odunc_id INTEGER PRIMARY KEY, kitap_id INTEGER NOT NULL,
                               uye_id INTEGER NOT NULL, alis_tarihi TEXT NOT NULL, iade_tarihi TEXT);
           CREATE TABLE giden_kutusu (ileti_id INTEGER PRIMARY KEY, govde TEXT NOT NULL,
                                      durum TEXT NOT NULL DEFAULT 'bekliyor');`);
  return db;
}

// Bildirim servisi kitap 3 icin bir kez hata veriyor.
const arizaliDinleyici = (kutu, arizali) => (olay) => {
  if (arizali.has(olay.kitapId)) throw new Error(`bildirim servisi yanit vermedi (kitap ${olay.kitapId})`);
  kutu.push(olay.oduncId);
};

const ISTEKLER = [[1, 1], [2, 3], [1, 5]];

function dogrudanYayin() {
  const db = kur("dogrudan.db");
  const kutu = [], arizali = new Set([3]);
  const y = new OlayYayinlayici().abone("odunc_verildi", arizaliDinleyici(kutu, arizali));
  for (const [uyeId, kitapId] of ISTEKLER) {
    db.exec("BEGIN");
    try {
      const oduncId = Number(db.prepare(
        "INSERT INTO odunc (kitap_id, uye_id, alis_tarihi, iade_tarihi) VALUES (?,?,'2025-06-20',NULL)")
        .run(kitapId, uyeId).lastInsertRowid);
      y.yayinla({ tur: "odunc_verildi", oduncId, uyeId, kitapId });
      db.exec("COMMIT");
    } catch { db.exec("ROLLBACK"); }
  }
  return { odunc: db.prepare("SELECT count(*) AS n FROM odunc").get().n, bildirim: kutu.length };
}

function gidenKutusuyla() {
  const db = kur("kutulu.db");
  const kutu = [], arizali = new Set([3]);
  const y = new OlayYayinlayici().abone("odunc_verildi", arizaliDinleyici(kutu, arizali));
  for (const [uyeId, kitapId] of ISTEKLER) {
    db.exec("BEGIN");
    const oduncId = Number(db.prepare(
      "INSERT INTO odunc (kitap_id, uye_id, alis_tarihi, iade_tarihi) VALUES (?,?,'2025-06-20',NULL)")
      .run(kitapId, uyeId).lastInsertRowid);
    db.prepare("INSERT INTO giden_kutusu (govde) VALUES (?)")
      .run(JSON.stringify({ tur: "odunc_verildi", oduncId, uyeId, kitapId }));
    db.exec("COMMIT");
  }
  const bosalt = () => {
    for (const i of db.prepare(
      "SELECT ileti_id, govde FROM giden_kutusu WHERE durum = 'bekliyor' ORDER BY ileti_id").all()) {
      try {
        y.yayinla(JSON.parse(i.govde));
        db.prepare("UPDATE giden_kutusu SET durum = 'gonderildi' WHERE ileti_id = ?").run(i.ileti_id);
      } catch { /* ileti bekliyor durumunda kalir, sonraki turda denenir */ }
    }
    return db.prepare("SELECT count(*) AS n FROM giden_kutusu WHERE durum = 'bekliyor'").get().n;
  };
  const ilkTur = bosalt();
  arizali.clear();                       // bildirim servisi ayaga kalkti
  const ikinciTur = bosalt();
  return { odunc: db.prepare("SELECT count(*) AS n FROM odunc").get().n,
           bildirim: kutu.length, ilkTur, ikinciTur };
}

const a = dogrudanYayin();
console.log(`dogrudan yayin : odunc satiri=${a.odunc}  bildirim=${a.bildirim}`);
const b = gidenKutusuyla();
console.log(`giden kutusu   : odunc satiri=${b.odunc}  bildirim=${b.bildirim}  ` +
            `bekleyen ileti: ilk tur=${b.ilkTur} ikinci tur=${b.ikinciTur}`);
node dinleyici-hatasi.mjs
dogrudan yayin : odunc satiri=2  bildirim=2
giden kutusu   : odunc satiri=3  bildirim=3  bekleyen ileti: ilk tur=1 ikinci tur=0

Doğrudan yayında üç isteğin ikisi kaydedildi. Üçüncü kitabın ödüncü, kitapla ya da üyeyle ilgili hiçbir sorun olmamasına rağmen kaybedildi; kaydın geri alınmasına bildirim servisinin erişilemez olması karar verdi. Giden kutusunda üç kayıt da yazıldı, iletinin biri ilk turda gönderilemeyip beklemede kaldı, servis yeniden erişilebilir olunca ikinci turda gönderildi.

Ayrım, kalıcılık ile teslimin farklı güvenceler taşımasıdır. Ödünç kaydının yazılması işlemin garantisidir; bildirimin gönderilmesi ise sonunda olacak bir iştir. Etkisiz İşlemler dersindeki koşul burada da geçerlidir: bekleyen ileti yeniden denendiği için dinleyici aynı olayı iki kez alabilir ve bunun ikinci kez etki üretmemesi gerekir.

Olayın Sınırları

Olay yayımı bedelsiz değildir; iki tuzağı vardır.

Birincisi gizli denetim akışıdır. Ödünç verme işlevini okuyan biri yayın satırını görür ama ne olacağını göremez; abonelikler başka bir dosyadadır. Bir kural gerçekten iş akışının zorunlu parçasıysa — örneğin kitabın durumunun güncellenmesi — onu olaya bağlamak akışı görünmez kılar. Ölçüt şudur: işlemin doğruluğu için gerekli olan iş dinleyiciye verilmez. Dinleyiciye verilen iş, yapılmasa da ödünç kaydının geçerli kaldığı iştir.

İkincisi sıra ve zamanlamadır. Giden kutusu düzeninde dinleyici işlemden sonra çalışır; arada geçen sürede veri değişmiş olabilir. Dinleyici, olayın taşıdığı bilgiyle yetinmeli ya da veriyi yeniden okuyup güncel duruma göre davranmalıdır. Olayın içine kararı verecek bilginin konması bu yüzden yararlıdır: oduncId ve kitapId olayın içinde olduğu için dinleyici sorgu yapmadan bildirim metnini kurabildi.

Özet

  • Alan olayı geçmiş zamanda bir olguyu anlatır; yayımlayan taraf kimin abone olduğunu bilmez. Üçüncü dinleyici eklendiğinde yayımlayan satır değişmedi.
  • Olay işlem içinde yayımlandığında geri alınan iki işlem için de bildirim gitti: iki kayıt karşılığında dört bildirim, ikisi yanlış; istatistik gerçeğin iki katını saydı.
  • Geri alınan işlemin kimliği serbest kaldığı için yanlış bildirimlerden biri var olan bir kimliği taşıyordu; denetimin kimlik ile kitabı birlikte kontrol etmesi gerekti.
  • Olay aynı işlemde giden kutusu tablosuna yazılıp kesinleşmeden sonra gönderildiğinde yanlış bildirim sayısı sıfıra indi.
  • Dinleyici doğrudan çağrıldığında bildirim servisinin hatası geçerli bir ödünç kaydını geri aldırdı; giden kutusunda kayıt yazıldı, ileti beklemede kaldı ve sonraki turda gönderildi.

Sonraki Adım

Bu konudaki dört ders alan katmanını adım adım yalnızlaştırdı: bağımlılık yönü kuralıyla altyapıdan, veri aktarım nesneleriyle dış sözleşmeden, doğrulama ayrımıyla girdi biçiminden, olaylarla yan etkilerden ayrıldı. Geriye son bir soru kalıyor. Alan kuralları hâlâ bir depo alıyor, bir işlem sınırının içinde çalışıyor ve sınanmak için bir veritabanı dosyası bekliyor. Kuralların kendisi bunların hiçbirine ihtiyaç duyuyor mu? Kursun son dersi iş kurallarını saf fonksiyonlarla yazar, veritabanı olmadan sınar, aynı kuralın veritabanına bağlı sürümüyle sınama süresini karşılaştırır ve alan modülünde kalıcılık izi kalmadığını bir taramayla 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