Når data gemmes i ElasticSearch, sker det ved hjælp af en struktur kaldet et dokument. Hvert dokument består af de felter man som bruger kommer med, samt et antal faste felter. Ét af disse felter er et id der unikt identificerer dokumentet og kan, fra grænsefladens side, bruges til at lave opdateringer og overskrivninger af dokumentet.
Under overfladen foregår tingene lidt anderledes. Lucene, der er motoren i ElasticSearch, arbejder på en immutable datastruktur, så uanset om en skrivning er en tilføjelse eller en opdatering, så indebærer det at der skrives et nyt dokument. Er der tale om en opdatering (eller en sletning) vil der ske en markering det gamle dokument og en asynkron proces vil sidenhen rydde op.
Når dokumenter skal ud igen, kan der bruges en høj grad af caching. Da dokumenter let kan identificeres og aldrig ændrer sig, kan mange søgninger og filtreringer caches vha. de super-effektive bitmaps. Datastrukturer hvor hvert matchende dokument kun optager én enkelt bit.
Netop disse egenskaber gør ElasticSearch yderst velegnet til event sourcing og jeg vil kigge lidt på hvordan en CQRS implementation kan foregå i praksis i C# og ved hjælp af den officielle ElasticSearch klient til .NET, NEST.
Skrivninger
Alle brugerhandlinger mod systemet gemmes én af gangen som separate kommandoer i et dertil indrettet index. Om der så er nogle fælles egenskaber på tværs af disse er mere domæneafhængigt. I det her eksempel er der tale om et ordresystem, så her er der et ordrenummer, der går igen på tværs af alle kommandoer og desuden skal vi vide hvornår hver enkelt handling er blevet udført. Hermed kan vi hente alle kommandoer relateret til én enkelt ordre og afspille dem i kronologisk orden.
Ydermere er ElasticSearch klienten NEST typestærk – alle skrivninger og læsninger foregår imod konkrete typer. Så for at lette arbejdet med denne vil samtlige kommandoer arve fra en fælles abstrakt klasse:
For at kunne gemme og genskabe konkrete typer benytter NEST sig af feltet _type. Når vi gemmer en ny kommando skal det foregå vha. den konkrete type for at få påstemplet det rigtige navn:
new ElasticClient().Index<InitializeOrder>(new InitializeOrder());
Herefter indeholder _type-feltet værdien initializeorder
.
Når vi så skal hente alle kommandoer op for en konkret ordre, benytter vi os af NEST klientens indbyggede support for covariance. Altså vi henter listen op baseret på den fælles basistype OrderCommand
og lader klienten konstruere de konkrete kommandoer ved at give den en liste af typerne:
var result = new ElasticClient() .Search<OrderCommand>(o => o .Types(typeof(InitializeOrder), typeof(AddItem), typeof(RemoveItem)) .Query(i => i .Term(s => s .OnField(h => h.OrderId) .Value(command.OrderId))));
Resultatet vil være en IEnumerable<OrderCommand>
indeholdende de konkrete typer. Det smarte er at NEST egentligt kan konstruere vores typer med en lille mængde information.
Havde vi valgt at persistere den fulde type, kunne vi sidenhen havde fået problemer med refactorings af namespaces m.v. Ønsker vi senere at omdøbe blot en enkelt kommando, uden at ændre vores data kan det foregå ved manuelt at konfigurere et alias gennem ConnectionSettings.MapDefaultTypeNames()
.
En lille sidebemærkning – da vores kommandoer hører sammen i små grupper, hver på sin ordre, kan vi med fordel give ElasticSearch et hint om hvordan de skal fordeles på hver underliggende shard. Dette gøres ved at vi ved samtlige skrivninger og læsninger tilføjer et .Routing(ordreId)
. Resultatet er at når vi senere skal hente alle kommandoer til en bestemt ordre, behøver ElasticSearch kun at kigge på én shard – og denne ene shard kan stadig være duplikeret ud på mange noder i vores cluster.
Læsninger
Til grundlag for søgning og læsning skal vi have skrevet vores fulde ordre-domæne-objekt. Principielt kan det foregå i samme index som vi skriver kommandoer til men hvis vi skriver til et andet, kan vi opnå noget mere fleksibilitet ift. skalering og hvis vi bruger ElasticSearch’ alias funktion kan vi fx. lade læsninger foregå til et index mens vi bygger et nyt op i et andet og så blot skifte over når vi er klar.
Oprettelse af snapshottet kan foregå ved blot at tilføje en funktion til hver enkelt kommando der indeholder det logik der skal til for at dekorere et domæne-objekt – her er det første kommando der initialiserer ordren:
public void Playback(Order order) { order.OrderId = OrderId; order.Created = Created; order.CustomerId = CustomerId; }
Og herefter er det blot et spørgsmål om at gemme det nye ordre-objekt i ElasticSearch. Bemærk at vi ved snapshots ikke slipper for at overskrive eksisterende data, i det her tilfælde vil et dokument med samme ordreId blive overskrevet:
client.Index(order);
Processen med at generere og skrive snapshots kan køres når vi vil. Det kunne fx. være en helt synkron handling lige efter hver kommando-tilføjelse, hvilket i så fald kan få indflydelse på den tid brugeren sidder og venter. Er vi mindre afhængige af at kunne læse lige efter skrivning kunne det foregå asynkront evt. gennem en kø. Ønsker vi fuld fleksibilitet kunne det være kommandoen der afgjorde hvilken type skrivning der skal udføres efter hver tilføjelse.
Læsninger foregår helt simpelt i det snapshot-baserede index. Der er ikke mange ting at tage højde for, for vi kan indrette indexet lige til formålet. Skulle der opstå behov for nye data eller nye måder at strukturere vores data på kan vi blot vælge at skrive snapshots til endnu et index.
ElasticSearch går fint i tråd med CQRS-arkitekturen. Når det kommer til skrivninger, foregår det til en lille skrive-log der som udgangspunkt bliver tømt en gang pr. sekund. Det at den eventual consistent og uden transaktioner gør at man alligevel skal designe sin løsning med forbehold for forsinkelse på læsninger.
Så har man et problemområde der egner sig til en CQRS arkitektur, bør man overveje ElasticSearch til en del af ens infrastruktur.