Event sourcing med s-expressions


mandag 11. juni 2012 Diverse prosjekter DSL Clojure Event sourcing

Helt siden i september 2009 (da Greg Young var på besøk i Bergen) har jeg tenkt på event sourcing. Og i årene etter har dette blitt en mer og mer populær arkitektur blant de softwareutviklerne som er mest cutting edge.

Men jeg har ikke bygget noe med det i tiden som har gått siden da. Å treffe Greg igjen på NDC i år har derimot stimulert meg på nytt, og her følger en blogpost som forklarer hva det går i, og som implementerer et lite eksempel.

Blogposten er også en liten leksjon i programmeringsspråket Clojure. Eksempelet er en nedskalert begynnelse på et verktøy jeg kommer til å lage for eget bruk, og dette språket er ideelt for oppgaven. Men kunnskap om Clojure er absolutt ikke nødvendig for å henge med i det jeg gjør, alt du behøver er et åpent sinn.

Hva er Event Sourcing?

Event sourcing består av to steg:

  1. Man lagrer alle endringer i et system som en sekvens av hendelser.
  2. Man bygger opp igjen systemets tilstand fra disse registrerte hendelsene.

event_sourcing

Se for deg hvordan en bank overfører penger fra en konto A til en konto B. De gjør ikke som i de typiske kodeeksemplene hvor man har en databasetransaksjon, og oppdaterer saldoen på de to kontoene i én atomisk operasjon. Nei, saldoen i banken din består av en serie med hendelser:

  1. Konto opprettet, saldo kr 0,-
  2. Innskudd a kr 30 000,-
  3. Overføring fra konto X a kr 560,-
  4. Betalt regning a kr 1 499,-
  5. 1,1% renter effektuert
  6. Debetkort belastet kr 19.50
  7. osv...

Selv om den ikke står der eksplisitt er det enkelt å beregne saldoen.

På grunn av at man kan bygge opp igjen systemets tilstand fra hendelsene kan man ofte klare seg uten noen annen form for lagring av tilstand. I praksis vil man nok ofte likevel lagre data i et annet format for eksempel for rapportering, og man vil også ta "snapshots" av tilstand for å slippe å kjøre gjennom hele loggen hver gang man trenger tilstand. Men rapportene og snapshotene kan når som helst forkastes og genereres på nytt fra loggen.

Fordelene med event sourcing

Fordelene med event sourcing er mange, men det kan være et komplekst tema å diskutere. Jeg har ikke mye erfaring, så jeg vil ikke gå i dybden, men her er de viktigste punktene slik jeg ser det:

  • Event sourcing innebærer såkalt append only data storage – man behøver ALDRI oppdatere data. Dette løser flere problemer, spesielt knyttet til transaksjoner og samtidighet, og skrivehastigheten til systemet kan øke dramatisk. Skulle samtidighetskonflikter mellom endringer snike seg inn så er det også mye enklere å rydde opp i dem med en hendelseskø.
  • Et system som baserer seg på event sourcing er godt tilrettelagt for testing. Man kan simulere all mulig adferd ved å sette opp en sekvens av hendelser. Og opplever man problemer i produksjon kan feilene alltid gjenskapes ved å kjøre hendelsesloggen derfra. Dette er gull!
  • Hendelsesloggen inneholder ikke bare hvilke dataendringer som har skjedd, men eksplisitt hvorfor de skjedde. Meningen bak tilstandsendringene blir tatt vare på. Dette er noe som mangler i de fleste "vanlige" systemer i dag, og er verdifullt av mange grunner. En av dem er at man etter at systemet er i produksjon kan komme opp med nye måter å analysere bruken på, og så kjøre disse analysene på historiske hendelser.
  • Og finner man en bug i systemet; da fikser man den, ruller systemet tilbake, og alle hendelsene blir registrert på nytt med den nye koden. Feil i data vil "fikse seg selv".

Hvorfor (og hva er) s-expressions?

For noen år siden var XML det ultimate dataformatet. Nå føles det som om JSON er blitt vårt standard format, både for lagring og for kommunikasjon mellom tjenester. Men det har lenge funnes et annet format i denne kategorien, et format som i sin tid ble utviklet for å representere både kode og data (i form av lister og trær) i programmeringsspråket LISP. Dette er såkalte symbolske uttrykk, gjerne forkortet til sexprs eller sexps.

