Към съдържанието
wannacode

Слоеве

Накратко: Слоестата архитектура дели приложението на представяне, приложение, домейн и данни, а зависимостите сочат само надолу, затова бизнес правилото живее на едно място.

Проблем

Отстъпката на касата и в отчета

Магазинът дава 10% отстъпка на количка от 100 € нагоре. Касата смята отстъпката в контролера, който обработва заявката. Отчетът за счетоводството е SQL заявка към същата таблица и има свое условие в WHERE:

# Касата: контролерът чете количката и сам смята отстъпката
class CheckoutController:
def __init__(self, carts):
self.carts = carts
def post(self, customer):
total = self.carts[customer]
if total >= 100:
total = total - total // 10
return f"{customer} плаща {total} €"
# Отчетът е SQL заявка към същата таблица:
# SELECT customer FROM carts WHERE subtotal > 100
# Тук таблицата е речник, а WHERE е if.
def discounted(carts):
return [customer for customer, subtotal in carts.items() if subtotal > 100]
carts = {"Мария": 100, "Иван": 150, "Петя": 80}
checkout = CheckoutController(carts)
for customer in carts:
print("Касата: " + checkout.post(customer))
print("Отчетът: с отстъпка са " + ", ".join(discounted(carts)))
Изход
Касата: Мария плаща 90 €
Касата: Иван плаща 135 €
Касата: Петя плаща 80 €
Отчетът: с отстъпка са Иван

Мария плаща 90 €, а отчетът казва, че няма отстъпка. В касата е >= 100, в заявката е > 100. Счетоводството вижда 10 € разлика и никой не знае коя версия е вярната.

Има и втора болка. Правилото е залепено за контролера: за да провериш дали >= 100 е вярно, ти трябват заявка, количка в таблица и отговор, който да прочетеш. Мобилното приложение ще иска трета версия.

Идеята

Четири слоя и една посока

В урока Repository видя контролер, услуга и хранилище един под друг. Тук ги подреждаме като цяло приложение и им даваме правило. Слоестата архитектура (layered architecture) дели кода на четири слоя:

  • представяне: контролерът. Чете заявката и показва отговора. Не смята;
  • приложение: услугата. Знае стъпките на една задача („вземи количката, сметни цената“), но не и правилата;
  • домейн: правилата на магазина. Pricing.total знае „от 100 € нагоре е 10%“ и нищо друго: нито заявки, нито таблици;
  • данни: хранилищата. Откъде идва количката.

Правилото на слоевете е едно: зависимостите сочат надолу. Горният слой вика долния, а долният не знае, че горният съществува. Затова домейнът може да се ползва от касата, от отчета и от мобилното приложение, без да се пипа.

Слоят не е непременно клас. В голям проект слоят е пакет или проект, в който стоят много класове.

Виж го

Заявката през слоевете

Четирите слоя на касата, отгоре надолу:

# Представяне: заявката влиза, отговорът излиза
class CheckoutController:
def __init__(self, service):
self.service = service
def post(self, customer):
total = self.service.checkout(customer)
return f"{customer} плаща {total} €"
# Приложение: стъпките на една задача
class CheckoutService:
def __init__(self, carts):
self.carts = carts
def checkout(self, customer):
subtotal = self.carts.subtotal_of(customer)
return Pricing.total(subtotal)
# Домейн: правилото на магазина и нищо друго
class Pricing:
@staticmethod
def total(subtotal):
if subtotal < 100:
return subtotal
return subtotal - subtotal // 10
# Данни: откъде идва количката
class CartRepository(Protocol):
def subtotal_of(self, customer: str) -> int: ...
CartRepository е Protocol: описва какво трябва да има хранилището, но никой не наследява от него. Python приема всеки обект, който има subtotal_of, а проверка на типовете (mypy) може да каже рано, ако го няма.

Хранилището в паметта и отчетът, който сега пита домейна, вместо да пише свое условие:

class InMemoryCarts:
def __init__(self):
self.subtotals = {}
def put(self, customer, subtotal):
self.subtotals[customer] = subtotal
def subtotal_of(self, customer):
return self.subtotals.get(customer, 0)
# Приложение: отчетът пита домейна, не пише свое правило
class DiscountReport:
def __init__(self, carts):
self.carts = carts
def discounted(self, customers):
names = []
for customer in customers:
subtotal = self.carts.subtotal_of(customer)
if Pricing.total(subtotal) < subtotal:
names.append(customer)
return names

