bash
作者: 一了 <1liao3@funlang.org>
日期: 2026-10-22
一句话总结: 这几天 Fun 的 `http-server` 从能回一句 hello 的架子,补上了查询、表单、Cookie、Basic 与 Bearer 认证、CORS 中间件,也把路由组、静态文件和响应头这些边角收干净了。这篇不列 API 清单,只说说这些零件怎么拼起来,哪里还留着边界。
HTTP 服务端,能写业务了
前几天它还只会回一句 hello。
现在再看 fun/lib/http-server.fun,从五百多行长到七百多行,旁边还多了 http-auth、http-bearer、http-cors 三个文件。路由、分组、中间件、静态文件、查询参数、表单、Cookie、认证、跨域,一个写业务的服务端该有的零件,基本齐了。这篇不打算做成 API 手册,手册在文件头里,我只想说说这些零件怎么拼起来,以及拼的时候踩过的几个坑。
先想清楚一次请求怎么走
一个最小的服务端,长这样:
bash
fun hello(req, res)
res.html('<h1>Hello from Fun!</h1>');
end fun;
var s = HttpServer(8080);
s.get('/', hello);
s.run();
HttpServer(8080) 只绑 127.0.0.1。要让别的机器连进来,得写 HttpServer(':8080') 或者 HttpServer('0.0.0.0:8080')。端口给 0 也可以,listen() 会把系统分到的端口返回来,测试里就用这一招。
请求进来以后,顺序是固定的:先过全局中间件,再匹配路由,命中了再跑路由自己那一串中间件,最后才是 handler。中间件长这样:
bash
fun auth(req, res, nx)
if req.header('token') <> 'secret' then
res.status(401).text('unauthorized');
else
nx();
end if;
end fun;
nx 就是「继续往下走」。不调用它,请求就停在这里,后面的路由和 handler 都不会执行。Fun 里 next 和 step 都是保留字,这个参数只能叫 nx,算是语言给 API 留下的一点印记。(吐槽:写路由的人,先得给关键字让路。)
中间件不是装饰
很多框架把中间件做成一条洋葱,进的时候一层层包,出的时候再一层层剥。Fun 这里没有标新立异,nx() 之前是前半段,nx() 之后是后半段:
bash
fun timing(req, res, nx)
var t0 = NowMs();
nx();
?. req.method & ' ' & req.path & ' ' & res.code & ' ' & int2str(NowMs() - t0) & 'ms';
end fun;
内置的 HttpLogger() 就是这么写的。它记录的是 res.code,所以必须等 nx() 回来以后再看,不然状态码还没定。
中间件可以是一个函数,也可以是一个带 run(req, res, nx) 方法的对象。前者适合无状态的小动作,后者适合要带配置的东西。Basic 和 Bearer 认证都是后者,HttpBasicAuth(verify, [realm: 'api']) 返回的其实是一个对象,Use 认的就是它的 run。
这里有个容易踩的次序问题:HttpServer.Use 的中间件在路由匹配之前跑,404 和 405 也会经过它;HttpGroup.Use 的中间件则是路由命中以后才跑。所以 CORS 这种要管预检、要管错误响应的东西,应该挂在 server 上,而不是挂在某个组里。组中间件还有一个细节,注册路由时会把当时的组中间件抄一份带走,所以先 Use 再 get,顺序反了,那一层就不生效。这个不算优雅,但很直白,看一眼源码就能明白。
路由:能写的路径,和不存在的路径
路径参数和通配符是很早就有的:
bash
fun user(req, res)
res.json([id: req.param('id')]);
end fun;
s.get('/users/:id', user);
s.static('/assets', './public');
/users/:id 里的 :id 会进 req.params,static 注册的 /* 则把剩下的整段路径放进 req.param('*')。匹配的时候,字面量段优先于参数段,参数段又优先于通配符。同样是 /users/42,/users/:id 会赢过 /*。要是路径能匹配上,但方法不对,服务端不回 404,而是回 405,并在 Allow 头里把该路径支持的方法列出来。这个细节很小,调接口的人会舒服很多。
路由组是为了少写前缀:
bash
var api = s.group('/api');
api.Use(HttpBearerAuth(verifyToken, [realm: 'api']));
api.get('/orders', orders);
组可以套组,中间件会一层层叠上去。前缀是直接拼字符串,匹配时再按 / 切段,所以多一个斜杠不会坏事。路径本身的规矩倒不少,.. 在静态文件里是直接拒绝的,免得有人顺着 URL 爬到目录外面去。
请求和响应,各自把脏活干了
req 这一侧,查询串、表单、Cookie 都做了 URL 解码。req.query('q') 读查询参数,req.form() 按需解析 application/x-www-form-urlencoded 的请求体,req.cookie('sid') 读 Cookie。请求体本身不用 handler 操心,底层的 winsock 层已经按 Content-Length 读完,连粘包都拆好了,交到手里就是一段完整的 body。req.json() 也是现成的,不过它用的是默认的宽松解析,坏 JSON 想拦在门外,得自己上 getJson(json: 2)。
res 这一侧是一串可以连起来写的方法:
bash
res.status(201)
.header('X-Request-Id', 'abc')
.cookie('sid', 'xyz', [path: '/', httpOnly: true, sameSite: 'Lax'])
.json([ok: true]);
text、html、json 都会顺手把 Content-Type 设好,redirect 设 Location 和状态码。send 只能调一次,第二次会直接抛异常,省得一个请求发出两份响应。多值头用 addHeader,Set-Cookie 和 Vary 都靠它,普通的 header 是覆盖。
安全和方便之间,这里做了几处不声不响的取舍。响应头名和值里不许出现 CR/LF,否则就是现成的头注入;Cookie 的名和值不许带分隔符,SameSite 只认 Lax、Strict、None 三个词;静态文件的相对路径里只要出现 ..,直接 404,不跟你讲道理。这些检查都不算聪明,但都放在该放的地方。
异常也有兜底。handler 里 raise 了,只要响应还没发出去,外层会回一个 500,正文带上异常信息;已经发出去一半的,就只能让它去了。
认证只负责认人
Basic 和 Bearer 两个中间件,形状是一样的:你给一个 verify(req, credentials),它负责把 Authorization 头拆开、解码、做基本的格式校验,然后把凭据交给你。认不认这个人,是你的业务:
bash
fun verifyToken(req, c)
var claims = Jwt.verify(c['token']);
if claims = nil then return false; end if;
result = [sub: claims.sub, roles: claims.roles];
end fun;
返回值有几种写法。false 或者 nil 就是 401;true 表示通过,但只有默认主体;返回一个集合,就把它放进 req.auth;集合里带 status 大于等于 400,就按自定义失败处理,message 和 headers 也一并认。status、message、headers 这三个键是保留的,业务字段别用这三个名字。
认证中间件只做认证,不做授权。角色够不够、这个订单是不是你的,仍然写在 handler 里。这一点分得很清,也省得中间件越写越像一锅粥。
几个细节值得记一笔。Basic 的密码是在 base64 里裸奔的,不是加密,线上必须上 HTTPS。Bearer 只从 Authorization 头里取 token,查询串和表单里的 token 一律不认,这是 RFC 6750 的建议,也省了日志里到处漏 token 的麻烦。验证完以后,req.credentials 会被清掉,req.auth 里只留主体;Bearer 的 token 连主体都不进。要是你只想解析、不想验证,HttpBasicAuth() 不带 verifier 就行,凭据留在 req.credentials 里,由你自己接手,也由你自己负责清掉。
401 的挑战头也按 RFC 来。Basic 回 Basic realm="...",Bearer 在 token 无效时多一个 error="invalid_token",头格式不对时是 invalid_request,没带头时只有 realm。optional: true 则是另一种场景:没带头就当匿名放过去,但头带了且是坏的,照样 401。
CORS 要站在认证前面
CORS 是这几笔里最容易被写错的一块。浏览器发的预检请求不带 Cookie,也不带 Authorization,它是一个 OPTIONS,只带 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers。要是把认证挂在 CORS 前面,预检先被 401 拦掉,后面的跨域请求根本发不出去。
所以 HttpCors 应该注册在 server 上,位置在认证之前:
bash
s.Use(HttpCors([
origins: ['https://app.example.com'],
methods: ['GET', 'POST'],
headers: ['Content-Type', 'Authorization'],
exposeHeaders: ['X-Request-Id'],
credentials: true,
maxAge: 600
]));
它会自己识别预检,检查来源、方法和请求头,通过了就直接回 204,连路由都不进。普通请求则加上 Access-Control-Allow-Origin 和 Vary: Origin,再放行。
默认的 origins 是 *,方便写公开接口。但 credentials: true 和 * 不能同时用,浏览器不认,这里也直接抛异常,逼你把来源写清楚。写了具体来源,响应里就回具体的来源,Vary: Origin 也一直带着,免得被 CDN 或反向代理缓存串了。请求头默认是「你要什么我反射什么」,想收紧就显式列 headers,列了以后不在表里的头会被 403。要按数据库或者租户动态判断来源,可以给 originCheck 一个函数,origins 就可以不写。
还有一句得说在前头:CORS 管的是浏览器能不能读响应,不是身份认证,也不是 CSRF 防护。它挡不住 curl,也挡不住同源的表单提交。把 CORS 当成安全边界,是另一类常见误会。
剩下的边界
这套东西现在是单线程的。run() 里是一个 poll 循环,网络事件和定时器都在这个循环里派发,handler 也是原地执行。写起来简单,调试也简单,代价是 handler 里不能阻塞:读一个大文件、等一个慢查询、sleep 几秒,整个服务端就跟着停。真要扛并发,前面还得放 nginx,Fun 这边做业务逻辑。
平台也有边界。Linux 和 macOS 上 listen、runOnce、run 都能用,Windows 的 winsock 后端还停在消息泵那一套,Start 要窗口句柄和消息号,这一层还没接上。HTTP 本身也只做了一个子集:请求体只认 Content-Length,不认 chunked;没有 TLS,没有 HTTP/2;响应是 Content-Length 加 keep-alive,够用,但别拿它去对标 nginx。
还有一个实现上的小脾气:模块里用一个全局变量记住当前应用,一个进程里按一个 HttpServer 来用最稳。多个实例各自监听,路由却会认最后一个构造出来的应用。这个坑不大,但踩上去会愣一下,姑且记在这里。
测试倒是不依赖这些。lib-http-test 自己开 socket,往端口上发原始 HTTP 文本,再用 runOnce 一下一下地泵,断言全是布尔值,不借外部的 curl,也不借浏览器。CORS 和 Bearer 各有一份这样的测试。服务端是单线程的,反而让这种写法特别顺手。
结语
一周前它还是个 demo,现在能挂路由、能分组、能认人、能处理跨域,也能把 Cookie 和表单这些琐事接住。代码仍然只有七百多行,没有框架魔法,没有隐藏的线程池,出了问题顺着调用链读下去,总能读到根上。
它大概还是不适合直接对着公网,但拿来写一个内部服务、一个回调接收端、一个给前端联调用的假后端,已经够了。剩下的,交给前面的 nginx 和后面的数据库。各司其职,挺好。