Bootcamp i backendudvikling med Node.js · Lektion

Opbygning af read models og projektioner

Udled optimerede projektioner til forespørgselssiden fra eventstreamen, og hold dem eventually consistent.

Lektion 3 af 413 trin

Opbygning af read models og projektioner er en gratis Bootcamp i backendudvikling med Node.js-lektion på CoddyKit. Dette er lektion 3 af 4. Du kan læse hele lektionen gratis nedenfor — og derefter øve dig praktisk i browseren med en indbygget kodeeditor og en AI-vejleder, der er tilgængelig døgnet rundt. Den er en del af læringsforløbet i Bootcamp i backendudvikling med Node.js, og dine fremskridt synkroniseres på tværs af nettet og CoddyKit-appen. Bootcamp i backendudvikling med Node.js-kurset indeholder 4 lektioner i alt.

Hvorfor læsemodeller findes

I et eventbaseret system gemmer skrive-siden fakta som en strøm af events, der kun kan tilføjes. Strømmen er god til at registrere historik, men dårlig til forespørgsler som "vis mig alle åbne ordrer sorteret efter totalbeløb".

Læse-siden løser dette. En læsemodel (også kaldet en projektion) er en denormaliseret, forespørgselsoptimeret visning, der udelukkende er afledt af events. Dette er Q'et i CQRS: kommandoer ændrer eventstrømmen, og forespørgsler rammer læsemodeller.

  • Én eventstrøm kan levere data til mange læsemodeller, hver formet til en bestemt forespørgsel.
  • Læsemodeller kan kasseres — du kan slette og genopbygge dem ud fra events når som helst.
  • De er eventuelt konsistente med skrive-siden, ikke transaktionelt konsistente.

En projektion er en venstrefoldning

I sin kerne er en projektion blot en reduktion over eventstrømmen: Du starter med en begyndelsestilstand og anvender hvert event i rækkefølge for at producere den næste tilstand.

Konceptuelt: readModel = events.reduce(apply, initialState). Funktionen apply er et rent skift over eventtyper. Alt, hvad du kan folde, kan du projektere.

// Pure projection: fold the event stream into a read model
const events = [
  { type: 'OrderPlaced', orderId: 'o1', total: 50 },
  { type: 'OrderPlaced', orderId: 'o2', total: 20 },
  { type: 'OrderPaid', orderId: 'o1' },
  { type: 'OrderCancelled', orderId: 'o2' },
];

function apply(state, event) {
  const next = { ...state };
  switch (event.type) {
    case 'OrderPlaced':
      next[event.orderId] = { status: 'placed', total: event.total };
      break;
    case 'OrderPaid':
      if (next[event.orderId]) next[event.orderId].status = 'paid';
      break;
    case 'OrderCancelled':
      delete next[event.orderId];
      break;
  }
  return next;
}

const readModel = events.reduce(apply, {});
console.log(readModel);
// { o1: { status: 'paid', total: 50 } }

En projektorklasse

En rigtig projektor er mere end en enkelt foldning. Den ejer tre ting:

  • handlere — en afbildning fra eventtype til en opdateringsfunktion.
  • tilstand — den læsemodel, den vedligeholder (i hukommelsen, i en tabel eller i en cache).
  • position — hvor langt den har forbrugt strømmen (kontrolpunktet).

Events, som den ikke interesserer sig for, ignoreres ganske enkelt. Det holder hver projektor fokuseret præcis på de data, dens forespørgsler har brug for.

class OrderSummaryProjection {
  constructor() {
    this.state = new Map();
    this.handlers = {
      OrderPlaced: (e) => this.state.set(e.orderId, {
        status: 'placed', total: e.total, customer: e.customer,
      }),
      OrderShipped: (e) => {
        const r = this.state.get(e.orderId);
        if (r) r.status = 'shipped';
      },
    };
  }

  when(event) {
    const handler = this.handlers[event.type];
    if (handler) handler(event); // ignore unknown event types
  }

  query(orderId) {
    return this.state.get(orderId) ?? null;
  }
}

const proj = new OrderSummaryProjection();
proj.when({ type: 'OrderPlaced', orderId: 'o1', total: 99, customer: 'Ada' });
proj.when({ type: 'OrderShipped', orderId: 'o1' });
proj.when({ type: 'Irrelevant', foo: 1 });
console.log(proj.query('o1'));
// { status: 'shipped', total: 99, customer: 'Ada' }

Persistering i et læselager

