☰
Gson 与 ProGuard/R8 压缩混淆共存验证:test-shrinker 模块集成测试全解析
2026/10/1 16:49:06 网站建设 项目流程
  • 后端
  • 序列化

【免费下载链接】gson

A Java serialization/deserialization library to convert Java Objects into JSON and back

项目地址:https://gitcode.com/gh_mirrors/gs/gson
点击查看免费下载

Gson 依赖反射实现 JSON 与 Java 对象互转,而 ProGuard、R8 这类代码压缩与混淆工具恰恰会删除未引用代码、重命名类与字段、甚至改写构造逻辑,两者天然存在冲突。本仓库中的 test-shrinker 就是为此设立的独立 Maven 模块:它在构建阶段真实地用 ProGuard 与 R8 压缩、混淆一组测试程序,再通过集成测试运行压缩产物,验证 Gson 在"被压缩后的代码"中依然能正确序列化与反序列化。读完本文,你将掌握该模块的构建流水线、三份 ProGuard/R8 规则文件的逐条语义、Gson 自带通用规则 gson.pro 的作用边界,以及如何在自己的应用里排查"R8 下 Gson 反序列化失败"这类典型问题。

模块定位:为什么需要一个专门验证压缩场景的测试模块

根据 test-shrinker/README.md 的说明,这是一个 Maven 集成测试模块,目的是检查 Gson 在与 ProGuard 或 R8 等代码压缩/混淆工具组合使用时的行为是否正确。其核心设计包含两条硬性约束:

  • 被压缩的代码放在src/main/java,且不应包含任何重要断言。原因在于代码压缩工具可能以意外方式改动这些断言本身(例如把assertTrue之类的调用折叠、内联或删除),导致断言结果失真。测试的"结论判定"必须全部放到src/test/java中,由测试代码去执行压缩后的 JAR 并核对输出。
  • 压缩工具在 Maven 构建期间执行。因此直接从 IDE 运行集成测试往往不可行,或者拿到的是上一次构建的过期产物。README 明确提示:先执行mvn clean verify,再尝试从 IDE 运行集成测试。

从模块命名Test: Code shrinking (ProGuard / R8)(见 test-shrinker/pom.xml)也可以确认它的定位:这是一个测试模块(父 POM 属性gson.isTestModule=true),并不产出任何可发布的库,而是作为 Gson 主库质量的"守卫"存在。

构建流水线:一次mvn clean verify里发生了什么

test-shrinker/pom.xml 完整定义了构建期的四段式流水线:编译 → ProGuard 压缩 → R8 压缩 → Failsafe 集成测试。依赖方面,该模块依赖当前仓库的gson主模块(版本跟随父 POM 的2.14.1-SNAPSHOT)、JUnit 与 Google Truth 断言库;同时由于 R8 目前只发布在 Google Maven 仓库,POM 中额外声明了maven.google.com作为插件仓库。

第一步:ProGuard 处理(proguard-maven-plugin)

POM 使用com.github.wvengen:proguard-maven-plugin(版本 2.7.0),关键配置如下:

<executions> <execution> <phase>package</phase> <goals><goal>proguard</goal></goals> </execution> </executions> <dependencies> <!-- 将 ProGuard 升级到插件默认版本之上 --> <dependency> <groupId>com.guardsquare</groupId> <artifactId>proguard-base</artifactId> <version>7.9.1</version> </dependency> <dependency> <groupId>com.guardsquare</groupId> <artifactId>proguard-core</artifactId> <version>9.3.2</version> </dependency> </dependencies> <configuration> <obfuscate>true</obfuscate> <proguardInclude>${project.basedir}/proguard.pro</proguardInclude> <options> <option>-include</option> <option>${project.basedir}/../gson/src/main/resources/META-INF/proguard/gson.pro</option> </options> <libs> <lib>${java.home}/jmods/java.base.jmod</lib> <lib>${java.home}/jmods/java.sql.jmod</lib> <!-- Gson 可选 SQL 类型支持 --> <lib>${java.home}/jmods/java.compiler.jmod</lib> <!-- Error Prone 注解传递依赖 --> </libs> <includeDependencyInjar>true</includeDependencyInjar> <outjar>proguard-output.jar</outjar> </configuration>

