如何验证所有自己抛出的运行时异常都包含在 Javadoc 中?

How to verify that all own thrown runtime exceptions are covered in Javadoc?

我在我的代码中抛出了一堆自定义运行时异常,我想确保在所有 public 方法中,我记录了可能抛出的运行时异常(我自己) 以及为什么。这将非常有用,因为我正在维护一个被许多项目使用的 library,我希望它在抛出的(运行时)异常方面是预先的和可预测的。

是否有编译器选项、maven 插件、Intellij 插件或自定义工具可以帮助我找到遗漏的 throws 子句?对于已检查的异常,这很容易,如果我错过了一个,编译器只会抱怨,但对于运行时异常,throws@throws 都没有强制执行。

我想到的一件事是暂时使我自己的所有运行时异常成为检查异常(它们已经共享一个超级class),但那将是一次性的练习。我想在每次进行更改时验证我的 code/documentation,这样我就永远不会忘记记录我的运行时异常。

另一种方法可能是在整个代码中实际检查异常并将它们仅在 public api:

中转换为运行时
class Foo {
    // oops, throws not documented with @throws
    public void publicMethod() {
        try {
            privateMethod1();
        } catch (CheckedFooException e) {
            throw new RuntimeFooException(e);
        }
    }

    private void privateMethod1() throws CheckedFooException {
        privateMethod2();
    }

    private void privateMethod2() throws CheckedFooException {
        throw new CheckedFooException();
    }
}

这种方法会迫使我考虑所有 public 方法中的 CheckedFooException。然后检查我是否遗漏了一个记录(即 @throws RuntimeFooException),我将简单地对 catch.*CheckedFooException 进行正则表达式搜索并检查缺少的 @throws 条目。虽然过程相当笨拙(并且有很多 public api 会被 try...catch 语句所充斥)。


答案:有一些关于您是否应该记录(您自己抛出的)运行时异常的讨论(到目前为止的总结:视情况而定),但就直接回答我的问题,已接受的答案已充分回答;我可以采用这种方法,实现我的用例,甚至用它制作一个 maven 插件,只要花一些时间和精力。我为此上传了 cleaned up start project

在理解了您的问题并研究了这个主题之后,我终于找到了我认为是完成这项工作的最佳工具之一。有了这个,您不仅可以找到您没有记录的每个 throws 实例,还可以找到您没有抛出任何东西但不小心记录了一个 throw 值的地方。

这背后的想法是将代码解析成抽象语法树。然后找方法,在方法中找throws语句。如果方法有任何 throw 语句,请从这些语句中提取异常名称。然后获取该方法的 Javadoc。检查所有 @throw 标记的 Javadoc 并获取已记录的异常的名称。之后,将异常抛出与已记录的异常抛出进行比较。最后,您必须根据自己的使用情况自行解决。

我为此使用的工具是JavaParser。您可以在 https://github.com/javaparser/javaparser. I downloaded their latest version. Their website is at https://javaparser.org/. They wrote a book on this subject and they mentioned that you can pay [=12=] dollar for the book. However, I didn't read that as they also have a Javadoc version for their program which can be found at https://www.javadoc.io/doc/com.github.javaparser/javaparser-core/3.15.1 的 Github 上找到它们。

我在下面写了一个演示代码。绝不意味着此代码是最终的。这只是一个例子。您必须修复它以使其适合您的情况。我没有考虑嵌套 classes、嵌套方法或 classes 中的方法。此外,示例代码仅针对 class 而不是接口。但是,很容易使代码适应更改以能够处理接口。

为此,您需要下载 javaParser,构建它,并将其 javaparser-core-3.15.1.jar 或 class 路径中的任何版本。

演示代码如下,test.java 是我编写的项目中的一个文件,但您可以使用任何一个。我还在示例代码中包含了注释。

import com.github.javaparser.*;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.comments.*;
import com.github.javaparser.ast.stmt.*;
import com.github.javaparser.ast.body.*;
import com.github.javaparser.javadoc.*;

