Az adatbázisok helye az architektúrákban az évek alatt nagy változásokon ment keresztül. A szoftverfejlesztés hajnalán az adatbázis volt az alfája és omegája az architektúrának, a kliens csak egyfajta frontend szerepet töltött be.
Idővel rájöttek, hogy ez annyira nem jó, mert ha a DB-ben van a logika nagy része, akkor módosítani azt annyira nem egyszerű, illetve komplikálja az DB rendszer cserélését is. Éppen ezért születettek meg a rétegelt architektúrák, majd később ezek problémáit felismerve és orvosolandó az Onion és Hexagonal architektúrák.
Függetlenül attól, hogy a szoftverünkben milyen architektúrát alkalmazunk, célszerű nem mindenhova bevinni az adateléréséért felelős komponenst, illetve célszerű legalább egy interfésszel leválasztani, absztraktálni. A leválasztás és absztraktálás tesztelési célokból szükséges és hasznos. Képzeljük el a szituációt, hogy kapunk a bankunktól egy telefonhívást, miszerint félresikerült a tesztelés az adatbázisukon és elvesztettük a megtakarításainkat. Valószínűleg nem örülnénk egy ilyen hívásnak és valószínűleg nem is lennénk túl büszkék magunkra, ha ezt a hibát mi követtük volna el.
Éppen ezért a DB elérési rétegeket legalább egy interfésszel le szokták választani az adatbázistól, hogy az implementáció mögötte cserélhető legyen.
Repository és Unit of Work
A leválasztás struktúrájára számos tervezési minta született. Az egyik legnépszerűbb a Repository, amit ki szoktak egészíteni a Unit of work mintával.
A Repository általában CRUD (Create – létrehozás, Read – olvasás, Update – frissítés, Delete – törlés) metódusokat definiál az adatbázis entitásaihoz, míg a unit of work ezt egy tranzakció kezeléssel egészíti ki. Az Entity Framework segítségével létrehozott DB Context osztályunk ezeket a mintákat valósítja meg, így ha Entity Framework segítségével építünk adatbázis elérési réteget, akkor már van egy repository és egy Unit of work implementációnk.
A DBSet<T> típusú tulajdonságaink és a hozzájuk kapcsolódó metódusok valósítják meg a repository részt, míg maga a SaveChanges() metódus a unit of work megvalósítása.
A Repository és Unit of Work implementációknál a neten fellelhető példakódok 90%-a elköveti azt a hibát, hogy minden táblához készít egy repository-t, ami felesleges komplexitást visz a rendszerbe. Helyesen repository-t alkalmazási területhez mérten kellene készíteni, vagyis ha az adatbázisom főleg könyvekkel dolgozik, akkor bőven elég egy interfész a könyvek eléréséhez, még akkor is, ha valójában a könyvek minden adatának eléréséhez több táblára van szükségem.
Ez gyakorlatban azt jelenti, hogy a DbContext osztályomat egy szimpla interfésszel is absztraktálhatom, például így:
public interface IKonyvekContext
{
DbSet<Author> Authors { get; set; }
DbSet<Book> Books { get; set; }
DbSet<Publisher> Publishers { get; set; }
int SaveChanges();
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Az interfészben is DbSet<T> típussal vannak reprezentálva a táblák, mivel az Entity Framework Core nem tartalmaz erre a típusra IDbSet<T> interfészt, mint a korábbi Entity Framework. Ennek az oka az, hogy a DbSet<T> típus már elve egy absztrakt osztály absztrakt és virtuális tagokkal, metódusokkal, így tesztelés során bármelyik metódusa helyettesíthető a Moq segítségével.
Ennek a fajta absztrakciónak a "hátránya", hogy az interfész használati oldalon is kell a Microsoft.EntityFrameworkCore csomag.
CQRS
A CQRS, a Command and Query Responsibility Segregation szavak rövidítése. Ez egy olyan tervezési minta, amely elválasztja az adatbázis olvasási és frissítési műveleteit. A CQRS megvalósítása az alkalmazásban maximalizálhatja annak teljesítményét, méretezhetőségét és biztonságát. A CQRS-re való átállás által teremtett rugalmasság lehetővé teszi a rendszer jobb fejlődését az idő múlásával, és megakadályozza, hogy a frissítési parancsok összevonási ütközéseket okozzanak domain szinten.
Egy hagyományos architektúrákban ugyanazt az adatmodellt használjuk az adatbázis lekérdezésére és frissítésére. Ez egyszerű, és jól működik az alapvető CRUD műveleteknél, de bonyolultabb alkalmazások esetén ez a megközelítés problémás lehet.
Például az olvasási oldalon az alkalmazás számos különböző lekérdezést hajt végre és különböző formájú adatátviteli objektumokat (DTO) ad vissza. Ebben az esetben a DTO gyártás logikája bonyolulttá válhat. Írási oldalon pedig az összetett modell érvényességének ellenőrzése és az üzleti logika miatt túlságosan komplex modellünk lesz, ami tuti biztos, hogy túl sok mindenért lesz felelős, vagyis előbb-utóbb valahol biztos sérülni fog a Single Responsibility.
Ezen felül:
-
Általában nem egyezik az adatok írási és olvasási modellje. Például adódhat, hogy további tulajdonságokat kell helyesen frissítenünk írás esetén, de lekérdezéskor ezekre nincs szükség.
-
A hagyományos megközelítés negatív hatással lehet a teljesítményre az adattároló és az adatelérési réteg terhelése, valamint az információk lekéréséhez szükséges lekérdezések összetettsége miatt.
-
A biztonság és az engedélyek kezelése bonyolulttá válhat, mivel minden entitás olvasási és írási műveleteknek is ki van téve, amelyek rossz kontextusban jeleníthetik meg az adatokat.
Ezekre a problémákra ad megoldást a CQRS minta, ami különböző modellekre választja szét az olvasást és az írást. Írás esetén parancsokat használva az adatok frissítéséhez és lekérdezéseket az adatok olvasásához.
Ezek a parancsok feladatalapúak, nem pedig adatközpontúak és aszinkron módon is feldolgozhatóak egy Que segítségével.
A lekérdezések soha nem módosítják az adatbázist. A lekérdezés olyan DTO-t ad vissza, ami nem áll kapcsolatban a DB-ben definiált entitás modellekkel.
A CQRS hátránya, hogy automatikusan nem generálható le meglévő adatbázis alapján, de cserébe a korábban említett előnyök mellett még azt is lehetővé teszi, hogy két külön adatbázist használjunk lekérdezési és írási oldalon. Azonban ha két külön DB van, akkor az adatbázisok közötti szinkronizációt nekünk kell megoldani.
Nézzük egy lehetséges példát CQRS implementációra!
A CQRS-hez szükséges dolgokat egy külön projektbe tettem, mivel a DB projektemnek és a kliensemnek is szüksége van ezekre a típusokra. Az egyszerűség kedvéért a minta implementáció csak könyvek írásával és lekérdezésével foglalkozik. Ezeknek az eléréséhez a projekt az alábbi DTO-kat definiálja:
namespace Konyvek.Cqrs;
public class BookDto
{
public int ISBN { get; set; }
public string Title { get; set; }
public int PublishYear { get; set; }
public string AuthorName { get; set; }
public string PublisherName { get; set; }
public BookDto()
{
Title = string.Empty;
PublisherName = string.Empty;
AuthorName = string.Empty;
}
}
public interface ICommand
{
int Id { get; set; }
}
public class WriteBookCommand : ICommand
{
public string Title { get; set; }
public int PublishYear { get; set; }
public string AuthorFirstName { get; set; }
public string AuthorLastName { get; set; }
public string PublisherName { get; set; }
public int Id { get; set; }
public WriteBookCommand()
{
Title = string.Empty;
PublisherName = string.Empty;
AuthorLastName = string.Empty;
AuthorFirstName = string.Empty;
}
}
A WriteBookCommand egy ICommand implementáció. Ez azért került így kialakításra, mert íráskor szükségünk lesz egy ID-ra mindig, illetve ugyan a CQRS nem követeli meg, de tipikusan ezt egy üzenet küldő rendszerrel alkalmazzák, ahova az írási parancsok befutnak és egy parancsfeldolgozó végrehajtja őket. Tehát az ICommand ilyen kettős szerepet tölt be. Illetve megfigyelhető, hogy a WriteBookCommand és a BookDto között jól elkülönül a szerző név ábrázolása.
Magát a CQRS-t az alábbi két interfész valósítja meg:
public interface IReadOnlyBooks
{
IAsyncEnumerable<BookDto> GetBooks();
}
public interface IWritableBooks
{
Task Write(WriteBookCommand writeBook);
}
Ezek implementációja a DB projektben található és így néznek ki:
using Konyvek.Cqrs;
using Konyvek.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace Konyvek.Database;
internal sealed class ReadonlyBooks : IReadOnlyBooks, IDisposable
{
private readonly KonyvekContext _context;
private bool _disposed;
public ReadonlyBooks(KonyvekContext context)
{
_context = context;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_context.Dispose();
}
}
public async IAsyncEnumerable<BookDto> GetBooks()
{
var books = _context.Books
.Include(book => book.Publisher)
.Include(book => book.Author)
.AsAsyncEnumerable();
await foreach (var book in books)
{
yield return new BookDto
{
ISBN = book.ISBN,
PublishYear = book.PublishYear,
Title = book.Title,
AuthorName = $"{book.Author!.LastName}, {book.Author!.FirstName}",
PublisherName = book.Publisher!.Name,
};
}
}
}
internal class WritableBooks : IWritableBooks, IDisposable
{
private readonly KonyvekContext _context;
private bool _disposed;
public WritableBooks(KonyvekContext context)
{
_context = context;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_context.Dispose();
}
}
public async Task Write(WriteBookCommand writeBook)
{
var publisher = _context.Publishers
.Where(p => p.Name == writeBook.PublisherName)
.First();
var author = _context.Authors
.Where(a => a.FirstName == writeBook.AuthorFirstName
&& a.LastName == writeBook.AuthorLastName)
.First();
var book = new Book
{
Author = author,
Publisher = publisher,
PublishYear = writeBook.PublishYear,
Title = writeBook.Title,
ISBN = writeBook.Id,
};
await _context.SaveChangesAsync();
}
}
Mindkét osztály implementálja az IDisposable interfészt, mivel a KonyvekContext is az. A könyvek lekérdezés implementációjakor egyszerűsítés miatt a szerző és kiadó null ellenőrzése ki van hagyva, mivel az Include utasítás miatt úgy is lesz szerzője és kiadója az adott könyvnek.
Az írás megvalósításakor pedig feltételeztem, hogy a kapott writeBook rendelkezik érvényes kiadó névvel és szerző névvel. Természetesen ezt ellenőrizni kellene éles körülmények között és nem létezésük esetén felvenni őket az adatbázisba, de jelen példa esetén nem ezen volt a hangsúly.
A CQRS összes előnyének ellenére óvatosan kell bánni a használatával. Sok rendszer jól illeszkedik egy olyan információs modellhez, amit ugyanúgy frissítenek, mint ahogy olvasnak. A CQRS hozzáadása egy ilyen rendszerhez jelentős komplexitást jelenthet.