Chặng mới của sê-ri: công cụ. Và Maven là thứ mọi dự án Java đều chạm tới nhưng ít người thật sự hiểu — phần lớn chúng ta chép một pom.xml từ dự án cũ rồi thêm phụ thuộc cho tới khi nó chạy.
Bài này về hai chỗ đáng hiểu nhất: vòng đời và cách Maven chọn phiên bản.
mvn package thực sự chạy những gì
resources:3.4.0:resources <- chép tài nguyên
compiler:3.15.0:compile <- biên dịch mã chính
resources:3.4.0:test <- chép tài nguyên test
compiler:3.15.0:test <- biên dịch mã test
surefire:3.5.4:test <- CHẠY test
jar:3.5.0:jar <- đóng gói
Điểm quan trọng nhất: các pha là tích luỹ. Gõ mvn package thì mọi pha trước package đều chạy — kể cả test.
Đây là nguồn của hai hiểu lầm phổ biến:
mvn clean compile test package là thừa. Chỉ cần mvn clean package; ba pha kia nằm trong đó.
mvn package chạy test. Nếu bạn muốn bỏ qua, -DskipTests biên dịch test nhưng không chạy, còn -Dmaven.test.skip=true thì không biên dịch luôn. Cái thứ hai nhanh hơn nhưng che mất lỗi biên dịch trong mã test.
Chuỗi pha đầy đủ đáng nhớ: validate → compile → test → package → verify → install → deploy.
install đưa artifact vào kho ~/.m2 của máy bạn; deploy đẩy lên kho từ xa. Với dự án cá nhân thì install là nơi bạn dừng lại.
Và clean không thuộc vòng đời mặc định — nó là một vòng đời riêng, nên phải gõ tường minh.
Năm phạm vi, và classpath tương ứng
Tôi khai bốn phụ thuộc với bốn phạm vi khác nhau rồi đếm classpath thật:
phạm vi số jar lombok(provided) junit(test) logback(runtime)
compile 99 có không không
runtime 102 KHÔNG không có
test 111 có có có
Bảng này giải thích trọn vẹn ý nghĩa từng phạm vi:
compile (mặc định) — có mặt ở mọi nơi, và lan sang dự án khác dùng thư viện của bạn.
provided — có lúc biên dịch, không lúc chạy. Dòng lombok cho thấy rõ: có ở compile, biến mất ở runtime. Dùng cho thứ mà môi trường chạy đã cung cấp — API servlet trên máy chủ ứng dụng, hoặc công cụ chỉ hoạt động lúc biên dịch như Lombok.
runtime — không có lúc biên dịch, có lúc chạy. Logback là ví dụ chuẩn: mã của bạn viết theo giao diện SLF4J và không được phép gọi thẳng vào Logback, nên để nó ngoài classpath biên dịch là cách ép buộc điều đó bằng công cụ thay vì bằng kỷ luật.
test — chỉ trong test, và không lan sang ai.
system — trỏ thẳng vào một tệp jar trên đĩa. Đã lỗi thời, đừng dùng.
Chi tiết đáng biết: provided và test không lan truyền. Nếu thư viện của bạn khai một phụ thuộc provided, dự án dùng thư viện đó sẽ không tự có nó — họ phải tự khai.
Xung đột phiên bản: chuyện thật
Tôi khai hai thư viện phổ biến, và chúng kéo theo hai phiên bản khác nhau của cùng một phụ thuộc:
+- httpclient:4.5.13
| \- commons-codec:1.11 <- thắng
+- hadoop-common:3.3.6
| +- (commons-codec:1.15 - omitted for conflict with 1.11) <- thua
Hadoop được xây và kiểm thử với commons-codec:1.15, nhưng lúc chạy nó nhận 1.11 — bản cũ hơn. Đây chính là kịch bản sinh ra NoSuchMethodError mà bài 81 đã nhắc: lớp có mặt, phương thức thì không.
Và đây là phần làm tôi phải đo lại để tin. Tôi đảo thứ tự hai dòng khai báo trong pom.xml, không đổi gì khác:
A. httpclient khai TRƯỚC -> commons-codec:1.11
B. hadoop khai TRƯỚC (chỉ đảo thứ tự) -> commons-codec:1.15
C. ghim bằng dependencyManagement -> commons-codec:1.16.1
pom.xml quyết định phiên bản thư viện bạn chạy. Không có cảnh báo nào, không có lỗi build. Ai đó sắp xếp lại danh sách phụ thuộc cho gọn mắt là có thể đổi hành vi của ứng dụng — và đó là loại thay đổi không ai nghĩ tới khi đọc lại lịch sử commit.
Quy tắc Maven dùng là "gần nhất thắng": phiên bản ở độ sâu nhỏ nhất trong cây thắng. Khi bằng nhau — như ở đây, cả hai đều ở độ sâu 2 — thì khai trước thắng.
Chú ý Maven không chọn phiên bản mới nhất. Gradle thì có; Maven thì không. Đây là khác biệt quan trọng nếu bạn chuyển qua lại giữa hai công cụ.
Ba lệnh để gỡ
mvn dependency:tree # cây đầy đủ
mvn dependency:tree -Dverbose # kèm những bản BỊ LOẠI
mvn dependency:tree -Dincludes=commons-codec # lọc theo một artifact
Cờ -Dverbose là cờ quan trọng nhất, vì cây thường giấu các phiên bản bị loại:
(commons-codec:1.15:compile - omitted for conflict with 1.11)
(commons-logging:1.1.3:compile - omitted for conflict with 1.2)
(slf4j-api:1.6.4:compile - omitted for conflict with 1.7.36)
Phân biệt hai loại dòng bị loại:
omitted for duplicate — vô hại, chỉ là cùng một phiên bản xuất hiện ở nhiều nhánh. Dự án của tôi có 63 dòng loại này.
omitted for conflict — đây mới là thứ cần đọc. Mỗi dòng là một thư viện đang chạy với phiên bản khác với phiên bản nó được kiểm thử.
Ba cách sửa
dependencyManagement — cách tôi khuyên. Khai một lần ở POM cha, mọi phụ thuộc bắc cầu đều theo:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>commons-codec</groupId><artifactId>commons-codec</artifactId><version>1.16.1</version>
</dependency>
</dependencies>
</dependencyManagement>
Nó ghi đè cả quy tắc "gần nhất thắng", nên kết quả không còn phụ thuộc thứ tự khai báo. Đó chính là dòng C trong bảng trên.
BOM — nhập một bộ phiên bản đã được kiểm thử cùng nhau:
<dependency>
<groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId>
<version>3.5.6</version><type>pom</type><scope>import</scope>
</dependency>
Đây là lý do dự án Spring Boot hiếm khi gặp xung đột: BOM đã ghim sẵn hàng trăm phiên bản.
<exclusions> — loại một nhánh cụ thể. Dùng khi bạn muốn bỏ hẳn thứ gì đó, không phải khi muốn thống nhất phiên bản.
Và một công cụ phòng ngừa đáng bật: maven-enforcer-plugin với quy tắc dependencyConvergence làm build thất bại khi có xung đột phiên bản, thay vì âm thầm chọn một bản.
dependency:analyze
[WARNING] Unused declared dependencies found:
[WARNING] org.apache.httpcomponents:httpclient:jar:4.5.13:compile
[WARNING] org.apache.hadoop:hadoop-common:jar:3.3.6:compile
Lệnh này tìm hai thứ: phụ thuộc khai mà không dùng, và phụ thuộc dùng mà không khai.
Loại thứ hai nguy hiểm hơn nhiều: mã của bạn gọi vào một thư viện đến từ phụ thuộc bắc cầu. Nó chạy — cho tới ngày phụ thuộc trung gian nâng cấp và bỏ thư viện đó đi. Nguyên tắc: dùng gì thì khai nấy.
Một lưu ý về kết quả trên: dự án thử nghiệm của tôi có mã nguồn trống nên mọi phụ thuộc đều bị báo là không dùng. Trên dự án thật, danh sách sẽ ngắn và đáng đọc hơn. Lệnh này cũng chỉ phân tích bytecode, nên nó không thấy các phụ thuộc chỉ dùng qua phản chiếu hay cấu hình — đừng xoá mù quáng theo cảnh báo.
Vài chỗ khác đáng biết
mvn -o chạy ngoại tuyến, dùng kho ~/.m2 sẵn có. Nhanh hơn nhiều và tránh được lỗi mạng — tôi dùng nó mặc định khi build lại.
mvn -X in log gỡ lỗi, kể cả POM hiệu dụng sau khi gộp mọi kế thừa. Khi không hiểu vì sao một tham số có giá trị lạ, mvn help:effective-pom cho câu trả lời trực tiếp hơn.
Đừng dùng khoảng phiên bản ([1.0,2.0)). Nó làm build không tái lập được: cùng mã nguồn, hai thời điểm, hai kết quả khác nhau.
Khoá phiên bản plugin. Không khai phiên bản plugin thì Maven chọn theo phiên bản Maven đang chạy, và build của bạn đổi hành vi khi ai đó nâng cấp Maven.
Thử ba mươi giây
Chạy trên dự án của bạn:
mvn dependency:tree -Dverbose | grep "omitted for conflict"
Mỗi dòng hiện ra là một thư viện đang chạy với phiên bản khác phiên bản nó được kiểm thử. Phần lớn vô hại, nhưng danh sách ấy chính là nơi đầu tiên cần nhìn khi gặp NoSuchMethodError hay NoClassDefFoundError không rõ nguyên nhân.
Ngày mai: Gradle — mô hình build khác hẳn Maven, và khi nào thì nó thật sự đáng đổi.