SpringMVC 参数解析器 和 Spring 类型转换

2,665 阅读9分钟

前言

最近同事因为工作忙没时间面试候选人,偶尔会让我临时替补,就这样我作为替补面试官面了一个初级妹子,两个(自称)高级的开发。我面试候选人的习惯就是看他简历写啥我就问啥。他们的简历上都有写熟悉 SpringMVC ,于是我随便问了两个问题就回答的很不理想

  • SpringMVC 的执行流程
  • 前端传递 ?username=xx&password=xxcontroller 中就能自动用 User 对象来接收,这是如何实现的?

第一个问题应该比较简单,随便背背就好了,然而两个高级开发也是回答的很模糊......

第二个问题对于初级开发来说可能是相对有难度,但是对于 5-10 年的高级开发连边都答不上,我觉得这是很不应该的,我的要求很简单,并不是要候选人把完整的源码流程说出来,只要候选人能够答道 参数解析器/类型转换器,我就觉得这个问题可以过了。

PS 最让我伤心的是候选人税前年薪 40w +,期望年薪 50w + 都快到我三倍了......然后居然这些基础都答不上,消息队列和缓存中间件的应用场景也回答的模模糊糊,随便提个问题就答不上了。于是我只能安慰自己,他们肯定是来面管理岗的!薪资高正常......对,就是这样!!!

灵魂拷问

让我们先来看一段很常见的代码

@GetMapping("/example/{id}")
public void example(@PathVariable Long id,
                    @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime dateTime,
                    @RequestParam List<Long> ids,
                    @RequestParam Boolean enable,
                    @RequestParam ExampleEnum exampleEnum,
                    User user) {
    log.info(id.toString());
    log.info(dateTime.toString());
    log.info(ids.toString());
    log.info(enable.toString());
    log.info(exampleEnum.toString());
    log.info(user.toString());
}

然后我们写个测试模拟 HTTP 请求

@Test
void example() throws Exception {
    mvc.perform(
                    get("/example/1")
                            .param("dateTime", "2020-09-02T16:00:01")
                            .param("ids", "1,2,3")
                            .param("enable", "no")
                            .param("exampleEnum", "ONE")
                            .param("username", "暮色妖娆丶")
            )
            .andExpect(status().isOk());
}

再来看打印的日志

: 1
: 2020-09-02T16:00:01
: [1, 2, 3]
: false
: ONE
: User(username=暮色妖娆丶)

这样的代码我们几乎每天都在写,但是你有没有想过

  • 为什么传的 1,2,3 能自动转为 List<Long>
  • 为什么传的 no 能够被转为布尔类型的 false
  • 为什么传的 username 就能被自动封装到 user 中?
  • 为什么传的 ONE 就能被自动封装到 ExampleEnum 中?

如果你提出疑问,我会用不就行了?为什么要理解它是怎么实现的?那么请你直接跳转到这里 自定义参数解析器优化分页代码

SpringMVC 执行流程

在搞清楚上述问题之前,我们先要知道 SpringMVC 在接收到一个请求,到这个请求进入控制器方法体之前这段时间都干了哪些事情。这个流程网上一搜到处都是,其实自己去 debug 一下源码就很清楚了,大致是以下几步

图中标颜色的,也是比较重要的一步,真正处理请求的 handle 方法,这里面包含了非常重要的一步操作,就是 参数解析 resolveArgument() 它的作用就是将我们原始请求的参数,解析成我们控制器方法上真正需要的参数类型。那么它是如何解析的呢?说到这就不得不提 SpringMVC 的参数解析器。

参数解析器

SpringMVC 提供了 20+ 种参数解析器来解析 @RequestParam、@RequestBody、@PathVariable...... 等注解的请求参数。

顶层接口是 HandlerMethodArgumentResolver ,所有的参数解析器都实现了这个接口,观察其源码

public interface HandlerMethodArgumentResolver {
    /**
     * ...
     */
    boolean supportsParameter(MethodParameter parameter);
    /**
     * ...
     */
    @Nullable
    Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                           NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}

这是一个典型的策略模式接口,判断当前的方法形参类型是否支持,如果支持就使用该实现类的参数解析器对其解析。那么聪明的你可能已经想到了,这很明显我们可以实现该接口定制化自己的参数解析器。

这样一来上面我们提的几个问题就很显而易见了,是 SpringMVC 提供的一系列参数解析器帮我们实现这么智能化的转换。那么只要去看参数解析器里面的具体实现就行了。说到具体实现就不得不提 Spring 核心之类型转换器

类型转换器

Spring 3.0 引入了一个 core.convert 提供通用类型转换系统的包。系统定义了一个 SPI 来实现类型转换逻辑,并定义一个 API 来在运行时执行类型转换。这套类型转换系统主要有以下几个核心接口

Converter

顶层接口 Converter 观察其源码

@FunctionalInterface
public interface Converter<S, T> {
    @Nullable
    T convert(S var1);
}

该接口的作用是将类型 S 转换为 T,在参数解析器中使用该接口的实现将前端请求参数转成控制器方法需要的参数类型。但是很多场景下,我们其实是要将一种类型转换为一组类型,比如刚刚上面的例子把 String 转换成 Enum ,为此 Spring 提供了另一个接口 ConverterFactory

ConverterFactory

观察其源码

public interface ConverterFactory<S, R> {
    <T extends R> Converter<S, T> getConverter(Class<T> var1);
}

该接口的作用是将类型 S 转换为 R 及其子类型(看源码的前提泛型必须要掌握,如果对泛型不太熟悉的同学可以看我写的 一文带你搞懂 Java 泛型 )。

ConversionService

ConversionService 定义了一个统一的 API 用来在运行时执行类型转换的逻辑,观察其源码

public interface ConversionService {
    boolean canConvert(@Nullable Class<?> var1, Class<?> var2);

    boolean canConvert(@Nullable TypeDescriptor var1, TypeDescriptor var2);

    @Nullable
    <T> T convert(@Nullable Object var1, Class<T> var2);

    @Nullable
    Object convert(@Nullable Object var1, @Nullable TypeDescriptor var2, TypeDescriptor var3);
}

源码中可以说就两个方法,先判断能不能 convert,如果可以就执行真正的 convert 逻辑。所有的 Converter 都是在 ConversionService 后面默默的执行工作,所以这里是一个外观模式的体现 (Spring 家族的源码中设计模式多的一批......)

Spring 提供的 Converter

以示例代码为例,

  • ids -> 1,2,3 能够用 List<Long> 接收是因为 Spring 提供了 StringToCollectionConverter

  • enable -> no 能够用 Boolean 接收是因为 Spring 提供了 StringToBooleanConverter,源码中定义了以下值全部都能用 Boolean 来接收,有兴趣可以亲自尝试~

static {
    trueValues.add("true");
    trueValues.add("on");
    trueValues.add("yes");
    trueValues.add("1");
    falseValues.add("false");
    falseValues.add("off");
    falseValues.add("no");
    falseValues.add("0");
}
  • exampleEnum -> ONE 能够用 ExampleEnum 接收是因为 Spring 提供了 StringToEnumConverterFactory 。这个 convert 方法体就更简单了
@Nullable
public T convert(String source) {
    return source.isEmpty() ? null : Enum.valueOf(this.enumType, source.trim());
}

Spring 提供的所有支持的类型转换器实现类都在 org.springframework.core.convert.support 包下,有兴趣可以阅读源码,很多转换器的 convert 方法体都非常简单~~

SpringBoot 提供的 Converter

在 Spring 提供的类型转换器之外,SpringBoot 又给我们提供了一些转换器的实现来简化我们的开发,这些转换器位于 org.springframework.boot.convert 包下,以最常见的 StringToDurationConverter 为例,你有没有想过为啥你在 application.yml 中配置 7d 这样的字符串,就能在配置类中用 Duration 映射?

auto-review-loan:
  effective-time: 7d  
@ConfigurationProperties(prefix = AutoReviewLoanProperties.PREFIX)
@Component
@Data
public class AutoReviewLoanProperties {
  public static final String PREFIX = "auto-review-loan";

  /** 风控给的额度有效时间,默认7天 */
  private Duration effectiveTime = Duration.ofDays(7);
}

翻一翻 StringToDurationConverter 源码就知道啦~

自定义参数解析器优化分页代码

案例场景

试想现在有这么一个场景,你的公司最近引入了 MyBatisPlus 组件,在使用它自带的 IPage 分页的时候你们会如何写代码?大多数写法可能是这样

public IPage<Response> page(@RequestParam Integer pageNum , @RequestParam Integer pageSize) {
  IPage<Response> page = new Page<>(pageNum,pageSize);
  return service.page(page);
}

或者也可能是这样

public IPage<Response> page(PageRequest request) {
  IPage<Response> page = new Page<>(request.getPageNum(),request.getPageSize());
  return service.page(page);
}

当然这两种写法本身没有什么问题,但是写多了之后你就会发现这行获取分页对象的代码是重复的,每一个分页接口都得写下面这行代码

IPage<Response> page = new Page<>(pageNum,pageSize)

本着 DRY(dont repeat yourself) 原则,我们有没有办法能省掉这行重复的代码呢?聪明的你可能已经想到了,我们可以尝试把 分页对象 IPage page; 定义到方法参数上去嘛,但是直接放上去的话,前端请求参数 pageNum、pageSize 是没法直接被转换成 IPage 类型的。想起上面我们说过的,反正它参数的解析规则都是参数解析器来做的,那么我实现 HandlerMethodArgumentResolver 自己写一个参数解析器来实现这个功能不就可以了嘛~~

自定义参数解析器

注意到分页字段在 IPage 的实现类 Page 中,所以我们的参数解析器策略要支持的是 Page.class,核心代码就是 HandlerMethodArgumentResolver 的两个方法实现。观察到项目中已经引入了 spring-data-common 依赖,它已经给我们提供了一个很好用的参数解析器实现类 PageableHandlerMethodArgumentResolver ,直接用它即可

/** 自定义分页参数解析器 */
public class PageHandlerMethodArgumentResolver<T> implements HandlerMethodArgumentResolver {

  private static final int DEFAULT_PAGE = 0;
  private static final int DEFAULT_SIZE = 10;
  private static final String DEFAULT_PAGE_PARAMETER = "pageNum";//参数名
  private static final String DEFAULT_SIZE_PARAMETER = "pageSize";//参数名

  private final PageableArgumentResolver pageableArgumentResolver;

   //构造 pageableArgumentResolver 对象,设置分页字段名,默认大小等属性
  public PageHandlerMethodArgumentResolver() {
    PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver();
    resolver.setFallbackPageable(PageRequest.of(DEFAULT_PAGE, DEFAULT_SIZE));
    resolver.setSizeParameterName(DEFAULT_SIZE_PARAMETER);
    resolver.setPageParameterName(DEFAULT_PAGE_PARAMETER);
    resolver.setOneIndexedParameters(true);
    this.pageableArgumentResolver = resolver;
  }

    //配置该参数解析器支持的方法参数类型是 Page
  @Override
  public boolean supportsParameter(MethodParameter parameter) {
    return Page.class.equals(parameter.getParameterType());
  }

     //具体解析逻辑,返回 Page 对象
  @Override
  public Object resolveArgument(
      @NonNull MethodParameter parameter,
      ModelAndViewContainer mavContainer,
      @NonNull NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) {
    Pageable pageable =
        pageableArgumentResolver.resolveArgument(
            parameter, mavContainer, webRequest, binderFactory);
    return new Page<T>(pageable.getPageNumber() + 1L, pageable.getPageSize());
  }
}

定义好参数解析器之后我们需要把它添加到 SpringMVC 的解析器集合中,写一个实现 WebMvcConfigurer 接口的配置类覆盖 addArgumentResolvers() 即可

/** 添加自定义分页参数解析器 */
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
  resolvers.add(new PageHandlerMethodArgumentResolver<>());
}

准备工作完成,现在我们就可以在控制器方法上使用 (Page page) 来接收 pageNumpageSize请求参数了,代码如下

@GetMapping("/xxx")
public IPage<Response> page(Page<Response> page) {
  return service.page(page);
}

经过测试,的确达到了我们的效果,GET /xxx?pageNum=1&pageSize=10 请求的分页参数的确被解析成了 Page 对象,但是新的问题来了,由于 Page 类中还有很多其他属性导致 swagger 页面显示很多无用信息。

image.png

优化分页对象在 swagger 页面的展示

集成 swagger 的组件都提供了类型替换功能,我们可以将一个类型替换成另一个类型在 swagger 页面展示。新建一个分页参数实体类

@Data
@ParameterObject
public class PageRequest {

  @Parameter(description = "每页记录数", example = "10")
  private Integer pageSize = 10;

  @Parameter(description = "页数", example = "1")
  private Integer pageNum = 1;
}

如果使用的是 springfox 组件,可以采用如下方法替换

@Bean
public AlternateTypeRuleConvention pageAlternateTypeRuleConvention(final TypeResolver resolver) {
  return new AlternateTypeRuleConvention() {
    @Override
    public int getOrder() {
      return Ordered.HIGHEST_PRECEDENCE;
    }
    
    /** 在 swagger 页面把 Page 类型替换成 PageRequest,只显示 pageNum 和 pageSize 两个参数 */
    @Override
    public List<AlternateTypeRule> rules() {
      return Collections.singletonList(
          AlternateTypeRules.newRule(
              resolver.resolve(Page.class, WildcardType.class),
              resolver.resolve(PageRequest.class)));
    }
  };
}

如果使用的是 springdoc 组件,可以采用如下方法替换

/*
  注意 swagger页面 Page 类型被替换成 PageRequest,
  所以分页接口返回值要使用 IPage 类型,不能用 Page,否则swagger页面不会正确显示
  */
static {
  SpringDocUtils.getConfig().replaceWithClass(Page.class, PageRequest.class);
}

替换之后再看 swagger 页面已经达到了效果

image.png

值得注意的是由于 swagger 页面把 Page 换成了 PageRequest,所以分页接口返回值不能再使用 Page 而要使用 IPage

总结

经过以上案例可以发现,只有对开源组件的原理有一定理解,才能够对其进行扩展,解决问题~~更为重要的是 能够对现有项目进行不断优化迭代,才能让领导对你刮目相看啊,升职加薪就指日可待了!!!

结语

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!