สร้าง CLI จริง — clap, serde, std::fs, cargo test และ modules
หกบทที่ผ่านมาเราสะสมชิ้นส่วนไว้ครบ — struct Task, enum Command, error type ด้วย thiserror, Vec/iterator สำหรับข้อมูล และ trait/generics สำหรับพฤติกรรม บทนี้คือ บทจ่ายผล: เราจะประกอบทุกอย่างเป็น taskcli ตัวจริงที่รับ argument จากบรรทัดคำสั่ง อ่าน/เขียน file JSON และมี test ครอบ ถ้าคุณมาจาก .NET นี่คือจุดที่ Rust map กับของที่คุณคุ้น — System.CommandLine สำหรับ parse args, System.Text.Json สำหรับ (de)serialize, และ xUnit สำหรับ test — ยกเว้น ทุกตัวทำงานตอน compile time ด้วย codegen ไม่ใช่ reflection ตอน runtime
code ลงมือของบทนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — บทนี้คือจุดที่ code กระจัดกระจายจากบท3–6 มารวมเป็น project เดียวที่ cargo build แล้วรันได้จริง มี --help/--version ให้อัตโนมัติ เก็บสถานะเป็น JSON และมี cargo test ผ่านครบ ทั้ง clap/serde เป็น derive macro ที่ทำงานตอน compile — คุณจะเห็นว่า struct คือ spec ไม่ใช่ code parse ที่เขียนมือ
version ดิบก่อน: parse args เองด้วยมือ
หัวข้อที่มีชื่อว่า “version ดิบก่อน: parse args เองด้วยมือ”ก่อนจะหยิบ clap มาดูว่าทำไมเราถึงอยากได้มัน ❌ นี่คือ version ดิบ ที่ parse std::env::args() เองล้วนๆ:
use std::env;use std::process;
fn main() { let args: Vec<String> = env::args().collect(); if args.len() < 2 { eprintln!("usage: taskcli <add|list|done> [args]"); process::exit(2); // usage error -> exit 2 } match args[1].as_str() { "add" => { let Some(title) = args.get(2) else { eprintln!("error: 'add' ต้องมีชื่อ task"); process::exit(2); }; println!("เพิ่ม task: {title}"); } "list" => println!("(ยังไม่ได้ทำการแสดงผลจริง)"), "done" => { let Some(raw) = args.get(2) else { eprintln!("error: 'done' ต้องมี id"); process::exit(2); }; match raw.parse::<u32>() { Ok(id) => println!("ทำ #{id} เสร็จแล้ว"), Err(_) => { eprintln!("error: id ไม่ใช่ตัวเลข: {raw}"); process::exit(2); } } } other => { eprintln!("error: ไม่รู้จักคำสั่ง {other}"); process::exit(2); } }}มัน compile และทำงานได้ แต่เจ็บตรงไหนสังเกตได้ทันที: เราไล่ index เอง (args[1], args.get(2)), เขียน error message กับ exit code เองทุกจุด, ไม่มี --help ไม่มี --version, และถ้าเพิ่ม flag --file เข้ามา code parse จะบวมเป็นสองเท่า ทั้งหมดนี้คือ งานซ้ำซาก ที่ทุก CLI ต้องทำ — และเป็นเหตุผลที่เราจะยกมันให้ clapclapcrate parse argument CLI แบบ derive (v4.x, ต้องเปิด feature `derive`) จัดการ
std::env::args() จะ panic ถ้ามี argument ที่ไม่ใช่ UTF-8 (เช่นชื่อ file ที่เข้ารหัสแปลกๆ บนบางระบบ) ถ้าต้องรองรับกรณีนั้นให้ใช้ std::env::args_os() ที่คืน OsString แทน — แต่ในทางปฏิบัติ clap จัดการเรื่องนี้ให้เราอยู่แล้ว จึงไม่ต้องกังวลเมื่อย้ายไปใช้ derive
clap derive — struct คือ arg spec
หัวข้อที่มีชื่อว่า “clap derive — struct คือ arg spec”clap version 4 แบบ derive พลิกวิธีคิด: แทนที่จะเขียน code parse คุณ ประกาศ รูปร่างของ argument เป็น struct แล้ว macro สร้างตัว parse ให้ — พร้อม --help/--version และ error message ที่ขึ้น exit code 2 เองครบ
use std::path::PathBuf;use std::process::ExitCode;use clap::{Parser, Subcommand};use taskcli::Store;
/// ตัวจัดการ task เล็ก ๆ บนบรรทัดคำสั่ง#[derive(Debug, Parser)]#[command(name = "taskcli", version, about)]struct Cli { /// file เก็บข้อมูล #[arg(long, default_value = "tasks.json")] file: PathBuf, #[command(subcommand)] command: Command,}
#[derive(Debug, Subcommand)]enum Command { /// เพิ่ม task ใหม่ Add { title: String }, /// แสดง task ทั้งหมด List, /// ทำเครื่องหมายว่าเสร็จแล้ว Done { id: u32 },}
fn main() -> ExitCode { let cli = Cli::parse(); match run(cli) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e}"); ExitCode::FAILURE } }}
fn run(cli: Cli) -> Result<(), taskcli::TaskError> { let mut store = Store::load(&cli.file)?; match cli.command { Command::Add { title } => { let t = store.add(title); println!("เพิ่ม #{}: {}", t.id, t.title); store.save()?; } Command::List => for t in store.tasks() { println!("[{}] #{} {}", if t.done {"x"} else {" "}, t.id, t.title); }, Command::Done { id } => { store.mark_done(id)?; store.save()?; println!("#{id} เสร็จแล้ว"); } } Ok(())}เทียบกับ version ดิบ ข้างบน: ไม่มี args[1] ไม่มี .parse::<u32>() เขียนมือ — #[derive(Parser)] อ่านจากชนิดของ field เอง (id: u32 แปลว่า clap จะ parse เป็น u32 และปฏิเสธค่าที่ไม่ใช่ตัวเลขให้พร้อม error) #[derive(Subcommand)] บน enum Command ทำให้ add/list/done กลายเป็น subcommand แบบ git-style และ Cli::parse() อ่าน std::env::args() ให้อัตโนมัติ พร้อมสร้าง --help/--version จาก doc comment (///) ที่เราเขียน
clap ปิด derive macro ไว้เป็นค่าปริยาย ต้องเปิด features = ["derive"] ใน Cargo.toml ไม่งั้น #[derive(Parser)] จะไม่มีให้ใช้ และถ้าคุณอยากอ่านค่าจาก environment variable ด้วย #[arg(env = "TASKCLI_FILE")] ต้องเปิด features = ["derive", "env"] เพิ่ม — ไม่งั้นชน error[E0599] เพราะ attribute env ไม่ถูก compile เข้ามา นี่ต่างจากธรรมเนียม .NET ที่ทุกอย่างมาใน package เดียว: crate ของ Rust แบ่งเป็น feature เพื่อไม่ให้แบก dependency ที่ไม่ได้ใช้
serde + serde_json — round-trip ข้อมูลภาษาไทยแบบ byte เท่ากัน
หัวข้อที่มีชื่อว่า “serde + serde_json — round-trip ข้อมูลภาษาไทยแบบ byte เท่ากัน”taskcli เก็บสถานะเป็น file JSON serdeserdeframework (de)serialization แบบ derive (คู่กับ serde_json สำหรับ JSON) คือ framework (de)serialization ที่ทำงานด้วย derive — คุณแปะ #[derive(Serialize, Deserialize)] บน struct แล้วได้ code แปลงไป/กลับ JSON ฟรี ส่วน serde_json คือ backend ที่รู้จักรูปแบบ JSON จริงๆ
use serde::{Deserialize, Serialize};#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]pub struct Task { pub id: u32, pub title: String, #[serde(default)] pub done: bool,}// A: อ่าน/เขียนทั้ง file ทีเดียวlet text = std::fs::read_to_string(&path)?;let tasks: Vec<Task> = serde_json::from_str(&text)?;std::fs::write(&path, serde_json::to_string_pretty(&tasks)?)?;// B: แบบ streaming ผ่าน buffered reader/writeruse std::io::{BufReader, BufWriter};let tasks: Vec<Task> = serde_json::from_reader(BufReader::new(std::fs::File::open(&path)?))?;serde_json::to_writer_pretty(BufWriter::new(std::fs::File::create(&path)?), &tasks)?;#[serde(default)] บน done คือประกันความเข้ากันได้ย้อนหลัง: ถ้า file JSON เก่าไม่มี key done เลย serde จะเติมค่า Default (คือ false) ให้แทนที่จะ error — สำนวนนี้ทำให้คุณเพิ่ม field ใหม่ในอนาคตได้โดยไม่ทำ file เดิมพัง
จุดที่คนไทยควรสบายใจ: serde ทำงานระดับ byte และ JSON เป็น UTF-8 — ข้อความ "เขียนโค้ด" เขียนลง file แล้วอ่านกลับมาได้ String เท่ากันเป๊ะทุก byte ไม่มีการแปลง encoding ซ่อนอยู่ (ต่างจากบาง library ที่ escape เป็น \uXXXX) เทียบกับ System.Text.Json ฝั่ง .NET แนวคิดใกล้กันมาก — ยกเว้น serde สร้าง code (de)serialize ตอน compile ไม่ใช่ reflection ตอน runtime จึงไม่มีต้นทุน warm-up ต่อชนิด และปิด derive เป็นค่าปริยายเช่นเดียวกับ clap (ต้อง features = ["derive"])
error type ของ CLI — thiserror อีกครั้ง
หัวข้อที่มีชื่อว่า “error type ของ CLI — thiserror อีกครั้ง”เราเอากล้ามเนื้อ thiserror จากบท4 มาใช้ซ้ำ สร้าง error type เดียวที่ครอบทุกทางล้มเหลวของ taskcli:
use std::path::PathBuf;use thiserror::Error;#[derive(Debug, Error)]pub enum TaskError { #[error("อ่าน/เขียน file {path} ไม่ได้")] Io { path: PathBuf, #[source] source: std::io::Error }, #[error("file ข้อมูลไม่ใช่ JSON ที่ถูกต้อง")] Parse(#[from] serde_json::Error), #[error("ไม่พบ task id {0}")] NotFound(u32),}สังเกตสองสำนวน: #[from] serde_json::Error สร้าง From ให้อัตโนมัติ เพื่อให้ ? แปลง error ของ serde_json เป็น TaskError::Parse ให้เอง (นี่คือกลไก From ที่เราแกะในบท6) ส่วน Io ใช้ #[source] แทน #[from] เพราะเราอยากแนบ path เข้าไปในข้อความด้วย — จึงต้องประกอบ variant เอง ไม่ใช่ให้ ? แปลงตรงๆ
แยก lib.rs กับ main.rs — เพื่อให้ test เข้าถึงได้
หัวข้อที่มีชื่อว่า “แยก lib.rs กับ main.rs — เพื่อให้ test เข้าถึงได้”Rust มี modulemoduleต้นไม้เนมสเปซตอน compile ภายใน crate; ไม่มีอะไร public จนกว่าจะใส่ `pub` เป็นต้นไม้ namespace ตอน compile ภายใน crate เดียว — และกฎสำคัญที่ทำให้วิศวกร C# สะดุด: ไม่มีอะไร public จนกว่าจะใส่ pub (ต่างจาก C# ที่ public ผูกกับ namespace หลวมๆ) สำนวนมาตรฐานของ CLI คือแยกเป็น library crate (lib.rs) ที่เก็บตรรกะทั้งหมด กับ binary crate (main.rs) ที่บางเฉียบ ทำหน้าที่แค่ parse args แล้วเรียก library
// src/lib.rs — ราก ของ library cratepub mod error;pub mod store;pub mod task;pub use error::TaskError;pub use store::Store;pub use task::Task;// src/store.rs เข้าถึง module พี่น้องผ่าน `crate::`use crate::{error::TaskError, task::Task};// src/main.rs พึ่ง library ด้วยชื่อ crateuse taskcli::Store;ทำไมต้องแยก? ไม่ใช่แค่ความสวยงาม — มันคือสิ่งที่ทำให้ตรรกะ test ได้จากภายนอก เพราะ integration test ใน tests/ จะเห็นเฉพาะ public API ของ library เท่านั้น ถ้าเราอัดทุกอย่างไว้ใน main.rs (binary crate ล้วน) integration test จะเข้าถึงมันไม่ได้เลย เทียบกับ .NET: crate (lib หรือ bin) ≈ assembly/project หนึ่งตัว ไม่ใช่ namespace — และ module คือ namespace ภายใน crate นั้น
flowchart TB MAIN["src/main.rs<br/>binary crate (เปลือกบาง)<br/>parse args → เรียก library"] LIB["src/lib.rs<br/>library crate (ราก)<br/>pub use TaskError, Store, Task"] TASK["task<br/>struct Task + serde"] STORE["store<br/>struct Store: load/add/save"] ERR["error<br/>enum TaskError (thiserror)"] IT["tests/integration.rs<br/>เห็นเฉพาะ public API"] MAIN -->|"use taskcli::Store"| LIB LIB --> TASK LIB --> STORE LIB --> ERR IT -.->|"public API เท่านั้น"| LIB classDef bin fill:#fde68a,stroke:#92400e,color:#451a03; classDef lib fill:#fed7aa,stroke:#7c2d12,color:#431407; classDef mod fill:#fef3c7,stroke:#a16207,color:#422006; classDef test fill:#e0e7ff,stroke:#3730a3,color:#1e1b4b; class MAIN bin; class LIB lib; class TASK,STORE,ERR mod; class IT test;
คำบรรยายภาพ: โครงสร้าง crate ของ taskcli — main.rs เป็น binary crate เปลือกบางที่ use taskcli::Store จาก library crate (lib.rs) ซึ่งรวม module task/store/error ไว้; tests/integration.rs เข้าถึงได้เฉพาะ public API ของ library จึงต้องแยก lib ออกจาก bin ไม่งั้น test จากภายนอกแตะตรรกะไม่ได้
cargo test — unit + integration + ตรวจ spec clap
หัวข้อที่มีชื่อว่า “cargo test — unit + integration + ตรวจ spec clap”Rust มี test ในตัว ไม่ต้องลง framework เสริม แบ่งเป็นสองที่ตามระดับการเข้าถึง: unit test อยู่ใน file เดียวกับ code ใน #[cfg(test)] mod tests (เห็น private item ได้) และ integration test อยู่ใน folder tests/ ระดับบนสุด (เห็นแค่ public API — เหมือนผู้ใช้ library จริง)
// unit test ใน file เดียวกับ code — เห็น private item#[cfg(test)]mod tests { use super::*; #[test] fn new_task_is_not_done() { let t = Task::new(1, "เขียน code".into()); assert_eq!(t.id, 1); assert!(!t.done); }}// tests/integration.rs — public API เท่านั้นuse taskcli::Store;#[test]fn add_then_reload_persists_tasks() { let file = std::env::temp_dir().join("taskcli-it.json"); { let mut s = Store::load(&file).unwrap(); s.add("ซื้อของ"); s.save().unwrap(); } assert_eq!(Store::load(&file).unwrap().tasks().len(), 1); std::fs::remove_file(&file).ok();}// ตรวจ spec clap เอง#[cfg(test)]mod cli_tests { use clap::{CommandFactory, Parser}; use super::Cli; #[test] fn cli_definition_is_valid() { Cli::command().debug_assert(); } #[test] fn parses_add() { let cli = Cli::parse_from(["taskcli", "add", "ทดสอบ"]); assert!(matches!(cli.command, super::Command::Add { .. })); }}สองสำนวนที่ต้องจำ: Cli::command().debug_assert() เป็น test ที่จับ spec clap ที่เขียนผิด (เช่น subcommand ชื่อซ้ำ, short flag ชนกัน) ตั้งแต่ตอน test ไม่ใช่ตอนผู้ใช้รันจริง; และ Cli::parse_from([...]) ให้เรา test ตัว parser โดยป้อน argument ปลอมเข้าไป ไม่ต้องพึ่ง argv จริง เทียบกับ xUnit ฝั่ง .NET ที่ test project แยกออกไปต่างหาก — Rust วาง unit test ไว้ ใน file เดียวกัน และแตะ private member ได้ ส่วน integration test แยกออกไปเหมือน xUnit
main คืน ExitCode — exit code 0/1/2 ที่ถูกต้อง
หัวข้อที่มีชื่อว่า “main คืน ExitCode — exit code 0/1/2 ที่ถูกต้อง”CLI ที่ดีต้องคืน exit code ให้ shell/CI อ่านได้ taskcli แยกสามค่าอย่างจงใจ:
0(ExitCode::SUCCESS) — สำเร็จ1(ExitCode::FAILURE) — error ใน domain ของเรา (file อ่านไม่ได้, JSON เสีย, ไม่พบ task id) เราจับในmatch run(cli)แล้วeprintln!("error: {e}")เป็นรูปแบบ Display (ไม่ใช่ Debug) ให้ผู้ใช้อ่านรู้เรื่อง2— usage error (คำสั่งผิด, argument ขาด) ซึ่งclapคืนให้เองอัตโนมัติ เมื่อ parse ไม่ผ่าน
มีกับดักสองข้อ: หนึ่ง — ถ้าคุณให้ main คืน Result แทน ExitCode แล้วปล่อย error หลุดออกไป Rust จะพิมพ์ error เป็นรูปแบบ Debug (มี { ... } รกตา) แล้ว exit 1 เสมอ เราจึงเลือกคืน ExitCode เองเพื่อคุม Display ให้สวย; สอง — clap คืน exit 2 สำหรับ usage error ไม่ใช่ 1 ดังนั้นอย่าเขียน test ที่ assert ว่า “ทุกความล้มเหลว == 1” เพราะ argument ผิดจะได้ 2 ต่างหาก
ถ้าไปเจอบทความเก่าที่สอน clap แบบ builder — App::new, Arg::with_name, .takes_value(true), .get_matches() — หรือ crate structopt (ถูกยุบรวมเข้า clap ตั้งแต่ version 3 แล้ว) นั่นคือของยุค clap 2/3 ที่ล้าสมัย คอร์สนี้ใช้ clap 4 แบบ derive (#[command]/#[arg]) ล้วน อย่าปนสำนวนเก่าเข้ามา
Cargo.toml — version ที่ตรึงไว้ (ลงวันที่กำกับ)
หัวข้อที่มีชื่อว่า “Cargo.toml — version ที่ตรึงไว้ (ลงวันที่กำกับ)”นี่คือ Cargo.toml เต็มของ taskcli และเป็น file ที่ ไวต่อ version ที่สุด ในคอร์ส — patch เลื่อนได้ทุกสัปดาห์ ตัวเลข exact ด้านล่างจึงลงวันที่กำกับ (as-of 2026-07-21) ในทางปฏิบัติให้ pin เป็น caret req ("4", "1", "2") แล้วปล่อย Cargo.lock ตรึง patch ให้เอง:
[package]name = "taskcli"version = "0.1.0"edition = "2024"
[dependencies]clap = { version = "4.6.4", features = ["derive"] }serde = { version = "1.0.229", features = ["derive"] }serde_json = "1.0.151"thiserror = "2.0.19"anyhow = "1.0.104" # optional: error ระดับ application/mainสองจุดที่พลาดบ่อย: thiserror ต้อง pin "2" (major ปัจจุบันคือ 2.x — code หน้าตาเหมือน 1.x ทุกอย่าง ต่างแค่บรรทัด version) และทั้ง clap/serde ต้องเปิด features = ["derive"] ไม่งั้น macro ไม่ถูก compile เข้ามา ส่วน anyhow เป็น optional สำหรับ error ระดับ application เท่านั้น (อย่าใช้ใน public API ของ library เพราะมันลบชนิด error ทิ้ง) เลข patch เป๊ะข้างบน (clap 4.6.4, serde 1.0.229, serde_json 1.0.151, thiserror 2.0.19, anyhow 1.0.104) คือชุดที่ resolve ได้ ณ 2026-07 — patch ขยับได้ตลอด ในทางปฏิบัติเขียนแค่ caret req "4"/"1"/"2" ก็พอ แล้วปล่อยให้ Cargo.lock ตรึง patch จริงให้ (เช็ก version ล่าสุดได้ที่ lib.rs หรือ docs.rs)
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ประกอบ taskcli ให้เสร็จเป็นตัวจริง: เริ่มจาก version ดิบที่ parse std::env::args() เองด้วย index แล้วยกระดับเป็น clap derive ที่ struct คือ arg spec (#[derive(Parser)] + #[derive(Subcommand)], ต้องเปิด features = ["derive"]); ใช้ serde/serde_json round-trip ข้อมูลภาษาไทยแบบ byte เท่ากันเป๊ะ (พร้อม #[serde(default)] กัน file เก่าพัง); นำ thiserror จากบท4 มาสร้าง TaskError เดียว; แยก lib.rs (ตรรกะ) กับ main.rs (เปลือกบาง) เพื่อให้ integration test เข้าถึง public API ได้ — โดยจำว่าไม่มีอะไร public จนกว่าจะใส่ pub; เขียน cargo test ทั้ง unit (เห็น private), integration (tests/, public เท่านั้น) และ Cli::command().debug_assert() ตรวจ spec clap; และให้ main คืน ExitCode เพื่อคุม exit code 0/1/2 พร้อมพิมพ์ error เป็น Display
บทหน้าเป็นบทปิดคอร์สและ capstone: fearless concurrency — เราจะเติมการประมวลผลแบบขนานให้ taskcli ด้วย std::thread, Arc<Mutex<T>>, mpsc channel และ scoped threads แล้วเห็นว่าทำไม data race ถึงกลายเป็น compile error ในโลกของ Rust — ปิดวงจร “compiler เป็นครู” ที่เราเปิดไว้ตั้งแต่บท1
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch07 Managing Growing Projects (Packages, Crates, Modules) (เข้าถึง 2026-07-24) —
mod/pub/use, ต้นไม้ module และเส้นแบ่ง crate - The Rust Programming Language — ch11 Writing Automated Tests (เข้าถึง 2026-07-24) — unit test ใน
#[cfg(test)] mod testsและ integration test ในtests/ที่เห็นแค่ public API - The Rust Programming Language — ch12 An I/O Project: Building a CLI (เข้าถึง 2026-07-24) — สำนวนแยก
src/lib.rs(ตรรกะ) กับsrc/main.rs(เปลือกบาง) - clap documentation (v4.6.4) — derive tutorial (as-of 2026-07-21) —
#[derive(Parser)],Cli::parse()อ่านstd::env::args()และสร้าง--help/--versionอัตโนมัติ; featurederive/env - serde — Serialize/Deserialize & attributes guide (เข้าถึง 2026-07-24) —
#[derive(Serialize, Deserialize)]และ#[serde(default)]เติมค่า Default เมื่อ key หายไป - serde_json documentation (v1.0.151) (as-of 2026-07-18) —
from_str/to_string_prettyและfrom_reader/to_writer_pretty - thiserror documentation (v2.0.19) (as-of 2026-07-18) —
#[derive(Error)],#[error("…")],#[from],#[source]
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ทำไม `taskcli` ถึงแยก code เป็น `src/lib.rs` (library crate) กับ `src/main.rs` (binary crate เปลือกบาง) แทนที่จะอัดทุกอย่างไว้ใน main.rs?