Сглобяването: касата и отчетът над едни и същи данни.

carts = InMemoryCarts()
carts.put("Мария", 100)
carts.put("Иван", 150)
carts.put("Петя", 80)
customers = ["Мария", "Иван", "Петя"]
web = CheckoutController(CheckoutService(carts))
for customer in customers:
print(web.post(customer))
report = DiscountReport(carts)
print("С отстъпка: " + ", ".join(report.discounted(customers)))
Изход
Мария плаща 90 €
Иван плаща 135 €
Петя плаща 80 €
С отстъпка: Мария, Иван

# Представяне: заявката влиза, отговорът излиза
class CheckoutController:
def __init__(self, service):
self.service = service
def post(self, customer):
total = self.service.checkout(customer)
return f"{customer} плаща {total} €"
# Приложение: стъпките на една задача
class CheckoutService:
def __init__(self, carts):
self.carts = carts
def checkout(self, customer):
subtotal = self.carts.subtotal_of(customer)
return Pricing.total(subtotal)
# Домейн: правилото на магазина и нищо друго
class Pricing:
@staticmethod
def total(subtotal):
if subtotal < 100:
return subtotal
return subtotal - subtotal // 10
# Данни: откъде идва количката
class CartRepository(Protocol):
def subtotal_of(self, customer: str) -> int: ...
Стъпка по стъпка, с думи
  1. Четири слоя: представяне, приложение, домейн, данни. Всеки вика само слоевете под себе си: стрелките сочат надолу.
  2. Заявката на касата за Мария влиза в контролера. Той само чете заявката и показва отговора.
  3. Контролерът дава задачата на слоя на приложението: услугата знае стъпките на плащането.
  4. Услугата пита слоя с данните за количката: 100 €. Хранилището дава данни и не държи правила.
  5. После пита домейна: Pricing знае правилото „от 100 € нагоре е 10% отстъпка“. 100 € става 90 €.
  6. Отговорът се качва до контролера и той печата реда. Отчетът пита същия Pricing.
  7. Сега някой кара хранилището да връща сумата след отстъпката. Слоят с данните вика Pricing: стрелка нагоре.
  8. Количката на Иван: хранилището дава 135 €, а услугата сваля 10% още веднъж. Плаща 122 € вместо 135 €.
  9. Надолу може, нагоре е бъгът: правилото пак изтече в данните, както в SQL заявката на отчета.

Мария плаща 90 € и е в отчета, защото и двете места питат едно и също правило. Услугата вика хранилището, без да минава през домейна: това е позволено, защото хранилището е под нея. Правилото е само за посоката.

Пробвай

Правилото без нищо около него

Домейнът не знае за контролери и хранилища, затова се проверява сам: без заявка, без количка и без база. Такъв е и тестът му, като в урока Тестове с unittest.

Какво печата програмата?

for subtotal in [99, 100, 101]:
print(f"{subtotal} € → {Pricing.total(subtotal)} €")
Отговор

Пробвай

Намери грешката

Отчетът иска сумата след отстъпката. Някой решава, че е най-лесно хранилището да я дава готова:

class InMemoryCarts:
def __init__(self):
self.subtotals = {}
def put(self, customer, subtotal):
self.subtotals[customer] = subtotal
def subtotal_of(self, customer):
subtotal = self.subtotals.get(customer, 0)
# отчетът искаше сумата след отстъпката
return Pricing.total(subtotal)

Касата и отчетът са същите, без нито един променен ред:

carts = InMemoryCarts()
carts.put("Мария", 100)
carts.put("Иван", 150)
carts.put("Петя", 80)
customers = ["Мария", "Иван", "Петя"]
web = CheckoutController(CheckoutService(carts))
for customer in customers:
print(web.post(customer))
report = DiscountReport(carts)
print("С отстъпка: " + ", ".join(report.discounted(customers)))
Изход
Мария плаща 90 €
Иван плаща 122 €
Петя плаща 80 €
С отстъпка: Иван

Иван плаща 122 € вместо 135 €, а Мария пак липсва в отчета. На кой ред е грешката?

Избери реда с грешката.

Идеята

По техника или по функция

Слоевете казват кой кого вика. Отделен въпрос е как се подреждат файловете. Първият начин е по техника: една папка за всеки слой.

