设计之道-controller层的设计补遗

自从《设计之道-controller层的设计》去年发布之后,收获了许多读者朋友和同僚的欢迎和喜爱,也收到了不少的意见和建议,首先十分感谢大家的支持。同时我也一直在思考如何进一步的优化这部分代码,在这里把最近的一些优化点总结一下。主要针对两部分进行了优化,统一返回对象的封装统一的请求/响应日志打印

首先回顾下在上一篇当中讲到的controller层主要的职责:
1.参数校验
2.调用service层接口实现业务逻辑
3.转换业务/数据对象
4.组装返回对象
5.异常处理

当时遗漏了一点现在补上:
6.请求日志打印
接下来进入正题:

1. 统一返回对象的封装

这一点其实在上一篇中已经讲过,就是第4点:组装返回对象。只不过当时使用的是在BaseController中封装返回方法,在业务controller中调用responseOK/responseFail方法。文章发出去不久后,天草二十六_就建议我可以使用SpringMVC的ResponseBodyAdvice接口来实现统一的返回对象封装从而进一步优化代码(感谢天草)。他山之石可以攻玉,这里就先来讲一下该接口给我们的代码带来的变化。

首先看下ResponseBodyAdvice这个接口:

/**
 * Allows customizing the response after the execution of an {@code @ResponseBody}
 * or a {@code ResponseEntity} controller method but before the body is written
 * with an {@code HttpMessageConverter}.
 *
 * <p>Implementations may be registered directly with
 * {@code RequestMappingHandlerAdapter} and {@code ExceptionHandlerExceptionResolver}
 * or more likely annotated with {@code @ControllerAdvice} in which case they
 * will be auto-detected by both.
 */
public interface ResponseBodyAdvice<T> {

    /**
     * Whether this component supports the given controller method return type
     */
    boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);

    /**
     * Invoked after an HttpMessageConverter is selected and just before
     * its write method is invoked.
     */
    T beforeBodyWrite(T body, MethodParameter returnType, MediaType selectedContentType,
            Class<? extends HttpMessageConverter<?>> selectedConverterType,
            ServerHttpRequest request, ServerHttpResponse response);

}

上面的源码由于篇幅问题,删掉了一些注释,感兴趣的同学可以自己查看代码。通过查看注释可以发现,这个接口可以让我在controller方法(需有@ResponseBody@ResponseEntity注解)的返回对象被写到HTTP response body之前做一些事情。这里的两个方法都很重要,首先supports决定了哪些controller会被该接口拦截,其次beforeBodyWrite决定了拦截之后我们的操作。另外,注意到上面注释中建议了该接口的使用方法,其中有提到可以在实现该接口的类上使用@ControllerAdvice注解,从而同时实现异常捕获返回对象的封装

弄明白了ResponseBodyAdvice的作用,我们便可摒弃之前的BaseController,让controller直接返回DTO,通过实现beforeBodyWrite方法来做统一的返回对象封装。既然BaseController不需要了,那其中的封装方法responseOK/responseFail又该何去何从呢?我这边的做法是参照了《Effective Java》中的建议,用静态方法代替构造函数,改写了统一返回包装类HttpResult:

public class HttpResult<T> implements Serializable {

    private static final long serialVersionUID = -1L;
    private boolean success;
    private T data;
    private String code;
    private String message;

    private HttpResult(boolean success, T data, String code, String message) {
        this.success = success;
        this.data = data;
        this.code = code;
        this.message = message;
    }

    private HttpResult(boolean success, T data, ResultCode resultCode) {
        this.success = success;
        this.data = data;
        this.code = resultCode.getCode();
        this.message = resultCode.getMessage();
    }

    /**
     * 成功返回
     */
    public static <T> HttpResult<T> ok(T data) {
        return new HttpResult<>(Boolean.TRUE, data, ResultCode.SUCCESS);
    }

