首页
/ Ruby on Rails 8.2 新手实战手册:从 `rails new` 构建 store 电商应用到 Kamal 生产部署

Ruby on Rails 8.2 新手实战手册:从 `rails new` 构建 store 电商应用到 Kamal 生产部署

2026-09-07 14:14:14作者:谭伦延

本指南是 Ruby on Rails(当前仓库版本 8.2.0.alpha,见 RAILS_VERSION)官方入门教程的中文深度解析。我们将以构建一个名为 store 的简易电商应用为主线,循序渐进地掌握 Rails 的核心技术栈:MVC 架构、Active Record 数据建模、路由与控制器、身份认证、缓存、富文本与文件上传、国际化、异步邮件、前端资产管线、自动化测试与 CI,最终用 Kamal 部署到生产服务器。读完本文你将能够独立完成一个 Rails 应用的"从零到生产上线"全流程。

Rails 的设计哲学:DRY 与约定优于配置

Rails 是基于 Ruby 语言的 Web 应用开发框架,它通过对"每个开发者起步都需要什么"做出假设,让你用更少的代码完成更多的工作。Rails 是"有主见"(opinionated)的软件——它假定存在一种"最佳做法",并默认引导你走这条"Rails Way":学习并顺应它,你会获得巨大的生产力提升;反之,把其他语言的习惯硬搬进来,体验可能并不愉快。

Rails 哲学包含两大指导原则:

  • Don't Repeat Yourself(DRY,不要重复自己):系统中每条知识都必须有单一、无歧义、权威的表达。避免重复编写相同信息,代码会更易维护、更易扩展、更少 bug。
  • Convention Over Configuration(约定优于配置):Rails 对 Web 应用中的许多事项持有默认看法,并默认采用这套约定,而不是要求开发者用无穷无尽的配置文件去逐一定义。

学习前的准备:环境与工具

本教程采用文档中的示例:构建一个名为 store 的电商项目,用于演示 Rails 内置的多项功能。你需要准备:

  • Ruby 3.4 或更新版本
  • Rails 8.2.0 或更新版本(本文仓库对应的正是 8.2.0.alpha
  • 一个趁手的代码编辑器

如需安装 Ruby/Rails,请参考 安装 Ruby on Rails 指南。安装后先在终端验证版本:

$ rails --version
Rails 8.2.0

版本号应不低于 Rails 8.2.0。Rails 自带大量便捷命令,rails --help 可查看全部命令;rails new --help 则能查看自定义生成应用的各种 flag。

MVC:Rails 代码的组织架构

Rails 代码基于 Model-View-Controller(MVC) 架构组织,绝大多数业务代码分布在三个概念层中:

  • Model(模型):管理应用数据,通常是数据库表及其关联逻辑。
  • View(视图):负责以 HTML、JSON、XML 等不同格式渲染响应。
  • Controller(控制器):处理用户交互与每个请求背后的逻辑。

Rails 各功能组件在仓库中也遵循同样的分层(例如模型引擎在 activerecord,视图引擎在 actionview/,控制器引擎在 actionpack/),理解 MVC 后你在 Rails 源码中查找任意功能都能快速定位。

Rails MVC 架构示意

创建第一个 Rails 应用

rails new 会为你生成一个全新的 Rails 应用基础框架,执行:

$ rails new store

生成完毕后进入应用目录,后续所有命令都应在此目录内运行:

$ cd store

新应用目录结构一览

对初学者来说,最关键的是 app/ 目录——控制器、模型、视图、辅助方法、邮件器、任务与资产都放在这里。下表汇总了新应用的各目录职责:

文件/目录 用途
app/ 存放应用的控制器、模型、视图、helpers、mailers、jobs 和 assets,本教程后续几乎都在此目录工作
bin/ 包含启动应用的 rails 脚本,以及用于 setup、update、deploy、run 的其他脚本
config/ 应用路由、数据库等配置,详见 Configuring Rails Applications
config.ru 基于 Rack 的服务器启动配置
db/ 当前数据库 schema 以及数据库迁移文件
Dockerfile Docker 配置文件
Gemfile / Gemfile.lock 通过 Bundler 声明应用所需 gem 依赖
lib/ 应用的扩展模块
log/ 应用日志文件
public/ 静态文件与编译后的资源;应用运行时此目录按原样对外暴露
Rakefile 定位并加载可从命令行运行的任务;自定义任务建议加入 lib/tasks 而非直接修改它
script/ 一次性脚本与基准测试脚本
storage/ SQLite 数据库文件与 Active Storage 的 Disk Service 文件,详见 Active Storage Overview
test/ 单元测试、fixtures 及其他测试装置,详见 Testing Rails Applications
tmp/ 临时文件(缓存与 pid 文件等)
vendor/ 第三方代码(典型应用包含 vendored gems)
.kamal/ Kamal secrets 与部署钩子
.ruby-version 默认 Ruby 版本
.gitignore / .dockerignore / .gitattributes Git 忽略规则、Docker 忽略规则与 Git 路径元数据
.github/ GitHub 专属文件
.rubocop.yml RuboCop 静态检查配置
README.md 应用说明书,应编写"应用做什么、如何 setup"等

Hello, Rails!——创建数据库并首次启动

store 目录中依次执行以下命令:

$ bin/rails db:create

该命令首先创建应用的数据库。接着启动 Web 服务器:

$ bin/rails server

NOTE:在应用目录内运行命令时应使用 bin/rails,以确保使用的是本应用锁定的 Rails 版本。

服务器启动后会运行 Puma,输出大致如下:

=> Booting Puma
=> Rails 8.2.0 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.4.3 (ruby 3.3.5-p100) ("The Eagle of Durango")
*  Min threads: 3
*  Max threads: 3
*  Environment: development
*          PID: 12345
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

在浏览器打开 http://localhost:3000,会看到默认的 Rails 欢迎页:

Rails 默认欢迎页

欢迎页是新应用的"冒烟测试"(smoke test),证明后台各环节能正常协作来渲染一个页面。随时可用 Ctrl-C 停止服务器。

开发环境的自动加载

开发者幸福感是 Rails 的核心理念之一,具体体现之一就是开发环境下的自动代码重载:一旦启动 Rails 服务器,新增文件或对既有文件的修改都会被侦测并自动加载/重载,无需每次改动后重启。你可能还注意到 Rails 应用几乎不使用其他语言常见的 require 语句——Rails 靠命名约定自动按需加载文件,你可以专心写业务代码。详见 Autoloading and Reloading Constants

Active Record:创建数据模型与数据库迁移

Active Record 是 Rails 将关系型数据库映射为 Ruby 代码的核心功能,它自动生成与数据库交互的 SQL(建表、增删改查记录等)。我们的应用默认使用 SQLite(Rails 的默认数据库)。

为商品建模,运行:

$ bin/rails generate model Product name:string

该命令生成名为 Product 的模型,其在数据库中对应一个 name 字符串列。终端输出:

      invoke  active_record
      create    db/migrate/20240426151900_create_products.rb
      create    app/models/product.rb
      invoke    test_unit
      create      test/models/product_test.rb
      create      test/fixtures/products.yml

一次生成三类文件:1)db/migrate 下的迁移文件;2)app/models/product.rb 中的 Active Record 模型;3)该模型的测试与测试 fixtures。

