Слоеве
Накратко: Слоестата архитектура дели приложението на представяне, приложение, домейн и данни, а зависимостите сочат само надолу, затова бизнес правилото живее на едно място.
Проблем
Отстъпката на касата и в отчета
Магазинът дава 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: ...# Представяне: заявката влиза, отговорът излиза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: ...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)Стъпка по стъпка, с думи
- Четири слоя: представяне, приложение, домейн, данни. Всеки вика само слоевете под себе си: стрелките сочат надолу.
- Заявката на касата за Мария влиза в контролера. Той само чете заявката и показва отговора.
- Контролерът дава задачата на слоя на приложението: услугата знае стъпките на плащането.
- Услугата пита слоя с данните за количката: 100 €. Хранилището дава данни и не държи правила.
- После пита домейна: Pricing знае правилото „от 100 € нагоре е 10% отстъпка“. 100 € става 90 €.
- Отговорът се качва до контролера и той печата реда. Отчетът пита същия Pricing.
- Сега някой кара хранилището да връща сумата след отстъпката. Слоят с данните вика Pricing: стрелка нагоре.
- Количката на Иван: хранилището дава 135 €, а услугата сваля 10% още веднъж. Плаща 122 € вместо 135 €.
- Надолу може, нагоре е бъгът: правилото пак изтече в данните, както в SQL заявката на отчета.
Мария плаща 90 € и е в отчета, защото и двете места питат едно и също правило. Услугата вика хранилището, без да минава през домейна: това е позволено, защото хранилището е под нея. Правилото е само за посоката.
Пробвай
Правилото без нищо около него
Домейнът не знае за контролери и хранилища, затова се проверява сам: без заявка, без количка и без база. Такъв е и тестът му, като в урока Тестове с unittest.
Какво печата програмата?
for subtotal in [99, 100, 101]: print(f"{subtotal} € → {Pricing.total(subtotal)} €")Границата е включена: 100 € вече дава отстъпка. От 101 € се сваля 10 €, защото делението е цяло и 101 / 10 е 10. Точно тези граници (99, 100, 101) са първият тест на всяко правило за пари.
Пробвай
Намери грешката
Отчетът иска сумата след отстъпката. Някой решава, че е най-лесно хранилището да я дава готова:
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 €, а Мария пак липсва в отчета. На кой ред е грешката?
Избери реда с грешката.
Слоят с данните вика домейна: стрелка нагоре. Хранилището вече знае правилото, и то се прилага два пъти: 150 € → 135 € в хранилището, после 135 € → 122 € в услугата. Отчетът пита „намалява ли се 90 €?“ и казва „не“ за Мария. Това е същият проблем като WHERE в SQL заявката от началото: правило, което живее в данните. Хранилището трябва да връща само това, което пази. Сумата след отстъпката е работа на услугата, която пита Pricing.
Идеята
По техника или по функция
Слоевете казват кой кого вика. Отделен въпрос е как се подреждат файловете. Първият начин е по техника: една папка за всеки слой.
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) и печата всяко извикване нагоре.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Тестове с unittest · Singleton и Dependency Injection · Repository · SOLID без догма
- Продължава в: Hexagonal и clean architecture · Монолит, модулен монолит, услуги · Кеш · ADR: записани архитектурни решения
- Задачи по темата: Софтуерни архитектури