losty

OpenResty 的函数式 Web 框架

$ opm get gnois/losty

Losty = Luaty + OpenResty

Losty 是一个针对 OpenResty 的函数式 Web 框架,依赖项极少。

通过几乎所有地方都使用函数组合,并利用 Lua 强大的语言特性,它在 OpenResty 上添加了辅助函数,而不会掩盖您熟悉的 API。

它包含以下功能:

  • 请求路由

  • 请求体解析器

  • 内容协商

  • Cookie 辅助函数

  • Flash 辅助函数

  • CSRF 辅助函数

  • 加密会话

  • URL 的 Slug 生成

  • HTML 生成 DSL

  • 输入验证辅助函数

  • 幂等 API 辅助函数

  • 服务器发送事件 (SSE) 支持

  • 表格、字符串和函数辅助函数

  • SQL 操作和种子数据辅助函数

  • SQL 测试辅助函数

Losty 使用 Luaty 编写并编译为 Lua。

非常欢迎您提交 Bug 报告和贡献。

依赖项

必需:OpenResty

可选

  • 如果使用 PostgreSQL,则需要 pgmoon

安装

使用 opm

    opm get gnois/losty

快速入门

nginx.conf

    events {
        worker_connections 4096;
    }
    http {
        server {
                listen 80;
    
                location / {
                        content_by_lua_block {
                                local web = require('losty.web')()
                                local w = web.route('/t')
                                w.get('/hi', function(q, r)
                                        r.status = 200
                                        r.headers["content-type"] = "text/plain"
                                        return "Hi world"
                                end)
                                web.run()
                        }
                }
        }
    }

更多示例请参考 losty-starters 仓库。

总体思路

Losty 可以在 OpenResty 的 content_by_lua_* 指令中使用。它将 HTTP 请求与用户定义的路由匹配,并将一个或多个处理函数与该路由关联,这些函数处理请求。类似于 Koajs 等框架,处理程序需要在下游调用,然后控制流返回上游。

处理程序

处理程序是一个函数,它接收一个请求 (q) 和一个响应 (r) 表格,以及可选的其他参数,这些参数可能来自之前的处理程序。

这是一个处理 http POST、PUT 或 DELETE 请求的处理程序,取自内置的 content.lua 辅助函数

    function form(q, r)
        local val, fail = body.buffered(q)
        local method = q.vars.request_method
        if val or method == "DELETE" then
                return q.next(val)
        end
        r.status = ngx.HTTP_BAD_REQUEST
        return {fail = fail or method .. " should have request body"}
    end

当路由与请求的 URL 匹配时,Losty 分发器将调用第一个处理程序,该处理程序可能会通过 q.next() 调用下一个处理程序并传递更多参数(例如上述示例中的 val),或者只返回响应体。

这是另一个处理程序,它打开一个 postgresql 数据库连接并将其传递给下一个处理程序,然后保持连接活动并返回接收到的结果。

    local pg = require('losty.sql.pg')
    
    function database(q, r)
        local db = pg(databasename, username, password)
        db.connect()
        local out = q.next(db)
        db.disconnect()
        return out
    end

以上处理程序可以这样链接

    w.post('/path', function(q, r)
        r.headers["Content-Type"] = "application/json"
        return q.next()
    end, form, database, function(q, r, body, db)
        -- use body and db
        db.insert("users(name) values (:?)", body.name)
        r.status = 201
        return json.encode({ok = true})
    end)

请注意,表单 bodydb 如何被追加并作为参数传递给后续的处理程序,最后一个处理程序可以选择返回 JSON 作为响应体。

如果响应体很大,或者可能无法一次全部获得,我们可以从处理程序返回一个函数,Losty 将把该函数作为一个协程调用并恢复它,直到它完成。然后,该函数可以使用 coroutine.yield() 在响应可用时返回响应。

其他框架通常使用一个上下文表,该表用键扩展并在处理程序之间传递,但 Losty 默认通过累积函数参数传递它们,这要归功于 Lua 的可变参数和多返回值。以下是 Losty 设计的一些考虑因素。

  • 参数易于查看。

  • 参数的 (解) 包装速度较慢,但如果只有少量处理程序,则可能不明显。

  • 切换到上下文表对 Losty 来说很容易;只需将键追加到请求 (q) 或响应 (r) 表格即可。但反过来则不行。

