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 中的初步落地:
- ✅ 服务层拆分为
QueryService
和CommandService
- ✅ 控制器分离,职责清晰
- ✅ 使用专用 DTO,读写解耦
下一步可以考虑:
- 将查询服务对接只读库(如 MySQL 从库、Elasticsearch)
- 引入事件机制,实现命令执行后自动更新查询视图
- 按资源进一步拆分微服务,向资源为中心的架构演进
CQRS 不是银弹,但在读写负载差异大、一致性要求适中的场景下,它能帮你避开不少坑。建议先在非核心模块试点,逐步推进。