SpringBoot中如何优雅的统一全局返回格式与处理系统异常?

发布时间:2025-05-17 12:28:37 作者:益华网络 来源:undefined 浏览量(1) 点赞(2)
摘要:来源:JAVA日知录 大家好,我是飘渺! 今天带来DDD系列的第七篇,欢迎持续关注! 在领域驱动设计(DDD)中,接口层主要负责处理与外部系统的交互,包括接收用户或外部系统的请求,调用应用层服务处理请求,以及将处理结果返回给请求方。

来源:JAVA日知录

大家好,我是飘渺! 今天带来DDD系列的第七篇,欢迎持续关注!

在领域驱动设计(DDD)中,接口层主要负责处理与外部系统的交互,包括接收用户或外部系统的请求,调用应用层服务处理请求,以及将处理结果返回给请求方。

我发现一些代码中,接口的返回值类型众多,有的直接返回数据传输对象(DTO),甚至直接返回数据对象(DO),还有的返回Result对象。在DailyMart项目中,为了简化客户端的处理流程,我们决定在接口层采用统一的返回格式——Result对象。

1. 统一返回格式

1.1 构建Result对象

为了实现统一返回格式,我们在DailyMart项目中构建了一个Result对象,代码如下:

@Data@Accessors(chain = true

)

public class Result<T

{

    public static final String SUCCESS_CODE = "OK"

;

    private

 String code;

    private

 String message;

    private

 T data;

    private long

 timestamp;

}

为了便于创建Result对象,我们构建了一个辅助类ResultHelper:

@Slf

4j

public class ResultHelper 

{

    public static <T> Result<T> success(T data) 

{

        return new

 Result()

                .setCode(SUCCESS_CODE)

                .setData(data)

                .setTimestamp(System.currentTimeMillis());

    }

    public static <T> Result<T> fail(String message) 

{

        return new

 Result()

                .setCode(ErrorCode.SERVICE_ERROR.getCode())

                .setMessage(message)

                .setTimestamp(System.currentTimeMillis());

    }

...

}

1.2 优化DailyMart中的接口

以DailyMart系统的注册接口为例,定义了Result对象后,我们可以在接口层这样优化代码:

@PostMapping("/api/customer/register"

)

public Result<UserRegistrationDTO> register(@RequestBody @Valid UserRegistrationDTO customerDTO)

{

 try

 {

  return

 ResultHelper.success(customerService.register(customerDTO));

 }catch

 (Exception e){

  return

 ResultHelper.fail(e.getMessage());

 }

}

为了避免每个接口都这样写,我们可以利用SpringBoot的全局异常处理器来处理,这将在下一节讨论。

1.3 优化后的结果

现在,当访问注册接口时,成功会返回如下响应:

{

    "code""OK"

,

    "message"null

,

    "data"

: {

        "userName""jianzh1"

,

        "password"null

,

        "email""jianzh5@162.com"

,

        "phone""18811117882"

    },

    "timestamp"1687338445851

}

失败时会返回如下响应:

{

    "code""B0001"

,

    "message""用户已存在"

,

    "data"null

,

    "timestamp"1687338319457

}

这样,我们成功地实现了接口层的返回格式的统一。

2. 异常控制

在DailyMart的代码实现中,我们通常会在遇到问题时抛出RuntimeException。例如,在用户登录时,如果用户不存在,我们会抛出一个RuntimeException:

@Overrideprotected CustomerUser authenticate(UserLoginDTO loginDTO) 

{

 CustomerUser actualUser = customerUserRepository.findByUserName(usernamePasswordLoginDTO.getUsername());

 if(actualUser == null

){

  throw new RuntimeException("用户不存在"

);

 }

 return

 actualUser;

}

