精读《手写 JSON Parser》这件事,我是被逼出来的。前阵子排查一个线上问题,接口返回的 JSON 里有个字段死活解析不出来,日志里只丢了一句“Unexpected token”,连位置都没给。当时手头没有趁手的工具,我索性翻出 JSON 规范,从零撸了一个解析器,一边写一边把整个流程吃透。后来发现,这个练习的价值远超“造轮子”本身——它把词法分析、递归下降、错误定位、性能取舍这些平时被库函数遮住的东西,全部摊开在桌面上。这篇博文就是那次实践的完整复盘,面向的是会写代码、但没系统拆过解析器的朋友,也适合想搞懂“一个字符串怎么变成对象”这个问题的任何人。读完你至少能拿到三样东西:一个能跑的手写解析器、一套可复用的递归下降思路、以及一堆只有踩过才知道的坑。
1. 为什么值得手写一个 JSON Parser
1.1 从“会用”到“懂原理”的跨越
绝大多数人接触 JSON 的方式是JSON.parse()或者某个语言的json.loads(),一行调用,完事。这当然没问题,工程上讲效率优先。但问题在于,一旦解析出错,或者遇到非标准输入,或者需要做流式处理、需要保留字段顺序、需要自定义数字精度,现成的库就开始露出边界。我那次线上问题就是典型:库报错信息太粗,定位不到具体字符,只能靠猜。
手写解析器的第一个收益,是把“黑盒”变成“白盒”。你会清楚地知道,一个 JSON 文本进来,先被切成 token,再被语法规则组装成树,最后映射成语言里的对象。这个链条一旦建立,任何解析异常你都能在脑子里还原出它卡在哪一步。第二个收益是思维训练:递归下降是解析领域最基础也最实用的方法,JSON 的文法简单到刚好适合入门,又复杂到足以覆盖字符串转义、数字边界、嵌套结构这些真实难点。拿它练手,性价比极高。
1.2 JSON 文法到底长什么样
动手之前得先把规范读薄。JSON 的完整文法用 BNF 写出来大概是这样(我做了简化,保留核心):
value := object | array | string | number | 'true' | 'false' | 'null' object := '{' (pair (',' pair)*)? '}' pair := string ':' value array := '[' (value (',' value)*)? ']' string := '"' char* '"' number := '-'? int frac? exp?看着简单,但魔鬼在细节里。比如object和array都允许空,所以{}和[]合法;比如pair的键必须是字符串,不能是数字;比如数字不允许前导零,01是非法的;比如字符串里的转义只认\" \\ \/ \b \f \n \r \t和\uXXXX这几种。这些约束如果不在词法层和语法层分别落实,写出来的解析器就会“宽容”地接受一堆非法输入,埋下隐患。
我当时的做法是:先把文法抄在纸上,然后对着它一条一条写函数。parseValue是总入口,根据当前字符分派到parseObject、parseArray、parseString、parseNumber或字面量分支。这种“一个非终结符对应一个函数”的映射,就是递归下降的精髓,后面会细讲。
1.3 手写解析器的适用场景与边界
得说清楚,不是所有场景都该手写。日常业务里,标准库足够快、足够稳,没必要重复造轮子。手写解析器真正有价值的场景有这么几类:一是需要精确的错误定位,比如编辑器、配置校验工具,要告诉用户“第 3 行第 15 列少了个逗号”;二是需要流式或增量解析,比如处理超大文件不想一次性读进内存;三是需要定制行为,比如保留键的顺序、把数字解析成高精度类型、对重复键做特殊处理;四是教学和面试,递归下降是绕不开的基本功。
反过来说,如果你只是想把一段配置读进来,别折腾,用现成的。手写解析器在性能和健壮性上很难超过经过多年打磨的库,它的价值在于可控和可理解。我在项目里最终也是把手写版本作为“诊断工具”保留,生产路径仍然走标准库,这个定位要摆正。
2. 整体架构:词法分析与语法分析的分离
2.1 两阶段设计的理由
解析器最经典的架构是两段式:先词法分析(Lexer/Tokenizer),把字符流切成有意义的 token 序列;再语法分析(Parser),把 token 序列按文法组装成抽象语法树(AST)或直接产出目标对象。为什么不一步到位、边读字符边建对象?因为混在一起会让代码迅速失控。
分开的好处很实在。词法层只关心“这个字符序列是什么类型”,比如遇到"就开始扫字符串、遇到数字字符就开始扫数字,它不需要知道当前处在对象里还是数组里。语法层只关心“token 的顺序对不对”,比如{之后必须跟字符串键或},它不需要关心字符串内部怎么转义。职责一分离,两边的逻辑都变得线性、可测试。我实测下来,分开写虽然多了一层 token 结构,但调试成本降低了一大半——出问题时你能立刻判断是切词切错了,还是组装组错了。
2.2 Token 的类型设计
Token 的设计要克制,够用就行。我定义了这么几类:
| Token 类型 | 说明 | 示例 |
|---|---|---|
| LBRACE | 左花括号 | { |
| RBRACE | 右花括号 | } |
| LBRACKET | 左方括号 | [ |
| RBRACKET | 右方括号 | ] |
| COLON | 冒号 | : |
| COMMA | 逗号 | , |
| STRING | 字符串字面量(已处理转义) | "abc" |
| NUMBER | 数字字面量 | -12.5e3 |
| TRUE / FALSE / NULL | 三个字面量 | true |
| EOF | 输入结束 | — |
每个 token 除了类型,还要带两个信息:原始值(或解析后的值)和在源文本中的起始位置(偏移量、行号、列号)。位置信息是后面做精确报错的基础,千万别省。我一开始图省事只存了偏移量,结果报错时还得自己换算行列,很别扭,后来补上了行列号才顺手。
2.3 解析器的状态管理
语法分析阶段需要一个“游标”来记录当前读到哪个 token,通常就是一个整数索引加上 token 数组。核心操作只有两个:peek()看当前 token 不消费,next()消费当前 token 并前进。所有解析函数都围绕这两个操作展开。这种设计让回溯变得简单——需要预读时peek一下,不需要就next,逻辑非常清晰。
有一点要提醒:JSON 的文法是无歧义的,不需要回溯,所以其实用不上复杂的预测分析。但保留peek的习惯有好处,写错误处理时能方便地判断“下一个 token 是不是我期待的”。我在parseObject里就用peek判断是空对象还是键值对,代码读起来很顺。
3. 词法分析器:把字符流切成 Token
3.1 主循环与空白跳过
Lexer 的主循环很朴素:只要没到输入末尾,就跳过空白,然后根据当前字符决定切什么 token。空白包括空格、制表符、换行、回车这四种,JSON 规范只认这四个,别自作主张把别的字符也当空白。
def next_token(self): self.skip_whitespace() if self.pos >= len(self.src): return Token(TokenType.EOF, None, self.pos) ch = self.src[self.pos] if ch == '{': return self.make_simple(TokenType.LBRACE) if ch == '}': return self.make_simple(TokenType.RBRACE) if ch == '[': return self.make_simple(TokenType.LBRACKET) if ch == ']': return self.make_simple(TokenType.RBRACKET) if ch == ':': return self.make_simple(TokenType.COLON) if ch == ',': return self.make_simple(TokenType.COMMA) if ch == '"': return self.read_string() if ch == '-' or ch.isdigit(): return self.read_number() if ch == 't': return self.read_literal('true', TokenType.TRUE) if ch == 'f': return self.read_literal('false', TokenType.FALSE) if ch == 'n': return self.read_literal('null', TokenType.NULL) raise ParseError(f"Unexpected character {ch!r}", self.pos)这段代码的意图很直白:单字符 token 直接映射,多字符的交给专门的读取函数。注意最后的兜底raise,任何不认识的字符都要立刻报错,绝不能默默跳过——这是很多手写解析器“宽容过头”的根源。
3.2 字符串读取与转义处理
字符串是词法层最麻烦的部分,因为要处理转义。核心逻辑是:遇到开引号后,一直读到闭引号,中间遇到反斜杠就进入转义分支。转义分两类:简单转义(\" \\ \/ \b \f \n \r \t)和 Unicode 转义(\uXXXX)。
def read_string(self): start = self.pos self.pos += 1 # 跳过开引号 buf = [] while self.pos < len(self.src): ch = self.src[self.pos] if ch == '"': self.pos += 1 return Token(TokenType.STRING, ''.join(buf), start) if ch == '\\': self.pos += 1 esc = self.src[self.pos] mapping = {'"':'"','\\':'\\','/':'/','b':'\b','f':'\f','n':'\n','r':'\r','t':'\t'} if esc in mapping: buf.append(mapping[esc]) self.pos += 1 elif esc == 'u': hex4 = self.src[self.pos+1:self.pos+5] buf.append(chr(int(hex4, 16))) self.pos += 5 else: raise ParseError(f"Invalid escape \\{esc}", self.pos) else: buf.append(ch) self.pos += 1 raise ParseError("Unterminated string", start)这里有个容易忽略的点:\uXXXX只处理了基本多文种平面内的字符,遇到代理对(surrogate pair)需要额外拼接。标准 JSON 允许用两个\u转义表示一个增补字符,严格实现要判断高位代理和低位代理并合并。我第一版没做,结果解析某些表情符号时出了乱码,后来补上了。如果你的场景不涉及增补字符,可以先跳过,但心里要有数。
注意:字符串里不允许出现未转义的控制字符(码点小于 0x20 的字符),严格实现应该报错。很多库对此宽容,但规范是禁止的。
3.3 数字读取与边界校验
数字的难点不在读取,而在校验。JSON 数字的文法是-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?,翻译成人话:可选负号、整数部分不能有前导零(除非就是单个 0)、可选小数部分、可选指数部分。我见过不少手写实现直接用正则或者float()一把梭,结果01、1.、.5、1e这些非法输入全被放行了。
def read_number(self): start = self.pos if self.src[self.pos] == '-': self.pos += 1 # 整数部分 if self.pos < len(self.src) and self.src[self.pos] == '0': self.pos += 1 elif self.pos < len(self.src) and self.src[self.pos].isdigit(): while self.pos < len(self.src) and self.src[self.pos].isdigit(): self.pos += 1 else: raise ParseError("Invalid number", start) # 小数部分 if self.pos < len(self.src) and self.src[self.pos] == '.': self.pos += 1 if not (self.pos < len(self.src) and self.src[self.pos].isdigit()): raise ParseError("Invalid fraction", start) while self.pos < len(self.src) and self.src[self.pos].isdigit(): self.pos += 1 # 指数部分 if self.pos < len(self.src) and self.src[self.pos] in 'eE': self.pos += 1 if self.pos < len(self.src) and self.src[self.pos] in '+-': self.pos += 1 if not (self.pos < len(self.src) and self.src[self.pos].isdigit()): raise ParseError("Invalid exponent", start) while self.pos < len(self.src) and self.src[self.pos].isdigit(): self.pos += 1 raw = self.src[start:self.pos] return Token(TokenType.NUMBER, raw, start)注意我存的是原始字符串raw,不是立刻转成 float。原因有二:一是浮点转换会丢精度,大整数或高精度小数在业务里可能需要特殊处理;二是转换时机应该交给使用方决定,词法层不该越俎代庖。这个取舍在需要精确数值的场景里很关键。
3.4 字面量与错误兜底
true、false、null三个字面量读取很简单,匹配固定字符串即可,但要保证匹配完之后下一个字符不是字母数字,否则truex会被误判成true加一个多余的x。我的做法是读完固定长度后检查边界,不合法就报错。
def read_literal(self, word, ttype): start = self.pos if self.src[self.pos:self.pos+len(word)] != word: raise ParseError(f"Invalid literal", start) self.pos += len(word) if self.pos < len(self.src) and (self.src[self.pos].isalnum() or self.src[self.pos] == '_'): raise ParseError("Invalid literal boundary", start) return Token(ttype, word, start)词法层的错误兜底原则只有一条:宁可报错,不可猜测。任何不符合文法的输入都应该在第一时间抛出带位置的异常,让问题暴露在源头,而不是留到语法层去“将错就错”。
4. 语法分析器:递归下降组装对象
4.1 递归下降的核心思想
递归下降说白了就是“为文法里的每个非终结符写一个函数,函数内部按产生式顺序调用其他函数”。JSON 的非终结符就那么几个:value、object、array、pair,一一对应四个函数。parseValue是分派中心,看当前 token 类型决定走哪条路;parseObject和parseArray处理嵌套,内部又递归调用parseValue。这种结构天然贴合 JSON 的嵌套特性,写起来几乎是把文法翻译成代码。
def parse_value(self): tok = self.peek() if tok.type == TokenType.LBRACE: return self.parse_object() if tok.type == TokenType.LBRACKET: return self.parse_array() if tok.type == TokenType.STRING: return self.next().value if tok.type == TokenType.NUMBER: return self.convert_number(self.next().value) if tok.type == TokenType.TRUE: self.next(); return True if tok.type == TokenType.FALSE: self.next(); return False if tok.type == TokenType.NULL: self.next(); return None raise ParseError(f"Unexpected token {tok.type}", tok.pos)这段代码是整个解析器的心脏,读起来应该像读文法一样顺畅。如果哪天你发现parse_value里塞满了各种特殊判断,那说明设计跑偏了,该回头检查是不是把词法层的活揽过来了。
4.2 对象与数组的解析细节
parseObject的逻辑是:吃掉{,看下一个是不是},是就返回空对象;否则循环“读键、吃冒号、读值、看逗号”,直到遇到}。这里有两个细节容易出错。第一,键必须是字符串 token,如果读到数字或其他类型要立刻报错,不能强行转字符串。第二,逗号的处理要严格——{"a":1,}这种尾随逗号是非法的,很多手写实现会宽容地接受,但规范不允许。
def parse_object(self): self.expect(TokenType.LBRACE) obj = {} if self.peek().type == TokenType.RBRACE: self.next() return obj while True: key_tok = self.peek() if key_tok.type != TokenType.STRING: raise ParseError("Object key must be string", key_tok.pos) key = self.next().value self.expect(TokenType.COLON) obj[key] = self.parse_value() tok = self.peek() if tok.type == TokenType.COMMA: self.next() continue if tok.type == TokenType.RBRACE: self.next() return obj raise ParseError("Expected ',' or '}'", tok.pos)数组的逻辑几乎对称,只是没有键和冒号。这里我想强调一个设计选择:重复键怎么处理?标准没有规定,多数实现是“后者覆盖前者”。我在手写版本里保留了这个行为,但加了一个可选的严格模式,遇到重复键直接报错。这个开关在配置校验场景里很有用,能提前发现手误。
4.3 错误定位与信息构造
手写解析器相对库的最大优势就是错误信息可以做到极致。我的做法是:每个ParseError都带上偏移量,再根据源文本换算出行列号,最后拼成“第 X 行第 Y 列:期望 Z,实际是 W”的格式。为了让信息更有用,我还会附上出错位置前后各若干字符的上下文片段,用^标出具体位置。
def format_error(self, err): line = self.src.count('\n', 0, err.pos) + 1 col = err.pos - self.src.rfind('\n', 0, err.pos) snippet_start = max(0, err.pos - 20) snippet = self.src[snippet_start:err.pos+20].replace('\n', ' ') caret = ' ' * (err.pos - snippet_start) + '^' return f"Line {line}, Col {col}: {err.msg}\n {snippet}\n {caret}"这套错误信息在实际排查里帮了大忙。之前那个线上问题,如果当时有这种定位能力,五分钟就能定位到是某个字段的字符串里混进了未转义的控制字符,而不是对着“Unexpected token”干瞪眼。
4.4 从 AST 到目标对象的映射
严格来说,JSON 解析可以直接产出目标语言的对象,不必显式建 AST,因为 JSON 的结构和大多数语言的原生结构(字典、列表、字符串、数字、布尔、空)几乎一一对应。我在实现里就是直接产出的,parse_object返回字典,parse_array返回列表,省去了 AST 这一层。但如果你的目标是做语法高亮、格式化、或者需要保留源码位置信息,那就应该建 AST,每个节点带上起止位置。这个取舍取决于下游用途,没有绝对的对错。
5. 实操全流程与关键参数
5.1 完整实现骨架与调用方式
把前面的片段拼起来,一个最小可用的解析器大概两百行以内。调用方式很直接:
def parse(src): lexer = Lexer(src) tokens = [] while True: tok = lexer.next_token() tokens.append(tok) if tok.type == TokenType.EOF: break parser = Parser(tokens, src) result = parser.parse_value() parser.expect(TokenType.EOF) # 确保没有多余内容 return result注意最后那个expect(EOF),它保证整个输入被完整消费。少了这一步,{"a":1} garbage这种输入会被静默接受,只解析出前半段。这是很多手写实现的通病,务必补上。
5.2 数字转换的精度取舍
前面提到词法层存的是原始字符串,转换发生在语法层。这里有个参数选择:转成什么类型?如果目标语言有原生大整数和高精度小数,优先用它们;如果没有,就得在“用浮点丢精度”和“保留字符串”之间选。我的默认策略是:整数且在安全范围内转 int,超出范围保留字符串;小数统一转 float,但提供一个选项保留原始字符串。这个策略在配置解析场景里很实用,因为配置里的数字往往不需要参与运算,保留字符串反而更安全。
| 输入 | 默认结果 | 保留字符串模式 |
|---|---|---|
123 | int 123 | "123" |
12345678901234567890 | 字符串(超范围) | 原样字符串 |
3.14 | float 3.14 | "3.14" |
1e400 | float inf(需警惕) | "1e400" |
1e400这个例子值得单独说:它超出了双精度浮点的表示范围,转换会得到无穷大,而 JSON 规范里数字是允许任意精度的。如果你的业务对数值敏感,遇到这种输入要么报错,要么保留字符串,绝不能默默变成inf。
5.3 性能实测与优化点
我拿一个约 10MB 的 JSON 文件做了对比测试,手写版本和标准库的差距大概在三到五倍,主要慢在 Python 层的字符逐个处理上。如果追求性能,有几个立竿见影的优化点:一是用索引扫描代替切片拼接,减少临时字符串;二是字符串读取时用find定位下一个引号,跳过无转义的快速路径;三是 token 用轻量结构(比如元组)而不是类实例。我试过把字符串读取改成“先找下一个引号,没有反斜杠就直接切片”,速度提升了将近一倍。不过说实话,除非你有明确的性能瓶颈,否则没必要过度优化,可读性优先。
5.4 测试用例的设计
解析器的测试要覆盖三类:合法输入、非法输入、边界输入。合法输入包括空对象、空数组、深层嵌套、各种转义、正负数和指数;非法输入包括尾随逗号、单引号字符串、前导零、未闭合结构、非法转义;边界输入包括空字符串、超长字符串、超大数字、深层嵌套导致的栈溢出。我整理了一份速查表,实际开发时对着过一遍,能省不少返工。
| 类别 | 用例 | 期望 |
|---|---|---|
| 合法 | {}、[]、{"a":[1,2]} | 正常解析 |
| 合法 | "\u4e2d\u6587" | 解析为“中文” |
| 非法 | {"a":1,} | 报错:尾随逗号 |
| 非法 | 01 | 报错:前导零 |
| 非法 | "abc | 报错:字符串未闭合 |
| 边界 | 嵌套 1000 层 | 注意递归深度 |
6. 踩坑实录与排查技巧
6.1 递归深度与栈溢出
深层嵌套是手写递归下降最容易翻车的地方。JSON 规范没限制嵌套层数,但递归实现受调用栈限制,嵌套几千层就可能栈溢出。我在测试里构造了一个一万层嵌套的数组,直接崩了。解决办法有两个:一是限制最大嵌套深度,超过就报错,这是最省事的做法,多数库也是这么干的;二是改写成显式栈的迭代版本,但代码复杂度会上升不少。我的选择是加一个可配置的深度上限,默认一千层,够用且安全。
注意:深度上限不要设得太大,否则恶意构造的深层嵌套输入可能拖垮服务。这个参数本质上是个安全阀。
6.2 转义与编码的隐蔽问题
字符串转义里最隐蔽的坑是代理对。前面提过,\uD83D\uDE00这种两个转义序列合起来才表示一个字符,如果逐个chr()转换,会得到两个孤立的代理码点,输出时变成乱码。正确做法是检测到高位代理(0xD800–0xDBFF)时,看下一个是不是\u开头且为低位代理(0xDC00–0xDFFF),是就合并计算码点。这个细节在只处理中文的场景里可能碰不到,但一旦涉及表情符号或生僻字就会暴露。
另一个坑是 BOM。有些文本编辑器会在文件开头加一个字节顺序标记,如果不去掉,第一个 token 就会解析失败。我的处理是在入口处检测并跳过 BOM,虽然 JSON 规范不建议带 BOM,但现实里就是会遇到。
6.3 常见错误速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 报“Unexpected character” | 输入含非法字符或 BOM | 检查文件头、检查是否有中文标点 |
| 报“Unterminated string” | 字符串缺闭引号或转义写错 | 检查\"是否漏了反斜杠 |
| 解析结果少字段 | 重复键被覆盖 | 开启严格模式检测重复键 |
| 数字变成科学计数法 | 大数转浮点丢精度 | 改用保留字符串模式 |
| 深层结构崩溃 | 递归栈溢出 | 加深度上限或改迭代 |
| 尾随内容被忽略 | 没检查 EOF | 解析完必须 expect(EOF) |
6.4 几个只有踩过才知道的技巧
第一个技巧:调试词法层时,写一个把 token 序列打印成表格的小工具,比在解析器里打断点高效得多。你能一眼看出是切词切错了还是组装组错了。第二个技巧:错误信息里一定要带上下文片段,光有行列号不够,人眼需要看到周围的字符才能快速判断问题。第三个技巧:写测试时优先写非法输入的用例,因为合法输入往往一次就过,非法输入才是暴露设计缺陷的地方。我最初写的十几个测试里,合法用例全绿,非法用例挂了一半,全是宽容过头导致的。
还有一个心得:别急着一次性写完。先把词法层写完、测通,再写语法层。两层混着调试,出了问题根本分不清是谁的锅。我第一版就是一口气写完,结果一个简单的{"a":1}都解析不出来,查了半天发现是词法层把冒号当成了字符串的一部分。分层开发、分层测试,这个纪律能省下大量时间。
7. 从手写解析器延伸出的通用能力
写完这个解析器之后,我最大的感受是:解析这件事的套路是相通的。词法分析加递归下降这套组合,换个文法就能用来解析配置文件、表达式、简单的查询语言,甚至某些标记语言。你只要把文法写清楚,剩下的就是机械地翻译成函数。这种“把文法变成代码”的能力,比记住某个库的 API 有价值得多。
另一个延伸是错误处理的设计思路。手写解析器逼着你去想“错误应该在哪一层被发现、带什么信息、怎么呈现给使用者”。这套思路迁移到任何输入校验场景都适用——接口参数校验、表单验证、日志解析,本质都是“判断输入是否符合预期,不符合就给出可操作的反馈”。我现在写校验逻辑,都会下意识地问自己:报错信息够不够定位问题?有没有把非法输入放行?
如果后续还想深入,可以往几个方向扩展:支持流式解析,边读边解析不占内存;支持注释和尾随逗号等宽松模式,做成可配置;把解析结果映射到强类型结构,做 schema 校验。这些都是在手写版本上自然生长出来的需求,比一开始就上重型框架更能理解每一层为什么存在。
最后分享一个我在实际使用中的习惯:每次遇到解析相关的疑难杂症,我都会先用自己写的这个解析器跑一遍,看它报什么错。它的错误信息比标准库细得多,往往能直接指出问题所在。这个手写版本平时不参与生产,但作为诊断工具,它已经帮我省下好几次加班了。