NOTE:模型名是单数的,因为实例化的模型代表数据库中的单条记录(你在创建一件product)。而数据库表名是复数的,因为表容纳的是该模型的所有实例(你创建的是一个装着大量products 的数据库)。

迁移文件:描述数据库变更

迁移(migration) 是一组希望对数据库施加的变更。通过迁移定义如何添加、修改、删除表/列/其他属性,使开发期的变更可以被安全地同步到生产环境。打开 Rails 生成的迁移:

# db/migrate/<timestamp>_create_products.rb
class CreateProducts < ActiveRecord::Migration[8.2]
  def change
    create_table :products do |t|
      t.string :name

      t.timestamps
    end
  end
end

create_table 块定义表结构与列类型;t.string :nameproducts 表创建名为 name 的字符串列;t.timestamps 是定义 created_atupdated_at 两列的快捷方式——它们是大多数 Active Record 模型的标配,由 Active Record 在创建/更新记录时自动维护。

运行迁移

执行:

$ bin/rails db:migrate

该命令检查新迁移并将其应用到数据库,输出大致如下:

== 20240426151900 CreateProducts: migrating ===================================
-- create_table(:products)
   -> 0.0030s
== 20240426151900 CreateProducts: migrated (0.0031s) ==========================

TIP:操作失误时可运行 bin/rails db:rollback 回滚上一次迁移。

Rails Console 与 Active Record 基础操作

Console(控制台)是测试应用代码的交互式工具:

$ bin/rails console

会看到如下提示符(store(dev)> 表示 development 环境):

Loading development environment (Rails 8.2.0)
store(dev)>

输入代码回车即执行,例如验证版本:

store(dev)> Rails.version
=> "8.2.0"

空模型为何能工作:Attribute 动态生成

app/models/product.rb 的类体几乎为空:

# app/models/product.rb
class Product < ApplicationRecord
end

Rails 之所以知道这个模型的定义,是因为当 Product 被使用时,它会查询数据库表取得列名与类型,在后台自动为这些属性生成代码——这就是"约定优于配置"的又一实例。在 console 验证 Rails 探测到的列:

store(dev)> Product.column_names
=> ["id", "name", "created_at", "updated_at"]

创建记录

store(dev)> product = Product.new(name: "T-Shirt")
=> #<Product:0x000000012e616c30 id: nil, name: "T-Shirt", created_at: nil, updated_at: nil>

此刻 product 尚未入库,因此没有 id 与时间戳。调用 save 落库:

store(dev)> product.save
  TRANSACTION (0.1ms)  BEGIN immediate TRANSACTION /*application='Store'*/
  Product Create (0.9ms)  INSERT INTO "products" ("name", "created_at", "updated_at") VALUES ('T-Shirt', '2024-11-09 16:35:01.117836', '2024-11-09 16:35:01.117836') RETURNING "id" /*application='Store'*/
  TRANSACTION (0.9ms)  COMMIT TRANSACTION /*application='Store'*/
=> true

save 会生成 INSERT SQL,并将数据库返回的 idcreated_atupdated_at 回填到内存对象中。与 save 类似,create 可在一次调用内完成"实例化 + 保存":

store(dev)> Product.create(name: "Pants")
=> #<Product:0x0000000120485c80 id: 2, name: "Pants", ...>

查询、过滤、排序与单条查找

查询全部记录用 all(类方法):

store(dev)> Product.all
  Product Load (0.1ms)  SELECT "products".* FROM "products" /* loading for pp */ LIMIT 11 /*application='Store'*/
