跳转至

快速入门

URL

当你用浏览器浏览网站的时候,有没有思考过,网站是如何把内容传输到你的浏览器,然后显示在你的浏览器上的?这里涉及到很多的知识点,你将在这里学习到基础内容,并在将来的课程中深入学习。

首先讲讲 URL(Uniform Resource Locator)的概念。实际上,经常说的网址,其实就是 URL,例如常见的:

上面的地址中,除了 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:请求包括两个部分:

  1. URL,以回车结束
  2. 要传输的额外内容,采用和 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:请求包括三个部分:

  1. 第一部分是 URL,以回车结束
  2. 第二部分是 Cookie,以回车结束
  3. 第三部分是载荷(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 的方法是:

  1. 浏览 OJ,找到要做的题目
  2. 在题目页面的提交框中,输入代码,然后点击提交
  3. 点击提交以后,跳转到评测任务的页面,页面会显示正在评测中
  4. 等待一段时间后,评测状态更新,显示出整体状态,以及每个测试点的状态

现在身份转换,你需要来实现一个 OJ 系统,那么从 OJ 服务器的角度来看,评测流程是这样的:

  1. 用户提交一份代码,提交到某一个题目
  2. 根据用户提交的信息,创建一个评测任务,然后开始评测
  3. 评测的第一步是编译:把用户提交的代码保存在文件中,然后执行编译器命令,生成可执行文件
  4. 评测的第二步是运行:提取题目的出题人提供的数据点,每个数据点有一个输入文件和一个答案文件。那么在运行用户程序的时候,把标准输入重定向到数据点的输入文件,然后把程序输出的内容保存下来。
  5. 评测的第三步是比对结果:把程序输出的内容,和出题人提供的答案进行对比,根据对比结果统计分数。

下面举一个具体的例子,以 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 评测流程中,涉及到很多文件操作,例如:

  1. 评测前,把评测临时目录删掉,再创建一个空的临时目录
  2. 把用户提交的代码保存到文件中
  3. 运行用户程序以后,读取输出文件的内容
  4. 最后再把评测临时目录删掉

评测前后都要删除临时目录的目的是,防止 OJ 系统中途异常结束,在下一次评测的时候出现问题。

此外还需要读取配置文件,进行状态持久化等等,这些都涉及到了文件操作。

为了实现这些文件操作,可以使用 Rust 标准库提供的函数,大多都在 std::fs 模块下,如

小结:学习了 Rust 的文件操作函数。

API

前面已经提到,在发送 HTTP 请求的时候,需要指定 path,告诉服务器我想要什么路径下的资源。那么客户端要完成一件事情的时候,怎么知道要用什么路径?

答案是需要客户端和服务端事先约定好。例如在 OJ 系统中,如果发送一个 POST 请求,路径为 /jobs,则代表用户想要提交代码到 OJ 上。这就称做一个 API。服务端给出它实现的 API 的定义,客户端根据 API 的定义,构造相应的请求,服务端根据请求的内容,进行相应的处理,构造出相应的响应。这样客户端和服务端就可以正常工作。

这时候,如果使用 HTTP 服务端框架,就可以很方便地实现这个功能。在实现 OJ 服务器的时候,通常会把每个 API 对应到一个函数:当客户端发送一个 HTTP 请求的时候,服务端就会调用相应的函数,函数的参数就是请求的内容,函数的返回值就是响应的内容。

此时 OJ 服务器实现的逻辑就是:

  1. 给每个 API 实现一个函数,函数的参数就是请求的内容,返回值就是响应的内容
  2. 向框架注册 API 路径和函数的对应关系

HTTP 服务端框架做的事情是:

  1. 启动 HTTP 服务器,监听新的 HTTP 请求
  2. 收到 HTTP 请求时,根据请求的路径和方法,找到 OJ 服务器事先注册好的函数
  3. 把请求的内容传给函数,得到函数的返回值
  4. 把函数的返回值转换成 HTTP 响应发送给客户端

在下一小节中会具体地看到 API 的实现方法。

小结:学习了 API 的概念,即 API 就是客户端和服务端约定好在 HTTP 上传输的内容和方式;学习了服务端框架的功能,理解了如何使用框架实现 API。

模板代码

在模板仓库中,已经用 actix-web 启动了一个简单的 HTTP 服务器,监听在 127.0.0.1:12345 上,并且实现了两个 API:GET /helloGET /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 /helloroute("/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_dataweb::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::Pathweb::Dataweb::Json 以外,还可以用类似的方法从请求中提取其他数据,详见 文档

小结:学习了模板代码,学习了 actix-web 框架的基本使用方法。

自动测试

和 Wordle 大作业一样,OJ 大作业也提供了自动测试,并且这次为部分提高要求也提供了测试:

基础要求:

cargo test --test basic_requirements -- --test-threads=1

提高要求(部分):

cargo test --test advanced_requirements -- --test-threads=1

由于 OJ 大作业涉及到 HTTP 的请求和响应,因此自动测试流程也更加复杂:

每个自动测试都有两个文件:

  1. [case_name].config.json:OJ 的配置文件,通过参数 -c [case_name].config.json 传递给 OJ
  2. [case_name].data.json:评测流程,包括自动测试会发起的 HTTP 请求以及预期的响应。

对于自动测试的每个测例,进行如下的操作:

  1. 结束当前正在运行的 OJ
  2. 运行 OJ: oj -c [case_name].config.json --flush-data
  3. 按照 [case_name].data.json 的顺序发送 HTTP 请求,将 HTTP 响应与答案进行比较(仅比较答案中出现的字段)
  4. 结束 OJ

[case_name].data.json 中可能出现的键:

  1. request:发送的 HTTP 请求
  2. response:预期的 HTTP 响应
  3. poll_for_job:在非阻塞评测时,轮询任务状态
  4. restart_server:重启 OJ,用于测试持久化功能

自动测试运行每个测试点后,会生成以下的文件:

  • [case_name].stdout/stderr:OJ 程序的标准输出和标准错误。你可以在代码中添加打印语句,然后结合输出内容来调试代码。
  • [case_name].http:测试过程中发送的 HTTP 请求和收到的响应。调试时,你可以先自己启动一个 OJ 服务端(cargo run),然后用 VSCode REST Client 来手动发送这些 HTTP 请求,并观察响应。

因此在自动测试失败的时候,不要忘记查看输出的信息,对你找到问题非常有帮助。

小结:学习了自动测试的运行方法,了解了自动测试的流程,知道出问题了在哪里找日志。