使用 MoonBit 开发一个 HTTP 文件服务器

在这篇文章中,我将会介绍如何使用 MoonBit 的异步编程功能和 moonbitlang/async 库,编写一个简单的 HTTP 文件服务器。如果你之前接触过 Python 语言,那么你可能知道,Python 有一个非常方便的内建 HTTP 服务器模块。只需要运行 python -m http.server,就能在当期文件夹启动一个文件服务器,用于局域网文件共享等用途。
在这篇文章中,我们将用 MoonBit 实现一个类似功能的程序,并借此了解 MoonBit 的异步编程支持。我们还将额外支持一个 python -m http.server 没有的实用功能:把整个文件夹打包成 zip 文件下载。
异步编程简史
异步编程,能让程序具有同时处理多项任务的能力。例如,对于一个文件服务器来说,可能会有多个用户同时访问这个服务器,而服务器需要同时服务所有用户,让它们的体验尽可能流畅、低延时。在典型的异步程序,例如服务器中,每项任务的大部分时间都花在等待 IO 上,实际的计算时间占比较低。因此,我们并不需要很多的计算资源,也能同时处理大量任务。而这其中的诀窍,就是频繁地在多个任务之间切换: 如果某项任务开始等待 IO,那么就不要继续处理它,而是马上切换到不需要等待的任务上。
过去,异步程序往往是通过多线程的方式实现的:每项任务对应一个操作系统的线程。 然而,操作系统线程需要 占用较多资源,而且在线程之间切换开销较大。 因此,进入 21 世纪后,实现异步程序的主要方式变成了事件循环。 整个异步程序的形态是一个巨大的循环,每次循环中, 程序检查哪些 IO 操作已经完成,然后运行那些等待着这些已完成的 IO 操作的任务, 直到它们发起下一次 IO 请求,重新进入等待状态。 在这种编程范式中,任务间的切换发生在同一个用户态的线程里,因此开销极低。
然而,手写事件循环是一件非常痛苦的事情。
因为同一个任务的代码会被拆散到多次不同的循环中执行,程序的逻辑变得不连贯了。
因此,基于事件循环的程序非常难编写和调试。
幸运的是,就像大部分其他现代编程语言一样,MoonBit 提供了原生的异步编程支持。
用户可以像写同步程序一样写异步代码,MoonBit 会自动把异步代码切分成不同的部分。
而 moonbitlang/async 库则提供了事件循环和各种 IO 原语的实现,负责把异步代码运行起来。
MoonBit 中的异步编程
在 MoonBit 中,可以用 async fn 语法来声明一个异步函数。
异步函数看上去和同步函数完全一样,只不过它们在运行时可能在中途被打断,
一段时间后才继续恢复运行,从而实现多个任务间的切换。
在异步函数中可以正常使用循环等控制流构造,MoonBit 编译器会自动将它们变成异步的样子。
和许多其他语言不同,在调用异步函数时,MoonBit 不需要用 await 之类的特殊语法标记,
编译器会自动推断出哪些函数调用是异步的。
不过,如果你使用 带有 MoonBit 支持的 IDE 或文本编辑器查看代码,
就会看到异步函数调用被渲染成了斜体、可能抛出错误的函数调用带有下划线。
因此,阅读代码时,依然可以一眼就找到所有异步的函数调用。
对于异步程序来说,另一个必不可少的组件是事件循环、任务调度和各种 IO 原语的实现。
这一点在 MoonBit 中是通过 moonbitlang/async 库实现的。
moonbitlang/async 库中提供了网络IO、文件IO、进程创建等异步操作的支持,
以及一系列管理异步编程任务的 API。
接下来,我们将会在编写 HTTP 文件服务器的途中介绍 moonbitlang/async 的各种功能。
HTTP 服务器的骨架
典型的 HTTP 服务器的结构是:
- 服务器监听一个 TCP 端口,等待来自用户的连接请求
- 接受来自用户的 TCP 连接后,服务器从 TCP 连接中读取用户的请求,处理用户的请求并将结果发回给用户
这里的每一项任务,都应该异步地进行: 在处理第一个用户的请求时,服务器仍应不断等待新的连接,并第一时间响应下一个用户的连接请求。 如果有多个用户同时连接到服务器,服务器应该同时处理所有用户的请求。 在这个过程中,所有可能耗费较多时间的操作,例如网络 IO 和文件 IO,都应该是异步的, 它们不应该阻塞程序、影响其他任务的处理。
在 moonbitlang/async 中,有一个辅助函数 @http.run_server,
能够绑我们自动完成上述工作,搭建一个 HTTP 服务器并运行它:
async fn async (path~ : String, port~ : Int) -> Unit
server_main(String
path~ : String
String, Int
port~ : Int
Int) -> Unit
Unit {
(Unit, (?, Unit) -> Unit) -> Unit
@http.run_server((String) -> Unit
@socket.Addr::parse("[::]:\{Int
port}"), fn (?
conn, Unit
addr) {
Unit
@pipe.stderr.(String) -> Unit
write("received new connection from \{Unit
addr}\n")
async (base_path : String, conn : ?) -> Unit
handle_connection(String
path, ?
conn)
})
}
server_main 接受两个参数,其中,
path 是文件服务器工作的路径,port 是服务器监听的端口。
在 moonbitlang/async 中,一切异步代码都是可以取消的,
而异步代码被取消时会抛出错误,所以所有异步函数都会抛出错误。
因此,在 MoonBit 中,async fn 默认就会抛出错误,无需再显式标注 raise。
在 server_main 中,我们使用 @http.run_server 创建了一个 HTTP 服务器并运行它。
@http 是 moonbitlang/async 中提供 HTTP 解析等支持的包 moonbitlang/async/http 的别名,
@http.run_server 的第一个参数是服务器要监听的地址。
这里我们提供的地址是 [::]:port,
这表示监听端口 port、接受来自任何网络接口的连接请求。
moonbitlang/async 有原生的 IPv4/IPv6 双栈支持,因此这里的服务器可以同时接受 IPv4 连接和 IPv6 连接。
@http.run_server 的第二个参数是一个回调函数,用于处理来自用户的连接。
回调函数会接受两个参数,第一个是来自用户的连接,
类型是 @http.ServerConnection,由 @http.run_server 自动获取并创建。
第二个参数是用户的网络地址。
这里,我们使用 handle_connection 函数来处理用户的请求,这个函数的实现将在稍后给出。
@http.run_server 会自动创建一个并行的任务,并在其中运行 handle_connection。
因此,服务器可以同时运行多份 handle_connection、处理多个连接。