请求表

在处理程序内部,传入的请求表 (q) 是 ngx.var 和 ngx.req 的一个薄包装器,从中可以访问所有属性。

响应表

在处理程序内部,传入的响应表 (r) 是一个薄型辅助函数,用于设置 HTTP 标头和 Cookie,并包装 ngx.status。直接设置 ngx.status 也按预期工作。

    r.headers[Name] = value
    r.status = 201
    assert(ngx.status == 201)

Cookie

Cookie 使用响应表分两步创建

    local ck = r.cookie('biscuit', true, nil, '/')  -- step 1
    local data = ck(nil, true, r.secure(), value) -- step 2

步骤 1. 使用名称以及可选的 httponly、域和路径调用 r.cookie。这 4 个参数构成了 Cookie 的标识,如果要删除 Cookie,则需要这些标识。

步骤 2. r.cookie 返回另一个函数,必须调用该函数才能指定年龄、samesite、secure 和 Cookie 值。

  • 年龄可以是 nil、正数或负数

    • nil 表示 Cookie 将在浏览器关闭时被删除

    • 正数是 Cookie 保持有效时间的秒数

    • 负数表示在返回响应时将删除 Cookie,并且不需要 samesite、secure 和值。例如:ck(-100)

  • 如果年龄不是负数,则 Cookie 值可以指定为:

    • 一个简单的字符串,按原样处理

    • 一个编码函数,例如 json.encode(),它将 Cookie 编码为键/值对象。从上面的例子继续

    data.id = xxx
    data.token = yyy

会话

会话通过一对 Cookie 实现,一个 Cookie 存储加密数据,它是 httponly,另一个 Cookie 存储其签名,它是 Javascript 可读的。这允许 Javascript 在没有额外服务器往返的情况下检测 Cookie 更改并相应地采取行动。

    var session = require('losty.sess')
    var sess = session('candy', "This IS secret", "this-is_key")
    
    w.post('/login', function(q, r)
        r.headers["Content-Type"] = "application/json"
        var s = sess(q, r, 3600 * 24 * 7) -- age 7 days
        s.data = "userid"
        s.extra = {other = "info"}
        r.redirect('/')
    )

在上面的例子中,将在 document.cookie 中有一个名为 'candy' 的 Cookie,Javascript 可以读取它,其中包含此会话 Cookie 的签名。实际的加密数据存储在另一个名为 'candy_' 的 Cookie 中,它是 httponly。这两个 Cookie 都是匹配的,以确保会话没有被篡改。

响应完成并返回控制权给 Nginx

包括 Cookie 和会话在内的响应头会在返回响应之前累积并最终设置到 ngx.headers 中。在返回响应之前直接设置 ngx.headers 也应按预期工作。

请注意,在处理程序中调用 ngx.exec()ngx.redirect()ngx.exit()ngx.flush()ngx.say()ngx.print()ngx.eof() 将会短路 Losty 分发器流程并立即将控制权返回给 Nginx。一个有效的示例是使用 return ngx.exit(status) 回退到 nginx.conf 中的 error_page 指令,而不是使用 Losty 生成的错误页面。

路由

路由使用 HTTP 方法定义,例如 get() 用于 GET 或 post() 用于 POST。

路由路径是字符串,以 '/' 开头,后面跟着多个由 '/' 分隔的段。尾部斜杠将被忽略。

以 ':' 开头的段指定一个捕获的 Lua 模式。捕获的值存储在请求表 (q) 的 match 数组中。

由于可能存在以下冲突路径,因此没有其他框架中的命名捕获

      /page/:id
      /page/:user

其中 :user 可能永远不会匹配,并且 q.match['user'] 始终为 nil

因此,q.match 不是一个带键的表,而是一个数组,这也允许在一个段中进行多个捕获。例如

    /page/:%w-(%d)-(%d)

无法指定可选的最后一个段,以避免可能的冲突

      /page/:?  <- not valid
      /page

而是分别指定带和不带可选段的路由

匹配模式不允许使用 %c, %s,当然也不允许使用 /,它始终是路径分隔符。