shop/
web/
checkout.py
catalog.py
services/
checkout.py
catalog.py
domain/
pricing.py
product.py
data/
carts.py
products.py

Вторият е по функция (vertical slice): една папка за всяка част от магазина, а слоевете са вътре в нея.

shop/
checkout/
views.py
service.py
pricing.py
carts.py
catalog/
views.py
service.py
product.py
products.py

По техника е лесно за начало, но всяка промяна в плащането пипа четири папки, а папката service/ расте с всичко. По функция промяната е на едно място, и всяка папка може да има собствени правила кой отвън я вика. Това е стъпката към модулите в урока Монолит, модулен монолит, услуги. Правилото за посоката важи и в двата случая.

Идеята

Кога слоевете вредят

Слоевете струват файлове и извиквания. Не си струват, когато:

  • няма правила. Форма, която само записва в базата, минава през контролер, услуга и домейн, които само препращат. Това се казва sinkhole: заявката пада през слоевете, без никой да я пипне;
  • всеки слой има свой обект за същите данни: CartDto, CartModel, CartEntity, и превръщането между тях е половината код;
  • всичко е подредено по техника и проектът е голям: папката service/ има 200 файла, които не са свързани помежду си.

Струват си, когато има правила, които трябва да се пазят на едно място и да се проверяват без база, и когато едно и също правило се вика от няколко места: каса, отчет, мобилно приложение.

По-точно, за любопитните

Строги и свободни слоеве. При строгите слоеве всеки слой вика само съседа си. При свободните (както в урока) може да прескочи надолу: услугата вика хранилището направо. Повечето проекти са свободни. Строгото правило дава повече препращане и рядко помага.

В рамките. Spring слага слоевете в анотации: @Controller, @Service, @Repository. ASP.NET Core има контролери, а услугите и хранилищата се регистрират в DI контейнера. Django е подреден по функция: всяко „app“ има свои views.py и models.py. Nest (Node) групира контролер и услуги в модул на функцията, а Express не налага нищо. Никоя рамка не пази посоката сама: @Repository, който вика @Service, се компилира.

Пазене на посоката. Посоката може да се провери автоматично: ArchUnit в Java, ArchUnitNET или NetArchTest в C#, import-linter в Python и dependency-cruiser в JavaScript падат, ако пакет от данните внася пакет от домейна. Малка проверка от този вид за модулите пишеш в урока Монолит, модулен монолит, услуги.

Парите. В урока сумите са цели евро, за да е изходът еднакъв на четирите езика. В истински магазин парите са в центове (long) или в Decimal, никога в double.

Пред какво още. В урока Hexagonal и clean architecture посоката се обръща докрай: домейнът не зависи дори от хранилището.

Упражнение

Доставката в домейна

Реши задачата в редактора си.

Доставката е 5 € за поръчка под 50 € и безплатна от 50 € нагоре (сумата е след отстъпката). Сложи правилото в домейна, в Delivery.fee(total). Нов контролер показва цената и доставката, а услугата и хранилището не се пипат. Накрая провери правилото само, без контролер, за 49 € и 50 €.

Изход
Мария плаща 90 € + доставка 0 €
Петя плаща 80 € + доставка 0 €
Георги плаща 30 € + доставка 5 €
49 € → доставка 5 €
50 € → доставка 0 €

Още задачи

  • Мобилното приложение. Добави втори контролер, който връща същата сума като JSON текст {"customer":"Мария","total":90}. Колко реда от правилото пипаш?
  • Отстъпка по клиент. Клиентите с карта за лоялност получават 15% от 100 €. Къде влиза картата: в хранилището, в домейна или в контролера? Защо?
  • Проверка на посоката. Напиши функция, която получава списък „кой слой кого вика“ (data → domain, application → data) и печата всяко извикване нагоре.

Тест

Провери се

1. В кой слой живее правилото „от 100 € нагоре е 10% отстъпка“?
2. Услугата вика хранилището направо, без да минава през домейна. Нарушение ли е?
3. Хранилището започва да вика Pricing, „за да дава сумата след отстъпката“. Какво става?
4. Вътрешен инструмент има три форми, които само записват в базата, без нито едно правило. Струват ли си четири слоя?
5. Подредбата е по функция (checkout/, catalog/). Какво печелиш спрямо подредбата по техника (web/, service/, data/)?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·