关于 action-controller
面向 Rails 8.1 的 Action Controller 技能,帮助开发者和编码 Agent 编写安全、符合惯例的控制器,涵盖参数过滤、回调、响应处理与安全实践。
主要功能
- 01Rails 8.1 控制器惯例
- 02强参数及嵌套参数模式
- 03回调顺序与授权
- 04渲染、重定向和会话
- 05CSRF 与错误处理指南
适用场景
- 01编写 CRUD 控制器操作
- 02过滤嵌套表单参数
- 03添加身份验证回调
- 04处理错误与 Turbo 响应
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill action-controller安装后在 Claude Code 中输入/action-controller确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/callbacks.mdrendering.mdsecurity.mdsessions-and-cookies.mdstreaming.mdstrong-params.md详细介绍
Rails Action Controller 专家
遵循 Rails 8.1 规范,编写正确、安全且符合惯例的 Rails 控制器。
理念
- 保持控制器精简——业务逻辑应放在模型或服务中,而非控制器中
- 强参数可防止批量赋值——原始参数会让攻击者设置任意属性(管理员标记、用户 ID 等)
- 约定优于配置——遵循 RESTful 模式;克制添加自定义操作的冲动
- 默认安全失败——默认限制访问,再有选择地开放
- 一个控制器对应一个资源——如果控制器处理两个资源,就拆分它
何时使用此技能
- 编写新的控制器操作(CRUD 或自定义操作)
- 允许参数(尤其是嵌套哈希和数组——这类地方最容易出错)
- 添加用于身份验证/授权的 before_action 过滤器
- 设置用于错误处理的 rescue_from
- 处理会话、Cookie 或 Flash 消息
- 渲染响应或重定向
- 实现流式传输或文件下载
- 配置 CSRF 保护或 HTTP 身份验证
说明
第 1 步:检查现有模式
先查看项目现有的控制器——与代码库保持一致比照搬教科书模式更重要:
# See what ApplicationController provides
cat app/controllers/application_controller.rb
# Find similar controllers
ls app/controllers/
# Check for shared concerns
ls app/controllers/concerns/
# Check routes for the resource
bin/rails routes | grep resource_name
一致性胜过“最佳实践”。
第 2 步:控制器结构
每个控制器内部都遵循以下顺序:
class ArticlesController < ApplicationController
# 1. Includes/concerns
include Searchable
# 2. Constants (if any)
ITEMS_PER_PAGE = 25
# 3. Callbacks — order matters, they run top-to-bottom
before_action :authenticate_user!
before_action :set_article, only: [:show, :edit, :update, :destroy]
before_action :authorize_article, only: [:edit, :update, :destroy]
# 4. Public actions (RESTful order: index, show, new, create, edit, update, destroy)
def index
@articles = Article.all
end
def show; end
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@article.destroy!
redirect_to articles_path, notice: "Article deleted.", status: :see_other
end
# 5. Private methods
private
def set_article
@article = Article.find(params.expect(:id))
end
def authorize_article
redirect_to articles_path, alert: "Not authorized." unless @article.user == current_user
end
def article_params
params.expect(article: [:title, :body, :published])
end
end
第 3 步:强参数
大多数控制器错误都源于此处——尤其是嵌套哈希和数组。
使用 expect(Rails 8+),而非 require + permit
# ✅ Rails 8+ — prefer expect (combines require + permit)
def article_params
params.expect(article: [:title, :body, :published])
end
# ⚠️ Older style — still works but expect is preferred
def article_params
params.require(:article).permit(:title, :body, :published)
end
嵌套哈希——危险地带
# Nested hash (belongs_to address, has fields)
# Form sends: { user: { name: "Jo", address: { street: "123 Main", city: "NY" } } }
def user_params
params.expect(user: [:name, address: [:street, :city, :zip]])
end
# ⚠️ Wrong — this permits NOTHING inside address
def user_params
params.expect(user: [:name, :address]) # address is a hash, not a scalar!
end
标量数组
# Array of simple values: { article: { title: "Hi", tag_ids: [1, 2, 3] } }
def article_params
params.expect(article: [:title, tag_ids: []])
end
哈希数组(accepts_nested_attributes_for)
# Array of nested objects — NOTE THE DOUBLE ARRAY SYNTAX [[...]]
# { project: { name: "X", tasks_attributes: [{ title: "A" }, { title: "B" }] } }
def project_params
params.expect(project: [:name, tasks_attributes: [[:title, :done, :id, :_destroy]]])
end
双重数组 [[...]] =“我希望得到一个哈希数组,其中每个哈希都包含这些键。”
这是 Rails 8 的 expect 语法。Agent 经常弄错这一点。
任意哈希(谨慎使用)
# When you genuinely can't enumerate keys (e.g., JSON metadata blob)
def product_params
params.expect(product: [:name, metadata: {}])
end
# ⚠️ metadata: {} permits ANY keys — only use when truly dynamic
常见陷阱
| 错误 | 修复方法 |
|---|---|
标签是数组时使用 permit(:tags) | 使用 permit(tags: []) |
地址是哈希时使用 permit(:address) | 使用 permit(address: [:street, :city]) |
上传文件时使用 permit(:images) | 多个文件使用 permit(images: []) |
嵌套属性未包含 _destroy 和 id | 使用 permit(items_attributes: [[:name, :id, :_destroy]]) |
使用 permit! 来“让它先能用” | 明确列出参数——permit! 允许攻击者设置任意属性 |
第 4 步:回调(before_action、after_action、around_action)
before_action
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: [:show, :edit, :update, :destroy]
# Skip inherited callbacks selectively
skip_before_action :authenticate_user!, only: [:index, :show]
private
def set_post
@post = Post.find(params.expect(:id))
end
end
关键规则:
- 回调按声明顺序运行——先进行身份验证,再加载资源
- 执行渲染或重定向的回调会中止调用链(后续回调和操作都不会运行)
- 使用
only:/except:将回调限定到特定操作 skip_before_action仅适用于从父类继承或更早注册的回调
around_action
around_action :wrap_in_transaction, only: [:create, :update]
private
def wrap_in_transaction
ActiveRecord::Base.transaction do
yield # executes the action
end
end
必须调用 yield——否则操作永远不会执行。
第 5 步:渲染响应
# Implicit render — renders app/views/posts/index.html.erb
def index
@posts = Post.all
# no explicit render needed
end
# Explicit render of a different template
render :new # same controller, different action template
render "posts/new" # cross-controller template
render plain: "OK" # plain text
render json: @post # JSON
render json: @post, status: :created # JSON with status
render html: "<h1>Hi</h1>".html_safe # raw HTML
render inline: "<%= 'hi' %>" # inline ERB (avoid)
render nothing: true, status: :ok # empty body (deprecated — use head)
# head — response with no body
head :no_content # 204
head :created, location: post_url(@post)
# Render with status (Turbo requires it for error responses)
render :new, status: :unprocessable_entity # 422 — required for Turbo
render :edit, status: :unprocessable_entity
**对于 Turbo/Hotwire:**表单提交失败时需要设置 status: :unprocessable_entity(422)——否则 Turbo 会忽略响应,用户看不到错误反馈。
用于多种格式的 respond_to
def show
@post = Post.find(params.expect(:id))
respond_to do |format|
format.html # renders show.html.erb
format.json { render json: @post }
format.pdf { send_data generate_pdf(@post), filename: "post.pdf" }
end
end
第 6 步:重定向
redirect_to @post # redirect to show
redirect_to posts_path # redirect to index
redirect_to root_path, notice: "Done!" # with flash notice
redirect_to root_path, alert: "Oops!" # with flash alert
redirect_to root_path, status: :see_other # 303 — use for DELETE actions
# Redirect back (with fallback)
redirect_back fallback_location: root_path
# ⚠️ After DELETE, use status: :see_other (303)
# This prevents browsers from replaying the DELETE on redirect
def destroy
@post.destroy!
redirect_to posts_path, notice: "Deleted.", status: :see_other
end
第 7 步:Flash 消息
# Set in redirect
redirect_to @post, notice: "Saved!"
redirect_to @post, alert: "Problem!"
# Set manually (available on NEXT request)
flash[:notice] = "Saved!"
flash[:alert] = "Problem!"
# Set for CURRENT request (use when rendering, not redirecting)
flash.now[:error] = "Could not save."
render :new, status: :unprocessable_entity
# Carry flash through an extra redirect
flash.keep
redirect_to another_path
**经验法则:**在 redirect_to 前使用 flash[...]。在 render 前使用 flash.now[...]。
第 8 步:会话和 Cookie
会话
# Store
session[:current_user_id] = user.id
# Read
session[:current_user_id]
# Delete
session.delete(:current_user_id)
# Nuke everything (do this on login to prevent session fixation)
reset_session
Cookie
# Basic (deleted when browser closes)
cookies[:theme] = "dark"
# With expiration
cookies[:theme] = { value: "dark", expires: 1.year }
# Permanent (20 years)
cookies.permanent[:locale] = "en"
# Signed (tamper-proof, but readable)
cookies.signed[:user_id] = current_user.id
cookies.signed[:user_id] # => 42
# Encrypted (tamper-proof AND unreadable)
cookies.encrypted[:token] = "secret"
cookies.encrypted[:token] # => "secret"
# Delete
cookies.delete(:theme)
第 9 步:使用 rescue_from 处理错误
class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found
respond_to do |format|
format.html { render file: Rails.root.join("public/404.html"), status: :not_found, layout: false }
format.json { render json: { error: "Not found" }, status: :not_found }
end
end
def unprocessable(exception)
respond_to do |format|
format.html { redirect_back fallback_location: root_path, alert: exception.message }
format.json { render json: { error: exception.message }, status: :unprocessable_entity }
end
end
def bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end
不要捕获 Exception 或 StandardError——它们会捕获 SystemExit 和 SyntaxError 等异常,破坏 Rails 内部机制并掩盖真正的错误。
第 10 步:CSRF 保护
默认启用。要点如下:
# Default — raises exception on CSRF failure
protect_from_forgery with: :exception
# For API controllers — skip CSRF (use token auth instead)
class Api::BaseController < ActionController::API # no CSRF by default
end
Turbo 和 rails-ujs 会自动处理 CSRF。对于自定义的 fetch 调用,从 <meta name="csrf-token"> 标签读取令牌,并将其作为 X-CSRF-Token 标头发送。
第 11 步:HTTP 身份验证、流式传输、请求/响应
# Basic Auth — quick and dirty (admin panels, staging)
http_basic_authenticate_with name: "admin", password: ENV["ADMIN_PASSWORD"]
# Token Auth — for APIs
authenticate_or_request_with_http_token do |token, _options|
ActiveSupport::SecurityUtils.secure_compare(token, ENV["API_TOKEN"])
end
# Send generated data as download
send_data pdf_content, filename: "report.pdf", type: "application/pdf"
# Send existing file
send_file Rails.root.join("storage/report.pdf"), filename: "report.pdf"
# Live streaming — always close the stream
include ActionController::Live
response.headers["Content-Type"] = "text/event-stream"
response.stream.write "data: hello\n\n"
ensure
response.stream.close
# Useful request properties
request.remote_ip # client IP
request.get? / request.post? # method checks
request.headers["X-Custom"]
request.format # Mime::HTML, Mime::JSON, etc.
request.variant = :mobile # for device-specific views
应避免的反模式
- 臃肿的控制器 — 将业务逻辑移至模型、服务或表单对象
permit!— 会为批量赋值攻击打开方便之门;应逐一列出所有允许的属性- 在模型调用中直接使用
params[:foo]— 会绕过强参数过滤;应通过 params 方法处理 - 大范围跳过 CSRF 防护 — 仅对使用令牌身份验证的真正 API 端点跳过;CSRF 可防止跨站表单提交
rescue_from Exception— 会捕获语法错误、SystemExit 以及其他不应被吞掉的异常- 在操作中使用嵌套的
if/else链 — 提取到服务对象中 - 多次渲染或重定向 — 控制器操作只能渲染或重定向一次;使用
and return或提前返回 - 错误渲染时未指定状态 — Turbo 要求使用
status: :unprocessable_entity - 所有内容都放进会话 — 使用 CookieStore 时,会话最大为 4KB;大型数据应存入数据库
- DELETE 后重定向时未使用
:see_other— 可能导致浏览器重放 DELETE 请求
快速参考
详细模式和示例请参阅 references/ 目录:
references/strong-params.md— 完整的参数许可模式(嵌套哈希、数组、双数组语法)references/callbacks.md— 所有回调类型、排序规则、中止方式、跳过模式references/rendering.md— 完整的渲染选项、重定向、闪存消息、respond_to、变体references/sessions-and-cookies.md— 会话存储、Cookie Jar 类型、配置references/security.md— rescue_from、CSRF、HTTP 身份验证、CSP、日志过滤、强制 SSL、浏览器版本控制references/streaming.md— send_data/send_file、SSE 流式传输、请求/响应对象、API 控制器、运行状况检查