资讯详情

资讯详情

Delphi System.JSON 封装实践:从链式读写到RTTI映射的工程化设计

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)或相似的可链式方法读取一个整数字段并给默认值TryGetValueinteger(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方法内部把路径按.分割后循环走子节点。这里有个性能考量路径分割我用了TArraystring和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 谁负责释放 TJSONValueSystem.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 提到内部维护了节点栈现在把实现的关键部分说完整。一个简单的TStackTJsonNode存父节点引用每次进入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 的转换特殊情况用属性注解。例如我要把这样一个类序列化成 JSONtype TUserInfo class private FName: string; FAge: Integer; FIsActive: Boolean; FTags: TArraystring; public property Name: string read FName write FName; property Age: Integer read FAge write FAge; property IsActive: Boolean read FIsActive write FIsActive; property Tags: TArraystring 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 : TArraystring.Create(dev, delphi); LJson : TJsonTool.Serialize(LUser); end;底层的序列化遍历利用了System.Rtti对每个发布属性读取PropertyInfo.GetValue然后根据TypeInfo判断是 Integer、string、Boolean 还是动态数组。因为使用了缓存机制同一个类第二次序列化的性能会比第一次快不少。4.2 反序列化的“容错赋值”策略反序列化相对复杂一些。接口返回的数据经常和本地定义的类型不完全一致举个例子服务端把年龄传成了字符串28本地属性是Integer。如果直接做强制转换运行时会直接异常。我的反序列化器采用“按需宽松转换”策略优先尝试TryGetValueT失败后再尝试字符串转数值。核心赋值逻辑为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.FromInteger(LIntVal)); Result : True; end else if AValue is TJSONString then begin if TryStrToInt(TJSONString(AValue).Value, LIntVal) then begin AMetaClass.SetValue(AObj, TValue.FromInteger(LIntVal)); Result : True; end; end; end;这种方法牺牲了一点严格性但大大提高了对接第三方接口的容错率。我见过太多项目因为服务端字段类型漂移而崩溃而这类问题在联调阶段用宽容转换策略能立刻解决。有一点要特别说明对象映射始终只作为“便捷层”不要试图让它完全覆盖所有复杂泛型、继承和多态场景。Delphi 的 RTTI 在TObjectListT、TDictionaryTKey,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.UTF8class 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 解析的琐碎细节折磨不妨按照这个思路自己包一层成本不高收益却很持久。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →