DDD-領域驅動設計示例

农民小工程师發表於2024-04-22

一、DDD概述

  • DDD,即領域驅動設計,核心是不斷提煉通用語言並用於與領域專家等團隊所有成員交流,並用程式碼來表達出一個與通用語言一致的領域模型

  • 通用語言:透過團隊交流達成共識的能夠簡單清晰準確傳遞業務規則的語言(可以是文字、圖片等)

  • 領域:軟體系統要解決的問題域,是有邊界的。領域一般包含多個子域,子域根據其功能劃分為核心域、通用域、支撐域。

  • 限界上下文:描述領域邊界,一個限界上下文可能包含多個子域,但一般實踐上都以一對一為好。應用單元和部署單元一般也與限界上下文一致。


    DDD-領域驅動設計示例
    領域與限界上下文.png
  • 限界上下文對映:多個上下文之間如何進展系統互動整合。


    DDD-領域驅動設計示例
    上下文對映.png
  • 領域模型:對我們軟體系統中要解決問題的抽象表達(解決方案)。模型一般在一個限界上下文中有效。

    • 模組
    • 聚合根
    • 實體
    • 值物件
    • 領域事件
    • 倉儲定義
    • 領域服務
    • 工廠
    • 限界上下文對映的反腐層定義


      DDD-領域驅動設計示例
      限界上下文中的領域模型.png
  • 領域實現:

    • 領域模型
    • 應用服務
    • 基礎設施
      • 服務暴露
      • 倉儲實現
      • 反腐層實現
  • 實踐步驟為:

    • 找到子域
    • 識別核心域、通用域、支撐域
    • 確定限界上下文對映
    • 在每個子域內設計領域模型
    • 實現領域模型和應用

二、示例需求分析

要實現多規格商品的建立和查詢。

DDD-領域驅動設計示例
spu domain

Spu相關操作如下:

@RestController
@RequestMapping("/v1/spu")
public class SpuRestApi {
  
  //spu建立
  @PostMapping("/create")
  public Result<Long> create(@RequestBody SpuCreateParam param){

  }

  //spu詳情
  @GetMapping("/detail")
  public Result<SpuVO> findSpuById(Long shopId, Long spuId){
      
  }
}

SpuCreateParam的定義如下:
其中spuNo,skuNo,barCodes等要求唯一

{
  "shopId": 0, //店鋪ID
  "categoryId": 0,//分類ID
  "unitId": 0,//單位ID
  "name": "string",//SPU名稱,長度20,不能為空
  "spuNo": "string",//SPU編碼,不可變更,用於各系統間傳遞
  "barCodes": [//SPU條碼列表,最多10個,用於搜尋
    "string"
  ],
  "photoTuple": {//圖片列表,最多10張
    "photos": [
      {
        "url": "string" //必須為合法url,每個url長度最大為120
      }
    ]
  },
  "specDefineTuple": {//規格定義,項與值都不能重複,相對順序用於sku列表的排序
    "defines": [//規格項列表,如【顏色+尺寸】
      {
        "key": "string",//規格項,如顏色
        "values": [//規格值列表,如紅色、白色等
          "string"
        ]
      }
    ]
  },
  "skus": [//SKU列表,要符合規格定義的笛卡爾積
    {
      "skuNo": "string",//SKU編碼
      "barCodes": [//SKU條碼,用於SKU維度搜尋,最多10個
        "string"
      ],
      "retailPrice": 0,//零售價,分,最大為 100w*100
      "specTuple": {//與規格定義笛卡爾積中每一個組合對應,如【紅色 + 20號】
        "specs": [
          {
            "key": "string", //規格項,如顏色
            "value": "string" //規格值,如紅色
          }
        ]
      }
    }
  ]
}

三、分層架構 + 程序導向設計

特徵:

  • 包劃分上以功能為準,如所有model放一個包,所有service放另一個
  • 服務依賴:SpuApi -> SpuService -> SpuMapper -> Mybatis
  • 建立時資料流向:SpuSaveParam -> Spu -> table
  • 查詢時資料流向:SpuVO <- Spu <- table
  • 整個SpuService只包含簡單的CRUD操作,尤其是更新操作,一般傾向於只有一個萬能的Update。從方法名稱,你看不出任何的業務含義。
  • SpuService:一個服務方法幾乎包含了所有的邏輯,負責校驗、獲取外部資訊、組裝、轉換SpuSaveParam為Spu、並呼叫SpuMapper儲存到資料庫。
  • Spu為失血模型,只包含欄位,沒有get/set之外的方法,Spu與table的欄位幾乎一一對應。

優缺點:

  • 在邏輯很簡單場景下,crud迭代最快,程序導向與人類思考的方式相近。
  • 在複雜場景下,如spu建立涉及大量的校驗組裝等,很快SpuService.save方法就會過於龐大。另外,有大量的校驗邏輯,在更新場景下是可以複用的。

