Spring Boot - 统一数据下发接口格式

[TOC]

前言

当前主流的 Web 应用开发通常采用前后端分离模式,前端和后端各自独立开发,然后通过数据接口沟通前后端,完成项目。

因此,定义一个统一的数据下发格式,有利于提高项目开发效率,减少各端开发沟通成本。

本篇博文主要介绍下在 Spring Boot 中配置统一数据下发格式的搭建步骤。

统一数据格式

数据的类型多种多样,但是可以简单划分为以下三种类型:

  • 简单数据类型:比如byteintdouble等基本数据类型。
    :在 Java 中,String属于Object类型,但是在数据层面上,我们通常将其看作是简单数据类型。

  • 对象数据类型:常见的比如说自定义 Java Bean,POJO 等数据。

  • 复杂/集合数据类型:比如ListMap等集合类型。

后端下发的数据肯定会包含上述列举的三种类型数据,通常这些数据都作为响应体主要内容,用字段data进行表示,同时我们会附加codemsg字段来描述请求结果信息,如下表所示:

字段 描述
code 状态码,标志请求是否成功
msg 描述请求状态
data 返回结果

到此,统一数据下发的格式就确定了,如下代码所示:

@Getter
@AllArgsConstructor
@ToString
public class ResponseBean<T> {
    private int code;
    private String msg;
    private T data;
}

此时,数据下发操作如下所示:

@RestController
@RequestMapping("/common")
public class CommonController {

    @GetMapping("/")
    public ResponseBean<String> index() {
        return new ResponseBean<>(200, "操作成功", "Hello World");
    }
}

进阶配置

在上文的统一数据ResponseBean中,还可以对其再进行封装,使代码更健壮:

  • 抽象codemsgcodemsg用于描述请求结果信息,直接放置再ResponseBean中,程序员可以随便设置这两个字段,请求结果一般就是成功、失败等常见的几种结果,可以将其再进行封装,提供常见的请求结果信息,缩小权限:

    @Getter
    @ToString
    public class ResponseBean<T> {
        private int code;
        private String msg;
        private T data;
    
        public ResponseBean(ResultCode result, T data) {
            this.code = result.code;
            this.msg = result.msg;
            this.data = data;
        }
    
        public static enum ResultCode {
            SUCCESS(200, "操作成功"),
            FAILURE(400, "操作失败");
    
            ResultCode(int code, String msg) {
                this.code = code;
                this.msg = msg;
            }
    
            private final int code;
            private final String msg;
        }
    }
    

    这里使用enum来封装codemsg,并提供两个默认操作SUCCESSFAILURE。此时调用方法如下:

    @GetMapping("/")
    public ResponseBean<String> index() {
        return new ResponseBean<>(ResponseBean.ResultCode.SUCCESS, "Hello World");
    }
    
  • 提供默认操作:前面的调用方法还是不太简洁,这里我们让ResponseBean直接提供相应的默认操作,方便外部调用:

    @Getter
    @ToString
    public class ResponseBean<T> {
        private int code;
        private String msg;
        private T data;
    
        // 成功操作
        public static <E> ResponseBean<E> success(E data) {
            return new ResponseBean<E>(ResultCode.SUCCESS, data);
        }
    
        // 失败操作
        public static <E> ResponseBean<E> failure(E data) {
            return new ResponseBean<E>(ResultCode.FAILURE, data);
        }
    
        // 设置为 private
        private ResponseBean(ResultCode result, T data) {
            this.code = result.code;
            this.msg = result.msg;
            this.data = data;
        }
    
        // 设置 private
        private static enum ResultCode {
            SUCCESS(200, "操作成功"),
            FAILURE(400, "操作失败");
    
            ResultCode(int code, String msg) {
                this.code = code;
                this.msg = msg;
            }
    
            private final int code;
            private final String msg;
        }
    }
    

    我们提供了两个默认操作successfailure,此时调用方式如下:

    @GetMapping("/")
    public ResponseBean<String> index() {
        return ResponseBean.<String>success("Hello World");
    }
    

    到这里,数据下发调用方式就相对较简洁了,但是结合 Spring Boot 还能继续进行优化,参考下文。

数据下发拦截修改

Spring 框架提供了一个接口:ResponseBodyAdvice<T>,当控制器方法被@ResponseBody注解或返回一个ResponseEntity时,该接口允许我们在HttpMessageConverter写入响应体前,拦截响应体并进行自定义修改。

