手把手教你使用DDD完成一个注册流程

欢迎回来,我是飘渺。今天继续更新DDD&微服务的系列文章。

在前面的文章中,我们深入探讨了DDD的核心概念。我理解,对于初次接触这些概念的你来说,可能难以一次性完全记住。但别担心,学习DDD并不仅仅是理论的理解,更重要的是将这些理论应用到实践中,理解其设计原则和实施方法。就如同编程界的一句流行格言所说:“Don’t talk, Show me the Code”。

今天,我们将以实现用户注册流程为例,一步步展示如何在实践中应用DDD的设计思想和技术手段,这将有助于你更好地理解并记住DDD的核心概念。让我们一起开始吧!

1. 实现领域层

在DDD的四层架构中,领域层扮演着核心角色。因此,我们首先着手实现这一层,其模块包结构如下:

1.1 配置依赖项

<dependencies>
	<dependency>
		<groupId>com.jianzh5</groupId>
		<artifactId>dailymart-common-spring-boot-starter</artifactId>
		<version>${project.version}</version>
	</dependency>
</dependencies>

我们在领域层首先引入了一个通用工具包依赖,这个工具包提供了我们在后续开发中可能需要的一些通用功能。利用这个工具包,我们能够保持代码的整洁,避免在领域层重复编写一些基础功能代码。

1.2 构造领域模型

在第三篇《如何构建商城的领域模型》一文中,我们完成了用户领域对象的建模。其中,最关键的部分是聚合对象CustomerUser

@Data
@Builder
public class CustomerUser {
    private Long customerId;
    private String userName;
    private CustomerUserPassword password;
    private CustomerUserPhone phone;
    private CustomerUserEmail email;
    private Points points;
    private DeliveryAddress defaultAddress;
    private List<DeliveryAddress> deliveryAddresses;
    private List<PointsRecord> pointsRecord;

}

在实现用户注册流程时,我们注意到DailyMart系统对于用户注册活动有几个要求:

  • 用户注册时需要提供邮箱、手机号和用户密码,这样在登录时允许使用任何一种方式进行登录
  • 数据库不允许使用明文存储密码
  • 用户名的长度必须大于等于6

为了满足注册功能的需求,我们对部分属性进行了进一步的抽象,将它们提升为DP(Domain Primitive)对象,这样能够保证它们内在的业务逻辑得到正确的封装。比如,我们将userNamepasswordemailphone都定义为了值对象,并为它们分别定义了合适的业务逻辑。

1.3 介绍DP

在我们的领域模型中,UserNameCustomerUserPasswordCustomerUserEmailCustomerUserPhone都被设计为DP(Domain Primitive)。DP是一个拥有精准定义,自我验证和行为的值对象,它代表了业务领域的最小单元。在实际开发中,我们通常将一些具有业务含义和行为的属性抽象为DP,如此,我们就能够保证这些属性的业务逻辑得到正确的封装和执行。

以CustomerUser对象来说,用户名、密码、邮箱、手机号它们有精准的定义(用户名长度必须>=6,密码必须进行加密,邮箱格式必须保证正确),能够自我验证(在构造函数或者工厂方法中验证自身的有效性),并且拥有特定的行为(例如密码的加密和比较)。

关于DP理解,我强烈推荐你阅读这一篇文章。Domain Primitive (qq.com)

1.4 构建资源库

在DDD中,资源库(Repository)扮演着领域对象持久化的角色,它提供了一种方式,允许我们在不关注底层持久化细节的情况下,实现领域对象的查询和存储。在用户注册功能中,我们创建了CustomerUserRepository资源库接口,并定义了保存用户和按用户名、邮箱、电话查询用户数量的方法。

public interface CustomerUserRepository {
    CustomerUser save(CustomerUser customerUser);

    Long countByUserNameOrEmailOrTelephone(String userName, String email, String phone);
}

在用户注册流程中我们创建了接口CustomerUserRepository,同时提供了两个方法,分别用于保存领域对象和根据条件查询记录条数。

2. 实现基础设施层

接下来,我们将在DailyMart的基础设施层中实现数据持久化。在这里,我们将使用MyBatis-Plus,一款灵活且强大的 ORM 框架,来简化数据库操作。其模块的包结构如下:

image.png

2.1 配置依赖项

<dependencies>
	...
	<dependency>
		<groupId>com.baomidou</groupId>
		<artifactId>mybatis-plus-boot-starter</artifactId>
	</dependency>
	<dependency>
		<groupId>mysql</groupId>
		<artifactId>mysql-connector-java</artifactId>
		<scope>runtime</scope>
	</dependency>
	...
	<dependency>
		<groupId>org.mapstruct</groupId>
		<artifactId>mapstruct</artifactId>
	</dependency>
	<dependency>
		<groupId>org.mapstruct</groupId>
		<artifactId>mapstruct-processor</artifactId>
	</dependency>
