本文从Maven谈起,分析了Maven的主要思想以及Gradle对Maven的改进,最后谈了下Go语言面临的依赖管理问题。

为什么要有依赖管理工具?


谈依赖管理之前我们先谈谈为什么要有依赖管理工具这东西。

我们学了一种编程语言,然后写了个『Hello World』,然后宣称自己学了一门语言,这时候确实不需要关心依赖问题。

然而,当你要写一个稍微复杂点的应用,那怕就是留言板这样的,需要读写数据库,就需要依赖数据库驱动,就会遇到依赖管理的问题了。

再进一步,你写了一个库,想共享给别人使用,更需要了解依赖管理的问题。

当然,如果项目足够简单,你可以直接将依赖方的源码放置在自己的项目中,或者将依赖库的二进制文件(比如jar,dll)放置在项目的lib里。要提供给别人呢?把二进制包提供下载或者给别人传过去。依赖管理工具出现之前大多数都是这样搞的。

但如果再复杂些,依赖库本身也有依赖怎么弄呢?将依赖压缩打包,然后放个readme帮助文件说明下,貌似也可以工作。

那如果你的项目依赖了好几个,乃至几十个库,而各库又有依赖,依赖也有自己的依赖,怎么办?怎么检测库的依赖是否有版本冲突?以后升级的时候怎么办?怎么判断lib目录下的某个文件是否被依赖了?

到这一步必须要承认需要有个依赖管理工具了,无论你使用任何语言。我们大约也清楚了依赖管理要做些什么。假设还没有依赖管理工具,我们自己要设计一个,如何入手?

  1. 要有一种依赖库的命名规则,或者叫坐标(Coordinates)的定义规则,可以通过坐标准确找到依赖的库。
  2. 要有对应的配置文件规则,来描述和定义依赖。
  3. 要有中心仓库保存这些依赖库,以及依赖库的元数据(metadata),供使用方拉取。
  4. 还需要一个本地工具去解析这个配置文件,实现依赖的拉取。

以上其实就是各依赖管理工具的核心要素。

聊聊Maven


Maven诞生于2004年(来源维基),查询了下,应该是各语言的依赖管理工具中早的。Ruby的gem也是2004年出现的,但gem离完备的依赖管理工具还差些,直到Ruby的bundler出现。Python的pip出现的更晚。

Maven的习惯是通过 groupID(一般是组织的域名倒写,遵循Java package的命名习惯)+ artifactId(库本身的名称) + version(版本)来定义坐标,通过xml来做配置文件,提供了中心仓库(repo.maven.org)以及本地工具(mvn)。

依赖定义:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
</dependency>

repo定义:

<repository>
    <id>repo.default</id>
    <name>Internal Release Repository</name>
    <url>http://repo.xxxxxx.com/nexus/content/repositories/releases</url>
    <releases>
    <enabled>true</enabled>
    <updatePolicy>interval:60</updatePolicy>
    <checksumPolicy>warn</checksumPolicy>
    </releases>
    <snapshots>
    <enabled>false</enabled>
    <updatePolicy>always</updatePolicy>
    <checksumPolicy>warn</checksumPolicy>
    </snapshots>
</repository>

同时为了避免依赖冲突的问题,Maven的依赖配置提供了exclude配置机制,用于阻断部分库的传递依赖。

Ruby的gem,Node的npm,Python的pip,iOS的CocoaPods都类似,只是配置文件语法和坐标命名规则有些差异。

至此,看起来Maven很简单啊,为啥许多人会觉得Maven复杂呢?

主要在于以下两点:

  1. Java这样需要编译的语言,发布的库是二进制版本的jar包,发布前需要有编译的流程,而依赖和编译是紧密相关的。不像Ruby,Node这样的脚本语言,将源码和配置文件扔到仓库就可以。
  2. Maven并没有将自己单纯的定义为依赖管理工具,而是项目管理工具,它将项目的整个生命周期都囊括进去了。