Sexps er enkle å parse, mye mindre bråkete enn XML, og mere fleksible enn JSON. Du ser ikke mye til dette formatet utenfor Lisp-sfæren, men det er ekstremt kraftig. Hvorfor? Fordi de kan representere data og programkode på en og samme tid.

I denne blogposten skal jeg implementere en del av et todo-program. I programmet skal man kunne opprette todo-items, flytte dem rundt mellom ulike "bøtter", markere dem som utført, slette dem, og lignende handlinger.

Hva om jeg lar todo-programmet logge alt som skjer til en fil, og jeg logger på et format som dette:

(todo-created (id t1) "Experiment with event sourcing s-expressions")
(todo-created (id t2) "Draw some good event sourcing illustrations")
(todo-created (id t3) "Blog about event sourcing")
(todo-moved   (id t1) (todo-bucket today))
(todo-moved   (id t2) (todo-bucket tomorrow))
(todo-moved   (id t3) (todo-bucket tomorrow))
(todo-done    (id t1))
(todo-created (id t4) "Create an awesome todo system")
(todo-moved   (id t4) (todo-bucket in-two-days))
(todo-created (id t5) "Rule the World!")
(todo-moved   (id t5) (todo-bucket later))
(todo-deleted (id t5) (reason "Just kidding"))

Det du ser over er kun en tekstfil. Men loggelinjene er formatert som sexps. Altså er de gyldige Lisp-uttrykk. Hva om jeg så implementerte funksjoner som het todo-created, todo-moved osv., og så kjørte loggfilen gjennom en Lisp-tolker – f.eks. Clojure?

Loggen vil da ha blitt til et program skrevet i et domenespesifikt språk som gjenskaper tilstanden som hendelsene loggen er generert ut ifra en gang før skapte.

Det kan tenkes du bør lese den forrige setningen mer enn én gang!

En implementasjon i Clojure

Så la oss skrive litt kode som er i stand til å gjøre dette. Jeg begynner med å deklarere et navnerom, og oppretter så en variabel todos (globalt i navnerommet). Denne peker til en hash / dictionary / lookup table som vil holde programmets todo-items.

(ns worklog.core)

;; Todo items are stored here
(def todos (atom {}))

Deretter begynner jeg å skrive implementasjoner for nøkkelordene i loggfilen. Jeg tar de enkleste først – dette er små makroer som transformerer symbolene de omkranser til spesifike datatyper i Clojure (henholdsvis string for id og keyword for todo-bucket). Reason, som brukes i siste linje i loggfilen, tar en streng og ikke et symbol som parameter, og returnerer den derfor bare som den er. Dette er et eksempel på en funksjon som bare eksisterer for å gjøre loggfilen mere leselig.

(defmacro id [id]
  (str id))

(defmacro todo-bucket [bucket-id]
  (keyword bucket-id))

(def reason identity)

Og så har jeg kommet til den første seriøse funksjonen, nemlig todo-created. Parametrene er en todo-id og en todo-beskrivelse. Hvis et todo-item med gitt id allerede finnes i todos kaster den et exception. Hvis ikke legger den til et nytt objekt i en default bøtte (staging) og med default tilstand (open):

(defn todo-created [id description]
  (if (@todos id)
    (throw (Exception. 
             (str "Todo with id " id 
                  " already exist, can't create!")))
    (swap! todos assoc id { :id     id
                            :desc   description
                            :bucket :staging
                            :state  :open })))

Før jeg implementerer de resterende tre funksjonene trenger jeg en liten makro – dette er for å unngå at jeg repeterer meg selv i koden. De tre funksjonene skal nemlig oppdatere et todo-item, så da lager jeg en makro jeg kaller update-todo! som gjør det for en gitt todo-id, et feltnavn, og en verdi.

Makroen kontrollerer at et todo-item med den gitte identifikatoren eksisterer, og kaster et exception hvis det ikke gjør det:

