看完这一篇,让机器帮你写开发文档

通过这一篇博客,可能会加深对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都找不着了😂,因为实际需求并用不上🤣。

 

 

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值