統一錯誤處理模組
Package:
io.leandev.appfuse.error.*,io.leandev.appfuse.exception.*
AppFuse Server 提供統一的錯誤處理機制,將所有異常轉換為 RFC 7807 ProblemDetail 標準格式。
核心特色
1. RFC 7807 ProblemDetail 格式
RFC 7807 定義了 HTTP API 的標準錯誤回應格式:
{
"type": "urn:appfuse:error:not-found",
"title": "Not Found",
"status": 404,
"detail": "User with ID 123 does not exist",
"instance": "/api/users/123",
"violations": [],
"format": "User with ID ${0} does not exist",
"params": ["123"]
}
| 欄位 | 說明 |
|---|---|
type | 問題類型 URI(識別錯誤種類) |
title | HTTP 狀態描述 |
status | HTTP 狀態碼 |
detail | 問題詳細描述 |
instance | 發生問題的請求路徑 |
violations | 驗證錯誤詳情(僅驗證錯誤) |
format | 訊息模板(用於國際化) |
params | 訊息參數 |
2. 架構圖
3. 工具類別
| 類別 | 用途 |
|---|---|
| StandardRestExceptionHandler | 統一異常處理器基類 |
| ExceptionMapper | 異常映射策略接口 |
| ExceptionMappingRegistry | 異常映射器註冊中心 |
| ProblemDetailFactory | ProblemDetail 建立工廠 |
| ApplicationException | 應用程式異常基類 |
| Violation | 驗證違規記錄 |
基本用法
啟用統一錯誤處理
只需繼承 StandardRestExceptionHandler 並加上 @ControllerAdvice:
import io.leandev.appfuse.error.StandardRestExceptionHandler;
import org.springframework.web.bind.annotation.ControllerAdvice;
@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {
}
這樣就完成了!所有異常都會自動轉換為 RFC 7807 格式。
預設處理的異常
StandardRestExceptionHandler 預設處理以下異常:
| 異常類型 | HTTP 狀態 | 說明 |
|---|---|---|
MissingServletRequestParameterException | 400 | 缺少必要參數 |
MissingPathVariableException | 400 | 缺少路徑變數 |
MethodArgumentNotValidException | 400 | @Valid 驗證失敗 |
ValidationException | 400 | Bean Validation 失敗 |
HttpMessageConversionException | 400 | JSON 解析錯誤 |
TransactionSystemException | 400/500 | 事務驗證失敗 |
DataAccessException | 500 | 資料庫操作錯誤 |
ApplicationException | 依子類 | 應用程式自定義異常 |
ApplicationException 異常體系
異常繼承結構
內建異常類型
| 異常 | HTTP 狀態 | 用途 |
|---|---|---|
NotFoundException | 404 | 資源不存在 |
ConflictException | 409 | 資源衝突 |
DuplicateException | 409 | 重複資料 |
InvalidDataException | 400 | 無效資料 |
ConstraintException | 400 | 驗證失敗(含詳細違規) |
LockoutException | 401 | 帳號鎖定 |
VerificationException | 400 | 驗證碼錯誤 |
拋出異常
import io.leandev.appfuse.exception.*;
@Service
public class UserService {
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User with ID ${0} does not exist", id));
}
public User create(UserRequest request) {
// 檢查重複
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateException("Email ${0} already exists", request.getEmail());
}
// 業務規則驗證
if (request.getAge() < 18) {
throw new InvalidDataException("User must be at least 18 years old");
}
return userRepository.save(toEntity(request));
}
public void updateStatus(Long id, String newStatus, String currentStatus) {
// 狀態衝突檢查
if (!isValidTransition(currentStatus, newStatus)) {
throw new ConflictException(
"Cannot change status from ${0} to ${1}",
currentStatus, newStatus
);
}
}
}
訊息格式化
ApplicationException 支援兩種格式化方式:
// 位置參數格式(推薦)
throw new NotFoundException("User ${0} in department ${1} not found", userId, deptId);
// 結果:User 123 in department IT not found
// 傳統 String.format 格式
throw new NotFoundException("User %s not found", userId);
// 結果:User 123 not found
Violation 驗證違規
基本用法
import io.leandev.appfuse.exception.Violation;
import io.leandev.appfuse.exception.ConstraintException;
// 單一欄位違規
Violation violation = new Violation("email", "Invalid email format");
// 帶參數的違規(符合 Bean Validation 標準)
Map<String, Object> params = Map.of("min", 8, "max", 20);
Violation violation = new Violation(
"password",
"Password length must be between ${min} and ${max}",
params
);
// 巢狀屬性違規
Violation violation = new Violation(
new String[]{"address", "zipCode"},
"Invalid zip code format",
Map.of()
);
// 拋出帶違規的異常
Set<Violation> violations = Set.of(
new Violation("email", "Invalid email format"),
new Violation("age", "Must be at least ${value}", Map.of("value", 18))
);
throw new ConstraintException("Validation failed", violations);
API 回應範例
{
"type": "urn:appfuse:error:validation-error",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/users",
"violations": [
{
"props": ["email"],
"message": "Invalid email format",
"params": {}
},
{
"props": ["age"],
"message": "Must be at least ${value}",
"params": {"value": 18}
}
]
}
ProblemDetailFactory
建立常用錯誤
import io.leandev.appfuse.error.ProblemDetailFactory;
// 400 Bad Request
ProblemDetail problem = ProblemDetailFactory.badRequest("Invalid input");
// 401 Unauthorized
ProblemDetail problem = ProblemDetailFactory.unauthorized("Authentication required");
// 403 Forbidden
ProblemDetail problem = ProblemDetailFactory.forbidden("Access denied");
// 404 Not Found
ProblemDetail problem = ProblemDetailFactory.notFound("Resource not found");
// 409 Conflict
ProblemDetail problem = ProblemDetailFactory.conflict("Resource already exists");
// 422 Unprocessable Entity(業務規則違反)
ProblemDetail problem = ProblemDetailFactory.unprocessableEntity("Business rule violated");
// 500 Internal Server Error
ProblemDetail problem = ProblemDetailFactory.internalError("Unexpected error");
認證相關錯誤
// 帳號鎖定
ProblemDetail problem = ProblemDetailFactory.accountLocked("Account locked due to too many failed attempts");
// 帳號停用
ProblemDetail problem = ProblemDetailFactory.accountDisabled("Account has been disabled");
// 帳號過期
ProblemDetail problem = ProblemDetailFactory.accountExpired("Account has expired");
// 憑證過期
ProblemDetail problem = ProblemDetailFactory.credentialsExpired("Password has expired");
// 無效憑證
ProblemDetail problem = ProblemDetailFactory.badCredentials("Invalid username or password");
驗證錯誤
Set<Violation> violations = Set.of(
new Violation("email", "Invalid email format"),
new Violation("password", "Password too weak")
);
ProblemDetail problem = ProblemDetailFactory.validationError(
"Validation failed. Please check your input.",
violations
);
擴充屬性
ProblemDetail problem = ProblemDetailFactory.notFound("User not found");
// 加入錯誤代碼
ProblemDetailFactory.withErrorCode(problem, "USER_NOT_FOUND");
// 加入國際化參數
ProblemDetailFactory.withI18n(problem, "error.user.notFound", new Object[]{"123"});
自定義 ExceptionMapper
實作 ExceptionMapper 接口
import io.leandev.appfuse.error.ExceptionMapper;
import io.leandev.appfuse.error.ProblemDetailFactory;
import org.springframework.http.ProblemDetail;
import java.net.URI;
public class PaymentExceptionMapper implements ExceptionMapper<PaymentException> {
@Override
public boolean supports(Exception exception) {
return exception instanceof PaymentException;
}
@Override
public ProblemDetail map(PaymentException ex) {
ProblemDetail problem;
if (ex instanceof InsufficientFundsException) {
problem = ProblemDetailFactory.unprocessableEntity(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:insufficient-funds"));
problem.setTitle("Insufficient Funds");
} else if (ex instanceof PaymentDeclinedException) {
problem = ProblemDetailFactory.unprocessableEntity(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:payment-declined"));
problem.setTitle("Payment Declined");
} else {
problem = ProblemDetailFactory.internalError(ex.getMessage());
}
// 加入自訂屬性
problem.setProperty("transactionId", ex.getTransactionId());
return problem;
}
}
註冊自定義 Mapper
@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {
static {
// 在靜態初始化區塊註冊自定義 Mapper
getRegistry().register(new PaymentExceptionMapper());
getRegistry().register(new ThirdPartyServiceExceptionMapper());
}
}
Mapper 優先順序
ExceptionMappingRegistry 按註冊順序匹配,先註冊的優先:
// 預設順序(越具體越優先)
1. LockoutExceptionMapper // 登入鎖定(特殊處理)
2. ApplicationExceptionMapper // AppFuse 自定義異常
3. ValidationExceptionMapper // Bean Validation
4. TransactionExceptionMapper // 事務異常
5. DataAccessExceptionMapper // 資料庫異常
6. HttpMessageConversionExceptionMapper // HTTP 訊息轉換
自定義 Mapper 會加到列表末尾,如需優先處理特定異常,確保 supports() 方法精確匹配。
Spring Boot 整合
配置類別
@Configuration
public class ErrorHandlingConfig {
@Bean
@ControllerAdvice
public StandardRestExceptionHandler exceptionHandler() {
return new StandardRestExceptionHandler() {
// 可覆寫方法自定義行為
};
}
}
自定義日誌等級
@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {
@Override
protected ResponseEntity<Object> handleProblemDetail(
ProblemDetail problem,
Throwable cause,
WebRequest request) {
// 自定義日誌邏輯
if (cause instanceof SecurityException) {
log.warn("Security violation: {}", problem.getDetail(), cause);
}
return super.handleProblemDetail(problem, cause, request);
}
}
與 Security 整合
@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException ex) throws IOException {
ProblemDetail problem = ProblemDetailFactory.unauthorized(ex.getMessage());
problem.setInstance(URI.create(request.getRequestURI()));
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), problem);
}
}
完整範例
訂單服務錯誤處理
// 自定義異常
public class OrderException extends ApplicationException {
public OrderException(String message, Object... params) {
super(message, params);
}
}
public class OrderNotFoundException extends OrderException {
public OrderNotFoundException(Long orderId) {
super("Order ${0} not found", orderId);
}
}
public class OrderCancelledException extends OrderException {
public OrderCancelledException(Long orderId) {
super("Order ${0} has already been cancelled", orderId);
}
}
// 自定義 Mapper
public class OrderExceptionMapper implements ExceptionMapper<OrderException> {
@Override
public boolean supports(Exception exception) {
return exception instanceof OrderException;
}
@Override
public ProblemDetail map(OrderException ex) {
ProblemDetail problem;
if (ex instanceof OrderNotFoundException) {
problem = ProblemDetailFactory.notFound(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:order-not-found"));
} else if (ex instanceof OrderCancelledException) {
problem = ProblemDetailFactory.conflict(ex.getMessage());
problem.setType(URI.create("urn:appfuse:error:order-cancelled"));
} else {
problem = ProblemDetailFactory.internalError(ex.getMessage());
}
ProblemDetailFactory.withI18n(problem, ex.getFormat(), ex.getParams());
return problem;
}
}
// 服務層
@Service
public class OrderService {
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public void cancel(Long id) {
Order order = findById(id);
if (order.isCancelled()) {
throw new OrderCancelledException(id);
}
order.cancel();
orderRepository.save(order);
}
}
// 全域異常處理器
@ControllerAdvice
public class GlobalExceptionHandler extends StandardRestExceptionHandler {
static {
getRegistry().register(new OrderExceptionMapper());
}
}
前端錯誤處理
interface ProblemDetail {
type: string;
title: string;
status: number;
detail: string;
instance: string;
violations?: Violation[];
format?: string;
params?: any[];
}
interface Violation {
props: string[];
message: string;
params: Record<string, any>;
}
// API 錯誤處理
async function handleApiError(response: Response): Promise<never> {
const problem: ProblemDetail = await response.json();
if (problem.violations?.length) {
// 驗證錯誤:顯示欄位錯誤
const fieldErrors = problem.violations.reduce((acc, v) => {
const field = v.props.join('.');
acc[field] = formatMessage(v.message, v.params);
return acc;
}, {} as Record<string, string>);
throw new ValidationError(problem.detail, fieldErrors);
}
// 其他錯誤
throw new ApiError(problem.status, problem.detail, problem.type);
}
function formatMessage(template: string, params: Record<string, any>): string {
return Object.entries(params).reduce(
(msg, [key, value]) => msg.replace(`\${${key}}`, String(value)),
template
);
}
最佳實踐
1. 使用語義化異常
// 推薦:使用語義化異常
throw new NotFoundException("User ${0} not found", userId);
throw new ConflictException("Order ${0} already confirmed", orderId);
// 避免:使用通用異常
throw new RuntimeException("User not found");
throw new IllegalStateException("Order already confirmed");
2. 提供國際化支援
// 保留 format 和 params,讓前端可以國際化
throw new NotFoundException("error.user.notFound", userId);
// 前端根據 format 查詢翻譯:「找不到使用者 {0}」
3. 驗證錯誤提供詳細資訊
// 推薦:提供詳細的驗證違規
Set<Violation> violations = new HashSet<>();
if (StringUtils.isBlank(request.getEmail())) {
violations.add(new Violation("email", "Email is required"));
}
if (request.getAge() < 18) {
violations.add(new Violation("age", "Must be at least ${value}", Map.of("value", 18)));
}
if (!violations.isEmpty()) {
throw new ConstraintException("Validation failed", violations);
}
4. 區分錯誤等級
// 4xx 錯誤:用戶可修正
throw new NotFoundException(...); // 404 - 資源不存在
throw new InvalidDataException(...); // 400 - 輸入錯誤
throw new ConflictException(...); // 409 - 狀態衝突
// 5xx 錯誤:系統問題
throw new ApplicationException(...); // 500 - 內部錯誤
5. 安全性考量
// 生產環境不暴露敏感資訊
if (ex instanceof DataAccessException) {
// 不要返回 SQL 錯誤詳情
return ProblemDetailFactory.internalError("Database error occurred");
}
// 認證錯誤使用模糊訊息
return ProblemDetailFactory.badCredentials("Invalid username or password");
// 不要說「使用者不存在」或「密碼錯誤」
下一步
- Security 模組 - 登入鎖定與 LockoutException
- NLS 模組 - 錯誤訊息國際化
- HTTP 模組 - HttpClientException 處理