(defmacro update-todo! [id key val]
  `(if-let [todo# (@todos ~id)]
     (swap! todos assoc-in [~id ~key] ~val)
     (throw (Exception.
              (str "Unable to find todo " ~id)))))

OBS: Nærmere gjennomlesning fikk meg til å innse at jeg her har laget en makro helt unødvendig – dette kunne ha vært en helt vanlig funksjon. Jaja..!

Når jeg har update-todo! er det trivielt å implementere funksjonene for å flytte, markere som utført, og å slette et todo-item:

(defn todo-moved [id bucket]
  (update-todo! id :bucket bucket))

(defn todo-done [id]
  (update-todo! id :state :done))

(defn todo-deleted [id _reason-ignored]
  (update-todo! id :state :deleted))

Den siste funksjonen illustrerer også et case hvor loggfilen inneholder informasjon som jeg ikke behøver for å gjenskape programmets tilstand – nemlig årsaken til hvorfor et todo-item ble slettet.

På tide å laste event-historikken

Alt jeg nå trenger å gjøre for å laste todo-listen min er å laste logfilen og eksekvere den. Følgende funksjon gjør det (den er egentlig bare en wrapper som gir meg en funksjon med et litt mere spesifikt navn):

(defn load-events-from-file [file]
  (load-file file)
  :ok)

Hvis jeg nå fyrer opp en REPL i terminalen min og laster DSL-koden så kan jeg kjøre funksjonen og gi den stien til loggfilen min (altså filen gjengitt i toppen av bloggposten):

worklog.core=> (require 'worklog2.core :reload)
nil
worklog.core=> (load-events-from-file "./todo.log")
:ok

Å skrive ut todo-listen

load-events-from-file svarer med nøkkelordet "ok". todos-variabelen vil nå inneholde todo-itemene mine. Vi kan se på innholdet direkte, men i stedet tar jeg meg tid til å vise deg litt kode jeg har skrevet for å presentere listen litt bedre.

Funksjonene er små og har gode navn, så se om du klarer å skjønne hva de gjør (det kommer ingen forklaring):

;; Formating functions to print todo list

(defn todo-state-to-str [t]
  (condp = (:state t)
    :done    "DONE"
    :deleted "XXXX"
    :open    "    "))

(defn todo-2-str [t]
  (str "  [" (todo-state-to-str t)  "] " 
       (:desc t)))

(defn todos-for-bucket [b todos]
  (filter #(= (:bucket %) b) todos))

(defn bucket-to-str [b-id]
  (let [s (name b-id)]
    (str (.toUpperCase (subs s 0 1))
         (.toUpperCase (subs s 1)))))

(defn print-bucket [id todos]
  (println (bucket-to-str id))
  (doseq [t (todos-for-bucket id todos)]
    (println (todo-2-str t))))

(defn print-todos 
  ([] (print-todos (vals @todos)))
  ([t]
    (println "--- TODO-LIST ----------------------------")
    (doall (map #(print-bucket % t)
                [:today :tomorrow :in-two-days :later]))
    'EOF))

Om jeg nå går tilbake til REPL'en og evaluerer funksjonen print-todos så får jeg en formatert utskrift av todo-listen min slik den ble etter at jeg lastet inn alle eventene fra todo.log:

worklog.core=> (print-todos)
--- TODO-LIST ----------------------------
TODAY
  [DONE] Experiment with event sourcing s-expressions
TOMORROW
  [    ] Blog about event sourcing
  [    ] Draw some good event sourcing illustrations
IN-TWO-DAYS
  [    ] Create an awesome todo system
LATER
  [XXXX] Rule the World!
EOF

Som du kan se er alle items flyttet inn i riktige bøtter, dagens todo-item er utført, og verdensherredømme er kansellert.

Konklusjon

Du har sett meg fortelle om event sourcing, som er en teknikk hvor man lagrer alle hendelser i et softwaresystem, for så å kunne gjenskape nåtilstand (eller tilstanden for et hvilket som helst tidspunkt) ved å kjøre hendelsene på nytt. Dette eliminerer mer eller mindre behovet for klassisk lagring av tilstand, og gir en rekke nye muligheter.

Jo mer jeg tenker på disse mulighetene, jo mer verdifull blir denne teknikken for meg. Jeg kan f.eks. veldig enkelt lage ulike versjoner av DSL'en / parseren som utfører ulike ting basert på hendelsene i loggen. Mulighetene rundt testing, både hva angår enkelhet og styrke, er også veldig interessante.

Se ikke bort ifra at jeg kommer til å begynne å logge i sexps-format overalt fremover. Du er herved advart! :D


comments powered by Disqus