第二点也是Ant+ivy和Maven思路上的区别,ivy认为已经有Ant这样的编译打包工具了,只需在上面做个插件解决依赖问题即可,而Maven认为Ant本身也有改进的地方,所以一并改造了。

Maven的改进的核心思路是

Convention Over Configuration

即『约定大于配置』。既然大多数人习惯都把源码目录命名为src,那就约定好都用这个目录,不用专门去配置。同样,clean,compile,package等也约定好,不需要专门定义Ant task。这样既简化了配置文件,同时也降低了学习成本。一个Ant定义的项目,你需要阅读帮助文件或者查看build.xml文件才能了解如何编译打包,而Maven定义的项目直接运行『mvn package』即可。

Java语言发明的比较早,初期这种思想还不普及,所以Java本身没有对项目的规范,而新的语言基本都吸收了这个思想,对项目都做了约定和规范。比如Go语言,如果用C/C++可能需要定义复杂的Makefile来定义编译的规则,以及如何运行测试用例,而在Go中,这些都是约定好的。

Maven定义为项目管理工具,包含了项目从源码到发布的整个生命周期:

validate → generate-sources → process-sources
→ generate-resources → process-resources → compile 
→ process-classes → generate-test-sources
→ process-test-sources → generate-test-resources
→ process-test-resources → test-compile
→ test → prepare-package → package 
→ pre-integration-test → integration-test 
→ post-integration-test → verify → install → deploy 

既然包含了这么多功能和阶段,所以Maven引入了插件机制,Maven的本身的编辑打包等功能都是用插件来实现的,也允许用户自己定义插件。

同时涉及构建生命周期的不同的阶段,依赖也需要确定是编译依赖?测试依赖?运行时依赖?于是依赖多了scope的定义。

如果仅仅是这样把Maven理解成标准化的Ant+ivy+可扩展的插件框架即可?但现实世界的项目往往更复杂。

我们有了function用于组合代码块逻辑,有了object用于组合一组方法,有了package,namespace用于组合一组相关对象,但其实还需要有更高一个层次的组合定义 —– module,或者叫子项目。同一个项目下不同的源码目录可能需要编译打包成不同的二进制文件,这些module共同构成了一个整体的项目。这个其实和源码管理习惯有关系,是每个独立的module作为单独的源码仓库呢还是将相关的module全部放在一起?从降低沟通成本的角度考虑,还是应该通过一个大的仓库组织。

于是Maven引入了module的概念,同一个项目下可以有多个module,每个module有单独的pom文件来定义,但为了避免重复,Maven的pom文件支持parent机制,子项目的pom文件继承parent pom的基本配置。可以说,module的机制将Maven的复杂度又提升了一个层次,很多人遇到Maven的坑多栽到这里了。

这里介绍一个Maven多项目版本管理的最佳实践:

  1. 父项目中配置版本号,子项目中不要显示配置版本号,直接继承父项目的版本号。
  2. 子项目之间的依赖通过${project.version}引用,不要明确配置版本号。
  3. 发布新版的时候,同时发布所有子项目,即便是该子项目未做变更。
  4. 最好通过Maven的release插件发布,避免手动修改版本号导致的不一致问题。

即便是这样,Maven的多项目版本管理经常也会遇到问题。主要是因为Maven的子项目之间的依赖也沿用的是第三方库依赖的配置方式,需要指定子项目的版本号。另外子项目的parent需要显式配置,也需要明确指定parent的版本号。一旦这些版本号出现错误,最后就会导致各种诡异的问题。

Maven的release插件使用也比较复杂,该插件其实做几个事情:

  1. 先构建一遍项目,确认项目可以正常构建。
  2. 修改pom文件的版本号到正式版,然后提交到源码仓库并打tag。
  3. 将该tag的源码检出,再构建一次,这次构建的jar包的版本是正式版的,将jar包上传到Maven仓库。
  4. 递增版本号,修改pom文件的版本号到SNAPSHOT,再次提交到源码仓库。

