第三十课:综合实战——用TDD开发一个JSON解析器
终于到了最后一课。说实话,我挺感慨的。
前面二十九课,我们讲了TDD的各种招式、单元测试的各种技巧。但说到底,这些知识能不能落地,还得看你能不能真正用它做出一个完整的东西来。
所以这一课,我们来干一票大的——用TDD开发一个JSON解析器。解析、序列化、错误处理,一个都不能少。
为什么选JSON解析器?
你想想看,JSON解析器几乎包含了所有编程中会遇到的核心问题:
- 输入解析:字符串怎么拆成有意义的结构?
- 数据结构设计:怎么表示嵌套的JSON对象?
- 错误处理:遇到不合法的JSON怎么办?
- 序列化:怎么把内存里的结构再变回字符串?
我在项目中遇到过至少三次需要自己写JSON解析的场景——一次是嵌入式设备上内存不够用标准库,一次是要解析超大JSON需要流式处理,还有一次是要支持某种非标准的日期格式。说白了,理解JSON解析器的内部原理,你以后遇到任何文本解析问题都不会慌。
整体架构设计
我们先不急着写代码。按照TDD的节奏,先想清楚我们要做什么。
核心功能清单:
- 解析JSON字符串为内部数据结构
- 支持null、bool、数字、字符串、数组、对象
- 序列化内部数据结构回JSON字符串
- 优雅的错误处理(位置、原因、上下文)
下面这张图展示了JSON解析器的整体工作流程:
第一步:定义数据结构
按照TDD的节奏,我们先写测试。但写测试之前,得先知道我们要操作什么数据结构。
// JsonValue.h
enum class JsonType {
Null,
Bool,
Number,
String,
Array,
Object
};
class JsonValue {
public:
JsonType type() const;
// 类型安全的取值
bool asBool() const;
double asNumber() const;
const std::string& asString() const;
const std::vector<JsonValue>& asArray() const;
const std::map<std::string, JsonValue>& asObject() const;
// 构造
static JsonValue null();
static JsonValue boolean(bool val);
static JsonValue number(double val);
static JsonValue string(const std::string& val);
static JsonValue array(const std::vector<JsonValue>& val);
static JsonValue object(const std::map<std::string, JsonValue>& val);
private:
// ... 实现细节
};
我的习惯:用静态工厂方法而不是构造函数。这样代码意图更清晰,调用的时候一眼就能看出你在创建什么类型的值。
第二步:写第一个测试——解析null
好,现在开始真正的TDD循环。我们从最简单的开始:解析一个null。
TEST(JsonParserTest, ParseNull) {
JsonValue val = JsonParser::parse("null");
EXPECT_EQ(val.type(), JsonType::Null);
}
TEST(JsonParserTest, ParseTrue) {
JsonValue val = JsonParser::parse("true");
EXPECT_EQ(val.type(), JsonType::Bool);
EXPECT_TRUE(val.asBool());
}
TEST(JsonParserTest, ParseFalse) {
JsonValue val = JsonParser::parse("false");
EXPECT_EQ(val.type(), JsonType::Bool);
EXPECT_FALSE(val.asBool());
}
写这三个测试的时候,我其实已经想好了实现方案。但按照TDD的纪律,先让测试失败,再写实现。
// 第一次实现:只处理null和bool
JsonValue JsonParser::parse(const std::string& input) {
if (input == "null") return JsonValue::null();
if (input == "true") return JsonValue::boolean(true);
if (input == "false") return JsonValue::boolean(false);
throw JsonParseError("unexpected token");
}
测试通过了。嗯,虽然很简陋,但这是TDD的第一步——让最简单的场景先跑通。
第三步:解析数字
数字解析比想象中复杂。整数、小数、科学计数法,都得考虑。
TEST(JsonParserTest, ParseInteger) {
JsonValue val = JsonParser::parse("42");
EXPECT_EQ(val.type(), JsonType::Number);
EXPECT_DOUBLE_EQ(val.asNumber(), 42.0);
}
TEST(JsonParserTest, ParseNegativeNumber) {
JsonValue val = JsonParser::parse("-3.14");
EXPECT_DOUBLE_EQ(val.asNumber(), -3.14);
}
TEST(JsonParserTest, ParseScientificNotation) {
JsonValue val = JsonParser::parse("1.5e10");
EXPECT_DOUBLE_EQ(val.asNumber(), 1.5e10);
}
注意:浮点数比较要用EXPECT_DOUBLE_EQ,不要用EXPECT_EQ。我曾经在一个项目里用EXPECT_EQ比较浮点数,结果测试时好时坏,排查了半天才发现是精度问题。
第四步:解析字符串
字符串解析是JSON解析器里最容易出bug的地方。转义字符、Unicode、控制字符,都得处理。
TEST(JsonParserTest, ParseSimpleString) {
JsonValue val = JsonParser::parse("\"hello\"");
EXPECT_EQ(val.asString(), "hello");
}
TEST(JsonParserTest, ParseStringWithEscape) {
JsonValue val = JsonParser::parse("\"hello\\nworld\"");
EXPECT_EQ(val.asString(), "hello\nworld");
}
TEST(JsonParserTest, ParseStringWithUnicode) {
JsonValue val = JsonParser::parse("\"\\u0041\"");
EXPECT_EQ(val.asString(), "A");
}
实现字符串解析的时候,我建议你用一个状态机。逐个字符扫描,遇到反斜杠就进入转义模式。这样代码清晰,也容易测试。
第五步:解析数组和对象
这才是JSON解析器的核心。嵌套结构,递归解析。
TEST(JsonParserTest, ParseEmptyArray) {
JsonValue val = JsonParser::parse("[]");
EXPECT_TRUE(val.asArray().empty());
}
TEST(JsonParserTest, ParseArrayWithElements) {
JsonValue val = JsonParser::parse("[1, \"two\", true]");
auto& arr = val.asArray();
EXPECT_EQ(arr.size(), 3);
EXPECT_DOUBLE_EQ(arr[0].asNumber(), 1.0);
EXPECT_EQ(arr[1].asString(), "two");
EXPECT_TRUE(arr[2].asBool());
}
TEST(JsonParserTest, ParseNestedObject) {
JsonValue val = JsonParser::parse(
"{\"name\": \"Alice\", \"age\": 30, \"scores\": [95, 87]}"
);
auto& obj = val.asObject();
EXPECT_EQ(obj["name"].asString(), "Alice");
EXPECT_DOUBLE_EQ(obj["age"].asNumber(), 30.0);
EXPECT_EQ(obj["scores"].asArray().size(), 2);
}
关键设计决策:解析数组和对象时,我选择用递归下降解析。每个解析函数只负责自己那一层,遇到嵌套就递归调用。这样做的好处是代码结构清晰,每个函数只做一件事。
第六步:错误处理
没有错误处理的解析器,说白了就是半成品。用户给你一个坏掉的JSON,你得告诉人家哪里坏了、为什么坏。
TEST(JsonParserTest, ErrorUnexpectedToken) {
EXPECT_THROW({
JsonParser::parse("{invalid}");
}, JsonParseError);
}
TEST(JsonParserTest, ErrorUnterminatedString) {
try {
JsonParser::parse("\"unterminated");
FAIL() << "Expected JsonParseError";
} catch (const JsonParseError& e) {
EXPECT_NE(e.position(), std::string::npos);
EXPECT_FALSE(e.message().empty());
}
}
TEST(JsonParserTest, ErrorTrailingComma) {
EXPECT_THROW({
JsonParser::parse("[1, 2,]");
}, JsonParseError);
}
错误信息里要包含三个要素:位置(第几个字符)、原因(为什么错)、上下文(附近的内容)。这样用户才能快速定位问题。
class JsonParseError : public std::runtime_error {
public:
JsonParseError(const std::string& msg, size_t pos = 0,
const std::string& context = "")
: std::runtime_error(buildMessage(msg, pos, context))
, position_(pos) {}
size_t position() const { return position_; }
private:
size_t position_;
static std::string buildMessage(
const std::string& msg, size_t pos,
const std::string& context) {
std::string result = "JSON parse error at position "
+ std::to_string(pos) + ": " + msg;
if (!context.empty()) {
result += "\n near: ..." + context + "...";
}
return result;
}
};
第七步:序列化
解析完了,还得能变回去。序列化就是解析的逆过程。
TEST(JsonSerializerTest, SerializeNull) {
JsonValue val = JsonValue::null();
EXPECT_EQ(JsonSerializer::serialize(val), "null");
}
TEST(JsonSerializerTest, SerializeObject) {
auto obj = JsonValue::object({
{"name", JsonValue::string("Bob")},
{"age", JsonValue::number(25.0)}
});
std::string result = JsonSerializer::serialize(obj);
// 注意:对象键的顺序可能不确定
EXPECT_TRUE(result.find("\"name\"") != std::string::npos);
EXPECT_TRUE(result.find("\"Bob\"") != std::string::npos);
}
TEST(JsonSerializerTest, SerializePrettyPrint) {
auto arr = JsonValue::array({
JsonValue::number(1),
JsonValue::number(2)
});
std::string pretty = JsonSerializer::serializePretty(arr);
// 美化输出应该包含换行和缩进
EXPECT_TRUE(pretty.find('\n') != std::string::npos);
}
我的建议:序列化器提供两种模式——紧凑模式和美化模式。紧凑模式用于网络传输,美化模式用于日志和调试。我在一个项目里就是因为日志里的JSON没有格式化,排查问题时看得眼睛都快瞎了。
完整测试套件概览
到这里,我们的测试套件已经覆盖了JSON解析器的所有核心功能。我整理了一下,大概有这些测试类别:
| 测试类别 | 测试用例数 | 覆盖内容 |
|---|---|---|
| 基础类型解析 | 6 | null, true, false, 整数, 小数, 科学计数法 |
| 字符串解析 | 5 | 普通字符串, 转义字符, Unicode, 空字符串 |
| 数组解析 | 4 | 空数组, 单元素, 多元素, 嵌套数组 |
| 对象解析 | 4 | 空对象, 单键值, 多键值, 嵌套对象 |
| 错误处理 | 7 | 非法token, 未闭合字符串, 尾随逗号, 非法数字等 |
| 序列化 | 5 | 紧凑输出, 美化输出, 特殊字符转义 |
| 边界情况 | 4 | 超大数字, 超长字符串, 深层嵌套, 空输入 |
一些经验之谈
做完这个项目,我有几点想跟你分享:
- 先写测试再写代码,真的能帮你理清思路。写测试的过程,其实就是设计API的过程。你会发现,如果某个API很难测试,那它很可能设计得不好。
- 错误处理不要最后才做。我见过太多项目,功能都写完了,错误处理随便糊一下。结果上线第一天就被各种非法输入搞崩溃了。
- 性能优化要基于测试。不要一开始就想着怎么优化。先让功能正确,然后用性能测试找到瓶颈,再针对性地优化。我在一个项目里就是过早优化,结果代码变得一团糟,最后不得不重写。
- 测试也是文档。新同事接手你的代码,看测试用例比看文档更快。好的测试用例,本身就是一份活文档。
最后说一句:三十课,从基础到实战,我们一路走过来。TDD不是银弹,但它是一种能让你写出更可靠代码的习惯。就像学开车一样,刚开始觉得麻烦,但习惯了之后,你会发现自己再也回不去了。
公众号:蓝海资料掘金营,微信deep3321