簡介
在生產中,您永遠不會將實體直接暴露給 API。 DTO 模式將表示層與持久層分開。結合 Bean 驗證和全域異常處理,您將建立一個強大且專業的 API。
1. 使用 Java 記錄的 DTO 模式
1.1 為什麼需要DTO?
Client ←→ Controller ←→ Service ←→ Repository ←→ Database
│ │
DTO/Request Entity/Model
DTO/Response
Lý do:
- Không expose fields nhạy cảm (password hash, internal IDs)
- Decouple API contract khỏi database schema
- Validate input tại boundary
- Versioning API dễ dàng hơn
1.2 使用 Java 記錄進行 DTO
// Entity - ánh xạ database
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String passwordHash;
private String role;
private boolean active;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// getters, setters...
}
// Request DTO - nhận input từ client
public record CreateUserRequest(
String name,
String email,
String password
) {}
public record UpdateUserRequest(
String name,
String email
) {}
// Response DTO - trả về cho client
public record UserResponse(
Long id,
String name,
String email,
String role,
LocalDateTime createdAt
) {
// Factory method từ Entity
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getRole(),
user.getCreatedAt()
);
}
}
1.3 服務中的映射
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public UserResponse create(CreateUserRequest request) {
User user = new User();
user.setName(request.name());
user.setEmail(request.email());
user.setPasswordHash(passwordEncoder.encode(request.password()));
user.setRole("USER");
user.setActive(true);
user.setCreatedAt(LocalDateTime.now());
User saved = userRepository.save(user);
return UserResponse.from(saved);
}
public List<UserResponse> findAll() {
return userRepository.findAll().stream()
.map(UserResponse::from)
.toList();
}
}
2.Bean 驗證
2.1 依賴關係
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2.2 驗證註釋
public record CreateUserRequest(
@NotBlank(message = "Tên không được để trống")
@Size(min = 2, max = 100, message = "Tên phải từ 2-100 ký tự")
String name,
@NotBlank(message = "Email không được để trống")
@Email(message = "Email không hợp lệ")
String email,
@NotBlank(message = "Mật khẩu không được để trống")
@Size(min = 8, message = "Mật khẩu phải ít nhất 8 ký tự")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Mật khẩu phải có chữ hoa, chữ thường và số")
String password,
@Min(value = 0, message = "Tuổi phải >= 0")
@Max(value = 150, message = "Tuổi phải <= 150")
Integer age,
@NotNull(message = "Ngày sinh không được null")
@Past(message = "Ngày sinh phải là ngày trong quá khứ")
LocalDate dateOfBirth
) {}
2.3 在控制器中啟用驗證
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
// @Valid trigger validation
// Nếu invalid, Spring tự động throw MethodArgumentNotValidException
UserResponse user = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
2.4 常用驗證註解
| 註釋 | 描述 |
|---|---|
@NotNull | 不為空 |
@NotBlank | 不為空,不為空,不只是空格 |
@NotEmpty | 不為空,不為空(對於字串、集合) |
@Size(min, max) | 極限長度 |
@Min / @Max | 數量限制 |
@Email | 電子郵件格式 |
@Pattern | 正規表示式模式 |
@Past / @Future | 過去/未來的日期 |
@Positive / @Negative | 正/負數 |
2.5 自訂驗證器
// Tạo custom annotation
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email đã được sử dụng";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Implement validator
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true; // @NotBlank sẽ handle null
return !userRepository.existsByEmail(email);
}
}
// Sử dụng
public record CreateUserRequest(
@NotBlank String name,
@Email @UniqueEmail String email,
@NotBlank @Size(min = 8) String password
) {}
3. 全域異常處理
3.1 問題詳細資料 (RFC 9457)
Spring Boot 4.x 支援 ProblemDetail — RFC 9457 錯誤回應標準:
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "Request validation failed",
"instance": "/api/v1/users",
"errors": [
{"field": "email", "message": "Email không hợp lệ"},
{"field": "password", "message": "Mật khẩu phải ít nhất 8 ký tự"}
]
}
3.2 @ControllerAdvice — 全域例外處理程序
@RestControllerAdvice
public class GlobalExceptionHandler {
// Validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationErrors(
MethodArgumentNotValidException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation Error");
problem.setDetail("Request validation failed");
problem.setInstance(URI.create(request.getRequestURI()));
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors().stream()
.map(error -> Map.of(
"field", error.getField(),
"message", error.getDefaultMessage() != null
? error.getDefaultMessage() : "Invalid value"
))
.toList();
problem.setProperty("errors", errors);
return problem;
}
// Resource not found
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(
ResourceNotFoundException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setTitle("Resource Not Found");
problem.setDetail(ex.getMessage());
problem.setInstance(URI.create(request.getRequestURI()));
return problem;
}
// Duplicate resource
@ExceptionHandler(DuplicateResourceException.class)
public ProblemDetail handleDuplicate(
DuplicateResourceException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problem.setTitle("Duplicate Resource");
problem.setDetail(ex.getMessage());
problem.setInstance(URI.create(request.getRequestURI()));
return problem;
}
// Catch-all for unexpected errors
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneral(
Exception ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatus(
HttpStatus.INTERNAL_SERVER_ERROR);
problem.setTitle("Internal Server Error");
problem.setDetail("An unexpected error occurred");
problem.setInstance(URI.create(request.getRequestURI()));
// Không expose exception details cho client trong production
return problem;
}
}
3.3 自訂異常類
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, String field, Object value) {
super(String.format("%s not found with %s: '%s'", resource, field, value));
}
}
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String resource, String field, Object value) {
super(String.format("%s already exists with %s: '%s'", resource, field, value));
}
}
// Sử dụng trong service
@Service
public class UserService {
public UserResponse findById(Long id) {
return userRepository.findById(id)
.map(UserResponse::from)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
}
}
4. 在 Spring Boot 中啟用 ProblemDetail
# application.yaml
spring:
mvc:
problemdetails:
enabled: true # Bật ProblemDetail cho tất cả exceptions
5.回應包絡模式(可選)
// API Response wrapper
public record ApiResponse<T>(
boolean success,
T data,
String message,
LocalDateTime timestamp
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, LocalDateTime.now());
}
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, data, message, LocalDateTime.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message, LocalDateTime.now());
}
}
// Sử dụng
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) {
UserResponse user = userService.findById(id);
return ResponseEntity.ok(ApiResponse.ok(user));
}
總結
- DTO 模式將 API 契約與資料庫實體分開,並使用 Java 記錄作為不可變的 DTO
- Bean 驗證(@Valid、@NotBlank、@Email...)驗證控制器層的輸入,支援自訂驗證器
- @RestControllerAdvice + @ExceptionHandler 集中處理異常,使用 ProblemDetail (RFC 9457) 進行標準化錯誤回應
練習
1.為Product實體建立DTO:CreateProductRequest(含驗證)、UpdateProductRequest、ProductResponse(不暴露成本價)
2. 實作自訂驗證器 @ValidSlug 檢查 slug 僅包含小寫字母、數字和破折號
3.建立一個GlobalExceptionHandler,處理至少4種不同類型的例外,回傳RFC 9457標準ProblemDetail