在领域驱动设计(DDD)中,接口层主要负责处理与外部系统的交互,包括接收用户或外部系统的请求,调用应用层服务处理请求,以及将处理结果返回给请求方。
我发现一些代码中,接口的返回值类型众多,有的直接返回数据传输对象(DTO),甚至直接返回数据对象(DO),还有的返回Result对象。在DailyMart项目中,为了简化客户端的处理流程,我们决定在接口层采用统一的返回格式——Result对象。
为了实现统一返回格式,我们在DailyMart项目中构建了一个Result对象,代码如下:
@Data
@Accessors(chain = true)
public class Result {
public static final String SUCCESS_CODE = "OK";
private String code;
private String message;
private T data;
private long timestamp;
}
为了便于创建Result对象,我们构建了一个辅助类ResultHelper:
@Slf4j
public class ResultHelper {
public static Result success(T data) {
return new Result()
.setCode(SUCCESS_CODE)
.setData(data)
.setTimestamp(System.currentTimeMillis());
}
public static Result fail(String message) {
return new Result()
.setCode(ErrorCode.SERVICE_ERROR.getCode())
.setMessage(message)
.setTimestamp(System.currentTimeMillis());
}
...
}
以DailyMart系统的注册接口为例,定义了Result对象后,我们可以在接口层这样优化代码:
@PostMapping("/api/customer/register")
public Result register(@RequestBody @Valid UserRegistrationDTO customerDTO){
try {
return ResultHelper.success(customerService.register(customerDTO));
}catch (Exception e){
return ResultHelper.fail(e.getMessage());
}
}
为了避免每个接口都这样写,我们可以利用SpringBoot的全局异常处理器来处理,这将在下一节讨论。
现在,当访问注册接口时,成功会返回如下响应:
{
"code": "OK",
"message": null,
"data": {
"userName": "jianzh1",
"password": null,
"email": "[email protected]",
"phone": "18811117882"
},
"timestamp": 1687338445851
}
失败时会返回如下响应:
{
"code": "B0001",
"message": "用户已存在",
"data": null,
"timestamp": 1687338319457
}
这样,我们成功地实现了接口层的返回格式的统一。
在DailyMart的代码实现中,我们通常会在遇到问题时抛出RuntimeException。例如,在用户登录时,如果用户不存在,我们会抛出一个RuntimeException:
@Override
protected CustomerUser authenticate(UserLoginDTO loginDTO) {
CustomerUser actualUser = customerUserRepository.findByUserName(usernamePasswordLoginDTO.getUsername());
if(actualUser == null){
throw new RuntimeException("用户不存在");
}
return actualUser;
}
然而,在构建大型系统时,通常建议使用自定义异常来替代RuntimeException
。自定义异常可以提供更精细和具有针对性的错误信息,有助于区分系统中的不同类型的错误。使用自定义异常不仅可以提高代码的可读性,因为它们的名称和内容可以直接反映出问题的性质,而且还可以包含更多的信息,比如错误码或其他相关的上下文数据。
在开发过程中,错误码的使用是提升异常处理可读性和效率的有效手段。根据《阿里巴巴开发规范-黄山版》,错误码的制定和使用应遵循一定的原则,以便实现快速溯源和标准化沟通。
错误码通常是一个包含5个字符的字符串,它分为两部分:错误来源标识(1个字符)和错误编号(4个数字)。错误来源标识可以是A
、B
或C
:
A
表示错误源于用户,例如参数错误、版本过低或支付超时。
B
表示错误源于当前系统,通常是由于业务逻辑错误或程序健壮性不足。
C
表示错误源于第三方服务,例如 CDN 服务故障或消息投递超时。
错误编号是一个在0001到9999之间的四位数,用于进一步细化错误的类别。
错误码的主要目的是:
快速指示错误来源,帮助开发者迅速判断问题所在。
清晰地对错误进行分类和标识。
有助于团队成员快速达成对错误原因的共识。
在 DailyMart 项目中,我们依据阿里巴巴的开发规范定义了一个错误码的枚举类。这个枚举类包含一系列预定义的错误码及其对应的错误信息。
public enum ErrorCode {
OK("00000","操作已成功"),
CLIENT_ERROR("A0001", "客户端错误"),
USER_NOT_FOUND("A0010", "用户不存在"),
USER_ALREADY_EXISTS("A0011", "用户已存在"),
USERNAME_PASSWORD_INCORRECT("A0012", "用户名或密码错误"),
VERIFICATION_CODE_EXPIRED("A0013", "验证码已过期"),
BAD_CREDENTIALS_EXPIRED("A0014", "用户认证异常"),
SERVICE_ERROR("B0001", "系统内部错误"),
SERVICE_TIMEOUT_ERROR("B0010", "系统执行超时"),
REMOTE_ERROR("C0001", "第三方服务错误");
/**
* 错误码
*/
private final String code;
/**
* 错误信息
*/
private final String message;
...
}
每个错误码包含两个部分:错误码和错误信息,分别由code
和message
字段表示。
为了在 DailyMart 中更有效地处理错误,我们创建了三种自定义异常类:ClientException
(客户端异常)、BusinessException
(业务逻辑异常)和RemoteException
(第三方服务异常)。这些异常类都继承自AbstractException
,这是一个抽象的基类。
AbstractException
基类包含错误码和错误信息,同时它继承自RuntimeException
,这意味着它是一个非受检异常。
@Getter
public abstract class AbstractException extends RuntimeException{
private final String code;
private final String message;
public AbstractException(ErrorCode errorCode,String message,Throwable throwable){
super(message,throwable);
this.code = errorCode.getCode();
this.message = Optional.ofNullable(message).orElse(errorCode.getMessage());
}
}
接下来,我们通过继承AbstractException
基类来定义具体的自定义异常类。
public class ClientException extends AbstractException{
public ClientException(){
this(ErrorCode.CLIENT_ERROR,null,null);
}
public ClientException(String message){
this(ErrorCode.CLIENT_ERROR,message,null);
}
// ... 其他构造方法 ...
}
以上是ClientException
的示例。我们可以为BusinessException
和RemoteException
采用类似的方式定义。
现在,我们已经创建了自定义异常类,接下来让我们看看如何在 DailyMart 中使用它们来替代标准的RuntimeException
。
例如,在验证用户登录时,如果用户不存在,我们不再抛出普通的RuntimeException
,而是抛出我们的自定义ClientException
。
@Override
protected CustomerUser authenticate(UserLoginDTO loginDTO) {
CustomerUser actualUser = customerUserRepository.findByUserName(usernamePasswordLoginDTO.getUsername());
if(actualUser == null){
throw new ClientException(ErrorCode.USER_NOT_FOUND,"用户不存在");
}
return actualUser;
}
对于在多个地方常用的异常,我们甚至可以创建更具体的自定义异常类。例如,对于“用户不存在”的场景,我们可以创建一个UserNotFoundException
类。
public class UserNotFoundException extends ClientException{
/**
* Constructs a UsernameNotFoundException
*/
public UserNotFoundException(){
super(ErrorCode.USER_NOT_FOUND);
}
}
在处理异常时,频繁使用try...catch
块可能会使代码变得混乱。为了简化异常处理并确保一致的响应格式,我们可以利用 SpringBoot 的全局异常处理功能。
@RestControllerAdvice
进行全局异常处理SpringBoot 提供了一个特殊的注解@RestControllerAdvice
,允许我们创建全局异常处理类。在这个类中,我们可以定义处理各种类型异常的方法。
在 DailyMart 中,我们创建一个GlobalExceptionHandler
类,并使用@RestControllerAdvice
注解。我们主要处理三类异常:
MethodArgumentNotValidException
:处理参数验证异常,并提供清晰的错误信息。
AbstractException
:处理我们之前定义的自定义异常。
Throwable
:作为最后的兜底,拦截所有其他异常。
下面是GlobalExceptionHandler
的实现:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理参数验证异常
@SneakyThrows
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handleValidException(HttpServletRequest request, MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
String exceptionStr = Optional.ofNullable(firstFieldError)
.map(FieldError::getDefaultMessage)
.orElse(StrUtil.EMPTY);
log.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionStr);
return ResultHelper.fail(ErrorCode.CLIENT_ERROR, exceptionStr);
}
// 处理自定义异常
@ExceptionHandler(value = {AbstractException.class})
public Result handleAbstractException(HttpServletRequest request, AbstractException ex) {
String requestURL = getUrl(request);
log.error("[{}] {} [ex] {}", request.getMethod(), requestURL, ex.toString());
return ResultHelper.fail(ex);
}
// 兜底处理
@ExceptionHandler(value = Throwable.class)
public Result handleThrowable(HttpServletRequest request, Throwable throwable) {
log.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
return ResultHelper.fail();
}
}
}
在启用全局异常处理功能后,DailyMart的用户模块不再需要在接口层手动使用try...catch
来处理异常。倘若出现其他异常,它们也会被defaultErrorHandler
拦截,从而确保DailyMart能够一致地实施统一的返回格式。
经优化后,接口层代码变得更为简洁:
@PostMapping("/api/customer/register")
public Result register(@RequestBody @Valid UserRegistrationDTO customerDTO){
return ResultHelper.success(customerService.register(customerDTO));
}
@PostMapping("/api/customer/login")
public Result login(@RequestBody Map parameters){
UserLoginDTO loginDTO = LoginDTOFactory.getLoginDTO(parameters);
return ResultHelper.success(customerService.login(loginDTO));
}
注意到目前所有的接口都需要通过手动调用 ResultHelper.success()
来对结果进行包装。这些重复的代码段可以优化吗?
答案是肯定的。在SpringBoot中,我们可以利用 ResponseBodyAdvice
来自动包装响应体。
提示:
ResponseBodyAdvice
可以拦截控制器(Controller)方法的返回值,允许我们统一处理返回值或响应体。这对于统一返回格式、加密、签名等场景非常有用。
在 DailyMart 中,我们可以创建一个实现 ResponseBodyAdvice
接口的类,来自动包装响应体。下面是示例代码:
@Slf4j
@RestControllerAdvice
public class GlobalResponseBodyAdvice implements ResponseBodyAdvice
经过这样的优化,我们的控制器层代码可以直接简写如下:
@PostMapping("/api/customer/register")
public UserRegistrationDTO register(@RequestBody @Valid UserRegistrationDTO customerDTO){
return customerService.register(customerDTO);
}
@PostMapping("/api/customer/login")
public UserLoginRespDTO login(@RequestBody Map parameters){
UserLoginDTO loginDTO = LoginDTOFactory.getLoginDTO(parameters);
return customerService.login(loginDTO);
}
考虑到 DailyMart 项目包含多个服务,并且在其他服务中也需要全局异常处理和响应体自动包装的功能,我们可以将这些功能封装成一个 Spring Boot Starter。这样,任何需要这些功能的模块只需引入该 Starter 即可。
@SpringBootConfiguration
@ConditionalOnWebApplication
public class WebAutoConfiguration {
/**
* 自定义全局异常处理器
*/
@Bean
@ConditionalOnMissingBean(GlobalExceptionHandler.class)
public GlobalExceptionHandler dailyMartGlobalExceptionHandler() {
return new GlobalExceptionHandler();
}
/**
* 接口自动包装
*/
@Bean
@ConditionalOnMissingBean(GlobalResponseBodyAdvice.class)
public GlobalResponseBodyAdvice dailyMartGlobalResponseBodyAdvice(){
return new GlobalResponseBodyAdvice();
}
}
我们还需要在 resources/META-INF/spring
目录下创建一个名为 org.springframework.boot.autoconfigure.AutoConfiguration.imports
的文件,并在此文件中声明我们的自动配置类,以便 Spring Boot 在启动时能够找到并加载它。
com.jianzh5.dailymart.springboot.starter.web.config.WebAutoConfiguration
这样,当其他服务需要使用全局异常处理和自动响应体包装时,只需在它们的 pom.xml
文件中添加对这个 Starter 的依赖即可。
本文主要讨论了SpringBoot项目中响应体自动包装和全局异常处理的优化方法。通过使用ResponseBodyAdvice
接口,我们能够自动化响应体的包装过程,消除了冗余的代码。此外,我们还探讨了如何创建一个Spring Boot Starter,以将全局异常处理和自动包装类作为插件,从而方便地在多个服务中重用这些功能。这些优化措施有助于简化代码,提高可维护性和项目效率。
DDD&微服务系列源码已经上传至GitHub,如果需要获取源码地址,请关注