当前位置: 首页 > news >正文

HarmonyOS7 网络层怎么封才不烂尾?HttpService、拦截器、重试、缓存一套讲清

文章目录

    • 前言
    • 为什么要统一网络层
    • 拦截器链设计
    • 请求拦截器:Token 注入 + 签名
    • 响应拦截器:错误码处理 + Token 自动刷新
    • 请求重试策略
    • GET 请求缓存 + 过期策略
    • HttpService 主类:把所有东西串起来
    • 业务层用起来
    • 一些实用建议

前言

写过几个鸿蒙项目之后,你会发现一个很痛的问题:网络请求代码散落在各个页面和 ViewModel 里,到处是重复的 Token 拼接、错误处理、loading 状态管理。改一个接口地址要全局搜索替换,加一个签名逻辑要改十几个文件。

这篇文章我把网络层彻底收拢到一个HttpService里,拦截器、重试、缓存一把搞定,后面所有业务都只跟这一个入口打交道。

为什么要统一网络层

分散的网络请求有这些坑:

  • Token 过期了,每个请求各自处理刷新逻辑,容易出现并发刷新

  • 接口报错,有的页面弹 Toast,有的静默失败,体验不一致
  • 弱网环境下没有重试,用户只能手动下拉刷新
  • 同一个 GET 接口短时间内重复请求,浪费流量和服务器资源

统一网络层的核心目标就一个:让业务代码只关心"请求什么数据",不关心"怎么请求"。

拦截器链设计

拦截器思路来自 OkHttp,鸿蒙虽然没有这个库,但模式可以自己实现。核心就是一个数组,请求前走一遍请求拦截器,响应后走一遍响应拦截器。

先定义拦截器接口:

// 拦截器接口定义exportinterfaceHttpInterceptor{onRequest?(config:RequestConfig):Promise<RequestConfig>;onResponse?(response:HttpResponse):Promise<HttpResponse>;onError?(error:HttpError):Promise<HttpError>;}exportinterfaceRequestConfig{url:string;method:string;headers:Record<string,string>;params?:Record<string,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:string='your_app_secret';asynconRequest(config:RequestConfig):Promise<RequestConfig>{// 注入 Tokenconsttoken=awaitthis.getToken();if(token){config.headers['Authorization']=`Bearer${token}`;}// 生成签名consttimestamp=Date.now().toString();constsignStr=`${timestamp}${this.appSecret}`;constsign=awaitthis.hmacSha256(signStr);config.headers['X-Timestamp']=timestamp;config.headers['X-Sign']=sign;returnconfig;}privateasyncgetToken():Promise<string|null>{constcontext=getContext(this)ascommon.UIAbilityContext;constprefs=awaitpreferences.getPreferences(context,'auth_store');returnprefs.getSync('access_token','')asstring;}privateasynchmacSha256(data:string):Promise<string>{consthmacAlg=cryptoFramework.createHmac({algName:'sha256'});// 简化示例,实际需要用密钥初始化constresult=awaithmacAlg.update(data);returnresult.toString();}}

响应拦截器:错误码处理 + Token 自动刷新

响应拦截器的重头戏是 Token 刷新。这里有个坑必须处理:多个请求同时收到 401,不能同时发多个刷新请求。用一个 Promise 锁来搞定。