import java.io.IOException;
import java.nio.file.*;
import java.nio.charset.Charset;
import java.util.*;
import java.util.stream.Collectors;

class Main{
    public static void main(String[] args) throws IOException {
        // Set file path  
        Path path = Paths.get("test.java");

        // Set configuration
        ParserConfiguration parseConfig = new ParserConfiguration();
        parseConfig.setCharacterEncoding(Charset.forName("UTF-8"));
        parseConfig.setTabSize(4);
        parseConfig.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_8);

        // Get the parser
        JavaParser jvParser = new JavaParser(parseConfig);

        // Parse the result
        ParseResult<CompilationUnit> parseResult = jvParser.parse(path);

        // Check for problem
        if ( !parseResult.isSuccessful() ) {
            System.out.print("Parsing java code fail with the following problems:");
            List<Problem> problems = parseResult.getProblems();
            for ( Problem problem : problems ){
                System.out.println(problem.getMessage());
            }
            return;
        }

        // Get the compilationUnit
        // No optional checking for Optional<CompilationUnit> due to already check above.
        CompilationUnit compilationUnit = parseResult.getResult().get();

        // Get Classes
        List<ClassOrInterfaceDeclaration> classes = compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
                                                    .filter(c -> !c.isInterface())
                                                    .collect(Collectors.toList());

        // Traverse through each class to get method
        for ( ClassOrInterfaceDeclaration c : classes ) {
            // Get methods
            List<MethodDeclaration> methods = c.getMethods();
            for ( MethodDeclaration method : methods ) {
                // Get the body statement
                Optional <BlockStmt> body = method.getBody();
                // if no body continue
                if ( !body.isPresent() ) continue;
                // After getting the body of the method code
                // Search for the throw statements.
                List<ThrowStmt> throwStatements = body.get().findAll(ThrowStmt.class);
                // No throw statements, skip
                if ( throwStatements.size() == 0 ) continue;

                // Storing name of exceptions thrown into this list.
                List<String> exceptionsThrown = new ArrayList<String>();

                for ( ThrowStmt stmt : throwStatements ){
                    // Convert the throw expression to object creation expression and get the type.
                    String exceptionName = stmt.getExpression().asObjectCreationExpr().getType().toString();
                    if ( !exceptionsThrown.contains(exceptionName) ) exceptionsThrown.add(exceptionName);
                }

                /* 
                 * Debug block for up to this point 
                System.out.println(method.getName());
                System.out.println(exceptionsThrown);
                System.out.println();
                * 
                **/ 

                // Get The Javadoc
                Optional<Javadoc> javadoc = method.getJavadoc();
                // To store the throws Tags
                List<JavadocBlockTag> throwTags;
                // A list of thrown exception that been documented.
                List<String> exceptionsDocumented = new ArrayList<String>();

                if ( javadoc.isPresent() ) {
                    throwTags = javadoc.get()
                                       .getBlockTags()
                                       .stream()
                                       .filter(t -> t.getType() == JavadocBlockTag.Type.THROWS)
                                       .collect(Collectors.toList());
                    for ( JavadocBlockTag tag : throwTags ) {
                        /* 
                         * This may be buggy as
                         * the code assumed @throw exception 
                         * to be on its own line. Therefore
                         * it will just take the first line as the exception name.
                         */
                        String exceptionName = tag.getContent().toText()
                                                  .split("\n")[0];  // Use system line separator or change
                                                                    // line accordingly.

                        if ( !exceptionsDocumented.contains(exceptionName) ) 
                            exceptionsDocumented.add(exceptionName);
                    }
                }

                // getBegin can extract the line out. But evaluating the optional would take some more code
                // and is just for example so this was done like this without any checking.
                System.out.println("Method: " + method.getName() + " at line " + method.getBegin());
                System.out.println("Throws Exceptions: ");
                System.out.println(exceptionsThrown);
                System.out.println("Documented Exceptions:");
                System.out.println(exceptionsDocumented);

                System.out.println(System.lineSeparator() + System.lineSeparator());
            }
        }
    }
}

test.java内容:

package host.fai.lib.faiNumber;
/*
 * Copyright 2019 Khang Hoang Nguyen
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files
 * (the "Software"), to deal in the Software without restriction,
 * including without limitation the rights to use, copy, modify, merge,
 * publish, distribute, sublicense, and/or sell copies of the Software,
 * and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
 * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
 * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 **/
/**
 * <p>The <code>Base2Util</code> class is a final class that provides
 * static methods for converting base 2 numbering system values in
 * string representation to a Java's Primitive Data Type.
 *
 * <p>Currently this class supports converting base 2 numbers values
 * in string representation to integer int values and integer
 * long values.
 *
 * <p>This class can parse unsigned base 2 numbers to a supported
 * integer signed type as if the integer type is unsigned. However,
 * some of the values must be interprete properly to get the correct
 * result.
 *
 * <p>Example for interpreting signed value as unsigned value.
 *
 * <p>It is possible to store the value of 18446744073709551615L
 * into a long(signed) value. However, if that value is stored into a
 * signed long integer type and if we were to interprete the value
 * normally, we would get a -1L value. However, if the -1L value is
 * pass to LongUtil.toStringAsUnsigned, we would get
 * 18446744073709551615 in string format.
 *
 * <p>The following example is to get to -1L. First, we assign a value
 * of 9223372036854775807L to an interger long variable, multiply that
 * variable to 2L, and add 1L to it.
 * <pre>
 *     long a = 9223372036854775807L * 2L + 1L;
 *     System.out.println(a);
 *     System.out.println(LongUtil.toStringAsUnsigned(a));
 * </pre>
 *
 * <p>Example methods for interprete signed type as unsigned type
 * in a decimal strings value are
 * {@link IntUtil#toStringAsUnsigned(int) IntUtil.toStringAsUnsigned}
 * and {@link LongUtil#toStringAsUnsigned(long) LongUtil.toStringAsUnsigned}.
 * </p>
 *
 * @author  Khang Hoang Nguyen
 *
 * @since  1.0.0.f
 **/
