簡介
專業的API需要驗證輸入數據,以標準格式傳回錯誤,並針對每個環境進行不同的配置。本文使用 Bean Validation(Hibernate Validator)、異常映射器(根據 RFC 9457 Problem Details)以及 Quarkus 的 Config Profiles。
Bean 驗證
依賴關係
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
DTO 驗證
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank(message = "Tên sản phẩm không được để trống")
@Size(min = 3, max = 255,
message = "Tên sản phẩm phải từ 3 đến 255 ký tự")
String name,
@Size(max = 5000, message = "Mô tả tối đa 5000 ký tự")
String description,
@NotNull(message = "Giá không được để trống")
@DecimalMin(value = "0.01", message = "Giá phải lớn hơn 0")
@DecimalMax(value = "999999999.99",
message = "Giá tối đa 999,999,999.99")
BigDecimal price,
@NotNull(message = "Danh mục không được để trống")
Long categoryId,
@Min(value = 0, message = "Số lượng không được âm")
@Max(value = 1000000, message = "Số lượng tối đa 1,000,000")
int stockQuantity
) {}
資源驗證
import jakarta.validation.Valid;
@POST
public Response create(@Valid CreateProductRequest request) {
// Nếu validation fail → Quarkus tự động throw
// ConstraintViolationException với HTTP 400
ProductDTO created = productService.create(request);
return Response.created(
URI.create("/api/v1/products/" + created.id()))
.entity(created).build();
}
自訂驗證器
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidSlug.Validator.class)
public @interface ValidSlug {
String message() default "Slug không hợp lệ (chỉ chấp nhận a-z, 0-9, dấu gạch ngang)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements ConstraintValidator<ValidSlug, String> {
private static final Pattern SLUG_PATTERN =
Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
@Override
public boolean isValid(String value,
ConstraintValidatorContext ctx) {
if (value == null) return true; // @NotNull xử lý riêng
return SLUG_PATTERN.matcher(value).matches();
}
}
}
跨領域驗證(類別層級)
驗證多個依賴欄位:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy =
ValidDateRange.Validator.class)
public @interface ValidDateRange {
String message() default
"Ngày kết thúc phải sau ngày bắt đầu";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements
ConstraintValidator<ValidDateRange,
CreatePromotionRequest> {
@Override
public boolean isValid(
CreatePromotionRequest req,
ConstraintValidatorContext ctx) {
if (req.startDate() == null
|| req.endDate() == null) {
return true;
}
if (req.endDate().isBefore(req.startDate())) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(
"endDate phải sau startDate")
.addPropertyNode("endDate")
.addConstraintViolation();
return false;
}
return true;
}
}
}
@ValidDateRange
public record CreatePromotionRequest(
@NotBlank String name,
@NotNull @DecimalMin("0.01") BigDecimal discount,
@NotNull @FutureOrPresent LocalDate startDate,
@NotNull @Future LocalDate endDate
) {}
驗證群組
使用群組對建立與更新進行不同的驗證:
// Groups
public interface OnCreate {}
public interface OnUpdate {}
public record ProductRequest(
@Null(groups = OnCreate.class,
message = "Không được truyền ID khi tạo mới")
@NotNull(groups = OnUpdate.class,
message = "ID bắt buộc khi cập nhật")
Long id,
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
String name,
@NotNull(groups = OnCreate.class)
BigDecimal price,
@NotNull(groups = OnCreate.class)
@Min(value = 0, groups = {OnCreate.class, OnUpdate.class})
Integer stockQuantity
) {}
// Resource sử dụng groups
@POST
public Response create(
@Valid @ConvertGroup(to = OnCreate.class)
ProductRequest request) {
// ...
}
@PUT @Path("/{id}")
public ProductDTO update(
@PathParam("id") Long id,
@Valid @ConvertGroup(to = OnUpdate.class)
ProductRequest request) {
// ...
}
方法級驗證
驗證參數和傳回值:
@ApplicationScoped
public class ProductService {
@Inject
ProductRepository productRepo;
// Validate parameter
public List<Product> search(
@NotBlank String keyword,
@Min(0) int page,
@Min(1) @Max(100) int size) {
return productRepo.search(keyword,
Page.of(page, size));
}
// Validate return value
@Valid
public ProductDTO getById(@Min(1) Long id) {
return productRepo.findByIdOptional(id)
.map(ProductDTO::from)
.orElseThrow(() ->
new ResourceNotFoundException(
"Product", id));
}
}
異常處理 — RFC 9457 問題詳細信息
問題詳細資訊 DTO
import java.net.URI;
import java.time.Instant;
import java.util.List;
public record ProblemDetail(
URI type,
String title,
int status,
String detail,
URI instance,
Instant timestamp,
List<FieldError> errors
) {
public record FieldError(
String field,
String message,
Object rejectedValue
) {}
public static ProblemDetail of(int status, String title,
String detail) {
return new ProblemDetail(
URI.create("about:blank"), title, status, detail,
null, Instant.now(), null);
}
public static ProblemDetail withErrors(int status, String title,
String detail,
List<FieldError> errors) {
return new ProblemDetail(
URI.create("about:blank"), title, status, detail,
null, Instant.now(), errors);
}
}
ConstraintViolation ExceptionMapper
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.List;
@Provider
public class ConstraintViolationExceptionMapper
implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException ex) {
List<ProblemDetail.FieldError> fieldErrors = ex
.getConstraintViolations()
.stream()
.map(cv -> new ProblemDetail.FieldError(
extractFieldName(cv),
cv.getMessage(),
cv.getInvalidValue()))
.toList();
ProblemDetail problem = ProblemDetail.withErrors(
400,
"Validation Error",
"Dữ liệu đầu vào không hợp lệ",
fieldErrors);
return Response.status(400)
.type("application/problem+json")
.entity(problem)
.build();
}
private String extractFieldName(ConstraintViolation<?> cv) {
String path = cv.getPropertyPath().toString();
// "create.request.name" → "name"
int lastDot = path.lastIndexOf('.');
return lastDot >= 0 ? path.substring(lastDot + 1) : path;
}
}
自訂業務例外
// Exception classes
public class ResourceNotFoundException extends RuntimeException {
private final String resourceType;
private final Object resourceId;
public ResourceNotFoundException(String type, Object id) {
super(type + " with id " + id + " not found");
this.resourceType = type;
this.resourceId = id;
}
// getters...
}
public class BusinessException extends RuntimeException {
private final int statusCode;
public BusinessException(int statusCode, String message) {
super(message);
this.statusCode = statusCode;
}
// getter...
}
// ExceptionMapper
@Provider
public class ResourceNotFoundExceptionMapper
implements ExceptionMapper<ResourceNotFoundException> {
@Override
public Response toResponse(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.of(
404, "Resource Not Found", ex.getMessage());
return Response.status(404)
.type("application/problem+json")
.entity(problem).build();
}
}
@Provider
public class BusinessExceptionMapper
implements ExceptionMapper<BusinessException> {
@Override
public Response toResponse(BusinessException ex) {
ProblemDetail problem = ProblemDetail.of(
ex.getStatusCode(), "Business Error", ex.getMessage());
return Response.status(ex.getStatusCode())
.type("application/problem+json")
.entity(problem).build();
}
}
Catch-all ExceptionMapper
@Provider
public class GenericExceptionMapper
implements ExceptionMapper<Exception> {
private static final Logger LOG =
Logger.getLogger(GenericExceptionMapper.class);
@Override
public Response toResponse(Exception ex) {
LOG.error("Unhandled exception", ex);
ProblemDetail problem = ProblemDetail.of(
500,
"Internal Server Error",
"Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau.");
return Response.status(500)
.type("application/problem+json")
.entity(problem).build();
}
}
ExceptionMappers 組織-集中處理程序
不要將許多單獨的映射器類別分組為一個類別:
@Provider
public class GlobalExceptionHandler
implements ExceptionMapper<Throwable> {
private static final Logger LOG =
Logger.getLogger(GlobalExceptionHandler.class);
@Override
public Response toResponse(Throwable ex) {
return switch (ex) {
case ConstraintViolationException cve ->
handleValidation(cve);
case ResourceNotFoundException rnfe ->
handleNotFound(rnfe);
case BusinessException be ->
handleBusiness(be);
case ForbiddenException fe ->
handleForbidden(fe);
case WebApplicationException wae ->
handleWebApp(wae);
default -> handleUnexpected(ex);
};
}
private Response handleValidation(
ConstraintViolationException ex) {
List<ProblemDetail.FieldError> fieldErrors =
ex.getConstraintViolations().stream()
.map(cv -> new ProblemDetail.FieldError(
extractFieldName(cv),
cv.getMessage(),
cv.getInvalidValue()))
.toList();
return respond(400, "Validation Error",
"Dữ liệu đầu vào không hợp lệ",
fieldErrors);
}
private Response handleNotFound(
ResourceNotFoundException ex) {
return respond(404, "Not Found",
ex.getMessage(), null);
}
private Response handleBusiness(BusinessException ex) {
return respond(ex.getStatusCode(),
"Business Error", ex.getMessage(), null);
}
private Response handleForbidden(ForbiddenException ex) {
return respond(403, "Forbidden",
ex.getMessage(), null);
}
private Response handleWebApp(
WebApplicationException ex) {
int status = ex.getResponse().getStatus();
return respond(status,
Response.Status.fromStatusCode(status)
.getReasonPhrase(),
ex.getMessage(), null);
}
private Response handleUnexpected(Throwable ex) {
LOG.error("Unhandled exception", ex);
return respond(500, "Internal Server Error",
"Đã xảy ra lỗi hệ thống."
+ " Vui lòng thử lại sau.", null);
}
private Response respond(int status, String title,
String detail,
List<ProblemDetail.FieldError> errors) {
ProblemDetail problem = errors != null
? ProblemDetail.withErrors(
status, title, detail, errors)
: ProblemDetail.of(status, title, detail);
return Response.status(status)
.type("application/problem+json")
.entity(problem).build();
}
private String extractFieldName(
ConstraintViolation<?> cv) {
String path = cv.getPropertyPath().toString();
int lastDot = path.lastIndexOf('.');
return lastDot >= 0
? path.substring(lastDot + 1) : path;
}
}
錯誤回應 i18n
Quarkus 支援多語言驗證訊息:
# src/main/resources/ValidationMessages.properties
# (default — Vietnamese)
product.name.required=Tên sản phẩm không được để trống
product.name.size=Tên sản phẩm phải từ {min} đến {max} ký tự
product.price.min=Giá phải lớn hơn {value}
# src/main/resources/ValidationMessages_en.properties
product.name.required=Product name is required
product.name.size=Product name must be between {min} and {max} characters
product.price.min=Price must be greater than {value}
public record CreateProductRequest(
@NotBlank(message = "{product.name.required}")
@Size(min = 3, max = 255,
message = "{product.name.size}")
String name,
@DecimalMin(value = "0.01",
message = "{product.price.min}")
BigDecimal price
) {}
客戶端發送標頭 Accept-Language: en → 接收英文錯誤訊息。
回應範例
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "Dữ liệu đầu vào không hợp lệ",
"timestamp": "2026-04-15T10:30:00Z",
"errors": [
{
"field": "name",
"message": "Tên sản phẩm không được để trống",
"rejectedValue": null
},
{
"field": "price",
"message": "Giá phải lớn hơn 0",
"rejectedValue": -100
}
]
}
設定檔
預設:開發、測試、生產
# application.properties
# Chung cho tất cả profiles
quarkus.application.name=product-service
quarkus.http.port=8080
# --- DEV profile (mặc định khi `quarkus dev`) ---
%dev.quarkus.log.level=DEBUG
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.hibernate-orm.log.sql=true
# --- TEST profile (mặc định khi `mvn test`) ---
%test.quarkus.log.level=INFO
%test.quarkus.hibernate-orm.database.generation=drop-and-create
# --- PROD profile (mặc định khi `java -jar`) ---
%prod.quarkus.log.level=WARN
%prod.quarkus.hibernate-orm.database.generation=none
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
%prod.quarkus.datasource.username=${DB_USERNAME}
%prod.quarkus.datasource.password=${DB_PASSWORD}
自訂設定檔
# Profile: staging
%staging.quarkus.datasource.jdbc.url=jdbc:postgresql://staging-db:5432/ecommerce
%staging.quarkus.log.level=INFO
%staging.quarkus.hibernate-orm.database.generation=none
# Chạy với custom profile
quarkus dev -Dquarkus.profile=staging
# hoặc
java -Dquarkus.profile=staging -jar target/quarkus-app/quarkus-run.jar
應用程式 YAML(替換屬性)
# src/main/resources/application.yml
quarkus:
application:
name: product-service
datasource:
db-kind: postgresql
hibernate-orm:
database:
generation: drop-and-create
"%prod":
quarkus:
datasource:
jdbc:
url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hibernate-orm:
database:
generation: none
使用@ConfigMapping 進行類型安全性配置
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import java.util.Optional;
@ConfigMapping(prefix = "app.product")
public interface ProductConfig {
@WithDefault("20")
int defaultPageSize();
@WithDefault("100")
int maxPageSize();
Optional<String> importFilePath();
FeatureFlags features();
interface FeatureFlags {
@WithDefault("false")
boolean enableRecommendations();
@WithDefault("true")
boolean enableFullTextSearch();
}
}
# application.properties
app.product.default-page-size=20
app.product.max-page-size=100
app.product.features.enable-recommendations=false
app.product.features.enable-full-text-search=true
@ApplicationScoped
public class ProductService {
@Inject
ProductConfig config;
public List<Product> list(int page, int size) {
int pageSize = Math.min(size, config.maxPageSize());
// ...
}
}
練習
- 新增 Bean 驗證
CreateProductRequest和UpdateProductRequest - 建立自訂
@ValidSlug註解驗證器 - 實施跨領域驗證
@ValidDateRange用於促銷 - 實施
ProblemDetail根據 RFC 9457 的回應 - 創建
GlobalExceptionHandler透過模式匹配處理所有異常 - 新增驗證組(
OnCreate,OnUpdate) 對於產品請求 - 創建
ValidationMessages.propertiesi18n(越南語和英語) - 配置
%dev,%test,%prod設定檔在application.properties - 創建
ProductConfig與@ConfigMapping管理鍵入的配置 - 寫
@QuarkusTest測試驗證錯誤返回正確的問題詳細資訊格式
測試驗證
@QuarkusTest
class ValidationTest {
@Test
void testCreateProductValidation() {
given()
.contentType("application/json")
.body("""
{
"name": "",
"price": -100,
"stockQuantity": -1
}
""")
.when().post("/api/v1/products")
.then()
.statusCode(400)
.contentType("application/problem+json")
.body("title", equalTo("Validation Error"))
.body("status", equalTo(400))
.body("errors.size()", greaterThan(0))
.body("errors.field",
hasItems("name", "price"));
}
@Test
void testNotFoundResponse() {
given()
.when().get("/api/v1/products/99999")
.then()
.statusCode(404)
.contentType("application/problem+json")
.body("title", equalTo("Not Found"));
}
@Test
void testCustomSlugValidation() {
given()
.contentType("application/json")
.body("""
{ "slug": "Invalid Slug With Spaces!" }
""")
.when().post("/api/v1/categories")
.then()
.statusCode(400)
.body("errors[0].field",
equalTo("slug"));
}
}
總結
- Bean 驗證 (
@NotBlank,@Size,@Min...) 使用時自動驗證@Valid - ExceptionMapper 將異常轉換為標準 JSON 回應
- RFC 9457 問題詳細資料 — 標準錯誤格式
application/problem+json - 設定檔 (
%dev,%test,%prod) — 每個環境的單獨配置 @ConfigMapping— 類型安全性配置替代方案@ConfigProperty
下一篇文章:領域驅動設計與每服務資料庫-微服務架構設計。