exportclassTokenRefreshInterceptorimplementsHttpInterceptor{privateisRefreshing:boolean=false;privaterefreshPromise:Promise<string>|null=null;asynconResponse(response:HttpResponse):Promise<HttpResponse>{// Token 过期,自动刷新if(response.code===401){constnewToken=awaitthis.refreshToken();// 刷新成功后抛出特殊标记,让 HttpService 重试原始请求throw{code:-1,message:'token_refreshed',retry:true}asHttpError;}// 业务错误码统一处理if(response.code!==200&&response.code!==0){throw{code:response.code,message:response.message||'未知错误',config:{}asRequestConfig}asHttpError;}returnresponse;}privateasyncrefreshToken():Promise<string>{// 防止并发刷新if(this.isRefreshing){returnthis.refreshPromise!;}this.isRefreshing=true;this.refreshPromise=newPromise<string>(async(resolve,reject)=>{try{constcontext=getContext()ascommon.UIAbilityContext;constprefs=awaitpreferences.getPreferences(context,'auth_store');constrefreshToken=prefs.getSync('refresh_token','')asstring;constresult=awaithttp.createHttp().request('https://api.example.com/auth/refresh',{method:http.RequestMethod.POST,extraData:{refresh_token:refreshToken}});constdata=JSON.parse(result.resultasstring)asRecord<string,string>;awaitprefs.put('access_token',data['access_token']);awaitprefs.flush();resolve(data['access_token']);}catch(e){// 刷新失败,踢用户到登录页reject(e);}finally{this.isRefreshing=false;this.refreshPromise=null;}});returnthis.refreshPromise;}}

请求重试策略

弱网环境太常见了,地铁里、电梯里都可能断网。自动重试能显著提升用户体验。我用指数退避策略,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。

privateasyncrequestWithRetry(config:RequestConfig):Promise<HttpResponse>{constmaxRetries=config.retryCount??3;letlastError:HttpError|null=null;for(letattempt=0;attempt<=maxRetries;attempt++){try{returnawaitthis.doRequest(config);}catch(error){lastError=errorasHttpError;// 只对网络错误重试,业务错误不重试if(!this.isRetryable(errorasHttpError)){throwerror;}if(attempt<maxRetries){constdelay=Math.pow(2,attempt)*1000;// 指数退避awaitthis.sleep(delay);console.info(`[HttpService] 重试第${attempt+1}次,等待${delay}ms`);}}}throwlastError!;}privateisRetryable(error:HttpError):boolean{// 网络超时、连接失败、5xx 服务端错误可以重试returnerror.code===-1||error.code===-2||(error.code>=500&&error.code<600);}privatesleep(ms:number):Promise<void>{returnnewPromise(resolve=>setTimeout(resolve,ms));}

GET 请求缓存 + 过期策略

对于不经常变化的数据(比如配置信息、分类列表),缓存一下能省不少请求。用一个简单的 Map + 过期时间来实现。

interfaceCacheEntry{data:HttpResponse;expireAt:number;}exportclassHttpCacheManager{privatecache:Map<string,CacheEntry>=newMap();privatedefaultTTL:number=5*60*1000;// 默认 5 分钟get(key:string):HttpResponse|null{constentry=this.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:HttpCacheManager=newHttpCacheManager();privatebaseUrl:string;constructor(baseUrl:string){this.baseUrl=baseUrl;}addInterceptor(interceptor:HttpInterceptor):HttpService{this.interceptors.push(interceptor);returnthis;}asyncget<T>(url:string,params?:Record<string,Object>,options?:Partial<RequestConfig>):Promise<T>{constconfig:RequestConfig={url:this.baseUrl+url,method:'GET',headers:{},params,...options};// 检查缓存if(config.cache!==false){constcached=this.cacheManager.get(config.url+JSON.stringify(params??{}));if(cached)returncached.dataasT;}constresponse=awaitthis.requestWithRetry(config);// 缓存 GET 响应if(config.cache!==false){this.cacheManager.set(config.url+JSON.stringify(params??{}),response,config.cacheTTL);}returnresponse.dataasT;}asyncpost<T>(url:string,body?:Object,options?:Partial<RequestConfig>):Promise<T>{constconfig:RequestConfig={url:this.baseUrl+url,method:'POST',headers:{'Content-Type':'application/json'},body,...options};constresponse=awaitthis.requestWithRetry(config);returnresponse.dataasT;}privateasyncdoRequest(config:RequestConfig):Promise<HttpResponse>{// 执行请求拦截器链letprocessedConfig=config;for(constinterceptorofthis.interceptors){if(interceptor.onRequest){processedConfig=awaitinterceptor.onRequest(processedConfig);}}// 发起实际请求consthttpRequest=http.createHttp();constresult=awaithttpRequest.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){response=awaitinterceptor.onResponse(response);}}returnresponse;}}// 全局单例 + 初始化exportconsthttpService=newHttpService('https://api.example.com').addInterceptor(newAuthInterceptor()).addInterceptor(newTokenRefreshInterceptor());

业务层用起来

封装完之后,业务代码变得特别干净:

// 在 ViewModel 或 Page 中使用interfaceUserInfo{name:string;avatar:string;level:number;}asyncfunctionloadUserInfo(){try{constdata=awaithttpService.get<UserInfo>('/user/profile',undefined,{cache:true,cacheTTL:10*60*1000// 缓存 10 分钟});this.userName=data.name;this.userAvatar=data.avatar;}catch(error){// 错误已经被拦截器处理过,这里只需要关心 UI 降级this.showErrorState=true;}}

一些实用建议

用了这套封装之后,我有几点感受比较深:

拦截器顺序很重要。Token 注入要在签名之前,Token 刷新要在业务错误码处理之前。顺序搞反了会出奇怪的 bug。

重试别太激进。最多 3 次,一定要用指数退避。我见过有人写死循环重试,直接把服务端打爆了。

缓存的 key 要精心设计。简单的 URL + 参数拼接对于大多数场景够用了,但如果参数里有时间戳之类的动态值,要做特殊处理,否则缓存永远命中不了。

Token 刷新的并发控制是关键。不用 Promise 锁的话,一个页面 5 个请求同时 401,就会发 5 个刷新请求,后面的刷新请求用的是已经失效的 refresh_token,全部失败,用户直接被踢到登录页。这个问题我调了一下午才发现。

http://www.jsqmd.com/news/1096528/

相关文章:

  • 从原理到选型:5大主流LED调光技术深度解析
  • 从JSON到清晰时序:WaveDrom在数字设计中的高效波形绘制实战
  • 从零到一:SkyWalking 9.x 与 Elasticsearch 8.x 生产环境部署实战
  • 七人拼团小程序:社交电商新玩法
  • 基因编辑产业化:从科研探索到临床应用,重构生命健康产业底层逻辑
  • 抖音内容自动化采集工具深度解析:架构设计与实战应用
  • 构建企业级权限管理平台:ZR.Admin.NET跨平台RBAC解决方案实战指南
  • 运营商 GenAI 数据安全赛道厂商分层与核心能力对比研究
  • HarmonyOS7 RenderSlot 为什么越用越香?可插拔组件设计一次讲明白
  • COMSOL后处理实战:精准提取动态接触面积
  • 算法:删除有序数组的重复项
  • Web身份验证漏洞攻防实战:从暴力破解到MFA绕过的全面防御指南
  • 从CT灰度到力学模型:Mimics中股骨多材料属性赋予的完整实践
  • STM32F407ZET6 SysTick延时:从寄存器配置到传感器精准触发的实战解析
  • 抖音直播录制神器:3步快速部署40+平台自动录制完整指南
  • VMware运维工具箱:从RVTools到PowerCLI的实战利器盘点
  • TinyML 推理引擎:从模型量化到 MCU 级部署的极致内存优化
  • 你玩的游戏,可能正在帮外国军队扫描你的国家
  • 【万字文档+源码】基于springboot+vue茶叶商城管理系统-可用于毕设-课程设计-练手学习-学习资料分享
  • Delphi 实战:从阻塞到流式,解锁OpenAI API异步调用与实时响应
  • 英雄联盟Akari助手:3分钟快速上手的游戏效率工具终极指南
  • 一行命令让 AI Agent 看遍全网:Agent-Reach 全平台数据源扩展实战
  • 从 1 台到 10 台:无人售货柜的规模化复制
  • Windows 11 系统盘越用越小怎么办?存储感知 DISM Compact OS 等专属工具详解
  • 论文AI写作软件推荐哪个好?2026年度榜单
  • WWW 2024 | 图嵌入新范式:从LINE到大规模动态网络的表示学习
  • 在Java中,如何使用break和continue关键字来控制循环?
  • 记录redis学习
  • 别再硬编码密钥了!Spring Boot项目实战:用配置文件安全管理AES256加解密密钥
  • 大模型 AGI 开发模式:从概念到落地的系统性技术解构