【原文】 http://leafo.net/lapis/reference/actions.html

每一个 LapisHttp 请求都会遵循 Nginx 的基础流程。第一步:路由;一个路由必须有一个匹配的 URL 与之对应。从语法角度,你定义一个路由同时你需要绑定一个关联的 Action 。当一个请求匹配到你之前定义的路由的时候,对应的 Action 的方法就会被调用。

所有 Action 被调用时都会传入一个 Request 对象参数。这个 Request 对象主要用于 Actionview 的数据传输。另外,Request 对象还充当着 WebServer 返回 Client 结果的接口。

这些 Action 的返回结果通常会被用于渲染输出。字符串的返回结果通常直接交由浏览器直接解析。table 的返回类型通常是会被当作渲染的可选参数。如果返回值不止一个,后期会将它们合并成一个结果集。所以输出结果可以返回字符串加 table

如果路由文件内没有定义与当前请求匹配的路由,会进入到默认的路由,在后面的 application callback 中会继续介绍。

路由

路由定义有它自己的语法规则。下面用一段简单的事例做一下介绍:

1
2
3
4
5
6
local lapis = require("lapis")
local app = lapis.Application()

app:match("/", function(self) end)
app:match("/hello", function(self) end)
app:match("/users/all", function(self) end)

上面的路由规则是一个精确匹配的路由定义。比如:一个 /hello/world 不会匹配到 /hello 的路由上。

当然你可以指定一个命名参数和 : 跟着这个名称。这个参数会匹配除了 / 之外的任意字符(一般情况下):

1
2
3
4
5
app:match("/page/:page", function(self)
print(self.params.page)
end)

app:match("/post/:post_id/:post_name", function(self) end)

注意: 我们可以调用 print 进行 debug 。在 OpenResty 内运行的时候,print 的输出会发送到 Nginxnotice 日志。

捕获的路由参数的值会被存储到 Request 对象的 params 字段中。当然每个命名参数至少包含一个字符,否则会匹配失败。

splat 是另外一种匹配模式,它可以匹配所有甚至包含 / 字符。所有通过 splat 规则匹配到的数据会被存储到 Request 对象的名叫 paramstable 中的 splat 的参数中。它的语法上就是一个 *

1
2
3
4
5
6
app:match("/browse/*", function(self)
print(self.params.splat)
end)
app:match("/user/:name/file/*", function(self)
print(self.params.name, self.params.splat)
end)

如果你直接把长文本直接拼在地址后面,将不会匹配成功。当然有其他的办法,你可以在URL参数的最后加上 .zip 后缀以及加上 /files/:filename.zip 的路由规则。

路由可选组件

括号可以让路由规则变得可选:

1
/projects/:username(/:project)

上面的路由规则可以匹配 /projects/leafo 也可以匹配 /projects/leafo/lapis 。可选项如果没有匹配到数据的话会是一个 nil 值。

当然路由的可选规则还可以嵌套,比如:

1
/settings(/:username(/:page))(.:format)

路由参数字符类

一个字符类可以像 Lua 语法一样限制一个参数的规则。下面的这路由例子就是为了确保 user_id 是一个数字:

1
/user/:user_id[%d]/posts

下面这个路由就是限制是一个 16进制 的参数。

1
/color/:hex[a-fA-F%d]

路由优先级

