第三十课:综合实战——用TDD开发一个JSON解析器

终于到了最后一课。说实话,我挺感慨的。

前面二十九课,我们讲了TDD的各种招式、单元测试的各种技巧。但说到底,这些知识能不能落地,还得看你能不能真正用它做出一个完整的东西来。

所以这一课,我们来干一票大的——用TDD开发一个JSON解析器。解析、序列化、错误处理,一个都不能少。

为什么选JSON解析器?

你想想看,JSON解析器几乎包含了所有编程中会遇到的核心问题:

  • 输入解析:字符串怎么拆成有意义的结构?
  • 数据结构设计:怎么表示嵌套的JSON对象?
  • 错误处理:遇到不合法的JSON怎么办?
  • 序列化:怎么把内存里的结构再变回字符串?

我在项目中遇到过至少三次需要自己写JSON解析的场景——一次是嵌入式设备上内存不够用标准库,一次是要解析超大JSON需要流式处理,还有一次是要支持某种非标准的日期格式。说白了,理解JSON解析器的内部原理,你以后遇到任何文本解析问题都不会慌。

整体架构设计

我们先不急着写代码。按照TDD的节奏,先想清楚我们要做什么。

核心功能清单:

  • 解析JSON字符串为内部数据结构
  • 支持null、bool、数字、字符串、数组、对象
  • 序列化内部数据结构回JSON字符串
  • 优雅的错误处理(位置、原因、上下文)

下面这张图展示了JSON解析器的整体工作流程:

JSON 输入字符串 词法分析器 (Lexer) Token 流 语法分析器 (Parser) JsonValue 树 序列化器 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