So sánh cách giải quyết phiên bản phụ thuộc trong Rust và Java
Trong lập trình, việc so sánh với những gì bạn đã biết là cách tốt nhất để học hỏi. Gần đây, tôi đã gặp phải vấn đề khi giả định rằng Rust hoạt động giống như Java trong việc giải quyết phiên bản phụ thuộc chuyển tiếp. Trong bài viết này, tôi sẽ so sánh cách hai ngôn ngữ này xử lý vấn đề này.
Phụ thuộc, tính chuyển tiếp và giải quyết phiên bản
Trước khi đi vào chi tiết của từng ngôn ngữ, hãy cùng mô tả lĩnh vực và những vấn đề phát sinh từ nó.
Khi phát triển bất kỳ dự án nào trên mức Hello World, bạn có khả năng sẽ phải đối mặt với những vấn đề mà người khác cũng đã gặp phải. Nếu vấn đề đó phổ biến, có thể có ai đó đã tử tế đóng gói mã nguồn để giải quyết nó, cho phép người khác sử dụng lại. Bạn có thể sử dụng gói đó và tập trung vào việc giải quyết vấn đề cốt lõi của mình. Đây là cách mà ngành công nghiệp xây dựng hầu hết các dự án ngày nay, mặc dù điều này cũng mang lại những vấn đề khác: bạn đang đứng trên vai của những người khổng lồ.
Các ngôn ngữ lập trình đi kèm với các công cụ xây dựng có thể thêm các gói này vào dự án của bạn. Hầu hết trong số đó gọi các gói mà bạn thêm vào dự án là phụ thuộc. Các phụ thuộc của dự án cũng có thể có các phụ thuộc riêng của chúng: các phụ thuộc sau gọi là phụ thuộc chuyển tiếp.
Trong hình trên, C và D là các phụ thuộc chuyển tiếp.
Các phụ thuộc chuyển tiếp có những vấn đề riêng. Vấn đề lớn nhất là khi một phụ thuộc chuyển tiếp được yêu cầu từ các đường khác nhau, nhưng ở các phiên bản khác nhau. Trong hình dưới đây, A và B đều phụ thuộc vào C, nhưng với các phiên bản khác nhau của nó.
Vậy phiên bản nào của C nên được công cụ xây dựng bao gồm vào dự án của bạn? Java và Rust có những câu trả lời khác nhau. Hãy cùng mô tả chúng lần lượt.
Cách giải quyết phiên bản phụ thuộc chuyển tiếp trong Java
Nhắc lại: Mã Java được biên dịch thành bytecode, sau đó được diễn dịch tại thời điểm chạy (và đôi khi được biên dịch thành mã gốc, nhưng điều này không nằm trong phạm vi vấn đề hiện tại). Tôi sẽ trước tiên mô tả cách giải quyết phụ thuộc tại thời điểm chạy và cách giải quyết phụ thuộc tại thời điểm xây dựng.
Tại thời điểm chạy, JVM cung cấp khái niệm về classpath. Khi cần tải một lớp, thời gian chạy sẽ tìm kiếm trong classpath đã được cấu hình theo thứ tự. Hãy tưởng tượng lớp sau:
java
public class Main {
public static void main(String[] args) {
Class.forName("ch.frankel.Dep");
}
}
Biên dịch và thực thi nó:
bash
java -cp ./foo.jar:./bar.jar Main
Lệnh trên sẽ tìm kiếm lớp ch.frankel.Dep
trong foo.jar
trước. Nếu tìm thấy, nó sẽ dừng lại ở đó và tải lớp, bất kể nó có thể cũng có trong bar.jar
hay không; nếu không, nó sẽ tìm kiếm trong bar.jar
. Nếu vẫn không tìm thấy, nó sẽ thất bại với ClassNotFoundException
.
Cơ chế giải quyết phụ thuộc tại thời điểm chạy của Java là có thứ tự và có granularity theo lớp. Nó áp dụng cho cả việc bạn chạy một lớp Java và xác định classpath trên dòng lệnh như trên, hoặc bạn chạy một JAR xác định classpath trong manifest của nó.
Hãy thay đổi mã trên thành:
java
public class Main {
public static void main(String[] args) {
var dep = new ch.frankel.Dep();
}
}
Bởi vì mã mới tham chiếu trực tiếp đến Dep
, mã mới yêu cầu giải quyết lớp tại thời điểm biên dịch. Cách giải quyết classpath hoạt động tương tự:
bash
javac -cp ./foo.jar:./bar.jar Main
Biên dịch sẽ tìm Dep
trong foo.jar
, sau đó là bar.jar
nếu không tìm thấy. Điều này là những gì bạn học được khi bắt đầu hành trình học Java.
Sau đó, đơn vị công việc của bạn là Java Archive, được gọi là JAR, thay vì lớp. Một JAR là một tập tin nén ZIP, với một manifest nội bộ xác định phiên bản của nó.
Giờ đây, hãy tưởng tượng rằng bạn là người dùng của foo.jar
. Các nhà phát triển của foo.jar
đã thiết lập một classpath cụ thể khi biên dịch, có thể bao gồm các JAR khác. Bạn sẽ cần thông tin này để chạy lệnh của riêng mình. Làm thế nào để nhà phát triển thư viện truyền đạt thông tin này đến người dùng phía dưới?
Cộng đồng đã đưa ra một vài ý tưởng để trả lời câu hỏi này: Phản ứng đầu tiên được chấp nhận là Maven. Maven có khái niệm POM, nơi bạn thiết lập siêu dữ liệu của dự án cũng như các phụ thuộc. Maven có thể dễ dàng giải quyết các phụ thuộc chuyển tiếp vì chúng cũng xuất bản POM của riêng mình, với các phụ thuộc của riêng. Do đó, Maven có thể truy tìm các phụ thuộc của mỗi phụ thuộc cho đến các phụ thuộc lá.
Quay lại vấn đề: Maven giải quyết xung đột phiên bản như thế nào? Phiên bản phụ thuộc nào sẽ Maven giải quyết cho C, 1.0 hay 2.0?
Tài liệu rõ ràng: phiên bản gần nhất.
Trong hình trên, đường đến v1 có khoảng cách là hai, một đến B, sau đó là một đến C; trong khi đó, đường đến v2 có khoảng cách là ba, một đến A, sau đó là một đến D, và cuối cùng là một đến C. Do đó, đường ngắn nhất chỉ vào v1.
Tuy nhiên, trong sơ đồ ban đầu, cả hai phiên bản C đều ở cùng một khoảng cách từ artifact gốc. Tài liệu không cung cấp câu trả lời. Nếu bạn quan tâm đến điều đó, nó phụ thuộc vào thứ tự khai báo của A và B trong POM! Tóm lại, Maven trả về một phiên bản duy nhất của một phụ thuộc bị trùng lặp để bao gồm nó vào classpath biên dịch.
Nếu A có thể làm việc với C v2.0 hoặc B với C 1.0, thật tuyệt! Nếu không, bạn có thể sẽ cần nâng cấp phiên bản của A hoặc hạ cấp phiên bản của B, để phiên bản C được giải quyết hoạt động với cả hai. Đây là một quá trình thủ công và đau đớn – hãy hỏi tôi làm thế nào tôi biết. Tệ hơn nữa, bạn có thể phát hiện ra rằng không có phiên bản C nào hoạt động với cả A và B. Thời gian để thay thế A hoặc B.
Cách giải quyết phiên bản phụ thuộc chuyển tiếp trong Rust
Rust khác với Java ở một số khía cạnh, nhưng tôi nghĩ rằng các điểm sau là quan trọng nhất cho cuộc thảo luận của chúng ta:
- Rust có cùng cây phụ thuộc tại thời điểm biên dịch và tại thời điểm chạy
- Nó cung cấp một công cụ xây dựng tích hợp, Cargo
- Các phụ thuộc được giải quyết từ mã nguồn
Hãy xem xét chúng một cách tuần tự.
Java được biên dịch thành bytecode, sau đó bạn chạy bytecode đó. Bạn cần thiết lập classpath cả tại thời điểm biên dịch và tại thời điểm chạy. Việc biên dịch với một classpath cụ thể và chạy với một classpath khác có thể dẫn đến lỗi. Ví dụ, hãy tưởng tượng bạn biên dịch với một lớp mà bạn phụ thuộc vào, nhưng lớp đó không có mặt tại thời điểm chạy. Hoặc ngược lại, nó có mặt, nhưng ở phiên bản không tương thích.
Ngược lại với cách tiếp cận mô-đun này, Rust biên dịch thành một gói gốc duy nhất bao gồm mã của crate và mọi phụ thuộc. Hơn nữa, Rust cung cấp công cụ xây dựng của riêng mình, do đó không cần phải nhớ các quy tắc của các công cụ khác nhau. Tôi đã đề cập đến Maven, nhưng các công cụ xây dựng khác có thể có các quy tắc khác nhau để giải quyết phiên bản trong trường hợp sử dụng ở trên.
Cuối cùng, Java giải quyết các phụ thuộc từ nhị phân: JARs. Ngược lại, Rust giải quyết các phụ thuộc từ mã nguồn. Tại thời điểm xây dựng, Cargo giải quyết toàn bộ cây phụ thuộc, tải xuống tất cả các mã nguồn cần thiết và biên dịch chúng theo thứ tự đúng.
Với điều này trong tâm trí, Rust giải quyết phiên bản của phụ thuộc C trong vấn đề ban đầu như thế nào? Câu trả lời có thể có vẻ lạ lẫm nếu bạn xuất phát từ nền tảng Java, nhưng Rust bao gồm cả hai. Thực tế, trong hình trên, Rust sẽ biên dịch A với C v1.0 và biên dịch B với C v2.0. Vấn đề đã được giải quyết.
Thực tiễn tốt nhất
- Luôn xác định rõ ràng các phụ thuộc trong POM (đối với Java) hoặc Cargo.toml (đối với Rust).
- Thường xuyên cập nhật các phụ thuộc để tránh xung đột phiên bản.
- Sử dụng các công cụ như Maven hoặc Cargo để quản lý phụ thuộc hiệu quả.
Những cạm bẫy phổ biến
- Không kiểm tra tương thích phiên bản giữa các phụ thuộc có thể dẫn đến lỗi không mong muốn.
- Quá phụ thuộc vào một hoặc hai thư viện có thể gây ra khó khăn trong việc bảo trì dự án.
Mẹo hiệu suất
- Tránh sử dụng các phụ thuộc không cần thiết để giảm kích thước gói và thời gian biên dịch.
- Sử dụng các công cụ tối ưu hóa như ProGuard (đối với Java) để giảm dung lượng ứng dụng.
Khắc phục sự cố
- Nếu gặp phải lỗi
ClassNotFoundException
, hãy kiểm tra lại classpath. - Kiểm tra các phiên bản phụ thuộc có tương thích với nhau hay không.
Kết luận
Các ngôn ngữ JVM, đặc biệt là Java, cung cấp cả classpath tại thời điểm biên dịch và tại thời điểm chạy. Điều này cho phép tính mô-đun và khả năng tái sử dụng, nhưng cũng mở ra những vấn đề liên quan đến việc giải quyết classpath. Ngược lại, Rust biên dịch crate của bạn thành một nhị phân tự chứa duy nhất, bất kể đó là thư viện hay thực thi.
Để tìm hiểu thêm:
- Maven - Giới thiệu về cơ chế phụ thuộc
- Effective Rust - Mục 25: Quản lý đồ thị phụ thuộc của bạn
Bài viết được xuất bản lần đầu tại A Java Geek vào ngày 14 tháng 9 năm 2025