AIGC:
Label: "1"
ContentProducer: 001191440300708461136T1XGW3
ProduceID: aed9bd2c2f3c6f83a54981e0ddb92ca2_92682142d87611f1a0ca525400de85a5
ReservedCode1: c7sRPTtynADlRxk50daYiYAvestwJWIsw+A4d9g8pHHfsaLD273KxOtYjoch4jbSA8t/FwK9xzst07Td0TCwtGq0qaaZiKN9eQLwBY83Y7vdMLE3wgJLrArabBZvl28b0Xea8k+v2nS97YFyFng1HSSXvvPaOOhe4z07twUiZNKgfPuBgRdDgZL5Llg=
ContentPropagator: 001191440300708461136T1XGW3
PropagateID: aed9bd2c2f3c6f83a54981e0ddb92ca2_92682142d87611f1a0ca525400de85a5
ReservedCode2: c7sRPTtynADlRxk50daYiYAvestwJWIsw+A4d9g8pHHfsaLD273KxOtYjoch4jbSA8t/FwK9xzst07Td0TCwtGq0qaaZiKN9eQLwBY83Y7vdMLE3wgJLrArabBZvl28b0Xea8k+v2nS97YFyFng1HSSXvvPaOOhe4z07twUiZNKgfPuBgRdDgZL5Llg=
Web::Lite
接手开发、排查问题先看 HANDOFF.md:环境基线、执行链、踩坑清单、欠账与红线。
极简 Sinatra / Flask 风格的 异步 Web 框架,底座是 Cro::HTTP。
目标只有一个:让写一个 HTTP 接口回到「一行一条路由」的舒服程度,同时天然不阻塞。
use Web::Lite;
use Web::Lite::Middleware;
my $app = Web::Lite.new: :port(3000);
$app.use: timing;
$app.after: access-log;
$app.after: cors;
$app.get: '/', -> $c { $c.html('<h1>hi</h1>') };
$app.get: '/api/ping', -> $c { $c.json({ pong => True }) };
$app.get: '/api/users/{id}', -> $c { $c.json({ id => $c.param('id').Int }) };
$app.run;
安装与运行
# 依赖:Rakudo 2024.01+、Cro::HTTP、JSON::Fast
zef install --deps-only .
raku -Ilib examples/app.raku
自带示例 7 条路由,启动后可直接访问:
http://127.0.0.1:3000/ 首页 HTML
http://127.0.0.1:3000/api/ping JSON
http://127.0.0.1:3000/api/users/42 路径参数
http://127.0.0.1:3000/api/search?q=x 查询串
POST http://127.0.0.1:3000/api/echo 读 JSON body
http://127.0.0.1:3000/api/slow 异步(1s,不阻塞其它请求)
http://127.0.0.1:3000/static/a/b.css ** 尾部通配
路由
$app.get('/path', &handler);
$app.post('/path', &handler);
$app.put / $app.patch / $app.delete
{name} 取路径参数,$c.param('name')** 捕获剩余路径,$c.splat- 已知路径但方法不匹配 → 自动
405 并带 Allow 头 HEAD 复用 GET 路由(响应体由服务器剥离)- 未匹配 → 自定义
on-not-found,否则 404
命名路由与 url_for
注册时给 name,之后反向拼 URL,别在 handler 里手写路径字符串:
$app.get: '/user/{id}', -> $c { $c.json({ id => $c.param('id') }) }, name => 'user-detail';
$app.get: '/post/{slug}/**', -> $c { $c.text('post') }, name => 'post-rest';
$app.url-for('user-detail', id => 5); # /user/5
$app.url-for('post-rest', slug => '你好', _splat => <x y>); # /post/%E4%BD%A0%E5%A5%BD/x/y
$app.url-for('/raw/{k}', k => 'a b'); # /raw/a%20b,直接传模式也行
$app.route-named('user-detail'); # 取 Route 对象(调试用)
- 变量段按段做百分号编码,只放行 unreserved(
A-Z a-z 0-9 - . _ ~),/ 不编码——按段传值即可 * 按顺序消费 _splat,** 吃掉剩余的全部- 缺参数直接
die:拼出半个 URL 让请求 404 更难查 - 同名路由取最先注册的那条(静态目录的 name 都是
static,只认首个)
Context
| 成员 | 说明 |
|---|
$c.method / $c.path / $c.http-version | 请求基础信息 |
$c.param(:$name) | 路径参数优先,其次查询串 |
$c.query-param(:$name) / $c.query-hash | 查询串(自动 %XX 与 + 解码,同名参数聚成 List) |
$c.header(:$name) | 请求头,缺失返回 '' |
$c.body / $c.body-str / $c.body-json | 同步读请求体(字节 / 文本 / JSON),类 Sinatra 的 request.body |
$c.text-body / $c.json-body | Promise,异步读 body;空 body 返回空串 / 空 Hash |
$c.form / $c.multipart | 表单视图(multipart 与 urlencoded 都走这里);$c.multipart 判断是否 multipart |
$c.field('name') | 表单字段:同名多个返回 List,单个返回 Str,没有返回 Any |
$c.file('name') / $c.files | 上传文件:单个 Part / List / Any,或全部文件的 List |
$c.session | 会话视图,需先 $app.session(secret => ...),否则直接报错 |
$c.cookie('name') / $c.cookies | 读请求 cookie,默认百分号解码(:raw 关闭) |
$c.set-cookie(...) / $c.delete-cookie('name') | 写 Set-Cookie,可多次调用,一次响应多项 |
$c.send-file('/path', :$download-name) | 回磁盘文件,带 ETag/Last-Modified/304 |
$c.set($k,$v) / $c.stash-get($k) | 中间件与 handler 共享数据 |
$c.json($data, :status, :headers) | 返回 JSON 响应 |
$c.text($str, :status, :content-type, :headers) | 返回文本响应 |
$c.html($str, :status, :headers) | 返回 HTML 响应 |
$c.redirect($url, :status = 302) | 302/301 跳转 |
handler 也可以直接返回 Hash(自动转 JSON)、Str(自动转 text/plain),或 Nil(204)。
表单与文件上传
$app.post: '/upload', -> $c {
# 注意:Block 里不能用 return(见实现要点 7),分支用 if/else 收尾
if !$c.multipart {
$c.text('要 multipart', status => 415);
}
else {
my %out = title => $c.field('title'); # 单值
my $tags = $c.field('tags'); # 同名多值 → List,顺序与表单一致
my $f = $c.file('avatar'); # Part / List / Any
if $f.defined {
my $one = $f ~~ Positional ?? $f[0] !! $f;
$one.filename; # 已解码,中文正常
$one.content-type; # 'image/png'
$one.data; # Blob
$one.size;
spurt("uploads/{$one.filename}", $one.data);
}
$c.json({
%out,
tags => ($tags.defined ?? $tags.List !! ()),
files => $c.files.map(*.filename).List,
});
}
};
multipart/form-data 与 x-www-form-urlencoded 统一入口 $c.form,视图一致(field / file / files / keys / has-field)- 非表单
Content-Type 返回空 Form 而非抛错,handler 里可以无脑取字段 - 中文
filename、filename*=UTF-8''%XX(RFC 5987)两种写法都还原成 UTF-8 - 同名多值按出现顺序累积(
tags=["a","b"],不是反序) - 解析发生在
max-body-bytes 检查之后:超大表单先回 413,不会先把 body 读进内存再解析 Part.text / Str 按 UTF-8 解码;二进制内容用 .data
Session
$app.session(secret => 'change-me', max-age => 7 * 24 * 3600);
$app.post: '/login', -> $c {
$c.session<uid> = 42; # 写入即标脏,响应自动落 Set-Cookie
$c.session<name> = '琼瑶老猪';
$c.text('ok');
};
$app.get: '/me', -> $c {
$c.json({ uid => $c.session<uid> }, status => ($c.session<uid>.defined ?? 200 !! 401));
};
$app.get: '/logout', -> $c { $c.session.clear; $c.text('bye') };
- 无状态签名 cookie:数据放在 cookie 里,服务端不存任何东西,天然支持多实例横向扩展
- 格式
<payload>.<hmac-sha1-sig>,两段都 base64url,签名用 常量时间比较,防时序侧信道 - 解不开(签名不符 / 被篡改 / 垃圾串 / 过期)一律返回空会话,不抛错、不泄露原因
- 只读请求不下发 cookie:没标脏就不写
Set-Cookie,省流量也避免每次响应都带 max-age / path / domain / secure / http-only(默认开)/ same-site 都可配- 会话文件不落盘,所以
secret 泄露 = 会话可伪造;换 secret 会让全部已发 cookie 失效
| 成员 | 说明 |
|---|
$c.session<k> / $c.session<k> = v | 读写单个键(写入自动标脏) |
$c.session.get($k) / set($k,$v) / remove($k) | 显式方法形式 |
$c.session.clear | 清空(不等于退出登录,仍需下次响应才覆盖 cookie) |
$c.session.hash / keys / elems / is-empty | 只读视图,hash 是副本,改了不影响会话 |
Cookie
$app.get: '/login', -> $c {
$c.set-cookie('sid', 'abc 123', same-site => 'Lax', max-age => 3600);
$c.set-cookie('theme', 'dark', :!http-only); # 要 JS 读的就关掉 HttpOnly
$c.text('ok');
};
$app.get: '/me', -> $c {
$c.json({ sid => $c.cookie('sid') }); # 'abc 123',已解码
};
$app.get: '/logout', -> $c { $c.delete-cookie('sid'); $c.text('bye') };
| 参数 | 默认 | 说明 |
|---|
:path | / | 默认整站生效,避开「cookie 只在 /api 下带上」的坑 |
:http-only | True | 默认开,挡 XSS 偷 token;前端要读才关 |
:secure / :same-site / :domain / :max-age / :expires | 未设 | 需要就传 |
:raw | False | 默认对值做百分号编码(cookie 只允许 ASCII 子集) |
- 值里的空格、中文、
; 等一律按 UTF-8 字节做百分号编码,读时自动解码,两端对称。 - 一次响应可以写多条
set-cookie,不会互相覆盖。 delete-cookie 用 Max-Age=0 + 过去的 Expires 双保险。
静态文件与 send_file
# 单文件
$app.get: '/report', -> $c {
$c.send-file('data/report.pdf', download-name => '报告.pdf');
};
# 静态目录:挂到 /s,/s/a.css → <root>/a.css,/s/ 和 /s 回落 index.html
$app.static('/s', 'public');
- MIME 按扩展名推断,
text/* 自动补 charset=utf-8 - 自带
ETag + Last-Modified,If-None-Match / If-Modified-Since 命中回 304(要关用 :!cache) :max-file-bytes 超限回 500(一次性读进内存,别拿它发视频)- 下载名按 RFC 5987 写成
filename*=UTF-8''...,同时给 filename="..." ASCII 兜底 - 静态目录会拦
.. / . 段(含 %2e%2e 解码后的形式),越界回 403 - HEAD 命中 GET 路由,响应体被剥掉但仍带真实
Content-Length
异步
handler 里随便 await,返回 Promise 也会被正确 await,不会被字符串化成垃圾:
$app.get: '/api/slow', -> $c {
# 要「等一会儿」请用 Promise.in / Promise.timer:
# sleep 是阻塞调用,会连整个调度线程一起卡住,并发请求全变串行。
my $p = Promise.in(1).then({ 'done' });
$c.json({ result => await $p });
};
$app.post: '/api/echo', -> $c {
my $payload = await $c.json-body;
$c.json({ received => $payload }, status => 201);
};
实测:两条独立连接同时打 1 秒接口,总耗时约 1.0~1.1 秒(而非 2 秒);同一连接上的连续请求仍然严格保序,不会串包。
中间件
$app.use: -> $c { # 前置:返回 Nil 继续,返回 Response 短路
$c.set('uid', 7);
Nil;
};
$app.after: -> $c, $response { # 后置:可改写响应
$response.append-header('X-Powered-By', 'Web::Lite');
$response;
};
内置(use Web::Lite::Middleware):
timing 记录起始时间access-log(:$sink, :$header) 打印一行访问日志并写 X-Response-Timecors(:$origin, :$methods, :$headers)bearer-auth('token') 校验 Authorization: Bearer,不匹配短路 401
注意:中间件闭包里不能用 return(Raku 的 return 会试图跳出外层 routine 直接炸),统一用表达式的值结束。
错误处理
$app.on-not-found: -> $c { $c.json({ error => 'not found' }, status => 404) };
$app.on-server-error: -> $c, $e { $c.text("boom: $e.message()", status: 500) };
handler 抛异常时,debug 模式会往 stderr 打一行,返回 500 或你的自定义响应。
运维层防护
默认值就能直接暴露到公网,不用额外挂 nginx 挡常见的低级攻击:
my $app = Web::Lite.new(
:timeout(30), # handler 处理上限,超时 504 并关连接
:read-timeout(15), # 请求头读超时,半截请求超时 408(防 slowloris)
:body-timeout(60), # 请求体读超时,body 传不完 408
:max-body-bytes(2 * 1024 * 1024), # 请求体上限 2MB,超出 413(不等收全就拒)
:shutdown-grace(10), # 优雅关闭宽限期
);
| 参数 | 默认 | 触发 | 挡住什么 |
|---|
timeout | 30s | 504 + close | 慢 handler 长期占连接 |
read-timeout | 15s | 408 + close | slowloris:只发半个头就挂着 |
body-timeout | 60s | 408 + close | 声明了 body 却只传一半 |
max-body-bytes | 2MB | 413 + close | 单客户端发大 body 吃满内存 |
shutdown-grace | 10s | 超时转强制断 | 重启时硬切在途请求 |
读超时按「出现半截请求的绝对时刻」算,不是「距上次收到数据的间隔」——
后者挡不住每秒滴一个字节的 trickle 变种。0 表示不限制。
优雅关闭
$app.run; # Ctrl-C 会自动排空,并打印 drained / forced close
my $drained = $app.shutdown; # 手工控制,用 shutdown-grace
$drained = $app.shutdown(:grace(3));
shutdown 三步走:停收新连接 → 等已有连接把手上请求做完(期间所有响应自动带
Connection: close,逼 keep-alive 客户端断开)→ 宽限期到点硬断。返回 True
表示靠自然排空收尾,False 表示是硬断。搭配 systemd KillSignal=SIGINT 或
k8s preStop 即可做到滚动重启零丢请求。
跑测试
prove6 -Ilib t/ # 或 raku -Ilib t/01-basic.rakutest
t/01-basic.rakutest 17 项:路由、参数、body、405/404、通配、中间件短路、异步t/02-async.rakutest 3 项:并发非阻塞、同连接保序不串包t/03-transport.rakutest 6 项:裸管道、拆包重组、keep-alive 保序t/04-hardening.rakutest 10 项:Parser 边界、错误出口统一、query 解码t/05-limits.rakutest 10 项:读超时 408、body 上限 413、slowloris 占位、优雅关闭t/06-features.rakutest 38 项:同步 body、Cookie 编解码、send-file/ETag/304、静态目录与路径穿越t/07-multipart-session.rakutest 36 项:multipart 中文文件名/多值保序、urlencoded、Session 签发与篡改、url_for
合计 120 项。各文件端口固定(18234 / 18241 / 18330 / 18350 / 18360 / 18370),必须串行跑,否则会撞端口。
实现要点(踩过的坑)
- 响应体必须
set-body:Cro::HTTP::Response.new(:body-blob(...)) 不会生效,得先 append-header('Content-Type', ...) 再 set-body($body.encode('utf-8')),统一封装在 Web::Lite::Response 里。 - 不用 Cro Router 而直接做
Cro::Transform:需要自己产出 Cro::HTTP::Response 并关联 $resp.request = $request,否则 HTTP/2 annotations 报「Cannot look up attributes in type object」。 - 同连接响应必须保序:
supply 里每个请求用 Promise 链串行化后再 emit,否则 keep-alive 下会串包(第二个请求收到第一个的 body)。 request.target 是 Str 不是 Cro::Uri:origin-form '/a/b?q=1',没有 path-segments 方法,得自己剥 ?query/#fragment 再切段。s{...} 字符类里的 0-9:Raku 要求写成 <[0..9]>,否则报 Unsupported use of - as character range。"\r\n\r\n" 在 Raku 里只占 2 个位置:CRLF 是单个 grapheme,$resp.index("\r\n\r\n") + 4 会吃掉 body 前两个字符。切头体用 $resp.split("\r\n\r\n", 2)。- 闭包里
return 会炸:-> $c { return ... } 报 Attempt to return outside of any Routine,需要提前返回就写匿名 sub ($c) { ... }(中间件也同理)。 any(...) 不能直接赋给 Bool:Junction 赋 Bool 变量会 Type check failed,套一层 so (...) 收敛。- 头值必须是 ASCII:Cro 会对非 ASCII 头值抛
Malformed header value,中文文件名走 RFC 5987 filename*=UTF-8''%XX。 name => $c.request 不能写成 :$c.request:后者会构造一个 Pair 当参数传进去,运行期报 No such method 'request' for invocant of type 'Pair'。同理 Part.new(:$ctype) 会被静默忽略——属性名是带连字符的 content-type,得写 content-type => $ctype。sleep 会卡住整个调度线程:start { sleep 1 } 在默认单线程调度器上等于全局串行,要「非阻塞地等」用 Promise.in / Promise.timer。Base64 模块别直接用在字节上:encode-base64($blob) 返回的是单字符 Str 的 Seq,decode-base64 又不认 Seq,往返得靠 iso-8859-1 中转。会话 cookie 索性手写 base64url(B64URL 表、无 padding),省掉全部歧义。- 快路径不能漏
return:!run 里快路径收尾若是 $ctx.adopt($resp); 而没 return $resp;,会继续落到 self!invoke(...),同一个 handler 被调用两次(GET 幂等所以测试全绿、只有耗时翻倍能看出来)。 Set-Cookie 贴头要按条数记账,不能用一个 Bool:「贴过了」的标记若在贴 0 条时就置位,后置钩子随后追加的 cookie 永远不会出现在响应里。用 $!cookies-flushed 计数,while ... < @!set-cookies.elems 补齐。"\x{FFFD}" 写法不被接受:要写 "\x[FFFD]";另外同名多值表单一律追加([|旧, 新]),别写成前置,否则顺序反了。
与 HTTP::Core 的关系
Web::Lite 是应用层框架,只关心路由/中间件/请求语义;底层网络、协议解析全部交给 Cro::HTTP。
手写 C 内核的 raku-HTTP-Core 是另一条线(协议栈研究,含自有 C 内核 + NativeCall),不参与本项目运行路径。
(内容由AI生成,仅供参考)