    /**
     * 异常返回-指定错误码
     */
    public static HttpResult fail(ResultCode resultCode) {
        return new HttpResult<>(Boolean.FALSE, null, resultCode);
    }

    /**
     * 异常返回-非指定异常
     */
    public static HttpResult fail(String code, String message) {
        return new HttpResult<>(Boolean.FALSE, null, code, message);
    }
    
    //getter and setter
}

并将先前的统一异常处理类ExceptionAdvice改名为ResponseAdvice,并实现ResponseBodyAdvice接口。
其中要注意两点:

  1. @ExceptionHandler要加上@ResponseBody注解,否则会默认返回mav。
  2. 返回结果被@ExceptionHandler处理后仍然会进入到beforeBodyWrite处理,所以为了需要增加判断逻辑以防重复包装返回结果。
/**
 * @Author: Sawyer
 * @Description: 统一异常处理及返回对象封装
 * @Date: Created in 上午11:17 17/8/11
 */
@Slf4j
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {

    @Autowired
    HttpServletRequest httpServletRequest;

    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        //返回对象封装
        if (body instanceof HttpResult) {
            // 被exceptionHandler处理过了,直接返回
            return body;
        } else {
            return HttpResult.ok(body);
        }
    }


    /**
     * 异常日志记录
     */
    private void logErrorRequest(Exception e) {
        log.error("报错API URL:{}", httpServletRequest.getRequestURL().toString());
        log.error("异常:{}", e.getMessage());
    }

    /**
     * 参数未通过@Valid验证异常,
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    private HttpResult methodArgumentNotValid(MethodArgumentNotValidException exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.INVALID_PARAM);
    }

    /**
     * 参数格式有误
     */
    @ExceptionHandler({MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class})
    @ResponseBody
    private HttpResult typeMismatch(Exception exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.MISTYPE_PARAM);
    }

    /**
     * 缺少参数
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    @ResponseBody
    private HttpResult missingServletRequestParameter(MissingServletRequestParameterException exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.MISSING_PARAM);
    }

    /**
     * 不支持的请求类型
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseBody
    private HttpResult httpRequestMethodNotSupported(HttpRequestMethodNotSupportedException exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.UNSUPPORTED_METHOD);
    }

    /**
     * 业务层异常
     */
    @ExceptionHandler(ServiceEx.class)
    @ResponseBody
    private HttpResult serviceExceptionHandler(ServiceEx exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.S_SYS_UNKNOWN.getCode(), exception.getMessage());
    }

    /**
     * 其他异常
     */
    @ExceptionHandler({HttpClientErrorException.class, IOException.class, Exception.class})
    @ResponseBody
    private HttpResult commonExceptionHandler(Exception exception) {
        logErrorRequest(exception);
        return HttpResult.fail(ResultCode.S_SYS_UNKNOWN);
    }
}

这样改造后,我们的UserController就不再需要继承BaseController及返回HttpResult对象了:

@RestController
@RequestMapping("/v1/user")
public class UserController {

    @Autowired
    UserService userService;

    @PutMapping("/{id}")
    public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
        return UserDTO.convert(userService.updateUser(id, userDTO));
    }
}

这样,我们就完成了统一返回对象封装的优化。有的同学要问了,你这儿明明没有指定@ResponseBody,为啥也能被拦截呢?这里暴露了很多同学写代码的一个问题:无意识地写一些多余的代码。仔细看@RestController的源码,其实其中已经包含了@ResponseBody注解了:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {

    String value() default "";

}

2. 统一的请求/响应日志打印

对于开发过web应用的同学来说,日志的重要性相信是不言而喻的。特别是排查问题的时候,如果如果少打了请求/响应日志,那查问题的难度简直一下子就上升了几个等级。那我们该如何做才能最简便呢?其实看了前一部分,你一定能想到可以在实现beforeBodyWrite接口的同时做日志打印,毕竟参数里都有ServerHttpRequestServerHttpResponse了嘛。但这有一个问题,这个方法是在responseBody被写入之前执行的,但如果controller本身就已经报错了,这个方法是不会被执行的,这个时候日志也就不会被打印了。