这个过程中,由于要构建两次,提交两次源码仓库,上传一次jar包,任何一步出错都会导致release失败,所以使用比较复杂。

到此,Maven的核心概念都分析完了,其他的都是插件机制上的一些扩展。大家也应该明白了Maven之所以最后变这么复杂的原因。

但无论如何,Maven基本上是项目管理工具的标杆了,有的语言直接通过扩展插件来用Maven管理,比如C++,C#(NMaven),或者做了移植Byldan(C#),不过貌似都是不太成功,估计主要原因应该是Maven是用Java写的,有社区隔膜。

Gradle对Maven的改进


聊了Maven的思路和优势,那Maven的缺点呢?这个我们和Gradle一起聊聊。Gradle就是在Maven的基础上进行的改进。优势主要体现在以下方面:

  1. 配置语言
    Maven使用的是XML,受限于XML的表达能力以及XML本身的冗余,会使pom.xml文件显得冗长而笨重。而Gradle是基于Groovy定义的一种DSL语言,简洁并且表达能力强大。在Maven中,任何扩展都需要通过Maven插件实现,但Gradle的配置文件本身就是一种语言,可以直接依赖任意Java库,可以直接在build.gradle文件中像Ant一样定义task,比Ant的表达能力更强(Ant本身也是XML定义的)。
    Gradle的配置文件中可以直接获取到Project对象以及环境变量,可以通过程序对build过程进行更细致的自定义控制,这个功能对于复杂的项目来说非常有用。
  2. 项目自包含(Self Provisioning Build Environment)
    用户下载了一个Maven定义的项目,如果没用过Maven,还需要下载Maven工具包,了解Maven。但Gradle可以给项目生成一个gradlew脚本,用户直接运行gradlew脚本即可,该脚本会自动检测本地是否已经有Gradle,没有则从网络下载,对用户透明(当然,国内网络下最好还是自己先下载好)。
    对仓库的配置,Maven提供了一个本地的settings.xml配置文件,用于定义私有仓库以及仓库密码这样的敏感的不应该放源码仓库里的文件。但这样带来的不便就是这些信息项目中没有自包含,所以Gradle干掉了这种本地配置的机制,所有的定义都在项目里。私有仓库密码这样的可以放在项目下的gradle.properties文件里不提交上去,通过其他方式分享给内部成员。这点可能各有优劣。
  3. 任务依赖以及执行机制
    Maven的构建生命周期的每一步都是预定义好的(参看前文),插件任务只能在预留的生命周期中的某个阶段切入,虽然Maven的生命周期阶段考虑很充分,但有时候也不能满足需求。Maven会严格按照生命周期的阶段从开始线性执行任务,而Gradle则使用了Directed Acyclic Graph来检测任务的依赖关系,决定哪些任务可以并行执行,这样使任务的定义以及执行都更灵活。
  4. 依赖管理更为灵活
    Maven对依赖管理比较严格,依赖必须是源码仓库的坐标。虽然也支持system scope的本地路径配置,但还是有许多不方便之处(system scope的依赖,打包的时候不包含进来)。如果世界上所有的库都通过Maven发布,当然没有问题,但现实往往不是这样的。这里要吐槽一下国内的各大厂发布的sdk之类的库,几乎都不提供仓库地址,就给个压缩包放一堆jar包进来,让用户自己搞定依赖管理问题。而Gradle在这方面比较灵活,比如支持:

     compile fileTree(dir: 'libs', include: '*.jar')  
    

    这样的配置规则。
    另外由于Gradle本身是一种语言,可以用编程的方式来管理依赖。比如大多数子项目都依赖某个库,除了个别几个,就可以这样写:

     configure(subprojects.findAll {it.name != 'xxx1’ && it.name != ‘xxx2’}) {  
         dependencies {  
             compile("com.google.guava:guava:18.0”)  
         }  
     }
    
  5. 子项目以及动态依赖机制
    动态依赖主要是用来解决几个互相依赖的库都在快速开发期间的依赖问题,不能每次地层库修改发布新版本,上层库都要修改依赖配置文件,所以需要动态设置依赖最新版本。
    Maven的解决方案是SNAPSHOT机制,子项目之间也是通过这个机制来实现依赖的。遇到的问题我们前面也分析了。
    Gradle的虽然也兼容Maven仓库的SNAPSHOT机制,但它自己的版本管理机制上,并没有引入SNAPSHOT机制。它的依赖支持4.x,2.+这样的配置规则,实现动态依赖(注:Maven也支持类似的规则,参看 Dependency Version Requirement Specification)。而子项目之间的依赖采用特殊的依赖配置,和第三方库的配置规则有区别。它直接使用:
    compile project(“:subpoject-name”);

    这样的配置,无需配置版本号,明确指定是子项目,避免Maven的子项目依赖带来的版本号问题。子项目的配置中也不需要显示配置父项目,只需要父项目单向中配置子项目列表即可。
    同时Gradle的release机制也更为灵活,支持release到各种仓库(包括Maven仓库),但不控制release过程中的版本号生成,修改源码仓库等步骤,留给用户自己通过手动或者CI工具,或者脚本去解决。

关于Gradle相对Maven的改进这里主要列举这几点,其他的可以参看Gradle官方的比较表格:maven_vs_gradle,这里不再详述。

Go语言的多项目以及依赖管理问题


最后再谈谈Go语言的多项目以及依赖管理问题。Go官方对这两方面并未做约定或者提供工具,于是只能各自想办法解决。多项目问题一般就是回归到了Makefile+脚本的解决方案,比如kubernetes。依赖管理,开源社区多用Godeps,kubernetes用的也是这个。Godeps通过源码仓库路径以及源码tag来确定库的坐标,只管理依赖,有点像ivy,不关心构建过程。Godepes会将依赖库的依赖也添加到当前项目的依赖配置中,不是动态的依赖传递机制。没有scope,不区分是否是单元测试的依赖。一个仓库只支持一个配置,没有子项目概念,项目大了管理就比较复杂。另外它对传递依赖以及版本冲突的问题当前还是没有解决太好(有一些相关Issue)。

一个语言的多项目以及依赖管理方案对这个语言的生态发展有很大的影响,Java发展到现在,Maven以及Gradle功不可没,所以感觉Go官方应该对这两方面有所作为。Go语言迟迟没出依赖管理工具个人觉得有几方面考虑:

  1. Go尚未确定动态库的机制。编译型语言依赖最好也是二进制的,而不是源码。一方面可以加快编译速度,另外一方面也可以实现源码保护,方便分发以及代理缓存,让语言的适用范围更广。许多商业上的库是不方便提供源码的。所以依赖管理工具的实现需要动态库的机制。而动态库尚未确定的原因我觉得是Go语言不想过早的引入二进制动态库的格式兼容问题,初期全部用源码是最省事的。
  2. 先让社区试试水,看看效果和反馈。

任何一个语言,发展到一定阶段都避不开依赖管理问题。前一段时间看到一篇写Go语言的文章,嘲讽Java的Maven构建个项目恨不能把半个互联网下载下来,我当时脑海中就浮现出长者的那句经典语录『图样图森破』。Go当前没遇到这些问题的原因只是Go还比较年轻,库还不够丰富,以及Go的很多项目还不够复杂。而像kubernetes这样的项目,当前依赖已经有226个了,构建一下,也快要下载半个Github了。所以个人觉得Go社区当前还是非常需要一个类似于Gradle的工具,来解决依赖管理,构建,多项目管理等问题。

code

相关阅读:


  1. How Bundler Works: A History of Ruby Dependency Management
  2. maven vs gradle
  3. Directed acyclic graph
  4. 唐巧–用CocoaPods做iOS程序的依赖管理
  5. 寻找最优解,Golang的包管理之道