程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java編程的動態性, 第4部分: 用Javassist進行類轉換

Java編程的動態性, 第4部分: 用Javassist進行類轉換

編輯:關於JAVA

厭倦了只能按編寫好源代碼的方式執行的 Java 類了嗎?那麼打起精神吧,因為您就要發 現如何將編譯器編譯好的類進行改造的方法了!在本文中,Java 顧問 Dennis Sosnoski 通 過介紹字節碼操作庫 Javassist 將他的 Java 編程的動態性系列帶入高潮,Javassist 是廣 泛使用的 JBoss 應用服務器中加入的面向方面的編程功能的基礎。您會看到到用 Javassist 轉換現有類的基本內容,並且了解到這種用框架源代碼處理類的方法的威力和局限性。

講過了 Java 類格式和利用反射進行的運行時訪問後,本系列到了進入更高級主題的時候 了。本月我將開始本系列的第二部分,在這裡 Java 類信息只不過是由應用程序操縱的另一 種形式的數據結構而已。我將這個主題的整個內容稱為 classworking。

我將以 Javassist 字節碼操作庫作為對 classworking 的討論的開始。Javassist 不僅 是一個處理字節碼的庫,而且更因為它的另一項功能使得它成為試驗 classworking 的很好 的起點。這一項功能就是:可以用 Javassist 改變 Java 類的字節碼,而無需真正了解關於 字節碼或者 Java 虛擬機(Java virtual machine JVM)結構的任何內容。從某方面將這一功 能有好處也有壞處 -- 我一般不提倡隨便使用不了解的技術 -- 但是比起在單條指令水平上 工作的框架,它確實使字節碼操作更可具有可行性了。

Javassist 基礎

Javassist 使您可以檢查、編輯以及創建 Java 二進制類。檢查方面基本上與通過 Reflection API 直接在 Java 中進行的一樣,但是當想要修改類而不只是執行它們時,則另 一種訪問這些信息的方法就很有用了。這是因為 JVM 設計上並沒有提供在類裝載到 JVM 中 後訪問原始類數據的任何方法,這項工作需要在 JVM 之外完成。

Javassist 使用 javassist.ClassPool 類跟蹤和控制所操作的類。這個類的工作方式與 JVM 類裝載器非常相似,但是有一個重要的區別是它不是將裝載的、要執行的類作為應用程 序的一部分鏈接,類池使所裝載的類可以通過 Javassist API 作為數據使用。可以使用默認 的類池,它是從 JVM 搜索路徑中裝載的,也可以定義一個搜索您自己的路徑列表的類池。甚 至可以直接從字節數組或者流中裝載二進制類,以及從頭開始創建新類。

裝載到類池中的類由 javassist.CtClass 實例表示。與標准的 Java java.lang.Class 類一樣, CtClass 提供了檢查類數據(如字段和方法)的方法。不過,這只是 CtClass 的 部分內容,它還定義了在類中添加新字段、方法和構造函數、以及改變類、父類和接口的方 法。奇怪的是,Javassist 沒有提供刪除一個類中字段、方法或者構造函數的任何方法。

字段、方法和構造函數分別由 javassist.CtField、javassist.CtMethod 和 javassist.CtConstructor 的實例表示。這些類定義了修改由它們所表示的對象的所有方法 的方法,包括方法或者構造函數中的實際字節碼內容。

所有字節碼的源代碼

Javassist 讓您可以完全替換一個方法或者構造函數的字節碼正 文,或者在現有正文的開始或者結束位置選擇性地添加字節碼(以及在構造函數中添加其他一 些變量)。不管是哪種情況,新的字節碼都作為類 Java 的源代碼聲明或者 String 中的塊傳 遞。Javassist 方法將您提供的源代碼高效地編譯為 Java 字節碼,然後將它們插入到目標 方法或者構造函數的正文中。

Javassist 接受的源代碼與 Java 語言的並不完全一致,不過主要的區別只是增加了一些 特殊的標識符,用於表示方法或者構造函數參數、方法返回值和其他在插入的代碼中可能用 到的內容。這些特殊標識符以符號 $ 開頭,所以它們不會干擾代碼中的其他內容。

對於在傳遞給 Javassist 的源代碼中可以做的事情有一些限制。第一項限制是使用的格 式,它必須是單條語句或者塊。在大多數情況下這算不上是限制,因為可以將所需要的任何 語句序列放到塊中。下面是一個使用特殊 Javassist 標識符表示方法中前兩個參數的例子, 這個例子用來展示其使用方法:

{
  System.out.println("Argument 1: " + $1);
  System.out.println("Argument 2: " + $2);
}

對於源代碼的一項更實質性的限制是不能引用在所添加的聲明或者塊外聲明的局部變量。 這意味著如果在方法開始和結尾處都添加了代碼,那麼一般不能將在開始處添加的代碼中的 信息傳遞給在結尾處添加的代碼。有可能繞過這項限制,但是繞過是很復雜的 -- 通常需要 設法將分別插入的代碼合並為一個塊。

用 Javassist 進行 Classworking

作為使用 Javassist 的一個例子,我將使用一個通常直接在源代碼中處理的任務:測量 執行一個方法所花費的時間。這在源代碼中可以容易地完成,只要在方法開始時記錄當前時 間、之後在方法結束時再次檢查當前時間並計算兩個值的差。如果沒有源代碼,那麼得到這 種計時信息就要困難得多。這就是 classworking 方便的地方 -- 它讓您對任何方法都可以 作這種改變,並且不需要有源代碼。

清單 1 顯示了一個(不好的)示例方法,我用它作為我的計時試驗的實驗品: StringBuilder 類的 buildString 方法。這個方法使用一種所有 Java 性能優化的高手都會 叫您 不要使用的方法構造一個具有任意長度的 String -- 它通過反復向字符串的結尾附加 單個字符來產生更長的字符串。因為字符串是不可變的,所以這種方法意味著每次新的字符 串都要通過一個循環來構造:使用從老的字符串中拷貝的數據並在結尾添加新的字符。最終 的效果是用這個方法產生更長的字符串時,它的開銷越來越大。

清單 1. 需要計時的方法

public class StringBuilder
{
   private String buildString(int length) {
     String result = "";
     for (int i = 0; i < length; i++) {
       result += (char)(i%26 + 'a');
     }
     return result;
   }

   public static void main(String[] argv) {
     StringBuilder inst = new StringBuilder();
     for (int i = 0; i < argv.length; i++) {
       String result = inst.buildString(Integer.parseInt(argv [i]));
       System.out.println("Constructed string of length " +
         result.length());
     }
   }
}

添加方法計時

因為有這個方法的源代碼,所以我將為您展示如何直接添加計時信息。它也作為使用 Javassist 時的一個模型。清單 2 只展示了 buildString() 方法,其中添加了計時功能。 這裡沒有多少變化。添加的代碼只是將開始時間保存為局部變量,然後在方法結束時計算持 續時間並打印到控制台。

清單 2. 帶有計時的方法

private String buildString(int length) {
     long start = System.currentTimeMillis();
     String result = "";
     for (int i = 0; i < length; i++) {
       result += (char)(i%26 + 'a');
     }
     System.out.println("Call to buildString took " +
       (System.currentTimeMillis()-start) + " ms.");
     return result;
   }

用 Javassist 來做

來做使用 Javassist 操作類字節碼以得到同樣的效果看起來應該不難。Javassist 提供 了在方法的開始和結束位置添加代碼的方法,別忘了,我在為該方法中加入計時信息就是這 麼做的。

不過,還是有障礙。在描述 Javassist 是如何讓您添加代碼時,我提到添加的代碼不能 引用在方法中其他地方定義的局部變量。這種限制使我不能在 Javassist 中使用在源代碼中 使用的同樣方法實現計時代碼,在這種情況下,我在開始時添加的代碼中定義了一個新的局 部變量,並在結束處添加的代碼中引用這個變量。

那麼還有其他方法可以得到同樣的效果嗎?是的,我 可以在類中添加一個新的成員字段 ,並使用這個字段而不是局部變量。不過,這是一種糟糕的解決方案,在一般性的使用中有 一些限制。例如,考慮在一個遞歸方法中會發生的事情。每次方法調用自身時,上次保存的 開始時間值就會被覆蓋並且丟失。

幸運的是有一種更簡潔的解決方案。我可以保持原來方法的代碼不變,只改變方法名,然 後用原來的方法名增加一個新方法。這個 攔截器(interceptor)方法可以使用與原來方法同 樣的簽名,包括返回同樣的值。清單 3 展示了通過這種方法改編後源代碼看上去的樣子:

清單 3. 在源代碼中添加一個攔截器方法

private String buildString$impl(int length) {
     String result = "";
     for (int i = 0; i < length; i++) {
       result += (char)(i%26 + 'a');
     }
     return result;
   }
   private String buildString(int length) {
     long start = System.currentTimeMillis();
     String result = buildString$impl(length);
     System.out.println("Call to buildString took " +
       (System.currentTimeMillis()-start) + " ms.");
     return result;
   }

通過 Javassist 可以很好地利用這種使用攔截器方法的方法。因為整個方法是一個塊, 所以我可以毫無問題地在正文中定義並且使用局部變量。為攔截器方法生成源代碼也很容易 -- 對於任何可能的方法,只需要幾個替換。

運行攔截

實現添加方法計時的代碼要用到在 Javassist 基礎中描述的一些 Javassist API。清單 4 展示了該代碼,它是一個帶有兩個命令行參數的應用程序,這兩個參數分別給出類名和要 計時的方法名。main() 方法的正文只給出類信息,然後將它傳遞給 addTiming() 方法以處 理實際的修改。addTiming() 方法首先通過在名字後面附加“ $impl” 重命名現有的方法, 接著用原來的方法名創建該方法的一個拷貝。然後它用含有對經過重命名的原方法的調用的 計時代碼替換拷貝方法的正文。

清單4. 用 Javassist 添加攔截器方法

public class JassistTiming
{
   public static void main(String[] argv) {
     if (argv.length == 2) {
       try {

         // start by getting the class file and method
         CtClass clas = ClassPool.getDefault().get(argv[0]);
         if (clas == null) {
           System.err.println("Class " + argv[0] + " not  found");
         } else {

           // add timing interceptor to the class
           addTiming(clas, argv[1]);
           clas.writeFile();
           System.out.println("Added timing to method " +
             argv[0] + "." + argv[1]);

         }

       } catch (CannotCompileException ex) {
         ex.printStackTrace();
       } catch (NotFoundException ex) {
         ex.printStackTrace();
       } catch (IOException ex) {
         ex.printStackTrace();
       }

     } else {
       System.out.println("Usage: JassistTiming class method- name");
     }
   }

   private static void addTiming(CtClass clas, String mname)
     throws NotFoundException, CannotCompileException {

     // get the method information (throws exception if method  with
     // given name is not declared directly by this class,  returns
     // arbitrary choice if more than one with the given name)
     CtMethod mold = clas.getDeclaredMethod(mname);

     // rename old method to synthetic name, then duplicate  the
     // method with original name for use as interceptor
     String nname = mname+"$impl";
     mold.setName(nname);
     CtMethod mnew = CtNewMethod.copy(mold, mname, clas,  null);

     // start the body text generation by saving the start  time
     // to a local variable, then call the timed method; the
     // actual code generated needs to depend on whether the
     // timed method returns a value
     String type = mold.getReturnType().getName();
     StringBuffer body = new StringBuffer();
     body.append("{\nlong start = System.currentTimeMillis();\n");
     if (!"void".equals(type)) {
       body.append(type + " result = ");
     }
     body.append(nname + "($$);\n");

     // finish body text generation with call to print the  timing
     // information, and return saved value (if not void)
     body.append("System.out.println(\"Call to method " + mname +
       " took \" +\n (System.currentTimeMillis()-start) + " +
       "\" ms.\");\n");
     if (!"void".equals(type)) {
       body.append("return result;\n");
     }
     body.append("}");

     // replace the body of the interceptor method with  generated
     // code block and add it to class
     mnew.setBody(body.toString());
     clas.addMethod(mnew);

     // print the generated code block just to show what was  done
     System.out.println("Interceptor method body:");
     System.out.println(body.toString());
   }
}

構造攔截器方法的正文時使用一個 java.lang.StringBuffer 來累積正文文本(這顯示了 處理 String 的構造的正確方法,與在 StringBuilder 的構造中使用的方法是相對的)。這 種變化取決於原來的方法是否有返回值。如果它 有返回值,那麼構造的代碼就將這個值保存 在局部變量中,這樣在攔截器方法結束時就可以返回它。如果原來的方法類型為 void ,那 麼就什麼也不需要保存,也不用在攔截器方法中返回任何內容。

除了對(重命名的)原來方法的調用,實際的正文內容看起來就像標准的 Java 代碼。它是 代碼中的 body.append(nname + "($$);\n") 這一行,其中 nname 是原來方法修改後的名字 。在調用中使用的 $$ 標識符是 Javassist 表示正在構造的方法的一系列參數的方式。通過 在對原來方法的調用中使用這個標識符,在調用攔截器方法時提供的參數就可以傳遞給原來 的方法。

