通過這一篇博客,可能會加深對AnnotationProcessor、SPI機制等的理解,可能會誘發你對已有的知識產生天馬行空的使用想法
背景
你是否在工作中遇到這樣的場景:“XX,你還記得我們項目裏面有寫過助手類,能把字符串格式化成這個樣子嗎。然後XX一臉懵逼”,你又是否聽過這樣的吐槽:“什麼low biiii中臺,寫個功能庫連個像樣的文檔都寫不出來,難道讓我去看他底褲(代碼)??”
我們總說優秀的代碼不需要文檔,看到就明白他的意思。快別搞笑了,不是每個人都敢把底褲漏給別人看的,畢竟程序員之間相互尊重的一大準則就是別輕易去看別人代碼😂,畢竟每個人編碼的思路不會完全一致,而且在對需求的理解不夠深刻的情況下去看別人的代碼,總是會看的很不爽的。半開玩笑的講,程序員一般不寫文檔(懶😂),寫文檔就是爲了讓別人別看代碼。
軟件開發文檔是軟件開發使用和維護過程中的必備資料。它能提高軟件開發的效率,保證軟件的質量,而且在軟件的使用過程中有指導,幫助,解惑的作用,尤其在維護工作中,文檔是不可或缺的資料
言歸正傳,開發文檔包括:《功能要求》、《投標方案》、《需求分析》、《技術分析》、《系統分析》、《數據庫文檔》、《功能函數文檔》、《界面文檔》、《編譯手冊》、《項目總結》等。不過在敏捷開發背景(或者打着名義壓縮工期背景)下,有些文檔往往略去了,對於迭代維護中,能夠起到較大幫助的往往是《系統分析》、《數據庫文檔》、《功能函數文檔》這三塊文檔。
《系統分析》 -- 包括功能實現、模塊組成、功能流程圖、函數接口、數據字典、軟件開發需要考慮的各種問題等。以《需求分析》爲基礎,進行詳細的系統分析 ( 產品的開發和實現方法 ) ,估計開發期間需要把什麼問題說明白,程序員根據《系統分析》,開始在項目主管的帶領下進行編碼。
《數據庫文檔》 -- 包括數據庫名稱、表名、字段名、字段類型、字段說明、備註、字段數值計算公式等。以《系統分析》爲基礎,進行詳細的數據庫設計。必要時可以用圖表解說,特別是關係數據庫。
7. 《功能函數文檔》 -- 包括變量名、變量初值、功能,函數名,參數,如何調用、備註、注意事項等。以《系統分析》爲基礎,進行詳細的說明,列出哪個功能涉及多少個函數,以便以後程序員修改、接手和擴展
在實際的開發工作中,我們可能並沒有充足的時間去完成一份詳實的文檔,在遇到一個較大規模的系統性問題時,纔有可能寫一份概要的、內容上涵蓋:“功能要求”+“技術分析”+“系統分析”+“功能函數、數據庫文檔”的大雜燴文檔,但是這樣的機會確實要視各個公司情況而定,而且需要手寫文檔、人工維護,如果公司給到的時間並不充足,那麼平時的軟件迭代、維護工作就會遇到一些障礙。
是否可以自動維護文檔
我們上面的背景聊得有點多了,可能大家在平時的工作中也接觸過服務端輸出的接口文檔(畢竟是剛需),除去手動維護文檔,也可以利用註解生成文檔,比如swagger之類的。那麼我們是否可以在移動端領域中,使用類似的機制,爲我們生成一些有效的文檔、或者是文檔的前期材料呢?
這裏呢先岔開說一段,我們都接觸過javadoc,也可以直接將javadoc生成文檔,但是這種生成的文檔我們使用的可能性是很低,javadoc往往僅作爲閱讀代碼時的輔助。我們不考慮使用這種方式生成獨立的文檔。
好了,讓我們來回憶一下,我們在軟件維護工作中,我們最希望獲得哪些信息,例如:
- 系統中的角色、角色的功能
- 某些類的實際含義,界面對應的類,界面的路由
- 某些特定功能的函數
- 一段代碼的主要邏輯 等等
這裏有些信息是方便我們正向閱讀代碼的,如“系統中的角色、角色的功能”;有些信息是爲了方便反向查找代碼,如界面對應的類、界面的路由,特定功能的函數等。這些信息我們可以用註解的方式,寫入源代碼之中,並且可以利用一定的機制、直接整合爲文檔輸出,直接作爲手冊文檔或者某類文檔的素材;而一段代碼的主要邏輯,以代碼註釋的形式更有意義。
說到這裏,有經驗的同學都會想起Annotation Processor技術,本篇博客中的內容也是基於使用APT的註解處理實現的,但是絕不是唯一選擇。
註解處理器是(Annotation Processor)是javac的一個工具,在編譯時掃描、處理註解(Annotation)
Annotation Processing Tool (APT),一個插件工具,可以讓Android的編譯過程中使用註解處理器,後被Google官方方案替代,但這個名詞一直被沿用
Talk is cheap,here is the code
https://github.com/leobert-lan/ReportPrinter 這是sample項目和庫源碼。
熟悉套路的同學會直接去尋找AbstractProcessor的實現類,去查看主要邏輯,所幸代碼不是太長,我們直接全貼上來
@AutoService(Processor.class)
@SupportedOptions({KEY_MODULE_NAME, MODE, ACTIVE, CLZ_WRITER})
public class ReportProcessor extends AbstractProcessor {
private Set<ReporterExtension> extensions;
private final ClassLoader loaderForExtensions;
private Logger logger;
private Elements elements;
private String module;
private Mode _mode;
private State _state;
private WriterType _writerType;
private Filer filer;
public ReportProcessor() {
this(ReportProcessor.class.getClassLoader());
}
private ReportProcessor(ClassLoader loaderForExtensions) {
this.loaderForExtensions = loaderForExtensions;
this.extensions = null;
}
@Override
public synchronized void init(ProcessingEnvironment env) {
super.init(env);
elements = env.getElementUtils();
logger = new Logger(env.getMessager());
String mode = "";
String state = "";
String writerType = "";
Map<String, String> options = env.getOptions();
if (MapUtils.isNotEmpty(options)) {
module = options.get(KEY_MODULE_NAME);
mode = options.get(MODE);
state = options.get(ACTIVE);
writerType = options.get(CLZ_WRITER);
logger.info(">>> module is " + module + " mode is:" + mode +
" state is:" + state + " writerType is:" + writerType +
" <<<");
}
if (module == null || module.equals("")) {
module = "default";
}
_mode = Mode.customValueOf(mode);
_state = State.customValueOf(state);
_writerType = WriterType.customValueOf(writerType);
filer = env.getFiler();
try {
extensions =
ImmutableSet.copyOf(ServiceLoader.load(ReporterExtension.class, loaderForExtensions));
StringBuilder tmp = new StringBuilder();
for (ReporterExtension ext : extensions) {
tmp.append(ext.getClass().getName()).append(" ; ");
}
logger.info(">>> check extensions:" + tmp.toString());
// ServiceLoader.load returns a lazily-evaluated Iterable, so evaluate it eagerly now
// to discover any exceptions.
} catch (Throwable t) {
StringBuilder warning = new StringBuilder();
warning.append("An exception occurred while looking for ReporterExtension extensions. "
+ "No extensions will function.");
if (t instanceof ServiceConfigurationError) {
warning.append(" This may be due to a corrupt jar file in the compiler's classpath.");
}
warning.append(" Exception: ")
.append(t);
logger.warning(warning.toString());
extensions = ImmutableSet.of();
}
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latest();
}
@Override
public Set<String> getSupportedAnnotationTypes() {
Set<String> supportedAnnotations = Sets.newLinkedHashSet();
for (ReporterExtension ext : extensions) {
supportedAnnotations.addAll(ext.applicableAnnotations());
}
return supportedAnnotations;
}
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
try {
internalProcess(set, roundEnvironment);
} catch (Exception e) {
logger.error(e);
}
return false;
}
private boolean internalProcess(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) throws Exception {
if (State.Off.equals(_state)) {
logger.warning(">>> reporter off");
return false;
}
if (CollectionUtils.isNotEmpty(set)) {
boolean handleByAnyOne = false;
for (ReporterExtension ext : extensions) {
Set<String> targetAnnotations = ext.applicableAnnotations();
Map<String, List<Model>> previousData = new HashMap<>();
for (String anno : targetAnnotations) {
List<Model> modelsForOneAnnotation = new ArrayList<>();
TypeElement annoType = elements.getTypeElement(anno);
Set<? extends Element> hitElements = roundEnvironment.getElementsAnnotatedWith(annoType);
for (Element element : hitElements) {
Model model = Model.newBuilder()
.annoType(annoType)
.element(element)
.elementKind(element.getKind())
.name(findElementName(element))
.build();
modelsForOneAnnotation.add(model);
}
previousData.put(anno, modelsForOneAnnotation);
}
Result result = ext.generateReport(previousData);
if (result == null)
continue;
handleByAnyOne = handleByAnyOne | result.isHandled();
if (result.isHandled()) {
if (Mode.MODE_FILE.equals(_mode))
generateReport(result);
else
generateExtReportJavaFile(result);
}
}
// return handleByAnyOne; change to always false
}
return false;
}
private String findElementName(Element element) {
String name = "unknown element name";
try {
if (element.getKind().isClass() || element.getKind().isInterface()) {
String path = element.getEnclosingElement().toString();
String simpleName = element.getSimpleName().toString();
name = path + "." + simpleName;
} else {//field
name = findLocation(element);
}
} catch (Exception e) {
logger.warning(e.getMessage());
}
return name;
}
private String findLocation(Element element) {
String path = "";
if (element != null) {
Element parent = element.getEnclosingElement();
if (element.getKind().isClass() || element.getKind().isInterface()) {
String p = element.getEnclosingElement().toString();
String s = element.getSimpleName().toString();
return p + "." + s;
}
return findLocation(parent) +
"#" + element.toString();
}
return path;
}
private void generateReport(Result result) {
String fileName = Utils.generateReportFilePath(module + result.getReportFileNamePrefix(), result.getFileExt());
logger.info("generate " + fileName);
if (Utils.createFile(fileName)) {
Utils.writeStringToFile(fileName, result.getReportContent(), false);
logger.info("generate success");
} else {
logger.info("generate failure");
}
}
private void generateExtReportJavaFile(Result result) {
String fileName = Utils.genFileName(module + result.getReportFileNamePrefix(), result.getFileExt());
logger.info("generate " + fileName);
Writeable writeable = getWriteable();
Utils.generatePrinterClass(Utils.genReporterClzName(module + result.getReportFileNamePrefix()),
fileName,
result.getReportContent(),
writeable);
logger.info("generate success");
}
private Writeable getWriteable() {
if (WriterType.Custom.equals(_writerType)) {
return Writeable.DirectionWriter.of(new File("./" + module + "/ext"));
} else {
return Writeable.FilerWriter.of(filer);
}
}
}
經過簡單的代碼閱讀,我們瞭解到:
- 在init中獲取了用戶配置、並且做了這樣一件事:
ServiceLoader.load(ReporterExtension.class, loaderForExtensions)
- 在獲取支持的的註解時,我們用了第一步中得到的ReporterExtension
- 在 public abstract boolean process(Set<? extends TypeElement> var1, RoundEnvironment var2); 的實現中,對掃描到的被註解的類或者類成員,並轉換爲數據模型後按照不同的配置進入處理
- 在處理時,我們使用了第一步中的到的ReporterExtension,並將處理完的結果,輸出爲文件。
主要邏輯就是上面這些內容,所以我們要去看看第一步到底做了啥。
ReporterExtension何許人也?一個接口而已。
public interface ReporterExtension {
Set<String> applicableAnnotations();
Result generateReport(Map<String,List<Model>> previousData);
}
定義了兩個方法,可以處理哪些註解,生成文檔內容。大家應該都聽說過SPI機制,其實AnnotationProcessor也是SPI機制的一種利用。下面我會費點筆墨介紹下SPI機制,熟悉的同學可以略過下一節
SPI機制
Service Provider Interface(SPI):是JDK內置的一種服務提供發現機制,可以用來啓用框架擴展和替換組件,主要是被框架的開發人員使用。Java中SPI機制主要思想是將裝配的控制權移到程序之外,在模塊化設計中這個機制尤其重要,其核心思想就是解耦。
用大白話來說,框架開發人員實現了一套系統,這套系統的主流程都已經實現好了,但是流程中的某些細節部分,是需要交給業務方的。比較理想的情況是框架對業務方沒有啥代碼邊界,框架的初始化過程基本透明,那麼可以進行依賴注入,但是這個情況是比較理想的,即使要求每個框架開發團隊都自己幹一套,也沒法實現統一性😂。
於是乎誕生了這麼一套服務發現機制,約定了要使用這套機制,必須使用標準服務接口,例如我們上面提到的ReporterExtension,然後業務方實現接口,並在resources/META-INF/services/目錄下創建一個和標準服務接口對應的文件,以本文代碼爲例:
文件名爲:osp.leobert.android.reportprinter.spi.ReporterExtension,其內容爲實現類的全類名。
以上的內容,都只是“約定”,因爲實際運行時,框架層的代碼和業務層的代碼都會加載到同一個虛擬機中,彼此是“透明”的,就可以使用反射獲取對象實例了。而ServiceLoader類可以按照上述的“約定”,尋找到標準服務接口的實現類並且通過反射生成實例。具體的代碼就不展示了,有興趣的同學還請自行查閱。
其實AutoService也是和SPI機制有關的,他可以幫助我們在對應目錄下生成此文件。
爲什麼要使用SPI機制
其實這是一個順理成章的事情,作爲框架層,我並不清楚使用者到底需要爲哪些信息生成文檔,並不清楚文檔內容的組織形式,所以基於約定,框架先詢問業務方需要收集哪些註解信息,然後掃描源碼尋找信息,將註解和被註解者的信息打包交給業務方,業務方處理後生成文檔的實際內容交給框架,框架按照配置信息輸出文檔。
自定義:如何按照SPI機制擴展自己的文檔生成器
先介紹下DEMO中的內容,實際操作時可以對照:
- ':kotlin_sample' :在kotlin項目中的使用演示
- ':sample':在Java項目中的使用演示
- ':ReportNotation':標準服務接口等,老版本中還有一些註解,即默認包含了一些可被生成的文檔,後被移除
- ':report-anno-compiler':註解處理器
- ':DemoReporterExt':一個例子,可以爲“@Demo”註解生成文檔
- ':utils_reporter':一個例子,可以爲“@Util”註解生成文檔,並利用了net.steppschuh.markdowngenerator:markdowngenerator按MarkDown語法編輯文檔
我們可以參考後兩個去按需擴展。
我們以DemoReporterExt爲例簡單閱讀一下代碼。
首先聲明依賴:
implementation project(":ReportNotation")
//目前發佈到倉庫的版本:osp.leobert.android:ReportNotation:1.1.2
annotationProcessor 'com.google.auto.service:auto-service:1.0-rc3'
implementation 'com.google.auto.service:auto-service:1.0-rc3'
然後聲明你需要的註解:
package osp.leobert.android.reporter.demoext;
public @interface Demo {
String foo();
}
target和Retention用默認的也就夠了,或者按需定義。
定義服務接口實現
package osp.leobert.android.reporter.demoext;
import com.google.auto.service.AutoService;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import osp.leobert.android.reportprinter.spi.Model;
import osp.leobert.android.reportprinter.spi.ReporterExtension;
import osp.leobert.android.reportprinter.spi.Result;
@AutoService(ReporterExtension.class)
public class FooReporter implements ReporterExtension {
@Override
public Set<String> applicableAnnotations() {
return Collections.singleton(Demo.class.getName());
}
@Override
public Result generateReport(Map<String, List<Model>> previousData) {
if (previousData == null)
return null;
List<Model> demoModels = previousData.get(Demo.class.getName());
if (demoModels == null || demoModels.isEmpty())
return Result.newBuilder().handled(false).build();
StringBuilder stringBuilder = new StringBuilder();
for (Model model : demoModels) {
Demo demo = model.getElement().getAnnotation(Demo.class);
String foo = demo.foo();
stringBuilder.append(model.getElementKind().toString())
.append(" :")
.append(model.getName())
.append("\r\n")
.append("foo:")
.append(foo)
.append("\r\n");
}
return Result.newBuilder()
.handled(true)
.reportFileNamePrefix("Demo")
.fileExt("md")
.reportContent(stringBuilder.toString())
.build();
}
}
直接用AutoService幫助生成resource文件。applicableAnnotations 方法中返回的Set只包含Demo註解的全類名,如果關心多個註解,就都加上,注意,一個擴展只能生成一份文檔,如果需要對不同的註解生成不同的文檔,那就要實現多個擴展,而且必須拆分到不同的Module。
在 Result generateReport(Map<String, List<Model>> previousData) 方法中取出關心的註解,並按照自己的格式生成文檔信息。
最終返回一個Result模型。
是不是很簡單? 我們看看Sample項目中的使用
@Demo(foo = "foo of demo notated at clz")
public class SampleClz {
// @ChangeLog(version = "1.0.0",
// changes = {
// "f1",
// "f2"
// })
@Demo(foo = "foo of demo notated at function")
private void foo(Object bar) {
}
@Demo(foo = "foo of demo notated at field")
private int i;
}
我們得到了下面的內容:
CLASS :osp.leobert.android.reportsample.SampleClz
foo:foo of demo notated at clz
METHOD :osp.leobert.android.reportsample.SampleClz#foo(java.lang.Object)
foo:foo of demo notated at function
FIELD :osp.leobert.android.reportsample.SampleClz#i
foo:foo of demo notated at field
看起來也很簡單,畢竟我們的實現很簡單😂
可以做什麼
這是一個值得關注的問題,我們可以拿這個來做什麼?
- 我們可以輸出一些規模龐大的類簇信息,例如某系統中使用解釋器模式,類確實會比較多,維護一份文檔的價值是比較高的,尤其是業務變動還比較頻繁時,可以實時維護;又如應用中自定義的UI、試圖組件等
- 用來實現TODO或者在代碼Review過程中標記一些改善計劃
- ChangeLog
- 工具類、工具方法標記並生成手冊
- 收集廢棄類使用信息或者Lint抑制信息(SuppressWarning、SuppressLint等)
還有其他方式嗎?
這裏就不賣關子了,我們除了可以利用AnnotationProcessor去收集註解信息,也可以利用gradle編譯時掃描的時機,更有甚者可以利用運行時反射創造掃描時機。
舉個例子,我們可以另行創建一個Java項目或者Web-Service項目(例如基於Spring-Boot的項目),將源碼的sourceset配置給此項目,配置要掃描的package,在運行時掃描package下的類,繼而收集註解信息(Retention必須是Runtime了)並生成文檔。如果你還想繼續折騰,可以利用模板引擎,將md文件轉爲Html,這樣你就擁有了“線上文檔”;合理的採用歸檔機制,還可以變成一個可持續更新、可版本回溯的文檔😂。當然,這都是折騰了,沒有實際需求的話,簡單瞭解下就好了,坦率的說,這些方式我在2018年都折騰過,利用Web-Service項目運行時反射生成代碼的Demo都找不着了😂,因爲實際需求並用不上🤣。