ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

HarmonyOS开发:基于Axios封装HTTP客户端,自动刷新Token并发重放实战

HarmonyOS开发:基于Axios封装HTTP客户端,自动刷新Token并发重放实战 说实话HarmonyOS应用开发进行到网络层的时候很多人会被“HTTP请求要不要再包一层”这个问题卡住。直接用ohos.net.http写原生请求吧逻辑能跑通但token过期、并发重放、异常拦截这些真实工程问题会把你逼疯。我这次就基于Axios在HarmonyOS上做了一套HTTP客户端封装核心功能是“自动刷新Token”把登录态维护、请求重放、并发控制全部收敛在一个模块里实测在多个业务场景下跑得很稳。这篇文章会从方案选型、拦截器设计、并发刷新队列、常见坑四个维度展开把每一步为什么这么做、怎么落地讲清楚适合正在做鸿蒙应用网络层、或者准备把Web端Axios经验迁移到鸿蒙的开发者参考。1. 整体设计与方案选型拆解1.1 为什么是Axios而不是原生http模块ohos.net.http是HarmonyOS提供的基础网络能力功能上没问题但用起来至少有两个痛点第一它没有一个原生拦截器机制token过期时你需要在每个业务请求的地方手动判断状态码、手动刷新、手动重放非常容易漏第二它的响应体解析、错误码映射都比较原始需要自己在外面套一层工具函数做多了就变成了手写一个半吊子封装维护成本和返工风险都很高。Axios则不同它在Web生态里经过了大量验证天然提供请求拦截器、响应拦截器、取消请求、统一错误处理这些能力。鸿蒙生态里有团队维护了基于TypeScript的移植版本核心API和Web版保持一致也就是说你在Web端写的axios.interceptors.request.use、axios.interceptors.response.use这些代码几乎可以直接平移到鸿蒙项目里。选它的本质不是“图省事”而是把一个成熟的请求生命周期管理模型引入到鸿蒙应用中把网络层做成一个模块而非散落在业务代码里的杂兵。1.2 封装的核心目标自动刷新Token并重放请求接入过有登录态业务的都懂token过期是常态但用户感知应该是“无感”的。我的目标很明确当任意请求因token失效返回401时客户端自动走一遍刷新逻辑刷新成功之后把之前失败的请求自动重放全程不让用户重新登录。这个目标拆开看有三件事必须做对第一如何识别token失效第二如何在多个请求同时401时只刷新一次第三如何保证刷新后的重放请求是安全的、顺序正确的。这三件事如果不用统一封装靠业务方在页面里写try...catch去处理大概率会出现“多个请求同时刷新、token被覆盖、部分请求始终重放不成功”的难看局面。我决定把这些逻辑统一收口到响应拦截器里业务方只负责发起请求其余全交给HTTP客户端。1.3 技术选型单实例还是多实例这里有个容易忽略的设计决策。很多人一上来就全局axios.defaults一把梭但真实项目里我需要至少两个实例一个给业务请求用带上鉴权拦截器、刷新拦截器另一个给刷新token的接口自身用这个实例必须干净不能挂“token过期就刷新”的逻辑否则刷新接口本身401时就会陷入死循环。我的做法是核心实例httpClient处理所有带认证标识的请求附加请求/响应拦截器内部再声明一个refreshClient只用于调用/auth/refresh接口自己控制超时时间和错误处理。两个实例隔离各自的职责边界非常清晰。你如果后续要接第三方API、或者做无鉴权的公共接口调用也可以基于这个思路再扩展一个publicClient不挂任何鉴权逻辑。2. 核心细节解析与实操要点2.1 拦截器职责划分拦截器是整套方案的灵魂但如果你把全部逻辑塞进一个拦截器里代码很快就会变成一坨浆糊。我建议严格划分三块请求拦截器只做“附带身份标识”这一件事响应拦截器负责“识别过期-触发刷新-重放请求”而刷新逻辑本身独立成函数不在拦截器里写大段复杂流程。具体来说请求拦截器里做的事情是从本地存储模块读取出当前token如果存在就设置到请求头Authorization字段。这里有个细节一定要判断token是否为空字符串不要盲目设置请求头否则后端可能因为收到空凭证而返回非预期错误码。响应拦截器则是核心战场当响应码是401时先判断当前是否已经有“正在进行刷新”的锁有则入队等待没有则主动发起刷新。刷新成功后遍历等待队列逐个重放。2.2 Token存储与并发访问的同步问题鸿蒙应用里本地存储的读写并不是绝对安全的尤其在并发请求同时401、同时去读存储时很容易读到旧值或者半个值。我在封装里单独做了一层TokenStore读写都走同一个同步入口并且把“当前缓存中的token值”常驻内存。刷新成功后立即更新内存值再异步持久化到首选项Preferences。这一步的顺序不能反因为并发请求重放时是从内存读最新token从磁盘读会慢半拍。关于存储的键名我建议用token_type access_token refresh_token分开存不要混成一个JSON。原因很简单刷新返回的分字段更新可以做到只改access_token不动refresh_token而且排查问题时线上能看到具体哪一项丢了不用整包解JSON。2.3 刷新接口的独立配置刷新接口有几个关键参数必须单独处理。第一个是超时时间刷新操作本身是“救火”请求不能跟普通接口一个超时时间我给refreshClient直接设置超时时间短一些且开启withCredentials等必要配置第二个是请求头里的Content-Type刷新接口用表单格式提交grant_typerefresh_token更稳用JSON的话有些服务端框架解析时反而出幺蛾子第三个是必须关闭“自动附加token”的逻辑因为刷新接口用的是refresh_token不是access_token。另外刷新接口的返回体设计我在项目里约定{ code, data: { access_token, refresh_token, expires_in } }拦截器拿到数据后优先检查data.access_token是否非空再决定是否更新存储。这个校验看起来多余但在服务端偶发返回空体或者异常结构时能救你一命。3. 实操过程与核心环节实现3.1 初始化Axios实例和类型声明先搞定类型声明。TypeScript的好处在这里体现得很直接如果你用的鸿蒙版本支持ArkTS尽量把请求配置、响应结构都定义成interface后面写拦截器时类型提示会帮你避开很多低级错误。import axios from ohos/axios; export interface ApiResponseT unknown { code: number; message: string; data: T; } export interface TokenData { access_token: string; refresh_token: string; expires_in: number; }然后是实例创建。核心实例挂默认配置基础URL、超时时间、响应类型。这里我把超时时间设为15秒上传类接口后续单独覆盖避免所有请求默认超时过长导致用户等半天。const httpClient axios.create({ baseURL: https://api.example.com, timeout: 15000, headers: { Content-Type: application/json, }, }); const refreshClient axios.create({ baseURL: https://api.example.com, timeout: 8000, headers: { Content-Type: application/x-www-form-urlencoded, }, });为什么超时时间差异化普通接口复杂业务确实可能需要更多时间但刷新接口就一个鉴权操作后端正常情况下都是毫秒级返回超过8秒基本可以判定网络或服务异常继续等没有意义还拖住了等待队列里那一堆重放请求。3.2 TokenStore实现TokenStore要提供四个能力读取token、写入token、清除token、判断是否登录。读写都走内存缓存避免并发访问时的数据错乱。class TokenStore { private static instance: TokenStore; private accessToken: string ; private refreshToken: string ; private expiresIn: number 0; static getInstance(): TokenStore { if (!TokenStore.instance) { TokenStore.instance new TokenStore(); } return TokenStore.instance; } getAccessToken(): string { return this.accessToken; } getRefreshToken(): string { return this.refreshToken; } setToken(data: TokenData) { this.accessToken data.access_token; this.refreshToken data.refresh_token; this.expiresIn data.expires_in; // 再异步持久化到 Preferences PreferencesUtil.putString(access_token, data.access_token); PreferencesUtil.putString(refresh_token, data.refresh_token); PreferencesUtil.putNumber(expires_in, data.expires_in); } clear() { this.accessToken ; this.refreshToken ; PreferencesUtil.delete(access_token); PreferencesUtil.delete(refresh_token); PreferencesUtil.delete(expires_in); } isLogin(): boolean { return this.accessToken ! this.refreshToken ! ; } }3.3 请求拦截器实现请求拦截器本身不复杂但有一个小细节值得注意判断URL是否在白名单内。有的接口本身是免登录的比如配置拉取、验证码获取它们不该被附加token但也不能因为没有token就拦截掉。我维护了一个skipAuthUrls数组通过url字段匹配命中则直接放行。这样做不是画蛇添足而是避免后台配置类接口在极端情况下因为带上了脏token而无法访问。httpClient.interceptors.request.use((config) { const url config.url ?? ; const skipAuth SKIP_AUTH_URLS.some((item) url.includes(item)); if (!skipAuth) { const token TokenStore.getInstance().getAccessToken(); if (token) { config.headers[Authorization] Bearer ${token}; } } return config; });3.4 响应拦截器与自动刷新流程实现这里是重点。响应拦截器要做的事情等价于“当401发生时的故障转移机制”。我先定义两个全局概念isRefreshing是一个布尔值表示当前是否已经有刷新请求正在进行waitQueue是一个数组装满了在刷新期间被拦截下来的请求配置。当响应拦截器捕获到401错误时流程为判断isRefreshing是否为true如果是直接把当前请求的config推入waitQueue返回一个pending状态的Promise等刷新完成后统一重放。如果不是把isRefreshing置为true调用刷新接口刷新成功则重放waitQueue中所有请求失败则清空队列并跳转登录页。let isRefreshing false; let waitQueue: any[] []; httpClient.interceptors.response.use( (response) { // 兼容业务码非200的情况 const res response.data; if (res.code ! 200) { return Promise.reject(new Error(res.message)); } return res.data; }, async (error) { const { response, config } error; if (response response.status 401) { // 刷新接口自身不可重入 const url config?.url ?? ; if (url.includes(/auth/refresh)) { TokenStore.getInstance().clear(); redirectToLogin(); return Promise.reject(error); } if (isRefreshing) { return new Promise((resolve) { waitQueue.push({ config, resolve, }); }); } isRefreshing true; try { const newToken await refreshToken(); TokenStore.getInstance().setToken(newToken); config.headers[Authorization] Bearer ${newToken.access_token}; waitQueue.forEach((item) { item.config.headers[Authorization] Bearer ${newToken.access_token}; httpClient(item.config).then((res) item.resolve(res)); }); waitQueue []; // 当前这次请求也重放 return httpClient(config); } catch (refreshError) { TokenStore.getInstance().clear(); redirectToLogin(); return Promise.reject(refreshError); } finally { isRefreshing false; } } return Promise.reject(error); } );这段逻辑的核心难点在于“重放时不能用同一个拦截器再触发刷新”我在重放时仍然走httpClient但由于token已经更新理论上不会再次401。万一后端仍返回401比如refreshToken也过期了就会陷入递归。所以我额外加了一个标记重放请求在config._isRetry字段上打标拦截器里如果看到这个标记并且仍是401就不再触发刷新直接失败并引导重新登录。这个细节我不写注释的话后来维护代码的同学一定想打人。3.5 刷新接口封装刷新接口的逻辑很简单但有一个关键点无论刷新成功还是失败都要确保isRefreshing状态能够被正确复位。我在上面用的是try...finally这比在成功和失败分支里分别复位更稳妥因为你永远不知道会不会有意外抛错。async function refreshToken(): PromiseTokenData { const refreshToken TokenStore.getInstance().getRefreshToken(); const params new URLSearchParams(); params.append(grant_type, refresh_token); params.append(refresh_token, refreshToken); const response await refreshClient.post(/auth/refresh, params.toString(), { headers: { Content-Type: application/x-www-form-urlencoded }, }); if (response.data?.code ! 200 || !response.data?.data?.access_token) { throw new Error(refresh token failed); } return response.data.data; }refreshToken函数本身不感知isRefreshing状态这个状态由拦截器层控制。谁调用刷新、何时允许刷新是上层策略刷新逻辑本身只管“拿refresh_token换新token”。这种职责分离的好处是可以单测刷新函数而不需要mock整个拦截器。3.6 并发请求合并刷新的推演假设页面加载时同时发出5个请求恰巧token在10秒前过期了这5个请求都会返回401。如果代码不做并发控制5个401几乎同时到达响应拦截器每个人都会尝试调一次刷新接口那就是5次刷新请求浪费带宽不说还可能出现刷新接口返回新token后后面几次刷新用旧refresh_token去换直接被服务端拒绝。用isRefreshing作为锁后只有第1个401能进入刷新分支后面4个401会直接进入waitQueue。刷新完成后再逐一重放。这里要特别注意第1个请求本身也有一份config要重放不能只救队列里的4个。另外Promise的resolve要正确触发否则业务接口就被吊着永远不返回页面一直转菊花。我测试下来并发20个请求同时401的场景这套机制能保证只发出1次刷新请求剩余19个请求在刷新完成后1秒内全部重放完成用户体感几乎无变化。3.7 登录过期后的降级处理自动刷新不是万能的。refresh_token过期、用户被踢下线、服务端手动吊销token这些场景下刷新接口会返回明确的错误码比如401或4003。这时候就不能再傻傻重放了要做两件事清空本地TokenStore然后跳转登录页。跳转登录页这个动作我不建议在网络层直接router.pushUrl写死因为鸿蒙应用里可能有多个入口栈写死页面路径会跟业务导航耦合。我采用的方式是往外部暴露一个setUnauthorizedHandler方法由应用入口处注入真正的登录跳转逻辑。网络层只管“判定登录态已失效”怎么跳由业务层决定这样后续做深链跳转、单点登录、多端互踢都能灵活适配。4. 工具选型与依赖管理4.1 鸿蒙Axios库的版本与能力边界选Axios之前我曾经犹豫过要不要用ohos.net.http自己封装。后来对比官方文档和社区反馈确认鸿蒙版Axios在API层面完整支持了拦截器、取消请求、超时、自定义适配器等内容而且底层自动适配了鸿蒙的网络栈同步接口和异步接口都有稳定性可以放心。需要注意的是鸿蒙版Axios对上传下载的进度事件支持需要额外配置onUploadProgress和onDownloadProgress这两个回调在部分版本里不支持或者实现有差异如果你要封装文件上传下载一定要先写demo验证。我用的版本是ohos/axios 1.0.x以上具体版本号记不太准了但你尽量不要锁死在太老的版本上尤其是如果你要用AbortController做请求取消的话老版本可能没有对应能力。4.2 依赖注入与模块解耦网络层不该直接依赖业务页面或全局路由。我在封装里通过init(config)方法注入三个回调getRefreshToken、onTokenRefreshed、onUnauthorized分别提供刷新凭证获取、token更新回调、登录失效通知。这样做的好处非常明显如果项目里有多个登录体系比如用户端管理员端可以各自初始化一套网络客户端实例如果以后要迁移到微前端或者模块化开发网络层也能作为一个独立的har包被引用。不要在模块顶层直接写死某个全局函数那是给自己埋雷。5. 常见问题与排查技巧实录5.1 刷新请求死循环问题典型症状页面卡死控制台网络面板看到同一批请求无限循环。原因多半是重放请求时没有加标记或者刷新接口本身也走了带鉴权拦截器的实例导致刷新接口401后又触发刷新逻辑。我自己的排查方法很直接给所有请求的config加一个_isRetry标记在拦截器入口第一步就判断config._isRetry response.status 401是则直接抛错不进入刷新分支。此外刷新接口一定走独立refreshClient实例双保险彻底断开这个环。5.2 多个401并发导致刷新多次有些同学按我上面的思路写完后测并发时发现刷新接口被调了多次。检查点有三个isRefreshing的赋值时机是否在await refreshToken()之前、waitQueue是否有清空操作、是否有多个httpClient实例各自有了状态。另外注意鸿蒙的异步回调时序在某些低端机上有微小概率出现两个函数同时通过if (isRefreshing)判断的情况所以我额外把“刷新中”状态用Promise替代纯布尔值只要第一个请求发起刷新后续请求判断到存在一个pending的promise就直接挂起从JavaScript事件循环层面杜绝了竞态。5.3 重放请求时丢失自定义配置重放请求看起来很简单把config原封不动再调一次httpClient(config)就行。但如果你在请求拦截器里动态修改过headers或者某些配置是拦截器执行后才补充的直接用旧的config重放可能会丢失这些篡改。稳妥做法是请求拦截器里不要直接改原始config对象而是创建副本。或者在进入等待队列之前就把最终要重放的配置快照存起来。我实际踩过的一个坑是请求头里有动态的X-Request-Id每次请求都会重新生成重放时如果不更新后端会认为这是幂等请求返回旧响应导致数据刷新不及时。5.4 网络异常非401拦截自动刷新token只处理401场景。但弱网环境下请求可能因为超时、DNS解析失败、服务端500等原因挂掉。这类错误我不推荐在网络层统一弹toast因为不是所有请求都需要用户感知错误。更好的方式是封装一个统一的错误分类器把错误分为“网络异常”“服务端异常”“鉴权失败”“业务错误”四类业务页面根据类型决定是静默处理、弹提示还是跳转登录。function classifyError(error: any): ErrorType { if (error.response) { switch (error.response.status) { case 401: return AUTH_FAILED; case 500: case 502: case 503: return SERVER_ERROR; default: return BIZ_ERROR; } } if (error.code ECONNABORTED || error.message.includes(timeout)) { return NETWORK_TIMEOUT; } return NETWORK_ERROR; }5.5 文件上传场景下的特殊处理如果项目中涉及图片、文件上传axios封装有几个地方要特别注意。第一上传接口通常要求Content-Type为multipart/form-data但axios会帮你自动设置boundary如果你手动指定了Content-Type反而可能导致服务端解析失败第二上传接口的401处理和普通请求一致但它不适合做“等待重放”因为用户等了半天的上传进度条刷新token后进度条清零重来体验很差。我的做法是上传接口不使用自动刷新重放机制而是在上传前检查token是否即将过期过期就先静默刷新再开始上传流程。这算是自动刷新方案的一种前置变体从源头上避免上传到一半被打断。5.6 排查必备抓包与日志分级鸿蒙应用的网络排查有个特点正式包无法直接像浏览器那样打开DevTools看网络面板。我一般分两层处理开发阶段在拦截器里把请求URL、请求头、响应状态、耗时全部打到日志里格式统一成[HTTP_REQ]前缀方便过滤真机调试阶段用Charles做代理抓包但不要依赖它看鸿蒙内部的异步代码执行顺序网络日志和抓包对照看才能定位是“请求发出去了”还是“响应处理环节出了问题”。注意这里提到Charles仅是流量查看工具请确保符合当地法规与使用规范。特别强调一点绝对不要把token明文打到日志里。打印请求头时一律用token.replace(/^(.{0,6}).*$/, $1***)这种脱敏写法既能确认请求头有没有被正确附加又不会泄露敏感信息。上线前建议把日志级别切到release模式关闭细节打印。6. 拦截器之外接入细节和扩展建议6.1 初始化Token的时机应用冷启动时需要从本地存储恢复token这个动作的时机很关键。不能等第一个请求发出时再读存储因为那可能已经在并发场景里了。我的做法是在应用入口执行TokenStore.getInstance().loadFromStorage()用同步构造函数后立即调用。如果存储里没有tokenisLogin()会直接返回false拦截器链路会跳过附加token的步骤界面停留在登录页即可。这里也顺便解释一下为什么不用异步的Preferences.get返回值直接做判断因为请求拦截器是同步函数你没办法在里面做await搞成异步的话要么改变axios的拦截器签名要么引入额外的复杂度。用内存副本配合启动时同步加载是目前最简单可靠的做法。6.2 对多个业务域的扩展思考一个HTTP客户端封装如果在团队里用得久必然会遇到“不同业务域要不同的BaseURL”“不同环境要不同的超时时间”这些衍生需求。我建议的扩展方案不是复制粘贴创建N个实例而是用一个工厂函数createHttpClient(options)内部统一走拦截器注册逻辑参数差异化。这样既能保证每个实例都有自动刷新token能力又能按需覆盖配置。export function createHttpClient(options: HttpClientOptions) { const client axios.create(options.axiosConfig); const domainKey options.domainKey ?? default; const authHandler new AuthHandler(domainKey); registerInterceptors(client, authHandler); return { instance: client, auth: authHandler, }; }6.3 关于ArkTS的兼容性提醒鸿蒙原生开发如果用的是ArkTS有两点跟常规TypeScript不一样第一ArkTS对类型声明和泛型支持有部分限制axios的AxiosRequestConfig类型可能不能跨包直接使用需要显式声明自己的接口类型第二ArkTS不支持any满天飞必须为请求配置、响应结构定义完整类型。建议封装时在tsconfig.json里开启strict模式宁可写起来烦一点也不要上线后一堆隐式类型错误。7. 优化技巧与后续演进7.1 token刷新时间预判除了靠401被动刷新还可以在请求发出前主动判断token是否即将过期。实现方式是在拿到token时记录expires_at时间戳请求前比较当前时间和过期时间如果剩余时间小于5分钟就先刷新再发请求。这个预判的最大好处是减少“请求先401再重放”的链路耗时在一些延迟敏感的场景下体验差异挺明显。function isTokenExpiringSoon(): boolean { const expiresIn TokenStore.getInstance().getExpiresIn(); if (expiresIn 0) return true; const timeLeft expiresIn - Date.now(); return timeLeft 5 * 60 * 1000; }7.2 刷新失败后的退避策略如果服务端临时故障刷新接口连续失败每次失败都立即跳登录页其实有点粗暴。稍微好一点的做法是连续两次刷新失败后再跳登录页每次刷新失败之间加上退避延迟比如第一次失败等0.5秒重试第二次等1秒重试第三次直接放弃。不过这个策略不建议做太复杂一个简单计数清空状态就够否则没有足够服务端配合时反而更容易出问题。7.3 与页面状态管理的联动自动刷新token后部分页面可能依赖“登录时间”展示业务信息比如会员到期时间、积分有效期。token刷新不一定要强制通知所有页面刷新数据但至少应该提供一个事件总线或者回调机制。我通常会在setToken方法里抛一个tokenUpdated事件页面对登录态有变化的刷新需求时订阅这个事件即可。这样可以保证token更新逻辑对上层业务是可见的而不是完全黑盒。8. 总结与个人心得写到这里核心的自动刷新Token的HTTP客户端封装已经完整呈现了。我个人在实际项目里体会最深的是拦截器设计的本质是“把异常流程变成正常流程”当你把401刷新、并发重放、失败降级做到位后业务代码里看几乎见不到token出现登录态的维护对你透明了这带来的开发体验提升是非常大的。如果你也要在鸿蒙项目里做这套封装我给三个建议第一一定要把TokenStore、刷新函数、拦截器拆成独立模块别图省事全塞在一个文件里后面需求演进时你会感谢这个决定第二并发刷新队列的测试不能只测“成功的路子”要多mock401响应、模拟刷新失败、模拟重放后再次401把异常路径走一遍才算可靠第三上线前做一次“登录态失效全局回归”因为自动刷新机制最怕的问题不是功能不能用而是某个边角场景没有降级而把用户卡死在白屏页。最后再分享一个我自己一直用的习惯每次封装网络层我都会在工程里留一个简单的NetworkTestPage页面里面放几个按钮手动触发已登录请求、过期请求、并发请求、无网请求每次改动完拦截器逻辑花三分钟把所有场景点一遍再提测。这比跟测试同学反复解释“这个流程是自动的你多试几次”高效得多。这套自动刷新token的HTTP客户端封装后续如果条件支持我还会把离线请求队列、网络状态监听、请求幂等内容加进来让网络层真正成为整个应用最扎实的基础设施。
返回列表