因此,要拦截Controller响应数据,只需实现一个自定义ResponseBodyAdvice,并将其注册到RequestMappingHandlerAdapterExceptionHandlerExceptionResolver,或者直接使用@ControllerAdvice注解进行激活。如下所示:

@RestControllerAdvice
public class FormatResponseBodyAdvice implements ResponseBodyAdvice<Object> {
    /**
     * @param returnType 响应的数据类型
     * @param converterType 最终将会使用的消息转换器
     * @return true: 执行 beforeBodyWrite 方法,修改响应体
               false: 不执行 beforeBodyWrite 方法
     */
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        boolean isResponseBeanType = ResponseBean.class.equals(returnType.getParameterType());
        // 如果返回的是 ResponseBean 类型,则无需进行拦截修改,直接返回即可
        // 其他类型则拦截,并进行 beforeBodyWrite 方法进行修改
        return !isResponseBeanType;
    }

    /**
     * @param body 响应的数据,也就是响应体
     * @param returnType 响应的数据类型
     * @param selectedContentType 响应的ContentType
     * @param selectedConverterType 最终将会使用的消息转换器
     * @param request
     * @param response
     * @return 被修改后的响应体,可以为null,表示没有任何响应
     */
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        return ResponseBean.success(body);
    }
}

这里需要注意的一个点是,仅仅实现一个自定义ResponseBodyAdvice,对其他类型的数据是可以成功进行拦截并转换,但是对于直接返回String类型的方法,这里会抛出一个异常:

java.lang.ClassCastException: class com.yn.common.entity.ResponseBean cannot be cast to class java.lang.String

这是因为请求体在返回给客户端前,会被一系列HttpMessageConverter进行转换,当Controller返回一个String时,beforeBodyWrite方法中的第四个参数selectedConverterType就是一个StringHttpMessageConverter,因此,我们在beforeBodyWrite中将String响应拦截并转换为ResponseBean类型,然后StringHttpMessageConverter就会转换我们的ResponseBean类型,这样转换就会失败,因为类型不匹配。解决这个问题的方法大致有如下三种,任选其一即可:

  1. 转换为String类型:由于采用的是StringHttpMessageConverter,因此,我们需要将ResponseBean转换为String,这样StringHttpMessageConverter就可以处理了:

    @RestControllerAdvice
    public class GlobalExceptionHandler implements ResponseBodyAdvice<Object> {
    
        @Override
        public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
            ResponseBean bean = ResponseBean.success(body);
            try {
                if (body instanceof String) {
                    response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
                    // String 类型则将 bean 转化为 JSON 字符串
                    return new ObjectMapper().writeValueAsString(bean);
                }
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
            return bean;
        }
    }
    
  2. 前置 JSON 转换器:能转换我们自定义的ResponseBean应当是一个 JSON 转换器,比如MappingJackson2HttpMessageConverter,因此,这里我们可以配置一下,让MappingJackson2HttpMessageConverter转换器优先级比StringHttpMessageConverter高,这样转换就能成功,如下所示:

    @Configuration
    @EnableWebMvc
    public class WebConfiguration implements WebMvcConfigurer {
    
        @Override
        public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
            converters.add(0, new MappingJackson2HttpMessageConverter());
        }
    }
    

    其实就是在转换器集合中将MappingJackson2HttpMessageConverter排列到StringHttpMessageConverter前面。

  3. 配置 JSON 转换器:如果是 Spring Boot 项目时,通常不建议在配置类上使用@EnableWebMvc注解,因为该注解会失效 Spring Boot 自动加载 SpringMVC 默认配置,这样所有的配置都需要程序员手动进行控制,会很麻烦。大多数配置 Spring Boot 都提供了对应的配置方法,比如,我们可以配置HttpMessageConverter,去除StringHttpMessageConverter等默认填充的转换器,只注入 JSON 转换器即可(因为前后端分离项目,只需 JSON 转换即可):

    @SpringBootApplication
    public class Application {
    
        @Bean
        public HttpMessageConverters converters() {
            return new HttpMessageConverters(
                    false, Arrays.asList(new MappingJackson2HttpMessageConverter()));
        }
    }
    

现在,Controller可以直接返回任意类型数据,最终都会被ResponseBodyAdvice拦截并更改为ResponseBean类型,如下所示:

@RestController
@RequestMapping("/common")
public class CommonController {

    // 简单类型
    @GetMapping("/basic")
    public int basic() {
        return 3;
    }

    // 字符串
    @GetMapping("/string")
    public String basicType() {
        return "Hello World";
    }

    // 对象类型
    @GetMapping("/obj")
    public User user() {
        return new User("Whyn", "whyncai@gmail.com");
    }

    // 复杂/集合类型
    @GetMapping("/complex")
    public List<User> users() {
        return Arrays.asList(
                new User("Why1n", "Why1n@qq.com"),
                new User("Why1n", "Why1n@qq.com")
        );
    }

    @Data
    @AllArgsConstructor
    private static class User {
        private String name;
        private String email;
    }

}

请求上述接口,结果如下:

$ curl -X GET localhost:8080/common/basic
{"code":200,"msg":"操作成功","data":3}

$ curl -X GET localhost:8080/common/string
{"code":200,"msg":"操作成功","data":"Hello World"}

$ curl -X GET localhost:8080/common/obj
{"code":200,"msg":"操作成功","data":{"name":"Whyn","email":"whyncai@gmail.com"}}

$ curl -X GET localhost:8080/common/complex
{"code":200,"msg":"操作成功","data":[{"name":"Why1n","email":"Why1n@qq.com"},{"name":"Why1n","email":"Why1n@qq.com"}]}

最后,当Controller抛出异常时,异常信息也会被我们自定义的RestControllerAdvice拦截到,但是data字段是系统的异常信息,因此最好还是手动对全局异常进行捕获,比如:

@RestControllerAdvice
public class FormatResponseBodyAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        boolean isResponseBeanType = ResponseBean.class.equals(returnType.getParameterType());
        // 如果返回的是 ResponseBean 类型,则无需进行拦截修改,直接返回即可
        // 其他类型则拦截,并进行 beforeBodyWrite 方法进行修改
        return !isResponseBeanType;
    }
    //...
    @ExceptionHandler(Throwable.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseBean<String> handleException() {
        return ResponseBean.failure("Error occured");
    }
}

刚好ResponseBodyAdvice需要@RestControllerAdvice进行驱动,而@RestControllerAdvice又能全局捕获Controller异常,所以这里简单地将异常捕获放置到自定义ResponseBodyAdvice中,一个需要注意的点就是:这里我们对异常手动返回ResponseBean对象,因为在自定义ResponseBodyAdvice中,supports方法内我们设置了对ResponseBean数据类型不进行拦截,而如果这里异常处理返回其他类型,最终都都会被自定义ResponseBodyAdvice拦截到,这里需要注意一下。

更多异常处理详情,可查看本人的另一篇博客:Spring Boot - 全局异常捕获

附录

上述内容的完整配置代码如下所示:

  • 数据统一下发实体
    @Getter
    @ToString
    public class ResponseBean<T> {
        private int code;
        private String msg;
        private T data;
    
        // 成功操作
        public static <E> ResponseBean<E> success(E data) {
            return new ResponseBean<E>(ResultCode.SUCCESS, data);
        }
    
        // 失败操作
        public static <E> ResponseBean<E> failure(E data) {
            return new ResponseBean<E>(ResultCode.FAILURE, data);
        }
    
        // 设置为 private
        private ResponseBean(ResultCode result, T data) {
            this.code = result.code;
            this.msg = result.msg;
            this.data = data;
        }
    
        // 设置 private
        private static enum ResultCode {
            SUCCESS(200, "操作成功"),
            FAILURE(400, "操作失败");
    
            ResultCode(int code, String msg) {
                this.code = code;
                this.msg = msg;
            }
    
            private final int code;
            private final String msg;
        }
    }
    
  • 转换器配置类
    @Configuration
    @EnableWebMvc
    public class WebConfiguration implements WebMvcConfigurer {
    
        @Override
        public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
            converters.add(0, new MappingJackson2HttpMessageConverter());
        }
    }
    
  • 数据下发拦截器
    @RestControllerAdvice
    public class FormatResponseBodyAdvice implements ResponseBodyAdvice<Object> {
    
        @Override
        public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
            boolean isResponseBeanType = ResponseBean.class.equals(returnType.getParameterType());
            // 如果返回的是 ResponseBean 类型,则无需进行拦截修改,直接返回即可
            // 其他类型则拦截,并进行 beforeBodyWrite 方法进行修改
            return !isResponseBeanType;
        }
    
        @Override
        public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
            return ResponseBean.success(body);
        }
    
        @ExceptionHandler(Throwable.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ResponseBean<String> handleException() {
            return ResponseBean.failure("Error occured");
        }
    }
    

参考

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