然而,在构建大型系统时,通常建议使用自定义异常来替代RuntimeException。自定义异常可以提供更精细和具有针对性的错误信息,有助于区分系统中的不同类型的错误。使用自定义异常不仅可以提高代码的可读性,因为它们的名称和内容可以直接反映出问题的性质,而且还可以包含更多的信息,比如错误码或其他相关的上下文数据。

2.1 错误码的概念与应用

在开发过程中,错误码的使用是提升异常处理可读性和效率的有效手段。根据《阿里巴巴开发规范-黄山版》,错误码的制定和使用应遵循一定的原则,以便实现快速溯源和标准化沟通。

错误码的组成

错误码通常是一个包含5个字符的字符串,它分为两部分:错误来源标识(1个字符)和错误编号(4个数字)。错误来源标识可以是A、B或C:

A 表示错误源于用户,例如参数错误、版本过低或支付超时。B 表示错误源于当前系统,通常是由于业务逻辑错误或程序健壮性不足。C 表示错误源于第三方服务,例如 CDN 服务故障或消息投递超时。

错误编号是一个在0001到9999之间的四位数,用于进一步细化错误的类别。

错误码的目的

错误码的主要目的是:

快速指示错误来源,帮助开发者迅速判断问题所在。清晰地对错误进行分类和标识。有助于团队成员快速达成对错误原因的共识。

2.2  在 DailyMart 中定义错误码

在 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字段表示。

2.3 自定义异常的创建和使用

为了在 DailyMart 中更有效地处理错误,我们创建了三种自定义异常类:ClientException(客户端异常)、BusinessException(业务逻辑异常)和RemoteException(第三方服务异常)。这些异常类都继承自AbstractException,这是一个抽象的基类。

自定义异常的基类

AbstractException基类包含错误码和错误信息,同时它继承自RuntimeException,这意味着它是一个非受检异常。

@Getterpublic 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采用类似的方式定义。

2.3 在DailyMart中实施自定义异常

现在,我们已经创建了自定义异常类,接下来让我们看看如何在 DailyMart 中使用它们来替代标准的RuntimeException。

例如,在验证用户登录时,如果用户不存在,我们不再抛出普通的RuntimeException,而是抛出我们的自定义ClientException。

@Overrideprotected 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);

    }

}

3. 全局异常处理

在处理异常时,频繁使用try...catch块可能会使代码变得混乱。为了简化异常处理并确保一致的响应格式,我们可以利用 SpringBoot 的全局异常处理功能。

3.1 使用@RestControllerAdvice进行全局异常处理

SpringBoot 提供了一个特殊的注解@RestControllerAdvice,允许我们创建全局异常处理类。在这个类中,我们可以定义处理各种类型异常的方法。

在 DailyMart 中,我们创建一个GlobalExceptionHandler类,并使用@RestControllerAdvice注解。我们主要处理三类异常:

MethodArgumentNotValidException:处理参数验证异常,并提供清晰的错误信息。AbstractException:处理我们之前定义的自定义异常。Throwable:作为最后的兜底,拦截所有其他异常。

下面是GlobalExceptionHandler的实现:

@Slf

4j

@RestControllerAdvicepublic class GlobalExceptionHandler 

{

    // 处理参数验证异常    @SneakyThrows    @ExceptionHandler(value = MethodArgumentNotValidException.class

)

    public Result<VoidhandleValidException(HttpServletRequest requestMethodArgumentNotValidException 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<VoidhandleAbstractException(HttpServletRequest requestAbstractException ex

{

        String requestURL = getUrl(request);

        log.error("[{}] {} [ex] {}"

, request.getMethod(), requestURL, ex.toString());

        return

 ResultHelper.fail(ex);

    }

    // 兜底处理    @ExceptionHandler(value = Throwable.class

)

    public Result<VoidhandleThrowable(HttpServletRequest requestThrowable throwable

{

        log.error("[{}] {} "

, request.getMethod(), getUrl(request), throwable);

        return

 ResultHelper.fail();

    }

  }

}

