Architektúra dilemma

Tegyük fel hogy van egy klasszikus, többrétegű alkalmazásunk. Van benne egy service layer, ami hív valamiféle alkalmazás logikát. A logika direktben hivatkozik Entity Framework DbContextre, abból olvas fel adatokat, módosítja őket, illetve direkt EF Context és DBSet hívásokkal menti őket.

Szeretnénk ezt az architektúrát egy kicsit jobban szétdarabolni, hogy átláthatóbb és tesztelhetőbb legyen. Ezért először az EF adatlekérő részeket kiszedjük Extract Method refactoringgal, majd utána átmozgatjuk őket saját osztályokba (Extract Class a vége). Ezeket az osztályokat elnevezhetjük repositoryknak. Ezek ugyan nem olyan szép, idealizált repository-k, mint amiket Martin Fowlerék álmodtak meg, de lényeg, hogy az adatelérő kódokat szeretnénk valamennyire elszigetelni a logikától. 

Miért? Mert így elszigetelve jobban meg lehet érteni őket, könnyebb optimalizálni a benne lévő adaterlését, és könnyebb az adateléréshez integrációs teszteket írni (például perf okokból).  A hívó, üzleti logika oldalon élesen szétválik az adatelérő kód és a logika, így az üzleti logikába könnyen tudunk fake adatokat bevinni, ami addig nem volt egyszerűen megoldható, amíg az entity frameworköt direktbe hivatkozva kért le adatokat a logika. Tehát a cél egy jobban kezelhető (SOLID) és jobban tesztelhető kód előállítása.

A dilemma a következő. Nyilván egy nagy osztályba nem lehet kimozgatni az összes helyen szétszórt eddigi adatelérő kódot, mert akkor kapunk egy több ezer soros monstrumot.  Valamilyen módon szét kell szedni az adatelérő metódusokat különböző osztályokba, ezeket hívtam az előbb repositoryknak. Ezek nem feltétlen 1-1 entitáshoz kapcsolódó repositoryk, hanem lehet, hogy valami üzleti területet foglalnak egybe. Például a SzámlázásRepo lehet, hogy 5-20 entitáshoz kapcsolódó adatelérést is csinál, hisz gyakori, hogy egy teljes gráfot kell letölteni (mint a Domain Aggregate a DDD-ben). De nincs jelentősége most a repó felbontásának.

Tételezzük fel, hogy itt tényleg entitás példányok jönnek vissza a repókból, tehát nem rakunk be egyelőre semmi mapping réteget, amivel DTO-k vagy Domin Objecteket alakítanánk ki. Egy ilyen refactoring során nem lehet ekkorát harapni egyszerre.

Tehát direktben entitásokat, persistent entityket adunk vissza. Minden repó mögött ugyanaz az EF DBContext példány van, ez könnyű elérni valamilyen Dependency Injection Framework Scope-olt lifetime fogalmával, általában a szerviz hívást mint scope-ot felhasználva. Ez azért fontos, mert ha letöltök egy entitást, majd letöltöm annak egy gyerekét, akkor belül az EF összekapcsolja a kettőt, azaz a szülő gyerek kollekciójába belekerül a gyerek, a gyerek szülő referenciája pedig a szülőre fog mutatni. Ez a közös Context egy kritikus koncepció az eddigi kódban, ugyanis bár lehet nem tudatos azokban, akik írták a kódot, de az EF Context révén implicit kapcsolatba kerül sok sornyi adatelérő kód, és csak azért működött helyesen az adatelérés, mert az EF így van megírva.

A get műveletek szépen és elegánsan becsomagolhatók repó műveletekbe. Ha van pl. egy GetInvoicesByCustomer metódus, akkor az visszaad Invoice entitásokat, ami mögé tetszőleges szintig betöltünk minden egyéb gyermek vagy szülő objektumokat. Igaz, ez a részlet nem látszik a metódus szignatúrából, de ezzel együtt élünk. A logikát könnyű tesztelni fake adatokkal, hisz csak ki kell cserélni a teljes repó metódust valami fix adatot visszaadó stubbal.

A módosítások repók felé történő kommunikációja már nehezebb. Az insert még elmegy, hisz pl. az InvoceRepository.AddInvoice vagy AddInvoceWithDetails vagy hasonló leírja, mit várunk a művelettől. A háttérben egyszerűen hívunk egy DbSet.Add-ot.

A Delete hasonlóan egyszerű, ha top level objektumról van szó, DeleteInvoice(Invoice).

Az egész viszont sokkal macerásabbá kezd válni, amikor például egy szülő objektum alá kell berakni egy gyerek objektumot.

Például, a repóból lekérek egy számlát, majd ehhez kellene tételeket hozzáadni.

Az InvoiceItems kollekció a visszakapott Invoice entitás része. Ha ehhez egyszerűen hozzáadunk egy új InvoceItemet, akkor az EF erről tudni fog, hisz neki végig van referenciája a gyökér Invoice objektumra, így a SaveChanges csak úgy el fogja menteni a gyerek sort, beállítva az FK-ját a szülő Invoice-ra.

A gondom az, hogy ez teljesen implicit. Nem tudok értelmes metódust felrakni a repóra, ami kommunikálná a logika azon szándékát, hogy ő itt most egy gyerek objektumot akar hozzáadni a szülőhöz. Emiatt tesztelni se tudom rendesen.

Ugyanez a probléma a módosításokkal. Lehozok a repó.GetValami metódussal egy entitást. Ezt direktben módosíthatja a logika, majd a SaveChanges-re az EF csak úgy tudja, hogy erre update SQL parancsot kell kiadni.

Megint, nem fogható meg jól tesztből, hogy a logika szándéka az volt, hogy módosít egy entitást.