四、pipeline設計

DDD-領域驅動設計示例
checker pipeline

spu的校驗可以根據spu的內聚資訊塊劃分成多個checker,然後將多個checker組合成一個pipeline流,從而可以更好的重用,並快速應對新增的校驗(加個checker就行了)。
另外,獲取外部資訊,如category、unit等,也可以用rxjava等併發去做,以加快速度。

缺點:

  • 但是pipeline要求設計出一個好的context,用於上下文傳遞,一般會出現context的腐化。
  • 另外,service的主邏輯不清晰,讀程式碼的成本變高。

五、六邊形架構 + 物件導向設計

DDD-領域驅動設計示例
六邊形架構層級

示例實現的github地址

特徵:

  • 採用分治法,將資料、約束、行為等劃分到最能表達它的領域模型中。
  • 包劃分上以業務模組為準,同業務的identity、valueObject、event、repository、service等放在一個包下。
  • SpuAppService:為應用服務,只是呼叫領域服務和倉儲等來串流程,不包含業務邏輯,如校驗等。
  • 領域服務:本例項中沒有領域服務,如果有的話,會定義為SpuXxxService(Xxx指明業務操作)
  • Spu: 包含欄位和行為,如校驗在構造和set時內建,方法體現業務操作如changeName,不是單一的update動作。
  • SpuRepo:定義了倉儲的操作,實現在infra中基於mybatis等
  • MybatisSpuRepo: 實現
  • SpuMapper:基於mybatis訪問資料庫

具體包劃分如下:

  • domain
    • shop
    • category
    • unit
    • spu
      • sku
      • spec
      • code
      • event
      • Spu
      • SpuRepo
      • SpuService
  • infra
    • repo
    • proxy

具體程式碼實現如下:

class SpuAppService{ //應用服務

  @Transactional
  public SpuId save(SpuCreateParam param){
    
    ShopId shopId = new ShopId(param.getShopId());
    
    //呼叫外部服務獲取關聯資訊,並驗證了關聯資訊的合法性
    Category category = categoryService.findById(param.getShopId(), param.getCategoryId());
    Unit unit  = unitService.findById(param.getShopId(), param.getUnitId());
    
    //呼叫Repo生成ID,後續流程中很有可能需要它
    SpuId spuId = spuRepo.nextId();
    
    //SpuNo構造時驗證引數的合法性,不包含特殊字元,不會超長等
    //lockSpuNo, 用於保證編碼的唯一性,注意要實現為可重入鎖
    SpuNo spuNo = codeLockService.lockSpuNo(new SpuNo(shopId, spuId, param.getSpuNo()));
    
    //與SpuNo相似
    SpuBarCodeTuple spuBarCodeTuple = codeLockService.lockSpuBarCodes(new SpuBarCodeTuple(shopId,spuId,param.getBarCodes()));
    
    //用於根據引數生成對應的sku列表
    List<Sku> skus = skuService.buildSkus(shopId, spuId, param.getSkus());
    
    Spu spu = Spu.builder()
        .shopId(shopId)
        .spuId(spuId)
        .no(spuNo)
        .barCodes(spuBarCodeTuple)
        .name(param.getName())
        .photoTuple(param.getPhotoTuple())
        .category(category)
        .unit(unit)
        .specDefineTuple(param.getSpecDefineTuple())
        .skus( skus) //構造時觸發笛卡爾積相關校驗
        .build(); //當例項化Spu時會呼叫法律時刻建構函式來校驗以上各資訊的約束條件
    
    //在本步驟前,spu和sku都未生成
    //spu是聚合根,其包含sku的實體的建立。
    //因為sku的規格組與spu的規格定義是有對應約束的。
    spuRepo.save(spu);
    
    return spuId;
  }
}
class MybatisSpuRepo implements SpuRepo{//倉儲實現
  @Override
  public void save(Spu spu) {
    spuMapper.create(spu);
    skuMapper.batchCreate(spu.getSkuTuple().getSkus());
  }
}

@Mapper
public interface SpuMapper { //Mybatis實現資料庫訪問
  @Options(useGeneratedKeys = true, keyProperty = "id")
  @InsertProvider(type = SpuMapper.class, method = "createSql")
  void create(Spu spu);
  
  @Results(
      id = "spuDetail",
      value = {
          @Result(property = "shopId.id", column = "shop_id"),//複雜物件對映
          @Result(property = "barCodes", column = "bar_codes", typeHandler = SpuBarCodeTupleHandler.class)) //複雜物件JSON化為字串
      }
  )
  @Select("select * from spu where shop_id = #{shopId} and spu_id = #{spuId}")
  Spu findById(@Param("shopId") Long shopId, @Param("spuId") Long spuId);
}
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Getter
@Setter(AccessLevel.PROTECTED)
@Accessors(chain = true)
@Builder
@Slf4j
public class Spu extends IdentifiedEntity {//實體,同時也是聚合根
  
