Cải tiến lớp Controller với 4 bước chính
- Xây dựng cấu trúc phản hồi thống nhất
- Tự động đóng gói kết quả trả về
- Kiểm tra tham số đầu vào
- Xử lý ngoại lệ tùy chỉnh và bắt lỗi tập trung
Lớp Controller giữ vai trò quan trọng trong việc cung cấp API cho client. Dù không trực tiếp xử lý logic nghiệp vụ, nhưng nó chịu trách nhiệm nhận yêu cầu, kiểm tra dữ liệu và trả kết quả phù hợp.
Vấn đề thường gặp khi viết Controller
Một Controller tiêu chuẩn thường thực hiện:
- Nhận và phân tích yêu cầu từ client
- Gọi service để xử lý logic
- Bắt exception và phản hồi lỗi
- Trả kết quả thành công
// DTO
@Data
public class CalculationInput {
private Integer number;
private String operation;
}
// Service
@Service
public class CalculationService {
public Double calculate(CalculationInput input) throws Exception {
if (input.getNumber() <= 0) {
throw new Exception("Số nhập vào phải lớn hơn 0");
}
switch (input.getOperation()) {
case "square":
return Math.pow(input.getNumber(), 2);
case "factorial":
double result = 1;
int num = input.getNumber();
while (num > 1) {
result *= num--;
}
return result;
default:
throw new Exception("Phép toán không hợp lệ");
}
}
}
// Controller ban đầu
@RestController
public class CalculationController {
@Autowired
private CalculationService calcService;
@PostMapping("/calculate")
public Double performCalculation(@RequestBody CalculationInput input) {
try {
return calcService.calculate(input);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Cách làm này dẫn đến một số hạn chế:
- Logic kiểm tra dữ liệu lẫn lộn với nghiệp vụ
- Thông báo lỗi thiếu nhất quán giữa các API
- Code trùng lặp khi xử lý exception
Bước 1: Thiết kế cấu trúc phản hồi đồng nhất
Việc định nghĩa kiểu trả về chuẩn giúp frontend dễ dàng xử lý kết quả:
// Interface mô tả kết quả
public interface ResponseStatus {
Integer getCode();
String getMessage();
}
// Enum các trạng thái phổ biến
public enum StatusEnum implements ResponseStatus {
SUCCESS(2001, "Thành công"),
VALIDATION_ERROR(2002, "Dữ liệu không hợp lệ"),
SYSTEM_ERROR(2003, "Lỗi hệ thống"),
ACCESS_DENIED(2004, "Không có quyền truy cập");
private final Integer code;
private final String message;
StatusEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
// getter methods
}
// Lớp đóng gói kết quả trả về
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private Integer status;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(StatusEnum.SUCCESS.getCode(), StatusEnum.SUCCESS.getMessage(), data);
}
public static <T> ApiResponse<T> error(String msg) {
return new ApiResponse<>(StatusEnum.SYSTEM_ERROR.getCode(), msg, null);
}
public static ApiResponse<?> fail(ResponseStatus status) {
return new ApiResponse<>(status.getCode(), status.getMessage(), null);
}
}
Bước 2: Tự động đóng gói kết quả trả về
Sử dụng ResponseBodyAdvice để tự động bọc kết quả mà không cần sửa từng controller:
@RestControllerAdvice(basePackages = "com.example.app")
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof ApiResponse) {
return body;
}
if (body instanceof String) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(ApiResponse.ok(body));
} catch (JsonProcessingException e) {
throw new RuntimeException("Không thể chuyển đổi chuỗi JSON", e);
}
}
return ApiResponse.ok(body);
}
}
Để tránh lỗi ép kiểu với kiểu String, cần điều chỉnh thứ tự MessageConverter:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
for (int i = 0; i < converters.size(); i++) {
if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
MappingJackson2HttpMessageConverter jacksonConverter = (MappingJackson2HttpMessageConverter) converters.get(i);
converters.set(i, converters.get(0));
converters.set(0, jacksonConverter);
break;
}
}
}
}
Bước 3: Kiểm tra tham số đầu vào
Sử dụng Bean Validation để tách biệt logic kiểm tra dữ liệu:
// DTO với annotation kiểm tra
@Data
public class UserRegistration {
@NotBlank(message = "Tên đăng nhập không được để trống")
private String username;
@NotBlank
@Size(min = 6, max = 20, message = "Mật khẩu phải từ 6-20 ký tự")
private String password;
@Email(message = "Email không đúng định dạng")
private String email;
}
// Controller sử dụng @Validated
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping("/register")
public void registerUser(@RequestBody @Validated UserRegistration user) {
// Xử lý đăng ký
}
@GetMapping("/{id}")
public String getUserById(@PathVariable @Min(value = 1, message = "ID phải lớn hơn 0") Long id) {
return "User #" + id;
}
}
Tự tạo validator riêng cho trường hợp đặc biệt:
// Annotation tùy chỉnh
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface ValidPhone {
String message() default "Số điện thoại không hợp lệ";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator cụ thể
public class PhoneNumberValidator implements ConstraintValidator<ValidPhone, String> {
private static final Pattern PHONE_PATTERN = Pattern.compile("^\\d{10,11}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value != null && PHONE_PATTERN.matcher(value).matches();
}
}
Bước 4: Xử lý ngoại lệ tập trung
Định nghĩa các loại exception riêng và bắt lỗi toàn cục:
// Các exception tùy chỉnh
public class UnauthorizedAccessException extends RuntimeException {
public UnauthorizedAccessException(String message) {
super(message);
}
}
public class BusinessRuleException extends RuntimeException {
public BusinessRuleException(String message) {
super(message);
}
}
// Bắt lỗi toàn cục
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessRuleException.class)
public ApiResponse<?> handleBusinessError(BusinessRuleException ex) {
return ApiResponse.error(ex.getMessage());
}
@ExceptionHandler(UnauthorizedAccessException.class)
public ApiResponse<?> handleUnauthorized(UnauthorizedAccessException ex) {
return ApiResponse.fail(StatusEnum.ACCESS_DENIED);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<?> handleValidationErrors(MethodArgumentNotValidException ex) {
StringBuilder errorMsg = new StringBuilder();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errorMsg.append(error.getField())
.append(": ")
.append(error.getDefaultMessage())
.append("; ");
}
return ApiResponse.fail(new ResponseStatus() {
@Override
public Integer getCode() { return StatusEnum.VALIDATION_ERROR.getCode(); }
@Override
public String getMessage() { return errorMsg.toString(); }
});
}
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleGeneralError(Exception ex) {
return ApiResponse.error("Đã xảy ra lỗi không xác định");
}
}