這篇文章中我們將會以 Clean Architecture 為基準,來定義好的架構的特點。
然後這本書我自已覺得他比較算是在定義好的軟體架構,比較不算是很明確的說像 Hexagonal Architecturev、Onion Architecture、DCI、BCE 之類這種明確的架構,也就是說好有那些 Layer 之類的。但題外話,這本書我自已覺得廢話有點多……
還有順到說一下,這裡面有一些也是我自已在找好的軟體架構特點,所以如果看到一些不是 clean architecture 裡面寫的,別太意外,因為我還是有參考其它東西 ~ 只是主要是 base Clean Architecture。
接下來我們先來看乾淨的架構最常看到的這張圖:
接下來我幾個好的特點,會以這張圖為基礎來開始一個一個說,然後總共有 5 個,但不一定都是乾淨架構的,他也有被人嘴,但整體上我覺得他可以當個 base。
首先這個地方應該也是傳統上分層架構想要的特點也是相同的,它們的目的都是為了:
讓每一個層級有職責,不要什麼都是大泥球
然後在 Clean Architecture 中它就是根據那張圖分層了大約以下的 layer:
這個第一個特點我覺得大概就降,然後順到說一下,作者也有提到不是只有這 4 個 :
Only Four Circles?
No, the circles are schematic. You may find that you need more than just these four. There’s no rule that says you must always have just these four.However, The Dependency Rule always applies.
根據最後一句話,事實上依賴規則才是重點。
這讓偶想到我們的 Util (淚
在書中提到,高階與低階的分法為 :
越接近 I/O 的就越低階,反之則越高階,以那張圖來看 Entity 就是最高階
那為什麼要這樣呢 ? 因為只要分的清楚,那就會知道我們具體實現大部份都集中在那,那這樣就代表我們可以解依賴,讓低階的修改不會影響到高階。
然後順到說一下,以這樣來說那以下兩個我們常見的 layer 都算是低階 :
所以也就是說 controller、repository 的修改是不能影響到 usecase 與 domain model 的。
總共有 3 點:
就簡單的說就是 domain model (最內層),不能 new 外部的東西,例如 usecase 之類的,如下範例,它是一個 user domain model,然後它需要訂單金額來計算等級,通常是這種情況下,會是由外部帶入 order domain model,或是外部計算好金額帶入到這個 domain model 中。
// Bad
class User {
id: string;
level: Level;
constructor(private orderService: OrderService) {}
calcuateUserLevel () {
const orders = await orderService.getOrdersByUserId(this.id)
// 然後用 orders 金額來計算等級之類的。
}
}
但這裡有個地方有矛盾,你的 usecase ( service ),一定需要 repository 啊……
那這樣不就是 usecase (內層) 依賴了 repository(外層),對吧 ?
// Domain Layer - CourseService.ts
import { CourseRepository } from '../infrastructure/CourseRepository';
class CourseService {
private courseRepository: CourseRepository;
constructor(couresRepository: CourseRepository) {
// 直接依賴具體的 CourseRepository 而非抽象接口
this.courseRepository = couresRepository;
}
async getCourse(courseId: string): Promise<Course | null> {
return await this.courseRepository.findCourseById(courseId);
}
async createCourse(course: Course): Promise<void> {
await this.courseRepository.saveCourse(course);
}
}
還記得我們之前提的依賴反轉原則 ( DIP: Dependency Inversion Principle )嗎 ?
- 高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。
- 抽象不要依賴細節,細節要依賴於抽象
Day-03 : 設計原則之 SOLID - ISP、DIP
它的反轉
就是可以幫我們解決這個問題。
將 repository 的 interface 放在 domain layer
然後順到說一下不只 repository 的 interface,還有以下的 inteface 可能都會開在內層, ,例如 usecase 的 input、output,我看過超多直接 api body 進來的東西直接丟到內層…
// Domain Layer - ICourseRepository.ts
interface ICourseRepository {
findCourseById(courseId: string): Promise<Course | null>;
saveCourse(course: Course): Promise<void>;
}
------------------------------------------------------------------
// Domain Layer - CourseService.ts
import { ICourseRepository } from './ICourseRepository';
import { Course } from './Course';
class CourseService {
private courseRepository: ICourseRepository;
constructor(courseRepository: ICourseRepository) {
this.courseRepository = courseRepository;
}
async getCourse(courseId: string): Promise<Course | null> {
return await this.courseRepository.findCourseById(courseId);
}
async createCourse(course: Course): Promise<void> {
await this.courseRepository.saveCourse(course);
}
}
// Infra Layer - CourseRepository.ts
export class CourseRepository implements ICourseRepository {
async findCourseById(courseId: string): Promise<Course | null> {
// 模擬從資料庫查詢數據
return await mockDatabase.find(courseId);
}
async saveCourse(course: Course): Promise<void> {
// 模擬將課程保存到資料庫
await mockDatabase.save(course);
}
}
簡單的說就是只有業務可以影響實作,而不能實作影響業務。然後工程術語就是只能外層 new 內層,不能內層的 new 外層。
這個在實作篇有提到,很多時後我們通常會分層三個層級 controller、service、repository,那在實務上是不是有很多時後會有人直接 controller -> repository 呢 ?
有吧 ? 我之前也有做這個事情,然後書中提到可能會有以下的後果:
主要的原因在於:
沒有分 Feature 或是 Bounded Context 的情況下,就像是一個 service 資料夾中,一塊 service,但是沒有人知道他們之間的關係。
沒有的話最後大概就長的如下,我相信應該不少人有看過:
src/
├── services/
│ ├── UserService.ts # 處理用戶邏輯,但與其他服務關聯不明確
│ ├── CourseService.ts # 負責課程管理,但與 User、Lecture 等的關係模糊
│ ├── LectureService.ts # 負責課程講座,但與 Assignment 是否有聯繫不明
│ ├── AssignmentService.ts # 處理作業管理,與 Course 是否有關?不清楚
│ ├── AnnouncementService.ts # 負責公告邏輯,是否與其他課程相關?不明確
│ ├── NotificationService.ts # 處理通知,但不清楚與 Announcement 和 Email 是否有關聯
│ ├── EmailService.ts # 處理郵件,但是否與 Notification 相關未明確
│ ├── PaymentService.ts # 處理付款邏輯,但不知與 Course 或 User 是否有依賴關係
│ ├── AuthService.ts # 處理用戶認證,但與 UserService 或其他安全邏輯的關係不明
│ ├── LoggingService.ts # 記錄系統日誌,不知道具體哪些服務會依賴這個
│ ├── ReportService.ts # 負責生成報告,但與 Assignment 或 Course 的聯繫不明
│ ├── StatisticsService.ts # 負責數據統計,不清楚是否與 Report 或其他服務共享資料
│ ├── FeedbackService.ts # 處理用戶反饋,是否與其他服務有關聯?不明確
│ ├── ArchiveService.ts # 負責歸檔,但與 Assignment 或 Course 是否有互動不清楚
│ ├── GradingService.ts # 負責打分,但是否與 Assignment 有直接關聯不清楚
│ ├── BadgeService.ts # 負責徽章系統,與 Grading 或 User 是否有聯繫未明
~備註~
這點預設的情況是正常產品服務的情況下,但是如果是一些比較特別的,例如 BFF,或是已經是微服務很單獨立,例如 Video Service 這種情況,我自已覺得這個就可能不需要了。
這個在 Clean Architecture 實作篇中有提到 4 種模式,分別為:
其中第一種除非你是那種專案做了後,就可以不用維護的情況,不然沒必要就不要這樣搞,像偶們家……第 1 個版本的軟體架構就是 No Mapping本 ( 偶們家最大服務有 4 個軟體架構…… ),就是資料庫的 mongoose model 世界通用,結果導致現在要追一個方法的回傳結果,都要看一下這個 model 中間有沒有修改還啥……
圖片來源: Clean Architecture 實作篇 - No Mapping
然後接下來第二種那個 Account 就是 Domain Model,然後 controller 那要出去時會轉成 WebModel,然後要儲到資料庫時就會轉成 Persistence Model。這樣的好處在於:
Domain Model 不會被 API 與資料庫的需要,而進行更改,例如 API 畫面要某個欄位,但 Domain Model 沒有。
但這個有幾個東西可能要想一下:
圖片來源: Clean Architecture 實作篇 - Two-Way Mapping
接下來第三種的 Full Mapping,它的特色如下,我自已覺得我實作上比較偏像這種,但還是有一些變化:
整體來說我自已比較喜歡這個。
圖片來源: Clean Architecture 實作篇 - Full Mapping
最後一種 One-Way Mapping 比較特別一點,那就是 DomainModel、WebModel、PersistenceModel 都會有個統一的 state inteface,然後這個好處就是可以減少 mapping 的工作。
圖片來源: Clean Architecture 實作篇 - One-Way Mapping
這個寫個範例會比較清楚一點 :
interface StateInterface {
id: string;
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
createdAt: Date;
updatedAt: Date;
}
// -----------------------------------------------------
// WebModel
class OrderWebModel implements StateInterface {
id: string;
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
createdAt: Date;
updatedAt: Date;
displayTotal: string; // Web 層特有的屬性,用於顯示格式化後的金額
constructor(order: OrderDomainModel) {
this.id = order.id;
this.status = order.status;
this.createdAt = order.createdAt;
this.updatedAt = order.updatedAt;
this.displayTotal = `$${order.totalAmount.toFixed(2)}`;
}
}
// -----------------------------------------------------
// Domain Model
class OrderWebModel implements StateInterface {
id: string;
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
createdAt: Date;
updatedAt: Date;
displayTotal: string; // Web 層特有的屬性,用於顯示格式化後的金額
constructor(order: OrderDomainModel) {
this.id = order.id;
this.status = order.status;
this.createdAt = order.createdAt;
this.updatedAt = order.updatedAt;
this.displayTotal = `$${order.totalAmount.toFixed(2)}`;
}
// 可以包含一些前端專用的格式化方法
getFormattedCreatedAt(): string {
return this.createdAt.toDateString();
}
}
// -----------------------------------------------------
// PersistenceModel
class OrderPersistenceModel implements StateInterface {
id: string;
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
createdAt: Date;
updatedAt: Date;
dbId: string; // Persistence 層特有的屬性,表示資料庫中的唯一識別符
constructor(data: any) {
this.id = data.id;
this.status = data.status;
this.createdAt = new Date(data.createdAt);
this.updatedAt = new Date(data.updatedAt);
this.dbId = data.dbId;
}
}
整體來說如下:
src/
├── course/
│ ├── use-cases/
│ │ ├── CreateCourseUseCase.ts
│ │ ├── GetCourseUseCase.ts
│ │ ├── intefaces/
│ │ └── ICourseRepository.ts
│ │ └── commands/
│ │ └── CreateCourseCommand.ts
│ ├── domain/
│ │ ├── entities/
│ │ │ └── Course.ts
│ │ └── value-objects/
│ │ └── CourseId.ts
│ ├── infrastructure/
│ │ ├── repositories/
│ │ │ └── CourseRepository.ts
│ │ └── database/
│ │ └── MongoDBConnection.ts
│ ├── presentation/
│ │ ├── controllers/
│ │ │ └── CourseController.ts
│ │ ├── models/
│ │ │ └── WebCourseModel.ts
│ └── shared/
│ ├── mapper/
│ │ └── CourseMapper.ts
│ └── utils/
│ └── DateFormatter.ts
├── article/
│ ├── use-cases/
│ ├── domain/
│ ├── infrastructure/
│ ├── presentation/
│ └── shared/
└── shared/
這篇文章中,咱們根據 Clean Architecture 來找出好的架構的特點 :
然後事實上我們很常聽到的 Hexagonal Architecture 事實也都有這些特點,所以他也是一種 Clean Architecture。
然後以整個維護性來看,我自已覺得軟體架構是最重要的,因為我發現就算一個地方寫的在好,但是只要軟體架構沒訂好,那最後那個好的地方也會被慢慢的污染。
然後接下來的章節就是在這個基準上,peak 每一個地方的細節了。