Pull to refresh

Потрошитель исходников и AST дерево Spring Boot

Reading time 11 min
Views 4.9K
Недавно мне попалась необычная задача получить доступ к комментариям кода в рантайм.



Это поможет поймать сразу 3х зайцев — кроме документации в коде проекта из тестов проекта легко будет сгенерировать sequence diagram, которые смогут читать аналитики, а QA сможет сравнить свои тест планы с автотестами проекта и дополнить их при необходимости. Появляется общий язык в команде между теми кто пишет код и теми кто не может его читать. Как результат — лучшее понимание проекта всеми в процессе разработки ну и с точки зрения разработчика не нужно рисовать ничего вручную — код и тесты первичны. Больше шансов что такая документация будет самой актуальной на проекте, так как генерируется из работающего кода. Заодно дисциплинирует разработчика документировать классы и методы, которые участвуют в диаграммах.

В этой публикации я расскажу как же извлечь javadoc из исходного кода проекта.

Конечно же перед написанием своего кода вспомнил что лучший код — уже написанный и оттестированный сообществом. Начал искать что существует для работы с javadoc в runtime и насколько удобно будет это использовать для моей задачи. Поиски привели к проекту
therapi-runtime-javadoc. К слову сказать проект жив и развивается и позволяет работать в runtime с комментариями исходников классов. Библиотека работает как AnnotationProcessor при компиляции и это достаточно удобно. Но есть одна фича, которая не позволяет использовать его без опасений с реальным кодом который в будущем пойдет в реальную эксплуатацию — это то что он модифицирует исходный байт-код классов, добавляя в него метаинформацию из комментариев. А еще необходимо перекомпилировать код и добавлять аннотацию @RetainJavadoc, что не сработает для зависимостей проекта. Жаль, решение на первый взгляд казалось идеальным.

Еще мне было важно было услышать мнение со стороны. Пообщавшись с достаточно бодрым разработчиком и послушав его мысли как бы решал эту задачу, он предложил парсить HTML javadoc. Это неплохо будет работать так как в центральном maven репозитарии есть javadoc архивы для артефактов, но по мне так не особо элегантное решение потрошить генерированную документацию когда есть исходный код. Хотя дело вкусов…

Мне более подходящим кажется способ извлекать документацию из исходного кода, к тому же в AST доступно гораздо больше информации чем в HTML документации на основе этого же исходного кода. Был опыт и заготовки для этого подхода, про что я когда-то рассказывал в публикации «Разбор Java программы с помощью java программы»

Так появился на свет проект extract-javadoc, который доступен в виде готовой сборки в maven central com.github.igor-suhorukov:extract-javadoc:1.0.

Потрошитель javadoc «под капотом»


Если отбросить ничем не примечательные части программы по работе с файловой системой, сохранением javadoc в виде JSON файла, параллелизацией парсинга и работе с содержимым jar и zip архивов, то самый фарш проекта начинается в методе parseFile класса com.github.igorsuhorukov.javadoc.ExtractJavadocModel.

Инициализация ECJ парсера java файлов и извлечение javadoc выглядят так:

public static List<JavaDoc> parseFile(String javaSourceText, String fileName, String relativePath) {
        ASTParser parser = parserCache.get();
        parser.setSource(javaSourceText.toCharArray());
        parser.setResolveBindings(true);
        parser.setEnvironment(new String[]{}, SOURCE_PATH, SOURCE_ENCODING, true);
        parser.setKind(ASTParser.K_COMPILATION_UNIT);
        parser.setCompilerOptions(JavaCore.getOptions());

        parser.setUnitName(fileName);
        CompilationUnit cu = (CompilationUnit) parser.createAST(null);
        JavadocVisitor visitor = new JavadocVisitor(fileName, relativePath, javaSourceText);
        cu.accept(visitor);
        return visitor.getJavaDocs();
}

Основная работа по разбору javadoc и мэпинг его на внутреннюю модель, которая позже серилизуется в JSON, происходит в JavadocVisitor:

package com.github.igorsuhorukov.javadoc.parser;

import com.github.igorsuhorukov.javadoc.model.*;
import com.github.igorsuhorukov.javadoc.model.Type;
import org.eclipse.jdt.core.dom.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

public class JavadocVisitor extends ASTVisitor {

