news 2026/10/21 18:42:05

手写JSON解析器:用状态机把字节流变成哈希表的全过程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写JSON解析器:用状态机把字节流变成哈希表的全过程

解析器这个东西,看着高深,其实干的事特别朴素:把一串字符按语法规则翻译成程序能直接操作的内存结构。我当年第一次被解析器难住,是在某公司写配置导入功能的时候——文件格式是自己定的,解析器写得不扎实,后面所有逻辑全部白搭。后来我把这个事拆开揉碎,发现拿JSON解析器当样本学习,是最快也最直观的路径。这篇就用手写一个极简版状态机实现的方式,把{"age": 18}是怎么变成内存里哈希表的全过程走一遍。适合正在学编译原理、刚接触parser、或者想补一补底层基本功的人,看完可以直接照着写,也能看懂其他语言里JSON解析库的核心逻辑。

1. 先搞清楚解析器到底在干嘛

1.1 字节流、字符串和内存结构的关系

你在文件里看到的{"age": 18},本质上是文件里的一串字节。JSON规范里默认UTF-8编码,所以这个字符串在存储层面其实是这些字节:7b 22 61 67 65 22 3a 20 31 38 7d。其中7b是{,22是双引号,61是a,67是g,65是e,3a是冒号,20是空格,31是1,38是8,7d是}。

解析器的工作,就是逐字节、逐字符扫过去,一边扫一边判断“我现在读到哪了”“这个字符是什么意思”“接下来该期待什么”。等扫完整个输入,它得在内存里构建出一个程序员真正想要的东西——一个键是字符串、值是各种数据类型、可以快速按键查找的结构。放到Python里就是dict(哈希表),放到JavaScript里就是Object,放到Java里可能就是HashMap。

这个从“字节序列”到“内存结构”的映射,就是解析器存在的全部意义。你可以把它理解成一个翻译官:机器收到的是按协议编码的字节流,业务代码需要的是能直接操作的数据结构,中间这层翻译,就是解析器干的活。

1.2 把{"age": 18}拆成语法零件

在写任何解析代码之前,得先搞清楚JSON到底由哪些语法零件构成。JSON的核心语法其实非常少,少到什么程度呢,一页纸能说完:

  • 对象:{}包裹,里面是键值对,键和值之间用:分隔,键值对之间用,分隔
  • 数组:[]包裹,里面是值,用,分隔
  • 键:必须是带双引号的字符串
  • 值:可以是字符串、数字、布尔值、null、对象、数组

所以{"age": 18}的语法分解是:一个对象,包含一个键值对。键是字符串age,值是数字18。

解析器的工作流程通常可以分成两个阶段。第一阶段叫词法分析,把输入拆成一个个token,比如{、"age"、:、18、}。第二阶段叫语法分析,把这些token按照语法规则组装成内存结构。很多手写解析器会把这两个阶段合在一起,边扫边搭结构,这就是我们这篇要写的状态机做法。

2. 状态机:你的解析器得有张状态地图

2.1 状态、转移条件和动作三件套

状态机这个词听着唬人,实际想法很简单。它就回答三个问题:我现在在哪个状态?我遇到了什么输入?我要做什么动作并且跳到哪个状态?

举一个生活例子。电梯门的状态机:门有“打开”和“关闭”两个状态。遇到“有人按开门键”这个输入,门从“关闭”跳到“打开”;遇到“门已开且超时”这个输入,门从“打开”跳回“关闭”。中间还可以有“正在开门”“正在关门”这种中间状态。电梯程序就是靠这套状态机逻辑在跑,它永远不会说“我同时是打开和关闭的”,因为状态是互斥的。

解析器也一样。一次解析过程中,同一个时刻一定处在某个确定的状态。状态决定了我看到下一个字符时该怎么反应。这比“想到哪写到哪”的解析代码要稳得多,因为逻辑是闭环的:每个状态都有明确的进入条件、退出条件和异常处理。

2.2 JSON对象解析的状态设计

针对{"age": 18}这种最简单对象,要设计哪些状态?我自己实践下来,最少需要这些:

状态含义合法输入转移目标
EXPECT_KEY期待对象键的开始"READ_KEY
READ_KEY正在读取键名任意字符、"EXPECT_COLON
EXPECT_COLON期待冒号:EXPECT_VALUE
EXPECT_VALUE期待值数字、"、{、[、字母对应的值读取状态
EXPECT_COMMA_OR_END值读取完毕,期待逗号或右括号,、}EXPECT_KEY 或 完成

