快速入门¶
URL¶
当你用浏览器浏览网站的时候,有没有思考过,网站是如何把内容传输到你的浏览器,然后显示在你的浏览器上的?这里涉及到很多的知识点,你将在这里学习到基础内容,并在将来的课程中深入学习。
首先讲讲 URL(Uniform Resource Locator)的概念。实际上,经常说的网址,其实就是 URL,例如常见的:
- www.baidu.com
- https://www.baidu.com
- http://info.tsinghua.edu.cn
- https://learn.tsinghua.edu.cn
- https://learn.tsinghua.edu.cn/f/wlxt/index/course/student/
- http://zhjwxk.cic.tsinghua.edu.cn/xklogin.do
- https://www.baidu.com/s?wd=%E5%A6%82%E4%BD%95%E5%AD%A6%E4%B9%A0+Rust+%E8%AF%AD%E8%A8%80
- https://www.google.com/search?q=how+to+learn+rust
- https://www.tsinghua.edu.cn/jxjywj/bkzy2023/zxzy/29-1.pdf
- http://localhost:8080/oj/submit?problem=1234&contest=5678
- https://git.tsinghua.edu.cn/rust-course/rust-docs/-/tree/master/docs#%E5%9F%BA%E6%9C%AC%E4%BF%A1%E6%81%AF
上面的地址中,除了 www.baidu.com
省略了开头的 http/https 协议以外,所有的 URL 都满足下面的格式:
scheme ":" "//" host [":" port] path ["?" query] ["#" fragment]
其中用中括号([
]
)括起来的意思是这一部分可选。每一部分的含义如下:
- scheme: 常见的有 http 和 https,代表了要用什么协议来访问这个 URL
- host:主机名称,可以是域名,也可以是 IP 地址,可以通过主机名称找到 Web 服务器
- port:网站所在的端口号,默认情况下,http 协议是 80 端口,https 协议是 443 端口,也可以自己选择一个端口号
- path:路径,指明了要获取网站的什么路径下的资源
- query:可以附带一些参数,参数以键值对的方式提供,以
&
分隔,就好像在传递参数 - fragment:用来指定网页里的标签,例如快速定位到网页里的某个章节
给出一个具体的例子:
URL: http://localhost:8080/oj/submit?problem=1234&contest=5678
scheme: http
host: localhost
port: 8080
path: /oj/submit
query: problem=1234&contest=5678
fragment: N/A
翻译成中文就是:使用 http 协议访问 localhost 上的 8080 端口的服务器,想要请求 /oj/submit
的内容,参数是 problem=1234&contest=5678
。当你把 URL 输入到浏览器的地址框的时候,浏览器就会按照这个流程去请求网站。
小结:学习了 URL 的组成部分,理解 URL 各部分的含义
HTTP 请求¶
上文提到,要使用 HTTP 协议访问服务器,那么浏览器是具体如何发送 http 请求的呢?
首先设想一下,如何想要自己设计一个 HTTP 协议,可以怎么设计。上面提到,浏览器要用 HTTP 协议把 URL 的信息传递给服务器,那最简单的办法,就把 URL 本身直接发送给服务器,让服务器返回我想要的网页内容:
PTTH 协议 v1.0:直接发送 URL,请求内容:http://localhost:8080/oj/submit?problem=1234&contest=5678
这里用 PTTH 协议表示自己设计的 HTTP 协议。但是这时候产生了新的需求:网站需要设计登录功能,并且登录以后,每个人看到的是不同的内容。翻译成计算机术语,需求就是:除了 URL 以外,还需要传输自定义的数据,例如登录功能里,要传输用户名和密码;登录以后,为了让服务器知道目前登录的是哪个用户,要传输用户信息。
你可能会想,为什么不直接在 URL 里传递呢,例如想要登录的话,就在 URL 里添加用户名和密码:
http://localhost:8080/oj/submit?problem=1234&contest=5678&username=tsinghua&password=12345678
但你肯定不希望在浏览网站的时候,密码就这么出现在地址栏里,这样密码就容易被旁边的人看到。因此,PTTH 协议第二版还需要允许传输自定义的数据,而不仅仅是 URL:
PTTH 协议 v2.0:请求包括两个部分:
- URL,以回车结束
- 要传输的额外内容,采用和 URL 里的 query 一样的数据格式
这时候,登录的请求就变成了:
http://localhost:8080/oj/submit?problem=1234&contest=5678
username=tsinghua&password=12345678
看起来是不是没什么区别?就是在传输用户名和密码前多了一个换行。但实际上,PTTH v2.0 的巨大进步就是,把 URL 和要传输的额外内容拆开,既然拆开了,额外内容就不仅限于 URL 里可以表示的那些东西,例如你想要上传文件到清华云盘,那么额外内容就是要上传的文件。这就把协议的使用场景拓宽了许多。
但是还有一个需求没有解决:登录了以后,服务器需要知道当前登录的是哪个用户。一个朴素的做法是,每次请求的时候,都把用户名和密码放在额外内容里传一遍,但是这样就意味着浏览器要记住明文密码,这就给坏人了可乘之机。
更好的办法是,在登录的时候,服务器生成一个一次性的凭证,这个凭证只能在有效期内使用。在有效期内,只要请求里附带了凭证,服务器就会认为你已经登录了某某帐号。有效期过了以后,你就需要重新登录。你应该感到很熟悉,因为很多网站正是这么工作的:登录了以后一段时间就不用登录,过了多少天以后,显示登录过期,需要重新登录。登录的时候,有时候还有一个小勾,问你要不要自动登录,并且会说多少天内自动登录。
这个凭证有一个很有趣的名字,叫做 Cookie。接下来出现了新的问题:如果每次请求,都需要携带 Cookie,那 Cookie 要放在 PTTH 协议 v2.0 的哪里呢?放在 URL 里,还是放在额外内容里呢?
成年人的世界是,“我全都要”。既然要传额外的东西,就继续扩充 PTTH 协议,提出 PTTH 协议 v3.0:请求包括三个部分:
- 第一部分是 URL,以回车结束
- 第二部分是 Cookie,以回车结束
- 第三部分是载荷(Payload),给 PTTH 协议 v2.0 里的额外内容起一个正式的名字
其实到这里,PTTH 协议就已经很接近 HTTP 协议对请求定义了。下面给出一个 PTTH 协议 v3.0 和 HTTP 协议的请求的例子,看看有哪些不同:
PTTH 协议 v3.0:
http://localhost:8080/oj/submit?problem=1234&contest=5678
LOGGED_IN_AS_USER=SOME_SECRET_THAT_CAN_NOT_BE_GUESSED
language=Rust&code=println
HTTP 协议:
POST /oj/submit?problem=1234&contest=5678 HTTP/1.1
Host: localhost:8080
Cookie: LOGGED_IN_AS_USER=SOME_SECRET_THAT_CAN_NOT_BE_GUESSED
language=Rust&code=println
下面来逐行解析 HTTP 协议的内容。HTTP 协议的请求的第一行格式如下:
method path_query_fragment HTTP/1.1
- method:请求的方法(Method),常见的有 GET 和 POST,表示的是请求的意图。例如 GET 表示浏览器想从服务器获取数据,POST 联想到英文里的 post message,表示浏览器想要给服务器发送数据
- path_query_fragment:对应 URL 里面的 path,query 和 fragment 三个部分,去掉了 host 和 path
例如当你在浏览器里输入 URL 的时候,浏览器就会以 GET 方法发送一个不带载荷的请求。
HTTP 协议的请求的第二行和第三行都是同一个格式:
Key: Value
原来的 URL 的 host 和 path 变成了:
Host: localhost:8080
原来的 Cookie 变成了:
Cookie: LOGGED_IN_AS_USER=SOME_SECRET_THAT_CAN_NOT_BE_GUESSED
这样做的好处是方便扩展:原来 PTTH 协议 规定了第二行传输的一定是 Cookie,如果未来想要添加更多的怎么办?就会出现不兼容的问题。所以 HTTP 设计了比较灵活的方式,传输多个键(Key)值(Value)对,那么未来只需要定义更多的 Key,例如 Host 表示 URL 里面的域名等等,方便了 HTTP 协议的扩展。
最后的载荷部分 HTTP 和 PTTH 协议一样。特别地,在最后一个键值对和载荷之间有两个回车换行作为分隔。
最后总结一下 HTTP 协议的请求的格式:
method path_query_fragment HTTP/1.1
Key1: Value1
Key2: Value2
...
Keyn: Valuen
Payload
各字段含义如下:
- method:请求的方法,常见的有 GET 和 POST
- path_query_fragment:URL 里面的 path,query 和 fragment,也就是不带域名的部分
- Key1-n,Value1-n:键值对,可以在这里传输各种扩展数据,例如 Host 和 Cookie
- Payload:载荷,可以在这里传输额外的数据
小结:从 HTTP 协议的需求出发,自行设计了 PTTH 协议,并且发现 PTTH 协议和实际的 HTTP 协议差距不大。
彩蛋:事实上还真有人提出了 PTTH 协议:Reverse HTTP,而且 AirPlay 还使用了它。这里提出的 PTTH 协议只是帮助学习 HTTP,和 Reverse HTTP 无关。
HTTP 响应¶
上面讲了 HTTP 请求,也就是浏览器(客户端)发送给服务器的格式。自然地,服务器在响应的时候,也需要遵循一定的格式。
你在浏览网站的时候,是否遇到过 404 Not Found 的错误页面?你是否好奇 404 这个数字是哪来的?
实际上,404 Not Found 就是 HTTP 响应的一部分。和请求类似,HTTP 响应定义如下:
HTTP/1.1 code message
Key1: Value1
Key2: Value2
...
Keyn: Valuen
Payload
例子:
HTTP/1.0 200 OK
Content-Type: text/html
Content-Length: 1234
<html>...</html>
是不是很熟悉?除了第一行以外,后面的部分和请求的格式一样,就是若干个键值对,一个空行,然后是载荷。code 和 message 含义如下:
- code:状态码,是一个数字,例如 404
- message:状态信息,是一个字符串,例如 Not Found
这些状态是用来告诉客户端,请求的结果是什么。例如常见的状态有:
- 200 OK:请求成功
- 403 Forbidden:无权限访问
- 404 Not Found:找不到要请求的网页
- 500 Internal Server Error:服务器内部错误
接下来的键值部分,经常可以看到的键:Content-Type 表示载荷的类型,比如 text/html
表示载荷是一个 HTML;Content-Length 表示载荷的长度。
在这里留一个小的作业给你:运行 curl -v http://www.baidu.com
,在输出的内容里,找到 HTTP 请求和 HTTP 响应,观察都有哪些内容。
小结:学习了 HTTP 响应的格式,了解常见的 HTTP 状态码的含义。
HTTP 调试工具¶
接下来介绍一些 HTTP 的调试工具,可以辅助你发送 HTTP 请求,然后观察 HTTP 的响应:
比较推荐的是第一个:VSCode REST Client
,可以直接在 VSCode 里进行操作。首先在 VSCode 中安装插件,然后新建一个文件 test.http
,填入如下内容:
GET / HTTP/1.1
Host: www.baidu.com
那么 VSCode REST Client 插件会在代码上显示一个 Send Request
按钮。点击按钮,等待一段时间后,VSCode 就会出现一个窗口,窗口内会显示 HTTP 响应的内容。在响应的载荷里,可以看到百度首页的 HTML 内容。
再尝试发送另一个请求,这次是访问 ipinfo.io/json
:
GET /json HTTP/1.1
Host: ipinfo.io
再次 Send Request 后,在响应中可以看到,载荷是一个 JSON,里面保存了你的 IP 地址和所在地信息。这下你就明白,各大网站显示用户所在地是怎么实现的了。
小结:安装 VSCode REST Client 插件,学会如何在 VSCode 里编写 HTTP 请求,并点击 Send Request 发送请求,在新弹出的页面里看到 HTTP 响应。
评测流程¶
介绍完 HTTP,接下来介绍一下 OJ 的工作流程。你在完成小作业的时候,使用 OJ 的方法是:
- 浏览 OJ,找到要做的题目
- 在题目页面的提交框中,输入代码,然后点击提交
- 点击提交以后,跳转到评测任务的页面,页面会显示正在评测中
- 等待一段时间后,评测状态更新,显示出整体状态,以及每个测试点的状态
现在身份转换,你需要来实现一个 OJ 系统,那么从 OJ 服务器的角度来看,评测流程是这样的:
- 用户提交一份代码,提交到某一个题目
- 根据用户提交的信息,创建一个评测任务,然后开始评测
- 评测的第一步是编译:把用户提交的代码保存在文件中,然后执行编译器命令,生成可执行文件
- 评测的第二步是运行:提取题目的出题人提供的数据点,每个数据点有一个输入文件和一个答案文件。那么在运行用户程序的时候,把标准输入重定向到数据点的输入文件,然后把程序输出的内容保存下来。
- 评测的第三步是比对结果:把程序输出的内容,和出题人提供的答案进行对比,根据对比结果统计分数。
下面举一个具体的例子,以 A+B 题目为例,出题人需要提供每个数据点的输入文件和答案文件。假如输入文件只有一行,内容就是两个整数 A 和 B;答案文件只有一行,内容就是 A+B。那么出题人要提供如下的文件:
1.in:
1 2
1.ans:
3
2.in:
1 -1
2.ans:
0
一共两个数据点,每个数据点一个输入文件(后缀是 .in
),一个答案文件(后缀是 .ans
)。
那么评测的时候,需要执行下面的命令:
# 第一步:OJ 服务器把用户提交的代码保存到 code.rs 文件
# 用代码实现,无实际执行的命令
# 第二步:用 rustc 编译 code.rs 为可执行文件 code
rustc -o code code.rs
# 第三步:对每个数据点运行 code,并进行重定向:标准输入重定向到数据点的输入文件,输出重定向到临时的输出文件
# 实际上代码中执行的命令只有 code,后面的重定向部分是代码中实现的,不会出现在命令中
code < 1.in > 1.out
code < 2.in > 2.out
# 第四步:比对每个数据点的输出文件和答案文件
# 用代码实现,无实际执行的命令
这样就实现了基础的 OJ 评测流程。
小结:了解了 OJ 的评测流程,了解了评测过程中需要执行哪些命令。
运行程序¶
你已经知道,在 OJ 系统中,编译可执行文件以及评测的时候,都需要运行程序,并且按照需求传递命令行参数,或进行输入输出的重定向。
Rust 标准库提供了 std::process::Command
来运行程序。下面我们来学习如何使用它。
首先最简单的用法是,直接运行一个程序,等待其运行结束,并获得它的结束状态:
// 样例出自 Rust 文档
// 这里用了 `?` 来把错误通过 Result 传播出去
// 你可能需要根据实际的代码修改成对应的错误处理代码
use std::process::Command;
let status = Command::new("/bin/cat")
.arg("file.txt")
.status()?;
println!("process finished with: {status}");
上面的代码运行了 /bin/cat file.txt
命令,等待其运行完成并获取它的结束状态。进一步,可以对结束状态调用 ExitStatus::success()
函数来判断程序是否正常退出。例如当编译器报错的时候,它就会异常退出,此时 OJ 系统就应该告诉用户,你的提交任务 Compilation Error 了。
进一步,可能想要传递多个参数,或者对标准输入输出进行重定向:
let in_file = File::open("wordle.in")?;
let out_file = File::create("wordle.out")?;
let status = Command::new("wordle")
.args(["--random", "-t"])
.stdin(Stdio::from(in_file))
.stdout(Stdio::from(out_file))
.stderr(Stdio::null())
.status()?;
上面的代码运行了 wordle --random -t
命令,将标准输入重定向为 wordle.in
,标准输出重定向为 wordle.out
,丢弃它的标准错误输出,等待其运行完成并获取它的结束状态。
至此,你已经学会如何在 Rust 中执行 OJ 系统中需要执行的两种命令,也就是编译器以及用户程序。
小结:学习了怎么在 Rust 中运行程序,等待程序运行结束,并获取结束状态。同时还学会了如何进行输入输出重定向。
文件操作¶
在 OJ 评测流程中,涉及到很多文件操作,例如:
- 评测前,把评测临时目录删掉,再创建一个空的临时目录
- 把用户提交的代码保存到文件中
- 运行用户程序以后,读取输出文件的内容
- 最后再把评测临时目录删掉
评测前后都要删除临时目录的目的是,防止 OJ 系统中途异常结束,在下一次评测的时候出现问题。
此外还需要读取配置文件,进行状态持久化等等,这些都涉及到了文件操作。
为了实现这些文件操作,可以使用 Rust 标准库提供的函数,大多都在 std::fs
模块下,如
std::fs::File::create
:以写模式打开或新建一个文件std::fs::File::open
:以读模式打开一个文件std::io::Read::read_to_string
:从已经打开的文件里,读取整个文件的内容到String
std::io::Write::write
:向已经打开的文件写入数据(&[u8]
)std::fs::write
:跳过打开文件的步骤,直接向文件写入数据std::fs::read
:跳过打开文件的步骤,直接从文件读取数据std::fs::read_to_string
:跳过打开文件的步骤,直接从文件读取数据为String
std::fs::create_dir
和std::fs::create_dir_all
:创建文件夹std::fs::remove_file
:删除文件std::fs::remove_dir_all
:删除目录以及目录下的所有内容
小结:学习了 Rust 的文件操作函数。
API¶
前面已经提到,在发送 HTTP 请求的时候,需要指定 path,告诉服务器我想要什么路径下的资源。那么客户端要完成一件事情的时候,怎么知道要用什么路径?
答案是需要客户端和服务端事先约定好。例如在 OJ 系统中,如果发送一个 POST
请求,路径为 /jobs
,则代表用户想要提交代码到 OJ 上。这就称做一个 API。服务端给出它实现的 API 的定义,客户端根据 API 的定义,构造相应的请求,服务端根据请求的内容,进行相应的处理,构造出相应的响应。这样客户端和服务端就可以正常工作。
这时候,如果使用 HTTP 服务端框架,就可以很方便地实现这个功能。在实现 OJ 服务器的时候,通常会把每个 API 对应到一个函数:当客户端发送一个 HTTP 请求的时候,服务端就会调用相应的函数,函数的参数就是请求的内容,函数的返回值就是响应的内容。
此时 OJ 服务器实现的逻辑就是:
- 给每个 API 实现一个函数,函数的参数就是请求的内容,返回值就是响应的内容
- 向框架注册 API 路径和函数的对应关系
HTTP 服务端框架做的事情是:
- 启动 HTTP 服务器,监听新的 HTTP 请求
- 收到 HTTP 请求时,根据请求的路径和方法,找到 OJ 服务器事先注册好的函数
- 把请求的内容传给函数,得到函数的返回值
- 把函数的返回值转换成 HTTP 响应发送给客户端
在下一小节中会具体地看到 API 的实现方法。
小结:学习了 API 的概念,即 API 就是客户端和服务端约定好在 HTTP 上传输的内容和方式;学习了服务端框架的功能,理解了如何使用框架实现 API。
模板代码¶
在模板仓库中,已经用 actix-web
启动了一个简单的 HTTP 服务器,监听在 127.0.0.1:12345
上,并且实现了两个 API:GET /hello
和 GET /hello/{name}
。下面对代码进行逐行解释:
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
初始化了日志的配置,默认情况下会打印出 HTTP 服务器收到的请求。
HttpServer::new(|| {
// ...
})
.bind(("127.0.0.1", 12345))?
.run()
.await
启动了一个 HTTP 服务器,监听在 127.0.0.1 地址的 12345 端口上。
App::new()
.wrap(Logger::default())
.route("/hello", web::get().to(|| async { "Hello World!" }))
.service(greet)
启用了请求日志(Logger::default()
),然后实现了 API GET /hello
(route("/hello", web::get())
),它的行为是直接返回一个 Hello World!
的响应(|| async { "Hello World!" }
)。
同时引入了 greet
,它的定义出现在文件的前面:
#[get("/hello/{name}")]
async fn greet(name: web::Path<String>) -> impl Responder {
log::info!(target: "greet_handler", "Greeting {}", name);
format!("Hello {name}!")
}
这样表示它实现了一个 GET /hello/{name}
的 API(#[get("/hello/{name}")]
),其中 {name}
部分会解析到 name: web::Path<String>
参数上,也就是说,如果访问了 /hello/abcde
,那么这个函数会被调用,并且 name
会等于 abcde
。接着,代码打印了日志,然后返回了 format!("Hello {name}")
作为响应的正文。
如果想要实现一个 POST
方法的 API,把 #[get()]
为 #[post()]
即可。
在之前的大作业中,已经接触过如何使用 serde_json
库来实现 JSON 和 struct 之间的转换。在 OJ 系统里,请求和响应大多都是 JSON 格式,如果每个函数里都要序列化和反序列化一遍,未免太多重复。因此框架提供了便捷的使用方法:只要 struct 标注了 #[derive(Deserialize)]
,并在参数处标记 web::Json<Type>
,就可以让框架自动把请求载荷中的 JSON 解析为 struct,再传给函数:
#[derive(Deserialize)]
struct PostJob {
// ...
}
#[post("/jobs")]
async fn post_jobs(body: web::Json<PostJob>) -> impl Responder {
// ...
}
fn main() {
// ...
App::new()
.service(post_jobs)
}
类似地,也可以让框架自动帮你把结构体转换为 JSON,只需要结构体标注了 #[derive(Serialize)]
:
#[derive(Serialize)]
struct Job {
// ...
}
#[post("/jobs")]
async fn post_jobs(body: web::Json<PostJob>) -> impl Responder {
// ...
return web::Json(Job {
// ...
});
}
这样在实现 API 的时候,就不再需要考虑 JSON,进来的是一个结构体,出去的也是一个结构体。只要结构体的定义和 API 的 JSON 定义一致即可。
除了响应的内容,有时候还会想要设置响应的状态码,例如 Bad Request:
#[post("/jobs")]
async fn post_jobs(body: web::Json<PostJob>) -> impl Responder {
// ...
return HttpResponse::BadRequest().json(Error {
// ...
});
}
在 OJ 系统中,经常需要读取配置,而如果可以把配置传到各个处理 API 的函数里,使用起来就会十分方便。框架提供了 app_data
和 web::Data
来协助这个流程:
#[derive(Clone)]
struct Config {
// ...
}
#[post("/jobs")]
async fn post_jobs(body: web::Json<PostJob>, config: web::Data<Config>) -> impl Responder {
// ...
}
fn main() {
// ...
// 加在原来 App::new() 的地方
App::new()
.app_data(web::Data::new(config.clone()))
}
这样,API 处理函数就会自动得到一份配置。这个方法也经常用来传递数据库的连接对象。
除了 web::Path
、web::Data
和 web::Json
以外,还可以用类似的方法从请求中提取其他数据,详见 文档。
小结:学习了模板代码,学习了 actix-web
框架的基本使用方法。
自动测试¶
和 Wordle 大作业一样,OJ 大作业也提供了自动测试,并且这次为部分提高要求也提供了测试:
基础要求:
cargo test --test basic_requirements -- --test-threads=1
提高要求(部分):
cargo test --test advanced_requirements -- --test-threads=1
由于 OJ 大作业涉及到 HTTP 的请求和响应,因此自动测试流程也更加复杂:
每个自动测试都有两个文件:
[case_name].config.json
:OJ 的配置文件,通过参数-c [case_name].config.json
传递给 OJ[case_name].data.json
:评测流程,包括自动测试会发起的 HTTP 请求以及预期的响应。
对于自动测试的每个测例,进行如下的操作:
- 结束当前正在运行的 OJ
- 运行 OJ:
oj -c [case_name].config.json --flush-data
- 按照
[case_name].data.json
的顺序发送 HTTP 请求,将 HTTP 响应与答案进行比较(仅比较答案中出现的字段) - 结束 OJ
[case_name].data.json
中可能出现的键:
request
:发送的 HTTP 请求response
:预期的 HTTP 响应poll_for_job
:在非阻塞评测时,轮询任务状态restart_server
:重启 OJ,用于测试持久化功能
自动测试运行每个测试点后,会生成以下的文件:
[case_name].stdout/stderr
:OJ 程序的标准输出和标准错误。你可以在代码中添加打印语句,然后结合输出内容来调试代码。[case_name].http
:测试过程中发送的 HTTP 请求和收到的响应。调试时,你可以先自己启动一个 OJ 服务端(cargo run
),然后用 VSCode REST Client 来手动发送这些 HTTP 请求,并观察响应。
因此在自动测试失败的时候,不要忘记查看输出的信息,对你找到问题非常有帮助。
小结:学习了自动测试的运行方法,了解了自动测试的流程,知道出问题了在哪里找日志。