=> [#<Product ... id: 1, name: "T-Shirt" ...>, #<Product ... id: 2, name: "Pants" ...>]

TIPall 返回 ActiveRecord::Relation 对象——一种类数组的数据库记录集合,支持过滤、排序等链式操作。

where 按列过滤:

store(dev)> Product.where(name: "Pants")
  Product Load (1.5ms)  SELECT "products".* FROM "products" WHERE "products"."name" = 'Pants' /* loading for pp */ LIMIT 11
=> [#<Product:0x... id: 2, name: "Pants", ...>]

order(name: :asc) 按名称升序排序。若需按 ID 精确取单条记录,用 find

store(dev)> Product.find(1)
  Product Load (0.2ms)  SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1 /*application='Store'*/
=> #<Product:0x... id: 1, name: "T-Shirt", ...>

find 生成的 SELECTWHERE id = 1LIMIT 1,返回 Product 实例(而非 Relation,因为只取一条)。

更新与删除记录

更新有两种方式:update 一步完成"赋值 + 校验 + 保存":

store(dev)> product.update(name: "Shoes")
=> true

或先逐个赋值再调用 save

store(dev)> product.name = "T-Shirt"
=> "T-Shirt"
store(dev)> product.save
=> true

删除用 destroy

store(dev)> product.destroy
  TRANSACTION (0.1ms)  BEGIN immediate TRANSACTION
  Product Destroy (0.4ms)  DELETE FROM "products" WHERE "products"."id" = 1
  TRANSACTION (0.1ms)  COMMIT TRANSACTION
=> #<Product:0x... id: 1, name: "T-Shirt", ...>

校验:保证数据合法

Active Record validations 可确保写入数据库的数据满足规则。给 Product 加 name 必填校验:

# app/models/product.rb
class Product < ApplicationRecord
  validates :name, presence: true
end

console 中如果代码有改动需要手动刷新(开发服务器会自动重载,但已运行的 console 不会):reload!。随后尝试创建无 name 的商品:

store(dev)> product = Product.new
store(dev)> product.save
=> false

save 返回 false。Rails 会在 create、update、save 时自动执行校验。通过 errors 查看错误详情:

store(dev)> product.errors
=> #<ActiveModel::Errors [#<ActiveModel::Error attribute=name, type=blank, options={}>]>
store(dev)> product.errors.full_messages
=> ["Name can't be blank"]

errors.full_messages 生成可直接用于用户界面的友好提示。验证完毕后输入 exit 退出 console。

一次请求的旅程:路由、控制器与视图

要让 Rails "开口说话",至少需要三样东西:route(路由)、带 action(动作)controller(控制器)、以及view(视图)。路由把请求映射到控制器动作;动作完成处理请求所需的工作并准备视图数据;视图以目标格式渲染数据。实现层面:路由是 Ruby DSL(领域特定语言)书写的规则,控制器是 Ruby 类、其公有方法即动作,视图是模板(HTML 与 Ruby 的混合体)。

URL 的构成与 HTTP 方法

https://example.org/products?sale=true&sort=asc 为例:

  • https协议(protocol)
  • example.org主机(host)
  • /products路径(path)
  • ?sale=true&sort=asc查询参数(query parameters)

HTTP 请求通过方法告知服务器对 URL 执行什么操作:

  • GET:取回数据(加载页面或读取记录)
  • POST:提交数据用于处理(通常创建记录)
  • PUT / PATCH:提交数据更新既有记录
  • DELETE:删除记录

定义路由

路由是一行把"HTTP 方法 + URL 路径"配对、并指明由哪个 controller#action 响应的代码。编辑 config/routes.rb

# config/routes.rb
Rails.application.routes.draw do
  # ...
  get "/products", to: "products#index"
end

这条路由指示 Rails 接收指向 /products 路径的 GET 请求,并转交给 ProductsControllerindex 动作。路由中无需声明协议、域名(它们负责把请求送达你的服务器)与查询参数(它们是控制器中做数据过滤时可选的"选项")。

Rails 请求路由流程

再看两个例子。POST 路由:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  get "/products", to: "products#index"
  post "/products", to: "products#create"
end

带**参数(parameter)**的路由:/products/:id 中的 :id 会捕获 URL 中的一段用于后续处理。访问 /products/1:id 被设为 1,控制器动作即可据此查询并展示 ID=1 的 Product;参数不限于整数,例如博客可用 get "/blog/:title", to: "blog#show" 匹配 /blog/hello-world 并按标题查文章。

CRUD 路由与 resources

一个资源通常需要 4 类操作(增删改查 CRUD),展开对应 8 条典型路由:Index(列出全部)、New(渲染新建表单)、Create(处理新建表单)、Show(渲染单条记录)、Edit(渲染编辑表单)、Update full(处理整记录更新,通常 PUT)、Update partial(处理部分字段更新,通常 PATCH)、Destroy(删除记录)。手写如下:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  get "/products", to: "products#index"

  get "/products/new", to: "products#new"
  post "/products", to: "products#create"

  get "/products/:id", to: "products#show"

  get "/products/:id/edit", to: "products#edit"
  patch "/products/:id", to: "products#update"
  put "/products/:id", to: "products#update"

  delete "/products/:id", to: "products#destroy"
end

每次手写太过冗余,Rails 提供等价快捷写法,用一行替代上面全部:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :products
end

TIP:若不需要全部 CRUD 动作,可用 only:/except: 精确指定,详见 routing 指南

routes 命令

运行 bin/rails routes 可查看应用响应的全部路由:

      Prefix Verb   URI Pattern                  Controller#Action
    products GET    /products(.:format)          products#index
             POST   /products(.:format)          products#create
 new_product GET    /products/new(.:format)      products#new
edit_product GET    /products/:id/edit(.:format) products#edit
     product GET    /products/:id(.:format)      products#show
             PATCH  /products/:id(.:format)      products#update
             PUT    /products/:id(.:format)      products#update
             DELETE /products/:id(.:format)      products#destroy

输出还包含健康检查等其他内置路由。注意最左列的 Prefix:它们对应生成 URL 的 helper(见后文)。

控制器与动作

用生成器创建带 index 动作的 ProductsController(路由已配好,用 --skip-routes 跳过生成):

$ bin/rails generate controller Products index --skip-routes
      create  app/controllers/products_controller.rb
      invoke  erb
      create    app/views/products
      create    app/views/products/index.html.erb
      invoke  test_unit
      create    test/controllers/products_controller_test.rb
      invoke  helper
      create    app/helpers/products_helper.rb
      invoke    test_unit

该命令生成:控制器本体、对应的 views 目录、动作视图、控制器测试文件,以及用于在视图中提取逻辑的 helper。控制器如下:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
  end
end

文件名 products_controller.rb 是类名 ProductsController 的下划线形式,这种约定使 Rails 免 require 自动加载代码。index 虽是空方法,Rails 默认仍会渲染同名模板 app/views/products/index.html.erb(初版内容是占位 HTML)。

发起请求:从浏览器到模板

重启 bin/rails server 后访问 http://localhost:3000/products:浏览器请求 /products → Rails 匹配 products#index → 调用空 index 动作 → 渲染同名模板并返回。再在 config/routes.rb 中把首页指到商品列表:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  root "products#index"
  resources :products
end

此后访问 http://localhost:3000 即渲染 Products#index

实例变量与 ERB

Rails 用实例变量@ 开头)在控制器与视图间共享数据。改造 index 动作:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end
end

在视图中用 ERB(Embedded Ruby) 动态生成 HTML。<%= %> 会执行 Ruby 并输出返回值;<% %> 只求值不输出。先用 debug helper 以 YAML 输出变量便于排查:

<%# app/views/products/index.html.erb %>
<%= debug @products %>

再把列表页改成循环输出全部商品名:

<%# app/views/products/index.html.erb %>
<h1>Products</h1>

<div id="products">
  <% @products.each do |product| %>
    <div>
      <%= product.name %>
    </div>
  <% end %>
</div>

ERB 遍历 @productsActiveRecord::Relation),为每个商品渲染一个包含名称的 <div>

展示单个商品:Show 动作

resources :products 已生成 /products/:id → products#show。补充 show 动作:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
  end
end

show单数 @product(读取单条记录),index复数 @productsparams 提供请求参数,此处即路由 :id:访问 /products/1 时 params 含 {id: 1},最终执行 Product.find(1)。按命名约定,show 对应视图 app/views/products/show.html.erb

<%# app/views/products/show.html.erb %>
<h1><%= @product.name %></h1>

<%= link_to "Back", products_path %>

URL helpers 与 link_to

