对接外部的一个接口时,发现一个鬼畜的问题,一直提示缺乏某个参数,一样的url,经过curl命令访问ok,可是改为RestTemplate请求就不行;由于提供接口的是外部的,因此也没法从服务端着手定位问题,特此记录下这个问题的定位以及解决过程java
首先咱们是经过get请求访问服务端,参数直接拼接在url中;与咱们常规的get请求有点不同的是其中一个参数要求url编码以后传过去。git
由于不知道服务端的实现,因此再过后定位到这个问题以后,反推了一个服务端可能实现方式github
模拟一个接口,要求必须传入accessKey,且这个参数必须和咱们定义的同样(模拟身份标志,用户请求必须带上本身的accessKey, 且必须合法)web
@RestController
public class HelloRest {
public final String ALLOW_KEY = "ASHJRK3LJFD+R32SADFLK+FASDJ=";
@GetMapping(path = "access")
public String access(String accessKey, String name) {
System.out.println(accessKey + "|" + name) ;
if (ALLOW_KEY.equals(accessKey)) {
return "true";
} else {
return "false";
}
}
}
复制代码
这个接口只支持get请求,把参数放在url中的时候,很明显这个accessKey须要编码spring
在拼接访问url时,首先对accessKey进行编码,获得一个访问的链接 http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui
后端
下面看下浏览器 + curl + restTemplate三种访问姿式的返回结果浏览器
浏览器访问结果:app
curl访问结果:curl
restTemplate访问结果:ide
@Test
public void testUrlEncode() {
String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";
RestTemplate restTemplate = new RestTemplate();
String ans = restTemplate.getForObject(url, String.class);
System.out.println(ans);
}
复制代码
看到上面的输出,结果就颇有意思了,一样的url为啥前面的访问没啥问题,换到RestTemplate就不对了???
若是服务端的代码也在咱们的掌控中,能够经过debug服务端,查看请求参数来定位问题;可是这个问题出现时,服务端不在掌握中,这个时候就只能从客户端出发,来推测可能出现问题的缘由了;
接下来记录下咱们定位这个问题的"盲人摸象"过程
很容易怀疑问题出在url编码后的参数上,直接传这种编码后的url参数会不会解析有问题,既然编码以后不行,那就改为不编码试一试
@Test
public void testUrlEncode() {
String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";
RestTemplate restTemplate = new RestTemplate();
String ans = restTemplate.getForObject(url, String.class);
System.out.println(ans);
url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD+R32SADFLK+FASDJ=&name=yihuihui";
ans = restTemplate.getForObject(url, String.class);
System.out.println(ans);
}
复制代码
毫无疑问,访问依然失败,模拟case以下
传编码后的不行,传编码以前的也不行,这就蛋疼了;接下来怎么办?换个http包试一试
接下来改用HttpClient访问,看下能不能正常访问
@Test
public void testUrlEncode() throws IOException {
String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";
RestTemplate restTemplate = new RestTemplate();
String ans = restTemplate.getForObject(url, String.class);
System.out.println(ans);
//建立httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
//建立请求方法的实例, 并指定请求url
HttpGet httpget = new HttpGet(url);
//获取http响应状态码
CloseableHttpResponse response = httpClient.execute(httpget);
HttpEntity entity = response.getEntity();
//接收响应头
String content = EntityUtils.toString(entity, "utf-8");
System.out.println(httpget.getURI());
System.out.println(content);
httpClient.close();
}
复制代码
输出结果以下,神器的一幕出现了,返回结果正常了
到了这一步,基本上能够知道是RestTemplate的使用问题了,要么就是操做姿式不对,要么就是RestTemplate有什么潜规则是咱们不知道的
一样的url,两种不一样的包返回结果不同,天然而然的就会想到对比下两个的实现方式了,看看哪里不一样;若是对两个包的源码不太熟悉的话,想一会儿定位都问题,并不容易,对这两个源码,我也是不熟的,不过由于巧和,没有深刻到底层的实现就发现了疑是问题的关键点所在
首先看的RestTemplate的发起请求的逻辑,以下(下图中有关键点,单独看不太容易抓到)
接下来再去debug HttpClient的请求链路中,在建立HttpGet
对象时,看到下面这一行代码
单独看上面两个,好像发现不了什么问题;可是两个对比着看,就发现一个有意思的地方了,在HttpTemplate
的execute
方法中,建立URI竟然不是咱们熟知的 URI.create()
,接下来就来验证下是否是这里的问题了;
测试方法也比较简单,直接传入URI对象参数,看可否访问成功
@Test
public void testUrlEncode() throws IOException {
String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";
RestTemplate restTemplate = new RestTemplate();
String ans = restTemplate.getForObject(url, String.class);
System.out.println(ans);
ans = restTemplate.getForObject(URI.create(url), String.class);
System.out.println(ans);
}
复制代码
从截图也能够看出,返回true表示成功了,所以咱们能够圈定问题的范围,就在RestTemplate中url参数的构建上了
前面定位到了出问题的环节,在RestTemplate建立URI对象的地方,接下来咱们深刻源码,看一下这段逻辑的神奇之处
经过单步执行,下面截取关键链路的代码,下面圈出的就是定位最终实现uri建立的具体对象org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder
接下来重点放在具体实现方法中
// org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder#build(java.lang.Object...)
@Override
public URI build(Map<String, ?> uriVars) {
if (!defaultUriVariables.isEmpty()) {
Map<String, Object> map = new HashMap<>();
map.putAll(defaultUriVariables);
map.putAll(uriVars);
uriVars = map;
}
if (encodingMode.equals(EncodingMode.VALUES_ONLY)) {
uriVars = UriUtils.encodeUriVariables(uriVars);
}
UriComponents uriComponents = this.uriComponentsBuilder.build().expand(uriVars);
if (encodingMode.equals(EncodingMode.URI_COMPONENT)) {
uriComponents = uriComponents.encode();
}
return URI.create(uriComponents.toString());
}
@Override
public URI build(Object... uriVars) {
if (ObjectUtils.isEmpty(uriVars) && !defaultUriVariables.isEmpty()) {
return build(Collections.emptyMap());
}
if (encodingMode.equals(EncodingMode.VALUES_ONLY)) {
uriVars = UriUtils.encodeUriVariables(uriVars);
}
UriComponents uriComponents = this.uriComponentsBuilder.build().expand(uriVars);
if (encodingMode.equals(EncodingMode.URI_COMPONENT)) {
uriComponents = uriComponents.encode();
}
return URI.create(uriComponents.toString());
}
复制代码
两个builder方法提供关键URI生成逻辑,根据最后的返回能够知道,生成URI依然是使用URI.create
,因此出问题的地方就应该是 uriComponents.encode()
实现url编码的地方了,对应的代码以下
// org.springframework.web.util.HierarchicalUriComponents#encode
@Override
public HierarchicalUriComponents encode(Charset charset) {
if (this.encoded) {
return this;
}
String scheme = getScheme();
String fragment = getFragment();
String schemeTo = (scheme != null ? encodeUriComponent(scheme, charset, Type.SCHEME) : null);
String fragmentTo = (fragment != null ? encodeUriComponent(fragment, charset, Type.FRAGMENT) : null);
String userInfoTo = (this.userInfo != null ? encodeUriComponent(this.userInfo, charset, Type.USER_INFO) : null);
String hostTo = (this.host != null ? encodeUriComponent(this.host, charset, getHostType()) : null);
PathComponent pathTo = this.path.encode(charset);
MultiValueMap<String, String> paramsTo = encodeQueryParams(charset);
return new HierarchicalUriComponents(
schemeTo, fragmentTo, userInfoTo, hostTo, this.port, pathTo, paramsTo, true, false);
}
// org.springframework.web.util.HierarchicalUriComponents#encodeQueryParams
private MultiValueMap<String, String> encodeQueryParams(Charset charset) {
int size = this.queryParams.size();
MultiValueMap<String, String> result = new LinkedMultiValueMap<>(size);
this.queryParams.forEach((key, values) -> {
String name = encodeUriComponent(key, charset, Type.QUERY_PARAM);
List<String> encodedValues = new ArrayList<>(values.size());
for (String value : values) {
encodedValues.add(encodeUriComponent(value, charset, Type.QUERY_PARAM));
}
result.put(name, encodedValues);
});
return result;
}
复制代码
记录下参数编码的先后对比,编码前参数为 ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D
编码以后,参数变为ASHJRK3LJFD%252BR32SADFLK%252BFASDJ%253D
对比下上面的区别,发现这个参数编码,会将请求参数中的 %
编码为 %25
, 因此问题就清楚了,我传进来原本就已是编码以后的了,结果再编码一次,至关于修改了请求参数了
看到这里,天然而然就有一个想法,既然你会给个人参数进行编码,那么为啥我传入的非编码的参数也不行呢?
接下来咱们改一下请求的url参数,再执行一下上面的过程,看下编码以后的参数长啥样
从上图很明显能够看出,现编码以后的和咱们URLEncode的结果不同,加号没有被编码, 咱们调用jdk的url解码,发现将上面编码后的内容解码出来,+号没了
因此问题的缘由也找到了,RestTemplate中首先url编码解码的逻辑和URLEncode/URLDecode
不一致致使的
最后一步,就是看下具体的url参数编码的实现方法了,下面贴出源码,并在关键地方给出说明
// org.springframework.web.util.HierarchicalUriComponents#encodeUriComponent(java.lang.String, java.nio.charset.Charset, org.springframework.web.util.HierarchicalUriComponents.Type)
static String encodeUriComponent(String source, Charset charset, Type type) {
if (!StringUtils.hasLength(source)) {
return source;
}
Assert.notNull(charset, "Charset must not be null");
Assert.notNull(type, "Type must not be null");
byte[] bytes = source.getBytes(charset);
ByteArrayOutputStream bos = new ByteArrayOutputStream(bytes.length);
boolean changed = false;
for (byte b : bytes) {
if (b < 0) {
b += 256;
}
// 注意这一行,咱们的type实际上为 org.springframework.web.util.HierarchicalUriComponents.Type#QUERY_PARAM
if (type.isAllowed(b)) {
bos.write(b);
}
else {
bos.write('%');
char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16));
char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16));
bos.write(hex1);
bos.write(hex2);
changed = true;
}
}
return (changed ? new String(bos.toByteArray(), charset) : source);
}
复制代码
if/else 这一段逻辑须要捞出来好好看一下,这里决定了什么字符会进行编码;其中 type.isAllowed
对应的代码为
// org.springframework.web.util.HierarchicalUriComponents.Type#QUERY_PARAM
QUERY_PARAM {
@Override
public boolean isAllowed(int c) {
if ('=' == c || '&' == c) {
return false;
}
else {
return isPchar(c) || '/' == c || '?' == c;
}
}
},
// isPchar 对应的相关代码为
/** * Indicates whether the given character is in the {@code pchar} set. * @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a> */
protected boolean isPchar(int c) {
return (isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c);
}
/** * Indicates whether the given character is in the {@code unreserved} set. * @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a> */
protected boolean isUnreserved(int c) {
return (isAlpha(c) || isDigit(c) || '-' == c || '.' == c || '_' == c || '~' == c);
}
/** * Indicates whether the given character is in the {@code sub-delims} set. * @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a> */
protected boolean isSubDelimiter(int c) {
return ('!' == c || '$' == c || '&' == c || '\'' == c || '(' == c || ')' == c || '*' == c || '+' == c ||
',' == c || ';' == c || '=' == c);
}
/** * Indicates whether the given character is in the {@code ALPHA} set. * @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a> */
protected boolean isAlpha(int c) {
return (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z');
}
/** * Indicates whether the given character is in the {@code DIGIT} set. * @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a> */
protected boolean isDigit(int c) {
return (c >= '0' && c <= '9');
}
复制代码
上面涉及的方法挺多,小结一下须要转码的字符为: =
, &
下图是维基百科中关于url参数编码的说明,好比上例中的+号,按照维基百科的须要转码;可是在Spring中倒是不须要转码的
因此为啥Spring要这么干呢?网上搜索了一下,发现有人也遇到过这个问题,并提给了Spring的官方,对应连接为
官方人员的解释以下
根据 RFC 3986 加号等符号的确实能够出如今参数中的,并且不须要编码,有问题的在于服务端的解析没有与时俱进
最后复盘一下这个问题,当使用RestTemplate
发起请求时,若是请求参数中有须要url编码时,不但愿出现问题的使用姿式应传入URI对象而不是字符串,以下面两种方式
@Override
@Nullable
public <T> T execute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
return doExecute(url, method, requestCallback, responseExtractor);
}
@Override
@Nullable
public <T> T getForObject(URI url, Class<T> responseType) throws RestClientException {
RequestCallback requestCallback = acceptHeaderRequestCallback(responseType);
HttpMessageConverterExtractor<T> responseExtractor =
new HttpMessageConverterExtractor<>(responseType, getMessageConverters(), logger);
return execute(url, HttpMethod.GET, requestCallback, responseExtractor);
}
复制代码
注意Spring的url参数编码,默认只会针对 =
和 &
进行处理;为了兼容咱们通常的后端的url编解码处理在须要编码参数时,目前尽可能不要使用Spring默认的方式,否则接收到数据会和预期的不一致
一灰灰blog