背景说明
在app开发中,我们需要保证用户登录之后,如果没有在其他设备上登录,则不需要再次登录,很多都会使用 token 作为安全令牌,开始阶段都会在登录时候获取,一直使用到下次登录。这样的 token 没有什么安全性可言,所以大多app都会做 token 时效性处理。
刷新 token 流程图如下:
根据刷新 token 的流程,后台返回以下信息:
- 登录成功,获得 token 信息为:
{"token":"",// token 令牌 "expiresIn":XX(单位秒),// token 有效时间长度 "refreshToken":""//刷新 token 用的参数,只允许使用一次 }
- 后台返回错误 errorCode 说明如下:
rest Code | errorCode | token | RefreshToken | 刷新Token | 重新登录获取Token |
---|---|---|---|---|---|
401 | 4010 | token为空 | 无效 | 无 | 是 |
401 | 4011 | token过期 | 有效 | 是 | 否 |
401 | 4012 | token失效 | 失效 | 否 | 是 |
401 | 4013 | token过期 | 过期 | 否 | 是 |
401 | 4014 | token失效 | 失效 | 否 | 是 |
rest Code | errorCode | 问题说明 | 解决办法 |
---|---|---|---|
400 | 4001 | 请求参数为空 | 需要检查逻辑 |
Android 端对 token 的处理是,在每次用到 token 的时候对 token 是否有效进行判断,获得有效的 token。具体代码如下:
public static Retrofit retrofitClient(String token, String apiUrl) {
OkHttpClient client = new OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.connectTimeout(60, TimeUnit.SECONDS).
addInterceptor(new Interceptor() {
@Override
public okhttp3.Response intercept(Chain chain) throws IOException {
Request original = chain.request();
Request request = original.newBuilder()
.addHeader(AUTH_HEADER_KEY
, BEARER_HEADER_VALUE
+ getToken())
.method(original.method(), original.body())
.build();
return chain.proceed(request);
}
}).build();
return new Retrofit.Builder()
.baseUrl(apiUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build();
}
public static String getToken(){
final String token = "";
TokenEntity entity = UserUtil.getInstance().getToken();
long startTime = getStartTime();//获取token时的时间,单位:s
long currentTime = System.currentTimeMillis()/1000;//获取当前时间
//判断token的有效期是否在时间段内
if ((currentTime - startTime) < Long.parseLong(entity.getExpiresIn())){
token = entity.getToken();
}else {
//请求刷新token接口,获取新的token
}
return token;
}
然而当 token 失效时,出现了多个接口同时 token 失效,并都用 refreshToken 进行 token 刷新,出现接口调用错误,具体情况如下。
问题说明
-
在 token 失效期间,多个接口同时请求刷新 token 这个接口,由于refreshToken 的有效性只为一次,当第一个请求接口去刷新 token,当接口还没有请求成功,token 还没有刷新,后面的接口请求时,token 也是失效的,需要刷新。后面的其他接口在使用原来的 refreshToken 刷新,则会失败,接口返回 4012 错误,退回到登录页面,与当初设想的效果不符。
就像十字路口一样,如果南北和东西两条路同时通车,会发生事故的,我们必须要按照顺序进行。
问题解决思路
在 retrofit 中有同步请求 execute() 和异步请求 enqueue(XXX) ,个人还没有找到可以实现多个接口并发,而且同步操作的方法。网上有人说建议使用 retrofit+Rxjava 可以实现,但是本人对 Rx 才上手还没有深入了解。所以能否解决这个问题,我还不知道。我解决问题的思路如下:
- 思路一
接口几乎是同时请求,那么我在请求每个接口的时候都增加一个 Thread.sleep(1000);请求 token 是异步操作,Android 不能在UI线程中进行同步请求,当网络比较慢的时候效果还可以,但是当网络状态良好,问题相当于没有解决。 - 思路二
使用 handler.sendMessage(); 使用消息队列进行处理,问题仍然没有解决 - 思路三
多线程同步,每次请求都是一个异步操作,想让获取 token 这个过程按照顺序来,就需要同步操作或者线程队列,并且获取 token 的方法使用同步操作。这个思路解决所遇到的问题。
解决办法
多线程同步可以解决当下的问题,同步有三种方法:
- 一是使用 synchronized 关键字,对方法进行同步。
public synchronized String getToken(){
final String token = "";
//对 token 进行有效判断,有效则使用原有的;无效则对 token 进行刷新操作
// ⚠️刷新 token 的接口要使用同步操作,否则无用
//....
return token;
}
- 二是使用 synchronized(object){} 同步代码快的方法,把需要同步的操作放到 “{}” 内。与方法一是一样的
synchronized (this){
//需要同步的代码块
}
- 三是使用重入锁 ReenreantLock 实现线程同步
private Lock mLock = new ReentrantLock();
public String getToken(){
mLock.lock();
try{
//需要上锁的代码块
}finally{
mLock.unlock();
}
}
此时要注意及时释放锁,否则会出现死锁,通常在finally代码释放锁
我使用的第一种解决办法,因为整个获取 token 的方法都需要同步实现, synchronized 关键字可以同步一个方法,也可以同步代码块(方法二),使用起来方便简洁,虽然没有 ReenreantLock 使用灵活,但是对于这个问题,使用 synchronized 已经足够了,代码如下:
public synchronized String getToken(){
final String token = "";
TokenEntity entity = UserUtil.getInstance().getToken();//获取登录时返回的 token 实体
long startTime = getStartTime();//获取 token 时的时间,单位:s
long currentTime = System.currentTimeMillis()/1000;//获取当前时间
//判断 token 的有效期是否在时间段内
if ((currentTime - startTime) < Long.parseLong(entity.getExpiresIn())) {
token = entity.getToken();
} else {
//根据 refreshToken 刷新 token,获得最新 token
//⚠️需要使用同步请求
}
return token];
}
涉及知识点
- retroift 网络请求框架的使用
官方网址:http://square.github.io/retrofit/ 讲述如何配置 retrofit,和接口的调用
基础入门:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0915/3460.html 讲述 retrofit 初步使用、代码的实现、一些注意事项。 - 多线程同步
1、synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。synchronized 既可以加在一段代码上,也可以加在方法上。
2、ReentrantLock:官方说明是一个可重入的互斥锁定 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁定相同的一些基本行为和语义,但功能更强大。ReentrantLock 将由最近成功获得锁定,并且还没有释放该锁定的线程所拥有。当锁定没有被另一个线程所拥有时,调用 lock 的线程将成功获取该锁定并返回。如果当前线程已经拥有该锁定,此方法将立即返回。可以使用 isHeldByCurrentThread() 和 getHoldCount() 方法来检查此情况是否发生。 - ⚠️多线程同步操作是一种耗费资源,耗时的操作,在开发中能不使用尽量不使用。
总结
在解决这个问题上,花费了我三天时间,想到延迟、消息队列、线程队列、同步请求等,经过所有的尝试后仍然解决不了问题,经过和同事的讨论,他们提供给我多线程同步的思路,经过查找资料,在获取 token 的方法上添加一个 synchronized 关键字,并使用 retrofit 的同步方法请求接口,问题得到解决。只有掌握更多的知识,才能够找到更好的思路。
参考文档
http://www.codeceo.com/article/java-multi-thread-sync.html 描述多线程同步的五种方法,并进行粗略的对比
https://stackoverflow.com/questions/31021725/android-okhttp-refresh-expired-token 讲述retrofit 进行网络请求,token 过期之后发生的并发问题,并进行解决方法的讨论。
http://blog.csdn.net/jdsjlzx/article/details/52442113 使用RxJava+retrofit进行网络请求,解决 token 失效,并刷新 token 的方法。