回到 bin/rails routes 输出的 Prefix 列——它对应 Ruby 中的 URL helper:

  • products_path 生成 "/products"
  • products_url 生成 "http://localhost:3000/products"
  • product_path(1) 生成 "/products/1"
  • product_url(1) 生成 "http://localhost:3000/products/1"

_path 返回相对路径(浏览器认为针对当前域名),_url 返回含协议/主机/端口的完整 URL,后者在渲染"浏览器之外查看"的邮件时非常有用。link_to 接受显示文本与目标 path,生成干净的内联链接。把 index 页重构为 helper 写法:

<%# app/views/products/index.html.erb %>
<h1>Products</h1>

<div id="products">
  <% @products.each do |product| %>
    <div>
      <%= link_to product.name, product_path(product.id) %>
    </div>
  <% end %>
</div>

创建商品:New 与 Create

创建需要两个动作:渲染表单的 new 与处理提交的 create

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
  end

  def new
    @product = Product.new
  end
end

new 实例化一个空 Product 供表单填充。列表页顶部加"新建"入口:

<%= link_to "New product", new_product_path %>

创建 app/views/products/new.html.erb

<%# app/views/products/new.html.erb %>
<h1>New product</h1>

<%= form_with model: @product do |form| %>
  <div>
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

<%= link_to "Cancel", products_path %>

form_with helper 借助 form builder 处理 CSRF token、依据 model: 生成 URL、并让提交按钮文案贴合模型。浏览器"查看源代码"可见表单 HTML:

<form action="/products" accept-charset="UTF-8" method="post">
  <input type="hidden" name="authenticity_token" value="UHQSKXCaFqy_aoK760zpSMUPy6TMnsLNgbPMABwN1zpW-Jx6k-2mISiF0ulZOINmfxPdg5xMyZqdxSW1UK-H-Q">

  <div>
    <label for="product_name">Name</label>
    <input type="text" name="product[name]" id="product_name">
  </div>

  <div>
    <input type="submit" name="commit" value="Create Product" data-disable-with="Create Product">
  </div>
</form>

因为传入的是新 Product 实例,表单自动被配置为向 /products 发送 POST。在控制器中实现 create

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
  end

  def new
    @product = Product.new
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      redirect_to @product
    else
      render :new, status: :unprocessable_entity
    end
  end

  private
    def product_params
      params.expect(product: [ :name ])
    end
end

Strong Parameters:参数白名单

product_paramsstrong parameters:它要求 params 中存在 :product 键,且其下仅允许 :name 一个字段,其他任何参数都会被忽略,从而防止恶意用户注入不该修改的属性。注意这里用的是 Rails 8.x 的 params.expect 新写法。

错误处理与重定向

@product.save 会执行校验。成功时 redirect_to @product——当传入 Active Record 对象时,Rails 会为它生成 show 路径(如 /products/2)。校验失败时执行 render :new(同控制器下渲染 app/views/products/new.html.erb),并把 HTTP 状态设为 422 Unprocessable Entity 通知浏览器本次 POST 失败;由于 @product 已赋值,表单会带数据回显。

编辑商品:Edit 与 Update,及 partial 抽取

编辑流程与新建几乎一致,区别是用 edit/update 动作:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  # index/show/new/create ...

  def edit
    @product = Product.find(params[:id])
  end

  def update
    @product = Product.find(params[:id])
    if @product.update(product_params)
      redirect_to @product
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private
    def product_params
      params.expect(product: [ :name ])
    end
end

用 Partial 复用表单

新建与编辑表单几乎相同,可用 partial(局部模板) 复用。把表单抽到 app/views/products/_form.html.erb(下划线前缀表示 partial),并将实例变量替换为局部变量 product,顺便在表单内展示错误:

<%# app/views/products/_form.html.erb %>
<%= form_with model: product do |form| %>
  <% if form.object.errors.any? %>
    <p class="error"><%= form.object.errors.full_messages.first %></p>
  <% end %>

  <div>
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

TIP:使用局部变量使 partial 可在同一页面以不同值多次复用,渲染列表项时尤其有用。

new 视图改为渲染 partial:

<%# app/views/products/new.html.erb %>
<h1>New product</h1>

<%= render "form", product: @product %>
<%= link_to "Cancel", products_path %>

edit 视图几乎一模一样:

<%# app/views/products/edit.html.erb %>
<h1>Edit product</h1>

<%= render "form", product: @product %>
<%= link_to "Cancel", @product %>

详情页也补上编辑入口:

<%# app/views/products/show.html.erb %>
<h1><%= @product.name %></h1>

<%= link_to "Back", products_path %>
<%= link_to "Edit", edit_product_path(@product) %>

partial 的更多用法见 Action View 指南

Before Actions:抽取共享代码

showeditupdate 都在执行 @product = Product.find(params[:id])before_action 允许在动作执行之前运行共享代码,一次抽取、多处复用,是 DRY 哲学的又一实践:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  before_action :set_product, only: %i[ show edit update ]

  def index
    @products = Product.all
  end

  def show
  end

  def new
    @product = Product.new
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      redirect_to @product
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
  end

  def update
    if @product.update(product_params)
      redirect_to @product
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private
    def set_product
      @product = Product.find(params[:id])
    end

    def product_params
      params.expect(product: [ :name ])
    end
end

删除商品:Destroy

最后实现删除。将 destroy 加入 before_action 名单,并实现动作:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  before_action :set_product, only: %i[ show edit update destroy ]
  # ...

  def destroy
    @product.destroy
    redirect_to products_path
  end
  # ...
end

show 视图加删除按钮。这里用 button_to 而非 link_to——它会生成内含单按钮的表单,点击即提交 DELETE 请求:

<%# app/views/products/show.html.erb %>
<h1><%= @product.name %></h1>

<%= link_to "Back", products_path %>
<%= link_to "Edit", edit_product_path(@product) %>
<%= button_to "Delete", @product, method: :delete, data: { turbo_confirm: "Are you sure?" } %>

turbo_confirm data 属性会指示 Turbo(见后文)在提交前弹出确认框。

加入身份认证

目前任何人都能改删商品,不安全。Rails 内置认证生成器,会创建 User 与 Session 模型以及登录所需的控制器/视图:

$ bin/rails generate authentication

随后迁移数据库以新增 User 与 Session 表:

$ bin/rails db:migrate

打开 console 创建一个用户(请替换为你自己的邮箱密码):

store(dev)> User.create! email_address: "you@example.org", password: "s3cr3t", password_confirmation: "s3cr3t"

