前言
最近在使用feign发起http请求的时候,碰到了一个问题:由于参数的值中带了大括号导致参数设置没有生效。
事情的背景是这样子的:
最近朋友推荐了一个叫做拉勾教育的APP,类似于极客时间,上面有些挺不错的技术文章,新注册用户有7天的VIP时间,可以免费看所有的技术文章。但是一旦7天之后所有的文章都需要收费。
作为一个背负着房贷车贷被生活压弯了腰的穷困潦倒的中年程序猿,本着能省就省的原则,想着趁着免费期间把这些文章扒下来转成pdf,留着慢慢学习。
过程
说干就干,整个过程思路其实很简单:
抓包,找到返回文章内容的接口。 测试接口所需要的参数。 写代码模拟调用接口,返回文章内容自行处理。
第一步和第二步很快就做出来了,并且在POSTMAN中测试了下,可以跑通。

其中x-l-req-header
这个参数是必须的,不传的话会校验不通过。
第三步,使用feign作为http client,很快代码也写出来了。
@FeignClient(name = "lagou-service",url = "拉勾教育服务器地址")
public interface LagouCall {
String COOKIE = "cookie=抓包获取自己的cookie";
String AUTH = "authorization=转包获取自己的authorization";
String XLREQHEADER = "x-l-req-header={\"deviceType\":1}";
@GetMapping(value = "拉勾教育的api", headers = {
COOKIE, AUTH,
XLREQHEADER
})
CourseCatalogResponse getCourseCatalog(@RequestParam("courseId") int courseId);
}
@RestController
@RequestMapping("/api/negols/lagou/edu")
@Slf4j
public class LagouController {
@Resource
private LagouCall lagouCall;
@GetMapping("/test")
public Object test(@RequestParam("courseId") int courseId) {
return lagouCall.getCourseCatalog(courseId);
}
}
调用这个接口返回报错
{
"state": 1003,
"message": "非法的访问",
"content": null
}
通过debug发现feign发起http请求的时候实际上没有把x-l-req-header
参数传给服务器:
header
header里面只包含了authorization
和cookie
。
代码中明明设置了x-l-req-header
,但是为什么没生效呢?
还是那句话,源码面前,了无秘密。
熟悉feign源码的同学应该都知道,feign在启动的时候会在SpringMvcContract.parseHeaders()
方法中解析feign client中配置的header数据,并生成对应的HeaderTemplate
对象保存在RequestTemplate
对象中
private void parseHeaders(MethodMetadata md, Method method,
RequestMapping annotation) {
// TODO: only supports one header value per key
if (annotation.headers() != null && annotation.headers().length > 0) {
for (String header : annotation.headers()) {
int index = header.indexOf('=');
if (!header.contains("!=") && index >= 0) {
// 生成对应的HeaderTemplate对象保存在RequestTemplate对象中
md.template().header(resolve(header.substring(0, index)),
resolve(header.substring(index + 1).trim()));
}
}
}
}
解析header最终实际上是调用RequestTemplate.appendHeader()
方法
private RequestTemplate appendHeader(String name, Iterable<String> values) {
if (!values.iterator().hasNext()) {
/* empty value, clear the existing values */
this.headers.remove(name);
return this;
}
this.headers.compute(name, (headerName, headerTemplate) -> {
if (headerTemplate == null) {
return HeaderTemplate.create(headerName, values);
} else {
return HeaderTemplate.append(headerTemplate, values);
}
});
return this;
}
HeaderTemplate.create()
调用new HeaderTemplate(template.toString(), name, values, Util.UTF_8)
生成HeaderTemplate
对象,
private HeaderTemplate(String template, String name, Iterable<String> values, Charset charset) {
super(template, ExpansionOptions.REQUIRED, EncodingOptions.NOT_REQUIRED, false, charset);
this.values = StreamSupport.stream(values.spliterator(), false)
.filter(Util::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
this.name = name;
}
HeaderTemplate
构造方法中会先调用父类Template
的构造函数
Template(
String value, ExpansionOptions allowUnresolved, EncodingOptions encode, boolean encodeSlash,
Charset charset) {
if (value == null) {
throw new IllegalArgumentException("template is required.");
}
this.template = value;
this.allowUnresolved = ExpansionOptions.ALLOW_UNRESOLVED == allowUnresolved;
this.encode = encode;
this.encodeSlash = encodeSlash;
this.charset = charset;
this.parseTemplate();
}
在Template()
方法中,会调用this.parseTemplate();
方法,该方法调用parseFragment()
方法:
private void parseFragment(String fragment) {
ChunkTokenizer tokenizer = new ChunkTokenizer(fragment);
while (tokenizer.hasNext()) {
/* check to see if we have an expression or a literal */
String chunk = tokenizer.next();
if (chunk.startsWith("{")) {
// 如果参数值(aaa=bbb,aaa是参数名,bbb是参数值)以 { 开始,则认为这是参数值是一个表达式,
// 则需要把参数值单独记录在templateChunks变量中
Expression expression = Expressions.create(chunk);
if (expression == null) {
this.templateChunks.add(Literal.create(this.encodeLiteral(chunk)));
} else {
this.templateChunks.add(expression);
}
} else {
this.templateChunks.add(Literal.create(this.encodeLiteral(chunk)));
}
}
}
从上面的代码中,可以看到,在形成HeaderTemplate
的时候,如果发现参数值是以左大括号{开头的,则认为改参数值是一个表达式,则会把该参数值转换成一个表达式对象Expression
保存在templateChunks
列表中。
expression
feign发起http调用是在SynchronousMethodHandler.invoke()
方法中
@Override
public Object invoke(Object[] argv) throws Throwable {
// 构造RequestTemplate
RequestTemplate template = buildTemplateFromArgs.create(argv);
Options options = findOptions(argv);
Retryer retryer = this.retryer.clone();
while (true) {
try {
return executeAndDecode(template, options);
} catch (RetryableException e) {
// 省略代码
}
}
}
invoke()
中首先会构造RequestTemplate.Factory
工厂类构造RequestTemplate
对象,最终会调用到RequestTemplate.resolve()
方法:
/**
* Resolve all expressions using the variable value substitutions provided. Variable values will
* be pct-encoded, if they are not already.
*
* @param variables containing the variable values to use when resolving expressions.
* @return a new Request Template with all of the variables resolved.
*/
public RequestTemplate resolve(Map<String, ?> variables) {
// ... 省略代码
/* headers */
if (!this.headers.isEmpty()) {
// 先清空目标RequestTemplate对象的header映射
resolved.headers(Collections.emptyMap());
// 依次遍历所有的headers,调用HeaderTemplate.expand()方法
// 如果header被认为是表达式,则会执行具体的表达式
for (HeaderTemplate headerTemplate : this.headers.values()) {
/* resolve the header */
// 解析header,如果是表达式对象,且表达式的值存在的话,则会替换表达式对象
// variables是定义FeignClient通过RequestParam注解注释的变量
String header = headerTemplate.expand(variables);
if (!header.isEmpty()) {
// 如果最终数据不为空,则会把该header放到目标RequestTemplate对象的headers映射表中
String headerValues = header.substring(header.indexOf(" ") + 1);
if (!headerValues.isEmpty()) {
resolved.header(headerTemplate.getName(), headerValues);
}
}
}
}
if (this.bodyTemplate != null) {
resolved.body(this.bodyTemplate.expand(variables));
}
/* mark the new template resolved */
resolved.resolved = true;
return resolved;
}
public String expand(Map<String, ?> variables) {
if (variables == null) {
throw new IllegalArgumentException("variable map is required.");
}
/* resolve all expressions within the template */
StringBuilder resolved = null;
for (TemplateChunk chunk : this.templateChunks) {
String expanded;
if (chunk instanceof Expression) {
// 如果是Expression对象,则调用resolveExpression()方法解析
expanded = this.resolveExpression((Expression) chunk, variables);
}
// ... 其他省略代码
return resolved.toString();
}
protected String resolveExpression(
Expression expression,
Map<String, ?> variables) {
String resolved = null;
Object value = variables.get(expression.getName());
// 从variables中key为找到参数值中大括号包围的值对应的value
if (value != null) {
String expanded = expression.expand(
value, this.encode.isEncodingRequired());
if (expanded != null) {
if (!this.encodeSlash) {
logger.fine("Explicit slash decoding specified, decoding all slashes in uri");
expanded = expanded.replaceAll("%2F", "/");
}
resolved = expanded;
}
} else {
if (this.allowUnresolved) {
/* unresolved variables are treated as literals */
resolved = encodeLiteral(expression.toString());
}
}
return resolved;
}
从注释中可以看出,resolve()
方法主要就是用具体的数据来替换表达式占位符。该方法中,会依次遍历当前的header对象,调用HeaderTemplate.expand()
方法来完成表达式的替换(使用定义FeignClient时使用@RequestParam
等注解注释的变量,本例中是courseId),如果替换之后数据不为空的话,则会把对应的header设置到目标RequestTemplate
对象的headers映射上去。
总结一下:feign client定义的参数值(aaa=bbb,aaa是参数名,bbb是参数值)中如果包含了大括号,则feign认为参数名对应的值为表达值,在运行期间会找到以参数值为名字的变量的值来替换,如果没有找到的话则忽略该参数。
所以在本例中,参数x-l-req-header={"deviceType":1}
中的参数值{"deviceType":1}
包含了大括号,所以参数x-l-req-header
具体的值被认为是表达值,在运行期间需要动态替换,但是本例中只设置了courseId
这个动态参数,没有设置名为"deviceType":1
的动态参数,所以直接忽略了x-l-req-header
这个参数,导致发送到拉勾教育服务器的请求中没有了x-l-req-header
这个header。
解法
根据上面的分析,可以把x-l-req-header
的值设置为动态参数,具体做法如下:
@FeignClient(name = "lagou-service",url = "拉勾教育服务器地址")
public interface LagouCall {
String COOKIE = "cookie=抓包获取自己的cookie";
String AUTH = "authorization=转包获取自己的authorization";
String XLREQHEADER = "x-l-req-header={xlReqHeader}";
@GetMapping(value = "拉勾教育的api", headers = {
COOKIE, AUTH,
XLREQHEADER
})
CourseCatalogResponse getCourseCatalog(@RequestParam("courseId") int courseId, @RequestParam("xlReqHeader") String xlReqHeader);
}
@RestController
@RequestMapping("/api/negols/lagou/edu")
@Slf4j
public class LagouController {
@Resource
private LagouCall lagouCall;
@GetMapping("/test")
public Object test(@RequestParam("courseId") int courseId) {
return lagouCall.getCourseCatalog(courseId, "{\"deviceType\":1}");
}
}





