本文通过使用DDD完成用户注册流程,展示如何在实践中使用DDD的设计思想和技术手段 代码结构
yigo-customer-center
|--yigo-customer-center-api --定义DTO、常量、二方包API
|--yigo-customer-center-application --应用层
|--yigo-customer-center-domain --领域层
|--yigo-customer-center-gateway --网关层,MQ、定时任务、Controller入口
|--yigo-customer-center-infra --基础设施层,负责和DB、MQ、外部接口交互
|--yigo-customer-center-starter --启动器,含启动配置等
1.领域层
在DDD中,领域层承载核心逻辑,模块包如下:
1.1.依赖项
<dependencies>
<dependency>
<groupId>com.yigo</groupId>
<artifactId>yigo-customer-center-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
</dependencies>
- 引入了
lombok简化get、set方法 - 引入
mapstruct实现了不同层对象之间的转换 - 引入
hutool简化一些重复工具类的编写 - 引入
yigo-customer-center-api(传递了依赖yigo-framework)
1.2 领域模型设计
Customer是我们的领域根
@Data
@AggregateRoot
public class Customer {
/**
* 主键Id
*/
private Long id;
/**
* 用户名
*/
private CustomerUserName userName;
/**
* 密码
*/
private CustomerPassword password;
/**
* 手机号
*/
private CustomerPhone phone;
/**
* 邮箱
*/
private CustomerEmail email;
/**
* 默认收件地址
*/
private DeliveryAddress defaultAddress;
/**
* 收件地址列表
*/
private List<DeliveryAddress> defaultAddressList;
}
@AggregateRoot是yigo-framework的自定义注解,用于标记实体类为集合根- 用户名、密码、手机号、邮箱等,在实际业务中会有一些校验相关的工作,因此使用值对象进行封装,可以在值对象里面完成校验相关工作。如:用户名限制长度大于等于6,密码需要同时包含大写、小写、数字、符合等字符,用户名和手机号不能为空等。
- 与常见项目不同的是,本项目聚合根和值对象禁止使用
@Builder注解,防止set方法不被调用。在实践中,set方法我们需要做一些统一的校验或者其他业务动作
1.3 值对象
在领域模型中,我们使用到了值对象,值对象可以做一些自我验证的工作。
@Data
@ValueObject
public class CustomerUserName {
private String userName;
public void setUserName(String userName) {
if (StrUtil.isBlank(userName)) {
throw new BException(RCodeEnum.PARAMS_VALID_ERROR, "用户名不能为空");
}
if (userName.length() < 6) {
throw new BException(RCodeEnum.PARAMS_VALID_ERROR, "用户名长度需要大于等于6");
}
this.userName = userName;
}
}
@ValueObject是yigo-framework自定义的注解,用于标记值对象- 重写set方法,进行非空和长度校验,可以让业务代码只关注核心流程,无需进行这些基本校验。
- 虽然可以在
Controller中加入@Valid实现入参校验,但是领域层的入口还有领域事件、MQ、定时任务、API引用,这些入口统一在领域层做校验更加全面。
1.4 构建资源库
在DDD中,资源库(Repository)负责完成对象的持久化,领域层不需要关注其细节即可完成领域对象的CRUD(AOP,领域层面向接口编程),本实例创建了CustomerRepo接口,定义了按照用户名、邮箱、手机号计数和保存方法,分别用于数据重复性校验和数据的持久化。
public interface CustomerRepo {
Long countByUserNameOrEmailOrPhone(@NonNull String userName,@NonNull String email,@NonNull String phone);
Customer save(@NonNull Customer customer);
Customer findById(@NonNull Serializable id);
}
@DomainService是自定义注解,用于标记领域服务,并且集成了@Service,可以被Spring容器管理- 引用了
CustomerRepo接口,在valid和save方法中,分别调用了计数接口和持久化接口,完成了入参校验和保存工作 CustomerDTO2DmoAssembler是使用mapstruct实现的DTO转DomainObject的转换器,在DDD的领域层中,我们一般使用领域对象进行业务操作,和基础设施层也是使用领域对象进行交互。
2.基础设施层实现
基础设施层,我们引入了mybatis-plus-ext,是对mybatis-plus的进一步封装,可以实现自动建表和自动更新表结构。
2.1 依赖项
<dependencies>
<dependency>
<groupId>com.yigo</groupId>
<artifactId>yigo-customer-center-domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
</dependency>
<dependency>
<groupId>com.tangzc</groupId>
<artifactId>mybatis-plus-ext-boot-starter</artifactId>
</dependency>
</dependencies>
- 引入了
lombok简化get、set方法 - 引入
mapstruct实现了不同层对象之间的转换 - 引入
hutool简化一些重复工具类的编写 - 引入
yigo-customer-center-domain(传递了依赖yigo-customer-center-api) - 引入
mybatis-plus-ext-boot-starter,负责数据库持久化相关
2.2 数据模型设计
@Data
@Table(value = "cuc_customer", comment = "客户表")
public class CustomerPO {
/**
* 主键Id
*/
@ColumnId(mode = IdType.ASSIGN_ID, comment = "id主键", type = MysqlTypeConstant.BIGINT, length = 20)
private Long id;
/**
* 用户名
*/
@Column(comment = "用户名", length = 30, notNull = true)
private String userName;
/**
* 密码
*/
@Column(comment = "密码", length = 30, notNull = true)
private String password;
/**
* 手机号
*/
@Column(comment = "手机号", length = 30, notNull = true)
private String phone;
/**
* 邮箱
*/
@Column(comment = "邮箱", length = 30, notNull = true)
private String email;
}
- 指定了表名为
cuc_customer - 指定了主键生成策略为雪花算法
2.3 模型转换器实现
使用MapStruct定义的转换器
@Mapper
public interface CustomerDmo2PoConverter extends SnapshotConverter {
CustomerDmo2PoConverter INSTANCE = Mappers.getMapper(CustomerDmo2PoConverter.class);
@Mapping(target = "userName", source = "source.userName.userName")
@Mapping(target = "password", source = "source.password.password")
@Mapping(target = "phone", source = "source.phone.phone")
@Mapping(target = "email", source = "source.email.email")
CustomerPO toCustomerPO(Customer source);
}
2.4 数据访问对象Mapper实现
public interface CustomerMapper extends BaseMapper<CustomerPO> {
}
2.5 仓储接口实现
@Repository
public class CustomerRepoImpl implements CustomerRepo {
@Resource
private CustomerMapper customerMapper;
@Override
public Long countByUserNameOrEmailOrPhone(@NonNull String userName,@NonNull String email, @NonNull String phone) {
LambdaQueryWrapper<CustomerPO> wrapper = Wrappers.lambdaQuery();
wrapper.eq(CustomerPO::getUserName, userName)
.or().eq(CustomerPO::getEmail, email)
.or().eq(CustomerPO::getPhone, phone);
return customerMapper.selectCount(wrapper);
}
@Override
public Customer save(@NonNull Customer dmo) {
CustomerPO po = CustomerDmo2PoConverter.INSTANCE.toCustomerPO(dmo);
if (null == po.getId()) {
if (customerMapper.insert(po) == 0) {
throw new BException(RCodeEnum.PERSIST_OBJECT_ERROR);
}
dmo.setId(po.getId());
} else {
if (customerMapper.updateById(po) == 0) {
throw new BException(RCodeEnum.PERSIST_OBJECT_ERROR);
}
}
return dmo;
}
}
CustomerRepoImpl实现了领域层CustomerRepo的接口CustomerRepoImpl引用了CustomerMapper- 持久化前,需要将领域模型转换为数据模型,实现了领域模型和数据模型的解耦
- save方法,根据id是否为空,决定调用insert方法还是update方法。
3.应用层实现
应用层负责对本领域和跨领域的服务编排,注册只涉及到本领域,流程如下:
- 校验
- 构造领域对象
- 持久化
- 转换成RespDTO进行返回
- 事务注解我们一般放在App层。
@Service
public class CustomerAppService {
@Resource
private CustomerRepo customerRepo;
/**
* 注册
*
* @param cmd
* @return
*/
@Transactional(rollbackFor = Exception.class)
public CustomerRegisterResp register(CustomerRegisterCmd cmd) {
Long count = customerRepo.countByUserNameOrEmailOrPhone(cmd.getUserName(), cmd.getEmail(), cmd.getPhone());
if (count > 0) {
throw new BException(CusCodeEnum.REGISTER_USER_EXISTS, cmd.getUserName(), cmd.getEmail(), cmd.getPhone());
}
Customer customer = CustomerDTO2DmoAssembler.INSTANCE.toDmo(cmd);
customerRepo.save(customer);
return CustomerDTO2DmoAssembler.INSTANCE.toRegisterResp(customer);
}
}
4.接口层实现(api+gateway)
4.1 构建数据传输对象(DTO)
- 本项目中,实现了查询命令分离(
CQRS),对于写操作,定义Command类型的DTO - 引入了
spring-boot-starter-validation完成入参校验,保证接口层接收到不合法的数据时,及时响应错误信息 - 我们在领域层完成DTO到领域对象的转换
CustomerDTO2DmoAssembler
@Data
public class CustomerRegisterCmd {
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空")
private String userName;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
private String password;
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
private String phone;
/**
* 邮箱
*/
@Email(message = "请输入正确的邮箱格式")
private String email;
}
另外,我们定义了注册接口的Resp类,把Id定义为String类型,解决前端js对于雪花算法生成的id精度失真问题
@Data
public class CustomerRegisterResp {
private String id;
}
4.2 构建注册接口
首先我们在apimodule中定义接口
public interface CustomerFeign {
@PostMapping("customer/register")
R<CustomerRegisterResp> register(@RequestBody @Valid Q<CustomerRegisterCmd> cmd);
}
- 将接口地址、请求格式,封装在接口中,这个
CustomerFeign可以供应用内的其他服务调用; - 也可以添加
@FeignClient注解,作为二方包提供给其他微服务使用(避免重复定义) - controller类实现
CustomerFeign接口,添加@RestController注解,在register的实现中,调用应用服务方法即可。
@RestController
public class CustomerController implements CustomerFeign {
@Resource
private CustomerAppService customerAppService;
@Override
public R<CustomerRegisterResp> register(Q<CustomerRegisterCmd> q) {
return R.ok(customerAppService.register(q.getNonNullParams()));
}
}
4.3 配置启动类
- 在启动类中,开启
EnableAutoTable,启动自动建表 - 指定
mybatis的扫描包逻辑 - 指定Spring容器加载的包路径包含framework的类和客户服务应用基础包
@EnableAutoTable
@MapperScan("com.yigo.cuc.infra.repo.mapper")
@SpringBootApplication(scanBasePackages = {
"com.yigo.cuc",
"com.yigo.framework"
})
public class CucStarter {
public static void main(String[] args) {
SpringApplication.run(CucStarter.class, args);
}
}
5.测试验证
- 启动应用,使用
postman调用,用户可以注册成功,返回用户id - 当使用相同参数继续调用时,报错用户已存在。
6.小结
本文中,详细介绍了用户注册功能在DDD架构下的设计和实现过程。关于代码中的出入参统一封装、异常处理,可以参考项目源码