重启 Rails 服务器以加载生成器引入的 bcrypt gem(用于安全地哈希密码)。之后访问任意页面都会要求登录——试试 http://localhost:3000/products/new,输入刚创建的邮箱密码即可通过,浏览器会缓存凭据供后续请求使用。

添加登出

在全局布局 app/views/layouts/application.html.erb 中加入导航与登出按钮(该布局放置每个页面都包含的头部/页脚等 HTML):

<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
  <%# ... %>
  <body>
    <nav>
      <%= link_to "Home", root_path %>
      <%= button_to "Log out", session_path, method: :delete if authenticated? %>
    </nav>

    <main>
      <%= yield %>
    </main>
  </body>
</html>

仅当用户已认证时(authenticated?)才显示登出按钮;点击后向 session 路径发 DELETE 请求完成登出。

允许未认证访问

默认认证生成器会限制所有页面仅认证用户可访问。我们的商品列表/详情页应对所有人开放,在控制器中放行:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  allow_unauthenticated_access only: %i[ index show ]
  # ...
end

仅向认证用户展示操作链接

列表页的"新建"入口仅在登录后显示:

<%# app/views/products/index.html.erb %>
<%= link_to "New product", new_product_path if authenticated? %>

可在导航栏增加未登录时的登录链接:

<%# app/views/layouts/application.html.erb %>
<nav>
  <%= link_to "Home", root_path %>
  <%= button_to "Log out", session_path, method: :delete if authenticated? %>
  <%= link_to "Login", new_session_path unless authenticated? %>
</nav>

同理可让详情页的 Edit/Delete 也只在认证后出现:

<%# app/views/products/show.html.erb %>
<h1><%= @product.name %></h1>

<%= link_to "Back", products_path %>
<% if authenticated? %>
  <%= link_to "Edit", edit_product_path(@product) %>
  <%= button_to "Delete", @product, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<% end %>

用 Solid Cache 缓存页面片段

缓存页面局部能改善性能。Rails 默认随附 Solid Cache(基于数据库的后端缓存)。用 cache 方法缓存详情页标题区:

<%# app/views/products/show.html.erb %>
<% cache @product do %>
  <h1><%= @product.name %></h1>
<% end %>

传入 @product 后,Rails 为商品生成唯一缓存键。Active Record 对象有 cache_key 方法(返回类似 "products/1" 的字符串),cache helper 再与模板 digest 合并构成该段 HTML 的唯一键。在开发环境启用缓存:

$ bin/rails dev:cache

首次访问 /products/2 时,服务器日志出现:

Read fragment views/products/show:a5a585f985894cd27c8b3d49bb81de3a/products/1-20240918154439539125 (1.6ms)
Write fragment views/products/show:a5a585f985894cd27c8b3d49bb81de3a/products/1-20240918154439539125 (4.0ms)

Read fragment 表示生成键并询问缓存是否存在;首次未命中故 Write fragment 生成并写入 HTML。刷新后不再有 Write fragment——第二次请求命中了缓存。当记录更新时 Rails 会改变缓存键,确保绝不渲染陈旧数据。详见 Caching with Rails

Action Text:富文本字段

许多应用需要带嵌入(多媒体)的富文本,Rails 用 Action Text 开箱提供。先运行安装器:

$ bin/rails action_text:install
$ bundle install
$ bin/rails db:migrate

重启服务器加载新特性。给 Product 加富文本描述字段:

# app/models/product.rb
class Product < ApplicationRecord
  has_rich_text :description
  validates :name, presence: true
end

在表单 partial 中提交按钮前加富文本编辑区:

<%# app/views/products/_form.html.erb %>
<%= form_with model: product do |form| %>
  <%# ... %>

  <div>
    <%= form.label :description, style: "display: block" %>
    <%= form.rich_textarea :description %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

控制器同步放开新参数:

# app/controllers/products_controller.rb
    def product_params
      params.expect(product: [ :name, :description ])
    end

详情页展示富文本(Rails 会安全渲染格式):

<%# app/views/products/show.html.erb %>
<% cache @product do %>
  <h1><%= @product.name %></h1>
  <%= @product.description %>
<% end %>

视图模板变化同样会改变缓存键,保证缓存与最新模板同步。新建商品并输入带粗体/斜体的描述即可在 show 页看到格式化文本,编辑时富文本也会完整保留在编辑器中。详见 Action Text Overview

Active Storage:文件上传

Action Text 底层依赖 Active Storage——Rails 便捷的文件上传方案。可直接把图片拖入富文本编辑器,保存后即自动上传并渲染。也可以直接使用 Active Storage:给 Product 增加头图:

# app/models/product.rb
class Product < ApplicationRecord
  has_one_attached :featured_image
  has_rich_text :description
  validates :name, presence: true
end

表单提交按钮前加文件上传字段:

<%# app/views/products/_form.html.erb %>
<%= form_with model: product do |form| %>
  <%# ... %>

  <div>
    <%= form.label :featured_image, style: "display: block" %>
    <%= form.file_field :featured_image, accept: "image/*" %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

放开 :featured_image 参数:

# app/controllers/products_controller.rb
    def product_params
      params.expect(product: [ :name, :description, :featured_image ])
    end

详情页顶部展示头图(仅在已附加时输出):

<%# app/views/products/show.html.erb %>
<%= image_tag @product.featured_image if @product.featured_image.attached? %>

详见 Active Storage Overview

国际化(I18n)

Rails 让应用翻译成其他语言变得容易。视图中的 translate/t helper 按名称查翻译并返回当前 locale 的文本。把 index 页标题改为翻译项:

<%# app/views/products/index.html.erb %>
<h1><%= t "hello" %></h1>

刷新后标题变为 Hello world——来自默认英文 locale 的 config/locales/en.yml

# config/locales/en.yml
en:
  hello: "Hello world"

新建西班牙语 locale 文件 config/locales/es.yml

# config/locales/es.yml
es:
  hello: "Hola mundo"

接着告知 Rails 使用哪个 locale:最简单的是在 URL 参数中查找。在 app/controllers/application_controller.rb 中:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # ...

  around_action :switch_locale

  def switch_locale(&action)
    locale = params[:locale] || I18n.default_locale
    I18n.with_locale(locale, &action)
  end
end

around_action 在每个请求运行:取 params[:locale],否则回退默认 locale;请求结束自动复位。验证:

  • http://localhost:3000/products?locale=en → 英文
  • http://localhost:3000/products?locale=es → 西班牙语
  • 不带 locale 参数 → 回退英文

再换用真实翻译取代 "Hello world"。注意 t ".title" 中标题前的点号:这是相对 locale 查找,自动把控制器与动作并入键名,即 en.products.index.title。相应更新两个 locale 文件:

# config/locales/en.yml
en:
  hello: "Hello world"
  products:
    index:
      title: "Products"
# config/locales/es.yml
es:
  hello: "Hola mundo"
  products:
    index:
      title: "Productos"

现在英文 locale 显示 "Products"、西班牙语显示 "Productos"。完整机制见 Internationalization (I18n)

Action Mailer 与邮件通知

电商常见的功能是"缺货登记、到货提醒"。我们利用它系统学习 Action Mailer。

基础库存追踪

先给 Product 增加库存字段。Rails 能从命名 AddInventoryCountToProducts 推断目标表为 products(遵循 add_<columns>_to_<table> 约定),从而预填 add_column

$ bin/rails generate migration AddInventoryCountToProducts inventory_count:integer

查看 bin/rails generate migration --help 可了解更多命名约定。

打开生成的迁移,把默认值设为 0 以确保永不为空:

# db/migrate/<timestamp>_add_inventory_count_to_products.rb
class AddInventoryCountToProducts < ActiveRecord::Migration[8.2]
  def change
    add_column :products, :inventory_count, :integer, default: 0
  end
end

运行 bin/rails db:migrate。将库存加入表单(用 number_field),并在控制器放开参数:

<%# app/views/products/_form.html.erb %>
<div>
  <%= form.label :inventory_count, style: "display: block" %>
  <%= form.number_field :inventory_count %>
</div>
# app/controllers/products_controller.rb
    def product_params
      params.expect(product: [ :name, :description, :featured_image, :inventory_count ])
    end

模型上加校验确保库存不为负:

# app/models/product.rb
class Product < ApplicationRecord
  has_one_attached :featured_image
  has_rich_text :description

  validates :name, presence: true
  validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }
end

订阅用户关联

生成 Subscriber 模型存储订阅邮箱并关联商品(product:belongs_to 声明一对多关系的一端;不指定 email 类型时 Rails 默认 string):

$ bin/rails generate model Subscriber product:belongs_to email

生成的迁移用 belongs_to 添加 product_id 外键列:

# db/migrate/<timestamp>_create_subscribers.rb
class CreateSubscribers < ActiveRecord::Migration[8.2]
  def change
    create_table :subscribers do |t|
      t.belongs_to :product, null: false, foreign_key: true
      t.string :email

      t.timestamps
    end
  end
end

运行 bin/rails db:migrate。一个 Product 可以有多个 Subscriber,故在 Product 模型补上关联的另一端 has_manydependent: :destroy 说明商品删除时级联删除订阅,并指导两张表如何联表查询):

# app/models/product.rb
class Product < ApplicationRecord
  has_many :subscribers, dependent: :destroy
  has_one_attached :featured_image
  has_rich_text :description

  validates :name, presence: true
  validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }
end

创建订阅控制器 app/controllers/subscribers_controller.rb

# app/controllers/subscribers_controller.rb
class SubscribersController < ApplicationController
  allow_unauthenticated_access
  before_action :set_product

  def create
    @product.subscribers.where(subscriber_params).first_or_create
    redirect_to @product, notice: "You are now subscribed."
  end

  private
    def set_product
      @product = Product.find(params[:product_id])
    end

    def subscriber_params
      params.expect(subscriber: [ :email ])
    end
end

redirect_tonotice: 参数设置 flash 消息——一种在控制器动作间传递临时数据的机制:放入 flash 的数据在下一次动作可用后即清除,常用于"重定向前设置提示"。在布局中展示 flash:

<%# app/views/layouts/application.html.erb %>
<body>
  <div class="notice"><%= flash[:notice] %></div>
  <div class="alert"><%= flash[:alert] %></div>
  <%# ... %>
</body>

Flash 详细说明见 Action Controller Overview

嵌套路由让订阅归属于具体商品,在 config/routes.rb 中:

# config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :products do
    resources :subscribers, only: [ :create ]
  end
end

新建库存展示 partial app/views/products/_inventory.html.erb:有货显示数量,缺货显示订阅表单:

<%# app/views/products/_inventory.html.erb %>
<% if product.inventory_count.positive? %>
  <p><%= product.inventory_count %> in stock</p>
<% else %>
  <p>Out of stock</p>
  <p>Email me when available.</p>

  <%= form_with model: [product, Subscriber.new] do |form| %>
    <%= form.email_field :email, placeholder: "you@example.com", required: true %>
    <%= form.submit "Submit" %>
  <% end %>
<% end %>

然后在 show.html.erbcache 块之后渲染该 partial:

<%# app/views/products/show.html.erb %>
<%= render "inventory", product: @product %>

发送到货邮件

Action Mailer 用于发送邮件。Mailer 类似"面向邮件的控制器":从数据库加载模型、施加业务逻辑、把数据传入模板生成邮件内容,但没有请求/响应循环。生成 mailer:

$ bin/rails g mailer Product in_stock

生成 app/mailers/product_mailer.rb,补全收件逻辑:

# app/mailers/product_mailer.rb
class ProductMailer < ApplicationMailer
  # Subject 可在 config/locales/en.yml 的
  #   en.product_mailer.in_stock.subject 键下设置
  def in_stock
    @product = params[:product]
    mail to: params[:subscriber].email
  end
end

mailer 生成器同时产出 HTML 与纯文本两套邮件模板。HTML 版 app/views/product_mailer/in_stock.html.erb

<%# app/views/product_mailer/in_stock.html.erb %>
<h1>Good news!</h1>

<p><%= link_to @product.name, product_url(@product) %> is back in stock.</p>

纯文本版 app/views/product_mailer/in_stock.text.erb

<%# app/views/product_mailer/in_stock.text.erb %>
Good news!

<%= @product.name %> is back in stock.
<%= product_url(@product) %>

邮件中要使用 product_url 而非 product_path——邮件客户端需要完整 URL 才能在浏览器打开。在 console 测试发信:

store(dev)> product = Product.first
store(dev)> subscriber = product.subscribers.find_or_create_by(email: "subscriber@example.org")
store(dev)> ProductMailer.with(product: product, subscriber: subscriber).in_stock.deliver_later

日志会打印完整邮件内容(From: from@example.comTo: subscriber@example.com、HTML 与文本双部分、以及 ActionMailer::MailDeliveryJob 的异步投递记录)。

要让"库存从 0 变正数时自动触发邮件",可用 Active Record 回调:

# app/models/product.rb
class Product < ApplicationRecord
  has_many :subscribers, dependent: :destroy
  has_one_attached :featured_image
  has_rich_text :description

  validates :name, presence: true
  validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }

  after_update_commit :notify_subscribers, if: :back_in_stock?

  def back_in_stock?
    inventory_count_previously_was.zero? && inventory_count.positive?
  end

  def notify_subscribers
    subscribers.each do |subscriber|
      ProductMailer.with(product: self, subscriber: subscriber).in_stock.deliver_later
    end
  end
end

after_update_commit 在数据库保存成功后触发;if: :back_in_stock? 限定仅当该方法返回 true 才运行。Active Record 会跟踪属性变更,inventory_count_previously_was 返回旧值,据此判断"之前为 0、现在为正"。notify_subscribers 借助关联查出该商品全部订阅者,并逐个把 in_stock 邮件投入后台队列。

用 Concern 重构

Product 模型的通知代码渐多,可抽成 ActiveSupport::Concern(Ruby 模块加一层语法糖,让模块中的行为并入类时更顺滑)。新建 app/models/product/notifications.rb

# app/models/product/notifications.rb
module Product::Notifications
  extend ActiveSupport::Concern

  included do
    has_many :subscribers, dependent: :destroy
    after_update_commit :notify_subscribers, if: :back_in_stock?
  end

  def back_in_stock?
    inventory_count_previously_was.zero? && inventory_count.positive?
  end

  def notify_subscribers
    subscribers.each do |subscriber|
      ProductMailer.with(product: self, subscriber: subscriber).in_stock.deliver_later
    end
  end
end

included 块内的代码在模块被 include 时如同写在类中一样执行;模块方法则成为类的实例方法。Product 模型随之简化:

# app/models/product.rb
class Product < ApplicationRecord
  include Notifications

  has_one_attached :featured_image
  has_rich_text :description

  validates :name, presence: true
  validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }
end

Concern 是组织特性代码的利器:功能多起来后按特性各抽一个自包含模块(如 Product::Notifications),还能在需要同样"订阅通知"能力的其他模型间复用。

退订链接

为邮件加上退订 URL。路由中新增顶层单数资源(resource :unsubscribe 用于匹配 /unsubscribe?token=xyz 这类无 ID 的路由):

# config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :products do
    resources :subscribers, only: [ :create ]
  end
  resource :unsubscribe, only: [ :show ]
end

Active Record 的 generates_token_for 能为不同用途生成唯一 token 查找记录。给 Subscriber 加退订 token:

# app/models/subscriber.rb
class Subscriber < ApplicationRecord
  belongs_to :product
  generates_token_for :unsubscribe
end

控制器先按 URL 中的 token 找订阅者,找到即销毁并重定向首页:

# app/controllers/unsubscribes_controller.rb
class UnsubscribesController < ApplicationController
  allow_unauthenticated_access
  before_action :set_subscriber

  def show
    @subscriber&.destroy
    redirect_to root_path, notice: "Unsubscribed successfully."
  end

  private
    def set_subscriber
      @subscriber = Subscriber.find_by_token_for(:unsubscribe, params[:token])
    end
end

把退订链接加进两套邮件模板(HTML 用 link_to,纯文本直接输出 URL):

<%# app/views/product_mailer/in_stock.html.erb %>
<h1>Good news!</h1>

<p><%= link_to @product.name, product_url(@product) %> is back in stock.</p>

<%= link_to "Unsubscribe", unsubscribe_url(token: params[:subscriber].generate_token_for(:unsubscribe)) %>
<%# app/views/product_mailer/in_stock.text.erb %>
Good news!

<%= @product.name %> is back in stock.
<%= product_url(@product) %>

Unsubscribe: <%= unsubscribe_url(token: params[:subscriber].generate_token_for(:unsubscribe)) %>

点击退订链接后记录即被删除;无效或过期的 token 会被控制器安全处理,不会抛错。

CSS 与 JavaScript

Propshaft:资产管线

Rails 的资产管线名为 Propshaft,负责把 CSS、JavaScript、图片等资产交付给浏览器;生产环境下它会为每个版本资产打指纹以支持缓存加速页面。修改 app/assets/stylesheets/application.css 设置字体与版式(导航右对齐、主区限宽、错误红/提示绿、商品图文并排等),并同步调整 show 视图的结构为 <section class="product"> + .product-info 两栏。完整 CSS 与本步视图结构请对照教程的 show.html.erb 最终形态实现,刷新即可看到效果。

Import Maps:无构建步骤的现代 JavaScript

Rails 默认用 import maps 管理 JavaScript——无需构建步骤即可书写现代 JS 模块。引脚(pin)配置在 config/importmap.rb

# config/importmap.rb
# Pin npm packages by running ./bin/importmap

pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "trix"
pin "@rails/actiontext", to: "actiontext.esm.js"

TIP:每个 pin 把 JS 包名(如 "@hotwired/turbo-rails")映射到具体文件/URL(如 "turbo.min.js");pin_all_from 把某目录下全部文件(如 app/javascript/controllers)映射进一个命名空间(如 "controllers")。注意 importmap 中已经出现了 Action Text 所需的 trix 与 actiontext 引脚。

Hotwire:Rails 默认的前端框架

import map 中这些 JS 属于 Hotwire——一个充分利用服务端生成 HTML 的前端框架,由三部分构成:

  1. Turbo:无需写任何自定义 JavaScript 即可处理导航、表单提交、页面组件与局部更新(你之前创建/编辑商品的表单就是 Turbo 驱动的)。
  2. Stimulus:当你需要自定义 JS 为页面加功能时,提供轻量框架。
  3. Native:把 Web 应用嵌入并渐进增强为混合移动应用。

目前你尚未手写任何 JS,但前端一直在使用 Hotwire。详见 Asset PipelineWorking with JavaScript in Rails

测试:用 Fixtures 与 Action Mailer 断言守护功能

Rails 自带健壮的测试体系。生成模型时会自动在 test/fixtures/ 下创建对应 fixtures 文件——运行测试前填充测试数据库的预定义数据,支持用易记名称访问记录。

更新 test/fixtures/products.yml

# test/fixtures/products.yml
tshirt:
  name: T-Shirt
  inventory_count: 15

test/fixtures/subscribers.yml(fixture 间可直接按名字引用 product: tshirt,Rails 自动处理 ID 与关联):

# test/fixtures/subscribers.yml
david:
  product: tshirt
  email: david@example.org

chris:
  product: tshirt
  email: chris@example.org

test/models/product_test.rb 中编写"到货通知邮件"测试:

