Moya 并发处理Token刷新

8,530 阅读4分钟

最近使用Moya突然碰到token刷新的问题,一直没有放在心上,最近有时间就好好的弄了下。 以前对于服务端Token的这个问题都没有怎么关心过,顶多处理一下过期后要求重新登录而已,就算要做续期服务端可能就直接帮我们做掉了。这次暴露出来的这个问题整了好久才算有一个比较满意的结果。

遇到的问题 🤨

服务端定义了token的有效期,差不多在2个小时。token过期后需要前端自行调用刷新token接口。 一开始其实没有放在心上,想着服务端报错后就进行token的刷新就行了,没什么问题。 结果还是太年轻啊,一个并发直接给整懵了。

如何解决 🧐

初步实践走的弯路 😩

这种大面积都会碰到的问题应该有很多资料的吧。 果然参照这个小哥哥的写法 [Moya 的 RxSwift 扩展之后台无感刷新token] 大致理解了下,感觉上逻辑也是行得通,无非就是并发的时候多处理一下就行。

流程大概是这样:

请求 -> 过滤不需要token的请求 -> 拿到响应结果后处理是否需要刷新token -> 重发上一次请求

结果真到处理并发的时候问题就来了,这个方式是在拿到响应结果后才会处理,在异步同时调用多个接口的时候会造成先后顺序不一致,且token刷新时会错乱,最终导致刷新token时造成token非法。

最终解决 🥰

在服务端的配合下将token过期时间调整到很短的情况下,多次尝试后最终放弃上面的方案。 经过一段时间的资料查找,最终找到相关可参考的方案 [Alamofire - 使用拦截器优雅的对接口进行授权] [Moya框架浅析][Swift Moya刷新过期Token]

最终的核心解决方式是使用Alamofire中的session添加授权拦截器AuthenticationInterceptor

流程大致是这样:

选定指定的Provider执行请求 -> 校验凭证本地设置的有效期 -> 处理401过期情况 -> 正常处理请求

基于这些资料,基本可以确定使用Alamofire中的AuthenticationInterceptor即可实现我想要的效果。 由于我使用了MultiTarget单个Provider统一管理所有请求,所以在最终的实现上还是有点差别

首先定义一个用于存放token的授权凭证 OAuthCredential

struct OAuthCredential: AuthenticationCredential {
    var accessToken: String = Global.shared.token {
        didSet {
            Global.shared.token = accessToken
        }
    }
    var refreshToken: String = Global.shared.refreshToken {
        didSet {
            Global.shared.refreshToken = refreshToken
        }
    }
    var userID: String = ""
    var expiration: Date = Date(timeIntervalSinceNow: OAuthAuthenticator.expirationDuration)

    // 这里我们在有效期即将过期的  `OAuthAuthenticator.expirationDuration - OAuthAuthenticator.expirationDuration * 0.2` 秒返回需要刷新
    var requiresRefresh: Bool { Date(timeIntervalSinceNow: OAuthAuthenticator.expirationDuration * 0.2) > expiration }
    
}

实现授权拦截器

class OAuthAuthenticator: Authenticator {
    /// token 过期时间
    static let expirationDuration: TimeInterval = 60 * 100
    
    /// 添加header
    func apply(_ credential: OAuthCredential, to urlRequest: inout URLRequest) {
        urlRequest.headers.add(.authorization(bearerToken: Global.shared.token))
    }
    /// 实现刷新流程
    func refresh(_ credential: OAuthCredential,
                 for session: Session,
                 completion: @escaping (Result<OAuthCredential, Error>) -> Void) {
        logger.debug("需要刷新token!!!")
        
        MoyaProvider<AuthApi>(plugins: [ NetworkLoggerPlugin()]).request(.refreshToken) { result in
            switch result {
            case .success(let response):
                logger.debug("response = \(response.statusCode)")
                guard let dic = dataToDictionary(data: response.data) else{
                    completion(.failure(YHError.other(code: -999, msg: "解析错误")))
                    return
                }
                guard let model = ResponseBaseModel<RefreshToken>(JSON: dic) else {
                    completion(.failure(YHError.other(code: -999, msg: "解析错误")))
                    return
                }
                guard model.code == 0 else {
                    let hud = HUD.show(message: "请重新登录")
                    hud?.pinned = true
                    Global.logout()
                    completion(.failure(YHError.other(code: -999, msg: "请重新登录")))
                    return
                }
                Global.shared.refreshToken = model.data?.refresh_token ?? ""
                Global.shared.token = model.data?.token ?? ""
                
                completion(.success(OAuthCredential(accessToken: model.data?.token ?? "",
                                                    refreshToken: model.data?.refresh_token ?? "",
                                                    userID: Global.shared.userInfo._id,
                                                    expiration: Date(timeIntervalSinceNow: OAuthAuthenticator.expirationDuration))))
            case .failure(let error ):
                logger.debug("error = \(error)")
                completion(.failure(error))
            }
        }
    }

    func didRequest(_ urlRequest: URLRequest,
                    with response: HTTPURLResponse,
                    failDueToAuthenticationError error: Error) -> Bool {
        logger.debug("服务端token过期!!!!!!!!")
        return response.statusCode == 401
    }

    func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: OAuthCredential) -> Bool {
        let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
        return urlRequest.headers["Authorization"] == bearerToken
    }
}

这里有点需要注意,如果服务端的token先过期,返回401时Moya需要额外处理配置validationType,否则无法触发AuthenticationInterceptorRetry方法!

extension TargetType {
    public var validationType: ValidationType {
        switch self {
        case .refreshToken:
            return .none
        default: return .successCodes
        }
    }
}

在处理是否需要授权凭证时,我这边是创建了2个不同的provider,用于创建有授权校验的session和无校验的session

public var netProvider = CustomProvider.createAuthProvider()
public var noAuthProvider = CustomProvider.createNoAuthProvider()
    /// 创建需认证的provider
    static func createAuthProvider() -> CustomProvider {
        
        let networkLoggerPlugin = NetworkLoggerPlugin(configuration: .init(formatter: NetworkLoggerPlugin.Configuration.Formatter.init(entry: defaultEntryFormatter), output: reversedPrint, logOptions: .verbose))
        
        let configuration = URLSessionConfiguration.default
        configuration.headers = .default
        let interceptor: AuthenticationInterceptor? =  AuthenticationInterceptor(authenticator: OAuthAuthenticator(), credential: YHProvider.credential)
        let customSession = Session(configuration: configuration, startRequestsImmediately: false, interceptor: interceptor)
        return CustomProvider(endpointClosure: endpointClosure(),
                          requestClosure: requestClosure(),
                          stubClosure: MoyaProvider.neverStub,
                          callbackQueue: DispatchQueue.main,
                          session: customSession,
                          plugins: [networkLoggerPlugin, StorePlugin(), HeaderPlugin(), ErrorPlugin()],
                          trackInflights: false)
    }
    
    
    /// 创建无需认证的provider
    static func createNoAuthProvider() -> CustomProvider {

        let networkLoggerPlugin = NetworkLoggerPlugin(configuration: .init(formatter: NetworkLoggerPlugin.Configuration.Formatter.init(entry: defaultEntryFormatter), output: reversedPrint, logOptions: .verbose))
        
        return CustomProvider(endpointClosure: endpointClosure(),
                          requestClosure: requestClosure(),
                          stubClosure: MoyaProvider.neverStub,
                          callbackQueue: DispatchQueue.main,
                          plugins: [networkLoggerPlugin, StorePlugin(), HeaderPlugin(), ErrorPlugin()],
                          trackInflights: false)
    }