DDD 設計之服務端落地實踐

本篇內容來源於本人部門的開發經驗總結--注者:廖同窗數據庫

什麼是 DDD

DDD 全稱領域驅動設計,分爲戰略設計和戰術設計兩個層次。咱們在此討論的均屬於戰術設計範疇。bash

DDD 戰術設計本質上是面向對象的一種設計方法。根本目的與面向對象一致,仍然是爲了解決軟件項目中不斷增加的複雜性問題。架構

DDD 的適應範圍比面向對象設計要狹窄,但據咱們的實踐,至少在服務端開發的領域,DDD 能很好地產生他的效用。app

DDD 能帶來什麼

  • 統一術語,下降團隊溝通成本
  • 提升代碼可讀性,甚至達到無文檔化(代碼即文檔)
  • 提升代碼複用性
  • 帶來靈活性,擁抱變化

DDD 不能帶來什麼

  • 性能
  • bug
  • 一勞永逸的設計
  • ……

DDD 落地

DDD 一詞起源於 Eric Evans 的一本書**《領域驅動設計——軟件核心複雜性應對之道》**。許多同窗應該都知道,而且多少看過這本書,可是大多數人都會以爲很是抽象、難以理解,看完後也不知道該如何將這些理論運用到實踐中去。我我的的見解是,其實並非這本書難以理解,而是這本書誕生於 C/S 架構流行的年代,裏面許多案例實際上是以 C/S 的角度去舉例的。而咱們如今流行的是 B/S 架構的軟件,而且許多框架(如 Spring)幾乎已經成爲了服務端軟件開發的必選項,若是隻是照搬書上的那些例子,天然是沒法很好地進行落地的。框架

如下談及的內容是我在帶領團隊的過程當中總結出來的一些 DDD 在服務端的落地實踐,並不表明適合全部團隊或全部技術棧。ide

DDD 編寫的代碼所屬層次

咱們把 DDD 設計的相關代碼放到 Domain 層,這一層是介於經典三層架構中 Service 與 DAO 層之間的特殊的一層,但嚴格意義上來講仍是屬於 Service 層(處理業務邏輯),能夠想象成在原先的 Service 層上又劃分了一層出來。函數

以下圖所示微服務

image.png

示例

下面是咱們在 JAVA 工程中採用的一個 DDD 包結構規範post

TODO::性能

實體

以標識做爲其基本定義的對象稱爲實體 - Eric Evans

換句話說,即全部實體必須有一個惟一標識。

在咱們的實踐中,咱們通常使用 id 字段做爲實體的惟一標識。若是要區別某個對象是否一個實體,只要看他是否有 id 便可。

實體除了惟一標識外,每每還有不少其它屬性,所以實體每每還會依賴一個倉儲對象。有關倉儲,會在後面說起。

一個典型的實體定義以下:

public class Project {
    private Long id;
    private ProjectRepository repo;
    
    public Project(Long id, ProjectRepository repo) {
        this.id = id;
        this.repo = repo;
    }
    
    public ProjectDO data() {
        return repo.selectById(this.id);
    }
}
複製代碼

引用

咱們建議實體間的聚合採用軟關聯的方式,緣由是在服務端開發中,這種有狀態的對象朝生夕滅的狀況很是常見(服務端要管理的對象很是多,不可能將全部實體都存在內存中,通常一個請求過來時會建立對象,請求結束後在下一次 GC 這個對象就會被銷燬),而實體之間的關聯多是很是複雜的,每次使用時都構建一個完整的聚合很是不划算。

能夠看看如下兩種方式的區別:

硬關聯
public class Project {
    private Long id;
    private List<Application> apps;
    
    public Project(Long id, List<Application> apps) {
        this.id = id;
        this.apps = apps;
    }
    
    public List<Application> listApplications() {
        return this.apps;
    }
}
複製代碼
軟關聯
public class Project {
    private Long id;
    private ApplicationManager applicationManager;
    
    public Project(Long id, ApplicationManager applicationManager) {
        this.id = id;
        this.applicationManager = applicationManager;
    }
    
    public List<Application> listApplications() {
        return this.listAllApplicationId()
            .stream()
            .map(id -> applicationManager.get(id))
            .collect(Collectors.toList())
    }
}
複製代碼

FAQ

Q: 實體定義方法時是否可使用值類型

