20 最后的项目:构建多线程web server
本章我们将会练习一个项目,顺便复习一下前面几章的内容,这个项目会实现宇哥返回”Hello“的web server
如下是我们的计划:
1.学习一些TCP与HTTP知识
2.在套接字(socket)上监听TCP请求
3.解析少量的HTTP请求
4.创建一个合适的HTTP响应
5.通过线程池改善server的吞吐量
但是注意,我们本次实例并不是使用Rust构建web server最好的方法。crates.io上有很多可用于生产环境的crate。它们提供了比我们所要编写的更为完整的web server 和线程池的实现
所以本章的目的在于学习而不是实现,走捷径。因为Rust是一个系统编程语言,我们能够选择处理什么层次的抽象,,并能够选择比其它语言可能或可用的层次更低的层次,因此我们将自己编写一个基础的HTTP server和线程池,以便学习将来可能用到的crate背后的通用理念和技术
20.1 构建单线程web server
首先我们创建一个可以运行的单线程web server。在开始之前我们先来看几个相关的的协议
HTTP 超文本传输协议和传控制协议TCP 它们都是请求-响应协议,也就是说客户端初始化请求,服务端监听请求并向客户端提供响应。而请求与相应的内容由协议本身定义
TCP是一个底层协议,它描述了信息如如何从一个server到另一个细节,不过其并不指定信息是什么HTTP构建于TCP之上,它定义了请求和响应的内容。技术上,HTTP可以用于其他协议之上,但是对于绝大多数情况,HTTP通过TCP传输
我们将要做的就是处理TCP和HTTP请求与相应的原始字节数据
监听TCP连接
我们的web server 所要做的第一件事就是能够监听TCP连接,我们使用标准库提供的std::net模块处理这些功能
让我们一如既往的创建一个新项目
C:\Users\xxx>cargo new hello
Created binary (application) `hello` package
C:\Usersxxx>cd hello
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!");
}
}
上述代码会在地址127.0.0.1:7878上监听传入的TCP流,当获取到传入的流,它会打印出Connection established!
我们选择监听地址127.0.0.1:7878.冒号之前的部分是一个代表本机的IP地址(这个地址每个计算机都相同),而7878是端口。选择这个端口出于两个原因:通常HTTP接受这个端口而且7878在电话上打出来就是 rust
在这个场景中bind函数类似于new函数,在这里他返回一个新的TcpListener实例,这个函数叫做bind是因为,在网络领域,连接到监听端口被称为”绑定到一个端口“(bind to a port)
bind 函数返回一个Result<T,E>,这表明绑定可能会失败。如连接80端口需要管理员权限,非管理员只能监听大于1024的端口,另外,如果多个程序监听同一个端口,绑定也会失败,这里我们都用unwrap方法进行处理
TcpListener 的incoming 方法返回一个迭代器,它提供了一系列 TcpStream 类型的流,流是客户端和服务端之间打开的连接。连接代表客户端连接服务端、服务端生成响应以及服务端关闭连接的全部请求、响应过程。为此,TcpStream允许我们读取它来查看客户端发送了什么,可以编写响应。总而言之,这个for循环会依次处理每个连接并产生一系列的流供我们处理
当客户端连接到服务端时,incoming方法返回错误是可能的,因为我们实际上没有遍历连接,而是遍历连接尝试。很多原因可能会导致连接不成功,大部分操作是和系统的相关的。例如很多系统限制同时打开的连接数,新连接产生错误,直到一些打开的连接关闭为止
我们运行上述代码,终端中会出出现如下结果:
Finished dev [unoptimized + debuginfo] target(s) in 0.76s
Running `target\debug\hello.exe`
Connection established!
Connection established!
有时候我们会看到一次浏览器请求会打印出多条信息,这可能是因为浏览器在请求页面的同时还请求了其他资源,比如出现在浏览器tab标签中的favicon.ico
还有可能是浏览器尝试多次连接server,因为server没有响应任何数据。当stream在循环结尾离开作用域并被丢弃,其连接将会被关闭,作为drop实现的一部分,浏览器有时候通过重连来处理关闭的连接,因为 这些问题可能是暂时的
读取请求
我们使用新的函数来处理stream
use std::io::prelude::*;
use std::net::TcpStream;
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();
handle_connection(stream);
}
}
fn handle_connection(mut stream:TcpStream) {
let mut buffer = [0;1024];
stream.read(&mut buffer).unwrap();
println!("Request:{}",String::from_utf8_lossy(&buffer[..]));
}
我先在这个函数中定义了一个一个1024字节的缓冲区。并且把stream读到了缓存中,stream参数是可变的,所以我们使用了mut,String::from_utf8_lossy 函数会获取一个&[u8]并生产出一个String。函数名lossy部分来源于当其遇到无效的UTF-8序列时的行为:它使用菱形里面一个问好U+FFFD REPLACEMENT CHARCTER,来代替无效序列。我们可能会在缓冲区的剩余部分看到这些替代字符,因为他们没有被请求数据填满
运行上述代码,看起来可能会像如下
Running `target\debug\hello.exe`
Request:GET / HTTP/1.1
Host: 127.0.0.1:7878
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/96.0.4664.93 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8
仔细观察HTTP请求
HTTP是一个基于文本的协议,同时一个请求如下格式
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
第一行叫做请求行,它存放了客户端请求了什么信息,请求行的第一部分是所使用的method,比如GET或POST,这描述了客户端如何进行请求,这里当然是使用了GET请求
请求接下来的部分是/,它代表客户端请求的统一资源标识符号,URI 大体上类似,但也不完全类似于URL(统一资源定位符)URI和URL之间的区别对于本章的目的来说并不重要,不过HTTP规范使用术语URI,所以这里可以简单的将URL理解为URI
最后一部分是客户端使用的HTTP版本,然后请求以CRLF序列(CRLF代表回车和换行,这是打字机时代的术语)结束。CRLF序列也可以写成\r\n,其中\r 是回车符,\n是换行符,CRLF序列将请求与其余请求数据分开。注意,打印CRLF时,我们会看到一个新的行,而不是\r\n
从Host:开始的其余行是headers;GET请求没有body
好了,我们现在知道了浏览器请求什么了,让我们返回一些数据
编写响应
我们将实现在客户端请求的响应中发送数据的功能,响应有以下格式
HTTP—Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
第一行叫做状态行,包含响应的HTTP版本、一个数字状态码用以总结请求的结果和一个描述之前状态码的文本原因短语。CRLF序列之后是任意header,另一个CRLF序列,和相应的body
这里是一个使用HTTP 1.1 版本的响应例子,其状态码为200,原因短语为OK,没有header,也没有body
HTTP/1.1 200 OK\r\n\r\n
状态码200是一个标准的成功响应。这些文本是一个微型的成功HTTP响应。让我们将这些文本写入流作为成功请求的响应
fn handle_connection(mut stream:TcpStream) {
let mut buffer = [0;1024];
stream.read(&mut buffer).unwrap();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap()
}
我们来看看我们更新的部分,我们定义了一个变量存放将要返回的成功响应的数据。接着,在response上调用as_bytes,因为stream 的 write方法获取一个&[u8] 并直接将这些字节发送给连接
因为write可能会操作失败,这里我们也用unwrap处理。最后flush会等待并阻塞程序执行直到所有字节都被写入连接中,TcpStream包含一个内部缓冲去来最小化对底层操作系统的调用
再来运行一下上述代码,之前的错误页面会变成空页面
返回真正的HTML
我们在项目根目录创建一个如下文件:hello.html
<!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>
我们在函数中修改,让请求时返回它
use std::fs;
fn handle_connection(mut stream:TcpStream) {
let mut buffer = [0;1024];
let contents = fs::read_to_string("hello.html").unwrap();
stream.read(&mut buffer).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\n\r\nContent-Length: {}\r\n\r\n{}",
contents.len(),
contents
);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap()
}
在开头增加一行来将标准库中的File引入作用域,然后我们新建一个变量,保存从hello.html文件中读取到j的内容
接下来,使用format!将文件内容加入到将要写入流的成功响应的body中
写好了函数,让我们运行一下,如下是网页显示的内容
Content-Length: 213
<!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>
目前我们忽略了buffer中的请求数据并无条件的发送了HTML文件的内容,这意味着如果尝试在浏览器中请求127.0.0.1:7878/something-else也会得到同样的HTML响应,这其实不是我们的初衷,我们希望检查请求并只对格式良好的请求/发送HTML文件
验证请求并有选择的进行响应
现在让我们增加在返回HTML文件前检查浏览器是否请求/,并在其请求任何其它内容时返回错误的功能。我们来修改一下代码
fn handle_connection(mut stream:TcpStream) {
let mut buffer = [0;1024];
let get = b"GET / HTTP/1.1\r\n";
if buffer.starts_with(get){
let contents = fs::read_to_string("hello.html").unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\n\r\nContent-Length: {}\r\n\r\n{}",
contents.len(),
contents
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap()
);
}else {
//其他请求
}
}
我们将/请求相关的数据硬编码进变量get。因为我们将原始字节读取进了缓冲区,所以在get 的数据开头增加b”“字节字符串语法将其转换为字节字符串,接着检查buffer是否以get中的字节开头,如果是,这就是一个格式良好的 / 请求,也就是if中处理成功的情况,返回html文件
如果buffer不以get中的字节开头,就说明接收的是其他请求。我们会在else块中增加相应的代码响应它
我们运行一下,正常
现在我们在else块中增加代码,告知请求内容没找到
else {
let status_line = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
let contents = fs::read_to_string("404.html").unwrap();
let response = format!("{}\r\nContent-Length: {}\r\n\r\n", status_line,contents);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
现在,运行代码,请求127.0.0.1:7878 会返回hello.html的内容,而对于任何其它请求,比如127.0.0.1:7878/foo,应该会返回404.html中的错误HTML
少量代码重构
现在if和else块中的代码有很多重复,唯一的区别是状态行和文件名,我们使用两个变量重构下代码
fn handle_connection(mut stream:TcpStream) {
let mut buffer = [0;1024];
stream.read(&mut buffer).unwrap();
let get = b"GET / HTTP/1.1\r\n";
let (status_line,filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK\r\n\r\n","hello.html")
}else {
("HTTP/1.1 404 NOT FOUND\r\n\r\n","404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let response = format!("{}\r\nContent-Length: {}\r\n\r\n", status_line,contents);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
这个程仍然可以按照我么期望的运行
目前的server运行于单线程中,它一次只能处理一个请求,下一节我们会修改它为多线程server