public final class Base2Util{
    private Base2Util(){};
    /**
     * Parse the input string as signed base 2 digits representation
     * into an integer int value.
     *
     * @param  input
     *         A string to be parsed as signed base 2 number to an
     *         integer int value.
     *
     * @return  An integer int value of the signed base 2 number
     *          {@code input} string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid signed
     *          base 2 digits, if the {@code input} string contains a
     *          value that is smaller than the value of Integer.MIN_VALUE(
     *          {@value java.lang.Integer#MIN_VALUE}),
     *          or if the {@code input} string contains a value that
     *          is larger than the value of Integer.MAX_VALUE(
     *          {@value java.lang.Integer#MAX_VALUE}).
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final int toInt(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        final char ch1 = input.charAt(0); int start;

        if ( ch1 == '-' || ch1 == '+' ){
            if ( length == 1 ) throw new NumberFormatException(input);
            start = 1;
        } else {
            start = 0;
        }

        int out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        final int runlen = length - start;

        if ( runlen > 31 ){
            if ( runlen > 32 ) throw new NumberFormatException(input);
            if ( ch1 != '-' ) throw new NumberFormatException(input);

            if ( input.charAt(start++) != '1') throw new NumberFormatException(input);

            for ( ; start < length; start++){
                 if ( input.charAt(start) != '0' ) throw new NumberFormatException(input);
            }

            return -2147483648;
        }

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1 ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        if ( ch1 == '-' ) return ~out + 1;
        return out;
    }

    /**
     * Parse the input string as unsigned base 2 number representation
     * into an integer int value as if the integer int is an unsigned
     * type. For values that need to be interpreted correctly, see the
     * {@link IntUtil#toStringAsUnsigned(int) toStringAsUnsigned} method
     * of the {@link IntUtil IntUtil} class.
     *
     * @param  input
     *         A string to be parsed as unsigned base 2 number to an
     *         integer int value as if the integer int is an unsigned
     *         type.
     *
     * @return  An int value that represents an unsigned integer int
     *          value of the unsigned base 2 number {@code input} string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid unsigned
     *          base 2 digits, if the {@code input} string contains a
     *          value that is beyond the capacity of the integer int
     *          data type.
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final int toIntAsUnsigned(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        int start = 0;

        int out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        if ( length - start > 32 ) throw new NumberFormatException(input);

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1 ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        return out;
    }

    /**
     * Parse the input string as signed base 2 number representation
     * into an integer long value.
     *
     * @param  input
     *         A string to be parsed as signed base 2 number to an
     *         integer long value.
     *
     * @return  An integer long value of the signed base 2 number
     *          {@code input} string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid signed
     *          base 2 digits, if the {@code input} string contains a
     *          value that is smaller than the value of Long.MIN_VALUE(
     *          {@value java.lang.Long#MIN_VALUE}), or if
     *          the {@code input} string contains a value that is larger
     *          than the value of Long.MAX_VALUE(
     *          {@value java.lang.Long#MAX_VALUE}).
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final long toLong(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        final char ch1 = input.charAt(0); int start = 0;

        if ( ch1 == '-' || ch1 == '+' ){
            if ( length == 1 ) throw new NumberFormatException(input);
            start = 1;
        }

        long out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        final int runlen = length - start;

        if ( runlen > 63 ){
            if ( runlen > 64 ) throw new NumberFormatException(input);
            if ( ch1 != '-' ) throw new NumberFormatException(input);

            if ( input.charAt(start++) != '1') throw new NumberFormatException(input);

            for ( ; start < length; start++){
                 if ( input.charAt(start) != '0' ) throw new NumberFormatException(input);
            }

            return -9223372036854775808L;
        }

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1L ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        if ( ch1 == '-' ) return ~out + 1L;
        return out;
    }

    /**
     * Parse the input string as unsigned base 2 number representation
     * into an integer long value as if the integer long is an unsigned
     * type. For values that need to be interpreted correctly, see the
     * {@link LongUtil#toStringAsUnsigned(long) toStringAsUnsigned} method
     * of the {@link LongUtil LongUtil} class.
     *
     * @param  input
     *         A string to be parsed as unsigned base 2 number to an
     *         integer long value as if the integer long is an unsigned
     *         type.
     *
     * @return  An integer long value represent the unsigned integer
     *          long value of the unsigned base 2 number {@code input}
     *          string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid unsigned
     *          base 2 digits, or if the {code input} string
     *          contains a value that is beyond the capacity of the
     *          long data type.
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final long toLongAsUnsigned(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        int start = 0;

        long out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        if ( length - start > 64 ) throw new NumberFormatException(input);

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1L ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        return out;
    }
}

让你所有的异常都继承自一个异常 superclass:

public class MySuperException extends RuntimeException {
}

public class MyException extends MySuperException {
}

要验证是否记录了所有异常,只需交换您的超级 class(例如,通过在 classpath 的后面位置提供文件的另一个版本):

// temporary class, only for compile time checks
// do not export this into jar
public class MySuperException extends Exception {
} 

请检查 Semmle 代码分析,其中有一个查询“Missing Javadoc for thrown exception

Semmle 有插件 LGTM 和 QL,可以像 Eclipse 一样从 IDE 使用。

或 作为替代方法,请使用类似于 Eclipse 插件 JAutodoc 的东西来完成现有的 Javadoc。

如果我没有正确理解你的问题,那么你违反了 RuntimeException 的目的。

如线程 here 中所述, RuntimeException(s) 是不应该由客户端处理的异常。相反,这是一种情况,客户无法恢复。在这种情况下,他所能做的就是要么放弃应用程序,要么将错误抛出。 如果您要添加文档来涵盖这些异常,则意味着您非常清楚为什么会出现此异常。在这种情况下,应该检查异常,而不是未检查。

所以从技术上讲,没有库会提供您正在寻找的功能,因为预计不会记录运行时异常。这是一种设计的味道。所以你更正设计,而不是添加文档。

如果不可能,并且您坚持只使用 RuntimeException,那么我建议您查看此 answer 并构建您自己的 Findbugs/checkstyle 规则把戏。