值得注意的细节:

  • 显式-include了 Gson 自带的 gson.pro 规则文件。POM 注释直言这是一个"hacky solution":目前只有 ProGuard 的 Android 插件会自动考虑库内打包的规则文件,普通 ProGuard 不会,因此这里手工引入;而 R8 始终会自动识别该文件。
  • <libs>指向 JDK 的.jmod文件(java.base.jmod、java.sql.jmod、java.compiler.jmod),用作 ProGuard 解析 JDK 类的库引用;这也解释了为何 JDK 25+ 且缺失 jmod 文件的环境需要特殊处理(见下文 profile)。
  • 产物输出为target/proguard-output.jar,这是集成测试要验证的 ProGuard 压缩 JAR。

第二步:R8 处理(maven-shade-plugin + exec-maven-plugin)

R8 目前没有成熟的 Maven 插件,POM 采用两步组合拳:

  1. maven-shade-plugin(3.6.2)在package阶段把模块 JAR 与其依赖(包括 Gson)打成单个带依赖的 fat JAR,替换主 JAR(shadedArtifactAttached=false),并排除重复的META-INF/MANIFEST.MF与module-info.class。
  2. exec-maven-plugin(3.6.3)在package阶段直接以com.android.tools.r8.R8为主类运行 R8(依赖版本 9.4.14,声明在插件<dependencies>中,includePluginDependencies=true使其进入运行类路径),参数如下:
<arguments> <argument>--release</argument> <!-- 生成 Java class 文件而非 Android DEX 文件 --> <argument>--classfile</argument> <argument>--lib</argument><argument>${java.home}</argument> <argument>--pg-conf</argument><argument>${project.basedir}/r8.pro</argument> <!-- 生成 mapping 文件,便于调试测试失败 --> <argument>--pg-map-output</argument> <argument>${project.build.directory}/r8_map.txt</argument> <argument>--output</argument> <argument>${project.build.directory}/r8-output.jar</argument> <argument>${project.build.directory}/${project.build.finalName}.jar</argument> </arguments>

POM 注释特别解释了--classfile的意义:R8 默认输出 Android DEX 字节码,这里指定--classfile让它输出普通 Java class 文件;同时由于没有传--pg-compat参数,R8 运行在所谓"full mode"(完整模式)下,优化比 ProGuard 激进得多——这正是下文大量规则差异的根源。产物为target/r8-output.jar。

第三步:Failsafe 集成测试

maven-failsafe-plugin绑定integration-test与verify两个 goal,负责执行 ShrinkingIT 这类以IT结尾的测试类,验证两个压缩 JAR 的实际行为。

第四步:JDK 25+ 的降级 Profile

POM 末尾定义了JDK25-proguardprofile:当运行 JDK 版本 ≥ 25 且${java.home}/jmods/java.base.jmod缺失时(JDK 25+ 不再随 JDK 提供 jmod 文件),自动跳过 ProGuard 插件与全部集成测试(skipITs=true),因为 ProGuard 依赖 jmod 文件才能解析 JDK 类。该 profile 在注释中被标注为临时的,等待 Guardsquare 相关 issue 解决后移除。

规则文件三件套:common.pro / proguard.pro / r8.pro

模块根目录下有三份规则文件,职责分工明确:common.pro 存放 ProGuard 与 R8 公用的规则,proguard.pro 与 r8.pro 分别存放各自特有规则。

common.pro:两者共用的基础规则

common.pro 开头的注释划定了边界:这里只放该集成测试自身需要的规则;对全体 Gson 用户通用的规则不应写在这里,而应放在 Gson 的META-INF/proguard下(即 gson.pro)。其具体内容:

-allowaccessmodification -dontusemixedcaseclassnames # Windows 上混合大小写类名可能引发问题 -dontnote module-info,jdk.internal.** # 忽略关于重复 JDK 类的提示 # 保留测试入口点 -keep class com.example.Main { public static void runTests(...); } -keep class com.example.NoSerializedNameMain { public static java.lang.String runTestNoArgsConstructor(); public static java.lang.String runTestNoJdkUnsafe(); public static java.lang.String runTestHasArgsConstructor(); } # 保留无注解但应当保留的字段 -keepclassmembers class com.example.ClassWithNamedFields { !transient <fields>; } -keepclassmembernames class com.example.ClassWithExposeAnnotation { <fields>; } -keepclassmembernames class com.example.ClassWithJsonAdapterAnnotation { ** f; } -keepclassmembernames class com.example.ClassWithVersionAnnotations { <fields>; } # 保留类名,以便压缩后通过反射检查该类是否仍然存在 -keepnames class com.example.UnusedClass

关键点解读:

  • -keep与-keepclassmembers的区别:-keep连类带成员一起保留;-keepclassmembers只保留指定成员,允许压缩器删除类本身;-keepclassmembernames更进一步,只保留成员名字而不阻止压缩/优化,适用于"类会被保留、成员会被重命名,但必须保留字段名"的场景。
  • -keepnames class com.example.UnusedClass的目的是在压缩后还能通过类名反射查到该类,供 ShrinkingIT 的testUnusedClassRemoved反向验证"未被 keep 的未使用类是否被删除"。
  • -keepclassmembers class com.example.ClassWithNamedFields { !transient <fields>; }中的!transient是 ProGuard 通配符语法,表示"排除 transient 修饰的字段"。

proguard.pro:ProGuard 特有规则

proguard.pro 只有一小段:

-include common.pro # 与 R8 不同,ProGuard 不会执行使类变得抽象的激进优化, # 因此反序列化只需保留字段名即可 -keepclassmembernames class com.example.NoSerializedNameMain$TestClassNoArgsConstructor { <fields>; } -keepclassmembernames class com.example.NoSerializedNameMain$TestClassNotAbstract { <fields>; } -keepclassmembernames class com.example.NoSerializedNameMain$TestClassHasArgsConstructor { <fields>; }

其注释点明了 ProGuard 与 R8 在优化策略上的核心差异:ProGuard 不会把类优化成抽象类,所以在"未使用@SerializedName的类"(即不被 Gson 默认规则覆盖的类)场景下,只需用-keepclassmembernames保留字段名,Gson 就能依靠反射按原名读写字段完成反序列化。

r8.pro:R8 full mode 特有规则

test-shrinker/r8.pro 则要为 R8 full mode 的激进优化补上额外规则:

-include common.pro # full mode 下,带泛型参数的类需要 keep 规则保留泛型签名 -keep,allowshrinking,allowoptimization,allowobfuscation,allowaccessmodification class com.example.GenericClasses$GenericClass -keep,allowshrinking,allowoptimization,allowobfuscation,allowaccessmodification class com.example.GenericClasses$GenericUsingGenericClass # 不混淆类名,以便在异常消息中检查 -keep,allowshrinking,allowoptimization class com.example.NoSerializedNameMain$TestClassNoArgsConstructor -keep,allowshrinking,allowoptimization class com.example.NoSerializedNameMain$TestClassHasArgsConstructor # 该规则有副作用:R8 仍会移除无参构造器,但不会使类抽象化 -keep class com.example.NoSerializedNameMain$TestClassNotAbstract { @com.google.gson.annotations.SerializedName <fields>; } # 保留代码中未显式使用的枚举常量 -keepclassmembers class com.example.EnumClass { ** SECOND; }

