FastNim
受 FastAPI 启发的 Nim 2 Web 框架 — 仅用标准库,极致性能,简洁语法
介绍
FastNim 是一个受 Python FastAPI 启发的 Nim 2 Web 框架,设计目标是用 Nim 原生的语法风格提供类似 FastAPI 的开发体验,同时保持 Nim 语言本身的极致性能。
核心特性
- 零第三方依赖 — 完全基于 Nim 标准库(
asynchttpserver、json、asyncnet、sha1等)实现 - 简洁的 DSL 语法 — 使用 Nim 模板宏实现
app.get、app.post等路由注册,代码即文档 - 路径参数 — 支持
/users/{id}风格的路径参数自动提取 - 查询参数 — 自动解析 URL 查询字符串
- JSON / 表单 / 文件上传 — 内置 JSON、URL 编码表单、multipart 表单解析
- 中间件 — 前置和后置中间件,支持请求拦截和响应修改
- CORS 跨域 — 一行代码启用 CORS,支持预检请求
- WebSocket — 完整实现 RFC 6455,支持文本/二进制消息、Ping/Pong
- 静态文件 — 挂载目录提供静态文件服务,自动 MIME 类型检测
- 异常处理 — 自定义 HTTP 异常处理器
- 生命周期钩子 — 启动和关闭时的回调函数
- Cookie 操作 — 设置、获取、删除 Cookie
与 FastAPI 的对比
| 特性 | FastAPI | FastNim |
|---|---|---|
| 语言 | Python | Nim 2 |
| 异步 | asyncio | asyncdispatch |
| 路由注册 | @app.get 装饰器 | app.get 模板 |
| 路径参数 | {id} | {id} |
| 依赖注入 | Depends() | 中间件 + ctx.state |
| 自动文档 | Swagger / ReDoc | 不支持(按需) |
| 第三方依赖 | Starlette, Pydantic 等 | 无(纯标准库) |
| 性能 | 高(ASGI) | 极高(编译为原生代码) |
安装与配置
环境要求
- Nim 2.2.0 或更高版本
- 支持 Linux、macOS、Windows
获取 FastNim
FastNim 是一个纯 Nim 源码库,无需安装二进制包。将源码放入项目即可使用。
方式一:直接复制源码
将 src/fastnim.nim 和 src/fastnim/ 目录复制到你的项目中。
方式二:通过 nimble 安装(如果有 nimble 包)
# 在项目目录下
nimble install fastnim
项目配置
在你的项目中创建 nim.cfg 文件,添加以下配置:
# nim.cfg
--threads:off
--mm:orc
--path:src
Nim 的异步 HTTP 服务器在单线程模式下运行效率最高。--threads:off 避免了 GC 安全检查的限制,同时 --mm:orc 提供了更好的内存管理。
目录结构
myapp/
├── nim.cfg # 编译配置
├── main.nim # 主程序
└── src/
├── fastnim.nim # 框架入口
└── fastnim/ # 框架子模块
├── types.nim
├── router.nim
├── context.nim
├── websocket.nim
└── multipart.nim
快速开始
以下是一个最简单的 FastNim 应用,只需 5 行代码即可启动一个 HTTP 服务器:
import fastnim
let app = newApp()
app.get "/":
ctx.json(%*{"message": "Hello, World!"})
app.run()
编译并运行:
nim c -r main.nim
# FastNim server running on http://0.0.0.0:8080
测试:
curl http://localhost:8080/
# {"message":"Hello, World!"}
代码解析
import fastnim— 导入框架,自动导出所有必要的类型和函数newApp()— 创建应用实例,默认监听0.0.0.0:8080app.get "/"— 注册 GET 路由,ctx变量在处理体内自动可用ctx.json(...)— 返回 JSON 响应,%*{}是 Nim 的 JSON 字面量语法app.run()— 启动服务器(阻塞调用)
ctx 是框架自动注入的上下文变量,无需手动声明。它封装了请求和响应的所有信息。
路由
FastNim 支持所有标准 HTTP 方法。每个路由由 HTTP 方法、路径模式和处理器组成。
HTTP 方法
框架为每种 HTTP 方法提供了对应的注册模板:
let app = newApp()
# GET 请求
app.get "/":
ctx.json(%*{"method": "GET"})
# POST 请求
app.post "/create":
ctx.json(%*{"method": "POST"}, Http201)
# PUT 请求(完整更新)
app.put "/update/{id}":
ctx.json(%*{"method": "PUT", "id": ctx.pathParam("id")})
# PATCH 请求(部分更新)
app.patch "/patch/{id}":
ctx.json(%*{"method": "PATCH"})
# DELETE 请求
app.delete "/delete/{id}":
ctx.json(%*{"method": "DELETE"})
# HEAD 请求(只返回头,不返回体)
app.head "/health":
ctx.setHeader("X-Status", "healthy")
ctx.noContent()
# OPTIONS 请求
app.options "/api":
ctx.noContent()
路由匹配规则
- 路径匹配区分大小写
- 尾部斜杠自动忽略(
/users和/users/等价) - 静态段优先于参数段匹配
- 未匹配到路由时返回 404
指定监听端口和地址
# 默认 0.0.0.0:8080
app.run()
# 自定义端口
app.run(3000)
# 自定义地址和端口
app.run(3000, "127.0.0.1")
路径参数
路径参数使用 {参数名} 语法在路由模式中声明,框架会自动从 URL 中提取并填充到 ctx.pathParams。
# 单个路径参数
app.get "/users/{id}":
let id = ctx.pathParam("id")
ctx.json(%*{"user_id": id})
# 多个路径参数
app.get "/users/{userId}/posts/{postId}":
let userId = ctx.pathParam("userId")
let postId = ctx.pathParam("postId")
ctx.json(%*{"user": userId, "post": postId})
# 转为整数
app.get "/items/{id}":
let id = ctx.pathParamInt("id")
ctx.json(%*{"id": id, "type": "number"})
# 带默认值
app.get "/page/{num}":
let page = ctx.pathParamIntOrDefault("num", 1)
ctx.json(%*{"page": page})
路径参数 API
| 方法 | 返回类型 | 说明 |
|---|---|---|
ctx.pathParam(name) | string | 获取路径参数值 |
ctx.pathParamOrDefault(name, default) | string | 获取路径参数,不存在时返回默认值 |
ctx.pathParamInt(name) | int | 获取路径参数并转为 int |
ctx.pathParamIntOrDefault(name, default) | int | 获取路径参数并转为 int,带默认值 |
查询参数
查询参数是 URL 中 ? 之后的部分,格式为 key1=value1&key2=value2。框架自动解析并存储在 ctx.queryParams 中。
# 基本用法
# GET /search?q=nim&limit=10
app.get "/search":
let q = ctx.queryParam("q")
let limit = ctx.queryParamOrDefault("limit", "10")
ctx.json(%*{"query": q, "limit": limit})
# 转为整数
# GET /products?page=2&page_size=20
app.get "/products":
let page = ctx.queryParamIntOrDefault("page", 1)
let pageSize = ctx.queryParamIntOrDefault("page_size", 10)
ctx.json(%*{"page": page, "page_size": pageSize})
# 布尔值
# GET /api?verbose=true
app.get "/api":
let verbose = ctx.queryParamBool("verbose")
ctx.json(%*{"verbose": verbose})
# 检查参数是否存在
app.get "/filter":
if ctx.hasQueryParam("category"):
let category = ctx.queryParam("category")
ctx.json(%*{"category": category})
else:
ctx.json(%*{"error": "缺少 category 参数"}, Http400)
# 获取所有查询参数
app.get "/debug":
var params = newJObject()
for key, val in ctx.allQueryParams():
params[key] = %val
ctx.json(params)
查询参数 API
| 方法 | 返回类型 | 说明 |
|---|---|---|
ctx.queryParam(name) | string | 获取查询参数值 |
ctx.queryParamOrDefault(name, default) | string | 获取查询参数,带默认值 |
ctx.queryParamInt(name) | int | 获取查询参数并转为 int |
ctx.queryParamIntOrDefault(name, default) | int | 获取查询参数并转为 int,带默认值 |
ctx.queryParamBool(name) | bool | 获取查询参数并转为 bool |
ctx.hasQueryParam(name) | bool | 检查查询参数是否存在 |
ctx.allQueryParams() | Table | 获取所有查询参数 |
请求体处理
FastNim 支持三种请求体格式:JSON、URL 编码表单和 multipart 表单(含文件上传)。
JSON 请求体
使用 ctx.jsonBody() 将请求体解析为 JsonNode:
# POST /users Content-Type: application/json
# {"name": "Alice", "age": 30}
app.post "/users":
let body = ctx.jsonBody()
let name = body["name"].getStr()
let age = body["age"].getInt()
ctx.json(%*{"created": {"name": name, "age": age}}, Http201)
# 安全解析(解析失败返回默认值)
app.post "/data":
let body = ctx.jsonBodyOrDefault(%*{"error": "invalid"})
ctx.json(body)
# 检查是否为 JSON 请求
app.post "/api":
if ctx.isJson():
let data = ctx.jsonBody()
ctx.json(%*{"received": data})
else:
ctx.json(%*{"error": "请发送 JSON"}, Http400)
# 获取原始请求体
app.post "/raw":
let raw = ctx.body()
ctx.text("Received: " & raw)
表单数据
解析 application/x-www-form-urlencoded 格式的表单数据:
# POST /login Content-Type: application/x-www-form-urlencoded
# username=admin&password=123456
app.post "/login":
let form = ctx.formBody()
let username = form.getOrDefault("username")
let password = form.getOrDefault("password")
if username == "admin" and password == "123456":
ctx.json(%*{"message": "登录成功"})
else:
ctx.json(%*{"error": "用户名或密码错误"}, Http401)
# 遍历所有表单字段
app.post "/form":
let form = ctx.formBody()
var result = newJObject()
for key, val in form:
result[key] = %val
ctx.json(%*{"received": result})
文件上传
使用 ctx.multipartBody() 解析 multipart/form-data 格式的请求体,支持文件上传:
# POST /upload Content-Type: multipart/form-data
app.post "/upload":
if ctx.isMultipart():
let form = ctx.multipartBody()
# 获取普通字段
let description = form.getField("description")
# 获取上传的文件
if form.hasFile("file"):
let file = form.files["file"]
ctx.json(%*{
"filename": file.filename,
"contentType": file.contentType,
"size": file.data.len,
"description": description
})
else:
ctx.json(%*{"error": "未找到文件"}, Http400)
else:
ctx.json(%*{"error": "请使用 multipart/form-data"}, Http400)
# 多文件上传
app.post "/upload-multi":
let form = ctx.multipartBody()
var filesInfo = newJObject()
for name, file in form.files:
filesInfo[name] = %*{
"filename": file.filename,
"size": file.data.len
}
ctx.json(%*{"files": filesInfo})
UploadedFile 包含三个字段:filename(原始文件名)、contentType(MIME 类型)、data(文件内容字符串)。
请求头
FastNim 提供了完整的请求头访问接口:
# 获取单个请求头
app.get "/api":
let auth = ctx.getHeader("Authorization")
let ua = ctx.userAgent()
let ct = ctx.contentType()
ctx.json(%*{"auth": auth, "ua": ua, "ct": ct})
# 带默认值
app.get "/safe":
let accept = ctx.getHeaderOrDefault("Accept", "*/*")
ctx.text(accept)
# 检查请求头是否存在
app.get "/check":
if ctx.hasHeader("X-API-Key"):
ctx.json(%*{"status": "authorized"})
else:
ctx.json(%*{"error": "Missing API Key"}, Http401)
# 获取所有请求头
app.get "/headers":
var headers = newJObject()
for key, val in ctx.allHeaders():
headers[key] = %val
ctx.json(headers)
# Bearer Token 提取
app.get "/profile":
let token = ctx.bearerToken()
if token.len == 0:
ctx.json(%*{"error": "未授权"}, Http401)
return
ctx.json(%*{"token": token})
便捷属性
| 方法 | 说明 |
|---|---|
ctx.getHeader(name) | 获取请求头值 |
ctx.getHeaderOrDefault(name, default) | 获取请求头,带默认值 |
ctx.hasHeader(name) | 检查请求头是否存在 |
ctx.allHeaders() | 获取所有请求头 |
ctx.contentType() | 获取 Content-Type |
ctx.contentLength() | 获取 Content-Length |
ctx.userAgent() | 获取 User-Agent |
ctx.authorization() | 获取 Authorization 头 |
ctx.bearerToken() | 提取 Bearer Token |
响应类型
FastNim 提供了多种响应类型,满足不同的 API 需求:
JSON 响应
app.get "/json":
ctx.json(%*{"message": "Hello"})
# 自定义状态码
app.post "/create":
ctx.json(%*{"id": 1}, Http201)
# 状态码在前(两种写法等价)
app.get "/error":
ctx.json(Http404, %*{"error": "Not Found"})
HTML 响应
app.get "/page":
ctx.html("<h1>Hello, World!</h1><p>Welcome to FastNim</p>")
纯文本响应
app.get "/text":
ctx.text("Plain text response")
重定向
app.get "/old":
ctx.redirect("/new") # 302 临时重定向
app.get "/moved":
ctx.redirect("/new", Http301) # 301 永久重定向
无内容响应 (204)
app.delete "/items/{id}":
# 删除逻辑...
ctx.noContent() # 204 No Content
原始响应
app.get "/xml":
ctx.raw("<?xml version=\"1.0\"?><root>data</root>",
"application/xml")
自定义响应头和状态码
app.get "/custom":
ctx.status(Http200)
ctx.setHeader("X-Custom-Header", "value")
ctx.setHeader("Cache-Control", "no-cache")
ctx.json(%*{"msg": "custom response"})
错误响应
app.get "/error":
ctx.errorResponse(Http400, "参数错误")
# 返回: {"error":"参数错误","status":400}
中间件
中间件是在请求处理前/后执行的函数,用于实现日志、认证、CORS 等横切关注点。
前置中间件
前置中间件在路由处理器之前执行。如果中间件设置了响应(ctx.sent = true),路由处理器不会被调用。
# 日志中间件
app.middleware:
echo ctx.httpMethod(), " ", ctx.path(), " from ", ctx.clientIP()
# 认证中间件
app.middleware:
let path = ctx.path()
if path.startsWith("/api/") and path != "/api/login":
let token = ctx.bearerToken()
if token.len == 0:
ctx.json(%*{"error": "未授权"}, Http401)
return # 短路,不执行后续中间件和路由处理器
# 添加自定义响应头
app.middleware:
ctx.setHeader("X-Powered-By", "FastNim")
ctx.setHeader("X-Request-ID", $getTime().toUnix())
# 使用 ctx.state 在中间件间传递数据
app.middleware:
ctx.state["user_id"] = "12345"
后置中间件
后置中间件在路由处理器之后执行,可以修改已设置的响应:
app.afterMiddleware:
# 记录响应状态
echo "Response: ", ctx.response.statusCode.int
# 添加响应时间头
ctx.setHeader("X-Response-Time", "5ms")
前置中间件中使用 return 可以短路请求链。当 ctx.sent 为 true 时,后续中间件和路由处理器都不会执行。
错误处理
抛出 HTTP 异常
使用 raiseHttpError 在处理器中抛出 HTTP 异常:
app.get "/users/{id}":
let id = ctx.pathParamInt("id")
if id > 100:
raiseHttpError(Http404, "用户不存在")
if id == 0:
raiseHttpError(Http400, "ID 不能为 0")
ctx.json(%*{"id": id})
app.post "/users":
let body = ctx.jsonBody()
if not body.hasKey("name"):
raiseHttpError(Http400, "缺少 name 字段")
ctx.json(%*{"created": true}, Http201)
自定义异常处理器
使用 app.exceptionHandler 注册自定义异常处理器,覆盖默认的错误响应格式:
# 404 处理器
app.exceptionHandler(404):
ctx.json(%*{
"error": "资源不存在",
"path": ctx.path(),
"method": $ctx.httpMethod()
}, Http404)
# 500 处理器
app.exceptionHandler(500):
ctx.json(%*{
"error": "服务器内部错误",
"timestamp": $getTime().toUnix()
}, Http500)
# 401 处理器
app.exceptionHandler(401):
ctx.setHeader("WWW-Authenticate", "Bearer")
ctx.json(%*{"error": "请先登录"}, Http401)
异常处理流程
- 路由处理器抛出
HttpError - 框架检查是否有对应状态码的自定义异常处理器
- 如果有,调用自定义处理器;如果没有,返回默认错误 JSON
- 对于未捕获的
CatchableError,按 500 处理
CORS 跨域
一行代码即可启用 CORS 支持:
# 基本启用(允许所有源)
app.enableCors()
# 自定义配置
app.enableCors(
allowOrigins = @["https://example.com", "https://app.example.com"],
allowMethods = @["GET", "POST"],
allowHeaders = @["Content-Type", "Authorization"],
allowCredentials = true,
maxAge = 3600,
exposeHeaders = @["X-Custom-Header"]
)
# 使用 CorsConfig 对象
app.enableCors(CorsConfig(
allowOrigins: @["*"],
allowMethods: @["GET", "POST", "PUT", "DELETE"],
allowHeaders: @["*"],
allowCredentials: false,
maxAge: 600,
exposeHeaders: @[]
))
启用 CORS 后,框架会自动处理 OPTIONS 预检请求,返回 204 No Content 和相应的 CORS 头。你无需为 CORS 单独注册 OPTIONS 路由。
文件下载与传输
FastNim 提供完整的文件下载和传输功能,包括内联显示、强制下载、动态文件生成、分块传输和 Range 请求支持。
发送文件(内联显示)
ctx.sendFile() 发送文件,浏览器可能直接内联显示(如图片、PDF):
app.get "/images/{name}":
ctx.sendFile("./uploads/" & ctx.pathParam("name"))
强制下载(附件)
ctx.download() 设置 Content-Disposition: attachment,强制浏览器下载文件:
app.get "/download/{name}":
ctx.download("./uploads/" & ctx.pathParam("name"))
# 自定义下载文件名
app.get "/report":
ctx.download("./data/report.pdf", fileName = "月度报告.pdf")
动态生成下载文件
ctx.downloadBytes() 发送字节数据作为下载文件,适用于动态生成 CSV、Excel 等:
app.get "/export/csv":
let csv = "name,age\nAlice,30\nBob,25\n"
ctx.downloadBytes(csv, "users.csv", "text/csv")
分块传输大文件
ctx.sendFileChunked() 分块读取文件,避免一次性加载到内存:
app.get "/video/{name}":
ctx.sendFileChunked("./videos/" & ctx.pathParam("name"), chunkSize = 65536)
Range 请求(视频流 / 断点续传)
ctx.serveFileRange() 自动处理 Range 请求头,返回 206 Partial Content,支持视频流和断点续传:
app.get "/stream/{name}":
ctx.serveFileRange("./media/" & ctx.pathParam("name"))
客户端发送 Range: bytes=0-1023 请求头时,服务器返回指定范围的数据,并设置 Content-Range 和 Accept-Ranges: bytes 响应头。
| 方法 | 用途 | 典型场景 |
|---|---|---|
sendFile | 内联发送文件 | 图片预览、PDF 在线查看 |
download | 强制下载 | 文件下载按钮 |
downloadBytes | 动态内容下载 | CSV/Excel 导出 |
sendFileChunked | 分块传输 | 大文件传输 |
serveFileRange | Range 请求 | 视频流、断点续传 |
框架自动将 HEAD 请求映射到对应的 GET 路由,返回相同的响应头但不返回 body。这意味着 curl -I 可以正常获取文件信息(Content-Type、Content-Length 等)而无需下载完整文件。
静态文件
使用 app.mount 挂载静态文件目录:
# 挂载 ./public 目录到 /static 路径
app.mount("/static", "./public")
# 访问 http://localhost:8080/static/index.html
# 会返回 ./public/index.html 文件
# 多个挂载点
app.mount("/css", "./assets/css")
app.mount("/js", "./assets/js")
app.mount("/images", "./assets/images")
框架自动防止目录遍历攻击(../ 路径会被拒绝)。访问目录根路径时会自动尝试返回 index.html。
框架会根据文件扩展名自动设置 Content-Type:
| 扩展名 | Content-Type |
|---|---|
| .html | text/html |
| .css | text/css |
| .js | application/javascript |
| .json | application/json |
| .png | image/png |
| .jpg | image/jpeg |
| .svg | image/svg+xml |
| application/pdf |
WebSocket
FastNim 实现了完整的 WebSocket 协议(RFC 6455),支持文本/二进制消息、Ping/Pong 和分片消息。
基本用法
# 注册 WebSocket 路由
app.websocket "/ws":
# ws 变量自动可用,代表 WebSocket 连接
# ctx 变量也可用,代表请求上下文
# 发送欢迎消息
await ws.sendText("连接成功!")
# 消息循环
while not ws.isClosed:
let msg = await ws.recvMessage()
case msg.kind:
of wsmText:
# 回显文本消息
await ws.sendText("Echo: " & msg.text)
of wsmBinary:
# 回显二进制消息
await ws.sendBinary(msg.data)
of wsmClose:
# 客户端关闭连接
break
else:
discard
发送 JSON 消息
app.websocket "/ws/json":
await ws.sendJson(%*{"type": "welcome", "message": "Hello!"})
while not ws.isClosed:
let msg = await ws.recvMessage()
if msg.kind == wsmText:
let data = parseJson(msg.text)
await ws.sendJson(%*{"type": "response", "data": data})
WebSocket API
| 方法 | 说明 |
|---|---|
ws.sendText(text) | 发送文本消息 |
ws.sendBinary(data) | 发送二进制消息 |
ws.sendJson(data) | 发送 JSON 消息 |
ws.recvMessage() | 接收消息(自动处理 Ping/Pong) |
ws.ping(data) | 发送 Ping 帧 |
ws.pong(data) | 发送 Pong 帧 |
ws.close(code, reason) | 关闭连接 |
ws.isClosed() | 检查连接是否已关闭 |
recvMessage() 返回 WebSocketMessage 对象,kind 字段表示消息类型:wsmText(文本)、wsmBinary(二进制)、wsmClose(关闭)、wsmPing、wsmPong。Ping 消息会自动回复 Pong,无需手动处理。
生命周期事件
FastNim 提供了启动和关闭时的生命周期钩子:
# 启动时执行(服务器开始监听前)
app.onStartup:
echo "初始化数据库连接..."
# 这里可以执行数据库连接、加载配置等初始化操作
# 关闭时执行(Ctrl+C 触发)
app.onShutdown:
echo "关闭数据库连接..."
# 这里可以执行资源清理操作
# 多个钩子按注册顺序执行
app.onStartup:
echo "加载缓存..."
app.onStartup:
echo "启动后台任务..."
当用户按下 Ctrl+C 时,框架会先执行所有 onShutdown 钩子,然后退出程序。这确保了资源的正确释放。
后台任务
后台任务是在服务器启动后异步执行的任务,不会阻塞请求处理:
# 定期清理过期 session
proc cleanupTask(): Future[void] {.async.} =
while true:
await sleepAsync(60000) # 每分钟执行一次
echo "清理过期 session..."
# 清理逻辑...
app.onStartup:
app.addBackgroundTask(cleanupTask)
# 发送邮件等异步操作
proc sendEmail(to, subject, body: string): Future[void] {.async.} =
await sleepAsync(1000) # 模拟发送
echo "邮件已发送到: ", to
app.post "/register":
let body = ctx.jsonBody()
let email = body["email"].getStr()
# 异步发送邮件,不阻塞响应
asyncCheck sendEmail(email, "欢迎注册", "...")
ctx.json(%*{"message": "注册成功"}, Http201)
API 文档(Swagger/OpenAPI)
FastNim 实现了 FastAPI 风格的自动 API 文档生成功能,包括 Swagger UI、ReDoc 和 OpenAPI JSON Schema。文档默认启用,无需额外配置。
访问文档
| 路径 | 说明 |
|---|---|
/docs | Swagger UI 交互式文档(可在线测试 API) |
/redoc | ReDoc 文档(只读,排版更美观) |
/openapi.json | OpenAPI 3.0 JSON Schema(可导入其他工具) |
配置文档
# 默认启用,可自定义标题和版本
app.enableDocs("我的 API", "2.0.0", "这是一个示例 API 服务")
# 禁用文档
app.disableDocs()
带文档的路由注册
使用 docGet、docPost 等模板注册路由时,可同时提供摘要和标签:
# 带文档的 GET 路由
app.docGet "/users", "获取用户列表", ["users"]:
ctx.json(%*{"users": []})
# 带文档的 POST 路由
app.docPost "/users", "创建用户", ["users"]:
let body = ctx.jsonBody()
ctx.json(%*{"id": 1, "name": body["name"].getStr()}, Http201)
# 带文档的 DELETE 路由
app.docDelete "/users/{id}", "删除用户", ["users"]:
ctx.noContent()
# 普通路由也可用 describeLast 补充文档
app.get "/health":
ctx.json(%*{"status": "ok"})
app.describeLast(summary = "健康检查", tags = ["system"])
请求体文档(文件上传 / JSON Body)
为了让 Swagger UI 显示文件上传控件或 JSON 输入框,需要为路由添加请求体文档。框架提供三个便捷方法:
# 文件上传 — Swagger UI 会显示 "Choose File" 按钮
app.docPost "/upload", "文件上传", ["files"]:
let form = ctx.multipartBody()
let file = form.files["file"]
ctx.json(%*{"filename": file.filename, "size": file.data.len})
app.describeFileUpload("file", @[
DocField(name: "description", kind: dfkString, required: false, description: "文件描述")
], "上传文件,支持任意类型")
# JSON Body — Swagger UI 会显示 JSON 输入框
app.docPost "/users", "创建用户", ["users"]:
let body = ctx.jsonBody()
ctx.json(%*{"id": 1, "name": body["name"].getStr()}, Http201)
app.describeJsonBody(@[
DocField(name: "name", kind: dfkString, required: true, description: "用户名"),
DocField(name: "email", kind: dfkString, required: true, description: "邮箱"),
DocField(name: "age", kind: dfkInteger, required: false, description: "年龄")
])
# 通用方法 — 支持所有请求体类型
app.describeBody(dbkFormUrlencoded, @[
DocField(name: "username", kind: dfkString, required: true),
DocField(name: "password", kind: dfkString, required: true)
])
字段类型(DocFieldKind):
| 类型 | 说明 | OpenAPI 映射 |
|---|---|---|
dfkString | 字符串 | type: string |
dfkInteger | 整数 | type: integer |
dfkNumber | 数字 | type: number |
dfkBoolean | 布尔 | type: boolean |
dfkFile | 文件 | type: string, format: binary |
请求体类型(DocBodyKind):
| 类型 | Content-Type | 说明 |
|---|---|---|
dbkJson | application/json | JSON 请求体 |
dbkMultipart | multipart/form-data | 文件上传 |
dbkFormUrlencoded | application/x-www-form-urlencoded | 表单提交 |
使用 describeFileUpload 后,Swagger UI 的 /docs 页面会显示 Choose File 按钮,可以直接在浏览器中选择文件并测试上传。这是通过 OpenAPI 的 format: binary 字段类型实现的。
框架自动从路由注册信息中提取路径、方法、路径参数,生成 OpenAPI 3.0 JSON。使用 docGet/docPost 等模板注册的路由会包含摘要和标签信息。Swagger UI 和 ReDoc 通过 CDN 加载,无需安装额外依赖。
API 参考
本节列出 FastNim 所有公开 API,按模块分类整理。
App 类型与构造
| API | 签名 | 说明 |
|---|---|---|
newApp | newApp(address = "0.0.0.0", port = 8080): App | 创建应用实例,可指定监听地址和端口 |
app.run | app.run(port = 0, address = "") | 启动服务器(阻塞),port/address 为 0/空时使用构造时的值 |
app.runAsync | app.runAsync(port = 0, address = "") | 异步启动服务器,返回 Future |
app.state | Table[string, string] | 全局应用状态,可在任意位置读写 |
app.routes | seq[Route] | 已注册的路由列表 |
路由注册模板
| 模板 | 说明 |
|---|---|
app.get(path, body) | 注册 GET 路由 |
app.post(path, body) | 注册 POST 路由 |
app.put(path, body) | 注册 PUT 路由 |
app.patch(path, body) | 注册 PATCH 路由 |
app.delete(path, body) | 注册 DELETE 路由 |
app.head(path, body) | 注册 HEAD 路由 |
app.options(path, body) | 注册 OPTIONS 路由 |
app.websocket(path, body) | 注册 WebSocket 路由,body 中可用 ws 和 ctx |
在路由处理体中,ctx: Context 变量自动可用。在 WebSocket 路由体中,ws: WebSocket 和 ctx: Context 均自动可用。路径参数使用 {name} 语法声明,如 /users/{id}。
Context — 路径与查询参数
| 方法 | 签名 | 说明 |
|---|---|---|
pathParam | ctx.pathParam(name): string | 获取路径参数 |
pathParamOrDefault | ctx.pathParamOrDefault(name, default): string | 获取路径参数,带默认值 |
pathParamInt | ctx.pathParamInt(name): int | 获取路径参数并转为 int |
pathParamIntOrDefault | ctx.pathParamIntOrDefault(name, default): int | 获取路径参数并转为 int,带默认值 |
queryParam | ctx.queryParam(name): string | 获取查询参数 |
queryParamOrDefault | ctx.queryParamOrDefault(name, default): string | 获取查询参数,带默认值 |
queryParamInt | ctx.queryParamInt(name): int | 获取查询参数并转为 int |
queryParamIntOrDefault | ctx.queryParamIntOrDefault(name, default): int | 获取查询参数并转为 int,带默认值 |
queryParamBool | ctx.queryParamBool(name): bool | 获取查询参数并转为 bool("true"/"1"/"yes") |
hasQueryParam | ctx.hasQueryParam(name): bool | 检查查询参数是否存在 |
allQueryParams | ctx.allQueryParams(): Table[string, string] | 获取所有查询参数 |
Context — 请求头
| 方法 | 签名 | 说明 |
|---|---|---|
getHeader | ctx.getHeader(name): string | 获取请求头 |
getHeaderOrDefault | ctx.getHeaderOrDefault(name, default): string | 获取请求头,带默认值 |
hasHeader | ctx.hasHeader(name): bool | 检查请求头是否存在 |
allHeaders | ctx.allHeaders(): HttpHeaders | 获取所有请求头 |
contentType | ctx.contentType(): string | 获取 Content-Type |
contentLength | ctx.contentLength(): int | 获取 Content-Length |
userAgent | ctx.userAgent(): string | 获取 User-Agent |
authorization | ctx.authorization(): string | 获取 Authorization 头 |
bearerToken | ctx.bearerToken(): string | 提取 Bearer Token |
Context — 请求体
| 方法 | 签名 | 说明 |
|---|---|---|
body | ctx.body(): string | 获取原始请求体 |
jsonBody | ctx.jsonBody(): JsonNode | 解析 JSON 请求体,失败抛 400 |
jsonBodyOrDefault | ctx.jsonBodyOrDefault(default): JsonNode | 解析 JSON 请求体,失败返回默认值 |
formBody | ctx.formBody(): Table[string, string] | 解析 URL 编码表单 |
multipartBody | ctx.multipartBody(): MultipartForm | 解析 multipart 表单(含文件) |
isMultipart | ctx.isMultipart(): bool | 检查是否为 multipart 请求 |
isJson | ctx.isJson(): bool | 检查是否为 JSON 请求 |
isFormUrlencoded | ctx.isFormUrlencoded(): bool | 检查是否为 URL 编码表单 |
Context — Cookie 操作
| 方法 | 签名 | 说明 |
|---|---|---|
getCookies | ctx.getCookies(): StringTableRef | 获取所有 Cookie |
getCookie | ctx.getCookie(name): string | 获取指定 Cookie |
getCookieOrDefault | ctx.getCookieOrDefault(name, default): string | 获取 Cookie,带默认值 |
hasCookie | ctx.hasCookie(name): bool | 检查 Cookie 是否存在 |
setCookie | ctx.setCookie(key, value, ...) | 设置响应 Cookie |
deleteCookie | ctx.deleteCookie(key, path, domain) | 删除 Cookie(设为过期) |
Context — 响应设置
| 方法 | 签名 | 说明 |
|---|---|---|
status | ctx.status(code: HttpCode) | 设置状态码 |
status | ctx.status(code: int) | 设置状态码(int 版本) |
setHeader | ctx.setHeader(name, value) | 设置响应头(覆盖) |
addHeader | ctx.addHeader(name, value) | 添加响应头(追加) |
setContentType | ctx.setContentType(contentType) | 设置 Content-Type |
json | ctx.json(data: JsonNode, code = Http200) | 设置 JSON 响应 |
json | ctx.json(code: HttpCode, data: JsonNode) | 设置 JSON 响应(状态码在前) |
text | ctx.text(data: string, code = Http200) | 设置纯文本响应 |
html | ctx.html(data: string, code = Http200) | 设置 HTML 响应 |
raw | ctx.raw(body, contentType, code) | 设置原始响应体 |
sendFile | ctx.sendFile(filePath, code, contentType) | 发送文件(内联显示) |
download | ctx.download(filePath, fileName, code) | 强制下载文件(附件) |
downloadBytes | ctx.downloadBytes(data, fileName, contentType, code) | 发送字节数据作为下载文件 |
sendFileChunked | ctx.sendFileChunked(filePath, chunkSize, code, contentType) | 分块传输大文件 |
serveFileRange | ctx.serveFileRange(filePath) | 支持 Range 请求的文件传输 |
redirect | ctx.redirect(url, code = Http302) | 设置重定向 |
noContent | ctx.noContent() | 设置 204 响应 |
errorResponse | ctx.errorResponse(code, message) | 设置 JSON 错误响应 |
Context — 请求信息
| 方法 | 签名 | 说明 |
|---|---|---|
httpMethod | ctx.httpMethod(): HttpMethod | 获取请求方法 |
path | ctx.path(): string | 获取请求路径 |
fullUrl | ctx.fullUrl(): string | 获取完整 URL |
clientIP | ctx.clientIP(): string | 获取客户端 IP |
isSecure | ctx.isSecure(): bool | 检查是否 HTTPS |
host | ctx.host(): string | 获取 Host 头 |
scheme | ctx.scheme(): string | 获取协议(http/https) |
queryString | ctx.queryString(): string | 获取原始查询字符串 |
中间件与钩子模板
| 模板 | 说明 |
|---|---|
app.middleware(body) | 添加前置中间件,body 中 ctx 可用 |
app.afterMiddleware(body) | 添加后置中间件,在路由处理器之后执行 |
app.exceptionHandler(code, body) | 注册状态码异常处理器 |
app.onStartup(body) | 添加启动钩子 |
app.onShutdown(body) | 添加关闭钩子 |
CORS 配置
| 方法 | 签名 | 说明 |
|---|---|---|
enableCors | app.enableCors(config: CorsConfig) | 启用 CORS(完整配置) |
enableCors | app.enableCors(allowOrigins, allowMethods, ...) | 启用 CORS(简化配置) |
CorsConfig 字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
allowOrigins | seq[string] | ["*"] | 允许的源 |
allowMethods | seq[string] | 全部方法 | 允许的 HTTP 方法 |
allowHeaders | seq[string] | ["*"] | 允许的请求头 |
allowCredentials | bool | false | 是否允许凭证 |
maxAge | int | 600 | 预检缓存时间(秒) |
exposeHeaders | seq[string] | [] | 暴露给前端的响应头 |
静态文件
| 方法 | 签名 | 说明 |
|---|---|---|
mount | app.mount(prefix, directory) | 挂载静态文件目录 |
WebSocket API
| 方法 | 签名 | 说明 |
|---|---|---|
sendText | ws.sendText(text: string) | 发送文本消息 |
sendBinary | ws.sendBinary(data: string) | 发送二进制消息 |
sendJson | ws.sendJson(data: JsonNode) | 发送 JSON 消息 |
recvMessage | ws.recvMessage(): Future[WebSocketMessage] | 接收消息 |
ping | ws.ping(data = "") | 发送 Ping 帧 |
pong | ws.pong(data = "") | 发送 Pong 帧 |
close | ws.close(code = 1000, reason = "") | 关闭连接 |
isClosed | ws.isClosed(): bool | 检查是否已关闭 |
MultipartForm API
| 方法 | 签名 | 说明 |
|---|---|---|
getFile | form.getFile(name): Option[UploadedFile] | 获取上传文件(返回 Option,注意与路由 get 冲突时用 form.files[name]) |
getField | form.getField(name): string | 获取表单字段 |
hasFile | form.hasFile(name): bool | 检查文件是否存在 |
hasField | form.hasField(name): bool | 检查字段是否存在 |
UploadedFile 字段:filename(文件名)、contentType(MIME 类型)、data(文件内容)。
错误处理
| 方法 | 签名 | 说明 |
|---|---|---|
newHttpError | newHttpError(code, message = ""): HttpError | 创建 HTTP 异常对象 |
raiseHttpError | raiseHttpError(code, message = "") | 抛出 HTTP 异常 |
后台任务
| 方法 | 签名 | 说明 |
|---|---|---|
addBackgroundTask | app.addBackgroundTask(task: BackgroundTask) | 添加后台异步任务 |
API 文档
| 方法/模板 | 签名 | 说明 |
|---|---|---|
enableDocs | app.enableDocs(title, version, description) | 启用 API 文档 |
disableDocs | app.disableDocs() | 禁用 API 文档 |
docGet | app.docGet(path, desc, tags, body) | 注册带文档的 GET 路由 |
docPost | app.docPost(path, desc, tags, body) | 注册带文档的 POST 路由 |
docPut | app.docPut(path, desc, tags, body) | 注册带文档的 PUT 路由 |
docPatch | app.docPatch(path, desc, tags, body) | 注册带文档的 PATCH 路由 |
docDelete | app.docDelete(path, desc, tags, body) | 注册带文档的 DELETE 路由 |
describeLast | app.describeLast(summary, description, tags, deprecated) | 为最后注册的路由补充文档信息 |
describeBody | app.describeBody(bodyKind, fields, description) | 为最后注册的路由添加请求体文档 |
describeJsonBody | app.describeJsonBody(fields, description) | 快捷方法:添加 JSON 请求体文档 |
describeFileUpload | app.describeFileUpload(fileField, extraFields, description) | 快捷方法:添加文件上传文档 |
HttpCode 常用值
| 常量 | 状态码 | 含义 |
|---|---|---|
Http200 | 200 | OK |
Http201 | 201 | Created |
Http204 | 204 | No Content |
Http301 | 301 | Moved Permanently |
Http302 | 302 | Found |
Http304 | 304 | Not Modified |
Http400 | 400 | Bad Request |
Http401 | 401 | Unauthorized |
Http403 | 403 | Forbidden |
Http404 | 404 | Not Found |
Http405 | 405 | Method Not Allowed |
Http409 | 409 | Conflict |
Http422 | 422 | Unprocessable Entity |
Http500 | 500 | Internal Server Error |
Http503 | 503 | Service Unavailable |
完整示例
以下是一个综合演示 FastNim 所有功能的完整应用,包含 CRUD API、认证中间件、CORS、WebSocket、文件上传、静态文件等:
# complete_app.nim — FastNim 完整示例
import std/[json, tables, times, os, strutils]
import fastnim
# --- 数据模型 ---
type
User = object
id: int
name: string
email: string
var users = initTable[int, User]()
var nextId = 1
var sessions = initTable[string, int]() # token -> userId
# --- 创建应用 ---
let app = newApp(port = 8080)
# --- CORS ---
app.enableCors(allowOrigins = @["http://localhost:3000"], allowCredentials = true)
# --- 静态文件 ---
app.mount("/static", "./public")
# --- 生命周期 ---
app.onStartup:
echo "服务器启动中..."
app.state["version"] = "1.0.0"
app.onShutdown:
echo "服务器关闭中..."
# --- 中间件:日志 ---
app.middleware:
echo "[", $now(), "] ", ctx.httpMethod(), " ", ctx.path()
# --- 中间件:认证(仅 /api 路径) ---
app.middleware:
if ctx.path().startsWith("/api/") and not ctx.path().startsWith("/api/login"):
let token = ctx.bearerToken()
if token.len == 0 or not sessions.hasKey(token):
ctx.errorResponse(Http401, "未授权访问")
return
ctx.state["userId"] = $sessions[token]
# --- 异常处理 ---
app.exceptionHandler(404):
ctx.json(%*{"error": "资源不存在", "path": ctx.path()}, Http404)
app.exceptionHandler(500):
ctx.json(%*{"error": "服务器内部错误"}, Http500)
# --- 基础路由 ---
app.get "/":
ctx.json(%*{
"name": "FastNim API",
"version": app.state.getOrDefault("version"),
"endpoints": ["/api/login", "/api/users", "/ws"]
})
# --- 登录 ---
app.post "/api/login":
let body = ctx.jsonBody()
let email = body["email"].getStr()
let password = body["password"].getStr()
if password != "123456":
ctx.errorResponse(Http401, "密码错误")
return
let token = "token-" & $nextId
sessions[token] = nextId
ctx.setCookie("session", token, path = "/", httpOnly = true)
ctx.json(%*{"token": token, "message": "登录成功"})
# --- 用户 CRUD ---
app.get "/api/users":
var userList: seq[JsonNode] = @[]
for u in users.values:
userList.add(%*{"id": u.id, "name": u.name, "email": u.email})
ctx.json(%*{"users": userList, "count": userList.len})
app.get "/api/users/{id}":
let id = ctx.pathParamInt("id")
if not users.hasKey(id):
raiseHttpError(Http404, "用户不存在")
let u = users[id]
ctx.json(%*{"id": u.id, "name": u.name, "email": u.email})
app.post "/api/users":
let body = ctx.jsonBody()
let user = User(
id: nextId,
name: body["name"].getStr(),
email: body["email"].getStr()
)
users[nextId] = user
nextId += 1
ctx.json(%*{"id": user.id, "name": user.name, "email": user.email}, Http201)
app.put "/api/users/{id}":
let id = ctx.pathParamInt("id")
if not users.hasKey(id):
raiseHttpError(Http404, "用户不存在")
let body = ctx.jsonBody()
var u = users[id]
if body.hasKey("name"): u.name = body["name"].getStr()
if body.hasKey("email"): u.email = body["email"].getStr()
users[id] = u
ctx.json(%*{"id": u.id, "name": u.name, "email": u.email})
app.delete "/api/users/{id}":
let id = ctx.pathParamInt("id")
if not users.hasKey(id):
raiseHttpError(Http404, "用户不存在")
users.del(id)
ctx.noContent()
# --- 搜索 ---
app.get "/api/users/search":
let keyword = ctx.queryParam("q")
var results: seq[JsonNode] = @[]
for u in users.values:
if u.name.contains(keyword) or u.email.contains(keyword):
results.add(%*{"id": u.id, "name": u.name, "email": u.email})
ctx.json(%*{"results": results, "keyword": keyword})
# --- 文件上传 ---
app.post "/api/upload":
if not ctx.isMultipart():
ctx.errorResponse(Http400, "需要 multipart 表单")
return
let form = ctx.multipartBody()
if form.hasFile("file"):
let file = form.files["file"]
writeFile("./uploads/" & file.filename, file.data)
ctx.json(%*{
"filename": file.filename,
"size": file.data.len,
"contentType": file.contentType
}, Http201)
else:
ctx.errorResponse(Http400, "未找到文件")
# --- WebSocket 聊天 ---
app.websocket "/ws":
await ws.sendText("连接成功!")
while not ws.isClosed:
let msg = await ws.recvMessage()
if msg.kind == wsmText:
if msg.text == "ping":
await ws.sendText("pong")
else:
await ws.sendText("Echo: " & msg.text)
elif msg.kind == wsmClose:
break
# --- 后置中间件:添加通用头 ---
app.afterMiddleware:
ctx.setHeader("X-Powered-By", "FastNim")
# --- 启动服务器 ---
app.run()
将以上代码保存为 complete_app.nim,然后执行:
nim c -r complete_app.nim
服务器将在 http://0.0.0.0:8080 启动。可用 curl 测试:
# 登录获取 token
curl -X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"email":"a@b.com","password":"123456"}'
# 创建用户(使用返回的 token)
curl -X POST http://localhost:8080/api/users \
-H "Authorization: Bearer token-1" \
-H "Content-Type: application/json" \
-d '{"name":"张三","email":"zhang@example.com"}'
# 获取用户列表
curl http://localhost:8080/api/users \
-H "Authorization: Bearer token-1"
myapp/
├── complete_app.nim # 主程序
├── public/ # 静态文件目录
│ └── index.html
├── uploads/ # 上传文件目录
└── nim.cfg # 编译配置