A: 能夠,但通常狀況下不建議(特殊狀況能夠這樣作,如考慮性能等問題的時候),由於這會致使方法的複用性大大下降。即便這樣作了,也應該儘可能返回較通用的值對象(如 DO),應避免使用 DTO, VO 等。

工廠

雖然在上面咱們採用了軟關聯的方式創建實體之間的引用關係,但這並不表明要構建一個實體就很是簡單了,緣由是咱們的實體除了依賴其它實體外,每每還須要依賴許多其它對象(如領域服務Manager倉儲等),而且隨着業務的變化,實體的依賴每每還會隨之發生變化,若是仍是經過傳統的 new 方式去建立一個實體,會產生一些災難性的問題:

  • 使用者必須清楚實體的建立細節,這會大大增長代碼的複雜度
  • 每當實體的構造方式發生變化時,不得不調整全部建立實體的代碼邏輯以解決代碼編譯問題

綜上,工廠的概念依然有必要存在於服務端 DDD 中。

通用實現

一個通用 Factory 的實現示例以下

public abstract class Factory {
    private static ProjectRepository projectRepository;
    
    public void setProjectRepository(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }
    
    public Project newProject(Long id) {
        return new Project(id, projectRepository);
    }
}
複製代碼

這種實現要求咱們在應用啓動的時候,經過鉤子函數去爲這個 Factory 把全部要用到的對象準備好,每當 Factory 須要的依賴變化時,都得調整這個鉤子函數,稍顯麻煩。如今服務端已經有許多很是成熟、方便的 IoC 框架(如 Spring),有條件的時候咱們也會結合這些框架來實現 Factory。

結合 Spring

一個基於 Spring 實現的 Factory 以下

@Component
public class Factory {
    @Autowired
    private ProjectRepository projectRepository;
    
    public Project newProject(Long id) {
        return new Project(id, projectRepository);
    }
}
複製代碼

實體管理者(Manager)

咱們稱其爲 Manager,對應的實際上是 Eric Evans 在書中提到的倉儲(實體倉儲)。爲何咱們不使用倉儲這個概念呢?緣由是在服務端開發中自己就有倉儲**(數據倉儲,也叫 DAO)**這個概念。爲了不概念混淆,咱們使用了另外一個概念 Manager。

與 Eric Evans 的倉儲概念定義一致,Manager 能夠爲使用者提供實體的建立刪除條件查詢操做。

Manager 每每還須要依賴倉儲(查詢持久化數據)及工廠(建立實體),而且能夠發佈事件

倉儲

上面提到咱們用 Manager 這個概念代替了本來 Evans 說的倉儲概念,那麼咱們如今說起的倉儲概念又是用來作什麼的呢?

咱們這裏定義的倉儲只負責與持久化數據打交道,即數據倉儲。爲何不直接使用 ORM?是由於咱們考慮到在如今流行的微服務架構中,服務拆分、沉澱是很常常發生的事。原先的大服務中,某個實體的數據多是經過 ORM 去查詢數據庫獲得的,而在拆分後,就變成了經過遠程調用去獲取了。爲了解決這一問題,咱們使用倉儲這一律念使得持久化數據的操做過程變得透明,若是發生服務拆分沉澱,那麼咱們的領域層不須要作任何修改(只要概念的定義沒有發生變化),只要調整倉儲層的實現便可。

一些使用原則

  • 實體不該該依賴屬於其它實體的倉儲
  • 實體不該該繞過倉儲直接訪問數據(如直接操做 ORM 框架)

領域服務

領域服務用於處理一些在概念上不屬於實體的操做,這些操做本質上每每是一些活動行爲,而且是無狀態的。對於這類操做,將其強制進行歸類會顯得很是彆扭,因而便引入了領域服務這一律念。須要明確的是,其與三層架構的 Service 層(應用服務)並非一個概念。另外與 Evans 在書中說起的示例不一樣,爲了不混亂,咱們通常不會爲領域服務的類命名加上 Service 後綴

示例

在某個管理主機的應用中,能夠指定主機執行一些 Shell 命令,而且會將輸出所有存儲起來。但因爲該操做執行頻繁,所以輸出記錄會至關龐大,須要須要定時查找超過 15 天的執行記錄並將其清理。

