Bí ẩn về phương thức bóng ma
IDE của bạn không hiển thị lỗi nào. Quá trình build diễn ra vô cùng suôn sẻ. Nhưng ngay khi bạn khởi chạy ứng dụng, nó lập tức gặp lỗi java.lang.NoSuchMethodError. Nếu bạn đã từng mất cả buổi chiều vật lộn với "JAR Hell" khét tiếng của Java, bạn chắc chắn hiểu rõ sự ức chế này.
Sự cố này xảy ra khi môi trường runtime tìm thấy một phiên bản class khác với phiên bản được sử dụng trong quá trình biên dịch. Mã nguồn của bạn mong đợi một signature phương thức cụ thể, nhưng JVM đã tải một tệp JAR mà ở đó phương thức đó bị thiếu, bị đổi tên hoặc có các tham số khác. Đây không phải là lỗi logic. Đó là lỗi cấu hình classpath.
Exception in thread "main" java.lang.NoSuchMethodError: com.example.MyClass.myMethod()V
Điều gì gây ra sự sai lệch này?
Thủ phạm hầu như luôn là transitive dependencies (phụ thuộc bắc cầu). Hãy tưởng tượng dự án của bạn sử dụng Library-A và Library-B. Cả hai đều phụ thuộc vào Guava, nhưng chúng lại yêu cầu các phiên bản khác nhau. Library-A cần Guava 31.0, trong khi Library-B lại kéo về Guava 14.0. Nếu công cụ build của bạn chọn phiên bản 14.0, bất kỳ mã nguồn nào được biên dịch dựa trên phiên bản mới hơn sẽ thất bại khi cố gắng gọi một phương thức vốn chưa tồn tại từ mười năm trước.
Tại thời điểm runtime, JVM chỉ tải phiên bản đầu tiên của một class mà nó tìm thấy trên classpath. Nếu nó chọn nhầm phiên bản, ứng dụng của bạn sẽ bị sập.
Bước 1: Sơ đồ hóa "mê cung" phụ thuộc
Đừng đoán mò xem phiên bản nào đang gây ra lỗi. Bạn cần trực quan hóa toàn bộ biểu đồ phụ thuộc (dependency graph) để xem thư viện nào đang "tuồn" tệp JAR lỗi thời vào.
Dành cho người dùng Maven
Mở terminal và chạy lệnh sau. Thường mất chưa đến 10 giây để tạo một báo cáo đầy đủ:
mvn dependency:tree -Dverbose -Dincludes=com.google.guava:guava
Flag -Dverbose là cực kỳ quan trọng. Nó tiết lộ các xung đột ẩn mà Maven đã tự động xử lý. Hãy tìm các dòng được đánh dấu omitted for conflict để xác định các phiên bản bị loại bỏ trong cuộc chiến phiên bản.
Dành cho người dùng Gradle
Thực thi task dependencies để kiểm tra cách Gradle phân giải biểu đồ:
./gradlew dependencies --configuration runtimeClasspath
Tìm biểu tượng ->. Nó cho bạn thấy nơi Gradle đã ép buộc nâng cấp hoặc hạ cấp (ví dụ: 1.0.0 -> 2.0.0).
Bước 2: Loại bỏ xung đột
Sau khi xác định được "kẻ xâm nhập", bạn có hai cách chính để lập lại trật tự.
Tùy chọn A: Loại trừ (Exclude) thư viện cụ thể
Nếu Library-B đang kéo theo phiên bản Guava sai, hãy yêu cầu công cụ build bỏ qua nó hoàn toàn. Điều này buộc dự án phải sử dụng phiên bản mà bạn thực sự mong muốn.
Trong Maven:
<dependency>
<groupId>com.another.library</groupId>
<artifactId>library-b</artifactId>
<version>2.4.0</version>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
Trong Gradle:
dependencies {
implementation('com.another.library:library-b:2.4.0') {
exclude group: 'com.google.guava', module: 'guava'
}
}
Tùy chọn B: Áp đặt phiên bản toàn cục
Nếu bạn đang đối mặt với 5 hoặc 10 thư viện khác nhau cùng tranh chấp một phụ thuộc, việc thiết lập một quy tắc toàn cục sẽ sạch sẽ hơn.
Trong Maven (Dependency Management):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
</dependencies>
</dependencyManagement>
Trong Gradle (Resolution Strategy):
configurations.all {
resolutionStrategy {
force 'com.google.guava:guava:31.0.1-jre'
}
}
Bước 3: Xác minh giải pháp
Đừng bao giờ mặc định rằng lỗi đã được khắc phục chỉ vì quá trình build hoàn tất. Hãy xác minh artifact cuối cùng trước khi triển khai.
- **Cập nhật lại cây phụ thuộc:** Chạy lại lệnh dependency để đảm bảo phiên bản không mong muốn đã biến mất.
- **Kiểm tra tệp JAR:** Nếu bạn sử dụng "fat JAR", hãy giải nén nó. Sử dụng `javap` để xác nhận phương thức tồn tại trong class đã biên dịch:
# Kiểm tra xem phương thức 'format' có thực sự tồn tại trong package cuối cùng không
javap -cp my-app-all.jar com.example.utils.StringHelper | grep format
Chiến lược chủ động để giữ Classpath sạch sẽ
- **Sử dụng BOM (Bill of Materials):** Các framework như Spring Boot hoặc AWS SDK cung cấp BOM. Nó đóng vai trò là "nguồn sự thật" (source of truth) để đảm bảo tất cả các thành phần nội bộ tương thích về phiên bản.
- **Lưu ý phạm vi 'Provided':** Nếu bạn đang triển khai lên một container như Tomcat, các phiên bản thư viện cục bộ của bạn phải khớp với những gì máy chủ cung cấp tại thời điểm runtime.
- **Kiểm tra Shadowing:** Đảm bảo các plugin Shade hoặc Shadow của bạn không vô tình đóng gói hai phiên bản khác nhau của cùng một class dưới các tên khác nhau.
Xung đột phụ thuộc là một trở ngại thường gặp trong phát triển Java. Bằng cách nắm vững dependency:tree và cơ chế loại trừ (exclusions), bạn có thể biến một cơn ác mộng gỡ lỗi kéo dài bốn tiếng thành một giải pháp khắc phục trong năm phút.

