(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)
一直想写篇文章整理一下几个构建工具,先从Ant 和Maven入手吧。
Make和Ant
我用过最简单的构建工具是make。它的功能就是控制编译的过程,底层本质上是依赖不同的编译器,shell命令,操作系统CLI等等,make就像是一个编排器。它的语法如下:
<target> : <prerequisites>
[tab] <commands>
譬如之前一个golang的项目,涉及多个模块的构建。截取部分makefile配置:
manager: bootstrap_node bootstrap_kibana restart_node op_failover_node replace_text_file execute_shell
gb build cmd/manager
bootstrap_node:
gb build manager/exe/cmd/bootstrap_node
bootstrap_kibana:
gb build manager/exe/cmd/bootstrap_kibana
含义是:
如果要使用make manager构建manager模块,那么底层执行的是一个gb vendor编译命令:
gb build cmd/manager
但是在这之前,manager模块还依赖其他模块,包括bootstrap_node bootstrap_kibana restart_node op_failover_node replace_text_file execute_shell的构建,所以make bootstrap_node会在make manager之前执行,它的底层是执行另一个gb vendor编译命令:
gb build manager/exe/cmd/bootstrap_node
当然也可以在<commands>中用shell命令控制编译后的文件目录,文件名称,位置,权限等等。
make很灵活,没有语言限制,但是基本上什么都需要用户自己做。
Ant 是一个基于java的构建工具, 定义使用XML文件(build.xml,类似makefile),里面封装了一些代表某些基本功能的XML标签;Ant同样非常灵活;用户可以定义定义大量的targets以及他们的依赖关系,当构建触发一个上层的target执行时,这个target的底层依赖会提前执行。
复制一个网上的sample:
<project>
<target name="clean">
<delete dir="classes" />
</target>
<target name="compile" depends="clean">
<mkdir dir="classes" />
<javac srcdir="src" destdir="classes" />
</target>
<target name="jar" depends="compile">
<mkdir dir="jar" />
<jar destfile="jar/HelloWorld.jar" basedir="classes">
<manifest>
<attribute name="Main-Class"
value="antExample.HelloWorld" />
</manifest>
</jar>
</target>
<target name="run" depends="jar">
<java jar="jar/HelloWorld.jar" fork="true" />
</target>
</project>
我还在Amazon工作时,内部的构建工具brazil就是基于ant。Ant的缺点是缺失依赖管理功能,brazil对此功能进行了增强,利用dependency标签进行依赖的管理。
灵活对应的缺点就是复杂度。在makefile或者build.xml中,用户需要定义的东西太多,往往需要从target目录的创建开始,包括后续的清理,编译,连接,打包。。。等等,任务的依赖也可能会错综复杂。我看到过一个词来形容build.xml文件-write-only,来形容它糟糕的可读性。
Maven则用另一种思路解决这个问题。
Maven – Convention over configuration 约定优先
在京东云工作时,除了ES源码开发用的gradle,其他大部分java项目都是maven管理的,直观感受就是简单。
maven对构建过程进行了标准化定义,形成了一个约定,所有的用户需要在这个约定上进行使用和开发。本质上就像一个框架,用户使用上更简单了,用户只需要去specify unconventional aspects of the application。
standard conventions包括了:项目结构的约定,生命周期的约定,依赖管理的约定等等多个方面。引入了约定,灵活性肯定会下降,衡量一个框架是否灵活可以用它的可拓展性,maven的可拓展性依赖它的插件机制(maven的所有功能其实都是以插件形式提供的),如果需要进行功能拓展则需要进行插件开发,插件的开发代价就成为了影响灵活性的关键因素。很多人认为相比gradle,maven的‘不灵活’,也正是在于所有的定制化功能都需要进行相对复杂的插件开发(当然还有固定的生命周期等难以改动的conventions)
maven构建生命周期
Maven的构建的生命周期分为三种类型:clean,default,site,分别对应:清理,默认构建,生成项目介绍页面。
其中default显然是最重要的。default生命周期可以细分成多个按顺序执行的阶段:
其中比较重要的过程为
- Validate 验证
- Compile 编译
- Test 测试
- Package 打包
- Verify 运行集成测试
- Install 安装到本地仓库
- Deploy 安装到远程仓库
常用的执行命令为:
mvn clean package
含义:(如果是多模块项目,每个子项目都会被遍历执行)依次执行clean,validate,compile,test,package。
maven插件
maven这样形容自己:Maven is – at its heart – a plugin execution framework。可见插件的重要程度。
上述是build lifecycle,是maven对构建流程从顶层的划分。每个具体的阶段(phase)执行什么操作,就是由插件以及插件中的goals决定的。
goal就是一个任务(类似ant中的一个target),它可以被绑定在生命周期的某个阶段,也可以脱离阶段独自存在。譬如我们经常使用的一个命令:
mvn dependency:tree
就是直接调用dependency插件的一个goal。
一个阶段可以包括多个goals,如果没有任何goals,显然这个phase就不会被执行。
同一个phase中的goals执行顺序是根据它们在pom文件中的声明先后。(版本2.0.5及以上)
如何绑定插件的goals到生命周期?
对于default lifecycle,是没有为任何phase绑定默认goals的,有以下两种方式可以绑定:
最简单的方式,利用<packaging>标签。
以下是packaging标签为jar时绑定的goals:
关于3.8.5的详细插件版本:
当然packaging还有多种属性可以配置,war,pom等。如果没有使用packaging标签,默认意味着设定了packaging类型为jar。
packaging标签是打包组合的绑定方式,当然也可以逐个指定plugin以及要绑定的goal。
可以用这种方式去指定packaging中指定插件的version,事实上为了开发过程中出现的问题的可复性,我们应该始终在pluginManagement中指定所有plugin的版本号,这是一个很好的习惯,而不是使用一个隐式的默认值;版本管理使用的标签是PluginManagement
除此之外,plugin将它的goal绑定到生命周期中的哪一个阶段,相应的goal就会在对应阶段被执行。这种绑定关系可以是隐式的,也可以是显式的。
同一个阶段多个goals的执行顺序是:packaging绑定的goals先执行,剩下的按照pom的声明顺序。
Clean lifecycle 默认已经绑定了一个goals:
org.apache.maven.plugins:maven-clean-plugin:2.5:clean
Site lifecycle 默认绑定了两个goals:
org.apache.maven.plugins:maven-site-plugin:3.3:site
org.apache.maven.plugins:maven-site-plugin:3.3:deploy
基本原理和示例
插件可以分为两类:构建插件(build plugin)和报告插件(reporting plugin),多数情况下使用前者,下文也围绕构建插件展开。
插件一个goal最重要的‘参数配置’和‘执行逻辑’其实可以简单对应到一个自定义类中,官方称其为mojo(m for maven)。举个例子,下面是一个mojo的定义,和省略的执行逻辑:
/**
* @goal query
* @phase package
*/
//上面定义goal的名称为query,和插件默认绑定的生命周期phase为package
public class MyQueryMojo
extends AbstractMojo
{
@Parameter(property = "query.url", required = true)
private String url;
@Parameter(property = "timeout", required = false, defaultValue = "50")
private int timeout;
@Parameter(property = "options")
private String[] options;
public void execute()
throws MojoExecutionException
{
...
}
}
一种常见的配置方式是利用<configuration>标签:
<build>
<plugins>
<plugin>
<artifactId>maven-myquery-plugin</artifactId>
<version>1.0</version>
<configuration>
<url>http://www.foobar.com/query</url>
<timeout>10</timeout>
<options>
<option>one</option>
<option>two</option>
<option>three</option>
</options>
</configuration>
</plugin>
</plugins>
</build>
另一种方式是通过<execution>标签进行定义,这种定义方式可以进行多次绑定,即绑定到不同的phase,多次执行:
<build>
<plugins>
<plugin>
<artifactId>maven-myquery-plugin</artifactId>
<version>1.0</version>
<executions>
<execution>
<id>execution1</id>
<phase>test</phase>
<configuration>
<url>http://www.foo.com/query</url>
<timeout>10</timeout>
<options>
<option>one</option>
<option>two</option>
<option>three</option>
</options>
</configuration>
<goals>
<goal>query</goal>
</goals>
</execution>
<execution>
<id>execution2</id>
<configuration>
<url>http://www.bar.com/query</url>
<timeout>15</timeout>
<options>
<option>four</option>
<option>five</option>
<option>six</option>
</options>
</configuration>
<goals>
<goal>query</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
(注意:这里是在<plugins>标签声明,区别于<pluginManagement>标签)
第一个execution绑定的执行phase是test;第二个execution没有绑定phase,则会在plugin的默认phase(package)执行。
上面是从官网扒的一个抽象的例子,下面拿个真实的插件举例:maven-surefire-plugin
这个插件只有一个goal:
surefire:test
这个goal被绑定到了test阶段,是用来执行应用的所有单元测试-它定义会扫描所有以Test开始,或者Test,Tests,TestCase结尾的类。
它有个非常大的优点就是兼容Junit,TestNG等多个单元测试框架。譬如如果用户想使用junit,只需引入junit的依赖
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
然后在src/test/java下添加测试类。
如果用户使用的是testNG,将上面的依赖替换为testNG的依赖
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>6.9.8</version>
<scope>test</scope>
</dependency>
如果想做一些testNG框架级别的配置,通过参数配置即可:
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>testng-qa.xml</suiteXmlFile>
</suiteXmlFiles>
<parallel>methods</parallel>
<threadCount>10</threadCount>
<argLine>-Dfile.encoding=UTF-8</argLine>
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
接着聊聊其他方面:
服务架构
几个重要组件:
- 中央仓库:central repo,服务端,用来存储三方(3rd party)依赖和插件的文件
- 本地仓库:local repo
- 私有仓库:公司可以在自己的私有服务器上搭建仓库存储三方依赖和插件
- 本地客户端:本地访问远端repo的client
目录结构
目录结构也是一个convention,maven做了下面的规定:
配置文件
全局配置文件settings.xml
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
<localRepository/>
<interactiveMode/>
<offline/>
<pluginGroups/>
<servers/>
<mirrors/>
<proxies/>
<profiles/>
<activeProfiles/>
</settings>
譬如我们常配置的中央仓库镜像:
<mirrors>
<mirror>
<id>alimaven</id>
<name>aliyun maven</name>
<url>http://maven.aliyun.com/nexus/content/groups/public/</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
项目的配置文件:pom.xml
这个不详述了,需要知道优先级 pom.xml > settings.xml
依赖
两个比较重要的点:
依赖的scope:
scope用来定义一个依赖的传递性,即在某个阶段是否需要这个依赖的存在。一个依赖包可能在三个阶段被用到:编译,测试,运行时。相应的有以下几个scope:
- compile:默认scope;在所有的阶段都会存在
- provided:在编译和测试阶段会使用,但是在运行时不会使用;运行时由容器或者jdk提供依赖包。
- runtime:编译阶段不会使用,但是测试和运行时会使用。
- test:只在测试阶段被使用,例如JUnit和TestNG
- system:类似provided,需要提供一个存放jar的目录路径
- import:很少用,懒得写了。
使用原则:当然是最小化原则,如果只在测试阶段使用的依赖,应当加上test scope,可以避免无意义的包冲突,减少部署文件的大小。
依赖冲突解决:
人工通过<exclusions>标签进行排除某一个依赖包。通常是引入最新版本,因为java包设计时,通常是向后兼容(Backwards Compatibility)的。
当然,maven也有自动解决冲突的方式:短路径优先,声明优先(路径长度相同,谁先声明听谁的)
一种可控的方式是在父项目中通过dependencyManagement对依赖的版本进行锁定。
父子项目配置
maven中允许给两个项目之间赋予继承关系,即构成父子项目。子项目会继承父项目pom中的一些属性,可以避免重复的定义。
- 父项目配置:
- 打包方式:pom
- 父项目中的基本依赖管理
- 父项目中的统一依赖管理<dependencyManagement>(只有在子项目中使用时才会引入)
- 子项目配置
- 父项目的基本依赖一定会引入
- 父项目的统一依赖,可以不写版本号
类似还可以定义聚合项目:将一个庞大复杂的项目拆分成多个模块(通过new ->module的方式),最上级项目为聚合项目,里面的模块都是它的‘子项目’;这样能够方便更好协作/管理。聚合项目在父项目中会有modules标签。
对聚合项目执行打包,会对下属所有模块执行打包。
一些功能性标签
还有一些功通过一些maven定义的标签来实现,譬如Filtering Resources功能
<build>
<filters>
<filter>src/main/filters/filter.properties</filter>
</filters>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
# application.properties
application.name=${project.name}
application.version=${project.version}
message=${my.filter.value}
# filter.properties
my.filter.value=hello!
利用上面的filter标签,可以对资源里的占位符进行值的填充。
小结maven,一个规范化的依赖管理/项目构建工具,对于熟悉其定义的conventions(like lifecycle)的用户来讲,项目是非常容易维护的。另一方面这些定义的conventions也限制了它的灵活性。maven的插件机制从一定程度上缓解了这个问题。市面上有大量的插件几乎可以满足所有日常构建需求,但是少数的场景还是需要用户定制,如果你恰巧擅长并喜欢maven插件开发,那么congrats,maven就非常适合你。而对于一些大型复杂的项目,构建过程中需要很多定制化的逻辑,这时候maven的不灵活成为了它很大的短板,于是就有人转向了gradle,后者可以直接用groovy,scala等脚本语言直接定义构建过程。这个就下篇文章再整理吧。