    private String file;
    private String relativePath;
    private String sourceText;
    private CompilationUnit compilationUnit;

    private String packageName;
    private List<? extends Comment> commentList;
    private List<JavaDoc> javaDocs = new ArrayList<>();

    public JavadocVisitor(String file, String relativePath, String sourceText) {
        this.file = file;
        this.relativePath = relativePath;
        this.sourceText = sourceText;
    }

    @Override
    public boolean visit(PackageDeclaration node) {
        packageName = node.getName().getFullyQualifiedName();
        javaDocs.addAll(getTypes().stream().map(astTypeNode -> {
            JavaDoc javaDoc = getJavaDoc(astTypeNode);
            Type type = getType(astTypeNode);
            type.setUnitInfo(getUnitInfo());
            javaDoc.setSourcePoint(type);
            return javaDoc;
        }).collect(Collectors.toList()));
        javaDocs.addAll(getMethods().stream().map(astMethodNode -> {
            JavaDoc javaDoc = getJavaDoc(astMethodNode);
            Method method = new Method();
            method.setUnitInfo(getUnitInfo());
            method.setName(astMethodNode.getName().getFullyQualifiedName());
            method.setConstructor(astMethodNode.isConstructor());
            fillMethodDeclaration(astMethodNode, method);
            Type type = getType((AbstractTypeDeclaration) astMethodNode.getParent());
            method.setType(type);
            javaDoc.setSourcePoint(method);
            return javaDoc;
        }).collect(Collectors.toList()));
        return super.visit(node);
    }

    private CompilationUnitInfo getUnitInfo() {
        return new CompilationUnitInfo(packageName, relativePath, file);
    }


    @SuppressWarnings("unchecked")
    private void fillMethodDeclaration(MethodDeclaration methodAstNode, Method method) {
        List<SingleVariableDeclaration> parameters = methodAstNode.parameters();
        org.eclipse.jdt.core.dom.Type returnType2 = methodAstNode.getReturnType2();
        method.setParams(parameters.stream().map(param -> param.getType().toString()).collect(Collectors.toList()));
        if(returnType2!=null) {
            method.setReturnType(returnType2.toString());
        }
    }

    private Type getType(AbstractTypeDeclaration astNode) {
        String binaryName = astNode.resolveBinding().getBinaryName();
        Type  type = new Type();
        type.setName(binaryName);
        return type;
    }

    @SuppressWarnings("unchecked")
    private JavaDoc getJavaDoc(BodyDeclaration astNode) {
        JavaDoc javaDoc = new JavaDoc();
        Javadoc javadoc = astNode.getJavadoc();
        List<TagElement> tags = javadoc.tags();
        Optional<TagElement> comment = tags.stream().filter(tag -> tag.getTagName() == null).findFirst();
        comment.ifPresent(tagElement -> javaDoc.setComment(tagElement.toString().replace("\n *","").trim()));
        List<Tag> fragments = tags.stream().filter(tag -> tag.getTagName() != null).map(tag-> {
            Tag tagResult = new Tag();
            tagResult.setName(tag.getTagName());
            tagResult.setFragments(getTags(tag.fragments()));
            return tagResult;
        }).collect(Collectors.toList());
        javaDoc.setTags(fragments);
        return javaDoc;
    }

    @SuppressWarnings("unchecked")
    private List<String> getTags(List fragments){
        return ((List<IDocElement>)fragments).stream().map(Objects::toString).collect(Collectors.toList());
    }
    private List<AbstractTypeDeclaration> getTypes() {
        return commentList.stream().map(ASTNode::getParent).filter(Objects::nonNull).filter(AbstractTypeDeclaration.class::isInstance).map(astNode -> (AbstractTypeDeclaration) astNode).collect(Collectors.toList());
    }

    private List<MethodDeclaration> getMethods() {
        return commentList.stream().map(ASTNode::getParent).filter(Objects::nonNull).filter(MethodDeclaration.class::isInstance).map(astNode -> (MethodDeclaration) astNode).collect(Collectors.toList());
    }

    @Override
    @SuppressWarnings("unchecked")
    public boolean visit(CompilationUnit node) {
        commentList = node.getCommentList();
        this.compilationUnit = node;
        return super.visit(node);
    }

    public List<JavaDoc> getJavaDocs() {
        return javaDocs;
    }
}

