- แม้ว่า ระบบจัดการการพึ่งพา ของ Rust จะช่วยให้การพัฒนาสะดวกขึ้น แต่จำนวนและคุณภาพของ dependency ก็กลายเป็นเรื่องที่น่ากังวล
- แม้แต่ Crate ที่ใช้งานกันอย่างแพร่หลายก็อาจไม่ได้อัปเดตอยู่เสมอไป จนบางครั้งการเขียนเองอาจดีกว่า
- หลังเพิ่ม Crate ชื่อดังอย่าง Axum, Tokio เป็นต้น จำนวน บรรทัดโค้ด รวมทั้งหมดเมื่อรวม dependency แล้วพุ่งไปถึง 3.6 ล้านบรรทัด จนยากจะรับมือ
- โค้ดที่ผู้เขียนเขียนจริงมีเพียงราว 1,000 บรรทัดเท่านั้น แต่โค้ดรอบข้างแทบเป็นไปไม่ได้ที่จะตรวจทานและ audit
- ยังไม่มี ทางออก ที่ชัดเจนว่าควรขยาย standard library ของ Rust หรือจัดการโครงสร้างพื้นฐานหลักอย่างไร และทั้งชุมชนก็ต้องช่วยกันคิดหาสมดุลระหว่างประสิทธิภาพ ความปลอดภัย และการดูแลรักษา
ภาพรวมของปัญหาการพึ่งพาใน Rust
- Rust เป็นภาษาที่ผู้เขียนชอบที่สุด และทั้ง ชุมชนกับประสบการณ์การใช้งานภาษา ก็ยอดเยี่ยมมาก
- แม้ประสิทธิภาพในการพัฒนาจะสูง แต่ช่วงหลังเริ่มกังวลในด้าน การจัดการ dependency
ข้อดีของ Rust Crate และ Cargo
- Cargo ช่วย จัดการแพ็กเกจและทำงาน build อัตโนมัติ ได้ ทำให้ประสิทธิภาพการพัฒนาดีขึ้นมาก
- ย้ายข้ามระบบปฏิบัติการและสถาปัตยกรรมต่าง ๆ ได้ง่าย และไม่ต้องกังวลเรื่อง จัดการไฟล์เองหรือปรับตั้งค่าเครื่องมือ build
- สามารถเริ่ม เขียนโค้ด ได้ทันทีโดยไม่ต้องกังวลเรื่องการจัดการแพ็กเกจเพิ่มเติม
ข้อเสียของการจัดการ Crate ใน Rust
- เมื่อไม่ต้องใส่ใจกับการจัดการแพ็กเกจมากนัก ก็ทำให้ละเลยเรื่อง ความเสถียร ได้ง่าย
- ตัวอย่างเช่น ผู้เขียนเคยใช้ crate
dotenv แล้วมาทราบผ่าน Security Advisory ว่าหยุดบำรุงรักษาแล้ว
- ระหว่างพิจารณา crate ทางเลือก (
dotenvy) ก็พบว่าส่วนที่ต้องใช้จริงเขียนเองได้ในประมาณ 35 บรรทัด
- ปัญหาแพ็กเกจขาดการดูแลเกิดขึ้นบ่อยในหลายภาษา ดังนั้นแก่นของปัญหาคือ สถานการณ์ที่หลีกเลี่ยง dependency ไม่ได้
ปริมาณโค้ดที่พุ่งสูงจาก dependency
- ผู้เขียนใช้งาน แพ็กเกจสำคัญและมีคุณภาพดีใน ecosystem ของ Rust อย่าง Tokio และ Axum อยู่
- ได้เพิ่ม Axum, Reqwest, ripunzip, serde, serde_json, tokio, tower-http, tracing และ tracing-subscriber เป็น dependency
- จุดประสงค์หลักมีแค่เว็บเซิร์ฟเวอร์ การแตกไฟล์บีบอัด และระบบล็อก จึงถือว่า ตัวโปรเจกต์เองค่อนข้างเรียบง่าย
- ใช้ความสามารถ Cargo vendor เพื่อ ดาวน์โหลด crate ที่พึ่งพาทั้งหมดมาไว้ในเครื่อง
- เมื่อลองวิเคราะห์จำนวนบรรทัดโค้ดด้วย tokei พบว่ารวม dependency แล้วมี ประมาณ 3.6 ล้านบรรทัด (ถ้าไม่รวม crate ที่ vendor มา จะเหลือประมาณ 11,136 บรรทัด)
- เพื่อเทียบให้เห็นภาพ เคยมีข้อมูลว่า Linux kernel ทั้งหมดมีประมาณ 27.8 ล้านบรรทัด ดังนั้นโปรเจกต์เล็ก ๆ นี้จึงมีขนาดราวหนึ่งในเจ็ดของนั้น
- โค้ดที่ผู้เขียนเขียนเองจริง ๆ มีเพียงประมาณ 1,000 บรรทัด
- การเฝ้าดูและ audit โค้ด dependency จำนวนมหาศาลเช่นนี้ แทบเป็นไปไม่ได้ในทางปฏิบัติ
ความกังวลเรื่องทางออก
- ตอนนี้ยังไม่มีทางออกที่ชัดเจน
- บางส่วนเสนอให้ ขยาย standard library แบบ Go แต่แนวทางนี้ก็สร้างปัญหาใหม่ เช่น ภาระในการดูแลรักษา
- Rust มุ่งเน้น ประสิทธิภาพสูง ความปลอดภัย และความเป็นโมดูลาร์ รวมถึงตั้งเป้าแข่งขันในงาน embedded และ C++ จึงต้องระมัดระวังเรื่องการขยาย standard library
- ตัวอย่างเช่น runtime ขั้นสูงอย่าง Tokio ก็ยังได้รับการดูแลอย่างคึกคักมากบน Github และ Discord
- ในความเป็นจริง การ ลงมือสร้าง runtime แบบ asynchronous หรือเว็บเซิร์ฟเวอร์ซึ่งเป็นโครงสร้างพื้นฐานหลักด้วยตัวเอง เป็นเรื่องเกินกำลังสำหรับนักพัฒนาเดี่ยว
- แม้แต่บริการขนาดใหญ่อย่าง Cloudflare ก็ยังใช้ tokio และ dependency จาก crates.io ตามเดิม โดยไม่ชัดเจนว่ามีการ audit บ่อยแค่ไหน
- Clickhouse เองก็เคยกล่าวถึงปัญหาเรื่องขนาดไบนารีและจำนวน crate
- Cargo เองก็มีข้อจำกัดตรงที่ ระบุจำนวนบรรทัดโค้ดที่ถูกรวมเข้าในไบนารีสุดท้ายได้ไม่แม่นยำ และยังนับรวมโค้ดที่ไม่จำเป็นในแต่ละแพลตฟอร์มด้วย
- ท้ายที่สุดแล้ว คงทำได้เพียงโยนคำถามนี้ให้ทั้งชุมชนช่วยกันหาคำตอบ
3 ความคิดเห็น
พอลองรัน Trivy ดู ก็พบว่าเมื่อเทียบกับ js NPM หรือ java Maven แล้ว จำนวนช่องโหว่ระดับ high หรือ critical มีน้อยกว่ามากและปลอดภัยกว่า แล้วบทความนี้ต้องการจะสื่ออะไรเกี่ยวกับ Rust กันแน่?
ความเห็นบน Hacker News
import foolibบรรทัดเดียวก็ใช้ได้แล้ว และแทบไม่มีใครสนใจว่าข้างในมีอะไรบ้าง แต่ละชั้นอาจใช้ความสามารถจริงแค่ราว 5% ทว่าพอต้นไม้ลึกขึ้นก็สะสมโค้ดที่ไม่เกิดประโยชน์ สุดท้ายไบนารีง่าย ๆ ตัวเดียวก็กลายเป็น 500MiB และถึงขั้นดึง dependency มาเพียงเพื่อจัดรูปแบบตัวเลขครั้งเดียว Go กับ Rust ก็ยิ่งสนับสนุนการรวมทุกอย่างไว้ในไฟล์เดียว ทำให้ถ้าอยากใช้แค่บางส่วนก็ลำบาก ในระยะยาวทางแก้ที่แท้จริงน่าจะเป็นการติดตาม symbol/dependency แบบละเอียดสุด ๆ ให้ทุกฟังก์ชัน/ชนิดข้อมูลระบุเฉพาะสิ่งที่ต้องใช้จริง แล้วดึงมาเท่าที่จำเป็น ที่เหลือก็ทิ้งไป ส่วนตัวผมไม่ค่อยชอบแนวคิดนี้นัก แต่ก็ยังนึกวิธีอื่นที่จะหยุดระบบปัจจุบันที่กวาดทั้งจักรวาลมาจากต้นไม้ dependency ไม่ออกserde_jsonออกได้ด้วยการแก้เล็กน้อยเท่านั้น ส่วน dependency ใหญ่กว่า (เช่นwinit/wgpu) ต้องเปลี่ยนโครงสร้างถึงจะถอดออกได้ จึงไม่ได้เอาออกกันง่าย ๆ.oแยกต่อฟังก์ชันแล้วรวมเป็น archive.aจากนั้น linker จะดึงเฉพาะฟังก์ชันที่ต้องใช้มาใช้จริง การทำ namespace ก็เป็นแบบfoolib_do_thing()ปัจจุบันกลับกลายเป็นแนว god object ที่มีทุกฟังก์ชันอยู่ใน top-level object เดียว พอ importfoolibทีเดียวทั้งก้อนก็ถูกดึงมาหมด แบบนี้ linker ก็ยากจะตัดสินได้ว่าฟังก์ชันไหนจำเป็นจริง ๆ แทนที่จะเป็นเช่นนั้น Go กลับทำ dead code elimination ได้ดีมาก ถ้าไม่ใช้ก็จะถูกตัดออกจากผลลัพธ์คอมไพล์min-sized-rustisEven,isOdd,leftpadซึ่งดูแลรักษาแบบกระจัดกระจาย ไลบรารีอเนกประสงค์ขนาดใหญ่ที่ทีมรวมศูนย์ดูแลกลับให้ความต่อเนื่องและความมั่นคงในอนาคตได้มากกว่า--gc-sectionsglobควรเป็นแค่ฟังก์ชัน globbing ธรรมดา แต่ผู้เขียนกลับยัดเครื่องมือ command line เข้ามาด้วยและเพิ่ม parser ขนาดใหญ่เป็น dependency ผลคือมีคำเตือน "dependency out-of-date" โผล่มาบ่อย อีกทั้งขอบเขตความรับผิดชอบของไลบรารีglobเองก็เป็นประเด็นถกเถียงด้วย แค่ทำ string pattern matching อย่างเดียวจะเป็นการออกแบบที่ยืดหยุ่นกว่า (ทดสอบง่ายกว่า แยก file system abstraction ได้ง่ายกว่า) แน่นอนว่ามีผู้ใช้จำนวนมากที่อยากได้ไลบรารีแบบ "ทำทุกอย่าง" แต่ยิ่งเป็นแบบนั้นผลข้างเคียงก็ยิ่งมาก Rust ก็คงไม่ได้ต่างกันมากนักstdlib::data_structures::automata::weighted_finite_state_transducer) พร้อม namespace ที่เป็นระเบียบแบบ "batteries included" เพราะตัวภาษาเองก็มีระบบ versioning และ backward compatibility ในตัวอยู่แล้ว จึงคาดหวังการปรับปรุงต่อไปได้globของ POSIX นั้นจริง ๆ แล้วสำรวจ file system ส่วนfnmatchมีไว้สำหรับ string matching ถ้าจะแยกให้ดีควรให้fnmatchอยู่เป็นโมดูลแยกต่างหากและเป็น dependency ของglobการเขียนglobเองขึ้นมาโดยตรงไม่ใช่เรื่องง่ายเลย เพราะมีข้อกำหนดซับซ้อนอย่างโครงสร้างไดเรกทอรี, brace expansion ฯลฯ จึงต้องอาศัยการผสมฟังก์ชันที่ออกแบบมาดีglobมาให้ด้วย#![deny(unsafe_code)]ที่จะทำให้เกิด compile error เมื่อมีการใช้ unsafe code และแจ้งผู้ใช้ให้รู้ เพียงแต่มันไม่ใช่การบังคับตรวจสอบทุกจุด และถ้าอนุญาตเป็นพิเศษก็ยังใช้ unsafe code ได้ เราอาจจินตนาการถึง capability system ที่ควบคุมความสามารถของ standard library แบบส่งต่อกันได้คล้ายfeatureflagimportออกแล้วทำทุกอย่างผ่าน dependency injection ถ้าไม่ inject ของอย่าง IO subsystem เข้าไป โค้ด third-party ก็จะเข้าถึงมันไม่ได้เลย หากอยากให้สิทธิ์แค่อ่าน ก็แค่ wrap ความสามารถอ่านแล้ว inject ให้เท่านั้น แต่ในงาน system programming ก็ยังมีข้อจำกัด (เช่นเพราะ unsafe code)dom0, แต่ละไลบรารีอยู่ใน template VM แยก และสื่อสารผ่าน network namespace วิธีนี้ใช้งานได้จริงในอุตสาหกรรมที่อ่อนไหวblessed.rsแนะนำรายชื่อไลบรารีที่มีประโยชน์แต่เอาใส่ standard library ได้ยาก ผมชอบระบบนี้เพราะช่วยให้แพ็กเกจส่วนใหญ่ถูกจำกัดขอบเขตตามวัตถุประสงค์เฉพาะและบริหารจัดการได้cargo-vetก็เป็นตัวที่แนะนำได้ มันช่วยติดตามและกำหนดแพ็กเกจที่เชื่อถือได้ เช่นกำหนดให้บางแพ็กเกจต้องผ่านการ audit จากผู้เชี่ยวชาญก่อนนำเข้า หรือใช้นโยบายกึ่ง YOLO อย่างเชื่อแพ็กเกจที่ดูแลโดย maintainer ของtokioไปเลยก็ได้ มันเป็นทางการกว่าblessed.rsเล็กน้อย และเหมาะจะใช้แชร์รายชื่อกึ่งมาตรฐานอย่างเป็นทางการภายในทีมleftpadหลายคนยังมีมุมมองลบกับ package manager อยู่ แต่ของอย่างtokioนั้นแทบเป็นฟีเจอร์ระดับภาษาอยู่แล้ว ถ้า OP มองว่าต้อง audit เองแม้แต่ทั้ง Go หรือ V8 ของ Node ด้วย ก็คงไม่สมจริงtokioเองก็มีคน audit อยู่เรื่อย ๆ ไม่ได้มีคนจำนวนมาก แต่ก็มีคนทำอยู่cargo treeก็ช่วยให้ดูต้นไม้ dependency ได้ง่าย มุมมองเรื่องจำนวนบรรทัดโค้ดที่เข้าไปอยู่ในไบนารีจริง ๆ ไม่ค่อยมีความหมายเท่าไร เพราะเวลาฟังก์ชันถูก inline มันมักจะถูกรวมเข้าmainไปเสียส่วนใหญ่ไม่ใช่ปัญหาเฉพาะของ Rust หรอก
มันเป็นทั้งข้อดีร่วมกันและปัญหาที่แฝงอยู่ของทุกภาษาที่มีตัวจัดการแพ็กเกจซึ่งรองรับคลังแพ็กเกจสาธารณะและการพึ่งพาแบบส่งต่อ
สุดท้ายก็ขึ้นอยู่กับคนที่เอาไปใช้ว่าจะใช้อย่างระมัดระวังแค่ไหน…
แม้จะมีกรณี leftpad ของ Node&npm มาก่อน ก็ยังไม่มีอะไรเปลี่ยนแปลง