將 CQRS 應用於 Spring REST API

REST,Spring
Remote
1
05:44 PM · Dec 01 ,2025

1. 概述

在本文中,我們將做一些新的事情。我們將演化一個現有的 REST Spring API,並使其使用命令查詢職責分離 – CQRS

目標是清晰地分離服務層和控制器層,以便分別處理系統中的讀取 – 查詢和寫入 – 命令。

請記住,這只是邁向這種架構的早期步驟,而不是“終點”。儘管如此——我對它感到興奮。

最後,我們將使用的 API 發佈User資源,它是我們持續的 Reddit 應用案例研究的一部分,旨在説明其工作原理——當然,任何 API 都可以。

2. 服務層

我們先從簡單入手——只識別我們之前 User 服務中的讀取和寫入操作,並將它們拆分為 2 個單獨的服務:UserQueryServiceUserCommandService:

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);

}

通過閲讀此 API,你可以清楚地看到查詢服務正在執行所有讀取操作,並且 命令服務沒有讀取任何數據——所有 void 返回

3. The Controller Layer

下一部分是控制器層。

3.1. The Query Controller

以下是我們的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;
    }
}

有趣的是,查詢控制器僅注入查詢服務。

更令人有趣的是,切斷此控制器的訪問權限到命令服務——通過將這些內容放在一個單獨的模塊中。

3.2. The Command Controller

現在,以下是命令控制器的實現:

@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);
    }
}

這裏發生了一些有趣的事情。首先——注意每個 API 實現都使用不同的命令。這主要是為了為 API 的進一步設計改進提供一個良好的基礎,並提取不同的資源,因為它們出現時。

另一個原因是,當我們採取下一步,向事件溯源方向發展時,我們擁有與命令協同工作的乾淨集。

3.3. Separate Resource Representations

現在,讓我們快速瀏覽一下我們用户資源的各種表示形式,在將這些內容分離到命令和查詢之後:

public class UserQueryDto {
    private Long id;

    private String username;

    private boolean enabled;

    private Set<Role> roles;

    private long scheduledPostsCount;
}

以下是我們的命令 DTO:

  • 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
          • 用於表示新的用户數據——此 DTO 在修改後使用
            public class UserUpdateCommandDto {
                private Long id;
            
                private boolean enabled;
            
                private Set<Role> roles;
            }

            4. 結論

            在本教程中,我們為 Spring REST API 建立了一個乾淨的 CQRS 實現的基礎。

            下一步將通過識別一些單獨的職責(以及資源)並將其劃分為單獨的服務,以便更好地與以資源為中心的架構對齊。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.