在以上背景中,存在幾個實體:Host、Exec、ExecOutput。從咱們的描述中可知,咱們須要完成的這個操做沒法歸類到任何一個實體中,所以咱們須要一個 ExecClearer 的領域服務來幫助咱們完成該操做。

因爲領域服務是無狀態的,所以咱們通常將其定義爲單例

@Compoment
public class ExecClearer {
    private ExecManager execManager;
    
    public void clearOutDated(Integer interval) {
        // 如下實現代碼與咱們要說明的內容無關,能夠無視
        OutDatedExecFinder finder = new OutDatedExecFinder(interval, execManager);
        while (finder.hasNext()) {
            finder.nextCollection()
                .stream().forEach(Exec::destroy);
        }
    }
}
複製代碼

在其它地方,咱們能夠直接注入該領域服務,並使用

@Slf4j
@Component
public class ExecScheduledTask {

    @Autowired
    private ExecClearer clearer;

    @Value("${exec.output.interval.days:15}")
    private Integer intervalDays;

    @Scheduled(cron = "0 0 0 * * ?")
    public void deleteExecData() {
        log.info("starting clear exec data, intervalDays=>{}", intervalDays);
        clearer.clearOutDated(intervalDays);
        log.info("clear exec data end");
    }
}
複製代碼

領域事件

在咱們的領域活動(實體、Manager 等操做)中會出現一系列的重要的事件,而這些事件的訂閱者,每每須要對這些事件做出響應(例如,新增用戶後,可能會觸發一系列動做:發送歡迎信息、發放優惠券等等)。領域事件能夠簡單地理解爲是發佈訂閱模式在 DDD 中的一種運用。

在咱們的實踐中,通常採用事件總線來快速地發佈一個領域事件。

事件總線的接口定義通常以下

public interface EventBus {
    void post(Event event);
}
複製代碼

經過調用 EventBus.post() 方法,咱們能夠快速發佈一個事件。

同時咱們還會提供一個抽象類 AbstractEventPublisher

public class AbstractEventPublisher implements EventPublisher {
    private EventBus eventBus;

    public void setEventBus(EventBus eventBus) {
        this.eventBus = eventBus;
    }

    @Override
    public void publish(Event event) {
        if (eventBus != null) {
            eventBus.post(event);
        } else {
            log.warn("event bus is null. event " + event.getClass() + " will not be published!");
        }
    }
}
複製代碼
public interface EventPublisher {
    void publish(Event event);
}
複製代碼

這樣咱們可讓實體或 Manager 繼承自 AbstractEventPublisher,其便有了發佈事件的能力。至於如何訂閱並處理這些事件,取決於 EventBus 的實現方式。舉個例子,咱們通常使用 Guava 的 EventBus,定義相關的 handler 並註冊到 EventBus 中即可方便地處理這些事件

@Component
public class DomainEventBus extends EventBus implements InitializingBean {
    @Autowired
    private FooEventHandler fooEventHandler;

    @Override
    public void afterPropertiesSet() {
        this.register(fooEventHandler);
    }
}
複製代碼
@Component
@Slf4j
public class FooEventHandler implements DomainEventHandler {
    @Override
    @Subscribe
    public void listen(ProjectCreatEvent e) {
        // do something here...
    }
}
複製代碼

限界上下文

顧名思義,在實際系統中會有很是多的業務上下文。對於這些業務上下文,可能會重複出現不少同名實體,這些實體有多是同一個概念,也有可能不是。

任何概念都有他適用的範圍,咱們在討論的時候必定要明晰咱們所討論的這些概念所處的一個上下文是什麼,不然咱們的溝通就有可能不在同一個頻道上。

單元測試

採用 DDD 的編碼模式後,業務邏輯主要彙集在實體中,原三層架構中的 Service 層會變得很是「薄」。所以,單元測試主要會針對實體領域服務等進行編寫。

DDD 設計

理解了 DDD 中的所有概念,也並不意味着就能作出一個好的設計了。

DDD 的設計最重要的是作好如下幾點:

  1. 準確地定義實體
  2. 準確地定義實體應該有哪些方法
  3. 確立實體與實體之間的關係

實體的設計實際上是一個建模的過程。面向對象的設計方法本質就是將現實世界的對象關係以簡化的形式提煉爲模型

模型是現實世界的一種簡化,但不該該與現實世界衝突。

概念不一致

關係不一致

相關文章
相關標籤/搜索