I produktion ligger læsemodellen i en database, der er optimeret til forespørgsler — Postgres, MongoDB, Redis, Elasticsearch — hvad der end passer til forespørgslens form. Hver eventhandler udfører en idempotent upsert mod lageret.

Bemærk, at SQL'en nedenfor er et fragment, som forudsætter, at tabellen order_summary allerede findes, så den er illustrativ og kan ikke køres alene.

// Postgres read-model upsert inside a projector (using node-postgres)
async function onOrderPlaced(pool, event) {
  await pool.query(
    `INSERT INTO order_summary (order_id, status, total, customer)
     VALUES ($1, 'placed', $2, $3)
     ON CONFLICT (order_id) DO UPDATE
       SET status = EXCLUDED.status,
           total  = EXCLUDED.total,
           customer = EXCLUDED.customer`,
    [event.orderId, event.total, event.customer]
  );
}

async function onOrderShipped(pool, event) {
  await pool.query(
    `UPDATE order_summary SET status = 'shipped' WHERE order_id = $1`,
    [event.orderId]
  );
}

Abonnement på eventstrømmen

En projektor afstemmer ikke blindt for evigt — den abonnerer på eventlageret og modtager events i rækkefølge, efterhånden som de tilføjes. Den kontrakt, alle eventlagre giver dig, er:

  • Events ankommer i global commit-rækkefølge (eller rækkefølge pr. strøm).
  • Hvert event har en monoton position (global forskydning eller sekvensnummer).
  • Abonnementet kan starte fra en given position — hvilket er afgørende for genoptagelse.

Du driver projektoren ved at give hvert modtaget event til dens when-metode og derefter flytte kontrolpunktet frem.

// A minimal in-memory event bus a projector can subscribe to
class EventStore {
  constructor() { this.log = []; this.subs = []; }
  append(event) {
    const stored = { ...event, position: this.log.length + 1 };
    this.log.push(stored);
    for (const cb of this.subs) cb(stored);
  }
  subscribeFrom(position, cb) {
    for (const e of this.log) if (e.position > position) cb(e); // catch up
    this.subs.push(cb); // then live
  }
}

const store = new EventStore();
store.append({ type: 'OrderPlaced', orderId: 'o1', total: 10 });
let count = 0;
store.subscribeFrom(0, (e) => { count++; });
store.append({ type: 'OrderPaid', orderId: 'o1' });
console.log('events seen:', count); // 2 (1 catch-up + 1 live)

Kontrolpunkter: Husk din position

Hvis din tjeneste genstarter, må du hverken behandle hele strømmen igen fra begyndelsen (langsomt) eller springe events over (datatab). Løsningen er et kontrolpunkt: Gem positionen for det senest behandlede event, der blev behandlet korrekt.

Ved opstart læser projektoren sit kontrolpunkt og genoptager abonnementet fra den position. Den vigtigste regel er:

  • Behandl eventet, og opdatér læsemodellen.
  • Derefter flytter du kontrolpunktet frem og gemmer det.
  • Skriv helst ændringen af læsemodellen og kontrolpunktet i den samme transaktion.
// Resuming from a stored checkpoint
async function startProjector(store, checkpointRepo, projection) {
  const last = await checkpointRepo.load(projection.name); // e.g. 42

  store.subscribeFrom(last, async (event) => {
    await projection.when(event);              // 1. update read model
    await checkpointRepo.save(
      projection.name, event.position          // 2. advance checkpoint
    );
  });
}

// checkpointRepo example backed by a 'projection_checkpoints' table:
// load:  SELECT position FROM projection_checkpoints WHERE name = $1
// save:  INSERT ... ON CONFLICT (name) DO UPDATE SET position = $2

Idempotens: Håndtering af genlevering

Fordi kontrolpunktet gemmes efter behandlingen, betyder et nedbrud mellem "opdatér læsemodellen" og "gem kontrolpunktet", at det samme event bliver leveret igen ved genstart. Det er levering mindst én gang, og det er normalt.

Derfor skal enhver handler være idempotent — at anvende det samme event to gange giver den samme tilstand. To pålidelige teknikker:

  • Brug upserts i stedet for blinde indsættelser (ingen duplikatrækker).
  • For ikke-idempotente handlinger (tællere, summer) skal du registrere den senest anvendte position pr. række og springe events over, der ligger på eller under den.
// Guarding a running total against redelivery using a per-row version
function applyRevenue(state, event) {
  const row = state[event.customer] ?? { revenue: 0, lastPos: 0 };
  if (event.position <= row.lastPos) {
    return state; // already applied — skip duplicate
  }
  row.revenue += event.amount;
  row.lastPos = event.position;
  return { ...state, [event.customer]: row };
}

