本blog文章为ShiShouFeng原创文章,如需转载引用请注明出处,谢谢
https://blog.csdn.net/ForwardSailing/article/details/106449790
Token设计
一般软件设计为保证安全服务器端引入Token令牌校验,客户端在发起网络请求时,需要在请求头中携带 Token 令牌信息,服务端对 Token 令牌进行校验,校验 Token合法且 Token超过有效期 服务端会返回相应数据,如果不合法 或 Token失效情况下会 HTTP Status 返回 401 或 接口返回信息中 code 相应错误码 (我们以返回 -10001为例)
为提高用户体验,避免因 AccessToken 失效而导致用户重新登录,所以客户端需要在 AccessToken 失效后 使用 Refresh Token 请求刷新Token接口,改接口会重新返回一个 AccessToken 、RefreshToken 等信息。
注意:客户端一般情况需要把接口返回信息保存在本地
现在考虑有如下业务场景:
用户想要获取 商品信息(/get/shop/details.v1.0) 但是接口返回Token失效,此时需要用刷新Token (/refresh/token.v1.0) 接口获取新的AccessToken,之后将新的Token添加到 获取商品信息接口中,之后再重新请求接口。
在上面的业务场景中,如果使用OKhttp 的 Interceptor 拦截器实现就比较简单了,下面看一下具体该怎么做。
Interceptor
是OKhttp 中 利用责任链设计模式设计的、链式请求和返回结果的链式调用,Interceptor
分为两种责任链,分别是:
这两者本身实现上并没有差别,唯一的区别是 工作的层次不同,Application 级 Interceptor 会首先执行,返回结果后最后执行,而 Network 级 Interceptor 是工作在 发起网络请求前执行,如下图所示:
添加 Application Interceptor 拦截器 只需要获取 OKHttpClient
对象 调用 addInterceptor
方法即可
public Builder addInterceptor(Interceptor interceptor) {
if (interceptor == null) throw new IllegalArgumentException("interceptor == null");
interceptors.add(interceptor);
return this;
}
添加 Network Interceptor 拦截器 只需要获取 OKHttpClient
对象 调用 addNetworkInterceptor
方法即可
public Builder addNetworkInterceptor(Interceptor interceptor) {
if (interceptor == null) throw new IllegalArgumentException("interceptor == null");
networkInterceptors.add(interceptor);
return this;
}
上面我们知道了两种拦截器添加方式,那么添加的拦截器是什么时候执行的呢?添加的拦截器最终会调用到 RealCall
类中的 getResponseWithInterceptorChain()
如下:
①处:Application 级别 Interceptor 在每次执行网络请求时 就添加至 责任链中
②处:在网络真正发起前将 Network 级 Interceptor 添加至拦截器中
上面添加Network 级 Interceptor 受 forWebSocket 变量控制,不过在默认情况下使用 forWebSocket 这个变量都是 false
前面我们 我们知道了 Interceptor
是什么以及如何使用 Interceptor
、接下来我们就利用 Interceptor
来实现我们的业务:
我们创建TokenInterceptor
并实现intercept()
实现以下逻辑:
/**
* Created by shishoufeng on 2019/4/16.
*
* desc : token 拦截器 用于校验 token 是否失效 如果失效需要 及时更新 token
*
*/
public class TokenInterceptor implements Interceptor{
private static final String TAG = "TokenInterceptor";
private static final Charset UTF8 = Charset.forName("UTF-8");
private Context mContext;
private OkHttpClient mOkHttpClient;
private static final Object LOCK_OBJ = new Object();
public TokenInterceptor(Context context) {
this.mContext = context;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request originRequest = chain.request();
//step1: 拿到原请求结果
Response originResponse = chain.proceed(originRequest);
//原请求结果为空 直接返回结果
if (originResponse == null) {
return null;
}
// 原请求地址
String originReqUrl = originRequest.url().toString();
//step2: 请求头中不含 token 直接返回原请求结果
String originReqAccessToken = originRequest.header("Authorization");
if (StringUtil.isEmpty(originReqAccessToken)) { //请求头中不含 token 直接返回原请求结果
return originResponse;
}
//step3: 判断是否在 拦截白名单中 如果在直接返回 原请求结果
if (InterceptorConfigUtils.isWhiteUrl(originReqUrl)){
return originResponse;
}
//进行同步处理防止多线程并发场景下造成重复刷新
synchronized (LOCK_OBJ) {
String localAccessToken = LoginUtils.getAccessToken();
//step4: 如果 请求token 和 本地token不一致 直接使用 本地token 进行请求
if (!TextUtils.equals(originReqAccessToken, localAccessToken)) {
// 重新构建请求
Request newRequest = originRequest.newBuilder()
.header("Authorization", localAccessToken)
.build();
// 重新发起请求
return chain.proceed(newRequest);
}
// 拿到返回结果
ResponseBody responseBody = originResponse.body();
if (responseBody == null) { //返回结果为空 直接返回
return originResponse;
}
//step5:读取返回数据并判断token是否失效、如果失效执行刷新操作
// 设置编码 准备读取返回数据
Charset charset = UTF8;
BufferedSource bufferedSource = responseBody.source();
bufferedSource.request(Long.MAX_VALUE);
Buffer buffer = bufferedSource.buffer();
MediaType contentType = responseBody.contentType();
if (contentType != null) {
charset = contentType.charset(UTF8);
}
//编码为空 直接返回
if (charset == null) {
return originResponse;
}
// 网络请求返回数据
String bodyString = buffer.clone().readString(charset);
if (isTokenOverdue(bodyString)) { // token 已过期
//step6: 请求 token 并保存结果
boolean isRefreshTokenOk = requestTokenAndSaveData();
if (isRefreshTokenOk) { // 更新token成功
// 获取原 请求头信息
Headers originHeaders = originRequest.headers();
// 如果请求头中含有 token 请求字段 重新添加更新后的token
if (originHeaders != null && originHeaders.names().contains("Authorization")) {
// 构建新的请求头
Headers newHeaders = originHeaders.newBuilder()
.set("Authorization", LoginUtils.getAccessToken())
.build();
// 重新构建请求
Request newRequest = originRequest.newBuilder()
.headers(newHeaders)
.build();
// 重新发起请求
return chain.proceed(newRequest);
}
// 重新发起请求
return chain.proceed(originRequest);
}
}
// 没有更新 token 成功 直接返回原结果
return originResponse;
}
}
}
上述代码虽然比较长但是每一步都有详细的注释,所以理解起来不会太费劲,下面我详细说一下每一步的意思:
step1:首先要使用 Chain
调用其 proceed()
完成整个网络请求,得到Response
step2:预处理返回。对接口请求参数中不含有Token的进行跳过提高性能
step3:接口免检查白名单。对于有些接口不需要拦截和检查则可以设立一个拦截白名单,如果在直接返回 原请求结果
这一步根据自己的需求而定,不是必须的
这里我直接是一个Set集合进行过滤的:
/**
* Created by shishoufeng on 2019/6/13.
*
* desc : 拦截器配置类
*/
class InterceptorConfigUtils {
// 请求白名单
private static Set<String> whiteUrlSet = new HashSet<>(3);
// 添加自己的白名单接口
static {
whiteUrlSet.add("https://your api url ");
//...
}
/**
*
* 判断 指定请求的URL 是否在 本地白名单中
*
* @param reqUrl 请求URL
* @return true 在白名单中 不需要拦截处理 false 不在配置集合中 需要进行拦截处理
*/
static boolean isWhiteUrl(String reqUrl){
return !ArrayUtils.isEmpty(whiteUrlSet) && whiteUrlSet.contains(reqUrl);
}
}
step4: 和本地Token进行对比、如果走到这里说明此接口的Token有可能已经失效、所以这里将请求Token 和 本地Token对比、如果不一致 直接使用 本地Token 进行请求。
为什么这样处理呢?是因为有可能在这个接口之前可能已经有接口刷新完Token并将最新结果保存到本地了,所以这里没有必要再去刷新Token
step5: 读取返回数据并判断Token是否失效、如果失效执行刷新操作,这一步骤是比较重要
requestTokenAndSaveData()
源码如下:
/**
* 请求token 并将token 保存到 本地
*
* @return true 成功 false 失败
* @throws IOException
*/
private boolean requestTokenAndSaveData() throws IOException {
Request tokenRequest;
Request.Builder reqBuilder;
// 刷新token 地址
String requestUrl = HostConfig.getHostConfig().getApiHost() + ServerAdr.TokenConst.refreshToken;
reqBuilder = new Request.Builder();
reqBuilder.url(requestUrl);
// 使用 refresh_token 刷新 access_token
reqBuilder.addHeader(LoginConstant.ACCESS_TOKEN_KEY, UserDataManager.getInstance().getLoginBean().getRefreshToken());
// 构建 body 请求体
RequestBody reqBody = RequestBody.create(Constant.HttpParamConstant.APPLICATION_JSON_TYPE, new JSONObject().toString());
// post 方式发送 并构建 request
tokenRequest = reqBuilder.post(reqBody)
.build();
// 同步请求 token
Response response = getOkHttpClient().newCall(tokenRequest)
.execute();
// 处理返回结果
try {
if (!response.isSuccessful()) {
return false;
}
ResponseBody body = response.body();
if (body == null) {
return false;
}
String resultData = body.string();
if (StringUtil.isEmpty(resultData)) {
return false;
}
// 解析数据
JSONObject jo = JSON.parseObject(resultData);
int code = DataParserUtil.getJsonInt(jo, Net.Field.code);
if ( code != Net.HttpErrorCode.SUCCESS) {
return false;
}
// 解析body
JSONObject data = DataParserUtil.getJsonObj(jo, Net.Field.body);
if (data == null) {
return false;
}
// 解析对象
UpdateTokenBean tokenBean = DataParserUtil.parseObject(data.toString(), UpdateTokenBean.class);
if (tokenBean == null || StringUtil.isEmpty(tokenBean.getAccessToken()) || StringUtil.isEmpty(tokenBean.getRefreshToken())) {
return false;
}
// 更新token
UserInfoUtils.saveTokenInfo(tokenBean);
return true;
} finally {
CloseUtils.close(response);
}
}
step6:在前面的操作中我们成功刷新了Token,并保存到本地,这个时候要利用 OKhttp 的拦截器优势,重新构建一个 request 再次调用 proceed()
重新发送一次请求来达到不丢掉本次请求的功能。
关键代码如下:
// 构建新的请求头
Headers newHeaders = originHeaders.newBuilder()
.set(LoginConstant.ACCESS_TOKEN_KEY, LoginUtils.getAccessToken())
.build();
// 重新构建请求
Request newRequest = originRequest.newBuilder()
.headers(newHeaders)
.build();
// 重新发起请求
return chain.proceed(newRequest);
至此我们的Token拦截器工作基本结束了,下面就看一下能不能正常使用。
最后将我们的拦截器加入到 OkHttpClient 中
mOkClient = getOkHttpBuilder(context)
.addInterceptor(new TokenInterceptor(getApplicationContext())
.build();
最后就可以根据自己的业务场景进行测试了,经过大量的并发测试,使用抓包工具查看如下结果:
我们看到 ①处同一时间会有 三个接口Token失效了,而②处刷新Token 只会请求一次,在刷新完Token之后,又重新将token失效的接口重新请求了一次③处
至此我们使用OKhttp Tnterceptor 刷新 Token 的工作和流程已经清楚了,我们来总结一下:
优点:
缺点:
由于拦截器基于 责任链设计模式 设计 对数据改造和拓展很灵活 但 对性能有稍微影响
经过统计会在原来的接口请求基础上慢一些! 3 ~ 20 毫秒的影响! 不过在灵活性、代码维护性、代码可侵入性上 这个代价还是可以接受的
https://github.com/square/okhttp