</dependencies>

在这段依赖配置中,我们引入了mybatis-plus-boot-starter,这是 MyBatis-Plus 的启动器,用来支持与 Spring Boot 的集成。同时,mysql-connector-java是 MySQL 的 JDBC 驱动,负责连接和操作 MySQL 数据库。我们还引入了 mapstructmapstruct-processor,这是一个用于在 Java 对象之间进行映射转换的工具库,我们将用它来实现领域模型和数据模型的转换。

2.2 构建数据模型

@Data
@TableName("customer_user")
public class CustomerUserDO {
    private Long customerId;
    private String userName;
    private String password;
    private String email;
    private String phone;
    private int points;
}

在这里,我们定义了 CustomerUserDO 类,用于映射数据库的 customer_user 表。它的每个属性都对应数据库表中的一个字段。

2.3 实现模型转换器

@Mapper(componentModel = "spring")
public interface CustomerUserConverter {
    @Mappings({
            @Mapping(target ="points",source = "customerUser.points.value"),
            @Mapping(target = "password",source = "customerUser.password.password"),
            @Mapping(target = "phone",source = "customerUser.phone.phone"),
            @Mapping(target = "email",source = "customerUser.email.email")
    })
    CustomerUserDO domainToDO(CustomerUser customerUser);
}

然后,我们使用 MapStruct 工具库定义了一个转换器接口 CustomerUserConverter,用来实现领域模型 CustomerUser 和数据模型 CustomerUserDO 之间的转换。

2.4 构建数据访问对象

public interface CustomerUserMapper extends BaseMapper<CustomerUserDO> {

}

我们定义了 CustomerUserMapper 接口,继承自 MyBatis-Plus 的 BaseMapper 接口。这样,我们就可以使用 BaseMapper 提供的各种方法来进行数据库操作,大大简化了数据库访问的复杂性。

2.5 实现仓储方法

@Repository
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class CustomerUserRepositoryImpl implements CustomerUserRepository {

    private  final CustomerMapper customerMapper;

    private  final CustomerUserConverter customerUserConverter;

    @Override
    public CustomerUser save(CustomerUser customerUser) {
        CustomerUserDO customerUserDO = customerUserConverter.domainToDO(customerUser);
        int insert = customerMapper.insert(customerUserDO);
        if(insert < 1){
            throw new RuntimeException("用户插入异常");
        }
        Long customerId = customerUserDO.getCustomerId();
        customerUser.setCustomerId(customerId);
        return customerUser;
    }


    @Override
    public Long countByUserNameOrEmailOrTelephone(String userName, String email, String phone) {
        QueryWrapper<CustomerUserDO> queryWrapper = new QueryWrapper<>();
        queryWrapper.or().eq("user_name",userName)
                .or().eq("email",email)
                .or().eq("phone",phone);

        return customerMapper.selectCount(queryWrapper);
    }
}

最后,我们实现了 CustomerUserRepository 接口,这是我们在领域层中定义的用户仓储接口。在实现类 CustomerUserRepositoryImpl 中,我们首先将领域模型转换为数据模型,然后通过 CustomerUserMapper 进行数据库操作。这样,领域模型和数据模型就实现了解耦,领域层和基础设施层之间的交互也变得更加灵活和便捷。

3. 实现应用服务层

现在我们将转向应用服务层的实现。其模块包结构如下:
image.png

3.1 配置依赖项

<dependencies>
	...
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-validation</artifactId>
	</dependency>
</dependencies>

在应用服务层,我们引入了 spring-boot-starter-validation 来对输入参数进行校验。这将保证我们的应用在接收到不符合要求的数据时能够响应适当的错误信息。

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

@Data
@Valid
public class UserRegistrationDTO {
    @NotBlank(message = "用户名不能为空")
    private String userName;

    @NotBlank(message = "密码不能为空")
    private String password;

    @Email(message = "请输入正确的邮箱格式")
    private String email;

    @NotBlank(message = "手机号不能为空")
    private String phone;
}

我们定义了 UserRegistrationDTO 类,这是一个数据传输对象 (DTO),主要用作接口层和应用层之间传递数据。在这里,它包含了用户注册所需的所有数据,如用户名、密码、电子邮件和手机号。

3.3 构建模型转换器

@Mapper(componentModel = "spring")
public interface CustomerUserAssembler {

    @Mappings({
            @Mapping(target ="password",ignore = true),
            @Mapping(target ="phone",source = "customerUser.phone.phone"),
            @Mapping(target ="email",source = "customerUser.email.email")
    })
    UserRegistrationDTO domainToDTO(CustomerUser customerUser);
}

