(一)边学边用DDD-用户注册流程实现

325 阅读7分钟

本文通过使用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      --启动器,含启动配置等

image.png

1.领域层

在DDD中,领域层承载核心逻辑,模块包如下:

image.png

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>
  1. 引入了lombok简化get、set方法
  2. 引入mapstruct实现了不同层对象之间的转换
  3. 引入hutool简化一些重复工具类的编写
  4. 引入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;
}
  1. @AggregateRootyigo-framework的自定义注解,用于标记实体类为集合根
  2. 用户名、密码、手机号、邮箱等,在实际业务中会有一些校验相关的工作,因此使用值对象进行封装,可以在值对象里面完成校验相关工作。如:用户名限制长度大于等于6,密码需要同时包含大写、小写、数字、符合等字符,用户名和手机号不能为空等。
  3. 与常见项目不同的是,本项目聚合根和值对象禁止使用@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;
    }
}
  1. @ValueObjectyigo-framework自定义的注解,用于标记值对象
  2. 重写set方法,进行非空和长度校验,可以让业务代码只关注核心流程,无需进行这些基本校验。
  3. 虽然可以在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);
}
  1. @DomainService是自定义注解,用于标记领域服务,并且集成了@Service,可以被Spring容器管理
  2. 引用了CustomerRepo接口,在valid和save方法中,分别调用了计数接口和持久化接口,完成了入参校验和保存工作
  3. CustomerDTO2DmoAssembler是使用mapstruct实现的DTO转DomainObject的转换器,在DDD的领域层中,我们一般使用领域对象进行业务操作,和基础设施层也是使用领域对象进行交互。

2.基础设施层实现

基础设施层,我们引入了mybatis-plus-ext,是对mybatis-plus的进一步封装,可以实现自动建表和自动更新表结构。

image.png

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>
  1. 引入了lombok简化get、set方法
  2. 引入mapstruct实现了不同层对象之间的转换
  3. 引入hutool简化一些重复工具类的编写
  4. 引入yigo-customer-center-domain(传递了依赖yigo-customer-center-api
  5. 引入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;
}
  1. 指定了表名为cuc_customer
  2. 指定了主键生成策略为雪花算法

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;
    }
}
  1. CustomerRepoImpl实现了领域层CustomerRepo的接口
  2. CustomerRepoImpl引用了CustomerMapper
  3. 持久化前,需要将领域模型转换为数据模型,实现了领域模型和数据模型的解耦
  4. save方法,根据id是否为空,决定调用insert方法还是update方法。

3.应用层实现

应用层负责对本领域和跨领域的服务编排,注册只涉及到本领域,流程如下:

  1. 校验
  2. 构造领域对象
  3. 持久化
  4. 转换成RespDTO进行返回
  5. 事务注解我们一般放在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)

image.png

4.1 构建数据传输对象(DTO)

  1. 本项目中,实现了查询命令分离(CQRS),对于写操作,定义Command类型的DTO
  2. 引入了spring-boot-starter-validation完成入参校验,保证接口层接收到不合法的数据时,及时响应错误信息
  3. 我们在领域层完成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);
}
  1. 将接口地址、请求格式,封装在接口中,这个CustomerFeign可以供应用内的其他服务调用;
  2. 也可以添加@FeignClient注解,作为二方包提供给其他微服务使用(避免重复定义)
  3. 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 配置启动类

  1. 在启动类中,开启EnableAutoTable,启动自动建表
  2. 指定mybatis的扫描包逻辑
  3. 指定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.测试验证

  1. 启动应用,使用postman调用,用户可以注册成功,返回用户id
  2. 当使用相同参数继续调用时,报错用户已存在。

image.png

image.png

6.小结

本文中,详细介绍了用户注册功能在DDD架构下的设计和实现过程。关于代码中的出入参统一封装、异常处理,可以参考项目源码