SpringCloud之Feign请求参数包装异常问题定位

通过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集合,直接被吃掉了

再对应到我们的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,空集合,则不会将参数拼接到最终的请求参数中,也就导致最终发起请求时,少了一个参数

问题清晰之后,然后就可以确认下是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. 项目

  • 工程:https://github.com/liuyueyi/spring-boot-demo

1. 一灰灰Blog

  • 一灰灰Blog个人博客 https://blog.hhui.top

  • 一灰灰Blog-Spring专题博客 http://spring.hhui.top

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

2. 声明

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

  • 微博地址: 小灰灰Blog

  • QQ: 一灰灰/3302797840

3. 扫描关注

一灰灰blog

知识星球

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一灰灰blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值