业务背景
A 服务调用 B 服务的 feign 接口报错,通过日志定位到如下代码,该接口会从请求头中获取用户信息进行校验,由于其中名为 partyId 的请求头为 null,导致 URLDecoder.decode 方法报空指针异常。
public static LoginUser getLoginUser() {
LoginUser loginUser = new LoginUser();
try {
HttpServletRequest httpServletRequest = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String partyId = URLDecoder.decode(httpServletRequest.getHeader("partyId"), "UTF-8");
loginUser.setPartyId(partyId);
} catch (UnsupportedEncodingException var7) {
}
return loginUser;
}
问题排查
由于该请求头非业务参数却有必填需求,猜测正常会由网关服务赋值,检查相关拦截器后发现该值默认为空字符串,但 B 服务获取时却为 null,显然从网关到 A 服务,再到 B 服务的传递过程中存在问题。检查 A 服务代码发现一段获取请求头后调用 feign 的 requestTemplate.head 进行赋值的代码。如下为简化代码。
/**
* 包含的header集合
*/
Set<String> headers = new HashSet<String>() {{
add("partyId");
}};
/**
* 包含则返回true
*
* @param headerName
* @return
*/
public boolean hasHeader(String headerName) {
return headers.contains(headerName);
}
/**
* 使用feign client发送请求时,传递header
*
* @return
*/
@Bean
public RequestInterceptor requestInterceptor() {
return requestTemplate -> {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
String headerName;
while (headerNames.hasMoreElements()) {
headerName = headerNames.nextElement();
String value = request.getHeader(headerName);
if (hasHeader(headerName)) {
requestTemplate.header(headerName, value);
}
}
}
FeignHeaderUtil.remove();
}
};
}
通过在中间增加日志打印发现,A 服务获取到的请求头信息中 partyId 就已经为null。搜索网关服务请求头丢失相关问题后,找到如下博文(还有其他内容相同的,不清楚原创是谁)。获取header 请求参数_APP 莫名崩溃,开始以为是 Header 中 name 大小写的锅,最后发现原来是容器的错!...-CSDN博客
检查了三个服务的配置,发现网关使用的是 tomcat,A、B 服务则为 undertow,符合文中描述。
参考文中信息并监控各服务接口后得出以下结论:
1.tomcat 容器会把请求头key值全部转成小写,undertow 容器不会转成小写
2.tomcat 容器对请求头忽略大小写,undertow 容器不忽略大小写
3.value 为空字符串时会被 feign 的 requestTemplate.header 方法过滤
tomcat 会把驼峰格式的头信息转换成小写进行传递,而在接收时又会忽略大小写,因此使用tomcat 容器的服务间互相调用不会报错,但 undertow 容器会区分大小写,导致未能正常传递。通过打印日志发现 A 服务接收到的头信息中 partId 确实变为了 partyid。此处匹配的头信息不包含 partyid,因此驼峰与小写的头信息均未传递到 B 服务。
到此解决问题的思路就清晰了,A、B 服务都是使用 undertow 容器,只需把 partyid 的值重新赋给 partyId 传递过去即可。如果 B 服务使用的是tomcat容器,也可以把 partyid 加入到如上匹配规则中。改写代码如下。
@Bean
public RequestInterceptor requestInterceptor() {
return requestTemplate -> {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
String headerName;
while (headerNames.hasMoreElements()) {
headerName = headerNames.nextElement();
String value = request.getHeader(headerName);
if ("partyid".equals(headerName) && StringUtils.isBlank(value)) {
requestTemplate.header("partyId", "");
}
if (hasHeader(headerName)) {
requestTemplate.header(headerName, value);
}
}
}
FeignHeaderUtil.remove();
}
};
}
然而调用接口后 B 服务仍未接收到该值,已进行赋值操作的情况下,可以推测出该请求头被过滤了,也就是后续的 requestTemplate.header 操作有问题,点进去一层层查看源码,忽略无关代码,最终找到如下语句。
// feign.RequestTemplate#appendHeader
private RequestTemplate appendHeader(String name, Iterable<String> values) {
if (!values.iterator().hasNext()) {
this.headers.remove(name);
return this;
} else if (name.equals("Content-Type")) {
this.headers.remove(name);
this.headers.put(name, HeaderTemplate.create(name, Collections.singletonList(values.iterator().next())));
return this;
} else {
this.headers.compute(name, (headerName, headerTemplate) -> {
return headerTemplate == null ? HeaderTemplate.create(headerName, values) : HeaderTemplate.append(headerTemplate, values);
});
return this;
}
}
// feign.template.HeaderTemplate#append
public static HeaderTemplate append(HeaderTemplate headerTemplate, Iterable<String> values) {
LinkedHashSet<String> headerValues = new LinkedHashSet(headerTemplate.getValues());
headerValues.addAll((Collection)StreamSupport.stream(values.spliterator(), false).filter(Util::isNotBlank).collect(Collectors.toCollection(LinkedHashSet::new)));
return create(headerTemplate.getName(), headerValues);
}
可以看到在存储头信息value值时,feign的源码通过jdk8的stream流筛选了所有不为空字符串的值,导致虽然设置了驼峰格式的请求头,原先为空字符串的值却仍为null。
解决方案
对所需传递的请求头进行赋值,由于调用的 B 服务接口并未对该值进行使用,将网关传递的空字符串改为任意非空字符串即可。如果 B 服务使用的是tomcat容器,仅传递 partyid 是同样适用的。