# test/models/product_test.rb
require "test_helper"

class ProductTest < ActiveSupport::TestCase
  include ActionMailer::TestHelper

  test "sends email notifications when back in stock" do
    product = products(:tshirt)

    # Set product out of stock
    product.update(inventory_count: 0)

    assert_emails 2 do
      product.update(inventory_count: 99)
    end
  end
end

拆解测试逻辑:include ActionMailer::TestHelper 引入邮件监控能力;products(:tshirt) 通过 fixtures helper 取得对象(数据库 ID 每次运行可能不同,故按名字引用);先把库存置 0;assert_emails 2 断言块内恰好产生 2 封邮件——把库存改为 99 会触发 notify_subscribers 回调向两名订阅者发信。

运行单个测试文件或全量测试:

$ bin/rails test test/models/product_test.rb
# ...
1 runs, 2 assertions, 0 failures, 0 errors, 0 skips

再补全生成器产出的 mailer 测试 test/mailers/product_mailer_test.rb

# test/mailers/product_mailer_test.rb
require "test_helper"

class ProductMailerTest < ActionMailer::TestCase
  test "in_stock" do
    mail = ProductMailer.with(product: products(:tshirt), subscriber: subscribers(:david)).in_stock
    assert_equal "In stock", mail.subject
    assert_equal [ "david@example.org" ], mail.to
    assert_equal [ "from@example.com" ], mail.from
    assert_match "Good news!", mail.body.encoded
  end
end

全量运行 bin/rails test,两条测试 7 个断言全部通过。这可以作为你继续扩充覆盖率、构建完整测试套件的起点,更多内容见 Testing Rails Applications

代码规范、安全审计与一键 CI

RuboCop:统一代码风格

Rails 附带 RuboCop 静态检查器保证格式一致:

$ bin/rubocop
# ...
53 files inspected, no offenses detected

自动修复违规可用 bin/rubocop --autocorrect(简写 -a)。

Brakeman:安全漏洞扫描

Rails 内置 Brakeman gem,可检出会话劫持、会话固定、不安全重定向等安全漏洞:

$ bin/brakeman
# ...
Controllers: 6
Models: 6
Templates: 15
Errors: 0
Security Warnings: 0

详细的安全加固见 Securing Rails Applications

bin/ci:一站式持续集成

Rails 应用含 bin/ci 脚本,依次运行 setup、RuboCop、安全审计与测试等全部关键检查,步骤定义于 config/ci.rb,可自定义。脚本逐步骤打印 ✅/❌,任一步失败即以非零状态退出;CI 平台只需把流水线指向 bin/ci,保证本地与 CI 检查一致。本地运行:

$ bin/ci
# ...
✅ Setup passed in 2.11s
✅ Style: Ruby passed in 1.17s
✅ Continuous Integration passed in 8.91s

CI Steps DSL 示例

config/ci.rb 用一套 DSL 管理步骤,例如新增"禁止遗留 TODO"检查:

# config/ci.rb
CI.run do
  step "Setup", "bin/setup --skip-server"

  step "Style: Ruby", "bin/rubocop"
  step "Check: No TODOs",
        "if grep -r TODO app/; then exit 1; fi"
  # ...
end

若在源码留下 # TODO 注释,bin/ci 会报错 ❌ 并给出具体文件;删掉注释后即通过 ✅。

部署到生产:Kamal + Docker + Solid Queue

使用 Kamal 部署

Rails 随附部署工具 Kamal:用 Docker 容器运行应用,零停机部署。Rails 默认带一份生产可用的 Dockerfile 供 Kamal 构建镜像(内含用 [Thruster] 压缩并高效服务生产资产),部署前需要准备:

  • 一台运行 Ubuntu LTS、内存 ≥ 1GB 的服务器(Hetzner、DigitalOcean 等均提供);
  • 一个 Docker Hub 账号及访问令牌,用于存放应用镜像。在 Docker Hub 创建名为 store 的仓库。

编辑 config/deploy.yml,把 192.168.0.1 换成服务器 IP、your-user 换成你的 Docker Hub 用户名:

# config/deploy.yml
# Name of your application. Used to uniquely configure containers.
service: store

# Name of the container image.
image: your-user/store

# Deploy to these servers.
servers:
  web:
    - 192.168.0.1

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  # server: registry.digitalocean.com / ghcr.io / ...
  username: your-user

proxy: 段可配置域名启用 SSL(DNS 指向服务器后,Kamal 会用 LetsEncrypt 自动签发证书):

# config/deploy.yml
proxy:
  ssl: true
  host: app.example.com

在 Docker 官网创建具备 Read & Write 权限的访问令牌,然后在终端导出供 Kamal 使用:

export KAMAL_REGISTRY_PASSWORD=your-access-token

首次部署用 setup 完成服务器初始化与部署:

$ bin/kamal setup

打开浏览器输入服务器 IP 即可看到 store 应用上线。之后每次改动推送到生产只需:

$ bin/kamal deploy

为生产环境添加用户

生产库尚无 User 记录。用 Kamal 打开生产 console:

$ bin/kamal console
store(prod)> User.create!(email_address: "you@example.org", password: "s3cr3t", password_confirmation: "s3cr3t")

之后即可用该邮箱密码在生产登录并管理商品。

Solid Queue:生产级后台任务

后台任务在独立进程中异步执行,避免打断用户体验——例如给一万名订阅者发到货邮件,就应放后台以免拖慢 HTTP 请求。开发环境 Rails 用 :async 队列适配器(内存存储,重启丢任务,不适合生产);生产环境使用 solid_queue:任务入库并由独立进程执行。Kamal 部署通过 SOLID_QUEUE_IN_PUMA: true 环境变量让 Puma 自动启停 Solid Queue 进程。

后台任务流程示意

当 Action Mailer 的 deliver_later 发信时,邮件会交给 Active Job 后台发送:生产环境下任务可靠入库、失败自动重试、重启不丢失。

继续前进

恭喜你完成了第一个 Rails 应用的构建与部署!下一步建议:

回顾本教程走完的路径:从 rails new 生成骨架、理解 MVC 与目录约定,到用 Active Record 建表建模、在 console 中做完整 CRUD,再到用路由/控制器/视图/partial 搭建 Web 界面,最后叠加认证、缓存、富文本、上传、国际化、邮件通知、测试、CI 并部署上线——这正是 Rails "约定优于配置 + DRY"哲学贯穿始终的一次完整实践。Happy building!

登录后查看全文
热门项目推荐
相关项目推荐