首先第一步会把所有的匹配到的路由都匹配进来,然后进行从高到低排序:

  • 精确路由 /hello/world
  • 可变参路由 /hello/:variable
    • 每添加一个可变参数会降低一下路由的优先级
  • 模糊路由 /hello/*
    • 每加一个模糊匹配的规则它的优先级就会升高一级

比如:/hello/*spat/hello/*spat/world/*rest 第二个的优先级要比第一个更高。

以上就是路由的优先级排序:精确路由 > 可变参数路由 > 模糊路由

路由名称

路由名称不再是一个硬编码的 URL 结构的一部分,而是可以通过路由的名称可以直观的知道路由对应的 Action 是干嘛的,同时可以方便重定向到其它的路由。

在定义路由的时候的第一个参数就是用于命名路由的:

1
2
3
4
5
6
7
8
9
10
local lapis = require("lapis")
local app = lapis.Application()

app:match("index", "/", function(self)
return self:url_for("user_profile", { name = "leaf" })
end)

app:match("user_profile", "/user/:name", function(self)
return "Hello " .. self.params.name .. ", go home: " .. self:url_for("index")
end)

我们可以用 self:url_for() 用于重定向操作。第一个参数为重定向的路由的名称,第二个参数为可选参数可以用于传路由的参数。

注意:后续会继续介绍 url_for 的不同的生成 URL 的用法。

Http的请求方式

通常一个 URL 地址可以通过定义不同的 Http 请求方式来定义出不同的 ActionLapis 内部的方法 respond_to 可以完成上述的目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
local lapis = require("lapis")
local respond_to = require("lapis.application").respond_to
local app = lapis.Application()

app:match("create_account", "/create-account", respond_to({
GET = function(self)
return { render = true }
end,
POST = function(self)
do_something(self.params)
return { redirect_to = self:url_for("index") }
end
}))

respond_to 也可以设置一个前置过滤器用于在请求前的一些操作。我们可以定义一个 before 函数。同时这个和前置过滤器有相同的语义,如果你调用了 self:write() 此调用往后的逻辑讲不会继续被执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local lapis = require("lapis")
local respond_to = require("lapis.application").respond_to
local app = lapis.Application()

app:match("edit_user", "/edit-user/:id", respond_to({
before = function(self)
self.user = Users:find(self.params.id)
if not self.user then
self:write({"Not Found", status = 404})
end
end,
GET = function(self)
return "Edit account " .. self.user.name
end,
POST = function(self)
self.user:update(self.params.user)
return { redirect_to = self:url_for("index") }
end
}))

当然还有一个特殊情况:任意的 POST 请求如果它的 Content-type 头信息被设置为 application/x-www-form-urlencoded ,不管有没有 respond_to ,所有的请求体会被转义同时所有的参数会赋值给 self.params

在此之前你可以看到过调用 app:get()app:post() 方法的例子。它们都是基于 respond_to 做了一些封装,这样你可以快速的定义一个 Http 请求。比如 :getpostdeleteput

1
2
3
4
5
6
7
app:get("/test", function(self)
return "I only render for GET requests"
end)

app:delete("/delete-account", function(self)
-- do something destructive
end)

前置过滤器

有时候你需要在执行某个 Action 之前运行一段代码。一个很常见的例子,比如:设置用户的 session 。我们可以定义一个前置过滤器或者一个函数在 Action 之前,比如:

1
2
3
4
5
6
7
8
9
10
11
local app = lapis.Application()

app:before_filter(function(self)
if self.session.user then
self.current_user = load_user(self.session.user)
end
end)

app:match("/", function(self)
return "current user is: " .. tostring(self.current_user)
end)

这样就相当于调用了 app:before_filter。这样里面的逻辑就会按照既定的顺序执行。

如果在前置过滤器中调用了 self:write() 方法,然后这个 action 的逻辑就会运行至此结束。比如我们可以结束某个 Action 的后续逻辑或者没有满足某个条件是重定向到其他的页面:

1
2
3
4
5
6
7
8
9
10
11
local app = lapis.Application()

app:before_filter(function(self)
if not user_meets_requirements() then
self:write({redirect_to = self:url_for("login")})
end
end)

app:match("login", "/login", function(self)
-- ...
end)

注意self:write() 是一个常规的 Action 返回结果的过程,所以在 self:write() 中你可以返回你过去在 Action 里面的返沪的数据。

Request 对象

每个 Action 的第一个参数就是 Request 对象。也就是上面例子中的 self

Request 对象有如下的参数:

  • self.params – 一个 table ,包含所有 GETPOST 的请求参数
  • self.req – 原生的 request table (由 ngx 生成)
  • self.res – 原生的 response table (用于更新 ngx)
  • self.app – 应用的实例
  • self.cookiescookiestable ,可用于设置 Cookies 数据,且值仅支持字符串
  • self.session – 和 Cookie 对应的,可以存储任何可以被 JSON 编码的类型
  • self.route_name – 与请求匹配的路由名称
  • self.options – 控制请求的选项,底层都通过 write
  • self.buffer – 输出的缓冲,不需要通过人为的干涉,底层依然通过 write

Request 对象还有其他的方法:

  • write(options, ...) – 告知请求如何渲染结果
  • url_for(route, params, ...) – 获取一个命名的路由的URL 地址,或者一个对象
  • build_url(path, params)build 一个完全限定的 URL 地址
  • html(fn) – 通过 Html 构造器的语法生成一个字符串

@req

这是对 ngx 中的原生的 self.req 进行的一次封装。以下是它的相关的特性。

  • self.req.headersRequestheadertable
  • self.req.parsed_url – 请求的 URL 的转义的结果。包含 schemepathhostportquery 这些属性。
  • self.req.params_post – 存储所有的 POST 请求的参数
  • self.req.params_get – 存储所有的 GET 请求的参数

Cookies

你可以通过 self.cookies 进行 cookie 的读写操作。当然需要注意的是,当你遍历 cookie 内部的数据的时候需要判断空的情况:

1
2
3
4
5
app:match("/my-cookies", function(self)
for k,v in pairs(self.cookies) do
print(k, v)
end
end)

现有的 cookie 会存储在 metatable__index 中。因为所有的数据都会存储在 self.cookies 中,所以我们可以直接在 action 中就使用。

因此,操作 cookie 的代码就会变得下面这么简洁:

1
2
3
app:match("/sets-cookie", function(self)
self.cookies.foo = "bar"
end)

通常情况下所有的 cookie 还有其它的属性 Path=/; HttpOnly (这会创建一个 Session Cookie)。你可以通过 cookie_attributes 函数配置你应用里面的 Cookie 设置。下面是一个简单的设置过期时间的例子:

1
2
3
4
5
6
7
local date = require("date")
local app = lapis.Application()

app.cookie_attributes = function(self)
local expires = date(true):adddays(365):fmt("${http}")
return "Expires=" .. expires .. "; Path=/; HttpOnly"
end

这个 cookie_attributes 方法将 Request 对象作为它的第一个参数,然后下面就是具体的 Cookie 的设置操作。

Session

self.session 是一个更高级的请求方式。session 的内容会被序列化成 JSON 然后存储到特定的 cookie 中。这些序列化的 cookie 通常是经过签名的,所以通常这些数据不能随意篡改的。

session 的读写方式和 cookie 的方式很像:

1
2
3
4
5
app.match("/", function(self)
if not self.session.current_user then
self.session.current_user = "Adam"
end
end)

默认 session 的名称会存储在叫 lapis_sessioncookie 中。当然可以通过修改配置文件中的 session_name 变量来修改 session 的名称。session 是通过你的应用的密钥进行签名,它存储在你的配置文件变量 secret 中。强烈建议替换掉这个默认值。

1
2
3
4
5
6
7
-- config.lua
local config = require("lapis.config").config

config("development", {
session_name = "my_app_session",
secret = "this is my secret string 123456"
})

Request 对象的方法

write(things...)

写所有的参数。不同的 action 根据参数的类型进行区分。

  • string – 字符串附加到输出缓冲区
  • function(或者可以调用的 table) – 在输出缓冲区被调用,同时结果递归传给 write
  • table – 键值对会分配给 self.options,其它的值会递归传给 write

大多数的情况下,将调用 write 作为 action 的返回值传给 write 这样的方式没有必要。在前置过滤器中,write 有双重作用,不但可以输出内容,还可以取消正在运行的 action

url_for(name_or_obj, params, query_params=nil, ...)

name_or_obj 生成一个 URL

注意url_for 命名上有点不太恰当,因为通常它会生成一个页面的路径。如果你想得到一个完整的路径,你可以使用 build_for 函数。

如果 name_or_obj 是一个字符串,然后会去查找这个名称的 route ,参数就是二个参数的值。如果没有指定名称的路由,会抛出一个错误。

事例如下:

1
2
3
4
5
6
7
app:match("index", "/", function()
-- ...
end)

app:match("user_data", "/data/:user_id/:data_field", function()
-- ...
end)

具体的 url_for 的使用如下:

1
2
3
4
5
-- returns: /
self:url_for("index")

-- returns: /data/123/height
self:url_for("user_data", { user_id = 123, data_field = "height"})

如果第三个参数 query_params 有值。将会转成具体的 URL 参数拼接在地址后面。如果 route 没有任何的参数,第二个参数仍要传一个 nil

1
2
3
4
5
-- returns: /data/123/height?sort=asc
self:url_for("user_data", { user_id = 123, data_field = "height"}, { sort = "asc" })

-- returns: /?layout=new
self:url_for("index", nil, {layout = "new"})

当然 route 的所有的可选组件只有在被传值之后才会引入进来。如果没有被赋值就会被忽略。

比如,给出的 route 规则如下:

1
2
3
app:match("user_page", "/user/:username(/:page)(.:format)", function(self)
-- ...
end)

以下是 URL 的生成逻辑:

1
2
3
4
5
6
7
8
9
10
11
-- returns: /user/leafo
self:url_for("user_page", { username = "leafo" })

-- returns: /user/leafo/projects
self:url_for("user_page", { username = "leafo", page = "projects" })

-- returns: /user/leafo.json
self:url_for("user_page", { username = "leafo", format = "json" })

-- returns: /user/leafo/code.json
self:url_for("user_page", { username = "leafo", page = "code", format = "json" })

如果一个 route 包含一个通用匹配符,在传值的过程中你可以将数据赋值给 splat 参数:

1
2
3
app:match("browse", "/browse(/*)", function(self)
-- ...
end)
1
2
3
4
5
-- returns: /browse
self:url_for("browse")

-- returns: /browse/games/recent
self:url_for("browse", { splat = "games/recent" })

url_for 的第一个参数为对象

如果 name_or_obj 是一个 table,然后在这个 table 中调用了 url_params 方法,最终返回值传递给 url_for

url_params 方法里面的 request 对象参数以及其它的参数都会传递给 url_for

我们常见的方式是在 model 中实现 url_params,同时赋予相关的功能。比如:一个 User model 定义了一个 url_params 方法,实现查看用户详情页的功能:

1
2
3
4
5
local Users = Model:extend("users", {
url_params = function(self, req, ...)
return "user_profile", { id = self.id }, ...
end
})

结合上面的 url_for 实例演示一下:

1
2
3
local user = Users:find(100)
self:url_for(user)
-- could return: /user-profile/100

你可能注意到传递给 url_params 一个 ... 参数以及返回值里面也有 ...。意思是可以传递 query_params 参数:

1
2
3
local user = Users:find(1)
self:url_for(user, { page = "likes" })
-- could return: /user-profile/100?page=likes

使用 url_key 方法

如果 params 的参数的值是一个字符串类型,会直接赋值到生成的地址中。如果是一个 table 类型,在 table 中会调用 url_key 方法,然后返回值同样会赋值到生成的地址中。

举个例子,在一个 user model中使用 url_key 方法:

1
2
3
4
5
local Users = Model:extend("users", {
url_key = function(self, route_name)
return self.id
end
})

如果我们想要生成一个用户详情页的路径,通常会这么写:

1
2
local user = Users:find(1)
self:url_for("user_profile", {id = user.id})

因为上面的 url_key 的例子中 User 对象等同于 id 参数,所以写法上可以变换一下:

1
2
local user = Users:find(1)
self:url_for("user_profile", {id = user})

注意url_key 方法的第一个参数是路径名称,所以我们可以根据自己的需求修改相应的 route 的句柄。

build_for(path, [options])

构建一个绝对地址。

比如,我们的服务运行在 localhost:8080 上面:

1
2
3
4
self:build_url() --> http://localhost:8080
self:build_url("hello") --> http://localhost:8080/hello

self:build_url("world", { host = "leafo.net", port = 2000 }) --> http://leafo.net:2000/world

渲染(render)的可选项

当一个 table 被写入时,它们的键值对(仅是字符串类型的key)会被拷贝到 self.options 中。比如,下面的例子中的 renderstatus 属性会被拷贝。这个 table 仅会在 action 生命周期结束的时候使用,用于返回数据。

1
2
3
app:match("/", function(self)
return { render = "error", status = 404}
end)

以下就是可选值清单:

  • status – 设置一个 Http 状态值 (例如:200、404、500 …)
  • render – 视图的名称,必须是一个字符串或者是一个视图类
  • content_type – 用于设置 Content-type头信息
  • headers – 返回数据的 header, table 类型
  • json – 返回的 json 字符串
  • layout – 修改应用的默认的 layout
  • redirect_to – 重定向到其它的地址,支持相对路径和绝对路径

当使用 json 渲染的时候,需要确保内容的类型,以及应用的 layout 属性会被禁用:

1
2
3
app:match("/hello", function(self)
return { json = { hello = "world" } }
end)

应用回调

应用回调是一个特殊的方法,在我们的应用中处理某些特定类型的请求的时候可以覆盖相应的方法。这些函数在我们的应用中可以正常的调用,这也意味着这些函数的第一个参数是 request 对象的实例。

默认 action

当一个请求不能匹配给定的所有的路由规则时,它会去运行一个默认的 actionLapis 会预定义一个 action ,事例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function app:default_route()
-- strip trailing /
if self.req.parsed_url.path:match("./$") then
local stripped = self.req.parsed_url:match("^(.+)/+$")
return {
redirect_to = self:build_url(stripped, {
status = 301,
query = self.req.parsed_url.query,
})
}
else
self.app.handle_404(self)
end
end

在上面的例子中,以 / 为后缀的请求会被重定向到没有斜杠的位置,其他的请求会调用应用的 handle_404 方法。

default_route 方法也是应用中的一个普通的方法。你可以根据你的实际的业务需求覆盖这个方法。比如,添加日志:

1
2
3
4
5
6
function app:default_route()
ngx.log(ngx.NOTICE, "User hit unknown path " .. self.req.parsed_url.path)

-- call the original implementaiton to preserve the functionality it provides
return lapis.Application.default_route(self)
end

你会注意到除了 default_route 还有另外一个方法 handle_404 也可以进行预定义操作:

1
2
3
function app:handle_404()
error("Failed to find route: " .. self.req.cmd_url)
end

上面的代码会触发一个 500 的错误和一个无效请求的堆栈的跟踪记录。如果你想让 404 页面变得更友好一点,你需要按照下面的操作进行重写。

通过覆盖 handle_404 方法进行自定义 404 页面。

下面是一个简单的 404 页面,仅输出 "Not Found!"

1
2
3
function app:handle_404()
return { status = 404, layout = false, "Not Found!" }
end

错误处理

每个 Lapisaction 的执行都会被 xpcall 进行封装。这样可以确保那些致命错误可以输出一些可读性更高的信息,而不是一些 Nginx 的默认的错误信息。

错误处理主要处理那些意想不到的错误,该知识点后面还会进一步展开。

Lapis 的预定义了错误处理相关的操作,比如所有的额错误信息会被渲染到 "lapis.views.error" 中。错误页面包含调用栈以及错误信息。

如果你想自定义错误信息,你可以覆盖 handle_error 方法:

1
2
3
4
5
6
7
8
-- config.custom_error_page is made up for this example
function app:handle_error(err, trace)
if config.custom_error_page then
return { render = "my_custom_error_page" }
else
return lapis.Application.handle_error(self, err, trace)
end
end

request 对象或者 self 在系统异常的时候会传递失败。Lapis 提供了一个其他的方式获取 request 对象。

你可以使用 self.original_request 获取原始的 request 对象。

因为在错误页面里面会把全部的调用栈全部打出来,所以在线上环境建议设置自己的自定义错误页面,同时在日志记录相关的异常信息。

lapis-exception 模块增加了错误记录数据库的功能,同时可以发送通知邮件。