Tehát, az átalakított kód úgy néz ki, hogy betölt mindenféle adatot mindenféle repo.Get hívásokkal. Ezek nem elszigetelt adatok, hanem “össze vannak ragadva”. Az EF mindet követi. A logika tetszőleges módon megbabrálja ezt a több darabból összerakott gráfot, majd az EF ezt elmenti “okosba”. De rém utálom az ilyen okosba megoldásokat.

Hogyan lehet erre tesztet írni, ami azt vizsgálja meg, hogy a logika a lehozott Invoce-hoz hozzáadott egy új tételt? Hogy lehet megfogni tesztből, hogy milyen entitás milyen property-jét mire állította be a logika? Azt feltételezem, hogy fake repók vannak a logika mögött. Ahogy megbeszéltük, a Getekben adatbázis helyett könnyű kamu adatokat behazudni memóriából, azaz visszaadhatunk teljesen feltöltött gráfokat. Például visszajön egy Invoice, benne két InvoiceItem, és referencia egy Customerre. Az logika legyen az, hogy ha a Customer neve Mari, akkor berak egy új tételt, InvoiceItemet, “Talp és egyéb masszázs” tartalommal.

A logika egyszerűen hozzáad egy new InvoiceItemet a Getben visszakapott Invoice objektumhoz, majd a repó mögötti EF DbContext.SaveChanges csak úgy el fogja menteni.

Hogyan lehet tesztből detektálni, hogy a logika tényleg hozzáadta a számlatételt?

Azt lehet tenni, hogy eltérítjük a SaveChanges hívást (mock scenario), és abban átnézzük a mit? Itt nincs igazi EF DbContext, nincs semmi state a kezünkben, amit meg vizsgálni.

Ha a repón lenne valami explicit metódus, akkor könnyebben lehetne tesztelni. Ha például lenne egy InvoceRepo.AddInvoiceItem(Invoice, InvoiceItem) metódus, akkor ezen keresztül explicit látszik a logika szándéka, hogy egy gyerekelemet akkor hozzáadni. Ez könnyen tesztelhető.

De mit csináljon az igazi AddInvoiceItem implementáció? Adja ő hozzá az itemet az átpasszolt Invoice-hoz? Ok, megteheti. De az EF kialakítása miatt a logika ezt minden további nélkül megteheti a visszakapott Invoice entitáson is, mindenféle repó hívás nélkül, mégis működni fog az igazi implementáció, míg a teszt hibát jelezne.

A probléma fő oka szerintem az, hogy a két réteg között csak úgy átmennek az entitások, így a logic layerben történt módosítások csak úgy átlátszanak a repó (data access) rétegbe.

Ez nyilvánvalóan egy nem elegáns, nem elég tiszta modell. 

De térjünk vissza az elejére. Van egy spagetti kódunk, amiben egymás után vannak logika és adatelérő kódok. Ez akarjuk elkezdeni kis kockázatú refactoringokkal darabolni tesztelés és kezelhetőség miatt. Mivel az eddigi kód erősen épített az EF-re mint change trackerre, ezt nem lehet egyszerű refactoringgal kiszedni belőle. Az átláthatóbb szerkezet érdekében kirakjuk saját osztályokba, repókba, de tudjuk, hogy erősen kilátszik az EF a repó mögül (google: Leaky Abstraction). Ezt tudjuk, elfogadjuk, mert nem lehet egy nagyon sok sorból álló kódot csak úgy átalakítani. A kérdés tehát az, hogyan lehet ilyen kötöttségek mellett úgy kialakítani egy értelmes repó interfészt és értelmes együttműködést a két réteg között, ami elfogadható szintű logika tesztelést tesz lehetővé?

4 thoughts on “Architektúra dilemma

  1. SebDani

    Az nyilvánvaló, hogy a mapping réteg megoldást jelentene (majdnem) minden problémádra. :-)
    Amíg ezt nem lehet meglépni, én úgy alakítanám ki a repository-kat, hogy lennének readonly, meg forChange repository-k.
    Az elsőbe tennék minden olyan hívást, ami nem változtatja meg az entitásokat. Ezt belül egy-egy AsNoTracking()-gel is nyomatékosítanám, biztos, ami biztos.
    A másodikba kerülne minden olyan, ami szándékosan megváltoztat valamit az entitáson. Esetleg az itt kiajánlott metódusok nevében el lehet rejteni a hívó szándékát, hogy mire is akarja/fogja majd használni azt az entitás (pl. GetInvoiceToAddInvoiceItem).
    Ha pedig eljön a mapping réteg ideje, akkor az utóbbi repókkal érdemes kezdeni az átalakítást, és akkor a metódusok neve alapján már lehet tudni, hogy ahhoz a műveletnek kb. milyen paraméterei legyenek, milyen dto-k kellenek hozzá.
    A tesztelés?
    Értem, hogy a unit-tesztek milyen szépek és jók, de miért ne lehetne helyette egy kicsit integrációsabb teszteket írni? Miért ne lehetne most még egy InMemory db-t az EF alá pakolni, és a repository-kat a service-réteggel együtt tesztelni? Meg tudnád nézni, hogy az entitás mezői megváltoztak-e… kitörlődött-e, amit törölni akartál… stb.
    Ha pedig majd megjönnek a dto-k és mapperek, akkor majd ráérsz valódi unit-teszteket írni.

    1. Soczó Zsolt Post author

      Dani, köszi az ötleteket. Nincs ellenemre az integrációs teszt, és itt most valóban, először azok lesznek. Csak picit próbálnék előbbre jutni, hogy ne kelljen minden apró dologhoz db a tesztekben.

Comments are closed.