DDD建模後寫程式碼的正確姿勢(Java、dotnet雙平臺)

老肖想当外语大佬發表於2024-08-22

本文書接上回《一種很變態但有效的DDD建模溝通方式》,關注公眾號(老肖想當外語大佬)獲取資訊:

  1. 最新文章更新;

  2. DDD框架原始碼(.NET、Java雙平臺);

  3. 加群暢聊,建模分析、技術交流;

  4. 影片和直播在B站。

終於到了寫程式碼的環節

如果你已經閱讀過本系列前面的所有文章,我相信你對需求分析和建模設計有了更深刻的理解,那麼就可以實現“需求-模型-程式碼”三者一致性的前半部分,如下圖所示:

那麼接下來,我們來分析一下如何實現“模型-程式碼”的一致性,嘗試透過一篇文章的篇幅,展示符合DDD價值判斷的程式碼組織方式的關鍵部分,初步窺探一下DDD實踐的程式碼樣貌:

領域模型與充血模型

現在假設我們透過需求分析,完成了對模型的設計,並推演確認模型滿足提出的所有需求,既然模型滿足需求,那麼意味著我們設計的模型具備下面特徵:

  1. 每個模型有自己明確的職責,這些職責分別對應這著不同的需求點;

  2. 每個模型都包含自己履行職責所需要的所有屬性資訊;

  3. 每個模型都包含履行職責行為能力,並可以發出對應行為產生的事件;

那麼提煉下來,我們會發現模型必須是“充血模型”,即同時包含屬性和行為,模型與程式碼的對應關係如下:

我們可以類圖來表達模型,即一個聚合根,也可以稱之為一個領域,當然一個聚合根可以包含一些複雜型別屬性或集合屬性,下圖示意了一個簡單的使用者聚合:

下面展示了該模型的示例程式碼:

Java程式碼:

package com.yourcompany.domain.aggregates;

import com.yourcompany.domain.aggregates.events.*;
import lombok.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import org.netcorepal.cap4j.ddd.domain.event.annotation.DomainEvent;
import org.netcorepal.cap4j.ddd.domain.event.impl.DefaultDomainEventSupervisor;

import javax.persistence.*;

/**
 * 使用者
 * <p>
 * 本檔案由[cap4j-ddd-codegen-maven-plugin]生成
 * 警告:請勿手工修改該檔案的欄位宣告,重新生成會覆蓋欄位宣告
 */
/* @AggregateRoot */
@Entity
@Table(name = "`user`")
@DynamicInsert
@DynamicUpdate

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class User {

    // 【行為方法開始】

    public void init() {
        DefaultDomainEventSupervisor.instance.attach(UserCreatedDomainEvent.builder()
                .user(this)
                .build(), this);
    }

    public void changeEmail(String email) {
        this.email = email;
        DefaultDomainEventSupervisor.instance.attach(UserEmailChangedDomainEvent.builder()
                .user(this)
                .build(), this);
    }

    // 【行為方法結束】


    // 【欄位對映開始】本段落由[cap4j-ddd-codegen-maven-plugin]維護,請不要手工改動

    @Id
    @GeneratedValue(generator = "org.netcorepal.cap4j.ddd.application.distributed.SnowflakeIdentifierGenerator")
    @GenericGenerator(name = "org.netcorepal.cap4j.ddd.application.distributed.SnowflakeIdentifierGenerator", strategy = "org.netcorepal.cap4j.ddd.application.distributed.SnowflakeIdentifierGenerator")
    @Column(name = "`id`")
    Long id;


    /**
     * varchar(100)
     */
    @Column(name = "`name`")
    String name;

    /**
     * varchar(100)
     */
    @Column(name = "`email`")
    String email;

    // 【欄位對映結束】本段落由[cap4j-ddd-codegen-maven-plugin]維護,請不要手工改動
}

C#程式碼:

領域事件的定義如下:

Java程式碼:

package com.yourcompany.domain.aggregates.events;

import com.yourcompany.domain.aggregates.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.netcorepal.cap4j.ddd.domain.event.annotation.DomainEvent;

/**
 * 使用者建立事件
 */
@DomainEvent
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserCreatedDomainEvent {
    User user;
}
package com.yourcompany.domain.aggregates.events;

import com.yourcompany.domain.aggregates.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.netcorepal.cap4j.ddd.domain.event.annotation.DomainEvent;
/**
 * 使用者郵箱變更事件
 */
@DomainEvent
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserEmailChangedDomainEvent {
    User user;
}

C#程式碼:

//定義領域事件
using NetCorePal.Extensions.Domain;
namespace YourNamespace;

public record UserCreatedDomainEvent(User user) : IDomainEvent;

public record UserEmailChangedDomainEvent(User user) : IDomainEvent;

至此,我們的一個領域模型的程式碼就完成了。

領域模型之外的關鍵要素

讓我們再回到“模型擬人化”的類比上,想象一下在企業裡一個任務是怎麼被完成的,下圖展示了一個典型流程:

如果我們將這個過程對應到軟體系統,可以得到如下流程:

根據上面的對應我可以知道除了領域模型之外,其他的關鍵要素:

  1. Controller

  2. Command與CommandHandler

  3. DomainEventHandler

接下來,我們分別對這些部分進行說明

Controller

有過web專案開發經驗的開發者,對Controller並不陌生,它是web服務與前端互動的入口,在這裡Controller的主要職責是:

  1. 接收外部輸入

  2. 將請求輸入及當前使用者會話等資訊組裝成命令

  3. 發出/執行命令

  4. 響應命令執行結果

Java程式碼:

package com.yourcompany.adapter.portal.api;

import com.yourcompany.adapter.portal.api._share.ResponseData;
import com.yourcompany.application.commands.CreateUserCommand;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

/**
 * 使用者控制器
 */
@Tag(name = "使用者")
@RestController
@RequestMapping(value = "/api/user")
@Slf4j
public class UserController {

    @Autowired
    CreateUserCommand.Handler createUserCommandHandler;

    @PostMapping("/")
    public ResponseData<Long> createUserCommand(@RequestBody @Valid CreateUserCommand cmd) {
        Long result = createUserCommandHandler.exec(cmd);
        return ResponseData.success(result);
    }
}

C#程式碼:

[Route("api/[controller]")]
[ApiController]
public class UserController(IMediator mediator) : ControllerBase
{
    [HttpPost]
    public async Task<ResponseData<UserId>> Post([FromBody] CreateUserRequest request)
    {
        var cmd = new CreateUserCommand(request.Name, request.Email);
        var id = await mediator.Send(cmd);
        return id.AsResponseData();
    }
}

===

===

Command與CommandHandler

基於前面的對應關係,Command對應任務,那麼我們可以這樣理解:

  1. Command是執行任務所需要的資訊

  2. CommandHandler負責將命令資訊傳遞給領域模型

  3. CommandHandler最後要將領域模型持久化

下面是一個簡單的示例:

Java程式碼:

package com.yourcompany.application.commands;

import com.yourcompany.domain.aggregates.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.netcorepal.cap4j.ddd.application.command.Command;
import org.netcorepal.cap4j.ddd.domain.repo.AggregateRepository;
import org.netcorepal.cap4j.ddd.domain.repo.UnitOfWork;
import org.springframework.stereotype.Service;


/**
 * 建立使用者命令
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserCommand {
    String name;
    String email;

    @Service
    @RequiredArgsConstructor
    @Slf4j
    public static class Handler implements Command<CreateUserCommand, Long> {
        private final AggregateRepository<User, Long> repo;
        private final UnitOfWork unitOfWork;

        @Override
        public Long exec(CreateUserCommand cmd) {
            User user = User.builder()
                    .name(cmd.name)
                    .email(cmd.email)
                    .build();
            user.init();
            unitOfWork.persist(user);
            unitOfWork.save();
            return user.getId();
        }
    }
}

C#程式碼:

public record CreateUserCommand(string Name, string Email) : ICommand<UserId>;

public class CreateUserCommandHandler(IUserRepository userRepository) 
    : ICommandHandler<CreateUserCommand, UserId>
{
    public async Task<UserId> Handle(CreateUserCommand request, CancellationToken cancellationToken)
    {
        var user = new User(request.Name, request.Email);
        user = await userRepository.AddAsync(user, cancellationToken);
        return user.Id;
    }
}

===

===

DomainEventHandler

當我們的命令執行完成,領域模型會產生領域事件,那麼關心領域事件,期望在領域事件發生時執行一些操作,就可以使用DomainEventHandler來完成:

  1. DomainEventHandler根據事件資訊產生新的命令併發出

  2. 每個DomainEventHandler只做一件事,即只發出一個命令

Java程式碼:

package com.yourcompany.application.subscribers;

import com.yourcompany.application.commands.DoSomethingCommand;
import com.yourcompany.domain.aggregates.events.UserCreatedDomainEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

/**
 * 使用者建立領域事件
 */
@Service
@RequiredArgsConstructor
public class UserCreatedDomainEventHandler {
    private final DoSomethingCommand.Handler handler;

    @EventListener(UserCreatedDomainEvent.class)
    public void handle(UserCreatedDomainEvent event) {
        handler.exec(DoSomethingCommand.builder()
                .param(event.getUser().getId())
                .build());
    }
}

C#程式碼:

public class UserCreatedDomainEventHandler(IMediator mediator) 
           : IDomainEventHandler<UserCreatedDomainEvent>
{
    public Task Handle(UserCreatedDomainEvent notification, CancellationToken cancellationToken)
    {
        return mediator.Send(new DoSomethingCommand(notification.User.Id), cancellationToken);
    }
}

===

===

模型的持久化

