Spring Boot: 怎么封装一个易用的 RESTful 工具库

在编写 RESTful 接口时,一个常用的实践是,我们并不直接返回前端需要的数据,而是对数据进行二次包装后再返回

比如,一个查询用户详情的接口并不是返回:

{
    "name""尤慕",
    "age"18
}

而是会返回:

{
    "code"1000,
    "message""success",
    "data": {
        "name""尤慕",
        "age"18
    }
}

同理,一个查询用户订单数的接口不会直接返回一个数字:

120

而是返回:

{
    "code"1000,
    "message""success",
    "data"120
}

如果接口出错,则返回:

{
    "code"5000,
    "message""用户不存在",
    "data"null
}

这样的一个好处是,客户端在使用数据之前,可以先根据 code 判断请求是否得到正确的处理。如果失败,根据 codemessage 做相应错误反馈;成功则使用 data 内的数据做展示或其它操作。

那么,在 Spring Boot 中如何对数据进行较为优雅的封装呢?

注意,以上的接口设计实践并不限于 Spring Boot,其它语言亦同。

1使用 Spring Boot 封装 RESTful 接口

首先,我们需要一个数据包装类 RestResult,它代表接口的返回类型:

@Data
public class RestResult<T{
    /**
     * 错误码。
     * 
     * 1. code = 1000 时表示正常处理
     * 2. code = 其它值的含义,由业务方自己定义
     */

    private int code;
    /**
     * 错误详情
     */

    private String message;
    /**
     * 真实的数据
     */

    private T data;
}

泛型参数 T 表示数据集可以是任何类型。

为了使用方便,我们给此类添加一些静态构造方法:

@Data
public class RestResult<T{

    /**
     * 调用成功时使用
     */

    public static <T> RestResult<T> success(T data) {
        RestResult<T> res = new RestResult<>();
        res.code = 1000;
        res.message = "success";
        res.data = data;
        return res;
    }

    /**
     * 失败时使用
     */

    public static <T> RestResult<T> fail(int code, String message) {
        RestResult<T> res = new RestResult<>();
        res.code = code;
        res.message = message;
        return res;
    }

}

我们以一个假想的关于 Foo 的服务来做使用说明:

一个关于 Foo 的实体:

@Data
public class Foo {
    private String name;
    private int rank;
}

一个关于 FooService

@Service
public class FooService {

    public Foo getFoo() {
        Foo foo = new Foo();
        foo.setName("尤慕");
        foo.setAge(18);
        return foo;
    }

    public String getFooName() {
        return getFoo().getName();
    }

    public int getFooAge() {
        return getFoo().getAge();
    }
}

最后,一个关于 FooController

@AllArgsConstructor
@RestController
@RequestMapping("foo/v1")
public class FooController {

    private FooService fooService;

    @GetMapping
    RestResult<Foo> getFoo() {
        return RestResult.success(fooService.getFoo());
    }

    @GetMapping("name")
    RestResult<String> getFooName() {
        return RestResult.success(fooService.getFooName());
    }

    @GetMapping("age")
    RestResult<Integer> getFooAge() {
        return RestResult.success(fooService.getFooAge());
    }
}

这些类比较普通,没什么要多做说明的。我们先介绍一个怎么在 IntelliJ IDEA 中测试 HTTP 服务的方法,再讨论上面代码的问题。

怎么在 IDEA 中调试 HTTP

先在项目根目录下创建一个 http 文件夹,然后在该文件夹下新建一个 http-client.env.json 的文件,其内容如下:

{
  "dev": {
    "host""http://localhost:8080"
  }
}

其中,dev 是环境名,host 是该环境下要请求的接口域名。

然后,在 http 文件夹下再新建一个 foo.http 的文件,用来对 FooController 进行调试,其内容如下:

### get foo

GET {{host}}/foo/v1

### get foo name

GET {{host}}/foo/v1/name

### get foo age

GET {{host}}/foo/v1/age

如下图,鼠标点击左侧的绿色箭头,选择相应的环境,即可以运行 HTTP 请求:

Spring Boot: 怎么封装一个易用的 RESTful 工具库

上图的运行结果如下:

Spring Boot: 怎么封装一个易用的 RESTful 工具库

