1. 概述

本文将带你迈出关键一步:对一个现有的 Spring REST API 进行重构,引入 CQRS(Command Query Responsibility Segregation,命令查询职责分离) 架构模式。

我们的核心目标是:✅ 在服务层和控制器层,清晰分离“读操作(Query)”和“写操作(Command)”
这样做的好处是职责更明确,后期扩展性更强,也为未来引入事件溯源(Event Sourcing)打下基础。

⚠️ 注意:本文只是 CQRS 的入门实践,不是终极方案。但别担心,这个思路非常实用,尤其适合中大型系统解耦。

本文的示例基于一个发布 User 资源的 API,它源自我们持续更新的 Reddit 应用案例研究。当然,你完全可以把这套思路套用到任何 REST API 上。


2. 服务层拆分

先从服务层下手,简单粗暴地把原来的 UserService 拆成两个接口:

  • UserQueryService:只负责数据查询
  • UserCommandService:只负责数据修改,不返回数据

来看代码:

public interface IUserQueryService {

    List<User> getUsersList(int page, int size, String sortDir, String sort);

    String checkPasswordResetToken(long userId, String token);

    String checkConfirmRegistrationToken(String token);

    long countAllUsers();
}
public interface IUserCommandService {

    void registerNewUser(String username, String email, String password, String appUrl);

    void updateUserPassword(User user, String password, String oldPassword);

    void changeUserPassword(User user, String password);

    void resetPassword(String email, String appUrl);

    void createVerificationTokenForUser(User user, String token);

    void updateUser(User user);
}

✅ 重点观察:

  • 查询接口返回数据(如 List<User>
  • 命令接口全部是 void,只做修改,不读数据

这是 CQRS 的核心原则之一:写操作不返回业务数据,避免模糊职责。


3. 控制器层分离

接下来,把控制器也拆成两个,分别对接查询和命令服务。

3.1 查询控制器(UserQueryRestController)

@Controller
@RequestMapping(value = "/api/users")
public class UserQueryRestController {

    @Autowired
    private IUserQueryService userService;

    @Autowired
    private IScheduledPostQueryService scheduledPostService;

    @Autowired
    private ModelMapper modelMapper;

    @PreAuthorize("hasRole('USER_READ_PRIVILEGE')")
    @RequestMapping(method = RequestMethod.GET)
    @ResponseBody
    public List<UserQueryDto> getUsersList(...) {
        PagingInfo pagingInfo = new PagingInfo(page, size, userService.countAllUsers());
        response.addHeader("PAGING_INFO", pagingInfo.toString());
        
        List<User> users = userService.getUsersList(page, size, sortDir, sort);
        return users.stream().map(
          user -> convertUserEntityToDto(user)).collect(Collectors.toList());
    }

    private UserQueryDto convertUserEntityToDto(User user) {
        UserQueryDto dto = modelMapper.map(user, UserQueryDto.class);
        dto.setScheduledPostsCount(scheduledPostService.countScheduledPostsByUser(user));
        return dto;
    }
}

✅ 关键点:

  • 只注入 IUserQueryService,天然隔离了写操作
  • 想更彻底?可以把查询控制器放到独立模块,直接从编译层面切断对命令服务的访问

3.2 命令控制器(UserCommandRestController)

@Controller
@RequestMapping(value = "/api/users")
public class UserCommandRestController {

    @Autowired
    private IUserCommandService userService;

    @Autowired
    private ModelMapper modelMapper;

    @RequestMapping(value = "/registration", method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.OK)
    public void register(
      HttpServletRequest request, @RequestBody UserRegisterCommandDto userDto) {
        String appUrl = request.getRequestURL().toString().replace(request.getRequestURI(), "");
        
        userService.registerNewUser(
          userDto.getUsername(), userDto.getEmail(), userDto.getPassword(), appUrl);
    }

    @PreAuthorize("isAuthenticated()")
    @RequestMapping(value = "/password", method = RequestMethod.PUT)
    @ResponseStatus(HttpStatus.OK)
    public void updateUserPassword(@RequestBody UserUpdatePasswordCommandDto userDto) {
        userService.updateUserPassword(
          getCurrentUser(), userDto.getPassword(), userDto.getOldPassword());
    }

    @RequestMapping(value = "/passwordReset", method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.OK)
    public void createAResetPassword(
      HttpServletRequest request, 
      @RequestBody UserTriggerResetPasswordCommandDto userDto) 
    {
        String appUrl = request.getRequestURL().toString().replace(request.getRequestURI(), "");
        userService.resetPassword(userDto.getEmail(), appUrl);
    }

    @RequestMapping(value = "/password", method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.OK)
    public void changeUserPassword(@RequestBody UserchangePasswordCommandDto userDto) {
        userService.changeUserPassword(getCurrentUser(), userDto.getPassword());
    }

    @PreAuthorize("hasRole('USER_WRITE_PRIVILEGE')")
    @RequestMapping(value = "/{id}", method = RequestMethod.PUT)
    @ResponseStatus(HttpStatus.OK)
    public void updateUser(@RequestBody UserUpdateCommandDto userDto) {
        userService.updateUser(convertToEntity(userDto));
    }

    private User convertToEntity(UserUpdateCommandDto userDto) {
        return modelMapper.map(userDto, User.class);
    }
}

✅ 设计亮点:

  • 每个接口使用独立的 Command DTO,职责清晰
  • 为后续引入事件溯源(Event Sourcing)铺路,每个命令天然对应一个事件
  • 权限控制(@PreAuthorize)按需配置,安全又灵活

3.3 分离资源表示(DTO)

CQRS 的另一个好处是:读写场景不同,DTO 也可以不同。

查询 DTO(UserQueryDto)

public class UserQueryDto {
    private Long id;
    private String username;
    private boolean enabled;
    private Set<Role> roles;
    private long scheduledPostsCount;
}

命令 DTO(Command DTOs)

  • UserRegisterCommandDto:用户注册数据
public class UserRegisterCommandDto {
    private String username;
    private String email;
    private String password;
}
  • UserUpdatePasswordCommandDto:更新当前用户密码
public class UserUpdatePasswordCommandDto {
    private String oldPassword;
    private String password;
}
  • UserTriggerResetPasswordCommandDto:触发密码重置(发送邮件)
public class UserTriggerResetPasswordCommandDto {
    private String email;
}
  • UserChangePasswordCommandDto:使用重置令牌后设置新密码
public class UserChangePasswordCommandDto {
    private String password;
}
  • UserUpdateCommandDto:管理员修改用户信息
public class UserUpdateCommandDto {
    private Long id;
    private boolean enabled;
    private Set<Role> roles;
}

✅ 优势总结:

  • 查询 DTO 可以聚合多个数据源(如 scheduledPostsCount
  • 命令 DTO 只包含必要字段,避免前端传多余参数
  • 类型安全,避免 Map<String, Object> 魔法参数

4. 总结

本文完成了 CQRS 在 Spring REST API 中的初步落地:

  • ✅ 服务层拆分为 QueryServiceCommandService
  • ✅ 控制器分离,职责清晰
  • ✅ 使用专用 DTO,读写解耦

下一步可以考虑:

  • 将查询服务对接只读库(如 MySQL 从库、Elasticsearch)
  • 引入事件机制,实现命令执行后自动更新查询视图
  • 按资源进一步拆分微服务,向资源为中心的架构演进

CQRS 不是银弹,但在读写负载差异大、一致性要求适中的场景下,它能帮你避开不少坑。建议先在非核心模块试点,逐步推进。


原始标题:Apply CQRS to a Spring REST API

« 上一篇: Baeldung周报第37期
» 下一篇: Java Web周报38