在前文,我們一直強調一個觀點,“在設計模型時忘掉資料庫”,那麼當我們完成模型設計之後,如何將模型儲存進資料庫呢?通常我們會使用倉儲模式在負責模型的“存取”操作,下面程式碼示意了一個倉儲具備的基本能力以及倉儲的定義,略微不同的是,我們實現了工作單元模式(UnitOfWork),以遮蔽資料庫的“增刪改查”語義,我們只需要從倉儲中“取出模型”、“操作模型”、“儲存模型”即可。

Java程式碼:

package com.yourcompany.adapter.domain.repositories;

import com.yourcompany.domain.aggregates.User;

/**
 * 本檔案由[cap4j-ddd-codegen-maven-plugin]生成
 */
public interface UserRepository extends org.netcorepal.cap4j.ddd.domain.repo.AggregateRepository<User, Long> {
    // 【自定義程式碼開始】本段落之外程式碼由[cap4j-ddd-codegen-maven-plugin]維護,請不要手工改動

    @org.springframework.stereotype.Component
    public static class UserJpaRepositoryAdapter extends org.netcorepal.cap4j.ddd.domain.repo.AbstractJpaRepository<User, Long>
{
        public UserJpaRepositoryAdapter(org.springframework.data.jpa.repository.JpaSpecificationExecutor<User> jpaSpecificationExecutor, org.springframework.data.jpa.repository.JpaRepository<User, Long> jpaRepository) {
            super(jpaSpecificationExecutor, jpaRepository);
        }
    }

    // 【自定義程式碼結束】本段落之外程式碼由[cap4j-ddd-codegen-maven-plugin]維護,請不要手工改動
}

C#程式碼:

public interface IRepository<TEntity, TKey> : IRepository<TEntity>
  where TEntity : notnull, Entity<TKey>, IAggregateRoot
  where TKey : notnull
{
  IUnitOfWork UnitOfWork { get; }
  TEntity Add(TEntity entity);
  Task<TEntity> AddAsync(TEntity entity, CancellationToken cancellationToken = default (CancellationToken));
  int DeleteById(TKey id);
  Task<int> DeleteByIdAsync(TKey id, CancellationToken cancellationToken = default (CancellationToken));
  TEntity? Get(TKey id);
  Task<TEntity?> GetAsync(TKey id, CancellationToken cancellationToken = default (CancellationToken));
}


public interface IUserRepository : IRepository<User, UserId>
{
}

public class UserRepository(ApplicationDbContext context) 
    : RepositoryBase<User, UserId, ApplicationDbContext>(context), IUserRepository
{
}

===

===

查詢的處理

下面展示了一個簡單的查詢的程式碼

Java程式碼:

package com.yourcompany.application.queries;

import com.yourcompany._share.exception.KnownException;
import com.yourcompany.domain.aggregates.User;
import com.yourcompany.domain.aggregates.schemas.UserSchema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.netcorepal.cap4j.ddd.application.query.Query;
import org.netcorepal.cap4j.ddd.domain.repo.AggregateRepository;
import org.springframework.stereotype.Service;


/**
 * 查詢使用者
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserQuery {
    private Long id;

    @Service
    @RequiredArgsConstructor
    @Slf4j
    public static class Handler implements Query<UserQuery, UserQueryDto> {
        private final AggregateRepository<User, Long> repo;

        @Override
        public UserQueryDto exec(UserQuery param) {
            User entity = repo.findOne(UserSchema.specify(
                    root -> root.id().eq(param.id)
            )).orElseThrow(() -> new KnownException("不存在"));

            return UserQueryDto.builder()
                    .id(entity.getId())
                    .name(entity.getName())
                    .email(entity.getEmail())
                    .build();
        }
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public static class UserQueryDto {
        private Long id;
        private String name;
        private String email;
    }
}

C#程式碼:

public class UserQuery(ApplicationDbContext applicationDbContext)
{
    public async Task<UserDto?> QueryOrder(UserId userId, CancellationToken cancellationToken)
    {
        return await applicationDbContext.Users.Where(p => p.Id == userId)
            .Select(p => new UserDto(p.Id, p.Name)).SingleOrDefault();
    }
}

===

===

CQRS似乎是唯一正解

我們在實際的軟體系統中,查詢往往是場景複雜的,不同的查詢需求,可能打破模型的整體性,顯然使用領域模型本身來滿足這些需求是不現實的,那麼就需要針對需求場景,組織對應的資料結構作為輸出結果,這就與“CQRS”模式不謀而合,或者說“CQRS”就是為了解決這個問題而被提出的,並且這個模式與“命令-事件”的思維渾然一體,前面的程式碼示例也印證了這一點,因此我們認為DDD的實踐落地,需要藉助CQRS的模式。

原始碼資料

本文示例分別使用了cap4j(Java)和netcorepal-cloud-framework(dotnet),歡迎參與專案討論和貢獻,專案地址如下:

https://github.com/netcorepal/cap4j

https://github.com/netcorepal/netcorepal-cloud-framework

相關文章