您可曾想過像 Checkstyle 或 FindBugs 這樣的工具如何執行靜態代碼分析嗎,或者像 NetBeans 或 Eclipse 這樣的集成開發環境(Integrated Development Environments IDE)如何執行快速代碼修復或 查找在代碼中聲明的字段的完全引用嗎?在許多情況下,IDE 具有自己的 API 來解析源碼並生成標准樹 結構,稱為 抽象語法樹(Abstract Syntax Tree AST) 或“解析樹”,此樹可用於對源碼元素的進一步 分析。好消息是,借助於在 Java 中作為 Java Standard Edition 6 發行版的一部分引入的三個新 API ,現在可以實現上述任務以及其他更多任務。可能與需要執行源碼分析的 Java 應用程序開發人員相關的 API 有 Java Compiler API (JSR 199)、可插入注解處理(Pluggable Annotation Processing)API (JSR 269) 和 Compiler Tree API。
在本文中,我們探討了其中每個 API 的功能,並繼續開發一個簡單的演示應用程序,來在作為輸入提 供的一套源碼文件上驗證特定的 Java 編碼規則。此實用程序還顯示了編碼違規消息以及作為輸出的違規 源碼的位置。考慮一個簡單的 Java 類,它覆蓋 Object 類的 equals() 方法。要驗證的編碼規則是實現 equals() 方法的每個類也應該覆蓋具有合適簽名的 hashcode() 方法。您可以看到下面的 TestClass 類 沒有定義 hashcode() 方法,即使它具有 equals() 方法。
public class TestClass implements Serializable {
int num;
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if ((obj == null) || (obj.getClass() != this.getClass()))
return false;
TestClass test = (TestClass) obj;
return num == test.num;
}
}
讓我們繼續借助這三個 API 將此類作為構建過程的一部分進行分析。
從代碼中調用編譯器:Java Compiler API
我們全部使用 javac 命令行工具來將 Java 源文件編譯為類文件。那麼我們為什麼需要 API 來編譯 Java 文件呢?好的,答案極其簡單:正如名稱所示,這個新的標准 API 告訴我們從自己的 Java 應用程 序中調用編譯器;比如,可以通過編程方式與編譯器交互,從而進行應用程序級別服務的編譯部分。此 API 的一些典型使用如下。
Compiler API 幫助應用服務器最小化部署應用程序的時間,例如,避免了使用外部編譯器來編譯從 JSP 頁面中生成的 servlet 源碼的開銷
IDE 等開發人員工具和代碼分析器可以從編輯器或構建工具中調用編譯器,從而顯著降低編譯時間。
Java Compiler 類包裝在 javax.tools 包中。此包的 ToolProvider 類提供了一個名為 getSystemJavaCompiler() 的方法,此方法返回某個實現了 JavaCompiler 接口的類的實例。此編譯器實 例可用於創建一個將執行實際編譯的編譯任務。然後,要編譯的 Java 源文件將傳遞給此編譯任務。為此 ,編譯器 API 提供了一個名為 JavaFileManager 的文件管理器抽象,它允許從各種來源中檢索 Java 文 件,比如從文件系統、數據庫、內存等。在此示例中,我們使用 StandardFileManager,一個基於 java.io.File 的文件管理器。此標准文件管理器可以通過調用 JavaCompiler 的 getStandardFileManager() 方法來獲得。上述步驟的代碼段如下所示:
//Get an instance of java compiler
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
//Get a new instance of the standard file manager implementation
StandardJavaFileManager fileManager = compiler.
getStandardFileManager(null, null, null);
// Get the list of java file objects, in this case we have only
// one file, TestClass.java
Iterable<? extends JavaFileObject> compilationUnits1 =
fileManager.getJavaFileObjectsFromFiles("TestClass.java");
診斷監聽 器可以傳遞給 getStandardFileManager() 方法來生成任何非致命問題的診斷報告。在此代碼段中,我們 傳遞 null 值,因為我們准備從此工具中收集診斷。有關傳遞給這些方法的其他參數的詳細信息,請參閱 Java 6 API。StandardJavaFileManager 的 getJavaFileObjectsfromFiles() 方法返回與所提供的 Java 源文件相java JavaFileObject 實例。
下一步是創建 Java 編譯任務,這可以使用 JavaCompiler 的 getTask() 方法來獲得。這時,編譯任 務尚未啟動。此任務可以通過調用 CompilationTask 的 call() 方法來觸發。創建和觸發編譯任務的代 碼段如下所示。
// Create the compilation task
CompilationTask task = compiler.getTask(null, fileManager, null,
null, null, compilationUnits1);
// Perform the compilation task.
task.call();
假設沒有任何編譯錯誤,這將在目標目錄中生成 TestClass.class 文件 。
注解處理:可插入的注解處理 API
眾所周知,Java SE 5.0 引入了在 Java 類、字段、方法等元素中添加和處理元數據或注解的支持。 注解通常由構建工具或運行時環境處理以執行有用的任務,比如控制應用程序行為,生成代碼等。Java 5 允許對注解數據進行編譯時和運行時處理。注解處理器是可以動態插入到編譯器中以在其中分析源文件和 處理注解的實用程序。注解處理器可以完全利用元數據信息來執行許多任務,包括但不限於下列任務。
注解可用於生成部署描述符文件,例如,對於實體類和企業 bean,分別生成 persistence.xml 或 ejb-jar.xml。
注解處理器可以使用元數據信息來生成代碼。例如,處理器可以生成正確注解的企業 bean 的 Home 和 Remote 接口。
注解可用於驗證代碼或部署單元的有效性。
Java 5.0 提供了一個 注解處理工具(Annotation Processing Tool APT) 和一個相關聯的基於鏡像 的反射 API (com.sun.mirror.*),以處理注解和模擬處理的信息。APT 工具為所提供的 Java 源文件中 出現的注解運行相匹配的注解處理器。鏡像 API 提供了源文件的編譯時只讀視圖。APT 的主要缺點是它 沒有標准化;比如,APT 是特定於 Sun JDK 的。
Java SE 6 引入了一個新的功能,叫做 可插入注解處理(Pluggable Annotation Processing) 框架 ,它提供了標准化的支持來編寫自定義的注解處理器。之所以稱為“可插入”,是因為注解處理器可以動 態插入到 javac 中,並可以對出現在 Java 源文件中的一組注解進行操作。此框架具有兩個部分:一個 用於聲明注解處理器並與其交互的 API -- 包 javax.annotation.processing -- 和一個用於對 Java 編 程語言進行建模的 API -- 包 javax.lang.model。
編寫自定義注解處理器
下一節解釋如何編寫自定義注解處理器,並將其插入到編譯任務中。自定義注解處理器繼承 AbstractProcessor(這是 Processor 接口的默認實現),並覆蓋 process() 方法。
注解處理器類將使用兩個類級別的注解 @SupportedAnnotationTypes 和 @SupportedSourceVersion 來裝飾。 SupportedSourceVersion 注解指定注解處理器支持的最新的源版本。 SupportedAnnotationTypes 注解指示此特定的注解處理器對哪些注解感興趣。例如,如果處理器只需處 理 Java Persistence API (JPA) 注解,則將使用 @SupportedAnnotationTypes ("javax.persistence.*")。值得注意的一點是,如果將支持的注解類型指定為 @SupportedAnnotationTypes("*"),即使沒有任何注解,仍然會調用注解處理器。這允許我們有效利用建 模 API 以及 Tree API 來執行通用的源碼處理。使用這些 API,可以獲得與修改符、字段、方法但等有 關的大量有用的信息。自定義注解處理器的代碼段如下所示:
@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes("*")
public class CodeAnalyzerProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnvironment) {
for (Element e : roundEnvironment.getRootElements()) {
System.out.println("Element is "+ e.getSimpleName());
// Add code here to analyze each root element
}
return true;
}
}
是否調用注解處理器取決於源碼中存在哪些注解,哪些處理器配置為可用,哪些注解 類型是可用的後處理器進程。注解處理可能發生在多個輪回中。例如,在第一個輪回中,將處理原始輸入 Java 源文件;在第二個輪回中,將考慮處理由第一個輪回生成的文件,等等。自定義處理器應覆蓋 AbstractProcessor 的 process()。此方法接受兩個參數:
源文件中找到的一組 TypeElements/ 注解。
封裝有關注解處理器當前處理輪回的信息的 RoundEnvironment。
如果處理器聲明其支持的注解類型,則 process() 方法返回 true,而不會為這些注解調用其他處理 器。否則,process() 方法返回 false 值,並將調用下一個可用的處理器(如果存在的話)。
插入到注解處理器中
既然自定義注解處理器已經可供使用,現在讓我們來看如何作為編譯過程的一部分來調用此處理器。 此處理器可以通過 javac 命令行實用程序或以編程方式通過獨立 Java 類來調用。Java SE 6 的 javac 實用程序提供一個稱為 -processor 的選項,來接受要插入到的注解處理器的完全限定名。語法如下:
javac -processor demo.codeanalyzer.CodeAnalyzerProcessor TestClass.java
其中 CodeAnalyzerProcessor 是注解處理器類,TestClass 是要處理的 輸入 Java 文件。此實用程序在類路徑中搜索 CodeAnalyzerProcessor;因此,一定要將此類放在類路徑 中。
以編程方式插入到處理器中的修改後的代碼段如下。CompilationTask 的 setProcessors() 方法允許 將多個注解處理器插入到編譯任務中。此方法需要在 call() 方法之前調用。還要注意,如果注解處理器 插入到編譯任務中,則注解處理首先發生,然後才是編譯任務。不用說,如果代碼導致編譯錯誤,則注解 處理將不會發生。
CompilationTask task = compiler.getTask(null, fileManager, null,
null, null, compilationUnits1);
// Create a list to hold annotation processors
LinkedList<AbstractProcessor> processors = new LinkedList<AbstractProcessor> ();
// Add an annotation processor to the list
processors.add(new CodeAnalyzerProcessor());
// Set the annotation processor to the compiler task
task.setProcessors(processors);
// Perform the compilation task.
task.call();
如果執行上述代碼,它將導致注解處理器在用於打印名稱“TestClass” 的 TestClass.java 的編譯期間啟動。
訪問抽象語法樹:Compiler Tree API
抽象語法樹(Abstract Syntax Tree)是將 Java 表示為節點樹的來源的只讀視圖,其中每個節點表 示一個 Java 編程語言構造或樹,每個節點的子節點表示這些樹有意義的組件。例如,Java 類表示為 ClassTree,方法聲明表示為 MethodTree,變量聲明表示為 VariableTree,注解表示為 AnnotationTree ,等等。
Compiler Tree API 提供 Java 源碼的抽象語法樹(Abstract Syntax Tree),還提供 TreeVisitor 、TreeScanner 等實用程序來在 AST 上執行操作。對源碼內容的進一步分析可以使用 TreeVisitor 來完 成,它訪問所有子樹節點以提取有關字段、方法、注解和其他類元素的必需信息。樹訪問器以訪問器設計 模式的風格來實現。當訪問器傳遞給樹的接受方法時,將調用此樹最適用的 visitXYZ 方法。
Java Compiler Tree API 提供 TreeVisitor 的三種實現;即 SimpleTreeVisitor、 TreePathScanner 和 TreeScanner。演示應用程序使用 TreePathScanner 來提取有關 Java 源文件的信 息。 TreePathScanner 是訪問所有子樹節點並提供對維護父節點路徑的支持的 TreeVisitor。需要調用 TreePathScanner 的 scan() 方法才能遍歷樹。要訪問特定類型的節點,只需覆蓋相應的 visitXYZ 方法 。在訪問方法中,調用 super.visitXYZ 以訪問後代節點。典型訪問器類的代碼段如下:
public class CodeAnalyzerTreeVisitor extends TreePathScanner<Object, Trees> {
@Override
public Object visitClass(ClassTree classTree, Trees trees) {
---- some code ----
return super.visitClass(classTree, trees);
}
@Override
public Object visitMethod(MethodTree methodTree, Trees trees) {
---- some code ----
return super.visitMethod(methodTree, trees);
}
}
可以看到訪問方法接受兩個參數:表示節點的樹(ClassTree 表示類節點), MethodTree 表示方法節點,等),和 Trees 對象。Trees 類提供用於提取樹中元素信息的實用程序方法 。必須注意,Trees 對象是 JSR 269 和 Compiler Tree API 之間的橋梁。在本例中,只有一個根元素, 即 TestClass 本身。
CodeAnalyzerTreeVisitor visitor = new CodeAnalyzerTreeVisitor();
@Override
public void init(ProcessingEnvironment pe) {
super.init(pe);
trees = Trees.instance(pe);
}
for (Element e : roundEnvironment.getRootElements()) {
TreePath tp = trees.getPath(e);
// invoke the scanner
visitor.scan(tp, trees);
}
下一節介紹使用 Tree API 來檢索源碼信息,並填充將來用於代碼驗證的通用模型。不管何時在使用 ClassTrees 作為參數的 AST 中訪問類、接口或枚舉類型,都會調用 visitClass() 方法。同樣地,對於 使用 MethodTree 作為參數的所有方法,調用 visitMethod() 方法,對於使用 VariableTree 作為參數 的所有變量,調用 visitVariable(),等等。
@Override
public Object visitClass(ClassTree classTree, Trees trees) {
//Storing the details of the visiting class into a model
JavaClassInfo clazzInfo = new JavaClassInfo();
// Get the current path of the node
TreePath path = getCurrentPath();
//Get the type element corresponding to the class
TypeElement e = (TypeElement) trees.getElement(path);
//Set qualified class name into model
clazzInfo.setName(e.getQualifiedName().toString());
//Set extending class info
clazzInfo.setNameOfSuperClass(e.getSuperclass().toString());
//Set implementing interface details
for (TypeMirror mirror : e.getInterfaces()) {
clazzInfo.addNameOfInterface(mirror.toString());
}
return super.visitClass(classTree, trees);
}
此代碼段中使用的 JavaClassInfo 是用於存儲有關 Java 代碼的信息的自定義模 型。執行此代碼之後,與類有關的信息,比如完全限定的類名稱、超類名稱、由 TestClass 實現的接口 等,被提取並存儲在自定義模型中以供將來驗證。
設置源碼位置
到目前為止,我們一直在忙於獲取有關 AST 各種節點的信息,並填充類、方法和字段信息的模型對象 。使用此信息,我們可以驗證源碼是否遵循好的編程實踐,是否符合規范等。此信息對於 Checkstyle 或 FindBugs 等驗證工具十分有用,但它們可能還需要有關違反此規則的源碼令牌的位置詳細信息,以便將 錯誤位置詳細信息提供給用戶。
SourcePositions 對象是 Compiler Tree API 的一部分,用於維護編譯單位樹中所有 AST 節點的位 置。此對象提供有關文件中 ClassTree、MethodTree、 FieldTree 等樹的開始位置和結束位置的有用信 息。位置定義為從 CompilationUnit 開始位置開始的簡單字符偏移,其中第一個字符位於偏移 0。下列 代碼段顯示如何獲得傳遞的 Tree 樹從編譯單位開始位置開始的字符偏移位置。
public static LocationInfo getLocationInfo(Trees trees,
TreePath path, Tree tree) {
LocationInfo locationInfo = new LocationInfo();
SourcePositions sourcePosition = trees.getSourcePositions();
long startPosition = sourcePosition.
getStartPosition(path.getCompilationUnit(), tree);
locationInfo.setStartOffset((int) startPosition);
return locationInfo;
}
但是,如果我們需要獲得提供類或方法本身名稱的令牌的位置,則這些信息將不夠。 要查找源碼中的實際令牌位置,一個選項是搜索源碼文件中 char 內容內的令牌。我們可以從與如下所示 編譯單位相應的 JavaFileObject 中獲取 char 內容。
//Get the compilation unit tree from the tree path
CompilationUnitTree compileTree = treePath.getCompilationUnit();
//Get the java source file which is being processed
JavaFileObject file = compileTree.getSourceFile();
// Extract the char content of the file into a string
String javaFile = file.getCharContent(true).toString();
//Convert the java file content to a character buffer
CharBuffer charBuffer = CharBuffer.wrap (javaFile.toCharArray());
下列代碼段查找源碼中類名稱令牌的位置。java.util.regex.Pattern 和 java.util.regex.Matcher 類用於獲取類名稱令牌的實際位置。Java 源碼的內容使用 java.nio.CharBuffer 轉換為字符緩沖器。匹 配器從編譯單位樹中類樹的開始位置開始,搜索字符緩沖器中與類名相匹配的令牌的第一次出現。
LocationInfo clazzNameLoc = (LocationInfo) clazzInfo.
getLocationInfo();
int startIndex = clazzNameLoc.getStartOffset();
int endIndex = -1;
if (startIndex >= 0) {
String strToSearch = buffer.subSequence(startIndex,
buffer.length()).toString();
Pattern p = Pattern.compile(clazzName);
Matcher matcher = p.matcher(strToSearch);
matcher.find();
startIndex = matcher.start() + startIndex;
endIndex = startIndex + clazzName.length();
}
clazzNameLoc.setStartOffset(startIndex);
clazzNameLoc.setEndOffset(endIndex);
clazzNameLoc.setLineNumber(compileTree.getLineMap().
getLineNumber(startIndex));
Complier Tree API 的 LineMap 類提 供 CompilationUnitTree 中字符位置和行號的映射。我們可以通過將開始偏移位置傳遞給 CompilationUnitTree 的 getLineMap() 方法來獲取所關注令牌的行號。
按照規則驗證源碼
既然已經從 AST 中成功檢索了所需的信息,下一個任務就是驗證所考慮的源碼是否滿足預定義的編碼 標准。編碼規則在 XML 文件中配置,並由名為 RuleEngine 的自定義類管理。此類從 XML 文件中提取規 則,並一個一個地將其啟動。如果此類不滿足某個規則,則此規則將返回 ErrorDescription 對象的列表 。 ErrorDescription 對象封裝錯誤消息和錯誤在源碼中的位置。
ClassFile clazzInfo = ClassModelMap.getInstance().
getClassInfo(className);
for (JavaCodeRule rule : getRules()) {
// apply rules one by one
Collection<ErrorDescription> problems = rule.execute(clazzInfo);
if (problems != null) {
problemsFound.addAll(problems);
}
}
每個規則實現為 Java 類;要驗證的類的模型信息傳遞給此類。規則類封裝邏輯以使 用此模型信息驗證規則邏輯。示例規則 (OverrideEqualsHashCode) 的實現如下所示。此規則規定覆蓋 equal() 方法的類還應該覆蓋 hashcode() 方法。在此,我們遍歷類的方法並檢查它是否遵循 equals() 和 hashcode() 合同。在 TestClass 中,hashcode() 方法不存在,而 equals() 方法存在,從而導致規 則返回 ErrorDescription 模型,其中包含適當的錯誤消息和錯誤的位置詳細信息。
public class OverrideEqualsHashCode extends JavaClassRule {
@Override
protected Collection<ErrorDescription> apply(ClassFile clazzInfo) {
boolean hasEquals = false;
boolean hasHashCode = false;
Location errorLoc = null;
for (Method method : clazzInfo.getMethods()) {
String methodName = method.getName();
ArrayList paramList = (ArrayList) method.getParameters();
if ("equals".equals(methodName) && paramList.size() == 1) {
if ("java.lang.Object".equals(paramList.get(0))) {
hasEquals = true;
errorLoc = method.getLocationInfo();
}
} else if ("hashCode".equals(methodName) &&
method.getParameters().size() == 0) {
hasHashCode = true;
}
}
if (hasEquals) {
if (hasHashCode) {
return null;
} else {
StringBuffer errrMsg = new StringBuffer();
errrMsg.append(CodeAnalyzerUtil.
getSimpleNameFromQualifiedName(clazzInfo.getName()));
errrMsg.append(" : The class that overrides
equals() should ");
errrMsg.append("override hashcode()");
Collection<ErrorDescription> errorList = new
ArrayList<ErrorDescription>();
errorList.add(setErrorDetails(errrMsg.toString(),
errorLoc));
return errorList;
}
}
return null;
}
}
運行樣例
可以從 參考資料 部分中下載此演示應用程序的二進制文件。將此文件保存到任何本地目錄中。在命 令提示符中使用下列命令執行此應用程序:
java -classpath \lib\tools.jar;.; demo.codeanalyzer.main.Main
結束語
本文討論如何使用新 Java 6 API 來從源碼中調用編譯器,如何使用可插入的注解處理器和樹訪問器 來解析和分析源碼。使用標准 Java API 而非特定於 IDE 的解析/分析邏輯使得代碼可以在不同的工具和 環境之間重用。我們在此只粗略描繪了與編譯器相關的三個 API 的表面;您可以通過進一步深入這些 API 來找到其他許多更有用的功能。