領域驅動設計 -DDD- 能給前端帶來什麼

爲什麼需要 DDD

在回答這個問題之前,我們先看下大部分軟件都會經歷的發展過程:頻繁的變更帶來軟件質量的下降

而這又是軟件發展的規律導致的:

可以看到需求的不斷變更和迭代導致了項目變得越來越複雜,那麼問題來了,項目複雜性提高的根本原因是需求變更引起的嗎?

根本原因其實是因爲在需求變更過程中沒有及時的進行解耦和擴展。

那麼在需求變更的過程中如何進行解耦和擴展呢?DDD 發揮作用的時候來了。

什麼是 DDD

DDD(領域驅動設計) 的概念見維基百科:https://zh.wikipedia.org/wiki/%E9%A0%98%E5%9F%9F%E9%A9%85%E5%8B%95%E8%A8%AD%E8%A8%88

可以看到領域驅動設計(domin-driven design)不同於傳統的針對數據庫表結構的設計,領域模型驅動設計自然是以提煉和轉換業務需求中的領域知識爲設計的起點。在提煉領域知識時,沒有數據庫的概念,亦沒有服務的概念,一切圍繞着業務需求而來,即:

在 DDD 中按照什麼樣的原則進行領域建模呢?

單一職責原則(Single responsibility principle)即 SRP:軟件系統中每個元素只完成自己職責內的事,將其他的事交給別人去做。

上面這句話有沒有什麼哪裏不清晰的?有,那就是 “職責” 兩個字。職責該怎麼理解?如何限定該元素的職責範圍呢?這就引出了 “限界上下文” 的概念。

Eric Evans 用細胞來形容限界上下文,因爲 “細胞之所以能夠存在,是因爲細胞膜限定了什麼在細胞內,什麼在細胞外,並且確定了什麼物質可以通過細胞膜。” 這裏,細胞代表上下文,而細胞膜代表了包裹上下文的邊界。

我們需要根據業務相關性耦合的強弱程度分離的關注點對這些活動進行歸類,找到不同類別之間存在的邊界,這就是限界上下文的含義。上下文(Context)是業務目標,限界(Bounded)則是保護和隔離上下文的邊界,避免業務目標的不單一而帶來的混亂與概念的不一致。

如何 DDD

DDD 的大體流程如下:

  1. 建立統一語言

統一語言是提煉領域知識的產出物,獲得統一語言就是需求分析的過程,也是團隊中各個角色就係統目標、範圍與具體功能達成一致的過程。

使用統一語言可以幫助我們將參與討論的客戶、領域專家與開發團隊拉到同一個維度空間進行討論,若沒有達成這種一致性,那就是雞同鴨講,毫無溝通效率,相反還可能造成誤解。因此,在溝通需求時,團隊中的每個人都應使用統一語言進行交流。

一旦確定了統一語言,無論是與領域專家的討論,還是最終的實現代碼,都可以通過使用相同的術語,清晰準確地定義領域知識。重要的是,當我們建立了符合整個團隊皆認同的一套統一語言後,就可以在此基礎上尋找正確的領域概念,爲建立領域模型提供重要參考。

舉個例子,不同玩家對於英雄聯盟(league of legends)的稱呼不盡相同;國外玩家一般叫 “League”,國內玩家有的稱呼“擼啊擼”,有的稱呼“LOL” 等等。那麼如果要開發相關產品,開發人員和客戶首先需要統一對 “英雄聯盟” 的語言模型。

  1. 事件風暴(Event Storming)

事件風暴會議是一種基於工作坊的實踐方法,它可以快速發現業務領域中正在發生的事件,指導領域建模及程序開發。它是 Alberto Brandolini 發明的一 種領域驅動設計實踐方法,被廣泛應用於業務流程建模和需求工程,基本思想是將軟件開發人員和領域專家聚集在一起,相互學習,類似頭腦風暴。

會議一般以探討領域事件開始,從前向後梳理,以確保所有的領域事件都能被覆蓋。

什麼是領域事件呢?

領域事件是領域模型中非常重要的一部分,用來表示領域中發生的事件。一個領域事件將導致進一步的業務操作,在實現業務解耦的同時,還有助於形成完整的業務閉環。

領域事件可以是業務流程的一個步驟,比如投保業務繳費完成後,觸發投保單轉保單的動作;也可能是定時批處理過程中發生的事件,比如批處理生成季繳保費通知單,觸發發送繳費郵件通知操作;或者一個事件發生後觸發的後續動作,比如密碼連續輸錯三次,觸發鎖定賬戶的動作。

  1. 進行領域建模,將各個模型分配到各個限界上下文中,構建上下文地圖。

