45从零开始用Rust编写nginx,静态文件服务器竟然还有这些细节

wmproxy

wmproxy已用Rust实现http/https代理,socks5代理, websocket代理,反向代理, 静态文件服务器,四层TCP/UDP转发,七层负载均衡,内网穿透等,力争打造和nginx的性能。

项目地址

国内: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

静态文件服务器

静态文件服务器是一种用于提供静态文件(如HTML、CSS、JavaScript、图片等)的网络服务器。当客户端(如浏览器)请求这些文件时,静态文件服务器会直接从文件系统中获取文件并返回给客户端,而不需要经过任何处理或动态生成。

静态文件服务器的主要特点包括:

  1. 简单性:静态文件服务器不需要复杂的逻辑或数据库支持,只需要能够读取和发送文件即可。
  2. 高效性:由于不需要处理复杂的逻辑或动态生成内容,静态文件服务器通常能够更快地响应客户端请求。
  3. 可扩展性:静态文件服务器可以轻松地通过增加服务器数量或优化服务器配置来扩展其处理能力。

设计时注意要点

以下是此次设计时的两个注意要点:

  1. 断点续传支持:静态资源服务器通常支持断点续传。当用户下载大文件时,如果出现网络中断或其他原因导致下载中断,静态资源服务器可以记录中断位置,当用户重新请求下载时,可以恢复到中断的位置继续下载,提供更好的下载体验。

  2. 缓存和浏览器缓存支持:静态资源服务器可以通过设置合适的缓存策略,利用浏览器缓存来提高性能和减少网络流量。静态资源可以设置缓存过期时间,当浏览器再次请求相同资源时,可以直接从缓存中获取,减少了网络请求和传输时间。

启动文件服务器

对当前项目启动8080端口监听

BASH 复制代码
wmproxy file-server -l :8080

缓存和浏览器缓存支持

浏览器缓存是一种机制,它将已访问过的资源的副本存储在浏览器中,以便在将来更快地加载相同的资源。可以提高网页性能、减轻服务器负担、节省网络带宽并提供更好的用户体验。在开发和优化网站时,合理地利用浏览器缓存可以显著提升网站的整体性能。

一个文件是否被修改过主要依靠以下两个属性:

  • Etag(Entity Tag):

    • Etag是一个HTTP首部字段,用于验证浏览器缓存的组件与从服务器上获取的组件是否一致。
    • 它是一个由服务器生成的唯一标识符,通常基于文件内容或某些其他属性通过特定算法计算得出。
    • 当资源发生变化时,Etag值也会改变。
    • 客户端会发送一个包含If-None-Match头部的请求,其中包含之前缓存资源的Etag值。
  • Last-Modified:

    • Last-Modified也是一个HTTP首部字段,指定资源最后一次修改的时间。
    • 服务器在响应头中包含该字段,告诉浏览器该资源的最后修改时间。
    • 其时间粒度通常只到秒级别,不如Etag精确。
    • 客户端会发送一个包含If-Modified-Since头部的请求,其中包含之前缓存资源的最后修改时间。

通常文件服务器返回时会附带该两个参数由客户端协带进行是否读取缓存数据。

控制缓存策略:

控制过期时间主要由两种方式:

  • Expires:

    • Expires是一个HTTP 1.0的头部字段,用于指定资源过期的时间。
    • 它是一个日期时间值,告诉浏览器资源何时过期,过期后浏览器需要重新请求资源。
    • 使用Expires的一个缺点是它基于服务器的时间,如果服务器的时间不准确,缓存可能会出问题。
  • Cache-Control:

    • Cache-Control是一个HTTP 1.1的头部字段,提供了更细粒度的缓存控制。
    • 它可以包含多个指令,如public、private、no-cache、max-age等,用于控制资源在浏览器缓存中的存储方式和有效期。
    • Cache-Control的优先级高于Expires,当两者同时存在时,Cache-Control的设置会覆盖Expires。

由于Expires的客户端时间和服务端时间可能存在的不一致,此处我们服务器不做Expires的实现,如果配置过期1d之类,将转化成:
cache-control: max-age=86400进行时间控制。

以下我们进行测试:

我们先对资源wmproxy.md

curl.exe http://127.0.0.1:8082/wmproxy.md -i

HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
transfer-encoding: chunked
Server: wmproxy
cache-control: max-age=1000
Date: Tue, 23 Jan 2024 09:12:05 GMT
Last-Modified: Tue, 23 Jan 2024 09:11:30 GMT
etag: 65af82c2-11c1

从返回结果中我们可以得知缓存时间1000s,最后修改时间及etag的值。让我们添加IF-NONE-MATCH进行测试

HTTP 复制代码
curl.exe http://127.0.0.1:8082/wmproxy.md -i -H "If-Modified-Since: Tue, 23 Jan 2024 09:11:30 GMT"

HTTP/1.1 304 Not Modified
Server: wmproxy
cache-control: max-age=1000
Date: Tue, 23 Jan 2024 09:18:58 GMT
Last-Modified: Tue, 23 Jan 2024 09:11:30 GMT
etag: 65af82c2-11c1
content-length: 0

可以尝试添加最后修改时间的

HTTP 复制代码
curl.exe http://127.0.0.1:8082/wmproxy.md -i -H "IF-NONE-MATCH: 65af82c2-11c1"

HTTP/1.1 304 Not Modified
Server: wmproxy
cache-control: max-age=1000
Date: Tue, 23 Jan 2024 09:18:58 GMT
Last-Modified: Tue, 23 Jan 2024 09:11:30 GMT
etag: 65af82c2-11c1
content-length: 0

一样会进行缓存,通常浏览器会将两个值做为传参一起写入,文件发生变更,将会使缓存失败,重新返回200请求

HTTP 复制代码
curl.exe http://127.0.0.1:8082/wmproxy.md -i -H "IF-NONE-MATCH: 65af82c2-11c1"

HTTP/1.1 200 OK
...

断点续传的支持

断点续传也就是客户端可以指定传输范围进行传输,该标准定义在RFC7233

HTTP 请求范围

HTTP 协议范围请求允许服务器只发送 HTTP 消息的一部分到客户端。范围请求在传送大的媒体文件,或者与文件下载的断点续传功能搭配使用时非常有用。

检测服务器端是否支持范围请求

假如在响应中存在 Accept-Ranges 首部(并且它的值不为"none"),那么表示该服务器支持范围请求。例如,你可以使用 cURL 发送一个 HEAD 请求来进行检测。

curl.exe -I http://127.0.0.1:8080/Cargo.toml
HTTP/1.1 200 OK
...
accept-ranges: bytes
content-length: 1565

在上面的响应中, Accept-Ranges: bytes 表示界定范围的单位是 bytes。这里 Content-Length 也是有效信息,因为它提供了要检索的文件的完整大小。

如果返回的Accept-Ranges: none则表示不支持,如果未返回则表示可能不支持范围请求。

从服务器端请求特定的范围

假如服务器支持范围请求的话,你可以使用 Range 首部来生成该类请求。该首部指示服务器应该返回文件的哪一或哪几部分。

单一范围

我们可以请求资源的某一部分。这次我们依然用 cURL 来进行测试。"-H" 选项可以在请求中追加一个首部行,在这个例子中,是用 Range 首部来请求图片文件的前 1024 个字节。

curl http://127.0.0.1:8080/Cargo.toml -i -H "Range: bytes=0-1023"

这样生成的请求如下:

http 复制代码
GET /Cargo.toml HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/8.0.1
Accept: */*
Range: bytes=0-1023

服务器端会返回状态码为 206 Partial Content 的响应:

http 复制代码
HTTP/1.1 206 Partial Content
content-type: text/plain; charset=utf-8
transfer-encoding: chunked
Server: wmproxy
Date: Tue, 23 Jan 2024 07:59:20 +0000
Last-Modified: Tue, 23 Jan 2024 02:33:35 +0000
etag: 65af257f-61d
content-range: bytes 0-1023/1565
...
(binary content)

在这里,Content-Length 首部现在用来表示先前请求范围的大小(而不是整个文件的大小)。Content-Range 响应首部则表示这一部分内容在整个资源中所处的位置。

多范围查询

发起请求和单一范围类似,只是在请求的时候多个范围地址,如:

curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

返回内容为Content-Type: multipart/byteranges boundary=THIS_STRING_SEPARATES并在body中以该字符做分隔成多数据块,如。

http 复制代码
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5
Content-Length: 282

--3d6b6a416f9b5
Content-Type: text/html
Content-Range: bytes 0-50/1270

<!doctype html>
<html>
<head>
    <title>Example Do
--3d6b6a416f9b5
Content-Type: text/html
Content-Range: bytes 100-150/1270

eta http-equiv="Content-type" content="text/html; c
--3d6b6a416f9b5--

请求多范围要针对解析body块,相对来说数据块请求比较割裂。在HTTP2中可以多流式请求范围或者用keep-alive同时发起多个请求,相对比较难与处理数据块,暂时不做实现。

条件式范围请求

当(中断之后)重新开始请求更多资源片段的时候,必须确保自从上一个片段被接收之后该资源没有进行过修改。

通过 If-Range 请求首部可以用来生成条件式范围请求:假如条件满足的话,条件请求就会生效,服务器会返回状态码为 206 Partial 的响应,以及相应的消息主体。假如条件未能得到满足,那么就会返回状态码为 200 OK 的响应,同时返回整个资源。该首部可以与 Last-Modified 验证器或者 ETag 一起使用,但是二者不能同时使用。

If-Range: Wed, 21 Oct 2015 07:28:00 GMT
或者
If-Range: 65af257f-61d

范围请求的响应

与范围请求相关的有三种状态:

  • 在请求成功的情况下,服务器会返回 206 Partial Content 状态码。
  • 在请求的范围越界的情况下(范围值超过了资源的大小),服务器会返回 416 Requested Range Not Satisfiable (请求的范围无法满足)状态码。
  • 在不支持范围请求的情况下,服务器会返回 200 OK 状态码。

源码相关

  • 关于文件服务器的相关源码均在file_server

  • 关于时间格式由RFC2822控制,这里我们用的解析库为chrono

  • 关于etag,我们这里采用的与nginx一致的算法,文件最后修改时间16进制-文件长度16进制。例:ETag: 65af8536-11c2

    文件长度为:

    10进制为->4546
    转为16进制->11c2

文件最后修改时间:

标准日期格式->Tue, 23 Jan 2024 09:21:58 GMT
转为秒->1706001718
转为16进制->65af8536
RUST 复制代码
pub fn calc_etag(data: &Metadata) -> String {
    let mut seconds = 0;
    let len = data.len();
    if let Ok(last) = data.modified() {
        if let Ok(n) = last.duration_since(SystemTime::UNIX_EPOCH) {
            seconds = n.as_secs();
        }
    }
    format!("{:x}-{:x}", seconds, len)
}
  • 关于中文目录,因为文件服务器是get请求,通常均带在path下,可能由于unicode的编码进行过转化,即问蒙服务框架->%E9%97%AE%E8%92%99%E6%9C%8D%E5%8A%A1%E6%A1%86%E6%9E%B6会进行一次转码,我们在path中如果存在%的时候,尝试进行一次转码,如果成功取新的path值。
RUST 复制代码
if path.contains("%") {
    if let Ok(p) = Url::url_decode(&path) {
        path = p;
    }
}
  • 断点续传(范围查询),通过在原有的基础上增加start_posend_pos来表示文件的起始及结束点。
RUST 复制代码
#[derive(Debug)]
struct InnerReceiver {
    receiver: Option<Receiver<(bool, Binary)>>,
    file: Option<Box<File>>,
    cache_buf: Vec<u8>,
    /// 数据包大小
    data_size: u64,
    /// 文件专用, 起始点
    start_pos: Option<u64>,
    /// 文件专用, 结束点
    end_pos: Option<u64>,
}

小结

本章中讲述了浏览器缓存的设计(ETAG, Last-Modified, Cache-Control, Expires)及断点续传(Accept-Ranges: bytes)的实现流程及相关的部分源码,希望可以让你更了解文件服务器内部的原理组成。

点击 [关注][在看][点赞] 是对作者最大的支持