Liigu peamise sisu juurde

Kolmekihiline arhitektuur

Sissejuhatus

Rakenduse kasvades muutub kogu koodi ühte klassi paigutamine kiiresti hallamatuks. Päringute käsitlemine, äriloogika ja andmetele ligipääs põimuvad omavahel, muutes koodi raskesti loetavaks, testitavaks ja muudetavaks.

Kolmekihiline arhitektuur (three-tier architecture) on praktikas laialt kasutatav muster, mis jaotab rakenduse kolmeks eraldi kihiks, millest igal on selge vastutus:

Controller  ->  Service  ->  Repository

Iga kiht suhtleb ainult otse enda all oleva kihiga. Selline eraldatus hoiab koodi organiseerituna ning muudab üksikute osade muutmise, testimise ja asendamise lihtsamaks.

Kolm kihti

Kontrollerkiht

Kontroller on sissepääs rakendusse. See võtab vastu päringu (nt HTTP päringu, sõne jne.), eraldab vajalikud parameetrid ning delegeerib töö vastavale Service meetodile.

Kontroller ei tohi sisaldada äriloogikat ega suhelda otse andmehoidlaga. Selle ainus ülesanne on mõista, mida küsitakse, ja see edasi delegeerida.

public class LibraryController {
private final BookService service;

public LibraryController(BookService service) {
this.service = service;
}

public String handleRequest(String request) {
if (request.equals("/get_books")) {
return service.getAllBooks();
}
// ... other routes
return "Not found";
}
}

Mis kuulub kontrollerisse:

  • Päringu töötlemine (parameetrite eraldamine URL-ist, päiste lugemine jne)
  • Otsustamine, millist Service’i meetodit kutsuda
  • Tulemuse tagastamine

Mis ei kuulu kontrollerisse:

  • Äriloogikat sisaldavad if-laused
  • Andmete filtreerimine, sortimine või valideerimine (peale lihtsa töötlemise)
  • Otsesed kutsed andmehoidlale

Teenuskiht

Teenuskiht sisaldab rakenduse põhilist äriloogikat. Siin tehakse otsuseid: valideeritakse sisendit, käsitletakse vigu, kontrollitakse tingimusi ning koordineeritakse kutsed andmehoidlale.

public class BookService {
private final BookRepository repository;

public BookService(BookRepository repository) {
this.repository = repository;
}

public String findBooksInPriceRange(String start, String end) {
BigDecimal lower;
BigDecimal upper;
try {
lower = new BigDecimal(start);
upper = new BigDecimal(end);
} catch (NumberFormatException e) {
return "Invalid arguments";
}

if (lower.compareTo(upper) > 0) {
return "Lower price must be less than upper price";
}

return repository.findByPriceBetween(lower, upper).toString();
}
}

Mis kuulub teenusesse:

  • Sisendi valideerimine ja teisendamine (nt String -> BigDecimal)
  • Vigade käsitlemine (try-catch, veateadete tagastamine)
  • Ärireeglid (nt kui alumine piir on suurem kui ülemine, tagasta viga)
  • Andmehoidlaga suhtlemine

Mis ei kuulu teenusesse:

  • Päringu töötlemine (see on kontrolleri ülesanne)
  • Andmete käsitsi filtreerimine for-tsüklite või voogudega — selle asemel delegeeri filtreerimine andmehoidlale, kutsudes vastavat meetodit

Andmehoidla kiht (repository)

Andmehoidla pakub ligipääsu andmeallikale (tavaliselt andmebaasile). See sisaldab meetodeid andmete pärimiseks, lisamiseks, uuendamiseks ja kustutamiseks.

Paljudel juhtudel on repositoorium defineeritud liidesena, sest tegelik andmebaasi teostus ei pruugi veel olemas olla, võib tulla raamistiku poolt või võib testides vajada asendamist.

public interface BookRepository {
List<Book> findAll();
List<Book> findByAuthor(String author);
List<Book> findByPriceBetween(BigDecimal lower, BigDecimal upper);
List<Book> findByYearGreaterThan(int year);
List<Book> findByYearLessThan(int year);
Book getByTitleAndAuthor(String title, String author);
boolean existsByTitleAndAuthor(String title, String author);
boolean deleteByTitleAndAuthor(String title, String author);
}

Konkreetne teostus võib näiteks salvestada andmeid lihtsas mälus olevas nimekirjas:

public class InMemoryBookRepository implements BookRepository {
private final List<Book> books = new ArrayList<>();

@Override
public List<Book> findAll() {
return books;
}

@Override
public List<Book> findByAuthor(String author) {
return books.stream()
.filter(book -> book.author().equals(author))
.toList();
}

@Override
public boolean existsByTitleAndAuthor(String title, String author) {
return books.stream()
.anyMatch(book -> book.title().equals(title) && book.author().equals(author));
}

// ... other methods
}

Praktikas teeks see klass päringuid andmebaasi, kuid Service ja Controller ei peaks muutuma - need sõltuvad BookRepository liidesest, mitte sellest konkreetsest teostusest.

Mis kuulub andmehoidlasse:

  • Andmetele ligipääsu operatsioonid (päringud, lisamised, uuendused, kustutamised)
  • Filtreerimine ja sortimine andmeallika tasemel

Mis ei kuulu andmehoidlasse:

  • Äriloogika või valideerimine
  • Päringu töötlemine

Kuidas kihid suhtlevad omavahel

Iga kiht on teadlik ainult endast täpselt all olevast kihist:

Controller  ->  Service  ->  Repository  ->  Database
  • Kontroller kutsub teenust, kuid mitte kunagi andmehoidlat.
  • Teenus kutsub andmehoidlat, kuid ei tea, kuidas päringud temani jõuavad
  • Andmehoidla suhtleb andmeallikaga, kuid ei tea, kes teda kutsus või miks.

See loob selge hierarhia. Kui andmeallikas muutub andmebaasist failisüsteemiks, muutub ainult repositooriumi teostus - teenus ja kontroller jäävad puutumata.

Terviklik näide

Vaatleme, kuidas päring liigub läbi kõigi kolme kihi:

Päring: /get_books?author=Martin Fowler

1. Kontroller - töötleb päringut, eraldab autori parameetrist, pöördub teenuskihi poole:

public String handleRequest(String request) {
// ... parse request ...
if (request.startsWith("/get_books?author=")) {
String author = request.substring("/get_books?author=".length());
return service.findBooksByAuthor(author);
}
// ...
}

2. Teenuskiht - vajadusel rakendab äriloogikat, delegeerib töö edasi andmehoidlale:

public String findBooksByAuthor(String author) {
return repository.findByAuthor(author).toString();
}

3. Andmehoidla — teeb päringu andmeallikale ja tagastab sobivad raamatud:

// Inside the concrete implementation (e.g. DatabaseBookRepository):
public List<Book> findByAuthor(String author) {
// query the database for books where author matches
}

Tulemus liigub tagasi käidud teed pidi:

Repository  ->  Service  ->  Controller  ->  response

Miks selline eraldatus on oluline

Testitavus

Igat kihti saab testida eraldi, kasutades sõltuvuste süstimist ja mock'imist:

  • Teenuse testid mock'ivad andmehoidlat ja kontrollivad äriloogikat eraldatult.
  • Kontrolleri testid mock'ivad teenust ja kontrollivad, et päringud töödeldakse õigesti.
  • Integratsioonitestid seovad kontrolleri ja teenuse kokku mock'itud repositooriumiga, et kontrollida kogu ahelat.
Unit test:          BookService        ->  mock(BookRepository)
Unit test: LibraryController -> mock(BookService)
Integration test: LibraryController -> BookService -> mock(BookRepository)

Hooldatavus

Kui igal kihil on üks selgelt määratletud vastutus:

  • Ärireeglite muudatused mõjutavad ainult teenust.
  • Päringu formaadi muudatused mõjutavad ainult kontrollerit.
  • Andmebaasi muudatused mõjutavad ainult andmehoidlat.

See muudab koodibaasi lihtsamini mõistetavaks, muudetavaks ja vähendab ootamatute vigade tekkimise riski.

Asendatavus

Kuna iga kiht sõltub allpool olevast kihist abstraktsiooni kaudu (liides või konstruktorisse süstitud klass), saab iga kihti asendada:

  • Vaheta DatabaseBookRepository FileBookRepository vastu ilma teenust muutmata.
  • Vaheta kontrolleri päringu formaat kohandatud stringist HTTP-ks ilma teenust või andmehoidlat muutmata.
  • Testides saab iga kihi asendada mock'iga.