鸿蒙HarmonyOS域名解析与双栈缓存容错实战 1. 为什么值得单独聊鸿蒙 HarmonyOS 开发里的网络域名解析做过几个鸿蒙 HarmonyOS 开发项目的朋友大概都有这样的体会写 HTTP 请求很顺http.createHttp()一调数据就回来了域名解析这件事好像从来不需要操心。直到某天接口突然连不上、首屏加载莫名其妙慢了三四秒、或者切到某个网络环境后请求集体超时才会回头去翻这一层——原来域名解析一直躲在封装底下平时不声不响出事就是大事。这篇内容想解决的就是这个环节在鸿蒙 HarmonyOS 开发体系里网络域名解析到底有哪几种常用做法各自的适用边界在哪参数怎么设出错怎么查。我会把ohos.net.connection里的getAddressesByName、ohos.net.http的隐式解析、ohos.net.socket的域名直连这三条路径拆开讲再补上缓存、降级、双栈这些工程化细节。适合已经能跑通 Hello World、准备把网络模块做扎实的初中级开发者也适合带团队做技术选型的人拿去对照。需要先说明一点下面所有代码示例都基于 ArkTS 和 Stage 模型API 以较新的 Network Kit 为准如果你手上的工程还是旧版导入方式我会在对应位置标注差异。文中出现的域名、IP、项目名全部是示意用的占位值不要直接照抄到生产环境。2. 动手之前环境、权限与前置检查2.1 工程依赖与模块导入的两种写法鸿蒙的网络能力集中在 Network Kit 里。新版本工程推荐用 Kit 聚合导入代码更干净import { connection } from kit.NetworkKit; import { http } from kit.NetworkKit; import { socket } from kit.NetworkKit;老工程或者 API 版本偏低的项目里你可能会见到这种写法import connection from ohos.net.connection; import http from ohos.net.http; import socket from ohos.net.socket;两种写法在功能上没有差别区别只在模块寻址方式。我踩过的坑是同一个工程里混用两种导入编译期不报错运行时偶尔出现类型不匹配的告警排查起来很费劲。建议团队统一一种写进代码规范里。另外kit.NetworkKit需要 SDK 版本支持如果编译报“找不到模块”先确认build-profile.json5里的compileSdkVersion不要急着重装依赖。2.2 权限声明少一条都跑不通域名解析相关的权限其实不多但漏一条就是直接抛异常。在module.json5的requestPermissions里至少要有{ module: { requestPermissions: [ { name: ohos.permission.INTERNET }, { name: ohos.permission.GET_NETWORK_INFO } ] } }ohos.permission.INTERNET是所有网络访问的基础getAddressesByName、HTTP 请求、Socket 连接都依赖它而且这个权限是 system_grant 级别声明后安装即生效不需要弹窗申请。ohos.permission.GET_NETWORK_INFO用来读取当前网络状态和连接属性如果你打算做“无网时直接走缓存”这类优化就需要它。注意权限名拼错不会在编译期报错只会在运行时返回 201 或 401 错误码。我见过有同学把INTERNET写成INTERNETS查了一下午。2.3 解析之前先看网络状态直接发起解析不是不行但在弱网或者飞行模式下这一步会白白消耗一次超时时间。更稳的做法是先拿默认网络句柄确认有可用网络再往下走async function hasActiveNetwork(): Promiseboolean { try { const netHandle await connection.getDefaultNet(); if (!netHandle || netHandle.netId 0) { return false; } const props await connection.getConnectionProperties(netHandle); return Array.isArray(props.linkAddresses) props.linkAddresses.length 0; } catch (err) { return false; } }getDefaultNet()返回的netId为 0 时代表当前没有默认网络这是个很实用的判断依据。getConnectionProperties里还能拿到dnses字段也就是当前系统配置的 DNS 服务器地址列表调试解析异常的时候把它打出来能快速判断是“解析服务本身有问题”还是“你的代码有问题”。3. getAddressesByName最直接可控的解析入口3.1 接口签名与返回结构拆解connection.getAddressesByName(host: string): PromiseArrayNetAddress是 Network Kit 里专门做域名解析的接口输入域名输出地址数组。返回的NetAddress结构大致是字段类型含义常见取值addressstring解析出的 IP 字符串203.0.113.10、2001:db8::1familynumber地址族1 表示 IPv42 表示 IPv6portnumber端口通常为 0解析阶段无意义这个接口最大的价值是把解析结果显式交到你手上。你可以自己决定先用哪个 IP、要不要缓存、失败后走哪条备用链路。相比之下HTTP 客户端内部解析完就直接连了中间过程你完全看不见。需要强调的是getAddressesByName返回的是数组而不是单个值原因就是双栈场景下一个域名可能同时有 A 记录和 AAAA 记录。很多新手拿到数组直接取[0]在 IPv4 优先的环境里没问题换到 IPv6 优先的网络里就可能连到一个不通的地址上。后面 3.3 节会专门讲排序。3.2 一段可以直接跑的完整示例下面这段代码我一般拿来当模板包含超时保护、空结果判断和错误分类import { connection } from kit.NetworkKit; import { BusinessError } from kit.BasicServicesKit; interface ResolveResult { host: string; ipv4: string[]; ipv6: string[]; costMs: number; } async function resolveHost(host: string, timeoutMs: number 3000): PromiseResolveResult { const start Date.now(); const timeoutPromise new Promisenever((_, reject) { setTimeout(() reject(new Error(DNS_TIMEOUT)), timeoutMs); }); try { const addresses await Promise.race([ connection.getAddressesByName(host), timeoutPromise ]); if (!addresses || addresses.length 0) { throw new Error(DNS_EMPTY); } const ipv4: string[] []; const ipv6: string[] []; for (const item of addresses) { if (item.family 1) { ipv4.push(item.address); } else if (item.family 2) { ipv6.push(item.address); } } return { host, ipv4, ipv6, costMs: Date.now() - start }; } catch (err) { const e err as BusinessError; console.error(resolve failed host${host} code${e.code} msg${e.message}); throw err; } }这里有个细节值得说一下getAddressesByName本身没有 timeout 参数它在系统层面走的是底层解析器超时行为跟当前网络环境相关。所以在业务层用Promise.race包一层是必要的否则在某些异常网络下这个调用可能挂很久才返回。3000ms 是我在多次实测后定下来的经验值再短容易在弱网下误判再长用户已经能感知到卡顿了。3.3 family 与排序别把数组当单值用前面提到[0]的风险这里展开讲。系统返回的地址数组顺序并不保证“可用性优先”它更多反映的是解析器拿到记录的顺序。一个稳妥的处理策略是先按family分组IPv4 一组、IPv6 一组结合当前网络的linkAddresses判断本机是否有 IPv6 地址没有就只用 IPv4 组组内做一次可用性探测比如 TCP 握手计时按耗时排序。第三步成本较高适合对延迟敏感的场景比如音视频首帧、长连接建连。普通业务接口用前两步就够了。我做过一个对比测试在双栈环境下随机取[0]和按上述策略取地址建连失败率差距能到一个数量级尤其是移动网络切换的瞬间。3.4 并发与频率控制getAddressesByName不是免费的。频繁调用会触发系统解析器的排队反而拖慢整体响应。我的做法是在业务层加一层并发闸门同一个 host 同时只允许一个解析请求在飞其他调用方等待同一个 Promise。const inflight new Mapstring, PromiseResolveResult(); function resolveHostDedup(host: string): PromiseResolveResult { const existing inflight.get(host); if (existing) { return existing; } const task resolveHost(host).finally(() { inflight.delete(host); }); inflight.set(host, task); return task; }这段逻辑看着简单效果很直接。列表页里十几个卡片同时请求同一个图片域名没有去重就是十几次解析有了去重实际只解析一次。实测下来首屏解析耗时能降一半以上。实操心得去重 Map 一定要在finally里清理否则解析失败后这个 host 会被永久锁死后续请求全部拿到同一个失败的 Promise。这个坑我踩过线上表现为“偶发某个域名永久不可用”重启应用才恢复。4. 让 HTTP 客户端替你解析省事但要知道代价4.1 http 模块的隐式解析流程绝大多数业务代码其实是这么写的const httpRequest http.createHttp(); const resp await httpRequest.request(https://api.example.com/v1/list, { method: http.RequestMethod.GET, connectTimeout: 10000, readTimeout: 15000, expectDataType: http.HttpDataType.STRING });在这行request背后系统依次做了解析域名、建立 TCP 连接、TLS 握手、发送请求、读响应。域名解析是第一步由底层网络栈自动完成你在 ArkTS 层拿不到任何中间结果。这种方式的好处显而易见代码量最少错误处理集中连接复用由底层管理。对大部分业务接口来说这就是正确选择。没必要为了“掌控感”把每个请求都拆成手工解析加手工连接那样维护成本会翻倍。4.2 什么时候该用它什么时候不该我整理了一个判断表团队里新人上手时按这个走基本不会选错场景推荐方式原因普通 REST 接口调用http 模块底层自动复用连接代码最简需要按 IP 做灰度或就近调度getAddressesByName需要拿到 IP 做业务判断长连接、自定义协议socket 域名直连http 模块不适用需要预解析加速首屏getAddressesByName 缓存提前把结果存起来需要统计解析耗时getAddressesByNamehttp 模块不暴露该指标上传下载大文件http 模块配合断点续传连接管理成熟关键判断点是你是否需要看见解析结果。看不见就用 http 模块看得见并且要用才上getAddressesByName。我看过一些项目为了“性能优化”把所有请求都改成手工解析加手工连接结果连接复用没了TLS 握手次数翻倍整体反而更慢。4.3 连接复用与 DNS 缓存的联动http 模块内部会维持连接池同一个 host 的连接会被复用。这意味着域名解析实际上不是每次请求都发生的只有连接池里没有可用连接时才会触发。这个机制带来一个容易忽略的现象你改了 DNS 记录应用侧可能几分钟甚至更久才生效因为旧连接还活着。做灰度发布或者切换后端地址时如果发现“部分用户还在访问旧 IP”先别怀疑 DNS 传播先想想连接池。解决办法是在服务端下发切换指令时主动销毁旧连接——http 模块提供了destroy()方法调用后连接池清空下次请求会重新解析。5. Socket 层的域名直连与双栈实践5.1 TCP/UDP 传域名的实际行为ohos.net.socket的connect方法里address字段既可以是 IP也可以是域名import { socket } from kit.NetworkKit; async function connectByHost(host: string, port: number): Promisesocket.TCPSocket { const tcp socket.constructTCPSocketInstance(); await tcp.connect({ address: { address: host, port: port, family: 1 }, timeout: 8000 }); return tcp; }注意family字段。传 1 表示期望 IPv4传 2 表示期望 IPv6传 0 或者不传则由底层决定。这里有个行为差异需要留意当你传域名且family固定为 1 时如果该域名只有 AAAA 记录连接会直接失败不会回退。所以在双栈环境里family最好不要写死或者写死之后要有失败重试另一族的逻辑。5.2 IPv6 优先与失败回退的思路现在不少网络环境是 IPv6 优先的但 IPv6 的可达性并不总是可靠。业界比较成熟的做法叫 Happy Eyeballs核心思路是同时发起 IPv6 和 IPv4 的连接尝试谁先成功用谁另一条取消。在鸿蒙上实现完整版需要自己管理两个 socket 实例和竞速逻辑代码量不小。如果不想做得这么重一个折中方案是串行回退先试 IPv62 秒内没连上就切 IPv4。这个方案实现简单代价是 IPv6 不通时首连会多等 2 秒。我的经验是2 秒这个阈值对大多数业务可接受比完整竞速方案省下的维护成本更划算。5.3 一个双栈探测的小工具实际项目里我会写一个探测函数缓存“这个 host 在当前网络下哪一族可用”避免每次都试错const familyCache new Mapstring, number(); async function probeFamily(host: string, port: number): Promisenumber { const cached familyCache.get(host); if (cached ! undefined) { return cached; } const candidates [2, 1]; for (const family of candidates) { try { const tcp socket.constructTCPSocketInstance(); await tcp.connect({ address: { address: host, port, family }, timeout: 2000 }); await tcp.close(); familyCache.set(host, family); return family; } catch (e) { continue; } } throw new Error(NO_REACHABLE_FAMILY); }缓存的失效时机很关键网络切换、应用从后台回前台、用户手动重试这三个时机都应该清空familyCache。否则在 Wi-Fi 切到移动数据后缓存里的判断可能已经失效了。6. 缓存、降级与容错让解析真正稳定6.1 本地 DNS 缓存表怎么设计系统本身有 DNS 缓存但它的 TTL 由服务端记录决定你控制不了。业务层再叠一层缓存主要目的不是省解析次数而是在解析失败时有兜底数据。缓存结构我一般这么设计字段说明host域名作为主键addresses地址数组含 familyexpireAt过期时间戳取解析时刻加自定义 TTLlastSuccessAt最近一次成功时间用于判断数据新鲜度source来源标记区分实时解析和兜底数据自定义 TTL 建议取 60 到 300 秒之间。太短没意义太长在服务端切 IP 时容易踩坑。lastSuccessAt的作用是即使缓存已过期如果本次解析失败仍然可以拿它兜底但要在业务层标记“降级模式”比如提示用户网络异常。6.2 多入口降级链路一个健壮的解析链路应该是这样的顺序查内存缓存未过期直接用调getAddressesByName实时解析解析失败查持久化缓存比如存到首选项里持久化缓存也没有走预置的兜底 IP 列表。第四步在很多团队里会被忽略。预置兜底 IP 的风险是 IP 变更后失效但它至少能保证“服务端完全挂掉时应用不是白屏”。我的做法是把兜底 IP 做成可远程下发的配置随其他配置一起更新这样既保留了兜底能力又不至于写死。6.3 网络切换时的缓存处理网络切换是 DNS 相关故障的高发场景。Wi-Fi 和移动数据用的是不同的 DNS 服务器解析结果可能完全不同尤其是做内网分流的环境。处理方式是在connection模块注册网络状态监听connection.on(netAvailable, (data) { // 网络可用清空族缓存保留地址缓存 }); connection.on(netLost, (data) { // 网络断开标记缓存为待验证 });我的策略是网络变化时清空familyCache因为可达性判断必然失效但地址缓存打个标记而不是直接删。这样在网络短暂抖动时还能用旧地址快速重连避免用户感知到卡顿。注意监听器要在页面销毁时用connection.off注销否则会内存泄漏。这个在长生命周期页面里不明显在频繁进出的页面里累积起来很致命。7. 常见问题与排查实录7.1 问题速查表现象可能原因排查动作返回 201 错误码缺少 INTERNET 权限检查 module.json5返回空数组域名拼写错误或记录不存在换设备验证同一域名解析成功但连接超时解析到的 IP 不可达打印 IP逐一探测首次请求慢后续正常系统 DNS 缓存未命中看首次解析耗时切换网络后请求失败缓存了旧网络的地址检查网络切换监听IPv6 环境连接失败family 写死为 1改为自动或加回退偶发某域名永久失败去重 Map 未清理检查 finally 分支7.2 几个印象比较深的坑第一个坑是域名末尾的点。有些配置系统里域名写成api.example.com.末尾带点表示绝对域名在部分解析器里能正常工作在另一些环境里会返回空。我建议在解析入口统一做一次规范化转小写、去掉末尾的点、去掉协议前缀。看着是小事实际能省掉大量沟通成本。第二个坑是解析成功但端口不通。getAddressesByName只负责把域名变成 IP完全不关心这个 IP 上的服务是否活着。有一回线上故障解析全部正常日志里 IP 也打印出来了但连接就是超时。最后发现是服务端某台机器的防火墙规则漏配。教训是解析成功不等于服务可用健康检查要单独做。第三个坑是测试环境的 hosts 干扰。开发机上的 hosts 文件如果配了测试域名映射应用跑在模拟器里可能读到的是宿主的配置表现和真机不一致。排查这类问题时第一件事是把解析结果打出来对比别急着怀疑代码。7.3 日志与调试手段解析相关的日志我建议至少记录四类信息域名、耗时、结果数量、地址族分布。格式统一成一行方便 grepconsole.info([DNS] host${host} cost${cost} count${addresses.length} v4${ipv4.length} v6${ipv6.length});调试时用hdc连设备抓日志过滤[DNS]前缀即可。如果要看系统层面的解析行为getConnectionProperties返回的dnses字段能告诉你当前用的是哪台 DNS 服务器这个信息在定位“为什么只有某个网络环境下解析慢”时特别有用。另外提一句抓包。解析过程本身是 UDP 53 端口的明文交互除非用了加密解析抓包能看到完整的请求和响应。但要注意生产环境抓包涉及用户数据必须走合规流程别在未经授权的情况下操作。8. 把解析封装成一个可复用的工具类8.1 类结构设计散落在各处的解析调用最后一定会失控我的做法是收敛到一个类里对外只暴露三个方法resolve、preload、clear。class DnsResolver { private cache: Mapstring, ResolveResult new Map(); private inflight: Mapstring, PromiseResolveResult new Map(); async resolve(host: string): PromiseResolveResult { /* ... */ } async preload(hosts: string[]): Promisevoid { /* ... */ } clear(): void { /* ... */ } }preload是很容易被低估的方法。应用启动时把首屏会用到的几个域名一次性预热用户点进页面时缓存已经就绪体感上会快不少。预热要控制数量我一般限制在 5 个以内太多反而拖慢启动。8.2 值得埋的几个指标如果团队有监控体系解析层至少上报这几个指标解析成功率、P50 和 P95 耗时、空结果率、降级触发次数。其中降级触发次数最能反映真实网络质量——它涨了说明有一部分用户已经在用兜底数据了这时候该去看看是不是某些地区的解析出了问题。8.3 一个容易忽略的细节应用生命周期应用切到后台再回前台网络环境可能已经变了。我的处理是在onForeground里做一次轻量校验拿缓存里最老的 host 重新解析一次对比结果是否变化。变了就清空整个缓存。这个动作成本很低但能避免大量“回前台后第一个请求失败”的投诉。最后分享一个我在实际项目中形成的习惯任何跟网络相关的工具类我都会给它加一个“手动触发全量刷新”的调试入口藏在开发者选项里。线上出问题时让用户点一下这个入口比让他重启应用有效得多也更容易定位问题到底是缓存还是真实网络。这个入口的实现成本不到二十行代码但救场的次数我自己都数不清了。