Rand Stats

Web::Lite

zef:skyter10086

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

命名路由与 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 对象(调试用)

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-bodyPromise,异步读 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,
        });
    }
};

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') };
成员说明
$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 是副本,改了不影响会话
$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-onlyTrue默认开,挡 XSS 偷 token;前端要读才关
:secure / :same-site / :domain / :max-age / :expires未设需要就传
:rawFalse默认对值做百分号编码(cookie 只允许 ASCII 子集)

静态文件与 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');

异步

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):

注意:中间件闭包里不能用 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),               # 优雅关闭宽限期
);
参数默认触发挡住什么
timeout30s504 + close慢 handler 长期占连接
read-timeout15s408 + closeslowloris:只发半个头就挂着
body-timeout60s408 + close声明了 body 却只传一半
max-body-bytes2MB413 + close单客户端发大 body 吃满内存
shutdown-grace10s超时转强制断重启时硬切在途请求

读超时按「出现半截请求的绝对时刻」算,不是「距上次收到数据的间隔」—— 后者挡不住每秒滴一个字节的 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

合计 120 项。各文件端口固定(18234 / 18241 / 18330 / 18350 / 18360 / 18370),必须串行跑,否则会撞端口。

实现要点(踩过的坑)

  1. 响应体必须 set-body:Cro::HTTP::Response.new(:body-blob(...)) 不会生效,得先 append-header('Content-Type', ...) 再 set-body($body.encode('utf-8')),统一封装在 Web::Lite::Response 里。
  2. 不用 Cro Router 而直接做 Cro::Transform:需要自己产出 Cro::HTTP::Response 并关联 $resp.request = $request,否则 HTTP/2 annotations 报「Cannot look up attributes in type object」。
  3. 同连接响应必须保序:supply 里每个请求用 Promise 链串行化后再 emit,否则 keep-alive 下会串包(第二个请求收到第一个的 body)。
  4. request.target 是 Str 不是 Cro::Uri:origin-form '/a/b?q=1',没有 path-segments 方法,得自己剥 ?query/#fragment 再切段。
  5. s{...} 字符类里的 0-9:Raku 要求写成 <[0..9]>,否则报 Unsupported use of - as character range。
  6. "\r\n\r\n" 在 Raku 里只占 2 个位置:CRLF 是单个 grapheme,$resp.index("\r\n\r\n") + 4 会吃掉 body 前两个字符。切头体用 $resp.split("\r\n\r\n", 2)。
  7. 闭包里 return 会炸:-> $c { return ... } 报 Attempt to return outside of any Routine,需要提前返回就写匿名 sub ($c) { ... }(中间件也同理)。
  8. any(...) 不能直接赋给 Bool:Junction 赋 Bool 变量会 Type check failed,套一层 so (...) 收敛。
  9. 头值必须是 ASCII:Cro 会对非 ASCII 头值抛 Malformed header value,中文文件名走 RFC 5987 filename*=UTF-8''%XX。
  10. name => $c.request 不能写成 :$c.request:后者会构造一个 Pair 当参数传进去,运行期报 No such method 'request' for invocant of type 'Pair'。同理 Part.new(:$ctype) 会被静默忽略——属性名是带连字符的 content-type,得写 content-type => $ctype。
  11. sleep 会卡住整个调度线程:start { sleep 1 } 在默认单线程调度器上等于全局串行,要「非阻塞地等」用 Promise.in / Promise.timer。
  12. Base64 模块别直接用在字节上:encode-base64($blob) 返回的是单字符 Str 的 Seq,decode-base64 又不认 Seq,往返得靠 iso-8859-1 中转。会话 cookie 索性手写 base64url(B64URL 表、无 padding),省掉全部歧义。
  13. 快路径不能漏 return:!run 里快路径收尾若是 $ctx.adopt($resp); 而没 return $resp;,会继续落到 self!invoke(...),同一个 handler 被调用两次(GET 幂等所以测试全绿、只有耗时翻倍能看出来)。
  14. Set-Cookie 贴头要按条数记账,不能用一个 Bool:「贴过了」的标记若在贴 0 条时就置位,后置钩子随后追加的 cookie 永远不会出现在响应里。用 $!cookies-flushed 计数,while ... < @!set-cookies.elems 补齐。
  15. "\x{FFFD}" 写法不被接受:要写 "\x[FFFD]";另外同名多值表单一律追加([|旧, 新]),别写成前置,否则顺序反了。

与 HTTP::Core 的关系

Web::Lite 是应用层框架,只关心路由/中间件/请求语义;底层网络、协议解析全部交给 Cro::HTTP。

手写 C 内核的 raku-HTTP-Core 是另一条线(协议栈研究,含自有 C 内核 + NativeCall),不参与本项目运行路径。 (内容由AI生成,仅供参考)