从零开始用 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 编程语言
  • 标准库:asyncdispatchasynchttpserver

为什么选择 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/1www.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 = 2252num = 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)

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 对象设置 beforeafter 函数类型:

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

独立思考最难得,赞赏支持是美德!(微信扫描下图)