其实对于ResponseBodyAdvice来说,还有一个对应的RequestBodyAdvice(这里就不展开了,感兴趣的同学可以自行研究),似乎可以在beforeBodyRead中打印请求日志,在beforeBodyWrite中打印正常返回日志,在@ExceptionHandler中打印异常返回日志。这个方案的确可行,但会有一个问题,这里卖个关子暂且不表,先来看我所采用的方法:单独创建一个切面来做统一的日志打印:

/**
 * @Author: Sawyer
 * @Description: 请求日志切面
 * @Date: Created in 3:07 PM 2019/8/15
 */
@Slf4j
@Aspect
@Component
public class RequestLogAspect {

    @Autowired
    HttpServletRequest request;

    @Around("execution(* com.sawyer.api.controller..*.*(..))")
    public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {

        log.info("请求url:{}", request.getRequestURL().toString());

        ObjectMapper mapper = new ObjectMapper();
        log.info("请求参数:{}", mapper.writeValueAsString(joinPoint.getArgs()));

        Object result = joinPoint.proceed();
        log.info("请求返回:{}", mapper.writeValueAsString(result));

        return result;
    }
}

这里稍微讲一下AOP表达式"execution(* com.sawyer.api.controller..*.*(..))"的含义:

  • execution()表示是最常用的切点函数,表示切面作用于方法执行时;
  • 第一个*表示不限制返回类型;
  • controller后面的..表示要拦截的包路径包含controller目录及其所有子目录;
  • 第二个*表示不限类名;
  • 第三个*表示不限方法名;
    -(..)表示不限参数;
  • @Around表示该切面的类型是包围类型;
    故总体的含义为:在com.sawyer.api.controller包下所有的类的所有方法的执行前后进行拦截。

通过定义这样一个切面,我们就可以在controller的方法被调用前打印请求日志,被调用后打印响应日志。当然,在抛出异常的情况,日志还是打印在@ExceptionHandler里的。这个做法和之前的方法相比,有什么特别的好处吗?这里就要讲到刚刚卖的关子。

真正在生产中,我们往往会遇到一个问题,就是有些接口的日志我们并不想打印出来。特别是一些批量查询接口的响应结果,一打就一堆,如果调用频繁,就可能会造成大量空间的浪费,也不方便日志的排查。那我们就需要针对不同的类,甚至方法进行区别对待。对于不同类,自定义切面和@ControllerAdvice都可以解决,对于AOP来说可以在表达式里使用'||'或者'or'来指定多个连接点,而@ControllerAdvice则可以用basePackages数组来指定多个类。但是如果同一个类中不同的方法有不同的日志需求,那@ControllerAdvice就爱莫能助了。不过,我们真的需要在切点表达式中维护那么复杂的又无聊的关系吗?有更好的做法吗?当然有。

这里我的做法创建了一个自定义注解@LessLog用来指定是否要打日志、打什么日志。然后通过切面中的joinPoint及java反射机制来获取到方法上的注解,从而影响日志的行为,直接看代码:

首先是忽略的日志内容,主要有url日志、请求日志、响应日志、全部忽略和全部不忽略这5种:

/**
 * @Author: Sawyer
 * @Description: 忽略的日志类型
 * @Date: Created in 3:56 PM 2019/8/14
 */

public enum LogType {

    /**
     * 请求url
     */
    URL,

    /**
     * 请求
     */
    REQUEST,

    /**
     * 返回
     */
    RESPONSE,

    /**
     * 全部
     */
    ALL,

    /**
     * 无
     */
    NONE
}

然后是注解@LessLog本身,这里制定了一个type参数,用来指定忽略的日志内容,默认是全部不忽略:

/**
 * @Author: Sawyer
 * @Description: 忽略日志的注解
 * @Date: Created in 2:40 PM 2019/8/14
 */

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LessLog {
    /**
     * 默认不忽略日志
     *
     * @return
     */
    LogType type() default LogType.NONE;
}

最后是改写我们的RequestLogAspect,利用反射机制获取到controller方法上的LessLog注解实例,并根据其type参数决定具体忽略的日志内容:

@Slf4j
@Aspect
@Component
public class RequestLogAspect {

    @Autowired
    HttpServletRequest request;

    @Around("execution(* com.yst.nfsq.vem.api.controller..*.*(..))")
    public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {

        boolean urlLogRequired = Boolean.TRUE;
        boolean requestLogRequired = Boolean.TRUE;
        boolean responseLogRequired = Boolean.TRUE;

        Class<?> clazz = joinPoint.getTarget().getClass();
        String methodName = joinPoint.getSignature().getName();
        Class<?>[] args = ((MethodSignature) joinPoint.getSignature()).getParameterTypes();
        Method method = clazz.getMethod(methodName, args);

        if (method.isAnnotationPresent(LessLog.class)) {
            //减少日志的注解
            LessLog lessLog = method.getAnnotation(LessLog.class);
            LogType logType = lessLog.type();
            switch (logType) {
                case URL:
                    urlLogRequired = Boolean.FALSE;
                    break;
                case REQUEST:
                    requestLogRequired = Boolean.FALSE;
                    break;
                case RESPONSE:
                    responseLogRequired = Boolean.FALSE;
                    break;
                case ALL:
                    urlLogRequired = Boolean.FALSE;
                    requestLogRequired = Boolean.FALSE;
                    responseLogRequired = Boolean.FALSE;
                    break;
                default:
            }
        }
        //url日志
        if (urlLogRequired) {
            log.info("请求url:{}", request.getRequestURL().toString());
        }

        ObjectMapper mapper = new ObjectMapper();
        //请求日志
        if (requestLogRequired) {
            log.info("请求参数:{}", mapper.writeValueAsString(joinPoint.getArgs()));
        }
        Object result = joinPoint.proceed();
        //响应日志
        if (responseLogRequired) {
            log.info("请求返回:{}", mapper.writeValueAsString(result));
        }

        return result;
    }
}

这样,我们就可以在具体的发放上使用@LessLog注解来控制日志打印的内容了,比如下面的方法就不会打印响应日志:

@RestController
@RequestMapping("/v1/user")
public class UserController {

    @Autowired
    UserService userService;

    //不打印响应日志
    @LessLog(type = LogType.RESPONSE)
    @PutMapping("/{id}")
    public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
        return UserDTO.convert(userService.updateUser(id, userDTO));
    }
}

写到这里,结合上一篇,我们已经完成了controller层的所有任务,再来回顾一下:
1.参数校验
2.调用service层接口实现业务逻辑
3.转换业务/数据对象
4.组装返回对象
5.异常处理
6.请求日志打印

这篇文章中用到的技术包括AOP、反射、注解等其实大家都耳熟能详,但很多时候都只是只知其然而不知其所以然。我还是鼓励大家在写代码时多加思考,创造机会使用这些技术,而非一味地照搬安全的老代码,从而丧失了使自己技术精进和代码更优雅的机会。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 最近想把平时工作中总结出来的一些技巧和最佳实践分享给大家,主要包含java编程和数据库设计,本篇着重于web应用开...
    SawyerZhou阅读 41,407评论 20 186
  • IoC 容器 Bean 的作用域 自定义作用域实现 org.springframework.beans.facto...
    Hsinwong阅读 2,410评论 0 7
  • 对于java中的思考的方向,1必须要看前端的页面,对于前端的页面基本的逻辑,如果能理解最好,不理解也要知道几点。 ...
    神尤鲁道夫阅读 738评论 0 0
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,036评论 1 32
  • Spring致力于提供一种方法管理你的业务对象。在大量Java EE的应用中,随处可见Spring。今天我将简单的...
    JAVA架构师的圈子阅读 1,276评论 0 16