这些规则背后的成因:

  • 泛型签名:R8 full mode 中,-keepattributes只有与-keep匹配的类/字段配合才会生效;GenericClass与GenericUsingGenericClass带类型参数T,若泛型签名(Signature 属性)被抹除,Gson 通过TypeToken解析具体类型(如GenericClass<DummyClass>)就会失败,因此必须用-keep,...规则保住它们(allowobfuscation等标志表示"只要保留类/签名,允许继续做缩小、优化、混淆、访问修饰符调整")。
  • 保留类名用于异常断言:TestClassNoArgsConstructor与TestClassHasArgsConstructor被-keep,allowshrinking,allowoptimization保留类名,是为了让 R8 压缩后 Gson 抛出的异常消息中仍能出现完整类名,从而让集成测试可以按精确消息断言(见下文"无 @SerializedName 的典型失败场景")。
  • 枚举常量:SECOND在代码中从不显式引用(反序列化时以 JSON 字符串"SECOND"触发),若不 keep,R8 会因"未被引用"而删除该常量,导致反序列化枚举失败。

Gson 自带的通用规则:gson.pro

上文多次提到的 gson.pro 是 Gson 库自身随包分发的规则文件,对所有 Gson 用户生效(R8 自动识别,ProGuard 经本模块 hacky 方式显式 include)。其注释明确两点:规则是增量式的,不包含任何与 Gson 无关的内容(如全局禁止混淆),且并非完整方案,用户仍需为自己的类补充规则。它覆盖四类场景:

  1. 保留元数据:-keepattributes Signature(类型解析所需泛型签名)+-keepattributes RuntimeVisibleAnnotations,AnnotationDefault(Gson 注解需在运行时可见)。
  2. TypeToken 相关:-keep,allowobfuscation class com.google.gson.reflect.TypeToken以及-keep,allowobfuscation class * extends com.google.gson.reflect.TypeToken,保证匿名/具名TypeToken子类的泛型签名不被抹除——这也是 R8 full mode 下泛型反序列化能工作的前提。
  3. 注解驱动的类与字段:@JsonAdapter标注的类整体 keep;@Expose、@JsonAdapter、@Since、@Until标注的字段用-keepclassmembers,allowobfuscation保留(允许混淆,因为这类用户通常会搭配@SerializedName固定 JSON 名)。
  4. 适配器类的无参构造器:TypeAdapter、TypeAdapterFactory、JsonSerializer、JsonDeserializer的实现类保留<init>(),因为@JsonAdapter默认通过无参构造实例化适配器。
  5. @SerializedName 字段及配套无参构造器:通过-if class *条件规则,只要类中存在@SerializedName字段就保留这些字段,并额外保留该类的无参构造器——这能显著缓解 R8 把无参构造器优化掉而导致的实例化失败问题。

被压缩的测试代码:src/main/java 的设计

入口与防优化技巧:Main 与 TestExecutor

Main.java 是 ProGuard/R8 压缩程序的主入口,runTests(BiConsumer<String, String>)把每个测试用例的输出(名称, 内容键值对)交给消费者,由集成测试统一校验。注释明确:不要在压缩代码里做任何重要断言,全部输出交给集成测试验证,因为压缩器可能影响断言行为。

TestExecutor.java 提供了两个关键助手:

public static void run(BiConsumer<String, String> outputConsumer, String name, Supplier<String> resultSupplier) { String result; try { result = resultSupplier.get(); } catch (Throwable t) { throw new RuntimeException("Test failed: " + name, t); } outputConsumer.accept(name, result); } /** 返回 t,但以一种(希望)能阻止压缩器简化它的方式 */ public static <T> T same(T t) { return Optional.of(t).map(v -> Optional.of(v).get()).orElseThrow(() -> new AssertionError("unreachable")); }

same()是刻意为之的"反优化"手法:它本质上就是return t,但包裹了Optional冗余代码。目的如注释所述,是让 ProGuard/R8 无法静态推断"这里传入了反射要用的Class对象",从而阻止压缩器据此把反射调用简化为直接调用或删除相关类。Main中所有toJson/fromJson辅助方法也都经过same()再传给 Gson(见 Main.java 的注释"hopefully in a way which prevents code shrinkers from understanding that reflection is used")。

