AOP - 安全使用接口引用

Photo by Joseph Maxim Reskp on Unsplash

我使用Java 開發過不少項目,這其中包括一些Web 應用和Android 客戶端應用。做爲Android 開發人員,Java 就像咱們的母語同樣,但Android 世界是多元化的,並非只有Java 才能用來寫Android 程序,Kotlin 和Groovy 一樣優秀,而且有着大量的粉絲。我在過去的一年中嘗試學習並使用它們,它們的語法糖讓我愛不釋手,我尤爲對安全導航(safe-navigation)操做符?. 感到驚訝,它讓我寫更少的代碼,就可以避免空指針異常(NullPointerException)。惋惜的是Java 並無提供這種操做符,因此本文就和你們聊聊如何在Java 中取代繁瑣的非空判斷。html

接口隔離原則

軟件編程中始終都有一些好的編程規範值得咱們的學習:若是你在一個多人協做的團隊工做,那麼模塊之間的依賴關係就應該創建在接口上,這是下降耦合的最佳方式;若是你是一個SDK 的提供者,暴露給客戶端的始終應該是接口,而不是某個具體實現類。java

在Android 開發中咱們常常會持有接口的引用,或註冊某個事件的監聽,如系統服務的通知,點擊事件的回調等,雖不勝枚舉,但大部分監聽都須要咱們去實現一個接口,所以咱們就拿註冊回調監聽來舉例:git

private Callback callback;

  public void registerXXXX(Callback callback) {
    this.callback = callback;
  }
  
  ......
  
  public interface Callback {
    void onXXXX();
  }

複製代碼

當事件真正發生的時候調用callback 接口中的函數:github

......

 if (callback != null) {
   callback.onXXXX();
}

複製代碼

這看起來並無什麼問題,由於咱們平時就是這樣書寫代碼的,因此咱們的項目中存在大量的對接口引用的非空判斷,即便有參數型註解@NonNull 的標記,但仍沒法阻止外部傳入一個null 對象。編程

