作为一个常年用 Delphi 写业务系统的老手,我太清楚原生System.JSON用起来有多别扭了。明明只是一个简单的“取字段值”操作,却要写出一长串FindValue、GetValue<string>,还得小心翼翼处理可能存在的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 ToObject<T: class>(const AJsonText: string): T; overload; class function ToObject<T: 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 Get<T>(const APath: string): T; overload; function Get<T>(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: TArray<string>; 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 类型转换细节:Get<T> 泛型取值的通用框架直接返回Variant固然方便,但业务代码往往需要强类型结果。于是我提供了泛型版本Get<T>,内部实现依赖 Delphi 的TypeInfo:class function TJsonObjectImpl.Get<T>(const APath: string): T; var V: Variant; begin V := Get(APath); // 先按变体取出 if V = Null then Result := Default(T) else Result := TValue.FromVariant(V).AsType<T>(); 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: TArray<string>; 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: TArray<string>; Level: Double; end;然后一句代码:var User: TUserInfo; begin User := TJson.ToObject<TUserInfo>('{"name":"Alice","age":25,"tags":["admin","vip"],"level":4.5}'); // 直接用 User.Name 就是 'Alice',不用做任何解析 end;ToObject<T>的实现核心是 RTTI。大致流程是:ParseJSONValue解析文本,得到TJSONObject;通过TValue获取T的类信息(TClass);遍历TRTTIType的公开字段(Published或Public)和属性;对每个字段名,在 JSON 对象中查找同名 key;用TValue将 JSON 值转为 Delphi 类型,赋值给字段。RTTI 遍历字段的代码骨架:class function TJson.ToObject<T>(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等不同类型;如果字段是对象/数组类型,要递归调用ToObject<T>或处理成TArray<T>;枚举类型要支持从字符串映射——很多接口返回的是"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;到了这个境界,就是真的“直接读”了。你甚至可以把TArray<string>也映射进去:type TUserInfo = class Tags: TArray<string>; end; var Tags: TArray<string>; begin Tags := TJson.ToObject<TUserInfo>(JsonText).Tags; end;我在实现TArray<T>的映射时,用了TValue做数组类型转换。核心代码思路:// 伪代码说明 // 1. 从 JSON 数组中逐个元素读取; // 2. 对每个元素做类型转换(整数、字符串等); // 3. 填充到动态数组 TArray<T>; // 4. 将 TArray<T> 转为 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;有了特性之后,ToObject<T>的关键代码变为:优先读特性的Name,取不到就用字段名。实现里需要增加一个查找特性的步骤。好在这个操作只需要在转换时做一次,TRttiField.GetAttributes会返回该字段上挂的所有特性,写个循环判断类型就行。这里我有句真的心里话:给字段加特性的代码比平时多写了约十行的判断逻辑,但对使用者而言这个功能“不用不行”——我处理过的第三方接口几乎有一半的 key 命名风格跟 Delphi 驼峰命名不一致,没有映射能力,封装库就只能内部项目自嗨。3.5 第五步:异常设计与容错策略一个好的封装库必须把“用户看不到的异常”处理得明明白白。我在库内部统一使用自定义异常类:type EJsonException = class(Exception); EJsonPathNotFoundException = class(EJsonException); EJsonTypeMismatchException = class(EJsonException);设计规则如下:Get/Get<T>除非显式传了“抛异常模式”,否则永不抛“字段不存在”异常,返回默认值(0、空字符串、nil);Put遇到路径非法(中间层是数组)时,抛EJsonPathNotFoundException,因为这时候“自动创建”没有意义;ToObject<T>如果 JSON 文本不是有效 JSON 或不是对象,抛EJsonException并包含底层错误信息;类型转换失败时(比如"abc"转Integer),抛EJsonTypeMismatchException,并带上路径字段与原始值,方便排查。还在内部实现了一个TryGet系列方法,返回布尔值。这样在 UI 层做“可选的字段适配”时,代码可以干净利落地走分支:var AgeValue: Integer; begin if Json.TryGet<Integer>('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": "zhangsan@example.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.Get<string>('name')); ShowMessage('等级:' + User.Get('level', 0).ToString); end; // 2. 判断状态 if Json.Get<string>('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;类型转换失败:Get<Integer>('data.user.name')抛EJsonTypeMismatchException;对象映射:嵌套 JSON 映射为嵌套对象字段;数组映射:JSON 数组映射为TArray<T>。单元测试最大的价值不是首占总能通过,而是后面调整内部实现时,能一眼看出哪些行为被无意破坏。我在重构链式路径解析时,因为改变了中间节点的查表顺序,一下子挂了三个用例——这要是没有测试,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方法;如果调用方是在写代码的早期定位问题,可以使用TryGet<T>系列,返回 false 时打日志。我在日志里会直观记录是哪个路径差了什么:if not Json.TryGet<Integer>('data.user.age', Age) then Logger.Warn('Missing or invalid path: data.user.age');这样在联调阶段,对端接口少字段时你能第一时间看到,而不是等到解析完成后数据全是默认值才发现——能省下不少开发时间。5.3 大 JSON 文本的性能问题有人担心封装层会带来不可接受的性能损耗。我实测过一个约 5MB 的 JSON 接口响应(真实业务中不太常见但用来压测),解析耗时大约是原生System.JSON的 1.2 倍。这个差距主要来自:链式路径每次都用Split(['.'])切分字符串,会产生临时数组;泛型Get<T>内部先转Variant再转TValue,频繁分配内存;接口引用计数本身也有开销。对于绝大多数业务系统(响应体几 KB 到几十 KB),这个差距完全在可接受范围内。如果你要处理超大 JSON 或高频调用,需要动手做两个优化:将Get<T>改成直接读取底层TJSONValue并用TValue.FromOrdinal等专用路径转换,绕开Variant;对路径字符串做缓存,比如把Split结果存入TDictionary<string, TArray<string>>,不让每次调用都切一次。我自己的项目里做了第一点优化,路径缓存因为收益没那么大,就没额外引入复杂度。如果读者你追求极致性能,也可以把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 Get<T>(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 Get<T>(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、ToObject<T>、数组支持等特性。等到这些能力积累完,一个顺手而够用的封装库就出来了。7. 我踩过的坑,和最终的体会真正把这个封装库推到生产环境后,我的体会总结成一句话:封装的价值不在代码多精美,而在让调用方少踩坑。用System.JSON写解析代码,一次不小心漏掉nil判断,线上就多一个偶发崩溃;而封装层把“不存在”变成“默认值”,“类型不符”变成“带路径的异常”,“内存释放”变成“作用域结束自动释放”,这些细节叠加起来,业务代码的健壮性提升会非常明显。当然,封装也带来了最初没料到的麻烦。我印象比较深的一次是彻夜定位一个“对象序列化后多出空白节点”的问题:某些字段值为Null时,我没有显式过滤,导致生成的 JSON 里到处都是"field": null。后来在ToJson里加了一个“忽略空值”选项,并按默认打开处理,问题才解决。这个经历让我意识到,封装库不能一股脑把所有数据都塞给用户,而是要主动提供策略选择——不光是“能做什么”,还包括“默认做什么”。如果你也在写 Delphi 项目,而且每天跟第三方接口打交道,我很建议你花一个周末把类似的封装做出来。它可以不用像我这样写全套 ToObject 反射,哪怕只是提供一个带默认值的SafeGet函数,就能让你的解析代码少很多if ... <> nil。先解决眼前最疼的那个点,再一点点完善,总比试图一步到位、最终烂尾强得多。最后再分享一个小技巧:封装库内部可以预留一个全局回调函数,允许调用方自定义“取不到字段时”的处理方式。比如有人希望严格模式下抛异常,有人希望宽松模式下给默认值。我一个项目里同时存在这两种需求——解析接口给默认值,解析本地配置严格校验——于是加了一个TJsonStrictMode全局开关。这个小小的扩展,让库在不同模块里都适用,也让我少写了两套解析逻辑。如果你也在为自己的项目做封装,建议一开始就把这种“策略差异”纳入设计范围。
作为一个常年用 Delphi 写业务系统的老手,我太清楚原生System.JSON用起来有多别扭了。明明只是一个简单的“取字段值”操作,却要写出一长串FindValue、GetValue<string>,还得小心翼翼处理可能存在的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 ToObject<T: class>(const AJsonText: string): T; overload; class function ToObject<T: 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 Get<T>(const APath: string): T; overload; function Get<T>(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: TArray<string>; 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 类型转换细节:Get<T> 泛型取值的通用框架直接返回Variant固然方便,但业务代码往往需要强类型结果。于是我提供了泛型版本Get<T>,内部实现依赖 Delphi 的TypeInfo:class function TJsonObjectImpl.Get<T>(const APath: string): T; var V: Variant; begin V := Get(APath); // 先按变体取出 if V = Null then Result := Default(T) else Result := TValue.FromVariant(V).AsType<T>(); 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: TArray<string>; 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: TArray<string>; Level: Double; end;然后一句代码:var User: TUserInfo; begin User := TJson.ToObject<TUserInfo>('{"name":"Alice","age":25,"tags":["admin","vip"],"level":4.5}'); // 直接用 User.Name 就是 'Alice',不用做任何解析 end;ToObject<T>的实现核心是 RTTI。大致流程是:ParseJSONValue解析文本,得到TJSONObject;通过TValue获取T的类信息(TClass);遍历TRTTIType的公开字段(Published或Public)和属性;对每个字段名,在 JSON 对象中查找同名 key;用TValue将 JSON 值转为 Delphi 类型,赋值给字段。RTTI 遍历字段的代码骨架:class function TJson.ToObject<T>(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等不同类型;如果字段是对象/数组类型,要递归调用ToObject<T>或处理成TArray<T>;枚举类型要支持从字符串映射——很多接口返回的是"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;到了这个境界,就是真的“直接读”了。你甚至可以把TArray<string>也映射进去:type TUserInfo = class Tags: TArray<string>; end; var Tags: TArray<string>; begin Tags := TJson.ToObject<TUserInfo>(JsonText).Tags; end;我在实现TArray<T>的映射时,用了TValue做数组类型转换。核心代码思路:// 伪代码说明 // 1. 从 JSON 数组中逐个元素读取; // 2. 对每个元素做类型转换(整数、字符串等); // 3. 填充到动态数组 TArray<T>; // 4. 将 TArray<T> 转为 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;有了特性之后,ToObject<T>的关键代码变为:优先读特性的Name,取不到就用字段名。实现里需要增加一个查找特性的步骤。好在这个操作只需要在转换时做一次,TRttiField.GetAttributes会返回该字段上挂的所有特性,写个循环判断类型就行。这里我有句真的心里话:给字段加特性的代码比平时多写了约十行的判断逻辑,但对使用者而言这个功能“不用不行”——我处理过的第三方接口几乎有一半的 key 命名风格跟 Delphi 驼峰命名不一致,没有映射能力,封装库就只能内部项目自嗨。3.5 第五步:异常设计与容错策略一个好的封装库必须把“用户看不到的异常”处理得明明白白。我在库内部统一使用自定义异常类:type EJsonException = class(Exception); EJsonPathNotFoundException = class(EJsonException); EJsonTypeMismatchException = class(EJsonException);设计规则如下:Get/Get<T>除非显式传了“抛异常模式”,否则永不抛“字段不存在”异常,返回默认值(0、空字符串、nil);Put遇到路径非法(中间层是数组)时,抛EJsonPathNotFoundException,因为这时候“自动创建”没有意义;ToObject<T>如果 JSON 文本不是有效 JSON 或不是对象,抛EJsonException并包含底层错误信息;类型转换失败时(比如"abc"转Integer),抛EJsonTypeMismatchException,并带上路径字段与原始值,方便排查。还在内部实现了一个TryGet系列方法,返回布尔值。这样在 UI 层做“可选的字段适配”时,代码可以干净利落地走分支:var AgeValue: Integer; begin if Json.TryGet<Integer>('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": "zhangsan@example.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.Get<string>('name')); ShowMessage('等级:' + User.Get('level', 0).ToString); end; // 2. 判断状态 if Json.Get<string>('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;类型转换失败:Get<Integer>('data.user.name')抛EJsonTypeMismatchException;对象映射:嵌套 JSON 映射为嵌套对象字段;数组映射:JSON 数组映射为TArray<T>。单元测试最大的价值不是首占总能通过,而是后面调整内部实现时,能一眼看出哪些行为被无意破坏。我在重构链式路径解析时,因为改变了中间节点的查表顺序,一下子挂了三个用例——这要是没有测试,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方法;如果调用方是在写代码的早期定位问题,可以使用TryGet<T>系列,返回 false 时打日志。我在日志里会直观记录是哪个路径差了什么:if not Json.TryGet<Integer>('data.user.age', Age) then Logger.Warn('Missing or invalid path: data.user.age');这样在联调阶段,对端接口少字段时你能第一时间看到,而不是等到解析完成后数据全是默认值才发现——能省下不少开发时间。5.3 大 JSON 文本的性能问题有人担心封装层会带来不可接受的性能损耗。我实测过一个约 5MB 的 JSON 接口响应(真实业务中不太常见但用来压测),解析耗时大约是原生System.JSON的 1.2 倍。这个差距主要来自:链式路径每次都用Split(['.'])切分字符串,会产生临时数组;泛型Get<T>内部先转Variant再转TValue,频繁分配内存;接口引用计数本身也有开销。对于绝大多数业务系统(响应体几 KB 到几十 KB),这个差距完全在可接受范围内。如果你要处理超大 JSON 或高频调用,需要动手做两个优化:将Get<T>改成直接读取底层TJSONValue并用TValue.FromOrdinal等专用路径转换,绕开Variant;对路径字符串做缓存,比如把Split结果存入TDictionary<string, TArray<string>>,不让每次调用都切一次。我自己的项目里做了第一点优化,路径缓存因为收益没那么大,就没额外引入复杂度。如果读者你追求极致性能,也可以把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 Get<T>(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 Get<T>(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、ToObject<T>、数组支持等特性。等到这些能力积累完,一个顺手而够用的封装库就出来了。7. 我踩过的坑,和最终的体会真正把这个封装库推到生产环境后,我的体会总结成一句话:封装的价值不在代码多精美,而在让调用方少踩坑。用System.JSON写解析代码,一次不小心漏掉nil判断,线上就多一个偶发崩溃;而封装层把“不存在”变成“默认值”,“类型不符”变成“带路径的异常”,“内存释放”变成“作用域结束自动释放”,这些细节叠加起来,业务代码的健壮性提升会非常明显。当然,封装也带来了最初没料到的麻烦。我印象比较深的一次是彻夜定位一个“对象序列化后多出空白节点”的问题:某些字段值为Null时,我没有显式过滤,导致生成的 JSON 里到处都是"field": null。后来在ToJson里加了一个“忽略空值”选项,并按默认打开处理,问题才解决。这个经历让我意识到,封装库不能一股脑把所有数据都塞给用户,而是要主动提供策略选择——不光是“能做什么”,还包括“默认做什么”。如果你也在写 Delphi 项目,而且每天跟第三方接口打交道,我很建议你花一个周末把类似的封装做出来。它可以不用像我这样写全套 ToObject 反射,哪怕只是提供一个带默认值的SafeGet函数,就能让你的解析代码少很多if ... <> nil。先解决眼前最疼的那个点,再一点点完善,总比试图一步到位、最终烂尾强得多。最后再分享一个小技巧:封装库内部可以预留一个全局回调函数,允许调用方自定义“取不到字段时”的处理方式。比如有人希望严格模式下抛异常,有人希望宽松模式下给默认值。我一个项目里同时存在这两种需求——解析接口给默认值,解析本地配置严格校验——于是加了一个TJsonStrictMode全局开关。这个小小的扩展,让库在不同模块里都适用,也让我少写了两套解析逻辑。如果你也在为自己的项目做封装,建议一开始就把这种“策略差异”纳入设计范围。