В методе com.github.igorsuhorukov.javadoc.parser.JavadocVisitor#visit(PackageDeclaration) на данный момент обрабатываются javadoc только для типов и их методов. Эта информация нужна мне для построения sequence диаграмм с комментариями.

Работа с AST программы для задачи извлечения документации из исходников оказалась не такая сложная, как казалось вначале. И разработать более-менее универсальное решение я смог пока у меня перерыв между работами и я отдыхаю, пару дней кодируя по 3-4 часа за раз.

Как извлекать javadoc в реальном проекте


Для maven проекта легко добавить извлечение javadoc во все модули проекта, добавив
в parent pom.xml проекта следующий profile
<profile>
    <id>extract-javadoc</id>
    <activation>
        <file>
            <exists>${basedir}/src/main/java</exists>
        </file>
    </activation>
    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>1.6.0</version>
                <executions>
                    <execution>
                        <id>extract-javadoc</id>
                        <phase>package</phase>
                        <goals>
                            <goal>java</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <includeProjectDependencies>true</includeProjectDependencies>
                    <includePluginDependencies>true</includePluginDependencies>
                    <mainClass>com.github.igorsuhorukov.javadoc.ExtractJavadocModel</mainClass>
                    <arguments>
                        <argument>${project.basedir}/src</argument>
                        <argument>${project.build.directory}/javadoc.json.xz</argument>
                    </arguments>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>com.github.igor-suhorukov</groupId>
                        <artifactId>extract-javadoc</artifactId>
                        <version>1.0</version>
                        <type>jar</type>
                        <scope>compile</scope>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>build-helper-maven-plugin</artifactId>
                <version>3.0.0</version>
                <executions>
                    <execution>
                        <id>attach-extracted-javadoc</id>
                        <phase>package</phase>
                        <goals>
                            <goal>attach-artifact</goal>
                        </goals>
                        <configuration>
                            <artifacts>
                                <artifact>
                                    <file>${project.build.directory}/javadoc.json.xz</file>
                                    <type>xz</type>
                                    <classifier>javadoc</classifier>
                                </artifact>
                            </artifacts>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>


Так в проекте появляется дополнительный артефакт, который содержит javadoc в формате json и он попадает в репозитарий при выполнении install/deploy.

Также не должно быть проблемой интегрировать это решение в сборку Gradle, так как это обычное консольное приложение на вход которому передаются два параметра — путь к исходникам и файл куда записывается javadoc в JSON формате или компрессированном json, если путь заканчивается на ".xz"

Подопытным кроликом сейчас станет проект Spring Boot как достаточно большой проект с отличной javadoc документацией.

Выполним команду:

git clone https://github.com/spring-projects/spring-boot.git

И добавим в файл spring-boot-parent/pom.xml в тег profiles,
наш тег profile
<profile>
    <id>extract-javadoc</id>
    <activation>
        <file>
            <exists>${basedir}/src/main/java</exists>
        </file>
    </activation>
    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>1.6.0</version>
                <executions>
                    <execution>
                        <id>extract-javadoc</id>
                        <phase>package</phase>
                        <goals>
                            <goal>java</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <includeProjectDependencies>true</includeProjectDependencies>
                    <includePluginDependencies>true</includePluginDependencies>
                    <mainClass>com.github.igorsuhorukov.javadoc.ExtractJavadocModel</mainClass>
                    <arguments>
                        <argument>${project.basedir}/src</argument>
                        <argument>${project.build.directory}/javadoc.json</argument>
                    </arguments>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>com.github.igor-suhorukov</groupId>
                        <artifactId>extract-javadoc</artifactId>
                        <version>1.0</version>
                        <type>jar</type>
                        <scope>compile</scope>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>build-helper-maven-plugin</artifactId>
                <version>3.0.0</version>
                <executions>
                    <execution>
                        <id>attach-extracted-javadoc</id>
                        <phase>package</phase>
                        <goals>
                            <goal>attach-artifact</goal>
                        </goals>
                        <configuration>
                            <artifacts>
                                <artifact>
                                    <file>${project.build.directory}/javadoc.json</file>
                                    <type>json</type>
                                    <classifier>javadoc</classifier>
                                </artifact>
                            </artifacts>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>


