看完這一篇,讓機器幫你寫開發文檔

通過這一篇博客,可能會加深對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都找不着了😂,因爲實際需求並用不上🤣。

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章