Skip to content

hana-music-api 架构详解 ​

假设你要搜索一首歌,调用的是 hana.search({ keywords: '海阔天空' })。从这行代码到网易云返回结果,中间会检查参数、准备身份、处理缓存、加密请求,还要照顾超时和取消。

这些工作由同一条内部执行流程完成。SDK 对外仍返回 Promise,你可以照常用 async/await。维护仓库里的模块时,才需要接触 Effect。

从三个入口进入 ​

SDK client、具名函数和 invokeModule 都会执行对应的业务模块。HTTP 服务先解析请求,再执行同一套模块。createRequest 则让你直接提供上游路径和数据,跳过模块的业务参数转换。

图里先省略了缓存和返回路径。Hono 只负责 HTTP 边界,内部请求内核不依赖 Hono。createRequest 也会进入同一个内核,但不会自动获得模块级读缓存或 SDK 的游客身份初始化。

跟着一次搜索走完流程 ​

先检查参数,再准备身份 ​

search 在自己的模块文件里声明输入类型和解码器。解码器负责检查并整理外部数据,比如确认 keywords 是字符串,limit 是数字或数字字符串。

这一步发生在身份初始化和缓存检查之前。参数不对,就不会为了这次调用再注册游客身份或发送搜索请求。已声明字段的模块会移除未知字段;保留旧式对象输入的模块则允许未知业务字段,具体边界由模块自己的解码器决定。

接下来根据配置选择登录身份或游客身份。SDK 缺少身份时会尝试初始化游客身份;HTTP 服务通过 CLI 启动时,会在启动阶段准备匿名令牌。

为这次调用保存一份快照 ​

内部的 Call 表示一次模块调用。它保存整理后的业务参数、执行配置、有效身份和截止时间。模块后续发出多次请求时,仍使用这次调用的身份,不会中途换成全局状态里刚更新的账号。

普通模块默认有 8 秒总期限。准备游客身份、重试和读取正文都算在这 8 秒里。两个相同的读取可以共享上游工作,但每个调用者仍有自己的期限,详见 超时与取消。

检查能否复用读取结果 ​

目前只有 search、lyric、song_detail、playlist_detail 进入共享读取流程。其他模块继续独立执行。

判断请求是否相同,除了业务参数,还会考虑账号身份、目标配置和传输实现。不同账号的结果不会因为搜索词一样就混用。

负责这部分工作的 ReadStore 同时保存缓存和正在执行的任务。缓存关闭时,正在执行的相同读取仍可合并。具体用法见 缓存与身份池。

模块描述请求,内核负责执行 ​

搜索模块把 keywords 转成上游要求的 s,再提供目标路径、协议、方法和正文。这份描述叫 RequestIntent,也就是请求意图。

模块通过传入的 RequestCapability 提交它。这个名字表示模块拥有的请求能力,其默认实现就是 requestEffect。模块不直接调用 fetch,也不在内部启动另一套 Promise 执行器。

内核根据调用快照准备 URL、Cookie、请求头和加密正文,校验最终目标,再按策略执行请求。内核不在本地限制发送频率,每次尝试都直接发出;请求频率由调用方控制,见 请求频率由调用方控制。只有允许重试的操作和失败类型才会进入下一次尝试。

响应头到了,不代表请求结束。正文读取、解密和解释也属于这次执行。遇到超时或取消,内核会中断工作并释放自己的资源;自定义 fetcher 仍需配合传入的取消信号。

哪些状态共享,哪些只属于一次调用 ​

范围保存什么对调用者的影响
进程匿名运行时状态多个 client 共用同一份匿名身份
client 或 HTTP 服务实例各自的 ReadStore;client 可选的匿名身份池不同 client 的缓存独立;同一 client 的方法共用读取状态
单次调用参数、配置、身份快照、截止时间单次覆盖只影响这次调用,取消也先作用于这个调用者

具名函数保留各自的调用上下文,公开 invokeModule 复用其调用上下文;它们默认不保存已完成结果的缓存。共享范围不能仅凭“在同一进程”判断。

Effect 在这里解决什么问题 ​

一次请求可能还在准备身份,也可能已经拿到响应头、正在读正文。Effect 让这些步骤使用同一套取消、超时和资源清理规则,减少每个入口自己维护计时器和清理逻辑的情况。

名字在项目里的用途
Context.Service、Effect.provideService声明工作需要的服务,并在执行时提供具体服务
Ref集中更新共享状态,例如缓存记录和等待人数
Deferred让多个等待者接收同一个任务结果
Fiber管理正在执行的共享工作,必要时中断它
Scope、acquireRelease将取得资源和退出时的释放动作放在一起

当前 SDK 和 HTTP 主路径先构造服务值,再用 Effect.provideService 提供给工作,不经过 Layer。

比如两个调用者等同一份歌词,一个取消时只减少一个等待者。还有人在等,共享工作就继续;最后一个等待者也离开时,才中断上游。图解见 共享读取的取消。

网页与上传也使用同一个请求能力 ​

related_playlist 读取网页文本,再提取歌单信息。图片和声音上传则先申请上传凭据,再向网易云对象存储 NOS 发送文件,最后提交业务信息。它们用不同的协议和正文类型,但都经过相同的目标检查和取消流程。

上传默认单阶段最多 60 秒,整个模块最多 5 分钟。已经完成的上游操作无法由本地取消回滚。如果完成部分阶段后失败,公开错误体会带 partialCompletion: true 和数字 completedStages。它表示完成的请求阶段数,不是上传百分比,也不是公开的阶段名称列表。

最后怎样返回给使用者 ​

普通上游响应保留为未知 JSON。模块只有需要读取字段时才检查那部分结构,比如歌单模块需要读取 trackIds。未知字段不会因为文档没有列出就被删掉,也不会被类型声明假定为一定存在。

内部会区分输入错误、目标拒绝、超时、上游限流和上传部分完成等原因。到 Promise 边界时,runPublicEffect 将执行失败映射成 { status, body, cookie } 对象并拒绝 Promise。模块也可能正常返回带业务失败状态的结果,因此调用方仍应检查返回状态。

HTTP 服务会把 body 写成 JSON,把 status 用作 HTTP 状态码,并按规则写回 Cookie。HTTP JSON 不是 SDK 的完整三字段外壳。错误处理示例见 编程式调用。

顺着源码继续看 ​

想了解什么仓库中的位置
SDK 入口与配置传递index.ts、src/sdk/runtime.ts
一次调用怎样建立src/core/call.ts、src/core/call-context.ts
搜索参数怎样转换src/modules/search.ts
缓存和共享任务src/core/read-store.ts
请求执行与期限src/core/request.ts
协议准备、目标与重试策略src/core/request-plan.ts、src/core/endpoint-policy.ts
网络发送与响应解释src/core/transport.ts、src/core/response.ts
HTTP 入口额度src/server/admission.ts

每个模块本地声明 ModuleInput、导出 decodeModuleInput,并用 ModuleEffect 实现默认导出。生成器据此维护 SDK 方法、registry 和公开输入类型。维护模块时应运行 bun run types:modules:generate,然后按仓库的 agent-docs/TESTING.md 选择检查。

取消、缓存、流量控制和上传行为可以用本地假上游验证。这样的检查证明本地执行规则,不能代替真实网易云接口的可用性验证。负载场景与验收规则见仓库中的 tests/load/、scripts/load-test.mts 和 agent-docs/TESTING.md。

Released under the MIT License.