# 🛠 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()));
```