После этого выполним сборку проекта, в процессе для всех java файлов из Spring Boot происходит построение AST дерева и извлечение javadoc типов и методов. В директориях target модулей содержащих java исходники появится файл javadoc.json. Но чем больше ядер процессора в вашей системе, тем больший объем памяти потребуется для парсинга, так что возможно прийдется увеличить max heap size в файле .mvn/jvm.config

Как пример создается файл spring-boot-tools/spring-boot-antlib/target/javadoc.json

[ {
  "comment" : "Ant task to find a main class.",
  "tags" : [ {
    "name" : "@author",
    "fragments" : [ " Matt Benson" ]
  }, {
    "name" : "@since",
    "fragments" : [ " 1.3.0" ]
  } ],
  "sourcePoint" : {
    "@type" : "Type",
    "unitInfo" : {
      "packageName" : "org.springframework.boot.ant",
      "relativePath" : "main/java/org/springframework/boot/ant",
      "file" : "FindMainClass.java"
    },
    "name" : "org.springframework.boot.ant.FindMainClass"
  }
}, {
  "comment" : "Set the main class, which will cause the search to be bypassed.",
  "tags" : [ {
    "name" : "@param",
    "fragments" : [ "mainClass", " the main class name" ]
  } ],
  "sourcePoint" : {
    "@type" : "Method",
    "unitInfo" : {
      "packageName" : "org.springframework.boot.ant",
      "relativePath" : "main/java/org/springframework/boot/ant",
      "file" : "FindMainClass.java"
    },
    "type" : {
      "@type" : "Type",
      "unitInfo" : null,
      "name" : "org.springframework.boot.ant.FindMainClass"
    },
    "name" : "setMainClass",
    "constructor" : false,
    "params" : [ "String" ],
    "returnType" : "void"
  }
}, {
  "comment" : "Set the root location of classes to be searched.",
  "tags" : [ {
    "name" : "@param",
    "fragments" : [ "classesRoot", " the root location" ]
  } ],
  "sourcePoint" : {
    "@type" : "Method",
    "unitInfo" : {
      "packageName" : "org.springframework.boot.ant",
      "relativePath" : "main/java/org/springframework/boot/ant",
      "file" : "FindMainClass.java"
    },
    "type" : {
      "@type" : "Type",
      "unitInfo" : null,
      "name" : "org.springframework.boot.ant.FindMainClass"
    },
    "name" : "setClassesRoot",
    "constructor" : false,
    "params" : [ "File" ],
    "returnType" : "void"
  }
}, {
  "comment" : "Set the ANT property to set (if left unset, result will be printed to the log).",
  "tags" : [ {
    "name" : "@param",
    "fragments" : [ "property", " the ANT property to set" ]
  } ],
  "sourcePoint" : {
    "@type" : "Method",
    "unitInfo" : {
      "packageName" : "org.springframework.boot.ant",
      "relativePath" : "main/java/org/springframework/boot/ant",
      "file" : "FindMainClass.java"
    },
    "type" : {
      "@type" : "Type",
      "unitInfo" : null,
      "name" : "org.springframework.boot.ant.FindMainClass"
    },
    "name" : "setProperty",
    "constructor" : false,
    "params" : [ "String" ],
    "returnType" : "void"
  }
}, {
  "comment" : "Quiet task that establishes a reference to its loader.",
  "tags" : [ {
    "name" : "@author",
    "fragments" : [ " Matt Benson" ]
  }, {
    "name" : "@since",
    "fragments" : [ " 1.3.0" ]
  } ],
  "sourcePoint" : {
    "@type" : "Type",
    "unitInfo" : {
      "packageName" : "org.springframework.boot.ant",
      "relativePath" : "main/java/org/springframework/boot/ant",
      "file" : "ShareAntlibLoader.java"
    },
    "name" : "org.springframework.boot.ant.ShareAntlibLoader"
  }
} ]

Чтение метаданных javadoc в рантайм


Превратить модель javadoc из JSON обратно в объектную модель и работать с ней в программе можно с помощью вызова com.github.igorsuhorukov.javadoc.ReadJavaDocModel#readJavaDoc в метод необходимо передать путь к JSON файлу с javadoc (либо к JSON сжатому в формате .xz).

Как работать с моделью я опишу в следующих публикациях про генерацию sequence diagram из тестов
Tags:
Hubs:
+6
Comments 1
Comments Comments 1

Articles