  @NotNull(message = "商家不能為空")
  private ShopId shopId;
  
  @NotNull(message = "ID不能為空")
  private SpuId spuId;
  
  @NotNull(message = "名稱不可為空")
  @Size(max = 100, min = 1, message = "名稱字元數為1到100個字")
  private String name;
  
  @NotNull(message = "編碼不能為空")
  private SpuNo no;
  
  //SpuBarCodeTuple內部保證其合法性,spu不用管理其細節,只要不為空,這個條碼組就是合法的。
  @NotNull(message = "條碼組不能為空")
  private SpuBarCodeTuple barCodes;
  
  @NotNull(message = "圖片不能為空")
  private PhotoTuple photoTuple; 
  
  @NotNull(message = "分類不能為空")
  private CategoryId categoryId;
  
  //導航屬性,可空,在某些需要的場景下去載入它
  //如Spu詳情中應該包含,而spu列表中可以不存在
  private Category category;

  @NotNull(message = "單位不能為空")
  private UnitId unitId;
  
  private Unit unit;
  
  @NotNull(message = "規格定義不能為空")
  private SpecDefineTuple specDefineTuple;
  
  @ListDistinct(message = "規格不能重複")
  @Size(max = 600, message = "規格數最大不能超過600")
  private List<Sku> skus = new ArrayList<>();
  
  protected Spu(){ //用於使mybatis等框架能正常工作
  
  }
  
  public Spu(
      ShopId shopId,
      SpuId spuId,
      SpuNo no,
      SpuBarCodeTuple barCodes,
      String name,
      PhotoTuple photoTuple,
      Category category,
      Unit unit,
      SpecDefineTuple specDefineTuple,
      List<Sku> skuTuple) {
    this.shopId = shopId;
    this.spuId = spuId;
    this.name = name;
    this.no = no;
    this.barCodes= barCodes;
    this.photoTuple = photoTuple;
    this.category = category;
    this.categoryId = category.getCategoryId();
    this.unit = unit;
    this.unitId = unit.getUnitId();
    this.specDefineTuple = specDefineTuple;
    this.skus = skuTuple;
    
    //整合valiation框架,能基於上面定義的註解去校驗,從而讓校驗以宣告式寫法來表述
    super.validate();
    
    //釋出領域事件
    DomainEventPublisher.publish(new SpuCreatedEvent()
        .setShopId(shopId)
        .setSpuId(spuId)
    );
    
  }

  public Spu loadCategory(){ //載入分類
    if(this.category!=null){
      return this;
    }
    if(categoryId!=null){
      this.category = DomainRegistry.repo(CategoryRepo.class).findByShopIdAndId(shopId, categoryId);
    }
    return this;
  }
}

@ToString
@EqualsAndHashCode
@Getter
@Setter(AccessLevel.PROTECTED)
@Accessors(chain = true)
public class SpuBarCodeTuple extends AssertionConcern {
  
  @NotNull(message = "商家不能為空")
  private ShopId shopId;
  @NotNull(message = "spuID不能為空")
  private SpuId spuId;
  
  @NotNull(message = "條碼列表不能為空")
  @ListStringSize(max = 20, message = "條碼最多20個字元")
  @ListDistinct(message = "條碼列表不能重複")
  @Size(max = 10, min = 0, message = "最多支援10個條碼")
  List<String> codes = new ArrayList<>();
  
  public SpuBarCodeTuple(ShopId shopId, SpuId spuId, List<String> codes) {
    this.codes = codes;
    this.shopId = shopId;
    this.spuId = spuId;
    this.validate(); //觸發約束校驗
  }
  
  protected SpuBarCodeTuple() {
  }
}

@Target({ ElementType.FIELD, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = { ListStringSize.ListStringSizeChecker.class })
public @interface ListStringSize { //自定義約束
  
  int min() default 0;
  int max() default Integer.MAX_VALUE;
  String message() default "列表元素大小不符合定義";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
  
  public static class ListStringSizeChecker implements ConstraintValidator<ListStringSize, List<String>> {
  
    ListStringSize annotation;
    
    @Override
    public void initialize(ListStringSize constraintAnnotation) {
      annotation = constraintAnnotation;
    }
  
    @Override
    public boolean isValid(List<String> objects, ConstraintValidatorContext constraintValidatorContext) {
      if(objects==null){
        return true;
      }
      return objects.stream().allMatch(s-> s.length()<=annotation.max() && s.length()>=annotation.min());
    }
  }
}


作者:zhackertop
連結:https://www.falvshike.com

相關文章