前言
在当今数字化时代,Java 在接口调用中广泛应用,但在需要二次验签(如百度地图 SN 签名认证)的场景中,请求参数的顺序对签名生成至关重要,参数乱序会导致客户端与服务端签名不一致,引发“APP SN 校验失败”(如状态码 211),从而中断开发进程并可能带来安全风险。
下文中,我将以百度 SN 签名认证为例,深入探讨在 Java 调用 UniHttp 接口时,如何解决请求参数乱序问题。
在解决该问题时,我向 UniHttp 项目提出了 Issue,并得到了项目作者即时详尽的指导。同时也在这里向大家分享我与项目开源作者交流以及参与开源项目讨论的启发。
1|场景重现
我将下列三方面出发,讲解在使用 UniHttp 调用第三方接口时,遇到SN验签认证失败错误的解决措施
1.1|UniHttp 模式下 SN 接口定义
首先按照官方文档说明,在 UniHttp 中进行百度搜索接口的调用。按照 UniHttp 的处理请求规范,定义第一版的请求接口,接口定义如下:
package com.yelang.project.thridinterface;
import com.burukeyou.uniapi.http.annotation.HttpApi;
import com.burukeyou.uniapi.http.annotation.param.QueryPar;
import com.burukeyou.uniapi.http.annotation.request.GetHttpInterface;
import com.burukeyou.uniapi.http.core.response.HttpResponse;
@HttpApi
public interface BaiduGeoSearchWithSnService {
/**
* - 百度行政区划区域检索接口
* @param query 检索关键字。行政区划区域检索不支持多关键字检索。如果需要按POI分类进行检索,请将分类通过query参数进行设置,如query=美食
* @param region 检索行政区划区域(增加区域内数据召回权重,如需严格限制召回数据在区域内,请搭配使用city_limit参数),可输入行政区划名或对应
* @param output 输出格式为json或者xml
* @param scope 检索结果详细程度。取值为1 或空,则返回基本信息;取值为2,返回检索POI详细信息
* @param ret_coordtype 返回的坐标类型,可选参数,添加后POI返回国测局经纬度坐标
* @param pageSize 单次召回POI数量,默认为10条记录,最大返回20条
* @param pageNum 分页页码,默认为0,0代表第一页
* @param ak 开发者的访问密钥,必填项
* @param sn 开发者的权限签名
* @return
*/
@GetHttpInterface(url="https://api.map.baidu.com/place/v2/search")
public HttpResponse<String> getSearch(@QueryPar("query") String query, @QueryPar("region") String region,
@QueryPar("output") String output, @QueryPar("scope") String scope,
@QueryPar("ret_coordtype") String ret_coordtype, @QueryPar("page_size") int pageSize,
@QueryPar("page_num") int pageNum, @QueryPar("ak") String ak, @QueryPar("sn") String sn);
}
与普通的接口请求一样,这里按照官方文档要求,这里只增加一个 SN 的签名参数。在调用的时候动态传入 SN 参数实现接口的调用。
1.2|第一次正式调用
接下来还是常规操作,在使用 Junit 进行接口测试,使用 UniHttp 代理服务访问之后,接口的调用就比较简单了。直接在测试类中注入 service 实例后就可以进行服务的调用,关键代码如下:
package com.yelang.project.unihttp;
import java.io.UnsupportedEncodingException;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import com.burukeyou.uniapi.http.core.response.HttpResponse;
import com.yelang.project.thridinterface.BaiduGeoSearchSnProcessorService;
import com.yelang.project.thridinterface.BaiduGeoSearchWithSnService;
import com.yelang.project.thridinterface.signature.BaiduSignature;
/**
* - 百度检索2.0接口带SN访问服务示例
* @author 夜郎king
*
*/
@SpringBootTest
@RunWith(SpringRunner.class)
public class BaiduGeoSearchWithSnServiceCase {
/**
* - 应用申请的AK值
*/
private static final String AK_VALUE = "yourak";
/**
* - 应用申请的SK值
*/
private static final String SK_VALUE = "yoursk";
@Autowired
private BaiduGeoSearchWithSnService bdGeoSearchWithSnService;
@Test
public void searchBySn() throws UnsupportedEncodingException {
/**
* -计算sn跟参数对出现顺序有关,get请求请使用LinkedHashMap保存<key,value>,该方法根据key的插入顺序排序;
* -post请使用TreeMap保存<key,value>,该方法会自动将key按照字母a-z顺序排序。
* -所以get请求可自定义参数顺序(sn参数必须在最后)发送请求,但是post请求必须按照字母a-z顺序填充body(sn参数必须在最后)。
* -以get请求为例:https://api.map.baidu.com/geocoder/v2/?address=百度大厦&output=json&ak=yourak,paramsMap中先放入address,
* -再放output,然后放ak,放入顺序必须跟get请求中对应参数的出现顺序保持一致。
*/
Map<String, String> paramsMap = new LinkedHashMap<String, String>();
String api_prefix = "/place/v2/search?";
//按接口定义顺序来创建参数
String query = "湘菜";
String region = "158";// 158表示长沙市
String output = "json";
String scope = "2";
String ret_coordtype = "WGS84";
int pageSize = 20;
int pageNum = 0;
paramsMap.put("query", query);
paramsMap.put("region", region);
paramsMap.put("output", output);
paramsMap.put("scope", scope);
paramsMap.put("ret_coordtype", ret_coordtype);
paramsMap.put("page_size", String.valueOf(pageSize));
paramsMap.put("page_num", String.valueOf(pageNum));
paramsMap.put("ak", AK_VALUE);
// 调用签名工具生成SN
BaiduSignature signature = new BaiduSignature(AK_VALUE, SK_VALUE,paramsMap,api_prefix);
String sn = signature.getSnByMap();
System.out.println("sn==>" + sn);
HttpResponse<String> result = bdGeoSearchWithSnService.getSearch(query, region, output, scope,
ret_coordtype, pageSize, pageNum ,AK_VALUE,sn);
System.out.println("第一页的数据如下:");
System.out.println(result);
System.out.println(result.getBodyResult());
System.out.println("--------------------------------------------------------------");
}
}
在这里请注意,为确保参数顺序与接口定义一致,应使用 LinkedHashMap 存储参数(而非普通 HashMap),使 SN 能按定义顺序生成。完成此设置后,即可运行测试验证接口连通性。
1.3|第一次遇到 211 APP SN 校验失败
将参数传入到 UniHttp 中进行携带访问,运行之后,在控制台中看到以下结果
这是我第一次遇到 {"status":211,"message":"APP SN校验失败"} 错误。
【211】APP SN校验失败:SERVER类型APP有两种校验方式IP校验和SN校验,当用户请求的SN和服务端计算出来的SN不相等的时候提示SN校验失败
出现 SN 校验失败的原因是服务端的 SN 值和客户端计算的值不一样。
导致不一样的原因有很多种,如参数值顺序、ak和sk的值变化等。那么使用 UniHttp 请求框架后,究竟是什么原因致使 SN 值不一样呢,下面将对此进行问题定位。
2|问题查找及开源寻求解决方案
SN 的计算生成问题究竟是哪里出了错误?既然是开源项目,就可以通过定位源码来分析,也可以通过 Debug 调试代码来辅助。
2.1|抽丝剥茧找到问题所在
基于经验,我怀疑是请求参数顺序导致 SN 校验失败。为验证此猜想,使用 Eclipse 的 Debug 模式进行追踪。请求参数可以在 HttpResponse 对象中的值中进行获取,在 UniHttpResponse 中可以看到:
在 HttpUrl 对象中可以看到请求的地址和请求的参数,进一步打开这个 HttpUrl 对象的值:
HttpUrl(
url=https://api.map.baidu.com/place/v2/search?,
path=,
anchor=null,
queryParam={output=json, query=湘菜, scope=2, page_num=0, ak=yourak, s
n=d1abf338a49bd82fca6992f5d2b9f686,
region=158, ret_coordtype=WGS84,
page_size=20},
pathParam={}
)
上面参数中 QueryParam 与我在接口中定义的顺序不太一样,为了方便对比,我整理成了以下图片表格:
可以看到在请求发送过程中,经过 UniHttp 的处理,参数的顺序发生了变化。
因此可以得出结论:在请求过程中,参数顺序发生变化,服务端根据顺序变化后的参数去加密计算得到的 SN,与我自己计算的一定是不一样的,所以就会发生SN验证失败的问题。基本定位问题所在后,我带着疑问和 UniHttp 项目的作者聊了聊。
2.2|与作者在 Issues 的交流
我在 Issue 中描述了个人的疑问,并咨询作者是否可以设置顺序,作者的回复非常迅速,他给出了详尽的参考文档,告诉了我实现 HttpApiProcessor 并重写 postBeforeHttpRequest的方法(详见下文 3.2)
2.3|开源项目交流有感
UniHttp 是一款优秀的服务接口接入框架,也赢得了大量开发者的青睐。当我遇到请求参数乱序的问题时,我非常快地找到了开源地址并留言说明了详细情况。
众所周知,开源项目大多是开发者利用业余时间维护的,通常不太可能即时响应社区反馈。但这次却出乎我的意料—— UniHttp 的作者不仅迅速回应了我,还精准地指出了解决方案和实现路径。这是我目前遇到响应最快的开源项目之一,上一次有类似体验的还是 kkfileview。开源不易,希望都能像我一样飞快地得到解决方法,也能贡献更多的好项目,为开源生态贡献自己的力量。
3|朝着正确的方向解决问题
言归正传,在开源作者的指导下,我又回到问题的本身,那么又该如何来解决问题呢?这里我从简单到复杂,从固化到灵活,列出三种不同的解决办法。如果还有更好的方法,也欢迎各位专家、博主在评论区指出。
3.1|加密顺序按实际请求参数求解
首先分享第一种比较简单的方法,已经知道导致验证失败的原因就是参数不一致的原因。在 UniHttp 中,使用以下方式定义的接口:
@GetHttpInterface(url="https://api.map.baidu.com/place/v2/search")
public HttpResponse<String> getSearch(@QueryPar("query") String query, @QueryPar("region") String region,
@QueryPar("output") String output, @QueryPar("scope") String scope,
@QueryPar("ret_coordtype") String ret_coordtype, @QueryPar("page_size") int pageSize,
@QueryPar("page_num") int pageNum, @QueryPar("ak") String ak, @QueryPar("sn") String sn);
通常定义完参数之后,就可以基本预估其请求参数的顺序了。因此第一种方式比较简单,只需要在客户端加密的时候,把参数顺序按照服务端的加密顺序进行处理就可以。服务端的加密顺序可以使用上一节中的方式,在 Debug 时进行跟踪。根据请求的参数顺序做如下调整:
@Test
public void searchBySn() throws UnsupportedEncodingException {
Map<String, String> paramsMap = new LinkedHashMap<String, String>();
String api_prefix = "/place/v2/search?";
/*
* - 按请求顺序来加密
*/
String output = "json";
String query = "湘菜";
String scope = "2";
int pageNum = 0;
String region = "158";// 158表示长沙市
String ret_coordtype = "WGS84";
int pageSize = 20;
paramsMap.put("output", output);
paramsMap.put("query", query);
paramsMap.put("scope", scope);
paramsMap.put("page_num", String.valueOf(pageNum));
paramsMap.put("ak", AK_VALUE);
paramsMap.put("region", region);
paramsMap.put("ret_coordtype", ret_coordtype);
paramsMap.put("page_size", String.valueOf(pageSize));
// 调用签名工具生成SN
BaiduSignature signature = new BaiduSignature(AK_VALUE, SK_VALUE,paramsMap,api_prefix);
String sn = signature.getSnByMap();
System.out.println("sn==>" + sn);
HttpResponse<String> result = bdGeoSearchWithSnService.getSearch(query, region, output, scope,
ret_coordtype, pageSize, pageNum ,AK_VALUE,sn);
System.out.println("第一页的数据如下:");
System.out.println(result);
System.out.println(result.getBodyResult());
System.out.println("--------------------------------------------------------------");
}
再次运行程序发现成功获取了数据:
上面这种方式虽然可以解决问题,但是每次都需要进行调整参数,使用起来比较麻烦,下面我将介绍作者推荐的方式——使用请求前置处理器来进行参数的重新设置。
3.2|一种兼容 Get 请求参数动态调整的方法
这里的改造重点是 BaiduHttpApiProcessor 中的 postBeforeHttpRequest 方法。此方法会在实际发送 Http 请求前被调用,允许我们对请求体进行二次处理。
因此首先拼接请求参数的方法。在这里需要完全重写请求 URL,然后根据 QueryParams 重新拼接请求字符串,从 QueryParams 中取出请求集合,然后根据 Map 设置 URL 的值,核心方法如下:
/**
*
* @param snMapParams
* @param queryParam
* @return
*/
protected Map<String, Object> reorderingQueryParamMap(String snMapParams,Map<String, Object> queryParam) {
Map<String, Object> paramsMap = null;
if(StringUtils.isNotEmpty(snMapParams)) {
paramsMap = new LinkedHashMap<String, Object>();
//将ak和sn分别放到paramsMap中
paramsMap.put("region", queryParam.get("region"));
//其它参数
//将ak和sn分别放到paramsMap中
paramsMap.put("ak", queryParam.get("ak"));
paramsMap.put("sn", queryParam.get("sn"));
}else {
paramsMap = queryParam;
}
return paramsMap;
}
在请求之前手动修改 URL 地址,关键方法如下:
/** * -实现-postBeforeHttpMetadata: 发送Http请求之前会回调该方法,可对Http请求体的内容进行二次处理
*
* @param uniHttpRequest 原来的请求体
* @param methodInvocation 被代理的方法
* @return 新的请求体
*/
@Override
public UniHttpRequest postBeforeHttpRequest(UniHttpRequest uniHttpRequest,
HttpApiMethodInvocation<BaiduHttpApi> methodInvocation) {
/**
* -在查询参数中添加提供的appId字段
*/
// 获取BaiduHttpApi注解
BaiduHttpApi apiAnnotation = methodInvocation.getProxyApiAnnotation();
// 获取所有查询参数
Map<String, Object> queryParam = uniHttpRequest.getHttpUrl().getQueryParam();
System.out.println("未处理之前的请求参数:" + queryParam);
Map<String, Object> paramsMap = this.reorderingQueryParamMap(snMapParams, queryParam);
System.out.println("处理之后的请求参数:" + paramsMap);
// 第二种方式,直接修改请求参数
String queryString = this.reorderingQueryParam(snMapParams,queryParam);
uniHttpRequest.getHttpUrl().setQueryParam(null);
String url = uniHttpRequest.getHttpUrl().getUrl() + uniHttpRequest.getHttpUrl().getPath() + "?" + queryString;
uniHttpRequest.getHttpUrl().setUrl(url);
uniHttpRequest.getHttpUrl().setPath("");
}
return uniHttpRequest;
}
请注意,使用这种方式,一般需要将 QueryParam 参数设置为空,并且完全使用 URL 参数,不要使用path,可以直接设置为null。还有在接口定义时不要多加"?"号,否则会变成双问号,导致请求报错,比如:
@GetHttpInterface(path = "/place/v2/search?")
public HttpResponse<String> getSearch(@QueryPar("query") String query, @QueryPar("region") String region,
@QueryPar("output") String output, @QueryPar("scope") String scope,
@QueryPar("ret_coordtype") String ret_coordtype, @QueryPar("page_size") String pageSize,
@QueryPar("page_num") String pageNum, @QueryPar("ak") String ak, @QueryPar("sn") String sn);
加完之后再查看 UniHttp 发出的请求地址,就会多了一个"?"号,如下图:
将请求地址复制出来:
url=https://api.map.baidu.com/place/v2/search??query=%E6%B9%98%E8%8F%9C®ion=158&output=json&scope=2&ret_coordtype=WGS84&page_size=20&page_num=0&ak=yourak&sn=d1abf338a49bd82fca6992f5d2b9f686
由此也极易导致 SN 校验失败,请大家注意。
3.3|使用注解来设置重排序规则
有没有什么方法,是可以让自己定义请求顺序的呢?答案是肯定的。可以将参数的顺序定义到注解中,同时增加一个 SN 校验的模式开关,这样程序实现起来就更加灵活。在注解类中作如下定义:
package com.yelang.project.thridinterface.apiprocessor;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
import com.burukeyou.uniapi.http.annotation.HttpApi;
@Inherited
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@HttpApi(processor = BaiduHttpApiProcessor.class)
public @interface BaiduHttpApi {
/**
* -渠道方域名地址
*/
@AliasFor(annotation = HttpApi.class)
String url() default "${unihttp.channel.baidu.url}";
/**
* -snMapParams 模式下的请求参数顺序,用于按顺序加密,在统一处理器中获取后处理
*/
String snMapParams() default "";
/**
* - 是否使用sn签名模式
* @return
*/
boolean snMode() default false;
}
重点就是这两个参数,通过这两个参数设置 SN 模式和具体的参数,定义了注解字之后,再看在 Processor 中如何使用。简单来说就是先看SN模式,然后再解析参数设置的顺序,再读取参数放置到集合中或者拼接到字符串中。核心方法如下:
/**
*
* @param snMapParams
* @param queryParam
* @return
*/
protected Map<String, Object> reorderingQueryParamMap(String snMapParams,Map<String, Object> queryParam) {
Map<String, Object> paramsMap = null;
if(StringUtils.isNotEmpty(snMapParams)) {
paramsMap = new LinkedHashMap<String, Object>();
//从注解中读取配置的参数
String [] snMap = snMapParams.split(",");
//按照参数重新组合参数,生成按顺序的请求字符串
for(String paramKey : snMap) {
paramsMap.put(paramKey, queryParam.get(paramKey));
}
//将ak和sn分别放到paramsMap中
paramsMap.put("ak", queryParam.get("ak"));
paramsMap.put("sn", queryParam.get("sn"));
}else {
paramsMap = queryParam;
}
return paramsMap;
}
这样一来,就通过结合注解的方式实现了参数顺序的定义。在创建接口时,还应注意注解的设置,实例如下,通过设置参数就设置了运行模式:
@BaiduHttpApi(snMode = true, snMapParams = "query,region,output,scope,ret_coordtype,page_size,page_num")
public interface BaiduGeoSearchSnProcessorService {
}
3.4|重写请求的QueryParam重排序方法
这里结合前面的注解方式对请求参数的重排序进行处理,这种方式适应性更加强。与 get 方式不同的是,使用设置 QueryParam 的方式,程序的通用性和方法的兼容性会更加好。重新设置 QueryParam 的方法核心代码如下:
/** * -实现-postBeforeHttpMetadata: 发送Http请求之前会回调该方法,可对Http请求体的内容进行二次处理
*
* @param uniHttpRequest 原来的请求体
* @param methodInvocation 被代理的方法
* @return 新的请求体
*/
@Override
public UniHttpRequest postBeforeHttpRequest(UniHttpRequest uniHttpRequest,
HttpApiMethodInvocation<BaiduHttpApi> methodInvocation) {
// 获取BaiduHttpApi注解
BaiduHttpApi apiAnnotation = methodInvocation.getProxyApiAnnotation();
String snMapParams = apiAnnotation.snMapParams();
boolean snMode = apiAnnotation.snMode();
//如果开启sn签名,则将请求参数进行充排序后进行签名,反之不用处理
if(snMode) {
System.out.println("开启SN签名处理");
// 获取所有查询参数
Map<String, Object> queryParam = uniHttpRequest.getHttpUrl().getQueryParam();
Map<String, Object> paramsMap = this.reorderingQueryParamMap(snMapParams, queryParam);
System.out.println("处理之后的请求参数:" + paramsMap);
//第一种请求参数重排序
uniHttpRequest.getHttpUrl().setQueryParam(paramsMap);
}
return uniHttpRequest;
}
经过这样的改造和设计,我们就完美的解决了在 UniHttp 中请求参数顺序不可控的问题。需要注意的是,参数的加密顺序和请求顺序一致即可。成功请求界面如下:
3.5|三种方法的使用场景及对比
以上这三种方法都能满足在 UniHttp 中实现请求参数动态调整的需求,以下是三种办法从代码通用性到改造复杂度的对比:
三种思路都能很好地满足业务需要,但是具体采用哪种方式,大家可以根据自己的场景自主选择。没有最好的架构,只有更好的架构,架构是慢慢演化而来,而不是一蹴而就,良好的架构值得好好学习。
4|总结
为了解决 Java 调用 UniHttp 接口时请求参数乱序,导致百度SN签名认证失败的问题,我在这篇文章中按照问题重现、查找原因、问题突破的顺序对相关内容进行了讲解,希望大家都能掌握着三种不同的处理方式。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。