我们使用了 MapStruct 工具库定义了 CustomerUserAssembler 接口,这是一个转换器,负责将领域模型 CustomerUser 转换为数据传输对象 UserRegistrationDTO

3.4 实现应用服务

@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class CustomerUserService {

    private final CustomerUserRepository customerUserRepository;

    private final CustomerUserAssembler customerUserAssembler;

    public UserRegistrationDTO register(UserRegistrationDTO userRegistrationDTO) {
        // 1. 校验用户是否存在
        boolean exists = existsByUserNameOrEmailOrTelephone(userRegistrationDTO.getUserName(), userRegistrationDTO.getEmail(), userRegistrationDTO.getPhone());

        if(exists){
            throw new RuntimeException("User already exists");
        }

        CustomerUser customerUser = CustomerUser.builder()
                .userName(new CustomerUserName(userRegistrationDTO.getUserName()))
                .phone(new CustomerUserPhone(userRegistrationDTO.getPhone()))
                .email(new CustomerUserEmail(userRegistrationDTO.getEmail()))
                .password(new CustomerUserPassword(userRegistrationDTO.getPassword()))
                .build();

        CustomerUser registerUser = customerUserRepository.save(customerUser);

        return  customerUserAssembler.domainToDTO(registerUser);
    }


    public boolean existsByUserNameOrEmailOrTelephone(String userName, String email, String phone) {
        Long count = customerUserRepository.countByUserNameOrEmailOrTelephone(userName,email,phone);
        log.info("记录条数{}",count);
        return count >= 1;
    }
}

CustomerUserService 类中,我们实现了用户注册的应用服务。首先,我们检查用户是否已经存在;如果不存在,我们将创建一个新的 CustomerUser 并将其保存到仓库。然后,我们将新创建的 CustomerUser 转换为 UserRegistrationDTO,并返回给调用者。

在领域驱动设计 (DDD) 中,我们经常将业务逻辑封装在领域模型中。然而,有些业务逻辑并不适合放在实体或值对象中,如这里的用户名唯一性检查,因为这需要与用户仓库进行交互,这是一个涉及基础设施的操作。领域模型应尽可能地与基础设施保持解耦,所以这样的业务逻辑更适合放在服务层中。

4. 实现用户接口层

最后,我们来实现用户接口层,作为与外部交互的主要入口。其模块包结构如下:

4.1 配置依赖

为了使用应用服务层的功能,我们需要添加其依赖项:

<dependencies>
	<dependency>
		<groupId>com.jianzh5</groupId>
		<artifactId>dailymart-customer-application</artifactId>
		<version>${project.version}</version>
	</dependency>
</dependencies>

4.1 构建注册接口

接下来,我们构建用户注册的RESTful接口。该接口将接收一个UserRegistrationDTO对象作为参数,并调用服务层的register方法进行用户注册。

@RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class CustomerController {

    private final CustomerUserService customerService;

    @PostMapping("/api/customer/register")
    public UserRegistrationDTO register(@RequestBody @Valid UserRegistrationDTO customerDTO){
        return customerService.register(customerDTO);
    }
}

4.2 配置启动类

最后,我们需要配置应用的启动类,它将启动整个Spring Boot应用并扫描指定包中的Mapper接口。

@SpringBootApplication
@MapperScan("com.jianzh5.dailymart.module.customer.infrastructure.dao.mapper")
public class CustomerUserApplication {
    public static void main(String[] args) {
        SpringApplication.run(CustomerUserApplication.class,args);
    }
}

4.3 测试验证

完成了上述工作,就可以进行测试验证了。

下面我用postman调用注册接口,用户可以成功注册,密码也被加密。

当使用相同的用户名、手机号、邮箱注册时,后台日志会提示用户已存在的异常。

6. 小结

本篇文章中,我们详细地实现了用户注册功能在DDD架构下的设计和实现过程。首先,我们构建了精确的领域模型,然后建立基础设施层,实现数据的持久化。接着,我们通过应用服务层处理用户注册的请求与响应,编排领域模型的行为。最后,构建了用户接口层,处理HTTP请求。

值得注意的是,本次实践中我们并没有采用领域服务,而是直接在应用服务层处理业务逻辑。这主要是因为注册功能的业务逻辑主要与基础设施层的交互有关,并未涉及到多个领域模型的协作。但在更复杂的业务场景中,我们可能会考虑引入领域服务。

总体来说,这篇教程旨在帮助你更深入地理解DDD,并将其应用到实际的项目中。未来,我们将继续优化代码,并讨论如何统一接口层的返回值、处理异常等问题。系列文章,欢迎持续关注。

DDD&微服务系列源码已经上传至GitHub,如果需要获取源码地址,请关注公号 java日知录 并回复关键字 DDD 即可。