一座太遠的橋

工程 | Rob Harrop | 2007 年 1 月 16 日 | ...

在我上一篇文章中,我介紹了一種建立策略類的方法,該方法充分利用了應用程式中存在的任何泛型元資料。在那篇文章的末尾,我展示了這段程式碼片段

EntitlementCalculator calculator = new DividendEntitlementCalculator();
calculator.calculateEntitlement(new MergerCorporateActionEvent());

您會記得DividendEntitlementCalculator被定義為

public class DividendEntitlementCalculator implements EntitlementCalculator<DividendCorporateActionEvent> {

    public void calculateEntitlement(DividendCorporateActionEvent event) {

    }
}

因此,將MergerCorporateActionEvent的例項傳遞給calculateEntitlement方法是不正確的。DividendEntitlementCalculator然而,正如我在上一篇文章中提到的,該程式碼將編譯。為什麼?嗯,EntitlementCalculator.calculateEntitlement()被定義為接受任何擴充套件CorporateActionEvent的型別,因此它應該編譯。那麼在這種情況下,執行時會發生什麼,Java 如何強制執行型別安全?嗯,正如您可能想象的,執行此程式碼會得到一個ClassCastException,提示您無法強制轉換MergerCorporateActionEvent轉換為DividendCoporateActionEvent。透過這種方式,Java 可以為您的應用程式強制執行型別安全——MergerCorporateActionEvent不可能“爬入”期望DividendCorporateActionEvent的方法中。

這裡真正的問題是:“那個ClassCastException是從哪裡來的?”答案很簡單——Java 編譯器透過引入一個橋接方法,添加了建立和丟擲它的程式碼。橋接方法是編譯器將生成並新增到您的類中的合成方法,以確保在面對泛型型別時的型別安全。

在上面所示的例子中EntitlementCalculator.calculateEntitlement可以呼叫任何與CorporateActionEvent型別相容的物件。然而,DividendEntitlementCalculator只接受與DividendCorporateActionEvent型別相容的物件,但是,由於您可以透過DividendEntitlementCalculator呼叫EntitlementCalculator介面,它也必須接受CorporateActionEvent。那麼這在編譯後的類檔案中意味著什麼呢?我們有使用者提供的方法

public void calculateEntitlement(DividendCorporateActionEvent event) {
    System.out.println(event);
}

這會轉化為以下位元組碼

public void calculateEntitlement(bigbank.DividendCorporateActionEvent);
  Code:
   Stack=2, Locals=2, Args_size=2
   0:   getstatic       #2; //Field java/lang/System.out:Ljava/io/PrintStream;
   3:   aload_1
   4:   invokevirtual   #3; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
   7:   return

但我們也有一個編譯器生成的橋接方法

public void calculateEntitlement(bigbank.CorporateActionEvent);
  Code:
   Stack=2, Locals=2, Args_size=2
   0:   aload_0
   1:   aload_1
   2:   checkcast       #4; //class bigbank/DividendCorporateActionEvent
   5:   invokevirtual   #5; //Method calculateEntitlement:(Lbigbank/DividendCorporateActionEvent;)V
   8:   return

這會轉換成這個 Java 程式碼

public void calculateEntitlement(CorporateActionEvent event) {
    calculateEntitlement((DividendCorporateActionEvent)event);
}

所以,在這裡你可以清楚地看到ClassCastException在傳入時來自哪裡CorporateActionEvents而不是DividendCorporateActionEvents- 編譯器生成的橋接方法

現在,這當然是一個很棒的功能。我們不希望將泛型新增到 Java 語言中會破壞我們長期以來所習慣的型別安全。但是,正如這些事情所預期的那樣——並非一切都那麼好。橋接方法在其當前 JDK 實現中的主要問題是,註解不會從被橋接的方法複製到橋接方法。當你在反射中意外地獲取到橋接方法並嘗試解析某些註解時,這會導致各種各樣的問題。

有些人可能會想,你怎樣才會錯誤地獲取到橋接方法。這是一個相當複雜的問題。常見的原因(以及我們在 Spring 中看到它最常發生的地方)是當你建立委託給某個物件的 JDK 代理,然後嘗試將代理介面中的方法對映到委託上相應的實現方法(通常是為了解析註解)。請看這段程式碼

