Building a Single-Threaded Web Server
เราจะเริ่มต้นด้วยการทำให้เว็บเซิร์ฟเวอร์แบบเธรดเดียว (single-threaded web server) ทำงานได้ ก่อนที่เราจะเริ่ม เรามาดูภาพรวมสั้นๆ ของโปรโตคอลที่เกี่ยวข้องกับการสร้างเว็บเซิร์ฟเวอร์กัน รายละเอียดของโปรโตคอลเหล่านี้อยู่เกินขอบเขตของหนังสือเล่มนี้ แต่ภาพรวมสั้นๆ จะมอบข้อมูลที่คุณจำเป็นต้องรู้
สองโปรโตคอลหลักที่เกี่ยวข้องกับเว็บเซิร์ฟเวอร์คือ Hypertext Transfer Protocol (HTTP) และ Transmission Control Protocol (TCP) ทั้งสองโปรโตคอลเป็นโปรโตคอลแบบ คำร้องขอ-การตอบกลับ (request-response) ซึ่งหมายความว่า ไคลเอนต์ (client) จะเป็นผู้เริ่มต้นส่งคำร้องขอ และ เซิร์ฟเวอร์ (server) จะรอฟังคำร้องขอและให้คำตอบกลับแก่ไคลเอนต์ เนื้อหาของคำร้องขอและการตอบกลับเหล่านั้นถูกกำหนดไว้ตามโปรโตคอล
TCP เป็นโปรโตคอลระดับต่ำกว่า (lower-level protocol) ที่อธิบายรายละเอียดของวิธีการที่ข้อมูลส่งจากเซิร์ฟเวอร์หนึ่งไปอีกเซิร์ฟเวอร์หนึ่ง แต่ไม่ได้ระบุว่าข้อมูลนั้นคืออะไร HTTP สร้างต่อยอดอยู่บน TCP โดยการกำหนดเนื้อหาของคำร้องขอและการตอบกลับ ในทางเทคนิคแล้วสามารถใช้ HTTP ร่วมกับโปรโตคอลอื่นได้ แต่ในกรณีส่วนใหญ่ HTTP จะส่งข้อมูลผ่าน TCP เราจะทำงานกับไบต์ดิบ (raw bytes) ของคำร้องขอและการตอบกลับใน TCP และ HTTP
Listening to the TCP Connection
เว็บเซิร์ฟเวอร์ของเราจำเป็นต้องรอฟังการเชื่อมต่อ TCP ดังนั้น นั่นคือส่วนแรกที่เราจะทำ ไลบรารีมาตรฐานเสนอมอดูล std::net ที่ช่วยให้เราทำสิ่งนี้ได้ มาสร้างโปรเจกต์ใหม่ตามปกติกันครับ:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
ตอนนี้ให้ใส่โค้ดในโค้ดตัวอย่างที่ 21-1 ลงใน src/main.rs เพื่อเริ่มต้น โค้ดนี้จะรอฟังที่แอดเดรสภายในเครื่อง 127.0.0.1:7878 สำหรับสตรีม TCP (TCP streams) ที่เข้ามา เมื่อได้รับสตรีมที่เข้ามา มันจะพิมพ์ Connection established!
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
println!("Connection established!");
}
}
การใช้ TcpListener ช่วยให้เรารอฟังการเชื่อมต่อ TCP ที่แอดเดรส 127.0.0.1:7878 ได้ ในแอดเดรสนั้น ส่วนก่อนเครื่องหมายทวิภาคคือ IP address ที่เป็นตัวแทนของคอมพิวเตอร์คุณ (ซึ่งเหมือนกันบนคอมพิวเตอร์ทุกเครื่องและไม่ได้เป็นตัวแทนคอมพิวเตอร์ของผู้เขียนโดยเฉพาะ) และ 7878 คือพอร์ต (port) เราเลือกพอร์ตนี้ด้วยสองเหตุผล: ปกติแล้ว HTTP จะไม่ถูกรับบนพอร์ตนี้ ดังนั้นเซิร์ฟเวอร์ของเราจึงไม่น่าจะขัดแย้งกับเว็บเซิร์ฟเวอร์อื่นใดที่คุณอาจกำลังรันอยู่บนเครื่องของคุณ และ 7878 คือคำว่า rust ที่พิมพ์บนแป้นพิมพ์โทรศัพท์
ฟังก์ชัน bind ในสถานการณ์นี้ทำงานเหมือนฟังก์ชัน new ตรงที่จะคืนค่าอินสแตนซ์ TcpListener ใหม่ ฟังก์ชันนี้ถูกเรียกว่า bind เพราะว่าในระบบเครือข่าย การเชื่อมต่อกับพอร์ตเพื่อรอฟังจะถูกเรียกว่า “การผูกเข้ากับพอร์ต (binding to a port)”
ฟังก์ชัน bind จะคืนค่า Result<T, E> ซึ่งระบุว่าการผูกพอร์ตอาจล้มเหลวได้ ตัวอย่างเช่น หากเรารันโปรแกรมของเราสองอินสแตนซ์พร้อมกัน และทำให้มีสองโปรแกรมรอฟังพอร์ตเดียวกัน เนื่องจากเรากำลังเขียนเซิร์ฟเวอร์พื้นฐานเพื่อการเรียนรู้เท่านั้น เราจะไม่กังวลเกี่ยวกับการจัดการข้อผิดพลาดประเภทเหล่านี้; แต่เราจะใช้ unwrap เพื่อหยุดโปรแกรมหากเกิดข้อผิดพลาดขึ้น
เมธอด incoming บน TcpListener จะคืนค่าตัววนซ้ำ (iterator) ที่มอบลำดับของสตรีมให้กับเรา (เจาะจงกว่านั้นคือ สตรีมประเภท TcpStream) สตรีม (stream) เดียวเป็นตัวแทนของการเชื่อมต่อที่เปิดอยู่ระหว่างไคลเอนต์และเซิร์ฟเวอร์ การเชื่อมต่อ (Connection) คือชื่อของกระบวนการคำร้องขอและการตอบกลับทั้งหมดที่ไคลเอนต์เชื่อมต่อกับเซิร์ฟเวอร์ เซิร์ฟเวอร์สร้างคำตอบกลับ และเซิร์ฟเวอร์ปิดการเชื่อมต่อ ดังนั้น เราจะอ่านจาก TcpStream เพื่อดูว่าไคลเอนต์ส่งอะไรมา แล้วเขียนคำตอบกลับของเราลงในสตรีมเพื่อส่งข้อมูลกลับไปยังไคลเอนต์ โดยรวมแล้ว ลูป for นี้จะประมวลผลการเชื่อมต่อแต่ละอันตามลำดับ และสร้างชุดสตรีมเพื่อให้เราจัดการ
ในตอนนี้ การจัดการสตรีมของเราประกอบด้วยการเรียก unwrap เพื่อยุติโปรแกรมหากสตรีมมีข้อผิดพลาดใดๆ; หากไม่มีข้อผิดพลาด โปรแกรมจะพิมพ์ข้อความ เราจะเพิ่มฟังก์ชันการทำงานเพิ่มเติมสำหรับกรณีที่สำเร็จในโค้ดตัวอย่างถัดไป เหตุผลที่เราอาจได้รับข้อผิดพลาดจากเมธอด incoming เมื่อไคลเอนต์เชื่อมต่อกับเซิร์ฟเวอร์ คือเราไม่ได้กำลังวนซ้ำเหนือการเชื่อมต่อจริงๆ แต่เรากำลังวนซ้ำเหนือ ความพยายามในการเชื่อมต่อ (connection attempts) การเชื่อมต่ออาจไม่สำเร็จเนื่องจากเหตุผลหลายประการ ซึ่งหลายประการขึ้นอยู่กับระบบปฏิบัติการโดยเฉพาะ ตัวอย่างเช่น ระบบปฏิบัติการหลายระบบมีขีดจำกัดจำนวนการเชื่อมต่อที่เปิดพร้อมกันซึ่งสามารถรองรับได้; ความพยายามเชื่อมต่อใหม่ที่เกินจำนวนนั้นจะเกิดข้อผิดพลาดจนกว่าการเชื่อมต่อที่เปิดอยู่บางอันจะถูกปิดลง
เรามาลองรันโค้ดนี้กัน! เรียกใช้ cargo run ในเทอร์มินัลแล้วโหลด 127.0.0.1:7878 ในเว็บเบราว์เซอร์ เบราว์เซอร์ควรแสดงข้อความแสดงข้อผิดพลาดเช่น “Connection reset” เนื่องจากเซิร์ฟเวอร์ไม่ได้ส่งข้อมูลใดๆ กลับไปในขณะนี้ แต่เมื่อคุณดูที่เทอร์มินัล คุณควรเห็นข้อความหลายข้อความที่ถูกพิมพ์ขึ้นเมื่อเบราว์เซอร์เชื่อมต่อกับเซิร์ฟเวอร์!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
บางครั้งคุณจะเห็นข้อความหลายข้อความพิมพ์ขึ้นสำหรับการร้องขอครั้งเดียวจากเบราว์เซอร์; เหตุผลอาจเป็นเพราะเบราว์เซอร์กำลังทำการร้องขอสำหรับหน้าเว็บ รวมถึงการร้องขอทรัพยากรอื่นๆ เช่น ไอคอน favicon.ico ที่ปรากฏในแท็บเบราว์เซอร์
อาจเป็นไปได้ว่าเบราว์เซอร์พยายามเชื่อมต่อกับเซิร์ฟเวอร์หลายครั้งเนื่องจากเซิร์ฟเวอร์ไม่ตอบกลับด้วยข้อมูลใดๆ เมื่อ stream หลุดออกจากขอบเขตและถูกดรอป (dropped) ที่จุดสิ้นสุดของลูป การเชื่อมต่อจะถูกปิดลงอันเป็นส่วนหนึ่งของการอิมพลีเมนต์ drop เบราว์เซอร์บางครั้งจะจัดการกับการเชื่อมต่อที่ปิดลงด้วยการลองใหม่ เนื่องจากปัญหาอาจเกิดขึ้นเพียงชั่วคราว
บางครั้งเบราว์เซอร์ยังเปิดการเชื่อมต่อหลายรายการไปยังเซิร์ฟเวอร์โดยไม่ส่งคำร้องขอใดๆ เพื่อที่ว่าหากพวกมันส่งคำร้องขอในภายหลัง การร้องขอเหล่านั้นจะเกิดขึ้นได้เร็วยิ่งขึ้น เมื่อสิ่งนี้เกิดขึ้น เซิร์ฟเวอร์ของเราจะมองเห็นแต่ละการเชื่อมต่อ โดยไม่คำนึงว่ามีการร้องขอใดๆ ผ่านการเชื่อมคอนั้นหรือไม่ เบราว์เซอร์ตระกูล Chrome หลายเวอร์ชันทำสิ่งนี้; คุณสามารถปิดการปรับแต่งนั้นได้โดยใช้โหมดท่องเว็บแบบส่วนตัวหรือใช้เบราว์เซอร์อื่น
ปัจจัยสำคัญคือเราสามารถรับมือ (get a handle) การเชื่อมต่อ TCP ได้สำเร็จแล้ว!
อย่าลืมหยุดโปรแกรมโดยการกด ctrl-C เมื่อคุณรันโค้ดเวอร์ชันเฉพาะนั้นเสร็จแล้ว จากนั้น รีสตาร์ตโปรแกรมโดยการเรียกใช้คำสั่ง cargo run หลังจากที่คุณได้ทำการเปลี่ยนแปลงโค้ดแต่ละชุด เพื่อให้แน่ใจว่าคุณกำลังรันโค้ดล่าสุด
Reading the Request
เรามาอิมพลีเมนต์ฟังก์ชันการทำงานเพื่ออ่านคำร้องขอจากเบราว์เซอร์กัน! เพื่อแยกความรับผิดชอบในการรับการเชื่อมต่อกับการดำเนินการกับการเชื่อมนั้น เราจะเริ่มต้นฟังก์ชันใหม่สำหรับการประมวลผลการเชื่อมต่อ ในฟังก์ชัน handle_connection ใหม่นี้ เราจะอ่านข้อมูลจากสตรีม TCP และพิมพ์ออกมา เพื่อให้เราเห็นข้อมูลที่ถูกส่งจากเบราว์เซอร์ เปลี่ยนโค้ดให้เหมือนกับโค้ดตัวอย่างที่ 21-2
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("Request: {http_request:#?}");
}
เรานำ std::io::BufReader และ std::io::prelude เข้าสู่ขอบเขตเพื่อรับสิทธิ์เข้าถึงเทรตและประเภทที่ช่วยให้เราอ่านและเขียนไปยังสตรีมได้ ในลูป for ในฟังก์ชัน main แทนที่จะพิมพ์ข้อความว่าเราได้เชื่อมต่อแล้ว ตอนนี้เราเรียกฟังก์ชัน handle_connection ใหม่และส่ง stream ไปให้มัน
ในฟังก์ชัน handle_connection เราสร้างอินสแตนซ์ BufReader ใหม่ที่หุ้มการอ้างอิงไปยัง stream ไว้ BufReader จะเพิ่มการบัฟเฟอร์ (buffering) โดยการจัดการการเรียกใช้เมธอดเทรต std::io::Read ให้เรา
เราสร้างตัวแปรชื่อ http_request เพื่อรวบรวมบรรทัดคำร้องขอที่เบราว์เซอร์ส่งไปยังเซิร์ฟเวอร์ของเรา ระบุว่าเราต้องการรวบรวมบรรทัดเหล่านี้ไว้ในเวกเตอร์โดยเพิ่มคำอธิบายประเภท Vec<_>
BufReader อิมพลีเมนต์ std::io::BufRead trait ซึ่งมอบเมธอด lines มาให้ เมธอด lines จะคืนค่าตัววนซ้ำของ Result<String, std::io::Error> โดยการแบ่งสตรีมข้อมูลเมื่อใดก็ตามที่เห็นไบต์ขึ้นบรรทัดใหม่ เพื่อรับแต่ละ String เราใช้ map และ unwrap แต่ละ Result โดย Result อาจเป็นข้อผิดพลาดหากข้อมูลไม่ได้เป็น UTF-8 ที่ถูกต้อง หรือหากมีปัญหาในการอ่านจากสตรีม อีกครั้ง โปรแกรมจริงในการทำงานควรจัดการข้อผิดพลาดเหล่านี้อย่างเหมาะสม แต่เราเลือกที่จะหยุดโปรแกรมในกรณีข้อผิดพลาดเพื่อความเรียบง่าย
เบราว์เซอร์จะส่งสัญญาณสิ้นสุดคำร้องขอ HTTP โดยการส่งตัวอักษรขึ้นบรรทัดใหม่สองตัวติดต่อกัน ดังนั้นเพื่อให้ได้หนึ่งคำร้องขอจากสตรีม เราจึงรับบรรทัดไปเรื่อยๆ จนกว่าจะได้บรรทัดที่เป็นสตริงว่าง เมื่อเรารวบรวมบรรทัดลงในเวกเตอร์แล้ว เราจะพิมพ์พวกมันออกมาโดยใช้รูปแบบดีบักแบบสวยงาม (pretty debug formatting) เพื่อให้เราสามารถดูคำสั่งที่เว็บเบราว์เซอร์ส่งไปยังเซิร์ฟเวอร์ของเราได้
เรามาลองรันโค้ดนี้กัน! เริ่มโปรแกรมและทำการร้องขอในเว็บเบราว์เซอร์อีกครั้ง โปรดทราบว่าเราจะยังคงได้รับหน้าข้อผิดพลาดในเบราว์เซอร์ แต่เอาต์พุตของโปรแกรมในเทอร์มินัลจะออกมาคล้ายดังนี้:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/hello`
Request: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language: en-US,en;q=0.5",
"Accept-Encoding: gzip, deflate, br",
"DNT: 1",
"Connection: keep-alive",
"Upgrade-Insecure-Requests: 1",
"Sec-Fetch-Dest: document",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-Site: none",
"Sec-Fetch-User: ?1",
"Cache-Control: max-age=0",
]
ขึ้นอยู่กับเบราว์เซอร์ของคุณ คุณอาจได้รับเอาต์พุตที่แตกต่างกันเล็กน้อย ตอนนี้เมื่อเราพิมพ์ข้อมูลคำร้องขอแล้ว เราจึงสามารถมองเห็นว่าทำไมเราจึงได้รับการเชื่อมต่อหลายรายการจากการร้องขอครั้งเดียวของเบราว์เซอร์ โดยดูที่เส้นทางตามหลัง GET ในบรรทัดแรกของคำร้องขอ หากการเชื่อมต่อที่ซ้ำกันทั้งหมดกำลังร้องขอ / เราจะทราบว่าเบราว์เซอร์กำลังพยายามดึงข้อมูล / ซ้ำๆ เนื่องจากไม่ได้รับการตอบกลับจากโปรแกรมของเรา
เรามาแยกแยะข้อมูลคำร้องขอนี้เพื่อทำความเข้าใจสิ่งที่เบราว์เซอร์กำลังร้องขอจากโปรแกรมของเรากันครับ
Looking More Closely at an HTTP Request
HTTP เป็นโปรโตคอลแบบข้อความ (text-based protocol) และคำร้องขอจะมีรูปแบบดังนี้:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
บรรทัดแรกคือ บรรทัดคำร้องขอ (request line) ที่ถือครองข้อมูลเกี่ยวกับสิ่งที่ไคลเอนต์กำลังร้องขอ ส่วนแรกของบรรทัดคำร้องขอระบุถึงวิธีการ (method) ที่กำลังถูกใช้ เช่น GET หรือ POST ซึ่งอธิบายว่าไคลเอนต์สร้างคำร้องขอนี้อย่างไร ไคลเอนต์ของเราใช้คำร้องขอแบบ GET ซึ่งหมายความว่ามันกำลังขอข้อมูล
ส่วนถัดไปของบรรทัดคำร้องขอคือ / ซึ่งระบุถึง uniform resource identifier (URI) ที่ไคลเอนต์กำลังร้องขอ: URI เกือบจะเหมือนกับ แต่ไม่เหมือนเสียทีเดียวกับ uniform resource locator (URL) ความแตกต่างระหว่าง URIs และ URLs ไม่ได้สำคัญสำหรับวัตถุประสงค์ของเราในบทนี้ แต่ข้อกำหนดของ HTTP ใช้คำว่า URI ดังนั้นเราจึงสามารถแทนที่คำว่า URL แทน URI ในใจได้ที่นี่
ส่วนสุดท้ายคือเวอร์ชัน HTTP ที่ไคลเอนต์ใช้ จากนั้นบรรทัดคำร้องขอจะจบลงด้วยลำดับ CRLF (CRLF ย่อมาจาก carriage return และ line feed ซึ่งเป็นคำตั้งแต่สมัยพิมพ์ดีด!) ลำดับ CRLF ยังสามารถเขียนเป็น \r\n โดยที่ \r คือ carriage return และ \n คือ line feed ลำดับ CRLF จะคั่นบรรทัดคำร้องขอออกจากส่วนที่เหลือของข้อมูลคำร้องขอ สังเกตว่าเมื่อพิมพ์ CRLF ออกมา เราจะเห็นบรรทัดใหม่เริ่มต้นขึ้นแทนที่จะเป็น \r\n
เมื่อดูที่ข้อมูลบรรทัดคำร้องขอที่เราได้รับจากการรันโปรแกรมของเราจนถึงตอนนี้ เราจะเห็นว่า GET คือวิธีการ, / คือ URI คำร้องขอ, และ HTTP/1.1 คือเวอร์ชัน
หลังจากบรรทัดคำร้องขอ บรรทัดที่เหลือที่เริ่มต้นตั้งแต่ Host: เป็นต้นไปคือส่วนหัว (headers) คำร้องขอแบบ GET ไม่มีบอดี้ (body)
ลองทำการร้องขอจากเบราว์เซอร์อื่น หรือลองร้องขอแอดเดรสอื่น เช่น 127.0.0.1:7878/test เพื่อดูว่าข้อมูลคำร้องขอเปลี่ยนแปลงไปอย่างไร
ตอนนี้เมื่อเรารู้แล้วว่าเบราว์เซอร์กำลังร้องขออะไร เรามาส่งข้อมูลกลับไปกันครับ!
Writing a Response
เรากำลังจะอิมพลีเมนต์การส่งข้อมูลเพื่อตอบกลับคำร้องขอของไคลเอนต์ การตอบกลับมีรูปแบบดังต่อไปนี้:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
บรรทัดแรกคือ บรรทัดสถานะ (status line) ที่บรรจุเวอร์ชัน HTTP ที่ใช้ในการตอบกลับ, รหัสสถานะเชิงตัวเลข (numeric status code) ที่สรุปผลลัพธ์ของคำร้องขอ, และวลีเหตุผล (reason phrase) ที่มอบคำอธิบายเป็นข้อความของรหัสสถานะ หลังจากลำดับ CRLF จะเป็นส่วนหัว (headers) ใดๆ, ลำดับ CRLF อีกชุดหนึ่ง, และบอดี้ของการตอบกลับ
นี่คือตัวอย่างการตอบกลับที่ใช้เวอร์ชัน HTTP 1.1 และมีรหัสสถานะ 200, วลีเหตุผลเป็น OK, ไม่มีส่วนหัว, และไม่มีบอดี้:
HTTP/1.1 200 OK\r\n\r\n
รหัสสถานะ 200 คือการตอบกลับสำเร็จมาตรฐาน ข้อความนี้คือการตอบกลับ HTTP ที่สำเร็จที่มีขนาดเล็กมาก เรามารายงานสิ่งนี้ลงในสตรีมเพื่อเป็นการตอบกลับคำร้องขอที่สำเร็จกันครับ! จากฟังก์ชัน handle_connection ให้ลบ println! ที่กำลังพิมพ์ข้อมูลคำร้องขอออก แล้วแทนที่ด้วยโค้ดในโค้ดตัวอย่างที่ 21-3
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();
}
บรรทัดใหม่บรรทัดแรกนิยามตัวแปร response ที่ถือครองข้อมูลของข้อความสำเร็จ จากนั้น เราเรียกใช้ as_bytes บน response ของเราเพื่อแปลงข้อมูลสตริงเป็นไบต์ เมธอด write_all บน stream จะรับ &[u8] และส่งไบต์เหล่านั้นโดยตรงผ่านการเชื่อมต่อ เนื่องจากปฏิบัติการ write_all อาจล้มเหลวได้ เราจึงใช้ unwrap กับผลลัพธ์ข้อผิดพลาดใดๆ เหมือนก่อนหน้านี้ อีกครั้ง ในแอปพลิเคชันจริง คุณควรเพิ่มการจัดการข้อผิดพลาดที่นี่
ด้วยการเปลี่ยนแปลงเหล่านี้ เรามารันโค้ดและทำการร้องขอกันครับ เราไม่ได้พิมพ์ข้อมูลใดๆ ออกทางเทอร์มินัลอีกต่อไป ดังนั้นเราจะไม่เห็นเอาต์พุตใดๆ นอกเหนือจากเอาต์พุตจาก Cargo เมื่อคุณโหลด 127.0.0.1:7878 ในเว็บเบราว์เซอร์ คุณควรได้หน้าเปล่าแทนที่จะเป็นข้อผิดพลาด คุณเพิ่งจะเขียนโค้ดด้วยตนเองในการรับคำร้องขอ HTTP และการส่งการตอบกลับสำเร็จแล้ว!
Returning Real HTML
เรามาอิมพลีเมนต์ฟังก์ชันการทำงานสำหรับการส่งคืนหน้าเว็บจริงแทนที่จะเป็นหน้าเปล่ากันครับ สร้างไฟล์ใหม่ชื่อ hello.html ในโฟลเดอร์หลัก (root) ของไดเรกทอรีโปรเจกต์ของคุณ ไม่ใช่ในไดเรกทอรี src คุณสามารถใส่ HTML ใดๆ ที่คุณต้องการได้; โค้ดตัวอย่างที่ 21-4 แสดงความเป็นไปได้แบบหนึ่ง
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
นี่คือเอกสาร HTML5 ขนาดเล็กที่มีหัวข้อและข้อความบางอย่าง ในการส่งคืนสิ่งนี้จากเซิร์ฟเวอร์เมื่อได้รับคำร้องขอ เราจะปรับเปลี่ยน handle_connection ดังแสดงในโค้ดตัวอย่างที่ 21-5 เพื่ออ่านไฟล์ HTML เพิ่มมันลงในการตอบกลับในฐานะบอดี้ และส่งมันไป
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
// --snip--
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
เราได้เพิ่ม fs ลงในคำสั่ง use เพื่อนำมอดูลระบบไฟล์ (filesystem module) ของไลบรารีมาตรฐานเข้าสู่ขอบเขต โค้ดสำหรับการอ่านเนื้อหาของไฟล์ลงในสตริงควรจะดูคุ้นเคย; เราเคยใช้มันเมื่อเราอ่านเนื้อหาของไฟล์สำหรับโปรเจกต์ I/O ของเราในโค้ดตัวอย่างที่ 12-4
ถัดไป เราใช้ format! เพื่อเพิ่มเนื้อหาของไฟล์เป็นบอดี้ของการตอบกลับที่สำเร็จ เพื่อรับประกันว่าจะได้การตอบกลับ HTTP ที่ถูกต้อง เราเพิ่มส่วนหัว Content-Length ซึ่งตั้งค่าเป็นขนาดของบอดี้การตอบกลับของเรา—ในกรณีนี้คือขนาดของ hello.html
รันโค้ดนี้ด้วย cargo run และโหลด 127.0.0.1:7878 ในเบราว์เซอร์ของคุณ; คุณควรเห็น HTML ของคุณถูกเรนเดอร์!
ในปัจจุบัน เรากำลังเพิกเฉยต่อข้อมูลคำร้องขอใน http_request และเพียงส่งเนื้อหาของไฟล์ HTML กลับไปโดยไม่มีเงื่อนไข นั่นหมายความว่าหากคุณลองร้องขอ 127.0.0.1:7878/something-else ในเบราว์เซอร์ของคุณ คุณก็จะยังได้รับคำตอบกลับเป็น HTML เดียวกันนี้ ในขณะนี้ เซิร์ฟเวอร์ของเรามีข้อจำกัดอย่างมากและไม่ได้ทำในสิ่งที่เว็บเซิร์ฟเวอร์ส่วนใหญ่ทำ เราต้องการปรับแต่งการตอบกลับตามคำร้องขอ และส่งไฟล์ HTML กลับไปเฉพาะคำร้องขอที่ถูกต้องไปยัง / เท่านั้น
Validating the Request and Selectively Responding
ในขณะนี้ เว็บเซิร์ฟเวอร์ของเราจะคืนค่า HTML ในไฟล์เสมอ ไม่ว่าไคลเอนต์จะร้องขออะไรก็ตาม เรามาเพิ่มฟังก์ชันการทำงานเพื่อตรวจสอบว่าเบราว์เซอร์กำลังร้องขอ / ก่อนส่งไฟล์ HTML กลับไป และคืนค่าข้อผิดพลาดหากเบราว์เซอร์ร้องขอสิ่งอื่น สำหรับสิ่งนี้เราจำเป็นต้องปรับเปลี่ยน handle_connection ดังแสดงในโค้ดตัวอย่างที่ 21-6 โค้ดใหม่นี้จะตรวจสอบเนื้อหาของคำร้องขอที่ได้รับเปรียบเทียบกับสิ่งที่เรารู้ว่าคำร้องขอสำหรับ / มีหน้าตาเป็นอย่างไร และเพิ่มบล็อก if และ else เพื่อจัดการคำร้องขอที่แตกต่างกัน
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
} else {
// some other request
}
}
เราจะดูเฉพาะบรรทัดแรกของคำร้องขอ HTTP ดังนั้นแทนที่จะอ่านคำร้องขอทั้งหมดลงในเวกเตอร์ เราจึงเรียกใช้ next เพื่อรับไอเทมแรกจากตัววนซ้ำ unwrap อันแรกจะจัดการ Option และหยุดโปรแกรมหากตัววนซ้ำไม่มีไอเทม unwrap อันที่สองจะจัดการ Result และมีผลเช่นเดียวกับ unwrap ที่อยู่ใน map ซึ่งเพิ่มเข้ามาในโค้ดตัวอย่างที่ 21-2
ถัดไป เราตรวจสอบ request_line เพื่อดูว่ามันเท่ากับบรรทัดคำร้องขอของคำร้องขอ GET ไปยังเส้นทาง / หรือไม่ หากใช่ บล็อก if จะคืนค่าเนื้อหาของไฟล์ HTML ของเรา
หาก request_line ไม่ เท่ากับคำร้องขอ GET ไปยังเส้นทาง / นั่นหมายความว่าเราได้รับคำร้องขออื่น เราจะเพิ่มโค้ดในบล็อก else ในอีกสักครู่เพื่อตอบกลับคำร้องขออื่นทั้งหมด
รันโค้ดนี้ตอนนี้และร้องขอ 127.0.0.1:7878; คุณควรได้ HTML ใน hello.html หากคุณทำการร้องขออื่นใด เช่น 127.0.0.1:7878/something-else คุณจะได้รับข้อผิดพลาดการเชื่อมต่อเช่นเดียวกับที่คุณเห็นเมื่อรันโค้ดในโค้ดตัวอย่างที่ 21-1 และโค้ดตัวอย่างที่ 21-2
ตอนนี้เรามาเพิ่มโค้ดในโค้ดตัวอย่างที่ 21-7 ลงในบล็อก else เพื่อคืนค่าการตอบกลับที่มีรหัสสถานะ 404 ซึ่งส่งสัญญาณว่าไม่พบเนื้อหาสำหรับคำร้องขอนั้น เราจะส่งคืน HTML สำหรับหน้าที่เรนเดอร์ในเบราว์เซอร์เพื่อระบุการตอบกลับแก่ผู้ใช้ปลายทางด้วย
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
// --snip--
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
}
ในที่นี้ การตอบกลับของเรามีบรรทัดสถานะพร้อมรหัสสถานะ 404 และวลีเหตุผล NOT FOUND บอดี้ของการตอบกลับจะเป็น HTML ในไฟล์ 404.html คุณจะต้องสร้างไฟล์ 404.html อยู่ข้างๆ hello.html สำหรับหน้าข้อผิดพลาด; อีกครั้ง สามารถใช้ HTML ใดก็ได้ที่คุณต้องการ หรือใช้ตัวอย่าง HTML ในโค้ดตัวอย่างที่ 21-8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
ด้วยการเปลี่ยนแปลงเหล่านี้ ให้รันเซิร์ฟเวอร์ของคุณอีกครั้ง การร้องขอ 127.0.0.1:7878 ควรคืนค่าเนื้อหาของ hello.html และการร้องขออื่นใด เช่น 127.0.0.1:7878/foo ควรคืนค่าข้อผิดพลาด HTML จาก 404.html
Refactoring
ในขณะนี้ บล็อก if และ else มีการเขียนซ้ำหลายแห่ง: ทั้งคู่ต่างอ่านไฟล์และเขียนเนื้อหาของไฟล์ลงในสตรีม ความแตกต่างเพียงอย่างเดียวคือบรรทัดสถานะและชื่อไฟล์ เรามาทำให้โค้ดกระชับขึ้นโดยการดึงความแตกต่างเหล่านั้นออกมาใส่ในบรรทัด if และ else แยกต่างหากที่จะกำหนดค่าของบรรทัดสถานะและชื่อไฟล์ให้กับตัวแปร; จากนั้นเราจะสามารถใช้ตัวแปรเหล่านั้นโดยไม่มีเงื่อนไขในโค้ดเพื่ออ่านไฟล์และเขียนการตอบกลับ โค้ดตัวอย่างที่ 21-9 แสดงโค้ดผลลัพธ์หลังจากแทนที่บล็อก if และ else ขนาดใหญ่
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
// --snip--
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
ตอนนี้บล็อก if และ else จะ คืนค่าเฉพาะค่าที่เหมาะสมสำหรับบรรทัดสถานะและชื่อไฟล์ในทูเพิล (tuple) เท่านั้น; จากนั้นเราใช้การกระจายโครงสร้าง (destructuring) เพื่อกำหนดสองค่านีให้กับ status_line และ filename โดยใช้รูปแบบในคำสั่ง let ดังที่ได้หารือกันในบทที่ 19
โค้ดที่เคยซ้ำซ้อนกันตอนนี้จะอยู่ภายนอกบล็อก if และ else และใช้ตัวแปร status_line และ filename สิ่งนี้ทำให้ง่ายต่อการมองเห็นความแตกต่างระหว่างสองกรณี และหมายความว่าเรามีเพียงจุดเดียวในการอัปเดตโค้ดหากเราต้องการเปลี่ยนแปลงวิธีที่การอ่านไฟล์และการเขียนการตอบกลับทำงาน พฤติกรรมของโค้ดในโค้ดตัวอย่างที่ 21-9 จะเหมือนกับในโค้ดตัวอย่างที่ 21-7
ยอดเยี่ยม! ตอนนี้เรามีเว็บเซิร์ฟเวอร์อย่างง่ายในโค้ด Rust ประมาณ 40 บรรทัด ซึ่งตอบกลับคำร้องขอหนึ่งด้วยหน้าเนื้อหา และตอบกลับคำร้องขออื่นทั้งหมดด้วยการตอบกลับ 404
ในปัจจุบัน เซิร์ฟเวอร์ของเราทำงานในเธรดเดียว ซึ่งหมายความว่ามันสามารถให้บริการได้เพียงครั้งละหนึ่งคำร้องขอเท่านั้น เรามาร่วมกันตรวจสอบว่าสิ่งนั้นอาจกลายเป็นปัญหาได้อย่างไรโดยการจำลองคำร้องขอที่ทำงานช้า จากนั้น เราจะแก้ไขเพื่อให้เซิร์ฟเวอร์ของเราสามารถจัดการคำร้องขอหลายคำร้องขอได้พร้อมกัน