本文书接上回《一种很变态但有效的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

DDD建模后写代码的正确姿势(Java、dotnet双平台)的更多相关文章

  1. 用VSCode写python的正确姿势(转载)

    最近在学习python,之前一直用notepad++作为编辑器,偶然发现了VScode便被它的颜值吸引.用过之后发现它启动快速,插件丰富,下载安装后几乎不用怎么配置就可以直接使用,而且还支持markd ...

  2. 用VSCode写python的正确姿势

    最近在学习python,之前一直用notepad++作为编辑器,偶然发现了VScode便被它的颜值吸引.用过之后发现它启动快速,插件丰富,下载安装后几乎不用怎么配置就可以直接使用,而且还支持markd ...

  3. [No000013E]用VSCode写python的正确姿势

    最近在学习python,之前一直用notepad++作为编辑器,偶然发现了VScode便被它的颜值吸引.用过之后发现它启动快速,插件丰富,下载安装后几乎不用怎么配置就可以直接使用,而且还支持markd ...

  4. Python学习笔记 - 用VSCode写python的正确姿势

    最近在学习python,之前一直用notepad++作为编辑器,偶然发现了VScode便被它的颜值吸引.用过之后发现它启动快速,插件丰富,下载安装后几乎不用怎么配置就可以直接使用,而且还支持markd ...

  5. 【技术分享:python 应用之二】解锁用 VSCode 写 python 的正确姿势

    之前一直用 notepad++ 作为编辑器,偶然发现了 VScode 便被它的颜值吸引.用过之后发现它启动快速,插件丰富,下载安装后几乎不用怎么配置就可以直接使用,而且还支持 markdown.当然, ...

  6. 合并Spark社区代码的正确姿势

    原创文章,转载请保留出处 最近刚刚忙完Spark 2.2.0的性能测试及Bug修复,社区又要发布2.1.2了,国庆期间刚好有空,过了一遍2.1.2的相关JIRA,发现有不少重要修复2.2.0也能用上, ...

  7. vs加调试代码的正确姿势

    为了方便,我们会在系统中加入一些调试代码,比如自动登录,这样会省掉很多精力时间,但用的姿势不对, 第一重姿势:打包注释 我看一些人在vs中加调试代码(比如自动登录),然后打包的时候注释掉,这样操作是省 ...

  8. 【Git】Git提交代码的正确姿势

    按此步骤基本没问题,中间有conflict,需要手动解决. 1.git stash 2.git pull 3.git stash pop 4.git add --xxx 5.git commit -m ...

  9. nginx docker 方式启动后日志切分的正确姿势

    Linux系统的日志文件的切分主要是由logrotate来完成的,以centos7为例配置通常在/etc/logrotate.d 目录下 添加 nginx 文件 cat nginx /var/log/ ...

  10. Git提交代码的正确姿势

    按此步骤基本没问题,中间有conflict,需要手动解决. 1.git stash 2.git pull 3.git stash pop 4.git add --xxx 5.git commit -m ...

随机推荐

  1. .NET 个人博客-首页排版优化

    个人博客-首页排版优化 优化计划 置顶3个且可滚动或切换 推荐改为4个,然后新增历史文章,将推荐的加载更多放入历史文章,按文章发布时间降序排列. 标签功能,可以为文章贴上标签 推荐点赞功能 本篇文章优 ...

  2. 从 Dict 转到 Dataclass

    从 dataclass 转到 dict 可以用 asdict 函数 , 反向转换的时候 就比较困难. 不用外部的包的情况下, 提供一种思路. def mask(v, d): #v 是 dict 数据, ...

  3. python rsa-oaep加密示例

    代码: 1 from Crypto.PublicKey import RSA 2 from Crypto.Cipher import PKCS1_OAEP 3 import base64 4 rsa_ ...

  4. Mybatis-plus 中Wrapper的使用

    mybatis plus条件构造器关系图 1.上图绿色框为抽象类abstract 2.蓝色框为正常class类,可new对象 3.黄色箭头指向为父子类关系,箭头指向为父类 QueryWrapper 继 ...

  5. Linux 更新网络时间

    下载包 yum install -y ntpdate 同步网络时间 ntpdate 0.asia.pool.ntp.org 若上面的时间服务器不可用,也可以改用如下服务器进行同步: time.nist ...

  6. Mybatis XXXMapper.xml映射文件配置

    <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "- ...

  7. SurveillanceStation破解版 SurveillanceStation-x86_64-8.2.2-5766

    直接手动安装好套件,许可就是65535.安装的时候提示套件损坏,不理继续安装.不过这个版本有时间炸弹问题,使用几个小时后会出现摄像机消失问题,显示摄像机被删除,但是配置其实是在的.只要禁用套件再启用一 ...

  8. 洛谷P2658

    我在洛谷第一次发个题解,管理员居然把这题的题解通道关了.... 看到好像没有优先队列的题解,来水一手 思路 形似A* 却不是A* 只需要求出其中一个点到其他点的D系数,所有D系数的最大值即是答案. 数 ...

  9. 创建数据库时排序规则utf8_general_ci与utf8_bin的区别

    在MySQL数据库中,字符集(如utf8)定义了字符如何存储,而排序规则(Collation)则定义了字符如何比较.排序和区分大小写.utf8_general_ci和utf8_bin是两种常用的UTF ...

  10. C# LINQ之IEqualityComparer<>接口应用

    在C#语言中,对集合的条件查询.分组统计等操作使用LINQ非常方便,LINQ的语法格式与SQL非常相似和便捷,而LINQ扩展方法配合Lambda更为简洁,如All.Any.Count.Max等Enum ...