let s = {};
const paid = { type: 'OrderPaid', customer: 'Ada', amount: 30, position: 5 };
s = applyRevenue(s, paid);
s = applyRevenue(s, paid); // redelivered, ignored
console.log(s.Ada.revenue); // 30, not 60

Eventuel konsistens og afstanden mellem læsning og egne skrivninger

Projektioner opdateres asynkront, så læsemodellen måske endnu ikke afspejler en kommando lige efter, at den lykkes. Denne afstand mellem læsning og egne skrivninger overrasker brugerne: De afgiver en ordre, men listen ser stadig tom ud i nogle få millisekunder.

Strategier til at håndtere det:

  • Returnér den nye tilstand fra kommandoen, så brugergrænsefladen kan vise den optimistisk uden at forespørge igen.
  • Vent på projektionen: Kommandoen returnerer eventets position; klienten forespørger gentagne gange læsemodellen, indtil dens kontrolpunkt når denne position.
  • Design brugeroplevelsen, så den tåler kortvarige forældede data (indlæsningsindikatorer, "behandles"-tilstande).

Forsøg aldrig at gøre projektioner synkrone "for en sikkerheds skyld" — så mister du den skalerbarhed og afkobling, der gør CQRS værdifuldt.

Flere projektioner fra én strøm

CQRS' virkelige styrke er, at én eventstrøm leverer data til mange uafhængige læsemodeller, som hver især er optimeret til en anden forespørgsel. Det samme OrderPlaced-event kan for eksempel opdatere:

  • en OrderList-projektion til kundens ordrehistorik,
  • en DailyRevenue-projektion til analyse,
  • en SearchIndex-projektion, der leverer data til Elasticsearch.

Hver kører med sine egne handlere og sit eget kontrolpunkt, så de kan tilføjes, genopbygges eller skaleres uafhængigt.

// One dispatcher fans an event out to many projections
const projections = [];

function register(name, handlers) {
  projections.push({ name, handlers, state: {} });
}

function dispatch(event) {
  for (const p of projections) {
    const h = p.handlers[event.type];
    if (h) h(p.state, event);
  }
}

register('orderCount', {
  OrderPlaced: (s) => { s.count = (s.count ?? 0) + 1; },
});
register('revenue', {
  OrderPlaced: (s) => { s.sum = (s.sum ?? 0) + 1; },
  OrderPaid:   (s, e) => { s.paid = (s.paid ?? 0) + e.total; },
});

dispatch({ type: 'OrderPlaced', orderId: 'o1', total: 40 });
dispatch({ type: 'OrderPaid', orderId: 'o1', total: 40 });
console.log(projections.map((p) => [p.name, p.state]));

Genopbygning af en projektion

Fordi læsemodeller er afledte data, kan du kassere dem og beregne dem igen. Du genopbygger en projektion, når du:

  • ændrer dens skema (tilføjer en ny kolonne eller et nyt denormaliseret felt),
  • retter en fejl i en handler,
  • tilføjer en helt ny læsemodel, der har brug for historiske data.

Opskriften på genopbygning:

  • Nulstil læselageret (tøm tabellen), og nulstil kontrolpunktet til 0.
  • Afspil hele strømmen fra begyndelsen gennem handlerne.
  • For ingen nedetid skal du bygge i en ny tabel og atomisk bytte over (blue-green), når den er indhentet live-data.
// Rebuild by replaying the whole log into a fresh projection
function rebuild(eventLog, projection) {
  projection.state = {};          // reset read model
  projection.checkpoint = 0;      // reset position
  for (const event of eventLog) {
    projection.when(event);
    projection.checkpoint = event.position;
  }
  return projection;
}

const log = [
  { type: 'OrderPlaced', orderId: 'o1', total: 10, position: 1 },
  { type: 'OrderPlaced', orderId: 'o2', total: 25, position: 2 },
];
const proj = {
  state: {}, checkpoint: 0,
  when(e) { if (e.type === 'OrderPlaced') this.state[e.orderId] = e.total; },
};
rebuild(log, proj);
console.log(proj.state, 'at', proj.checkpoint);
// { o1: 10, o2: 25 } at 2

Rækkefølge, fejl og skadelige events

