ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从散落混乱到统一入口:一套JSON工具类封装方案的设计与难点复盘

从散落混乱到统一入口:一套JSON工具类封装方案的设计与难点复盘 写接口对接的时候我最怕的不是业务逻辑写错而是JSON这层皮反反复复出问题——今天这个接口用new Gson()解析明天那个模块自己copy了一段Fastjson后天又有人在代码里直接操作JsonObject取字段。字段一多、调用一多整个项目就成了JSON解析的重灾区。所以在经历了一次改字段名改到怀疑人生的线上事故之后我给项目落地了一套JSON工具类的统一封装。这篇文章就把这套方案完整拆开API怎么设计、核心代码怎么写、哪些增强真正值得做以及我踩过的几个坑。这篇内容主要面向Android/Java方向的开发者后端做接口处理的同学同样可以参考C、Python、JS等方向的同学也可以借鉴封装思路只是内部实现换一套库而已。我的原则很简单工具类要解决的问题不是把JSON转成对象而是让整个项目里所有的JSON行为保持一致、可控、可排查。1. 为什么项目里全是JSON解析的重复代码一次事故催生的封装先说一个真实场景。某个版本迭代中后端把一个字段名从userName改成了user_name前端三个页面分别用了三种方式解析同一个接口页面A用Gson的fromJson直接映射到UserBean页面B用Fastjson的JSONObject.parseObject然后getString(user_name)页面C干脆把响应体字符串拿过来用JSONObject一层层getJSONObject往里挖。结果就是A页面改一个字段名B页面逻辑没动但解析出来的值全为空C页面直接因为某个层级取不到值崩溃。那个晚上我翻着三个页面的代码一遍一遍确认到底还有哪里直接用了JSON库心态彻底崩了。冷静下来之后我发现根子不在某个字段改名而在于JSON处理这件事在整个项目里没有统一入口。每个开发者都有自己顺手的方式有人喜欢Gson的注解有人习惯了Fastjson的JSON.parseArray还有人根本不知道项目里已经引入了JSON库直接手写字符串拼接。一旦接口结构变化需要改动的地方散落在几十个文件里谁能保证全部改完所以封装工具类首要目标不是显得专业而是解决三个实际痛点依赖收敛整个项目只需要一个JSON序列化库其他库全部从业务代码中隔离开。以后就算要换库改一个类就够了而不是全局搜索替换。行为统一日期格式怎么处理、字段命名策略是什么、null字段序不序列化、未知字段要不要忽略这些现在由工具类说了算不依赖开发者个人习惯。异常兜底JSON解析失败是常态但如果每个调用点都自己去try-catch不仅代码丑还容易漏。统一封装后解析失败至少会走日志不会无声无息地变成null。有了这个认知后面的事情就顺理成章了设计API、选底层库、写核心实现、补增强功能。2. 动手前先把API定义清楚工具类到底要暴露什么很多人写工具类是一上来就写代码写到哪算哪最后类里堆了二十多个方法一半自己都用不上。我的习惯是先列调用场景再定接口最后才是实现。2.1 核心方法签名覆盖90%的日常需求JSON工具类最基础的能力就两个方向对象转字符串、字符串转对象。但字符串转对象在真实业务里又分三种情况转普通Bean、转JSON数组ListT、转带有泛型参数的复杂类型。所以我最终的API设计如下方法参数返回值说明toJson(Object obj)任意对象String序列化null传入返回空字符串parseObject(String json, ClassT clazz)JSON字符串、目标类型T反序列化为普通BeanparseArray(String json, ClassT clazz)JSON字符串、元素类型ListT反序列化为List元素类型通过clazz指定parse(String json, Type type)JSON字符串、TypeT最底层方法支持复杂泛型parseResponse(String json, ClassT dataClazz)JSON字符串、data类型BaseResponseT专门解析统一响应结构这里有一个关键决策不给方法重载加乱七八糟的默认值参数。比如parseObject不搞解析失败返回null和解析失败返回空对象两个版本而是统一返回null由调用方决定要不要判空。原因后面会讲。2.2 返回值策略解析失败返回什么这是封装前后最明显的差异。直接使用Gson时开发者写Gson.fromJson(json, UserBean.class)解析失败会抛JsonSyntaxException崩溃与否取决于有没有try-catch。封装之后我的默认策略是输入为null或空字符串时返回null不做解析解析过程中发生任何异常记录日志并返回null提供parseStrict系列方法这种时候才把异常往外抛给那些严格要求数据完整性的场景用。这个策略说白了就是默认宽容按需严格。接口联调阶段字段对不上是常有的事宽容模式可以保证后续流程不至于因为一个非核心字段直接崩溃而像支付回调、订单状态这类核心数据调用方可以主动选择严格模式宁可抛异常也不能静默吞掉问题。2.3 底层库为什么选Gson而不是Fastjson或Jackson可能有人会问现在新项目不是都推荐Jackson吗我承认Jackson功能强大性能也好但在Android端和中小型后端项目里Gson仍然是更稳妥的选择。我的对比结论如下维度GsonFastjsonJacksonAPI易用性注解简单TypeToken成熟上手快但API变动大功能全配置偏复杂泛型反序列化TypeToken稳定支持TypeReferenceJavaType处理略繁琐安全漏洞记录极少历史上爆出过多个反序列化漏洞相对安全但也要及时升级Android兼容体积小无依赖体积中等依赖较多包体影响大常见生产问题面反射字段名稳定getter/setter命名坑多命名策略配置不当会翻车当然这只是一个默认推荐。你完全可以把内部实现换成Jackson或Fastjson因为封装的意义恰恰在于底层库是可替换的。只要对外暴露的方法不变今天用Gson明天想换Jackson只需要改JsonUtil内部代码业务层一行都不用动。3. 核心实现一个能直接抄的JSON工具类接下来是干货部分。这个类我在Android项目里实际使用过去掉注释后不到150行但覆盖了日常开发里绝大多数JSON处理场景。3.1 类骨架与Gson实例的初始化Gson是线程安全的可以全局共享一个实例不需要每次调用都new。如果你用new GsonBuilder().create()而不是new Gson()还能顺手定制很多行为public final class JsonUtil { private static final String TAG JsonUtil; private static final Gson GSON new GsonBuilder() // 字段命名策略如果不设置默认使用Java字段名原样输出 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) // 日期统一格式避免出现默认的 Dec 3, 2024 这种反人类格式 .setDateFormat(yyyy-MM-dd HH:mm:ss) // 关闭Html转义否则 等内容会被转成 \u003c 这种 .disableHtmlEscaping() // 默认忽略反序列化时未知字段避免后端多加字段导致崩溃 .create(); private JsonUtil() { throw new UnsupportedOperationException(u cant instantiate me...); } }这里强调三个容易被忽略的点setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)如果后端接口习惯返回user_name而Java字段叫userName这个策略能在序列化和反序列化时自动转换。但它同时也会影响toJson的输出所以定策略时最好前后端一起约定别自己默默改。disableHtmlEscaping()Gson默认会把转成\u003c。如果你把JSON直接存进数据库或者拼进日志看到一坨\u003c会非常难受。这个设置开了之后输出才是人类可读的。未知字段忽略默认Gson反序列化时如果JSON里有Java类不存在的字段不会报错但为了保险我还是显式配置一下。以后后端加字段老客户端至少不会挂。3.2 序列化与基础反序列化public static String toJson(Object obj) { if (obj null) { return ; } try { return GSON.toJson(obj); } catch (Exception e) { Log.e(TAG, toJson error: e.getMessage(), e); return ; } } public static T T parseObject(String json, ClassT clazz) { if (isBlank(json)) { return null; } try { return GSON.fromJson(json, clazz); } catch (Exception e) { Log.e(TAG, parseObject error, json: json , clazz: clazz, e); return null; } }这里把catch的异常打出完整json内容是调试阶段特别有用的习惯。JSON解析报错最常见的原因就是多了一个逗号少了一个引号没有原始字符串排查全靠猜。3.3 反序列化数组TypeToken的正确打开方式数组解析是JSON工具类最容易被写错的地方。很多人图省事直接写// 错误示例 ListUserBean users GSON.fromJson(json, List.class);这句代码运行时不会立刻报错但List不是具体类型Gson没办法知道你要的是ListUserBean还是ListString结果会反序列化成ListLinkedTreeMap。等你真的去调users.get(0).getId()时才会抛ClassCastException而且崩溃栈指向的是业务代码排查半天找不到根因。正确做法是用TypeToken把泛型信息传进去public static T ListT parseArray(String json, ClassT clazz) { if (isBlank(json)) { return null; } try { Type type TypeToken.getParameterized(List.class, clazz).getType(); return GSON.fromJson(json, type); } catch (Exception e) { Log.e(TAG, parseArray error, json: json , clazz: clazz, e); return null; } }注意这里用的是TypeToken.getParameterized(List.class, clazz)而不是很多人写的new TypeTokenListT(){}。原因后面章节会单独讲这里先记住结论泛型方法内部通过匿名内部类拿到的Type往往还是泛型擦除后的类型容易留坑getParameterized能基于运行时传入的clazz精确构造ListT的真实Type。如果你的项目Gson版本比较老没有TypeToken.getParameterized可以用一个变通方案public static T ListT parseArrayOld(String json, ClassT clazz) { Type type new TypeTokenListT() {}.getType(); // 注意这里的T是擦除后的类型仍可能有问题 // 更稳妥的是让调用方直接传入Type }所以从封装第一天起我的parseArray方法签名就必须带ClassT clazz以此作为构造真实Type的依据。3.4 复杂泛型解析给调用方一条逃生通道日常开发中总有绕不开的复杂类型比如BaseResponseListOrderBean、MapString, ListItem。这种情况下固定参数签名的parseObject和parseArray都不够用所以我保留了一个接受Type的底层方法public static T T parse(String json, Type type) { if (isBlank(json)) { return null; } try { return GSON.fromJson(json, type); } catch (Exception e) { Log.e(TAG, parse error, json: json , type: type, e); return null; } }调用方按下面这种方式使用Type type new TypeTokenBaseResponseListOrderBean() {}.getType(); BaseResponseListOrderBean resp JsonUtil.parse(json, type);这种做法要求调用方在方法体外构造好Type所以不是最优雅但它是逃生通道专门应对那些通用方法覆盖不了的场景。3.5 辅助方法isBlankprivate static boolean isBlank(String str) { return str null || str.trim().length() 0; }这个判断是必须的。Gson对空字符串的容忍度很低GSON.fromJson(, UserBean.class)会直接抛JsonSyntaxException而实际后端服务在异常时可能真的会返回空字符串。统一在入口拦掉能省下大量无谓的catch。4. 让工具类真正好用的四个增强设计如果工具类只有toJson和parseObject那它顶多算包了一层壳。封装的价值应该体现在更贴近业务场景的增强能力上。4.1 空字符串和null的兜底处理开发中最脏的数据不是格式错误而是有时候有值有时候为空有时候还是个空格。后端服务返回这样的数据很常见{ name: 张三, avatar: , remark: null }如果调用方拿到toString()结果发现是空的或者parseObject返回null后没有判空很容易产生NPE。我在增强部分做了一件小事所有解析入口统一走isBlank检查凡是空串一律返回null。这样业务方只需要记住一条规则凡是解析失败结果都是null凡是null要么自己兜底要么走严格模式。4.2 JSON树中安全取值的便捷方法不是所有场景都需要把JSON映射成Bean。有时候我们只是想知道某个字段的值比如判断一下错误码、取一个token。这种摸一下就走的需求如果也要定义完整DTO就太重了。所以我在工具类里加了基于JsonElement的安全取值方法public static String getString(String json, String key) { try { JsonElement element JsonParser.parseString(json); if (element.isJsonObject() element.getAsJsonObject().has(key)) { JsonElement value element.getAsJsonObject().get(key); if (!value.isJsonNull()) { return value.getAsString(); } } } catch (Exception e) { Log.e(TAG, getString error, json: json , key: key, e); } return ; } public static int getInt(String json, String key) { String value getString(json, key); try { return Integer.parseInt(value); } catch (NumberFormatException e) { return 0; } }注意一个细节getInt的默认值是0。有些场景0是非法值比如订单状态这时候业务方要自己判断。如果觉得0不够安全可以增加一个getInt(String json, String key, int defaultValue)重载把兜底权交还给调用方。4.3 统一响应结构的配套解析接口封装场景下后端最常见的返回结构是{code:0,msg:ok,data:{...}}这种三段式。我在项目里定义了一个泛型基类public class BaseResponseT { private int code; private String msg; private T data; public boolean isSuccess() { return code 0; } // getter/setter 省略 }然后在JsonUtil里提供一个专用方法public static T BaseResponseT parseResponse(String json, ClassT dataClazz) { if (isBlank(json)) { return null; } try { Type type TypeToken.getParameterized(BaseResponse.class, dataClazz).getType(); return GSON.fromJson(json, type); } catch (Exception e) { Log.e(TAG, parseResponse error, json: json , dataClazz: dataClazz, e); return null; } }这方法的意义在于调用方不再需要自己拼TypeToken了。比如解析用户信息接口BaseResponseUserBean resp JsonUtil.parseResponse(json, UserBean.class); if (resp ! null resp.isSuccess()) { UserBean user resp.getData(); }整个调用链非常干净也统一了所有接口的错误处理模式。再配合一个ResponseCode常量类后端改了错误码前端只需要改一处映射而不是每个页面各写一套判断逻辑。4.4 日志打印与调试模式开关生产环境里JSON工具类最怕两件事一是解析失败后信息被吞掉二是日志里把敏感信息全打出来。我加了一个简易开关private static boolean debug true; public static void setDebug(boolean enable) { debug enable; } private static void log(String msg) { if (debug) { Log.d(TAG, msg); } }在parseObject这类方法的catch块里把完整json内容打到日志联调阶段一开debug就能看到问题上线前通过setDebug(false)关掉减少日志量也避免个人信息泄漏。可能有人觉得这功能太简单没必要写但实际项目中这个开关帮我省了至少三次联调扯皮。5. 复盘踩过的坑从解析失败到release包字段全null封装工具类不是写完就万事大吉真正值钱的是那些你不踩一次永远不知道的坑。这节我挑四个最有代表性的按现象—排查—根因—修复的完整链路讲。5.1 泛型擦除导致List解析成LinkedTreeMap现象新同事在代码里直接写了Gson().fromJson(str, List.class)没有走JsonUtil。运行时偶尔闪退崩溃栈是ClassCastException: java.util.LinkedTreeMap cannot be cast to UserBean。排查崩在业务代码里访问user.getId()那行表面上看是UserBean转型失败但日志里没有任何JSON解析异常。我让他打印出list.get(0).getClass()结果是LinkedTreeMap才确定根因是Gson根本不知道List里装的是什么。根因List.class是裸类型泛型参数在运行时已经被擦除Gson只能按Object处理内部用LinkedTreeMap兜底。这就是泛型擦除的经典表现。修复统一走JsonUtil.parseArray(json, UserBean.class)方法内部用TypeToken.getParameterized构造带泛型的Type问题立刻消失。顺便把那个同事代码里所有直接使用Gson/Fastjson的地方全部review了一遍。这个坑让我意识到工具类不仅要提供正确方法还要在项目里强制约定不允许直接使用JSON库。光靠代码规范文档没有用最好在Code Review阶段就直接打回。5.2 Java Bean大写开头字段变小写绕开统一封装后的连锁事故现象老模块里有个类字段定义是private String pName;前端调用接口时发现后端收到了pname而不是pName接口对接失败。当时第一个被怀疑的就是JsonUtil因为Gson是项目主序列化库。排查我打印了JsonUtil序列化该对象的结果输出的字段名是pName完全正常。再查那个老模块发现它没有走JsonUtil而是自己new了一个ObjectMapperJackson用默认配置做的序列化。根因Jackson在默认配置下会按getter/setter方法名推断属性名。对于getPName()这个getterJavaBeans的Introspector.decapitalize规则规定当属性名的第二个字符是大写时首字母保持大写所以推断出来的属性名是PName而某些配置链路上再经过一次驼峰处理输出就变成了pname。Gson默认走反射读取字段值字段名pName是什么就输出什么不会做这种推导。修复让老模块的所有序列化行为也收敛到JsonUtil同时在涉及这类缩写字段的DTO上显式注明了SerializedName(pName)彻底绕过命名推断逻辑。这个事故之后我在Code Review规范里加了一条项目内不允许直接new任何JSON库实例所有JSON行为必须经由JsonUtil。5.3 非静态内部类导致Gson无法实例化现象服务端返回一个订单列表客户端定义了OrderResponse里面有个ListOrderItemOrderItem是OrderResponse的内部类。解析时抛异常Unable to invoke no-args constructor for OrderResponse$OrderItem。排查我先看崩溃栈明确指向OrderItem的构造器。一开始以为是OrderItem缺少无参构造检查后发现它明明有。后来查了Gson文档才意识到问题出在内部类三个字上。根因Java的非静态内部类会隐式持有外部类引用所以即使写了无参构造底层仍然无法直接new出来。Gson通过反射实例化时需要访问这个隐式引用会直接失败。这是很多把DTO定义成内部类的项目常见的坑。修复把OrderItem从OrderResponse里挪出来定义成独立的public static class或者直接放在单独文件中。修改之后解析恢复正常。这条经验我在代码注释里专门写了一行DTO一律定义成static类或独立类不要用非静态内部类。5.4 ProGuard混淆后release包解析出来全是null现象Debug包一切正常打release包后接口数据JSON能拿到但parseObject出来的对象所有字段都是null没有崩溃也没有日志。排查第一反应是Gson序列化字段名被混淆了。我打开release mapping文件发现model类里的字段全被重命名成了a、b、c这种而JSON里的key还是userName。Gson反射读取字段名时拿到的已经是混淆后的短名自然匹配不上。根因ProGuard/R8在混淆阶段把字段名改了没有保留模型类的字段名和泛型签名。修复在混淆配置里加三条规则-keepattributes Signature -keepattributes *Annotation* # 保留所有model对象及其字段名 -keep class com.example.project.model.** { *; }这里尤其要注意Signature属性。TypeToken解析泛型依赖Signature如果被抹掉parseArray拿到的就不是ListUserBean而是裸List又会走回5.1那个坑。这个坑属于Android专属但后端同学如果做了代码混淆或用了GraalVM这类工具思路是类似的凡是反射依赖的类必须显式保留字段名。6. 封装的边界与后续扩展什么该做什么不该做封装工具类有个常见的反面案例什么东西都往工具类里塞最后变成一个万能类又臭又长。我自己也经历过这个阶段后来总结出几条边界原则。6.1 工具类不管业务只管数据形态JsonUtil只做对象跟JSON字符串之间的形态转换不做业务判断。比如BaseResponse.isSuccess()判断code 0这个逻辑我放在响应类里而不是工具类里。业务方判断这个接口是否成功应该依赖resp.isSuccess()而不是在工具类里写一堆跟具体接口相关的状态码枚举。工具类一旦被业务耦合后续复用和迁移都会变得很痛苦。6.2 多端视角同样思路在C、Python、JS里怎么落地热搜里出现了很多其他语言的JSON相关话题比如C保存JSON到SQLite、Python处理JSON、前端用fetch拦截器还是axios拦截器。这些场景虽然语言不同但封装思路完全一致C可以把nlohmann/json这类库包在一个JsonUtil命名空间里处理类型转换和默认值降低业务代码里直接调用json::parse的频率Python写一个jsonutil.py统一封装json.loads/dumps把datetime、Decimal这类非默认类型的转换集中处理JS/TS在前端项目里封装一个json.ts统一处理响应拦截器的类型断言和容错这其实跟fetch拦截器好还是axios拦截器好问题背后的动机一致——你不想让每个页面自己去处理响应体里的类型细节。轮子可以不同但收敛、统一、可替换这六个字是通用的。6.3 要不要引入注解驱动什么时候该升级如果项目里字段命名频繁变动或者一个字段在后端不同接口里有不同名字光靠命名策略就不够了这时候可以配合SerializedName注解。但要注意注解是Gson的API推广注解等于在业务代码里再引入一层对底层库的依赖这和封装的初衷有点冲突。我的折中方案是通用命名策略能解决的优先靠策略只有极少数特殊字段才允许在DTO上用注解。如果哪天项目里注解满天飞说明后端接口设计的字段风格太乱应该先推动后端统一而不是在客户端逐个打补丁。关于是否要从Gson切换到Kotlinx.serialization或者Moshi我的看法是没有必须升级的理由就不要升级。只要JsonUtil内部接口稳定换底层实现的成本其实很低这也是当初坚持封装的最大红利。最后再分享一个我在实际项目里验证过的小技巧在JsonUtil里加一个静态方法printJson(Object obj)内部调用toJson后格式化输出方便开发阶段打印请求参数和响应结果。省得每次调试都手动拼日志。封装这件事本身不复杂但做好了之后真的能少熬很多个排查JSON问题的夜。
返回列表