以上即是在 IDE 中调试 HTTP 接口的方法,也是一个小插曲。我们再回到正题—— 观察上面的 FooController 类,有没有发现什么问题?

我想到了2个:

  1. 每个 request mapping 方法中,都要对 service 执行结果用 RestResult.success() 方法,进行手动包装。
  2. 没有考虑到 service 异常的情况。

很明显,1 违反了 Don’t repeat yourself 原则;2 使得程序不够健壮。

有的开发可能会这样解决问题:

方式1:在 controller 层,对异常进行补捉:

@GetMapping
RestResult<Foo> getFoo() {
    try {
        return RestResult.success(fooService.getFoo());
    } catch (Exception e) {
        // 只是个示例,实际工作中不要这么写
        return RestResult.fail(5000, e.getMessage());
    }
}

但这也只是解决了问题 2

方式2:在 service 层进行异常补捉:

@Service
public class FooService {

    public RestResult<Foo> getFoo() {
        try {
            Foo foo = new Foo();
            foo.setName("尤慕");
            foo.setAge(18);
            return RestResult.success(foo);
        } catch (Exception e) {
            return RestResult.fail(5000, e.getMessage());
        }
    }
}

然后 controller 层做如下调整:

@GetMapping
RestResult<Foo> getFoo() {
    return fooService.getFoo();
}

这和方法1并没有什么不同,仍然解决不了问题 2。

下面提供一个技巧,它可以同时有效的解决问题 12

2Don’t Repeat Yourself.

在阅读 Spring 源码的过程中,偶遇了一个类让我大有收获:RequestResponseBodyAdviceChain。该类有一个方法如下:

@Nullable
private <T> Object processBody(@Nullable Object body, MethodParameter returnType, MediaType contentType,
    Class<? extends HttpMessageConverter<?>> converterType,
    ServerHttpRequest request, ServerHttpResponse response)
 
{

  for (ResponseBodyAdvice<?> advice : getMatchingAdvice(returnType, ResponseBodyAdvice.class)) {
    if (advice.supports(returnType, converterType)) {
      body = ((ResponseBodyAdvice<T>) advice).beforeBodyWrite((T) body, returnType,
          contentType, converterType, request, response);
    }
  }
  return body;
}

简单读一下即可知,该方法用于,在写 response body 之前,对 body 进行转换,实际被写的是转换后的 body。

因此,我们可以想像,我们可以在 FooController 中直接返回 Foo,然后通过 ResponseBodyAdvice 对结果进行包装转换。

封装一个 ResponseBodyAdvice 包装转换类

鉴于 RESTful 接口基本都是基于 JSON 类型的,这个转换类我们就叫它 JSONResponseWrapper 吧:

@ControllerAdvice
public class JSONResponseWrapper implements ResponseBodyAdvice {

    @Autowired
    ObjectMapper objectMapper;

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

