SpringWebURL解析常见错误

虽然Spring很强大,他有很多很多的功能。

但是他最主要大部分的使用领域还是在Web开发领域。

针对Web开发,那必然会涉及到Http请求,那请求的URI就十分的重要。

Spring是如何对Http请求中URI进行解析的呢???而期间Spring会出现很多哪些常见的问题呢?


一、当 @PathVariable 遇到 /

在解析一个 URL 时,我们可能会使用到 @PathVariable 这个注解。例如我们会经常见到如下风格的代码:

@RestController
@Slf4j
public class HelloWorldController {

    @RequestMapping(path = "/hi1/{name}", method = RequestMethod.GET)
    public String hello1(@PathVariable("name") String name){
        return name;
    };  
    
}

但但 name 中含有特殊字符 / 时(例如http://localhost:8080/hi1/xiao/ming ),会如何?如果我们不假思索,或许答案是"xiao/ming"?然而稍微敏锐点的程序员都会判定这个访问是会报错的,具体错误参考:

ios解析url指定参数 url解析错误_ios解析url指定参数


如图所示,当 name 中含有 /,这个接口不会为 name 获取任何值,而是直接报 Not Found 错误。

当然这里的“找不到”并不是指 name 找不到,而是指服务于这个特殊请求的接口。

实际上,这里还存在另外一种错误,即当 name 的字符串以 / 结尾时,/ 会被自动去掉。

例如我们访问 http://localhost:8080/hi1/xiaoming/,Spring 并不会报错,而是返回 xiaoming。

针对这两种类型的错误,应该如何理解并修正呢?


Spring URL 匹配执行方法AbstractHandlerMethodMapping#lookupHandlerMethod

@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
   List<Match> matches = new ArrayList<>();
   //尝试按照 URL 进行精准匹配
   List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
   if (directPathMatches != null) {
      //精确匹配上,存储匹配结果
      addMatchingMappings(directPathMatches, matches, request);
   }
   if (matches.isEmpty()) {
      //没有精确匹配上,尝试根据请求来匹配
      addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
   }

   if (!matches.isEmpty()) {
      Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
      matches.sort(comparator);
      Match bestMatch = matches.get(0);
      if (matches.size() > 1) {
         //处理多个匹配的情况
      }
      //省略其他非关键代码
      return bestMatch.handlerMethod;
   }
   else {
      //匹配不上,直接报错
      return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
   }

大致为如下步骤

  • 根据 Path 进行 精确匹配

这个步骤执行的代码语句是"this.mappingRegistry.getMappingsByUrl(lookupPath)",实际上,它是查询 MappingRegistry#urlLookup,它的值可以用调试视图查看,如下图所示:

ios解析url指定参数 url解析错误_spring boot_02


查询 urlLookup 是一个精确匹配 Path 的过程。

很明显,http://localhost:8080/hi1/xiao/ming 的 lookupPath 是"/hi1/xiao/ming",并不能得到任何精确匹配。

这里需要补充的是,"/hi1/{name}"这种定义本身也没有出现在 urlLookup 中。


  • 假设 Path 没有精确匹配上,则执行模糊匹配

addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request)执行模糊匹配

ios解析url指定参数 url解析错误_spring boot_03


显然,"/hi1/{name}"这个匹配方法已经出现在待匹配候选中了。

具体匹配过程可以参考方法 RequestMappingInfo#getMatchingCondition

public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
   RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
   if (methods == null) {
      return null;
   }
   ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
   if (params == null) {
      return null;
   }
   //省略其他匹配条件
   PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(request);
   if (patterns == null) {
      return null;
   }
   //省略其他匹配条件
   return new RequestMappingInfo(this.name, patterns,
         methods, params, headers, consumes, produces, custom.getCondition());
}

匹配会查询所有的信息,例如 Header、Body 类型以及 URL 等。如果有一项不符合条件,则不匹配。

在我们的案例中,当使用 http://localhost:8080/hi1/xiaoming 访问时,其中 patternsCondition 是可以匹配上的,也就是在模糊匹配中。

实际的匹配方法执行是通过 AntPathMatcher#match 来执行,判断的相关参数可参考以下调试视图:

ios解析url指定参数 url解析错误_java_04


当我们使用 http://localhost:8080/hi1/xiao/ming 来访问时,AntPathMatcher 执行的结果是"/hi1/xiao/ming"匹配不上"/hi1/{name}"。


  • 根据匹配情况返回结果

如果找到匹配的方法,则返回方法;

如果没有,则返回 null。

在本案例中,http://localhost:8080/hi1/xiao/ming 因为找不到匹配方法最终报 404 错误。

追根溯源就是 AntPathMatcher 匹配不了"/hi1/xiao/ming"和"/hi1/{name}"。


另外,我们再回头思考 http://localhost:8080/hi1/xiaoming/ 为什么没有报错而是直接去掉了 /。

这里我直接贴出了负责执行 AntPathMatcher 匹配的 PatternsRequestCondition#getMatchingPattern 方法的部分关键代码:

