Flutter中基于Dio实现Token Refresh
1. 背景介绍
目前项目在采用Flutter开发一款App,该工程中采用Dio框架作为网络请求框架,用户登录方面采用 OAuth2 协议。众所周知, OAuth2 协议中是用户初次登录时获取 access_token,之后当 access_token 过期后采用 refresh_token 再获取新的 access_token 。现在问题的难点是用户在使用过程中如果出现票据过期了,服务就不能正常返回数据,此时需要自动刷新票据,且刷新过程需要对用户透明。
说到这里,有几种解决思路:
- 简单的方式:在用户打开App时计算过期时间,始终保证票据过期前更新票据。
这种方式存在的问题是如果账号在其它终端登录了会使当前终端的票据提前失效(看服务实现,一般来说会限制为一个账号只允许一个终端登录)。
- 正规的方式:用户在App端访问服务时服务端对access_token校验,当票据失效后给出对应状态码提示。 App端对返回状态码解析,根据状态码提示执行刷新票据的请求,当获取到新的票据后再将用户当前访问请求重发。
下面介绍的就是第2种方式,直接刷新Token的方式。
2. Token刷新流程
Token刷新流程如下所示:
- 用户通过App查询服务
- App端调用App服务端接口进行查询
- App服务端调用OAuth服务端接口校验access_token是否失效
- OAuth服务端校验出access_token已经失效,返回错误提示
- App服务端将OAuth服务端错误提示返回给App端
- App端收到票据过期提示后,调用OAuth服务端接口刷新access_token
- OAuth服务端返回新的access_token
- App端将新的access_token保存到本地cache
- App端携带新的access_token并重发查询服务请求到App服务端
- App服务端校验access_token通过后并将服务查询结构返回给App端
- App端将服务查询结构展示给用户
从图中可以看出,关键部分在于第6步刷新Token和第9步重发请求 ,下面逐一介绍这2处的实现。
3. 刷新Token
由于不确定票据失效时是哪个请求触发的,所以可以全局拦截请求,对请求的响应信息进行解析判断。 Dio 中有拦截器,可以达到拦截请求的目的。
1. 添加拦截器 TokenInterceptor
Dio添加拦截器
Dio dio = new Dio(); // with default Options
// Set default configs
dio.options.baseUrl = baseURL ?? ApiPath.baseURL;
dio.options.connectTimeout = 100000; // 100s
dio.options.receiveTimeout = 100000; // 100s
dio.interceptors.clear();
// 添加拦截器TokenInterceptor
dio.interceptors.add(TokenInterceptor(dio));
TIP: 拦截器只对单个 Dio 实例生效,所以不同的 Dio 实例之间的 Interceptor 是不共享的。所以需要确保我们使用工程中的所有请求都是走的同一个 Dio 实例。
TokenInterceptor类
class TokenInterceptor extends Interceptor {
Dio _dio;
bool isReLogin = false;
Queue queue = new Queue();
TokenInterceptor(this._dio);
@override
Future onRequest(RequestOptions options) async {
return options;
}
@override
Future onResponse(Response response) async {
bool needRefreshToken = _checkIfNeedRefreshToken(response);
if (!needRefreshToken) {
return super.onResponse(response);
}
// TODO 发送刷新Token请求
return super.onResponse(response);
}
/// 判断是否需要刷新Token
bool _checkIfNeedRefreshToken(Response<dynamic> response) {
if (response.data == null || response.data.isEmpty) {
return false;
}
var responseMap =
response.data is String ? jsonDecode(response.data) : response.data;
var head = responseMap['head'];
if (head == null) {
return false;
}
var statusCode = head['code'];
if (statusCode != 99999 || "未发现登录用户" != responseMap['data']) {
return false;
}
return true;
}
}
TIP: 由于我们的服务端实现时将Token校验失败的错误放在正常的response json中,所以这里在 onResponse 中进行解析处理的。一般情况下是在 onError 根据状态码进行判断处理的。
2. 发送刷新Token请求
// 先上锁,防止刷新票据时其它请求执行
dio.interceptor.request.lock();
dio.interceptors.responseLock.lock();
dio.interceptors.errorLock.lock();
/// 在这里执行刷新Token逻辑
// 释放锁
dio.interceptors.errorLock.unlock();
dio.interceptor.response.lock();
dio.interceptors.responseLock.unlock();
网上大多数是上面给出的代码来实现刷新 Token 的,但是这种实现方式在多请求并发执行时会出现重复执行刷新 Token ,甚至一直阻塞住的问题。
当一个页面上同时发送多个请求时,采用上述方式刷新 Token 的流程如下图所示:
显然,通过这种加锁的方式并不能应对这种多请求并发执行的场景。
3. 采用队列执行刷新Token
参考链接:https://github.com/flutterchina/dio/issues/590
queue 这个包支持将多个 Future 按顺序逐一执行。目前这种情况,只需要让请求逐一进入刷新票据方式,并控制后只有一个请求能真实地刷新票据即可。具体流程如下图所示:
onResponse
Future onResponse(Response response) async {
bool needRefreshToken = _checkIfNeedRefreshToken(response);
if (!needRefreshToken) {
return super.onResponse(response);
}
// 参考 https://github.com/flutterchina/dio/issues/590
// Check for if the token were successfully refreshed
bool success = await queue.add(() async {
var requestToken = response.request.data['head']['token'];
var globalToken = UserUtil.token.access_token;
// token一致表示需要更新token,不一致则表示token已经在其它请求中更新了,这里可以防止重复更新Token
if (requestToken == globalToken) {
// 注意:刷新Token用单独的 Dio 实例(避免被TokenInterceptor重复拦截),不要与其它请求共用 Dio实例。
return await locator<UserService>()
.refreshToken(UserUtil.user.user_name, UserUtil.user.user_sfzh);
}
return true;
});
// token refresh succeed
if (success) {
// 重试请求
return _retry(response);
}
// token refresh failed
showTipDialog(
'错误提示', '刷新用户票据失败,请重启应用!', NavigatorUtils.navigatorKey.currentContext);
return super.onResponse(response);
}
refreshToken
Future<bool> refreshToken(String userName, String idCardNo) async {
try {
Token token = await _doLogin(userName, idCardNo);
bool succeed = token != null && token.access_token != null;
if (succeed) {
UserUtil.token.access_token = token.accessToken;
}
return succeed;
} on FetchDataException catch (e, stackTrace) {
Log.e("Refresh user token failed. userName: $userName, idCardNo: $idCardNo", e,
stackTrace);
}
return false;
}
4. 重发请求
上面完成 Token 刷新后,只需要携带上新的 access_token 并重新发送请求就可以了。
/// 重发请求
Future<Response<dynamic>> _retry(Response<dynamic> response) {
// 从response中读取到当前请求的相关信息
RequestOptions options = response.request;
// update token
var data = options.data;
data['head']['token'] = UserUtil.token.access_token;
// 重发请求
return _dio.post(options.path,
options: options, data: data, queryParameters: options.queryParameters);
}
5. 完整示例代码
TokenInterceptor
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:hbgaydsj/src/common/utils/navigator_utils.dart';
import 'package:hbgaydsj/src/common/utils/user_util.dart';
import 'package:hbgaydsj/src/locator.dart';
import 'package:hbgaydsj/src/modules/user/service/user_service.dart';
import 'package:hbgaydsj/src/ui/widgets/tip/tip_dialog.dart';
import 'package:queue/queue.dart';
class TokenInterceptor extends Interceptor {
Dio _dio;
bool isReLogin = false;
Queue queue = new Queue();
TokenInterceptor(this._dio);
@override
Future onRequest(RequestOptions options) async {
return options;
}
@override
Future onResponse(Response response) async {
bool needRefreshToken = _checkIfNeedRefreshToken(response);
if (!needRefreshToken) {
return super.onResponse(response);
}
// 参考 https://github.com/flutterchina/dio/issues/590
// Check for if the token were successfully refreshed
bool success = await queue.add(() async {
// refreshTokens returns true when it has successfully retrieved the new tokens.
// When the Authorization header of the original request differs from the current Authorization header of the Dio instance,
// it means the tokens where refreshed by the first request in the queue and the refreshTokens call does not have to be made.
var requestToken = response.request.data['head']['token'];
var globalToken = UserUtil.token.access_token;
// token一致表示需要更新token,不一致则表示token已经在其它请求中更新了
if (requestToken == globalToken) {
return await locator<UserService>()
.refreshToken(UserUtil.user.user_name, UserUtil.user.user_sfzh);
}
return true;
});
// token refresh succeed
if (success) {
return _retry(response);
}
// token refresh failed
showTipDialog(
'错误提示', '刷新用户票据失败,请重启应用!', NavigatorUtils.navigatorKey.currentContext);
return super.onResponse(response);
}
/// 重发请求
Future<Response<dynamic>> _retry(Response<dynamic> response) {
RequestOptions options = response.request;
// update token
var data = options.data;
data['head']['token'] = UserUtil.token.access_token;
return _dio.post(options.path,
options: options, data: data, queryParameters: options.queryParameters);
}
/// 判断是否需要刷新Token
bool _checkIfNeedRefreshToken(Response<dynamic> response) {
if (response.data == null || response.data.isEmpty) {
return false;
}
var responseMap =
response.data is String ? jsonDecode(response.data) : response.data;
var head = responseMap['head'];
if (head == null) {
return false;
}
var statusCode = head['code'];
if (statusCode != 99999 || "未发现登录用户" != responseMap['data']) {
return false;
}
return true;
}
}
6. 总结
在做票据刷新时,网上大多数方案都是基于 dio 锁的方式 实现的,但是经常实际测试发现并不能满足要求,在这个地方花费了太多时间。
其实理清思路后,采用同步队列一样能快速解决问题,不必纠结于大多数的 dio锁 的解决方案。
7. 关于作者
作者是一个热爱学习、开源、分享,传播正能量,头发还很多的程序员-。- 热烈欢迎大家关注、点赞、评论交流!
- csdn: https://blog.csdn.net/u010920692
- 掘金: https://juejin.cn/user/3237392838298663
- 博客园: https://www.cnblogs.com/zhangzhxb/
|