清單 5 展示了首先運行未修改過的 StringBuilder 程序、然後運行 JassistTiming 程 序以添加計時信息、最後運行修改後的 StringBuilder 程序的結果。可以看到修改後的 StringBuilder 運行時會報告執行的時間,還可以看到因為字符串構造代碼效率低下而導致 的時間增加遠遠快於因為構造的字符串長度的增加而導致的時間增加。

清單 5. 運行這個程序

[dennis]$ java StringBuilder 1000 2000 4000 8000 16000
Constructed string of length 1000
Constructed string of length 2000
Constructed string of length 4000
Constructed string of length 8000
Constructed string of length 16000
[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder  buildString
Interceptor method body:
{
long start = System.currentTimeMillis();
java.lang.String result = buildString$impl($$);
System.out.println("Call to method buildString took " +
  (System.currentTimeMillis()-start) + " ms.");
return result;
}
Added timing to method StringBuilder.buildString
[dennis]$ java StringBuilder 1000 2000 4000 8000 16000
Call to method buildString took 37 ms.
Constructed string of length 1000
Call to method buildString took 59 ms.
Constructed string of length 2000
Call to method buildString took 181 ms.
Constructed string of length 4000
Call to method buildString took 863 ms.
Constructed string of length 8000
Call to method buildString took 4154 ms.
Constructed string of length 16000

可以信任源代碼嗎?

Javassist 通過讓您處理源代碼而不是實際的字節碼指令清單而使 classworking 變得容 易。但是這種方便性也有一個缺點。正如我在 所有字節碼的源代碼中提到的,Javassist 所 使用的源代碼與 Java 語言並不完全一樣。除了在代碼中識別特殊的標識符外,Javassist 還實現了比 Java 語言規范所要求的更寬松的編譯時代碼檢查。因此,如果不小心,就會從 源代碼中生成可能會產生令人感到意外的結果的字節碼。

作為一個例子,清單 6 展示了在將方法開始時的攔截器代碼所使用的局部變量的類型從 long 變為 int 時的情況。Javassist 會接受這個源代碼並將它轉換為有效的字節碼,但是 得到的時間是毫無意義的。如果試著直接在 Java 程序中編譯這個賦值,您就會得到一個編 譯錯誤,因為它違反了 Java 語言的一個規則:一個窄化的賦值需要一個類型覆蓋。

清單 6. 將一個 long 儲存到一個 int 中

[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder  buildString
Interceptor method body:
{
int start = System.currentTimeMillis();
java.lang.String result = buildString$impl($$);
System.out.println("Call to method buildString took " +
  (System.currentTimeMillis()-start) + " ms.");
return result;
}
Added timing to method StringBuilder.buildString
[dennis]$ java StringBuilder 1000 2000 4000 8000 16000
Call to method buildString took 1060856922184 ms.
Constructed string of length 1000
Call to method buildString took 1060856922172 ms.
Constructed string of length 2000
Call to method buildString took 1060856922382 ms.
Constructed string of length 4000
Call to method buildString took 1060856922809 ms.
Constructed string of length 8000
Call to method buildString took 1060856926253 ms.
Constructed string of length 16000

取決於源代碼中的內容,甚至可以讓 Javassist 生成無效的字節碼。清單7展示了這樣的 一個例子,其中我將 JassistTiming 代碼修改為總是認為計時的方法返回一個 int 值。 Javassist 同樣會毫無問題地接受這個源代碼,但是在我試圖執行所生成的字節碼時,它不 能通過驗證。

清單7. 將一個 String 儲存到一個 int 中

[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder  buildString
Interceptor method body:
{
long start = System.currentTimeMillis();
int result = buildString$impl($$);
System.out.println("Call to method buildString took " +
  (System.currentTimeMillis()-start) + " ms.");
return result;
}
Added timing to method StringBuilder.buildString
[dennis]$ java StringBuilder 1000 2000 4000 8000 16000
Exception in thread "main" java.lang.VerifyError:
  (class: StringBuilder, method: buildString signature:
  (I)Ljava/lang/String;) Expecting to find integer on stack

只要對提供給 Javassist 的源代碼加以小心,這就不算是個問題。不過,重要的是要認 識到 Javassist 沒有捕獲代碼中的所有錯誤,所以有可能會出現沒有預見到的錯誤結果。

後續內容

Javassist 比我們在本文中所討論的內容要豐富得多。下一個月,我們將進行更進一步的 分析,看一看 Javassist 為批量修改類以及為在運行時裝載類時對類進行動態修改而提供的 一些特殊的功能。這些功能使 Javassist 成為應用程序中實現方面的一個很棒的工具,所以 一定要繼續跟隨我們了解這個強大工具的全部內容。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved