Node.js-taustakehityksen bootcamp · Oppitunti

Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki

Korvaa muuttuva tila muuttumattomalla tapahtumavirralla ja rakenna tila uudelleen toistamalla tapahtumat.

Oppitunti 1/413 vaihetta

Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki on ilmainen Node.js-taustakehityksen bootcamp-oppitunti CoddyKitissä. Tämä on oppitunti 1/4. Voit lukea koko oppitunnin alta ilmaiseksi ja harjoitella sen jälkeen käytännössä selaimessa sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla. Oppitunti kuuluu Node.js-taustakehityksen bootcamp-oppimispolkuun, ja edistymisesi synkronoituu verkon ja CoddyKit-sovelluksen välillä. Node.js-taustakehityksen bootcamp-kurssilla on yhteensä 4 oppituntia.

Muuttuvan tilan ongelma

Perinteisessä CRUD-taustajärjestelmässä tallennetaan entiteetin nykyinen tila ja korvataan se jokaisen muutoksen yhteydessä. Käyttäjän saldo on yksi rivi; kun rahaa siirretään, numero päivitetään paikallaan komennolla UPDATE.

Tämä on kätevää, mutta tietoa häviää. Kun vanha arvo korvataan, historia on menetetty. Et voi enää vastata seuraaviin kysymyksiin:

  • Miten saldo päätyi tähän arvoon?
  • Milloin ja miksi kukin muutos tapahtui?
  • Miltä tila näytti viime tiistaina?

Event Sourcing kääntää tämän asetelman päinvastaiseksi: viimeisimmän tilan sijaan tallennetaan sen tuottanut tosiasioiden sarja.

// Classic mutable CRUD: history is destroyed on every write
let account = { id: 'acc-1', balance: 100 };

function deposit(amount) {
  account.balance += amount; // old value is gone forever
}

function withdraw(amount) {
  account.balance -= amount; // no record of why or when
}

deposit(50);
withdraw(30);
console.log(account); // { id: 'acc-1', balance: 120 }
// We know the result, but not the journey.

Tapahtumat totuuden lähteenä

Event Sourcingissa tapahtuma on muuttumaton tietue jostakin jo tapahtuneesta. Tapahtumat nimetään imperfektissä: MoneyDeposited, MoneyWithdrawn, AccountOpened.

Tapahtumalokista tulee totuuden lähde. Nykyistä tilaa ei enää tallenneta suoraan — se on johdettu arvo, jonka lasket tapahtumat toistamalla.

  • Tapahtumat ovat tosiasioita: niitä ei voi muuttaa eikä poistaa.
  • Tila on tulkinta: tosiasioista tiettynä ajanhetkenä muodostettu projektio.

Kukin tapahtuma tallentaa tarkoituksen ja asiayhteyden, ei vain lopputuloksena syntynyttä lukua.

// An event is an immutable, past-tense fact
const events = [
  { type: 'AccountOpened',  data: { accountId: 'acc-1', owner: 'Ada' }, at: '2026-01-01T09:00:00Z' },
  { type: 'MoneyDeposited', data: { accountId: 'acc-1', amount: 50 },  at: '2026-01-02T10:15:00Z' },
  { type: 'MoneyWithdrawn', data: { accountId: 'acc-1', amount: 30 },  at: '2026-01-03T11:30:00Z' }
];

// Each entry is a fact that already happened. Nothing is overwritten.
console.log(`Stored ${events.length} immutable facts.`);

Vain lisäämiseen tarkoitettu loki

Tapahtumat sisältävä tallennuspaikka on vain lisäämiseen tarkoitettu loki. Siinä on käytännössä vain kaksi toimintoa:

  • Lisää uusi tapahtuma loppuun.
  • Lue tapahtumat järjestyksessä, yleensä tietystä virrasta (esimerkiksi yhdeltä tililtä).

UPDATE- eikä DELETE-toimintoa ole. Tämä yksi rajoitus antaa automaattisesti täydellisen, järjestetyn ja muutosten havaitsemisen mahdollistavan tarkastusketjun.

Koska loki on järjestetty, kunkin tapahtuman sijainnilla (tai versiolla) on merkitystä: samassa järjestyksessä tehdyn toiston tuloksena on aina sama tila.

