字數總計:0 個 | 閱讀時長:0 分鐘 |閱讀次數:

前言

過去幾十年,我們已經看過許多系統架構的一系列思維(Alistair Cockburn六角形架構、James Coplien&Trygve Reenskaug的DCI架構、Ivar Jacobson的BCE架構),儘管這些架構在細節上有所不同,但它們都非常相似,那就是關注點的分離,它們都將軟體分成幾層(Layers)來實現這種分離; 業務規則、使用者及系統界面也都有屬於它們自己的layer存在著,layers具備以下特徵:

  1. 獨立於框架: 架構不依賴於一些現有的強大軟體程式庫,它允許你使用這樣的框架作為工具,但不是強迫你將系統塞進有限的約束之中。
  2. 可測試: 業務規則可以在沒有UI、資料庫、Web伺服器或任何其他外部元素的情況下,進行測試。
  3. 獨立於UI: 在無需更改系統其餘部分的情況下,UI應該容易做更改,如當把Web UI更換成主控台UI時,無須更改業務規則。
  4. 獨立於資料庫: 你可以將 Mongo、BigTable、CouchDB或其他資料儲存抽換成 Oracle 或 SQL server,你的業務規則不綁定到資料庫。
  5. 獨立於任何外部代理: 業務規則應該對外部的介面一無所知。

依賴規則

下圖的同心圓代表軟體的不同領域,一般來說,越裡面的軟體,其層次就越高,外圈是機制,內圈是策略,實現下圖最重要的規則是依賴規則(Dependency Rule):

原始碼依賴關係只能指向內部,朝向更高層級的策略。

內圈不用了解外圈的任何事情,任何在外圈宣告的函式、類別、變數、資料格式等都不應該在內圈的程式碼中出現。

Image 1
  1. Use Case Interactor 實現 Use Case Input Port。
  2. Use Case Interactor 引用了(或依賴) Use Case Output Port。
錯誤案例
# presenter.py

from pydantic import BaseModel

class PersonModel(BaseModel):
    id: int
    name: str
    age: int
    grade: str
# controller.py

from src.model.presenter.person_output import PersonModel
from src.services.test_service import get_person_info_srvs
from fastapi import APIRouter

test_router = APIRouter(prefix="/api", tags=["testTools"])

"""
1. response_model是一個presenter,且實踐了 PersonModel。
2. test_router是一個controller,且引用了 get_person_info_srvs。
"""
@test_router.get("/study/example", response_model=PersonModel)
def get_person_info(id: int):
    return get_person_info_srvs(id=id)
# interactor.py

from src.model.presenter.person_output import PersonModel # 違反內層不能依賴外層的原則

def get_person_info_srvs(id: str) -> PersonModel:
    people_db = [{
        "id": 1,
        "name": "John Doe",
        "age": 20,
        "grade": "A"
    }, {
        "id": 2,
        "name": "Jane Smith",
        "age": 22,
        "grade": "B"
    }, {
        "id": 3,
        "name": "Alice Johnson",
        "age": 21,
        "grade": "A"
    }]
    for person in people_db:
        if person["id"] == id:
            return PersonModel(**person)
    not_existed_person = {"id": 0, "name": "NA", "age": 0, "grade": "NA"}
    return PersonModel(**not_existed_person)

不好的架構: Image 1

實體層

Entity封裝企業級的關鍵業務規則,其可以是具有方法的物件,也可以是一組資料結構和函式,只要能被不同的應用程式使用即可。

將關鍵業務資料與關鍵業務規則一起放進一個單一的軟體模組中,如此而已,因此,既然它只是業務規則,當外部(GUI、資料庫等)發生變化時,它們是最不可能改變的。

使用案例層

封裝並實作系統所有的使用案例,指揮 Entity 層的物件來實現業務邏輯的計算。

而為什麼Entity是高層級的但使用者案例是低層級的呢? 因為使用案例是特定於單一應用程式的,它更接近系統的輸入與輸出,而entity則是可以使用在許多不同的應用程式的一種泛式表達,因此use case 依賴entity,但entity不依賴Use case。

因此,此層的變化不會影響到實體,更重要的是,GUI與資料庫的變更也不會影響到此層。

案例說明: 上面的PersonModel中,如果多一個屬性叫做alias,其業務規則就是由id+name所組成,而在不同的使用者案例中我們都會用到此alias做不同的事情,此時我們應該可以考慮在Entity層中,新增類似PersonModel的物件,實作alias的邏輯,並給interactor(使用案例層)所使用。(應拆離出來)

介面轉接層

是一組轉接器,可以將使用案例和實體的資料格式轉換為某些外部代理所需的資料格式,反之亦然。例如包含GUI的MVC架構裡的表示器(presenter)、視圖(view)和控制器(controller)都屬於此層。

案例說明: 上面的controller.py、presenter.py 就是隸屬介面轉接層的一種物件。

框架和驅動層

此層由框架和工具(例如資料庫和Web框架)所組成。一般來說,這一層不需要編寫太多程式碼,有時候更多的是對框架裡面細節的設定。

案例說明: fastapi是一種Web API框架,因此隸屬這層。

只有四圈嗎?

或許你需要的不只四圈,也可能不需要四圈,本書沒有規定你一定要用四圈,但依賴規則始終是適用的。原始碼的依賴關係永遠要指向內部,隨著你朝內移動時,抽象和策略的層級會更高。

跨越邊界

我們通常利用依賴反向原則DIP來解決依賴關係與控制流相反的問題,在類似Java語言中,我們透過安排介面和繼承關係,來解決這種矛盾,例如,使用者案例需要呼叫表示器,但直接呼叫會違反依賴規則(外圈沒有任何函式、類別、變數等等可以出現在內圈的程式碼中。),所以在內圈我們要有一個使用案例呼叫的介面(Use case Output),並在外圈要有個表示器(Presenter)來實作它。 Image 1

