关于 routing
面向 Rails 开发者的路由技能,指导在 `config/routes.rb` 中设计精简的 RESTful 路由。内容涵盖资源嵌套、命名空间、约束、API 路由和路由测试。
主要功能
- 01按需生成 RESTful 资源路由
- 02使用浅层嵌套组织资源
- 03区分 namespace 与 scope
- 04定义成员路由和集合路由
- 05设置路由约束与 API 路由
- 06测试和排查路由
适用场景
- 01为控制器仅开放所需路由
- 02设计带父资源上下文的浅层嵌套路由
- 03为管理区或 API 版本设置命名空间
- 04验证路由映射并查找冲突
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill routing安装后在 Claude Code 中输入/routing确认已加载。
附带文件reference.md
详细介绍
Rails 路由专家
定义清晰、符合 RESTful 风格且精简的路由。额外路由会扩大不必要的攻击面,也会让阅读 routes.rb 的其他开发者感到困惑。
理念
- 默认使用 RESTful 风格 — 使用
resources和resource。自定义路由通常是问题的征兆。 - 尽量精简 — 始终使用
only:或except:,只开放必要的路由。 - 处处使用路径辅助方法 — 硬编码的 URL 字符串会在路由变更时失效。使用
_path和_url辅助方法。 - 只进行浅层嵌套 — 资源嵌套不要超过 1 层;更深的嵌套会导致 URL 和辅助方法名称难以维护。
- 新增资源优于自定义操作 — 如果要添加很多自定义操作,就需要新建一个控制器。
适用场景
- 在
config/routes.rb中添加或修改路由 - 在 namespace、scope 和 module 之间进行选择
- 嵌套资源
- 添加 member 或 collection 路由
- 设置 API 版本控制
- 调试路由冲突
- 测试路由
说明
步骤 1:检查现有路由
添加新路由前先检查当前路由 — 重复和冲突会导致难以察觉的错误:
# Show all routes
bin/rails routes
# Filter by controller
bin/rails routes -c users
# Search by path or helper name
bin/rails routes -g admin
# Find unused routes (clean these up!)
bin/rails routes --unused
步骤 2:正确使用资源
resources(复数)— 用于集合
生成 7 条路由:index、show、new、create、edit、update、destroy。
# Generates all 7 routes — unused routes create dead endpoints and confuse devs
resources :articles
# Better — only generate what the controller actually implements
resources :articles, only: [:index, :show, :create]
思考:这个控制器实际实现了哪些操作? 只为这些操作添加路由。
resource(单数)— 用于单例资源
当每个用户或上下文中只有一个该资源时使用。不包含 index 操作。URL 中不包含 :id。
# Current user's profile — there's only one
resource :profile, only: [:show, :edit, :update]
# Current user's session
resource :session, only: [:new, :create, :destroy]
# App-wide dashboard
resource :dashboard, only: :show
⚠️ 重要:resource(单数)仍然会路由到复数形式的控制器。
resource :profile → ProfilesController(而不是 ProfileController)
路径辅助方法使用单数形式:profile_path(而不是 profiles_path)、edit_profile_path。
何时使用单数形式或复数形式:
- 用户能拥有或查看多个资源吗?→ 使用
resources(复数) - 在当前上下文中是否恰好只有一个?→ 使用
resource(单数) - 是否适用
index操作?→ 使用resources(复数)
步骤 3:嵌套资源(最多 1 层)
当子资源只有在其父资源中才有意义时,进行嵌套。
# GOOD — 1 level deep
resources :articles do
resources :comments, only: [:index, :create, :destroy]
end
# Avoid — 2+ levels creates unwieldy URLs and painful helper names
resources :users do
resources :articles do
resources :comments # ← /users/1/articles/2/comments/3
end
end
当子资源具有独立身份时,使用浅层嵌套:
# GOOD — collection routes nested, member routes flat
resources :articles do
resources :comments, shallow: true
end
# Creates:
# /articles/:article_id/comments (index, create, new)
# /comments/:id (show, edit, update, destroy)
这样,在创建或列出资源时可以获得父级上下文,而直接访问时则使用简洁的 URL。
嵌套路径辅助方法会包含父资源:
article_comments_path(@article) # GET /articles/1/comments
article_comment_path(@article, @comment) # GET /articles/1/comments/5
# With shallow:
comment_path(@comment) # GET /comments/5
步骤 4:Namespace、Scope 与 Module 的区别
这三者看起来相似,但行为各不相同——混淆使用会导致难以理解的路由错误。
namespace — 改变所有内容(路径、模块和辅助方法)
namespace :admin do
resources :articles, only: [:index, :show]
end
- 路径:
/admin/articles - 控制器:
Admin::ArticlesController(位于app/controllers/admin/) - 辅助方法:
admin_articles_path
适用场景: 管理面板、API 版本,以及使用独立控制器的不同区域。
scope module: — 仅改变模块(不改变路径)
scope module: :admin do
resources :articles, only: [:index, :show]
end
- 路径:
/articles(没有前缀!) - 控制器:
Admin::ArticlesController - 辅助方法:
articles_path
适用场景: 将控制器整理到子目录中,但不改变 URL。
scope path: — 仅改变路径(不改变模块)
scope "/admin" do
resources :articles, only: [:index, :show]
end
- 路径:
/admin/articles - 控制器:
ArticlesController(没有模块!) - 辅助方法:
articles_path
适用场景: 为 URL 添加前缀,但不使用单独的控制器命名空间。
快速参考
| 方法 | URL 前缀 | 控制器模块 | 辅助方法前缀 |
|---|---|---|---|
namespace :admin | /admin | Admin:: | admin_ |
scope module: :admin | 无 | Admin:: | 无 |
scope "/admin" | /admin | 无 | 无 |
不确定时,使用 namespace。 它的表达最明确,也最不容易引起意外。
步骤 5:Member 与 Collection 路由
当标准的 7 个 RESTful 操作之外还需要其他路由时使用。
Member 路由 — 针对一条特定记录执行操作(需要 :id)
resources :articles, only: [:index, :show] do
member do
patch :publish # PATCH /articles/:id/publish
patch :archive # PATCH /articles/:id/archive
end
end
生成:publish_article_path(@article)、archive_article_path(@article)
Collection 路由 — 针对整个集合执行操作(不需要 :id)
resources :articles, only: [:index, :show] do
collection do
get :search # GET /articles/search
get :drafts # GET /articles/drafts
delete :clear_all # DELETE /articles/clear_all
end
end
生成:search_articles_path、drafts_articles_path
简写(单一路由)
resources :photos do
get :preview, on: :member # GET /photos/:id/preview
get :search, on: :collection # GET /photos/search
end
⚠️ 停下——你真的需要自定义操作吗?
如果你要添加超过 2 个自定义操作,可能需要新建一个资源:
# BAD — too many custom actions on one resource
resources :articles do
member do
patch :publish
patch :unpublish
patch :archive
patch :unarchive
patch :feature
end
end
# GOOD — extract a new resource
resources :articles, only: [:index, :show]
resources :article_publications, only: [:create, :destroy] # publish/unpublish
resources :article_archives, only: [:create, :destroy] # archive/unarchive
步骤 6:路由关注点(DRY 共享模式)
当多个资源共享相同的嵌套路由时:
concern :commentable do
resources :comments, only: [:index, :create, :destroy]
end
concern :taggable do
resources :tags, only: [:index, :create, :destroy]
end
resources :articles, concerns: [:commentable, :taggable]
resources :photos, concerns: [:commentable]
resources :videos, concerns: [:commentable, :taggable]
步骤 7:约束
段约束(验证参数)
resources :users, constraints: { id: /\d+/ }
请求约束(子域名、格式等)
constraints subdomain: "api" do
namespace :api do
resources :articles, only: [:index, :show]
end
end
高级约束(自定义逻辑)
# Lambda
get "*path", to: "errors#not_found",
constraints: lambda { |req| !req.path.start_with?("/admin") }
# Object (responds to matches?)
class ApiConstraint
def matches?(request)
request.headers["Accept"]&.include?("application/json")
end
end
constraints ApiConstraint.new do
resources :articles, only: [:index, :show]
end
步骤 8:根路由
始终定义根路由:
root "pages#home"
命名空间根路由:
namespace :admin do
root "dashboard#show" # /admin → Admin::DashboardController#show
end
步骤 9:重定向
# Simple redirect (301 by default)
get "/old-path", to: redirect("/new-path")
# With status code
get "/old-path", to: redirect("/new-path", status: 302)
# Dynamic redirect
get "/articles/:id", to: redirect("/posts/%{id}")
步骤 10:挂载引擎
mount Sidekiq::Web => "/sidekiq"
mount ActionCable.server => "/cable"
步骤 11:API 路由
namespace :api do
namespace :v1 do
resources :articles, only: [:index, :show, :create, :update, :destroy]
resources :users, only: [:show]
end
end
对于仅限 API 的控制器,使用 defaults: { format: :json }:
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :articles, only: [:index, :show]
end
end
步骤 12:Direct 和 Resolve(自定义 URL 帮助方法)
direct — 创建自定义 URL 帮助方法
direct :homepage do
"https://example.com"
end
# homepage_url => "https://example.com"
direct :cdn_image do |model|
"https://cdn.example.com/#{model.image_path}"
end
# cdn_image_url(@photo) => "https://cdn.example.com/photos/1.jpg"
resolve — 自定义模型的多态路由
使用 resource(单数)搭配 form_with 时必须设置:
resource :basket, only: [:show, :update]
resolve("Basket") { [:basket] }
# Now form_with(model: @basket) generates /basket (not /baskets/:id)
路由文件组织
按如下方式排列 config/routes.rb:
Rails.application.routes.draw do
# 1. Root
root "pages#home"
# 2. Authentication / sessions
resource :session, only: [:new, :create, :destroy]
resources :registrations, only: [:new, :create]
# 3. Core resources (most used first)
resources :articles do
resources :comments, only: [:index, :create], shallow: true
end
# 4. Namespaced sections
namespace :admin do
root "dashboard#show"
resources :users
end
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :articles, only: [:index, :show]
end
end
# 5. Utility routes
get "up", to: "rails/health#show", as: :rails_health_check
# 6. Catch-all (if needed — put LAST)
get "*path", to: "errors#not_found"
end
测试路由
# test/routing/articles_routing_test.rb
class ArticlesRoutingTest < ActionDispatch::IntegrationTest
test "routes to articles#index" do
assert_routing "/articles", controller: "articles", action: "index"
end
test "routes to articles#show" do
assert_routing "/articles/1", controller: "articles", action: "show", id: "1"
end
test "generates correct path" do
assert_generates "/articles/1", controller: "articles", action: "show", id: "1"
end
test "recognizes route" do
assert_recognizes(
{ controller: "articles", action: "create" },
{ path: "/articles", method: :post }
)
end
end
反模式
- 需要 2 个路由时却生成全部 7 个路由 — 始终使用
only:或except: - 嵌套层级超过 1 层 — 使用浅层嵌套或拆分资源来扁平化结构
- 硬编码路径 — 路由更改时,硬编码字符串会失效;请使用
article_path(@article),而不是"/articles/#{@article.id}" - 使用
match ... via: :all— 暴露所有 HTTP 动词会扩大攻击面;应明确指定 - 过多自定义成员/集合操作 — 改为提取一个新资源
- 混淆 namespace/scope/module — 查看步骤 4 中的表格
- 需要
resources时却使用resource— 单数形式表示单个对象,没有 index,也没有:id - 嵌套资源缺少
only:/except:— 嵌套路由很快就会膨胀 - 使用单数
resource时忘记resolve— 不设置它,form_with将无法正常工作 - 路由顺序错误 — 更具体的路由应放在通用路由之前(Rails 从上到下匹配,匹配到第一个即生效)
调试
# See what a specific URL maps to
bin/rails routes -g "GET /articles"
# Check for conflicts
bin/rails routes | grep articles
# In rails console
Rails.application.routes.url_helpers.articles_path
app.edit_article_path(Article.first)
有关详细模式、示例和边缘情况,请参阅此技能目录中的 reference.md。