// A minimal in-memory append-only log
class EventLog {
  constructor() { this.events = []; }

  append(event) {
    // Only ever push to the end — never mutate or remove
    const stored = { ...event, position: this.events.length + 1 };
    this.events.push(Object.freeze(stored));
    return stored;
  }

  read() {
    return [...this.events]; // ordered copy
  }
}

const log = new EventLog();
log.append({ type: 'AccountOpened', data: { accountId: 'acc-1' } });
log.append({ type: 'MoneyDeposited', data: { accountId: 'acc-1', amount: 50 } });
console.log(log.read());

Tilan palauttaminen toistamalla tapahtumat

Jos tila johdetaan tapahtumista, miten saamme sen takaisin? Teemme toiston: aloitamme tyhjästä tilasta ja sovellamme jokaista tapahtumaa järjestyksessä. Tätä funktiota kutsutaan usein nimellä apply, evolve tai reduceriksi.

Huomaa muoto: (state, event) => newState. Se on täsmälleen reduceri — sama idea kuin Array.prototype.reduce-metodissa.

  • Reducer on puhdas: samat tapahtumat tuottavat saman tilan.
  • Se käsittelee jokaisen tapahtuman tyypin ja palauttaa uuden tilaolion.
function applyEvent(state, event) {
  switch (event.type) {
    case 'AccountOpened':
      return { id: event.data.accountId, owner: event.data.owner, balance: 0 };
    case 'MoneyDeposited':
      return { ...state, balance: state.balance + event.data.amount };
    case 'MoneyWithdrawn':
      return { ...state, balance: state.balance - event.data.amount };
    default:
      return state; // ignore unknown events
  }
}

const events = [
  { type: 'AccountOpened',  data: { accountId: 'acc-1', owner: 'Ada' } },
  { type: 'MoneyDeposited', data: { amount: 50 } },
  { type: 'MoneyWithdrawn', data: { amount: 30 } }
];

const state = events.reduce(applyEvent, null);
console.log(state); // { id: 'acc-1', owner: 'Ada', balance: 20 }

Virrat ja aggregaatit

Harvoin toistat kaikkia järjestelmän tapahtumia. Tapahtumat ryhmitellään virroiksi, yksi kutakin entiteettiä kohden — esimerkiksi account-acc-1. Virrasta palautettua entiteettiä kutsutaan aggregaatiksi.

Aggregaatin lataaminen tapahtuu näin:

  • Lue vain kyseisen virran tapahtumat (suodata kentän streamId perusteella).
  • Toista ne reducerin kautta.
  • Palauta tuloksena syntyvä muistissa oleva tila.

Kun virrat pidetään pieninä, toisto pysyy nopeana ja johdonmukaisuuden rajat selkeinä.

function loadAggregate(allEvents, streamId, reducer) {
  return allEvents
    .filter(e => e.streamId === streamId)
    .sort((a, b) => a.position - b.position)
    .reduce(reducer, null);
}

const allEvents = [
  { streamId: 'account-acc-1', position: 1, type: 'AccountOpened',  data: { accountId: 'acc-1', owner: 'Ada' } },
  { streamId: 'account-acc-2', position: 1, type: 'AccountOpened',  data: { accountId: 'acc-2', owner: 'Lin' } },
  { streamId: 'account-acc-1', position: 2, type: 'MoneyDeposited', data: { amount: 75 } }
];

const reducer = (s, e) => {
  if (e.type === 'AccountOpened') return { id: e.data.accountId, balance: 0 };
  if (e.type === 'MoneyDeposited') return { ...s, balance: s.balance + e.data.amount };
  return s;
};

console.log(loadAggregate(allEvents, 'account-acc-1', reducer));

Komennot ja tapahtumat

Ratkaiseva ero on tämä: komento on pyyntö tehdä jotakin (se voidaan hylätä), kun taas tapahtuma on tietue siitä, että jokin tapahtui (sitä ei voi perua).

  • Withdraw on komento — käskymuotoinen, preesensissä ja mahdollisesti epäonnistuva.
  • MoneyWithdrawn on tapahtuma — imperfektissä ilmaistu, vahvistettu tosiasia.

Aggregaatin tehtävänä on ottaa nykyinen tila ja komento, soveltaa liiketoimintasääntöjä ja päättää, mitkä tapahtumat lisätään — tai hylätä komento kokonaan.

