HTTP 服务端,能写业务了

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 和后面的数据库。各司其职,挺好。

相关推荐
电科_银尘1 小时前
【论文阅读】Deep Image Prior:随机初始化的网络为什么也能重建图像?
网络·论文阅读
情 九11 小时前
Dify HTTP 请求节点实战:发送 JSON、解析响应与处理异常
网络协议·http·json·dify
Cry丶11 小时前
ubuntu安装vsftpd
服务器·网络·ubuntu
SHENGWEI胜为14 小时前
交换机端口显示正常,终端却获取不到 IP 地址?从 DHCP 流程定位故障
服务器·网络·测试工具·缓存·通信工程
ITxiaobing202315 小时前
海外广告投放的 IP 定位服务怎么选?一份基于实测数据的选型参考
网络协议·tcp/ip·php
IMPYLH16 小时前
Linux 系统备份与镜像
linux·服务器·网络
cfm_291416 小时前
初步了解ECDHE密钥交换
http
淘气包海鸟17 小时前
水声网络框架
网络·ns3·水
Lsetea18 小时前
curl -I检查HTTPS返回405:HEAD、GET与证书验收分层排查
linux·http·https·ssl证书·curl