ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Rust标准库从零实现HTTP服务器:TCP解析、并发模型与排坑指南

用Rust标准库从零实现HTTP服务器:TCP解析、并发模型与排坑指南 简介这是一份面向计算机网络课程设计/期末大作业的基于 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读Vecu8。把请求行拆成三个字段常见做法是这样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; charsetutf-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。写响应时我习惯先把响应体放进Vecu8再用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 监听与 acceptTcpListener 的正确打开方式打开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; charsetutf-8, bh1minihttp/h1), (GET, /index.html) build_ok_response(text/html; charsetutf-8, bh1minihttp/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]) - Vecu8 { build_response(HTTP/1.1 200 OK, content_type, body) } fn build_404_response() - Vecu8 { build_response(HTTP/1.1 404 Not Found, text/plain; charsetutf-8, b404 Not Found) } fn build_response(status_line: str, content_type: str, body: [u8]) - Vecu8 { 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()在Vecu8上取的是字节数这正好满足Content-Length的定义。header.into_bytes()把头部字符串转成字节再extend_from_slice拼上响应体这样返回的是一个完整的Vecu8可以直接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 Boxdyn FnOnce() Send static; pub struct ThreadPool { workers: VecJoinHandle(), sender: OptionSenderJob, } 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 executeF(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加ArcMutexReceiver。多个 worker 线程共享同一个接收端每次recv()只有一个线程能拿到锁也就保证了每个任务只被一个线程执行。ThreadPool::new(4)里的 4 可以根据机器 CPU 数调整。课程设计里 4 或 8 都是常见值。注意如果size设为 0execute会把任务发送到一个永远没人处理的 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) - Vecu8 { // 只允许访问 static 目录下的文件防止路径穿越 let relative path.strip_prefix(/static/).unwrap_or(); if relative.contains(..) { return build_response(HTTP/1.1 403 Forbidden, text/plain; charsetutf-8, bforbidden); } 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; charsetutf-8, Some(css) text/css; charsetutf-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?namefoo 变成 /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; charsetutf-8, bh1minihttp/h1), (GET, /index.html) build_ok_response(text/html; charsetutf-8, bh1minihttp/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?v2时Rust 拿到的 path 是/static/a.css?v2不切掉问号后面的部分fs::read会去找一个文件名带?v2的文件结果必然 404。4.3 连接超时与 read 阻塞第一个并发伴生问题线程池解决了并发线程失控但handle_connection里read_line是阻塞的。如果一个客户端建立了 TCP 连接却一直不发数据一个 worker 线程就会一直停在read_line上线程池很快被占满。解决办法是给 TCP 流设置读超时。Rust 标准库提供了set_read_timeoutfn 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, bheader 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; charsetutf-8保证 404 页面也有基本样式。至少不要看起来像个坏掉的页面。5.4 中文乱码与 Content-Length 不一致——字节数和字符数混淆现象返回的中文页面在浏览器里显示乱码或者内容被截断。原因第一个原因是Content-Type没有带charsetutf-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]) - Vecu8 { 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; charsetutf-8, 你好.as_bytes())这样就从类型层面杜绝了字符数误算。字符串转Vecu8再拼接逻辑更稳。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还没算对。本文还有配套的精品资源点击获取
返回列表