37signals Writebook 源码学习(2) - routes.rb
只是打开
routes.rb浏览了一下,我就开始怀疑自己是否还能成为一个优秀的程序员。
Writebook 符合我对理想 Rails 程序的所有假设:尽量只使用“纯粹”的 Rails 代码,避免使用第三方 gem,这不仅有助于保持简单和可维护性,也会减少 Rails 版本升级所引起的问题。
从 Gemfile 可以看出,Writebook 所使用的堆栈大致如下:
- Thruster
- 用作 HTTP/2 代理、静态文件缓存和 TLS,作用类似 Nginx 但更轻量级。
- GitHub 文档中有一句话:The goal is that simply running your Puma server with Thruster should be enough to get a production-ready setup。所以对于不大的 Rails 项目来说,Thruster 无需调整 (可能要设置 TLS) 就是生产可用的。
- https://github.com/basecamp/thruster
- SQLite3
- ERB
- JavaScript:propshaft,importmaps,Stimulus
- Minitest
这是不能更纯粹的 Rails。
本笔记基于 Writebook
1.1.0。
1. routes.rb
大致浏览了 Gemfile 之后,我就直奔 routes.rb,毕竟是一切操作的入口。
1.1 resource vs resources
我第一次见到
resource时以为是笔误 🥲
代码第六行定义的路由如下:
resource :session, only: %i[ new create destroy ] do
scope module: "sessions" do
resources :transfers, only: %i[ show update ]
end
end
这段方法所对应的路由如下:
new_session GET /session/new(.:format) sessions#new
session DELETE /session(.:format) sessions#destroy
POST /session(.:format) sessions#create
session_transfer GET /session/transfers/:id(.:format) sessions/transfers#show
PATCH ... sessions/transfers#update
PUT ... ...
# 如果没有 scope 方法
session_transfer GET /session/transfers/:id(.:format) transfers#show
PATCH /session/transfers/:id(.:format) transfers#update
PUT ... ...
resource 和 resources 方法都用于定义 RESTful 资源的路由,它们的主要区别是生成的路由数量和 URL 结构:
| resource | resources | |
|---|---|---|
| 资源名称 | 单数 (resource :session) | 复数 (resources :sessions) |
| 路由个数 | 六个 (不含 index) | 七个 |
| URL 示例 | /session (show) | /sessions/:id (show) |
Rails 通过它们的参数 (即资源名) 推断出 controller 和 model 名称,并最终找到对应的数据库表。
resource 通常用于管理单个资源,如用户资料、购物车等,这些资源通常只有一个实例与当前用户关联 (当前用户不需要有一个 index 方法来列出所有其他用户的资料);resources 用于管理多个资源,如产品、博客文章等。然而,如果一个资源只有一个实例,但需要支持多个操作,使用 resource 仍然是合适的。
所以 resource :session 加上后面的 :only 约束生成了上面的前三个路由。
而 scope 方法的主要作用是创建命名空间,从而避免控制器和路由的命名冲突,尤其是在大型应用中。它通过指定模块来组织控制器,提高代码的可组织性和可维护性。
以上面的路由为例,scope module: "sessions" 指定了 transfers controller 所在的命名空间。用 Rails 的约定来说,差异在于:
| 有 scope | 无 scope | |
|---|---|---|
| 文件位置 | sessions/transfers_controller.rb | transfers_controller.rb |
| 类名 | Sessions::TransfersController | TransfersController |
resource和resources都可以接受很多 options 使得生成的路由更定制化,API 文档有详细的解释: https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/Resources.html
1.1.1 练习
下面的路由方法来自第十五行,思考一下会生成什么:
resource :account do
scope module: "accounts" do
resource :join_code, only: :create
resource :custom_styles, only: %i[ edit update ]
end
end
1.1.2 答案
account_join_code POST /account/join_code(.:format) accounts/join_codes#create
edit_account_custom_styles GET /account/custom_styles/edit(.:format) accounts/custom_styles#edit
account_custom_styles PATCH /account/custom_styles(.:format) accounts/custom_styles#update
... PUT ... ...
1.2 namespace vs scope
Writebook 的主要业务逻辑都与 books 相关,从路由方法上也可以看出来:
resources :books, except: %i[ index show ] do
resource :publication, controller: "books/publications", only: %i[ show edit update ]
resource :bookmark, controller: "books/bookmarks", only: :show
scope module: "books" do
namespace :leaves do
resources :moves, only: :create
end
resource :search
end
resources :sections
resources :pictures
resources :pages
end
这里 namespace 和 scope 的相似性困惑了我很久,看起来它们区别也不大,都可以创建命名空间,添加 URL 前缀等等,功能基本上是重叠的。看 API 文档和 Rails Guides 也没看出个子丑寅卯。
API 文档: https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/Scoping.html
Rails Guides: https://guides.rubyonrails.org/routing.html#controller-namespaces-and-routing
查了很多资料之后,发现它们的区别其实就是一句话:namespace 是定制版的 scope。scope 支持三个选项:
:module:指定命名空间 (即类名或使用 module):path:添加 URL 前缀:as:添加生成的 path/url 前缀
以下面的代码为例:
scope module: "admin", path: "admin", as: "admin" do
resources :articles
end
module: "admin"会使:articles的类名为Admin::ArticlesController(当然也可以把class ArticlesController放到module Admin下)path: "admin"会使生成的 URL 为/admin/articles/:id形式as: "admin"会使生成的 path/url 为admin_articles_path形式
这里的关键是,这三个选项都是用了才会生效,所以如果只使用 :module 和 :path 选项,那么生成的 path 则依然是原先的 articles_path,而不会是 admin_articles_path。
了解了 scope 的这三个选项,就容易理解为什么 namespace 是定制版的 scope 了,因为 namespace 是一个预设了这三个选项的 scope。而 scope 没有为这三个选项的任何一个指定默认值,它允许我们选择性地使用这三个选项。也因此,namespace 更便捷,而 scope 更灵活。所以下面这两个代码片段结果是完全相同的:
namespace "admin" do
resources :articles
end
scope "/admin", as: "admin", module: "admin" do
resources :articles
end
关于何时使用这两者,我的想法是根据资源的相关性:namespace 更适合表示强关联的资源,而 scope 更适合表示弱关联或逻辑分组的资源。如果一个资源是完全属于另一个资源的子类,namespace 比较合适;如果关联性不大只在 URL 或其他方面有共性,使用 scope 更灵活。举一个不知道是否恰当的例子:
- 比如美短和布偶都属于猫,它们既需要放到猫这个目录下,在访问上也需要
/猫/布偶,/猫/美短这种形式,显然namespace更合适 - 而猫和狗都属于动物,除了 URL 需要
/动物/猫,/动物/狗之外,其他方面关联性不大,这时候只使用scope path: '动物'可能更好点
https://courses.bigbinaryacademy.com/learn-rubyonrails/rails-routing-in-depth/
TODO: 除此之外,上面的代码还有一点是我没搞明白的,即 resource :bookmark, controller: "books/bookmarks", only: :show 这里为什么使用 controller: "books/bookmarks" 而不是直接把 resource :bookmark 放到 scope module: "books",从 rails routes 的结果来看,它们没什么区别。
两者比较的结果:
一种可能的解释是 scope 指定块中的所有内容都在指定模块内,但这里的”模块“未必是 controller 或资源本身,比如 Admin::UsersController 中 “admin” 既不是 controller 也不是资源。而 resource 中的 :controller 选项直接指定 controller 路径,这种写法避免了额外的路由层级,效率更高。正如“一棵是枣树,另一棵也是枣树”,而不是“两棵枣树” 并非简单的文字选择,而是经过深思熟虑的艺术处理。
但也可能真的只是作者的个人偏好。
顺便提一下,使用
rails g controller books/bookmarks"可以生成app/controllers/books/bookmarks_controller.rb。
1.3 direct
这是我第一次见到 direct 方法。但也并不惭愧,看到一些盛赞的博主都是第一次见 🥹。
get "/:id/:slug", to: "books#show", constraints: { id: /\d+/ }, as: :slugged_book
direct :book_slug do |book, options|
route_for :slugged_book, book, book.slug, options
end
咋一看似懂非懂,其实明白了也并不复杂。direct 方法使我们能够定义自定义的 URL 生成器,它接受一个生成器名字和一个块作为参数,并返回一个 URL 字符串或 url_for 方法可以处理的其他数据结构,调用这个生成器名字时传递的任何参数都会被传递给块。
就像下面这样:
direct :landing_page do
"https://example.com/"
end
# >> landing_page_url
# => "https://example.com/"
direct :greeting_page do |user|
"https://example.com/#{user.name}"
end
# >> greeting_page_path(matz)
# => "/yukihiro"
除了被视为 URL 的字符串之外,direct 方法还可以返回以下选项之一:
- 一个哈希,如
{controller: "page",action: "index"} - 一个数组,被传递给
polymorphic_url - 一个 Active Model 实例
- 一个 Active Model 类
事实上,这些返回值都是 url_for 方法的有效参数,换句话说,只要 url_for 能够利用这些返回值构造出有效的 URL 字符串就行。
https://api.rubyonrails.org/classes/ActionDispatch/Routing/UrlFor.html#method-i-url_for
route_for类似url_for,只是前者生成 path,后者生成 URL。
所以,现在回头看一下 Writebook 中的 direct 方法代码,它定义了一个 book_slug_path() 路由名字,这个名字对应的字符串是 route_for 方法利用 slugged_book_path() 构造的。这样我们就可以通过传递对象来构造 URL,而不是依次提取对象的属性:
book_slug_path(book)而不是slugged_book_path(book, book.slug)
注意,
rails routes的输出结果只有slugged_book而没有book_slug,所以相当于direct只是为我们定义了一个”普通“的方法来简写路由。
如果这个说服力还不太强,那么下面这个路由方法同样是出自 Writebook:
get "/:book_id/:book_slug/:id/:slug", to: "leafables#show", constraints: { book_id: /\d+/, id: /\d+/ }, as: :slugged_leafable
direct :leafable_slug do |leaf, options|
route_for :slugged_leafable, leaf.book, leaf.book.slug, leaf, leaf.slug, options
end
leafable_slug_path(leaf) VS slugged_leafable(leaf.book, leaf.book.slug, leaf, leaf.slug),这大概就是对代码美学的追求吧。
有一点需要注意的是 direct 方法不能在任何 scope 块内 (比如 namespace 或 scope) 使用,从源码可以看出:
# actionpack/lib/action_dispatch/routing/mapper.rb
def direct(name, options = {}, &block)
unless @scope.root?
raise RuntimeError, "The direct method can't be used inside a routes scope block"
end
@set.add_url_helper(name, options, &block)
end
DHH 有一篇文章 “Patek levels of finishing” 讲述对代码的雕琢: https://world.hey.com/dhh/patek-levels-of-finishing-467e5dc0
direct方法的文章:https://www.writesoftwarewell.com/direct-custom-url-helpers-rails/
directAPI: https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/CustomUrls.html#method-i-direct
1.4 如何只为子资源生成路由
最后一个值得注意的是:
resources :pages, only: [] do
scope module: "pages" do
resources :edits, only: :show
end
end
这里的 only: [] 表示避免生成任何与 pages 资源相关的标准 RESTful 路由,但仍然允许嵌套 pages/edits 资源的路由。
生成的结果如下:
page_edit GET /pages/:page_id/edits/:id(.:format) pages/edits#show
这是一种常见的模式,用于创建一个仅通过嵌套资源访问的资源。
2. 总结
Rails Router 里有相当多的高级用法,包括 concern、shallow 等,但一般的入门书籍里很少有介绍到,只能是见招拆招,遇到了再学习印象会更深刻。如果一下子把官网的 “Rails Routing from the Outside In” 看完,好像用处也不大…
Rails Routing from the Outside In: https://guides.rubyonrails.org/routing.html
The Rails Router: https://books.writesoftwarewell.com/3/rails-router