function decide(state, command) {
  switch (command.type) {
    case 'Withdraw':
      if (command.amount > state.balance) {
        throw new Error('Insufficient funds'); // command rejected
      }
      return [{ type: 'MoneyWithdrawn', data: { amount: command.amount } }];
    case 'Deposit':
      return [{ type: 'MoneyDeposited', data: { amount: command.amount } }];
    default:
      throw new Error('Unknown command: ' + command.type);
  }
}

const state = { balance: 40 };
console.log(decide(state, { type: 'Withdraw', amount: 30 }));
try { decide(state, { type: 'Withdraw', amount: 100 }); }
catch (e) { console.log('Rejected:', e.message); }

Päätä–kehitä-sykli

Kun yhdistät osat, saat tapahtumapohjaisen aggregaatin keskeisen kirjoituskulun:

  • Lataa: rakenna nykyinen tila uudelleen toistamalla virta.
  • Päätä: suorita komento kyseisessä tilassa uusien tapahtumien tuottamiseksi.
  • Lisää: kirjoita uudet tapahtumat lokiin.
  • Kehitä: sama reducer, joka latasi tilan, pitää sen myös ajan tasalla.

decide-funktio ei koskaan kirjoita, eikä evolve-reducer koskaan validoi. Tämä erottelu pitää toimialuelogiikan selkeänä ja helposti testattavana.

function evolve(state, event) {
  if (event.type === 'MoneyDeposited') return { ...state, balance: state.balance + event.data.amount };
  if (event.type === 'MoneyWithdrawn') return { ...state, balance: state.balance - event.data.amount };
  return state;
}
function decide(state, cmd) {
  if (cmd.type === 'Deposit') return [{ type: 'MoneyDeposited', data: { amount: cmd.amount } }];
  if (cmd.type === 'Withdraw' && cmd.amount <= state.balance)
    return [{ type: 'MoneyWithdrawn', data: { amount: cmd.amount } }];
  throw new Error('Invalid command');
}

let history = [{ type: 'MoneyDeposited', data: { amount: 100 } }];
let state = history.reduce(evolve, { balance: 0 });   // load
const newEvents = decide(state, { type: 'Withdraw', amount: 60 }); // decide
history = [...history, ...newEvents];                 // append
state = newEvents.reduce(evolve, state);              // evolve
console.log(state); // { balance: 40 }

Optimistinen samanaikaisuus odotetun version avulla

Kaksi pyyntöä saattaa yrittää muuttaa samaa aggregaattia samanaikaisesti. Koska lokiin voi vain lisätä tietoja, kirjoituksia suojataan odotetulla versiolla: sijainnilla, jossa kirjoittaja uskoo virran olevan.

Tapahtumia lisättäessä ilmoitat: ”Odotan tämän virran olevan versiossa N.” Jos toinen kirjoittaja on jo edennyt virrassa, lisäys epäonnistuu ja kutsuja yrittää uudelleen lataamalla tilan ensin.

  • Pyyntöjen ajaksi ei tarvitse pitää lukkoja.
  • Ristiriidat havaitaan, eikä niitä koskaan menetetä huomaamatta.
class VersionedStore {
  constructor() { this.streams = new Map(); }

  append(streamId, expectedVersion, newEvents) {
    const current = this.streams.get(streamId) || [];
    if (current.length !== expectedVersion) {
      throw new Error(
        `Concurrency conflict: expected v${expectedVersion}, got v${current.length}`
      );
    }
    this.streams.set(streamId, [...current, ...newEvents]);
    return current.length + newEvents.length;
  }
}

const store = new VersionedStore();
store.append('acc-1', 0, [{ type: 'AccountOpened' }]);   // ok -> v1
try {
  store.append('acc-1', 0, [{ type: 'MoneyDeposited' }]); // stale version
} catch (e) { console.log(e.message); }
store.append('acc-1', 1, [{ type: 'MoneyDeposited' }]);  // correct -> v2
console.log('Final version:', store.streams.get('acc-1').length);

Tilannevedokset: toisto ilman kaiken uudelleenlukemista

