缘由
相信大家都用过 javadoc 命令或者 IDE 封装命令生成 java api doc 文档吧,但是你有没有反思过 javadoc 命令是怎么解析文件生成的呢?其实 javadoc 在 jdk 目录下只是一个可执行程序,但是这个可执行程序是基于 jdk 的 tools.jar 的一个封装,也就是说 javadoc 实现在 tools.jar 中。
很多时候我们可能会有一些奇葩的需求,譬如获取 java 文档注释搞事情,我们该怎样解析 java 文件去获取这些注释信息呢?你可能一开始想过使用正则匹配,但是这个方案其实是有兼容性问题的。或者说,你考虑过使用一些第三方库来解析 java 源码文件,但是这些库很多都是针对 java 源码的,而非源码中的注释。所以有一个超级棒的方案就是自定义 doclet,采用 javadoc 操作。
方案验证
既然说到这个方案依赖 javadoc 和 doclet,那就先去看看这方面的文档进行一下技术评估,具体参见 oracle 官方文档:
javadoc doclet
javadoc tools
javadoc 源码:
public final class javadoc extends ListResourceBundle {
public javadoc() {
}
protected final Object[][] getContents() {
return new Object[][]{{"javadoc.Body_missing_from_html_file", "Body tag missing from HTML file"}, {"javadoc.End_body_missing_from_html_file", "Close body tag missing from HTML file"}, {"javadoc.File_Read_Error", "Error while reading file {0}"}, {"javadoc.JavaScript_in_comment", "JavaScript found in documentation comment.\nUse --allow-script-in-comments to allow use of JavaScript."}, {"javadoc.Multiple_package_comments", "Multiple sources of package comments found for package \"{0}\""}, {"javadoc.class_not_found", "Class {0} not found."}, {"javadoc.error", "error"}, {"javadoc.error.msg", "{0}: error - {1}"}, {"javadoc.note.msg", "{1}"}, {"javadoc.note.pos.msg", "{0}: {1}"}, {"javadoc.warning", "warning"}, {"javadoc.warning.msg", "{0}: warning - {1}"}, {"main.Building_tree", "Constructing Javadoc information..."}, {"main.Loading_source_file", "Loading source file {0}..."}, {"main.Loading_source_files_for_package", "Loading source files for package {0}..."}, {"main.No_packages_or_classes_specified", "No packages or classes specified."}, {"main.Xusage", " -Xmaxerrs <number> Set the maximum number of errors to print\n -Xmaxwarns <number> Set the maximum number of warnings to print\n"}, {"main.Xusage.foot", "These options are non-standard and subject to change without notice."}, {"main.cant.read", "cannot read {0}"}, {"main.doclet_class_not_found", "Cannot find doclet class {0}"}, {"main.doclet_method_must_be_static", "In doclet class {0}, method {1} must be static."}, {"main.doclet_method_not_accessible", "In doclet class {0}, method {1} not accessible"}, {"main.doclet_method_not_found", "Doclet class {0} does not contain a {1} method"}, {"main.done_in", "[done in {0} ms]"}, {"main.error", "{0} error"}, {"main.errors", "{0} errors"}, {"main.exception_thrown", "In doclet class {0}, method {1} has thrown an exception {2}"}, {"main.fatal.error", "fatal error"}, {"main.fatal.exception", "fatal exception"}, {"main.file_not_found", "File not found: \"{0}\""}, {"main.illegal_locale_name", "Locale not available: {0}"}, {"main.illegal_package_name", "Illegal package name: \"{0}\""}, {"main.incompatible.access.flags", "More than one of -public, -private, -package, or -protected specified."}, {"main.internal_error_exception_thrown", "Internal error: In doclet class {0}, method {1} has thrown an exception {2}"}, {"main.invalid_flag", "invalid flag: {0}"}, {"main.locale_first", "option -locale must be first on the command line."}, {"main.malformed_locale_name", "Malformed locale name: {0}"}, {"main.more_than_one_doclet_specified_0_and_1", "More than one doclet specified ({0} and {1})."}, {"main.must_return_boolean", "In doclet class {0}, method {1} must return boolean."}, {"main.must_return_int", "In doclet class {0}, method {1} must return int."}, {"main.must_return_languageversion", "In doclet class {0}, method {1} must return LanguageVersion."}, {"main.no_source_files_for_package", "No source files for package {0}"}, {"main.option.already.seen", "The {0} option may be specified no more than once."}, {"main.out.of.memory", "java.lang.OutOfMemoryError: Please increase memory.\nFor example, on the JDK Classic or HotSpot VMs, add the option -J-Xmx\nsuch as -J-Xmx32m."}, {"main.requires_argument", "option {0} requires an argument."}, {"main.usage", "Usage: javadoc [options] [packagenames] [sourcefiles] [@files]\n -overview <file> Read overview documentation from HTML file\n -public Show only public classes and members\n -protected Show protected/public classes and members (default)\n -package Show package/protected/public classes and members\n -private Show all classes and members\n -help Display command line options and exit\n -doclet <class> Generate output via alternate doclet\n -docletpath <path> Specify where to find doclet class files\n -sourcepath <pathlist> Specify where to find source files\n -classpath <pathlist> Specify where to find user class files\n -cp <pathlist> Specify where to find user class files\n -exclude <pkglist> Specify a list of packages to exclude\n -subpackages <subpkglist> Specify subpackages to recursively load\n -breakiterator Compute first sentence with BreakIterator\n -bootclasspath <pathlist> Override location of class files loaded\n by the bootstrap class loader\n -source <release> Provide source compatibility with specified release\n -extdirs <dirlist> Override location of installed extensions\n -verbose Output messages about what Javadoc is doing\n -locale <name> Locale to be used, e.g. en_US or en_US_WIN\n -encoding <name> Source file encoding name\n -quiet Do not display status messages\n -J<flag> Pass <flag> directly to the runtime system\n -X Print a synopsis of nonstandard options and exit\n"}, {"main.warning", "{0} warning"}, {"main.warnings", "{0} warnings"}, {"tag.End_delimiter_missing_for_possible_SeeTag", "End Delimiter } missing for possible See Tag in comment string: \"{0}\""}, {"tag.Improper_Use_Of_Link_Tag", "Missing closing ''}'' character for inline tag: \"{0}\""}, {"tag.illegal_char_in_arr_dim", "Tag {0}: Syntax Error in array dimension, method parameters: {1}"}, {"tag.illegal_see_tag", "Tag {0}: Syntax Error in method parameters: {1}"}, {"tag.missing_comma_space", "Tag {0}: Missing comma or space in method parameters: {1}"}, {"tag.see.can_not_find_member", "Tag {0}: can''t find {1} in {2}"}, {"tag.see.class_not_specified", "Tag {0}: class not specified: \"{1}\""}, {"tag.see.illegal_character", "Tag {0}:illegal character: \"{1}\" in \"{2}\""}, {"tag.see.malformed_see_tag", "Tag {0}: malformed: \"{1}\""}, {"tag.see.missing_sharp", "Tag {0}: missing ''#'': \"{1}\""}, {"tag.see.no_close_bracket_on_url", "Tag {0}: missing final ''>'': \"{1}\""}, {"tag.see.no_close_quote", "Tag {0}: no final close quote: \"{1}\""}, {"tag.serialField.illegal_character", "illegal character {0} in @serialField tag: {1}."}, {"tag.tag_has_no_arguments", "{0} tag has no arguments."}};
}
}
Doclet 源码:
public abstract class Doclet {
public Doclet() {
}
public static boolean start(RootDoc var0) {
return true;
}
public static int optionLength(String var0) {
return 0;
}
public static boolean validOptions(String[][] var0, DocErrorReporter var1) {
return true;
}
public static LanguageVersion languageVersion() {
return LanguageVersion.JAVA_1_1;
}
}
通过文档我们可以发现,其实我们只用自定义一个 Doclet 类就行了,至于怎么定义其实文档中已经写的很详细了,还给出了具体代码片段,我们可以直接搬过来进行验证即可,代码如下:
public class CustomerDoclet extends Doclet {
public static boolean start(RootDoc root) {
ClassDoc[] classes = root.classes();
//注释文档信息,自己爱怎么解析组织就怎么解析了,看自己需求
return true;
}
}
public static void main(String[] args) {
String[] docArgs =
new String[] {
"-doclet", CustomerDoclet.class.getName(), "/home/yan/test/cn/test/JavaSource.java"
};
com.sun.tools.javadoc.Main.execute(docArgs);
}
简单吧,运行上面代码段就能自定义 javadoc 输出解析了。跑了下发现没问题,那就开始搞事情吧。
实现一个 gradle 插件进行 javadoc 自定义操作
这里我们为了简单和直接说明核心,所以打算实现一个检查 android、androidLibrary、java、javaLibrary 代码源文件中是否包含 javadoc @author 的插件,插件名称 gradle-javadoc-checker,具体完整插件源码可以访问 https://github.com/yanbober/gradle-javadoc-checker 获取。
注意:这部分内容需要你先对 gradle 插件开发比较熟悉才能看懂,所以建议先掌握所说的知识后进行研读。
添加依赖
dependencies {
compile gradleApi()
compile 'com.android.tools.build:gradle:3.1.0'
//tools.jar 的依赖
compile files(org.gradle.internal.jvm.Jvm.current().toolsJar)
}
编写自定义 javadoc 判断 @author 工具
public class JavaDocReader {
private static RootDoc root;
//自定义 doclet
public static class CustomerDoclet {
public static boolean start(RootDoc root) {
JavaDocReader.root = root;
return true;
}
}
//tools.jar 中 javadoc 的封装
public static RootDoc process(String[] extraArges) {
List<String> argsOrderList = new ArrayList<>();
argsOrderList.add("-doclet");
argsOrderList.add(CustomerDoclet.class.getName());
argsOrderList.addAll(Arrays.asList(extraArges));
String[] args = argsOrderList.toArray(new String[argsOrderList.size()]);
System.out.println(args);
Main.execute(args);
return root;
}
//tools.jar 中 javadoc 的封装
public static void process(List<String> sourcePaths, List<String> javapackages,
List<String> excludePackages, String outputDir) throws Exception {
String paths = list2formatString(sourcePaths, ";");
String includes = list2formatString(javapackages, ":");
String excludes = list2formatString(excludePackages, ":");
List<String> argsOrderList = new ArrayList<>();
argsOrderList.add("-doclet");
argsOrderList.add(CustomerDoclet.class.getName());
if (paths != null && paths.length() > 0) {
argsOrderList.add("-sourcepath");
argsOrderList.add(paths);
}
argsOrderList.add("-encoding");
argsOrderList.add("utf-8");
argsOrderList.add("-verbose");
if (includes != null && includes.length() > 0) {
argsOrderList.add("-subpackages");
argsOrderList.add(includes);
}
if (excludes != null && excludes.length() > 0) {
argsOrderList.add("-exclude");
argsOrderList.add(excludes);
}
String[] args = argsOrderList.toArray(new String[argsOrderList.size()]);
System.out.println(Arrays.toString(args));
//执行 tools.jar 中的 javadoc 命令
Main.execute(args);
File file = new File(outputDir);
if (!file.exists()) {
file.mkdirs();
}
file = new File(file, new Date().toString() + ".txt");
FileOutputStream outputStream = new FileOutputStream(file);
//判断每个顶级 java class 是否有编写 @author 人,没有就筛出来写入一个文件记录
ClassDoc[] classes = root.classes();
if (classes != null) {
for (int i = 0; i < classes.length; ++i) {
if (classes[i].containingClass() == null && classes[i].isPublic()) {
Tag[] authorTags = classes[i].tags("author");
if (authorTags == null || authorTags.length == 0) {
String filename = classes[i].position().file().getAbsolutePath();
outputStream.write((filename+"\r\n").getBytes());
}
}
}
}
root = null;
outputStream.flush();
outputStream.close();
}
private static String list2formatString(List<String> srcs, String div) {
StringBuilder stringBuilder = new StringBuilder();
for (int index=0; index<srcs.size(); index++) {
if (index > 0) {
stringBuilder.append(div);
}
stringBuilder.append(srcs.get(index));
}
return stringBuilder.toString();
}
}
有了 javadoc 自定义工具类,接下来编写 gradle 自定义 task 即可。
编写自定义 gradle task 进行检查
//groovy 编写
class JavaDocCheckerTask extends DefaultTask {
//自定义 task 的输入
@Input
List<String> includePackages
@Input
List<String> excludePackages
@Input
List<String> sourcePaths
//自定义 task 的输出
@OutputDirectory
String outputDir
//自定义 task 的执行逻辑
@TaskAction
void checker() {
if (sourcePaths == null || sourcePaths.size() == 0) {
throw new GradleScriptException("JavaDocCheckerTask sourcePaths params can't be null or empty!")
}
if (outputDir == null || outputDir.length() == 0) {
throw new GradleScriptException("JavaDocCheckerTask outputDir params can't be null or empty!")
}
//task 依据输出输出参数进行 javadoc 命令操作
JavaDocReader.process(sourcePaths, includePackages, excludePackages, outputDir)
}
}
有了自定义 gradle task 进行 javadoc 操作,接下来就该接入插件了。
将自定义 task 加入构建 project
先定义插件的 extension 拓展参数:
class CheckerExtension {
public static final String NAME = "javadocChecker"
List<String> includePackages
List<String> excludePackages
List<String> sourcePaths
String outputDirectory
}
将拓展参数与 task 结合:
class JavaDocCheckerPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
//插件添加自定义 extension
project.extensions.create(CheckerExtension.NAME, CheckerExtension)
//将自定义任务加入 project
project.tasks.create("javaDocChecker", JavaDocCheckerTask)
//依据 apply 的是 java、androidlibrary、androidapplication 分别获取对应的拓展参数
JavaPluginConvention java = null
BaseExtension android = null
if (project.plugins.hasPlugin(AppPlugin)) {
android = project.extensions.getByType(AppExtension)
} else if(project.plugins.hasPlugin(LibraryPlugin)) {
android = project.extensions.getByType(LibraryExtension)
} else if (project.plugins.hasPlugin(JavaPlugin)) {
java = project.convention.getPlugin(JavaPluginConvention)
}
if (java == null && android == null) {
throw new GradleException("it's a not support plugin type!")
}
project.afterEvaluate {
afterEvaluateInner(project, java, android)
}
}
private void afterEvaluateInner(Project project, JavaPluginConvention java, BaseExtension android) {
if (java != null) {
//java 插件就进行 java 的 sourceSets 处理
processJava(project, java)
} else if (android != null) {
//Android 插件就进行 android 的 sourceSets 处理
processAndroid(project, android)
}
}
private void processJava(Project project, JavaPluginConvention java) {
List<String> sources = new ArrayList<>()
//拿到 java sourceSets main 的 src 进行检查处理
SourceSet mainSourceSet = java.sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME)
mainSourceSet.allJava.srcDirs.each {
sources.add(it.absolutePath)
}
assignedTask(project, sources)
}
private void processAndroid(Project project, BaseExtension android) {
List<String> sources = new ArrayList<>()
//拿到 android sourceSets main 的 src 进行检查处理
AndroidSourceSet mainSourceSet = android.sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME)
mainSourceSet.java.srcDirs.each {
sources.add(it.absolutePath)
}
assignedTask(project, sources)
}
//把插件 extension 的自定义属性赋值给自定义 task 的 input 和 output
private void assignedTask(Project project, List<String> sources) {
def checker = project[CheckerExtension.NAME]
if (checker == null) {
return
}
project.getTasksByName("javaDocChecker", false).each {
it.configure {
includePackages = checker.includePackages == null ? [] : checker.includePackages
excludePackages = checker.excludePackages == null ? [] : checker.excludePackages
sourcePaths = sources
outputDir = checker.outputDirectory
}
}
}
}
到此插件核心主体就开发完了,然后就可以使用了,这就是一个完整的通过自定义 javadoc 输出来解决实际问题的小项目,感兴趣可以访问项目源码进行研究,也可以自定义自己的操作。具体完整插件源码可以访问 https://github.com/yanbober/gradle-javadoc-checker 获取。
总结
本文给出了一个实现思路,你可以发现,doclet 简直就是一个巨无霸,对于 java doc 文档操作只有你想不到的,没有他做不到的。希望对你有所启发。