【Feign系列】Feign请求参数包装异常问题定位

文章目录
  1. 场景复现
    1. 1. 环境相关版本
    2. 2. 服务接口
    3. 2. 问题定位
    4. 3. 小结
  2. II. 其他
    1. 0. 项目
    2. 1. 一灰灰Blog
    3. 2. 声明
    4. 3. 扫描关注

通过Feign包装rpc的调用姿势,在使用的版本中发现一个奇怪的bug,大部分场景下请求正常,少数情况下请求返回400,记录下原因

场景复现

1. 环境相关版本

Spring版本如

1
2
<spring.boot.version>2.0.1.RELEASE</spring.boot.version>
<spring.cloud.version>Finchley.RELEASE</spring.cloud.version>

Feign版本

1
2
3
4
5
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>

对应的feign-core版本为

1
2
3
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<version>9.5.1</version>

2. 服务接口

接口形如

1
2
3
4
@RequestMapping(value = "getMarketDailySummary")
BaseRsp<MarketDailySummaryDTO> getMarketDailySummary(@RequestParam("datetime") Long datetime,
@RequestParam(value = "coinIds") List<Integer> coinIds,
@RequestParam(value = "pairIds") List<Integer> pairIds);

使用时报400的case

1
marketDailyReportService.getMarketDailySummary(1551836411000L, Arrays.asList(1, 2, 3, 10), Arrays.asList());

简单来说,接口参数为集合的情况下,如果传一个空集合,那么这就会出现400的错误

通过在提供服务的应用中,写一个fitler拦截请求,打印出请求参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
@WebFilter(value = "/**")
public class ReqFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
try {
System.out.println(servletRequest.getParameterMap());
} finally {
filterChain.doFilter(servletRequest, servletResponse);
}

}

@Override
public void destroy() {

}
}

然后发起rpc调用前面的测试用例,通过断点查看请求参数,确实只有两个参数,而我们传入空pairIds集合,直接被吃掉了

filter截图

再对应到我们的api声明方式,要求三个参数,因此问题就很清晰了,解决办法就是在api中参数的必填设置为false即可

1
2
3
4
@RequestMapping(value = "getMarketDailySummary")
BaseRsp<MarketDailySummaryDTO> getMarketDailySummary(@RequestParam("datetime") Long datetime,
@RequestParam(value = "coinIds", required = false) List<Integer> coinIds,
@RequestParam(value = "pairIds", required = false) List<Integer> pairIds);

上面只是表层的解决了问题,接下来就需要确定,为什么请求参数会被吃掉,通过浅显的推测,多半原因在feign的请求参数封装上了

2. 问题定位

对于容易复现的问题,最佳的定位方法就是debug了,直接单步进去,找到对应的请求参数封装逻辑,

第一步定位到RequestTemplate的创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// feign.SynchronousMethodHandler#invoke
@Override
public Object invoke(Object[] argv) throws Throwable {
// 下面这一行为目标逻辑,创建请求模板类,请求参数封装肯定是在里面了
RequestTemplate template = buildTemplateFromArgs.create(argv);
Retryer retryer = this.retryer.clone();
while (true) {
try {
return executeAndDecode(template);
} catch (RetryableException e) {
retryer.continueOrPropagate(e);
if (logLevel != Logger.Level.NONE) {
logger.logRetry(metadata.configKey(), logLevel);
}
continue;
}
}
}

接下来深入进去之后,参数解析的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// feign.ReflectiveFeign.BuildTemplateByResolvingArgs#resolve
protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable,
Map<String, Object> variables) {
// Resolving which variable names are already encoded using their indices
Map<String, Boolean> variableToEncoded = new LinkedHashMap<String, Boolean>();
for (Entry<Integer, Boolean> entry : metadata.indexToEncoded().entrySet()) {
Collection<String> names = metadata.indexToName().get(entry.getKey());
for (String name : names) {
variableToEncoded.put(name, entry.getValue());
}
}

// 核心逻辑了,使用请求参数来替换模板中的占位
return mutable.resolve(variables, variableToEncoded);
}
}

再进去一步就到了根源点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// feign.RequestTemplate#replaceQueryValues(java.util.Map<java.lang.String,?>, java.util.Map<java.lang.String,java.lang.Boolean>)
void replaceQueryValues(Map<String, ?> unencoded, Map<String, Boolean> alreadyEncoded) {
Iterator<Entry<String, Collection<String>>> iterator = queries.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Collection<String>> entry = iterator.next();
if (entry.getValue() == null) {
continue;
}
Collection<String> values = new ArrayList<String>();
for (String value : entry.getValue()) {
if (value.indexOf('{') == 0 && value.indexOf('}') == value.length() - 1) {
Object variableValue = unencoded.get(value.substring(1, value.length() - 1));
// only add non-null expressions
if (variableValue == null) {
// 如果请求参数为null,也不会凭借到url参数中
continue;
}
if (variableValue instanceof Iterable) {
// 将目标集中在这里,如果请求参数时空集合,下面的for循环不会走到,所以也就不会拼接在url参数中
for (Object val : Iterable.class.cast(variableValue)) {
String encodedValue = encodeValueIfNotEncoded(entry.getKey(), val, alreadyEncoded);
values.add(encodedValue);
}
} else {
String encodedValue = encodeValueIfNotEncoded(entry.getKey(), variableValue, alreadyEncoded);
values.add(encodedValue);
}
} else {
values.add(value);
}
}
if (values.isEmpty()) {
iterator.remove();
} else {
entry.setValue(values);
}
}
}

下图是我们最终定位的一个截图,从代码实现来看,feign的设计理念是,如果请求参数为null,空集合,则不会将参数拼接到最终的请求参数中,也就导致最终发起请求时,少了一个参数

debug截图

问题清晰之后,然后就可以确认下是bug还是就是这么设计的了,最简单的办法就是看最新的代码有没有改掉了,从git上,目前已经更新到10.x;10.x与9.x的差别挺大,底层很多东西重写了,然而官方的Spring-Cloud-openfeing并没有升级到最新,so,只能取看9.7.0版本的实现了,和9.5.2并没有太大的区别;

so,站在feign开发者角度出发,这么设计的理由可能有以下几点

  • 既然允许传入空集合、null参数,那么在api的声明时,就有必要加上 require=False
  • 对于这种无效的请求参数,也没有太大的必要传过去(虽然从使用者角度来说,你就应该老老实实的把我调用的参数都丢过去)

3. 小结

最后小结一下,使用feign作为SpringCloud的rpc封装工具时,请注意,

  • 如果api的请求参数允许为null,请在注解中显示声明;
  • 此外请求方传入的null、空集合最终不会拼装的请求参数中,即对于接受者而言,就像没有这个参数一样,对于出现400错误的场景,可以考虑下是否是这种问题导致的
  • 对于复杂的请求参数,推荐使用DTO来替代多参数的类型(因为这样接口的复用性是最佳的,如新增和修改条件时,往往不需要新增api)

II. 其他

0. 项目

1. 一灰灰Blog

一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

2. 声明

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

3. 扫描关注

一灰灰blog

QrCode

知识星球

goals


打赏 如果觉得我的文章对您有帮助,请随意打赏。
分享到