Android网络实战篇——单进程多线程情况下Token自动刷新方案探讨

在上篇文章《Android网络实战篇——Token添加、过期判定以及处理》中探讨了Token的添加、过期判定以及RefreshToken问题,有同学留言探讨在多线程情况下Token怎样刷新问题,所以笔者此篇就这个问题和读友探讨一下。

Token刷新情形:

通常来说,Token刷新时会有以下三种情况:

1.单线程刷新:只有一个线程检测到Token失效,刷新后继续之前的Request。
2.单进程多线程刷新:同时有多个线程检测到Token失效,然后刷新,至于怎么刷新则是本篇探讨的内容。
3.多进程多线程刷新:Application采用多进程模式,不同进程中的多个线程同时检测到Token失效,然后刷新,至于怎么刷新,解决方案思想和即将要探讨的第二种情况大致相同,但是需要考虑多进程不共享内存问题,需要使用多进程通信,情况就比较复杂了,本篇暂不谈论。

多线程刷新Token问题:

一般来说,Token刷新大多数属于第一种情况即单进程单线程RefreshToken,但是也存在其他两种情况,例如Application使用单进程但是一个界面打开后需要发起多个Network Request去服务器拉取数据,此时就需要考虑多线程并发状况。

若遇到多线程同时检测到Token失效时会发生什么情况,先来看下单线程Token刷新的解决方式:

/**
 * 自动刷新Token拦截器
 */
public class AutoRefreshTokenInterceptor implements Interceptor {
    @NotNull
    @Override
    public Response intercept(@NotNull Chain chain) throws IOException {
        Request request = chain.request();
        Response response = chain.proceed(request);
        //L1
        if (response.code() == HttpCode.REQUEST_TOKEN_INVALID) {//token过期使用RefreshToken接口刷新token
            HttpApi httpApi = RetrofitFactory.createRetrofitService(false, HttpApi.class);//注意:这儿不能再次让拦截
            retrofit2.Response> execute =
                    httpApi.requestRefreshToken(new RefreshTokenRequestBody(SpUtil.getHttpRefreshToken())).execute();
            if (execute.isSuccessful()) {
                HttpResponse body = execute.body();
                if (body != null && body.getCode() == HttpCode.REQUEST_SUCCESS && body.getData() != null
                        && Utils.isNonEmpty(body.getData().getAccessToken())) {
                    RefreshTokenResponseBody data = body.getData();
                    String accessToken = data.getAccessToken();
                    SpUtil.saveHttpAccessToken(accessToken);
                    //L2
                    Request newRequest = request.newBuilder()
                            .header(Config.HTTP_TOKEN_KEY, Config.HTTP_TOKEN_HEADER + accessToken)
                            .build();
                    response.close();
                    return chain.proceed(newRequest);
                }
            }
        }
        return response;
    }
}

以上解决方式总结一下就是若检测到Token失效就会在当前线程调用RefreshToken接口同步刷新后再将新Token更新到原Request中并保存,单线程下此种解决方式没有太大问题,若多线程情况下就有问题啦,接下来我们分析一下。

状况一:若多线程同时运行到L1处,就会触发两次Token刷新。
状况二:若Thread1已经RefreshToken Success并将token加入到之前Request1中(L2)然后继续请求(但未提交到服务器),此时Thread2又重复了一遍RefreshToken,此时之前Thread1刷新得到的token就会被Thread2刷新得到的Tokens覆盖并导致之前的Token失效,此时Request1携带的Token就会被服务器判定为无效,那么就会导致整个流程异常。

在业务逻辑比较复杂的情景中多线程并发状况也是比较常见的,所以必须针对以上两种状况寻求解决方案,否则这始终是一个雷,不知啥时候就会“轰”的一声爆炸,炸着了测试还好,炸着了用户老版就会找你麻烦了!

解决方案探讨:

经过以上分析主要矛盾点就是多线程同时刷新Token,那么通过措施让其中一个线程刷新就成,其他线程只负责读取,那么按照此思路可以有以下两种解决方案:

方案一:让一个独立的功能线程负责RefreshToken,其他业务线程只负责读取,具体实现可以是启动一个定时任务,设定刷新时间(一般要略小于Token有效时间)负责RefreshToken,这样就会避免多线程同时刷新Token即Token读写分离。
方案二:还是采用单线程解决方案思想,在多个业务线程Request时发现Token失效时,让最先发现的业务线程去更新Token,更新完毕通知其他业务线程,不设置独立的RefreshToken功能线程。

以上两种方案都是解决问题之道,但是就笔者来说寻找方案会遵循就地解决原则(尽量不引入外援)就近解决原则(这俩原则是笔者总结的,名字也是笔者起的,可能不贴切,但是领会意思就Ok了)

就地原则(尽量不引入外援)

所谓就地解决(不引入外援)原则就是解决问题尽量由出现问题的宿主解决即哪儿出现问题哪儿解决,例如Thread1发起Request时出现了token失效问题,尽量由Thread1本身去解决,若引入第三方Thread2去解决一来多出一个资源,二来Thread2本身的稳定性直接影响流程甚至系统的稳定性,试想若Thread2突然挂了,怎么破?Application所有的Request都会over(当然也可以通过监控Thread2线程,若Thread2挂了再起Thread3,那Thread3同样面临此问题,如此反复笔者总觉得差点儿意思);

就近原则

