1. 为什么要在 System.JSON 外面再做一层封装1.1 原生 API 用起来最别扭的三个地方先说说我的真实感受。我在一个跨平台工具项目里频繁处理接口数据,第一版直接拿System.JSON裸写,写着写着就发现效率极低。原生库的功能其实都在:TJSONObject、TJSONArray、TJSONValue这些类要什么有什么,但问题是这些类设计得太“底层”了,每次拼 JSON 或者读 JSON,都要在类型转换和异常处理上浪费大量代码。举一个最常见的例子。你想起一个对象,原生写法大概是这样的:var LObj: TJSONObject; begin LObj := TJSONObject.Create; LObj.AddPair('name', TJSONString.Create('zhangsan')); LObj.AddPair('age', TJSONNumber.Create(28)); LObj.AddPair('active', TJSONBool.Create(True)); end;读的时候更崩溃,你要先GetValue,再判断是不是TJSONString,还要自己处理 nil。数据简单还好,一旦嵌套两层以上,代码的括号和类型判断能把人绕晕。第二个问题是TJSONValue和它的子类之间缺少足够自然的“默认值”机制。接口数据里字段经常缺失,比如用户信息有时候少一个nickname,原生写法里GetValue('nickname')拿回来的可能是个 nil,还得自己写一大堆Assigned()判断,稍不注意就崩在类型转换上。第三个问题,也是我后来决定必须要封装的原因:原生TJSONObject的链式表达力太弱。你没办法写出一行能读懂的“声明式”代码,所有动作都得拆成过程式语句。代码一旦长了,光看结构都得花半天时间,更别说维护。1.2 我的封装目标:把“动词”变成“声明”我给自己定了个原则:封装不是要把System.JSON替换掉,而是把它包装成一个更好用的形态。理想状态下,读一个 JSON 字段应该像读一个TStringList.Values那样简单,写一个 JSON 字段也应该像设置属性那样自然。我当时脑子里浮现的是一张“目标对照表”,后来也成了我做设计评审时用的标准:问题场景原生做法封装后希望的样子添加一个字符串字段AddPair('name', TJSONString.Create('x'))JO.Str('name', 'x')或相似的可链式方法读取一个整数字段并给默认值TryGetValue<integer>('age', LAge)JO.Int('age', 25),缺失时自动返回默认值快速判断字段是否存在Assigned(LObj.GetValue('name'))JO.Contains('name')解析响应里常见的嵌套结构逐级 GetValue 判断类型一次传入路径如'data.user.id'最终我实现的这套库,核心 API 只有几个模块:TJsonDoc(文档对象)、TJsonNode(节点对象)、TJsonTool(序列化工具)。它们全部只依赖系统自带的System.JSON,编译期不需要引入额外第三方依赖。下面的内容我会把关键设计思路和实测代码一步步说清楚。2. 核心 API 设计:更直观的读写方式2.1 写 JSON:从“过程式拼装”变成“链式声明”我先展示一个成品 API 的使用效果。假设要拼这样的结构:{ "name": "zhangsan", "age": 28, "active": true, "tags": ["dev", "delphi"], "address": { "city": "Shanghai", "zip": "200000" } }用我封装之后的代码写,是这样:var LJson: TJsonDoc; begin LJson := TJsonDoc.Create; LJson .Node .AddStr('name', 'zhangsan') .AddInt('age', 28) .AddBool('active', True) .AddArray('tags') .Append('dev') .Append('delphi') .EndArray .AddObject('address') .AddStr('city', 'Shanghai') .AddStr('zip', '200000') .EndObject; end;每个方法返回的都是同一个节点对象的引用,所以可以一路点下去。核心实现并没有多复杂,底层就是先创建对应子类,然后统一挂到父节点上。关键是内部要维护一个“当前节点”的栈,才能让EndArray和EndObject正确回到父级。下面是AddStr和AddObject的示意实现,我从实际项目里抽了一段核心逻辑出来,去掉了调试噪音:function TJsonNode.AddStr(const AName, AValue: string): TJsonNode; begin FObject.AddPair(AName, TJSONString.Create(AValue)); Result := Self; end; function TJsonNode.AddObject(const AName: string): TJsonNode; var LChild: TJSONObject; LChildNode: TJsonNode; begin LChild := TJSONObject.Create; FObject.AddPair(AName, LChild); LChildNode := TJsonNode.Create(LChild); FNodeStack.Push(CurrentNode); // 记录父节点 FCurrentNode := LChildNode; Result := LChildNode; end;EndObject做的就是弹栈操作。这里有一个容易被忽略的细节:如果你在链式调用中间抛了异常,栈状态会乱,因此我会在TJsonDoc的析构里统一释放所有已创建的TJSONObject、TJSONArray临时对象。这个点我在后面“踩坑”部分再展开讲。2.2 读 JSON:属性访问加上默认值兜底读数据是我封装库最重要的改进点。原生代码读嵌套字段往往要写很多层判断,我的封装直接用属性访问器加路径字符串解决。比如解析上面那个结构,拿address.city的代码是:var LJson: TJsonDoc; LCity: string; begin LJson := TJsonDoc.Parse('{"name":"zhangsan","age":28,"active":true,"tags":["dev","delphi"],"address":{"city":"Shanghai","zip":"200000"}}'); try LCity := LJson.Node.Find('address.city', 'unknown'); finally LJson.Free; end; end;Find方法内部把路径按.分割后循环走子节点。这里有个性能考量:路径分割我用了TArray<string>和TStringSplitOptions,没有引入正则,因为 JSON 路径匹配的语法并不复杂,没必要为这点功能引入额外复杂度。对于最常见的标量字段,我还实现了带类型的读取器:function TJsonNode.GetStr(const AName: string; const ADefault: string = ''): string; var LValue: TJSONValue; begin LValue := FObject.GetValue(AName); if Assigned(LValue) and (LValue is TJSONString) then Result := LValue.Value else Result := ADefault; end;GetStr、GetInt、GetBool、GetFloat四个方法是我实测使用频率最高的,加起来占了我所有解析代码的八成。后期我又补了GetStrDef、GetIntDef等别名方法,其实内部完全一样,只是命名风格上更接近大部分 Delphi 开发者的习惯。2.3 数组节点:避免 TJSONArray 的“双重类型”尴尬TJSONArray原生用起来有一个很尴尬的点:它继承自TJSONValue,但实际是集合容器,读单个元素时经常要Items[i],然后用.Value拿字符串,用TJSONNumber再转换一次数值。我在封装里直接把数组元素封装成TJsonArrayReader,并且给每个元素一个统一的隐式转换路径。读取数组的典型用法:var LTags: TJsonArray; I: Integer; LItem: TJsonNode; begin LTags := LJson.Node.ArrayByName('tags'); for I := 0 to LTags.Count - 1 do begin LItem := LTags.Item[I]; // 这里可以直接用 LItem.AsString end; end;数组节点设计的关键在于:Item[I]返回的TJsonNode必须是一个轻量包装,不能为每次访问都创建新对象,否则内存消耗会很大。我用了一个缓存节点对象,每次只更新内部的TJSONValue引用,这样既避免了频繁TJsonNode.Create又保证了调用方拿到的接口一致。3. 关键实现细节:节点生命周期与内存管理3.1 谁负责释放 TJSONValue?System.JSON的内存模型有一点很像手工管理时代的 C 语言:谁创建谁释放。TJSONObject里挂了子节点后,父对象析构时会把所有子值释放掉。但如果你拿到一个TJSONValue后把它从父对象里Remove出来,又不自己释放,就会泄漏。我在封装设计里定了一个铁律:所有进入文档对象的节点,生命周期都归文档对象管。也就是说,你通过TJsonDoc创建的节点,永远不要手动去Free;你从外部 Parse 得到的TJSONObject,也只需要释放TJsonDoc本身。这个约定看起来很普通,但在实际协作中避免了很多次崩溃。这里有一个典型错误,我在团队里见过不止一次:// 错误示范 var LObj: TJSONObject; LName: TJSONString; begin LObj := TJSONObject.Create; LName := TJSONString.Create('x'); LObj.AddPair('name', LName); LName.Free; // 崩溃!父对象析构时还会再释放一次 end;封装库里的AddStr内部就是直接AddPair(AName, TJSONString.Create(AValue)),你根本拿不到子对象,所以天然避开了这类问题。3.2 链式调用的“栈”是怎么维护的我在 2.1 提到内部维护了节点栈,现在把实现的关键部分说完整。一个简单的TStack<TJsonNode>存父节点引用,每次进入AddObject或AddArray时把当前节点压栈,遇到EndObject/EndArray时弹栈。这里有一个值得注意的边界情况:用户在链式调用中途没有写EndArray就直接结束,比如:LJson .Node .AddObject('a') .AddStr('b', 'c'); // 没有调用 EndObject这个时候当前节点还停留在a对象上。如果接下来直接LJson.Node.AddStr('name', 'x'),那name字段会被加到a里面去,而不是加到根节点。为了避免这种隐蔽问题,我在TJsonDoc里提供了一个Reset方法,并且在TJsonDoc.Free前强制把所有栈中节点释放干净。严格来说这不影响正确性,因为节点引用都挂在对象树里,只是当前指针位置不一样。但对使用体验影响很大,接口使用者经常会对“为什么字段没加到根节点”感到困惑。后来我改成了更严格的策略:EndObject和EndArray不是可选的,而是在节点析构前必须成对出现。实在漏写了,就通过调试断言在 debug 模式下给出明确提示。不要小看这个设计,它把“链式 API”最容易踩的坑提前拦掉了一大半。3.3 Parse 入口:异常处理与输入校验TJsonDoc.Parse是另一个用户接触最多的入口。我最初直接调用TJSONObject.ParseJSONValue,但很快发现一个问题:这个方法在输入是合法的 JSON 数组时,返回的是TJSONArray而不是TJSONObject。如果不做区分,后续所有节点操作都会报错。所以我的Parse做了严格封装,核心逻辑如下:class function TJsonDoc.Parse(const AJson: string): TJsonDoc; var LValue: TJSONValue; begin Result := TJsonDoc.Create; try LValue := TJSONObject.ParseJSONValue(AJson); if LValue is TJSONObject then Result.SetRootObject(TJSONObject(LValue)) else if LValue is TJSONArray then Result.SetRootArray(TJSONArray(LValue)) else raise EJsonParseException.Create('输入内容不是合法的 JSON 对象或数组'); except Result.Free; raise; end; end;这里我刻意把“顶层是数组”的情况也支持了,因为接口返回的 JSON 不一定都是对象。遇到空字符串或者非法输入时,TJSONObject.ParseJSONValue会返回 nil,我把它升级成带提示的EJsonParseException,避免用户在 nil 上继续访问导致无意义崩溃。这个封装让调用方代码可以放心直接使用返回值,不需要每次Assigned()判断。4. 基于 RTTI 的对象映射:序列化与反序列化4.1 为什么不推荐“万能反射”封装库做到这个阶段,我已经能很舒服地读写任意 JSON 了。但项目越做越复杂时,新需求很快来了:很多业务对象的结构和 JSON 字段几乎一一对应,每个地方都手写字段映射太累。于是我开始设计序列化模块。Delphi 的 RTTI 功能已经足够支持自动映射,但我不建议上来就做一个“万能反射库”。原因是 RTTI 映射虽然省事,但在字段命名差异、嵌套级别和集合类型上的坑非常多,而且性能损耗不小。我的方案是做一个“半自动”映射器:基础标量类型自动处理,命名规则统一用 CamelCase 到 PascalCase 的转换,特殊情况用属性注解。例如我要把这样一个类序列化成 JSON:type TUserInfo = class private FName: string; FAge: Integer; FIsActive: Boolean; FTags: TArray<string>; public property Name: string read FName write FName; property Age: Integer read FAge write FAge; property IsActive: Boolean read FIsActive write FIsActive; property Tags: TArray<string> read FTags write FTags; end;序列化调用只需要一行:var LJson: string; LUser: TUserInfo; begin LUser := TUserInfo.Create; LUser.Name := 'zhangsan'; LUser.Age := 28; LUser.IsActive := True; LUser.Tags := TArray<string>.Create('dev', 'delphi'); LJson := TJsonTool.Serialize(LUser); end;底层的序列化遍历利用了System.Rtti,对每个发布属性读取PropertyInfo.GetValue,然后根据TypeInfo判断是 Integer、string、Boolean 还是动态数组。因为使用了缓存机制,同一个类第二次序列化的性能会比第一次快不少。4.2 反序列化的“容错赋值”策略反序列化相对复杂一些。接口返回的数据经常和本地定义的类型不完全一致,举个例子:服务端把年龄传成了字符串"28",本地属性是Integer。如果直接做强制转换,运行时会直接异常。我的反序列化器采用“按需宽松转换”策略:优先尝试TryGetValue<T>,失败后再尝试字符串转数值。核心赋值逻辑为:function TryAssignIntProp(AMetaClass: TRttiProperty; AValue: TJSONValue; var AObj: TObject): Boolean; var LIntVal: Integer; begin Result := False; if AValue is TJSONNumber then begin LIntVal := TJSONNumber(AValue).AsInt; AMetaClass.SetValue(AObj, TValue.From<Integer>(LIntVal)); Result := True; end else if AValue is TJSONString then begin if TryStrToInt(TJSONString(AValue).Value, LIntVal) then begin AMetaClass.SetValue(AObj, TValue.From<Integer>(LIntVal)); Result := True; end; end; end;这种方法牺牲了一点严格性,但大大提高了对接第三方接口的容错率。我见过太多项目因为服务端字段类型漂移而崩溃,而这类问题在联调阶段用宽容转换策略能立刻解决。有一点要特别说明:对象映射始终只作为“便捷层”,不要试图让它完全覆盖所有复杂泛型、继承和多态场景。Delphi 的 RTTI 在TObjectList<T>、TDictionary<TKey,TValue>这类泛型容器上的支持并不稳定,硬要自动化往往是自找麻烦。我的实际建议是:标量属性用反射,集合属性手写映射。4.3 忽略字段与自定义名称实际项目里还有一种高频需求:某些字段不想暴露给前端,或者接口字段名和本地属性名不一致。我在属性扩展上加了自定义注解,通过 RTTI 的Attributes读取:type JsonNameAttribute = class(TCustomAttribute) private FName: string; public constructor Create(const AName: string); property Name: string read FName; end;然后在类里写:type TUserInfo = class private FInternalId: Integer; published [JsonName('id')] property InternalId: Integer read FInternalId write FInternalId; end;序列化器发现属性上有JsonName特性时,就使用特性指定的名称作为 JSON 键,否则用属性名首字母小写。这套机制实现起来不到五十行,却让库的适用范围一下子扩大了不少,特别是在对接不同团队维护的接口时非常实用。5. 实战:解析复杂嵌套数据的完整流程5.1 带着真实感的场景设定为了说清楚这套库到底能省多少事,我准备了一个相对接近线上环境的示例。假设某个接口返回了一个文章列表,结构是:{ "code": 0, "message": "ok", "data": { "total": 2, "list": [ { "id": 1001, "title": "Delphi JSON 封装实践", "author": { "name": "zhangsan", "level": 3 }, "tags": ["delphi", "json"] }, { "id": 1002, "title": "内存管理杂谈", "author": { "name": "lisi", "level": 5 }, "tags": ["memory"] } ] } }这种结构在开发中非常常见:外层有状态码和消息,中间层是分页信息,内层是列表对象,列表内部还有嵌套对象和数组。用原生System.JSON去取第二篇文章的author.name,大概要写四五层GetValue,配合一堆类型检查和括号匹配。5.2 用封装库解析的对比下面是我封装库的实际调用代码:var LJson: TJsonDoc; LList: TJsonArray; I: Integer; LId: Integer; LTitle, LAuthorName: string; begin LJson := TJsonDoc.Parse(SResponseJson); try LList := LJson.Node.ArrayByPath('data.list'); for I := 0 to LList.Count - 1 do begin LId := LList.Item[I].GetInt('id', 0); LTitle := LList.Item[I].GetStr('title', ''); LAuthorName := LList.Item[I].GetStr('author.name', 'unknown'); // 业务处理 end; finally LJson.Free; end; end;注意看,GetStr('author.name', 'unknown')这个操作是递归找路径的,它省掉了逐层判断author是不是对象、name存不存在的代码。如果某条数据里恰巧没有author.name,也不会崩,会走默认值'unknown'。这个例子大概可以看出封装库的核心价值:把繁琐的类型检查变成了字段名描述。如果要用原生写等效代码,大概是这样:var LJsonObj: TJSONObject; LData, LListValue: TJSONValue; LArray: TJSONArray; LItem: TJSONValue; LAuthor, LAuthorName: TJSONValue; begin LJsonObj := TJSONObject.ParseJSONValue(SResponseJson) as TJSONObject; LData := LJsonObj.GetValue('data'); LListValue := LJSONObject(LData).GetValue('list'); LArray := LListValue as TJSONArray; for I := 0 to LArray.Count - 1 do begin LItem := LArray.Items[I]; LAuthor := TJSONObject(LItem).GetValue('author'); LAuthorName := TJSONObject(LAuthor).GetValue('name'); if Assigned(LAuthorName) then // ... end; end;而且上面这段原生代码还没有考虑类型转换失败、缺失字段为 nil 的异常情况。实际生产环境再叠加这些判断,代码量至少翻倍。这也是我始终强调分层封装的原因:底层安全逻辑做一次,业务层代码就能保持干净。5.3 防御式读取:避免把时间浪费在“找 bug”上解析接口数据,最常见的崩溃诱因不是 JSON 语法错误,而是字段缺失或类型不匹配。我的封装库在读取时统一遵循“读不到就返回默认值”的约定。但你也要注意一个边界:路径中间某一层不是对象,比如data.list里的list字段并不是数组而是字符串,那我ArrayByPath就不能硬转,它会返回一个空数组而不是抛异常。这里我个人有一个经验:调试阶段可以先开启严格模式,让所有类型不匹配都抛异常,这样能快速定位服务端返回结构哪里不对劲。稳定之后再切回宽容模式,保证线上不会因为数据不齐而崩。封装库里专门加了一个TJsonDocOptions枚举,soStrict和soLoose两个值切换,默认是严格模式。项目上线前我建议切成宽容模式。6. 踩坑记录:关于 System.JSON 的几个底层事实6.1 引用计数与所有权陷阱System.JSON里的TJSONValue.Owned属性是很多人忽略的“雷管”。创建一个TJSONObject,然后把一个TJSONValueAddPair 进去后,父对象就把子对象的析构责任扛下来了。问题出在,如果你对这个子对象调用过Owned := False,父对象析构时不会管它,这个对象就会泄漏。我在封装库里统一采取“不碰 Owned,所有权跟着对象树走”的策略。因为库的顶层TJsonDoc持有根对象,析构时由它释放整棵树,所以用户完全没有必要去修改Owned。假如你在自己的项目里直接用原生TJSONObject,记住一个原则:AddPair 之后不要再手动 Free 子对象,Remove 出来的对象才需要自己释放。6.2 浮点数序列化的“文化差异”这个坑我印象非常深。System.JSON 里的TJSONNumber在将浮点数转为字符串时,使用的是当前系统的区域设置。这意味着,如果你的操作系统区域设置是德语或法语,浮点数可能以逗号作为小数点,这会导致对方接口解析失败。封装的AddFloat方法里,我强制用TFormatSettings.Invariant来格式化,保证任何时候输出的 JSON 都是标准的点号写法:function TJsonNode.AddFloat(const AName: string; const AValue: Double): TJsonNode; var LStr: string; begin LStr := FormatFloat('0.##########', AValue, TFormatSettings.Invariant); FObject.AddPair(AName, TJSONNumber.Create(LStr)); Result := Self; end;同理,读取浮点时也要用StrToFloat的 invariant 重载版本,否则对方返回"3.14"时在某些区域设置下会被解析成 314。这个问题在平时的开发环境里不容易暴露,但一部署到国际用户环境就立刻爆炸。6.3 中文与 UTF-8 编码Delphi 的TJSONString内部处理的是 UnicodeString,所以一般中文读写没问题。但有一个隐蔽的场景:当你把 JSON 字符串直接通过TFile.WriteAllText写到磁盘时,如果不指定 UTF-8 with BOM,很多文本编辑器会识别成 ANSI。若再遇到旧版 Delphi 的AnsiString转换,中文就可能乱码。我在封装库中增加了一个辅助方法TJsonTool.Beautify和SaveToFile,写入时强制使用TEncoding.UTF8:class procedure TJsonTool.SaveToFile(const AJson: string; const AFileName: string); begin TFile.WriteAllText(AFileName, AJson, TEncoding.UTF8); end;这种做法在 Windows 和 macOS 上表现一致,避免了因为跨平台默认编码差异导致的中文乱码问题。6.4 大数组的性能问题如果你的 JSON 里有一个几千上万的数组,直接用TJSONObject.ParseJSONValue去解析,内存和耗时都很可观。原生System.JSON在解析过程中会创建大量TJSONValue子对象,数据量大了之后性能将明显下降。我的封装库内部做了一个缓存层,只针对反复访问同一个数组节点的场景生效。每次ArrayByPath返回的TJsonArray都带着根对象引用,如果下次再用同一个路径查询,会先在一个 LRU 缓存里找,找不到才重新走对象树。实测在一个包含五千条记录的数组场景下,封装库的读取性能比每次都从根节点逐层 GetValue 快了三倍。这个优化不算复杂,但效果立竿见影。6.5 关于异常信息的“可读性”最后一件事是异常消息。原生System.JSON在解析失败时给的提示比较笼统,往往是“JSON 解析错误”这种信息,对排查问题帮助有限。我封装层的Parse方法会捕获原始异常,并把异常位置附近的一段文本截取出来放到新的异常消息里。例如,输入 JSON 在第五十行附近少了一个引号,封装后的错误消息会是类似这样的:JSON 解析失败:位置 1364 附近,"data":{"list":[{"id":1001}这个改进对排查接口返回的数据问题非常有效,因为你可以瞬间定位到出错的节点,而不是在全篇 JSON 里靠眼睛找。封装的价值不只是“把 API 变好看”,更在于把底层的不友好信息转成开发者能理解的上下文。关于这套库,我实际使用大半年后的体会是:封装层的核心价值不在代码量减少多少,而在于把System.JSON那些泄漏点、类型坑、区域设置坑全部挡在业务层外面。后面我还打算给它加上 JSON Schema 校验和更完整的注解映射支持,但目前这套形态已经能覆盖我接过的绝大多数第三方接口场景。如果你也在 Delphi 项目里反复被 JSON 解析的琐碎细节折磨,不妨按照这个思路自己包一层,成本不高,收益却很持久。
1. 为什么要在 System.JSON 外面再做一层封装1.1 原生 API 用起来最别扭的三个地方先说说我的真实感受。我在一个跨平台工具项目里频繁处理接口数据,第一版直接拿System.JSON裸写,写着写着就发现效率极低。原生库的功能其实都在:TJSONObject、TJSONArray、TJSONValue这些类要什么有什么,但问题是这些类设计得太“底层”了,每次拼 JSON 或者读 JSON,都要在类型转换和异常处理上浪费大量代码。举一个最常见的例子。你想起一个对象,原生写法大概是这样的:var LObj: TJSONObject; begin LObj := TJSONObject.Create; LObj.AddPair('name', TJSONString.Create('zhangsan')); LObj.AddPair('age', TJSONNumber.Create(28)); LObj.AddPair('active', TJSONBool.Create(True)); end;读的时候更崩溃,你要先GetValue,再判断是不是TJSONString,还要自己处理 nil。数据简单还好,一旦嵌套两层以上,代码的括号和类型判断能把人绕晕。第二个问题是TJSONValue和它的子类之间缺少足够自然的“默认值”机制。接口数据里字段经常缺失,比如用户信息有时候少一个nickname,原生写法里GetValue('nickname')拿回来的可能是个 nil,还得自己写一大堆Assigned()判断,稍不注意就崩在类型转换上。第三个问题,也是我后来决定必须要封装的原因:原生TJSONObject的链式表达力太弱。你没办法写出一行能读懂的“声明式”代码,所有动作都得拆成过程式语句。代码一旦长了,光看结构都得花半天时间,更别说维护。1.2 我的封装目标:把“动词”变成“声明”我给自己定了个原则:封装不是要把System.JSON替换掉,而是把它包装成一个更好用的形态。理想状态下,读一个 JSON 字段应该像读一个TStringList.Values那样简单,写一个 JSON 字段也应该像设置属性那样自然。我当时脑子里浮现的是一张“目标对照表”,后来也成了我做设计评审时用的标准:问题场景原生做法封装后希望的样子添加一个字符串字段AddPair('name', TJSONString.Create('x'))JO.Str('name', 'x')或相似的可链式方法读取一个整数字段并给默认值TryGetValue<integer>('age', LAge)JO.Int('age', 25),缺失时自动返回默认值快速判断字段是否存在Assigned(LObj.GetValue('name'))JO.Contains('name')解析响应里常见的嵌套结构逐级 GetValue 判断类型一次传入路径如'data.user.id'最终我实现的这套库,核心 API 只有几个模块:TJsonDoc(文档对象)、TJsonNode(节点对象)、TJsonTool(序列化工具)。它们全部只依赖系统自带的System.JSON,编译期不需要引入额外第三方依赖。下面的内容我会把关键设计思路和实测代码一步步说清楚。2. 核心 API 设计:更直观的读写方式2.1 写 JSON:从“过程式拼装”变成“链式声明”我先展示一个成品 API 的使用效果。假设要拼这样的结构:{ "name": "zhangsan", "age": 28, "active": true, "tags": ["dev", "delphi"], "address": { "city": "Shanghai", "zip": "200000" } }用我封装之后的代码写,是这样:var LJson: TJsonDoc; begin LJson := TJsonDoc.Create; LJson .Node .AddStr('name', 'zhangsan') .AddInt('age', 28) .AddBool('active', True) .AddArray('tags') .Append('dev') .Append('delphi') .EndArray .AddObject('address') .AddStr('city', 'Shanghai') .AddStr('zip', '200000') .EndObject; end;每个方法返回的都是同一个节点对象的引用,所以可以一路点下去。核心实现并没有多复杂,底层就是先创建对应子类,然后统一挂到父节点上。关键是内部要维护一个“当前节点”的栈,才能让EndArray和EndObject正确回到父级。下面是AddStr和AddObject的示意实现,我从实际项目里抽了一段核心逻辑出来,去掉了调试噪音:function TJsonNode.AddStr(const AName, AValue: string): TJsonNode; begin FObject.AddPair(AName, TJSONString.Create(AValue)); Result := Self; end; function TJsonNode.AddObject(const AName: string): TJsonNode; var LChild: TJSONObject; LChildNode: TJsonNode; begin LChild := TJSONObject.Create; FObject.AddPair(AName, LChild); LChildNode := TJsonNode.Create(LChild); FNodeStack.Push(CurrentNode); // 记录父节点 FCurrentNode := LChildNode; Result := LChildNode; end;EndObject做的就是弹栈操作。这里有一个容易被忽略的细节:如果你在链式调用中间抛了异常,栈状态会乱,因此我会在TJsonDoc的析构里统一释放所有已创建的TJSONObject、TJSONArray临时对象。这个点我在后面“踩坑”部分再展开讲。2.2 读 JSON:属性访问加上默认值兜底读数据是我封装库最重要的改进点。原生代码读嵌套字段往往要写很多层判断,我的封装直接用属性访问器加路径字符串解决。比如解析上面那个结构,拿address.city的代码是:var LJson: TJsonDoc; LCity: string; begin LJson := TJsonDoc.Parse('{"name":"zhangsan","age":28,"active":true,"tags":["dev","delphi"],"address":{"city":"Shanghai","zip":"200000"}}'); try LCity := LJson.Node.Find('address.city', 'unknown'); finally LJson.Free; end; end;Find方法内部把路径按.分割后循环走子节点。这里有个性能考量:路径分割我用了TArray<string>和TStringSplitOptions,没有引入正则,因为 JSON 路径匹配的语法并不复杂,没必要为这点功能引入额外复杂度。对于最常见的标量字段,我还实现了带类型的读取器:function TJsonNode.GetStr(const AName: string; const ADefault: string = ''): string; var LValue: TJSONValue; begin LValue := FObject.GetValue(AName); if Assigned(LValue) and (LValue is TJSONString) then Result := LValue.Value else Result := ADefault; end;GetStr、GetInt、GetBool、GetFloat四个方法是我实测使用频率最高的,加起来占了我所有解析代码的八成。后期我又补了GetStrDef、GetIntDef等别名方法,其实内部完全一样,只是命名风格上更接近大部分 Delphi 开发者的习惯。2.3 数组节点:避免 TJSONArray 的“双重类型”尴尬TJSONArray原生用起来有一个很尴尬的点:它继承自TJSONValue,但实际是集合容器,读单个元素时经常要Items[i],然后用.Value拿字符串,用TJSONNumber再转换一次数值。我在封装里直接把数组元素封装成TJsonArrayReader,并且给每个元素一个统一的隐式转换路径。读取数组的典型用法:var LTags: TJsonArray; I: Integer; LItem: TJsonNode; begin LTags := LJson.Node.ArrayByName('tags'); for I := 0 to LTags.Count - 1 do begin LItem := LTags.Item[I]; // 这里可以直接用 LItem.AsString end; end;数组节点设计的关键在于:Item[I]返回的TJsonNode必须是一个轻量包装,不能为每次访问都创建新对象,否则内存消耗会很大。我用了一个缓存节点对象,每次只更新内部的TJSONValue引用,这样既避免了频繁TJsonNode.Create又保证了调用方拿到的接口一致。3. 关键实现细节:节点生命周期与内存管理3.1 谁负责释放 TJSONValue?System.JSON的内存模型有一点很像手工管理时代的 C 语言:谁创建谁释放。TJSONObject里挂了子节点后,父对象析构时会把所有子值释放掉。但如果你拿到一个TJSONValue后把它从父对象里Remove出来,又不自己释放,就会泄漏。我在封装设计里定了一个铁律:所有进入文档对象的节点,生命周期都归文档对象管。也就是说,你通过TJsonDoc创建的节点,永远不要手动去Free;你从外部 Parse 得到的TJSONObject,也只需要释放TJsonDoc本身。这个约定看起来很普通,但在实际协作中避免了很多次崩溃。这里有一个典型错误,我在团队里见过不止一次:// 错误示范 var LObj: TJSONObject; LName: TJSONString; begin LObj := TJSONObject.Create; LName := TJSONString.Create('x'); LObj.AddPair('name', LName); LName.Free; // 崩溃!父对象析构时还会再释放一次 end;封装库里的AddStr内部就是直接AddPair(AName, TJSONString.Create(AValue)),你根本拿不到子对象,所以天然避开了这类问题。3.2 链式调用的“栈”是怎么维护的我在 2.1 提到内部维护了节点栈,现在把实现的关键部分说完整。一个简单的TStack<TJsonNode>存父节点引用,每次进入AddObject或AddArray时把当前节点压栈,遇到EndObject/EndArray时弹栈。这里有一个值得注意的边界情况:用户在链式调用中途没有写EndArray就直接结束,比如:LJson .Node .AddObject('a') .AddStr('b', 'c'); // 没有调用 EndObject这个时候当前节点还停留在a对象上。如果接下来直接LJson.Node.AddStr('name', 'x'),那name字段会被加到a里面去,而不是加到根节点。为了避免这种隐蔽问题,我在TJsonDoc里提供了一个Reset方法,并且在TJsonDoc.Free前强制把所有栈中节点释放干净。严格来说这不影响正确性,因为节点引用都挂在对象树里,只是当前指针位置不一样。但对使用体验影响很大,接口使用者经常会对“为什么字段没加到根节点”感到困惑。后来我改成了更严格的策略:EndObject和EndArray不是可选的,而是在节点析构前必须成对出现。实在漏写了,就通过调试断言在 debug 模式下给出明确提示。不要小看这个设计,它把“链式 API”最容易踩的坑提前拦掉了一大半。3.3 Parse 入口:异常处理与输入校验TJsonDoc.Parse是另一个用户接触最多的入口。我最初直接调用TJSONObject.ParseJSONValue,但很快发现一个问题:这个方法在输入是合法的 JSON 数组时,返回的是TJSONArray而不是TJSONObject。如果不做区分,后续所有节点操作都会报错。所以我的Parse做了严格封装,核心逻辑如下:class function TJsonDoc.Parse(const AJson: string): TJsonDoc; var LValue: TJSONValue; begin Result := TJsonDoc.Create; try LValue := TJSONObject.ParseJSONValue(AJson); if LValue is TJSONObject then Result.SetRootObject(TJSONObject(LValue)) else if LValue is TJSONArray then Result.SetRootArray(TJSONArray(LValue)) else raise EJsonParseException.Create('输入内容不是合法的 JSON 对象或数组'); except Result.Free; raise; end; end;这里我刻意把“顶层是数组”的情况也支持了,因为接口返回的 JSON 不一定都是对象。遇到空字符串或者非法输入时,TJSONObject.ParseJSONValue会返回 nil,我把它升级成带提示的EJsonParseException,避免用户在 nil 上继续访问导致无意义崩溃。这个封装让调用方代码可以放心直接使用返回值,不需要每次Assigned()判断。4. 基于 RTTI 的对象映射:序列化与反序列化4.1 为什么不推荐“万能反射”封装库做到这个阶段,我已经能很舒服地读写任意 JSON 了。但项目越做越复杂时,新需求很快来了:很多业务对象的结构和 JSON 字段几乎一一对应,每个地方都手写字段映射太累。于是我开始设计序列化模块。Delphi 的 RTTI 功能已经足够支持自动映射,但我不建议上来就做一个“万能反射库”。原因是 RTTI 映射虽然省事,但在字段命名差异、嵌套级别和集合类型上的坑非常多,而且性能损耗不小。我的方案是做一个“半自动”映射器:基础标量类型自动处理,命名规则统一用 CamelCase 到 PascalCase 的转换,特殊情况用属性注解。例如我要把这样一个类序列化成 JSON:type TUserInfo = class private FName: string; FAge: Integer; FIsActive: Boolean; FTags: TArray<string>; public property Name: string read FName write FName; property Age: Integer read FAge write FAge; property IsActive: Boolean read FIsActive write FIsActive; property Tags: TArray<string> read FTags write FTags; end;序列化调用只需要一行:var LJson: string; LUser: TUserInfo; begin LUser := TUserInfo.Create; LUser.Name := 'zhangsan'; LUser.Age := 28; LUser.IsActive := True; LUser.Tags := TArray<string>.Create('dev', 'delphi'); LJson := TJsonTool.Serialize(LUser); end;底层的序列化遍历利用了System.Rtti,对每个发布属性读取PropertyInfo.GetValue,然后根据TypeInfo判断是 Integer、string、Boolean 还是动态数组。因为使用了缓存机制,同一个类第二次序列化的性能会比第一次快不少。4.2 反序列化的“容错赋值”策略反序列化相对复杂一些。接口返回的数据经常和本地定义的类型不完全一致,举个例子:服务端把年龄传成了字符串"28",本地属性是Integer。如果直接做强制转换,运行时会直接异常。我的反序列化器采用“按需宽松转换”策略:优先尝试TryGetValue<T>,失败后再尝试字符串转数值。核心赋值逻辑为:function TryAssignIntProp(AMetaClass: TRttiProperty; AValue: TJSONValue; var AObj: TObject): Boolean; var LIntVal: Integer; begin Result := False; if AValue is TJSONNumber then begin LIntVal := TJSONNumber(AValue).AsInt; AMetaClass.SetValue(AObj, TValue.From<Integer>(LIntVal)); Result := True; end else if AValue is TJSONString then begin if TryStrToInt(TJSONString(AValue).Value, LIntVal) then begin AMetaClass.SetValue(AObj, TValue.From<Integer>(LIntVal)); Result := True; end; end; end;这种方法牺牲了一点严格性,但大大提高了对接第三方接口的容错率。我见过太多项目因为服务端字段类型漂移而崩溃,而这类问题在联调阶段用宽容转换策略能立刻解决。有一点要特别说明:对象映射始终只作为“便捷层”,不要试图让它完全覆盖所有复杂泛型、继承和多态场景。Delphi 的 RTTI 在TObjectList<T>、TDictionary<TKey,TValue>这类泛型容器上的支持并不稳定,硬要自动化往往是自找麻烦。我的实际建议是:标量属性用反射,集合属性手写映射。4.3 忽略字段与自定义名称实际项目里还有一种高频需求:某些字段不想暴露给前端,或者接口字段名和本地属性名不一致。我在属性扩展上加了自定义注解,通过 RTTI 的Attributes读取:type JsonNameAttribute = class(TCustomAttribute) private FName: string; public constructor Create(const AName: string); property Name: string read FName; end;然后在类里写:type TUserInfo = class private FInternalId: Integer; published [JsonName('id')] property InternalId: Integer read FInternalId write FInternalId; end;序列化器发现属性上有JsonName特性时,就使用特性指定的名称作为 JSON 键,否则用属性名首字母小写。这套机制实现起来不到五十行,却让库的适用范围一下子扩大了不少,特别是在对接不同团队维护的接口时非常实用。5. 实战:解析复杂嵌套数据的完整流程5.1 带着真实感的场景设定为了说清楚这套库到底能省多少事,我准备了一个相对接近线上环境的示例。假设某个接口返回了一个文章列表,结构是:{ "code": 0, "message": "ok", "data": { "total": 2, "list": [ { "id": 1001, "title": "Delphi JSON 封装实践", "author": { "name": "zhangsan", "level": 3 }, "tags": ["delphi", "json"] }, { "id": 1002, "title": "内存管理杂谈", "author": { "name": "lisi", "level": 5 }, "tags": ["memory"] } ] } }这种结构在开发中非常常见:外层有状态码和消息,中间层是分页信息,内层是列表对象,列表内部还有嵌套对象和数组。用原生System.JSON去取第二篇文章的author.name,大概要写四五层GetValue,配合一堆类型检查和括号匹配。5.2 用封装库解析的对比下面是我封装库的实际调用代码:var LJson: TJsonDoc; LList: TJsonArray; I: Integer; LId: Integer; LTitle, LAuthorName: string; begin LJson := TJsonDoc.Parse(SResponseJson); try LList := LJson.Node.ArrayByPath('data.list'); for I := 0 to LList.Count - 1 do begin LId := LList.Item[I].GetInt('id', 0); LTitle := LList.Item[I].GetStr('title', ''); LAuthorName := LList.Item[I].GetStr('author.name', 'unknown'); // 业务处理 end; finally LJson.Free; end; end;注意看,GetStr('author.name', 'unknown')这个操作是递归找路径的,它省掉了逐层判断author是不是对象、name存不存在的代码。如果某条数据里恰巧没有author.name,也不会崩,会走默认值'unknown'。这个例子大概可以看出封装库的核心价值:把繁琐的类型检查变成了字段名描述。如果要用原生写等效代码,大概是这样:var LJsonObj: TJSONObject; LData, LListValue: TJSONValue; LArray: TJSONArray; LItem: TJSONValue; LAuthor, LAuthorName: TJSONValue; begin LJsonObj := TJSONObject.ParseJSONValue(SResponseJson) as TJSONObject; LData := LJsonObj.GetValue('data'); LListValue := LJSONObject(LData).GetValue('list'); LArray := LListValue as TJSONArray; for I := 0 to LArray.Count - 1 do begin LItem := LArray.Items[I]; LAuthor := TJSONObject(LItem).GetValue('author'); LAuthorName := TJSONObject(LAuthor).GetValue('name'); if Assigned(LAuthorName) then // ... end; end;而且上面这段原生代码还没有考虑类型转换失败、缺失字段为 nil 的异常情况。实际生产环境再叠加这些判断,代码量至少翻倍。这也是我始终强调分层封装的原因:底层安全逻辑做一次,业务层代码就能保持干净。5.3 防御式读取:避免把时间浪费在“找 bug”上解析接口数据,最常见的崩溃诱因不是 JSON 语法错误,而是字段缺失或类型不匹配。我的封装库在读取时统一遵循“读不到就返回默认值”的约定。但你也要注意一个边界:路径中间某一层不是对象,比如data.list里的list字段并不是数组而是字符串,那我ArrayByPath就不能硬转,它会返回一个空数组而不是抛异常。这里我个人有一个经验:调试阶段可以先开启严格模式,让所有类型不匹配都抛异常,这样能快速定位服务端返回结构哪里不对劲。稳定之后再切回宽容模式,保证线上不会因为数据不齐而崩。封装库里专门加了一个TJsonDocOptions枚举,soStrict和soLoose两个值切换,默认是严格模式。项目上线前我建议切成宽容模式。6. 踩坑记录:关于 System.JSON 的几个底层事实6.1 引用计数与所有权陷阱System.JSON里的TJSONValue.Owned属性是很多人忽略的“雷管”。创建一个TJSONObject,然后把一个TJSONValueAddPair 进去后,父对象就把子对象的析构责任扛下来了。问题出在,如果你对这个子对象调用过Owned := False,父对象析构时不会管它,这个对象就会泄漏。我在封装库里统一采取“不碰 Owned,所有权跟着对象树走”的策略。因为库的顶层TJsonDoc持有根对象,析构时由它释放整棵树,所以用户完全没有必要去修改Owned。假如你在自己的项目里直接用原生TJSONObject,记住一个原则:AddPair 之后不要再手动 Free 子对象,Remove 出来的对象才需要自己释放。6.2 浮点数序列化的“文化差异”这个坑我印象非常深。System.JSON 里的TJSONNumber在将浮点数转为字符串时,使用的是当前系统的区域设置。这意味着,如果你的操作系统区域设置是德语或法语,浮点数可能以逗号作为小数点,这会导致对方接口解析失败。封装的AddFloat方法里,我强制用TFormatSettings.Invariant来格式化,保证任何时候输出的 JSON 都是标准的点号写法:function TJsonNode.AddFloat(const AName: string; const AValue: Double): TJsonNode; var LStr: string; begin LStr := FormatFloat('0.##########', AValue, TFormatSettings.Invariant); FObject.AddPair(AName, TJSONNumber.Create(LStr)); Result := Self; end;同理,读取浮点时也要用StrToFloat的 invariant 重载版本,否则对方返回"3.14"时在某些区域设置下会被解析成 314。这个问题在平时的开发环境里不容易暴露,但一部署到国际用户环境就立刻爆炸。6.3 中文与 UTF-8 编码Delphi 的TJSONString内部处理的是 UnicodeString,所以一般中文读写没问题。但有一个隐蔽的场景:当你把 JSON 字符串直接通过TFile.WriteAllText写到磁盘时,如果不指定 UTF-8 with BOM,很多文本编辑器会识别成 ANSI。若再遇到旧版 Delphi 的AnsiString转换,中文就可能乱码。我在封装库中增加了一个辅助方法TJsonTool.Beautify和SaveToFile,写入时强制使用TEncoding.UTF8:class procedure TJsonTool.SaveToFile(const AJson: string; const AFileName: string); begin TFile.WriteAllText(AFileName, AJson, TEncoding.UTF8); end;这种做法在 Windows 和 macOS 上表现一致,避免了因为跨平台默认编码差异导致的中文乱码问题。6.4 大数组的性能问题如果你的 JSON 里有一个几千上万的数组,直接用TJSONObject.ParseJSONValue去解析,内存和耗时都很可观。原生System.JSON在解析过程中会创建大量TJSONValue子对象,数据量大了之后性能将明显下降。我的封装库内部做了一个缓存层,只针对反复访问同一个数组节点的场景生效。每次ArrayByPath返回的TJsonArray都带着根对象引用,如果下次再用同一个路径查询,会先在一个 LRU 缓存里找,找不到才重新走对象树。实测在一个包含五千条记录的数组场景下,封装库的读取性能比每次都从根节点逐层 GetValue 快了三倍。这个优化不算复杂,但效果立竿见影。6.5 关于异常信息的“可读性”最后一件事是异常消息。原生System.JSON在解析失败时给的提示比较笼统,往往是“JSON 解析错误”这种信息,对排查问题帮助有限。我封装层的Parse方法会捕获原始异常,并把异常位置附近的一段文本截取出来放到新的异常消息里。例如,输入 JSON 在第五十行附近少了一个引号,封装后的错误消息会是类似这样的:JSON 解析失败:位置 1364 附近,"data":{"list":[{"id":1001}这个改进对排查接口返回的数据问题非常有效,因为你可以瞬间定位到出错的节点,而不是在全篇 JSON 里靠眼睛找。封装的价值不只是“把 API 变好看”,更在于把底层的不友好信息转成开发者能理解的上下文。关于这套库,我实际使用大半年后的体会是:封装层的核心价值不在代码量减少多少,而在于把System.JSON那些泄漏点、类型坑、区域设置坑全部挡在业务层外面。后面我还打算给它加上 JSON Schema 校验和更完整的注解映射支持,但目前这套形态已经能覆盖我接过的绝大多数第三方接口场景。如果你也在 Delphi 项目里反复被 JSON 解析的琐碎细节折磨,不妨按照这个思路自己包一层,成本不高,收益却很持久。