HarmonyOS7 网络层怎么封才不烂尾?HttpService、拦截器、重试、缓存一套讲清
文章目录前言为什么要统一网络层拦截器链设计请求拦截器Token 注入 签名响应拦截器错误码处理 Token 自动刷新请求重试策略GET 请求缓存 过期策略HttpService 主类把所有东西串起来业务层用起来一些实用建议前言写过几个鸿蒙项目之后你会发现一个很痛的问题网络请求代码散落在各个页面和 ViewModel 里到处是重复的 Token 拼接、错误处理、loading 状态管理。改一个接口地址要全局搜索替换加一个签名逻辑要改十几个文件。这篇文章我把网络层彻底收拢到一个HttpService里拦截器、重试、缓存一把搞定后面所有业务都只跟这一个入口打交道。为什么要统一网络层分散的网络请求有这些坑Token 过期了每个请求各自处理刷新逻辑容易出现并发刷新接口报错有的页面弹 Toast有的静默失败体验不一致弱网环境下没有重试用户只能手动下拉刷新同一个 GET 接口短时间内重复请求浪费流量和服务器资源统一网络层的核心目标就一个让业务代码只关心请求什么数据不关心怎么请求。拦截器链设计拦截器思路来自 OkHttp鸿蒙虽然没有这个库但模式可以自己实现。核心就是一个数组请求前走一遍请求拦截器响应后走一遍响应拦截器。先定义拦截器接口// 拦截器接口定义exportinterfaceHttpInterceptor{onRequest?(config:RequestConfig):PromiseRequestConfig;onResponse?(response:HttpResponse):PromiseHttpResponse;onError?(error:HttpError):PromiseHttpError;}exportinterfaceRequestConfig{url:string;method:string;headers:Recordstring,string;params?:Recordstring,Object;body?:Object;timeout?:number;retryCount?:number;cache?:boolean;cacheTTL?:number;}exportinterfaceHttpResponse{code:number;data:Object;message:string;rawResponse:http.HttpResponse;}exportinterfaceHttpError{code:number;message:string;config:RequestConfig;rawError?:Error;}请求拦截器Token 注入 签名请求拦截器最常用的场景就是往 header 里塞 Token 和签名。Token 从 Preferences 里读签名用时间戳 AppSecret 做 HMAC。exportclassAuthInterceptorimplementsHttpInterceptor{privateappSecret:stringyour_app_secret;asynconRequest(config:RequestConfig):PromiseRequestConfig{// 注入 Tokenconsttokenawaitthis.getToken();if(token){config.headers[Authorization]Bearer${token};}// 生成签名consttimestampDate.now().toString();constsignStr${timestamp}${this.appSecret};constsignawaitthis.hmacSha256(signStr);config.headers[X-Timestamp]timestamp;config.headers[X-Sign]sign;returnconfig;}privateasyncgetToken():Promisestring|null{constcontextgetContext(this)ascommon.UIAbilityContext;constprefsawaitpreferences.getPreferences(context,auth_store);returnprefs.getSync(access_token,)asstring;}privateasynchmacSha256(data:string):Promisestring{consthmacAlgcryptoFramework.createHmac({algName:sha256});// 简化示例实际需要用密钥初始化constresultawaithmacAlg.update(data);returnresult.toString();}}响应拦截器错误码处理 Token 自动刷新响应拦截器的重头戏是 Token 刷新。这里有个坑必须处理多个请求同时收到 401不能同时发多个刷新请求。用一个 Promise 锁来搞定。exportclassTokenRefreshInterceptorimplementsHttpInterceptor{privateisRefreshing:booleanfalse;privaterefreshPromise:Promisestring|nullnull;asynconResponse(response:HttpResponse):PromiseHttpResponse{// Token 过期自动刷新if(response.code401){constnewTokenawaitthis.refreshToken();// 刷新成功后抛出特殊标记让 HttpService 重试原始请求throw{code:-1,message:token_refreshed,retry:true}asHttpError;}// 业务错误码统一处理if(response.code!200response.code!0){throw{code:response.code,message:response.message||未知错误,config:{}asRequestConfig}asHttpError;}returnresponse;}privateasyncrefreshToken():Promisestring{// 防止并发刷新if(this.isRefreshing){returnthis.refreshPromise!;}this.isRefreshingtrue;this.refreshPromisenewPromisestring(async(resolve,reject){try{constcontextgetContext()ascommon.UIAbilityContext;constprefsawaitpreferences.getPreferences(context,auth_store);constrefreshTokenprefs.getSync(refresh_token,)asstring;constresultawaithttp.createHttp().request(https://api.example.com/auth/refresh,{method:http.RequestMethod.POST,extraData:{refresh_token:refreshToken}});constdataJSON.parse(result.resultasstring)asRecordstring,string;awaitprefs.put(access_token,data[access_token]);awaitprefs.flush();resolve(data[access_token]);}catch(e){// 刷新失败踢用户到登录页reject(e);}finally{this.isRefreshingfalse;this.refreshPromisenull;}});returnthis.refreshPromise;}}请求重试策略弱网环境太常见了地铁里、电梯里都可能断网。自动重试能显著提升用户体验。我用指数退避策略第一次等 1 秒第二次等 2 秒第三次等 4 秒最多重试 3 次。privateasyncrequestWithRetry(config:RequestConfig):PromiseHttpResponse{constmaxRetriesconfig.retryCount??3;letlastError:HttpError|nullnull;for(letattempt0;attemptmaxRetries;attempt){try{returnawaitthis.doRequest(config);}catch(error){lastErrorerrorasHttpError;// 只对网络错误重试业务错误不重试if(!this.isRetryable(errorasHttpError)){throwerror;}if(attemptmaxRetries){constdelayMath.pow(2,attempt)*1000;// 指数退避awaitthis.sleep(delay);console.info([HttpService] 重试第${attempt1}次等待${delay}ms);}}}throwlastError!;}privateisRetryable(error:HttpError):boolean{// 网络超时、连接失败、5xx 服务端错误可以重试returnerror.code-1||error.code-2||(error.code500error.code600);}privatesleep(ms:number):Promisevoid{returnnewPromise(resolvesetTimeout(resolve,ms));}GET 请求缓存 过期策略对于不经常变化的数据比如配置信息、分类列表缓存一下能省不少请求。用一个简单的 Map 过期时间来实现。interfaceCacheEntry{data:HttpResponse;expireAt:number;}exportclassHttpCacheManager{privatecache:Mapstring,CacheEntrynewMap();privatedefaultTTL:number5*60*1000;// 默认 5 分钟get(key:string):HttpResponse|null{constentrythis.cache.get(key);if(!entry)returnnull;if(Date.now()entry.expireAt){this.cache.delete(key);returnnull;}returnentry.data;}set(key:string,data:HttpResponse,ttl?:number):void{this.cache.set(key,{data,expireAt:Date.now()(ttl??this.defaultTTL)});}// 清除指定前缀的缓存invalidate(prefix:string):void{for(constkeyofthis.cache.keys()){if(key.startsWith(prefix)){this.cache.delete(key);}}}clear():void{this.cache.clear();}}HttpService 主类把所有东西串起来最后把拦截器、重试、缓存组装到一起exportclassHttpService{privateinterceptors:HttpInterceptor[][];privatecacheManager:HttpCacheManagernewHttpCacheManager();privatebaseUrl:string;constructor(baseUrl:string){this.baseUrlbaseUrl;}addInterceptor(interceptor:HttpInterceptor):HttpService{this.interceptors.push(interceptor);returnthis;}asyncgetT(url:string,params?:Recordstring,Object,options?:PartialRequestConfig):PromiseT{constconfig:RequestConfig{url:this.baseUrlurl,method:GET,headers:{},params,...options};// 检查缓存if(config.cache!false){constcachedthis.cacheManager.get(config.urlJSON.stringify(params??{}));if(cached)returncached.dataasT;}constresponseawaitthis.requestWithRetry(config);// 缓存 GET 响应if(config.cache!false){this.cacheManager.set(config.urlJSON.stringify(params??{}),response,config.cacheTTL);}returnresponse.dataasT;}asyncpostT(url:string,body?:Object,options?:PartialRequestConfig):PromiseT{constconfig:RequestConfig{url:this.baseUrlurl,method:POST,headers:{Content-Type:application/json},body,...options};constresponseawaitthis.requestWithRetry(config);returnresponse.dataasT;}privateasyncdoRequest(config:RequestConfig):PromiseHttpResponse{// 执行请求拦截器链letprocessedConfigconfig;for(constinterceptorofthis.interceptors){if(interceptor.onRequest){processedConfigawaitinterceptor.onRequest(processedConfig);}}// 发起实际请求consthttpRequesthttp.createHttp();constresultawaithttpRequest.request(processedConfig.url,{method:processedConfig.methodashttp.RequestMethod,header:processedConfig.headers,extraData:processedConfig.body??processedConfig.params,connectTimeout:processedConfig.timeout??15000,readTimeout:processedConfig.timeout??15000,});letresponse:HttpResponse{code:result.responseCode,data:JSON.parse(result.resultasstring),message:,rawResponse:result};// 执行响应拦截器链for(constinterceptorofthis.interceptors){if(interceptor.onResponse){responseawaitinterceptor.onResponse(response);}}returnresponse;}}// 全局单例 初始化exportconsthttpServicenewHttpService(https://api.example.com).addInterceptor(newAuthInterceptor()).addInterceptor(newTokenRefreshInterceptor());业务层用起来封装完之后业务代码变得特别干净// 在 ViewModel 或 Page 中使用interfaceUserInfo{name:string;avatar:string;level:number;}asyncfunctionloadUserInfo(){try{constdataawaithttpService.getUserInfo(/user/profile,undefined,{cache:true,cacheTTL:10*60*1000// 缓存 10 分钟});this.userNamedata.name;this.userAvatardata.avatar;}catch(error){// 错误已经被拦截器处理过这里只需要关心 UI 降级this.showErrorStatetrue;}}一些实用建议用了这套封装之后我有几点感受比较深拦截器顺序很重要。Token 注入要在签名之前Token 刷新要在业务错误码处理之前。顺序搞反了会出奇怪的 bug。重试别太激进。最多 3 次一定要用指数退避。我见过有人写死循环重试直接把服务端打爆了。缓存的 key 要精心设计。简单的 URL 参数拼接对于大多数场景够用了但如果参数里有时间戳之类的动态值要做特殊处理否则缓存永远命中不了。Token 刷新的并发控制是关键。不用 Promise 锁的话一个页面 5 个请求同时 401就会发 5 个刷新请求后面的刷新请求用的是已经失效的 refresh_token全部失败用户直接被踢到登录页。这个问题我调了一下午才发现。