|
|
|
|
公众号矩阵

Android 避坑指南:Gson 又搞了个坑!

这是我之前项目同学遇到的一个问题,现实代码比较复杂,现在我将尽可能简单的描述这个问题,并且内容重心会放在探索为什么会出现这样的情况以及后续监控。

作者:鸿洋来源:掘金|2021-02-01 17:00

这是我之前项目同学遇到的一个问题,现实代码比较复杂,现在我将尽可能简单的描述这个问题,并且内容重心会放在探索为什么会出现这样的情况以及后续监控。

一、问题的起源

先看一个非常简单的model类Boy:

  1. public class Boy { 
  2.  
  3.     public String boyName; 
  4.     public Girl girl; 
  5.  
  6.     public class Girl { 
  7.         public String girlName; 
  8.     } 

项目中一般都会有非常多的model类,比如界面上的每个卡片,都是解析Server返回的数据,然后解析出一个个卡片model对吧。

对于解析Server数据,大多数情况下,Server返回的是json字符串,而我们客户端会使用Gson进行解析。

那我们看下上例这个Boy类,通过Gson解析的代码:

  1. public class Test01 { 
  2.  
  3.     public static void main(String[] args) { 
  4.         Gson gson = new Gson(); 
  5.         String boyJsonStr = "{\"boyName\":\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}"
  6.         Boy boy = gson.fromJson(boyJsonStr, Boy.class); 
  7.         System.out.println("boy name is = " + boy.boyName + " , girl name is = " + boy.girl.girlName); 
  8.     } 
  9.  

运行结果是?

我们来看一眼:

  1. boy name is = zhy , girl name is = lmj 

非常正常哈,符合我们的预期。

忽然有一天,有个同学给girl类中新增了一个方法getBoyName(),想获取这个女孩心目男孩的名称,很简单:

  1. public class Boy { 
  2.  
  3.     public String boyName; 
  4.     public Girl girl; 
  5.  
  6.     public class Girl { 
  7.         public String girlName; 
  8.  
  9.         public String getBoyName() { 
  10.             return boyName; 
  11.         } 
  12.     } 

看起来,代码也没毛病,要是你让我在这个基础上新增getBoyName(),可能代码也是这么写的。

但是,这样的代码埋下了深深的坑。

什么样的坑呢?

再回到我们的刚才测试代码,我们现在尝试解析完成json字符串,调用一下girl.getBoyName():

  1. public class Test01 { 
  2.  
  3.     public static void main(String[] args) { 
  4.         Gson gson = new Gson(); 
  5.         String boyJsonStr = "{\"boyName\":\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}"
  6.         Boy boy = gson.fromJson(boyJsonStr, Boy.class); 
  7.         System.out.println("boy name is = " + boy.boyName + " , girl name is = " + boy.girl.girlName); 
  8.         // 新增 
  9.         System.out.println(boy.girl.getBoyName()); 
  10.     } 
  11.  

很简单,加了一行打印。

这次,大家觉得运行结果是什么样呢?

还是没问题?当然不是,结果:

  1. boy name is = zhy , girl name is = lmj 
  2. Exception in thread "main" java.lang.NullPointerException 
  3.     at com.example.zhanghongyang.blog01.model.Boy$Girl.getBoyName(Boy.java:12) 
  4.     at com.example.zhanghongyang.blog01.Test01.main(Test01.java:15) 

Boy$Girl.getBoyName报出了npe,是girl为null?明显不是,我们上面打印了girl.name,那更不可能是boy为null了。

那就奇怪了,getBoyName里面就一行代码:

  1. public String getBoyName() {  
  2. return boyName; // npe 

到底是谁为null呢?

二、令人不解的空指针

return boyName; 只能猜测是某对象.boyName,这个某对象是null了。

这个某对象是谁呢?

我们重新看下getBoyName()返回的是boy对象的boyName字段,这个方法更细致一些写法应该是:

  1. public String getBoyName() { 
  2.  
  3. return Boy.this.boyName; 
  4.  

所以,现在问题清楚了,确实是Boy.this这个对象是null。

** 那么问题来了,为什么经过Gson序列化之后需,这个对象为null呢?**

想搞清楚这个问题,还有个前置问题:

  • 在Girl类里面为什么我们能够访问外部类Boy的属性以及方法?

三、非静态内部类的一些秘密

探索Java代码的秘密,最好的手段就是看字节码了。

我们下去一看Girl的字节码,看看getBodyName()这个“罪魁祸首”到底是怎么写的?

  1. javap -v Girl.class 

看下getBodyName()的字节码:

  1. public java.lang.String getBoyName(); 
  2.     descriptor: ()Ljava/lang/String; 
  3.     flags: ACC_PUBLIC 
  4.     Code: 
  5.       stack=1, locals=1, args_size=1 
  6.          0: aload_0 
  7.          1: getfield      #1                  // Field this$0:Lcom/example/zhanghongyang/blog01/model/Boy; 
  8.          4: getfield      #3                  // Field com/example/zhanghongyang/blog01/model/Boy.boyName:Ljava/lang/String; 
  9.          7: areturn 

可以看到aload_0,肯定是this对象了,然后是getfield获取this0字段,再通过this0字段,再通过this0字段,再通过this0再去getfield获取boyName字段,也就是说:

  1. public String getBoyName() { 
  2.     return boyName; 

相当于:

  1. public String getBoyName(){ 
  2.     return $this0.boyName; 

那么这个$this0哪来的呢?

我们再看下Girl的字节码的成员变量:

  1. final com.example.zhanghongyang.blog01.model.Boy this$0; 
  2.     descriptor: Lcom/example/zhanghongyang/blog01/model/Boy; 
  3.     flags: ACC_FINAL, ACC_SYNTHETIC 

其中果然有个this$0字段,这个时候你获取困惑,我的代码里面没有呀?

我们稍后解释。

再看下这个this$0在哪儿能够进行赋值?

翻了下字节码,发现Girl的构造方法是这么写的:

  1. public com.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy); 
  2.     descriptor: (Lcom/example/zhanghongyang/blog01/model/Boy;)V 
  3.     flags: ACC_PUBLIC 
  4.     Code: 
  5.       stack=2, locals=2, args_size=2 
  6.          0: aload_0 
  7.          1: aload_1 
  8.          2: putfield      #1                  // Field this$0:Lcom/example/zhanghongyang/blog01/model/Boy; 
  9.          5: aload_0 
  10.          6: invokespecial #2                  // Method java/lang/Object."<init>":()V 
  11.          9: return 
  12.       LineNumberTable: 
  13.         line 8: 0 
  14.       LocalVariableTable: 
  15.         Start  Length  Slot  Name   Signature 
  16.             0      10     0  this   Lcom/example/zhanghongyang/blog01/model/Boy$Girl; 
  17.             0      10     1 this$0   Lcom/example/zhanghongyang/blog01/model/Boy; 

可以看到这个构造方法包含一个形参,即Boy对象,最终这个会赋值给我们的$this0。

而且我们还发下一件事,我们再整体看下Girl的字节码:

  1. public class com.example.zhanghongyang.blog01.model.Boy$Girl { 
  2.   public java.lang.String girlName; 
  3.   final com.example.zhanghongyang.blog01.model.Boy this$0; 
  4.   public com.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy); 
  5.   public java.lang.String getBoyName(); 

其只有一个构造方法,就是我们刚才说的需要传入Boy对象的构造方法。

这块有个小知识,并不是所有没写构造方法的对象,都会有个默认的无参构造哟。

也就是说:

如果你想构造一个正常的Girl对象,理论上是必须要传入一个Boy对象的。

所以正常的你想构建一个Girl对象,Java代码你得这么写:

  1. public static void testGenerateGirl() { 
  2.     Boy.Girl girl = new Boy().new Girl(); 

先有body才能有girl。

这里,我们搞清楚了非静态内部类调用外部类的秘密了,我们再来想想Java为什么要这么设计呢?

因为Java支持非静态内部类,并且该内部类中可以访问外部类的属性和变量,但是在编译后,其实内部类会变成独立的类对象,例如下图:让另一个类中可以访问另一个类里面的成员,那就必须要把被访问对象传进入了,想一定能传入,那么就是唯一的构造方法最合适了。

可以看到Java编译器为了支持一些特性,背后默默的提供支持,其实这种支持不仅于此,非常多的地方都能看到,而且一些在编译期间新增的这些变量和方法,都会有个修饰符去修饰:ACC_SYNTHETIC。

不信,你再仔细看下$this0的声明。

  1. final com.example.zhanghongyang.blog01.model.Boy this$0; 
  2. descriptor: Lcom/example/zhanghongyang/blog01/model/Boy; 
  3. flags: ACC_FINAL, ACC_SYNTHETIC 

到这里,我们已经完全了解这个过程了,肯定是Gson在反序列化字符串为对象的时候没有传入body对象,然后造成$this0其实一直是null,当我们调用任何外部类的成员方法、成员变量是,熬的一声给你扔个NullPointerException。

四、Gson怎么构造的非静态匿名内部类对象?

现在我就一个好奇点,因为我们已经看到Girl是没有无参构造的,只有一个包含Boy参数的构造方法,那么Girl对象Gson是如何创建出来的呢?

是找到带Body参数的构造方法,然后反射newInstance,只不过Body对象传入的是null?

好像也能讲的通,下面看代码看看是不是这样吧:

我就长话短说了:

Gson里面去构建对象,一把都是通过找到对象的类型,然后找对应的TypeAdapter去处理,本例我们的Girl对象,最终会走走到ReflectiveTypeAdapterFactory.create然后返回一个TypeAdapter。

我只能再搬运一次了:

  1. # ReflectiveTypeAdapterFactory.create 
  2. @Override  
  3. public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) { 
  4.     Class<? super T> raw = type.getRawType(); 
  5.      
  6.     if (!Object.class.isAssignableFrom(raw)) { 
  7.       return null; // it's a primitive! 
  8.     } 
  9.      
  10.     ObjectConstructor<T> constructor = constructorConstructor.get(type); 
  11.     return new Adapter<T>(constructor, getBoundFields(gson, type, raw)); 

重点看constructor这个对象的赋值,它一眼就知道跟构造对象相关。

  1. # ConstructorConstructor.get 
  2. public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) { 
  3.     final Type type = typeToken.getType(); 
  4.     final Class<? super T> rawType = typeToken.getRawType(); 
  5.      
  6.     // ...省略一些缓存容器相关代码 
  7.  
  8.     ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType); 
  9.     if (defaultConstructor != null) { 
  10.       return defaultConstructor; 
  11.     } 
  12.  
  13.     ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType); 
  14.     if (defaultImplementation != null) { 
  15.       return defaultImplementation; 
  16.     } 
  17.  
  18.     // finally try unsafe 
  19.     return newUnsafeAllocator(type, rawType); 
  20.   } 

可以看到该方法的返回值有3个流程:

  1. newDefaultConstructor 
  2. newDefaultImplementationConstructor 
  3. newUnsafeAllocator 

我们先看第一个newDefaultConstructor

  1. private <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> rawType) { 
  2.     try { 
  3.       final Constructor<? super T> constructor = rawType.getDeclaredConstructor(); 
  4.       if (!constructor.isAccessible()) { 
  5.         constructor.setAccessible(true); 
  6.       } 
  7.       return new ObjectConstructor<T>() { 
  8.         @SuppressWarnings("unchecked") // T is the same raw type as is requested 
  9.         @Override public T construct() { 
  10.             Object[] args = null
  11.             return (T) constructor.newInstance(args); 
  12.              
  13.             // 省略了一些异常处理 
  14.       }; 
  15.     } catch (NoSuchMethodException e) { 
  16.       return null
  17.     } 
  18.   } 

可以看到,很简单,尝试获取了无参的构造函数,如果能够找到,则通过newInstance反射的方式构建对象。

追随到我们的Girl的代码,并没有无参构造,从而会命中NoSuchMethodException,返回null。

返回null会走newDefaultImplementationConstructor,这个方法里面都是一些集合类相关对象的逻辑,直接跳过。

那么,最后只能走:newUnsafeAllocator 方法了。

从命名上面就能看出来,这是个不安全的操作。

newUnsafeAllocator最终是怎么不安全的构建出一个对象呢?

往下看,最终执行的是:

  1. public static UnsafeAllocator create() { 
  2. // try JVM 
  3. // public class Unsafe { 
  4. //   public Object allocateInstance(Class<?> type); 
  5. // } 
  6. try { 
  7.   Class<?> unsafeClass = Class.forName("sun.misc.Unsafe"); 
  8.   Field f = unsafeClass.getDeclaredField("theUnsafe"); 
  9.   f.setAccessible(true); 
  10.   final Object unsafe = f.get(null); 
  11.   final Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class); 
  12.   return new UnsafeAllocator() { 
  13.     @Override 
  14.     @SuppressWarnings("unchecked"
  15.     public <T> T newInstance(Class<T> c) throws Exception { 
  16.       assertInstantiable(c); 
  17.       return (T) allocateInstance.invoke(unsafe, c); 
  18.     } 
  19.   }; 
  20. } catch (Exception ignored) { 
  21.    
  22. // try dalvikvm, post-gingerbread use ObjectStreamClass 
  23. // try dalvikvm, pre-gingerbread , ObjectInputStream 
  24.  

嗯...我们上面猜测错了,Gson实际上内部在没有找到它认为合适的构造方法后,通过一种非常不安全的方式构建了一个对象。

关于更多UnSafe的知识,可以参考:

每日一问 | Java里面还能这么创建对象?

五、如何避免这个问题?

其实最好的方式,会被Gson去做反序列化的这个model对象,尽可能不要去写非静态内部类。

在Gson的用户指南中,其实有写到:

github.com/google/gson…

大概意思是如果你有要写非静态内部类的case,你有两个选择保证其正确:

  • 内部类写成静态内部类;
  • 自定义InstanceCreator

2的示例代码在这,但是我们不建议你使用。

嗯...所以,我简化的翻译一下,就是:

别问,问就是加static

不要使用这种口头的要求,怎么能让团队的同学都自觉遵守呢,谁不注意就会写错,所以一般遇到这类约定性的写法,最好的方式就是加监控纠错,不这么写,编译报错。

六、那就来监控一下?

我在脑子里面大概想了下,有4种方法可能可行。

嗯...你也可以选择自己想下,然后再往下看。

  1. 最简单、最暴力,编译的时候,扫描model所在目录,直接读java源文件,做正则匹配去发现非静态内部类,然后然后随便找个编译时的task,绑在它前面,就能做到每次编译时都运行了。
  2. Gradle Transform,这个不要说了,扫描model所在包下的class类,然后看类名如果包含AB的形式,且构造方法中只有一个需要A的构造且成员变量包含B的形式,且构造方法中只有一个需要A的构造且成员变量包含B的形式,且构造方法中只有一个需要A的构造且成员变量包含this0拿下。
  3. AST 或者lint做语法树分析;
  4. 运行时去匹配,也是一样的,运行时去拿到model对象的包路径下所有的class对象,然后做规则匹配。

好了,以上四个方案是我临时想的,理论上应该都可行,实际上不一定可行,欢迎大家尝试,或者提出新方案。

有新的方案,求留言补充下知识面

鉴于篇幅...

不,其实我一个都没写过,不太想都写一篇了,这样博客太长了。

  • 方案1,大家拍大腿都能写出来,过,不过我感觉1最实在了,而且触发速度极快,不怎么影响研发体验;
  • 方案2,大家查一下Transform基本写法,利用javassist,或者ASM,估计也问题不大,过;
  • 方案3,AST的语法我也要去查,我写起来也费劲,过;
  • 方案4,是我最后一个想出来的,写一下吧。

其实方案4,如果你看到ARouter的早期版本的初始化,你就明白了。

其实就是遍历dex中所有的类,根据包+类名规则去匹配,然后就是发射API了。

我们一起写下。

运行时,我们要遍历类,就是拿到dex,怎么拿到dex呢?

可以通过apk获取,apk怎么拿呢?其实通过cotext就能拿到apk路径。

  1. public class PureInnerClassDetector { 
  2.     private static final String sPackageNeedDetect = "com.example.zhanghongyang.blog01.model"
  3.  
  4.     public static void startDetect(Application context) { 
  5.  
  6.         try { 
  7.             final Set<String> classNames = new HashSet<>(); 
  8.             ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0); 
  9.             File sourceApk = new File(applicationInfo.sourceDir); 
  10.             DexFile dexfile = new DexFile(sourceApk); 
  11.             Enumeration<String> dexEntries = dexfile.entries(); 
  12.             while (dexEntries.hasMoreElements()) { 
  13.                 String className = dexEntries.nextElement(); 
  14.                 Log.d("zhy-blog""detect " + className); 
  15.                 if (className.startsWith(sPackageNeedDetect)) { 
  16.                     if (isPureInnerClass(className)) { 
  17.                         classNames.add(className); 
  18.                     } 
  19.                 } 
  20.             } 
  21.             if (!classNames.isEmpty()) { 
  22.                 for (String className : classNames) { 
  23.                     // crash ? 
  24.                     Log.e("zhy-blog""编写非静态内部类被发现:" + className); 
  25.                 } 
  26.             } 
  27.         } catch (Exception e) { 
  28.             e.printStackTrace(); 
  29.         } 
  30.     } 
  31.  
  32.     private static boolean isPureInnerClass(String className) { 
  33.         if (!className.contains("$")) { 
  34.             return false
  35.         } 
  36.         try { 
  37.             Class<?> aClass = Class.forName(className); 
  38.             Field $this0 = aClass.getDeclaredField("this$0"); 
  39.             if (!$this0.isSynthetic()) { 
  40.                 return false
  41.             } 
  42.             // 其他匹配条件 
  43.             return true
  44.         } catch (Exception e) { 
  45.             e.printStackTrace(); 
  46.             return false
  47.         } 
  48.     } 
  49.  

启动app:

以上仅为demo代码,并不严谨,需要自行完善。

就几十行代码,首先通过cotext拿ApplicationInfo,那么apk的path,然后构建DexFile对象,遍历其中的类即可,找到类,就可以做匹配了。

【责任编辑:未丽燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

订阅专栏+更多

数据湖与数据仓库的分析实践攻略

数据湖与数据仓库的分析实践攻略

助力现代化数据管理:数据湖与数据仓库的分析实践攻略
共3章 | 创世达人

5人订阅学习

云原生架构实践

云原生架构实践

新技术引领移动互联网进入急速赛道
共3章 | KaliArch

31人订阅学习

数据中心和VPDN网络建设案例

数据中心和VPDN网络建设案例

漫画+案例
共20章 | 捷哥CCIE

215人订阅学习

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊

51CTO服务号

51CTO官微