外观
第 20 章 收尾大项目:我的第一个小网站
小螃蟹说
恭喜你,走到了全书最后一章!
还记得第 0 章吗?那时候你连"变量"是什么都不知道。而现在,我们要做一件真正了不起的事——从零写一个网站服务器:用 Rust 自己监听端口、接收浏览器的请求、把网页送回浏览器。打开浏览器,输入地址,页面上写着"欢迎来到小螃蟹的小网站"——这个页面,是运行在你自己电脑上的程序送出来的。
这就是"我的第一个小网站"。它综合了全书的武功:结构体、字符串处理、文件读取、多线程、并发……所有学过的东西,在这一章来一次大会师。
20.1 预览:我们要做什么
- 写一个服务器:监听
127.0.0.1:7878端口 - 浏览器访问
/,收到首页;访问别的路径,收到 404 页面 - 加一个
/sleep慢路由(故意等 5 秒),验证多线程:一个客人慢慢等,不影响其他客人 - 打开浏览器,亲眼看到自己的小网站
运行结果
text
$ cargo run
小网站开张了!打开浏览器访问 http://127.0.0.1:7878/
(浏览器访问 http://127.0.0.1:7878/ 后……)浏览器里看到:
运行结果
text
欢迎来到小螃蟹的小网站!
这个页面,是我用 Rust 自己写的服务器送出来的!
恭喜你,完成了整本书的学习!这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| HTTP 协议 | 浏览器和网站的"对话规矩" | 请求、响应、状态码 |
TcpListener | 站在门口等客人的接线员 | 监听端口 |
| 路由 | 根据请求选页面 | / 首页、其它 404 |
| 状态码 | 响应的"暗号" | 200 OK、404 NOT FOUND |
| 多线程服务器 | 一个客人一个服务员 | /sleep 不挡别人 |
20.2 动手做
步骤一:网站是怎么工作的?
打开浏览器,输入网址,页面出现——这背后发生的事,其实只有四步:
- 浏览器发请求:浏览器是个"客人",它敲响服务器的门,说:"我要看
/这个页面!"(这句话叫 HTTP 请求) - 服务器接话:服务器是个"接线员",一直守在门口,听到敲门就接
- 服务器回话:接线员找出页面,回一句:"好的,这是你要的页面!"(这句话叫 HTTP 响应)
- 浏览器展示:客人拿到页面,画在屏幕上
所以"网站" = 一个一直等着接电话的程序。我们今天写的,就是这个"接线员"。
步骤二:开张!第一个服务器
建项目:
bash
cargo new my_website
cd my_website把 src/main.rs 替换成:
rust
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").expect("绑定端口失败");
println!("小网站开张了!打开浏览器访问 http://127.0.0.1:7878/");
for stream in listener.incoming() {
let stream = stream.expect("连接失败");
println!("来了一位客人!");
}
}运行,然后打开浏览器,访问 http://127.0.0.1:7878/:
运行结果
text
小网站开张了!打开浏览器访问 http://127.0.0.1:7878/
来了一位客人!
来了一位客人!(浏览器可能访问两次——它还会顺便要一个"网站图标" favicon。)终端里出现了"来了一位客人!",而浏览器里是……空白页。为什么?因为我们的接线员接了电话,却没说话。
两个新朋友:
TcpListener:TCP 是"网络通话协议"(浏览器和服务器之间的通话规矩),TcpListener 是"站在门口等客人"的接线员。bind("127.0.0.1:7878") 是"把电话线插到这个号码上":127.0.0.1 是"本机"的地址,7878 是端口(门牌号)。
listener.incoming():"一直等客人"的循环——每来一位客人,就吐出一个 stream(和这位客人通话的话筒)。for stream in listener.incoming() 是接线员循环:接电话、挂电话、再接……
端口是什么?
端口是电脑上的"门牌号":一台电脑同时跑着好多程序(浏览器、游戏、服务器),7878 就是"我这个小网站的门"。127.0.0.1 是"本机"——只有你自己电脑上的程序能访问。选 7878 是 Rust 圈的惯例(TRPL 的服务器都住这)。
步骤三:听一听客人的话
接线员接了电话,得听客人说什么。用 TcpStream 读客人的话:
rust
use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").expect("绑定端口失败");
println!("小网站开张了!打开浏览器访问 http://127.0.0.1:7878/");
for stream in listener.incoming() {
let stream = stream.expect("连接失败");
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).expect("读取请求失败");
println!("请求内容:\n{}", String::from_utf8_lossy(&buffer));
}use std::io::prelude::*; 把"读写"的能力请进门(第 3 章 std::io 的家族)。buffer 是 1024 个字节的"记录本"(第 1 章数组),stream.read(&mut buffer) 把客人说的话记下来。String::from_utf8_lossy 把字节变成文字(第 3 章讲过汉字占字节)。
运行,访问,看终端:
运行结果
text
请求内容:
GET / HTTP/1.1
Host: 127.0.0.1:7878
User-Agent: Mozilla/5.0 ...
Accept: text/html,...
(……还有好多行……)这就是 HTTP 请求的真面目! 第一行最关键:
text
GET / HTTP/1.1GET:客人要"拿"东西(还有POST:"交"东西,以后学)/:要哪个页面(根页面)HTTP/1.1:用的哪版通话规矩
浏览器说的话,原来就是一行行的文字。 我们的服务器,接下来就要读懂这些文字。
步骤四:回答客人(第一版响应)
客人说话了,接线员该回话了。HTTP 响应的格式(暗号)是:
text
HTTP/1.1 200 OK\r\nContent-Length: 网页长度\r\n\r\n网页内容先做一个最简单的"固定回答"。准备网页文件——在项目文件夹里新建 hello.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>小螃蟹的小网站</title>
</head>
<body>
<h1>欢迎来到小螃蟹的小网站!</h1>
<p>这个页面,是我用 Rust 自己写的服务器送出来的!</p>
<p>恭喜你,完成了整本书的学习!</p>
</body>
</html>然后把 handle_connection 改成"读文件、回响应":
rust
use std::fs;
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).expect("读取请求失败");
let contents = fs::read_to_string("hello.html").expect("找不到网页文件");
let length = contents.len();
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).expect("发送响应失败");
stream.flush().expect("刷新失败");
}fs::read_to_string 是第 3 章的老朋友(读文件)。响应暗号拆开看:
HTTP/1.1 200 OK:状态行——200是"一切正常"的暗号,OK是它的含义Content-Length: {length}:告诉浏览器"网页有多长"(第 3 章学过,contents.len()数的是字节)\r\n\r\n:空一行,表示"暗号头到此为止,下面是正文"{contents}:网页正文
stream.write_all 把整个响应说给客人听,flush 是"把话说完,别憋着"(和 println! 的刷新一个道理)。
运行,刷新浏览器——看到页面了吗?!
运行结果
text
(浏览器里出现:)
欢迎来到小螃蟹的小网站!
这个页面,是我用 Rust 自己写的服务器送出来的!
恭喜你,完成了整本书的学习!你的第一个网站,开张了! 从这一刻起,浏览器里这个页面,是运行在你自己电脑上的程序送出来的。世界上第一个网站就是这么来的。
步骤五:路由:404 页面
现在访问 /hello、/abc……浏览器还是收到同一个页面——这不专业。加路由(routing):根据请求的路径,选不同的页面。
改 handle_connection:
rust
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).expect("读取请求失败");
let get = b"GET / HTTP/1.1\r\n";
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).expect("找不到网页文件");
let length = contents.len();
let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).expect("发送响应失败");
stream.flush().expect("刷新失败");
}两个新知识:
b"GET / HTTP/1.1\r\n":b"..." 是字节字符串——buffer 里存的是字节(第 3 章:电脑只认字节),所以判断请求是不是"要根页面",要用字节版本的文字来比。\r\n 是"回车换行"(HTTP 暗号的规矩)。
buffer.starts_with(get):buffer 的开头是不是 GET / HTTP/1.1\r\n?是 → 首页(200 OK);不是 → 404 页面。这就是最简单的路由:看请求的开头,决定送哪个页面。
再建 404.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>找不到页面</title>
</head>
<body>
<h1>哎呀,404!</h1>
<p>这个页面不存在哦。试试访问 / 吧!</p>
</body>
</html>重启程序,访问 http://127.0.0.1:7878/ 和 http://127.0.0.1:7878/abc:
运行结果
text
(访问 /)
欢迎来到小螃蟹的小网站!
(访问 /abc)
哎呀,404!
这个页面不存在哦。试试访问 / 吧!首页和 404,分家了。 200 OK 和 404 NOT FOUND 是状态码家族最出名的两兄弟——4xx 是"客人找错门"(404 是"没这个页面"),5xx 是"服务器自己病了"。
步骤六:多线程服务器
现在的服务器是单线程的:一次只能招呼一位客人,客人没走,下一位就等着。怎么演示这个毛病?加一个"慢路由" /sleep——故意让服务器睡 5 秒再回答:
rust
use std::thread;
use std::time::Duration;
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).expect("读取请求失败");
let get = b"GET / HTTP/1.1\r\n";
let sleep = b"GET /sleep HTTP/1.1\r\n";
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK", "hello.html")
} else if buffer.starts_with(sleep) {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).expect("找不到网页文件");
let length = contents.len();
let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).expect("发送响应失败");
stream.flush().expect("刷新失败");
}thread::sleep 是"睡 5 秒"(第 16 章小鸟的亲戚)。现在做个实验:开两个浏览器标签页,同时访问 /sleep——单线程下,两个标签页都要等 5 秒,但是轮流等的:第一个 5 秒完,第二个才开始数 5 秒,总共 10 秒!一个慢客人,拖住了所有人。
修法:第 15 章的绝招——每个客人一个线程。把 main 的循环改成:
rust
for stream in listener.incoming() {
let stream = stream.expect("连接失败");
thread::spawn(|| {
handle_connection(stream);
});
}现在每个客人都有自己的服务员,各聊各的。再做一次实验:两个标签页同时访问 /sleep,这次同时等 5 秒,一起回来(总共 5 秒,不是 10 秒)!
运行结果
text
(两个标签页同时访问 /sleep)
(几乎同时,5 秒后两个页面都出现了!)一个慢客人,不再拖累所有人。 这就是"多线程服务器":第 15 章的蚂蚁工队,今天正式上岗。
小贴士
thread::spawn 的闭包要 move 吗?这里不用——stream 是从 incoming() 里"长出来"的,本来就是这次循环独有的,闭包直接拿走就行(stream 的所有权进闭包,第 15 章讲过 move 是"把用到的搬进去",这里编译器自动处理)。
20.3 知识深挖
20.3.1 HTTP:浏览器和网站的对话规矩
HTTP(HyperText Transfer Protocol,超文本传输协议)是浏览器和服务器之间的"普通话"。它由请求和响应组成:
text
请求(客人说):
GET / HTTP/1.1
Host: 127.0.0.1:7878
(空行)
(请求没有正文)
响应(服务器回):
HTTP/1.1 200 OK
Content-Length: 123
(空行)
(网页正文)状态码(status code)是响应的"暗号",家族庞大,记住几个常客:
| 状态码 | 含义 | 什么时候见 |
|---|---|---|
| 200 | OK,一切正常 | 页面正常打开 |
| 302 | 请去别处 | 网页重定向 |
| 404 | 没这个页面 | 地址打错了 |
| 500 | 服务器内部出错 | 网站出 bug 了 |
记住 2xx 正常、4xx 客人错、5xx 服务器错——以后看网站报错,一眼就知道是谁的锅。
20.3.2 TcpListener:站在门口等客人
rust
let listener = TcpListener::bind("127.0.0.1:7878")?;
for stream in listener.incoming() {
// 一位客人来了
}bind:把电话线插到"本机 + 端口"。127.0.0.1是本机,7878是门牌号incoming():客人排队进来的"流水线",每一轮循环吐出一个stream(和客人的通话)stream:TcpStream,和一位客人的专属通话渠道——读(read)、写(write_all)、挂(drop)
服务器 = 接线员循环:等客人 → 接话 → 回话 → 挂电话 → 等下一个客人。这个循环,就是所有服务器的骨架。
20.3.3 单线程 vs 多线程
| 单线程服务器 | 多线程服务器 | |
|---|---|---|
| 客人多时 | 排队,一个慢全慢 | 各聊各的 |
| 代码 | 简单 | 多一个 spawn |
/sleep 实验 | 两个标签页 10 秒 | 5 秒 |
单线程的问题,在真实世界里更严重:一个客人下载大文件,全网站都卡住。所以真实服务器都是多线程(或异步,第 16 章的武功!)的。你从第 15 章带来的 spawn,今天变成了一个真正的网站。
20.3.4 真实世界的服务器
我们的小网站,和真实服务器差在哪?三件事:
- 线程池(thread pool):我们的服务器是"来一个客人雇一个服务员";真实服务器是"提前雇好 4 个服务员,客人轮流上"——避免频繁"雇人"的开销。实现方式:把
spawn换成"从池子里取一个工人",TRPL 原书这一章就是这么做的(用 channel + Mutex,第 15 章的两件宝!) - 优雅停机(graceful shutdown):按
Ctrl+C时,真实服务器会"等当前客人聊完再下班";我们的直接挂电话。TRPL 用Drop(第 14 章)给线程池实现了"礼貌下班" - 异步(第 16 章):现代服务器用
tokio的异步版本,一台机器扛几千个连接
想深入?去读 TRPL 原书的第 20 章(参考资料文件夹里就有中文版!),它会教你亲手实现完整的线程池和优雅停机。我们这本书毕业了,但 Rust 的路还长着呢——下一本书,可能就是"我的第一个大网站"。
20.3.5 全书毕业感言
从第 0 章的 Hello, world!,到这一章的"我的第一个小网站"——回头看,你走过的路:
| 阶段 | 学到的东西 |
|---|---|
| 第 0~2 章 | 变量、函数、控制流、match |
| 第 3~4 章 | 所有权、借用、结构体——Rust 的灵魂 |
| 第 5~8 章 | 枚举、模块、集合、错误处理 |
| 第 9~12 章 | 泛型、trait、测试、闭包迭代器、Cargo |
| 第 13 章 | 第一个完整的命令行工具 |
| 第 14~16 章 | 智能指针、线程、异步 |
| 第 17~19 章 | trait 对象、模式、unsafe、宏 |
| 第 20 章 | 自己写一个网站服务器! |
这些不是八个阶段,是同一套思维方式的八个侧面:怎么组织代码、怎么防错、怎么测试、怎么分享、怎么和安全规则共处。
小螃蟹的最后一句话:编程不是背语法,是动手做。 你已经用 20 个项目证明了自己。接下来做什么?去写你的下一个项目吧——可以回来复习,可以去读 TRPL 原书,可以试试 Rust 的网页框架……你的 Rust 之旅,从这一章开始,才刚刚启航。
再见,小程序员!🦀
20.4 动脑筋练习
练习一:关于页面 /about
给网站加一个新页面:访问 /about 返回 about.html(内容是"关于这个小网站"的介绍)。
提示:照抄 /sleep 的套路——加一个 about 判断(b"GET /about HTTP/1.1\r\n"),加一个 about.html 文件。
点开看答案
第一处,handle_connection 里加判断:
rust
let get = b"GET / HTTP/1.1\r\n";
let about = b"GET /about HTTP/1.1\r\n";
let sleep = b"GET /sleep HTTP/1.1\r\n";
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK", "hello.html")
} else if buffer.starts_with(about) {
("HTTP/1.1 200 OK", "about.html")
} else if buffer.starts_with(sleep) {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};第二处,新建 about.html(格式照抄 hello.html,内容写你自己)。
访问 http://127.0.0.1:7878/about,看到新页面;访问 /hello,还是 404。加一个页面 = 加一个判断 + 加一个文件——路由系统就是这么多。想让首页上有个"关于"的链接?在 hello.html 里加一行 <a href="/about">关于</a> 试试!
练习二:慢客人大挑战
做一次"并发实验",亲手验证多线程的威力:
- 在终端开两个窗口,或者打开两个浏览器标签页
- 同时访问
http://127.0.0.1:7878/sleep - 计时:两个页面是"一个等完另一个等"(10 秒),还是"一起等一起回"(5 秒)?
然后再做一次对比实验:把 main 里的 thread::spawn 那一行注释掉(变回单线程),重跑实验——这次是多少秒?
点开看答案
多线程版:两个标签页同时等 5 秒,几乎同时回来——总耗时约 5 秒。
单线程版(注释掉 spawn):第一个标签页等满 5 秒,第二个才开始数 5 秒——总耗时约 10 秒。
看终端里的输出顺序也能发现区别:多线程版两条"来了一位客人"几乎同时出现;单线程版是一条处理完,下一条才来。
同一个网站,一个 spawn,体验天差地别。 这就是第 15 章"小蚂蚁搬糖块"在真实世界的变身——你现在写的,已经是真实服务器的雏形了。
练习三:访问计数器
给网站加一个"访问计数器":每来一位客人,终端打印"第 N 次访问!"。
提示:需要一个全局共享、还能改的计数器——还记得第 19 章的 static mut 吗?它在线程里用有风险。标准库有个更妙的工具:AtomicUsize(原子计数器)——线程安全版的 static mut,fetch_add(1, Ordering::Relaxed) 是"原子地 +1"(Ordering::Relaxed 是"计数无所谓顺序,照抄即可")。
点开看答案
两处改动。第一处,文件顶部:
rust
use std::sync::atomic::{AtomicUsize, Ordering};
static VISIT_COUNT: AtomicUsize = AtomicUsize::new(0);第二处,handle_connection 开头:
rust
fn handle_connection(mut stream: TcpStream) {
let visit = VISIT_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
println!("第 {} 次访问!", visit);
let mut buffer = [0; 1024];运行,访问几次:
text
小网站开张了!打开浏览器访问 http://127.0.0.1:7878/
第 1 次访问!
第 2 次访问!AtomicUsize 是"不用 Mutex 的共享计数器":fetch_add 一步完成"读、加、写",天然不会打架(第 15 章讲的"两只蚂蚁同时加糖会数错"的问题,它从设计上就避免了)。真实服务器统计访问量,用的就是它。
20.5 完整代码清单
项目结构:
text
my_website/
├── Cargo.toml
├── hello.html
├── 404.html
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "my_website"
version = "0.1.0"
edition = "2024"文件:hello.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>小螃蟹的小网站</title>
</head>
<body>
<h1>欢迎来到小螃蟹的小网站!</h1>
<p>这个页面,是我用 Rust 自己写的服务器送出来的!</p>
<p>恭喜你,完成了整本书的学习!</p>
</body>
</html>文件:404.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>找不到页面</title>
</head>
<body>
<h1>哎呀,404!</h1>
<p>这个页面不存在哦。试试访问 / 吧!</p>
</body>
</html>文件:src/main.rs
rust
use std::fs;
use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::time::Duration;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").expect("绑定端口失败");
println!("小网站开张了!打开浏览器访问 http://127.0.0.1:7878/");
for stream in listener.incoming() {
let stream = stream.expect("连接失败");
thread::spawn(|| {
handle_connection(stream);
});
}
}
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).expect("读取请求失败");
let get = b"GET / HTTP/1.1\r\n";
let sleep = b"GET /sleep HTTP/1.1\r\n";
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK", "hello.html")
} else if buffer.starts_with(sleep) {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).expect("找不到网页文件");
let length = contents.len();
let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).expect("发送响应失败");
stream.flush().expect("刷新失败");
}怎么运行:
bash
cd my_website
cargo run然后打开浏览器,访问 http://127.0.0.1:7878/。
运行检查单:
浏览器访问 /,看到"欢迎来到小螃蟹的小网站"
浏览器访问 /abc(随便什么),看到 404 页面
访问 /sleep,页面 5 秒后才出现
两个标签页同时访问 /sleep,几乎同时出现(多线程!)
把 thread::spawn 注释掉,再试双标签页——10 秒(看完改回来!)
终端里能看到浏览器的 HTTP 请求(步骤三的打印,如果你保留了的话)