private String getMatchingPattern(String pattern, String lookupPath) {
   //省略其他非关键代码
   if (this.pathMatcher.match(pattern, lookupPath)) {
      return pattern;
   }
   //尝试加一个/来匹配
   if (this.useTrailingSlashMatch) {
      if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) {
         return pattern + "/";
      }
   }
   return null;
}

在这段代码中,AntPathMatcher 匹配不了"/hi1/xiaoming/“和”/hi1/{name}",所以不会直接返回。

进而,在 useTrailingSlashMatch 这个参数启用时(默认启用),会把 Pattern 结尾加上 / 再尝试匹配一次。

如果能匹配上,在最终返回 Pattern 时就隐式自动加 /。

很明显,我们的案例符合这种情况,等于说我们最终是用了"/hi1/{name}/“这个 Pattern,而不再是”/hi1/{name}"。所以自然 URL 解析 name 结果是去掉 / 的。


二、错误使用 @RequestParam、@PathVarible 等注解

当如下定义声明@RequestParam

@RequestMapping(path = "/hi1", method = RequestMethod.GET)
public String hi1(@RequestParam("name") String name){
    return name;
};

@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestParam String name){
    return name;
};

很明显,对于喜欢追究极致简洁的同学来说,这个酷炫的功能是一个福音。

但当我们换一个项目时,有可能上线后就失效了,然后报错 500,提示匹配不上。

ios解析url指定参数 url解析错误_java_05


要理解这个问题出现的原因,首先我们需要把这个问题复现出来。例如我们可以修改下 pom.xml 来关掉两个选项:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
   <configuration>
        <debug>false</debug>
        <parameters>false</parameters>
    </configuration>
</plugin>

上述配置显示关闭了 parameters 和 debug,这 2 个参数的作用你可以参考下面的表格:

ios解析url指定参数 url解析错误_java_06

通过上述描述,我们可以看出这 2 个参数控制了一些 debug 信息是否加进 class 文件中。

我们可以开启这两个参数来编译,然后使用下面的命令来查看信息:

javap -verbose HelloWorldController.class

执行完命令后,我们会看到以下 class 信息:

ios解析url指定参数 url解析错误_spring boot_07

debug 参数开启的部分信息就是 LocalVaribleTable,而 paramters 参数开启的信息就是 MethodParameters。

观察它们的信息,你会发现它们都含有参数名 name。

如果你关闭这两个参数,则 name 这个名称自然就没有了。

而这个方法本身在 @RequestParam 中又没有指定名称,那么 Spring 此时还能找到解析的方法么?

答案是否定的,这里我们可以顺带说下 Spring 解析请求参数名称的过程,参考代码 AbstractNamedValueMethodArgumentResolver#updateNamedValueInfo

private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) {
   String name = info.name;
   if (info.name.isEmpty()) {
      name = parameter.getParameterName();
      if (name == null) {
         throw new IllegalArgumentException(
               "Name for argument type [" + parameter.getNestedParameterType().getName() +
               "] not available, and parameter name information not found in class file either.");
      }
   }
   String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue);
   return new NamedValueInfo(name, info.required, defaultValue);
}

其中 NamedValueInfo 的 name 为 @RequestParam 指定的值。

很明显,在本案例中,为 null。所以这里我们就会尝试调用 parameter.getParameterName() 来获取参数名作为解析请求参数的名称。

但是,很明显,关掉上面两个开关后,就不可能在 class 文件中找到参数名了,这点可以从下面的调试试图中得到验证:

ios解析url指定参数 url解析错误_spring boot_08

当参数名不存在,@RequestParam 也没有指明,自然就无法决定到底要用什么名称去获取请求参数,所以就会报本案例的错误。


三、未考虑参数是否可选

当如下定义Controller,当我们只传了name,但没传address的情况下,会出现http的400错误,即(http://localhost:8080/hi4?name=xiaoming)

@RequestMapping(path = "/hi4", method = RequestMethod.GET)
public String hi4(@RequestParam("name") String name, @RequestParam("address") String address){
    return name + ":" + address;
};

ios解析url指定参数 url解析错误_spring boot_09


RequestParamMethodArgumentResolver 对参数解析的一些关键操作,参考其父类方法 AbstractNamedValueMethodArgumentResolver#resolveArgument

public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
      NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
   NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
   MethodParameter nestedParameter = parameter.nestedIfOptional();
   //省略其他非关键代码
   //获取请求参数
   Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
   if (arg == null) {
      if (namedValueInfo.defaultValue != null) {
         arg = resolveStringValue(namedValueInfo.defaultValue);
      }
      else if (namedValueInfo.required && !nestedParameter.isOptional()) {
         handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
      }
      arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
   }
   //省略后续代码:类型转化等工作
   return arg;
}
  • 查看 namedValueInfo 的默认值,如果存在则使用它

这个变量实际是通过下面的方法来获取的,参考 RequestParamMethodArgumentResolver#createNamedValueInfo:

@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
   RequestParam ann = parameter.getParameterAnnotation(RequestParam.class);
   return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo());
}

ios解析url指定参数 url解析错误_spring_10

  • 在 @RequestParam 没有指明默认值时,会查看这个参数是否必须,如果必须,则按错误处理

ios解析url指定参数 url解析错误_ios解析url指定参数_11

  • 如果不是必须,则按 null 去做具体处理

ios解析url指定参数 url解析错误_web_12


说了以上,那提供几种解决方案

  • 设置 @RequestParam 的默认值
@RequestParam(value = "address", defaultValue = "no address") String address
  • 设置 @RequestParam 的 required 值
@RequestParam(value = "address", required = false) String address)
  • 标记任何名为 Nullable 且 RetentionPolicy 为 RUNTIME 的注解
//org.springframework.lang.Nullable 可以
//edu.umd.cs.findbugs.annotations.Nullable 可以
@RequestParam(value = "address") @Nullable String address
  • 修改参数类型为 Optional
@RequestParam(value = "address") Optional address

在 Spring Web 中,默认情况下,请求参数是必选项


四、Date格式参数格式错误

Spring 支持日期类型的转化,于是我们可能会写出类似下面这样的代码:

@RequestMapping(path = "/hi6", method = RequestMethod.GET)
public String hi6(@RequestParam("Date") Date date){
    return "date is " + date ;
};

然后,我们使用一些看似明显符合日期格式的 URL 来访问,例如 http://localhost:8080/hi6?date=2021-5-1 20:26:53,我们会发现 Spring 并不能完成转化,而是报错如下:

ios解析url指定参数 url解析错误_ios解析url指定参数_13


此时,返回错误码 400,错误信息为"Failed to convert value of type ‘java.lang.String’ to required type 'java.util.Date"。


针对Spring,他在处理参数转换的时候,需要知道source和target,并通过去寻找对应的参数转换器

public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
   if (source == null) {
      return null;
   }
   Class<?> sourceClass = sourceType.getType();
   Class<?> targetClass = targetType.getType();
   //根据源类型去获取构建出目标类型的方法:可以是工厂方法(例如 valueOf、from 方法)也可以是构造器
   Member member = getValidatedMember(targetClass, sourceClass);
   try {
      if (member instanceof Method) {
         //如果是工厂方法,通过反射创建目标实例
      }
      else if (member instanceof Constructor) {
         //如果是构造器,通过反射创建实例
         Constructor<?> ctor = (Constructor<?>) member;
         ReflectionUtils.makeAccessible(ctor);
         return ctor.newInstance(source);
      }
   }
   catch (InvocationTargetException ex) {
      throw new ConversionFailedException(sourceType, targetType, source, ex.getTargetException());
   }
   catch (Throwable ex) {
      throw new ConversionFailedException(sourceType, targetType, source, ex);
   }

当使用 ObjectToObjectConverter 进行转化时,是根据反射机制带着源目标类型来查找可能的构造目标实例方法,例如构造器或者工厂方法,然后再次通过反射机制来创建一个目标对象。

所以对于 Date 而言,最终调用的是下面的 Date 构造器:

public Date(String s) {
    this(parse(s));
}

然而,我们传入的 2021-5-1 20:26:53 虽然确实是一种日期格式,但用来作为 Date 构造器参数是不支持的,最终报错,并被上层捕获,转化为 ConversionFailedException 异常。


同样,提供对应的解决方案

  • 使用 Date 默认支持的格式

http://localhost:8080/hi6?date=Sat, 12 Aug 1995 13:30:00 GMT

  • 指定告诉Spring对应的转换格式@DateTimeFormat
@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date date

五、总结

  • 当我们使用 @PathVariable 时,一定要注意传递的值是不是含有 / ;
  • 当我们使用 @RequestParam、@PathVarible 等注解时,一定要意识到一个问题,虽然下面这两种方式(以 @RequestParam 使用示例)都可以,但是后者在一些项目中并不能正常工作,因为很多产线的编译配置会去掉不是必须的调试信息。
@RequestMapping(path = "/hi1", method = RequestMethod.GET)
public String hi1(@RequestParam("name") String name){
    return name;
};
//方式2:没有显式指定RequestParam的“name”,这种方式有时候会不行
@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestParam String name){
    return name;
};
  • 任何一个参数,我们都需要考虑它是可选的还是必须的。同时,你一定要想到参数类型的定义到底能不能从请求中自动转化而来。Spring 本身给我们内置了很多转化器,但是我们要以合适的方式使用上它。另外,Spring 对很多类型的转化设计都很贴心,例如使用下面的注解就能解决自定义日期格式参数转化问题。
@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date date

在最后,当我们定义如下代码

@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestParam("name") String name){
    return name;
};

http://localhost:8080/hi2?name=xiaoming&name=hanmeimei,那Spring会如何处理这个name参数,并解析呢????


Spring会吧同名的参数转换为String[],再String[] —> String,并通过分隔开。
所以结果是xiaoming,hanmeimei