支付系统 - 全局异常以及对外返回的统一处理

2,188 阅读7分钟

前言

在笔者从业的多年时间内,参与设计了很多系统。从满足业务需要的角度出发,能快速支撑业务发展都能称之为「好」的系统。毕竟,创造价值的是业务,如果没有业务驱动,工程师掌握屠龙之技没有龙也是相当苦闷之事。做为一名卓越的程序员,相信大家都希望自己开发的系统易维护,更健壮。 当然这只是一种理想主义。且不说互联网行业瞬息万变,单是工期紧,面向deadline编程就需要使很多工程师放弃对代码维护性的执念。就笔者个人经历而言,早上拿到需求文档晚上上线也是常有的事。尽管如此,我还是希望从个人角度来谈谈,一些简单易行、顺手培养的习惯到底能给程序维护性带来怎样的便利。 本文是从异常的使用着手,聊一聊使用不当带来的坏味道。

受检/非受检异常

Java中存在RuntimeExceptionException。典型的如NullPointerException就属于RuntimeException,这些异常不需要开发者捕获在运行时一旦触发自动抛出。同时,RuntimeException也被称为非受检异常,见名知意,在编译期不受检查。除了RuntimeException以外都称为受检异常,在编译期需要强制处理,抛出或捕获,常见的如ClassNotFoundExceptionInterruptedException

感觉这里有异常就声明

笔者见过很多这样的代码:

PayOrder selectByOrderId(Long orderId) throw Exception;

出于对网络连接/数据库的怀疑,总感觉自己写的SQL会抛出异常,并且想让上游去处理这个异常。美其名曰,面向防御编程,但其实给程序维护增加了很多的烦恼。 首先,因为这种异常的抛出很泛泛,调用方并不知道抛出这个异常的人当时的想法,只能在外层强制捕获这个异常。如果上层调用者也不想处理这个异常,他又会继续往上层抛,这样处理几次后,最外层的程序块则完全不知道这个异常是什么情况下抛出的了。 可能很多小伙伴会说,我的编程风格就是别人有异常自己能处理就处理,不能处理才往上层抛。那我想说,你真棒。但是,一个项目中并不是只有一个开发者,每个人都有自己的编程习惯。有的小伙伴就是喜欢把异常往外层抛,继续为难上层调用者。 当然,这里并不是说抛异常不好。合理的使用异常能使程序的结构更清晰,语义更明确。也方便高层调用者针对不同的异常进行处理,而不是只能无奈的捕获Exception

不管有没有就捕获

接着上文,如果有人从很最底层一路抛出了一个受检异常。在这样的系统中,想想大家会怎么做呢?很容易,捕获就行。但是,如果下层抛出的是一个非受检异常也就是RuntimeException呢?不好意思,一旦团队中有人这么做,在没有全局异常处理的情况下,有经验的开发者会选择在外层捕获,而缺乏一些经验或者不熟悉系统的开发者自然不会捕获这个异常。那这种错误就会抛到容器中,这又是什么意思呢?假设你返回的是一个 JSON 格式,如果抛到容器中返回的内容就是程序的错误信息。其他调用方就无法解析这个返回值,这种情况肯定是不能出现的。

从此以后,你可能会看见所有的外层程序都有着丑陋的try catch块,无论调用的程序是否会抛出异常。

一些实践

异常与枚举

Spring容器中,大家一般使用声明式事务来管理数据库事务。在@Transaction的使用过程中,必须指定对应的异常类型。笔者遇到很多项目中,回滚的异常是RuntimeException。问及原因回答是Exception不会回滚,这其实是配置不正确导致的。上面已经分析过了使用RuntimeException的坏处,那我们现在来说说Exception。这种强制需要捕获的异常配合自定义异常有很强的语义,便于高层灵活选择处理,一般在工程中应用比较广泛。但是如果每种异常都定义一个新的类,这样又显得很啰嗦。一种常见的实践是,通过枚举值配合异常来做业务的判断。如下:

public enum ResultCodeEnum {
    /**
     * 成功
     */
    SUCCESS("SUCCESS", "ok"),
    /**
     * 操作失败
     */
    FAIL("FAIL", "操作失败"),
    /**
     * 系统错误
     */
    ERROR("ERROR", "系统繁忙,请稍后再试。"),
    /**
     * 验签失败
     */
    VERIFY_FAILED("VERIFY_FAILED", "验签失败"),
    /**
     * 缺少参数
     */
    LACK_PARAM("LACK_PARAM", "缺少参数"),

    ;


    @Getter
    private String code;
    @Getter
    private String msg;
    private ResultCodeEnum(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }

异常中配合枚举参数:

public class BusinessException extends Exception {