Main.runTests覆盖的用例矩阵(对应 ShrinkingIT 的完整预期输出)包括:

  • TypeToken 写读:匿名new TypeToken<List<ClassWithAdapter>>(){}与TypeToken.getParameterized(...)手动构造两种方式——按需创建 TypeToken,因为泛型签名被抹除时创建会失败;
  • 命名字段 / @SerializedName:无注解字段与@SerializedName字段的读写;
  • 构造器四种形态:无参构造、有参构造、未被引用(代码中从不 new)的无参/有参构造——后者用于检验 Gson 的UnsafeAllocator等实例化路径在压缩后的可靠性;
  • 禁用 JDK Unsafe:new GsonBuilder().disableJdkUnsafe().create()场景;
  • 枚举:普通枚举与带@SerializedName的枚举;
  • 注解族:@Expose(配合excludeFieldsWithoutExposeAnnotation)、@Since/@Until(配合setVersion(1))、字段级@JsonAdapter(覆盖 TypeAdapter、TypeAdapterFactory、JsonSerializer、JsonDeserializer 四种形态);
  • 泛型:GenericClass<T>、UsingGenericClass、GenericUsingGenericClass<T>的 TypeToken 反序列化;
  • 接口实现反序列化:TypeToken<List<InterfaceWithImplementation.Implementation>>。

字段级@JsonAdapter测试类 ClassWithJsonAdapterAnnotation.java 特意设计了只注册JsonSerializer或只注册JsonDeserializer的字段,用以验证 Gson 的委托回退行为(例如 f4 只有反序列化器,序列化时回退到反射)。

无 @SerializedName 场景:NoSerializedNameMain

NoSerializedNameMain.java 覆盖了一类 Gson 用户最容易踩坑的场景:类的字段完全没有@SerializedName注解,因此不被默认 gson.pro 规则匹配。它定义三个静态嵌套测试类:

  • TestClassNoArgsConstructor:带隐式无参构造器,只有一个public String s;
  • TestClassNotAbstract:同样只有一个字段,r8.pro 中专门为它设计规则让 R8 移除无参构造器但保持类非抽象;
  • TestClassHasArgsConstructor:显式声明有参构造器,从而隐式无参构造器不存在。

三个入口方法分别对应三种反序列化路径:默认new Gson()、禁用 JDK Unsafe 的GsonBuilder、以及有参构造器(依赖 Unsafe 绕过构造器)场景。

集成测试如何验证压缩产物:ShrinkingIT

ShrinkingIT.java 是验证端,其设计有几大亮点:

参数化双 JAR 与隔离类加载

测试用@RunWith(Parameterized.class)对target/proguard-output.jar与target/r8-output.jar两个产物分别执行(@Parameters返回两个路径)。@Before阶段先检查 JAR 文件是否存在,不存在则直接fail并提示"请用mvn clean verify运行"。

执行时通过new URLClassLoader(new URL[]{jarToTest.toUri().toURL()}, null)以bootstrap class loader 为父加载器加载压缩 JAR,确保测试所用的自定义类全部来自被压缩的 JAR 而不是本测试模块的类路径依赖,从而真正验证"压缩产物"而非"未压缩代码"。

精确输出断言

test()将Main.runTests的全部输出拼接后与一段极长的期望字符串逐字节比对(ShrinkingIT.java),包括"myField": 2、adapter-1、{t=read-1}等细节,任何字段名被混淆、字段丢失、适配器失效都会导致断言失败。期望值中有一处值得注意的条件分支:

"Read: Interface implementation", // TODO: 目前只有 ProGuard 可用,R8 不行 isTestingProGuard() ? "value" : "ClassCastException",