public static void main(String[] args) {
    EntitlementCalculator ec = createProxy(new DividendEntitlementCalculator());
    ec.calculateEntitlement(null);
}

private static EntitlementCalculator createProxy(EntitlementCalculator calculator) {
    InvocationHandler handler = new TransactionLoggingInvocationHandler(calculator);
    return (EntitlementCalculator) Proxy.newProxyInstance(calculator.getClass().getClassLoader(),
                                                                calculator.getClass().getInterfaces(), handler);
}

private static class TransactionLoggingInvocationHandler implements InvocationHandler {

    private final EntitlementCalculator delegate;

    public TransactionLoggingInvocationHandler(EntitlementCalculator delegate) {
        this.delegate = delegate;
    }

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Method delegateMethod = delegate.getClass().getMethod(method.getName(), method.getParameterTypes());
        Transactional annotation = delegateMethod.getAnnotation(Transactional.class);
        if(annotation != null) {
            System.out.println("Executing transactional method: " + delegateMethod);
        } else {
            System.out.println("Executing non-transactional method: " + delegateMethod);
        }
        return method.invoke(delegate, args);
    }
}

這裡我們為給定的EntitlementCalculator物件建立了一個代理,該代理將記錄被代理物件上的方法是否是事務性的。如果我們像下面這樣註解DividendEntitlementCalculator類,我們可以預期代理會在呼叫calculateEntitlement時記錄我們正在執行一個事務性方法main.

@Transactional
public void calculateEntitlement(DividendCorporateActionEvent event) {
    System.out.println(event);
}

然而,執行上面的示例會導致如下結果

Executing non-transactional method: public volatile void bigbank.DividendEntitlementCalculator.calculateEntitlement(bigbank.CorporateActionEvent)

請注意,這與我們呼叫的DividendEntitlementCalculator上的方法不對應。當然,這顯然是這種情況;這裡的重點是介面方法和委託方法的方法簽名不同的。一個是根據父型別定義的,在本例中是CorporateActionEvent,另一個是根據子型別定義的,在本例中是DividendCorporateActionEvent。你還會注意到,我們實際上得到了橋接方法——因為它的簽名確實與介面方法(根據定義)匹配。

也許查詢委託方法的更好的解決方案是使用傳入引數的型別,而不是介面方法的型別。當面對使用繼承的引數時,你可以簡單地沿著引數的型別層次結構向上搜尋型別匹配。不幸的是,這種方法無法可靠地工作。考慮一下你有一個如下介面的情況

public interface Foo<T> {
    void bar(T t);
}

然後是這個實現

public class FooImpl implements Foo<Number>{

    public void bar(Number t) {
    }

    public void bar(Serializable t) {
    }
}

如果你在解析委託方法時使用傳入到InvocationHandler中的具體引數的型別,那麼當面對一個型別為Integer的引數時,你會選擇以下哪種方法?你無法(從介面方法)得知型別引數是Number並且因為兩種方法都與Integer型別相容,所以無法始終如一地以通用方式解析正確的方法。

解決這個問題只有兩種方法(據我所知)。第一種方法是使用像 ASM 這樣的庫來讀取橋接方法的位元組碼,並找出它呼叫了哪個方法。使用 ASM 讀取位元組碼是一個很好的解決方案,而且通常是萬無一失的。然而,在安全的環境中,它可能需要對不允許的庫的讀取許可權,這可能會帶來問題。第二種解決方案是利用橋接方法中的泛型元資料來解析實現類中的哪個方法被橋接了。

在上面的例子中,我們可以看到介面方法是barT引數化。我們可以使用FooImpl的泛型介面元資料 (Class.getGenericInterfaces()) 來確定T被實現為Number。從那裡,可以很容易地知道被橋接的方法是bar(Number)而不是bar(Serializable)。不幸的是,在面對涉及多個帶有邊界的型別引數的複雜繼承層時,這種方法會變得越來越複雜。幸運的是,這個邏輯被封裝在 Spring 的BridgeMethodResolver類中。這是 Spring 解決 Java 開發者面臨的困難基礎設施問題並將其整合到應用程式堆疊中的一個完美例子。任何時候在 Spring 中執行註解查詢,橋接方法都會被透明地解析。

的實現BridgeMethodResolver基本已完成;然而,我確信還有一些我們尚未考慮到的複雜情況,我將樂於聽取遇到此領域任何問題的使用者反饋。

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有