做 Delphi 开发这些年,JSON 处理几乎躲不掉:对接外部接口、读写本地配置、给日志拼结构化数据,哪样都离不开它。System.JSON 这套官方单元不是不能用,但它给我的感觉一直是“功能都有,就是不够直观”。解析一个嵌套字段要连续 GetValue、强转、判空,构造 JSON 时要一个接一个 AddPair,代码稍微长一点,满屏都是括号和类型转换。后来我在一个高频对接接口的中间层项目里,干脆在 System.JSON 之上做了一套轻量封装库,把日常 90% 的 JSON 操作收敛成几个可读性很好的方法。这篇文章就把这套封装的设计思路、核心实现和踩坑记录整理出来,给同样被原生 JSON API 折腾过的 Delphi 开发者一个参考。
1. 为什么还要自己封装:System.JSON 的原生痛感
System.JSON 从 Delphi XE 时代开始提供,到今天已经覆盖了 JSON 的解析、构建、序列化这些基础能力。对很多项目来说,直接用官方单元完全跑得通。可为什么我还坚持在它外面再包一层?因为我站在“业务代码作者”的角度回看这些 API 时发现,它把大量本该由底层解析器处理的复杂性,直接暴露给了上层调用方。
1.1 原生 API 的上手体验
假设接口返回了这么一段 JSON:
json复制{"name": "张三", "age": 30, "active": true}
用原生代码取 name,常规写法长这样:
delphi复制var
J: TJSONObject;
V: TJSONValue;
begin
J := TJSONObject.ParseJSONValue(S) as TJSONObject;
if J = nil then
raise Exception.Create('invalid json');
try
V := J.GetValue('name');
if V is TJSONString then
Name := TJSONString(V).Value;
finally
J.Free;
end;
end;
每个键都要重复“拿值、判空、转型、取值”四步。一旦字段不存在,第一次 as 就会抛异常;如果不包一层 try/except,程序直接崩。就算你改用部分版本支持的 J.GetValue<string> 泛型重载,可读性也没有本质提升,而且不同版本的行为还不完全一致——有的无法转换就直接抛异常,而不是给默认值。
更麻烦的是多层嵌套。真实接口很少有平铺结构,动不动就是 data.content.list[0].title。用原生写法要一层一层往下 GetValue,每层都可能拿到 nil,每层都要判断。写个三层的取值,业务代码还没开始,防御代码已经写了快二十行。这不是开发者懒,是 API 本身确实不顺手。
1.2 高频痛点:缺省值、路径、构造、类型与生命周期
我把实际项目里反复碰到的高频痛点整理成了一张表,方便你对照自查:
| 痛点 | 原生表现 | 后果 |
|---|---|---|
| 缺省值 | 字段不存在时返回 nil | 业务代码堆满 if Assigned 或 try/except |
| 深层路径 | 多层对象要逐层 GetValue | 代码冗长,可读性急剧下降 |
| 构造 JSON | 需要逐个创建 TJSONString、TJSONNumber | 几十个字段时括号满天飞,极易漏写释放 |
| 类型转换 | 需要 as 成具体子类 | 类型不匹配时崩溃,尤其数字被写成字符串的场景 |
| 生命周期 | AddPair 会接管所有权,但新手容易重复 Free | 轻则内存泄漏,重则访问已释放对象 |
缺省值这个点最痛。业务字段经常是可选的,或者接口在异常时会返回 null。原生的 GetValue 对这类情况一概返回 nil,你不判空就直接 as,必然异常。为了一个可选字段写这么多防御代码,实在不值得。
构造 JSON 是另一个重灾区。原生 API 加一个字段要现 new 一个对象:
delphi复制Obj.AddPair('name', TJSONString.Create('Alice'));
五六个字段还能忍,二十个字段时,代码里全是对象创建和括号嵌套,读者根本分不清哪一层是哪一层。我见过同事为了拼一个稍复杂的请求体,代码写到后面自己都数不清 end 该写几个。
1.3 封装的目标与硬约束
在动手之前,我给自己定了几条硬约束,避免封装库本身变成一个新的负担:
- 不引入任何第三方 JSON 库,直接基于 System.JSON,团队接手成本低;
- API 总数控制在 20 个左右,绝不能做成“第二套 System.JSON”;
- 性能损耗要在可接受范围,不能比原生差太多;
- 保持 Delphi 习惯,不搞花哨的语法糖。
基于这些约束,核心思路就清晰了:把 System.JSON 当成底层引擎,把“读、写、遍历、序列化”四类高频操作变成统一入口,用“默认值 + 路径 + 链式调用”来降低业务代码噪音。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 封装库整体设计:从读取到写入的统一入口
这一章介绍最终敲定的类型划分和调用方式。你先看一个总览,再往下看每个类型的具体职责。
2.1 核心类型与职责划分
封装层一共就四个东西:
| 类型 | 职责 | 主要使用场景 |
|---|---|---|
| TDJson | 静态门面 | Parse 解析、New 创建、Serialize 序列化 |
| TDJsonObject | 对象节点 | 读取/构建 JSON 对象的字段 |
| TDJsonArray | 数组节点 | 遍历数组、按索引取元素 |
| TJsonValue | 值视图 | 统一处理 string、Int64、Double、Bool、Null |
使用者最常见到的其实只有前两个。比如解析一段 JSON:
delphi复制var
J: TDJsonObject;
begin
J := TDJson.Parse('{"user":{"name":"Alice","age":25}}');
try
ShowMessage(J.GetString('user.name', ''));
finally
J.Free;
end;
end;
TDJson.Parse 返回的是已经包装好的根对象,调用方只需要在 finally 里 Free 一次。整个解析树的内存由根对象统一管理,子节点、数组视图都不需要你操心。
2.2 链式读取:点分路径与默认值
点分路径是这套库最爽的一点:GetString('a.b.c') 自动走路径。内部实现是逐层取节点,遇到 nil 直接返回默认值。因为有默认值兜底,业务代码完全不需要写 if Assigned。
delphi复制Level := J.GetInteger('user.level', 1);
Name := J.GetString('user.name', '');
Ok := J.GetBool('user.ok', False);
这里有一个设计取舍要说明:我没有把路径机制做成支持类似 XPath 的复杂表达式,只支持简单的 a.b.c。原因有二:一是复杂表达式的解析成本不可控;二是功能膨胀后,封装库会越来越像一门新语言。点分路径已经覆盖了绝大多数场景,剩下的用 GetObject、GetArray 组合一下也能实现。
默认值机制的另一个好处是:可选字段的读取逻辑被大大简化。以前是“字段可能不存在,所以我要判断一下”,现在是“你直接读,读不到就用我给你兜底的那个值”。语义上更接近配置读取器,而不是解析树。
2.3 链式构建:从 AddPair 到 Add
构建 JSON 是原生 API 最啰嗦的部分。封装库的做法很简单,让每个 Add 都返回 Self,于是可以连续写下去:
delphi复制var
J: TDJsonObject;
begin
J := TDJson.New
.Add('name', 'Alice')
.Add('age', 25)
.Add('tags', TDJson.NewArray.Append('admin').Append('ops'))
.Add('active', True);
end;
没有临时对象,没有 TJSONString.Create,没有一层套一层的 AddPair。Add 会根据值的编译期类型,自动创建对应的 TJSONString、TJSONNumber、TJSONBool,或者把传入的 TDJsonObject、TDJsonArray 挂到当前节点下面。
这套写法的可读性提升是肉眼可见的。以前一个二十行都看不明白的请求体,现在斜着读一遍就能在脑子里还原出完整的 JSON 结构。对团队里刚接触 Delphi 的新人来说,“ Add”这个方法名比“AddPair + TJSONString.Create”直观太多。
3. 核心代码实现讲解:怎么做到“直观”
虽然使用者不需要关心底层实现,但理解实现能帮你更好地应对边界情况。这一章拆几个关键方法,讲清楚“直观”背后到底做了什么。
3.1 类型安全的数据读取:Resolve 与宽松转换
最核心的方法是路径解析。它把 a.b.c 逐层拆开,每层都在当前节点上调用 GetValue:
delphi复制function TDJsonObject.Resolve(const APath: string): TJSONValue;
var
Cur: TJSONValue;
Segment: string;
P: Integer;
begin
Result := nil;
Cur := FRoot;
P := 1;
while P <= Length(APath) do
begin
Segment := FetchSegment(APath, P);
if not (Cur is TJSONObject) then
Exit(nil);
Cur := TJSONObject(Cur).GetValue(Segment);
if Cur = nil then
Exit(nil);
end;
Result := Cur;
end;
这里有个细节:路径中某一段不存在,或者中间节点不是对象,Resolve 都返回 nil,绝不抛异常。因为 GetString 这类方法要靠 nil 判断来走默认值逻辑,一旦抛异常,默认值机制就形同虚设了。
有了 Resolve,GetString 的实现就很直接:
delphi复制function TDJsonObject.GetString(const APath: string; const ADefault: string): string;
var
V: TJSONValue;
begin
V := Resolve(APath);
if V = nil then
Exit(ADefault);
if V is TJSONString then
Result := TJSONString(V).Value
else if V is TJSONNumber then
Result := TJSONNumber(V).ToString
else if V is TJSONBool then
begin
if TJSONBool(V).AsBoolean then
Result := 'true'
else
Result := 'false';
end
else
Result := ADefault;
end;
我在做类型转换时故意用了“宽松策略”:
| 期望类型 | 实际类型为字符串 | 实际类型为数字 | 实际类型为布尔 |
|---|---|---|---|
| 字符串 | 直接返回 | 转成字符串 | 转成 'true'/'false' |
| 整数 | 尝试 StrToInt,失败给默认值 | 取 AsInt64 | 转成 0/1 |
| 布尔 | 把 'true'/'false'/0/1 转成布尔 | 非 0 即真 | 直接返回 |
一句话总结:字段类型和你期望的不一致时,只要还能转,就给值;实在转不了,就给默认值。这是为了对接真实接口时不被类型不匹配打断流程。但副作用也很明显——如果字段语义真的填错了,你不会立刻发现。所以我额外提供了 Exists 和 IsNull 两个方法,给需要严格校验的场景用。
3.2 链式构建:Add 重载与所有权转移
Add 的链式效果靠一个最简单的手段实现:每个方法都返回 Self。
delphi复制function TDJsonObject.Add(const AName: string; const AValue: string): TDJsonObject;
begin
FRoot.AddPair(AName, TJSONString.Create(AValue));
Result := Self;
end;
function TDJsonObject.Add(const AName: string; const AValue: Integer): TDJsonObject;
begin
FRoot.AddPair(AName, TJSONNumber.Create(AValue));
Result := Self;
end;
function TDJsonObject.Add(const AName: string; const AValue: Boolean): TDJsonObject;
begin
FRoot.AddPair(AName, TJSONBool.Create(AValue));
Result := Self;
end;
Delphi 编译器会根据字面量类型自动选择重载:整数字面量匹配 Integer,字符串匹配 string,True/False 匹配 Boolean。真实的 Add 重载远不止这三种,还有 Int64、Double、TDJsonObject、TDJsonArray、nil,但这些实现的套路完全一样。
关于生命周期,这里有一个使用上的红线和便利点需要说清楚:Add 内部创建的 TJSONString、TJSONNumber 等临时对象,被 AddPair 接管了所有权,使用者永远不要尝试去 Free 它们。当你把一个 TDJsonObject 通过 Add 挂到另一个对象下面时,内部会做“托管转移”,原临时对象的所有权交给目标根节点。所以使用习惯可以收敛成一条规则:只有你亲手用 Parse 或 New 创建出来的那个根对象需要 Free,其他中间对象全都不要动。
3.3 序列化与反序列化的快捷方案
对于简单的 DTO 类,我提供了 TDJson.Serialize 和 TDJson.Deserialize 两个门面方法,底层调用的就是 System.JSON 自带的序列化能力:
delphi复制type
TUserDTO = class
public
Name: string;
Age: Integer;
end;
var
S: string;
U: TUserDTO;
begin
U := TUserDTO.Create;
U.Name := 'Alice';
U.Age := 25;
S := TDJson.Serialize(U); // 得到 {"name":"Alice","age":25}
U.Free;
end;
但我要泼一盆冷水:这一层不负责复杂场景。如果 DTO 里有泛型集合、多重继承、属性名要映射成下划线风格,或者字段值需要自定义转换,我强烈建议还是手写解析。原因有三个:第一,不同 Delphi 版本的序列化映射规则确实有差异,踩过才知道;第二,自动序列化的报错信息往往不直接指向业务字段;第三,手写解析虽然代码多一点,但每一步都在你掌控之内,出问题一眼能看到。
4. 两个实战场景:接口对接与本地配置
理论讲完,上实战。这两个场景是我日常最常用的,一个是外部接口数据的读取,一个是本地配置文件的读写。
4.1 对接报表服务接口时的取数思路
假设某个报表服务返回如下的 JSON:
json复制{
"status": 0,
"message": "ok",
"data": {
"total": 100,
"list": [
{ "orderId": "20250600001", "amount": 123.45, "paid": true },
{ "orderId": "20250600002", "amount": 67.80, "paid": false }
]
}
}
用封装库解析,核心代码可以缩得很短:
delphi复制var
J: TDJsonObject;
List: TDJsonArray;
Item: TDJsonObject;
i: Integer;
begin
J := TDJson.Parse(ResponseText);
try
if J.GetInteger('status', -1) <> 0 then
raise Exception.CreateFmt('业务错误: %s', [J.GetString('message', '')]);
List := J.GetArray('data.list');
for i := 0 to List.Count - 1 do
begin
Item := List.GetObject(i);
OrderId := Item.GetString('orderId', '');
Amount := Item.GetDouble('amount', 0.0);
Paid := Item.GetBool('paid', False);
end;
finally
J.Free;
end;
end;
这里有两个经验值得单独说。
第一,订单号这种长数字一定要当字符串处理。JSON 数字底层通常用浮点数或 Int64 表达,超过安全范围的 ID 很容易丢精度。所以我不仅解析时用 GetString,连接口文档都会要求对方把这类字段定义为字符串。宁可后面要转字符串再转,也不要一开始就被精度坑了。
第二,封装库只负责“把 JSON 变成易读的值”,不负责“判断业务成功还是失败”。status 是不是 0,应该由业务代码判断。有人可能会想,为什么封装库不直接抛一个业务异常?因为不同接口的业务字段名、语义完全不同,封装层一旦掺入业务语义,就失去复用价值了。
4.2 本地配置文件的读取与保存
本地配置也是 JSON 的常见去处。一个典型的 config.json 长这样:
json复制{
"window": { "left": 100, "top": 80, "width": 1024, "height": 768 },
"theme": "dark",
"autoSave": true
}
读取配置时,默认值就是整个封装库的亮点:
delphi复制if TFile.Exists(AppDir + 'config.json') then
begin
J := TDJson.Parse(TFile.ReadAllText(AppDir + 'config.json'));
try
Left := J.GetInteger('window.left', 100);
Top := J.GetInteger('window.top', 80);
Theme := J.GetString('theme', 'light');
AutoSave := J.GetBool('autoSave', False);
finally
J.Free;
end;
end
else
begin
// 直接使用默认值
end;
保存配置时,链式构建的便利就体现出来了:
delphi复制J := TDJson.New
.Add('window', TDJson.New
.Add('left', Left)
.Add('top', Top)
.Add('width', Width)
.Add('height', Height))
.Add('theme', Theme)
.Add('autoSave', AutoSave);
try
TFile.WriteAllText(AppDir + 'config.json', J.ToJSON);
finally
J.Free;
end;
这里提醒一个容易被忽略的问题:配置文件读取时的默认值,必须和程序初次启动时使用的初始值保持一致。比如 UI 初始化时主题用的是 'light',但你读取配置时默认值写成了 'dark',那么用户改过一次主题后,下次启动就会跳变。建议把默认值收敛到一个函数里统一管理,而不是散落在各个读取点。
5. 性能、内存与异常处理的实测细节
封装毕竟是多了一层调用,性能、内存、异常策略这些事,我在迭代过程中都专门做过验证。
5.1 薄封装到底差多少性能
我用一个约 2MB 的 JSON 文件做过测试,里面大概有 2000 个对象和 1.5 万个字段。分别用原生 System.JSON 和封装库做相同的“完整遍历 + 取字段值”操作,重复 5 次取平均值:
| 操作 | 原生 System.JSON | 封装库 | 差距 |
|---|---|---|---|
| 遍历 1.5 万字段取值 | 约 38ms | 约 41ms | 约 8% |
| 构造 1 万条、每条 8 字段 | 约 46ms | 约 50ms | 约 9% |
封装的额外开销主要来自路径拆分的字符串处理和每次 Get 的多一层空值判断,这些都在可接受范围内。对绝大多数业务系统来说,差个几毫秒根本不是问题。
但有一条使用习惯必须强调:不要图方便在循环里反复调用 TDJson.Parse。Parse 才是真正的重操作,后面读取只是访问内存树。正确的做法是一次解析拿到根对象,然后反复从根对象读取。很多性能问题不是封装库造成的,而是使用方式不对。
5.2 对象的生命周期与释放规则
生命周期是最容易让人困惑的部分,所以我把规则定得非常死:
- TDJson.Parse 创建的根对象,由调用方 Free;
- TDJson.New 创建的根对象,由调用方 Free;
- Add、Append 内部创建的底层 TJSONValue,由所在根树统一释放;
- GetArray、GetObject 返回的数组视图、对象视图,全部由根对象管理,调用方不要 Free。
只要守住“只有 Parse 或 New 出来的根对象需要 Free”这一条,基本不会踩内存坑。我在实现时特意不让数组视图和对象视图拥有底层数据的所有权,它们只是携带指针的轻量包装。这样调用方不需要为它们维护生命周期,也不存在重复释放的风险。
如果你在退出时看到 Delphi 默认内存管理器报告内存块泄漏,优先怀疑的应该是“Parse 得到的对象没有 Free”或“手动取出了底层 TJSONObject 后重复释放/没释放”。封装层本身一般不会泄漏,除非你用特殊方法把底层对象拿了出来。
5.3 异常策略:哪些该抛,哪些该容
异常策略我定了三个原则,用下来很顺手:
- JSON 本身是非法字符串,必须抛异常,因为数据源坏了,不能静默吞掉;
- 合法 JSON 里的字段缺失或类型不匹配,走默认值,不抛异常;
- 业务语义错误,比如 status 不等于 0,由业务代码自己抛异常。
如果调用方不想让解析异常打断程序,封装库提供 TryParse:
delphi复制if not TDJson.TryParse(ResponseText, J) then
begin
Log('解析失败,接口返回: ' + ResponseText);
Exit;
end;
注意 TryParse 成功时,J 同样需要在最后 Free,这个不要忘。
在需要严格校验的场景,不要一味依赖默认值。比如接口必须返回 token 字段,如果你只写 if J.GetString('token', '') = '' then,可能会把“字段存在但值为空”和“字段缺失”混为一谈。更严谨的是先用 Exists 判断字段是否存在,再看值是否为空。默认值机制是给可选字段用的,不是给必填字段用的。
6. 从原生迁移到封装库:迁移路线与避坑清单
最后聊聊迁移。如果你也想在项目里引入类似的封装,我建议按特定顺序来,不要一刀切。
6.1 迁移路线图
第一步,新代码直接用封装库,老代码暂时不动。第二步,把解析入口统一替换,凡是原来直接调 TJSONObject.ParseJSONValue 的地方,都改成 TDJson.Parse。第三步,替换读取逻辑,把 (J.GetValue('name') as TJSONString).Value 改成 J.GetString('name', '')。第四步,替换构建逻辑,把 AddPair 链改成 Add 链。最后再处理序列化相关的代码。
每完成一步,跑一遍回归测试。测试数据建议准备一个覆盖了各种类型、嵌套、空值、数组的 JSON fixture,解析后断言每个字段的值,再重新序列化并和原串对比。这样基本能覆盖大多数问题。不要在一个大改动里同时替换几十个单元,JSON 的问题一旦出现,会被其他改动淹没,排查起来非常痛苦。
6.2 容易踩的坑一览
| 坑 | 现象 | 处理方式 |
|---|---|---|
| 键名大小写敏感 | JSON 键严格区分大小写,接口文档却含糊 | 统一约定小写 key,必要时提供 GetStringCI 不区分大小写方法 |
| 中文编码 | 文件带 BOM 或接口返回 GBK 字节 | 先转码成 Unicode 字符串再解析 |
| 大数精度 | 订单号、流水号超过 Int64 范围 | 字段用字符串承载,不要用 JSON 数字传递 |
| 布尔兼容 | 很多系统返回 0/1 而不是 true/false | GetBool 兼容 true/false/0/1,但不建议无限扩展 |
| 版本差异 | 不同 Delphi 版本的 System.JSON 行为不一致 | 封装层统一兜底,业务代码不感知 |
这里单独说两个最常踩的坑。
中文编码是我遇到最多的。Delphi 的 string 本质是 UTF-16,System.JSON 内部对 Unicode 的处理是正常的。但如果你从文件读取配置时直接 TFile.ReadAllText,而文件是带 BOM 的 UTF-8,一般没问题;如果接口返回的字节流是 GBK,就必须先做转码,否则 JSON 里所有中文都会变成乱码。我的习惯是:所有 JSON 文本,进封装库之前保证是已经正确解码的 Unicode 字符串。
大数精度这个坑,很多没被订单号坑过的人不会在意。JSON 数字如果落到 Double,尾数会丢;就算用 Int64,超过范围的照样炸。所以我在接口设计评审时就会提一条:所有 ID 类字段一律用字符串。
6.3 什么时候不要用封装库
封装库不是银弹。我列几个明确“不该用”的场景:
- 一次性脚本或简单小工具,直接用原生或字符串处理就够了;
- 高频解析大量 JSON,比如每秒解析几百次几十 MB 文件,封装层的额外开销会被放大;
- 动态 schema 或强校验需求,需要的是 JSON Schema 校验器,不是一个返回默认值的小库;
- 团队已经大量使用 DTO 映射且没有痛点,就没有必要增加一层抽象。
坦白说,封装库最容易犯的毛病不是不够强大,而是功能膨胀。每加一个方法,都应该问一句:这是不是只是某个项目的一次性需求?我自己的做法是每半年清理一次,没人用的方法直接删。保持“小、直观”比“大而全”更重要。
最后再多说一句我自己的体会。这套封装我前后维护了好几年,最大的收获不是代码写得变漂亮了,而是团队里的初级成员终于可以放心地写 JSON 解析,不用担心内存和强转。我建议你不要照抄我的设计,而是根据自己的项目痛点去裁剪:把高频的那十来个方法做出来,把低频的全砍掉。工具这种东西,永远是贴着自己的使用习惯才最好用。
