从零开始用 Nim 语言编写一个简单的 Web 框架
2024-06-19 乙酉 甲辰年 庚午 甲寅 - 农历五月十四 - 小雨
作者:ringabout
翻译:海云青飞
不要重复发明轮子吗?这得依情况而定
有时候我们被告知“不要重复发明轮子”。当我们想要编写可靠和稳定的软件时,这可能是正确的。我们应该依赖成熟的框架,而不是自己编写框架。然而,有时候如果我们不挑战自己,生活就会变得太无聊。如果我们想了解 web 框架是如何工作的,我们应该自己动手写。 “我看到了,我记住了。我做了,我理解了。”
好的,你可能想要“从零开始”编写自己的 web 框架。好吧,现在你有两个问题 :-)。什么是“从零开始”?这是否意味着我们需要重新发明“半导体”。当然不是。根据你想要学习的内容,你可以“站在巨人的肩膀上”。如果你想学习底层模块,你可以从 TCP 或 HTTP 协议开始编写。你可以编写一个 HTTP 服务器,并基于它制作一个 web 框架
关于本文,我们将依赖他人的 HTTP 服务器,因为我只想知道 web 框架是如何工作的
什么是 Web 框架
web 框架也被称为 web 应用框架。它建立在 HTTP 服务器之上,后者又建立在 HTTP 协议之上。通常,HTTP 服务器只用于接受客户端的请求并响应客户端。web 框架提供了更多有用和强大的工具,例如数据验证或用户认证
例如,当你在线购物时,你点击购买按钮,你的浏览器将向 web 服务器发送请求。web 服务器将请求解析成 web 框架可以理解的数据结构。web 框架将验证你的身份,将该产品或用户信息添加到数据库中,并发送请求从你的信用卡扣款等等
本文将用到的内容
- Nim 编程语言
- 标准库:
asyncdispatch
和asynchttpserver
为什么选择 Nim 编程语言开发 Web 框架
Nim 是一种静态类型、编译型的系统编程语言。它具有直观和清晰的语法。Nim 高效、富有表现力且优雅。选择 Nim,享受你的生活!
我喜欢它有三个原因:
- 语句按缩进分组,优雅的语法
- 静态类型
- 高性能
asynchttpserver 的简单用法
Nim 内置了一个异步 HTTP 服务器,即 asynchttpserver
。你不需要安装它,只需在代码中输入 import asynchttpserver
就可以使用它
HTTP 服务器帮助我们将内容(如 HTML、Json 和文本)传输给我们的客户端(如浏览器或 curl)。它会将请求解析成 Nim 语言中的 seq 或 tables,这样我们的 web 框架就可以理解了
让我们看看代码:
# nim c -r thisfile.nim
import asynchttpserver, asyncdispatch
var server = newAsyncHttpServer()
proc cb(req: Request) {.async.} =
await req.respond(Http200, "Hello,tuenhai.com", newHttpHeaders())
waitFor server.serve(Port(2252), cb)
上面代码中,Http200
是一个状态代码,它告诉客户端一切正常。“Hello,tuenhai.com”是我们用来响应客户端的文本。我们还需要使用 HTTP 标头响应客户端
在 VS code 中创建一个 .nim 结尾的文件,输入上面的代码,再按 F6 就会编译出程序并运行
(结束程序运行的方法,在 Windows VS code 的 msys 64 终端里按 Ctrl + C)
运行上面的代码后,在浏览器中输入 localhost:2252
,你将看到网页显示 Hello,tuenhai.com
让我们看看请求头和响应头以获得更多直观的认识
请求头
GET / HTTP/1.1 # start line
Host: 127.0.0.1:8080 # request headers
......
响应头
HTTP/1.1 200 OK # start line
Content-Length: 11 # response headers
现在让我们扩展使用我们的框架!
用 Nim 编写 Web框架 伺服静态文件
有时我们想要伺服静态文件,如 CSS、HTML、图片等,使用 Nginx 是明智的,但对于小型应用程序,我们只想使用我们自己的框架
在 Nim 中,你只需要准备字符串并将字符串发送给客户端。如果你想发送 HTML 文件,你读取它并通过 socket 发送。如果你想发送 Json 文本,首先将其转换/转储为字符串,然后通过 socket 发送
现在按照三个步骤操作:
- 首先,判断文件是否存在
- 其次,判断我们是否有权访问文件
- 最后,判断我们是否可以打开文件
让我们来看看伪代码:
import asyncdispatch, asynchttpserver, os
proc serveStaticFile*(req: Request, dir, filename: string) {.async.} =
# exist -> have access -> can open
let path = dir / filename
# whether exists file
if not fileExists(path):
await req.respond(Http404, "File doesn't exist.", newHttpHeaders())
# whether has access to file
var filePermission = getFilePermissions(path)
if fpOthersRead notin filePermission:
await req.respond(Http403, "You have no access to the file.", newHttpHeaders())
# whether can open file
try:
let content = readFile(path)
await req.respond(Http200, content, newHttpHeaders())
except IOError:
await req.respond(Http404, "404 Not Found.", newHttpHeaders())
resp
后面跟着我们提供给 asynchttpserver
中的 respond proc 的内容
注意:await 必须在函数中使用
上面是伪代码,无法验证其是否有误,下面代码经 海云青飞 测试通过:
import asyncdispatch, asynchttpserver, os
proc serveStaticFile*(req: Request) {.async.} =
let
dir = r"C:\tuenhai.com\nim\run"
filename ="test.nim"
# exist -> have access -> can open
let path = dir / filename
# whether exists file
if not fileExists(path):
await req.respond(Http404, "File doesn't exist.", newHttpHeaders())
# whether has access to file
var filePermission = getFilePermissions(path)
if fpOthersRead notin filePermission:
await req.respond(Http403, "You have no access to the file.", newHttpHeaders())
# whether can open file
try:
let content = readFile(path)
await req.respond(Http200, content, newHttpHeaders())
except IOError:
await req.respond(Http404, "404 Not Found.", newHttpHeaders())
var server = newAsyncHttpServer()
waitFor server.serve(Port(2252), serveStaticFile)
基本路由
路由用于将 URL 映射到相应的 proc,该 proc 以内容响应客户端
例如:
import httpcore
proc findHandler*(httpMethod: HttpMethod, path: string) =
case httpMethod
of HttpGet:
case path
of "/hello":
echo "get 127.0.0.1:8080/hello"
# sendEmail()
of "/home":
echo "get 127.0.0.1:8080/home"
# fetchInfoFromDatabase()
else:
discard
of HttpPost:
case path
of "/hello":
echo "post 127.0.0.1:8080/hello"
# login()
of "/home":
echo "post 127.0.0.1:8080/home"
# returnHome()
else:
discard
else:
discard
- get 127.0.0.1:8080/hello -> send email
- get 127.0.0.1:8080/home -> fetch info from Database
这个例子展示了 web 框架根据相应的 HTTP 方法和 URL 执行一些操作,例如从数据库中获取信息、登录、返回 HTML 等等
使用哈希表进行静态路由
如果客户端请求 www.example.com/login
,我们的应用程序将查找哈希表以找到相应的处理程序。处理程序是处理客户端请求并生成响应的 proc
在哈希表中查找 URL 是很快的
type
Router* = Table[URL, procHandler]
动态路由
有时我们需要动态 URL 来满足我们的需求。例如,我们使用一个 procHandler 来处理不同客户的登录操作。所以每个客户都会有不同的登录 URL,如 www.example.com/login/1
、www.example.com/login/2
等等
我们需要像 www.example.com/login/{id}
这样的 URL 匹配来捕获不同的 id
首先我们用 / 分割 URL,将 www.example.com/login/1
转换为 @[“www.example.com”, “login”, “1”]
。然后我们迭代哈希表以决定 URL 是否匹配模式
让我们来看看伪代码:
let routeList = route.split("/")
# iterate all URL and procHandler pairs
let pathList = iterate(HandlerTable)
for idx in 0 ..< pathList.len:
# if match continue
# www.example.com => www.example.com
# login => login
if pathList[idx] == routeList[idx]:
continue
# match {id} => 2
if routeList[idx].startsWith("{"):
let key = routeList[idx]
if key.len <= 2:
raise newException(RouteError, "{} shouldn't be empty!")
let
params = key[1 ..< ^1]
正则表达式路由
我们可以使用 seq
来存储正则路由
/post(?P<num>[\d]+) => /post2252
或 /post1314
并获取匹配的参数,如 num = 2252
或 num = 1314
我们只需迭代 URL 和 procHandler 在 Reroute 中以匹配请求 URL
让我们来看看伪代码:
# find regex route
for (URL, ProcHandler) in reRouter:
if path.httpMethod != URL.httpMethod:
continue
var m: RegexMatch
# save matched params like id = 2
if URL.route.match(path.route, m):
for name in m.groupNames():
pathParams[name] = m.groupFirstCapture(name, URL.route)
Cookie
HTTP 协议是无状态的,但我们使用普通 Cookie 来保存用户信息
Cookie 是 HTTP 头部。它可以携带用户不敏感的信息。当你想携带敏感信息时,确保使用加密算法(如 Sha-256、Sha-512 等)加密。只有在 HTTPS 下使用 Cookie 才能防止中间人攻击
这就是 Cookie 的样子。它是以名称-值对的形式出现的。不同的对之间用分号分隔
Cookie: username=flywind; age=21
解析 CookieJar
我们使用 StringTable
来存储不同的名称-值对:
type
CookieJar* = object
data: StringTableRef
proc parse*(cookieJar: var CookieJar, text: string) {.inline.} =
var
pos = 0
name, value: string
while true:
pos += skipWhile(text, {' ', '\t'}, pos)
# name = username
pos += parseUntil(text, name, '=', pos)
if pos >= text.len:
break
inc(pos) # skip '='
# value = flywind
pos += parseUntil(text, value, ';', pos)
# username = flywind
cookieJar[name] = move(value)
if pos >= text.len:
break
inc(pos) # skip ';'
我们也想要设置 cookie:
import options, times, strtabs, parseutils, strutils
type
SameSite* {.pure.} = enum
None, Lax, Strict
Cookie* = object
name*, value*: string # root => admin
expires*: string
maxAge*: Option[int]
domain*: string
path*: string
secure*: bool
httpOnly*: bool
sameSite*: SameSite
设置 cookie:
proc setCookie*(cookie: Cookie): string =
result.add cookie.name & "=" & cookie.value
if cookie.domain.strip.len != 0:
result.add("; Domain=" & cookie.domain)
if cookie.path.strip.len != 0:
result.add("; Path=" & cookie.path)
if cookie.maxAge.isSome:
result.add("; Max-Age=" & $cookie.maxAge.get())
if cookie.expires.strip.len != 0:
result.add("; Expires=" & cookie.expires)
if cookie.secure:
result.add("; Secure")
if cookie.httpOnly:
result.add("; HttpOnly")
if cookie.sameSite != None:
result.add("; SameSite=" & $cookie.sameSite)
中间件
中间件用于在 procHandler 之前和之后执行一些操作。例如,我们想要验证用户身份,我们可以使用中间件来完成这项工作
让我们来看看伪代码
proc verify(user: User, next: Handler) =
# check whether user is valid
if not user.isValid:
resp 404, "You have no access to this URL."
return
# call next middleware or procHandler
next()
# If user is valid, print "Welcome" message
resp "Welcome, " & $user
实现中间件有两种方式,一种是基于函数调用,另一种是基于钩子 hook
让我们看看使用钩子的方法
我们为每个 Middleware 对象设置 before
和 after
函数类型:
type
Middleware = object
before: proc()
after: proc()
在应用程序的生命周期中,假设我们有两个中间件。所以执行顺序是:
m1.before -> m2.before -> hello -> m2.after -> m1.after
让我们来看看伪代码:
proc hello() =
resp "Hello"
var app = Application()
var m1 = Middleware(...)
var m2 = Middleware(...)
app.addRoute(procHandler = hello, middlwares = [m1, m2])
让我们看看使用函数调用的方法
我们使用 seq
来存储中间件。await next
用于调用下一个中间件或 procHandler
我们有 size 变量来获取当前的中间件。一开始 size
为 0,我们获取第一个中间件。现在我们开始执行第一个中间件。当我们遇到 await next 时,
size` 会增加 1,然后调用第二个中间件。最后我们调用最后一个中间件,并执行其余程序
m1 -> m1.next -> m2 -> m2.next -> hello -> m2 -> m1
让我们看看伪代码:
type
Middlewares* = seq[procHandler]
proc httpRedirectMiddleWare*() =
case request.scheme
of "http":
setScheme(request, "https")
of "ws":
setScheme(request, "wss")
else:
return
# Will call next middleware or procHandler
await next()
response.code = Http307
异常处理
我们可以将 HTTP 状态代码映射到用户定义的异常处理程序
有时,我们希望将 HTTP 状态代码关联到统一页面,例如自定义 404 页面
type
ErrorHandlerTable* = Table[HttpCode, ErrorHandler]
proc default404Handler*() =
response.body = errorPage("404 Not Found!", PrologueVersion)
app.errorHandlerTable.add(Http404, default404Handler)
让我们看看伪代码
if response.code in app.errorHandlerTable:
await (app.errorHandlerTable[response.code])()
更多部分
关于 Web 框架,还有更多部分,您可以查看
相关内容
- 原文 https://dev.to/ringabout/write-a-simple-web-framework-in-nim-language-from-scratch-ma0
独立思考最难得,赞赏支持是美德!(微信扫描下图)