常见的错误和陷阱

就源代码而言,Maven 并不是最小的项目,因此已经遭受了许多错误。仔细研究所有问题后,我们发现了一些在各个子组件中普遍存在的编码问题。本文档列出了这些常见的反模式,以帮助 Maven 社区预防而不是修复错误。请注意,主要关注点是指出本质上微妙的问题,而不是为 Java 或 Maven 开发提供全面的指南。

读写文本文件

文本内容由字符组成,而文件系统仅存储字节流。文件编码(又名字符集)用于在字节和字符之间进行转换。挑战在于使用正确的文件编码。

JVM 有一个默认编码的概念(由file.encoding属性给出),它派生自系统的语言环境。虽然有时这可能是一个方便的功能,但在项目构建中使用此默认编码通常是一个坏主意:构建输出将取决于运行构建的机器/开发人员。因此,使用默认编码会威胁到可重现构建的梦想。

例如,如果开发人员 A 使用 UTF-8 作为默认编码,而开发人员 B 使用 ISO-8859-1,则文本文件很可能在资源过滤或类似任务期间变得混乱。

因此,开发人员应避免直接或间接使用仅使用平台默认编码的类/方法。例如,FileWriter通常FileReader应该避免:

/*
 * FIXME: This assumes the source file is using the platform's default encoding.
 */
Reader reader = new FileReader( javaFile );

相反,类OutputStreamWriterOutputStreamReader可以与显式编码值结合使用。可以从 mojo 参数中检索此编码值,以便用户可以配置插件以满足他/她的需要。

为了避免用户单独配置每个插件,已经建立了允许用户集中配置每个 POM 的文件编码的约定。插件开发人员应尽可能尊重这些约定:

最后请注意,XML 文件需要特殊处理,因为它们在 XML 序言中配备了编码声明。encoding使用与其 XML prolog属性不匹配的编码读取或写入 XML 文件是一个坏主意:

/*
 * FIXME: This assumes the XML encoding declaration matches the platform's default encoding.
 */
Writer writer = new FileWriter( xmlFile );
writer.write( xmlContent );

ReaderFactory.newXmlReader()为了简化 XML 文件的正确处理,鼓励开发人员使用WriterFactory.newXmlWriter()Plexus 实用程序。

在 URL 和文件系统路径之间转换

URL 和文件系统路径实际上是两个不同的东西,它们之间的转换并非易事。问题的主要来源是不同的编码规则适用于构成 URL 或文件系统路径的字符串。例如,考虑以下代码片段及其相关的控制台输出:

File file = new File( "foo bar+foo" );
URL url = file.toURI().toURL();

System.out.println( file.toURL() );
> file:/C:/temp/foo bar+foo

System.out.println( url );
> file:/C:/temp/foo%20bar+foo

System.out.println( url.getPath() );
> /C:/temp/foo%20bar+foo

System.out.println( URLDecoder.decode( url.getPath(), "UTF-8" ) );
> /C:/temp/foo bar foo

首先,请注意File.toURL()不要转义空格字符(和其他字符)。根据RFC 2396,第 2.4.3 节“排除的 US-ASCII 字符”,这会产生无效的 URL 。该类java.net.URL将静默接受此类无效 URL,相反java.net.URI则不会(另请参阅 参考资料URL.toURI())。因此,File.toURL()已弃用,应替换为File.toURI().toURL().

接下来,URL.getPath()一般不会返回可用作文件系统路径的字符串。它返回 URL 的子字符串,因此可以包含转义序列。突出的例子是空格字符,它将显示为“%20”。人们有时会通过以下方式解决此问题,replace("%20", " ")但这并不能涵盖所有情况。值得一提的是,另一方面,相关方法URI.getPath()确实解码了转义,但结果仍然不是文件系统路径(比较构造函数的源File(URI))。总而言之,应避免以下成语:

URL url = new URL( "file:/C:/Program%20Files/Java/bin/java.exe" );

/*
 * FIXME: This does not decode percent encoded characters.
 */
File path = new File( url.getPath() );

为了对 URL 进行解码,人们有时还会选择java.net.URLDecoder. 这个类的缺陷是它实际上执行 HTML 表单解码,这是另一种编码,与 URL 编码不同(比较类 javadoc 中的最后一段java.net.URL)。例如,aURLDecoder会错误地将字符“+”转换为空格,如上例中最后一个 sysout 所示。

在理想情况下,针对 JRE 1.4+ 的代码可以通过使用File(URI)以下代码段建议的构造函数轻松避免这些问题:

URL url = new URL( "file:/C:/Documents and Settings/user/.m2/settings.xml" );

/*
 * FIXME: This assumes the URL is fully compliant with RFC 3986.
 */
File path = new File( new URI( url.toExternalForm() ) );

剩下的挫折来源是从URL到的转换URI。如前所述,URL该类接受格式错误的 URL,这将使构造函数URI抛出异常。实际上,从 Sun JRE 到 Java 1.4 的类加载器在查询资源时会提供格式错误的 URL。同样,无论 JRE 版本如何,Maven 2.x 使用的类加载器都会提供格式错误的资源 URL(请参阅MNG-3607)。

