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)
请注意,表单 body
和 db
如何被追加并作为参数传递给后续的处理程序,最后一个处理程序可以选择返回 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 和带引号的字符串。name
和 email
中的值将在发送到数据库之前被插入到占位符中。
存在其他占位符修饰符,用于自定义从 Lua 到 PostgreSQL 数据类型的转换:对于 Lua 表格
对于 Lua 标量值
:b
bytea:?
转义的文字:)
或:]
原样输出,仅转换注释,并删除分号以及)
或]
结束字符。
请参考 pgmoon 或 lua-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', '©' .. 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
版本
-
gnois/losty 0.3.6OpenResty 的函数式 Web 框架 2020-09-09 08:08:19
-
gnois/losty 0.3.3OpenResty 的函数式 Web 框架 2020-05-12 01:18:25
-
gnois/losty 0.3.0OpenResty 的函数式 Web 框架 2020-04-17 01:03:07
-
gnois/losty 0.2.0OpenResty 上的 Luaty Web 框架 2019-12-20 11:17:15
-
gnois/losty 0.1.0OpenResty 上的 Luaty Web 框架 2019-06-05 16:09:59