領域建模時,我們會根據場景分析過程中產生的領域對象,比如命令、事件等之間關係,找出產生命令的實體,分析實體之間的依賴關係組成聚合,爲聚合劃定限界上下文,建立領域模型以及模型之間的依賴。

上面我們大體瞭解了 DDD 的作用,概念和一般的流程,雖然前端和後端的 DDD 不盡相同,但是我們仍然可以將這種思想應用於我們的項目中。

DDD 能給前端項目帶來什麼

通過領域模型 (feature) 組織項目結構,降低耦合度

很多通過 react 腳手架生成的項目組織結構是這樣的:

-components
    component1
    component2
-actions.ts
  ...allActions
-reducers.ts
  ...allReducers

這種代碼組織方式,比如 actions.ts 中的 actions 其實沒有功能邏輯關係;當增加新的功能的時候,只是機械的往每個文件夾中加入對應的 component,action,reducer,而沒有關心他們功能上的關係。那麼這種項目的演進方向就是:

項目初期:規模小,模塊關係清晰 ---> 迭代期:加入新的功能和其他元素 ---> 項目收尾:文件結構,模塊依賴錯綜複雜。

因此我們可以通過領域模型的方式來組織代碼,降低耦合度。

  1. 首先從功能角度對項目進行拆分。將業務邏輯拆分成高內聚松耦合的模塊。從而對 feature 進行新增,重構,刪除,重命名等變得簡單 ,不會影響到其他的 feature,使項目可擴展和可維護。

  1. 再從技術角度進行拆分,可以看到 componet, routing,reducer 都來自等多個功能模塊

可以看到:

通過 feature 來組織代碼結構的好處是:當項目的功能越來越多時,整體複雜度不會指數級上升,而是始終保持在可控的範圍之內,保持可擴展,可維護。

如何組織 componet,action,reducer

文件夾結構該如何設計?

  1. 每個 feature 下面分爲 redux 文件夾 和 組件文件

  1. redux 文件夾下面的 action.js 只是充當 loader 的作用,負責將各個 action 引入,而沒有具體的邏輯。reducer 同理

  1. 項目的根節點還需要一個 root loader 來加載 feature 下的資源

如何組織 router

組織 router 的核心思想是把每個路由配置分發到每個 feature 自己的路由表中,那麼需要:

  1. 每個 feature 有自己的路由配置

  2. 頂層的 routerConfig 引入各個 feature 的子路由

import { App } from '../features/home';
import { PageNotFound } from '../features/common';
import homeRoute from '../features/home/route';
import commonRoute from '../features/common/route';
import examplesRoute from '../features/examples/route';

const childRoutes = [
  homeRoute,
  commonRoute,
  examplesRoute,
];

const routes = [{
    path: '/',
    componet: App,
    childRoutes: [
        ... childRoutes,
        { path:'*', name: 'Page not found', component: PageNotFound },
    ].filter( r => r.componet || (r.childRoutes && r.childRoutes.length > 0))
}]

export default routes
  1. 解析 JSON 路由到 React Router
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import routeConfig from './common/routeConfig';

function renderRouteConfig(routes, path) {
    const children = []        // children component list
      const renderRoute = (item, routeContextPath) ={
    let newContextPath;
    if (/^\//.test(item.path)) {
      newContextPath = item.path;
    } else {
      newContextPath = `${routeContextPath}/${item.path}`;
    }
    newContextPath = newContextPath.replace(/\/+/g, '/');
    if (item.component && item.childRoutes) {
      const childRoutes = renderRouteConfigV3(item.childRoutes, newContextPath);
      children.push(
        <Route
          key={newContextPath}
          render={props => <item.component {...props}>{childRoutes}</item.component>}
          path={newContextPath}
        />,
      );
    } else if (item.component) {
      children.push(
        <Route key={newContextPath} component={item.component} path={newContextPath} exact />,
      );
    } else if (item.childRoutes) {
      item.childRoutes.forEach(r => renderRoute(r, newContextPath));
    }
  };
    routes.forEach(item => renderRoute(item,path))
    return <Switch>children</Switch>
}


function Root() {
  const children = renderRouteConfig(routeConfig, '/');
  return (
      <ConnectedRouter>{children}</ConnectedRouter>
  );
}

reference

領域驅動設計(DDD)[1]

Rekit:幫助創建遵循一般的最佳實踐,可拓展的 Web 應用程序 http://rekit.js.org/

[1] 領域驅動設計 ance.feishu.cn/wiki/wikcncPLWNvItt4VHg03TyXH0BL#kjOnT5

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/e3At7gx87zIQNS2lH8uWEw