Spring Boot 專案開發規範(後台)

Posted by Adam on August 24, 2022
# 🛠 Spring Boot 專案開發規範(後台) > 適用於中小型 Java 後台開發團隊,使用 Spring Boot + REST API 架構。 --- ## 📁 專案目錄結構 ```bash src/main/java/com/example/project ├── config/ # 全域設定,如 CORS、安全性、Swagger ├── controller/ # REST API 控制器 ├── dto/ # 請求與回應資料傳輸物件 ├── entity/ # JPA 實體(資料庫對應表) ├── exception/ # 自訂例外與統一錯誤處理 ├── repository/ # JPA Repository ├── service/ # 商業邏輯層 ├── util/ # 工具類別 └── Application.java # 啟動主類別 ``` --- ## 🧱 命名規則 * **類別命名**:PascalCase,如 `UserController`、`UserService` * **變數命名**:camelCase,如 `userId`、`startDate` * **資料庫欄位**:`snake_case`,如 `user_name`, `created_at` --- ## 📡 RESTful API 規範 | 項目 | 範例 | | -------- | ------------------------- | | `GET` | `/api/users/123` → 取得單筆資料 | | `POST` | `/api/users` → 建立資料 | | `PUT` | `/api/users/123` → 更新整筆資料 | | `PATCH` | `/api/users/123` → 局部更新資料 | | `DELETE` | `/api/users/123` → 刪除資料 | > ✅ **版本化 API**:建議使用 `/api/v1/...` 路徑統一版本管理。 --- ## 📦 DTO 使用規範 * 不直接使用 `Entity` 做為 API 請求/回應。 * 每個 API 建議建立對應的: * `xxxRequest`(請求格式) * `xxxResponse`(回傳格式) ```java public class CreateUserRequest { @NotBlank private String username; @Email private String email; } ``` --- ## ✅ 驗證機制 * 使用 **Bean Validation**(JSR-380): ```java @PostMapping public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) ``` * 常用註解: * `@NotNull`, `@NotBlank`, `@Size`, `@Email`, `@Pattern` --- ## 🧾 統一回應格式(建議) ```json { "status": "success", "message": "建立成功", "data": { "id": 123, "username": "adam" } } ``` 對應 Java: ```java public class ApiResponse<T> { private String status; private String message; private T data; } ``` --- ## ⚠ 統一錯誤處理 使用 `@ControllerAdvice` 統一處理: ```java @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<?> handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(e -> e.getField() + ": " + e.getDefaultMessage()) .collect(Collectors.joining(", ")); return ResponseEntity.badRequest().body(new ApiResponse<>("fail", message, null)); } // 其他自定例外 } ``` --- ## 🧪 單元測試與整合測試 * 使用 JUnit 5 + Spring Boot Test * 控制器測試用 `@WebMvcTest`,Service 測試用 `@SpringBootTest` * 建議覆蓋率超過 70% --- ## 🔐 安全性(如有) * 使用 Spring Security 搭配 JWT/OAuth2 * 所有 API 預設需驗證身分 * `/login`, `/register` 等可開放匿名 --- ## 🧭 Swagger / OpenAPI 註解 * 每個 Controller 建議加上 `@Tag`, `@Operation` * 使用 SpringDoc 或 Swagger UI 提供測試 API ```java @Operation(summary = "建立使用者", description = "提供使用者帳號與信箱") @PostMapping("/users") public ResponseEntity<?> createUser(...) { ... } ``` --- ## 🧹 其他建議 | 項目 | 建議 | | ---- | ----------------------------------- | | 日誌 | 使用 `Slf4j`,不使用 `System.out.println` | | 時間 | 統一使用 `ZonedDateTime` 或 `Instant` 儲存 | | 檔案上傳 | 使用 `MultipartFile`,儲存於 S3/磁碟/資料庫 | | 分頁查詢 | 使用 `Pageable`,統一輸出格式 | | 參數驗證 | 絕不手動檢查欄位,善用 `@Valid` 與自訂 Validator | --- ## 📄 可擴充規範(選擇性) * API 檔名與描述自動生成(OpenAPI 3.0) * 多語系支援(使用 `MessageSource`) * CI/CD 打包流程說明 * Git 分支命名與 Commit 規則 --- # Spring Data JPA Repository 以下是一份「Spring Data JPA Repository 回傳型別只能使用 Optional、List、Page、Slice」的專案規範範本(繁體中文),您可依團隊需求調整。 --- ## 1. 目的 確保 Repository 層方法回傳型別一致性,利於後續呼叫端統一處理,提升程式可維護性與可讀性。 ## 2. 適用範圍 - Spring Boot / Spring Data JPA - 專案中所有繼承自 `JpaRepository`(或自訂介面)之 Repository ## 3. 定義 - 單筆查詢:回傳「0 或 1 筆」 - 多筆查詢:回傳「0~N 筆」,不分頁 - 分頁查詢:回傳附帶總筆數的分頁結果 - 切片查詢:僅關心是否有下一頁 ## 4. 回傳型別規範 1. 單筆查詢 - 必須使用 `Optional<T>` - 禁止直接回傳 `T`(避免 NullPointerException) - 範例: ```java public interface UserRepository extends JpaRepository<User, Long> { // 正確:Optional 包裝 Optional<User> findByEmail(String email); // ❌ 錯誤:不應回傳 User // User findByUsername(String username); } ``` 2. 多筆查詢(不分頁) - 使用 `List<T>` - 禁止使用陣列(T[])、Set、Stream…等其他型別 - 範例: ```java public interface OrderRepository extends JpaRepository<Order, Long> { // 正確:List 包裝 List<Order> findByStatus(String status); // ❌ 錯誤:T[]、Set<T> // Order[] findByStatus(String status); // Set<Order> findByStatus(String status); } ``` 3. 分頁查詢 - 使用 `Page<T>`,需接收 `Pageable` 參數 - `Page<T>` 同時包含分頁內容與總筆數、總頁數 - 範例: ```java public interface ProductRepository extends JpaRepository<Product, Long> { // 正確:Pageable 參數 + 返回 Page Page<Product> findByCategory(String category, Pageable pageable); } ``` 4. 切片查詢 - 使用 `Slice<T>`,需接收 `Pageable` 參數 - `Slice<T>` 只關心當前頁及是否有下一頁(效能較 Page 輕量) - 範例: ```java public interface CommentRepository extends JpaRepository<Comment, Long> { // 正確:Slice<T> 用於僅需下一頁資訊時 Slice<Comment> findByPostId(Long postId, Pageable pageable); } ``` ## 5. 命名與參數建議 - Repository 方法前綴:`find…By` - 建議使用 `Pageable pageable` 參數作為最後一個參數 - 如有排序需求,可 `Pageable` 一併處理 ## 6. 禁用與注意事項 - 禁止回傳: - 任何非上述 4 種型別 - `Stream<T>`(可在 service 內轉) - 原生陣列、Set、Map、自訂 Wrapper - 若必須自訂 return DTO,可搭配 JPQL Constructor Expression,但仍包在上述型別中: ```java List<UserDto> findAllUserDto(); // 不分頁 Page<UserDto> findAllUserDto(Pageable pageable); // 分頁 ``` ## 7. 程式範例整合 ```java @Repository public interface MemberRepository extends JpaRepository<Member, Long> { // 單筆查詢 Optional<Member> findByUsername(String username); // 多筆查詢 List<Member> findByStatus(MemberStatus status); // 分頁查詢 Page<Member> findByRole(String role, Pageable pageable); // 切片查詢 Slice<Member> findByCreatedAfter(LocalDateTime dateTime, Pageable pageable); // 自訂 DTO + 分頁 @Query("SELECT new com.myapp.dto.MemberInfo(m.id, m.name) " + "FROM Member m WHERE m.active = true") Page<MemberInfo> findActiveMembers(Pageable pageable); } ``` --- 請依此規範撰寫/審查 Repository 方法,確保回傳型別一致且符合團隊最佳實踐。若有特例需求,請先與架構師討論。 ## 📘 Java Stream 與 Optional 使用規範 ### ✅ Optional 使用原則 * ✅ 用於表示 **可能為空** 的回傳值 * ✅ 絕不應回傳 null 的 Optional * ✅ 請使用 `ifPresent()`、`map()`、`orElse()` 等鏈式操作 * ❌ 不要直接 `optional.get()`,請使用 `orElseThrow()` #### 範例: ```java Optional<User> userOpt = userRepository.findById(id); // 錯誤用法:可能拋出 NoSuchElementException User user = userOpt.get(); // 正確用法: User user = userOpt.orElseThrow(() -> new NotFoundException("找不到使用者")); ``` ### ✅ Java Stream 使用原則 * ✅ 優先使用 `filter()`、`map()`、`collect()` 等中介操作取代 for 迴圈 * ✅ 使用 `findFirst()` 或 `findAny()` 時,回傳 `Optional` * ❌ 不應在 Stream 中使用副作用操作(如在 `map()` 中寫入資料庫) #### 範例: ```java List<User> activeUsers = users.stream() .filter(User::isActive) .collect(Collectors.toList()); Optional<User> userOpt = users.stream() .filter(u -> u.getId().equals(id)) .findFirst(); userOpt.ifPresent(u -> logger.info("找到使用者:{}", u.getName())); ```