遠山呼喚

工程 | 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。那麼這在編譯後的 class 檔案中意味著什麼?我們有使用者提供的方法

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 讀取位元組碼是一個很好的解決方案,通常是萬無一失的。然而,在安全環境中,它可能需要對不允許訪問的庫具有讀取許可權,這可能會帶來問題。第二種解決方案是利用橋接方法中的泛型元資料來解析實現類中的哪個方法正在被橋接。

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

的實現BridgeMethodResolver在很大程度上已經完成;然而,我相信我們還沒有考慮到一些複雜的情況,我很樂意聽取在該領域遇到任何問題的使用者的意見。

訂閱 Spring 電子報

透過 Spring 電子報保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助你快速提升。

瞭解更多

獲取支援

Tanzu Spring 透過一個簡單的訂閱,即可獲得 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案。

瞭解更多

即將舉行的活動

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

檢視全部