在启用全局异常处理功能后,DailyMart的用户模块不再需要在接口层手动使用try...catch来处理异常。倘若出现其他异常,它们也会被defaultErrorHandler拦截,从而确保DailyMart能够一致地实施统一的返回格式。

经优化后,接口层代码变得更为简洁:

@PostMapping("/api/customer/register"

)  

public Result<UserRegistrationDTO> register(@RequestBody @Valid UserRegistrationDTO customerDTO)

{  

 return

 ResultHelper.success(customerService.register(customerDTO));  

}

@PostMapping("/api/customer/login"

)

public Result<UserLoginRespDTO> login(@RequestBody Map<String, String> parameters)

{

 UserLoginDTO loginDTO = LoginDTOFactory.getLoginDTO(parameters);

 return

 ResultHelper.success(customerService.login(loginDTO));

}

4. 自动包装类

注意到目前所有的接口都需要通过手动调用 ResultHelper.success() 来对结果进行包装。这些重复的代码段可以优化吗?

答案是肯定的。在SpringBoot中,我们可以利用 ResponseBodyAdvice 来自动包装响应体。

提示: ResponseBodyAdvice 可以拦截控制器(Controller)方法的返回值,允许我们统一处理返回值或响应体。这对于统一返回格式、加密、签名等场景非常有用。

在 DailyMart 中,我们可以创建一个实现 ResponseBodyAdvice 接口的类,来自动包装响应体。下面是示例代码:

@Slf

4j

@RestControllerAdvicepublic class GlobalResponseBodyAdvice implements ResponseBodyAdvice<Object

{

    @Autowired    private

 ObjectMapper objectMapper;

    // 此处可以通过判断决定哪些响应需要包装    @Override    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) 

{

        return true

;

    }

    @SneakyThrows    @Override    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) 

{

        if(body instanceof

 String){

            // 当响应体是String类型时,使用ObjectMapper转换,因为Spring默认使用StringHttpMessageConverter处理字符串,不会将字符串识别为JSON            return

 objectMapper.writeValueAsString(ResultHelper.success(body));

        }

        if(body instanceof

 Result ){

            // 已经包装过的结果无需再次包装            return

 body;

        }

        // 对响应体进行包装        return

 ResultHelper.success(body);

    }

}

经过这样的优化,我们的控制器层代码可以直接简写如下:

@PostMapping("/api/customer/register"

)

public UserRegistrationDTO register(@RequestBody @Valid UserRegistrationDTO customerDTO)

{

 return

 customerService.register(customerDTO);

}

@PostMapping("/api/customer/login"

)

public UserLoginRespDTO login(@RequestBody Map<String, String> parameters)

{

 UserLoginDTO loginDTO = LoginDTOFactory.getLoginDTO(parameters);

 return

 customerService.login(loginDTO);

}

5. 定义starter

考虑到 DailyMart 项目包含多个服务,并且在其他服务中也需要全局异常处理和响应体自动包装的功能,我们可以将这些功能封装成一个 Spring Boot Starter。这样,任何需要这些功能的模块只需引入该 Starter 即可。

@SpringBootConfiguration@ConditionalOnWebApplicationpublic 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,以将全局异常处理和自动包装类作为插件,从而方便地在多个服务中重用这些功能。这些优化措施有助于简化代码,提高可维护性和项目效率。

二维码

扫一扫,关注我们

声明:本文由【益华网络】编辑上传发布,转载此文章须经作者同意,并请附上出处【益华网络】及本页链接。如内容、图片有任何版权问题,请联系我们进行处理。

感兴趣吗?

欢迎联系我们,我们愿意为您解答任何有关网站疑难问题!

您身边的【网站建设专家】

搜索千万次不如咨询1次

主营项目:网站建设,手机网站,响应式网站,SEO优化,小程序开发,公众号系统,软件开发等

立即咨询 15368564009
在线客服
嘿,我来帮您!