无需收集全部数据的 JSON Lines 导出:设计原理
一句话总结
把惰性生成、行边界、公开字段和输出上限,与 HTTP 流连接起来。
为什么需要它
管理员一下载所有订单,服务器内存就猛增。原因是 export 函数在生成 JSON 之前,先把所有行都收集进了列表。后来改成了按行输出,可是正文里的换行破坏了真正的行边界,内部成本也原样发了出去。流式传输不是改一下返回类型就行,而是要一并确定惰性求值和表示契约。
工作原理
验证每一行,只投影出公开字段,再编码为 JSON,并在行尾加上一个换行。生成器不会仅因调用就消耗输入,每取出一项,就只推进一行。输出上限用排除 bool 的整数来验证。最后连接到 FastAPI 的 StreamingResponse,并对 Content-Type 和下载得到的行重新解析。
입력 iterator → 한 행 검증 → 공개 투영 → JSON + 개행 → StreamingResponse
阅读契约并预测失败的工作表
下面不是让你把整个实现背下来的答案,而是分步骤的代码评审。每个改动片段都故意破坏了契约。请注意,改动之后,正常用例仍然可能通过。在运行之前,先预测观测哪些输入、异常和状态才能看出差异,实现之后,再把这个预测与结果进行比较。
1. 验证行契约
validate_row(row) 在它是 dict,id 是排除 bool 的正 int,name 是非空 str 时,返回 row。其余都是 ValueError。允许有额外的内部字段。
判断依据:区分 bool 和数字,并把空名称当作错误处理。
待评审的错误改动片段:
not isinstance(row.get("id"), int)
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
2. 只构造公开的行
project(row) 在 validate_row 之后,返回只含 id 和 name 的新 dict。原件的内部字段保持原样。
判断依据:导出路径也必须应用与普通 API 相同的公开字段策略。
待评审的错误改动片段:
"name":row["name"], "internal_cost":row.get("internal_cost")}
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
3. 保持行边界地编码
encode_line(row) 是一个 str:把 project 的结果用 ensure_ascii=False、separators=(',',':')、sort_keys=True 编码为 JSON,并在末尾附加一个 ' '。name 中的换行必须是 JSON 转义。
判断依据:如果用字符串拼接来生成 JSON,格式会在引号和换行处被破坏。
待评审的错误改动片段:
ensure_ascii=True
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
4. 验证输出数量上限
validate_max(value) 只原样返回排除 bool 的 1–1000 的 int,其余都是 ValueError。
判断依据:要求调用者给出上限,以免意外地把无限输入一直读到底。
待评审的错误改动片段:
<= 1001
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
5. 只消耗需要的行
take_rows(rows, maximum) 是一个 iterator,它借助 islice 等方式,最多只惰性地返回 maximum 条。调用时验证 maximum,每执行一次 next,只消耗一次输入。
判断依据:一旦转成 list(rows),就无法处理无限输入和大容量输入。
待评审的错误改动片段:
islice(list(rows), validate_max(maximum))
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
6. 惰性序列化行
json_lines(rows, maximum=100) 对从 take_rows 得到的每一行,yield encode_line。不要返回全部拼接好的字符串或列表。
判断依据:让对象的选择和表示的转换,各自都保持为惰性的步骤。
待评审的错误改动片段:
for row in list(take_rows(rows, maximum)):
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
7. 重新验证下载得到的行
decode_lines(text) 对 splitlines 中每一个非空的行,先 json.loads,再 validate_row,然后作为列表返回。空字符串是 [],空的中间行是 ValueError。
判断依据:区分空文件与格式损坏的空记录。
待评审的错误改动片段:
continue
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
8. 完成 HTTP 下载
create_app(rows) 在 GET /export 中,把 json_lines(rows, 100) 作为 application/x-ndjson 的 StreamingResponse 返回。rows 是可以再次遍历的列表。必须没有内部字段,并保留每一行的内容和顺序。
判断依据:不能只把 Content-Type 写成流式,还要配合生成器测试,确认内部并没有把全部内容收集起来。
待评审的错误改动片段:
media_type="application/json"
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
在现场相遇的样子
TestClient 会缓冲响应,所以并不能证明网络首字节延迟或整体的内存上限。是否惰性求值,要另外用计数生成器来检查。流开始之后如果遇到错误的行,就很难再用正常的错误 JSON 改变状态。真实服务必须确定,在预先验证、按行的错误格式、中止策略之中选择哪一种。
下一项实验要做什么
八个步骤会连成一个可运行的成果。验证行契约 → 只构造公开的行 → 保持行边界地编码 → 验证输出数量上限 → 只消耗需要的行 → 惰性序列化行 → 重新验证下载得到的行 → 完成 HTTP 下载。
每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。