出于所有这些原因,建议FileUtils.toFile()从 Commons IO 或FileUtils.toFile()最近的 Plexus Utilities 中使用。

不区分大小写地处理字符串

String.toLowerCase()当开发人员需要在不考虑大小写的情况下比较字符串或想要实现一个不区分大小写的字符串键的映射时,他们通常会String.toUpperCase()在执行简单的String.equals(). 现在,这些to*Case()方法被重载了:一个不带参数,一个带一个Locale对象。

无参数方法的问题是它们的输出取决于 JVM 的默认语言环境,但默认语言环境不受开发人员的控制。这意味着开发人员(使用 locale 在 JVM 中运行/测试他的代码xy)期望的字符串不一定与另一个用户(使用 locale 运行 JVM ab)看到的字符串匹配。例如,对于具有默认语言环境土耳其语的系统,下一个代码片段中显示的比较可能会失败,因为土耳其语对字符“i”和“I”有不寻常的大小写规则:

/*
 * FIXME: This assumes the casing rules of the current platform
 * match the rules for the English locale.
 */
if ( "info".equals( debugLevel.toLowerCase() ) )
    logger.info( message );

对于应该不区分区域设置的不区分大小写的字符串比较,应该使用该方法String.equalsIgnoreCase()。如果只比较前缀/后缀之类的子字符串,则String.regionMatches()可以使用该方法。

如果String.to*Case()无法避免Locale使用 ,则应使用带对象的重载版本,传入Locale.ENGLISH. 生成的代码仍将在非英语系统上运行,该参数仅锁定用于字符串比较的大小写规则,以便代码在所有平台上提供相同的结果。

创建资源束族

特别是报告插件使用资源包来支持国际化。提供一种语言(通常是英语)作为基本资源包中的备用/默认语言。由于 执行的查找策略ResourceBundle.getBundle(),也必须始终为此默认语言提供专用资源包。这个包应该是空的,因为它通过父链从基本包继承字符串,但它必须存在。

以下示例说明了此要求。想象一下下面显示的损坏的资源包系列,它旨在为英语、德语和法语提供本地化:

src/
+- main/
   +- resources/
      +- mymojo-report.properties
      +- mymojo-report_de.properties
      +- mymojo-report_fr.properties

现在,如果要在默认语言环境恰好是法语的 JVM 上查找资源包的英语,mymojo-report_fr.properties则将加载该包而不是预期的包mymojo-report.properties

mvn site -D locales=xy,en通过执行wherexy表示特定插件支持的任何其他语言代码,可以轻松检测到遭受此错误的报告插件。指定xy为第一个语言环境将使 Maven 站点插件更改 JVM 的默认语言环境,xy从而导致查找en失败,如上所述,除非插件具有专用的英语资源包。

使用系统属性

Maven 的命令行支持通过形式参数定义系统属性-D key=value。虽然这些属性称为系统属性,但插件不应该使用System.getProperty()相关方法来查询这些属性。例如,当 Maven 嵌入到 IDE 或 CI 服务器中时,以下代码片段将无法可靠地工作:

public MyMojo extends AbstractMojo
{
    public void execute()
    {
        /*
         * FIXME: This prevents proper embedding into IDEs or CI systems.
         */
        String value = System.getProperty( "maven.test.skip" );
    }
}

问题是System类管理的属性是全局的,即在当前JVM中的所有线程之间共享。为防止与在同一 JVM 中运行的其他代码发生冲突,Maven 插件应改为查询执行属性。这些可以从MavenSession.getExecutionProperties().

使用关闭挂钩

人们偶尔会使用关闭挂钩来执行清理任务,例如删除临时文件,如下例所示:

public MyMojo extends AbstractMojo
{
    public void execute()
    {
        File tempFile = File.createTempFile( "temp", null );
        /*
         * FIXME: This assumes the JVM terminates soon after
         * the Maven build has finished.
         */
        tempFile.deleteOnExit();
    }
}

问题是执行 Maven 的 JVM 运行的时间可能比实际的 Maven 构建要长得多。当然,这不适用于从命令行单独调用 Maven。但是,它会影响 Maven 在 IDE 或 CI 服务器中的嵌入式使用。在这些情况下,清理任务也将被推迟。如果 JVM 正在执行一堆其他的 Maven 构建,那么许多这样的清理任务可以总结起来,占用 JVM 的资源。

出于这个原因,插件开发人员应该避免使用关闭挂钩,而是在不再需要资源时使用try/块来执行清理。finally

解析相对路径

Maven 用户在 POM 中指定相对路径是常见的做法,更不用说 Super POM 也这样做了。目的是针对当前项目的基本目录解析此类相对路径。换句话说,路径target/classes${basedir}/target/classes应该解析到给定 POM 的相同目录。

不幸的是,该类java.io.File没有解析项目基目录的相对路径。正如其类 javadoc 中所提到的,它根据当前工作目录解析相对路径。简而言之:除非 Maven 组件完全控制当前工作目录,否则任何java.io.File与相对路径结合使用的情况都是错误。

