- Hệ thống quản lý dependency của Rust giúp việc phát triển thuận tiện hơn, nhưng số lượng và chất lượng dependency lại là điều đáng băn khoăn
- Ngay cả những crate được dùng phổ biến cũng có thể không còn được cập nhật, nên đôi khi tự triển khai lại còn tốt hơn
- Sau khi thêm các crate nổi tiếng như Axum, Tokio, tổng số dòng mã tính cả dependency lên tới 3,6 triệu, rất khó kiểm soát
- Phần mã tôi thực sự viết chỉ khoảng 1.000 dòng, nhưng gần như không thể rà soát và kiểm toán phần mã xung quanh
- Vẫn chưa có giải pháp rõ ràng về việc có nên mở rộng thư viện chuẩn của Rust hay cách triển khai hạ tầng cốt lõi; toàn bộ cộng đồng đang phải cùng cân nhắc sự cân bằng giữa hiệu năng, an toàn và khả năng bảo trì
Tổng quan về vấn đề dependency trong Rust
- Rust là ngôn ngữ tôi yêu thích nhất, với cộng đồng và trải nghiệm sử dụng ngôn ngữ rất xuất sắc
- Năng suất phát triển cao, nhưng gần đây tôi bắt đầu lo lắng ở khía cạnh quản lý dependency
Ưu điểm của crate Rust và Cargo
- Với Cargo, có thể quản lý package và tự động hóa quy trình build, giúp tăng năng suất đáng kể
- Việc chuyển đổi giữa nhiều hệ điều hành và kiến trúc trở nên dễ dàng hơn, không cần quá bận tâm đến việc tự quản lý file hay cấu hình công cụ build
- Có thể bắt tay vào viết code ngay mà không phải suy nghĩ nhiều về quản lý package riêng
Nhược điểm của việc quản lý crate Rust
- Vì ít phải bận tâm đến quản lý package hơn nên dễ lơ là về độ ổn định
- Ví dụ, tôi từng dùng crate dotenv, rồi biết qua Security Advisory rằng nó đã ngừng được bảo trì
- Khi cân nhắc crate thay thế (dotenvy), tôi nhận ra phần mình thực sự cần chỉ khoảng 35 dòng nên đã tự triển khai
- Việc package bị bỏ bảo trì xảy ra thường xuyên ở nhiều ngôn ngữ, nên cốt lõi vấn đề là tình huống phụ thuộc là điều khó tránh khỏi
Dependency khiến lượng mã tăng vọt
- Tôi đang dùng những package quan trọng và chất lượng cao của hệ sinh thái Rust như Tokio, Axum
- Tôi đã thêm Axum, Reqwest, ripunzip, serde, serde_json, tokio, tower-http, tracing, tracing-subscriber dưới dạng dependency
- Mục đích chính chỉ là web server, giải nén file và logging nên bản thân project khá đơn giản
- Tôi dùng tính năng Cargo vendor để tải toàn bộ crate phụ thuộc về máy cục bộ
- Khi phân tích số dòng mã bằng tokei, tổng cộng khoảng 3,6 triệu dòng nếu tính cả dependency (nếu bỏ các crate đã vendor thì khoảng 11.136 dòng)
- Để tham chiếu, toàn bộ nhân Linux được cho là khoảng 27,8 triệu dòng, nghĩa là project nhỏ của tôi đã bằng khoảng một phần bảy con số đó
- Phần mã tôi thực sự viết chỉ khoảng 1.000 dòng
- Việc theo dõi và kiểm toán khối lượng mã phụ thuộc lớn như vậy gần như là bất khả thi
Trăn trở về lời giải
- Hiện tại vẫn chưa có giải pháp rõ ràng
- Một số người cho rằng nên mở rộng thư viện chuẩn như Go, nhưng điều đó cũng tạo ra vấn đề mới như gánh nặng bảo trì
- Rust theo đuổi hiệu năng cao, an toàn và tính mô-đun, đồng thời nhắm tới cạnh tranh trong embedded hoặc với C++, nên việc mở rộng thư viện chuẩn cần hết sức thận trọng
- Ví dụ, những runtime cao cấp như Tokio cũng đang được quản lý rất tích cực trên GitHub và Discord
- Trên thực tế, việc tự triển khai hạ tầng cốt lõi như async runtime hay web server là quá sức với một lập trình viên cá nhân
- Ngay cả Cloudflare, một dịch vụ quy mô lớn, cũng vẫn dùng nguyên tokio và dependency từ crates.io; còn họ kiểm toán chúng thường xuyên đến đâu thì không rõ
- Clickhouse cũng từng nhắc đến vấn đề kích thước binary và số lượng crate
- Với Cargo, rất khó xác định chính xác những dòng mã nào thực sự được đưa vào binary cuối cùng, và vẫn có hạn chế là mã không cần thiết theo từng nền tảng cũng bị kéo vào
- Cuối cùng, thực tế là chỉ còn cách đặt câu hỏi này cho cả cộng đồng cùng tìm lời giải
3 bình luận
Nếu chạy Trivy thì số lỗ hổng mức high hoặc critical ít hơn rất nhiều so với js NPM hay Java Maven nên an toàn hơn, vậy bài viết này muốn dùng Rust để lập luận điều gì?
Ý kiến trên Hacker News
import foolib, và chẳng ai bận tâm bên trong có gì. Ở mỗi tầng có lẽ chỉ cần khoảng 5% chức năng, nhưng cây phụ thuộc càng sâu thì mã vô dụng càng tích tụ. Kết quả là một binary đơn giản cũng có thể thành 500MiB, chỉ vì cần mỗi việc định dạng số mà phải kéo cả dependency vào. Go hay Rust còn khuyến khích dồn mọi thứ vào một file, nên nếu chỉ muốn dùng một phần thì rất khó xử. Về lâu dài, giải pháp thực sự có lẽ là theo dõi symbol/dependency ở mức siêu chi tiết, để mọi hàm/kiểu đều khai báo chính xác phần tử mình cần, chỉ lấy đúng lượng mã cần thiết và bỏ phần còn lại. Cá nhân tôi không thích ý tưởng này lắm, nhưng tôi cũng không nghĩ ra cách nào khác để giải quyết hệ thống hiện tại, nơi cả vũ trụ bị kéo theo từ cây dependencyserde_jsonbằng vài chỉnh sửa nhỏ. Các dependency lớn hơn (winit/wgpu...) thì cần thay đổi kiến trúc nên không thể bỏ dễ dàng.orồi gói vào archive.a, và linker chỉ lấy ra những hàm cần thiết. Namespace cũng được làm kiểufoolib_do_thing(). Còn hiện tại, theo kiểu god object, mọi hàm đều nằm trong object top-level, nên chỉ cần importfooliblà kéo theo cả khối. Trong trạng thái đó, linker rất khó xác định chính xác hàm nào là bắt buộc. Ngược lại, Go làm dead code elimination rất tốt, nên cái gì không dùng sẽ bị cắt khỏi kết quả biên dịchmin-sized-rustisEven,isOdd,leftpadtrong npm, thì một thư viện đa dụng lớn do một nhóm liên hiệp quản lý sẽ đảm bảo tương lai và tính liên tục tốt hơn nhiềurust--gc-sectionsglobđáng lẽ chỉ nên là một hàm globbing đơn giản, nhưng tác giả lại gói luôn cả công cụ dòng lệnh và thêm parser cồng kềnh vào dependency. Điều đó dẫn đến các cảnh báo "dependency out-of-date" xuất hiện liên tục. Phạm vi trách nhiệm của thư việnglobcũng gây tranh cãi. Chỉ làm pattern matching trên chuỗi sẽ là thiết kế linh hoạt hơn nhiều (dễ test, dễ trừu tượng hóa filesystem). Dù vậy, cũng có nhiều người muốn một thư viện toàn năng kiểu "Do everything", nhưng càng như vậy thì tác dụng phụ càng lớn. Tôi đoán Rust cũng sẽ không khác quá nhiềustdlib::data_structures::automata::weighted_finite_state_transducer. Vì ngôn ngữ này đã tích hợp quản lý phiên bản và tương thích ngược ở cấp ngôn ngữ, nên tôi vẫn kỳ vọng nó tiếp tục cải thiệnglobcủa POSIX thực sự duyệt filesystem. Nếu cần matching trên chuỗi thì đã cófnmatch. Lý tưởng nhất là đểfnmatchở một module riêng rồi choglobphụ thuộc vào nó. Tự triển khaiglobthật ra khá khó, vì còn hàng loạt yêu cầu phức tạp như cấu trúc thư mục, brace expansion, v.v., nên cần một tổ hợp hàm được thiết kế cẩn thậnglobcapability systemđể cô lập an toàn toàn bộ cây thư viện. Ví dụ, khi thiết kế thư viện tải ảnh, hãy để nó chỉ nhận stream thay vì file, hoặc khai báo rõ là nó "không có quyền mở file", để việc dùng hàm nguy hiểm bị chặn ngay từ lúc biên dịch. Trong các hệ sinh thái hiện tại thì chuyện này không dễ, nhưng nếu làm đúng thì có thể giảm thiểu đáng kể bề mặt tấn công. Văn hóa tối thiểu hóa dependency cũng khó giải quyết tận gốc, và các ngôn ngữ như Go cũng không miễn nhiễm trước tấn công chuỗi cung ứng#![deny(unsafe_code)], việc dùngunsafesẽ bị chặn bằng lỗi biên dịch và được thông báo rõ cho người dùng. Tuy nhiên đây không phải là cơ chế kiểm tra bắt buộc tuyệt đối; nếu cho phép đặc biệt thì vẫn có thể dùngunsafe. Có thể hình dung một capability system giốngfeatureflag, cho phép điều chỉnh xuyên suốt các chức năng của standard librarypanic, đồng thời cần đầu tư viết và phân phối capability profile cho từng thư viện. Điều tương tự thực ra đã được chứng minh phần nào trong hệ sinh thái TypeScripttls,x509,base64... cũng khiến việc chọn và quản lý thư viện trở nên đau đầuimport, thay mọi thứ bằng dependency injection. Nếu không inject những thứ như IO subsystem thì mã bên thứ ba sẽ tuyệt đối không thể chạm vào đó. Nếu chỉ muốn cấp quyền đọc, thì chỉ cần bọc phần đọc rồi inject đúng phần ấy. Dù vậy, trong lĩnh vực lập trình hệ thống thì cách này có giới hạn (dounsafe codevà những yếu tố tương tự)dom0, mỗi thư viện ở một template VM riêng, giao tiếp qua network namespace. Với các ngành nhạy cảm thì cách này khá thực tếblessed.rsgợi ý một danh sách thư viện hữu ích khó có thể đưa vào standard library. Tôi thích hệ thống này vì nhờ đó phần lớn package có thể bị giới hạn và quản lý theo mục đích cụ thểcargo-vetcũng rất đáng khuyên dùng. Nó giúp theo dõi và xác định package đáng tin cậy, ví dụ từ các package phải được chuyên gia audit trước khi import, cho tới các chính sách semi-YOLO như cứ tin những package do maintainer của tokio quản lý. Nó formal hơnblessed.rsmột chút, và rất phù hợp để chia sẻ danh sách bán-chuẩn chính thức trong nội bộ đội ngũleftpad, cái nhìn tiêu cực về package manager vẫn còn tồn tại. Những thứ nhưtokiothực chất gần như là tính năng cấp ngôn ngữ, nên nếu OP cho rằng phải tự audit cả Go hay thậm chí V8 của Node thì điều đó không thực tếtokiocũng vẫn luôn có ai đó audit đều đặn. Không phải số đông, nhưng dù sao cũng có người làmcargođưa cả hai version vào khi hai dependency dùng hai version khác nhau là một điểm màcargohỗ trợ khá đặc biệtcargolà một điểm rất hay. Tôi thường gửi PR để giấu các dependency không cần thiết đằng sau những flag này. Có thể xem cây dependency rất dễ bằngcargo tree. Góc nhìn số dòng mã thực sự đi vào binary thì lại không có nhiều ý nghĩa; vì khi hàm bị inline thì phần lớn cuối cùng dồn hết vàomainĐây không phải chỉ là vấn đề riêng của Rust.
Đó là ưu điểm chung đồng thời cũng là vấn đề tiềm ẩn của mọi ngôn ngữ có trình quản lý gói hỗ trợ kho lưu trữ gói công khai và phụ thuộc bắc cầu.
Cuối cùng thì vẫn là người mang về dùng phải dùng cho cho đúng thôi…
Dù đã có vụ việc leftpad của Node&npm, nhưng vẫn chẳng có gì thay đổi.