Tuhansien tapahtumien toistaminen jokaisen latauksen yhteydessä hidastuu. Tilannevedos on välimuistiin tallennettu kopio aggregaatin tilasta tietyssä tunnetussa versiossa. Lataamista varten aloitetaan tilannevedoksesta ja toistetaan vain sen jälkeen syntyneet tapahtumat.

Tärkeää: tilannevedokset ovat optimointi, eivät totuuden lähde. Voit poistaa kaikki tilannevedokset ja rakentaa silti täydellisen tilan uudelleen tapahtumista. Loki on edelleen auktoritatiivinen.

  • Tallenna tilannevedos ja sen edustama versio.
  • Latauksen yhteydessä muodosta tila tilannevedoksesta ja toista sitten loppuosa.
function loadWithSnapshot(snapshot, events, evolve) {
  // snapshot = { state, version } or null
  let state = snapshot ? snapshot.state : { balance: 0 };
  const fromVersion = snapshot ? snapshot.version : 0;
  return events
    .filter(e => e.position > fromVersion)
    .reduce(evolve, state);
}

const evolve = (s, e) =>
  e.type === 'MoneyDeposited' ? { balance: s.balance + e.data.amount } : s;

const events = [
  { position: 1, type: 'MoneyDeposited', data: { amount: 100 } },
  { position: 2, type: 'MoneyDeposited', data: { amount: 50 } },
  { position: 3, type: 'MoneyDeposited', data: { amount: 25 } }
];
const snapshot = { state: { balance: 150 }, version: 2 };
console.log(loadWithSnapshot(snapshot, events, evolve)); // { balance: 175 }

Skeeman kehitys ja upcasting

Tapahtumat säilyvät ikuisesti, joten niiden rakenne elää niitä kirjoittaneen koodin yli. Vanhaa tapahtumaa ei voi koskaan muokata paikallaan, mutta uuden koodin on silti ymmärrettävä se.

Vakiotyökalu tähän on upcasting: funktio, joka muuntaa vanhan tapahtumaversion nykyisen rakenteen mukaiseksi lukuhetkellä, ennen kuin tapahtuma päätyy reducerille.

  • Lisää jokaiseen tapahtumatyyppiin version-kenttä.
  • Anna uusille kentille järkevät oletusarvot ja tee uudelleennimeäminen upcasterilla.
  • Älä koskaan muuta tallennettuja tapahtumia — muunna kopio matkalla sisään.
// v1 had `amount` (cents implied); v2 adds explicit `currency`
function upcast(event) {
  if (event.type === 'MoneyDeposited' && (event.version || 1) === 1) {
    return {
      ...event,
      version: 2,
      data: { ...event.data, currency: 'USD' } // default for legacy events
    };
  }
  return event;
}

const legacy = { type: 'MoneyDeposited', data: { amount: 50 } };
console.log(upcast(legacy));
// { type: 'MoneyDeposited', data: { amount: 50, currency: 'USD' }, version: 2 }

Lokien tallentaminen Node.js:ssä

Tuotannossa vain lisäämiseen tarkoitettu loki perustuu kestävään tallennukseen: esimerkiksi erityiseen tallennusjärjestelmään kuten EventStoreDB tai lokina toimivaan relaatiotauluun. Tyypillinen Postgres-rakenne:

  • Yksi events-taulu, jossa on (stream_id, version, type, data jsonb, recorded_at).
  • Yksikäsitteisyysrajoite kentille (stream_id, version) — se pakottaa optimistisen samanaikaisuuden tietokantatasolla.
  • Vain lisäyksiä; sovelluskoodi ei koskaan suorita taululle komentoja UPDATE tai DELETE.

Sama lataus–päätös–lisäys-sykli toimii tämän päällä, mutta tallennusrajapinnan takana on SQL.

// Sketch of an append against a Postgres-backed log (pg client `db`).
// The UNIQUE (stream_id, version) constraint rejects concurrent duplicates.
async function appendEvents(db, streamId, expectedVersion, events) {
  const client = await db.connect();
  try {
    await client.query('BEGIN');
    let version = expectedVersion;
    for (const e of events) {
      version += 1;
      await client.query(
        `INSERT INTO events (stream_id, version, type, data)
         VALUES ($1, $2, $3, $4)`,
        [streamId, version, e.type, JSON.stringify(e.data)]
      );
    }
    await client.query('COMMIT');
    return version;
  } catch (err) {
    await client.query('ROLLBACK'); // unique violation => concurrency conflict
    throw err;
  } finally {
    client.release();
  }
}

