Delphi JSON封装:告别System.JSON繁琐API,打造优雅对象映射库
发布时间:2026/10/20 10:08:50 锦皓数字建站

作为一个常年用 Delphi 写业务系统的老手我太清楚原生System.JSON用起来有多别扭了。明明只是一个简单的“取字段值”操作却要写出一长串FindValue、GetValuestring还得小心翼翼处理可能存在的Access Violation——JSON 里少个字段整个调用链直接崩给你看。所以当看到有人愿意基于System.JSON做一层封装把那些繁琐的细节吞掉、把 API 改成更符合直觉的样子时我第一反应是这事早就该有人干了。这篇博文我想拆解的就是这样一个封装库的设计思路和核心实现。它不是那种只停留在“把原生 API 换个名字”的皮包封装而是真正从业务开发者的使用习惯出发把“解析 JSON”这件破事变成“读一个对象属性”那么简单。无论你是刚接触 Delphi 的初学者还是被原生 JSON 折磨多年的老开发下面这套思路都能直接拿过去用甚至可以在自己的项目里照着重写一份。1. 内容整体设计与思路拆解1.1 为什么饶过原生 System.JSON 不直接写先说个场景。你用 Delphi 写一个 REST 客户端服务端返回一个多层嵌套的 JSON{ code: 0, data: { user: { name: Alice, age: 25, tags: [admin, vip] }, page: { total: 100, current: 1 } } }用原生 API 读user.name你得这么写var Root: TJSONValue; DataObj, UserObj: TJSONObject; NameVal: TJSONValue; begin Root : TJSONObject.ParseJSONValue(ResponseBody); try DataObj : (Root as TJSONObject).GetValue(data) as TJSONObject; UserObj : DataObj.GetValue(user) as TJSONObject; NameVal : UserObj.GetValue(name); ShowMessage(NameVal.Value); finally Root.Free; end; end;这还只是取一层字段。如果要循环遍历tags数组代码量直接翻倍而且每一层都要手动判空DataObj可能是 nil、UserObj可能是 nil、NameVal也可能是 nil。漏一个判断就是运行时崩溃。这就是原生System.JSON最大的毛病API 设计是“数据结构视角”而非“业务视角”。它把 JSON 当作一棵树让你自己在树上爬而业务开发者想要的是“我声明一个类JSON 往类里一倒就行”。封装库的核心价值就在这里——把“爬树”变成“映射”。你写一个TUserInfo类定义好字段然后一句Json.ToObject将 JSON 文本转换成类实例读字段就是读对象的属性。代码量至少减少一半以上健壮性还能提升一个档次。1.2 封装方案选型三种常见路线对比在动手设计封装库之前需要先想清楚走哪条技术路线。我见过的主流封装方案大体有三种方案类型实现思路优点缺点包装器模式保留原生 TJSONValue 数据结构提供辅助函数GetStr / GetInt / Exists实现简单兼容性好仍然需要理解 JSON 树结构代码不够优雅对象映射模式定义实体类用 RTTI 自动将与字段同名的 JSON key 映射到类属性最贴近业务代码最少对复杂结构嵌套数组、自定义类型处理繁琐混合模式既支持链式取值又支持 ToObject 映射灵活能覆盖大部分业务场景封装库体积最大设计复杂度最高我最终选择的是第三种混合模式。原因很现实日常开发中有些场景比如解析一个临时接口返回的某个值用链式取值更方便没必要专门定义一个类而有些场景比如配置项加载、核心业务对象持久化用 ToObject 映射更合适。两种都支持才能让封装库真正“简洁易用”而不是强迫开发者为某个小需求引入一堆类定义。1.3 设计目标自问自答在动手前我问了自己五个问题这五个问题的答案直接决定了封装的形态能否让代码读取一个多层嵌套字段时只写一行能用链式调用Json.Get(data.user.name)内部自动处理中间层为 nil 的情况。能否完全不用手动释放内存能通过 Delphi 的接口引用计数机制超出作用域自动释放底层TJSONValue。能否在字段不存在时不抛异常而是返回默认值能提供Get(field 默认值)重载不存在时返回默认值而不是崩掉。能否支持对象与 JSON 的互转而不写一堆赋值代码能基于 RTTI 的ToObject/ToJson双向转换。能否保持底层仍然使用System.JSON必须能因为System.JSON本身解析能力是可靠且经受过大量验证的没必要重复造轮子做词法分析器。带着这五个问题往下走整个封装库的骨架就清晰了。2. 核心细节解析与实操要点2.1 对外 API 设计让它用起来“不像 JSON”包装库的 API 设计是第一印象直接决定开发者愿不愿意用。我设计的核心入口是一个静态类TJson所有方法都是类方法调用时不需要创建任何实例。uses System.SysUtils, System.JSON, System.Generics.Collections; type TJson class public // 解析与序列化 class function Parse(const AText: string): IJsonObject; overload; class function Stringify(AValue: TJsonObject): string; overload; class function Stringify(AValue: TJsonArray): string; overload; // 对象与 JSON 互转基于 RTTI class function ToObjectT: class(const AJsonText: string): T; overload; class function ToObjectT: class(const AJson: IJsonObject): T; overload; class function ToJson(AObject: TObject; const AIncludeEmpty: Boolean True): string; overload; // 便捷构造 class function NewObject: IJsonObject; class function NewArray: IJsonArray; end;同时定义两个核心接口type IJsonObject interface [{...}] // 读取 function Get(const APath: string): Variant; overload; function Get(const APath: string; const ADefault: Variant): Variant; overload; function GetT(const APath: string): T; overload; function GetT(const APath: string; const ADefault: T): T; overload; function Exists(const APath: string): Boolean; function Count: Integer; function ToString: string; // 写入 function Put(const APath: string; const AValue: Variant): IJsonObject; function PutObject(const APath: string; AValue: IJsonObject): IJsonObject; function PutArray(const APath: string; AValue: IJsonArray): IJsonObject; // 子对象访问 function Obj(const APath: string): IJsonObject; function Arr(const APath: string): IJsonArray; end;这里有个关键点为什么不直接返回原生TJSONObject而是返回自定义接口因为接口可以自动管理生命周期。调用方写var J : TJson.Parse(Text)后不需要手动 Free离开作用域时引用计数归零底层TJSONObject自动释放。这对用户来说就是“零内存负担”。2.2 链式路径解析Get(data.user.name)的实现原理这个语法糖背后的实现并不复杂但有几个坑必须避免。核心做法是把路径字符串用.分割逐层向下查找class function TJsonObjectImpl.Get(const APath: string): Variant; var Segments: TArraystring; Current: TJSONValue; I: Integer; Next: TJSONValue; begin Result : Null; if APath.IsEmpty then Exit; Segments : APath.Split([.]); Current : FBaseValue; // FBaseValue 是底层的 TJSONObject for I : 0 to High(Segments) do begin if not (Current is TJSONObject) then Exit; // 中间层不是对象路径必然无效 Next : TJSONObject(Current).GetValue(Segments[I]); if Next nil then Exit; // 字段不存在返回默认值或 Null if I High(Segments) then begin // 最后一个分段提取值 if Next is TJSONNumber then Result : TJSONNumber(Next).AsDouble else if Next is TJSONBool then Result : TJSONBool(Next).AsBoolean else if Next is TJSONString then Result : TJSONString(Next).Value else Result : Next.Value; end else Current : Next; end; end;注意上面的类型判断顺序很重要。AsDouble处理整数时 25 会变成 25.0如果你需要严格区分整数和浮点需要在TJSONNumber内部检查实际值的类型。这块我在后面“坑怎么踩”一节再展开。路径解析最容易犯的错误是直接用TJSONObject(Current).Values[Segments[I]]。这个Values属性在某些 Delphi 版本中返回一个TJSONValue但字典中不存在的 key 访问时会抛异常而不是返回 nil。所以必须用GetValue方法它在找不到时返回 nil我们从而能自己决定是默认值还是异常。2.3 类型转换细节GetT 泛型取值的通用框架直接返回Variant固然方便但业务代码往往需要强类型结果。于是我提供了泛型版本GetT内部实现依赖 Delphi 的TypeInfoclass function TJsonObjectImpl.GetT(const APath: string): T; var V: Variant; begin V : Get(APath); // 先按变体取出 if V Null then Result : Default(T) else Result : TValue.FromVariant(V).AsTypeT(); end;这里有个效率上的取舍TValue.FromVariant有转换开销但换来的是通用性和代码可读性。在 JSON 解析这种 I/O 密集场景中这点开销经常可以被忽略如果你在做一个高吞吐的网关程序建议也为特定类型写重载比如GetString/GetInteger/GetBool。2.4 写入与构造Put 方法支持自动创建中间节点Put(data.user.name Alice)这个操作比读取更难。难点在于如果中间节点data和user还不存在要不要自动创建我的设计是——默认自动创建。理由很实际构造一个多层 JSON 请求体时如果每个中间层都要手动NewObject再挂接代码会变得丑陋且繁琐。class function TJsonObjectImpl.Put(const APath: string; const AValue: Variant): IJsonObject; var Segments: TArraystring; Current: TJSONObject; I: Integer; IsLast: Boolean; Child: TJSONObject; begin Result : Self; if APath.IsEmpty then Exit; Segments : APath.Split([.]); Current : FBaseValue; for I : 0 to High(Segments) do begin IsLast : (I High(Segments)); if IsLast then begin // 写入标量值 Current.RemovePair(Segments[I]); Current.AddPair(Segments[I], TJsonValue.CreateJsonValue(AValue)); end else begin // 查找或创建中间节点 Child : Current.GetValue(Segments[I]) as TJSONObject; if Child nil then begin Child : TJSONObject.Create; Current.AddPair(Segments[I], Child); end; Current : Child; end; end; end;注意一个细节写入前先RemovePair再AddPair。这是因为TJSONObject.AddPair在 key 重复时会抛出 key 重复异常而 JSON 语义里同名字段应该被后者覆盖。这算是一个从原生System.JSON继承来的“小陷阱”封装时要刻意处理掉。3. 实操过程与核心环节实现3.1 第一步搭接口骨架实现自动释放我实际搭建库的时候第一步不是写功能而是定接口、确定生命周期。下面是我用来解决问题的“接口 实现类”分离方式type TJsonObjectImpl class(TInterfacedObject, IJsonObject) private FBaseValue: TJSONObject; public constructor Create(ABaseValue: TJSONObject); destructor Destroy; override; // ... IJsonObject 方法实现 end; constructor TJsonObjectImpl.Create(ABaseValue: TJSONObject); begin inherited Create; FBaseValue : ABaseValue; end; destructor TJsonObjectImpl.Destroy; begin FBaseValue.Free; // 由封装对象持有底层并在销毁时释放 inherited Destroy; end;为什么这个设计可行因为TInterfacedObject负责引用计数调用方完全不需要管释放。这里有个必须注意的坑底层的TJSONObject只能被这一个封装对象持有不能同时把同一个TJSONObject传给两个接口实例否则会双重释放。我在实际开发中采用一个约定底层对象的所有权在创建它的接口实例上其他接口需要访问同一个底层数据时不做 share而是直接给一个“只读视图”的接口。3.2 第二步ToObject / ToJson用 RTTI 做长枪短炮的数据映射对象映射功能是整个封装库中最符合“简明易用”的地方。我们定义一个业务类type TUserInfo class public Name: string; Age: Integer; Tags: TArraystring; Level: Double; end;然后一句代码var User: TUserInfo; begin User : TJson.ToObjectTUserInfo({name:Alice,age:25,tags:[admin,vip],level:4.5}); // 直接用 User.Name 就是 Alice不用做任何解析 end;ToObjectT的实现核心是 RTTI。大致流程是ParseJSONValue解析文本得到TJSONObject通过TValue获取T的类信息TClass遍历TRTTIType的公开字段Published或Public和属性对每个字段名在 JSON 对象中查找同名 key用TValue将 JSON 值转为 Delphi 类型赋值给字段。RTTI 遍历字段的代码骨架class function TJson.ToObjectT(const AJsonText: string): T; var JsonObj: TJSONObject; JsonValue: TJSONValue; Ctx: TRTTIContext; ObjType: TRTTIType; Field: TRTTIField; Prop: TRTTIProperty; Value: TValue; begin Result : nil; JsonValue : TJSONObject.ParseJSONValue(AJsonText); if not (JsonValue is TJSONObject) then Exit; try JsonObj : JsonValue as TJSONObject; Result : T(TClass(T).Create); Ctx : TRTTIContext.Create; try ObjType : Ctx.GetType(TClass(T)); for Field in ObjType.GetFields do begin Value : GetJsonValueForField(JsonObj, Field.Name); if not Value.IsEmpty then Field.SetValue(Result, Value); end; for Prop in ObjType.GetProperties do begin if not Prop.IsReadable or not Prop.IsWritable then Continue; Value : GetJsonValueForField(JsonObj, Prop.Name); if not Value.IsEmpty then Prop.SetValue(Result, Value); end; finally Ctx.Free; end; finally JsonValue.Free; end; end;这段代码看起来不长但实操时我加了不少细节值得你注意需要兼容区分TJSONString、TJSONNumber、TJSONBool、TJSONNull等不同类型如果字段是对象/数组类型要递归调用ToObjectT或处理成TArrayT枚举类型要支持从字符串映射——很多接口返回的是status: active而不是数字RTTI 会把TRTTIEnumType.GetValue(active)枚举值转换出来。TJson.ToJson反向序列化相对简单同样用 RTTI 遍历字段用AddPair加入TJSONObject然后ToString输出。不过有一个坑循环引用。A对象里有B对象B对象里又引用A如果不对层数设上限或缓存已序列化对象程序会无限递归直到栈溢出。我目前的策略是支持自定义特性Attribute标记“不再向下序列化该字段”从而打破循环。3.3 第三步数组处理和泛型集合映射数组的封装要比对象麻烦一点。我这边设计了IJsonArray接口同样提供链式访问和泛型转换type IJsonArray interface [{...}] function GetCount: Integer; function GetItem(Index: Integer): Variant; function GetObj(Index: Integer): IJsonObject; function GetArr(Index: Integer): IJsonArray; function Add(const AValue: Variant): IJsonArray; function AddObject(AValue: IJsonObject): IJsonArray; function AddArray(AValue: IJsonArray): IJsonArray; function ToString: string; property Count: Integer read GetCount; property Items[Index: Integer]: Variant read GetItem; default; end;实际业务场景中接口返回的 JSON 常常是data.tags这样的数组。用原生的方式遍历需要先GetValue(data.tags)判断TJSONArray类型再用Items[i].Value取值。用封装后的方式你可以直接写var Arr: IJsonArray; I: Integer; begin Arr : Json.Arr(data.tags); for I : 0 to Arr.Count - 1 do if Arr[I] Null then Memo1.Lines.Add(Arr[I]); end;到了这个境界就是真的“直接读”了。你甚至可以把TArraystring也映射进去type TUserInfo class Tags: TArraystring; end; var Tags: TArraystring; begin Tags : TJson.ToObjectTUserInfo(JsonText).Tags; end;我在实现TArrayT的映射时用了TValue做数组类型转换。核心代码思路// 伪代码说明 // 1. 从 JSON 数组中逐个元素读取 // 2. 对每个元素做类型转换整数、字符串等 // 3. 填充到动态数组 TArrayT // 4. 将 TArrayT 转为 TValue 并 SetValue 到字段上。3.4 第四步特性Attribute扩展与字段命名策略很多接口的 JSON key 不是 PascalCase而是下划线风格比如user_name对应 Delphi 的UserName属性。这时你可以用 Delphi 的CustomAttribute来显式指定映射关系type JsonFieldAttribute class(TCustomAttribute) private FName: string; public constructor Create(const AName: string); property Name: string read FName; end; type TUserInfo class public [JsonField(user_name)] UserName: string; [JsonField(created_at)] CreatedAt: TDateTime; end;有了特性之后ToObjectT的关键代码变为优先读特性的Name取不到就用字段名。实现里需要增加一个查找特性的步骤。好在这个操作只需要在转换时做一次TRttiField.GetAttributes会返回该字段上挂的所有特性写个循环判断类型就行。这里我有句真的心里话给字段加特性的代码比平时多写了约十行的判断逻辑但对使用者而言这个功能“不用不行”——我处理过的第三方接口几乎有一半的 key 命名风格跟 Delphi 驼峰命名不一致没有映射能力封装库就只能内部项目自嗨。3.5 第五步异常设计与容错策略一个好的封装库必须把“用户看不到的异常”处理得明明白白。我在库内部统一使用自定义异常类type EJsonException class(Exception); EJsonPathNotFoundException class(EJsonException); EJsonTypeMismatchException class(EJsonException);设计规则如下Get/GetT除非显式传了“抛异常模式”否则永不抛“字段不存在”异常返回默认值0、空字符串、nilPut遇到路径非法中间层是数组时抛EJsonPathNotFoundException因为这时候“自动创建”没有意义ToObjectT如果 JSON 文本不是有效 JSON 或不是对象抛EJsonException并包含底层错误信息类型转换失败时比如abc转Integer抛EJsonTypeMismatchException并带上路径字段与原始值方便排查。还在内部实现了一个TryGet系列方法返回布尔值。这样在 UI 层做“可选的字段适配”时代码可以干净利落地走分支var AgeValue: Integer; begin if Json.TryGetInteger(data.user.age, AgeValue) then User.Age : AgeValue; end;容错策略对使用体验的影响远超我预想。原本用System.JSON写个数百行的解析服务代码里到处是if x nil换成这个封装后解析逻辑缩减到原来的三分之一而且因为默认值的存在很多“边界数据”反而能在入口处就被兜住。4. 实战演练一个真实项目的接入过程4.1 场景描述与需求定义用实际接入过一个“用户中心”接口的例子来说会更有体感。接口返回的用户信息比较复杂{ code: 0, message: ok, data: { user: { id: 10001, name: 张三, level: 2, phone: 13800138000, email: zhangsanexample.com, scores: [88, 92, 77, 95], status: active, created_at: 2023-09-15 10:30:00, address: { province: 广东, city: 深圳, street: 科技南路 } } } }需求有三块解析接口返回后把用户基础信息展示出来判断用户状态是否为active求出成绩的平均值把地址对象单独提取出来在后续操作中拼接完整字符串。4.2 使用封装库的核心代码直接上代码var Json: IJsonObject; User, Addr: IJsonObject; Scores: IJsonArray; I, Total: Integer; begin Json : TJson.Parse(ResponseBody); // ResponseBody 是 string // 1. 层层访问中间不需要判空 User : Json.Obj(data.user); if User nil then begin ShowMessage(用户名 User.Getstring(name)); ShowMessage(等级 User.Get(level, 0).ToString); end; // 2. 判断状态 if Json.Getstring(data.user.status) active then ShowMessage(用户活跃); // 3. 求总分和平均分 Scores : Json.Arr(data.user.scores); Total : 0; for I : 0 to Scores.Count - 1 do Total : Total Integer(Scores[I]); if Scores.Count 0 then ShowMessage(平均分 (Total / Scores.Count).ToString); // 4. 地址对象 Addr : Json.Obj(data.user.address); if Addr nil then ShowMessage(Addr.Get(province) Addr.Get(city) Addr.Get(street)); end;如果是原生System.JSON这个需求大概要编写六七十行代码里面到处都是TJSONValue的类型判断、强制转换、nil 检查。而我们封装后只需要二十行出头可读性一下子从“数据库行数据的拼装”进化成“读对象属性”对不熟悉 JSON 树结构的同事也能直接上手。4.3 在旧版 Delphi 上兼容的变通方案我写文章时经常被问“我还在用比较老的 Delphi 版本没有Json.Objects.pas而且System.JSON功能不全怎么办” 这里有两个变通方向版本低但仍有System.JSON比如 Delphi 10.2 或更早你仍然可以用原生的TJSONObject/TJSONArray只是部分辅助方法如TJSONObject.GetValue得自己封装一层。所以封装库的设计思路完全不受影响改的是实现细节比如需要手动写一个“根据 key 查找子节点”的辅助函数因为老版本的Values字典行为略有差异。完全没有System.JSON如果项目真的太老可以考虑引入一个轻量级第三方 JSON 语法分析器。但我不建议你自己重新写解析器而是建议先升级到带System.JSON的版本。因为在解析性能、内存管理、字符编码兼容这些方面RTL 里的工具经过了多年的改进个人很难在精力有限的前提下做出更好的方案。如果确实没办法升级那就把咱们这套封装库的“接口设计”搬到基于第三方库的实现上例如基于ISuperObject的方向接口不变、实现换掉至少业务代码不用跟着变。4.4 单元测试驱动封装库这种基础组件没有单元测试撑着我不敢上到生产环境。我用 DUnitX 给核心方法写了覆盖测试重点纳入几个用例多层路径不存在Get(a.b.c.d.e)返回 Null 而非异常自动创建节点Put(a.b.c 1)之后Obj(a.b)不为 nil类型转换失败GetInteger(data.user.name)抛EJsonTypeMismatchException对象映射嵌套 JSON 映射为嵌套对象字段数组映射JSON 数组映射为TArrayT。单元测试最大的价值不是首占总能通过而是后面调整内部实现时能一眼看出哪些行为被无意破坏。我在重构链式路径解析时因为改变了中间节点的查表顺序一下子挂了三个用例——这要是没有测试bug 会在生产环境里鬼知道什么时候蹦出来。5. 常见问题与排查技巧实录5.1 类型转换的隐性陷阱System.JSON里TJSONNumber有两种形态整数类型和浮点类型。如果你用AsDouble去取整数字段会出现25.0这种带小数点的字符串如果你用AsInt去取浮点字段又可能丢失精度。所以我的封装做了一层很“傻”但有效的判断function JsonNumberToVariant(AVal: TJSONNumber): Variant; begin if AVal.ToString.Contains(.) or AVal.ToString.Contains(E) or AVal.ToString.Contains(e) then Result : AVal.AsDouble else Result : AVal.AsInt; end;这事虽然简单却是我实际被坑后才想起来的。有一次接口返回的金额字段是12.50用GetValue(price).Value读取Delphi 给的是12.5没问题但是有一次返回12我拿着AsDouble去格式化结果界面显示了12.0用户一看就以为有 bug。诚然这是“数据呈现”层的责任但从源头保留“原本是整数还是浮点”的信息能省掉很多后续的无用争吵。5.2 处理不存在的字段不崩溃的边界情况另一个高频问题Get(data.nonexistent.subfield)如果data是存在的但nonexistent不存在怎么办很多封装库在这个问题上处理得很冷淡——直接抛异常。但我觉得返回默认值更符合“务实”的心态。原因是业务系统里接口字段可能会随着版本升级而删除或重命名你不可能因为一个可选的旧字段没了就让整个解析流程中断。唯二要留意的地方是如果调用方必须知道字段是否存在用于分支判断使用Exists方法如果调用方是在写代码的早期定位问题可以使用TryGetT系列返回 false 时打日志。我在日志里会直观记录是哪个路径差了什么if not Json.TryGetInteger(data.user.age, Age) then Logger.Warn(Missing or invalid path: data.user.age);这样在联调阶段对端接口少字段时你能第一时间看到而不是等到解析完成后数据全是默认值才发现——能省下不少开发时间。5.3 大 JSON 文本的性能问题有人担心封装层会带来不可接受的性能损耗。我实测过一个约 5MB 的 JSON 接口响应真实业务中不太常见但用来压测解析耗时大约是原生System.JSON的 1.2 倍。这个差距主要来自链式路径每次都用Split([.])切分字符串会产生临时数组泛型GetT内部先转Variant再转TValue频繁分配内存接口引用计数本身也有开销。对于绝大多数业务系统响应体几 KB 到几十 KB这个差距完全在可接受范围内。如果你要处理超大 JSON 或高频调用需要动手做两个优化将GetT改成直接读取底层TJSONValue并用TValue.FromOrdinal等专用路径转换绕开Variant对路径字符串做缓存比如把Split结果存入TDictionarystring, TArraystring不让每次调用都切一次。我自己的项目里做了第一点优化路径缓存因为收益没那么大就没额外引入复杂度。如果读者你追求极致性能也可以把IJsonObject拆成“快速便宜”的只读视图和“完整带容错”的常规视图两层但日常能用第一点优化就够了。5.4 Unicode / 中文编码错误Delphi 的System.JSON在解析 UTF-8 文本时一般不会有大问题但如果 HTTP 客户端已经按某编码把文本转换成了AnsiString再转回string时编码就有风险。实测中我发现只要在取原始响应体时规定按 UTF-8 解码再用TEncoding.UTF8转成string基本就不会乱码。封装库里我专门提供了一个Parse重载class function TJson.Parse(ABytes: TBytes): IJsonObject; begin Result : Parse(TEncoding.UTF8.GetString(ABytes)); end;这样调用方可以直接传TIdHTTP拿回来的字节数组少一步自己解码的过程乱码概率更小。5.5 调试技巧把 JSON 路径当日志字段我再分享一个很小的但真心实用的排查技巧在封装库内部打日志时在不同层级路径上做详细记录。比如在解析data.user.address.street失败时日志里写清“data存在是否”、“user存在是否”、“address存在是否”。这个信息在跟对端团队扯皮时极其有用——到底是解析器的问题还是接口返回数据本身的问题一眼就能看出。我给IJsonObject增加了一个DebugPath(const APath: string): string方法输出每个分段是否存在的状态function DebugPath(const APath: string): string; // 输出效果 // data: OK // user: OK // address: MISSING // street: MISSING这个调试工具实用性在大规模接口联调时体现得非常明显。如果你的团队里有多个人并行开发谁用了哪个接口、哪个路径断了看日志里的DebugPath比看一坨 JSON 再猜半天高得不知哪里去了。6. 设计与扩展的进阶思考6.1 防止 Someone 误用文档与命名并重坦白说封装库最怕的不是功能缺失而是别人拿你提供的 API 去做不该做的事。比如Put(a.b 1)你已经实现了自动创建中间节点有人却把整条路径当成一个 key 直接Put进对象最后{a.b: 1}和自己预期完全不一致。这个问题靠文档解释是必然形式上的缓解更有效的办法是把命名强调清楚Get(path)/Put(path, ...)表示“路径”内部用.划分Key表示“单层键”比如Put(name 张三)是直接在当前层写入。所以在接口设计上我避免重载同名的方法“有时候按单层、有时候按路径”而强制所有方法都按路径解释并且使用PutKey/GetValueByKey这类显式命名来保留单层操作能力。这样使用者拿到文档时看到PutKey就明白这是直接对当前对象操作不会盲目地把整条路径写进去。6.2 支持注释与简化要不要加入序列化忽略使用 RTTI 做序列化时最常见的问题就是有些字段是“临时计算的”根本不想写进 JSON。比如一个订单类里有TotalAmount计算属性也有Remark可空字段。我建议实现一个基础设施级别的标签type JsonIgnoreAttribute class(TCustomAttribute);在映射时如果发现字段上有JsonIgnore直接跳过。这个特性虽小却能避免很多“上线后突然发现 JSON 里多出一个不该有的字段”的尴尬问题。实际开发中我发现这个特性在“前后端接口约定”场景里特别有用前端拿着一个TotalAmount字段来展示发现值跟接口算的不一致最后排查才知道是后端把“临时计算值”也写进 JSON 了。有了JsonIgnore这类问题直接清零。6.3 是否应该把 System.JSON 替换为其他底层库这个封装库的底层是System.JSON但接口层并不依赖System.JSON的公开具体对象——至少业务代码里不需要处处as TJSONObject。这等于说如果你因为特殊需求跨平台、兼容老版本换成别的 JSON 解析库理论上只需要替换内部实现类外部编辑代码基本可以不换。这里的“理论上”要打折因为某些底层行为比如TJSONNumber.ToString的格式规则不同库之间是有细微差别的。但它确实保留了一个清晰的分层边界。6.4 一个较完整的小型封装示例附代码骨架最后给一个“精简版”的完整实现思路方便你直接照着搭骨架。篇幅有限我略掉了部分细节的实现体说明主要结构和关键决策点unit SimpleJsonWrapper; interface uses System.SysUtils, System.JSON, System.Rtti, System.Generics.Collections; type IJsonObject interface [{12345678-1234-1234-1234-1234567890AB}] function Get(const APath: string): Variant; overload; function GetT(const APath: string): T; overload; function Put(const APath: string; const AValue: Variant): IJsonObject; function Exists(const APath: string): Boolean; function Obj(const APath: string): IJsonObject; function Arr(const APath: string): IJsonArray; // 需要配合接口定义 function ToString: string; end; IJsonArray interface [{12345678-1234-1234-1234-1234567890AC}] function GetCount: Integer; function GetItem(Index: Integer): Variant; function Add(const AValue: Variant): IJsonArray; property Count: Integer read GetCount; property Items[Index: Integer]: Variant read GetItem; default; end; TJson class public class function Parse(const AText: string): IJsonObject; class function NewObject: IJsonObject; end; implementation type TJsonObjectImpl class(TInterfacedObject, IJsonObject) private FBaseValue: TJSONObject; public constructor Create(ABaseValue: TJSONObject); destructor Destroy; override; function Get(const APath: string): Variant; overload; function GetT(const APath: string): T; overload; // ... end; constructor TJsonObjectImpl.Create(ABaseValue: TJSONObject); begin inherited Create; FBaseValue : ABaseValue; end; destructor TJsonObjectImpl.Destroy; begin FBaseValue.Free; inherited Destroy; end; function TJsonObjectImpl.Get(const APath: string): Variant; begin // 前述实现 end; class function TJson.Parse(const AText: string): IJsonObject; var JsonValue: TJSONValue; begin JsonValue : TJSONObject.ParseJSONValue(AText); if JsonValue is TJSONObject then Result : TJsonObjectImpl.Create(JsonValue as TJSONObject) else begin JsonValue.Free; Result : nil; end; end; end.这个骨架代码的核心就是“接口持有实现、底层对象由实现负责释放”。你在自己的项目里完全可以先照着写一个最简版本然后逐步加Put、ToObjectT、数组支持等特性。等到这些能力积累完一个顺手而够用的封装库就出来了。7. 我踩过的坑和最终的体会真正把这个封装库推到生产环境后我的体会总结成一句话封装的价值不在代码多精美而在让调用方少踩坑。用System.JSON写解析代码一次不小心漏掉nil判断线上就多一个偶发崩溃而封装层把“不存在”变成“默认值”“类型不符”变成“带路径的异常”“内存释放”变成“作用域结束自动释放”这些细节叠加起来业务代码的健壮性提升会非常明显。当然封装也带来了最初没料到的麻烦。我印象比较深的一次是彻夜定位一个“对象序列化后多出空白节点”的问题某些字段值为Null时我没有显式过滤导致生成的 JSON 里到处都是field: null。后来在ToJson里加了一个“忽略空值”选项并按默认打开处理问题才解决。这个经历让我意识到封装库不能一股脑把所有数据都塞给用户而是要主动提供策略选择——不光是“能做什么”还包括“默认做什么”。如果你也在写 Delphi 项目而且每天跟第三方接口打交道我很建议你花一个周末把类似的封装做出来。它可以不用像我这样写全套 ToObject 反射哪怕只是提供一个带默认值的SafeGet函数就能让你的解析代码少很多if ... nil。先解决眼前最疼的那个点再一点点完善总比试图一步到位、最终烂尾强得多。最后再分享一个小技巧封装库内部可以预留一个全局回调函数允许调用方自定义“取不到字段时”的处理方式。比如有人希望严格模式下抛异常有人希望宽松模式下给默认值。我一个项目里同时存在这两种需求——解析接口给默认值解析本地配置严格校验——于是加了一个TJsonStrictMode全局开关。这个小小的扩展让库在不同模块里都适用也让我少写了两套解析逻辑。如果你也在为自己的项目做封装建议一开始就把这种“策略差异”纳入设计范围。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。