做后端这几年,最消耗耐心的往往不是业务本身,而是联调时看各系统返回的数据格式。同一个用户查询,有的接口直接返回数组,有的包一层 data,有的出错时连 code 都没有,只给你一句不带任何结构的 msg。后来我在团队内部折腾了一套 TOON 数据格式,名字来自 "to one" 的变体,核心想法很简单:把五花八门的返回形式收拢成一种行式声明格式,用格式本身去约定结构,而不是靠一份没人维护的接口文档。这不是要发明一个国际标准,而是工程层面的统一实践。如果你同时管过多个服务的接口,或者想找一种轻量、人肉可读、解析起来又不费劲的数据格式,这篇应该能给你一点参考。
1. 被接口返回格式逼出来的 TOON:它到底解决了什么问题
1.1 三个真实的联调反面案例
先说三个我亲身遇到的场景,每个都够写一篇吐槽日记。
第一个是服务 A 的订单接口。成功时直接返回 JSON 数组,前端拿到数据后自己猜字段含义;失败时返回 HTTP 401,同时在 body 里塞一句{"message": "token expired"}。它没有业务 code,也没有统一的错误体,前端只能靠 HTTP 状态码做全局拦截。问题来了:有些业务失败是 200,有些成功反而是 3xx 重定向,状态码被塞进了各种语义。
第二个是服务 B,看起来比 A 规范一些,统一包了一层{"statusCode": "0000", "result": {...}, "msg": "success"}。但 statusCode 是字符串,有的是"0000",有的是"0";result 里面还分行返回,嵌套层级完全靠后端心情。前端接这种接口,必须写一套"猜结构"的兼容层,每换一个后端就多一堆 if。
第三个是服务 C 的导出接口。成功时返回文件流,失败时却返回 JSON。前端下载文件时,必须同时处理 blob 和 json 两种 contentType,一旦失败,还要从流里解析错误信息。这种设计在单点登录、文件网关里其实很常见,但对前端来说,就是典型的"格式分裂"。
我后来把这些痛处整理成一张表,给团队看的时候大家都沉默了:
| 问题来源 | 返回体示例 | 业务码 | 错误表达 | 前端处理成本 |
|---|---|---|---|---|
| 服务 A | 直接裸数组 | 无 | 靠 HTTP 状态码 | 高:猜数据类型 |
| 服务 B | statusCode 字符串 | 有,但不统一 | 字符串状态码 | 中:多层兼容 |
| 服务 C | 成功是文件流,失败是 JSON | 无 | 隐藏到流里 | 高:双 contentType |
结论很直白:所谓接口规范,如果没有一个物理载体去约束,很快就会变成一纸空文。这就是我想做 TOON 的起点。
1.2 折腾过 JSON Schema 和 OpenAPI 之后,我为什么还是想自己做
一开始我也走"正规军"路线,试过 JSON Schema,也试过 OpenAPI。JSON Schema 确实强大,能定义字段类型、枚举、必填项、嵌套结构,但问题也明显:一份接口的 schema 动不动几百行,团队里没人愿意维护。更尴尬的是,写 schema 的人往往就是写接口的人,他们很清楚字段含义,但写完 schema 之后就再也不更新了。几个月后再去看,schema 和实际返回已经完全脱节。
OpenAPI 则是另一个极端,功能全,工具链丰富,但学习成本和落地成本都不低。小团队后端加前端一共十个人,大家宁愿把时间花在业务上,也不愿意去学一套完整的 API 描述体系。每次评审接口,真正看得懂 OpenAPI 文件的人没几个,最后等于没规范。
我需要的是一个更轻的方案:把约束直接写进格式里,而不是依赖外部工具去校验。TOON 的出发点很简单,格式本身就是协议,你看到这个文件,就知道它的类型、层级、状态和数据结构,不需要再去查别的文档。
名字也是这么来的。T 是 Type,O 是 Object,O 是 Onion(数据一层层嵌套像洋葱),N 是 Notation。但更直白的解释是 "to one":把散乱的数据表达统一成一种形式。用这个词还有个好处,搜索引擎一搜基本不会撞车,团队内部说 "toon 格式",大家都知道在说什么。
1.3 TOON 的设计目标:约定变成格式本身
定 TOON 之前,我给自己列了三个目标。
第一,简洁可读。我不想让团队成员花一周去学语法,要的就是扫一眼能看懂。文件打开,第一行就知道版本,第二行就知道类型,后面每一行都是键值对,嵌套用缩进和花括号表达,跟写配置一样。
第二,类型明确。字符串、数字、布尔、null、数组,都要在语法层面区分开,不能像某些 JSON 接口那样,count一会儿是字符串"12",一会儿是数字12。TOON 规定数字不带引号,字符串必须带双引号,谁写错了,解析器直接报错。
第三,状态和数据分离。这是统一返回协议的核心。业务数据放 data 里,状态信息放 code、message、trace_id 里,两层不混在一起,前端拿到后不用猜。
同时我也划清了边界:TOON 不替代 JSON,不追求极致性能,也不打算做成完备类型系统。它就是一把螺丝刀,专门解决"接口返回结构不统一"这个螺丝。
2. TOON 语法设计:行式声明、缩进分层、状态分离
2.1 一个最小示例看全貌
直接看例子。下面是一个用户对象的 TOON 表达:
@TOON/1.0 @type:user id: 1001 name: "jason" active: true scores: [98, 87, 92] address: { city: "beijing" district: "chaoyang" geo: { lat: 39.9 lng: 116.3 } }这个文件我命名为user.toon,从头到尾没有任何括号噪声,比 JSON 干净很多。逐行拆解一下:
@TOON/1.0是格式版本声明,解析器看到这行就知道按哪套语法规则解析。@type:user是元数据,标识这是用户对象。元数据行都以@开头,可以理解为"给解析器和运维人员看的信息"。id、name、active是普通键值对,冒号左边是键,右边是值。scores: [98, 87, 92]是数组表达,TOON 规定数组必须在一行内写完整,元素用逗号分隔。address: {表示这里开始一个嵌套对象,后面缩进的行都属于 address,遇到}表示这个块结束。- geo 层再嵌套一层,同样用
{}包起来。
这里有个设计细节:TOON 里的缩进只是给人看增强可读性的,不是语义必须。真正决定层级的是花括号,左括号开块,右括号关块。为什么这么设计?因为很多配置格式用纯缩进表达层级,一旦混用 Tab 和空格就出问题;TOON 把层级交给了显式的花括号,缩进错了最多难看,不会解析错。
2.2 类型怎么表达:标量、数组、对象、null、引用
TOON 的类型系统刻意保持克制,一共五类基础值:
| 类型 | 写法 | 示例 |
|---|---|---|
| 字符串 | 必须用双引号包裹 | "jason" |
| 数字 | 直接写,不区分整型和浮点 | 1001、39.9 |
| 布尔 | 只有 true / false | active: true |
| null | 用~表示,避免与字符串混 | remark: ~ |
| 数组 | 单行方括号,元素逗号分隔 | [98, 87, 92] |
字符串强制双引号是故意为之。JSON 允许单引号在某些宽松解析器里被接受,但跨语言时单双引号容易出幺蛾子。TOON 直接锁死双引号,各语言解析行为完全一致。
null 为什么不是null而是~?因为裸写的 null 在文本里很容易被当成普通字符串,尤其某些后端序列化时把 null 漏了引号,解析器就会得到奇奇怪怪的值。用~做一个专用记号,一眼就能看出这里是有意置空。
数组只支持简单值单行,对象数组怎么办?这是 TOON 一个反直觉但很实用的设计:对象数组尽量用带 key 的字典块代替。比如一个订单列表,不用orders: [{id: 1}, {id: 2}],而是写成:
orders: { "1001": { amount: 199 status: "paid" } "1002": { amount: 299 status: "pending" } }这样做的收益是语义更强:你不再依赖数组下标去引用某条数据,而是直接用订单号作为 key。业务中最常见的按 ID 查找,从"遍历数组"变成了"一次 dict get"。而且避免了"对象数组里到底有多少字段"这种问题,每个 key 下结构一清二楚。
需要真正表示实体间引用时,TOON 提供_ref字段:
order: { id: "1001" user_ref: "user:1001" }user_ref指向另一块数据里的用户对象,格式是类型:主键。跨服务传递时,接收方知道该去哪个服务补全用户信息,不用把一个完整用户对象塞进订单里。
2.3 统一返回协议 Response 包装
数据格式本身不强制返回协议,但接口统一这件事,必须有一层固定包装。TOON 在格式之上定义了一个 Response 结构,后端所有接口均按这个包返回:
@TOON/1.0 @response code: 0 message: "ok" trace_id: "5f9c-2ab1-4e7d" data: { total: 128 items: { "1001": { name: "jason" active: true } "1002": { name: "lucy" active: false } } }code 是业务编码,0 固定表示成功。非 0 按段位划分:1xxx 参数错误,2xxx 业务规则错误,9xxx 系统异常。HTTP 状态码只表达传输层语义:200 代表请求被正常处理并返回了 Response 包,401 代表未认证,500 代表网关或框架层出了问题。业务错误一律通过业务 code 表达,HTTP 上仍然返回 200,前端只看 code 就能判断结果。
message 是人类可读的描述,中文、英文均可,但不建议把详细堆栈塞进去,堆栈走 trace_id 查日志。trace_id 每次请求唯一,从入口网关生成,贯穿下游所有服务,定位问题全靠它。
data 里面放业务数据,成功时必填,失败时可以为空。这样一套包装,前端拦截器只需要做一件事:检查 code 是否为 0,是就取 data,不是就弹 message,顺便把 trace_id 带上反馈给客服。所有接口一个逻辑,不用再写各种特判。
3. 从零写一个 TOON 解析器:核心思路与关键代码
3.1 两段式处理:先拆行,再建树
我们当时决定不引第三方库,自己写解析器。原因是:TOON 语法足够简单,自研大概两天能搞定,引用一个不成熟的库反而要适配它的行为。
解析器的核心思路是两段式处理:先按行做词法分析,把文本变成一个个 token;再维护一个栈,根据 token 构建嵌套结构。
为什么不用正则一把梭?因为正则适合匹配单向模式,不适合处理有嵌套层级的数据。你用正则解析简单键值对没问题,但遇到address: {这种块结构,正则就得靠计数,写出来又丑又难维护。用栈结构处理括号配对,是教科书级的方案,放在这里刚好合适。
我的建议是:如果你的格式碰到嵌套,第一反应就该是"使用栈",而不是"加强正则"。这个习惯在写任何自定义格式解析器时都成立。
3.2 一个能跑的最小解析器(Python)
我写了一个教学版解析器,能处理元数据行、普通键值对、标量类型、数组、嵌套对象块。生产版本在此基础上加了更严格的错误定位,核心逻辑一样:
import re def parse_toon(text): lines = text.splitlines() # meta 保存 @ 开头的元数据 meta = {} # root 是最终返回的顶层对象 root = {} # 栈里放 (key, container),container 是 dict 或 list # 这里我们只演示 dict 容器,list 用单行解析处理 stack = [("", root)] current = root for lineno, raw_line in enumerate(lines, 1): line = raw_line.strip() if not line or line.startswith("#"): continue # 元数据行 if line.startswith("@"): body = line[1:] if ":" in body: k, _, v = body.partition(":") meta[k.strip()] = v.strip().strip('"') continue # 嵌套对象结束 if line == "}": if len(stack) > 1: stack.pop() current = stack[-1][1] else: raise ValueError(f"第 {lineno} 行有多余的 }}") continue # 嵌套对象开始 if line.endswith("{"): key = line[:-1].strip() new_block = {} if key: current[key] = new_block stack.append((key, new_block)) current = new_block continue # 普通键值对 if ":" in line: key, _, value = line.partition(":") key = key.strip() value = value.strip() current[key] = parse_scalar(value) continue raise ValueError(f"第 {lineno} 行无法解析: {raw_line}") return {"meta": meta, "data": root} def parse_scalar(raw): raw = raw.strip() # 数组:单行简单值 if raw.startswith("[") and raw.endswith("]"): inner = raw[1:-1].strip() if not inner: return [] return [parse_scalar(item.strip()) for item in inner.split(",")] # 字符串:双引号包裹 if raw.startswith('"') and raw.endswith('"'): return raw[1:-1].replace('\\"', '"') # 空值 if raw == "~": return None if raw == "true": return True if raw == "false": return False # 数字 try: return int(raw) except ValueError: pass try: return float(raw) except ValueError: pass # 裸字符串兜底,生产环境建议直接报错 return raw这段代码没有做 tokenizer 的独立抽象,但对于行式格式已经足够。核心就是那个stack,它记录了从根到当前节点的路径。遇到{就 push 一个新字典,遇到}就 pop,回到父节点。只要括号配对正确,嵌套多少层都不会乱。
parse_scalar里的顺序也有讲究:先判断数组,再判断字符串,再处理 null 和布尔,最后尝试数字。因为"true"带引号是字符串,不带引号的true才是布尔,顺序错了就会误判。这个坑我在第一版里踩过,后来把判断顺序固定成上面这样,再没出过问题。
3.3 错误处理:最容易翻车的三个地方
自研解析器最值得说的不是正常逻辑,而是错误处理。我落地过程中遇到的三类高频问题:
第一,字符串引号未闭合。name: "jason少了右引号,解析器会把它当成裸字符串,然后走兜底逻辑返回原始文本,结果前端拿到的是带引号的脏数据。我的建议是:生产级解析器不要做裸字符串兜底,宁可报错也不要静默接受。这个兜底逻辑是我故意留在教学版里演示"双刃剑"的,方便你看到风险。
第二,花括号不平衡。后端生成的 TOON 如果少了个},解析器必须明确指出是哪一行开始缺少闭合,而不是只抛一个泛泛的 "parse error"。上面的代码里,len(stack) > 1的判断能处理多余}的情况,但对于缺少}的情况,需要在文件结束后检查栈深:如果栈里还有非根节点,说明有块没闭合,此时报错要带上块的起始行号。这个信息在 push 的时候就要存到栈元素里。
第三,行首有空格或者全角冒号。全角冒号:和半角冒号:肉眼几乎分不出来,但解析结果完全不同。我们后来做了一个很土但有效的检查:对每一行做字符级校验,只允许 ASCII 冒号作为键值分隔符,遇到全角冒号直接提示"请检查第 N 行是否使用了中文标点"。这个检查看着低级,却在团队里避免了无数次低级事故。
4. 落地 TOON 的踩坑记录与工程化方案
4.1 第一批坑:转义、编码、空值
解析器写完只是开始,真正落地时第一批坑全在"看起来很简单"的地方。
转义首当其冲。业务数据里有引号、反斜杠、换行,这些在 JSON 里都有标准转义规则,TOON 也要明确:字符串内反斜杠需写\\,双引号需写\",换行统一用\n。我们第一版协议里没写清楚,结果有人把一段带换行的备注直接贴在 TOON 里,解析器到第二行就炸了。后来专门在协议文档里加了转义对照表,并要求所有序列化实现复用 JSON 的转义规则,因为大家脑海里对这套规则最熟。
编码问题同样隐蔽。TOON 文件强制使用 UTF-8,这个要求几乎每个格式都会写,但真正做到很难。曾经有个服务跑在 Windows 服务器上,默认编码是 GBK,输出中文用户名的 TOON 时,字节序列到了 Linux 网关直接乱码。排查半天,最后发现是编码不一致。从那以后,我们约定:一切 TOON 文本在内存里只允许以 Unicode 字符串形式存在,字节输入输出必须在 IO 边界统一转成 UTF-8,中间环节绝不碰字节数组。
空值语义也是一个经典分歧。有人用remark: ~表示"这个字段没有值",有人直接不写 remark 字段表示"无"。TOON 把两者区分开:字段缺失代表"这个上下文里不存在该字段",显式~代表"字段存在但值为空"。比如用户备注,交易链路里根本没有备注字段,就缺失;备注字段有定义但用户没填,就写~。前端处理逻辑因此变得明确:缺失值不展示,空值展示占位符。这个约定花了两次评审才定下来,但理顺之后,前后端没有为"到底有没有这个字段"再吵过架。
4.2 兼容层:给老系统写一个 TOON 到 JSON 的转换器
TOON 落地最大的阻力不是技术,而是存量系统。几十个老接口都是 JSON,你不可能让它们一夜之间全换格式。我的方案是加一个网关转换层:老系统外面包一层 TOON 适配器,对外仍然输出 JSON,内部新逻辑逐步切换。
转换器的核心逻辑其实很短:
def toon_to_json(toon_text): parsed = parse_toon(toon_text) return json.dumps(parsed["data"], ensure_ascii=False)这个函数看着简单,但实际工程里要处理元数据映射:@response结构里的 code、message、trace_id 要映射到传统 JSON 的{code, msg, traceId}结构,data 保持不变。转换层存在的意义是给前端一个过渡期:前端继续吃 JSON,后端新接口开始写 TOON,等前端改造完成后再切真 TOON。整个过程不需要发布大版本,灰度推进就行。
我个人的经验是:任何新数据格式的推广,都要先想想"老系统怎么接",否则格式再漂亮,也没人敢用。
4.3 与 Spring Boot 集成的一点实践
Java 后端集成 TOON 的方法,本质是写一个自定义 HttpMessageConverter。Spring Boot 里默认用 Jackson 处理 JSON,要让 Controller 方法直接返回 TOON,需要注册一个支持text/vnd.toon媒体类型的转换器,把 Java 对象序列化成 TOON 字符串。
具体做法分三步:第一,定义@ToonResponse注解,标记哪些接口需要返回 TOON;第二,实现一个自定义HttpMessageConverter,在支持的媒体类型里加上text/vnd.toon;第三,在新老接口共存期间,通过 Content-Type 协商决定走 JSON 还是 TOON。
这中间有个容易忽视的点:错误页面和框架异常处理器返回的结果,也要统一成 TOON 格式,否则前端在正常接口上收 TOON,在报错上收 JSON,又回到最初的问题。我们把全局异常处理器也接入了 TOON 序列化,保证了任何出口都是统一格式。
4.4 性能实测与瓶颈分析
有人会担心自定义格式的解析性能。我们之前在 4 核 8G 的虚拟机里做过一次并不严谨的压测:单次解析 1000 行左右的 TOON 文本,耗时大约在几百微秒到 1 毫秒之间,跟同一台机器上 JSON 解析一个同数据量的字符串相比,差距不大。TOON 的瓶颈主要在序列化端的字符串拼接,而不是解析端。
真正影响线上延迟的,从来不是解析器,而是序列化时反复做字符串拼接、对象反射、日志输出。TOON 也一样。如果你要面向高吞吐场景,我的建议是提前用字节流输出直接写入响应缓冲区,避免构造大字符串;这属于通用优化,跟格式本身关系不大。
5. 哪些场景适合 TOON,哪些场景千万别硬上
5.1 适合的场景:内部服务、配置格式、教学演示
TOON 我实际用下来最舒服的场景是内部服务间接口。服务数量不多,往往是同一个团队维护,统一格式的成本很低,而收益很高。每个服务看对方接口时,不需要看文档,直接看.toon文件就能明白字段结构,字段类型也写在里面,比看 Markdown 文档可靠得多。
配置格式也很合适。TOON 本身长得就像配置文件,嵌套结构表达清晰,注释行用#,运维能看懂,业务也能看懂。我们内部一些数据同步任务的配置,后来直接改成.toon后缀,代码里用同一个解析器加载,少维护一套格式。
教学演示是我没想到的意外收获。团队里带新人时,直接讲 JSON 有点枯燥,讲 YAML 有缩进坑,讲 XML 又太啰嗦。TOON 语法量很小,二十分钟能讲完,新同学用解析器跑一遍就上手了。这个场景让我意识到,自定义格式最大的价值未必是生产,而是"让团队拥有一个共同的技术语言"。
5.2 不适合的场景:开放 API、超大流量、强生态场景
TOON 绝对不适合做开放 API。外部开发者不认自定义格式,他们只认 JSON、XML、Protobuf 这类成熟标准。对外开放接口,格式的统一性本质上不如生态兼容性重要。就算 TOON 内部很好用,外部客户也不会因为你一句"接受 TOON 请求"就去改他们的 SDK。
超大流量场景也不建议硬上。虽然 TOON 解析性能不差,但它的文本格式比二进制传输更占带宽。一天几十亿请求的服务,省一个字节都是巨大的成本,这种情况用 Protobuf 或 FlatBuffers 明显更合理。
已有强生态的领域也别折腾。前端如果深度依赖 JSON 的JSON.parse,你换格式就得自研浏览器端解析器,即便有 1 分钟能跑完的解析器,后续维护成本也不划算。数据格式的价值很大程度来自生态,自建格式只能在内部实现里最大化收益。
我把适用性整理成一个简易判断表:
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 内部服务间接口 | 推荐 | 团队可控,格式即契约 |
| 分布式配置管理 | 推荐 | 可读性好,注释支持 |
| 教学与团队培训 | 推荐 | 语法少,容易上手 |
| 开放 API | 不推荐 | 生态兼容性要求高 |
| 高并发大流量 | 不推荐 | 文本格式传输成本高 |
| 已有强标准场景 | 不推荐 | 更换成本大于收益 |
5.3 格式设计的三条原则
折腾完 TOON 之后,我自己总结出三条格式设计原则,放在这里送给也想自造格式的读者。
第一,可读性优先。任何格式最终都是给人看的,机器解析慢一点可以优化,人看不懂才是致命伤。TOON 宁可牺牲一些简洁性,也要保证行尾{一眼能看出来"这里进了一层"。
第二,状态与数据分离。无论做接口、做配置文件还是做消息体,都应该把状态元信息和业务数据分开。没有这条边界,前端和后端永远要为"这个字段到底是不是业务数据"扯皮。
第三,演进优先于完备。第一版 TOON 只支持简单值数组,不支持对象数组的优雅写法。我收到过不少建议,要求加泛型、加枚举继承、加条件表达式,全被我拒绝了。格式一旦复杂起来,维护成本会指数上升。真正该做的是让格式能演进:版本号开头、新字段可选、旧字段标记废弃,而不是一次把类型系统做全。
我个人在实际操作中的体会是,TOON 的命名确实有点玩梗的味道,但它背后"统一返回、格式即契约"的思想,才是真正值得带进下一个项目的东西。最后分享一个小技巧:我们团队维护了一个极小的命令行工具,输入.toon文件直接输出格式化 JSON,配合在线接口调试和日志排查非常好用。你如果也想推广新格式,先把这个转换工具做出来,比任何文档都更有说服力。