乍一看,人们可能会争辩说项目基目录等于当前工作目录。但是,这种假设通常是不正确的。考虑以下场景:

  1. 反应堆建造

    在 reactor 构建期间构建子模块时,当前工作目录通常是父项目的基本目录,而不是当前模块的基本目录。这是用户面临错误的最常见情况。

  2. 嵌入式 Maven 调用

    其他在后台运行 Maven 的工具,尤其是 IDE,可能已将当前工作目录设置为它们的安装文件夹或任何它们喜欢的文件夹。

  3. -f使用Switch 的 Maven 调用

    虽然这肯定是一个不常见的用例,但用户可以通过指定绝对路径(如mvn -f /home/me/projects/demo/pom.xml.

因此,此示例代码很容易出现错误行为:

public MyMojo extends AbstractMojo
{
    /**
     * @parameter
     */
    private String outputDirectory;

    public void execute()
    {
        /*
         * FIXME: This will resolve relative paths like "target/classes" against
         * the user's working directory instead of the project's base directory.
         */
        File outputDir = new File( outputDirectory ).getAbsoluteFile();
    }
}

为了保证可靠的构建,Maven 及其插件必须根据项目的基本目录手动解析相对路径。像下面这样一个简单的成语就可以了:

File file = new File( path );
if ( !file.isAbsolute() )
{
    file = new File( project.getBasedir(), file );
}

如果许多 Maven 插件将受影响的 mojo 参数声明为 typejava.io.File而不是java.lang.String. 参数类型的这种细微差别将触发一个称为路径转换的功能,即 Maven 核心将在将 XML 配置泵入 mojo 时自动解析相对路径。

确定站点报告的输出目录

大多数报告插件继承自AbstractMavenReport. 在这样做时,他们需要实现继承但抽象的方法getOutputDirectory()。为了实现这个方法,插件通常会声明一个名为outputDirectory它们在方法中返回的字段。到目前为止没有任何问题。

现在,一些插件需要在报告输出目录中创建附加文件,这些文件伴随通过接收器接口生成的报告。虽然直接使用方法getOutputDirectory()或字段outputDirectory来设置输出文件的路径很诱人,但这很可能导致错误。更准确地说,当 Maven 站点插件作为站点生命周期的一部分运行时,这些插件将无法正确输出文件。当站点的输出目录直接在 Maven 站点插件中配置时,最好注意这一点,这样它就会偏离${project.reporting.outputDirectory}插件默认使用的表达式。多语言站点生成是利用此错误的另一种方案,如下所示:

public MyReportMojo extends AbstractMavenReport
{
    /**
     * @parameter default-value="${project.reporting.outputDirectory}"
     */
    private File outputDirectory;

    protected String getOutputDirectory()
    {
        return outputDirectory.getAbsolutePath();
    }

    public void executeReport( Locale locale )
    {
        /*
         * FIXME: This assumes the mojo parameter reflects the effective
         * output directory as used by the Maven Site Plugin.
         */
        outputDirectory.mkdirs();
    }
}

原则上有两种情况可以调用报告魔咒。mojo 可以直接从命令行或默认构建生命周期运行,也可以作为站点生成的一部分与其他报告 mojo 一起间接运行。这两个调用之间的明显区别在于输出目录的控制方式。在第一种情况下,使用outputDirectory来自 mojo 本身的参数。MavenReport.setReportOutputDirectory()然而,在第二种情况下,Maven 站点插件将接管控制权,并通过调用正在生成的报告根据自己的配置设置输出目录。

MavenReport.getReportOutputDirectory()因此,开发人员在需要查询报表的有效输出目录时应始终使用。的实施AbstractMavenReport.getOutputDirectory()仅作为备用,以防 mojo 不作为站点生成的一部分运行。

检索 Mojo 记录器

Maven 使用名为 Plexus 的 IoC 容器在插件执行之前设置插件的 mojos。换句话说,mojo 所需的组件将通过依赖注入,更准确地说是字段注入来提供。要记住的重要一点是,这个字段注入发生mojo 的构造函数完成之后。这意味着在 mojo 的构建期间对注入组件的引用是无效的。

例如,下一个片段尝试在构建期间检索 mojo 记录器,但 mojo 记录器是一个注入组件,因此尚未正确初始化:

public MyMojo extends AbstractMojo
{
    /*
     * FIXME: This will retrieve a wrong logger instead of the intended mojo logger.
     */
    private Log log = getLog();

    public void execute()
    {
        log.debug( "..." );
    }
}

在记录器的情况下,上述 mojo 将简单地使用默认控制台记录器,即代码缺陷不会立即被NullPointerException. 然而,此默认记录器将使用不同的消息格式进行输出,并且即使未启用 Maven 的调试模式,也会输出调试消息。出于这个原因,开发人员不能在构建期间尝试缓存记录器。该方法getLog()足够快,可以在需要时简单地调用。+---