即接口实现反序列化用例目前仅在 ProGuard 下成功,R8 会抛ClassCastException——这是 Gson 已知问题(源码 TODO 指向 google/gson#2658),测试用捕获异常并返回标记字符串的方式将其固化为"当前预期行为",而非让整条测试红掉。

无 @SerializedName 的典型失败场景

三个testNoSerializedName_*测试针对 ProGuard 与 R8 采用完全不同的断言:

  • ProGuard:三个入口均返回"value",反序列化成功;
  • R8 full mode:由于 R8 更激进的优化(如把类变成抽象类、移除无参构造器),断言抛出InvocationTargetException,且精确匹配 Gson 的异常消息:
    • Abstract classes can't be instantiated! Adjust the R8 configuration or register an InstanceCreator or a TypeAdapter for this type. Class name: com.example.NoSerializedNameMain$TestClassNoArgsConstructor(并指向 Troubleshooting.md 的 R8 章节);
    • 禁用 JDK Unsafe 时:Unable to create instance of class ...; usage of JDK Unsafe is disabled. ... adjust your R8 configuration to keep the no-args constructor of the class.

这说明 Gson 为 R8 用户内置了可诊断的错误提示:当你遇到这类异常,按提示补充 keep 规则(保留无参构造器、保留字段名),或注册InstanceCreator/TypeAdapter即可。

未使用类删除验证

testUnusedClassRemoved通过assumeFalse(isTestingProGuard())只对 R8 生效(注释说明 ProGuard 会保留未使用类),利用-keepnames保留的类名com.example.UnusedClass反射加载并断言抛出ClassNotFoundException,确认压缩器确实删除了未引用类——同时反向验证-keepnames规则没有阻止删除。

调试、维护与实操建议

从 IDE 运行的正确姿势

由于压缩发生在 Maven 构建期,README 与verifyJarExists的失败消息都指向同一结论:先在模块根目录(或仓库根目录)执行mvn clean verify,让target/proguard-output.jar与target/r8-output.jar就位,再运行ShrinkingIT。若改动了规则文件后忘记重新构建,IDE 会静默使用过期 JAR,造成"改规则无效"的假象。

失败调试:利用 mapping 文件

README 明确推荐:测试失败时,查看target目录下 ProGuard 与 R8 生成的 mapping 文件。其中 R8 的 mapping 输出路径由 POM 显式指定为target/r8_map.txt(--pg-map-output参数),ProGuard 同样会在target下产出 mapping。通过 mapping 可以查出"混淆后的类/字段名",对照断言中的原始名称,快速定位是哪条 keep 规则缺失。

脆弱性认知与维护准则

README 坦率地指出:这套测试,尤其是 R8 测试设置,可能比较脆弱;未来的 ProGuard/R8 版本可能改变行为导致测试结果不同。因此仓库给出了明确的维护指引:必要时重写测试,甚至在无法适配新版本时移除它们。这也解释了 r8.pro 中若干"副作用型"规则(如TestClassNotAbstract那条-keep会同时阻止 R8 移除无参构造器的连带效应)与JDK25-proguardprofile 的存在——它们都是与具体压缩器版本行为博弈的产物。

面向你自己的项目的实践要点

  • Gson 打包的 gson.pro 覆盖了通用场景,但正如其注释所说并不完整:没有@SerializedName的类、需要保留的特定字段/无参构造器、自定义泛型类等,仍要像本模块的 proguard.pro 与 r8.pro 那样自行补充 keep 规则;
  • R8 full mode 与 ProGuard 行为不同:泛型类要显式 keep 以保留 Signature 属性;被代码引用但不影响 Gson 反射的枚举常量、无参构造器也可能被删除,需要按需 keep;
  • 若反序列化报 "Abstract classes can't be instantiated!" 或 "Unable to create instance",优先排查 R8 是否移除了无参构造器或使类抽象化,可参考 Troubleshooting.md 中对应的 R8 小节,也可像本模块那样为类注册InstanceCreator或TypeAdapter作为替代方案。
  • 后端
  • 序列化

【免费下载链接】gson

A Java serialization/deserialization library to convert Java Objects into JSON and back

项目地址:https://gitcode.com/gh_mirrors/gs/gson
点击查看免费下载
上一篇:Zsh Codex:让AI在命令行为你写代码的终极ZSH插件
下一篇:Containerd容器镜像签名安全指南:与Cosign集成的终极防护方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询