你們以為我的大石頭推完了嗎?
錯, 架構的優化是無止盡的!
前言
故事回到一週之前, 我們好不容易瘦身了 saasLoginUseCase, 準備往登入後的權限獲取和 Session 走, 結果打開 LoginSessionBuilderService 一看, 一股不妙的氣息撲面而來.
When a Service Becomes a Use Case
盤點一下流程, LoginSessionBuilderService 主要做了下面幾件事情:
- 取出 role and permission
- 判斷權限狀態
- 生成前端 MenuTree
- 建立 JWT
- 儲存資訊到 Redis Session 內
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
| @Service @RequiredArgsConstructor public class LoginSessionBuilderService {
private final UserRoleRepository userRoleRepository; private final RoleRepository roleRepository; private final RolePermissionRepository rolePermissionRepository; private final PermissionRepository permissionRepository; private final FunctionMenuRepository functionMenuRepository; private final SaaSJwtUtils saaSJwtUtils;
@Autowired @Qualifier("redisTemplateString") private RedisTemplate<String, String> redisTemplate;
public SaasLoginRs buildLoginSession(CashaUser user) {
Optional<UserRoleEntity> userRoleEntity = userRoleRepository.findByUserId(user.getId());
if (userRoleEntity.isEmpty()) { throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_ROLE_INACTIVE); }
Optional<RoleEntity> userRole = roleRepository.findById(userRoleEntity.get().getRoleId());
if (userRole.isEmpty()) { throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_ROLE_INACTIVE); }
List<RolePermissionEntity> rolePermissionEntityList = rolePermissionRepository.findAllByRoleId(userRoleEntity.get().getRoleId());
List<Long> permissionIds = rolePermissionEntityList.stream().map(RolePermissionEntity::getPermissionId).collect(Collectors.toList());
List<PermissionEntity> permissionEntityList = permissionRepository.findAllById(permissionIds);
List<String> permissionCodeList = permissionEntityList.stream().map(PermissionEntity::getCode).collect(Collectors.toList());
List<FunctionMenuEntity> allMenus = functionMenuRepository.findAllByIsActive((byte) 1);
List<String> allowButtons = functionMenuRepository.findAllButtonsByIsActive((byte) 1);
List<MenuNode> menuTree = MenuTreeUtil.buildMenuTree(allMenus); MenuTreeUtil.sortMenuTree(menuTree); MenuTreeUtil.filterMenuTree(menuTree, permissionCodeList); MenuTreeUtil.filterAllowedButtons(permissionCodeList, allowButtons);
String token = saaSJwtUtils.generateToken(user.getId().toString(), user.getUserAccount());
storeTokenAndPermissionsInRedis(user, token, permissionCodeList);
SaasLoginRs rs = new SaasLoginRs();
rs.setUserId(user.getId().toString()); rs.setUserAccount(user.getUserAccount()); rs.setName(user.getUsername()); rs.setMenus(menuTree); rs.setRoles(userRole.get().getName()); rs.setAccessToken(token); rs.setPermissionBtn(allowButtons);
String defaultPath = switch (user.getAccountScope()) { case "PLATFORM" -> "/platform/dashboard"; case "RESTAURANT" -> "/restaurant/dashboard"; case "BRANCH" -> "/branch/dashboard"; default -> throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.USER_DEFAULT_PATH_ERROR); };
rs.setDefaultPath(defaultPath);
return rs; }
private void storeTokenAndPermissionsInRedis(CashaUser user, String token, List<String> permissionCodeList) { String redisKey = "session:" + user.getUserAccount();
UserSession session = new UserSession(user.getId(), user.getUserAccount(), user.getUsername(), user.getEmployeeNo(), user.getRestaurantId(), user.getBranchId(), user.getAccountScope(), user.getIsActive(), token, permissionCodeList);
String sessionJson = null;
try { sessionJson = new ObjectMapper().writeValueAsString(session); } catch (JsonProcessingException e) {
throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_SESSION_SERIALIZE_FAILED); }
redisTemplate.opsForValue().set(redisKey, sessionJson, Duration.ofMinutes(120));
} }
|
乍看之下其實也沒有明顯的錯誤, 如果是我 Review 下周要上線的 Code 我就給過了, 它集中處理登入後的所有事情,流程也算線性清楚.
對外也只暴露一個 buildLoginSession 方法,對呼叫端來說也很方便, 站在功能完成度與交付的角度,這樣的設計完全是可以接受的. 但我們要求的一定不只那樣對吧.
問題所在
首先,它同時在處理多個層級的核心業務:
- 授權模型的取得與驗證(role / permission)
- 前端側欄與按鈕的組裝(MenuTree、allowButtons)
- 安全與 Session 機制(JWT、Redis Session)
- 使用者體驗相關的決策(defaultPath)
這些邏輯彼此之間並不複雜,但它們屬於不同的抽象層次, 當這些責任被壓縮在同一個 Service 裡時,它就不再只是做一件事的 Service,而是開始扮演登入驗證成功後流程的主導者。
換句話說,這個 Service 已經不只是 how, 而是在隱約回答 what happens after login.
第二個問題,是變化的影響面積正在變大.
現在只要有任何一個需求出現,例如:
- 權限模型調整(多角色、角色繼承)
- 前端選單結構改變
- Button 權限規則細化
- Token payload 增加欄位
- Session 儲存策略改變(Redis → 其他方案)
幾乎都會直接影響到這一個 Service, 個別提多人開發下, 搞不好還會有衝突。
最後是在測試層面變得越來越混雜, 我檢查了一下之前寫的測試, 果然只有測 usecase 的 happy path + Repository, 因為要同時面對太多的情境, 而我覺得這也是很多使用傳統 3 Layer 的開發團隊不喜歡寫測試的原因, 不是因為不想或是偷懶, 而是架構所致.
解決方法
最一開始我直覺地想把這些邏輯拆成多個 Service,然後在 usecase 裡一個一個呼叫與組裝, 就跟前面一樣。
但這個解法會帶來另一個問題, 就是 usecase 的語意開始變得過於複雜, 我在文件中也說明 usecase 原本應該描述的是這個行為是否成立、流程是否允許, 而非登入後要怎麼一步一步拼出前端需要的資料.
這樣又讓 usecase 開始充滿怎麼組裝結果的細節, 從原本定義的描述行為,變成在描述實作流程, 然後就回到我上一篇的狀態…
這時我想起過往的專案面對大量的資料組合, 都會開出一個 DataHelper 或是 TaskHelper 去整合這一項任務, 那我何不把這個 Helper 的概念放大, 建立一支在 usecase 下的 Assembler ?
Assembler
Assembler 的概念也很單純, 在 login 的 usecase 中, 他負責組裝登入成功後的應用層結果,而不是定義登入規則本身。
對此我建立一支 LoginContextAssembler, 因為 context 本身就具備 role, permission, session 等含意, 它能提供登入完成後,整個應用層對這個使用者是誰、能做什麼、從哪裡開始的完整描述.
概念上,它會像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| @Service public class LoginContextAssembler {
private final AuthorizationContextService authorizationContextService; private final MenuTreeService menuTreeService; private final UserSessionService userSessionService;
private final JwtUtils jwtUtils;
public BsLoginRs assemble(BsUser user) {
AuthorizationContext authCtx = authorizationContextService.resolve(user);
List<MenuNode> menus = menuTreeService.buildMenuTree(authCtx.permissionCodes());
String accessToken = jwtUtils.generateAccessToken(user.getId().toString(), user.getUserAccount());
CreateUserSessionCommand cmd = new CreateUserSessionCommand( user.getId(), user.getUserAccount(), user.getUsername(), user.getIsActive(), accessToken, authCtx.permissionCodes() );
try { userSessionService.store(cmd); } catch (JsonProcessingException e) { throw new AuthServiceException(AuthServiceException.AuthServiceErrorType.ACCOUNT_SESSION_SERIALIZE_FAILED); }
BsLoginRs rs = new BsLoginRs(); rs.setUserId(user.getId().toString()); rs.setUserAccount(user.getUserAccount()); rs.setName(user.getUsername()); rs.setAccessToken(accessToken); rs.setMenus(menus); rs.setPermission(authCtx.permissionCodes());
return rs; } }
|
Assembler 本身不承載複雜邏輯,它只負責:
- 呼叫語意清楚的 Service
- 照順序組合結果
- 組裝出最終的 Response
這樣一來, Service 可以回到單一責任, usecase 也不會再度膨脹, 登入後流程的全貌仍然集中好讀, 而 LoginSessionBuilderService,這個當初在我趕專案時的產物也終於可以安心退休,不必再假裝自己只是個單純的 Service.
另外是我讓 Assembler 直接 return SaasLoginRs, 因為 SaasLoginRs 本身就是 API Contract, 是一個合理的 Assembler Output, 另外也可以免於 entity → dto → rs 的轉換, 但當然, 如果今天這個 Service 會被多個 usecase 調用, 那自然是建立成 dto 較好, 這時又回到如果我未來 implement Oauth, 就有可能要把 Assembler 的 return 做成一個 dto 再到 usecase 內組裝了, 但因為組裝的邏輯算單純, 這裡也可以先留決策點, 等之後再更新。
結語
這一段重構帶給我最大的體悟是 Service 變得不乾淨,往往不是因為寫錯,而是因為它承擔了流程的責任, 我設計 Assembler 並不是為了多一層抽象,而是為了讓流程站在它該站的地方.
倘若今天在正常開發的階段, 若這個 usecase 已經被 QA 測試好了, 現在又新加入一些 feature, 公司或開發團隊是否又有本錢與制度去 refactor 呢, 我們焉知這端 Leagcy Code 的前身, 不是一個乾淨的 Service.
2026 的計畫是在 1 月底前完成 Rstar, 結果才重構一個 login 就搞得我暈頭轉向(還生了兩篇文章, rpa 進度整個大停擺), 但其實也反思, 若是在有限的時間下, 能挺住壓力好好溝通, 把事情做得好, 這才是真的有實力.
當然, 在我準備把 Rstar 更新成兼容微服務與單體部署的架構時, 又是另一回事了… (石頭又滾下來了, 酸蘿蔔別吃, -> 最近跟中國同事學的網路用語…), 不果也本來就預計要寫一篇部署架構, 希望過年前可以寫出來.