指南
从一个应用开始,逐项讲配置、命令行、TLS、WebSocket、HTTP/2、HTTP/3、lifespan、多 worker 与代理头。代码都能编译,终端块都是运行输出。
第一个服务器
装两个包:服务器与接口定义。
moon add moonbitstack/mooncat moon add moonbitstack/moonasgi
ASGI 应用是接受三个参数的异步函数:scope、读请求体、写响应。
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send( @moonasgi.Event::HttpResponseStart( status=200, headers=[("content-type", "text/plain; charset=utf-8")], trailers=false, ), ) send(@moonasgi.Event::HttpResponseBody(body=b"hello from mooncat\n", more_body=false)) } @mooncat.serve(app, host="127.0.0.1", port=12000) }
serve 绑定套接字,停在接受循环里,直到任务被取消。
moon run src/main --target native curl -i http://127.0.0.1:12000/
$ curl --noproxy '*' -i http://127.0.0.1:12000/ HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 server: mooncat date: Mon, 02 Nov 2026 10:11:23 GMT content-length: 19 hello from mooncat
server 与 date 由服务器添加,都可关闭。单段响应按正文算 content-length,分段响应改用 chunked。
scope 里有什么
请求以 scope 到达。下列字段在三代 HTTP 上一致。
| 字段 | HTTP 请求上 | WebSocket 上 |
|---|---|---|
http_method | "GET"、"POST" … | — |
path · raw_path | 百分号解码后的,以及原样的字节 | — |
query_string | 字节,不含 ? | — |
headers | 名字小写,重复的字段都留着 | 升级请求的 headers |
scheme | "http" 或 "https",经代理头改写 | "ws" 或 "wss" |
client · server | 对端与监听端,都是 (host, port) | 同上 |
http_version | "1.0"、"1.1"、"2"、"3" | "1.1" |
root_path | 前面的东西剥掉的前缀 | 同上 |
state | startup 存下的东西的浅拷贝 | 同一份拷贝 |
流式读取请求体
请求体不整块收下:每次 receive() 给出已到的片段,并说明后面还有没有。
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is Http(http) else { return } let mut size = 0 for ;; { guard receive() is HttpRequest(body~, more_body~) else { return } size = size + body.length() if !more_body { break } } send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) let line = "\{http.http_method} \{http.path}: \{size} bytes\n" send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(line), more_body=false)) } @mooncat.serve(app, port=12000) }
可以先作答再读完:剩余部分被读掉或关闭连接,不会当成下一个请求。请求体交完后,receive() 等到响应结束或客户端离开才给 http.disconnect。
配置
serve_config 收一个 Config。字段都有默认值,只写不同的:
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=b"ok", more_body=false)) } let config = @mooncat.Config::new( host="127.0.0.1", port=12000, limit_concurrency=64, limit_max_requests=1000, timeout_keep_alive=5000, server_headers=Map([("server", "mooncat"), ("x-app", "demo")]), ) @mooncat.serve_config(app, config) }
要紧的那些字段
| 字段 | 默认 | 作用 |
|---|---|---|
host · port | "127.0.0.1" · 12000 | 监听地址 |
backlog | 2048 | 监听队列;只记录不生效——套接字层自己调 listen |
dual_stack | false | 一个 IPv6 套接字同时收 IPv4 |
max_connections | 无 | 超过之后,客户端在套接字上等 |
limit_concurrency | 无 | 超过之后,请求直接回 503 |
limit_max_requests | 无 | 答满这么多请求后服务器退休 |
timeout_keep_alive | 5000 毫秒 | 空闲的 keep-alive 连接到点就关 |
timeout_handshake | 60000 毫秒 | 到点还没握完手的 TLS 连接丢掉 |
graceful_timeout | 无 | 排空在飞的请求最多等多久 |
max_head_size | 16384 | 请求头超过这个数回 431 |
server_headers | {"server": "mooncat"} | 加进每个响应;留空就什么都不加 |
date_header | true | 给每个响应盖 date |
root_path | "" | 前面的东西剥掉的前缀 |
lifespan | Auto | Auto、On 或 Off |
cleartext | true | TLS 端口上,也服务不以握手开头的连接 |
workers | 1 | 一个端口上的进程数,见下文 |
ws · h2 | 见下文 | 两个协议各自的开关组 |
命令行
@cli.run(app) 就是完整的服务器程序:读参数、拼配置、服务。
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=b"served from the command line", more_body=false)) } @cli.run(app) }
moon run src/main --target native -- --port 9000 --log-level debug moon run src/main --target native -- --help
参数都可省略,含义与同形状的 Python 服务器一致:
$ ./app --help Usage: mooncat [options] Serve an ASGI application over HTTP/1.1, HTTP/2, HTTPS or HTTP/3. Options: -h, --help Show help information. -V, --version Show version information. --[no-]date-header stamp a Date header on every response --[no-]server-header stamp a Server header --[no-]proxy-headers believe X-Forwarded-* from the allowed peers --[no-]access-log log one line per request --[no-]reuse-addr set SO_REUSEADDR on the listener --dual-stack accept IPv4 and IPv6 on one socket --[no-]cleartext serve the clear beside TLS on the TLS port --h2c speak HTTP/2 in the clear instead of HTTP/1.1 --http3 serve HTTP/3 on UDP beside HTTPS, on the same port --host <host> address to bind [env: MOONCAT_HOST] --port <port> port to bind [env: MOONCAT_PORT] --workers <workers> processes to serve from (Linux) [env: WEB_CONCURRENCY] --limit-concurrency <limit-concurrency> refuse with 503 above this many in flight --limit-max-requests <limit-max-requests> stop serving after this many requests --timeout-keep-alive <timeout-keep-alive> seconds an idle keep-alive connection is held --forwarded-allow-ips <forwarded-allow-ips> comma-separated peers whose forwarded headers count --lifespan <lifespan> auto, on or off --ssl-certfile <ssl-certfile> PEM certificate; serves HTTPS --ssl-keyfile <ssl-keyfile> PEM private key; serves HTTPS --ssl-ciphers <ssl-ciphers> TLS 1.3 and 1.2 suites to accept by IANA name, colon-separated, best first
做不到的参数回原因,不说「未知参数」。
$ ./app --uds /tmp/app.sock mooncat: --uds: the async transport binds TCP only
只读参数用 @cli.parse(argv),返回计划,不打开任何东西:
fn main { let plan = @cli.parse(@env.args()[1:]) catch { err => { println("\{err}") return } } println("bind \{plan.config.bind()}") println("h2c \{plan.h2c}") println("http3 \{plan.http3}") }
TLS
serve_tls 用 TLS 1.3 服务同一个应用。PEM 启动时读一次,私钥与证书不配对就在此时报错。
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=b"over tls", more_body=false)) } @mooncat.serve_tls( app, certificate_file="src/certs/cert.pem", // 先叶证书,再中间证书 private_key_file="src/certs/key.pem", // PKCS#8、SEC1 或 PKCS#1 port=12000, ) }
握手时 ALPN 给出 h2 与 http/1.1,客户端挑一个:
$ openssl s_client -connect 127.0.0.1:12000 -alpn h2,http/1.1
Protocol: TLSv1.3
Cipher is TLS_CHACHA20_POLY1305_SHA256
ALPN protocol: h2
同端口、同证书、同应用:
$ curl -k --http2 https://127.0.0.1:12000/ HTTP/2 200 content-type: text/plain server: mooncat alt-svc: h3=":12000"; ma=86400 date: Mon, 02 Nov 2026 09:25:21 GMT 2 GET / 0 $ curl -k --http1.1 https://127.0.0.1:12000/ HTTP/1.1 200 OK content-type: text/plain server: mooncat alt-svc: h3=":12000"; ma=86400 date: Mon, 02 Nov 2026 09:25:22 GMT content-length: 12 1.1 GET / 0
同端口默认也答明文:首字节不是 TLS 记录按 HTTP/1.1 或 h2c 处理,scheme 为 "http"。cleartext=false(--no-cleartext)只说 TLS。
WebSocket
握手完成前是一次普通请求;之后 scope 变成 websocket,同一对 receive/send 改说帧。ws:// 与 wss:// 共用一条路径。
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is WebSocket(_) else { return } for ;; { match receive() { WebSocketConnect => send(@moonasgi.Event::WebSocketAccept(subprotocol=None, headers=[])) WebSocketReceive(text=Some(text), ..) => send(@moonasgi.Event::WebSocketSendText(text)) WebSocketReceive(bytes=Some(bytes), ..) => send(@moonasgi.Event::WebSocketSendBytes(bytes)) WebSocketDisconnect(..) => break _ => () } } } @mooncat.serve(app, port=9001) }
握手实录:
$ curl -v ws://127.0.0.1:9001/ > GET / HTTP/1.1 > Host: 127.0.0.1:9001 > Upgrade: websocket > Sec-WebSocket-Version: 13 > Sec-WebSocket-Key: D/NLFJQ2oY2kcWKtAc1kUg== > Connection: Upgrade < HTTP/1.1 101 Switching Protocols < Upgrade: websocket < Connection: Upgrade < Sec-WebSocket-Accept: ujJCyRitqg7NDMxhzmBTEBtTRhI=
帧有校验:RSV 置位、保留操作码、未掩码帧、畸形控制帧都以 1002 断开;超过 ws.max_size 在读载荷前以 1009 拒绝;非法 UTF-8 以 1007 失败;关闭帧按规范回显。
Config.ws | 默认 | 作用 |
|---|---|---|
max_size | 16 MiB | 能收下的最大消息 |
max_queue | 32 | 等着应用的讯息;满了就停止读取,客户端被 TCP 挡住 |
ping_interval | 20000 毫秒 | 保活 ping 的间隔;None 关掉 |
ping_timeout | 20000 毫秒 | 对端没按时回 pong 就以 1011 关闭 |
deflate | true | 客户端提出 permessage-deflate 时接受 |
ping 由服务器应答,不交给应用。升级前拒绝回 403。
HTTP/2 与 h2c
serve 与 serve_tls 已在同端口上说 HTTP/2:TLS 下由 ALPN 点名 h2,明文下看前言。应用不用改。
$ curl --http2-prior-knowledge http://127.0.0.1:12000/ HTTP/2 200 content-type: text/plain; charset=utf-8 server: mooncat date: Mon, 02 Nov 2026 10:11:38 GMT hello from mooncat
serve_h2c 只说 HTTP/2,不嗅探、不回落:
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=b"h2 only", more_body=false)) } @mooncat.serve_h2c(app, host="127.0.0.1", port=12000) }
每条流一个任务,慢处理器只拖住自己。请求体随索取而到,窗口按这个节奏还回。
Config.h2 | 默认 | 作用 |
|---|---|---|
max_streams | 100 | 并发流数;多出来的以 REFUSED_STREAM 拒掉 |
max_header_list | 64 KiB | 一个头块允许的字段 |
max_frame | 16 KiB | 服务器愿意发的最大帧 |
window | 1 MiB | 单条流的流控窗口 |
connection_window | 1 MiB | 整条连接的流控窗口 |
限额按流算:超过 limit_concurrency 回 503,每条流计入 limit_max_requests,timeout_keep_alive 内无流则发 GOAWAY。客户端的 RST_STREAM 变成 http.disconnect。响应前出错回 500,之后用 INTERNAL_ERROR 重置。
HTTP/3
HTTP/3 在 UDP 上,端口号与 TCP 相同。@quic.serve_config 收同一应用、同一份 Config 与证书:
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is Http(http) else { return } send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) let line = "\{http.http_version} \{http.http_method} \{http.path}\n" send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(line), more_body=false)) } @quic.serve_config(app, @mooncat.Config::new(port=12000), { certificate_file: "src/certs/cert.pem", private_key_file: "src/certs/key.pem", }) }
此后每个响应带 alt-svc,客户端由此知道 UDP 端口:
$ python h3trace.py # 一个 HTTP/3 客户端,ALPN 为 h3 :status 200 content-type text/plain server mooncat alt-svc h3=":12000"; ma=86400 date Mon, 02 Nov 2026 09:25:39 GMT 3 GET / 0
应用给 http_version 答的是 "3"。每条流一个任务,响应按客户端已确认的部分推进。
lifespan
应用在监听前与退出前各被调用一次,都在 Lifespan scope 下。连接池在这里开关。Config.lifespan 决定严格程度:
| 模式 | 行为 |
|---|---|
Auto | 照跑;在 lifespan scope 上失败的应用照样服务 |
On | 照跑;在它上面失败,服务器就不启动 |
Off | 完全不跑 |
async fn main { let mut pool = "" let app : @moonasgi.AsgiApp = (scope, receive, send) => { match scope { Lifespan(_) => for ;; { match receive() { LifespanStartup => { pool = "connected" send(@moonasgi.Event::LifespanStartupComplete) } LifespanShutdown => { pool = "closed" send(@moonasgi.Event::LifespanShutdownComplete) break } _ => () } } _ => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(pool), more_body=false)) } } } @mooncat.serve_config(app, @mooncat.Config::new(port=12000, lifespan=On)) }
startup 放进 state 的东西以浅拷贝进入每个 scope。服务任务被取消时 shutdown 照样跑。
多 worker
workers 把程序跑若干份,共享一个端口。被启动的进程自己不服务,只监督。
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=b"ok", more_body=false)) } @mooncat.serve_config(app, @mooncat.Config::new(port=12000, workers=4)) }
每个 worker 自己绑定端口、跑 lifespan,内核分摊连接。死掉的被替换,limit_max_requests 也这样退休。起不来的 worker 让监督进程以退出码 3 结束。停监督进程即停全部,各自按 timeout_graceful_shutdown 排空。
HTTP/3 换一种方式共享。moonbitlang/async 打开 UDP 套接字是一步完成的,设不上 SO_REUSEPORT,所以 UDP 端口由监督进程持有,经本机回环把数据报交给各 worker。每个 worker 把自己的编号写进它发放的每个连接 ID 的首字节,监督进程从此后的每个数据报里读出它;客户端为首个 Initial 自选的 ID 按同一字节分派,握手不会被拆开。
仅 Linux:只有它的内核在绑到同一端口的套接字间分摊连接。别的系统会拒绝该设置。缺省时由 WEB_CONCURRENCY 决定。
代理头
负载均衡后面,对端地址是均衡器的。proxy_headers 让服务器相信 X-Forwarded-For、X-Forwarded-Proto、Forwarded,但只对 forwarded_allow_ips 里的对端。
async fn main { let app : @moonasgi.AsgiApp = (scope, receive, send) => { guard scope is Http(http) else { return } let who = match http.client { Some((host, _port)) => "\{http.scheme} from \{host}" None => "nowhere" } send(@moonasgi.Event::HttpResponseStart(status=200, headers=[], trailers=false)) send(@moonasgi.Event::HttpResponseBody(body=@utf8.encode(who), more_body=false)) } @mooncat.serve_config(app, @mooncat.Config::new( port=12000, proxy_headers=true, forwarded_allow_ips=["127.0.0.1", "10.0.0.0"], )) }
* 表示相信所有对端,仅在套接字不可外达时安全。proxy_rewrite 是同一判断的函数形式。
扩展
HTTP scope 声明支持的扩展,三代一致:
| 扩展 | 服务器做什么 |
|---|---|
http.response.early_hint | 答案之前先发 103 Early Hints,每个链接一个 link 字段 |
http.response.trailers | 以 trailers=true 开始的响应改用 chunked,尾字段在最后一块里;HTTP/2 上是收尾的 HEADERS 帧 |
tls | TLS 连接上:tls_version、cipher_suite,以及服务器证书的 PEM |
async fn main { let app : @moonasgi.AsgiApp = (_scope, _receive, send) => { send(@moonasgi.Event::HttpResponseEarlyHint(links=["</style.css>; rel=preload; as=style"])) send(@moonasgi.Event::HttpResponseStart( status=200, headers=[("content-type", "text/plain")], trailers=true, )) send(@moonasgi.Event::HttpResponseBody(body=b"body first", more_body=false)) send(@moonasgi.Event::HttpResponseTrailers(headers=[("x-checksum", "42")], more_trailers=false)) } @mooncat.serve(app, port=12000) }
HTTP/1.0 客户端两者都收不到。
目标平台
有异步套接字即可服务:native 与 wasm,测试两边都跑。js 与 wasm-gc 上只有配置、lifespan、代理三个包,服务包为空。QUIC 只在 native。