简介:这是一份面向计算机网络课程设计/期末大作业的基于 Rust 的 Web 服务器实现项目,内含完整源码与配套文档说明,适合计算机相关专业学生用于课程设计或毕业设计参考。项目实现了 HTTP 请求解析、参数配置、异常处理、缓存管理、响应构造等核心模块,src 目录下 main.rs、request.rs、response.rs、cache.rs、config.rs、exception.rs、util.rs 等文件分工清晰,从 TCP 连接监听到请求行/报文头解析、静态资源返回、错误页面生成形成完整链路,配有 log4rs.yaml 日志配置和 README 说明;通过 Cargo.toml 可快速构建运行,部署门槛低,代码注释完善,新手也能看懂。压缩包共 34 个文件,整体约 523KB,其中 Rust 源码 8 个、JavaScript 6 个、HTML 3 个、CSS 2 个,还有 TOML/YAML/XML/TXT/MD 等配置和文档;JS 与 HTML/CSS 负责前端演示页面,TOML/YAML 用于工程配置与日志设定,README 介绍启动方式与项目结构,前后端结构齐全。该资源已在 CSDN 获得 263 人学习/下载,适合作为期末大作业或课程设计的高分模板;直接下载后简单部署即可使用,也可根据注释和文档快速二次开发,具有较高的实际应用价值。
1. 基于 Rust 的 Web 服务器到底解决什么问题:一份计算机网络课程设计的完整交付思路
如果你是正在做计算机网络课程设计的学生,大概率会被要求实现一个 Web 服务器,而不是只写一份“HTTP 协议概述”交差。这个项目标题把范围定得很清楚:用 Rust 写一个 Web 服务器,还要带上项目源码和文档说明。很多人的第一反应是去抄一个 Python Flask 或 Node.js 的十几行示例,但那只能说明你会调框架,不能说明你看懂了 TCP 连接、HTTP 报文和并发模型。换成 Rust 标准库从零写,反而能把网络分层、请求解析、响应构造这些基本功全部暴露出来,答辩时也更有东西讲。这篇文章我会按“先立理论、再给最小实现、然后并发增强、最后排坑验证”的顺序,把一条能直接复现的路径完整讲完。
2. 动手前先立住模型:HTTP/1.1 处理一个请求要过哪几关
写服务器前,先别急着敲代码。HTTP 服务器本质上是一个 TCP 服务端:浏览器发起 TCP 连接,发送一段符合 HTTP 报文格式的字节流,服务器读取、解析、按业务逻辑生成响应,再通过同一条 TCP 连接写回去。这一来一回里,真正决定成败的是报文边界、换行符和字节长度,不是业务逻辑。
2.1 请求报文到 Rust 结构体:从 TCP 字节流到 Method、Path、Version
浏览器发来的原始数据是字节流,不是结构体。一个最常见的 GET 请求在 TCP 层看起来是这样的:
GET /index.html HTTP/1.1\r\n Host: 127.0.0.1:8080\r\n User-Agent: Mozilla/5.0\r\n \r\n注意每一行结尾都是 CRLF,也就是\r\n,不是单个\n。请求行由空格分隔成三部分:方法、路径、HTTP 版本。后续是若干头部,最后有一个空行,空行之后才是请求体,GET 请求一般没有请求体。
在 Rust 里,我会用BufReader包住TcpStream,然后逐行读取。因为BufRead::read_line会读到行尾并把\n也包含进来,所以解析前要先trim_end_matches('\n')或trim()。这里有一个很容易忽略的问题:read_line把数据读到String,如果字节流不是合法 UTF-8,会直接报错。HTTP 请求行和头部基本是 ASCII,所以课程设计阶段用String足够;如果以后要处理二进制上传,就得改用read_until读Vec<u8>。
把请求行拆成三个字段,常见做法是这样:
fn parse_request_line(line: &str) -> (String, String, String) { let mut parts = line.split_whitespace(); let method = parts.next().unwrap_or("").to_string(); let path = parts.next().unwrap_or("").to_string(); let version = parts.next().unwrap_or("").to_string(); (method, path, version) }split_whitespace的好处是不用手动处理\r,因为它会把回车当作空白过滤掉。解析结果分别对应 HTTP 报文里三个最关键的信息:用什么方法、请求哪个资源、用的哪个协议版本。
2.2 响应状态行与头部:最容易丢的 CRLF 和 Content-Length
服务器写回给浏览器的响应也一样分三块:状态行、头部字段、空行和响应体。一个最小响应长这样:
HTTP/1.1 200 OK\r\n Content-Type: text/html; charset=utf-8\r\n Content-Length: 13\r\n Connection: close\r\n \r\n Hello, world!状态行里的200 OK表示成功,404 Not Found表示资源不存在。头部之后必须有一个空行,这个空行其实就是一个单独的\r\n,很多第一次写的人会漏掉它,结果浏览器把响应体也当成头部去解析,表现就是白屏或乱码。
Content-Length是另一个重灾区。它的值是响应体的字节长度,不是字符串长度,更不是页面里中文字符的个数。Hello, world!是 13 字节,如果改成“你好”这两个字,它在 UTF-8 下是 6 字节,而不是 2。写响应时我习惯先把响应体放进Vec<u8>,再用body.len()填Content-Length,从源头避免算错。
2.3 为什么是 std::net 而不是 axum:课程设计的选型边界
很多熟悉 Rust 的人会问:直接上 axum 或 hyper 不香吗?香,但那是另一个目标。课程设计的验收点通常是“你是否理解 HTTP 协议和网络编程”,而不是“你会不会用框架”。框架把 TCP 监听、请求解析、路由分发全部封装掉了,你只需要写一个处理函数,结果是概念全黑盒,答辩时一问就露馅。
用标准库写,你会被迫亲手处理这些事:
| 关注点 | std::net 方案 | axum/hyper 方案 |
|---|---|---|
| TCP 连接 | 自己 accept、自己读字节 | 框架已封装 |
| HTTP 解析 | 自己切请求行和头部 | 框架自动完成 |
| 并发模型 | 自己选线程、线程池 | 异步运行时托管 |
| 响应构造 | 自己拼状态行和头部 | 框架提供响应类型 |
| 学习价值 | 高,能看清协议 | 高,但偏工程化 |
如果你以后要做生产级 Web 服务,Axum 确实是更合理的路线;但课程设计这个场景,我的建议是先用标准库把协议走通,交完作业再拿 axum 对比,反而理解更深。
3. 最小可运行版本:用 Rust 标准库把 GET 请求跑通
理论再多,不如一个能跑的最小版本。这一章的目标不是一次到位,而是先让浏览器访问http://127.0.0.1:8080/能看到一段固定文本。我会用单线程处理请求,先把链路打通,下一章再升级为线程池。
3.1 项目结构与 Cargo.toml:先建空依赖,再写 main
先创建一个 Cargo 项目。目录名可以叫minihttp,内部结构不需要复杂,课程设计阶段一个src/main.rs就够。
cargo new minihttp cd minihttp这是Cargo.toml:
[package] name = "minihttp" version = "0.1.0" edition = "2021" [dependencies]依赖区是空的。这意味着后面不管遇到并发还是文件读取,都只用 Rust 标准库。这个设计写在文档说明里是一个很好的加分项,因为它能证明你没有靠第三方库掩盖协议细节。
3.2 监听与 accept:TcpListener 的正确打开方式
打开src/main.rs,先写最核心的监听逻辑:
use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::thread; fn main() { // 监听本机 8080 端口,注意端口小于 1024 时在 Linux 上需要 root 权限 let listener = TcpListener::bind("127.0.0.1:8080").expect("bind failed"); println!("server running at http://127.0.0.1:8080"); // incoming() 返回一个迭代器,每次 accept 到一个新的 TCP 连接 for stream in listener.incoming() { match stream { Ok(stream) => { // 先直接用线程处理连接,下一章会换成线程池 thread::spawn(|| { handle_connection(stream); }); } Err(e) => eprintln!("accept error: {}", e), } } } fn handle_connection(mut stream: TcpStream) { let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); // read_line 读到换行符为止;返回 0 表示对端关闭连接 let bytes_read = reader.read_line(&mut request_line).unwrap(); if bytes_read == 0 { return; } println!("request: {}", request_line.trim_end()); // 响应体是 12 字节,和下面的 Content-Length 严格对应 let body = "Hello, HTTP!"; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); }代码里的bind("127.0.0.1:8080")是唯一的入口配置。127.0.0.1表示只允许本机访问,如果想在局域网里用手机或另一台电脑测试,要改成0.0.0.0:8080,同时注意防火墙放行。
read_line返回的是读到的字节数,不是字符串长度。如果浏览器建立了连接但一直不发数据,这里会阻塞;如果连接被关闭或超时,返回Ok(0)。这个判断很重要,否则空连接会让线程空转。
运行:
cargo run然后打开浏览器访问http://127.0.0.1:8080/。看到Hello, HTTP!就说明最小链路已经通了。这个版本每来一个连接就thread::spawn一个新线程,理论上能应付几个并发请求,但线程数量没有上限,连接多时系统会扛不住,这就是下一章要解决的问题。
3.3 解析请求行:从字符串切割到 match 分发
在 3.2 里我们没有解析请求内容,无论访问什么路径都返回 200。一个老师只要顺手访问一个不存在的路径,就能看出服务器没有真正处理请求。所以下一步是把请求行拆开。
把handle_connection里的request_line交给一个解析函数,然后根据方法和路径分发。
fn handle_connection(mut stream: TcpStream) { let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); if reader.read_line(&mut request_line).unwrap() == 0 { return; } let (method, path, version) = parse_request_line(&request_line); println!("{} {} {}", method, path, version); let response = match (method.as_str(), path.as_str()) { ("GET", "/") => build_ok_response("text/html; charset=utf-8", b"<h1>minihttp</h1>"), ("GET", "/index.html") => build_ok_response("text/html; charset=utf-8", b"<h1>minihttp</h1>"), _ => build_404_response(), }; stream.write_all(&response).unwrap(); stream.flush().unwrap(); }这里的match是对元组做模式匹配,method和path同时参与分支判断。比如("GET", "/")精确匹配 GET 方法加根路径,_兜底返回 404。这个模式非常直观,后面加路由只需要继续加分支。
3.4 返回静态页面与 404:写一个能交差的响应函数
现在把响应构造统一封装一下。为什么要封装?因为Content-Length、CRLF 这些细节如果散落在每个分支里,改一处漏多处。
fn build_ok_response(content_type: &str, body: &[u8]) -> Vec<u8> { build_response("HTTP/1.1 200 OK", content_type, body) } fn build_404_response() -> Vec<u8> { build_response("HTTP/1.1 404 Not Found", "text/plain; charset=utf-8", b"404 Not Found") } fn build_response(status_line: &str, content_type: &str, body: &[u8]) -> Vec<u8> { let header = format!( "{}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", status_line, content_type, body.len() ); let mut result = header.into_bytes(); result.extend_from_slice(body); result }body.len()在Vec<u8>上取的是字节数,这正好满足Content-Length的定义。header.into_bytes()把头部字符串转成字节,再extend_from_slice拼上响应体,这样返回的是一个完整的Vec<u8>,可以直接write_all写回 TCP 流。
到这一步,服务器的基本形态已经出来了:能监听端口、能解析请求行、能按路径返回不同内容、能返回 404。这个版本已经可以作为课程设计的初稿提交,但我建议继续做.
4. 从能跑到能并发:线程池、路由、静态文件与安全项
课程设计要拿高分,单线程服务器肯定不够。老师可能会用压测工具同时开几十个连接,或者反复刷新页面制造并发请求。这一章会把服务器升级成线程池模型,并补上静态文件服务和几个安全边界。
4.1 线程池实现:不引外部 crate 的 worker 队列
一种最简单的并发方案是每连接一线程,但缺点很明显:线程创建销毁有开销,且数量不可控。线程池的思路是启动时一次性创建固定数量线程,每个线程循环从任务队列里取任务执行。
用标准库的mpsc和互斥锁就能实现一个够用的线程池:
use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; // Job 是一个可执行的闭包,发送到队列后由 worker 取走 type Job = Box<dyn FnOnce() + Send + 'static>; pub struct ThreadPool { workers: Vec<JoinHandle<()>>, sender: Option<Sender<Job>>, } impl ThreadPool { // size 是 worker 线程数量,一般设置为 CPU 核心数或略小于它 pub fn new(size: usize) -> ThreadPool { let (sender, receiver) = channel(); let receiver = Arc::new(Mutex::new(receiver)); let mut workers = Vec::with_capacity(size); for _ in 0..size { let receiver = Arc::clone(&receiver); let handle = thread::spawn(move || loop { // 从 channel 里取任务;sender 被 drop 后 recv 会返回 Err,从而退出循环 let message = receiver.lock().unwrap().recv(); match message { Ok(job) => job(), Err(_) => break, } }); workers.push(handle); } ThreadPool { workers, sender: Some(sender), } } pub fn execute<F>(&self, f: F) where F: FnOnce() + Send + 'static, { if let Some(sender) = &self.sender { // send 可能失败,因为所有 worker 都已退出 let _ = sender.send(Box::new(f)); } } } impl Drop for ThreadPool { fn drop(&mut self) { // 关闭 sender,通知所有 worker 退出 drop(self.sender.take()); for worker in self.workers.drain(..) { worker.join().unwrap(); } } }这个实现核心是mpsc::channel加Arc<Mutex<Receiver>>。多个 worker 线程共享同一个接收端,每次recv()只有一个线程能拿到锁,也就保证了每个任务只被一个线程执行。
ThreadPool::new(4)里的 4 可以根据机器 CPU 数调整。课程设计里 4 或 8 都是常见值。注意如果size设为 0,execute会把任务发送到一个永远没人处理的 channel,请求全部卡死,所以new函数里最好加一个assert!(size > 0)。
在 main 里把原来的thread::spawn替换成线程池:
fn main() { let listener = TcpListener::bind("127.0.0.1:8080").expect("bind failed"); let pool = ThreadPool::new(4); for stream in listener.incoming() { match stream { Ok(stream) => pool.execute(|| handle_connection(stream)), Err(e) => eprintln!("accept error: {}", e), } } }4.2 路由表与静态文件:把 /、/index.html、/api/hello 分开处理
现在的路由还只是字符串相等判断。一个实用的课程设计服务器至少要支持三种情况:返回首页、返回 API 数据、返回静态文件。静态文件是指 CSS、JS、图片这些直接存磁盘上的资源。
先定义一个统一的静态文件处理函数:
use std::path::Path; fn handle_static_file(path: &str) -> Vec<u8> { // 只允许访问 static 目录下的文件,防止路径穿越 let relative = path.strip_prefix("/static/").unwrap_or(""); if relative.contains("..") { return build_response("HTTP/1.1 403 Forbidden", "text/plain; charset=utf-8", b"forbidden"); } let full_path = Path::new("static").join(relative); match std::fs::read(&full_path) { Ok(data) => { let content_type = match full_path.extension().and_then(|e| e.to_str()) { Some("html") => "text/html; charset=utf-8", Some("css") => "text/css; charset=utf-8", Some("js") => "application/javascript", Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", _ => "application/octet-stream", }; build_response("HTTP/1.1 200 OK", content_type, &data) } Err(_) => build_404_response(), } }这里用strip_prefix("/static/")去掉前缀,得到相对路径。relative.contains("..")是一个简单粗暴但有效的防护:只要路径里包含..就拒绝,能挡掉../Cargo.toml这类穿越尝试。
然后在路由里补上静态文件分支:
let (method, raw_path, _version) = parse_request_line(&request_line); // 去掉查询参数,比如 /index.html?name=foo 变成 /index.html let path = raw_path.split('?').next().unwrap_or(raw_path); let response = match (method.as_str(), path) { ("GET", "/") => build_ok_response("text/html; charset=utf-8", b"<h1>minihttp</h1>"), ("GET", "/index.html") => build_ok_response("text/html; charset=utf-8", b"<h1>minihttp</h1>"), ("GET", "/api/hello") => { build_ok_response("application/json", br#"{"message":"hello"}"#) } ("GET", p) if p.starts_with("/static/") => handle_static_file(p), _ => build_404_response(), };split('?')这一步很容易漏。浏览器请求http://127.0.0.1:8080/static/a.css?v=2时,Rust 拿到的 path 是/static/a.css?v=2,不切掉问号后面的部分,fs::read会去找一个文件名带?v=2的文件,结果必然 404。
4.3 连接超时与 read 阻塞:第一个并发伴生问题
线程池解决了并发线程失控,但handle_connection里read_line是阻塞的。如果一个客户端建立了 TCP 连接却一直不发数据,一个 worker 线程就会一直停在read_line上,线程池很快被占满。
解决办法是给 TCP 流设置读超时。Rust 标准库提供了set_read_timeout:
fn handle_connection(mut stream: TcpStream) { // 读超时设成 5 秒;超过时间没有数据到达,read_line 返回 WouldBlock 错误 let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); match reader.read_line(&mut request_line) { Ok(0) | Err(_) => return, Ok(_) => {} } // ... 后续处理 }注意set_read_timeout返回io::Result,超时后read_line返回Err(std::io::ErrorKind::WouldBlock),不是返回 0。如果直接unwrap会导致线程 panic。这里用match把错误当成连接结束处理,是更稳妥的写法。
5秒这个值要说明一下:超时太短会让慢客户端请求读不完,太长又会浪费线程资源。本地课程设计场景 5 秒完全够用。
4.4 给课程设计加分的安全项:限制请求行长度、禁止目录穿越
到这里服务器功能已经完整,但安全检查还能再加两个亮点。第一个是限制请求行长度:
// 在 handle_connection 开头 let mut request_line = String::new(); reader.read_line(&mut request_line).unwrap(); if request_line.len() > 8192 { let response = build_response("HTTP/1.1 431 Request Header Fields Too Large", "text/plain", b"header too long"); stream.write_all(&response).unwrap(); return; }HTTP 协议对请求行长度没有硬性规定,但生产服务器都会设置上限,防止恶意客户端用超长请求行消耗内存。8192字节是一个常用值。
第二个是目录穿越的补丁。前面用relative.contains("..")能挡掉明文..,但攻击者可能用 URL 编码绕过,比如%2e%2e/。严格的处理是先做 URL 解码,再检查路径。课程设计里可以在handle_static_file里加一个最小解码:
// 简单替换最常见的 URL 编码,只用于课程设计演示 fn url_decode(input: &str) -> String { input.replace("%2e", ".").replace("%2E", ".") }然后对解码后的路径再做contains("..")检查。这个细节即使不完整实现,写在文档说明的“已知不足”里,也比不提要好,因为老师看到你能主动说明安全边界,印象分会明显不一样。
5. 报文解析与服务部署避坑:现象、原因、解决
这个项目看起来简单,真正跑起来后踩坑点不少。我把最常见的问题按“现象、原因、解决”整理出来,每一类都是我实际遇到过或辅导学生时见过的。建议你写文档说明时也按这个结构记录,答辩时很有说服力。
5.1 浏览器一直转圈,响应永远不到——Connection: close 缺失
现象:先用 curl 测试一切正常,但浏览器访问时一直转圈,直到超时。
原因:HTTP/1.1 默认是 Keep-Alive,浏览器发完请求后不主动关闭 TCP 连接,继续等待服务器下一个响应。早期代码里Content-Length没写对,浏览器无法判断响应体何时结束,就一直在等。更常见的是头部没有空行,导致响应被当成头部的一部分。
解决:第一保证头部最后有\r\n\r\n;第二在响应里显式加上Connection: close,告诉浏览器响应结束后关闭连接。课程设计阶段不要做 Keep-Alive 长连接,主动关闭是最省事的策略。如果还想保持连接重用,需要正确解析头部里的Connection: keep-alive字段,并精确管理Content-Length,那是另一个进阶题目。
5.2 favicon.ico 请求导致 404 刷屏,日志被污染
现象:服务器终端不断打印GET /favicon.ico HTTP/1.1 404 Not Found,把真实请求日志淹没。
原因:浏览器打开页面后会自动请求网站图标/favicon.ico,这个不是用户手动访问造成的属于正常行为。服务器没提供图标文件,自然只能返回 404。
解决:在static目录放一个favicon.ico,或者在路由表里专门匹配("/GET", "/favicon.ico")返回空内容:
("GET", "/favicon.ico") => build_response("HTTP/1.1 204 No Content", "image/x-icon", b""),204 No Content告诉浏览器“请求已处理但没有内容”,比 404 更优雅。要是懒得放图标,用这段代码能有效减少日志噪音。
5.3 curl 能通,浏览器打不开——HEAD 方法与 Keep-Alive
现象:用curl http://127.0.0.1:8080/能返回完整 HTML,但 Chrome 访问时页面空白或报错。打开开发者工具发现浏览器发的是GET但响应解析异常,甚至能看到部分浏览器会先发一个HEAD请求探测。
原因:有一部分 HTTP 客户端或监控脚本会通过HEAD方法检查资源是否存在。服务器路由匹配里如果只写了("GET", "/"),遇到HEAD会走_分支返回 404,某些浏览器对 404 页面的样式缺失表现成空白。
解决:在路由分发里显式处理HEAD方法。HEAD的语义是“返回与 GET 相同的头部,但不返回响应体”。最简单的方式是把 GET 分支的结果先构造出来,然后只写头部不写 body。示例:
("HEAD", p) => { let get_response = route_get(p); // 把响应体去掉,但保留 Content-Length build_head_response(&get_response) }这个实现稍复杂,因为要把Content-Length保留为 GET 时的值。如果课程设计时间紧,退一步的做法是在build_404_response里设置Content-Type: text/html; charset=utf-8,保证 404 页面也有基本样式。至少不要看起来像个坏掉的页面。
5.4 中文乱码与 Content-Length 不一致——字节数和字符数混淆
现象:返回的中文页面在浏览器里显示乱码,或者内容被截断。
原因:第一个原因是Content-Type没有带charset=utf-8,浏览器用默认编码解析,中文就乱了。第二个原因更隐蔽:如果响应体是String,用body.len()在 UTF-8 下返回的是字节数,不是字符数,所以这里必须用字节长度。如果响应体是&str,错误常见于直接写body.chars().count(),那就把字符数当成了长度。
解决:build_response里统一接收&[u8],所有地方都走.len():
fn build_response(status_line: &str, content_type: &str, body: &[u8]) -> Vec<u8> { let header = format!( "{}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", status_line, content_type, body.len() ); let mut result = header.into_bytes(); result.extend_from_slice(body); result }调用时把字符串转成字节:
build_ok_response("text/html; charset=utf-8", "你好".as_bytes())这样就从类型层面杜绝了字符数误算。字符串转Vec<u8>再拼接,逻辑更稳。
5.5 Windows 下端口被占用或防火墙弹窗,bind 失败
现象:cargo run报address in use,或者浏览器访问时连接被拒绝;Windows 第一次运行还弹防火墙授权窗口。
原因:127.0.0.1:8080已被其他程序占用,常见的是之前运行的服务器没有退出,或者别的大程序占了 8080。防火墙弹窗是 Windows 对监听端口的安全提示,不点允许的话外部设备无法访问,本机访问一般不受影响。
解决:先用命令查端口占用。Windows 下:
netstat -ano | findstr 8080看到占用进程 PID 后在任务管理器里结束对应进程。如果只是端口冲突,也可以用TcpListener::bind的返回错误信息判断。常见做法是把端口设为从命令行读:
let port = std::env::args().nth(1).unwrap_or_else(|| "8080".to_string()); let addr = format!("127.0.0.1:{}", port); let listener = TcpListener::bind(&addr).expect("bind failed");这样换端口不用改代码。注意TcpListener::bind接受ToSocketAddrs,传&str也可以,但错误信息可能不够直观,使用显式格式化更清楚。
6. 交付前最后一步:用 curl、wrk 和 Wireshark 验证你的服务器
代码写完,文档写完,不要急着打包。先用工具把服务器真正验一遍,顺便截图放进课程设计报告,这是最直接可行的验证方法论。
6.1 最小验收清单:curl -v 看什么
curl -v http://127.0.0.1:8080/index.html重点看输出里的> GET /index.html HTTP/1.1、< HTTP/1.1 200 OK和Content-Length。如果这三处正确,说明最基本的请求响应链路没毛病。再试一个不存在的路径:
curl -v http://127.0.0.1:8080/nope确认返回 404,并且响应体是正常文本。
6.2 压测读自己:wrk 结果怎么解释
wrk -t2 -c10 -d5s http://127.0.0.1:8080/-t2表示两个线程,-c10表示保持 10 个并发连接,-d5s表示持续 5 秒。看Requests/sec这个指标,能直观感受线程池和单线程的差距。如果Latency出现大量connect timeout,多半是线程池太小或请求行读取太慢。
6.3 Wireshark 过滤表达式与抓包要点
抓包时过滤tcp.port == 8080。先用curl发一个请求,再点开一个 TCP 流,你能看到三次握手、HTTP 请求报文、响应报文。把这张截图放进文档说明,比任何文字描述都有说服力。
6.4 最后一个小技巧:把访问日志写进文件
与其在终端println!,不如每次请求往access.log追加一行:
fn log_request(remote: &str, method: &str, path: &str, status: &str) { use std::io::Write; let line = format!("{} {} {} {}\n", remote, method, path, status); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open("access.log") .unwrap(); let _ = file.write_all(line.as_bytes()); }我第一次做完这个项目后最大的教训,就是日志和压测放在最后才补。结果答辩演示时终端刷满了 favicon 请求,临时去改代码,现场一团糟。要是早一点把日志写进文件,并把压测和抓包截图放进文档说明,演示会从容很多。希望这些排坑经验能帮到你,至少别在交付前一晚才想起Content-Length还没算对。
本文还有配套的精品资源,点击获取