說實話,我須要的無非就是當接口引用爲空的時候,不進行任何的函數調用,然而咱們卻須要在每一行代碼之上強行添加醜陋的非空判斷,這讓個人代碼看起來失去了信任,變得極其不可靠,並且頻繁的非空判斷讓我感到十分疲憊 : (安全

使用操做符 ' ?. '

Kotlin 和Groovy 彷佛意識到了上述尷尬,所以加入了很是實用的操做符:bash

?. 操做符只有對象引用不爲空時纔會分派調用框架

接下來分別拿Kotlin 和Groovy 舉例:jvm

在Kotlin 中使用 ' ?. ' :

fun register(callback: Callback?) {
    
    ......

    callback?.on()
  }

  interface Callback {
    fun on()
  }

複製代碼

在Groovy 中使用 ' ?. ' :

void register(Callback callback) {

    ......

    callback?.on()
  }

  interface Callback {
    void on() } 複製代碼

能夠看到使用?. 操做符後咱們不再須要添加if (callback != null) {} 代碼塊了,代碼更加清爽,所要表達的意思也更加直接:若是callback 引用不爲空則調用on() 函數,不然不作任何處理ide

' ?. ' 是黑魔法嗎?咱們將在下一個章節介紹操做符?. 的實現原理。

反編譯操做符 ' ?. '

我始終相信在代碼層面沒有所謂的黑魔法,更沒有萬能的銀彈,咱們之因此可以使用語法糖,必定是語言自己或者框架內部幫咱們作了更復雜的操做。

如今,咱們能夠先提出一個假設:編譯器將操做符?. 優化成了與if (callback != null) {} 效果相同的代碼邏輯,不管是Java,Kotlin 仍是Groovy,它們在字節碼層面的表現相同

爲了驗證這個假設,咱們分別用kotlinc 和groovyc 將以前的代碼編譯成class 文件,而後再使用javap 指令進行反彙編。

編譯/反編譯KotlinSample.kt

# $ kotlinc KotlinSample.kt
# $ javap -c KotlinSample.kt

Compiled from "KotlinSample.kt"
public final class KotlinSample {
  public final void register(KotlinSample$Callback);
    Code:
       0: aload_1
       1: dup
       2: ifnull        13
       5: invokeinterface #13, 1 // InterfaceMethod KotlinSample$Callback.on:()V
      10: goto          14
      13: pop
      14: return
    
    ......

}
複製代碼

經過分析register() 函數體中的全部JVM 指令,咱們看到了熟悉的ifnull 指令,所以咱們能夠很快地將字節碼還原:

fun register(callback: Callback?) {
    if (callback!=null){
      callback.on()
    }
  }

複製代碼

因而可知:kotlinc 編譯器在編譯過程當中將操做符?. 完徹底全地替換成if (callback != null) {} 代碼塊。這和咱們手寫的Java 代碼在字節碼層面毫無差異。

編譯/反編譯GroovySample.groovy

# $ groovyc GroovySample.groovy
# $ javap -c GroovySample.class

Compiled from "GroovySample.groovy"
public class GroovySample implements groovy.lang.GroovyObject {

  public void register(GroovySample$Callback);
    Code:
       0: invokestatic  #19 // Method $getCallSiteArray:()[Lorg/codehaus/groovy/runtime/callsite/CallSite;
       3: astore_2
       4: aload_2
       5: ldc           #32 // int 0
       7: aaload
       8: aload_1
       9: invokeinterface #38, 2 // InterfaceMethod org/codehaus/groovy/runtime/callsite/CallSite.callSafe:(Ljava/lang/Object;)Ljava/lang/Object;
      14: pop
      15: return

    ......

}

複製代碼

須要注意的是,groovy 文件在編譯過程當中由編譯器生成大量的不存在於源代碼中的額外函數和變量,感興趣的朋友能夠自行閱讀反編譯後的字節碼。此處爲了方便理解,在不影響原有核心邏輯的條件下作出近似還原:

public void register(GroovySample.Callback callback) {

    String[] strings = new String[1]
    strings[0] = 'on'

    CallSiteArray callSiteArray = new CallSiteArray(GroovySample.class, strings)
    CallSite[] array = callSiteArray.array

    array[0].callSafe(callback)
  }

複製代碼

其中CallSite 是一個接口,具體實現類是AbstractCallSite ,:

public class AbstractCallSite implements CallSite {

    public final Object callSafe(Object receiver) throws Throwable {
        if (receiver == null)
            return null;

        return call(receiver);
    }

  ......

}

複製代碼

函數AbstractCallSite#call(Object) 以後是一個漫長的調用過程,這其中包括一系列重載函數的調用和對接口引用callback 的代理等,最終得益於Groovy 的元編程能力,在標準GroovyObject對象上獲取meatClass ,最後使用反射調用接口引用的指定方法,即callback.on()

callback.metaClass.invokeMethod(callback, 'on', null);

複製代碼

那麼回到文章的主題,在AbstractCallSite#call(Object) 函數中咱們能夠看到對receiver 參數也就是對callback 引用進行了非空判斷,所以咱們能夠確定的是:操做符?. 在Groovy 和Kotlin 中的原理是基本相同的。

所以能夠得出結論:編譯器將?. 操做符編譯成亦或在框架內部調用與if (callback != null) {} 等同效果的代碼片斷。Java,Kotlin 和Groovy 在字節碼層面使用了相同方式的非空判斷

爲Java 添加' ?. ' 操做符

事情變得簡單起來,咱們只須要給Java 添加?. 操做符就好了。

其實,與其說爲Java 添加?. 操做符不如說是經過一些小技巧達到相同的處理效果,畢竟改變javac 的編譯方式成本較大。

面向接口的編程方式,使咱們有自然的優點能夠利用,並且動態代理也是基於接口的,所以咱們能夠對接口引進行動態代理並返回代理後的值,這樣callback 實際指向了動態代理對象,在代理的內部咱們使用反射調用callback 引用中的函數:

private void register(Callback callback) {
    callback = ProxyHandler.wrap(callback, Callback.class);

    ......

    callback.on();
  }


public static final class ProxyHandler {

  public static <T> T wrap(final T reference, Class<? extends T> interfacee) {

    if (interfacee.isInterface()) {
      return (T) Proxy.newProxyInstance(interfacee.getClassLoader(), new Class[] { interfacee },
          new InvocationHandler() {
            @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
              if (reference == null) return null;
              return method.invoke(reference, args);
            }
          });
    }
    return reference;
  }
}

複製代碼

經過這樣的一層代理關係,咱們能夠安全使用callback 引用上的任何函數,而沒必要關心空指針的發生。也就是說,咱們在Java 上經過使用動態代理加反射的方式,構造出了一個約等於?. 操做符的效果

集成Android gradle plugin (AGP)

