ทำไมต้องมี key-value store และการออกแบบ wire protocol เอง
คุณจบ #21 (rust-from-scratch) มาแล้ว — ownership/borrowing, struct/enum/Option/Result, trait, collection, การจัดการ error และ std::thread เบื้องต้นอยู่ในมือคุณครบ คอร์สนี้ กิน ทักษะชุดนั้นเข้าไป ไม่สอนซ้ำ แล้วเอาไปสร้างของจริง: kaen-kvstore — ฐานข้อมูล key-value บนเครือข่ายที่กู้คืนจากการล่มได้ (crash recoverycrash recoveryการกู้สถานะหลัง crash โดย replay log แล้ว rebuild index — crash กลางคันแล้ว reopen กลับมาได้สถานะที่ถูกต้อง) เขียนด้วย Rust std ล้วน ไม่มี async/tokio ไม่มี serde
เส้นเรื่องของทั้งคอร์สคือมุมมองเดียว: ทุกชิ้นที่เราต่อขึ้นมา map กับแนวคิดจริงของ storage engine (Bitcask / WAL / LSM) เพียงแต่ย่อลงมาในระดับ toy — append-only log, hash index, write→fsync→ack, tombstone, compaction ล้วนเป็น primitive ที่ฐานข้อมูลจริงใช้ บทนี้วาง โครงบนเครือข่าย ก่อน: wire protocol ที่เราออกแบบเอง + server/client TCP แบบ single-threaded + store ในหน่วยความจำ ส่วน persistence (การเขียนลง disk) เริ่มบท2
code ลงมือของคอร์สนี้อยู่ใน repo kaen-kvstore (code ตัวอย่างกำลังจัดทำ) — ตลอด 8 บทเราสร้าง key-value store บนเครือข่ายที่กู้คืนจาก crash ได้ หนึ่งตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde — serialization เขียนเองด้วย byte แบบ length-prefixed) บทนี้ต่อโครงบนเครือข่าย: wire protocol + server/client TCP + store ในหน่วยความจำ — persistence เริ่มบท2 บทนี้ตั้ง precedent ของ honesty spine (พูดความจริงเรื่องขีดจำกัดของ toy) กับ 🔗 callout อ้างอิงต้นทาง ให้ทุกบทหลังจากนี้เดินตาม
ทุกบท pin ไว้ที่ Rust stable 1.97.1 (ออก 2026-07-16) และ edition = “2024” (ค่าปริยายของ cargo new บน 1.97.1) — เลข compiler ขยับทุก ~6 สัปดาห์ เช็กเองด้วย rustc --version ได้เสมอ ส่วน edition = "2024" นิ่งเพราะผูกกับ crate ไม่ใช่รุ่น compiler [dependencies] ใน Cargo.toml ว่างเปล่าตลอดคอร์ส — ZERO external crate
ทั้งคอร์สหยิบใช้ std เพียงไม่กี่ module: std::net (TcpListener, TcpStream), std::thread, std::sync (Arc, Mutex, RwLock, mpsc), std::fs (File, OpenOptions, rename), std::io (Read, Write, BufReader, BufWriter, Seek, read_exact), std::time, std::collections (HashMap) — บทนี้แตะสามตัวแรกในกลุ่ม std::net/std::io/std::collections
ทำไมต้องเป็น key-value store
หัวข้อที่มีชื่อว่า “ทำไมต้องเป็น key-value store”key-value storekey-value storeฐานข้อมูลที่เก็บข้อมูลเป็นคู่ key→value ค้นด้วย key ตรง ๆ คือฐานข้อมูลที่เก็บข้อมูลเป็นคู่ key → value แล้วค้นด้วย key ตรงๆ — API ที่เล็กที่สุดที่ยังมีประโยชน์จริงมีแค่สามคำสั่ง: SET key value, GET key, DELETE key เท่านั้น เราเลือกมันเป็นโจทย์เพราะมันคือ ฐานข้อมูลที่มีประโยชน์ตัวที่ง่ายที่สุด — พื้นผิว API แคบพอที่เราจะไม่เสียเวลากับการ parse query แต่ ข้างใน กลับต้องแก้ปัญหาแกนกลางของฐานข้อมูลทุกตัว: จะเก็บลง disk อย่างไรให้เร็ว, จะรับประกันว่าข้อมูลรอด crash ได้อย่างไร, จะ index เพื่อค้นให้ไว, จะรับหลาย client พร้อมกันอย่างไร, และจะเก็บกวาดพื้นที่โดยไม่ทำลายความถูกต้องอย่างไร ทั้งหมดนี้คือสิ่งที่ 7 บทถัดไปจะไล่ทีละเรื่อง
kaen-kvstore เป็น infra แบบยืนเดี่ยว ไม่ผูกกับ domain ธุรกิจใดๆ — คิดเสียว่ามันคือ Redis/Memcached version จิ๋วที่เรารื้อออกมาต่อเองทีละชิ้นเพื่อ เข้าใจ ว่าฐานข้อมูลจริงทำงานอย่างไร ก้าวแรกก่อนจะพูดเรื่อง disk เลยด้วยซ้ำ คือ: client จะ “คุย” กับ server ผ่านเครือข่ายด้วยภาษาแบบไหน
TCP เป็นสาย byte ที่ไม่มีขอบเขตของ message
หัวข้อที่มีชื่อว่า “TCP เป็นสาย byte ที่ไม่มีขอบเขตของ message”พอสองฝั่งจะคุยกันบนเครือข่าย เราต้องตกลง “ภาษา” กันก่อน — wire protocolwire protocolรูปแบบ byte ที่ตกลงกันระหว่าง client/server บนสาย TCP คือรูปแบบ byte ที่ทั้ง client และ server ตกลงกันว่าจะส่งและตีความอย่างไรบนสาย ประเด็นที่มือใหม่มักสะดุด: TCP เป็น byte stream ที่ ไม่มี ขอบเขตของ message คุณเขียน SET foo bar ลงไปเป็นก้อนเดียว แต่ปลายทางอาจอ่านได้เป็น SET fo ก่อน แล้วค่อยได้ o bar ตามมา หรือกลับกัน สอง write ของคุณอาจมาถึงรวมเป็นก้อนเดียว TCP รับประกันแค่ ลำดับ และ ครบถ้วน ของ byte ไม่ได้รับประกันว่ามันจะมาถึงเป็นก้อนตรงกับที่คุณส่ง
ทางออกมาตรฐานคือ framing: ใส่ ความยาว นำหน้าทุกก้อนข้อมูล ปลายทางอ่านความยาวก่อน แล้วจึงรู้ว่าต้องอ่าน byte ตามมาอีกกี่ตัวถึงจะครบ1 message เราออกแบบ record แบบ length-prefixed คือ [u32 length][bytes...] — 4 byte แรกบอกความยาว แล้วตามด้วย byte ตามจำนวนนั้นเป๊ะ (นี่คือ serializationserializationการแปลงข้อมูลในหน่วยความจำเป็น byte เพื่อส่ง/เก็บ (คอร์สนี้เขียนเอง ไม่ใช้ serde) ที่เราเขียนเอง ไม่พึ่ง serde)
จุดสำคัญที่ทำให้ framing ทำงานถูกคือ ต้องอ่านให้ครบพอดี — และนี่คือเหตุผลที่เราใช้ read_exact ไม่ใช่ read ธรรมดา ตามเอกสาร std: Read::read() ครั้งเดียว “อาจคืน byte น้อยกว่าที่ buffer ขอ” (short read) ได้เสมอบน stream ส่วน read_exact จะวน loop อ่านจนเต็ม buf.len() พอดี หรือคืน error UnexpectedEof ถ้าสายจบก่อนอ่านครบ — ซึ่งพอดีเป๊ะกับสิ่งที่ framing ต้องการ (อ่าน 4 byte ของความยาวให้ครบ แล้วอ่าน payload ให้ครบตามความยาวนั้น) เรื่องนี้ไม่ใช่ของ Rust โดยเฉพาะ — เป็นปัญหา on-disk/on-wire record framing ที่ตำราฐานข้อมูล (Petrov ch3, DDIA ch3) พูดถึงตรงๆ
write_frame / read_frame — แกนที่ใช้ซ้ำทั้งคอร์ส
หัวข้อที่มีชื่อว่า “write_frame / read_frame — แกนที่ใช้ซ้ำทั้งคอร์ส”2 function นี้คือ แกนที่ใช้ซ้ำ — บทนี้ใช้มันส่ง byte ลง socket, บท2 จะใช้ format เดียวกันนี้เขียน record ลง File บน disk ทั้งคู่ generic บน Write/Read จึงใช้ได้กับทั้ง socket และ file:
use std::io::{self, Read, Write};
// 1 frame = ความยาว u32 แบบ little-endian นำหน้า แล้วตามด้วย byte ตามจำนวนนั้นพอดีfn write_frame<W: Write>(w: &mut W, bytes: &[u8]) -> io::Result<()> { let len = bytes.len() as u32; w.write_all(&len.to_le_bytes())?; w.write_all(bytes)?; Ok(())}
fn read_frame<R: Read>(r: &mut R) -> io::Result<Vec<u8>> { let mut len_buf = [0u8; 4]; r.read_exact(&mut len_buf)?; // อ่าน 4 byte ให้ครบ วน loop ข้าม short read let len = u32::from_le_bytes(len_buf) as usize; let mut buf = vec![0u8; len]; r.read_exact(&mut buf)?; // อ่านให้ครบ `len` byte พอดี Ok(buf)}to_le_bytes/from_le_bytes แปลงความยาวเป็น byte แบบ little-endian คงที่ ไม่ว่าจะรันบนเครื่องสถาปัตยกรรมไหน — ถ้าใช้ to_ne_bytes (native endian) แทน ข้อมูลที่ machine หนึ่งเขียนอาจ machine อีกตัวอ่านกลับผิด เราจึง pin endianness ไว้เลย และเพราะเราใส่ความยาวนำหน้า ตัว value จะเก็บ byte อะไรก็ได้ — เว้นวรรค, 0x0A (newline), byte สูงที่ไม่ใช่ UTF-8 — โดยไม่พัง ต่างจากการอ่านแบบบรรทัด (lines()) ที่จะแตกทันทีเมื่อ value มี \n หรือไม่ใช่ text
server single-threaded ด้วย std::net
หัวข้อที่มีชื่อว่า “server single-threaded ด้วย std::net”ทีนี้ต่อ server TcpListenerTcpListenerตัวรับการเชื่อมต่อ TCP ขาเข้าใน `std::net` (`bind` + `incoming()`) คือตัวรับการเชื่อมต่อ TCP ขาเข้าใน std::net: TcpListener::bind จอง port แล้ว .incoming() คืน iterator ของการเชื่อมต่อที่เข้ามา — วน for ทีละอันคือ รับ client ทีละราย store ของบทนี้คือ HashMap<Vec<u8>, Vec<u8>> ในหน่วยความจำล้วนๆ (key และ value เป็น byte ดิบ จึงรับ binary ได้) opcode 1 byte นำหน้าทุกคำสั่งเพื่อบอกว่าเป็น GET/SET/DELETE:
use std::collections::HashMap;use std::io::{self, BufReader, BufWriter, Read, Write};use std::net::{TcpListener, TcpStream};
const OP_GET: u8 = 1; const OP_SET: u8 = 2; const OP_DEL: u8 = 3;const ST_OK: u8 = 0; const ST_NOT_FOUND: u8 = 1; const ST_ERR: u8 = 2;
fn serve(listener: TcpListener) -> io::Result<()> { let mut store: HashMap<Vec<u8>, Vec<u8>> = HashMap::new(); for stream in listener.incoming() { // ทีละ client let stream = stream?; if let Err(e) = handle_connection(stream, &mut store) { eprintln!("connection error: {e}"); } } Ok(())}
fn handle_connection(stream: TcpStream, store: &mut HashMap<Vec<u8>, Vec<u8>>) -> io::Result<()> { let mut reader = BufReader::new(stream.try_clone()?); // แยกครึ่ง read/write ของ socket เดียวกัน let mut writer = BufWriter::new(stream); loop { let mut op = [0u8; 1]; match reader.read_exact(&mut op) { // EOF ตรงนี้ = client วางสายแบบสะอาด Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()), Err(e) => return Err(e), } match op[0] { OP_GET => { let key = read_frame(&mut reader)?; match store.get(&key) { Some(v) => { writer.write_all(&[ST_OK])?; write_frame(&mut writer, v)?; } None => { writer.write_all(&[ST_NOT_FOUND])?; write_frame(&mut writer, &[])?; } } } OP_SET => { let key = read_frame(&mut reader)?; let val = read_frame(&mut reader)?; store.insert(key, val); writer.write_all(&[ST_OK])?; write_frame(&mut writer, &[])?; } OP_DEL => { let key = read_frame(&mut reader)?; let ok = store.remove(&key).is_some(); writer.write_all(&[if ok { ST_OK } else { ST_NOT_FOUND }])?; write_frame(&mut writer, &[])?; } _ => { writer.write_all(&[ST_ERR])?; write_frame(&mut writer, b"unknown opcode")?; writer.flush()?; return Ok(()); } } writer.flush()?; // สำคัญมาก: ถ้าไม่ flush คำตอบจะค้างอยู่ใน buffer ไม่ออกไปหา client }}โครงนี้คือ final project ของหนังสือ The Rust Programming Language ฉบับปี 2024 ตรงๆ — และตรงนี้มีกับดักการอ้างอิงที่ต้องระวัง: ในฉบับปัจจุบัน (2024) project ปิดท้าย “Building a Multithreaded Web Server” อยู่ บท 21 (§21.1 เป็น version single-threaded ที่ใช้ TcpListener/TcpStream/BufReader เป๊ะแบบข้างบน) แต่ในทุกฉบับ ก่อน ปี 2024 บทนี้เคยเป็น บท 20 — บท async ที่เพิ่มเข้ามาใหม่ดันเลขทุกอย่างขึ้นหนึ่ง เราจึงอ้าง บท 21 เท่านั้น ไม่ใช่บท 20
รายละเอียดสามจุดที่ load-bearing:
try_clone()แยกครึ่ง read/write —TcpStreamหนึ่งตัวถูกtry_cloneออกเป็น2 handle ชี้ socket เดียวกัน อันหนึ่งห่อBufReaderไว้อ่าน อีกอันห่อBufWriterไว้เขียน เราจึงถือทั้งฝั่งอ่านและเขียนบน socket เดียวได้พร้อมกันflush()ทุกตา —BufWriterสะสม byte ไว้ใน buffer ก่อน ตามเอกสาร std มันจะเขียนออกจริงก็ต่อเมื่อเรียกflush()หรือตอน drop เท่านั้น server ที่เขียนคำตอบลงBufWriterแล้ววนไปรออ่านคำสั่งถัดไป โดยไม่ flush จะทำให้ client รอคำตอบที่ไม่มีวันมา — ค้างทั้งคู่ตลอดกาล บรรทัดwriter.flush()?ท้าย loop จึงขาดไม่ได้UnexpectedEofบน op byte = วางสายสะอาด — เมื่อ client ปิดการเชื่อมต่อread_exactบน op byte แรกจะได้UnexpectedEofเรา match มันแล้วreturn Ok(())เงียบๆ (ไม่ log เป็น error) เพราะนี่คือการจบปกติ ไม่ใช่ความผิดพลาด
client ที่เข้าคู่กัน
หัวข้อที่มีชื่อว่า “client ที่เข้าคู่กัน”เพราะเรา เป็นเจ้าของทั้งสองฝั่ง จึงไม่ต้องมี schema library อะไร — client ใช้ opcode และ framing ชุดเดียวกันแบบสมมาตร ทุกคำขอ flush หลังส่งเสร็จ เหมือนฝั่ง server:
use std::net::TcpStream;struct Client { reader: std::io::BufReader<TcpStream>, writer: std::io::BufWriter<TcpStream> }impl Client { fn connect(addr: &str) -> io::Result<Client> { let stream = TcpStream::connect(addr)?; Ok(Client { reader: BufReader::new(stream.try_clone()?), writer: BufWriter::new(stream) }) } fn get(&mut self, key: &[u8]) -> io::Result<Option<Vec<u8>>> { self.writer.write_all(&[OP_GET])?; write_frame(&mut self.writer, key)?; self.writer.flush()?; // client ก็ต้อง flush เอง let mut st = [0u8; 1]; self.reader.read_exact(&mut st)?; let payload = read_frame(&mut self.reader)?; match st[0] { ST_OK => Ok(Some(payload)), ST_NOT_FOUND => Ok(None), _ => Err(io::Error::other("server error")), // io::Error::other เสถียรตั้งแต่ 1.74 } } // set()/delete() สะท้อน wire format แบบเดียวกัน และแต่ละตัว flush หลังส่งคำขอ}รันจริงบน musl (server อยู่บน thread เบื้องหลัง, client อยู่บน main, คุยกันผ่าน loopback 127.0.0.1) ได้ผลตามนี้:
SET lang=rust ; GET lang -> Some("rust")GET nope (absent) -> NoneDELETE lang -> true ; GET lang -> None ; DELETE lang again -> falsebinary-safe value round-trips unchanged -> truesecond client dropped -> server saw clean UnexpectedEof (no 'connection error' above)all assertions passedอ่านทีละบรรทัด: SET แล้ว GET คืน byte เดิมเป๊ะ; GET key ที่ไม่มี → None; DELETE ที่มีอยู่ → true แล้ว GET ตามได้ None, DELETE ซ้ำ → false; value ที่มี byte พิสดาร (เว้นวรรค, 0x0A, byte สูง 0xFF) รอดกลับมาครบ — พิสูจน์ว่า framing แบบ binary ปลอดภัยจริง; และ client ตัวที่สองที่ต่อแล้ววางสาย ทำให้ server เจอ UnexpectedEof แบบสะอาดโดยไม่ log error
นี่คือเหตุผลที่เราไม่เลือก protocol แบบบรรทัด (text ที่อ่านด้วย nc ได้สวยๆ): มันดูเป็นมิตรตอนเดโม แต่พังทันทีเมื่อ value มี \n หรือไม่ใช่ UTF-8 — ซึ่ง key-value store ต้อง รับได้ length-prefixed byte framing แลกความสวยงามตอน nc กับความถูกต้องแบบ binary-safe ที่ผลรันข้างบนยืนยันแล้ว
sequenceDiagram
participant C as Client
participant S as Server (single-threaded)
participant M as HashMap ในหน่วยความจำ
C->>S: OP_SET | len | key | len | value (byte บนสาย TCP)
S->>M: store.insert(key, value)
S-->>C: ST_OK | len=0
C->>S: OP_GET | len | key
S->>M: store.get(key)
M-->>S: Some(value)
S-->>C: ST_OK | len | value
คำบรรยายภาพ: หนึ่งรอบคำสั่ง SET แล้ว GET บน wire protocol แบบ length-prefixed — client ส่ง opcode 1 byte นำหน้า record ที่ใส่ความยาว (len) กำกับทุกก้อน, server ตอบ status byte (ST_OK) แล้วตามด้วย frame ของ value; ระหว่างนั้น store ที่แท้จริงคือ HashMap ในหน่วยความจำ (ยังไม่ลง disk จนบท2)
สองความจริงที่เราพูดตรงๆ
หัวข้อที่มีชื่อว่า “สองความจริงที่เราพูดตรงๆ”honesty spine ของคอร์สนี้เริ่มที่นี่ — เราจะไม่ปิดบังขีดจำกัดของสิ่งที่เพิ่งสร้าง มีสองเรื่องที่ต้องพูดออกมาตรงๆ ตั้งแต่บทแรก:
1. server single-threaded นี้ให้บริการ client แบบเรียงคิว — for stream in listener.incoming() รับทีละราย ตราบใดที่ client A ยังไม่วางสาย client B ต้องรอ client ที่ช้าเพียงรายเดียว block ทุกคน นี่ไม่ใช่ bug แต่เป็นข้อจำกัดของ model — เราจะจ่ายคืนที่ บท5 ด้วย ThreadPool ที่ share store ผ่าน Arc<Mutex>/Arc<RwLock>
2. เราจงใจเลื่อน async ออกไป — ในหนังสือ The Rust Programming Language ปี 2024 server std::net แบบ blocking นี้อยู่ บท 21 ส่วน async/await/futures อยู่แยกที่ บท 17 หนังสือเองก็วาง server std ไว้ฝั่ง blocking ของเส้นแบ่ง เราเดินตามเส้นนั้น: concurrency ของคอร์สนี้คือ std::thread ล้วนๆ ส่วน tokio/async เป็นโลก runtime แยกที่เลื่อนไปคอร์ส #23 (Distributed Systems) — การ ตั้งชื่อเส้นแบ่ง นี้ให้ชัดคือบทเรียน ไม่ใช่ช่องโหว่
หลักคิดที่จะย้ำตลอดคอร์ส: “thread เป็นค่าปริยายที่ถูกต้องจนกว่าจำนวน connection — ไม่ใช่ CPU — จะกลายเป็นคอขวด” async เกิดมาเพื่อ multiplex connection ที่ส่วนใหญ่ idle นับหมื่น (ปัญหา C10k) บน thread ไม่กี่ตัว ตราบใดที่เรายังไม่ชนเพดานนั้น thread-per-connection คือคำตอบที่ถูกและง่ายกว่า
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้วางโครงบนเครือข่ายของ kaen-kvstore: เราเข้าใจแล้วว่า key-value store คือฐานข้อมูลที่ง่ายที่สุดที่ยังสอนแกนกลางของฐานข้อมูลได้ครบ; TCP เป็น byte stream ที่ไม่มีขอบเขต message เราจึงออกแบบ wire protocol แบบ length-prefixed เอง อ่านด้วย read_exact (ไม่ใช่ read ที่ short-read ได้) และ pin endianness ด้วย to_le_bytes/from_le_bytes; ต่อ server single-threaded ด้วย TcpListener::bind + incoming() โดยระวัง try_clone() แยก read/write และ flush() ทุกตา; ต่อ client ที่เข้าคู่กันเพราะเราเป็นเจ้าของทั้งสองฝั่ง; store บทนี้คือ HashMap ในหน่วยความจำล้วนๆ; และพูดความจริงสองข้อไว้ล่วงหน้า — server นี้ serialize client (แก้ที่บท5) และเราจงใจเลื่อน async ไป #23 ทุก snippet compile และรันได้จริงบน Rust 1.97.1 / edition 2024 / std ล้วน
บท2 เราลง disk: HashMap ในบทนี้ตายพร้อม process — บทหน้าเราทำให้ทุก set/delete รอด การปิดโปรแกรม ด้วยการ append ทุกคำสั่งเป็น record ลง append-only log file เดียว แล้วเก็บ hash index ในหน่วยความจำที่ชี้จาก key → offset ใน file (model Bitcask) — write_frame/read_frame ตัวเดิมจากบทนี้จะย้ายจาก socket ไปเขียนลง File แทบไม่ต้องแก้อะไร
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch21 “Final Project: Building a Multithreaded Web Server” (เข้าถึง 2026-07-24) — project ปิดท้ายของหนังสือฉบับ 2024 คือ บท 21 (เคยเป็นบท 20 ในฉบับก่อนหน้า — อ้างบท 21 เท่านั้น)
- The Rust Programming Language — §21.1 “Building a Single-Threaded Web Server” (เข้าถึง 2026-07-24) —
TcpListener::bind+incoming()+TcpStream+BufReaderแบบ single-threaded ที่โครงบทนี้เดินตาม - The Rust Programming Language — ch17 “Fundamentals of Asynchronous Programming: Async, Await, Futures, and Streams” (เข้าถึง 2026-07-24) — บท async ที่แยกต่างหาก คือเส้นแบ่งที่คอร์สนี้เลื่อนไป #23
- std
Read::read_exact(เข้าถึง 2026-07-24) — อ่านให้เต็มbuf.len()พอดี หรือคืนUnexpectedEof(byte ที่อ่านได้ตอน error ไม่ระบุ) - std
net::TcpListener(เข้าถึง 2026-07-24) —bind+incoming() - std
net::TcpStream(เข้าถึง 2026-07-24) —connect,try_clone, อ่าน/เขียน - std
io::BufWriter(เข้าถึง 2026-07-24) — สะสม byte ไว้ใน buffer เขียนออกจริงตอนflush()/drop - std
u32::to_le_bytes(เข้าถึง 2026-07-24) — byte แบบ little-endian คงที่ 4 byte เสถียรตั้งแต่ 1.32.0
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3wire protocol ที่เราออกแบบในบทนี้คืออะไร?