Roda 的路由树以 Request 对象为核心,route do |r| 中的 r 并不是徒有虚名的参数,它承载了 on、is、get、post 等路由方法,也暴露当前请求的 env。每个请求都会实例化一个 Roda::RodaRequest 或应用自定义的 Request 子类。想让路由代码更贴合业务,不必每次都写重复的 env 判断、header 解析或权限检查,而可以把这些逻辑放进 Request 对象的扩展中。本文会说明如何在不破坏 Roda 路由匹配机制的前提下,新增自定义路由方法与请求属性,同时保持扩展在测试和后续维护中可复用。

一、先从 Roda::Request 的职责说起
Roda 的设计非常依赖路由树,每个请求进入应用后会根据分支逐层匹配。route do |r| 代码块里的 r 是 Roda::RodaRequest 的实例,它内部保存了当前 Rack env、响应对象以及路由匹配的上下文。和 Rails 的 ActionDispatch::Request 不同,Roda 的 Request 没有把大量能力塞进基类,而是把大多数解析方法做成可以按需加载的插件。这种轻量设计给扩展留下了空间,也让开发者能够准确控制路由方法的行为。
需要明确的是,扩展 Request 对象并不是简单地在类上添加几个方法。Roda 的路由 DSL 依赖 halt 机制来终止匹配,on 和 is 在执行匹配块后会抛出 halt,确保后续路由不再执行。因此自定义路由方法如果要以块的形式参与路由匹配,最好复用已有的 on、is 方法,而不是自己手工处理 halt。如果只是做条件判断,就可以定义普通谓词方法,返回布尔值或对象,再由 on 去承接。
下面是一个最小应用,可以看到 r 的基本用法。这里没有扩展,但后续所有代码都会基于这个结构。
require 'roda'
class App < Roda
route do |r|
r.root do
'home'
end
r.on 'posts' do
r.get Integer do |id|
"post #{id}"
end
end
end
end
二、自定义路由方法:从布尔谓词到块式匹配
最常见的一类扩展是把重复出现的判断逻辑封装成语义清晰的方法。比如需要判断当前请求是否为 JSON 请求,或者当前用户是否具备管理员权限。如果不扩展,路由树里会出现 env['CONTENT_TYPE'].to_s.include? 这样的代码,既不易读也容易写错。把这些封装成 r.json? 或 r.admin? 后,路由代码会简洁很多。
自定义谓词方法可以直接定义在应用自己的 Request 子类中,但更推荐的方式是通过插件加载。插件能拿到应用类,再通过 app::RodaRequest.class_eval 将方法注入到该应用专属的 Request 类中。这样做不会影响其他 Roda 应用,也符合 Roda 插件化的风格。下面是一个通过插件注入 admin? 和 on_admin 的例子。
module AuthRequestExtensions
def self.load_dependencies(app)
app::RodaRequest.class_eval do
def admin?
user = env['roda_user']
user && user[:role] == 'admin'
end
def on_admin(&block)
on(admin?, &block)
end
end
end
end
class App < Roda
plugin AuthRequestExtensions
route do |r|
r.on 'dashboard' do
r.on_admin do
r.get do
'admin area'
end
end
r.get do
'forbidden'
end
end
end
end
上面的 on_admin 没有自己实现 halt 逻辑,而是把判断结果交给 on。当 admin? 为真时,on 会执行后面的块并终止匹配;当为假时,on 返回 false,路由树继续执行后面的 r.get do,因此不会误伤匿名访问。这种方式比手工处理 throw :halt 更安全,也不容易破坏 Roda 内部的匹配状态。
除了权限判断,还可以把分页、版本号、内容协商等逻辑封装成路由方法。比如做一个 on_api_version,配合请求头中的 Accept-Version 来区分接口版本。这样多版本 API 的路由树不再散落着 header 取值和字符串比较,而是直接以 r.on_api_version 'v1' 的形式表达。需要注意的是,路由方法的名字最好带上明确的业务前缀,否则容易和 Roda 未来版本新增的 API 冲突。前缀 on_ 是比较常见的选择,表明该方法会参与路由匹配。
三、请求属性:把 env 解析结果变为可复用的数据
请求属性和路由方法略有区别:路由方法主要服务于匹配,请求属性更偏重从原始 env 中提取结构化数据。一个典型场景是 JWT 认证,每次从 Authorization 头中解出 token、再解码出用户信息,这些步骤如果散落在多个路由分支中,不仅重复,还容易出现解析不一致。扩展 Request 对象后,可以直接用 r.current_user 拿到结果。
请求属性在实现上适合采用惰性求值。不要在 initialize 阶段读取 body 或解析 header,因为很多请求可能永远用不到这些数据。每次使用时解析并用实例变量缓存,既保证同一个请求内的一致性,又避免重复计算。下面的代码展示了如何从 Authorization 头中提取 Bearer token,以及如何解析 JSON 请求体。
module ApiRequestProperties
def self.load_dependencies(app)
app::RodaRequest.class_eval do
def bearer_token
@bearer_token ||= begin
header = env['HTTP_AUTHORIZATION'].to_s
header[/\ABearer\s+(.+)\z/, 1]
end
end
def json_body
@json_body ||= JSON.parse(body.read)
rescue JSON::ParserError
nil
end
end
end
end
require 'json'
class ApiApp < Roda
plugin ApiRequestProperties
route do |r|
r.post 'login' do
data = r.json_body
if data
"welcome, #{data['name']}"
else
'invalid json'
end
end
end
end
上面的 bearer_token 方法使用正则的 \A 和 \z 锚点,既避免匹配到 Authorization 头里的其他 Bearer 内容,也在没有该头时返回 nil。json_body 方法则利用方法级 rescue 捕获解析失败并返回 nil,调用方无需在路由里写 begin rescue。这种封装让错误处理集中在一个位置,路由代码只需要处理业务结果。
属性扩展还可以进一步组合。例如 current_user 可以依赖 bearer_token,如果不希望每次都解码 token,可以把解码结果也缓存起来。但要注意 Roda::RodaRequest 实例是每个请求独立的,所以实例变量缓存不会跨请求泄漏。如果使用的底层库(如 Warden 或 JWT 库)本身已经有请求级别的缓存,也可以直接委托,不必重复造轮子。
四、完整示例:版本化 API 请求扩展
把路由方法和请求属性结合起来,可以形成一个比较完整的 API 请求扩展模块。假设系统需要根据请求头 Accept-Version 走不同版本的接口,同时需要分页参数和 JSON 内容类型判断。扩展后,路由树可以写成下面的形式。
module ApiRequestExtensions
def self.load_dependencies(app)
app::RodaRequest.class_eval do
def api_version
@api_version ||= env['HTTP_ACCEPT_VERSION'] || 'v1'
end
def pagination_params
@pagination_params ||= begin
page = params['page'].to_i
per_page = params['per_page'].to_i
page = 1 if page < 1
per_page = 20 if per_page < 1 || per_page > 100
{ page: page, per_page: per_page }
end
end
def on_api_version(version, &block)
on(api_version == version, &block)
end
end
end
end
class ApiApp < Roda
plugin ApiRequestExtensions
route do |r|
r.on 'api' do
r.on_api_version 'v1' do
r.get 'users' do
p = r.pagination_params
User.page(p[:page]).per(p[:per_page]).to_json
end
end
r.on_api_version 'v2' do
r.get 'users' do
p = r.pagination_params
UserV2.list(page: p[:page], limit: p[:per_page]).to_json
end
end
end
end
end
这个例子中的 on_api_version 复用了 on 的块处理机制。如果版本不匹配,方法会返回 falsy,路由树继续向下寻找其他版本分支;如果匹配,则执行块并终止当前路由。这样无论有多少版本,路由树的层级都很清晰,不需要在分支内部再写 if version == 'v1' 之类的判断。
分页参数封装为 pagination_params 后,默认值和边界检查都集中在 Request 扩展里。路由代码不再重复 page = params['page'].to_i 的转换逻辑,也不容易再出现某个接口忘记限制 per_page 上限的问题。需要注意的是,params 是 Roda 提供的请求参数方法,扩展方法中可以直接使用,但最好在文档中注明依赖,避免后续替换参数解析插件时遗漏。
五、测试与维护中的几个注意点
为这类扩展写测试时,不建议直接实例化 Request 对象来单测,因为 Roda::RodaRequest 需要一个完整的 Rack 环境和 Roda 应用上下文。更实际的做法是通过 Rack::Test 发起完整请求,验证路由匹配和属性解析。这样既覆盖了扩展逻辑,也验证了插件加载是否正常。下面是一个使用 Minitest 的测试示例。
require 'minitest/autorun'
require 'rack/test'
class ApiAppTest < Minitest::Test
include Rack::Test::Methods
def app
ApiApp.freeze.app
end
def test_v1_pagination_defaults
header 'Accept-Version', 'v1'
get '/api/users'
assert last_response.ok?
body = JSON.parse(last_response.body)
assert_equal 1, body['page']
end
end
维护扩展时,最重要的原则是尽量使用应用专属的 Request 类,而不是直接修改 Roda::RodaRequest。如果直接把方法注入基类,会让同一个 Ruby 进程中所有 Roda 应用都获得这些方法,可能引发命名冲突和难以追踪的行为差异。通过插件里的 app::RodaRequest.class_eval,扩展范围被限制在加载插件的应用中,符合隔离原则。
另一个容易忽略的问题是加载顺序。如果扩展方法依赖 Warden、JWT 或数据库模型,应该确保这些依赖在 plugin 调用之前已经可用。Roda 插件的 load_dependencies 方法通常用于声明依赖,但如果是应用外部 gem,需要在 plugin 前先 require。若扩展方法中调用的常量尚未加载,插件加载阶段不会立即报错,而是在某个请求真正调用该方法时才触发 NameError,排查起来比较麻烦。可以在插件的 load_dependencies 里显式 require 或通过 app 上下文提前引用这些常量。
性能方面,Request 扩展应当保持轻量。谓词方法多数情况下只是做内存中的判断或哈希取值,开销很小。但像 json_body 这样的方法会读取并解析请求体,如果同一请求中多处调用,必须依赖实例变量缓存,否则 body 流被读取一次后再读可能为空。缓存后的方法在单个请求内只解析一次,后续调用直接返回同一个对象,既快又稳定。