咱們發現每次使用前都須要手動添加代理關係實在麻煩,可否像javac 或者kotlinc 那樣在編譯過程或者構建過程當中使用自動化的方式代替手動添加呢?

答案是確定的:在構建過程當中修改字節碼!

首先,咱們找一段簡單的java 代碼:

public class JavaSample {

  public Callback callback;

  public void doOperation() {

    //Called when progress is updated
    callback.onProgress(99);
  }

  interface Callback {
    void onProgress(int progress);
  }
}
複製代碼

編譯/反編譯JavaSample.java

# $ javac JavaSample.java
# $ javap -c JavaSample.class

public class JavaSample {
  public JavaSample$Callback callback;

  public void doOperation();
    Code:
       0: aload_0
       1: getfield      #2 // Field callback:LJavaSample$Callback;
       4: bipush        99
       6: invokeinterface #3, 2 // InterfaceMethod JavaSample$Callback.onProgress:(I)V
      11: return
}

複製代碼

而後,經過觀察字節碼指令,咱們知道調用Java 接口中聲明的方法使用的是invokeinterface 指令,所以咱們只須要找到函數體中invokeinterface 指令所在位置,對其進行就修改便可。本項目所採起的思路是將invokeinterface 替換成invokestatic 並調用根據接口函數調用信息所生成的靜態函數static void buoy$onProgress(JavaSample$Callback, int);

public void doOperation();
    Code:
       0: aload_0
       1: getfield      #19 // Field callback:LJavaSample$Callback;
       4: bipush        99
       6: invokestatic  #23 // Method buoy$onProgress:(LJavaSample$Callback;I)V
       9: return

  static void buoy$onProgress(JavaSample$Callback, int);
    Code:
       0: aload_0
       1: ldc           #25 // String JavaSample$Callback
       3: ldc           #27 // String JavaSample$Callback.onProgress:(int)void
       5: invokestatic  #33 // Method com/smartdengg/interfacebuoy/compiler/InterfaceBuoy.proxy:(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object;
       8: iload_1
       9: invokeinterface #37, 2 // InterfaceMethod JavaSample$Callback.onProgress:(I)V
      14: return

複製代碼

值得一提的是:源碼級別中咱們沒法在非靜態內部類中建立靜態函數,可是在字節碼中這是容許的

下面咱們將JavaSample.class 還原:

public class JavaSample {
  public Callback callback;

  public void doOperation() {
    buoy$onProgress(this.callback, 99);
  }

  @Buoy
  static void buoy$onProgress(JavaSample.Callback var0, int var1) {
    ((JavaSample.Callback)InterfaceBuoy.proxy(var0, "JavaSample$Callback", "JavaSample$Callback.onProgress:(int)void")).onProgress(var1);
  }

  interface Callback {
    void onProgress(int var1);
  }
}

複製代碼

其中:

  • @Buoy 註解表示該函數用戶保護接口引用的安全使用。
  • InterfaceBuoy 類則用於建立接口引用的動態代理對象。

這裏須要說明一下,我並無在生成的靜態函數中直接對接口引用進行非空判斷,而是交給了源碼級別的InterfaceBuoy 類,我給出的理由是:字節碼織入應該儘量的簡單,更復雜的操做應該交給源碼級別的類,這不只能夠防止調用棧的過分污染,從而下降調試成本,並且源代碼比字節碼更容易編寫,出現問題的概率會更小,由於咱們不會比編譯器更瞭解字節碼!

最後,經過ASM 修改字節碼並集成到AGP 中,使其成爲Android 構建過程的一部分,咱們作到了 : )

總結&討論

通篇下來,其實咱們並無修改javac ,咱們不能也不該該去修改這些編譯工具,咱們使用Java 平臺所提供的動態代理與反射就完成了相似?. 操做符的功能。

可能有人會說反射很慢,加上動態代理後會變得更慢,我卻是認爲這種觀點是缺少說服力的,由於在這個級別上擔憂性能問題是不明智的,除非可以分析代表這種方式正是形成性能損耗的源頭,不然在沒有統一衡量標準的前提下,盲目反對反射和動態代理的觀點是站不穩腳的。

爲了安全使用定義在接口中的函數,我作了這個小工具,目前已經開源,全部代碼均可以經過github 獲取,但願這個避免空指針的「接口救生圈」可以讓你在Java 的海洋中盡情遨遊。

歡迎討論或在評論區留下您寶貴的建議。

相關文章
相關標籤/搜索