En projektor forbruger events sekventielt for at bevare rækkefølgen — et afsendt event må aldrig anvendes før det event, der placerede ordren. Denne begrænsning former din fejlhåndtering:

  • Ved en midlertidig fejl (kortvarigt databaseudfald) prøver du igen med stigende ventetid; du må ikke flytte kontrolpunktet frem, så eventet anvendes igen.
  • Ved et skadeligt event (et, der altid kaster en fejl) må du ikke blokere hele projektionen for evigt. Flyt det til en dead-letter-log, og send en alarm, så resten af strømmen kan fortsætte.
  • Parallelisér på tværs af partitioner (f.eks. efter aggregat-id), når du har brug for større gennemløb, men bevar rækkefølgen inden for hver partition.

Hold handlerne små og deterministiske, så fejl er sjældne og kan genskabes.

Hurtigt tjek: Beslutningen om kontrolpunktet

En projektor opdaterer en Postgres-læsemodel og gemmer sit kontrolpunkt i en separat tabel. Tjenesten kan gå ned når som helst. Hvilken tilgang garanterer bedst korrektheden?

Opsummering: Læsemodeller og projektioner

Du ved nu, hvordan forespørgselssiden i et eventbaseret system bygges:

  • En projektion er en venstrefoldning over eventstrømmen til en denormaliseret, forespørgselsoptimeret læsemodel.
  • En projektor ejer håndteringer, persisteret tilstand og et kontrolpunkt; den abonnerer på lageret og anvender events i rækkefølge.
  • Gem kontrolpunktet efter behandlingen, og gør håndteringer idempotente, så de kan tåle levering mindst én gang (upserts, positionskontrol pr. række).
  • Læsemodeller er eventuelt konsistente — håndtér forskellen mellem skrivning og efterfølgende læsning med optimistiske svar eller ved at vente på projektionen, aldrig ved at gennemtvinge synkrone projektioner.
  • Én strøm kan levere data til mange læsemodeller, og enhver af dem kan genopbygges ved at afspille strømmen igen (byg-og-skift uden nedetid).
  • Behandl sekventielt pr. partition, gentag midlertidige fejl uden at flytte kontrolpunktet frem, og send giftige events til en karantænekø.
Gratis at komme i gang

Lær JavaScript med en AI-underviser — gratis

Skriv og kør rigtig kode i din browser, få øjeblikkelig hjælp fra en AI-underviser døgnet rundt, og fortsæt, hvor du slap, på web eller i appen.

Kurser
22
Lektioner
92

Ofte stillede spørgsmål

Er lektionen “Opbygning af read models og projektioner” gratis?

Ja — hele teksten til “Opbygning af read models og projektioner” kan læses gratis her på nettet. Hvis du vil øve dig interaktivt med en indbygget kodeeditor og en AI-vejleder døgnet rundt og få adgang til resten af Bootcamp i backendudvikling med Node.js-kurset, skal du opgradere til CoddyKit PRO. Bootcamp i backendudvikling med Node.js-kurset indeholder 4 lektioner i alt.

Hvad lærer jeg i “Opbygning af read models og projektioner”?

Udled optimerede projektioner til forespørgselssiden fra eventstreamen, og hold dem eventually consistent. Du øver dig i Bootcamp i backendudvikling med Node.js med praktisk kode, som du kører direkte i browseren, og en AI-vejleder døgnet rundt besvarer dine spørgsmål, mens du arbejder dig gennem lektionen.

Skal jeg have erfaring for at begynde på Bootcamp i backendudvikling med Node.js?

Der kræves ingen tidligere erfaring. Bootcamp i backendudvikling med Node.js på CoddyKit er tilrettelagt for både begyndere og øvede, så du kan starte her eller fra begyndelsen og lære i dit eget tempo. Dette er lektion 3 af 4.

Hvor lang tid tager lektionen “Opbygning af read models og projektioner”?

De fleste CoddyKit-lektioner tager cirka 5–10 minutter. Hver lektion er kort og interaktiv, så du gør løbende fremskridt og kan fortsætte, hvor du slap – på både web og app.

Kan jeg skrive og køre kode i denne Bootcamp i backendudvikling med Node.js-lektion?

Ja. Alle Bootcamp i backendudvikling med Node.js-lektioner har en indbygget kodeeditor, så du kan skrive og køre rigtig kode direkte i din browser og få øjeblikkelig feedback fra AI – uden lokal opsætning.

Alle lektioner i dette kursus

  1. Events som sandhedskilde og append-only-loggen
  2. Aggregater, commands og modellering af domæneevents
  3. Opbygning af read models og projektioner
  4. Snapshots, versionering og udvikling af eventskemaer
← Tilbage til Bootcamp i backendudvikling med Node.js