    @Override
    public Object beforeBodyWrite(Object returnValue, MethodParameter returnType, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {

        // todo 实现具体的转换逻辑
        return null;
    }
}

supports() 方法用来判断是否对某个 request mapping 结果进行转换;而真正的转换逻辑位于 beforeBodyWrite() 方法内。

为了支持所有的 JSON response body,我们让 supports() 永远返回 true。真正的转换逻辑可以这样写:

@Override
public Object beforeBodyWrite(Object returnValue, MethodParameter returnType, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {

    // 如果结果已经被包装过,直接返回
    if (returnValue instanceof RestResult) {
        return returnValue;
    }

    // 对结果进行包装
    RestResult<Object> result = RestResult.success(returnValue);

    // 如果结果集是 String 类型,要提前进行序列化操作,否则会包装失败
    if (returnType.getParameterType() == String.class{
        try {
            return objectMapper.writeValueAsString(result);
        } catch (Exception e) {
            // todo 按自己业务需求进行调整
            throw new RuntimeException(e);
        }
    }

    // 返回包装后的结果集
    return result;
}

重要的部分都加了注释。我们看看使用效果:

controller 层调整如下:

@GetMapping
Foo getFoo() {
    return fooService.getFoo();
}

注意,controller 层返回的仍然是 Foo ,但当我们测试 HTTP 请求时,它返回是包装类型:

Spring Boot: 怎么封装一个易用的 RESTful 工具库

我们做到了不重复自己(解决了问题 1)。

那异常呢?怎么处理?

你忘了 Spring 还有一个 @RestControllerAdvice 注解了吗?可以借助它专门集中捕获 RESTful 方法内的异常。

假设我们要集中捕获 IllegalArgumentException ,则只需要:

@RestControllerAdvice
public class ControllerAdvisor {

    @ExceptionHandler(IllegalArgumentException.class)
    public RestResult<Voidhandle(IllegalArgumentException e
{
        return RestResult.fail(5000, e.getMessage());
    }
}

假设 FooService 抛了该异常:

public Foo getFoo() {
    throw new IllegalArgumentException("异常啦!");
}

再执行 HTTP 请求,得到如下结果:

Spring Boot: 怎么封装一个易用的 RESTful 工具库

哪此,异常的处理也得到了解决,而且是在一个集中的地方。

3怎么将以上实践逐步引入到自己项目

在新项目中可以采用以上实践,但老的项目怎么办?

如果单纯地引入上面的 JSONResponseWrapper 类,项目中的所有 RESTful 返回类型都会改变,导致线上调用全部报错,显然是不行的。

解决办法是,我们可以通过注解,达到选择性启用的目的。即,新编写一个注解类,只有应用了该注解的 controller 类或方法,才进行结果集转换。

一个参考实现如下:

/**
 * 用来将controller返回值用{@link RestResult}包裹
 */

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RestWrapper {

    /**
     * 是否临时禁用
     */

    boolean disabled() default false;
}

定义了该注解,我们还要对 JSONResponseWrapper 类进行调整,对该注解进行解析:

@Override
public Object beforeBodyWrite(Object returnValue, MethodParameter returnType, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {

    // 如果结果已经被包装过,直接返回
    if (returnValue instanceof RestResult) {
        return returnValue;
    }

    // 先从方法上取注解
    RestWrapper restWrapper = returnType.getMethodAnnotation(RestWrapper.class);
    if (restWrapper == null) {
        // 再从类上取
        restWrapper = returnType.getDeclaringClass().getAnnotation(RestWrapper.class);
    }

    // 如果没有定义该注解或禁用了,则直接返回
    if (restWrapper == null || restWrapper.disabled()) {
        return returnValue;
    }

    // 对结果进行包装
    RestResult<Object> result = RestResult.success(returnValue);

    // 如果结果集是 String 类型,要提前进行序列化操作,否则会包装失败
    if (returnType.getParameterType() == String.class{
        try {
            return objectMapper.writeValueAsString(result);
        } catch (Exception e) {
            // todo 按自己业务需求进行调整
            throw new RuntimeException(e);
        }
    }

    // 返回包装后的结果集
    return result;
}

如此一来,我们既可以对整个 controller 类的方法进行结果集转换,也可针对某一个方法。例如,

FooController 可以像下面这样使用:

@RestWrapper
@AllArgsConstructor
@RestController
@RequestMapping("foo/v1")
public class FooController {

    private FooService fooService;


    /**
     * 继承类上的注解
     */

    @GetMapping
    Foo getFoo() {
        return fooService.getFoo();
    }

    /**
     * 启用结果转换
     */

    @RestWrapper
    @GetMapping("name")
    String getFooName() {
        return fooService.getFooName();
    }

    /**
     * 禁用结果转换
     */

    @RestWrapper(disabled = true)
    @GetMapping("age")
    Integer getFooAge() {
        return fooService.getFooAge();
    }
}

注意,之所以给 @RestWrapper 添加 disabled 属性,一是可以快捷的进行禁用;二是,并非所有接口都支持 JSON 返回类型,比如支付宝的支付回调,成功时,只需要返回 "success" 这个字符串,可用此参数来配置。

4总结

文章先是介绍了对 RESTful 接口返回值进行封装的必要性,然后结合 Spring Boot 谈了怎么封装一个易用的结果转换类以及怎么以渐进地方式应用到自己的项目中。希望对你有用。


原文始发于微信公众号(背井):Spring Boot: 怎么封装一个易用的 RESTful 工具库

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/246932.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!