一 背景
為什么想寫此文
去年的Log4j-core的安全問題,再次把供應(yīng)鏈安全推向了高潮。在供應(yīng)鏈安全的場(chǎng)景,螞蟻集團(tuán)在靜態(tài)代碼掃描平臺(tái)-STC和資產(chǎn)威脅透視平臺(tái)-哈勃這2款產(chǎn)品的聯(lián)動(dòng)合作下,優(yōu)勢(shì)互補(bǔ),很好的解決了直接依賴和間接依賴的場(chǎng)景。
但是由于STC是基于事前,受限于掃描效率存在遺漏的風(fēng)險(xiǎn)面,而哈勃又是基于事后,存在修復(fù)時(shí)間上的風(fēng)險(xiǎn)。基于此,筆者嘗試尋找一種方式可以同時(shí)解決2款產(chǎn)品的短板。筆者嘗試研究了一下Maven是如何處理一個(gè)項(xiàng)目中的直接依賴和間接依賴的,并且在遇到相同依賴時(shí),Maven是如何進(jìn)行抉擇的,這里的如何抉擇其實(shí)就是Maven的仲裁機(jī)制。帶著這些問題,筆者嘗試調(diào)研了Maven的源碼和做了一些本地的測(cè)試實(shí)驗(yàn)??偨Y(jié)了這篇文章。
坐標(biāo)是什么?
在空間坐標(biāo)系中,我們可以通過xyz表示一個(gè)點(diǎn),同樣在Maven的世界里,我們可以通過一組GAV在依賴的世界里明確表示一個(gè)依賴,比如:
: com.alibaba 一般是公司的名稱
: fastjson 項(xiàng)目名稱
: 1.2.24 版本號(hào)
影響依賴的標(biāo)簽都有哪些
1.
直接引入具體的依賴信息。注意是不在標(biāo)簽內(nèi)的情況。如果是在內(nèi)的情況,請(qǐng)參考2號(hào)標(biāo)簽。
2.
只聲明但不發(fā)生實(shí)際引入,作為依賴管理。依賴管理是指真正發(fā)生依賴的時(shí)候,再去參考依賴管理的數(shù)據(jù)。
這樣使用dependency的時(shí)候,可以缺省version。
另外還可以管控所有的間接依賴,即使間接依賴聲明了version,也要被覆蓋掉。
3.
聲明自己的父親,Maven的繼承哲學(xué)跟JAVA很類似,因?yàn)镸aven本身也是用Java實(shí)現(xiàn)的,滿足單繼承。
一旦子pom繼承了父pom,那么會(huì)把父pom里的,等等屬性都繼承過來的。當(dāng)然如果在繼承的過程中,出現(xiàn)一樣的元素,也是子去覆蓋父親,和Java類似。
繼承時(shí),會(huì)分類繼承。dependencies繼承dependencies,dependencyManagement里的依賴管理只能繼承dependencyManagement范圍內(nèi)的依賴管理。
每一個(gè)pom文件都會(huì)有一個(gè)父親,即使不聲明Parent,也會(huì)默認(rèn)有一個(gè)父親。和Java的Object設(shè)計(jì)哲學(xué)類似。后面在源碼分析中我們還會(huì)提到。
4.
代表當(dāng)前自己的項(xiàng)目的一個(gè)屬性的集合。
properties僅僅代表屬性的聲明,一個(gè)屬性聲明了,和他是否被引用并無關(guān)系。我完全可以聲明一系列不被人使用的屬性。
依賴的作用域都有哪些
一個(gè)依賴在引入的時(shí)候,是可以聲明這個(gè)依賴的作用范圍的。比如這個(gè)依賴只對(duì)本地起作用,比如只對(duì)測(cè)試起作用等等。作用域一共有compile,provided,system,test,import,runtime 這幾個(gè)值。
簡單總結(jié)一下:
compile和runtime會(huì)參與最后的打包環(huán)節(jié),其余的都不會(huì)。compile可以不寫。
test只會(huì)對(duì) src/test目錄下的測(cè)試代碼起作用。
provided是指線上已經(jīng)提供了這個(gè)Jar包,打包的時(shí)候不需要在考慮他了,一般像servlet的包很多都是provided。
system和provided沒什么太大的區(qū)別。
import只會(huì)出現(xiàn)在dependencyManagement標(biāo)簽內(nèi)的依賴中,是為了解決Maven的單繼承。引入了這個(gè)作用域的話,maven會(huì)把此依賴的所有的dependencyManagement內(nèi)的元素加載到當(dāng)前pom中的,但不會(huì)引入當(dāng)前節(jié)點(diǎn)。如下圖,并不會(huì)引入fastjson作為依賴管理的元素,只是會(huì)把fastjson文件定義的依賴管理引入進(jìn)來。
二 單個(gè)Pom樹的依賴競(jìng)爭
Pom文件本質(zhì)
一個(gè)Pom文件的本質(zhì)就是一棵樹。
在人的視角來觀察一個(gè)Pom文件的時(shí)候,我們會(huì)認(rèn)為他是一個(gè)線狀的一個(gè)依賴列表,我們會(huì)認(rèn)為下圖的Pom文件抽象出來的結(jié)果是C依賴了A,B,D。但我們的視角是不完備的,Maven的視角來看,Maven會(huì)把這一個(gè)Pom文件直接抽象成一個(gè)依賴樹。Maven的視角能看到除了ABD之外的節(jié)點(diǎn)。而人只能看到ABD三個(gè)節(jié)點(diǎn)。
既然是在一棵樹上,那么相同的節(jié)點(diǎn)就必然會(huì)存在競(jìng)爭關(guān)系。這個(gè)競(jìng)爭關(guān)系就是我們提到了仲裁機(jī)制。
Maven仲裁機(jī)制原則
1.依賴競(jìng)爭時(shí),越靠近主干的越優(yōu)先。
2.單顆樹在依賴在競(jìng)爭時(shí)(dependencies)(注意:不是dependencyManagement里的dependencies):
當(dāng)deep=1,即直接依賴。同級(jí)是靠后優(yōu)先。
當(dāng)deep>1,即間接依賴。同級(jí)是靠前優(yōu)先。
3.單顆樹在依賴管理在競(jìng)爭時(shí)(注意:是dependencyManagement里的dependencies)是靠前優(yōu)先的。
4.maven里最重要的2個(gè)關(guān)系,分別是繼承關(guān)系和依賴關(guān)系。我們所有的規(guī)律都應(yīng)該只從這2個(gè)關(guān)系入手。
下圖中分別是2個(gè)子pom文件(方塊代表依賴的節(jié)點(diǎn),A-1 表示A這個(gè)節(jié)點(diǎn)使用的是1版本,字母代表節(jié)點(diǎn),數(shù)字代表版本)。
左邊這個(gè)子pom生成的樹依賴了 D-1,D-2和D-5。滿足依賴競(jìng)爭原則1,即越靠近樹的左側(cè)越優(yōu)先的原則,所以D-5會(huì)競(jìng)爭成功。
但是B-1和B-2同時(shí)都位于樹的同一深度,并且深度為1,由于B-2更加靠后,所以B-2會(huì)競(jìng)爭成功。
右邊的子pom生成的樹依賴了 D-1和D-2,并且位于同一深度,但由于D-1和D-2是屬于間接依賴的范圍,deep大于1,所以是靠前優(yōu)先,那么也就是D-1會(huì)競(jìng)爭成功。
常見場(chǎng)景
看到這里,想必大家已經(jīng)了解了Maven的仲裁原則。但是在實(shí)際的工作中,光有原則還需要在代碼中可以靈活的運(yùn)用才能有屬于自己的理解,這里筆者準(zhǔn)備了5個(gè)場(chǎng)景,每個(gè)場(chǎng)景對(duì)應(yīng)的答案都在后面,大家閱讀時(shí),可以自己嘗試用Maven的原則來去推理,看看有沒有哪里不符合預(yù)期的情況。
場(chǎng)景一 難度(*)
場(chǎng)景描述
主POM里有這個(gè)屬性為1.2.24。
父親是spring-boot-starter-parent-3.13.0。父親里的是1.2.77。
并且在主pom中,消費(fèi)了這個(gè)屬性。
那么針對(duì)主POM這顆樹,他最終會(huì)是使用哪一個(gè)fastjson呢?
場(chǎng)景示例
結(jié)構(gòu)圖
場(chǎng)景二 難度(**)
在同一個(gè)主POM或者子POM中的dependencies中同時(shí)使用了Fastjson,第一個(gè)聲明了1.2.24的版本,第二個(gè)聲明了1.2.25版本。那么針對(duì)主POM或者子pom這棵樹,最終會(huì)選擇fastjson 1.2.24還是1.2.25呢?
場(chǎng)景示例
結(jié)構(gòu)圖
場(chǎng)景三 難度(***)
下圖中左圖為主POM文件內(nèi)的dependencyManagement里的fastjson為1.2.77,這個(gè)時(shí)候子POM中顯示聲明自己的版本1.2.78。那么針對(duì)子POM這顆樹,子POM會(huì)選擇聽從父命還是遵從內(nèi)心呢?
場(chǎng)景示例
結(jié)構(gòu)圖
場(chǎng)景四 難度(****)
主POM的dependenciesFastjson:1.2.24主POM的dependencymanagentFastjson:1.2.77
主POM的父親(springboot)的dependenciesFastjson 1.2.78
子POM里的dependenciesFastjson 1.2.25
這種情況下針對(duì)子pom來說,他會(huì)選擇4個(gè)版本中的哪一個(gè)呢?
場(chǎng)景示例
結(jié)構(gòu)圖
場(chǎng)景五 難度(*****)
主POM的dependenciesFastjson:1.2.24主POM的dependencymanagentFastjson:1.2.77
主POM的父親(springboot)的dependenciesFastjson 1.2.78
子POM里的dependencies 不寫version
場(chǎng)景五跟場(chǎng)景四整體沒有差別,只是將子pom的dependencies的版本進(jìn)行缺省。
這種情況下針對(duì)子pom來說,針對(duì)子pom,他會(huì)選擇3個(gè)版本中的哪一個(gè)呢?
場(chǎng)景示例
結(jié)構(gòu)圖
答案
場(chǎng)景一
1.2.24會(huì)最終生效。
因?yàn)樽訒?huì)繼承父親的屬性,但是由于自己有這個(gè)屬性,那么則覆蓋!
繼承一定會(huì)伴隨著覆蓋的,這個(gè)設(shè)計(jì)在編程語言中還是比較普遍的。
場(chǎng)景二
1.2.25會(huì)最終生效。
參考 單顆樹在依賴在競(jìng)爭時(shí):當(dāng)deep=1,即直接依賴。同級(jí)是靠后優(yōu)先。
滿足Maven的核心競(jìng)爭依賴策略!
場(chǎng)景三
1.2.78最終會(huì)生效。
一個(gè)項(xiàng)目里的dependencyManagement只能對(duì)不聲明version的dependency和間接依賴有效!
場(chǎng)景四
1.2.25會(huì)最終生效。這個(gè)比較復(fù)雜。
〇: 首先根據(jù)父子的繼承關(guān)系,1.2.24會(huì)覆蓋掉1.2.78。所以78版本淘汰
一: 由于一個(gè)項(xiàng)目里的dependencyManagement只能對(duì)不聲明version的dependency和間接依賴有效,所以
1.2.77無法對(duì)1.2.25起作用。
二: 由于父子的繼承關(guān)系,1.2.25會(huì)覆蓋掉1.2.24.
所以最終1.2.25勝出!
場(chǎng)景五
1.2.77會(huì)最終生效。
〇: 首先根據(jù)父子的繼承關(guān)系,1.2.24會(huì)覆蓋掉1.2.78。所以78版本淘汰
一: 由于一個(gè)項(xiàng)目里的dependencyManagement是可以對(duì)不聲明的version起作用,所以子pom的版本為1.2.77
二: 由于父子的繼承關(guān)系,1.2.77會(huì)覆蓋掉1.2.24.
所以最終1.2.77勝出!
三 多個(gè)Pom樹合并打包
多棵樹構(gòu)建順序原則
現(xiàn)在的項(xiàng)目一般都是多模塊管理,會(huì)存在非常多的pom文件。多棵樹的情況下每棵樹的出場(chǎng)順序都是事先已經(jīng)被計(jì)算好的。
這個(gè)功能在Maven的源碼中是一個(gè)叫Reactor(反應(yīng)堆)實(shí)現(xiàn)的。它主要做了一件事情就是決定一個(gè)項(xiàng)目中,多個(gè)子pom誰先進(jìn)行build的順序,這個(gè)出廠順序很重要,在合并打包時(shí),往往決定了最終誰會(huì)在多個(gè)pom之間勝出的問題。
Reactor的原則
多棵樹(多個(gè)子pom)構(gòu)建的順序是按照被依賴方的要在前,依賴方在后的原則。
項(xiàng)目要保證這里是不能出現(xiàn)循環(huán)依賴的。
Reactor的原則圖解
如下圖子pom1 在被子pom2和子pom3同時(shí)依賴,所以子pom1最先被構(gòu)建,子pom3沒有人被依賴,所以最后構(gòu)建。
SpringBoot Fatjar打包的策略
SpringBoot 打包會(huì)打成一個(gè)Fatjar,所有的依賴都會(huì)放在BOOT-INF/lib/目錄下。SpringBoot的打包是越靠后的構(gòu)建pom越優(yōu)先,因?yàn)橐话銜?huì)把springboot的打包插件放在最不被依賴的module里(比如上圖里的Pom3)。(SpringBoot的打包插件一般放在bootstrap pom里,這個(gè)名字可以我們自己起,一般都是依賴關(guān)系最靠上的module。在多模塊管理的springboot應(yīng)用內(nèi),bootstrap往往是最不被依賴的那個(gè)module。)
子pom3最后參與構(gòu)建,而且SpringBoot打包插件一般打的就是這個(gè)module。所以最終進(jìn)入到SpringBoot打包產(chǎn)物的有A-2,B-2,E-2,F-2和D-1。因?yàn)锳-2和B-2相比于其他幾個(gè)相同節(jié)點(diǎn)更靠近樹的主干。E-2和F-2也是同理。這個(gè)規(guī)律體感上是靠后優(yōu)先了,因?yàn)榭亢蟮臉涮烊桓涌拷鞲伞?/p>
四 仲裁機(jī)制在Maven源碼中的實(shí)現(xiàn)
以Maven的3.6.3版本的源碼進(jìn)行分析,我們嘗試分析Maven中對(duì)依賴處理的幾處原則,方能從源碼的層面上正向的證明仲裁機(jī)制的準(zhǔn)確性。另外從源碼上也可以看出一些Maven上的機(jī)制為什么是這樣,而不是單單的他的機(jī)制是什么樣。因?yàn)楣P者相信,任何機(jī)制都無法保證與時(shí)俱進(jìn)下的先進(jìn)性,所以筆者認(rèn)為上文中提到的所有的仲裁機(jī)制有一天可能會(huì)發(fā)生變化,這些結(jié)論并非最重要,而是如何調(diào)研這些結(jié)論更為重要!
Maven是如何實(shí)現(xiàn)出繼承并且相同屬性子覆蓋父的
Maven中有2條非常重要的主線。一個(gè)是依賴,另一個(gè)就是繼承。Maven在源碼中實(shí)現(xiàn)繼承大體如下。在下圖中使用readParent進(jìn)行對(duì)父親的模型獲取之后,便讓自己陷入這個(gè)循環(huán)中。唯一可以出去這個(gè)循環(huán)的方式就是追不到父親為止。并且把每次取到模型數(shù)據(jù)放到linega這個(gè)對(duì)象當(dāng)中。下圖中最下面的assembleInheritance我們看他消費(fèi)了linega這個(gè)對(duì)象,目的就是完成真實(shí)的繼承和覆蓋。
在assembleInheritance中我們會(huì)發(fā)現(xiàn)一個(gè)很有意思的現(xiàn)象,lingage是倒著進(jìn)行遍歷,并且是從倒數(shù)第二個(gè)元素開始,這正是上文中我們提到了的Maven的一個(gè)設(shè)計(jì)哲學(xué)。Maven認(rèn)為這個(gè)世界上所有的pom文件都存在一個(gè)父親,類似Java的Object。這里便是對(duì)這個(gè)哲學(xué)處理的一個(gè)淺邏輯。
另外Maven自上而下的去遍歷,更加方便自己去實(shí)現(xiàn)相同的元素子覆蓋父的能力,這也是筆者認(rèn)為在編碼上的一個(gè)小心思。
Reactor反應(yīng)堆在源碼中的實(shí)現(xiàn)
上文中我們還提到了一個(gè)非常重要的概念,就是反應(yīng)堆。反應(yīng)堆直接決定了各個(gè)子pom是如何決定構(gòu)建順序的。在Maven的源碼中,他是在getProjectsForMavenReactor函數(shù)中進(jìn)行實(shí)現(xiàn)的。并且我們從下圖中也可以看到,Maven的反應(yīng)堆是不能解決循環(huán)依賴的,他直接捕獲了這種異常!
真正實(shí)現(xiàn)反應(yīng)堆算法的是在ProjectSorter的構(gòu)造函數(shù)中通過Dag進(jìn)行實(shí)現(xiàn)的。Dag(有向無環(huán)圖)和廣度優(yōu)先搜索是解決依賴場(chǎng)景是一個(gè)很好的方式。
在有向無環(huán)圖中通過每次挑選出入度為0的節(jié)點(diǎn),再刪除該節(jié)點(diǎn)和此節(jié)點(diǎn)的相鄰邊,不斷重復(fù)上述步驟。就可以高效率的計(jì)算出DAG上的所有節(jié)點(diǎn)的依賴順序,Maven也正是用到了這個(gè)思路。
從這個(gè)源碼的視角也可以解釋為什么Maven必須要保證每一個(gè)子pom之前不能出現(xiàn)循環(huán)依賴。
同一個(gè)Pom文件內(nèi)dependency 后聲明的優(yōu)先的實(shí)現(xiàn)
在處理Dependencies時(shí),Maven并沒有對(duì)此進(jìn)行特殊處理,是直接使用的Map的方式進(jìn)行覆蓋的。關(guān)于這里為什么這么設(shè)計(jì),筆者并不清楚。筆者曾一度猜測(cè)這么設(shè)計(jì)是為了讓開發(fā)同學(xué)更好的編寫,因?yàn)榭亢髢?yōu)先往往符合大部分人的編碼習(xí)慣。但是在這里我們看到了作者的一行注釋,意思大概是說,這樣設(shè)計(jì)是為了向后兼容Maven2.x,因?yàn)镸aven2.x 是不會(huì)去校驗(yàn)一個(gè)文件是否只存在一個(gè)同GA的唯一依賴。所以后面的maven的版本應(yīng)該也是延續(xù)了這種風(fēng)格。
當(dāng)循環(huán)進(jìn)行處理到1.2.25的時(shí)候,依然進(jìn)行對(duì)normalized這個(gè)map進(jìn)行put操作導(dǎo)致了 key值相同的情況下的覆蓋。
五 安全視角應(yīng)如何避免間接依賴
分析
作為安全同學(xué),筆者更希望的是針對(duì)這種多module的Maven項(xiàng)目可以梳理出一個(gè)經(jīng)驗(yàn),怎樣去避免間接依賴的問題。
經(jīng)過上面的分析,我們可以得出3條結(jié)論:
1.子pom聲明版本在安全視角是非常危險(xiǎn)的,子pom不應(yīng)該顯示聲明版本。
由于子pom會(huì)繼承主pom的元素,并且在繼承的時(shí)候會(huì)出現(xiàn)覆蓋的場(chǎng)景。那么針對(duì)CE或者SpringBoot打包時(shí),有可能出現(xiàn)子pom的build的順序位置天然非常有優(yōu)勢(shì),容易造成子pom的版本進(jìn)入最終的打包產(chǎn)物。
2.主POM的dependencyManagent可以管控到 間接依賴 和 不顯示聲明version的直接依賴。
3.主POM的dependencies不能出現(xiàn)危險(xiǎn)版本。否則子pom天然的繼承了這個(gè)危險(xiǎn)版本參與打包。
結(jié)論
以上幾條同時(shí)滿足,便可以解決間接依賴的問題。
即:
針對(duì)SpringBoot而言,子pom不應(yīng)該顯示聲明版本,主Pom的dependencyManagent應(yīng)該管控安全版本的依賴,并且主pom不能出現(xiàn)危險(xiǎn)版本。(主Pom dependencies強(qiáng)行寫上安全版本更佳,這樣可以避免掉依賴的父親里存在殘留的不安全的依賴)
六 最后
Maven的源碼地址
https://archive.Apache.org/dist/maven/maven-3/
我是怎么分析的
本人在本地針對(duì)SpringBoot,做多輪測(cè)試。在根目錄下執(zhí)行mvn clean package即可!
另外就是嘗試在源碼中找到這里的實(shí)現(xiàn),這樣更能加深理解!
常用的分析命令
0. mvn clean package -DSkipTest 直接進(jìn)行打包,進(jìn)行結(jié)果分析
1. mvn dependency:tree 會(huì)把整個(gè)的maven的樹形結(jié)構(gòu)輸出
2.mvn help:effective-pom -Dverbose 這個(gè)命令輸出的信息更加完整,輸出的是effectivepom