18 KiB
apsis-web 设计说明
English | 简体中文
这些说明解释服务背后的决策。它们写给读代码的人,描述的是设计,而不是计划。
为什么只用标准库
ApsisRS 工作区的写法,是为了让它的数值内核不必跟随一棵依赖树就能被审计。一个引入 异步运行时、Web 框架和序列化框架的 Web 服务,会在暴露内核的那一层破坏这个性质—— 而那恰恰是读者最可能首先查看的一层。
代价是实在的,值得写明:
- HTTP 服务器是同步的,每条连接处理一个请求。对一个文档与工具服务来说这够用, 对流式或长轮询负载则不够。
- JSON 编解码器是在这里写的。它覆盖服务所交换的 RFC 8259 子集,并且针对畸形 输入做过测试,但它不是一个通用库。
- Markdown 渲染器覆盖内容所<E5AEB9><E68980>用的构造。它不是 CommonMark 实现。
这些各自都是有界的工作而非无止境的工作,而且每一项都小到可以一次读完——这正是 重点。
请求处理
一条连接被接受,读入一个请求,写出一个响应,连接关闭。服务器对每个响应都发送
Connection: close,这样就不需要连接状态机,也不需要 keep-alive 记账。
请求头逐字节读取。这比缓冲读<EFBFBD><EFBFBD><EFBFBD>,但正是它让请求头与请求体之间的边界精确,无需回退 缓冲:不属于请求头的字节绝不会被消费掉。读超时限制了慢速对端占用一个工作线程的 时长,而对请求头与请求体的字节上限则限制了恶意对端能让进程分配多少内存。
请求由固定规模的工作线程池处理,任务来自一个有界队列。当所有工作线程都在忙时,
被接受的连接在队列中等待,因此内存用量由线程池规模决定,而不是由连接数决定。
Connection: close 策略意味着工作线程绝不会在同一连接上等待第二个请求。
处理函数中的 panic 会被捕获并变成一个服务器错误响应。处理函数并不刻意 panic; 这道防线之所以存在,是因为工作线程中的 panic 否则会终止该线程,而在固定线程池下 这会悄悄降低服务能力。
JSON
Value 持有 Null、Bool、Number(f64)、String、Array 与 Object。
对象保持插入顺序,这让编码输出稳定且可读。
有两项性质对安全性是重要的:
- 编码绝不产生无法解析的输出。 非有限数没有 JSON 表示,因此被写作
null, 而不是NaN或inf。于是产生非数值的计算不可能产生一份客户端解析失败的文档。 - 解码是有界的。 嵌套深度受限,因此恶意文档无法通过递归耗尽栈。
字符串按 RFC 8259 转义,代理对被合并。不是合法 UTF-8 的文本被拒绝而非替换, 这样客户端就无法通过解码器夹带字节序列。
文档与内容
内容由 build.rs 以文本形式嵌入,它扫描 content/ 并生成一张 include_str!
条目表。文档层在启动时解析该表一次。
一篇文档是带小型元数据头部的 Markdown。该头部被解析出标识、标题、所属章、概要、 标签与前置条件;正文被渲染成 HTML 一次,两种形式都保留。因此提供一篇文档的 代价只是一次字符串拷贝。
Markdown 渲染器转义每一个文本节点,只输出它自己生成的标记,因此文档无法注入
标记。前端中的 {@html} 是服务把标记交给浏览器的唯一位置,而后端保证了其中
可以出现什么。
交叉引用在解析过程中被收集,并与文档集合比对。无法解析的引用由
Tree::missing_links 报告,状态测试断言它对内嵌内容为空。因此一处笔误的引用
会导致测试失败,而不是变成页面中的死链。
搜索
索引把一个小写的词元映射到包含它的文档集合。查询以同样方式分词。词元在任意
非字母数字字符处切分,因此 mean_motion、mean-motion 与 mean motion
都能匹配对 mean motion 的查询。
打分是查询各词元的「权重乘频次项」之和,出现在标题中的词元另有加分。权重随词元 出现的文档数增加而下降,因此常见词的贡献小于罕见词。索引在启动时构建一次且不再 变化,因此倒排表是只读的,不需要锁。
一个查询分两种方式处理。能产出词元的查询与倒排表匹配;产不出词元的查询(例如 单个字符)则按子串匹配,因为单字符查询无法构成词元,否则将永远一无所获。
中文需要的比分词更多。拉丁文本按空白与标点切分,而中文书写不带空白,因此按 非字母数字字符切分一个中文句子,只会留下一个长词元,两字查询永远匹配不到。因此 一串中文字符每相邻两个字符产出一个词元,这是仍足够具体到能视作一个词的最小 单位。查询「坐标」能匹配含有「坐标」的句子,而整串词元做不到。单个中文字符留给 子串回退而不成为词元,因为一个字符的词元几乎会匹配每一篇文档。
图
一种图结构同时服务于知识图谱、crate 结构与算法流程图:带种类与分组的节点,以及 带标签的边。后端给出结构;布局在浏览器中计算。
布局是一个力导向仿真,初始位置由节点标识的散列导出,因此同一张图每次加载都画成 同一个样子。仿真在数据变化时跑到收敛即止,而不是做成动画,这使该组件成为其 props 的纯函数,并使图在测试或截图中可复现。
工具
工具是一份声明,而不是前端认识的代码。注册表为每个参数声明其类型、单位、取值 范围、默认值与帮助文本;前端据此渲染出表单。
参数值以 JSON 对象到达。Params 完成带类型的读取,并依据声明做校验,于是处理
函数只面对 f64、i64、String 与枚举选择。值既可以作为 JSON 数字也可以作为
字符串到达,因为表单控件发送的是文本;两者都被接受。落在声明范围之外的值会被
连同参数名一起拒绝,这正是表单能够高亮该字段的原因。
处理函数通过 out 塑造结果,它把结果固定为标量、表格与数值序列三类。前端只有
一个结果渲染器,因此新增一个工具意味着新增一份声明与一个处理函数,而前端
无需改动。
当失败源于非法输入时,错误携带参数名与一条写给表单使用者的消息。消息指明条件 与可接受的范围。它不引用内部状态,因此失败不会泄露实现的形状。
接口目录
接口目录由 scripts/extract_api.sh 从 ApsisRS 源码生成:脚本读取各 crate 目录,
收集模块文档与公共条目的文档注释和签名,写入 content/api.json。该文件被提交,
因此构建 Rust 二进制既不依赖 ApsisRS 的检出,也不依赖 Python。
采用提取而非手工维护,正是接口文档保持诚实的原因:库中签名的改动,在脚本重跑 之后就是生成目录的改动,而手写的描述会悄无声息地失准。
脚本在 generated_from 中报告它读过的 crate,服务在 /api/api 中返回该字段,
因此所显示内容的来源是可核查的。
译文是另一个文件 content/api.zh.json,以 crate 名与条目名为键。它与目录分开,
是因为目录是生成的而译文是撰写的:重新生成目录不应丢弃译文。关联条目以声明
它的类型为键,写作 Type::name,因为有若干类型都有关联的 new;裸名作为回退。
文档注释用 Rust 路径引用另一个条目,写作 [Epoch](crate::Epoch) 或简写
[`Epoch::mjd`]。渲染器把这类引用变成指向该条目接口页的链接。名称的解析
方式是从右沿路径回溯,取第一个已知条目的片段,因此指向变体或方法的链接会落到
声明它的枚举或 trait 上。若所有片段都未知,该引用保持为代码片段,这正是
[`0, 1`] 这样的标签不会变成一个指向空处的链接的原因。
文档注释由后端用概念文档所用的同一个渲染器渲染为 HTML,因此含有列表、表格或 代码块的注释也会被照原样画出。
前端与嵌入
前端是 Svelte 与 TypeScript,由 Vite 构建。构建产物是一组稳定的文件名而非带内容 哈希的文件名,因此未改动的源码产生未改动的内嵌表,增量的 Rust 构建不会重新链接。
build.rs 在 release 配置下运行前端构建,在 debug 配置下跳过它。这一划分是刻意
的:调试构建迭代的是后端,不应为前端构建付出代价;发布构建则应当产出无需后续
步骤的单一二进制。APSIS_WEB_SKIP_FRONTEND=1 强制在发布构建中跳过。前端构建
失败会作为警告报告,并嵌入上一次成功的产物,因此在没有网络的情况下发布构建
仍能产出二进制。
当没有嵌入任何内容时,服务会提供一个内置页面,说明构建步骤并列出 JSON 路由, 因此一份尚未构建前端的新检出仍能给出有用的回应。
语言
界面是双语的,而后端并不知道读者选了哪种语言,因为这个选择是在浏览器中做出的, 且可以不经请求而改变。因此每一条面向读者的字符串都以两种语言保存。
有三种机制承载译文:
- [
Text] 值持有一个中文串与一个英文串,编码为{"zh": ..., "en": ...}。 工具注册表用它承载每个标签,图谱数据用它承载每个节点与边的说明,章节列表 用它承载每个章标题。 - 一篇文档以英文写一次,并以带
.zh.md后缀的第二个文件翻译。读者的语言决定 取哪种形式;没有译文的文档显示英文,并在页面中附一条说明。 - 工具结果的标签在处理函数中以英文书写,再由
src/tools/labels.rs中的查表 翻译。表中没有的标签是一个符号,例如position.x或BSTAR,在两种语言中 都按原样显示——对一个名字而言,这正是读者所期望的。
默认语言是中文。该选择保存在 localStorage 中,并反映在文档元素的 lang
属性上。
有两类内容在两种语言中都保持英文。单位按标准所用的符号书写;接口条目保留它们 在库源码中的名字,因为译过的标识符将无法与它所记录的代码对应。
命令行
监听由 --host 与 --port 绑定,或由合并的 --address 绑定。主机可以是字面量、
IPv6 字面量,或由操作系统解析的名字;名字在绑定监听之前解析一次,因此解析失败
会被报告为配置错误,而不是绑定失败。
--address 不能与 --host 或 --port 合用。同时接受两者需要一个命令行并未
定义的生效顺序,其行为会令人困惑。
界面布局
三个布局决策让页面在任何窗口宽度下都可读。
一个页面是阅读栏。多数页面是文本、卡片网格或表单,三者在居中的一栏中都比 横跨整个宽屏更好读,因此内容被限制在一个阅读尺度内并居中。文档是唯一比这一栏 更宽的页面,因为它在正文旁边带一份目录;目录计入这个上限,而没有标题的文档会 去掉目录,单独使用阅读尺度。图谱朝相反方向打破这一规则:它使用整个宽度,因为宽屏 应当显示更多的图,而不是更多的留白。
接口索引每个 crate 只显示一行,而不是完整的模块文档。一个 crate 的概要有好几 段,把它们排成网格无法阅读;索引显示首句,其余由 crate 页面呈现。文档注释由 后端当作 Markdown 渲染,因此含有列表、表格或代码块的注释会照原样画出,前端不 必去解析它。
图绘制在一个固定的布局空间上,并按视口缩放。双击会打开节点所命名的文档或 接口条目,因此图也是进入文档的一条路径。
卡片网格在整页只有一个卡片尺寸,而不是每行一个。
网格只会让同一行的卡片等高,这还不够:每一行各自测量,于是一页由若干网格构成
时,行高会逐行变化,卡片列看上去参差不齐。因此浏览器中会测量所有网格里最高的
那张卡片,并把它应用到全部卡片上,同时用一个 ResizeObserver 使测量跟随列数
与语言的变化。
列数是固定值而非有多少放多少。用 auto-fill 为列定尺寸会让列宽取决于该行
有多少张卡片,于是同一页上三个一组的小节与四个一组的小节会给出不同的卡片宽度。
固定列数在整页维持同一种节奏;填不满一行的<E8A18C><E79A84><EFBFBD>节把空隙留在末尾,而不是把卡片
拉宽。
一张卡片就是一列,因此它的操作按钮位于每张卡片的最后一行,无论上方文字 有多长。概要不被截断:统一的高度意味着概要较长的卡片只是多带几行。
卡片之间的间距只来自网格的 gap。在网格内会抵消同级外边距,因为一张带
前序兄弟的卡片否则会同时带上行间距与同级外边距。每行的第一张卡片没有前序兄弟,
因此它会紧贴而行中其余卡片下沉一个间距,看上去像错位,尽管每张卡片尺寸相同。
侧栏是位于一个框架内的粘性元素。框架承载底色与分隔线,使这一列画满整页;
侧栏本身是 sticky 并随页滚动。没有框架的话,这一列会在一个视口高度处结束,
在长页面上读起来像是被截断。
图画布采用布局空间的宽高比,因此绘图填满画布,而不是被加黑边塞在里面。 力导向仿真收敛为一个大致圆形的簇,而画布是宽的,因此在拟合之前会先把布局拉伸 到画布的形状;这一改动是均匀缩放,因而相对距离仍然可读。
锚点与大纲
标题锚点由标题文本归约而来,目录携带该锚点。有两条规则让链接落在读者预期的位置。
归约保留每种文字的字母。只保留 ASCII 字母意味着每个中文标题都归约到同一个 回退值,于是整份目录都指向第一节,点任何一条都滚到文档顶部。文档正文与大纲用 同一条规则归减,因此一个条目所命名的锚点是文档确实携带的。
重复出现的标题在两侧都编号。否则两个同文本的标题会共用一个锚点,第二个条目 会滚到第一个上。文档与大纲以同样方式为重复项编号,因此映射保持精确。
大纲携带每个标题的层级,于是嵌套的标题缩进在其父级之下,而不是与之并列。 折叠控件只为有子项的标题显示,因为一个什么也藏不住的控件比没有控件更糟。
语言覆盖
面向读者的字符串分三类,各自处理方式不同。
界面框架——导航、标题、按钮、帮助文本——是 frontend/src/lib/i18n.ts 中一份
手写词典,以稳定的名称为键。它覆盖图谱分组说明与接口条目种类,因此中文读者会
看到中文图例与中文分组标题。
撰写的内容——概念文档、crate 概要、条目文档——按语言各一个文件,因此后端两者 都提供,读者切换时无需再一次请求。一篇文档在每个语言文件下用同一个键声明自己 的标签,因此标签列表也随语言变化。
派生文本——卡片里用的一行概要——由后端从文档生成,渲染器会解析文档注释所用的
rustdoc 形式。[`Vec3`] 这样的简写引用会被归约为条目名;若不如此,方括号与
反引号就会原样出现在概要里。
缓存
服务提供的是在升级时被整体替换的单一二进制,因此缓存要回答的唯一问题是:浏览器 会不会握着一份已经不是这个二进制所嵌入的副本?
构建给出的答案是把内容哈希放进每个资源名。改动某个文件的构建同时改动了该文件的 名字,于是新包的 URL 是浏览器从未请求过的,因而不可能被缓存。服务接着声明一个 仅凭名字就该有的策略:
- 名字带哈希的文件是
public, max-age=31536000, immutable。该文件保留一年 且永不重新验证,因为不会再有谁请求那个名字。 - 入口页面是
no-cache, must-revalidate。它指名当前的包,因此不经询问就不会从 缓存提供,而这一要求既对浏览器声明,也对中间代理声明。 - 名字不带哈希的文件是
no-cache, must-revalidate,因为后续构建会复用那个名字。
这个判断只读名字,因此对早期构建所嵌入的文件与当前构建所嵌入的文件同样正确。
两种替代方案被否决。对每个文件使用 no-store 消除了这个问题,代价是每次页面
加载都重新下载整个包。对每个文件使用 no-cache 是正确的,但在服务没有条件请求
处理逻辑的前提下,会让浏览器重新下载本可复用的内容。两者都不如把内容编进名字
来得快。
服务不注册 service worker,也不发出 manifest,因此没有第二层缓存需要推理。
工具路由
工具路由有两种状态。没有工具标识时页面是索引:每个工具一张卡片,按类别分组, 每张卡片打开该工具。有标识时页面是该工具自身。
这两种状态是一<EFBFBD><EFBFBD><EFBFBD>路由而非两个页面。单一路由意味着后退按钮、共享链接与刷新, 无论读者是从索引还是从别处的链接到达,行为都一致。工具页面带有返回索引的链接, 因为索引并不在它上面。
工具页面不含列表。这正是这一划分的意义:从链接打开工具的读者看到的是表单, 而不是它被引用的那份列表,表单得以占据整个宽度。代价是比较两个工具需要经由索引 往返两次。
测试
单元测试与它所测试的代码相邻,覆盖解析器、编解码器、Markdown 渲染器、文档模型、 搜索排序、图谱解析、路由器以及每一个工具处理函数。
tests/service.rs 中的端到端测试在一个临时端口上启动构建出的二进制,并以普通
TCP 请求与之通信。它们把请求解析器、路由器、编码后的载荷、内嵌内容与工具处理
函数放在一起检验,这是任何单元测试都做不到的。
有一处断言值得单独指出:状态测试检查 Tree::missing_links 为空、且图谱没有悬空
的边。这两项检查把一处内容错误变成一次测试失败。