สลับสองบรรทัดใน pom.xml แล้ว build ยังเขียว แต่โปรแกรมพิมพ์ได้บรรทัดเดียว
คุณเปิด pom.xml แล้วสลับที่ <dependency> สองก้อน เพราะอยากให้มันเรียงตามตัวอักษร
คุณไม่ได้แตะรุ่นสักตัว ไม่ได้ลบอะไร ไม่ได้เพิ่มอะไร
mvn compile ยังเขียว รหัสออก 0 เหมือนเดิมทั้งก่อนและหลัง
แล้วโปรแกรมที่เคยพิมพ์ 2 บรรทัด พิมพ์ได้ 1 บรรทัด
แล้วตายด้วย NoSuchMethodError
หน้านี้เอากราฟ dependency ใบเดียว ที่มี library ตัวหนึ่งถูกขอสองรุ่นจากสองเส้นทาง ไปให้ resolver ตัวจริงสองตัวตัดสิน แล้ววัดว่าใครได้อะไรมาบน classpath
ธีสิสของหน้านี้อยู่ตรงนี้ — ทั้งสองตัวเลือกให้คุณเงียบ ๆ อยู่แล้ว และกฎที่มันใช้เลือก ไม่ใช่กฎเดียวกัน สิ่งที่ Maven เรียกว่าคำตอบที่ถูก คือสิ่งที่ Gradle ทิ้ง และบรรทัดที่คุณเขียนเองเพื่อสั่งมัน ฝั่งหนึ่งเชื่อฟัง อีกฝั่งเขียนทับ
กราฟที่ใช้วัด มีสี่ artifact และเส้นทางสองเส้น
หัวข้อที่มีชื่อว่า “กราฟที่ใช้วัด มีสี่ artifact และเส้นทางสองเส้น”ทำไมถึงต้องสร้าง library ปลอมขึ้นมาเอง แทนที่จะหยิบของจริงจาก Maven Central มาวัด
graph TD app["app<br/>โปรแกรมของคุณ"] --> liba["liba 1.0.0"] app --> libb["libb 1.0.0"] liba --> core1["core 1.0.0<br/>มี name()"] libb --> core2["core 2.0.0<br/>มี name() และ extra()"]
liba ขอ core รุ่น 1.0.0 ส่วน libb ขอรุ่น 2.0.0 ทั้งคู่อยู่ลึกเท่ากันคือสองชั้น
และ app ไม่ได้เอ่ยถึง core เลยสักบรรทัด
core รุ่น 2.0.0 เพิ่ม method ชื่อ extra() ที่รุ่น 1.0.0 ไม่มี
และ libb เรียก method นั้นอยู่ข้างใน มันจึงเป็นการเปลี่ยนที่เข้ากันไม่ได้ที่ระดับ binary
การใช้ของปลอมที่เราคุมเองทำให้ผลซ้ำได้ทีละอักขระ ของจริงจาก Maven Central มีรุ่นใหม่ออกเมื่อไรก็ได้ แล้วตัวเลขในหน้านี้จะขยับตามโดยไม่มีใครแก้อะไร
ทุกค่าในหน้านี้มาจาก scripts/dep-conflict/spike.py ที่รัน resolver ตัวจริงทั้งสองฝั่ง
บน Apache Maven 3.9.16 (2bdd9fddda4b155ebf8000e807eb73fd829a51d5) · Gradle 9.7.1 · openjdk version "25.0.4.1" 2026-08-18
หน่วยของหลักฐานทั้งหน้าคือสตริงรุ่นที่ resolver เลือก รหัสออกของ build รหัสออกของโปรแกรม ชื่อ class ของข้อยกเว้น จำนวน jar และจำนวนบรรทัด ไม่มีหน่วยเวลาสักช่อง เครื่องช้าจึงเปลี่ยนคำตอบไม่ได้ · probe รันชุดเดิม 3 รอบต่อการเรียกหนึ่งครั้ง และแดงถ้ามีค่าไหนขยับ
Maven เลือกตัวที่ใกล้ที่สุด และเมื่อเสมอกัน มันเลือกตัวที่ประกาศก่อน
หัวข้อที่มีชื่อว่า “Maven เลือกตัวที่ใกล้ที่สุด และเมื่อเสมอกัน มันเลือกตัวที่ประกาศก่อน”ลำดับของ
<dependency>สองก้อนตัดสินว่า jar ใบไหนขึ้น classpath
ทั้งสองเส้นทางอยู่ลึกสองชั้นเท่ากัน กฎ nearest-wins จึงเสมอ และตัวที่ตัดสินการเสมอคือลำดับที่คุณพิมพ์
| ประกาศตัวไหนก่อน | core ที่ได้ | รหัสออกของ build | รหัสออกของโปรแกรม |
|---|---|---|---|
liba | 1.0.0 | 0 | 1 |
libb | 2.0.0 | 0 | 0 |
pom.xml สองใบนี้ต่างกันที่การเรียงของสองก้อนเท่านั้น รุ่นที่ประกาศเหมือนกันทุกตัว
จำนวน jar บน classpath เท่ากันทั้งสองทรง คือ 3 ใบ ปัญหาจึงไม่ได้อยู่ที่จำนวน มันอยู่ที่ตัวตน
jar ที่ไม่มีใครแตะ พิมพ์ข้อความคนละอย่าง
หัวข้อที่มีชื่อว่า “jar ที่ไม่มีใครแตะ พิมพ์ข้อความคนละอย่าง”liba ถูกคอมไพล์ครั้งเดียว แล้วไม่มีใครแก้มันอีก แต่บรรทัดที่มันพิมพ์ออกมาคนละบรรทัด
| ประกาศตัวไหนก่อน | บรรทัดแรกที่ได้ |
|---|---|
liba | A sees core-1.0.0 |
libb | A sees core-2.0.0 |
liba ถูกสร้างขึ้นโดยเห็น core รุ่น 1.0.0 แต่ตอนรันมันได้รุ่น 2.0.0 มาแทน
แล้วมันก็ทำงานต่อไปเงียบ ๆ ด้วยพฤติกรรมของรุ่นที่มันไม่เคยเห็น
ส่วน libb ไม่ได้โชคดีแบบนั้น มันเรียก extra() ซึ่งรุ่น 1.0.0 ไม่มี
ทรงแรกจึงพิมพ์ได้บรรทัดเดียวแล้วตายด้วย NoSuchMethodError
mvn compile ในทรงที่ชนกัน คายคำเตือนที่เอ่ยถึง core ออกมา
0 บรรทัด
การเลือกเกิดขึ้นแล้ว รุ่นหนึ่งถูกทิ้งแล้ว และไม่มีอะไรบนหน้าจอบอกว่ามันเกิดขึ้น
Gradle เลือกตัวที่สูงที่สุด ลำดับจึงไม่มีผล
หัวข้อที่มีชื่อว่า “Gradle เลือกตัวที่สูงที่สุด ลำดับจึงไม่มีผล”กราฟใบเดียวกัน สลับลำดับเหมือนกัน แต่คำตอบไม่ขยับ
| ประกาศตัวไหนก่อน | core ที่ Maven เลือก | core ที่ Gradle เลือก |
|---|---|---|
liba | 1.0.0 | 2.0.0 |
libb | 2.0.0 | 2.0.0 |
Gradle ไม่สนใจว่าใครอยู่ใกล้กว่าหรือใครถูกพิมพ์ก่อน มันเอารุ่นที่สูงที่สุดที่มีคนขอ
กฎนี้ทำให้ผลนิ่งกว่า แต่มันไม่ได้แปลว่าปลอดภัยกว่า มันแปลว่าคุณถูกอัปเกรดโดยไม่ได้ขอ
liba ที่เขียนไว้สำหรับรุ่น 1.0.0 ได้รุ่น 2.0.0 ไปทุกครั้ง
บรรทัดที่คุณเขียนเอง ฝั่งหนึ่งเชื่อฟัง อีกฝั่งเขียนทับ
หัวข้อที่มีชื่อว่า “บรรทัดที่คุณเขียนเอง ฝั่งหนึ่งเชื่อฟัง อีกฝั่งเขียนทับ”เติม
coreรุ่น 1.0.0 เข้าไปตรง ๆ ที่ชั้นบนสุด แล้วดูว่าใครฟังคุณ
นี่คือสิ่งที่คนส่วนใหญ่ทำเมื่อรู้ว่ามีการชนกัน คือประกาศรุ่นที่ต้องการเองที่ระดับบนสุด เพราะมันดูเหมือนคำสั่งที่ชัดที่สุดเท่าที่จะเขียนได้
| เครื่องมือ | บรรทัดที่เขียน | core ที่ได้ | รหัสออกของโปรแกรม |
|---|---|---|---|
| Maven | <dependency> ของ core รุ่น 1.0.0 | 1.0.0 | 1 |
| Gradle | implementation("...core:1.0.0") | 2.0.0 | 0 |
Maven เชื่อฟัง เพราะบรรทัดนั้นอยู่ลึกชั้นเดียว ซึ่งใกล้กว่าทุกเส้นทาง
มันจึงชนะแม้แต่ libb ที่ประกาศไว้ก่อนหน้ามัน
Gradle เขียนทับ เพราะกฎของมันไม่ได้ดูว่าใครอยู่ใกล้ มันดูว่าใครสูง
และ 2.0.0 ที่ libb ขอ ยังสูงกว่าบรรทัดที่คุณเพิ่งพิมพ์
ผลที่ตามมากลับด้านกับสัญชาตญาณ ฝั่งที่เชื่อฟังคุณคือฝั่งที่ตายตอนรัน ส่วนฝั่งที่เมินบรรทัดของคุณคือฝั่งที่รันจบ 2 บรรทัดตามปกติ
สองอักขระที่ทำให้ Gradle เชื่อฟัง
หัวข้อที่มีชื่อว่า “สองอักขระที่ทำให้ Gradle เชื่อฟัง”Gradle มีรูปเขียนที่แปลว่า “รุ่นนี้เท่านั้น” คือเติม !! ท้ายรุ่น
ผู้เขียน probe ทำนายไว้ก่อนวัดว่ามันจะทำให้ build แดง เพราะมันขัดกับรุ่นที่ libb ขอ
คำทำนายนั้นผิด
| ทรง | รหัสออกของ build | core ที่ได้ | รหัสออกของโปรแกรม |
|---|---|---|---|
core:1.0.0 | 0 | 2.0.0 | 0 |
core:1.0.0!! | 0 | 1.0.0 | 1 |
build ยังเขียว รหัสออก 0 เท่าเดิม resolver ลดรุ่นที่ libb
ขอไว้ลงมาให้เงียบ ๆ แล้วโปรแกรมตายด้วย NoSuchMethodError
สองอักขระนี้พลิกผลจากรันจบเป็นตายตอนรัน โดยไม่มีอย่างอื่นในทั้งโปรเจกต์เปลี่ยนเลย
สวิตช์ที่ทำให้การชนกันดังขึ้นมาเอง
หัวข้อที่มีชื่อว่า “สวิตช์ที่ทำให้การชนกันดังขึ้นมาเอง”สองฝั่งมีทางบังคับที่ใช้ได้จริง และฝั่งหนึ่งมีทางทำให้มันแดงก่อนถึงตอนรัน
Maven มี dependencyManagement ซึ่งอยู่คนละชั้นกับการประกาศธรรมดา
มันไม่ได้แข่งเรื่องความใกล้ มันตั้งรุ่นให้ทุกเส้นทางที่เอ่ยถึง core
| ทาง | core ที่ได้ | รหัสออกของโปรแกรม |
|---|---|---|
Maven · dependencyManagement ตรึง 2.0.0 | 2.0.0 | 0 |
Gradle · resolutionStrategy.force ตรึง 1.0.0 | 1.0.0 | 1 |
ทั้งสองทางบังคับได้จริง แต่สังเกตว่าทั้งสองทางก็ยังเงียบเหมือนเดิม มันเปลี่ยนคำตอบ ไม่ได้เปลี่ยนว่าคุณจะรู้หรือไม่รู้
Gradle มีอีกสวิตช์ที่ต่างออกไป คือ failOnVersionConflict() ซึ่งไม่เลือกให้เลย
| สิ่งที่วัด | ค่าที่ได้ |
|---|---|
| รหัสออกของ build | 1 |
| jar ที่ resolve ได้ | 0 |
| ข้อความเอ่ยถึงทั้งสองรุ่น | ใช่ |
นี่คือทางเดียวในสิบทรงที่การชนกันกลายเป็นสิ่งที่คุณเห็นก่อนโปรแกรมรัน ราคาของมันคือทุกการชนกันต้องมีคนตัดสิน แม้แต่ครั้งที่ไม่สำคัญ
เครื่องมือที่คุณใช้ตอบคำถามนี้ ไม่ตอบโดยไม่ถูกถาม
หัวข้อที่มีชื่อว่า “เครื่องมือที่คุณใช้ตอบคำถามนี้ ไม่ตอบโดยไม่ถูกถาม”
mvn dependency:treeพิมพ์ต้นไม้ที่ดูครบ และตัวที่แพ้ไม่ได้อยู่ในนั้น
คำสั่งแรกที่ทุกคนพิมพ์เมื่อสงสัยเรื่องรุ่นที่ชนกัน คือ mvn dependency:tree
| สิ่งที่วัด | ค่าปริยาย | เติม -Dverbose |
|---|---|---|
| ปมที่พิมพ์ | 3 | 3 |
| บรรทัดที่บอกว่ามีการชนกัน | 0 | 1 |
ค่าปริยายพิมพ์ 3 ปม และไม่มีปมไหนบอกว่ามีรุ่นที่สองอยู่ ต้นไม้ที่ได้จึงอ่านเหมือนกราฟที่ไม่มีการชนกันเลย
เติมธงเดียวแล้วบรรทัดนั้นโผล่มาทันที พร้อมคำว่า omitted for conflict with 1.0.0
ฝั่ง Gradle ไม่มีธงให้เติม gradle dependencies พิมพ์ 1.0.0 -> 2.0.0 ให้เห็นในคำสั่งเดียวกัน
หนึ่ง ทั้งสอง resolver เลือกให้คุณเสมอ คำถามไม่ใช่ว่ามันเลือกไหม แต่เลือกด้วยกฎอะไร
สอง Maven ดูความใกล้ แล้วตัดสินการเสมอด้วยลำดับที่พิมพ์ · Gradle ดูรุ่นที่สูงที่สุด
สาม การประกาศรุ่นตรง ๆ ที่ชั้นบนสุด เป็นคำสั่งสำหรับ Maven แต่เป็นแค่ข้อเสนอสำหรับ Gradle
สี่ ทางที่บังคับได้จริงคือ dependencyManagement กับ resolutionStrategy.force
ส่วนทางที่ทำให้คุณ รู้ มีทางเดียวคือ failOnVersionConflict()
ตารางรวมทั้งสิบทรง
หัวข้อที่มีชื่อว่า “ตารางรวมทั้งสิบทรง”ทุกช่องในตารางนี้มาจาก probe ตัวเดียวกัน และซ้ำได้ทีละอักขระ
| ทรง | เครื่องมือ | core ที่ได้ | build | รัน | ข้อยกเว้น |
|---|---|---|---|---|---|
liba ก่อน | Maven | 1.0.0 | 0 | 1 | NoSuchMethodError |
libb ก่อน | Maven | 2.0.0 | 0 | 0 | — |
ประกาศ core ตรง ๆ | Maven | 1.0.0 | 0 | 1 | NoSuchMethodError |
dependencyManagement | Maven | 2.0.0 | 0 | 0 | — |
liba ก่อน | Gradle | 2.0.0 | 0 | 0 | — |
libb ก่อน | Gradle | 2.0.0 | 0 | 0 | — |
ประกาศ core ตรง ๆ | Gradle | 2.0.0 | 0 | 0 | — |
force | Gradle | 1.0.0 | 0 | 1 | NoSuchMethodError |
core:1.0.0!! | Gradle | 1.0.0 | 0 | 1 | NoSuchMethodError |
failOnVersionConflict | Gradle | — | 1 | 1 | — |
เช็กความเข้าใจ
ข้อ 1 / 3pom.xml ประกาศ liba แล้วตามด้วย libb ทั้งคู่อยู่ลึกสองชั้นและขอ core คนละรุ่น สลับสองก้อนนี้แล้วเกิดอะไรขึ้น
วิธีวัดซ้ำ
หัวข้อที่มีชื่อว่า “วิธีวัดซ้ำ”ทุกค่าในหน้านี้สร้างใหม่ได้จากคำสั่งสองบรรทัด
scripts/dep-conflict/up.sh # อุ่น repo ที่แยกไว้ · ต้องใช้เน็ตรอบแรกnpm run verify:dep-conflict # วัด 10 ทรง 3 รอบ แล้วเทียบกับข้ออ้างทุกข้อprobe ไม่แตะ ~/.m2 และ ~/.gradle ของเครื่องคุณ มันวาง repo ของตัวเองไว้ที่
scripts/dep-conflict/.tools/ ทั้งหมด
รหัสออก 1 แปลว่าข้ออ้างในหน้านี้ผิด ส่วนรหัสออก 2 แปลว่าวัดไม่ได้ เช่นหา JDK ไม่เจอ หรือดึง plugin ของ Maven ไม่ได้ สองอย่างนี้ไม่เหมือนกัน
scripts/dep-conflict/README-predictions.md เก็บคำทำนายที่เขียนไว้ก่อนวัดรอบแรก
พร้อมข้อที่ถูกหักล้าง