本文書接上回《一種很變態但有效的DDD建模溝通方式》,關注公眾號(老肖想當外語大佬)獲取資訊:
-
最新文章更新;
-
DDD框架原始碼(.NET、Java雙平臺);
-
加群暢聊,建模分析、技術交流;
-
影片和直播在B站。
終於到了寫程式碼的環節
如果你已經閱讀過本系列前面的所有文章,我相信你對需求分析和建模設計有了更深刻的理解,那麼就可以實現“需求-模型-程式碼”三者一致性的前半部分,如下圖所示:
那麼接下來,我們來分析一下如何實現“模型-程式碼”的一致性,嘗試透過一篇文章的篇幅,展示符合DDD價值判斷的程式碼組織方式的關鍵部分,初步窺探一下DDD實踐的程式碼樣貌:
領域模型與充血模型
現在假設我們透過需求分析,完成了對模型的設計,並推演確認模型滿足提出的所有需求,既然模型滿足需求,那麼意味著我們設計的模型具備下面特徵:
-
每個模型有自己明確的職責,這些職責分別對應這著不同的需求點;
-
每個模型都包含自己履行職責所需要的所有屬性資訊;
-
每個模型都包含履行職責行為能力,並可以發出對應行為產生的事件;
那麼提煉下來,我們會發現模型必須是“充血模型”,即同時包含屬性和行為,模型與程式碼的對應關係如下:
我們可以類圖來表達模型,即一個聚合根,也可以稱之為一個領域,當然一個聚合根可以包含一些複雜型別屬性或集合屬性,下圖示意了一個簡單的使用者聚合:
下面展示了該模型的示例程式碼:
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;
至此,我們的一個領域模型的程式碼就完成了。
領域模型之外的關鍵要素
讓我們再回到“模型擬人化”的類比上,想象一下在企業裡一個任務是怎麼被完成的,下圖展示了一個典型流程:
如果我們將這個過程對應到軟體系統,可以得到如下流程:
根據上面的對應我可以知道除了領域模型之外,其他的關鍵要素:
-
Controller
-
Command與CommandHandler
-
DomainEventHandler
接下來,我們分別對這些部分進行說明
Controller
有過web專案開發經驗的開發者,對Controller並不陌生,它是web服務與前端互動的入口,在這裡Controller的主要職責是:
-
接收外部輸入
-
將請求輸入及當前使用者會話等資訊組裝成命令
-
發出/執行命令
-
響應命令執行結果
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對應任務,那麼我們可以這樣理解:
-
Command是執行任務所需要的資訊
-
CommandHandler負責將命令資訊傳遞給領域模型
-
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來完成:
-
DomainEventHandler根據事件資訊產生新的命令併發出
-
每個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