    private static final long serialVersionUID = -121219158129626814L;
    @Getter
    private ResultCodeEnum resultCode;
    @Getter
    private String msg;
    public BusinessException() {
    }
    public BusinessException(ResultCodeEnum rsCode) {
        super(rsCode.getCode() + ":" + rsCode.getMsg());
        this.resultCode = rsCode;
        this.msg = rsCode.getMsg();
    }
    public BusinessException(ResultCodeEnum rsCode, String message) {
        super(rsCode.getCode() + ":" + message);
        this.resultCode = rsCode;
        this.msg = message;
    }
    public BusinessException(ResultCodeEnum rsCode, Throwable cause) {
        super(rsCode.getCode() + ":" + rsCode.getMsg(), cause);
        this.resultCode = rsCode;
        this.msg = rsCode.getMsg();
    }
    public BusinessException(ResultCodeEnum rsCode, String message, Throwable cause) {
        super(rsCode.getCode() + ":" + message, cause);
        this.resultCode = rsCode;
        this.msg = message;
    }
}

如此,在需要抛出异常的地方使用即可。

PayTypeEnum payTypeEnum = PayTypeEnum.toEumByName(payRequestDTO.getPayType());
if (payTypeEnum == null) {
    throw new BusinessException(ResultCodeEnum.INVALID_PAY_TYPE);
}

这样外层必须捕获这个异常,可以根据 ResultCodeEnum 的值来区分业务进行相应的处理。

统一返回值

一般而言,一个服务提供给外界的出参建议是统一的。可以使用payload的模式将返回的结果包装起来,你可能没明白,看下下面这个类:

public class ResultMessageVO<T> {

    public static final String SUCCESS = "success";

    public static final String ERROR = "error";
    private String status;      //状态

    private String message;     //消息

    private T data;     //返回的数据

    ...
}

这样,如果如果使用了切面或者其它全局异常的处理机制,就很容易规范返回。 以一个验证参数的切面举例:

@Aspect
@Component
@Slf4j
public class ValidationAspect {
    @Around("execution(* io.github.pleuvoir.gateway..*.*(..))")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        Method method = methodSignature.getMethod();
        Object[] args = point.getArgs();
        Parameter[] parameters = method.getParameters();
        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].isAnnotationPresent(Valid.class)) {
                Object val = args[i];
                ValidationResult validationResult = HibernateValidatorUtils.validateEntity(val);
                if (validationResult.isHasErrors()) {
                    return ResultMessageVO.fail(ResultCodeEnum.PARAM_ERROR, validationResult.getErrorMessageOneway());
                }
            }
        }
        return point.proceed();
    }
}

因为我们对外返回的都是 ResultMessageVO 所以可以在切面中做统一处理,否则每个方法都需要单独做参数校验,这就是统一返回值的好处。

当然了,如果有统一兜底异常的地方,也因为这个统一返回值的存在,更好处理:

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ResultMessageVO<?> exception(HttpServletRequest request, Exception e){
        if (e instanceof NoHandlerFoundException) {
            log.error("页面不存在:{}", e.getMessage());
            return new ResultMessageVO(ResultCodeEnum.ERROR, "页面不存在");
        } else if (e instanceof BindException) {
            log.error("参数格式错误:{} url:{}", e.getMessage(), request.getRequestURI());
            return new ResultMessageVO(ResultCodeEnum.INVALID_ARGUMENTS, "参数格式错误");
        } else if (e instanceof HttpRequestMethodNotSupportedException){
            log.error("不支持的请求方式:{} url:{}", e.getMessage(), request.getRequestURI());
            return new ResultMessageVO(ResultCodeEnum.ERROR, "不支持的请求方式");
        } else if (e instanceof BusinessException) {
            BusinessException exception = (BusinessException) e;
            log.warn("业务异常:{} url: {}", exception.getMsg(), request.getRequestURI());
            return new ResultMessageVO<>(exception.getResultCodeEnum(), exception.getMsg());
        } else {
            log.error("系统异常:{} \t\r\n url: {} \t\r\n header: {} \t\r\n params: {} \t\r\n body: {}",
                    e.getMessage(), request.getRequestURI(), RequestUtil.getHeaders(request), RequestUtil.getParameterMap(request), getBody(request), e);
            return new ResultMessageVO<>(ResultCodeEnum.ERROR);
        }
    }
    private String getBody(HttpServletRequest request) {
        String body = StringUtils.EMPTY;
        try {
            body = RequestUtil.getBody(request);
        } catch (IOException e) {
            log.error("打印系统异常日志时,读取请求body失败,url:{}", request.getRequestURI(), e);
        }
        return body;
    }
}

通过以上两步的组合拳,我们成功使用受检异常配合枚举完成了异常的合理使用,再配合全局异常处理,完成了最后的兜底。这样,程序中只需要捕获 BusinessException 即可。成功的消除了恶心的代码片段。

后语

本文都是本人经验的一些总结,难免疏漏甚至是错误,如果有不合理、不足,还望指正。另外,希望大家聊聊自己在项目中是如何使用异常的,互相学习。