Pikatarkistus

Tarkastele tapahtumapohjaista tiliaggregaattia.

Kertaus

Korvasit muuttuvan tilan muuttumattomalla tapahtumavirralla:

  • Tapahtumat ovat imperfektissä ilmaistuja, muuttumattomia tosiasioita; niitä sisältävä vain lisäämiseen tarkoitettu loki on totuuden lähde.
  • Tila johdetaan, sitä ei tallenneta — rakennat sen uudelleen toistamalla tapahtumat puhtaan reducerin kautta: (state, event) => newState.
  • Tapahtumat ryhmitellään virroiksi, yksi kutakin aggregaattia kohden.
  • Kirjoitussykli on lataa → päätä → lisää → kehitä; komennot voidaan hylätä, tapahtumia ei koskaan.
  • Odotettu versio mahdollistaa optimistisen samanaikaisuuden; UNIQUE(stream_id, version)-rajoite pakottaa sen tietokannassa.
  • Tilannevedokset nopeuttavat toistoa, mutta ovat valinnaisia; upcasting pitää vanhat tapahtumat luettavina skeemojen kehittyessä.

Seuraavaksi rakennat lukumalleja (projektioita) ja täydennät CQRS-kokonaisuuden.

Aloita maksutta

Opi JavaScript tekoälytuutorin avulla — ilmaiseksi

Kirjoita ja suorita oikeaa koodia selaimessa, saa välitöntä apua tekoälytuutorilta ympäri vuorokauden ja jatka siitä, mihin jäit, verkossa tai sovelluksessa.

Kurssit
22
Oppitunnit
92

Usein kysytyt kysymykset

Onko oppitunti ”Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki” ilmainen?

Kyllä – oppitunnin ”Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki” koko tekstin voi lukea täällä verkossa ilmaiseksi. Jos haluat harjoitella interaktiivisesti sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla sekä avata koko Node.js-taustakehityksen bootcamp-kurssin, päivitä CoddyKit PROhon. Node.js-taustakehityksen bootcamp-kurssilla on yhteensä 4 oppituntia.

Mitä opin oppitunnilla ”Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki”?

Korvaa muuttuva tila muuttumattomalla tapahtumavirralla ja rakenna tila uudelleen toistamalla tapahtumat. Harjoittelet Node.js-taustakehityksen bootcamp-aihetta koodilla, jonka suoritat suoraan selaimessa. Ympäri vuorokauden käytettävissä oleva tekoälytuutori vastaa kysymyksiisi oppitunnin aikana.

Tarvitsenko kokemusta aloittaakseni Node.js-taustakehityksen bootcamp-opiskelun?

Aiempi kokemus ei ole tarpeen. CoddyKitin Node.js-taustakehityksen bootcamp-oppimispolku sopii vasta-alkajista edistyneisiin, joten voit aloittaa tästä tai alusta ja edetä omaan tahtiisi. Tämä on oppitunti 1/4.

Kuinka kauan ”Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki”-oppitunnin suorittaminen kestää?

Useimmat CoddyKitin oppitunnit kestävät noin 5–10 minuuttia. Jokainen oppitunti on lyhyt ja interaktiivinen, joten edistyt tasaisesti ja voit jatkaa siitä, mihin jäit – sekä verkossa että sovelluksessa.

Voinko kirjoittaa ja suorittaa koodia tällä Node.js-taustakehityksen bootcamp-oppitunnilla?

Kyllä. Jokainen Node.js-taustakehityksen bootcamp-oppitunti sisältää sisäänrakennetun koodieditorin, joten voit kirjoittaa ja suorittaa oikeaa koodia suoraan selaimessa ja saada välitöntä palautetta tekoälyltä – paikallista asennusta ei tarvita.

Kaikki tämän kurssin oppitunnit

  1. Tapahtumat totuuden lähteenä ja vain lisäyksiä sisältävä loki
  2. Aggregaatit, komennot ja toimialuetapahtumien mallintaminen
  3. Lukumallien ja projektioiden rakentaminen
  4. Snapshotit, versiointi ja tapahtumaskeeman kehitys
← Takaisin: Node.js-taustakehityksen bootcamp