哪些資料跨越邊界?

通常跨越邊界的物件(class、hashmap等)是由簡單的資料結構組成的,簡單的資料結構跨越邊界傳遞的重點在於獨立,總之我們不希望資料結構有任何違背依賴規則的依賴關係,舉例來說,DB查詢回來的資料物件勢必會在Entity層或Use case層被使用,該物件應被設計成是內層容易使用的形式,而非配合外層。

一個典型的場景

下圖是一個基於Web的Java系統使用資料庫的典型場景,Web伺服器將使用者輸入的資料傳遞至Controller,Controller將這些資料打包成一個舊的Java物件POJO(plain old java object),透過InputBoundary將這個物件傳給UseCaseInteractor,UseCaseInteractor透過呼叫Entities並傳入這些資料進行一些操作,在操作的過程中,Entities使用DataAccessInterface將會使用到的資料從資料庫傳遞到記憶體中進行操作便返回給UseCaseInteractor,UseCaseInteracto在將這些資料包裝成OutputData並建構另一個POJO,最後透過OutputBoundary傳給Presenter。

Presenter的工作是將OutputData重新打包為視圖形式的ViewModel,這是另外一個POJO,而View物件要做的事情只有將ViewModel中的資料移動到HTML頁面。

重點: 下圖所有的依賴關係在跨越邊界的時候,都是指向內圈的。

Image 1
範例
# test_router
from fastapi import File, UploadFile, Form, HTTPException, Response
from src.model.presenter.person_output import PersonPresenter
from src.utils.cloud_sql.test_sql import DataAccess
from src.services.test_service import person_use_case_interactor

@test_router.get("/study/example", response_model=PersonPresenter)
def get_person_info(id: int):
    output_data = person_use_case_interactor(id=id, db=DataAccess())
    return PersonPresenter(alias=output_data.alias, color='red')
# Presenter
from pydantic import BaseModel

class PersonPresenter(BaseModel):
    alias: str
    color: str
# interactor
from abc import ABC, abstractmethod
from typing import Dict, Any
from src.services.entities.test_entity import PersonEntity

class IDataAccess(ABC):

    @abstractmethod
    def get_person(id: str) -> Dict[str, Any]:
        pass


class OutputData:

    def __init__(self, alias: str):
        self.__alias = alias

    @property
    def alias(self):
        return self.__alias


def person_use_case_interactor(id: str, db: IDataAccess) -> OutputData:
    # 1. 可以再寫一個interface給person_use_case_interactor繼承,該interface就是Input Boundary。
    person_temp = db.get_person(id=id)
    person_entity = PersonEntity(id=person_temp['id'], name=person_temp['name'], age=person_temp['age'], grade=person_temp['grade'])
    return OutputData(alias=person_entity.get_alias())
# Entity
class PersonEntity():

    def __init__(self, id: int, name: str, age: int, grade: str):
        self.id = id
        self.name = name
        self.age = age
        self.grade = grade

    def get_alias(self):
        return self.name + ',' + self.grade
# DB
from src.services.test_service import IDataAccess

class DataAccess(IDataAccess):

    def get_person(self, id: str):
        people_db = [{
            "id": 1,
            "name": "John Doe",
            "age": 20,
            "grade": "A"
        }, {
            "id": 2,
            "name": "Jane Smith",
            "age": 22,
            "grade": "B"
        }, {
            "id": 3,
            "name": "Alice Johnson",
            "age": 21,
            "grade": "A"
        }]
        for person in people_db:
            if person["id"] == id:
                return person
        not_existed_person = {"id": 0, "name": "NA", "age": 0, "grade": "NA"}
        return not_existed_person

實作整理:

  1. Controller: fastapi中router的部分就是請求得進入點,即Controller。
  2. (DS)Input Data: Use Case Interactor所使用的資料集,就是範例person_use_case_interactor中函式的input。
  3. (I)Input Boundary: 沒有實作。
  4. Use Case Interactor: 範例的person_use_case_interactor。
  5. Entities: 範例的PersonEntity。
  6. (I)Data Access Interface: 範例的IDataAccess。
  7. Data Access: 範例的DataAccess物件。
  8. DataBase: 沒有實作,或是說就是範例中的people_db變數(Mock data)。
  9. (DS)Output Boundary: 沒有實作。
  10. (I)Output Data: 範例的OutputData。
  11. Presenter: 範例的PersonPresenter。
  12. View Model: 書上解釋"Presenter的工作是將OutputData重新打包為視圖形式的ViewModel",所以Fastapi中的BaseModel應為ViewModel。
ChatGPT: 
在 FastAPI 中,BaseModel 是由 Pydantic 提供的一个基类,用于定义数据模型。它的主要作用是进行数据验证和序列化/反序列化。通过继承 BaseModel,可以方便地定义请求和响应的格式,并确保数据符合预期的结构和类型。

BaseModel 在 Clean Architecture 中可以看作是一种 ViewModel,因为它负责在不同层之间传递数据,并确保数据的完整性和一致性。具体来说,BaseModel 主要用于以下几种情况:
1. 请求数据验证:确保从客户端接收到的数据符合预期的格式和类型。
2. 响应数据格式化:定义返回给客户端的数据格式。
3. 数据转换:在不同层之间传递数据时,进行数据的转换和验证。
  1. View: 只負責將資料呈現在畫面上,即Swagger。

總結

遵守這些簡單的規則並不困難,它可以給你帶來很多好處,其中的好處包括,這是一個可(容易)測試的系統、外部元件(例如換資料庫或Web框架)過時時,你也可以很輕鬆的換掉它。