所谓就近原则就是问题啥时候出现啥时候解决,方案一中通过定时刷新Token方案,但是定时时间周期与Token失效总会有时间差(定时时间周期总会小于Token有效时间,不然业务线程就有可能读取到失效Token了),这样从总体来看就会浪费不少资源。

总结一下方案一的缺点:

1.系统会专门拿出资源RefreshToken
2.将整个系统Token刷新机制建立在一个线程上不太可靠
3.浪费Token的有效时间,一定程度上会增加服务器的压力

综上,笔者选择方案二来解决多线程Token刷新问题,接下来开始探讨具体解决方案。

具体解决方案实现:

我们再来分析一下方案二,让其中一个业务线程去RefreshToken并阻止其他线程更新此时其他业务线程需要等待RefreshToken完毕,更新完毕后通知其他业务线程带着新Token继续Request,这里有几个技术点:

1.怎样阻止其他业务线程再次RefreshToken?设置一个变量呗;
2.怎样让其他业务线程等待Token更新完成?CountDownLatch来实现;
3.怎样通知其他业务线程RefreshToken成功后?接口回调呗;

下面通过代码来具体说明一下。

代码实现:
public class AutoRefreshTokenInterceptor implements Interceptor {
    private static ArrayList mRefreshListenerList = new ArrayList<>();
    private static volatile boolean isRefreshing = false;

    @NotNull
    @Override
    public Response intercept(@NotNull Chain chain) throws IOException {
        Request request = chain.request();
        Response response = chain.proceed(request);
        if (response.code() == HttpCode.REQUEST_TOKEN_INVALID) {//token过期使用RefreshToken接口刷新token
            if (!isRefreshing) {
                //Thread1
                isRefreshing = true;
                HttpApi httpApi = RetrofitFactory.createRetrofitService(false, HttpApi.class);//注意:这儿不能再次让拦截
                retrofit2.Response> execute =
                        httpApi.requestRefreshToken(new RefreshTokenRequestBody(SpUtil.getHttpRefreshToken())).execute();
                if (execute.isSuccessful()) {
                    HttpResponse body = execute.body();
                    if (body != null && body.getCode() == HttpCode.REQUEST_SUCCESS && body.getData() != null
                            && Utils.isNonEmpty(body.getData().getAccessToken())) {
                        RefreshTokenResponseBody data = body.getData();
                        String accessToken = data.getAccessToken();
                        SpUtil.saveHttpAccessToken(accessToken);
                        accessToken = Config.HTTP_TOKEN_HEADER + accessToken;
                        Request newRequest = request.newBuilder()
                                .header(Config.HTTP_TOKEN_KEY, accessToken)
                                .build();
                        response.close();
                        isRefreshing = false;
                        triggerRefreshListener(true, accessToken);
                        return chain.proceed(newRequest);
                    }
                }
                triggerRefreshListener(false, null);
            } else {
                //Thread2
                CountDownLatch countDownLatch = new CountDownLatch(1);
                //加入到请求队列中(回调运行在其他线程中,非当前线程)
                mRefreshListenerList.add(new OnRefreshListener() {
                    @Override
                    public void onRefresh(boolean isSuccess, String accessToken) {
                        mRefreshListenerList.remove(this);//从队列中删除
                        if (isSuccess) {
                            countDownLatch.countDown();//成功后继续请求
                        } else {//可能由于网络原因造成RefreshToken失败
                            //这儿就不再处理了, 直到线程终止
                        }
                    }
                });
                try {
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    LogUtils.d("countDownLatch InterruptedException:" + e.toString());
                }
            }
        }
        return response;
    }

    /**
     * 将RefreshToken结果通知到所有队列中等待的线程
     *
     */
    private void triggerRefreshListener(boolean isSuccess, String accessToken) {
        for (OnRefreshListener onRefreshListener : mRefreshListenerList) {
            onRefreshListener.onRefresh(isSuccess, accessToken);
        }
    }

    public interface OnRefreshListener {
        void onRefresh(boolean isSuccess, String accessToken);
    }
}

可以看见多线程Token刷新解决方案就是在单线程基础上修改而来,看下那几个技术点,通过静态变量isRefreshing来避免多线程刷新混乱;使用OnRefreshListener接口来通知其他等待刷新线程,并其加入到队列,等到RefreshToken完毕统一通知;通过CountDownLatch来实现线程等待,等到刷RefreshToken完毕后再放行,让等待线程继续执行。(关于CountDownLatch不清楚的同学可以学习一下这篇文章,讲的很不错《多线程并发之CountDownLatch(闭锁)使用详解》)

以上就是笔者关于多线程情形下Token刷新的解决方案,此方案笔者初步测试通过,目前来说还行得通,但是可能会有笔者没有想到的坑,请读友不吝赐。

此方案还有一个问题就是当处理刷新Token的业务线程更新失败后就会将Token失败展示给用户,其实一个线程更新失败还可以让其他等待线程继续更新直到队列中没有等待线程,目前笔者对于这种优化还没有想到具体实现方案,还请读友不吝赐教。

对于多进程多线程Token刷新情形以后再探讨,读友有好的实现方案也可以分享!!!

若觉得不错就给个赞吧,谢谢!

后记:
此篇文章《高并发如何保证微信 access_token 的有效》对于笔者有很多的启迪,虽然文章是基于网页Token,但是思想是相通的,在此感谢此作者!!!

你可能感兴趣的:(Android网络实战篇——单进程多线程情况下Token自动刷新方案探讨)