这五个状态,加上一个初始状态和一个结束状态,就是解析单个JSON对象所需的全部状态地图。你会发现,解析器之所以能不出错,关键在于“状态决定期待”:在EXPECT_KEY状态下看到:就是错误,在EXPECT_VALUE状态下看到{意味着进入嵌套对象。

设计状态的时候有一个经验:每个状态只负责一件特别小的事。比如不要设计一个叫“读取键值对”的大状态,它会让你在代码里到处塞条件分支。状态拆得越细,代码越好写,错误也越好定位。

2.3 为什么状态机特别适合JSON

JSON的设计恰好是状态机的好朋友,因为它的语法是“严格嵌套”的:一个{必须对应一个},一个[必须对应一个],一个值要么是原子(数字、字符串、布尔),要么是复合体(对象、数组)。这种递归结构天然可以用状态来描述。

对比一下用正则表达式解析JSON。正则能匹配字符串,但遇到嵌套结构就彻底没辙,因为正则本质上是有限状态机,无法处理无限嵌套的递归语法。而手写状态机或递归下降解析器,则可以轻松应对任意深度。这也是为什么造JSON解析器基本都用手写,而不是用正则库硬扛。

3. 手写极简状态机解析器

3.1 纯状态机代码:解析单个对象

下面这份代码,是“教科书级”的状态机写法——整个解析过程只有一个主循环,靠一个state变量驱动,没有任何递归调用。它只处理单层对象、字符串键、整数和浮点数值,但足够说明状态机的核心机制。

def parse_single_object(text): i = 0 n = len(text) state = "EXPECT_KEY" key = None result = {} while i < n: c = text[i] if c in ' \t\n\r' and state != "READ_KEY_STRING": i += 1 continue if state == "EXPECT_KEY": if c == '"': key = "" state = "READ_KEY_STRING" i += 1 else: raise ValueError(f"line 1: expected '\"' to start key, got '{c}'") elif state == "READ_KEY_STRING": if c == '"': state = "EXPECT_COLON" i += 1 else: key += c i += 1 elif state == "EXPECT_COLON": if c == ':': state = "EXPECT_VALUE" i += 1 else: raise ValueError("line 1: expected ':' after key") elif state == "EXPECT_VALUE": num_str = "" while i < n and (text[i].isdigit() or text[i] in '.-+eE'): num_str += text[i] i += 1 if num_str == "": raise ValueError(f"line 1: expected value, got '{c}'") if '.' in num_str or 'e' in num_str or 'E' in num_str: result[key] = float(num_str) else: result[key] = int(num_str) state = "EXPECT_COMMA_OR_END" elif state == "EXPECT_COMMA_OR_END": if c == ',': state = "EXPECT_KEY" i += 1 elif c == '}': return result else: raise ValueError(f"line 1: expected ',' or '}}', got '{c}'") else: raise RuntimeError(f"unreachable state: {state}") raise ValueError("line 1: unterminated JSON object, missing '}'")

调用方式:

data = parse_single_object('{"age": 18}') print(data) # {'age': 18} print(type(data)) # <class 'dict'> print(data["age"]) # 18

这段代码严格用状态机思想驱动:没有递归,没有回溯,就是一个while循环从第一个字符走到最后一个字符。所有状态转移都在循环体内完成,遇到非法输入直接抛异常。

3.2 逐步跟踪:一个字符一个字符看状态变化

现在拿{"age": 18}实际走一遍状态机的运转,每个关键步奏都展开:

步骤当前位置字符当前状态做了什么下一个状态
10{EXPECT_KEY检查是否引号?不是,报错?等等,这里出现了一个问题

等等,我上面的代码在EXPECT_KEY状态下遇到{会直接报错。因为我先跳过了对象开头的大括号。实际解析时,最外层的大括号需要被消费掉。修正一下:在进入主循环之前先消费掉开头的{。

其实严谨的写法是,主循环开始前先做一次初始状态判断:第一个非空字符必须是{。然后进入状态机。修正后的顺序是这样:

def parse_single_object(text): i = 0 n = len(text) while i < n and text[i] in ' \t\n\r': i += 1 if i >= n or text[i] != '{': raise ValueError("expected '{'") i += 1 state = "EXPECT_KEY" key = None result = {} while i < n: c = text[i] # ... 上面的状态机循环

好,我们用修正后的代码来跟踪:

步骤位置字符当前状态关键动作
00{初始消费开括号,建空dict{}
11"EXPECT_KEY键开始,key置空串
22aREAD_KEY_STRING拼进key,key="a"
33gREAD_KEY_STRING拼进key,key="ag"
44eREAD_KEY_STRING拼进key,key="age"
55"READ_KEY_STRING键结束,key定稿="age"
66:EXPECT_COLON消费冒号
77EXPECT_VALUE空格跳过
881EXPECT_VALUE开始读数字,拼1
998EXPECT_VALUE拼数字,数字="18"
1010}EXPECT_COMMA_OR_END写哈希表 result["age"]=18,返回

第8步比较关键。数字读取是状态机里的“子循环”,它在EXPECT_VALUE状态下出现,连续吞掉所有数字字符,直到遇到非数字字符(这里是})才停下来。这个过程中状态机不跳转,因为还在读同一个值。

第10步是点题的地方:当}到来并且状态是EXPECT_COMMA_OR_END,说明整个对象已经完整读完,把累计了半天的key和value真正写入哈希表。这里就是字节流变成内存结构的临门一脚。

3.3 哈希表到底是怎么被填出来的

我遇到不少初学者问:解析器是不是解析到一个{立刻就创建哈希表?答案是:对象开始就创建空哈希表,但键值对不是边读边写入的,而是等一个键值对完整读取完毕后一次性写入。

原因是,如果你在键读到一半就写入,那写入的键是残的。如果值没读完就写入,那写入的值是错的。所以状态机的设计选择是:key先缓存到一个变量里,value也缓存起来,等读到}或,时才算一个键值对“定型”了,此时才执行result[key] = value。这种“暂存、定型、提交”三步走的模式,在处理嵌套对象时同样适用,只是暂存的不再是单个变量,而是一个调用栈。

哈希表的最终状态,就是result这个dict变量。打印它,你能看到内存里确实是{'age': 18},可以data["age"]直接取值,也可以"age" in data快速判断键存在。这些操作全部基于哈希表,时间复杂度是O(1)级别。解析的终极目的,就在这一步达成。

4. 从玩具到真家伙:完整JSON支持要补什么

4.1 字符串值、转义和unicode

真实JSON里,字符串值不光在键的位置出现,也在值的位置出现。而且字符串里可能含有转义字符:\"、\\、\n、\t、\r、\b、\f、\/,还有unicode转义\uXXXX。

极简版状态机里,我把键的读取写死成了“字符拼到key直到引号”,这个逻辑扩展成通用字符串读取函数,再处理一下双引号内的转义即可。特别要说的是\uXXXX的处理,它需要把4位十六进制转成真正的unicode字符。例如"\u4f60"就是“你”。这里要小心代理对的问题,如果遇到\uD83D\uDE00这种emoji代理对,简单实现可以不做合并,直接当成两个独立字符处理;但如果你的下游业务需要真正的emoji,就得把两个代理项合并成一个完整字符。

字符串拼接我建议用list收集再join,直接key += c在Python里虽然方便,但字符串是不可变对象,每次拼接都会创建新字符串,解析大文件时性能会明显下降。list收集再join是手写解析器的常规优化。

4.2 数字解析:负数、小数、科学计数法

JSON的数字语法是:-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?。翻译成人话:可选的负号,整数部分不能以0开头(除非整节就是0),可以有小数部分,可以有指数部分。

这里有几个容易踩的坑。第一,01是非法JSON,整数部分不能有前导零。第二,1.是非法JSON,小数点后面必须跟数字。第三,.5是非法JSON,小数点前面必须有数字。第四,1e是非法JSON,指数后面必须跟数字。如果你用Python的float()去转换,它很宽容,会把1.转成1.0,但这不等于输入合法。所以解析器必须自己在语法层面校验格式。

我的经验是写一个独立函数处理数字,内部用一个小的状态机:整数部分状态、小数部分状态、指数部分状态。每一步检查当前字符是否属于该状态的合法字符集。遇到非法字符就报错,并带上位置信息。

4.3 嵌套对象和数组:状态机加一个栈

真实JSON没有我们上面那么乖,一定有嵌套:对象套数组,数组套对象,对象套对象,深度可能几十层。纯状态机的“单一状态变量”就不好使了,因为你需要记住“我从哪来的”“处理完这个子结构回到哪去”。

两种常见方案。第一种是递归下降解析:写一个parse_value函数,里面遇到{就调用parse_object,遇到[就调用parse_array。每个函数都负责解析一个完整结构。递归下降本质上是状态机的结构化解法,每一个函数对应一个非终结符。我之前极简版的状态机,其实就是parse_object这个函数的一种线性展开。

第二种是显式用栈:手工维护一个调用栈,遇到{[就入栈,遇到}]就出栈。栈里保存父对象的引用、父数组的引用,以及当前状态下key的暂存。很多性能敏感的手写解析器会用显式栈代替递归,因为递归有调用深度限制,碰到恶意构造的超深嵌套JSON可能爆栈。

完整版代码我就不全部贴了,但核心结构是:主循环里维护一个stack列表,栈顶是“当前正在构建的容器”和“回到父容器时需要执行的动作”。比如解析到{ "person": { "name": "A" } }时,遇到外层对象里的{就创建新dict,把新dict挂到person键上,然后把新dict和“目前状态”压栈,继续读里层对象。等读到内层对象的},弹栈,回到外层对象的状态,更新外层容器。

这里给出伪代码,帮你建立骨架感:

def parse_json(text): i = 0 stack = [] # 每个元素是 (container, action) current = None # 当前正在构建的容器 current_key = None state = "START" while i < len(text): c = text[i] if state == "START": if c == '{': current = {}; stack.append(current); state = "OBJECT" elif c == '[': current = []; stack.append(current); state = "ARRAY" elif state == "OBJECT": # 期待键 ... elif state == "ARRAY": # 期待值 ... # 处理嵌套、逗号、冒号、结束

这个结构再加一个parse_value逻辑,就是完整JSON解析器的骨架。真的是骨架,因为细节都在状态处理里,但方向是对的。

4.4 布尔值和null支持

这仨是锦上添花的存在。遇到字母t,期望后面跟着true;遇到f,期望false;遇到n,期望null。实现方式是:截取当前位置往后的定长字符串,和关键字比对,不匹配就报错。注意必须整体匹配且后面紧跟非字母数字字符,防止把truex也当成合法true。

这块逻辑最简单,但反而容易出bug,因为比字符串需要多一个“越界”判断:如果JSON文件在true后面直接截断,text[i:i+4]可能只有两个字符,比较会失败,要正确处理。

5. 解析器调试与踩坑心得

5.1 常见的解析失败类型

手写解析器,最怕的不是写不出来,而是写出来之后遇到各种诡异输入。我把这几个月写JSON解析器过程中遇到的高频bug按“症状”分了几类,方便你排查时对照:

症状可能原因排查思路
解析到一半报错,说期望:键后面多了空格/换行没跳过检查跳过空白逻辑是不是所有状态都覆盖了
解析大文件时特别慢字符串拼接用了+=,或者递归函数反复拷贝子串字符串拼接改用list,子串处理改用索引区间
嵌套很深时程序崩溃递归深度超过语言限制改用显式栈,或加一个最大深度限制提前报错
数字1e-5解析成1.0指数部分解析逻辑有bug单独为数字写状态机,指数符号和指数位数都要处理
中文键解析成乱码编码问题输入先转成str,明确编码是UTF-8

我觉得最重要的一条排查经验是:先写一个小巧的“打印当前状态和当前字符”调试函数,解析每个测试输入时把每次状态转移都打出来。人眼看着打印结果,就能立刻发现在哪个状态少了转移分支。这个调试法虽然土,但比上来就用调试器逐步断点要快得多。

5.2 性能思考:解析器的瓶颈在内存而不在CPU

很多人以为解析器性能瓶颈在CPU运算,实际在内存分配。解析JSON时,每个字符串值都要分配内存,每个对象都要创建哈希表,每个数组都要创建列表。分配次数越多,GC压力越大,性能越差。

一个经典优化是:能用索引区间表示子串就别切片。比如数字18在源字符串里的区间是[8,10),如果你直接切片就会创建新字符串,消耗一次分配。极简版代码里我用的是逐字符拼接数字字符串,这个性能不好。正确做法是先记下起始位置,扫描结束再用text[start:end]切片只创建一次新字符串。对于大数字尤其有效。

另一个优化是“流式解析”概念:解析器不一定非得一次性把整个JSON加载进内存再解析。真正的生产级解析器支持增量解析,读一段流就解析一段,构建出来的结构逐步补全。这个复杂,但方向值得知道。对我们手写小解析器,核心的优化就是减少内存分配次数。

5.3 怎么给自己的解析器写测试

写解析器不写测试,等于在悬崖边开车。我的经验是准备三组测试数据:合法样例、非法样例、边界样例。合法样例覆盖所有JSON类型:字符串、数字、数组、对象、布尔、null、嵌套。非法样例覆盖最容易出错的点:缺括号、缺逗号、键没加引号、值后面多了逗号。边界样例覆盖:空对象{}、空数组[]、空字符串""、负数-1、小数0.5、科学计数法1e10、深层嵌套{"a":{"b":{"c":1}}}。

每次改动解析器逻辑,把这三组测试全跑一遍。手动造一个测试护城河,比什么都管用。我曾经因为改了转义字符串的解析逻辑,修好了\n却把\uXXXX搞坏了,如果没有边界测试,这种回归bug会在生产环境里炸得很惨。

6. 一点个人经验

写JSON解析器这事,很多人觉得无非是调库,但真正动手写一遍,对“字节流变成结构”的理解会完全不同。我第一次把状态机跑通、看到{"age": 18}变成{'age': 18}的时候,有点豁然开朗的感觉,原来程序里那些看似神奇的结构化读取,底层就是这么一步步搬砖搬出来的。

最后分享一个特别实用的建议:如果要在生产环境解析JSON,直接用成熟库就好,标准库的c实现性能高、坑少。手写解析器的意义不在“取代库”,而在学习。写一遍,你就具备了给自定义配置文件写解析器的能力,遇到项目里奇奇怪怪的文本格式也不用发怵。后续想继续深入,可以试着给解析器加上行号、列号的错误定位,或者把解析结果序列化回去,那个方向又是另一片天地了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 9:53:27

解决user32.dll拒绝访问:一文讲透Windows文件所有权与权限修改

前几天我收到一张报错截图&#xff0c;对方用的是管理员账户&#xff0c;双击一个美化的user32.dll想替换系统原文件&#xff0c;系统却直接弹出一条提示&#xff1a;“无法保存对user32.dll权限所做的更改&#xff0c;拒绝访问”。类似的情况我处理过很多次&#xff0c;每次都…

作者头像 李华
网站建设 2026/10/18 18:18:09

非法加密货币挖矿的合规红线与风险防范

简介&#xff1a;一套基于PHP构建的海南私彩模拟程序源码&#xff0c;主要面向具备一定PHP基础、希望深入了解彩票类系统开发流程的中高级学习者与独立开发者&#xff0c;可通过无加密代码快速理解从用户投注、后台记录、随机开奖到奖金结算的完整业务流程。资源包共32个文件&a…

作者头像 李华
网站建设 2026/10/18 21:06:18

SpringBoot+Vue+MySQL滑雪场管理系统实战:前后端分离与部署全解析

1. 项目概述与核心价值1.1 滑雪场管理系统能解决什么问题先聊点实际的。你有没有想过&#xff0c;一个滑雪场日常运营到底要管多少事&#xff1f;雪票销售、雪具租赁、教练预约、场地维护记录、会员储值、客户信息管理、员工排班、经营报表……如果全靠Excel表格加微信群&#…

作者头像 李华
网站建设 2026/10/21 10:23:24

Zemax透镜建模与tfcalc薄膜设计实操指南

简介&#xff1a;本资源是一份面向高校光学类专业本科生的《应用光学》课程上机实验配套报告&#xff0c;聚焦Zemax透镜设计与tfcalc光学薄膜分析两大核心实践环节&#xff0c;有效弥补理论教学中软件操作与工程建模能力的短板。报告内容完整覆盖F/4、100mm焦距BK7透镜的设计全…

作者头像 李华
网站建设 2026/10/18 17:54:07

基于XFS文件系统的OverlayFS容器存储方案详解与故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 19:01:30

FNF模组开发实战:从零搭建“vs 享利(但是Pico)”完整流程

做 FNF 模组开发时&#xff0c;最容易被卡住的往往不是美术&#xff0c;而是角色接入和谱面调通这两步。尤其当你做的还是“换主角”这种玩法变体&#xff0c;比如把原版按 Pico 的战斗视角重新设计一遍&#xff0c;就会发现角色 XML、Lua 逻辑、动画帧名、谱面 JSON 这四块必须…

作者头像 李华