对于以下按指定顺序注册的路由

    1. /page/:%a+
    2. /page/:.*
    3. /page/:%d+
    4. /page/near
    5. /:p(%a+)/:%d(%d)

匹配以下请求。

    /page/near  -> 4
    /page/last  -> 1,  q.match = {'last'}
    /page/:id   -> 2,  q.match = {':id'}
    /page/123   -> 2 due to precedence, q.match = {'123'}
    /past/56    -> 5,  q.match = {'past', 'ast', '56', '6'}

请注意,最后一个路由在一个段中接收多个捕获。

路径匹配是确定性的。它们按声明顺序匹配,并且非模式路径具有更高的优先级。

server.route(prefix) 可以多次调用,每次都接收一个用于分组的路径前缀。

SQL 操作

Losty 为 MySQL 和 PostgreSQL 驱动程序提供包装器,以及一个基本的迁移实用程序。没有 ORM 层。(直接学习 SQL 会更有价值)

例如,假设我们要使用现有的 PostgreSQL 数据库。让我们使用 SQL 文件创建一个新表

users.sql

    CREATE TABLE user (
        id serial PRIMARY KEY
        , name text NOT NULL
        , email text NOT NULL
    );

以及另一个使用 Lua 文件的表

friends.lua

    return {
        "CREATE TABLE friend (
                id int NOT NULL REFERENCES users
                , userid int NOT NULL REFERENCES users
                , UNIQUE (id, userid)
        );"
    }

然后,我们可以使用 `resty cli` 将表迁移到 PostgreSQL,如下所示

    resty -I ../ -e 'require("losty.sql.migrate")(require("losty.sql.pg")("dbname", "user", "password", host, port))' users.sql friends

数据库服务器主机和端口是可选的,默认为 '127.0.0.1' 和 5432。Losty 迁移接受 SQL 和 Lua 源文件,并且 .lua 文件扩展名是可选的。

Lua 源文件应返回一个字符串数组,这些字符串是 SQL 命令。每个数组项都以单独的批次发送到数据库服务器。这意味着我们可以使用 Lua 以编程方式生成 SQL。

SQL 文件使用 ---- 作为批次分隔符。将 SQL 命令分成批次有助于在发生错误时,如果没有分批,则难以定位错误行。

让我们创建一个插入用户的函数

user.lua

    local db = require("losty.sql.pg")("dbname", "user", "password")
    
    function insert(name, email)
        db.connect()
        local r, err = db.insert("user (name, email) VALUES (:?, :?) RETURNING id", name, email)
        db.disconnect()
        return r and r.id, err
    end

请注意,db.connect() 必须在函数内部调用(而不是在顶层),否则会发生 cannot yield across C-call boundary 错误。db.disconnect() 在内部调用 keepalive(),它将连接放回连接池,这被认为比调用 close() 更好的做法。

:? 是占位符,其中 ? 是一个默认修饰符,它分别将 Lua 表格和字符串转换为 PostgreSQL JSON 和带引号的字符串。nameemail 中的值将在发送到数据库之前被插入到占位符中。

存在其他占位符修饰符,用于自定义从 Lua 到 PostgreSQL 数据类型的转换:对于 Lua 表格

对于 Lua 标量值

  • :b bytea

  • :? 转义的文字

  • :):] 原样输出,仅转换注释,并删除分号以及 )] 结束字符。

请参考 pgmoonlua-resty-mysql 文档以了解如何解释查询返回值。

生成 HTML

与在 HTML 结构内嵌入控制流的模板库不同,Losty 通过使用 Lua 生成 HTML 来反其道而行之,并充分利用语言特性。在 Javascript 中,就像 JSX 与 hyperscript 的结合,HTML 标签本身成为函数,这要归功于 Lua 函数环境及其元表。

    function tmpl(args)
        html({
                head({
                        meta('[charset=UTF-8]')
                        , title(args.title)
                        , style({
                                '.center { text-align: center; }'
                        })
                })
                , body({
                        div('.center', {
                                h1(args.title)
                        })
                        , footer({
                                hr()
                                , div('.center', '&copy' .. args.copyright)
                        })
                })
        })
    end
    
    local view = require('losty.view')
    local output = view(tmpl, {title='Sample', copyright='company'})
    

