運用Java 6 API 分析源碼

火星人 @ 2014-03-10 , reply:0


  您可曾想過像 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");

  診斷JianTingQi可以傳遞給 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 libtools.jar;.; demo.codeanalyzer.main.Main

  結束語

  本文討論如何使用新 Java 6 API 來從源碼中調用編譯器,如何使用可插入的註解處理器和樹訪問器來解析和分析源碼.使用標準 Java API 而非特定於 IDE 的解析/分析邏輯代碼可以在不同的工具和環境之間重用.我們在此只粗略描繪了與編譯器相關的三個 API 的表面;您可以通過進一步深入這些 API 來找到其他許多更有用的功能.





[火星人 via ] 運用Java 6 API 分析源碼已經有187次圍觀

http://www.coctec.com/docs/java/show-post-61623.html