HTML 生成从一个视图模板函数开始,该函数可能接受一个参数,该参数应为键/值表。它应该返回一个字符串或一个字符串数组。

例如,在一个视图模板函数中,

    img({src='/a.png', alt='A'})

返回此字符串

    <img alt="A" src="/a.png">

事实上,您可以引用并使用第二个字符串,生成的 HTML 将相同,如上例中的 style() 标记所示。这意味着您可以复制现有的 HTML 代码并将其作为 Lua 字符串引用,并根据需要与 Losty HTML 标记函数交织在一起。

如您所知,存在空 HTML 元素和普通 HTML 元素。空元素(如 <br><hr><img><link> 等)不能包含子元素,而普通元素(如 <div><p>)可以。因此,以下代码会报错,因为 hr() 不能有子元素。

    hr(hr())
    hr({div(), span()})

而以下代码可以正常工作

    div("foo")
    div(".foo", '')
    div("#id1.foo", '')
    div("[class=foo][title=bar]", {})

结果如下

    <div>foo</div>
    <div class="foo"></div>
    <div class="foo" id="id1"></div>
    <div class="foo" title="bar"></div>

请注意,如果给出了两个或多个参数,并且如果第一个参数是字符串或键/值表,则将其视为属性。使用字符串作为属性需要特殊的语法。它们可以分别列在方括号中,或者以点开头表示类名,或者以井号开头表示 ID,如上所示。

这按预期工作,没有属性

    p(h1("blog"))
    nav(span('z'), span(1), span(false))
    ul({li("item1"), li("item2")})
    strong(nil, "Home")

生成

    <p><h1>blog</h1></p>
    <nav><span>z</span><span>1</span><span>false</span></nav>
    <ul><li>item1</li><li>item2</li></ul>
    <strong>Home</strong>

通常,Losty 视图模板比其 HTML 对应物更短。

不幸的是,<table> 标签和 Lua 中的 table 库具有相同的名称。因此,诸如 table.remove()table.insert()table.concat() 之类的函数仅公开为 remove()insert()concat(),而无需使用 table 进行限定。

最后,要生成 HTML 字符串,请使用您的视图模板作为第一个参数,以及所需的键/值表作为参数,调用 Losty 的 view() 函数。如果第三个布尔参数为真,则会阻止 <!DOCTYPE html> 附加到结果中,而第四个布尔参数则会在使用无效的 HTML5 标签时开启断言。

(SQL)测试或填充助手

Losty 具有一个简单的单元测试助手,用于练习您的 SQL 或 Lua 功能。

    local setup = require('losty.test')
    local pg = require('losty.sql.pg')
    
    local sql = pg(databasename, username, password, true)
    
    -- the 1st parameter `sql` can be nil if we are not testing database operations
    setup(sql, function(test, a, p, q)
        -- test is a function that tests some assertions
        -- a is an assert function
        -- p is a printing function
        -- q holds a table of functions for sql query (optional)
    
        p('user test')
        q.begin()
    
        local uid
        test("can create user", function()
                local u = user.add(q, "belly@email.com", 'Passw0rd')
                a(u and u.user_id, u)  -- works like assert
                uid = u.user_id
        end, true) -- true means commit a savepoint to database, until end of parent scope, which then decide whether to commit or rollback the whole setup
    
        test("can match user", function()
                local i = q.s1([[* from find_user(:?, :?)]], "belly@email.com", 'Passw0rd')
                a(i and i.user_id == uid, i)
        end)
    
        q.rollback() -- use q.commit() if seeding database
    end)
    

当使用 resty cli 运行时,上面的测试会生成已通过/失败的测试摘要。

要填充数据库,请省略 q.begin() 和 q.rollback() 语句,并将 true 作为最后一个参数传递给 test()

鸣谢

该项目借鉴了 Lapis、Mashape 路由器、lua-resty-session 等受人尊敬的项目的理念和代码,以及 OpenResty 和网络上的有用示例。

当然,如果没有最初的强大 OpenResty,它将不存在。

作者

Wi Siong (gnois)

许可证

mit

版本