关于 Hello Ruby

**最好的学习方法是间隔性重复学习。**

一个编程高手是怎样练成的呢? 惟手熟尔。重在刻意练习。 这意为着,就是不断重复练习,实践,再实践,熟练掌握各种技能。因为,只有反复练习,才能真正掌握。

Hello Ruby 是如何产生的呢?这是我在学习 Ruby 过程中,不断编写样例代码,不断点滴积累经验,最终形成的。

Ruby 是一门优雅简洁、开发者幸福度极高的编程语言,功能强大,生态系统丰富。然而,很多开发者只停留在 "Ruby on Rails" 的层面,对 Ruby 本身的元编程、并发模型、模块混入等核心概念了解不深。Hello Ruby 就是为了弥补这一知识缺口而创建的。

对于新手来说,Hello Ruby 是一个绝佳的起点。通过这个项目,你不仅能快速入门 Ruby 编程,还能通过编码、调试、运行示例代码,迅速掌握 Ruby 的核心知识点和基础概念。更棒的是,它还涵盖了高级进阶知识和精选的 Ruby Gem 库应用示例。

本书的当前版本假设你使用 Ruby 3.2.1 或更高版本。请查看 Getting Started 的 "安装" 部分了解如何安装和配置 Ruby 环境。

简介

Ruby 是一种现代的、面向对象的编程语言,强调开发者的幸福感和代码的优雅简洁。由松本行弘(Matz)于 1995 年创建,广泛应用于 Web 开发、脚本编写、DevOps 工具链和 API 服务。

Ruby 是一种多范式编程语言。它支持面向对象编程(类、模块混入、单例方法),函数式编程(块、Proc、Lambda、高阶函数),以及元编程(运行时动态生成和修改代码)。Ruby 的设计哲学是"让程序员快乐",语法接近自然语言,编写代码如同写作文一样流畅。

为什么选择 Ruby?

  • 开发者幸福感:Ruby 的语法设计始终围绕着"让程序员快乐"这一核心理念。代码读起来像自然语言,减少认知负担。
  • 高生产力:Rails 框架的"约定优于配置"哲学让 Web 开发效率极高。一个开发者可以在几小时内搭建出可部署的原型应用。
  • 元编程能力:Ruby 的元编程能力在主流语言中名列前茅。你可以动态定义方法、修改类结构、创建 DSL。这让你能够编写出极其优雅的框架和库。
  • 优雅的块与迭代:Ruby 的块(block)机制让迭代、资源管理、回调模式变得无比优雅。Enumerable 模块提供了上百个迭代方法。
  • 丰富的 Gem 生态:RubyGems 拥有超过 170,000 个包,覆盖从 Web 框架到机器学习的所有领域。Bundler 的依赖管理方案是所有语言中的标杆。
  • 代码风格统一:RuboCop 和 RuboCop 自动修复确保 Ruby 项目的代码风格高度一致,降低团队协作成本。

Ruby 与其他语言对比

如果你已经掌握其他编程语言,理解 Ruby 的差异点将加速你的学习。

Ruby vs Python。 两者都是动态类型的高级语言。Python 强调"只有一种明确的写法",Ruby 强调"灵活自由,让程序员选择"。Python 用缩进定义块,Ruby 用 do/end{}。Python 的数据科学生态更广,Ruby 的 Web 开发和 DSL 能力更强。

Ruby vs JavaScript。 两者都是"一切皆对象"的语言。Ruby 的块、Proc、Lambda 与 JavaScript 的闭包功能相似但更统一。Ruby 有真正的类和模块系统,JavaScript 使用原型链。两者语法风格截然不同,但都崇尚简洁表达。

Ruby vs Rust。 Rust 在编译时强制内存安全,零成本抽象,适合系统编程和高性能场景。Ruby 在运行时检查,追求开发速度和表达力。两者形成互补:Ruby 快速验证想法,Rust 保证生产级性能和安全性。

Ruby 3.2+ 值得关注的新特性

Ruby 3.2+ 在 2023 年底发布,带来了多项实用改进:

Ractor 渐趋成熟。 Ruby 3.0 引入 Ractor(基于 Actor 模型的真正并行原语),到 3.2+ 版 API 更稳定。每个 Ractor 拥有独立的内存空间,通过消息传递通信,从根本上避免竞态条件。

模式匹配增强。 从 3.0 开始 case/in 不再是实验性语法,支持更灵活的数组/哈希/类实例匹配。这在处理 JSON 解析结果等嵌套数据结构时非常实用。

正则表达式性能改进。 Ruby 3.2 使用优化的正则引擎 Onigmo,正则表达式执行速度显著提升。

内置 YJIT 改进。 Ruby 3.2 内置的 YJIT(Method-based JIT 编译器)支持更多 Linux 发行版和 macOS,对某些负载场景下带来 10-40% 的性能提升。

实用标准库增强。 Hash#exceptArray#intersect?Enumerator::Lazy#eager 等高频方法加入,错误信息可读性大幅提升。

快速开始

安装 Ruby

首先,你需要安装 Ruby 3.2 或更高版本。推荐使用 rv(Ruby Version manager)来管理 Ruby 版本,它比传统的 rbenv 更快更现代化。你也可以选择 mise 统一管理多语言版本。

rv 安装(推荐)

rv 是一个极速的 Ruby 版本和项目管理器,用 Rust 编写。它不仅能管理 Ruby 版本,还能隔离管理项目 gems,性能远超 rbenv/asdf。

macOS 用户可以使用 Homebrew 安装:

$ brew install rv

# 设置自动版本切换 Shell 集成
$ rv shell zsh  # 或 bash / fish / nu

# 安装 Ruby 3.2
$ rv ruby install 3.2.1

# 验证安装
$ ruby --version
# ruby 3.2.1

Linux 用户可通过 Homebrew Linux 或独立安装脚本:

# 方式一:Homebrew
$ brew install rv

# 方式二:独立脚本
$ curl -LsSf https://rv.dev/install | sh

# 安装 Ruby 3.2
$ rv ruby install 3.2.1
$ ruby --version

Windows 用户(PowerShell 管理员模式):

> powershell -ExecutionPolicy Bypass -c "irm https://rv.dev/install.ps1 | iex"
# 注意:Windows 上使用 rvw 代替 rv(rv 是 PowerShell 内置别名)
> rvw ruby install 3.2.1

rv 的核心功能:

  • 极速运行rv run rubyrvx rails new . 直接运行任何 Ruby 命令
  • 自动版本切换:进入目录自动切换 .ruby-version 指定的版本
  • 隔离 gem 环境rv clean-install 从 Gemfile.lock 隔离安装项目 gems
  • CLI 工具管理rv tool install 在独立环境中安装 gem CLI 工具
  • 跨平台:原生支持 macOS / Linux / Windows

备选:rbenv 安装

macOS 用户可以使用 Homebrew 安装 rbenv:

$ brew install rbenv ruby-build
$ rbenv init
# 按照提示将以下内容加入 shell 配置文件(~/.zshrc 或 ~/.bashrc)
# eval "$(rbenv init - zsh)"

$ rbenv install 3.2.1
$ rbenv global 3.2.1

# 验证安装
$ ruby --version
# ruby 3.2.1

备选:mise 安装

或者使用 mise(推荐,统一管理多语言版本):

$ brew install mise
$ mise use ruby@3.2.1
$ ruby --version

Linux 安装

Linux 用户也可以使用 rv 安装:

# 通过独立脚本
$ curl -LsSf https://rv.dev/install | sh
$ rv shell zsh

$ rv ruby install 3.2.1
$ ruby --version

或使用 rbenv / asdf 作为替代:

$ git clone https://github.com/rbenv/rbenv.git ~/.rbenv
$ cd ~/.rbenv && src/configure && make -C src
$ echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
$ ~/.rbenv/bin/rbenv init
$ rbenv install 3.2.1
$ rbenv global 3.2.1

Windows 安装

Windows 用户推荐使用 WSL2(Windows Subsystem for Linux)安装 Ubuntu,然后在 WSL2 中安装 rv

# 在 WSL2 的 Ubuntu 中
$ curl -LsSf https://rv.dev/install | sh
$ rv shell zsh
$ rv ruby install 3.2.1
$ ruby --version

也可以直接使用 Windows 原生安装(PowerShell 管理员模式):

> powershell -ExecutionPolicy Bypass -c "irm https://rv.dev/install.ps1 | iex"
> rvw ruby install 3.2.1

注意:Windows PowerShell 中 rvRemove-Variable 的内置别名,需使用 rvw 代替。也可以使用 RubyInstaller 项目直接安装。

克隆并设置项目

安装完成后,克隆 Hello Ruby 项目并安装依赖:

$ git clone https://github.com/savechina/hello-ruby.git
$ cd hello-ruby

这将在当前目录创建一个 hello-ruby/ 项目文件夹,其中包含所有源代码、测试和文档。

进入项目目录:

$ cd hello-ruby

你应该会看到以下目录结构:

.
├── bin/
│   ├── setup              # 安装脚本
│   └── console            # 交互式控制台
├── exe/
│   └── hello              # CLI 入口
├── lib/
│   ├── hello.rb           # Gem 主入口
│   └── hello/
│       ├── version.rb
│       ├── basic/         # 15 个基础主题
│       ├── advance/       # 10 个进阶主题
│       └── awesome/       # 实战层
├── spec/
│   ├── spec_helper.rb
│   └── basic/
├── docs/
│   └── src/               # mdBook 教程文档
├── Gemfile
└── hello.gemspec

运行设置脚本安装所有依赖:

$ bin/setup
# 等同于 bundle install
# 安装 Thor、RSpec、Sequel、dry-system 等 gems

Gemfilehello.gemspec 内容说明:

# Gemfile
source "https://rubygems.org"
gemspec
# hello.gemspec(核心部分)
Gem::Specification.new do |spec|
  spec.name          = "hello"
  spec.version       = Hello::VERSION
  spec.required_ruby_version = ">= 3.2.0"

  # 运行时依赖
  spec.add_dependency "thor", "~> 1.1"
  spec.add_dependency "dry-system", "~> 1.0"
  spec.add_dependency "sequel", "~> 5.54"
  # ...
end
  • [gemspec] 定义了包的名称、版本、Ruby 版本要求和依赖关系。
  • [dependencies] 部分定义了运行时需要的 gems。开发依赖(RSpec、RuboCop 等)通过 Gemfilegroup :development, :test 加载。

编译和运行

安装完成后,运行 hello 命令验证:

# 查看所有可用命令
$ bundle exec hello --help

# 运行全部基础示例
$ bundle exec hello basic

# 运行指定主题
$ bundle exec hello advance metaprogramming

你会看到控制台的输出内容,展示各种 Ruby 概念的运行结果。

一个完整的 Ruby gem 项目结构

Bundler 推荐的 gem 目录结构如下:

  • hello.gemspec — gem 规范文件,定义包的元数据和依赖
  • GemfileGemfile.lock — Bundler 依赖锁定
  • lib/ — 源代码放在这里
    • lib/hello.rb — gem 的入口文件
    • lib/hello/ — 子模块和主题
  • exe/ — 可执行脚本(CLI 入口)
  • spec/ — 测试代码(RSpec)
    • spec/spec_helper.rb — 测试配置
    • spec/basic/ — 基础层测试
  • docs/ — mdBook 教程文档
    • docs/src/SUMMARY.md — 目录结构
    • docs/src/basic/ — 基础层文档
    • docs/src/advance/ — 进阶层文档
  • bin/ — 开发辅助脚本
  • .github/workflows/ — CI/CD 配置

测试你的代码

良好的编程习惯:一定要写测试。 下面先认识如何编写一个简单的单元测试。你可以参照样例编写自己的测试,逐步深入理解。

测试结构

RSpec 测试通常包含以下部分:

  1. 描述块:使用 describecontext 描述被测试的行为。
  2. 测试用例:使用 it 定义具体的测试场景。
  3. 断言:使用 expect(...).to 验证期望的行为。
  4. 运行测试:使用 bundle exec rspec 命令执行测试。

示例:基础测试

# frozen_string_literal: true

require "spec_helper"

RSpec.describe "Strings module" do
  it "executes without error" do
    expect { Hello::Basic::Strings.run }.not_to raise_error
  end
end

运行测试后,你会看到以下输出:

$ bundle exec rspec --format documentation

Randomized with seed 12345

Strings module
=== 字符串操作 ===

双引号(支持 \n 换行): Hello
Ruby
插值: Ruby 当前版本 3.4
...
  executes without error

Finished in 0.003 seconds (files took 0.14 seconds to load)
1 example, 0 failures

说明测试通过了。如果测试失败,你会看到详细的错误信息:

$ bundle exec rspec --format documentation

Randomized with seed 54321

Strings module
  executes without error (FAILED - 1)

Failures:

  1) Strings module executes without error
     Failure/Error: expect { Strings.run }.not_to raise_error

       expected no Exception, got #<NameError: uninitialized constant Strings>
     # ./spec/basic/strings_spec.rb:7:in `block (2 levels) in <top (required)>'

Finished in 0.008 seconds (files took 0.15 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/basic/strings_spec.rb:6 # Strings module executes without error

测试失败时的调试方法: 仔细阅读错误信息,定位出错的文件和行号。最好的方法是通过错误信息调试代码并解决这些问题,最终可以成功运行测试。这个过程能快速提升你的代码能力。

本地查看文档

Hello Ruby 使用 mdBook 编写文档。你可以在本地预览:

$ cd docs
$ mdbook serve --open

这将在浏览器中打开文档页面,支持实时预览。编辑任何 .md 文件后自动刷新。

经过上述简单旅程,我们已经对 Hello Ruby 有了初步了解。接下来,让我们深入探索 Ruby 的核心概念和特性。开始进入 Ruby 的世界旅行吧!

基础入门 (Basic)

学习内容概览

欢迎来到 Ruby 编程之旅的第一站!基础入门部分将带你掌握 Ruby 的核心概念和编程风格。Ruby 是一门注重开发者幸福感的语言——它的语法优雅自然,读起来像英文,写起来像诗歌。

完成本部分学习后,你将具备用 Ruby 解决日常问题的能力,并为后续高级主题(元编程、并发、测试驱动开发)打下坚实基础。

你将学到什么

通过 15 个循序渐进的章节,你将掌握:

  1. 变量与数据类型 — 理解 Ruby 的动态类型系统和五种变量作用域
  2. 字符串与集合 — 熟练处理文本、数组、哈希,掌握 Ruby 最常用数据结构
  3. 流程控制与方法 — 编写条件判断、循环和灵活可调用的方法
  4. 类与对象 — 用面向对象的方式组织代码,设计清晰的对象接口
  5. 模块与混入 — 用模块实现代码复用和横切关注点
  6. 块与 Proc — 理解 Ruby 最具特色的闭包和回调模式
  7. 文件与异常 — 读写文件、处理错误、编写健壮的程序

章节列表

#章节说明难度预计时间
1变量与作用域局部变量、实例变量、类变量、全局变量、常量🟢 简单20 分钟
2字符串操作插值、方法链、frozen_string_literal、Heredoc🟢 简单30 分钟
3数组创建、索引增删、map/select/reduce、迭代器🟢 简单25 分钟
4哈希符号键、dig、merge、transform_values🟢 简单25 分钟
5控制流if/case/unless、循环、迭代器、next/break🟢 简单30 分钟
6方法定义与调用默认参数、关键字参数、splat、块捕获🟢 简单25 分钟
7类与对象initialize、attr_*、类方法、继承🟡 中等30 分钟
8模块与混入include、extend、prepend、module_function🟡 中等30 分钟
9块与 ProcBlock、Proc、Lambda 的创建与差异🟡 中等35 分钟
10文件 I/OFile.open、Pathname、Dir.glob、CSV🟢 简单25 分钟
11异常处理rescue、ensure、retry、raise、else 块🟡 中等30 分钟
12数字与数值运算Integer 任意精度、Float 精度、BigDecimal、Rational🟢 简单25 分钟
13符号(Symbol)符号内部原理、哈希键惯例、内存影响🟡 中等25 分钟
14正则表达式字面量、修饰符、捕获组、gsub/scan🟡 中等35 分钟
15文件管理Dir、FileTest、Pathname、临时目录🟢 简单25 分钟

总计: 15 章 | 总时间: 约 7 小时

学习路径

变量 → 字符串 → 数组 → 哈希 → 控制流 → 方法
                                        ↓
类 ←── 模块 ←── 块与 Proc ←── 文件 I/O ←── 异常
                                        ↓
数字 ←── 符号 ←── 正则 ←── 文件管理 → 🎓 阶段复习

前置要求

无需 Ruby 基础! 本部分从零开始教学。

建议具备:

  • 基本编程概念(变量、循环、函数)
  • Ruby 3.2+ 开发环境已安装

怎么用这个教程

每章都是可独立运行的代码模块。运行方式:

# 运行全部基础示例
hello basic

# 运行单个主题
hello basic variables

# 运行单个主题
hello basic strings

你也可以在 IRB 中交互式学习:

bin/console

学习检查点

完成本部分后,你应该能够:

  • 区分局部变量、实例变量、类变量和全局变量
  • 熟练使用字符串插值和字符串操作链
  • 用 map/select/reduce 处理数组和哈希
  • 编写带默认参数和关键字参数的方法
  • 定义类、声明属性、实现继承
  • 用 include/extend 混入模块功能
  • 理解 Block、Proc、Lambda 的差异
  • 读写文件并处理 CSV 数据
  • 用 rescue/ensure 构建健壮的错误处理
  • 使用正则表达式匹配和提取文本

实践建议

  1. 每章的代码示例都要亲手敲一遍,不要只看不练
  2. 修改示例中的值,观察输出变化
  3. 尝试用刚学的知识解决简单的实际问题
  4. 完成阶段复习的综合练习

下一步

完成基础入门后,继续学习 高级进阶 部分,你将深入:

  • 元编程与动态代码生成
  • 并发模型(Thread/Fiber/Ractor)
  • ActiveRecord 数据库操作
  • RSpec 测试驱动开发
  • dry-system 依赖注入

准备好了吗?让我们开始 变量与作用域 的学习!


术语表

English中文
Variable变量
Scope作用域
Dynamic Typing动态类型
Collection集合
Control Flow控制流
Method方法
Class
Module模块
Mixin混入
Block代码块
Closure闭包
Pattern Matching模式匹配

💡 提示:Ruby 的设计哲学是 "最小惊讶原则"——大多数情况下,代码的表现会符合你的直觉信任它。

变量与作用域

开篇故事

想象你在厨房做菜。每个调料罐都有自己的位置:有的放在手边(局部变量),有的贴在橱柜上(实例变量),有的放在仓库里所有厨师都能用(类变量),有的在整栋楼的公共柜子里(全局变量)。Ruby 的变量作用域就是这个道理——不同符号前缀决定变量在哪里可见。

本章适合谁

如果你是 Ruby 新手,本章是你学习的第一站。变量是所有编程语言的起点,理解作用域能让你避免很多隐蔽的 bug。

你会学到什么

  1. 局部变量、实例变量、类变量、全局变量的区别
  2. 常量命名约定与行为
  3. Ruby 动态类型的含义
  4. 五种变量前缀规则

前置要求

无。本章是零基础入门章节。

第一个例子

# 运行: hello basic variables

# 局部变量 — 小写字母或下划线开头
greeting = "你好,Ruby!"
count = 42

puts "局部变量 - greeting: #{greeting}"
puts "  count: #{count}"

输出

局部变量 - greeting: 你好,Ruby!
  count: 42

这段代码演示了 Ruby 最基础的赋值方式。变量不需要声明类型,赋值即创建。

五类变量

1. 局部变量

以小写字母或下划线开头。作用域是当前块(方法、循环、if 语句等)。

def example
  local_var = "我只在当前方法内有效"
  puts local_var
end

# puts local_var  # ❌ 报错:变量未定义

为什么要关心:局部变量是 Ruby 中最常用的变量类型。把它们当作方法的私有数据,外部无法触碰。

2. 实例变量

@ 开头。作用域是当前对象实例。

obj = Object.new

# 为 obj 动态添加实例变量
class << obj
  attr_accessor :name
end

obj.name = "实例对象"
puts "实例变量 - obj.name: #{obj.name}"

为什么要关心:实例变量就是 OOP 里的"对象的属性"。每个对象实例拥有自己独立的实例变量副本。

class User
  def initialize(name)
    @name = name  # 每个 User 实例有自己的 @name
  end
end

alice = User.new("Alice")
bob = User.new("Bob")

puts alice.instance_variable_get(:@name)  # "Alice"
puts bob.instance_variable_get(:@name)    # "Bob"

3. 类变量

@@ 开头。作用域是当前类及其所有子类(共享同一个变量!)。

class Counter
  @@count = 0

  def self.increment
    @@count += 1
  end

  def self.count
    @@count
  end
end

Counter.increment  # → 1
Counter.increment  # → 2
Counter.increment  # → 3
puts "计数器: #{Counter.count}"  # 3

警告:类变量在继承链中共享,子类修改会影响父类。大多数时候,使用类实例变量(@count 在单例类上)更安全。

# 更安全的替代方案:类实例变量
class Counter
  class << self
    attr_accessor :count
  end

  @count = 0  # 类实例变量,不被子类共享

  def self.increment
    @count += 1
  end
end

4. 全局变量

$ 开头。整个 Ruby 程序中任何地方都可以访问。

$global_config = { version: "0.1.0", debug: false }

puts "全局变量: #{$global_config}"

为什么要慎用:全局变量会破坏封装。任何代码都可以修改它,调试时很难追踪 bug 来源。Ruby 社区惯例是尽量避免使用 $ 变量。

例外情况:Ruby 内置的全局变量是安全的,比如:

  • $stdout — 标准输出
  • $stderr — 标准错误
  • $? — 最近一次子进程退出状态
  • $~ — 最近一次正则匹配的 MatchData

5. 常量

以大写字母开头。重新赋值会给出警告而不是报错。

MAX_RETRIES = 3

# 重新赋值会报警告,不会崩溃
# MAX_RETRIES = 5  # warning: already initialized constant

为什么要关心:常量并不是真正"不可变"。对于字符串和数组,内容仍然可以修改:

API_URL = "https://api.example.com"
# API_URL << "/v2"  # ❌ frozen_string_literal 模式下会报错

PATHS = ["/app", "/lib"]
PATHS << "/bin"    # ✅ 数组内容可以修改(常量只保护变量绑定)

Ruby 的动态类型

Ruby 是动态类型语言——变量类型随赋值而变,不需要显式声明。

x = 10
puts "x 初始为 Integer: #{x.class}"     # Integer

x = "现在是字符串"
puts "x 变为 String: #{x.class}"        # String

x = [1, 2, 3]
puts "x 又变为 Array: #{x.class}"       # Array

这和静态类型语言有什么区别

对比项Ruby(动态类型)Java(静态类型)
声明类型不需要必须(int x = 10;
类型变化可以随意变编译报错
类型检查运行时编译时
灵活性
安全性运行时才能发现类型错误编译时发现大部分错误

作用域规则总结

变量类型前缀作用域示例
局部变量小写/下划线当前代码块count, user_name
实例变量@当前对象@name, @age
类变量@@当前类及子类@@count
全局变量$整个程序$global_config
常量大写当前作用域及嵌套MAX_RETRIES, API::URL

常见错误

错误 1:在方法内使用实例变量但忘记 @

class User
  def set_name(name)
    name = name  # ❌ 这是一个局部变量赋值!
  end
end

修复

class User
  def set_name(name)
    @name = name  # ✅ 实例变量
  end
end

错误 2:滥用全局变量

# ❌ 坏做法
$db = Database.connect
$cache = Cache.new
$logger = Logger.new

def process
  $db.query("SELECT ...")  # 难以测试,难以追踪
end

更好的做法

# ✅ 通过参数传递依赖
def process(db:)
  db.query("SELECT ...")
end

错误 3:类变量的继承陷阱

class Parent
  @@value = "parent"

  def self.value
    @@value
  end
end

class Child < Parent
  @@value = "child"  # ❌ 同时修改了 Parent 的 @@value!
end

puts Parent.value  # 输出 "child",不是预期的 "parent"

修复:使用类实例变量。

动手练习

练习 1:跟踪变量作用域

# 说出每个变量属于哪种类型
@instance = "A"
@@class_var = "B"
$global = "C"
LOCAL = "D"
local_var = "E"
查看答案
  • @instance — 实例变量
  • @@class_var — 类变量
  • $global — 全局变量
  • LOCAL — 常量
  • local_var — 局部变量

练习 2:动态类型体验

value = 100
# 在不报错的前提下,让 value 依次变成 String、Array、Hash
# 每次打印它的 class
参考答案
value = 100
puts value.class   # Integer

value = "文字"
puts value.class   # String

value = [1, 2, 3]
puts value.class   # Array

value = { key: "value" }
puts value.class   # Hash

故障排查 (FAQ)

Q: 为什么 Ruby 不用声明变量类型?

A: Ruby 是动态类型语言,类型信息存储在对象上,不在变量上。变量只是一个指向对象的标签。这让代码更简洁,但在大型项目中建议配合 Sorbet 等类型检查工具。

Q: 类变量和类实例变量有什么区别?

A: 类变量 @@x 在继承链中共享;类实例变量 @x(定义在类对象上)不共享。大多数情况下,类实例变量更安全、更可预测。

Q: 常量真的不能改吗?

A: 变量绑定不能改(重新赋值为警告),但对象内容可以改。ARR = [1]; ARR << 2 可以执行,但 ARR = [3] 会报警告。加上 .freeze 可以冻结内容:ARR = [1].freeze

小结

核心要点

  1. 五种前缀决定作用域:小写字母/下划线(局部)、@(实例)、@@(类)、$(全局)、大写(常量)
  2. 作用域越窄越安全:优先使用局部变量,慎用全局变量
  3. 动态类型 ≠ 无类型:每个值都有类型(class 方法查看)
  4. 常量只保护绑定不保护内容:需要时使用 .freeze
  5. 类变量会继承共享:谨慎使用,优先考虑类实例变量

术语

  • Scope(作用域):变量可见的代码区域
  • Dynamic Typing(动态类型):类型绑定到值而非变量
  • Binding(绑定):变量名指向内存对象的操作
  • Singleton Class(单例类):每个对象独有的匿名类

继续学习

运行 hello basic variables 查看完整示例代码。

字符串操作

开篇故事

字符串是程序世界里最普遍的存在。用户的输入、文件的文本、API 的响应——所有信息都以字符串的形式流动。Ruby 把字符串当成一等公民来对待,提供了一套丰富、直观的操作方式。

本章适合谁

如果你需要处理文本数据(几乎每个程序都需要),本章会教你 Ruby 字符串的常用操作和最佳实践。

你会学到什么

  1. 双引号与单引号的区别
  2. 字符串插值的强大能力
  3. %q%Q%[] 百分号语法
  4. 常用字符串方法链
  5. frozen_string_literal: true 的影响
  6. Heredoc 多行文本

前置要求

第一个例子

# 运行: hello basic strings

name = "Ruby"
version = 3.4
puts "#{name} 当前版本 #{version}"
# 输出: Ruby 当前版本 3.4

为什么这很 Ruby:不需要拼接符,不需要类型转换。#{} 插值自动调用 to_s,简洁、直观。

引号之争:双引号 vs 单引号

双引号(""

  • 支持插值 #{}
  • 支持转义字符 \n\t\\
text = "换行\n换行\t制表"
puts text
# 输出:
# 换行
# 换行    制表

单引号(''

  • 不支持插值
  • 仅支持两个转义:\\(反斜杠)和 \'(单引号)
  • 性能略好(不需要解析)
literal = "Hello\nRuby"
puts literal  # 输出: Hello\nRuby(不解析 \n)

%q%Q

  • %q[] 等价于单引号——不插值、不转义
  • %Q[] 等价于双引号——支持插值和转义
  • %() 也很常用
  • 分隔符可以互换:%q{}%q<>%q!!
# %q 等价于单引号
single_quoted = %q[Hello\nRuby]
puts "单引号行为: #{single_quoted}"

# %Q 或 %() 等价于双引号
interpolated2 = %(他说:"我爱 #{name}")
puts "% 语法插值: #{interpolated2}"

选择建议:日常开发中使用双引号。当字符串包含大量引号时(如生成 HTML),可以用 %Q() 避免转义。

字符串插值

#{} 可以在双引号字符串中嵌入任意表达式:

name = "Ruby"
version = 3.4

# 基本插值
puts "欢迎学习 #{name}"

# 表达式插值
puts "1 + 2 + 3 = #{1 + 2 + 3}"

# 方法调用
puts "大写: #{name.upcase}"

# 条件表达式
status = version >= 3.0 ? "最新" : "旧版"
puts "Ruby 版本 #{version} 是 #{status}"

底层发生了什么:Ruby 把 #{} 中的表达式转换为 to_s 结果,然后拼接到字符串中。

常用方法

Ruby 字符串提供了极其丰富的方法。

text = "  Hello, Ruby World!  "

puts "strip: '#{text.strip}'"          # 去除首尾空白
puts "upcase: '#{text.strip.upcase}'"   # 大写
puts "downcase: '#{text.strip.downcase}'" # 小写
puts "swapcase: '#{text.strip.swapcase}'" # 大小写互换
puts "reverse: '#{text.strip.reverse}'" # 反转
puts "length: #{text.strip.length}"   # 长度
puts "include?('Ruby'): #{text.include?("Ruby")}"  # 包含
puts "start_with?('Hello'): #{text.strip.start_with?("Hello")}"  # 前缀
puts "end_with?('World!'): #{text.strip.end_with?("World!")}"   # 后缀

分割与连接

csv = "apple,banana,cherry"
parts = csv.split(",")
puts "split: #{parts.inspect}"   # ["apple", "banana", "cherry"]

joined = parts.join(" - ")
puts "join: #{joined}"          # "apple - banana - cherry"

# 链式调用
chained = "hello world ruby".split.map(&:capitalize).join(" ")
puts "链式拆分→映射→连接: #{chained}"

替换:gsub 和 sub

original = "Hello World"
global_replaced = original.gsub("o", "0")
puts "gsub(o→0): #{global_replaced}"    # "Hell0 W0rld"

single_replaced = original.sub("o", "0")
puts "sub(o→0): #{single_replaced}"    # "Hell0 World"

# 正则替换
redacted = "My email is user@example.com".gsub(/[\w.]+@[\w.]+/, "[已隐藏]")
puts "正则替换: #{redacted}"

sub 只替换第一个匹配,gsub 替换所有匹配。

frozen_string_literal 的影响

在文件顶部添加以下注释:

# frozen_string_literal: true

所有字符串字面量会变成冻结状态,不能修改:

# frozen_string_literal: true

normal = "可以变"
# normal << "化"  # ❌ FrozenError

# 解决方案:.dup 创建可变副本
mutable = "动态修改".dup
mutable << "完毕"
puts "frozen 下 .dup 创建副本: #{mutable}"

# 显式冻结
frozen_string = "冻结了".freeze
# frozen_string << "!"  # ❌ FrozenError

为什么要冻结字符串

  1. 性能提升:Ruby 可以复用相同的冻结字符串对象
  2. 安全性:防止意外修改共享字符串
  3. Ruby 3 趋势:未来版本可能默认冻结所有字符串

Heredoc 多行文本

<<~ 创建缩进感知的多行字符串:

sql = <<~SQL
  SELECT users.name, orders.total
  FROM users
  JOIN orders ON users.id = orders.user_id
  WHERE orders.total > 100
  ORDER BY orders.total DESC
SQL

puts sql
# 缩进自动调整,输出整洁的 SQL 语句

<<~ 会自动去除公共前导空白,代码缩进和字符串内容缩进分离。

常见错误

错误 1:忘记 frozen_string_literal

# ❌ 文件顶部缺少注释
text = "hello"
text << " world"  # 如果后来加了 frozen_string_literal 注释会崩

修复:始终在每行文件顶部加上 # frozen_string_literal: true

错误 2:用 + 拼接字符串

# ❌ 可读性和性能都不好
result = "Hello, " + name + "! You are " + age.to_s + " years old."

# ✅ 用插值更清晰
result = "Hello, #{name}! You are #{age} years old."

错误 3:混淆 sub 和 gsub

"aaa".sub("a", "b")   # → "baa"(只替换第一个)
"aaa".gsub("a", "b")  # → "bbb"(替换所有)

动手练习

练习 1:方法链

" hello WORLD " 变成 "World"

参考答案
"  hello   WORLD  ".strip.split.map(&:capitalize).join(" ").downcase.then(&:capitalize)
# 更简洁的方式:
"  hello   WORLD  ".strip.split(" ").map(&:capitalize).join(" ")

练习 2:统计单词

统计这个句子中每个单词出现的次数:"the quick brown fox jumps over the lazy dog"

参考答案
sentence = "the quick brown fox jumps over the lazy dog"
words = sentence.split
counts = words.tally  # Ruby 2.7+
puts counts.inspect
# => {"the"=>2, "quick"=>1, "brown"=>1, ...}

故障排查 (FAQ)

Q: 什么时候用单引号,什么时候用双引号?

A: 默认用双引号。字符串包含大量双引号时(如生成 HTML 属性),可以改用 %Q()%q() 避免转义。

Q: frozen_string_literal 会影响性能吗?

A: 会提升性能。冻结的字符串字面量在运行时只有一份拷贝,减少了内存分配和垃圾回收负担。

Q: 怎么比较两个字符串是否相等?

A: 用 == 比较内容(区分大小写),casecmp 不区分大小写:

"a" == "a"       # true
"a" == "A"       # false
"a".casecmp("A") # 0(相等)

小结

核心要点

  1. 双引号是默认选择:支持插值和转义
  2. 插值优于拼接#{}+ 更清晰、更高效
  3. 方法链是 Ruby 风格strip.upcase.reverse
  4. frozen_string_literal: true 是惯例:每行文件加这个注释
  5. Heredoc 处理多行<<~SQL 自动处理缩进

术语

  • Interpolation(插值):在字符串中嵌入表达式
  • Frozen String(冻结字符串):不可变的字符串对象
  • Method Chaining(方法链):连续调用方法
  • Heredoc(多行字符串)<<~ 语法的缩进感知多行文本

继续学习

运行 hello basic strings 查看完整示例代码。

数组

开篇故事

数组是编程世界中最基础的数据容器。它像一个有编号的储物柜,每个格子都能放一个东西,按编号取用,整齐有序。Ruby 的数组比许多语言更灵活——可以放任意类型的对象,支持负数索引,还有丰富的变换方法。

本章适合谁

如果你需要有序地存储一组数据(无论是名单、数字列表还是文件路径),数组就是你的首选工具。

你会学到什么

  1. 创建数组的多种写法
  2. 索引访问(正数、负数、切片)
  3. 添加与移除元素(push/pop/shift/unshift)
  4. 集合变换(map/select/reduce/compact/flatten)
  5. each 迭代器

前置要求

第一个例子

# 运行: hello basic arrays

fruits = ["apple", "banana", "cherry"]
puts "第一个水果: #{fruits[0]}"
puts "最后一个水果: #{fruits[-1]}"

输出

第一个水果: apple
最后一个水果: cherry

Ruby 数组的负数索引 [-1] 直接取最后一个元素,不需要 length - 1 的繁琐计算。

创建数组

# 字面量(最常见)
fruits = ["apple", "banana", "cherry"]

# 从范围创建
numbers = Array(1..5)   # [1, 2, 3, 4, 5]

# %w[] — 单词数组(不需要引号和逗号)
words = %w[foo bar baz]  # ["foo", "bar", "baz"]

# %W[] — 支持插值
today = "Monday"
days = %W[Monday #{today} Wednesday]  # ["Monday", "Monday", "Wednesday"]

# Array.new — 预设大小和默认值
zeros = Array.new(5, 0)  # [0, 0, 0, 0, 0]

索引与访问

fruits = ["apple", "banana", "cherry"]

# 正数索引
puts "fruits[0]: #{fruits[0]}"       # "apple"

# 负数索引
puts "fruits[-1]: #{fruits[-1]}"     # "cherry"(最后)
puts "fruits[-2]: #{fruits[-2]}"     # "banana"(倒数第二)

# 切片 — 从索引 1 开始取 2 个
puts "fruits[1, 2]: #{fruits[1, 2].inspect}"  # ["banana", "cherry"]

# 辅助方法
puts "fruits.first: #{fruits.first}"         # "apple"
puts "fruits.first(2): #{fruits.first(2).inspect}"  # ["apple", "banana"]
puts "fruits.last: #{fruits.last}"           # "cherry"

为什么负数索引好用:在 Python 中也支持,但 Java/C++ 不支持。这个设计让"取最后一个元素"变成一行代码。

添加与移除元素

数组像栈一样,两端都可以操作:

arr = [1, 2, 3]

# 尾部操作
arr.push(4)        # [1, 2, 3, 4]
arr << 5           # [1, 2, 3, 4, 5](链式)

# 头部操作
arr.unshift(0)     # [0, 1, 2, 3, 4, 5]

puts "操作后: #{arr.inspect}"

# 移除 — 尾部
popped = arr.pop   # 移除并返回 5
puts "pop 返回: #{popped}"

# 移除 — 头部
shifted = arr.shift  # 移除并返回 0
puts "shift 返回: #{shifted}"

puts "剩余: #{arr.inspect}"  # [1, 2, 3, 4]
方法操作位置返回值
push / <<尾部添加数组本身
unshift头部添加数组本身
pop尾部移除移除的元素
shift头部移除移除的元素

map 变换

map 对每个元素执行变换,返回新数组:

numbers = [1, 2, 3, 4, 5]

# 块语法
squared = numbers.map { |n| n ** 2 }
puts "平方: #{squared.inspect}"  # [1, 4, 9, 16, 25]

# & 速记语法(:upcase 被转为 Proc)
fruits = ["apple", "banana", "cherry"]
upper = fruits.map(&:upcase)
puts "大写: #{upper.inspect}"  # ["APPLE", "BANANA", "CHERRY"]

&:upcase 的原理:upcase.to_proc 返回一个 Proc,等价于 { |obj| obj.upcase }。这是 Ruby 惯用写法。

select 和 reject

过滤元素:

numbers = [1, 2, 3, 4, 5]

evens = numbers.select(&:even?)
puts "偶数: #{evens.inspect}"   # [2, 4]

odds = numbers.reject(&:even?)
puts "奇数: #{odds.inspect}"    # [1, 3, 5]

select 保留符合条件的元素,reject 剔除它们。

reduce 聚合

reduce(也叫 inject)将数组聚合为单个值:

numbers = [1, 2, 3, 4, 5]

# 求和
sum = numbers.reduce(0) { |acc, n| acc + n }
puts "求和: #{sum}"  # 15

# 用法简化(符号法)
product = numbers.reduce(1, :*)
puts "积: #{product}"  # 120

reduce 的思维方式:想象你在折叠一摞纸,每次把两张压成一张。0 是初始值,acc 是已折叠的结果 (acc 初始为 0),然后 0 + 1 = 1, 1 + 2 = 3, 3 + 3 = 6... 最后得到 15。

compact 和 flatten

# compact 移除 nil
with_nils = [1, nil, 2, nil, 3]
puts "compact: #{with_nils.compact.inspect}"  # [1, 2, 3]

# flatten 展平嵌套
# 展平嵌套数组
nested = [[1, 2], [3, [4, 5]]]
puts "展平所有: #{nested.flatten.inspect}"    # [1, 2, 3, 4, 5]
puts "展平一层: #{nested.flatten(1).inspect}"   # [1, 2, 3, [4, 5]]

# uniq 去重
duplicates = [1, 2, 2, 3, 3, 3]
puts "去重: #{duplicates.uniq.inspect}"  # [1, 2, 3]

each 迭代器

each 是 Ruby 中最常用的遍历方式:

arr = [10, 20, 30]
arr.each do |val|
  puts "值: #{val}"
end

# 带索引
arr.each_with_index do |val, idx|
  puts "  [#{idx}] = #{val}"
end
# 输出:
#   [0] = 10
#   [1] = 20
#   [2] = 30

为什么不常见 for 循环:Ruby 的 for 关键字存在但很少使用。each 更符合 Ruby 的函数式编程风格。

常见错误

错误 1:修改正在遍历的数组

arr = [1, 2, 3, 4, 5]
arr.each do |n|
  arr.delete(n) if n.even?  # 行为不可预测
end

修复:用 selectreject 创建新数组:

arr.reject!(&:even?)  # 删除所有偶数

错误 2:用 reduce 做 map 能做的事

# ❌ 不必要的复杂度
result = arr.reduce([]) { |acc, x| acc << x * 2 }

# ✅ 直接用
result = arr.map { |x| x * 2 }

错误 3:混淆 push 和 <<

arr = [1]
arr.push(2, 3)  # ✅ 可以添加多个元素
arr << 2 << 3   # ✅ 链式添加,但每次只添加一个

# 注意返回值不同
arr.push(4)     # 返回 arr
arr << 4        # 返回 arr(支持链式)

动手练习

练习 1:方法组合

[1, -2, 3, nil, -4, 5] 变成 [1, 2, 3, 4, 5](取绝对值,移除 nil)

参考答案
[1, -2, 3, nil, -4, 5].compact.map(&:abs)
# → [1, 2, 3, 4, 5]

练习 2:用 reduce 实现 join

words = ["hello", "world", "ruby"]
# 不用 join 方法,用 reduce 实现 "hello-world-ruby"
参考答案
words.reduce("") { |acc, w| acc.empty? ? w : "#{acc}-#{w}" }
# → "hello-world-ruby"

故障排查 (FAQ)

Q: 怎么判断数组中是否包含某个元素?

A: 用 include?

[1, 2, 3].include?(2)  # true
[1, 2, 3].include?(5)  # false

Q: mapeach 的区别?

A: each 遍历并执行动作(返回原数组),map 遍历并生成新数组。需要收集结果时用 map

Q: 数组元素是引用还是拷贝?

A: Ruby 里都是引用。修改可变对象会影响原数组:

arr = ["hi"]
copy = arr.map { |s| s }
copy[0] << "!"
puts arr[0]  # "hi!"(被修改了)

小结

核心要点

  1. 创建方式多样[]%w[]Array() 各有所长
  2. 负数索引取末尾[-1] 是最后元素,很 Ruby
  3. 两端操作:push/pop(尾)、shift/unshift(头)
  4. map/select/reduce 三件套:变换、过滤、聚合
  5. compact/flatten/uniq:清理和整理数组
  6. each 是主力遍历:而不是 for 循环

术语

  • Index(索引):数组中元素的位置编号
  • Slice(切片):取出子数组
  • Transform(变换):对每个元素应用函数(map)
  • Filter(过滤):筛选符合条件的元素(select/reject)
  • Reduce(归约):将数组聚合为单个值

继续学习

运行 hello basic arrays 查看完整示例代码。

哈希

开篇故事

哈希(Hash)是 Ruby 里最通用的数据容器。它就像一本字典:根据词语查释义。相比数组需要记住位置编号,哈希允许你用"名字"直接找到对应的值。Ruby 的哈希灵活、高效,几乎每个 Ruby 程序都会大量使用它。

本章适合谁

如果你需要用名称而非编号来组织数据(比如配置项、用户信息、JSON 响应),哈希是最佳选择。

你会学到什么

  1. 符号键和字符串键的区别
  2. 安全访问嵌套哈希(dig)
  3. 哈希合并(merge)
  4. 值变换(transform_values)
  5. 遍历键值对

前置要求

第一个例子

# 运行: hello basic hashes

user = { name: "Alice", age: 30, city: "Shanghai" }
puts "#{user[:name]} 今年 #{user[:age]} 岁,住在 #{user[:city]}"
# 输出: Alice 今年 30 岁,住在 Shanghai

为什么这很 Ruby:符号键 {name: "Alice"} 的写法接近 JSON,但性能更好。Ruby 社区约定俗成——哈希键用符号。

符号键 vs 字符串键

# 符号键(Ruby 1.9+ 推荐写法)
user_sym = { name: "Alice", age: 30, city: "Shanghai" }

# 字符串键(火箭语法,旧写法)
user_str = { "name" => "Bob", "age" => 25, "city" => "Beijing" }

# 互相转换
str_from_sym = user_sym.transform_keys(&:to_s)
puts "符号键: #{user_sym.inspect}"
puts "字符串键: #{user_str.inspect}"
puts "转换后: #{str_from_sym.inspect}"

关键区别:符号键 :name 和字符串键 "name" 在同一个哈希中是不同的键

mixed = { :name => "符号键的值", "name" => "字符串键的值" }
puts mixed[:name]     # "符号键的值"
puts mixed["name"]    # "字符串键的值"

访问方式:[] vs fetch

user = { name: "Alice", age: 30 }

# [] 访问 — 找不到返回 nil
puts user[:name]         # "Alice"
puts user[:missing].inspect  # nil

# fetch 访问 — 找不到抛 KeyError(或指定默认值)
puts user.fetch(:name, "N/A")  # "Alice"
puts user.fetch(:missing, "N/A")  # "N/A"(默认值)

# fetch 无默认值时,找不到抛出 KeyError
# user.fetch(:missing)  # ❌ KeyError

选择建议:如果你希望"找不到"时程序报错(及早发现问题),用 fetch。如果可以接受 nil(可选值),用 []

嵌套哈希与 dig

深层嵌套时,dig 是救命稻草:

config = {
  database: {
    host: "localhost",
    port: 5432,
    options: { timeout: 5, pool: 10 }
  }
}

# dig — 安全访问任意层级
puts config.dig(:database, :options, :timeout)  # 5
puts config.dig(:redis, :host).inspect   # nil(不会报错)

对比传统写法

# ❌ 容易报 NoMethodError
if config[:database] && config[:database][:options]
  config[:database][:options][:timeout]
end

# ✅ dig 一行搞定
config.dig(:database, :options, :timeout)

合并哈希

defaults = { log_level: :info, verbose: false, timeout: 30 }
overrides = { log_level: :debug, verbose: true }

merged = defaults.merge(overrides)
puts "合并后: #{merged.inspect}"
# → { log_level: :debug, verbose: true, timeout: 30 }

merge 不修改原哈希。merge! 会原地修改。

值变换

transform_values 对每个值应用变换:

scores = { math: 95, english: 88, science: 92 }

letter_grades = scores.transform_values do |score|
  case score
  when 90..100 then "A"
  when 80..89  then "B"
  when 70..79  then "C"
  else              "D"
  end
end

puts "等级: #{letter_grades.inspect}"
# → { math: "A", english: "B", science: "A" }

遍历

user = { name: "Alice", age: 30, city: "Shanghai" }

# 遍历键值对
user.each do |key, value|
  puts "  #{key}: #{value}"
end

# 获取所有键、值
puts "keys: #{user.keys.inspect}"      # [:name, :age, :city]
puts "values: #{user.values.inspect}"  # ["Alice", 30, "Shanghai"]

# 检查值是否存在
puts "包含 Alice?: #{user.value?("Alice")}"  # true

哈希是有序的:Ruby 1.9+ 的哈希保持插入顺序。遍历时会按添加顺序返回键值对。

常见错误

错误 1:混用符号键和字符串键

data = { name: "Alice" }
puts data["name"]  # nil(找不到!)

修复:统一使用符号键,或在接收外部数据时标准化:

data = { name: "Alice" }
data[:name]  # "Alice" ✅

错误 2:嵌套哈希访问崩溃

config = { database: { host: "localhost" } }
puts config[:database][:options][:timeout]  # ❌ NoMethodError(options 是 nil)

修复:用 dig 安全访问。

错误 3:在遍历时修改哈希

hash = { a: 1, b: 2, c: 3 }
hash.each do |key, value|
  hash.delete(key) if value == 2  # 行为可能不可预测
end

修复:用 reject!transform_values

hash.reject! { |_, v| v == 2 }  # 安全

动手练习

练习 1:嵌套数据提取

response = {
  data: {
    users: [
      { name: "Alice", contacts: { email: "alice@example.com" } },
      { name: "Bob", contacts: { phone: "138-1234-5678" } }
    ]
  }
}

# 用 dig 提取第一个用户的邮箱
参考答案
response.dig(:data, :users, 0, :contacts, :email)
# → "alice@example.com"

练习 2:统计词频

words = %w[apple banana apple cherry banana apple]
# 用 reduce 统计每个单词出现的次数
参考答案
words.reduce(Hash.new(0)) do |counts, word|
  counts[word] += 1
  counts
end
# → {"apple"=>3, "banana"=>2, "cherry"=>1}

# Ruby 2.7+ 有更简单的写法:
words.tally

故障排查 (FAQ)

Q: 什么时候用符号键,什么时候用字符串键?

A: 代码内部的哈希用符号键。从外部(如 JSON 解析、HTTP 请求参数)接收的哈希用字符串键。Rails 中 params 就是字符串键。

Q: 哈希的查找效率如何?

A: O(1) 平均时间复杂度。哈希表用哈希函数计算键的存储位置,所以查找速度和哈希大小基本无关。

Q: 怎么给哈希设置默认值?

A: Hash.new(default_value) 或带 Proc 的 Hash.new { |h, k| h[k] = [] }

# 所有找不到的键默认返回空数组
grouped = Hash.new { |h, k| h[k] = [] }
grouped[:fruits] << "apple"  # 不报错

小结

核心要点

  1. 符号键是惯例{ name: "Alice" } 而非 { "name" => "Alice" }
  2. fetch 比 [] 更安全:能指定默认值,找不到时报错而非静默 nil
  3. dig 处理嵌套:安全穿透多层哈希,任何一层 nil 都安全返回 nil
  4. merge 合并配置:默认值和覆盖值的经典模式
  5. transform_values 变换:对值批量处理
  6. 哈希保持插入顺序:遍历顺序等于添加顺序

术语

  • Key-Value Pair(键值对):哈希的基本数据单元
  • Hash Key(哈希键):用于查找的标识符
  • Default Value(默认值):找不到键时的回退值
  • Transform(变换):对每个值应用函数

继续学习

运行 hello basic hashes 查看完整示例代码。

控制流

开篇故事

程序之所以能处理复杂业务,不是因为它能计算多少东西,而是因为它能根据不同条件做不同决策。控制流就是 Ruby 代码的"判断力"——什么时候走这条路,什么时候走那条路,什么时候停下来。

本章适合谁

如果你需要让代码做出决策(条件判断)或重复执行(循环),本章涵盖了 Ruby 中所有控制流结构。

你会学到什么

  1. if/elsif/else 和三元运算符
  2. unless — Ruby 特有的否定条件
  3. case/when — 多路分支的优雅写法
  4. while/until — 循环
  5. each 迭代器 — Ruby 的推荐遍历方式
  6. next/break/redo — 循环控制

前置要求

第一个例子

# 运行: hello basic control-flow

score = 85
grade = if score >= 90
  "A"
elsif score >= 80
  "B"
elsif score >= 70
  "C"
else
  "D"
end
puts "#{score}分 → #{grade}"
# 输出: 85分 → B

这为什么值得注意:在 Java/C++ 中,if 是语句,不返回值。在 Ruby 中,if 是表达式,它本身有返回值,可以直接赋值给变量。

if / elsif / else

# 多行形式
score = 85
grade = if score >= 90
  "A"
elsif score >= 80
  "B"
else
  "D"
end

三元运算符

status = score >= 60 ? "及格" : "不及格"
puts status  # "及格"

Ruby 的所有都是表达式:if、case、循环都返回值。这让你在需要时写出更简洁的赋值语句。

unless

unless 是 "if not" 的 Ruby 式写法。它只在条件为 false 时执行:

password = "secret123"

unless password.length >= 8
  puts "密码长度不足!"
end

# 单行形式(Ruby 特色)
puts "unless 单行: #{password.length < 8 && "密码不够长"}"

何时使用 unless:仅在逻辑是否定时使用。如果条件本身已经是正向描述,不要为了让代码"看起来像 Ruby"就硬套 unless。

# ✅ 好的用法
unless user.admin?
  raise "权限不足"
end

# ❌ 不推荐(否定逻辑嵌套)
unless !user.active?
  puts "用户已激活"
end
# 应该写成 if user.active?

case / when

case 替代多重 if/elsif,更清晰:

day = "Monday"
activity = case day
           when "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"
             "工作日"
           when "Saturday", "Sunday"
             "周末"
           else
             "未知"
           end
puts "case/when: #{day} → #{activity}"

case 的强大之处是匹配规则不只是等号。它使用 === 操作符,所以可以匹配:

# Range 匹配
temperature = 35
weather = case temperature
          when 0..15   then "寒冷"
          when 16..25  then "舒适"
          when 26..35  then "炎热"
          else "极端"
          end

# 正则匹配
input = "user@example.com"
kind = case input
       when /\A[\w.]+@[\w.]+\z/ then "邮箱地址"
       when /\A\d{3}-\d{4}\z/  then "电话号码"
       else "其他格式"
       end

while 和 until

# while
sum = 0
i = 1
while i <= 5
  sum += i
  i += 1
end
puts "while: 1+2+3+4+5 = #{sum}"

# until(等效于 while not)
countdown = 3
until countdown == 0
  print "#{countdown}... "
  countdown -= 1
end
puts "🚀!"

while 的返回值是 nil。如果最后一次循环体执行后没有显式返回,整个 while 表达式返回 nil。

for 循环

print "for (1..3): "
for n in 1..3
  print "#{n} "
end
# 输出: for (1..3): 1 2 3

Ruby 社区几乎不用 forfor 在 Ruby 中不常用,更地道的写法是 each 迭代器。

each 迭代器

colors = %w[red green blue]

# each 遍历
colors.each { |c| print "#{c} " }

# each_with_index
colors.each_with_index { |c, i| puts "[#{i}] = #{c}" }

为什么 each 比 for 更受推崇

  1. 作用域隔离each 中的块变量不会泄漏到外部
  2. 链式调用:可以接 mapselect 等方法
  3. 延迟计算:配合 Lazy 可以处理大数据集

循环控制:next / break

# next — 跳过当前迭代(类似 continue)
(1..5).each do |n|
  next if n.even?
  print "#{n} "
end
# 输出: 1 3 5

# break — 终止整个循环
(1..10).each do |n|
  break if n > 4
  print "#{n} "
end
# 输出: 1 2 3 4

ranges

# 范围是 case 和循环的好搭档
(1..5).each { |n| print "#{n} " }   # 包含结束值
(1...5).each { |n| print "#{n} " }  # 不包含5..5)  # 不包含结束值

三元运算符

status = score >= 60 ? "及格" : "不及格"

Ruby 习惯用法:三元运算符在 Ruby 中不常用,因为 if/unless 单行形式通常更清楚:

# 传统三元
result = x > 0 ? "正" : "负"

# Ruby 风格
result = if x > 0; "正"; else; "负"; end

常见错误

错误 1:在 unless 中使用 else

# ❌ unless 不建议带 else(语义混乱)
unless user.active?
  puts "已禁用"
else
  puts "活跃"
end

# ✅ 改用 if 更清晰
if user.active?
  puts "活跃"
else
  puts "已禁用"
end

错误 2:case 匹配时忘记顺序

# 注意:case 从上到下匹配,更具体的条件放前面!
case value
when 1..10
  puts "1-10"
when 1..5       # 永远不会执行!被上面的 1..10 截胡了
  puts "1-5"
end

错误 3:用 while 做数组遍历

# ❌ C/C++ 风格(Ruby 不推荐)
i = 0
while i < arr.length
  puts arr[i]
  i += 1
end

# ✅ Ruby 风格
arr.each { |item| puts item }

动手练习

练习 1:FizzBuzz

# 经典面试题:打印 1-20
# 3的倍数打印 "Fizz",5的倍数打印 "Buzz"
# 同时是3和5的倍数打印 "FizzBuzz"
参考答案
(1..20).each do |n|
  if n % 15 == 0
    puts "FizzBuzz"
  elsif n % 3 == 0
    puts "Fizz"
  elsif n % 5 == 0
    puts "Buzz"
  else
    puts n
  end
end

练习 2:用 case 分类

# 根据分数输出等级(100-90: A, 89-80: B, 79-70: C, 69-60: D, <60: F)
# 用 case when range 写法
参考答案
def grade(score)
  case score
  when 90..100 then "A"
  when 80..89  then "B"
  when 70..79  then "C"
  when 60..69  then "D"
  else             "F"
  end
end

故障排查 (FAQ)

Q: Ruby 有没有 switch 语句?

A: 没有。Ruby 用 case/when 替代 switch。它更强大——支持 Range 匹配和正则匹配,不仅是等号比较。

Q: next 和 break 有什么区别?

A: next 跳过当前迭代(进入下一次循环),break 终止整个循环。类比:next 是跳过一个音符,break 是终止整首歌。

Q: Ruby 为什么不用 for 循环?

A: Ruby 的 for 关键字存在但很少使用。each 迭代器更 Ruby——闭包隔离作用域、支持方法链、更符合函数式风格。

小结

核心要点

  1. if 也是表达式:可以赋值给变量,不只能做语句
  2. unless 仅用于简单否定:复杂的不要强行用 unless
  3. case 不只匹配等号:支持 Range、正则、类名
  4. each 是主流遍历方式:而非 while/for
  5. next/break 控制循环:next 跳过当前,break 终止全部
  6. Range 是条件匹配利器90..100 用于 case 非常直观

术语

  • Expression(表达式):求值并返回结果(if、case 都是)
  • Iteration(迭代):重复执行代码块
  • Guard Clause(卫语句):提前返回的条件判断
  • Predicate(谓词):返回 true/false 的方法(以 ? 结尾)

继续学习

运行 hello basic control-flow 查看完整示例代码。

方法定义与调用

开篇故事

方法是代码的组织单元。一段重复的逻辑,把它放进方法里,起个名字,以后调名字就行了。Ruby 的方法特别灵活——支持默认参数、关键字参数、参数解构、块传递。本章不讲 def 语法(基础中的基础),而是深入 Ruby 方法的高级特性。

本章适合谁

如果你写 Ruby 代码时遇到过以下情况,本章适合你:

  • "这个方法参数太多了,调用时容易搞混"
  • "我想传未知数量的参数"
  • "我想把一段代码逻辑当成参数传给方法"

你会学到什么

  1. Lambda 作为方法对象
  2. 默认参数值
  3. 关键字参数
  4. 参数解构(*args / **kwargs)
  5. 块捕获(&block)
  6. Proc.new vs lambda 的 return 行为差异

前置要求

第一个例子

# 运行: hello basic methods

greet = ->(name) { "Hello, #{name}!" }
puts greet.call("Ruby")
# 输出: Hello, Ruby!

本章用 Lambda 来做演示。在 Ruby 中,lambda 是一种一等公民的方法对象,可以赋值给变量、作为参数传递、作为返回值返回。

默认参数值

greet_default = ->(name = "World") { "Hi, #{name}!" }
puts greet_default.call         # "Hi, World!"
puts greet_default.call("Alice") # "Hi, Alice!"

和 Python 的区别:Ruby 的默认参数也是从左到右计算,和 Python 一样。

关键字参数

create_url = ->(host:, port:, path: "/") { "#{host}:#{port}#{path}" }
puts create_url.call(host: "localhost", port: 8080)
# → "localhost:8080/"

puts create_url.call(host: "localhost", port: 3000, path: "/api/v1")
# → "localhost:3000/api/v1"

关键字参数的优势:调用顺序无关紧要,且语义明确

# 顺序无关
create_url.call(port: 8080, host: "localhost", path: "/")  # 一样

# 比位置参数更清晰
create_url.call("localhost", 8080, "/")  # 这三个参数是什么意思?

位置参数捕获 — Splat *

# 捕获所有额外参数
collect_args = ->(*args) { "收到 #{args.length} 个参数: #{args.inspect}" }
puts collect_args.call(1, 2, 3, 4)
# → "收到 4 个参数: [1, 2, 3, 4]"

# Splat 解构赋值
first, *rest = [1, 2, 3, 4, 5]
puts "首元素: #{first}, 其余: #{rest.inspect}"
# → 首元素: 1, 其余: [2, 3, 4, 5]

关键字参数捕获 — Double Splat **

collect_kwargs = ->(**kwargs) { "选项: #{kwargs.inspect}" }
puts collect_kwargs.call(color: "red", size: "large", active: true)
# → "选项: {:color=>\"red\", :size=>\"large\", :active=>true}"

块捕获 &block

repeater = ->(n, &block) { n.times { |i| puts "  第 #{i + 1} 次: #{block.call(i)}" } }

repeater.call(3) { |i| "迭代 #{i}" }
# 输出:
#   第 1 次: 迭代 0
#   第 2 次: 迭代 1
#   第 3 次: 迭代 2

&block 将传入的匿名 block 转换为一个 Proc 对象,可以在方法内自由传递、存储、延迟调用。

Proc.new vs lambda 的核心差异

这也是 Ruby 中经常混淆的问题。

差异 1:参数检查(Arity)

# Lambda 严格检查参数数量
strict_lambda = ->(a, b) { a + b }
strict_lambda.call(1, 2)    # 3 ✅
strict_lambda.call(1)       # ❌ ArgumentError

# Proc.new 宽松处理
loose_proc = Proc.new { |a, b| "a=#{a.inspect}, b=#{b.inspect}" }
loose_proc.call(1, 2, 3)    # "a=1, b=2"(忽略多余参数)
loose_proc.call(1)          # "a=1, b=nil"(缺失参数填 nil)

差异 2:return 行为

# Proc.new 的 return 跳出整个外部方法!
outer = -> do
  p = Proc.new { return "从 Proc.new 直接跳出外部方法!" }
  p.call
  "这行不会执行"    # ← 永远不会到这里
end
# 输出: "从 Proc.new 直接跳出外部方法!"

# Lambda 的 return 仅从 lambda 自身返回
outer2 = -> do
  l = lambda { return "仅从 lambda 返回" }
  result = l.call
  "lambda 返回了: '#{result}',继续执行"  # ← 会到这里
end
# 输出: "lambda 返回了: '仅从 lambda 返回',继续执行"

记忆口诀Proc.new 的 return 是"跳楼逃生",lambda 的 return 是"关门退出"。

常见错误

错误 1:关键字参数漏传必传参数

create_url = ->(host:, port:) { "#{host}:#{port}" }
create_url.call(host: "localhost")  # ❌ missing keyword: :port

修复:给关键字参数设置默认值,或在调用时传全部必传的。

错误 2:混淆 * 和 **

# ❌ 把哈希作为 *args 传入
def show(*args)
  puts args.inspect  # [{:color=>"red"}] — 哈希被当成数组元素
end
show(color: "red")

# ✅ 应该用 **kwargs
def show(**kwargs)
  puts kwargs.inspect # {:color=>"red"} — 直接解构为哈希
end
show(color: "red")

错误 3:用 Proc.new 时意外 return

def process
  items.each do |item|
    Proc.new { return "提前退出" }.call  # ❌ 直接退出整个 process 方法
    puts "后处理"  # 永远不会执行
  end
  "正常结束"
end

修复:用 lambda 或 next

动手练习

练习 1:通用日志方法

# 写一个 log 方法:支持任意位置参数、关键字参数、块
# 调用示例:log("msg", level: :warn) { "extra info" }
参考答案
def log(*args, **kwargs, &block)
  puts "Args: #{args.inspect}"
  puts "Keywords: #{kwargs.inspect}"
  puts "Block result: #{block.call if block}"
end

log("hello", level: :warn) { "extra" }

练习 2:参数解构

# 用 * 解构赋值:从 [10, 20, 30, 40, 50] 提取首、尾、中间元素
参考答案
first, *middle, last = [10, 20, 30, 40, 50]
# first=10, middle=[20, 30, 40], last=50

故障排查 (FAQ)

Q: 什么时候用 lambda,什么时候用 Proc.new?

A:

  • 用 lambda:需要严格参数检查、return 只退出自身(大多数场景)
  • 用 Proc.new:需要宽松的 arity、需要 return 跳出外部方法(少见)

Q: &blockyield 有什么区别?

A: yield 隐式调用块(更快,但块不能存储);&block 显式捕获为 Proc(可以存储、传递)。块只能被 yield 调用一次的方法用 yield;需要存储块的方法用 &block

Q: 关键字参数可以设置默认值吗?

A: 可以。def method(key: "default")。没有默认值的关键字参数是必传的。

小结

核心要点

  1. 默认参数简化调用:常见场景有合理默认值
  2. 关键字参数提高可读性:参数多时强烈推荐
  3. *args 捕获位置参数**kwargs 捕获关键字参数
  4. &block 将块转为 Proc:可存储、传递、延迟调用
  5. Proc.new 和 lambda 行为不同:arity 检查和 return 语义是关键差异
  6. 方法参数顺序:位置参数 → splat → 关键字 → double splat → 块

术语

  • Lambda:严格模式的方法对象
  • Proc:宽松的块对象
  • Arity(元数):方法所需的参数数量
  • Splat(*):打包/解包位置参数
  • Double Splat()**:打包/解包关键字参数
  • Block Capture(块捕获)&block 将匿名块转为 Proc

继续学习

运行 hello basic methods 查看完整示例代码。

类与对象

开篇故事

面向对象编程的核心思想是:程序是由交互的对象组成的。对象有自己的数据(状态)和行为(方法)。在 Ruby 中,几乎所有东西都是对象——数字、字符串、甚至类本身。本章带你理解 Ruby 的类系统。

本章适合谁

如果你想用对象组织代码、设计可复用的组件、或理解 Rails 等框架中的模型系统,本章是必经之路。

你会学到什么

  1. initialize 构造函数
  2. attr_reader / attr_writer / attr_accessor
  3. 类方法与实例方法
  4. 继承与 is_a? 检查
  5. 单例类概念

前置要求

第一个例子

# 运行: hello basic classes

# 等价的类定义
class Person
  attr_reader :name, :age
  attr_accessor :email

  def initialize(name, age)
    @name = name
    @age = age
  end

  def greet
    "我叫 #{@name},今年 #{@age} 岁。"
  end
end

person = Person.new("Alice", 30)
person.email = "alice@example.com"
puts person.greet  # 我叫 Alice,今年 30 岁。

Ruby 的 Person.new 做了什么new 类方法先分配内存,然后调用 initialize。你通常只重写 initialize,而不是 new

initialize 构造函数

class Person
  def initialize(name, age)
    @name = name    # 实例变量
    @age = age
  end
end

person = Person.new("Alice", 30)

initialize 是实例方法(私有),不能直接调用。它只在 ClassName.new 时被自动调用。

attr_* 属性声明

Ruby 提供三个快捷方法来生成 getter/setter:

class Person
  attr_reader :name, :age        # 只读:def name; @name; end
  attr_writer :email             # 只写:def email=(v); @email = v; end
  attr_accessor :phone           # 读写:两者都有
end

person = Person.new
person.phone = "138-1234-5678"   # writer
puts person.phone                # reader
生成方法用途
attr_readerdef name; @name; end只读属性
attr_writerdef name=(v); @name = v; end只写属性
attr_accessor两者都生成读写属性

为什么不推荐全用 attr_accessor:只读属性更安全。让数据只能通过受控的方法修改,避免意外篡改。

类方法与实例方法

class Counter
  @@count = 0  # 类变量

  # 类方法 — 用 def self.method_name
  def self.increment
    @@count += 1
  end

  def self.count
    @@count
  end

  # 实例方法 — 普通 def
  def initialize
    self.class.increment
  end
end

Counter.increment
Counter.increment
puts "计数器: #{Counter.count}"  # 2

调用方式

  • 类方法:ClassName.method_name
  • 实例方法:instance.method_name

self 关键字

self 指向当前对象:

# 在类方法中,self = 类本身
class Foo
  def self.bar
    puts self  # Foo
  end
end

# 在实例方法中,self = 当前实例
class Foo
  def bar
    puts self  # #<Foo:0x0000...>
  end
end

单例类:每个 Ruby 对象都有一个隐藏的单例类,存放它独有的方法。

obj = "hello"
class << obj
  def shouted
    upcase
  end
end
puts obj.shouted  # "HELLO"
# 另一个字符串没有这个方法

继承

class Animal
  def speak
    "..."
  end
end

class Dog < Animal
  def speak
    "汪!"
  end
end

class Cat < Animal
  def speak
    "喵!"
  end
end

dog = Dog.new
cat = Cat.new
puts dog.speak  # 汪!
puts cat.speak  # 喵!
puts dog.is_a?(Animal)   # true
puts Dog < Animal        # true(Dog 继承自 Animal)

Ruby 只有单继承:一个类只能有一个直接父类。多行为通过模块(Mixin)实现。

常见错误

错误 1:在 initialize 中使用 return

class Person
  def initialize(name)
    @name = name
    return "some value"  # ❌ initialize 的返回值被忽略
  end
end

initialize 的返回值总是被忽略,new 返回的是对象实例本身。

错误 2:类方法中用 self 却当成实例

class Counter
  def self.increment
    @count = 10  # ❌ @count 是类实例变量,不是类变量
                 # 类实例变量存在 Counter 对象上
  end

  def self.count
    @count  # 可以访问
  end
end

错误 3:忘记 super

class Dog < Animal
  def initialize(name)
    # @name = name  # ❌ 如果父类 initialize 做了其他事会被跳过
    super(name)    # ✅ 调用父类 initialize
  end
end

动手练习

练习 1:设计银行账户

# 设计 Account 类:
# - 实例变量: owner (只读), balance (只写)
# - 类变量: @@total_accounts
# - 类方法: total_accounts
# - 实例方法: deposit(amount), withdraw(amount)
# - initialize(owner, initial_balance)
参考答案
class Account
  @@total_accounts = 0

  attr_reader :owner
  attr_writer :balance

  def self.total_accounts
    @@total_accounts
  end

  def initialize(owner, initial_balance = 0)
    @owner = owner
    @balance = initial_balance
    @@total_accounts += 1
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    @balance -= amount
  end
end

练习 2:类继承链

# 创建 Animal → Dog → ServiceDog 三层继承
# ServiceDog 添加 work 方法
参考答案
class Animal
  def speak; "..."; end
end

class Dog < Animal
  def speak; "汪!"; end
end

class ServiceDog < Dog
  def speak; "汪!我是导盲犬。"; end
  def work; "正在引导主人..."; end
end

故障排查 (FAQ)

Q: attr_accessor 和手动写 getter/setter 有什么区别?

A: 功能完全一样。attr_accessor :name 是语法糖,等价于:

def name; @name; end
def name=(v); @name = v; end

如果 getter/setter 中有额外逻辑(如验证、转换),需要手动写方法。

Q: 类变量 @@ 和类实例变量 @ 有什么区别?

A: @@ 在继承链中共享;@(定义在 class << self 块内或 def self.method 中)是类对象自己的实例变量,不共享。推荐使用类实例变量。

Q: Ruby 有构造函数重载吗?

A: 没有(方法名不支持重载)。解决方法:使用关键字参数、工厂方法 Class.from_json(str) 或默认参数。

小结

核心要点

  1. initialize 是私有构造函数:通过 Class.new 调用,返回值被忽略
  2. attr_reader/writer/accessor 是语法糖:按需选择,保持封装性
  3. def self.method 定义类方法:通过 ClassName.method 调用
  4. 继承用 <:单继承,多行为用模块
  5. self 指向当前上下文:类方法中是类,实例方法中是对象
  6. is_a?< 检查继承关系:做类型判断时很有用

术语

  • Class(类):对象的模板
  • Instance(实例):通过类创建的对象
  • Constructor(构造函数)initialize 方法
  • Accessor(访问器):读写内部状态的方法
  • Singleton Class(单例类):每个对象独有的匿名类

继续学习

运行 hello basic classes 查看完整示例代码。

模块与混入

开篇故事

Ruby 只有单继承,但现实中的对象往往有多种角色。一个人可以"会唱歌"也会"会画画"——这些能力不是继承关系,而是可组合的特征。Ruby 用模块(Module)来解决这个问题:模块就像工具箱,可以混入(mixin)到任何类里。

本章适合谁

如果你需要实现代码复用而非继承关系(比如日志功能、序列化功能、权限检查),模块就是你的工具。

你会学到什么

  1. include — 模块方法成为实例方法
  2. extend — 模块方法成为类方法
  3. prepend — 在方法链中前置拦截
  4. module_function — 模块本身作为命名空间调用
  5. 自定义 Enumerable 类

前置要求

第一个例子

# 运行: hello basic modules

module Loggable
  def log(msg)
    puts "[LOG] #{self.class.name}: #{msg}"
  end
end

class Service
  include Loggable

  def do_work
    log("doing something")
  end
end

Service.new.do_work
# 输出: [LOG] Service: doing something

为什么这比继承好:一个类可以同时 include 多个模块,但只能继承一个父类。模块是组合优于继承的 Ruby 式体现。

include — 模块方法成为实例方法

module Loggable
  def log(msg)
    puts "[LOG] #{self.class.name}: #{msg}"
  end
end

class Service
  include Loggable

  def do_work
    log("doing something")
  end
end

include 把模块的方法变成类的实例方法。一个类可以 include 多个模块:

class Report
  include Loggable
  include Serializable
  extend Timestamped

  def generate
    log("generating report")
  end
end

extend — 模块方法成为类方法

module Serializable
  def to_json_like
    "{instance_variables: #{instance_variables.map(&:to_s)}}"
  end
end

class Config
  extend Serializable
end

Config.instance_variable_set(:@foo, "bar")
puts Config.to_json_like
# → {instance_variables: ["@foo"]}

记忆区别include 给实例提供方法,extend 给类提供方法。

prepend — 方法拦截

module Timestamped
  attr_accessor :created_at
end

class Task
  prepend Timestamped

  def initialize
    @created_at = "original"
  end
  attr_accessor :created_at
end

task = Task.new
task.created_at = "2024-01-01"
puts task.created_at  # "2024-01-01"

prepend 把模块插在方法查找链的最前面。如果模块和类有同名方法,模块的方法先执行,可以调用 super 继续调用类的方法。

module Logging
  def do_something
    puts "before"
    super  # 调用类的方法
    puts "after"
  end
end

class Worker
  prepend Logging

  def do_something
    puts "working..."
  end
end

Worker.new.do_something
# 输出:
# before
# working...
# after

module_function — 模块作为命名空间

module Greetings
  module_function

  def hello
    "Hello from module_function"
  end

  def goodbye
    "Goodbye from module_function"
  end
end

puts Greetings.hello    # "Hello from module_function"
puts Greetings.goodbye  # "Goodbye from module_function"

module_function 做两件事:

  1. 把模块的方法变成可以直接调用的类方法
  2. 生成私有实例方法副本,可以被 include 使用

适用场景:工具类方法(如 JSON.parse、File.read),不需要实例化即可调用。

include Enumerable

Ruby 的 Enumerable 模块提供了 mapselectfindeach 等 50+ 个方法。你只需实现 each,即可获得所有方法:

class UserList
  include Enumerable

  def initialize(data)
    @data = data
  end

  def each(&block)
    @data.each(&block)
  end
end

users = UserList.new(["alice@email.com", "BOB@EMAIL.COM"])

# 实现了 each 后,map、find 等全都有
puts users.map(&:upcase).inspect
# → ["ALICE@EMAIL.COM", "BOB@EMAIL.COM"]

puts users.find { |s| s.include?("@") }
# → "alice@email.com"

为什么这很强大:你定义一次 each,就免费获得 all?any?countgroup_bymaxmin 等几十个方法。

常见错误

错误 1:include vs extend 混淆

module StringUtils
  def capitalize_words(str)
    str.split.map(&:capitalize).join(" ")
  end
end

class TextProcessor
  include StringUtils  # ❌ 想用类方法调用,却用了 include
end

# TextProcessor.capitalize_words("hello")  # ❌ NoMethodError
# ✅ 应该用 extend

错误 2:模块方法中的 self 困惑

module Logger
  def log(msg)
    puts "[LOG] #{self.class.name}: #{msg}"
    # self 指向 include 这个模块的实例
  end
end

selflog 方法中指向调用它的对象,而非模块本身。这保证了日志输出正确的类名。

动手练习

练习 1:设计 Mixin

# 创建可复用的模块
# 1. Auditable 模块:添加 created_at, updated_at, audit_log
# 2. 让 Order 类和 User 类 include 它
参考答案
module Auditable
  attr_accessor :created_at, :updated_at

  def self.included(base)
    base.class_eval do
      before_save :update_timestamp
    end
  end

  def audit_log
    puts "记录于 #{Time.now}"
  end

  private

  def update_timestamp
    @updated_at = Time.now
  end
end

class Order
  include Auditable
end

练习 2:扩展 Enumerable

# 创建一个 RangeList 类
# 包含 [1..5, 10..15] 这样的范围列表
# include Enumerable 使其支持 map, select 等
参考答案
class RangeList
  include Enumerable

  def initialize(*ranges)
    @ranges = ranges
  end

  def each(&block)
    @ranges.each { |range| range.each(&block) }
  end
end

rl = RangeList.new(1..3, 10..12)
rl.select(&:even?)  # [2, 10, 12]

故障排查 (FAQ)

Q: include vs extend vs prepend 怎么选?

A:

  • include:需要模块方法作为实例方法(最常见)
  • extend:需要模块方法作为类方法
  • prepend:需要拦截/包装原有方法

Q: 模块可以继承吗?

A: 不可以直接用 <。但模块可以 include 其他模块,实现模块级别的组合。

Q: module_function 和 extend self 区别?

A: 两者效果类似。module_function 更正式,extend self 更简洁(一行搞定)。小模块用 extend self,需要 include 和直接调用两用的用 module_function。

小结

核心要点

  1. include → 实例方法:给对象添加行为
  2. extend → 类方法:给类添加功能
  3. prepend → 方法拦截:在方法链最前面插入
  4. module_function → 直接调用:工具方法模式
  5. include Enumerable → 免费获得 50+ 方法:只需实现 each
  6. 模块 = 可组合的特征:弥补单继承的限制

术语

  • Module(模块):无实例化的方法集合
  • Mixin(混入):将模块方法注入类的机制
  • Enumerable:提供集合遍历功能的模块
  • Method Lookup Path(方法查找路径):Ruby 查找方法定义的顺序
  • Ancestor Chain(祖先链):包含父类和 include 的模块

继续学习

运行 hello basic modules 查看完整示例代码。

块与 Proc

开篇故事

块(Block)是 Ruby 的灵魂。没有块,Ruby 就只是一门普通的面向对象语言;有了块,Ruby 变成了一门优雅的函数式混合语言。Rails 的路由配置、RSpec 的测试描述、ActiveRecord 的链式查询——都建立在块的基础上。理解块,才能真正理解 Ruby。

本章适合谁

如果你想写出地道的 Ruby 代码,理解 Ruby 生态中最常见的设计模式,本章不可跳过。

你会学到什么

  1. Block 的基本用法和 yield 机制
  2. Proc — 将块变为对象
  3. Lambda — 更严格的 Proc
  4. Proc.new vs Lambda 的核心差异
  5. 回调与函数组合模式
  6. Symbol#to_proc 速记法

前置要求

第一个例子

# 运行: hello basic blocks-procs

# 块是最核心的 Ruby 特性
[1, 2, 3].each { |n| print "#{n * 10} " }
# 输出: 10 20 30

为什么这很 Rubyeach 方法本身不知道你要对每个元素做什么。你把逻辑写成 { |n| ... } 传给 eacheach 负责遍历,块负责处理。这是经典的策略模式。

Block — 方法的隐式伙伴

Block 不是参数,是方法的隐式搭档。方法通过 yield 调用块:

def repeat(n)
  n.times do |i|
    yield(i)  # 调用传入的块
  end
end

repeat(3) { |i| puts "第 #{i + 1} 次" }
# 输出:
# 第 1 次
# 第 2 次
# 第 3 次

yield 的开销极低:隐式 yield 比显式 &block 快。如果只需要调用块一次或几次,用 yield。

Proc — 一等公民的块

Proc 让块变成可以存储、传递的对象:

my_proc = Proc.new { |x| x * 2 }

# Proc 支持 .(), .call, .yield, [] 四种调用方式
puts my_proc.call(5)   # 10
puts my_proc[10]       # 20
puts my_proc.(15)      # 30

什么时候需要 Proc

  1. 需要把块存储到变量中
  2. 需要把块作为参数传给另一个方法
  3. 需要块作为方法的返回值

Lambda — 更严格的 Proc

my_lambda = ->(x) { x * 2 }
# 等价于: lambda { |x| x * 2 }

puts my_lambda.call(5)   # 10
puts my_lambda.class      # Proc

Lambda 和 Proc 都是 Proc 类的实例,但行为有差异。

Proc.new vs Lambda 的核心差异

差异 1:参数检查(Arity)

# Lambda 严格检查
strict = ->(a, b) { a + b }
strict.call(1, 2)   # ✅ 3
strict.call(1)      # ❌ ArgumentError

# Proc.new 宽松
loose = Proc.new { |a, b| "a=#{a.inspect}, b=#{b.inspect}" }
loose.call(1, 2, 3) # "a=1, b=2"(多余参数忽略)
loose.call(1)       # "a=1, b=nil"(缺失填 nil)

差异 2:return 行为

# Proc.new — return 跳出整个外部方法
outer = -> do
  p = Proc.new { return "Proc.new 的 return 直接跳出!" }
  p.call
  "这行不会执行"
end
# 输出: "Proc.new 的 return 直接跳出!"

# Lambda — return 只从 lambda 返回
outer2 = -> do
  l = lambda { return "仅从 lambda 返回" }
  result = l.call
  "lambda 继续运行: #{result}"
end
# 输出: "lambda 继续运行: 仅从 lambda 返回"

记忆口诀:Proc.new 是"跳楼逃生",lambda 是"关门退出"。

回调模式

retry_with_callback = ->(retries: 3, &on_attempt) {
  retries.times do |i|
    puts "尝试 #{i + 1}/#{retries}..."
    on_attempt.call(i + 1)
  end
}

retry_with_callback.call { |i| puts "  执行 #{i}" }
# 输出:
# 尝试 1/3...
#   执行 1
# 尝试 2/3...
#   执行 2
# 尝试 3/3...
#   执行 3

函数组合

compose = ->(f, g) { ->(x) { f.call(g.call(x)) } }

add_one = ->(x) { x + 1 }
times_two = ->(x) { x * 2 }

add_then_double = compose.call(times_two, add_one)
puts add_then_double.call(5)
# → 12(5+1=6, 6*2=12)

Symbol#to_proc 速记法

%w[hello world].map(&:upcase)
# → ["HELLO", "WORLD"]

# :upcase 被自动调用 .to_proc 转为 Proc
# 等价于 %w[hello world].map { |s| s.upcase }

常见错误

错误 1:{} 和 do...end 的优先级

# {} 优先级高于 do...end
# 以下两条不同:
arr.map { |x| x * 2 }.select(&:even?)  # 正确
arr.map do |x| x * 2 end.select(&:even?)  # ❌ 语法错误

惯例:单行块用 {},多行块用 do...end

错误 2:用 Proc.new 意外 return

def process
  items.each do |item|
    Proc.new { return "退出" }.call  # 直接退出整个方法
  end
end

修复:大多数情况下用 lambda。

错误 3:块变量泄漏

[1, 2, 3].each { |n| temp = n * 2 }
puts temp  # 在 Ruby 2.0+ 中不会泄漏

块变量作用域仅限于块内部(Ruby 2.0+)。Ruby 1.9 之前块变量会泄漏到外层。

动手练习

练习 1:实现 retry_with_callback

# 写一个 retry 方法:
# retry(3) { 可能失败的代码块 }
# 如果块抛异常,重试,最多 3 次
参考答案
def retry(max_times = 3)
  max_times.times do |attempt|
    begin
      return yield  # 成功则直接返回
    rescue => e
      puts "第 #{attempt + 1} 次失败: #{e.message}"
      raise if attempt == max_times - 1  # 最后一次失败则抛出
    end
  end
end

retry(3) { 
  # 模拟可能失败的代码
  rand > 0.5 ? "成功" : raise("失败")
}

练习 2:currying

# Currying:把多参数函数转换为单参数函数的嵌套
# add = ->(a, b) { a + b }
# add_five = add.curry.(5)
# add_five.(3)  # → 8
参考答案
add = ->(a, b) { a + b }
add_five = add.curry.(5)
puts add_five.(3)  # 8

故障排查 (FAQ)

Q: yield 和 &block 什么时候用?

A:

  • yield:只需要调用一次或几次,性能好
  • &block:需要存储、传递、延迟调用

Q: Lambda 和 Proc.new 到底用哪个?

A: 默认用 lambda。它的行为更可预测——参数检查和局部 return 符合直觉。Proc.new 的特殊行为只在少数场景需要(如 DSL 中需要 return 跳出外部方法)。

Q: 块和闭包是一回事吗?

A: Ruby 的块都是闭包。它们捕获定义时的上下文(外部变量),在调用时仍可访问。

小结

核心要点

  1. Block 是 Ruby 的灵魂:yield 调用,方法不需要显式声明
  2. Proc 让块变成对象:可存储、传递、延迟调用
  3. Lambda 是严格的 Proc:参数检查、局部 return
  4. Proc.new return 跳出外部方法——小心使用
  5. Symbol#to_proc 是惯用速记:upcase 自动转 { |x| x.upcase }
  6. 块天然关闭:捕获定义时的变量环境
  7. 回调模式是 Ruby 的 API 设计精髓:API 框架的基石

术语

  • Block(块):方法隐式传递的代码段
  • Proc:块的对象化
  • Lambda:严格的 Proc(参数检查、局部 return)
  • Closure(闭包):捕获定义时上下文的代码块
  • Yield(产出):方法内部调用传入的块
  • Currying(柯里化):多参数函数转为单参数函数链
  • Arity(元数):方法需要的参数数量
  • to_proc:转为 Proc 对象的协议

继续学习

运行 hello basic blocks-procs 查看完整示例代码。

文件 I/O

开篇故事

文件 I/O 是每个实用程序都需要的功能。Ruby 提供了一套简洁的文件操作 API——File.openDir.globPathnameCSV——让你用几行代码就能完成文件的读写、匹配和解析。

本章适合谁

如果你需要读取配置文件、解析 CSV 数据、批量处理文件,本章涵盖了 Ruby 标准库中最常用的文件操作。

你会学到什么

  1. File.writeFile.read
  2. File.open 的块模式(自动关闭文件)
  3. Pathname — 面向对象的文件路径
  4. Dir.glob — 文件模式匹配
  5. CSV 读写
  6. IO.foreach — 逐行读取

前置要求

第一个例子

# 运行: hello basic file-io

require "tempfile"

tmp = Tempfile.new("hello_demo")
content = "第一行\n第二行\n第三行\n"
bytes_written = File.write(tmp.path, content)
puts "写入 #{bytes_written} 字节"

read_content = File.read(tmp.path)
puts "读取内容: #{read_content}"

为什么这很方便:Ruby 的文件 API 非常直观。File.write 一行写完,File.read 一行读完。

File.write 和 File.read

# 写入文件
File.write("output.txt", "Hello, World!\n")

# 读取整个文件
content = File.read("output.txt")
puts content

File.write 返回写入的字节数。File.read 返回完整字符串。

File.open 块模式

# 块形式自动关闭文件,即使块内抛异常
File.open("output.txt", "a") do |f|
  f.puts "追加一行"
  f.puts "又追加一行"
end
# 退出块时自动 f.close

打开模式

模式含义
"r"只读(默认)
"w"写入(清空已有内容)
"a"追加(在尾部添加)
"r+"读写

块模式是 Ruby 惯例File.open 如果传块,块执行完毕后自动关闭文件,即使块内抛异常。

Pathname — 面向对象路径操作

require "pathname"

path = Pathname.new("/usr/local/bin/ruby")

puts "basename: #{path.basename}"    # "ruby"
puts "dirname:  #{path.dirname}"     # "/usr/local/bin"
puts "extname:  #{path.extname}"     # ""(无扩展名)
puts "存在?#{path.exist?}"
puts "可读?#{path.readable?}"
puts "大小: #{path.size} 字节"

Pathname/ 运算符合并路径:

project_root = Pathname.new(".")
lib_path = project_root / "lib" / "hello.rb"
# → lib/hello.rb

Dir.glob — 文件模式匹配

# 查找当前目录的所有 .rb 文件
rb_files = Dir.glob("*.rb")

# 递归查找
all_rb = Dir.glob("**/*.rb")

# 匹配多种扩展名
all_docs = Dir.glob("**/*.{rb,md}")

# Pathname 也有 glob
Pathname.glob("docs/src/**/*.md")

glob 模式

  • * — 匹配单个目录层级
  • ** — 递归匹配所有层级
  • {a,b} — 匹配 a 或 b

IO.foreach — 逐行读取

File.write("large.txt", "line1\nline2\nline3\n")

line_count = 0
IO.foreach("large.txt") do |line|
  line_count += 1
  puts "第 #{line_count} 行: #{line.chomp}"
end

IO.foreach 逐行读取,不把所有内容加载到内存。适合大文件

CSV 读写

require "csv"

csv_content = <<~CSV
  name,age,city
  Alice,30,Shanghai
  Bob,25,Beijing
  Charlie,35,Shenzhen
CSV

File.write("data.csv", csv_content)

# 逐行读取
CSV.foreach("data.csv", headers: true) do |row|
  puts "#{row["name"]} (#{row["age"]}岁), 来自 #{row["city"]}"
end

# 读取全部
all_rows = CSV.read("data.csv", headers: true)
puts "共 #{all_rows.length} 行"

常见错误

错误 1:忘记关闭文件

f = File.open("data.txt")
content = f.read
# 忘记 f.close 导致文件描述符泄漏

修复:用块模式自动关闭。

错误 2:用 File.read 处理大文件

# ❌ 1GB 文件会把 1GB 数据加载到内存
content = File.read("huge_log.txt")

# ✅ 逐行处理(内存友好)
IO.foreach("huge_log.txt") do |line|
  process(line)
end

错误 3:路径拼接用字符串拼接

# ❌ 容易出错
path = "/tmp/" + filename  # 可能变成 "//tmp//file.txt"

# ✅ 用 Pathname 或 File.join
path = Pathname.new("/tmp") / filename
path = File.join("/tmp", filename)

动手练习

练习 1:统计文件行数

# 写一个方法 count_lines(filename)
# 返回文件的行数
参考答案
def count_lines(filename)
  IO.foreach(filename).count
end

练习 2:查找大文件

# 查找当前目录下所有超过 1MB 的文件
参考答案
Dir.glob("**/*").select do |f|
  File.file?(f) && File.size(f) > 1_000_000
end

故障排查 (FAQ)

Q: File.read 和 IO.foreach 的区别?

A: File.read 一次全部读入内存;IO.foreach 逐行读取。小文件用 read,大文件用 foreach

Q: Pathname 和 File 的区别?

A: File 是模块方法(函数式),Pathname 是面向对象。Pathname 更优雅(支持 / 操作符),File 更直接。两者都用,Pathname 在路径操作中更清晰。

Q: 怎么判断路径是文件还是目录?

A: 用 File.file?File.directory?(或 FileTest 模块)。

小结

核心要点

  1. File.write/read 最简单:一行搞定
  2. File.open 用块模式:自动关闭文件,安全
  3. Pathname 是 OOP 路径操作:支持 / 运算符
  4. Dir.glob 查找文件**/*.rb 递归匹配
  5. IO.foreach 处理大文件:逐行读取,内存友好
  6. CSV 是标准库:headers: true 使用行头

术语

  • File Handle(文件句柄):打开文件的引用
  • Stream(流):文件内容的序列
  • Buffer(缓冲区):内存临时存储区
  • Descriptor(描述符):操作系统级别的文件标识

继续学习

运行 hello basic file-io 查看完整示例代码。

异常处理

开篇故事

程序运行的过程就是不断出错和恢复的过程。文件找不到、网络超时、除数为零、格式不对——异常无处不在。Ruby 的异常处理不是用来掩盖错误的,而是让你在错误发生时优雅地恢复,或者把错误信息传递清楚。

本章适合谁

如果你想写出健壮的程序,能够妥善处理意外情况,而不是在遇到错误时直接崩溃,本章是必读内容。

你会学到什么

  1. begin / rescue / ensure 结构
  2. 捕获多种异常类型
  3. retry — 重新执行
  4. raise — 主动抛出异常
  5. rescue 修饰符
  6. else 块
  7. 异常层级

前置要求

第一个例子

# 运行: hello basic exceptions

begin
  10 / 0
rescue ZeroDivisionError => e
  puts "捕获: #{e.class} — #{e.message}"
end
# 输出: 捕获: ZeroDivisionError — divided by 0

和 Java 的 try/catch 对比:Ruby 的 begin/rescue 和 Java 的 try/catch 概念相似,但 Ruby 的 rescue 更简洁——不需要包裹整个方法,可以只包裹可能出错的行。

begin / rescue / ensure

result = begin
  raise "出错了!"
ensure
  puts "ensure 块执行(用于清理资源)"
end

puts "最终结果: #{result.inspect}"
# ensure 块不管有没有异常都会执行

捕获多种异常类型

begin
  # 可能产生不同异常的代码
  Exceptions.divide(10, 0)
rescue Hello::ValidationError => e
  puts "自定义异常: #{e.message}"
rescue StandardError => e
  puts "其他错误: #{e.message}"
end

ensure — 总是执行

f = File.open("data.txt")
begin
  content = f.read
  process(content)
ensure
  f.close  # 不管有没有异常都会关闭文件
end

更 Ruby 的写法:用 File.open 的块模式,它内部有 ensure。

rescue 修饰符(单行)

value = undefined_variable rescue "变量不存在,用默认值"
safe = Integer("not_a_number") rescue 0

何时使用:处理简单、可预期的异常。不适用于需要详细错误信息的场景。

retry — 重新执行

attempts = 0
max_retries = 3

begin
  attempts += 1
  puts "第 #{attempts} 次尝试..."
  raise "模拟失败" unless attempts >= max_retries
  puts "成功!"
rescue
  retry if attempts < max_retries
end

警告retry 会重新执行整个 begin 块。如果逻辑复杂,建议用循环代替。

else 块

begin
  puts "正常代码"
rescue
  puts "有异常"
else
  puts "else:没有异常,执行这里"
ensure
  puts "ensure:总是执行"
end

else 块在没有异常时执行,rescue 之后、ensure 之前。

主动抛出异常

def divide(a, b)
  raise Hello::ValidationError, "除数不能为零" if b == 0
  a / b
end

异常层级

Exception
├── NoMemoryError
├── ScriptError
│   ├── SyntaxError
│   └── LoadError
├── SecurityError
├── SignalException
└── StandardError  ← rescue 默认捕获这里
    ├── ArgumentError
    ├── IOError
    ├── IndexError
    ├── KeyError
    ├── NoMethodError
    ├── RuntimeError
    ├── TypeError
    └── ZeroDivisionError

关键理解rescue 默认捕获 StandardError 及其子类。如果不加类型,只捕获 StandardError。要捕获所有异常,用 rescue Exception(通常不推荐)。

常见错误

错误 1:bare rescue — 不带类型

# ❌ 这会捕获 StandardError 及其所有子类,包括 SystemExit!
begin
  do_something
rescue
  puts "出错了"
end

# ✅ 明确指定异常类型
begin
  do_something
rescue StandardError => e
  puts "出错了: #{e.message}"
end

错误 2:忽略异常信息

# ❌ 吞掉错误,调试时毫无头绪
begin
  do_something
rescue
  nil  # 错误被静默吃掉了
end

# ✅ 至少记录或传递
begin
  do_something
rescue => e
  logger.error "do_something failed: #{e.message}"
  raise  # 重新抛出
end

错误 3:用异常做流程控制

# ❌ 异常不是用来做条件判断的
begin
  user = User.find(id)
rescue ActiveRecord::RecordNotFound
  user = nil
end

# ✅ 用条件判断
user = User.find_by(id: id)

动手练习

练习 1:安全解析

写一个 safe_parse_int(str) 方法,解析失败时返回 nil 而不是抛异常。

参考答案
def safe_parse_int(str)
  Integer(str)
rescue ArgumentError, TypeError
  nil
end

练习 2:自定义异常

class InsufficientFundsError < StandardError
  attr_reader :balance, :amount

  def initialize(balance, amount)
    @balance = balance
    @amount = amount
    super("余额 #{balance} 不足,需要 #{amount}")
  end
end

# 测试
begin
  balance = 100
  amount = 200
  raise InsufficientFundsError.new(balance, amount) if amount > balance
rescue InsufficientFundsError => e
  puts e.message
end
运行结果
余额 100 不足,需要 200

故障排查 (FAQ)

Q: rescue 不指定异常类型时会捕获什么?

A: StandardError 及其子类。不会捕获 SyntaxErrorLoadError 等。大多数运行时错误都是 StandardError 的子类。

Q: rescue => erescue StandardError => e 一样吗?

A: 不完全一样。前者的默认类型取决于所在上下文(方法体内的默认类型)。在方法体内,rescue => e 等价于 rescue StandardError => e

Q: retry 和循环有什么区别?

A: retry 重新执行整个 begin 块,包括块内的所有初始化。循环只在循环体内部。如果逻辑简单,用循环;如果需要重新初始化,用 retry。

小结

核心要点

  1. begin/rescue/ensure 是基础结构:像 Java 的 try/catch/finally
  2. 明确指定异常类型:不要用 bare rescue
  3. ensure 总是执行:用于清理资源
  4. rescue 修饰符用于简单场景value = risky() rescue default
  5. raise 主动抛出:自定义异常类继承 StandardError
  6. rescue 默认捕获 StandardError:不是 Exception
  7. 异常不是流程控制工具:能用条件判断时不要用异常

术语

  • Exception(异常):程序运行时的错误事件
  • Catch(捕获):使用 rescue 处理异常
  • Raise(抛出):主动触发异常
  • Ensure(确保):无论是否有异常都执行的代码
  • Retry(重试):重新执行 begin 块
  • Bare Rescue(裸 rescue):不指定异常类型的 rescue

继续学习

运行 hello basic exceptions 查看完整示例代码。

数字与数值运算

开篇故事

数字是编程的基本材料。Ruby 处理数字的方式与其他语言不同——整数没有大小限制、浮点数遵循 IEEE 754、金融计算用 BigDecimal。了解 Ruby 数字的特性,能避免精度错误和性能陷阱。

本章适合谁

如果你的程序涉及数值计算、财务数据、或需要处理大范围数字,本章帮你理解 Ruby 数字系统的完整图景。

你会学到什么

  1. Integer 的任意精度
  2. Float 精度问题
  3. BigDecimal 用于金融计算
  4. Rational 精确分数
  5. 进制转换和位运算
  6. 浮点数舍入
  7. 数字格式化输出

前置要求

第一个例子

# 运行: hello basic numbers

# Ruby 整数没有大小限制
small_int = 42
huge_int = 2**100
puts "小整数: #{small_int}"
puts "超大整数: #{huge_int}"
# → 1267650600228229401496703205376

为什么这很强大:Ruby 3 中 Fixnum 和 Bignum 已统一为 Integer,不再区分"小整数"和"大整数"。内部自动管理,你不需要关心。

整数类型

small = 42
big = 999_999_999_999_999_999   # 用下划线分隔更易读
huge = 2**100                   # 自动处理超大整数

puts small.class  # Integer
puts big.class    # Integer(不是 Bignum!)
puts huge.class   # Integer

# 任意精度意味着没有溢出
puts huge + 1  # 1267650600228229401496703205377

Ruby 2 vs Ruby 3:Ruby 2 有 Fixnum 和 Bignum 两个类(自动切换)。Ruby 3 统一为 Integer。你不需要知道这个区别,但看老代码时可能会遇到。

浮点数

Float 遵循 IEEE 754 双精度标准,有效精度约 15-17 位十进制数。

pi = 3.14159265358979
tiny = 1.0e-10        # 科学计数法
huge = 1.5e20

puts pi.class     # Float
puts Float::DIG       # 15(有效精度位数)
puts Float::EPSILON   # 2.220446049250313e-16

浮点精度问题

puts 0.1 + 0.2      # 0.30000000000000004(不是 0.3!)
puts (0.1 + 0.2).round(10) == 0.3  # true

为什么会这样:二进制无法精确表示 0.1。这是所有语言都有的问题,不是 Ruby 独有的。

BigDecimal — 金融级精确计算

require "bigdecimal"
require "bigdecimal/util"

# 精确的小数运算
exact = BigDecimal("0.1") + BigDecimal("0.2")
puts exact  # 0.3e0(精确的 0.3)

# 金额计算
price = BigDecimal("99.99")
tax = BigDecimal("0.13")
total = price * (1 + tax)
puts "¥#{price} × 1.13 = ¥#{total.round(2)}"
# → ¥99.99 × 1.13 = ¥112.99

什么时候用 BigDecimal:任何涉及金钱的计算。浮点数的精度问题会导致 0.01 的误差,在财务系统中这是 unacceptable 的。

算术运算符

a = 17
b = 5

puts "#{a} + #{b} = #{a + b}"       # 22
puts "#{a} - #{b} = #{a - b}"       # 12
puts "#{a} * #{b} = #{a * b}"       # 85
puts "#{a} / #{b} = #{a / b}"       # 3(整数除法!)
puts "#{a}.to_f / #{b} = #{a.to_f / b}"  # 3.4(浮点除法)
puts "#{a} % #{b} = #{a % b}"       # 2(取模)
puts "#{a} ** #{b} = #{a ** b}"     # 1419857(幂)
puts "#{a}.divmod(#{b}) = #{a.divmod(b)}"  # [3, 2](商和余数)

注意整数除法17 / 5 在 Ruby 中等于 3(截断),不是 3.4。用 to_f 转换或直接把除数写为浮点数:17 / 5.0

整数方法

n = 0xFF  # 255

puts "#{n}.to_s(16) = #{n.to_s(16)}"  # ff(十六进制)
puts "#{n}.to_s(2)  = #{n.to_s(2)}"   # 11111111(二进制)
puts "#{n}.to_s(8)  = #{n.to_s(8)}"   # 377(八进制)
puts "#{n}.bit_length = #{n.bit_length}"  # 8(二进制位数)
puts "#{n}.even?    = #{n.even?}"      # false
puts "#{n}.odd?     = #{n.odd?}"       # true

# Ruby 提供了奇偶判断
puts 42.even?   # true
puts 43.odd?    # true

浮点数舍入

f = 3.7
nf = -3.7

puts "#{f}.ceil     = #{f.ceil}"      # 4(向上)
puts "#{f}.floor    = #{f.floor}"     # 3(向下)
puts "#{f}.round    = #{f.round}"     # 4(四舍五入)
puts "#{f}.truncate = #{f.truncate}"  # 3(向零)

puts "#{nf}.ceil     = #{nf.ceil}"    # -3
puts "#{nf}.floor    = #{nf.floor}"   # -4
puts "#{nf}.truncate = #{nf.truncate}"# -3

注意负数的区别

  • ceil(向上):-3.7 → -3
  • floor(向下):-3.7 → -4
  • truncate(向零):-3.7 → -3

Rational 有理数

r1 = Rational(1, 3)
r2 = Rational(2, 5)

puts r1               # (1/3)
puts r2 + r1          # (11/15)
puts r1 * 3           # (1/1)(精确等于 1)
puts Rational(0.5)    # (1/2)

有理数的意义:分数运算不会丢失精度。1/3 在浮点数中是 0.3333... 的近似值,Rational 能精确表示。

数字格式化

v = 42.123456789
puts sprintf("%.2f", v)       # "42.12"
puts sprintf("%05d", 42)      # "00042"
puts sprintf("%x", 255)       # "ff"
puts sprintf("%08b", 42)      # "00101010"
puts sprintf("%e", 1234567)   # "1.234567e+06"

# % 运算符
puts '%.3f' % 3.14159    # "3.142"
puts '%010d' % 42        # "0000000042"

特殊值

puts Float::MAX           # 最大浮点数
puts Float::INFINITY      # Infinity
puts Float::NAN           # NaN

puts 1.0 / 0.0            # Infinity
puts 0.0 / 0.0            # NaN
puts Float::NAN.nan?      # true
puts Float::INFINITY.infinite?  # 1
puts 1.0.finite?          # true

常见错误

错误 1:浮点数直接比较

# ❌ 不可靠
if 0.1 + 0.2 == 0.3
  puts "相等"
end

# ✅ 用 epsilon 容差
if (0.1 + 0.2 - 0.3).abs < 1e-10
  puts "近似相等"
end

错误 2:用 Float 做金额计算

# ❌ 精度丢失
total = 99.99 * 0.13
# → 13.099999999999998(不是精确的 13.0999...999...)

# ✅ 用 BigDecimal
total = BigDecimal("52.99") * BigDecimal("0.13")

错误 3:整数除法陷阱

# ❌ 17 / 5 = 3,不是 3.4
result = 17 / 5
puts result  # 3

# ✅ 转浮点数
result = 17.to_f / 5
puts result  # 3.4

动手练习

练习 1:温度转换

# 写一个 celsius_to_fahrenheit(c) 方法
# 公式: F = C * 9/5 + 32
# 用 Rational 保持精度
参考答案
def celsius_to_fahrenheit(c)
  c * Rational(9, 5) + 32
end

puts celsius_to_fahrenheit(0)   # 212
puts celsius_to_fahrenheit(100) # 212(浮点 212.0)

练习 2:计算斐波那契

# 计算前 20 个斐波那契数
# 提示:递归或迭代
参考答案
fib = [0, 1]
18.times do
  fib << fib[-1] + fib[-2]
end
puts fib.inspect

故障排查 (FAQ)

Q: 什么时候用 Integer,什么时候用 Float?

A: 精确计算用 Integer(计数、索引、ID),近似计算用 Float(物理量、科学计算),金融计算用 BigDecimal。

Q: Float 精度不够怎么办?

A: 用 BigDecimal。BigDecimal 支持任意精度(但速度较慢)。

Q: 怎么把字符串转为数字?

A:

  • "42".to_i → 42
  • "3.14".to_f → 3.14
  • Integer("42") → 42(失败抛异常)
  • Float("3.14") → 3.14(失败抛异常)

to_ito_f 失败时返回 0(宽松),Integer()Float() 失败时抛异常(严格)。

小结

核心要点

  1. Integer 任意精度:没有大小限制,不会溢出
  2. Float 有精度问题:0.1 + 0.2 ≠ 0.3
  3. 金融计算用 BigDecimal:避免浮点精度丢失
  4. Rational 精确分数:不丢失精度
  5. 整数除法会被截断:17/5 = 3,不是 3.4
  6. 格式化用 sprintf:控制输出精度

术语

  • Arbitrary Precision(任意精度):不受固定位数限制
  • IEEE 754:浮点数标准
  • Epsilon(ε):最小可表示差值
  • Rational Number(有理数):分数形式
  • Float(浮点数):有限精度的小数
  • BigDecimal:高精度小数库

继续学习

运行 hello basic numbers 查看完整示例代码。

符号(Symbol)

开篇故事

Symbol 是 Ruby 独有的类型。它看起来像字符串,但本质上是"内部化的字符串"——每个唯一的符号名只有一个对象实例,无论在哪里使用。这个设计让符号成为哈希键、方法名、状态标识的最佳选择。

本章适合谁

如果你在 Ruby 代码中反复看到 :name:status,想知道为什么不是字符串 "name",本章会给你完整的答案。

你会学到什么

  1. 符号的 object_id 唯一性
  2. 字符串 vs 符号的区别
  3. 创建符号的多种方式
  4. 符号作为哈希键
  5. 新旧哈希语法对比
  6. 内存影响与最佳实践

前置要求

第一个例子

# 运行: hello basic symbols

# 同一个符号的 object_id 永远相同
puts :foo.object_id  # 第1次: 4170742
puts :foo.object_id  # 第2次: 4170742
puts "同一个符号的 object_id 永远相同: #{:foo.object_id == :foo.object_id}"
# → true

为什么这很重要:每次 :foo 都指向同一个对象。而字符串 "hello" 每次都是新对象。

s1 = "hello"
s2 = "hello"
puts "字符串 object_id (s1): #{s1.object_id}"  # 80
puts "字符串 object_id (s2): #{s2.object_id}"  # 80(但不同)
puts "同样内容的字符串 object_id 不同: #{s1.object_id == s2.object_id}"
# → false(每个 "hello" 都是新对象)

创建符号的方式

# 方式1: 冒号前缀(最常见)
method_name = :length
# 方式2: 字符串转符号
dynamic = "user".to_sym

# 方式3: 动态创建(允许非标准命名)
key = "access"
dynamic_symbol = :"#{key}_token"  # :access_token

# 方式4: %i[] 批量创建
%w[foo bar baz].each { |sym| puts "  #{sym.inspect} (#{sym.class})" }
# → :foo (Symbol), :bar (Symbol), :baz (Symbol)

相等性比较

str = "foo"
sym = :foo

puts ":foo == :foo (符号): #{:foo == :foo}"           # true
puts '"foo" == "foo" (字符串): #{"foo" == "foo"}'            # true
puts ':foo == "foo" (跨类型 ==): #{sym == str}'      # true(符号 == 字符串)
puts ':foo.eql?(:foo): #{:foo.eql?(:foo)}'           # true
puts ':foo.eql?("foo"): #{:foo.eql?(str)}'      # false(eql? 比类型)
puts ':foo.equal?(:foo): # {:foo.equal?(:foo)}'  # true(同一对象)
比较方式含义
==内容相等(可跨类型)
eql?内容 + 类型相等
equal?同一内存地址

符号作为哈希键

这是符号最主要的用途。

user = { name: "Alice", age: 30, role: "admin" }

puts user[:name]           # "Alice"
puts user.fetch(:age)      # 30
puts user.fetch(:email, "N/A")  # "N/A"
puts user.key?(:role)      # true
puts user.key?(:missing)   # false

符号键 vs 字符串键(它们不互通!)

mixed = { :name => "Alice", "name" => "Bob" }
puts mixed[:name]    # "Alice"(符号键)
puts mixed["name"]   # "Bob"(字符串键)

新旧哈希语法

# 旧式(火箭语法)—— 仍然有效
old_style = { :name => "Alice", :age => 30 }

# 新式(JSON 风格)—— 推荐
new_style = { name: "Alice", age: 30 }

puts "两者等价: #{old_style == new_style}"  # true

新式语法更简洁,是 Ruby 1.9+ 的惯例。

freeze vs Symbol

frozen_str = "immutable".freeze
symbol = :immutable

puts "frozen_str.object_id: #{frozen_str.object_id}"
puts ":immutable.object_id: #{symbol.object_id}"
puts "frozen_str.frozen?: #{frozen_str.frozen?}"
puts ":immutable.frozen?: #{symbol.frozen?}"

区别:冻结字符串不能修改,但每次都是新对象。符号天然冻结且全局唯一。

Symbol.all_symbols 与内存

count_before = Symbol.all_symbols.size
puts "当前符号总数: #{count_before}"

100.times do |i|
  :"dynamic_#{i}"
end

count_after = Symbol.all_symbols.size
puts "增加 100 个动态符号后总数: #{count_after}"
puts "增长: #{count_after - count_before}"

警告:Symbol 不会被垃圾回收(Ruby 3.2 之前)。大量动态创建 Symbol 会导致内存泄漏。

Ruby 3.2+ 增加了符号垃圾回收,但最佳实践仍然是:只在已知键名时使用 Symbol。运行时接收的外部输入应使用 String。

使用建议

使用 Symbol: 哈希键、方法名、枚举值
  :user_status     ✅ 哈希键(惯例)
  :each            ✅ 方法名引用(&:each)
  :red             ✅ 枚举值

使用 String: 外部输入、用户数据、动态内容
  params[:name]    ✅ 获取值(符号键)
  params.fetch("name") ✅ Web 框架中常见

经验法则:内部标识符用 Symbol,外部数据用 String。

常见错误

错误 1:动态创建 Symbol 导致内存泄漏

# ❌ 不要用用户输入创建符号
user_input = params[:search]
:"dynamic_#{user_input}"  # 每次搜索创建一个 Symbol,内存增长

修复:用字符串代替。

错误 2:符号键和字符串键混用

# 前端传来字符串键
data = { "name" => "Alice", "email" => "alice@example.com" }

# ❌ 用符号键取不到
puts data[:name]  # nil

# ✅ 用字符串键
puts data["name"]  # "Alice"

# ✅ 或者标准化
data.transform_keys(&:to_sym)

错误 3:过度使用 Symbol

# ❌ 把长文本当 Symbol
:"这是一段很长的用户输入文本"  # 永远不会被 GC 回收

修复:短标识符用 Symbol,长文本用 String。

动手练习

练习 1:标准化哈希键

# 写一个符号键和字符串键统一为符号键
mixed = { "name" => "Alice", age: 30, "email" => "alice@example.com" }
# → { name: "Alice", age: 30, email: "alice@example.com" }
参考答案
mixed.transform_keys(&:to_sym)

练习 2:对比 Symbol vs Frozen String

# 证明 Symbol 和 frozen string 不是同一对象
s = "hello".freeze
sym = :hello
puts s.object_id == sym.object_id  # → false
参考答案
s = "hello".freeze
sym = :hello
puts s.object_id == sym.object_id  # false

故障排查 (FAQ)

Q: Ruby 3.2 之后 Symbol 还会内存泄漏吗?

A: Ruby 3.2 新增了 Symbol 垃圾回收(只在内存压力时回收)。但动态创建 Symbol 仍然可能占用内存,最佳实践仍然是:已知标识符用 Symbol,运行时输入用 String。

Q: 哈希键用 Symbol 还是 String 效率高?

A: Symbol 略快(因为 object_id 比较即可),但 Ruby 的哈希查找已相当优化。两者的性能差异在绝大多数场景可以忽略,应优先考虑代码可读性。

小结

核心要点

  1. Symbol object_id 全局唯一:name 不管用多少次都是同一个对象
  2. String 每次都是新对象"name" 每次 new 都有不同 object_id
  3. 符号键是哈希惯例{ name: "Alice" }
  4. 新式哈希语法更简洁{ name: ... } 替代 { :name => ... }
  5. 内部标识符用 Symbol,外部数据用 String:经验法则
  6. 避免动态创建 Symbol:防止内存泄漏

术语

  • Symbol(符号):全局唯一的内部字符串
  • Object ID(对象 ID):Ruby 中每个对象的唯一标识
  • Interning(内部化):相同内容的对象只存一份
  • Frozen(冻结):不可变的对象
  • Symbol GC:符号垃圾回收

继续学习

运行 hello basic symbols 查看完整示例代码.

正则表达式

开篇故事

正则表达式是文本处理的瑞士军刀。它能在一行代码中提取邮箱、验证手机、解析 URL、替换格式——这些事用字符串方法要写十几行。Ruby 的正则表达式语法强大、表达力强,是值得投资的技能。

本章适合谁

如果你需要处理文本模式匹配(验证表单、解析日志、提取数据),本章教你 Ruby 正则表达式的完整使用方法。

你会学到什么

  1. 创建正则表达式的三种方式
  2. 修饰符 /i/m/x
  3. 匹配操作 =~match
  4. 捕获组(位置组和命名组)
  5. 贪婪 vs 惰性
  6. 字符串方法配合正则(gsub/scan/split
  7. 实用示例

前置要求

第一个例子

# 运行: hello basic regex

text = "我的电话是 138-1234-5678"

# =~ 返回匹配的起始索引
index = text =~ /\d{3}-\d{4}-\d{4}/
puts "=~ 返回索引: #{index}"  # 5

# match 返回 MatchData 对象
match = text.match(/(\d{3})-(\d{4})-(\d{4})/)
puts "完整匹配: #{match[0]}"  # 138-1234-5678
puts "第1组: #{match[1]}"     # 138
puts "第2组: #{match[2]}"     # 1234
puts "第3组: #{match[3]}"     # 5678

为什么用正则:如果用字符串方法,你需要写循环、判断、截取。一行正则搞定。

创建正则表达式

# 字面量斜杠(最常用)
pattern_slash = /hello/

# %r{} — 适合包含斜杠的正则(如 URL)
pattern_r = %r{https?://[\w.]+}

# Regexp.new — 适合动态构建正则
dynamic = Regexp.new("\d{3}-\d{4}")

修饰符

# /i — 忽略大小写
insensitive = /hello/i
puts "忽略大小写: #{insensitive.match?("Hello")}"  # true

# /m — 多行模式(. 也匹配换行符)
multiline = /a.*b/m
puts "多行模式: #{multiline.match?("a\nb")}"  # true

# /x — 忽略空白,允许注释(适合复杂正则)
verbose = /
  \d +    # 一位或多位数字
  -       # 连字符
  \d +    # 一位或多位数字
/x
puts "verbose 模式: #{verbose.match?("123-456")}"  # true

匹配操作

text = "我的电话是 138-1234-5678"

# =~ 返回索引或 nil
index = text =~ /\d{3}-\d{4}-\d{4}/
puts "=~ 返回索引: #{index}"  # 5

# !~ 不匹配返回 true/false
puts "不匹配: #{(text !~ /xyz/)}"  # true

# match 返回 MatchData
match_data = text.match(/(\d{3})-(\d{4})-(\d{4})/)
puts "完整匹配: #{match_data[0]}"
puts "第1组: #{match_data[1]}"

# match? 只判断是否匹配(更快)
puts "包含数字?: #{text.match?(/\d+/)}"  # true

常用元字符

sample = "abc123 XYZ def45"

puts "原始字符串: '#{sample}'"

# 字符类
puts "\d (数字): #{sample.scan(/\d/).inspect}"      # ["1", "2", "3", "4", "5"]
puts "\w  (单词字符): #{sample.scan(/\w/).inspect}"  # 包括字母数字下划线
puts "\s  (空白符): #{sample.scan(/\s/).inspect}"    # [" ", " "]
puts "\D (非数字): #{sample.scan(/\D/).inspect}"     # 非数字
puts "\W (非单词): #{sample.scan(/\W/).inspect}"     # 非单词字符

量词

puts "+ (1次或多次): #{sample.scan(/\d+/).inspect}"     # ["123", "45"]
puts "* (0次或多次): #{sample.scan(/\d*/).inspect}"       # ["123", "45", "", "", "", ""]
puts "? (0次或1次): #{sample.scan(/\w\w?\d?/).inspect}"     # 允许0次
puts "{2,3} (2到3次): #{sample.scan(/\w{2,3}/).inspect}"  # 范围
puts "{3} (恰好3次): #{sample.scan(/\d{3}/).inspect}"    # 精确次数

捕获组

位置捕获组 ( )

date = "2025-12-31"
date_match = date.match(/(\d{4})-(\d{2})-(\d{2})/)
puts "年: #{date_match[1]}"  # 2025
puts "月: #{date_match[2]}"  # 12
puts "日: #{date_match[3]}"  # 31

命名捕获组 (?<name>pattern)

named_match = date.match(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/)
puts "year: #{named_match[:year]}"    # 2025
puts "month: #{named_match[:month]}"  # 12
puts "day: #{named_match[:day]}"      # 31

非捕获组 (?:pattern)

# 分组但不捕获
non_capturing = "2025-12-31".match(/(?:\d{4})-(\d{2})-(\d{2})/)
puts "捕获组数量: #{non_capturing.captures.length}"  # 2(不是3)

贪婪 vs 惰性

greedy = "<div>hello</div>".match(/<.*>/)    # 匹配: `<div>hello</div>`
lazy = "<div>hello</div>".match(/<.*?>/)   # 匹配: `<div>`
puts "贪婪 .*: #{greedy[0]}"
puts "惰性 .*?: #{lazy[0]}"

字符串方法配合正则

sentence = "Hello World Hello Ruby Hello World"

# sub — 单次替换
puts "sub: #{sentence.sub(/Hello/, "Hi")}"
# → "Hi World Hello Ruby Hello World"

# gsub — 全局替换
puts "gsub: #{sentence.gsub(/Hello/, "Hi")}"
# → "Hi World Hi Ruby Hi World"

# split — 用正则分割
csv = "apple,banana;cherry:dragon"
puts "split: #{csv.split(/[,;:]/).inspect}"
# → ["apple", "banana", "cherry", "dragon"]

# scan — 查找所有匹配
numbers = "a1b22c333d4444e5555".scan(/\d+/)
puts "scan: #{numbers.inspect}"
# → ["1", "22", "333", "4444"]

# grep — 数组匹配
words = %w{apple banana cherry date elderberry}
puts "grep: #{words.grep(/e$/).inspect}"
# → ["apple", "cherry", "date", "elderberry"]

特殊变量

匹配后 Ruby 设置了一些全局变量:

"hello 123 world" =~ /(\d+)/

puts "$1 (最后匹配的第1组): #{$1}"    # "123"
puts "$& (最后完整匹配): #{$&}"      # "123"
puts "$~ (MatchData): #{$~.class}"    # MatchData
puts "$` (匹配前): #{$`}"            # "hello "
puts "$' (匹配后): #{$'}"            # " world"

Regexp.escape — 转义特殊字符

raw = "Price: $19.99 (50% off)"
escaped = Regexp.escape(raw)
puts "转义: #{escaped}"
# Price: \$19\.99 \(50% off\)

实用示例

邮箱验证

email_pattern = /\A[\w.+-]+@[\w-]+\.[\w.]+\z/
emails = [
  "user@example.com",
  "invalid@",
  "good+tag@domain.co.uk"
]

emails.each do |email|
  valid = email =~ email_pattern ? "✓" : "✗"
  puts "  #{valid} #{email}"
end

解析 URL

url_pattern = %r{(?<protocol>https?)//(?<host>[\w.:-]+)(?<path>/[\w/.-]*)?(?:\?(?<query>[\w=&-]+))?(?:#(?<fragment>\w+))?}

url = "http://example.com:8080/path/to/page?query=1#anchor"
m = url.match(url_pattern)
puts "协议: #{m[:protocol]}"      # http
puts "主机: #{m[:host]}"          # example.com:8080
puts "路径: #{m[:path]}"          # /path/to/page
puts "查询: #{m[:query]}"         # query=1
puts "锚点: #{m[:fragment]}"      # anchor

常见错误

错误 1:忘记锚点

# 只匹配包含,不匹配完整
"hello world".match?(/hello/)  # true
"hello world".match?(/\Ahello\z/)  # false(多了 world)

修复:用 \A\z 限制完整匹配。

错误 2:贪婪匹配

"<div>hello</div>".match(/<.*>/)[0]
# → "<div>hello</div>"(贪婪匹配整个)

"<div>hello</div>".match(/<.*?>/)  # → "<div>"(惰性,只匹配第一个标签)

错误 3:用正则解析 HTML

# ❌ 正则不适合解析 HTML
# HTML 结构复杂,嵌套、属性顺序、实体字符等
# 应使用 Nokogiri 等 HTML 解析器

动手练习

练习 1:提取中文数字

# 从文本提取所有连续的中文数字
# "今天买了三斤苹果,花了二十元" → ["三", "二十"]
参考答案
text = "今天买了三斤苹果,花了二十元"
text.scan(/[\u4e00-\u9fff]+/)
# 需要更复杂的逻辑处理中文数字

练习 2:驼峰转蛇形

# "helloWorld" → "hello_world"
# "XMLHttpRequest" → "xml_http_request"
参考答案
def camel_to_snake(str)
  str.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '')
end

故障排除 (FAQ)

Q: 为什么 \d 在 Ruby 里和 Python 不一样?

A: Ruby 的 \d 默认只匹配 ASCII 数字(0-9)。如需匹配 Unicode 数字,用 \p{Nd}

Q: 匹配特殊字符如 // 需要转义吗?

A: 需要。/ 是正则的边界字符,所以 / 需要写 \/。或者用 %r{} 代替:%r{https?://}

Q: /i /m /x 可以一起用吗?

A: 可以:/pattern/imx

小结

核心要点

  1. 字面量 /pattern/ 最常用
  2. =~ 返回索引 / match 返回 MatchData / match? 返回布尔
  3. 捕获组 (...) 和命名组 (?<name>pattern)
  4. 量词区分贪婪 vs 惰性
  5. gsub/scan/split 配合正则处理字符串
  6. \A 锚定开头/结尾

继续学习

运行 hello basic regex 查看完整示例代码.

文件管理

开篇故事

文件管理是文件系统操作的集合。你经常需要:列出某个目录下的所有文件、查找特定类型的文件、检查文件是否存在、获取文件大小、创建临时目录。File 方法、Dir 的目录操作、Pathname 对象的文件路径操作、FileTest 方法的文件属性检查、临时目录创建。

本章适合谁

如果你需要写脚本、做批量文件处理、或管理文件系统,本章覆盖了 Ruby 标准库中所有文件管理的工具。

你会学到什么

  1. File 路径操作 — join、basename、dirname、extname
  2. Dir — pwd、entries、each_child、mkpath、rmdir
  3. FileTest — exist?、file?、directory?、size、identical?
  4. Pathname — basename、dirname、extname、ascend、glob
  5. 临时目录 — tmpdir、mktmpdir

前置要求

第一个例子

# 运行: hello basic file-management

# Pathname — 面向对象的文件路径
pn = Pathname.new("/usr/local/bin/ruby")
puts "basename: #{pn.basename}"    # ruby
puts "dir: #{pn.dirname}"         # /usr/local/bin

为什么这比 File.basename 好:Pathname 提供路径的"面向对象"接口。/ 操作符拼接路径,比 File.join 更优雅。

File 路径操作

# 拼接路径
joined = File.join("usr", "local", "bin", "ruby")
puts "File.join: #{joined}"

# 提取文件名
basename = File.basename("/usr/local/bin/ruby")
puts "File.basename: #{basename}"

# 提取文件名(去掉扩展名)
basename_no_ext = File.basename("/usr/local/bin/ruby", ".rb")
puts "File.basename (去扩展名): #{basename_no_ext}"

# 提取目录名
dirname = File.dirname("/usr/local/bin/ruby")
puts "File.dirname: #{dirname}"

# 提取扩展名
extname = File.extname("/usr/local/bin/script.rb")
puts "File.extname: #{extname}"

# 判断是否为绝对路径
puts "绝对路径? #{File.absolute_path?("/usr/local")}"

# 展开路径(~ 缩写)
expanded = File.expand_path("~")
puts "扩展 ~/ #{expanded}"

Dir 目录操作

# 当前工作目录
puts "Dir.pwd: #{Dir.pwd}"

# 获取当前目录下的 .rb 文件
puts "当前目录 .rb 文件:"
Dir.glob("*.rb").each { |f| puts "  #{f}" }

# 递归查找所有 .rb 文件
rb_count = Dir.glob("**/*.rb").length
puts "项目中 .rb 文件总数: #{rb_count}"

# 匹配多种扩展名(花括号扩展)
all_docs = Dir.glob("**/*.{rb,md}")
puts "Ruby + Markdown 文件总数: #{all_docs.length}"

# Dir.entries — 列出目录所有内容(含 . 和 ..)
puts "Dir.entries('lib/hello/basic'):"
Dir.entries("lib/hello/basic").each { |entry| puts "  #{entry}" }

# Dir.each_child — 迭代子目录/文件(不含 . 和 ..)
puts "Dir.each_child('lib/hello/basic'):"
Dir.each_child("lib/hello/basic") { |child| puts "  #{child}" }

FileTest — 文件属性检查

gemfile_path = "Gemfile"

# 检查文件是否存在
puts "FileTest.exist?(#{gemfile_path}): #{FileTest.exist?(gemfile_path)}"

# 检查是否为普通文件
puts "FileTest.file?(#{gemfile_path}): #{FileTest.file?(gemfile_path)}"

# 检查是否为目录
puts "FileTest.directory?('lib'): #{FileTest.directory?("lib")}"

# 检查可读/可写权限
puts "FileTest.readable?(#{gemfile_path}): #{FileTest.readable?(gemfile_path)}"

# 获取文件大小(字节)
size = FileTest.size(gemfile_path)
puts "FileTest.size(#{gemfile_path}): #{size} bytes"

# 检查两个路径是否指向同一文件
readme_a = "README.md"
readme_real = File.realpath(readme_a)
puts "FileTest.identical?(#{readme_a}, #{readme_real}): #{FileTest.identical?(readme_a, readme_real)}"

Pathname — 面向对象路径操作

# 创建 Pathname 对象
pn = Pathname.new("/usr/local/bin/ruby")
puts "Pathname('/usr/local/bin/ruby'):"
puts "  .basename: #{pn.basename}"
puts "  .dirname:  #{pn.dirname}"
puts "  .extname:  #{pn.extname}"
puts "  .ascend:   #{pn.ascend.to_a}"

# / 运算符拼接路径(比 File.join 更优雅)
project_root = Pathname.new(".")
lib_path = project_root / "lib" / "hello.rb"
puts "\nPathname / 运算符:"
puts "  lib/hello.rb 路径: #{lib_path}"
puts "  存在?#{lib_path.exist?}"

# 读取文件内容
readme_path = Pathname.new("README.md")
if readme_path.exist?
  first_three = readme_path.readlines.take(3)
  puts "\nREADME.md 前 3行:"
  first_three.each { |line| puts "  ${line.chomp}" }
end

# Pathname.glob — 匹配文件模式
md_count = Pathname.glob("docs/src/**/*.md").length
puts "\nPathname.glob('docs/src/**/*.md'): #{md_count} 个文件"

# relative_path_from — 计算相对路径
absolute = Pathname.new("/usr/local/bin/ruby")
base = Pathname.new("/usr/local")
relative = absolute.relative_path_from(base)
puts "\nPathname#relative_path_from:"
puts "  #{absolute} 相对 /usr/local 是: #{relative}"

临时目录 — Dir.mktmpdir

Dir.mktmpdir("hello_") do |tmp_dir|
  tmp_path = Pathname.new(tmp_dir)
  puts "创建临时目录: #{tmp_dir}"

  # 在临时目录中创建文件
  demo_file = tmp_path / "demo.txt"
  demo_file.write("Hello from 临时文件!")
  puts "  写入 #{demo_file}: #{demo_file.read}"

  # 创建子目录
  subdir = tmp_path / "subdir"
  subdir.mkpath
  subdir_file = subdir / "nested.txt"
  subdir_file.write("嵌套文件内容")

  # 列出临时目录的所有内容
  puts "  临时目录内容:"
  tmp_path.glob("**/*").each do |item|
    type = item.directory? ? "[目录]" : "[文件]"
    puts "    #{type} #{item.relative_path_from(tmp_path)}"
  end

  puts "  临时目录大小: #{tmp_path.size} (目录元数据大小)"
  puts "  临时目录可读:#{tmp_path.readable?}"
end
# 退出块后,临时目录已自动清理
puts "临时目录已自动清理完毕"

mktmpdir 的优势:自动清理临时文件,不需要手动 rm -rf

常见错误

错误 1:用字符串拼接处理路径

# ❌ 容易出错,不同 OS 的斜杠不同
path = "/tmp/" + filename  # 可能 "//tmp//file.txt"
path = filename + "/"      # 可能 "/tmp//file.txt"

# ✅ 用 File.join 或 Pathname
path = File.join("/tmp", filename)
path = Pathname.new("/tmp") / filename

错误 2:检查文件存在后操作

# ❌ TOCTOU 问题:检查和操作之间文件可能被删除
if File.exist?("data.txt")
  content = File.read("data.txt")  # 可能已经不存在了
end

# ✅ 用 rescue 处理
begin
  content = File.read("data.txt")
rescue Errno::ENOENT
  puts "文件不存在"
end

错误 3:忘记临时目录的清理

# ❌ 手动创建的临时目录不会自动清理
tmp = Dir.mktmpdir
# ... 使用后忘记清理

# ✅ 用 Dir.mktmpdir 块形式
Dir.mktmpdir("hello_") do |tmp_dir|
  # ... 自动清理
end

动手练习

练习 1:递归查找大文件

# 查找当前目录下所有超过 1MB 的文件,按大小排序
参考答案
Dir.glob("**/*").map do |f|
  next unless File.file?(f)
  size = File.size(f)
  [f, size] if size > 1_000_000
end.compact.sort_by(&:last)

练习 2:批量重命名

# 将当前目录所有 .txt 文件改为 .md
参考答案
Dir.glob("*.txt").each do |f|
  new_name = f.sub(/\.txt$/, ".md")
  FileUtils.mv(f, new_name)
end

故障排查 (FAQ)

Q: File.join 和 Pathname / 运算哪个更好?

A: 两者功能相同。Pathname 更面向对象,支持方法链;File.join 更直接。项目一致即可。

Q: 怎么判断路径是文件、目录、或符号链接?

A: 用 FileTest 模块:

FileTest.file?("path")       # 是普通文件
FileTest.directory?("path")  # 是目录
FileTest.symlink?("path")    # 是符号链接

Q: Dir.glob 和 Pathname.glob 的区别?

A: Dir.glob 返回字符串数组;Pathname.glob 返回 Pathname 对象数组,支持后续方法。Pathname.glob 更面向对象。

小结

核心要点

  1. File.join 拼接路径:跨平台安全
  2. Pathname 面向对象路径:支持 / 运算符
  3. Dir.glob 查找文件**/*.rb 递归
  4. FileTest 检查属性:exist?、file?、directory?、size
  5. Dir.mktmpdir 自动清理:块形式自动清理
  6. FileTest vs File 模块FileTest.exist?File.exist? 功能相同

继续学习

运行 hello basic file-management 查看完整示例代码.

阶段复习:基础部分

开篇故事

想象你学完了驾驶理论——交通规则、标志含义、操作步骤全都清楚。但真正上路前,你需要一次"驾驶模拟考"——把分散的概念整合成完整的能力。阶段复习就是你的实践练习——把变量、控制流、方法、集合等概念串起来。

本章适合谁

如果你已经完成了基础部分所有 15 章,现在想检验自己的学习成果并综合运用,本章适合你。

你会学到什么

完成本章后,你可以:

  1. 综合运用变量、控制流、方法、集合知识
  2. 识别 Ruby 代码中的最佳实践
  3. 设计包含类、模块、方法的小系统
  4. 调试常见 Ruby 错误

前置要求

完成以下所有章节:

知识整合图

基础部分知识体系:

变量与数据类型 → 字符串 → 集合(数组/哈希) → 控制流 → 方法
                                              ↓
类 ←── 模块 ←── 块与 Proc ←── 文件 I/O ←── 异常
                                              ↓
数字 ←── 符号 ←── 正则 ←── 文件管理 → 🎓 阶段复习

每个概念都建立在前一个概念之上,形成完整的知识链。

复习范围

第 1-15 章:变量、字符串、数组、哈希、控制流、方法、类、模块、块、文件 I/O、异常、数字、符号、正则、文件管理

综合练习 1:学生成绩管理系统

需求

设计一个简单的学生成绩管理系统:

  • 学生有姓名、学号、成绩列表
  • 计算平均分、最高分、最低分
  • 按成绩排序
  • 输出成绩单

练习代码

# TODO: 实现 Student 类
# 字段: name (String), id (String), scores (Array<Integer>)
# 方法: average_score, max_score, min_score, grade_level

# TODO: 实现 GradeBook 类
# 方法: add_student, find_student, print_report
参考答案
class Student
  attr_reader :name, :id, :scores

  def initialize(name, id, scores = [])
    @name = name
    @id = id
    @scores = scores
  end

  def average_score
    return 0.0 if scores.empty?
    scores.sum.to_f / scores.length
  end

  def max_score
    scores.max || 0
  end

  def min_score
    scores.min || 0
  end

  def grade_level
    avg = average_score
    case avg
    when 90..100 then "A"
    when 80..89  then "B"
    when 70..79  then "C"
    else "D"
    end
  end

  def to_s
    "#{name} (#{id}): 平均 #{average_score.round(1)} 分, 等级 #{grade_level}"
  end
end

class GradeBook
  def initialize
    @students = {}
  end

  def add_student(student)
    @students[student.id] = student
  end

  def find_student(id)
    @students[id]
  end

  def print_report
    @students.values.sort_by(&:average_score).reverse.each do |s|
      puts s
    end
  end
end

综合练习 2:日志分析器

需求

分析以下格式的日志文件,统计每个 IP 的请求次数和错误率:

192.168.1.1 - GET /api/users 200
192.168.1.2 - POST /api/login 401
192.168.1.1 - GET /api/users 200
# TODO: 实现 LogAnalyzer 类
# 方法: parse_line, count_by_ip, error_rate
参考答案
class LogAnalyzer
  def initialize
    @data = []
  end

  def parse_line(line)
    # 解析日志
    ip, method, path, status = line.match(/(\d+\.\d+\.\d+\.\d+) - (\w+) (\S+) (\d+)/).captures
    { ip: ip, method: method, path: path, status: status.to_i }
  rescue
    nil
  end

  def count_by_ip
    @data.group_by { |r| r[:ip] }.map do |ip, records|
      [ip, records.length]
    end.to_h
  end

  def error_rate(ip)
    records = @data.select { |r| r[:ip] == ip }
    return 0.0 if records.empty?
    
    errors = records.count { |r| r[:status] >= 400 }
    errors.to_f / records.length * 100
  end
end

综合练习 3:配置管理器

需求

设计一个配置管理器:

  • 支持嵌套哈希配置
  • 支持默认值合并
  • 支持覆盖配置
# TODO: 实现 ConfigManager 类
# 方法: set, get, merge, save, load
参考答案
class ConfigManager
  def initialize(defaults = {})
    @config = defaults
  end

  def set(key, value)
    # 设置配置项
    @config[key] = value
  end

  def get(key, default = nil)
    # 获取配置项
    @config.dig(*key.to_s.split(".")) || default
  end

  def merge(other)
    # 合并配置
    @config = @config.deep_merge(other)
  end

  def save(path)
    require "yaml"
    File.write(path, @config.to_yaml)
  end

  def load(path)
    require "yaml"
    @config = YAML.load_file(path)
  end
end

知识检查

问题 1:变量作用域

class Test
  @@count = 0
  @count = 10

  def self.check_count
    puts "类变量 @@count: #{@@count}"
    puts "类实例变量 @count: #{@count}"
  end
end

Test.check_count
输出是什么?
类变量 @@count: 0
类实例变量 @count: 10

问题 2:块与 Proc

def test_block
  [1, 2, 3].each do |n|
    Proc.new { return "从块 return" }.call
    puts "这行会执行吗?"
  end
  "循环结束"
end

puts test_block
输出是什么?
从块 return

Proc.new 的 return 会跳出整个 test_block 方法。

问题 3:哈希键

h = { :name => "符号", "name" => "字符串" }
puts h[:name]
puts h["name"]
输出是什么?
符号
字符串

问题 4:正则匹配

text = "2025-04-15"
match = text.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/)
puts match[:year]
puts match[:month]
输出是什么?
2025
04

常见错误回顾

错误原因修复
undefined local variable变量拼写错误或拼写检查变量名拼写
undefined method调用了没有定义检查调用方法
TypeError类型不匹配to_sto_i 转换
FrozenErrorfrozen_string_literal.dup 创建可变副本
KeyError哈希键不存在fetch 带默认值

小结

核心要点

  1. 变量有五类:局部、实例、类、全局、常量
  2. 字符串操作丰富:插值、方法链、frozen
  3. 集合三剑客:map/select/reduce
  4. 控制流:if/case/unless/循环
  5. 类是 OOP 基础:实例变量、方法、继承
  6. 模块是组合优于继承
  7. 块是 Ruby 的灵魂:yield/Proc/Lambda
  8. 文件/异常/正则/文件管理

术语表

English中文
Scope作用域
Interpolation插值
Collection集合
Control Flow控制流
Class
Object (对象)对象
Module模块
Mixin混入
Block
Iterator迭代器
Yield产出

下一步高级进阶 🎓

相关: 基础入门 | 高级进阶

高级进阶

完成了 Basic 部分的学习后,你已经掌握了 Ruby 的核心语法。现在该进入 Advance 部分了,这里的内容会让你对 Ruby 的理解从"会写代码"升级到"理解 Ruby 的设计思想"。

Advance 部分涵盖 10 个主题,每个主题都深入 Ruby 的一个高级领域。这些内容不是孤立的知识点,而是彼此关联的体系。比如元编程是理解 Rails 魔法的基础,并发模型是构建高性能服务的前提,测试和依赖注入是生产级项目不可或缺的环节。

学习内容概览

#主题核心内容运行命令
1并发模型:Thread/Fiber/Ractor三种并发原语、GVL、消息传递hello advance async_await
2元编程method_missing、define_method、class_eval、open classeshello advance metaprogramming
3Enumerable 深度探索自定义 Enumerable、惰性求值、高级遍历方法hello advance enumerable
4数据库与 ORMSequel 连接、模型、迁移、关联、查询hello advance database
5错误处理模式Result monad、safe navigation、异常层级hello advance error_handling
6测试模式 RSpecdescribe/context/it、shared examples、mock/stubhello advance testing
7dry-system 依赖注入容器、自动注册、Provider、Import mixinhello advance dry_system
8Thor CLI 高级用法class_option、subcommands、参数解析hello advance cli_advanced
9线程与协程GVL 详解、Thread 生命周期、Fiber 管道、Ractorhello advance threads_fibers
10性能优化Benchmark、GC 统计、对象分配分析、惰性求值hello advance performance

阅读本部分时,建议先理解每个主题的设计动机,再学习具体 API。Ruby 的设计哲学是"让程序员快乐",每个高级特性背后都有明确的场景需求。理解了"Why",使用"How"就会自然很多。

学完这 10 个主题后,你可以完成阶段复习,综合运用元编程、DI 和测试来设计一个小服务。复习章节会引导你将零散的知识点串联成完整的工程能力。

并发模型:Thread/Fiber/Ractor

在单线程程序中,代码按顺序一条条执行。这是最直观的模式,但也是最低效的。现实世界的应用需要同时做很多事:处理用户请求、读写文件、调用外部 API、处理后台任务。如果所有操作都排队等待,用户体验就会很差。

Ruby 提供了三种不同的并发原语来解决这个问题:Thread、Fiber 和 Ractor。它们各有适用场景,理解每种原语的特点和限制是写出高效 Ruby 程序的关键。

运行 hello advance async_await 可以查看完整演示代码。

Thread:系统级线程

Ruby 的 Thread 是对操作系统线程的封装。每个 Thread 都有独立的调用栈和执行上下文,可以在不同 CPU 核心上并行执行。但 Ruby 有一个重要的限制:GVL(全局虚拟机锁)。GVL 保证同一时刻只有一个线程在执行 Ruby 字节码,这是为了保护 Ruby 内部数据结构不被并发修改。

这个限制意味着什么?CPU 密集型的计算不能通过多线程加速,因为 GVL 会 serialize 所有线程。但 I/O 操作会释放 GVL,多个线程可以同时等待网络响应或磁盘读写。所以 Thread 最适用的场景是 I/O 密集型任务。

# 创建三个并发线程
results = []
mutex = Mutex.new

threads = 3.times.map do |i|
  Thread.new do
    sleep(0.1)  # 模拟 I/O 操作,会释放 GVL
    value = "Thread #{i} 完成,结果 #{i * 10}"
    mutex.synchronize { results << value }
    value
  end
end

# 等待所有线程结束
threads.each(&:join)
puts results
# 三个线程并发执行,总耗时约 0.1 秒
# 如果是顺序执行需要 0.3 秒

注意这里使用了 Mutex 来保护 results 数组。多个线程同时修改同一个对象会导致竞态条件,数据损坏。Mutex 的 synchronize 方法保证同一时刻只有一个线程能进入临界区。

线程之间还可以通过线程局部变量传递信息:

Thread.current[:request_id] = "request-42"
puts Thread.current[:request_id]  # "request-42"

每个线程有独立的 Thread.current 命名空间,子线程不会继承父线程的局部变量。这在 Web 框架中很常见,用于在同一个请求的处理链路中传递上下文信息。

Fiber:轻量级协程

Fiber 与 Thread 最大的区别在于调度方式。Thread 由操作系统调度,Fiber 由程序员手动调度。Fiber 是协作式的,只有当 Fiber 主动调用 Fiber.yield 交出控制权时,另一个 Fiber 才能运行。

这种手动调度的设计让 Fiber 非常轻量。创建 Fiber 几乎零成本,你可以在单个程序中使用数十万个 Fiber 而不会有性能问题。Fiber 适合的场景是协作式数据流处理:生成器、管道、状态机。

fiber = Fiber.new do
  Fiber.yield "第一步完成"
  puts "  (Fiber 内部恢复执行)"
  Fiber.yield "第二步完成"
  Fiber.yield "第三步完成"
end

puts fiber.resume  # "第一步完成"
puts fiber.resume  # "第二步完成"
puts fiber.resume  # "第三步完成"
puts fiber.alive?  # false,已执行完毕

Fiber.yield 暂停当前 Fiber 并返回一个值,Fiber.resume 恢复执行。注意 Fiber 不是自动并行运行的。它只是让你把一个计算过程拆成多个阶段,在合适的时机手动推进。

Fiber 最常见的用途是生成器。Ruby 的 Enumerator 类底层就是用 Fiber 实现的:

counter = Fiber.new do
  n = 0
  loop do
    n += 1
    Fiber.yield n
  end
end

5.times { puts counter.resume }
# 输出: 1, 2, 3, 4, 5

这个 Fiber 是一个无限递增的计数器。每次 resume 产生一个值并暂停,不会阻塞其他代码。如果你需要一个按需计算的序列,Fiber 生成器是最简洁的方式。

Ractor:真正的并行

Thread 受 GVL 限制不能并行执行 CPU 密集型代码。Ractor 是 Ruby 3.0 引入的全新并发模型,专门解决这个问题。Ractor 之间不共享内存,每个 Ractor 有独立的 GVL,所以多个 Ractor 可以在不同 CPU 核心上真正并行运行。

通信方式决定了 Ractor 的安全边界。Ractor 之间只能通过消息传递交换数据,不能直接访问其他 Ractor 的内部状态。这个限制消除了所有竞态条件的可能。

ractor = Ractor.new do
  # Ractor 内部不受 GVL 限制
  sum = (1..1_000_000).sum
  sum  # 返回值
end

result = ractor.take  # 阻塞等待结果
puts result  # 500000500000

如果需要多个 Ractor 并行处理不同数据集:

data_sets = [(1..100), (101..200), (201..300)]
ractors = data_sets.map { |range| Ractor.new(range) { |r| r.sum } }
results = ractors.map(&:take)
puts results.sum  # 45150

Ractor 也可以使用消息传递模式:

echo = Ractor.new do
  msg = Ractor.receive  # 阻塞接收消息
  "收到: #{msg}"
end

echo.send("Hello, Ractor!")
puts echo.take  # "收到: Hello, Ractor!"

注意 Ractor 的限制:共享可变对象不能在 Ractor 之间传递。如果你尝试传递一个在其他 Ractor 中被修改的对象,Ruby 会抛出 Ractor::Error。这是 Ruby 在语言层面保证并发安全的方式。

如何选择并发原语

场景推荐原因
HTTP 请求、数据库查询、文件读写ThreadI/O 操作释放 GVL,多线程可并行
生成器、协作式管道、轻量状态机Fiber零成本创建,手动调度灵活
大数据处理、独立计算任务Ractor多 CPU 真正并行,无共享安全
复杂并发编排async gem结构化并发,更高级的抽象

如果你不确定该用哪种,可以从 Thread 开始。Thread 覆盖大多数常见场景,配合 Queue 和 Mutex 就能实现安全的线程通信。当你发现 Thread 的调度开销太大,或者需要创建大量"虚拟线程"时,再考虑 Fiber。当你确实需要利用多核 CPU 做 CPU 密集型计算时,Ractor 是最后的选择。

本章要点

  • Thread 是系统级线程,受 GVL 限制,适合 I/O 密集型并行
  • Fiber 是轻量级协程,手动调度,适合生成器和协作式数据流
  • Ractor 是真正并行的 Actor 模型,无共享内存,适合 CPU 密集型并行
  • Mutex 保护共享状态,Queue 实现线程安全通信
  • 选择并发原语时要考虑任务类型:I/O 密集型用 Thread,CPU 密集型用 Ractor
  • Ruby 没有内建的 async/await,但可以通过 Fiber + 事件循环模拟结构化并发

元编程

元编程是关于"编写能编写代码的代码"的技术。在大多数语言中,类和方法一旦定义就固定不变。Ruby 不同,它在运行时仍然保留了完整的类型系统信息,你可以在程序执行过程中动态创建方法、修改类、甚至重建整个对象模型。这就是元编程的能力。

理解元编程是掌握 Ruby 的关键一步。Rails 的 has_manyvalidates 这些魔法方法全部基于元编程实现。学完这一章你会明白 Rails 背后没有黑魔法,只有 Ruby 在运行时动态操作对象模型。

运行 hello advance metaprogramming 可以查看完整演示代码。

define_method:动态定义方法

Ruby 中 def 关键字不是唯一定义方法的方式。define_method 可以在运行时接受一个方法名和一个代码块,动态创建方法:

klass = Class.new do
  %w[dog cat bird].each do |animal|
    define_method("#{animal}_sound") do
      case animal
      when "dog"  then "汪!"
      when "cat"  then "喵!"
      when "bird" then "叽!"
      end
    end
  end
end

instance = klass.new
puts instance.dog_sound   # "汪!"
puts instance.cat_sound   # "喵!"
puts instance.bird_sound  # "叽!"

注意代码块中捕获了外部变量 animal。这是因为 define_method 接受一个闭包,闭包可以访问定义时的词法环境。这个特性让动态方法可以根据不同的外部参数生成不同的行为。

define_method 创建的方法与 def 定义的方法完全等价,同样出现在 instance.methods 列表中,同样可以被 super 调用。区别只是定义时机不同:def 在解析代码时确定,define_method 在运行时创建。

method_missing:方法拦截

当对象收到一个未定义的方法调用时,Ruby 默认会抛出 NoMethodErrormethod_missing 允许你拦截这个调用并自定义行为。这是一个强大的模式,很多 DSL 都基于它实现。

class DynamicHash
  def initialize
    @data = {}
  end

  def method_missing(name, *args)
    name_str = name.to_s
    if name_str.end_with?("=")
      @data[name_str.chomp("=").to_sym] = args.first
    else
      @data[name.to_sym]
    end
  end

  def respond_to_missing?(name, include_private = false)
    true
  end
end

obj = DynamicHash.new
obj.name = "Alice"
obj.age = 30
puts obj.name  # "Alice"
puts obj.age   # 30

method_missing 接收方法名和参数,你可以在内部实现任意逻辑。这个例子中把 obj.name = "Alice" 翻译成 @data[:name] = "Alice",把 obj.name 翻译成 @data[:name]

有一个容易被忽略的细节:当你重写 method_missing 时,也应该重写 respond_to_missing?。否则 obj.respond_to?(:name) 会返回 false,即使 obj.name 能正常调用。Rails 的 ActiveRecord 就同时实现了这两个方法。

警告method_missing 是双刃剑。用得好可以写出非常优雅的 DSL,用不好会隐藏 bug。如果你拼错了方法名却得到了一个看似合法的返回值,调试起来会非常痛苦。建议只在明确需要拦截的场景使用,并在方法内部做好参数校验。

class_eval 和 instance_eval

这三者 (class_evalmodule_evalinstance_eval) 的区别在于执行代码的上下文环境。

class_evalmodule_eval 完全等价,都是在类/模块的上下文中执行代码块。在这个上下文中,self 指向类本身,可以定义实例方法:

klass = Class.new { attr_reader :value }

klass.class_eval do
  define_method(:value_doubled) { value * 2 }
  def greet
    "Hello from class_eval!"
  end
end

instance = klass.new
instance.instance_variable_set(:@value, 21)
puts instance.value_doubled  # 42
puts instance.greet          # "Hello from class_eval!"

instance_eval 是在对象实例的上下文中执行代码。在这个上下文中,self 指向实例对象,可以定义单例方法:

obj = Object.new
obj.instance_eval do
  @special = "仅属于这个对象"
  define_singleton_method(:special_method) { @special }
end

puts obj.special_method  # "仅属于这个对象"

other_obj = Object.new
puts other_obj.respond_to?(:special_method)  # false,方法只在这个对象上

选择哪个取决于你的需求:要修改类的定义用 class_eval,要给单个对象添加方法用 instance_eval,要执行类级别的宏(比如 Rails 的 before_save)用 class_exec

send 和 public_send:反射调用

send 允许你用符号或字符串调用对象的任意方法,包括私有方法。public_send 的行为相同,但只调用公开方法,调用私有方法会抛出 NoMethodError

text = "hello world"
puts text.send(:upcase)          # "HELLO WORLD"
puts text.send(:+, "!")          # "hello world!"
puts text.send(:split, " ").join("-")  # "hello-world"

什么时候会用到 send?最常见的是动态方法名。比如从配置或用户输入中获取方法名,再用 send 调用。但要注意 public_send 更安全,它不会意外触发私有方法。

const_get:动态常量访问

const_get 允许你通过字符串或符号获取常量引用:

StringClass = Object.const_get(:String)
puts StringClass.new("hello")  # "hello"

puts Math.const_get(:PI)       # 3.14159...

这在插件系统或工厂模式中很有用。你需要根据用户配置加载不同的类时,用 const_get 可以免去冗长的 if/else 分支。

开放类:随时修改已有类

Ruby 的类随时可以重新打开,添加新方法或修改已有方法:

class String
  def shout
    upcase + "!"
  end
end

puts "hello".shout  # "HELLO!"

这个特性是 Ruby 元编程的基础,但也带来风险:如果你修改了核心类的方法,可能与其他库产生冲突。Rails 的 ActiveSupport 大量使用了这个特性,比如给 Integer 添加了 2.days.ago 这样的语法糖。使用开放类时要遵循两个原则:方法命名加前缀避免冲突、只添加不覆盖。

本章要点

  • define_method 在运行时动态创建方法,接受闭包捕获外部变量
  • method_missing 拦截未定义的方法调用,配合 respond_to_missing? 使用
  • class_eval 在类上下文中执行代码,定义实例方法
  • instance_eval 在对象上下文中执行代码,定义单例方法
  • send/public_send 用符号动态调用方法,public_send 更安全
  • const_get 通过名称获取常量引用,适合动态加载场景
  • 开放类 允许随时修改已有类,命名要谨慎避免冲突
  • 元编程的终极目标不是炫技,而是写出更贴合业务语义的 API
  • 运行 hello advance metaprogramming 查看完整示例

Enumerable 深度探索

任何 Ruby 开发者都会频繁使用 mapselectreduce。大多数教程到此为止。但 Enumerable 的能力远不止这些基础方法。深入理解 Enumerable,你可以构建自定义集合类、实现惰性无限序列、用高级遍历方法替代手动循环。

Enumerable 是 Ruby 标准库中最被低估的模块之一。它只需要一个 each 方法就能提供超过 50 个集合操作方法。这一章带你挖掘 Enumerable 的全部潜力。

运行 hello advance enumerable 可以查看完整演示代码。

自定义 Enumerable 类

Enumerable 是一个 mixin 模块。只要你的类实现了 each 方法并 include Enumerable,就自动获得所有集合操作能力:

class TemperatureReadings
  include Enumerable

  def initialize(locations)
    @data = locations
  end

  def each(&block)
    @data.each(&block)
  end

  # Enumerable 提供的能力:
  def average
    map { |_, temp| temp }.reduce(0, :+) / count
  end

  def hottest
    max_by { |_, temp| temp }
  end

  def coldest
    min_by { |_, temp| temp }
  end
end

readings = TemperatureReadings.new([
  ["北京", 28], ["上海", 32], ["广州", 35],
  ["哈尔滨", 18], ["成都", 26], ["武汉", 33]
])

puts "平均气温: #{readings.average}°C"       # 29
puts "最热: #{readings.hottest[0]}"           # 广州
puts "过热城市: #{readings.select { |_, t| t > 30 }.map(&:first).join(', ')}"
# 过热城市: 上海, 广州, 武汉

核心规则就是:实现 eachinclude Enumerable,获得一切。这个模式在 Ruby 中被称为"最小接口,最大能力"。

惰性求值与无限序列

普通的 mapselect 会立即遍历整个集合并创建新数组。对于大型集合,这意味着大量的中间对象分配。lazy 方法改变了这种行为:它返回一个惰性 Enumerable,只在需要时才计算元素。

# 无限斐波那契数列
fib = Enumerator.new do |yielder|
  a = 0
  b = 1
  loop do
    yielder << a
    a, b = b, a + b
  end
end

# 惰性操作:取 > 100 的前 3 个偶数
# 不会导致无限循环!take(3) 后停止计算
result = fib.lazy.select { |n| n > 100 && n.even? }.take(3).force
puts result.inspect  # [144, 987, 6765]

force 方法将惰性序列转为普通数组。在调用 force 之前,链路上的所有操作都不会实际执行。这是处理大数据集的标准做法。

惰性求值的另一个优势是性能:

large_range = (1..1_000_000)

# 立即求值:map 先处理 100 万个元素,select 再过滤
result1 = large_range.map { |n| n * 3 }.select(&:even?).take(5)

# 惰性求值:逐元素流过 map → select → take,取够 5 个后停止
result2 = large_range.lazy.map { |n| n * 3 }.select(&:even?).take(5).force

puts result1 == result2  # true,结果相同
# 但惰性方式只处理了约 10 个元素,立即方式处理了 100 万个

当你处理的数据集远大于最终结果时,lazy 会带来数量级的性能提升。

minmax_by:一次遍历获最值

minmax_by 在一次遍历中同时找出最小值和最大值。比分别调用 min_bymax_by 少遍历一次,对于大数据集能省一半的时间。

words = %w[apple banana cherry date elderberry fig grape]
shortest, longest = words.minmax_by(&:length)
puts "最短: #{shortest}, 最长: #{longest}"
# 最短: fig, 最长: elderberry

# 按自定义规则比较
numbers = [15, 3, 42, 7, 28, 1]
min_max = numbers.minmax_by { |n| Math.sqrt(n) }
puts "平方根最小/最大: #{min_max}"  # [1, 42]

minmax(不带 _by)是按元素自身的默认比较。minmax_by 是按你提供的转换函数的结果比较。两者都在一次遍历中完成。

chunk_while:按条件分组

chunk_while 根据相邻元素之间的关系将序列分段。这对于按自然断点分组数据非常有用。

numbers = [1, 2, 3, 5, 6, 8, 10, 11, 12]
groups = numbers.chunk_while { |a, b| b == a + 1 }.to_a
puts groups.inspect
# [[1, 2, 3], [5, 6], [8], [10, 11, 12]]
# 把连续的数字分成一组

# 按奇偶分组
parity = [1, 3, 5, 2, 4, 7, 9, 11].chunk_while do |a, b|
  (a.odd? && b.odd?) || (a.even? && b.even?)
end.to_a
puts parity.inspect
# [[1, 3, 5], [2, 4], [7, 9, 11]]

chunk_while 接收一个条件块,当相邻两个元素满足条件时,它们属于同一组。这个模式在处理日志、时间序列、连续事件时非常常见。

slice_after:按标记分割

slice_after 在匹配条件的元素之后将序列切开。常用于按标题分割文档、按分隔符分割日志等场景。

mixed = ["# 标题", "内容1", "内容2", "# 章节", "内容3", "# 结束"]
sections = mixed.slice_after(/^#/).to_a

sections.each_with_index do |section, i|
  puts "段#{i}: #{section.inspect}"
end
# 段0: ["# 标题", "内容1", "内容2"]
# 段1: ["# 章节", "内容3"]
# 段2: ["# 结束"]

类似的还有 slice_before(在匹配之前切开)和 slice_when(条件变化时切开)。这三个方法覆盖了大多数基于标记的分组需求。

grep_v:反向匹配

grep_vgrep 的反面,返回不匹配模式的元素。grep=== 运算符测试匹配,grep_v 取反。

words = %w[hello world ruby programming rust c golang python]
long_words = words.grep_v(/^. {0,5}$/)
puts long_words.join(", ")
# ruby, programming, golang, python

emails = %w[user@example.com admin@test.org not_an_email root@localhost]
invalid = emails.grep_v(/@.*\./)
puts "无效邮件: #{invalid.join(', ')}"
# not_an_email

grepgrep_v 支持正则表达式、类、范围等任何实现了 === 的对象,比 select { |x| x =~ pattern } 更简洁。

Enumerable 方法全景图

只要实现了 each,你就自动获得以下方法:

过滤和筛选: select/filterrejectgrepgrep_vtaketake_whiledropdrop_whilefirstcompact

聚合和统计: reduce/injectcountmin/max/minmaxmin_by/max_by/minmax_bysum

分组和排序: sort/sort_bygroup_bychunkchunk_whileslice_afterslice_beforeslice_whenpartition

遍历和操作: map/collectflat_map/collect_concateach_conseach_sliceeach_with_indexeach_with_objectzip

查询: any?all?none?one?include?find/detectfind_index

转换: to_ato_htallycycleentries/to_enum

惰性: lazyeager

完整的 Enumerable 提供约 50 个方法。掌握它们意味着你不再需要手动编写大量的 for 循环和 if 条件。用声明式的方式描述"我要什么",而不是"怎么得到"。

本章要点

  • include Enumerable 只需实现 each,获得 50+ 集合方法
  • lazy 实现惰性求值,可以安全操作无限序列
  • minmax_by 一次遍历同时找到最小和最大值
  • chunk_while 根据相邻元素关系分组
  • slice_after 按标记将序列分段
  • grep/grep_v 使用 === 进行正向/反向匹配
  • tally 统计元素频次,to_h 将键值对转为 Hash
  • Enumerable 将命令式循环转换为声明式表达式,代码更短、更安全
  • 运行 hello advance enumerable 查看完整示例

数据库与 ORM

没有哪个现代 Web 应用不操作数据库。Ruby 生态中有两个主要的 ORM 框架:ActiveRecord(Rails 的默认选择)和 Sequel(更灵活的替代方案)。这两个都基于相同的核心模式:连接管理、模型定义、迁移系统、链式查询接口。学习任何一个,另一个都能快速上手。

这一章以 Sequel 为例讲解 Ruby ORM 的核心概念。即使你主要使用 ActiveRecord,理解这些底层模式也能帮助你写出更好的数据库代码。

运行 hello advance database 可以查看完整演示代码。

运行示例

本章节的代码可以通过 CLI 运行完整演示:

hello advance database  # 查看 Sequel ORM 完整演示

演示包含两部分:

  1. 内存模拟(Section 1-6):MemoryDatabase 展示 ORM 内部实现模式,帮助理解 Ruby 如何封装数据库操作
  2. 真实 Sequel(Section 7):Sequel.sqlite + Schema + CRUD + 查询链 + 事务,生产级用法参考

建议先运行演示代码,再看文档理解原理。

数据库连接

ORM 的第一步是建立与数据库的连接。Sequel 支持 SQLite、PostgreSQL、MySQL、MSSQL 等多种数据库,连接接口一致:

require "sequel"

# SQLite 内存数据库(开发测试用)
DB = Sequel.connect("sqlite::memory:")

# PostgreSQL 生产连接
DB = Sequel.connect(
  adapter: "postgres",
  host: "localhost",
  database: "myapp",
  user: "app_user",
  password: "secret",
  max_connections: 20,
  pool_timeout: 5
)

# SQLite 文件数据库
DB = Sequel.connect("sqlite:///myapp.db")

Sequel 的 connect 方法返回一个 Database 对象,所有后续的表和查询操作都通过这个对象进行。它内置了连接池管理,自动处理多线程序列化和空闲连接回收。

迁移:版本化的数据库结构

在真实项目中,数据库结构不是一次性定义完的。你需要随着业务需求的增长,逐步调整表结构。迁移(Migration)就是版本管理系统,每个迁移文件代表一次结构变更:

# 创建 users 表
DB.create_table :users do
  primary_key :id
  String :name, null: false
  String :email, unique: true
  Integer :age
  DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
end

# 创建 posts 表,关联 users
DB.create_table :posts do
  primary_key :id
  String :title, null: false
  Text :content
  foreign_key :user_id, :users, null: false
  DateTime :published_at
  index :user_id
  index :published_at
end

注意这里定义的约束:null: false 保证字段不为空,unique: true 防止重复,foreign_key 建立表间关联,index 加速查询。这些都是数据库层面的安全保障,即使应用层有 bug 也不会破坏数据完整性。

在 Sequel 项目中,迁移通常用独立的迁移文件管理,类似 Rails 的 db/migrate/ 目录。sequel -m migrations/ 命令可以执行所有待运行的迁移。

模型定义

模型是 ORM 的核心。每个模型类对应一个数据库表,实例对应一行记录:

class User < Sequel::Model
  # 关联声明
  one_to_many :posts

  # 验证规则
  validates_presence :name
  validates_format /\A[\w.]+@[\w.]+\z/, :email
  validates_integer :age
  validates_min_length 3, :name

  # 自定义回调
  before_create { self.created_at = Time.now }
  after_save { |obj| puts "用户 #{name} 已保存" }

  # 自定义范围
  def self.active
    where { created_at > Time.now - 30 * 86400 }
  end

  def self.by_age_range(min, max)
    where { age >= min && age <= max }
  end
end

class Post < Sequel::Model
  many_to_one :user
  validates_presence :title

  def self.recent(limit = 10)
    where { published_at > Time.now - 7 * 86400 }
      .order(Sequel.desc(:published_at))
      .limit(limit)
  end
end

Sequel::Model 提供了完整的 ORM 能力:has_many / belongs_to 等价物(one_to_many / many_to_one)、验证、回调、作用域、实例方法和类方法。

模型继承关系也是支持的:

class Admin < User
  one_to_many :moderated_posts, class: :Post
end

CRUD 操作

模型的实例方法提供了完整的增删改查接口:

# 创建
user = User.create(name: "Alice", email: "alice@example.com", age: 30)
puts user.id  # 自动生成的主键

# 读取:多种方式
user = User[1]                       # 通过主键查找
user = User.first(name: "Alice")      # 条件查找第一条
users = User.where(age: 25..35)        # 条件查找多条
users = User.where { age > 25 }        # 块语法(更灵活)

# 更新
user.update(age: 31)                   # 批量更新
user.update_columns(name: "Alice2")    # 跳过验证和回调

# 删除
user.destroy                           # 执行回调后删除
user.delete                            # 直接删除,跳过回调

create 内部调用 new + saveupdate 内部调用赋值 + save。如果你需要更细粒度的控制,可以手动拆分这些步骤:

user = User.new(name: "Bob", email: "bob@example.com")
user.age = 25
user.save  # 执行验证和回调

链式查询接口

ORM 的查询接口是可链式的。每次查询方法返回一个新的 Relation 对象,不修改原始数据,只在最终取值时执行 SQL:

# 基础查询
User.where(active: true).limit(10)

# 块语法查询(支持任意 Ruby 表达式)
User.where { age > 18 }.order(:name).select(:name, :email)

# 关联查询
User.join(:posts).group(:user_id).having { count(Sequel[:id]) > 5 }

# 模式匹配查询
User.select { name.lowercase.like "alice" }

# 预加载关联(避免 N+1 问题)
User.eager(:posts).where(id: 1)

注意最后一个例子中的 eager。如果加载 100 个用户然后访问每个用户的 posts,不做预加载会产生 101 条 SQL 查询(1 条查用户 + 100 条查帖子)。eager 会让 ORM 用 IN 查询批量加载所有关联,减少到 2 条 SQL。这就是经典的 N+1 查询问题的解决方案。

关联查询还支持更复杂的链式:

# 深度预加载
Post.eager(:user, comments: :author).where(published: true)

# 自定义关联过滤
User.eager_loaded_posts.where_posts(active: true)

关联关系

Sequel 支持四种关联类型,覆盖了几乎所有数据库关系场景:

class User < Sequel::Model
  many_to_one :profile   # has_one:一对一,外键在 profile 表
  one_to_many :posts     # has_many:一对多
  one_to_one :setting    # has_one:一对一
  many_to_many :tags     # has_many through:多对多
end

class Post < Sequel::Model
  many_to_one :user      # belongs_to:属于某个用户
  one_to_many :comments  # 一对多评论
end

关联方法自动生成一系列便捷方法:

user = User[1]

# 读取关联
user.posts              # 返回 Post 对象数组
user.posts_dataset      # 返回 Relation,可以继续链式查询

# 创建关联
user.add_post(title: "Hello", content: "...")
user.create_post(title: "Hello")

# 删除关联
user.remove_post(some_post)
user.remove_all_posts

预加载关联使用 eagereager_load。两者的区别是 eagerIN 子查询分两次取数,eager_loadJOIN 一次取数。数据量小时 eager_load 更快,关联多时 eager 更省内存。

连接池

ORM 的数据库连接是稀缺资源。Sequel 内置连接池自动管理连接的生命周期:

DB = Sequel.connect(
  adapter: "postgres",
  host: "localhost",
  database: "myapp",
  max_connections: 20,     # 池中最大连接数
  pool_timeout: 5,         # 获取连接超时秒数
  idle_timeout: 300,       # 空闲连接超时秒数
  after_connect: proc { |conn|
    conn.run "SET statement_timeout = '5s'"
  }
)

连接池中每个连接在同一时刻只能被一个线程使用。当线程完成数据库操作后,连接自动归还池中供其他线程复用。如果池中所有连接都被占用,新请求等待 pool_timeout 秒,抛出超时异常。

本章要点

  • Sequel.connect 建立数据库连接,内置连接池管理
  • create_table 定义表结构,支持主键、外键、索引、约束
  • Model 类 继承 Sequel::Model,提供 CRUD、验证、回调、作用域
  • 链式查询 返回 Relation,按需执行 SQL,可组合
  • 关联声明 one_to_many / many_to_one / one_to_one / many_to_many
  • eager 预加载关联,防止 N+1 查询问题
  • 连接池自动管理连接复用,max_connectionspool_timeout 控制资源
  • 运行 hello advance database 查看完整示例

错误处理模式

程序总会出错。网络超时、文件不存在、用户输入无效、数据库连接断开。如何处理这些错误,决定了程序的健壮性和可维护性。Ruby 提供了多层错误处理机制,从最基础的 rescue/catch 到函数式的 Result monad,每种方式适合不同的场景。

理解错误處理不仅是学会 rescue 语法,更是学会在系统中建立清晰的错误边界。这一章从安全导航到 Result monad,带你掌握 Ruby 中所有主流的错误处理模式。

运行 hello advance error_handling 可以查看完整演示代码。

Safe Navigation Operator(&.)

Ruby 3.0 正式支持的 &. 运算符是处理"可能为 nil"的最简洁方式。它会在左侧为 nil 时跳过方法调用并返回 nil,而不是抛出 NoMethodError

config = { database: { host: "localhost", port: 5432 } }

# 传统方式:需要嵌套判断
host = config[:database] && config[:database][:host]

# safe navigation:简洁且语义清晰
host = config[:database]&.[](:host)

# 更简洁的方式:Hash#dig
host = config.dig(:database, :host)  # "localhost"

# nil 时安全返回 nil
host = config.dig(:cache, :host)     # nil,不抛异常

&. 最适合的场景是遍历嵌套结构,而 dig 最适合 Hash 的深层查找。两者的共同点是:不会在中间某个层次为 nil 时崩溃。

&. 不是万能的。如果一个对象不应该为 nil,使用 &. 反而会隐藏 bug。正确的做法是让 NoMethodError 暴露出来,然后在修复 bug 后移除 &.&. 只适用于那些"nil 是合法值"的场景。

Result Monad(函数式错误处理)

Ruby 传统的错误处理用 raise/rescue,这是命令式的。函数式编程中常用 Result monad 模式,把错误当成正常返回值的一部分:

# 简化版 Result monad(纯 Ruby 实现)
success = ->(value) { { success: true, value: value } }
failure = ->(error) { { success: false, error: error } }

bind = ->(result, &handler) {
  return result unless result[:success]
  handler.call(result[:value])
}

# 可能失败的操作
parse_int = ->(str) {
  begin
    success.call(Integer(str))
  rescue ArgumentError => e
    failure.call(e.message)
  end
}

double = ->(n) { success.call(n * 2) }

# 链式操作
good = bind.call(parse_int.call("42"), &double)
puts good  # { success: true, value: 84 }

bad = bind.call(parse_int.call("abc"), &double)
puts bad   # { success: false, error: "invalid value for Integer()" }

bind 是 Result monad 的核心操作。它在 success 时执行下一步,在 failure 时短路传递错误。这种模式避免了异常控制流,让错误处理变得可见且类型安全。

实际项目中建议使用 dry-monads gem,它提供了完整的 Maybe、Result、Either 等函数式数据结构:

require "dry/monads"

class CreateUser
  extend Dry::Monads[:result, :validation]

  def call(params)
    validate(params).bind do |valid_params|
      save(valid_params)
    end
  end

  def validate(params)
    # 返回 Success(valid_params) 或 Failure(errors)
  end

  def save(params)
    # 返回 Success(user) 或 Failure(error)
  end
end

Result monad 的核心价值是把"可能失败"这件事显式地编码在类型系统中。调用方一眼就能看出这个函数可能失败,而不需要通读文档或源码才知道有哪些异常需要处理。

异常层级树

Ruby 的异常体系是一个严格的继承树。理解这个层级是正确处理异常的前提:

Exception
├── StandardError(rescue 默认捕获)
│   ├── ArgumentError
│   ├── KeyError
│   ├── NoMethodError
│   ├── NameError
│   ├── TypeError
│   ├── RuntimeError
│   ├── FrozenError
│   └── ...
├── SignalException
├── SystemExit
└── Interrupt(Ctrl+C)

规则很简单:永远不要 rescue Exception。这样做会捕获 SystemExitexit 方法)和 Interrupt(Ctrl+C),导致你的程序无法通过正常方式退出。

# 错误示范
begin
  do_something
rescue Exception => e   # 永远不要这样做
  log_error(e)
end

# 正确示范
begin
  do_something
rescue StandardError => e  # 或简写为 rescue => e
  log_error(e)
end

# 如果你确实需要处理特定异常
begin
  do_something
rescue ArgumentError, TypeError => e
  handle_specific(e)
rescue StandardError => e
  handle_general(e)
end

rescue 不加参数时默认捕获 StandardError。大多数情况下这就是你想要的。如果你需要更细粒度的控制,先列出具体异常类型,最后再兜一个 StandardError

清理模式:ensure 和 at_exit

有些操作必须在异常发生时也要执行。文件句柄需要关闭、网络连接需要断开、临时文件需要删除。ensure 块保证无论是否异常都会执行:

resource_demo = -> {
  puts "  获取资源..."
  begin
    raise "处理失败"
  rescue
    puts "  捕获异常"
  ensure
    puts "  ensure: 释放资源(无论是否异常)"
  end
}

ensure 在 rescue 之后执行。即使没有 rescue 块,ensure 也会在执行完 begin 块后运行。这使得它成为资源清理的理想位置。

at_exit 是另一种清理模式,在程序退出时执行:

at_exit do
  puts "程序退出,清理临时文件"
  FileUtils.rm_rf(Dir.tmpdir + "/myapp_*")
end

装饰器式错误处理

对于需要统一处理多个外部调用的场景,可以编写一个通用的错误处理装饰器:

safe_call = ->(description, &block) {
  begin
    result = block.call
    puts "  ✓ #{description}: 成功"
    result
  rescue => e
    puts "  ✗ #{description}: #{e.class} - #{e.message}"
    nil
  end
}

# 使用方式
safe_call.call("请求 /api/users") { fetch_api("/api/users") }
safe_call.call("请求 /api/error") { fetch_api("/api/error") }
safe_call.call("计算 1/0") { 1 / 0 }

这种模式在批量处理外部调用时非常有用。它统一了错误日志格式,避免了每个调用处重复写 begin/rescue 块。

自定义异常

当内置异常类型不足以表达你的业务语义时,可以定义自定义异常类。Ruby 3.4 支持用 Ruby::Enum 定义带有固定取值的异常类型:

class PaymentError < StandardError
  attr_reader :code, :message

  def initialize(code, message)
    @code = code
    @message = message
    super(message)
  end
end

class InsufficientFundsError < PaymentError
  def initialize(amount, balance)
    super(:insufficient_funds, "余额 #{balance} 不足支付 #{amount}")
  end
end

class InvalidCardError < PaymentError
  def initialize(card_last4)
    super(:invalid_card, "卡片尾号 #{card_last4} 已过期")
  end
end

# 使用时精确捕获
begin
  process_payment(100)
rescue InsufficientFundsError => e
  puts "退款原因: #{e.message} (代码: #{e.code})"
rescue InvalidCardError => e
  puts "卡片问题: #{e.message}"
rescue PaymentError => e
  puts "支付失败: #{e.message}"
end

自定义异常继承 StandardError 而不是 Exception。这是一个重要约定。这样其他人在用 rescue => e 捕获错误时不会漏掉你的异常。

本章要点

  • &.(Safe Navigation) 在 nil 时安全跳过方法调用,dig 适合嵌套 Hash 查找
  • Result monad 把错误编码为返回值,避免异常控制流
  • 异常层级:只 rescue StandardError 或其子类,永远不要 rescue Exception
  • ensure 保证资源释放,at_exit 在程序退出时执行清理
  • 装饰器模式 用统一的 begin/rescue 块处理多个外部调用
  • 自定义异常 继承 StandardError,提供业务语义化的错误信息
  • 运行 hello advance error_handling 查看完整示例

测试模式 RSpec

测试是确保代码质量的核心手段。Ruby 生态中最主流的测试框架是 RSpec。它的语法高度接近自然语言,用 describe 组织测试、context 区分场景、it 描述行为。这一章带你从基础结构到高级技巧,全面掌握 RSpec 的使用方式。

注意:这一章讲解的是 RSpec 的语法和设计模式。代码以示例形式展示,实际运行需要 rspec gem。运行 hello advance testing 查看完整示例代码。

describe / context / it:测试结构

RSpec 的测试代码由三个层级组成。describe 定义测试目标(通常是一个类或方法),context 描述前置条件或场景,it 描述具体期望的行为:

RSpec.describe User do
  context "when creating a new user" do
    it "requires a name" do
      user = User.new(name: nil)
      expect(user.valid?).to be false
    end

    it "generates a unique ID" do
      user = User.new(name: "Alice")
      expect(user.id).to be_a(Integer)
    end
  end

  context "when the user already exists" do
    it "does not create a duplicate" do
      User.create!(name: "Alice")
      expect {
        User.create!(name: "Alice")
      }.to raise_error(ActiveRecord::RecordNotUnique)
    end
  end
end

这种结构的威力在于文档性。任何人读测试代码,即使不了解实现细节,也能知道 User 类在所有场景下应该有什么行为。描述性文字是第一优先级:it "..." 中的字符串是测试的文档。

嵌套 context 也是支持的。如果某个行为需要多层条件才能触发,可以用嵌套 context 表达:

RSpec.describe Order do
  context "when payment fails" do
    context "and the user has credits" do
      it "fallbacks to credits" do
        order = create(:order, payment_method: :failed)
        order.process!
        expect(order.reload.status).to eq("paid_by_credits")
      end
    end
  end
end

let / let!:延迟和即时赋值

letlet! 用于在测试中定义辅助变量。两者的区别在于执行时机:

RSpec.describe User do
  # let:懒加载,首次调用时才执行,结果会被缓存
  let(:user) { User.create(name: "Alice") }

  # let!:立即执行(在每个 example 的 setup 阶段)
  let!(:admin) { User.create(name: "Admin", role: :admin) }

  it "reuses the same user object" do
    expect(user.id).to eq(user.id)  # 缓存保证同一对象
  end

  it "admin is already created" do
    # let! 确保 admin 在 it 块执行前已创建
    expect(User.find_by(role: :admin)).to eq(admin)
  end
end

何时用 let!?当你需要在 it 块执行前就把数据写入数据库时。比如测试关联查询,需要确保关联数据存在。何时用 let?当你只需要一个值而不需要副作用时。大多数情况下 let 就够了。

before / after hooks

hooks 在所有 or 部分 example 执行前后运行。RSpec 支持以下 hooks:

RSpec.describe FileProcessor do
  before(:suite) do
    # 整个测试套件运行一次
    setup_global_config
  end

  before(:context) do
    # 每个 describe/context 运行一次
    @shared_resource = allocate_shared_resource
  end

  before(:each) do
    # 每个 example 运行前
    @file = Tempfile.new("test")
  end

  after(:each) do
    # 每个 example 运行后
    @file.close
    @file.unlink
  end

  after(:context) do
    # 每个 describe/context 运行一次
    release_shared_resource(@shared_resource)
  end
end

before(:each) 是最常用的,它保证每个 example 都有干净的初始状态。after 块用于清理资源,即使 example 抛出异常也会执行。

Shared Examples:共享示例

当多个类实现了相同的行为接口时,用 shared_examples 定义测试模板,避免重复代码:

RSpec.shared_examples "a sortable collection" do
  it "sorts in ascending order" do
    expect(collection.sort).to eq(collection.sort.reverse.reverse)
  end

  it "returns self when already sorted" do
    sorted = collection.sort
    expect(sorted.sort).to eq(sorted)
  end

  it "handles empty collection" do
    empty_collection = described_class.new([])
    expect(empty_collection.sort).to be_empty
  end
end

RSpec.describe Array do
  include_examples "a sortable collection" do
    let(:collection) { [3, 1, 2] }
  end
end

RSpec.describe Set do
  include_examples "a sortable collection" do
    let(:collection) { Set[3, 1, 2] }
  end
end

shared_examples 定义了通用行为模板。include_examples 在每个具体类中实例化这个模板,传入 let(:collection) 提供具体的数据集。这样新增加的集合类只需 include 就能自动获得完整的排序测试。

subject:被测试对象

subject 定义了当前测试上下文的"主角"。is_expectedexpect(subject) 的简写:

RSpec.describe Array do
  subject { [1, 2, 3] }

  it { is_expected.to have(3).items }
  it { is_expected.to contain_exactly(1, 2, 3) }

  describe "#first" do
    subject { super().first }
    it { is_expected.to eq(1) }
  end
end

# 具名 subject
RSpec.describe User do
  subject(:admin) { User.new(name: "Admin", role: :admin) }

  it { is_expected.to be_admin }
  it "has all permissions" do
    expect(admin.permissions).to include(:read, :write, :delete)
  end
end

具名 subject(:name) 会在例子中生成一个同名的辅助方法。你可以用 namesubject 访问它。这比每次写 user = 更简洁。

常用匹配器

匹配器是 RSpec 的核心。expect(x).to matcher 是最常见的断言模式:

# 值相等
expect(value).to eq(42)           # 用 == 比较
expect(value).to eql(42)          # 用 eql? 比较(类型也相同)

# 真值判断
expect(value).to be true           # 严格等于 true
expect(value).to be_truthy         # 非 nil 和非 false
expect(value).to be false
expect(value).to be_nil

# 类型检查
expect(value).to be_a(Integer)
expect(value).to be_an(String)
expect(value).to be_a_kind_of(Numeric)

# 集合匹配
expect([1, 2, 3]).to include(2)
expect([1, 2, 3]).to match_array([3, 2, 1])  # 顺序无关
expect({a: 1, b: 2}).to include(a: 1)

# 字符串匹配
expect("hello world").to match(/world/)
expect("hello").to start_with("hel")
expect("hello").to end_with("lo")

# 状态变化
expect { user.destroy }.to change(User, :count).by(-1)
expect { user.save }.to change(user, :valid?).from(false).to(true)

# 异常捕获
expect { 1 / 0 }.to raise_error(ZeroDivisionError)
expect { 1 / 0 }.to raise_error("divided by 0")

# 浮点数容差
expect(Math::PI).to be_within(0.001).of(3.14159)

# 输出捕获
expect { puts "hello" }.to output("hello\n").to_stdout

Mock & Stub:测试替身

测试替身用于隔离外部依赖。RSpec 提供了 double、instance_double、spy 等多种替身类型:

# double:基本替身
email_svc = double("EmailService")
allow(email_svc).to receive(:send).and_return(true)
email_svc.send("hello@ruby.dev")  # 返回 true,不真正发邮件

# instance_double:类型安全的替身
# 会验证 send 方法确实存在于 EmailService 类上
email_svc = instance_double("EmailService")
allow(email_svc).to receive(:send).with("hello@ruby.dev").and_return(true)

# spy:验证调用
api = spy("Api")
api.fetch("/users")
expect(api).to have_received(:fetch).with("/users")

# stub_chain:链式 stub(尽量不用,代码味道)
allow(User).to receive_message_chain(:where, :active, :count).and_return(5)

最佳实践:

  • 优先使用 instance_double,它会在编译时验证方法名是否正确
  • spy 代替 double 来验证调用。spy 允许先调用后验证,代码逻辑更自然
  • 只 stub 外部依赖(网络、数据库、文件系统)。测试自己的代码时,尽量用真实对象
  • 避免 receive_message_chain。链式 stub 意味着你的类依赖了太多内部结构,应该用 delegate 或引入中间层

FactoryBot 集成

在需要大量测试数据的场景,手动创建对象很繁琐。FactoryBot 提供了一套声明式的测试数据工厂:

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name { "Alice" }
    email { "alice@example.com" }
    age { 25 }
    role { :user }

    # trait 定义变体
    trait :admin do
      role { :admin }
    end

    trait :inactive do
      active { false }
    end

    # 关联
    after(:create) do |user|
      create_list(:post, 3, user: user)
    end
  end
end

# 使用工厂
user = create(:user)                    # 创建一个用户
admin = create(:user, :admin)           # 使用 admin trait
inactive_admin = build(:user, :admin, :inactive)  # build 不持久化
many_users = create_list(:user, 10)     # 批量创建

create 持久化到数据库,build 只在内存中。测试中尽量用 build 减少数据库 I/O。只有当需要测试关联查询或数据库层面的约束时才用 create

本章要点

  • describe/context/it 是 RSpec 的三层结构,描述"在什么场景下期望什么行为"
  • let 懒加载缓存,let! 立即执行用于数据准备
  • before/after hooks 在 example 前后执行 setup 和清理
  • shared_examples 定义可复用的行为测试模板
  • subject 定义被测试对象,is_expected 是它的简写
  • 匹配器 覆盖等值、类型、集合、字符串、状态变化、异常、输出等场景
  • double/instance_double/spy 是测试替身的三种主要形式
  • FactoryBot 提供声明式的测试数据工厂,支持 trait 和关联
  • 运行 hello advance testing 查看完整 RSpec 模式示例

dry-system 依赖注入

在大型 Ruby 项目中,对象之间的依赖关系会变得越来越复杂。服务层依赖仓储层,仓储层依赖数据库连接,控制器依赖多个服务。如果这些依赖都是手动创建的,代码很快会变得难以维护。dry-system 是 dry-rb 系列中的依赖注入框架,它用容器管理对象的创建和注入,让你的代码保持清晰和可测试。

dry-system 的哲学与传统 Rails 的全局状态模型不同。它强调显式依赖、不可变配置、和惰性加载。学完这一章你会理解为什么现代 Ruby 项目越来越多地使用 dry-system 替代 Rails 内置的 autoload。

运行 hello advance dry_system 可以查看完整演示代码。

运行示例

本章节的代码可以通过 CLI 运行完整演示:

hello advance dry_system  # 查看 dry-system DI 完整演示

演示包含两部分:

  1. 模拟容器(Section 1-6):DIContainer 展示依赖注入容器内部实现模式,理解 Ruby 如何管理对象生命周期
  2. 真实 Dry::Container(Section 7):Dry::Container + resolve + AutoInject + namespace,生产级用法参考

建议先运行演示代码,再看文档理解原理。

容器设置

依赖注入的核心是一个容器。容器负责管理所有组件的创建和生命周期:

require "dry/system/container"

class Application < Dry::System::Container
  config.root = Pathname("/path/to/app")

  # 定义组件目录
  config.component_dirs.add "lib/services" do |dir|
    dir.auto_register = true
  end

  config.component_dirs.add "lib/repositories" do |dir|
    dir.auto_register = true
  end
end

容器定义了从哪里加载组件和如何加载。component_dirs 声明了组件所在的目录。每个目录可以有自己的注册规则,比如 auto_register = true 表示自动按命名约定注册。

在 Hello Ruby 项目中,容器已经定义好了。你可以在 Awesome 层看到完整的容器配置。

自动注册(Auto-registration)

自动注册是 dry-system 最强大的特性。它会扫描指定目录,根据文件路径和类名的命名约定自动注册组件:

lib/services/
├── email_service.rb        → class EmailService    → 注册为 'email_service'
├── user_service.rb         → class UserService     → 注册为 'user_service'
└── payment_service.rb      → class PaymentService  → 注册为 'payment_service'

lib/repositories/
├── user_repo.rb            → class UserRepo        → 注册为 'user_repo'
└── post_repo.rb            → class PostRepo        → 注册为 'post_repo'

注册后的组件通过容器访问:

# 访问已注册的组件
email_svc = Application["email_service"]
user_repo = Application["user_repo"]

# 组件是单例的,容器中只有一份实例
email_svc2 = Application["email_service"]
email_svc.object_id == email_svc2.object_id  # true

命名约定是干系系统自动注册的基础:

  • 文件名用 snake_case,类名用 PascalCase
  • 目录名映射为命名空间路径
  • 组件的键名等于 snake_case(文件名),不含扩展名

自动注册不需要手动写配置。你只需要在正确的目录下放置文件,容器会自动发现并注册它。

Provider:管理外部资源

组件通常是纯 Ruby 对象,但有些资源需要外部管理,比如数据库连接、HTTP 客户端、日志配置。Provider 允许你在容器启动和关闭时执行自定义逻辑:

Application.register_provider :database do
  start do
    # 容器启动时执行
    target.use :sequel, url: "postgres://localhost/myapp"
  end

  stop do
    # 容器关闭时执行
    target[:database]&.disconnect
  end
end

Application.register_provider :logger do
  start do
    target.register("logger", Logger.new($stdout))
  end
end

注册 Provider 后,组件可以通过容器键访问对应资源:

Application["database"]      # Sequel 数据库连接
Application["logger"]        # Logger 实例

Provider 与自动注册的区别在于:自动注册是基于文件发现的,Provider 是手动注册的。Provider 适合管理那些不需要对应 Ruby 文件的资源。

Import Mixin:注入依赖

注入依赖的最常见方式是使用 Import mixin。它把容器中注册的组件以方法形式注入到类中:

# 定义 Import mixin
module Import
  extend Application.injector
end

# 在具体类中使用
class CreateUserService
  extend Import["user_repo", "email_service", "logger"]

  def call(attrs)
    user = user_repo.create(attrs)
    email_service.welcome(user)
    logger.info("Created user: #{user.email}")
    user
  end
end

Import["key1", "key2"] 在类上定义了 self.key1self.key2 方法,它们从容器中解析对应组件并缓存。这意味着:

  • 依赖是显式声明的。看类定义就知道它依赖什么
  • 依赖是从容器中解析的,不需要手动 new
  • 组件是单例的,多次调用 key1 返回同一实例

在实际项目中,你会把 Import 定义为项目的标准依赖注入方式:

# lib/hello/system/import.rb
module Hello
  module System
    module Import
      extend Application.injector
    end
  end
end

然后所有服务类都用 extend Import[...] 声明依赖。这种模式让依赖关系透明且可测试。

在测试中使用容器

依赖注入的最大好处之一是测试。你可以用 test double 替换容器中的真实组件:

RSpec.describe CreateUserService do
  let(:user_repo) { instance_double(UserRepo, create: user) }
  let(:email_service) { instance_double(EmailService, welcome: true) }
  let(:logger) { instance_double(Logger, info: nil) }

  let(:service) do
    CreateUserService.new(
      container: double(
        "email_service" => email_service,
        "logger" => logger
      )
    ).tap { |s| s.define_singleton_method(:user_repo) { user_repo } }
  end

  it "creates user and sends email" do
    service.call(name: "Alice", email: "alice@example.com")
    expect(email_service).to have_received(:welcome).with(user)
  end
end

在测试中替换容器组件,你就不需要真正连接数据库或发送真实邮件。每个测试只验证当前类的逻辑,隔离了外部依赖。这是单元测试的核心原则。

与 Hello Ruby 项目的集成

在 Hello Ruby 的 Awesome 层,dry-system 容器是整体架构的支柱:

lib/hello/
├── system/
│   ├── container.rb    → Application < Dry::System::Container
│   ├── import.rb       → Application.injector
│   └── provider/
│       └── database.rb → 数据库 Provider
├── services/
│   ├── create_user.rb
│   └── send_email.rb
└── repositories/
    └── user_repo.rb

CLI 命令通过 System::Import 注入服务组件:

class CreateUserCommand
  extend Hello::System::Import["create_user", "logger"]

  def execute(name:, email:)
    result = create_user.call(name: name, email: email)
    logger.info("User created: #{result.email}")
    result
  end
end

这种设计让命令对象本身不包含业务逻辑,只是调度各个服务。业务逻辑在服务中,服务通过依赖注入获得所需的协作组件。

本章要点

  • 容器(Container)管理所有组件的创建和生命周期
  • 自动注册 按文件名和类名约定自动注册组件,免去手动配置
  • Provider 用于管理外部资源(数据库连接、日志等),支持 start/stop 生命周期
  • Import mixin 将容器中的组件以方法形式注入到类中
  • 依赖声明是显式的:extend Import["key1", "key2"] 清楚地列出依赖
  • 依赖注入让测试变得简单:用 test double 替换容器中的真实组件
  • 在 Hello Ruby 项目中,Awesome 层使用 dry-system 构建完整的 DI 架构
  • 运行 hello advance dry_system 查看完整 DI 模式演示

Thor CLI 高级用法

Hello Ruby 的命令行工具基于 Thor 构建。Thor 是 Ruby 生态中最流行的 CLI 框架之一,Rails 的命令行也是用 Thor 实现的。这一章带你深入了解 Thor 的高级功能,包括全局选项、命令专属选项、子命令注册、参数解析和自定义帮助。

掌握 Thor 后,你可以为自己的 Ruby 项目构建功能完整的命令行工具。运行 hello advance cli_advanced 可以查看完整演示代码。

class_option vs method_option

Thor 提供两种选项声明方式:class_optionmethod_option。理解两者的区别是构建复杂 CLI 的第一步。

class_option 定义一个类内所有命令共享的选项。适合全局配置项,比如 --verbose--config

class MyCLI < Thor
  class_option :verbose, type: :boolean, default: false,
                desc: "显示详细信息"
  class_option :config, aliases: ["-c"],
                desc: "指定配置文件路径"

  desc "build", "构建项目"
  def build
    puts "verbose: #{options[:verbose]}"
    puts "config: #{options[:config]}"
  end

  desc "deploy", "部署项目"
  def deploy
    puts "verbose: #{options[:verbose]}"
    # class_option 在所有命令中都可用
  end
end

method_option 只影响单个命令。适合命令专属的配置项:

class MyCLI < Thor
  desc "build [INPUT]", "构建项目"
  method_option :output, aliases: ["-o"], default: "./dist",
                desc: "输出目录"
  method_option :minify, type: :boolean, default: false,
                desc: "压缩输出文件"
  method_option :format, aliases: ["-f"], default: "esm",
                enum: ["esm", "cjs"],
                desc: "输出格式"
  def build(input = ".")
    puts "输入: #{input}"
    puts "输出: #{options[:output]}"
    puts "压缩: #{options[:minify]}"
    puts "格式: #{options[:format]}"
  end
end

调用方式:

mycli build --output ./out --minify --format esm
mycli build -o ./out -f cjs
mycli --verbose build -o ./out

规则很简单:全局共享的用 class_option,单个命令专属的用 method_option

选项类型

Thor 支持多种选项类型,每种类型有不同的解析行为:

class AppCLI < Thor
  # string(默认类型)— 接受任意字符串
  method_option :name, type: :string
  # 用法: --name Alice

  # boolean — 标志位,无需值
  method_option :verbose, type: :boolean, default: false
  # 用法: --verbose(启用) 或 --no-verbose(禁用)

  # numeric — 数字类型,自动转换
  method_option :port, type: :numeric, default: 3000
  # 用法: --port 8080

  # hash — 键值对
  method_option :headers, type: :hash
  # 用法: --headers key1=value1 --headers key2=value2

  # array — 数组,可多次出现
  method_option :files, type: :array
  # 用法: --files a.txt --files b.txt --files c.txt

  # default — 允许 nil,nil 不转默认值
  method_option :timeout, type: :default
  # 用法: --timeout(nil) 或 --timeout 30(30)
end

enum 约束选项只能取特定值。这对于有固定选项的场景非常有用,Thor 会自动校验:

method_option :env, enum: ["dev", "staging", "prod"], default: "dev"
# 如果传入 --env test,Thor 会自动报错并显示有效值

所有选项都在 options hash 中访问,例如 options[:verbose]options[:config]

Subcommands:子命令注册

当命令数量增长时,把所有方法放在一个类中会变得臃肿。Thor 的 register 允许你将子命令拆分到独立的类中:

class AppCLI < Thor
  register(UserCommands, "user", "user [CMD]", "用户管理相关命令")
  register(BuildCommands, "build", "build [CMD]", "构建工具相关命令")
end

class UserCommands < Thor
  desc "create NAME EMAIL", "创建用户"
  def create(name, email)
    puts "创建用户: #{name} <#{email}>"
  end

  desc "delete ID", "删除用户"
  def delete(id)
    puts "删除用户: #{id}"
  end
end

class BuildCommands < Thor
  desc "production", "构建生产版本"
  def production
    puts "构建生产版本..."
  end

  desc "development", "构建开发版本"
  def development
    puts "构建开发版本..."
  end
end

注册后,用户可以这样调用:

app user create Alice alice@example.com
app build production
app user delete 42

register 的四个参数分别是:子命令类、子命令名、横幅描述、简短描述。这种方式让 CLI 的结构保持清晰,每个子命令类只关心自己的命令。

参数解析

Thor 支持多种参数模式:位置参数、剩余参数(splat)、必须参数。

class DeployCLI < Thor
  # 位置参数 — 按顺序映射
  desc "transfer FROM TO AMOUNT", "转账"
  def transfer(from, to, amount)
    puts "从 #{from} 转 #{amount} 到 #{to}"
  end

  # 剩余参数(splat)— 收集所有剩余参数
  desc "add FILES...", "添加文件"
  def add(*files)
    files.each { |f| puts "添加: #{f}" }
  end

  # 必须参数 — 没有默认值 = 必填
  desc "create NAME EMAIL", "创建用户"
  def create(name, email)
    # 缺少参数时 Thor 自动报错并显示帮助
    puts "创建: #{name} <#{email}>"
  end

  # 可选参数 — 有默认值
  desc "greet [NAME]", "打招呼"
  def greet(name = "World")
    puts "Hello, #{name}!"
  end
end

调用方式:

app transfer alice bob 100
app add file1.txt file2.txt file3.txt
app create Alice alice@example.com
app greet Ruby      # Hello, Ruby!
app greet           # Hello, World!

参数也可以通过 args 数组访问。Thor 自动把位置参数映射为方法参数,不需要手动解析。

帮助与文档

Thor 自动生成 --help 输出。你需要提供的是命令描述和长描述:

class MyCLI < Thor
  desc "build [INPUT]", "构建项目"
  long_desc <<-DESC
    将指定目录中的源代码构建为可部署的产物。

    默认输出到 ./dist 目录,可以用 --output 自定义。

    支持的格式: esm, cjs, iife。默认 esm。

    示例:
      mycli build
      mycli build ./src -o ./out --minify
      mycli build --format cjs --verbose
  DESC

  method_option :output, aliases: ["-o"], default: "./dist"
  def build(input = ".")
  end

  # 自定义横幅
  banner "mycli build [OPTIONS]"

  # 短选项映射
  map "-T" => :tasks

  # 隐藏命令(不出现在 --help 中)
  # 使用 method_option 的 hidden: true 或 desc nil
end

生成的帮助输出:

$ mycli --help
Commands:
  mycli build [INPUT]        # 构建项目

$ mycli help build           # 查看 build 命令的详细帮助(显示 long_desc)

desc 提供简短描述,出现在命令列表中。long_desc 提供详细文档,只有在单独查看某个命令的帮助时才显示。map 创建短选项别名。

最佳实践

构建 CLI 工具时遵循以下原则:

  1. 每个命令一个方法,保持方法体简洁。超过 30 行的逻辑应该抽离到独立的服务类。
  2. 用 class_option 共享全局选项,如 --verbose--config--dry-run。这些选项几乎在每个命令中都需要。
  3. 用 method_option 定义命令专属选项,避免无关选项污染其他命令。
  4. 复杂命令抽离为独立类,通过 register 挂载。一个类管理一组相关的命令。
  5. 设置 exit_on_failure? = true。确保未知子命令或参数错误时程序退出而不是静默忽略。
  6. 自定义版本命令。Thor 的默认 --version 输出不够自定义,建议用 desc "version" 重写。
  7. 所有用户输入都校验。如果参数必须是数字、枚举值或文件路径,在方法内部校验而不是依赖 Thor 的默认行为。

本章要点

  • class_option 定义类内所有命令共享的选项
  • method_option 定义单个命令专属的选项
  • 选项类型包括 string、boolean、numeric、hash、array,每种有不同的解析行为
  • register 将子命令注册到独立的类中,保持代码组织清晰
  • 参数模式包括位置参数、剩余参数(splat)、必须参数和可选参数
  • desc + long_desc 生成 --helpthor help COMMAND 的文档输出
  • 每个命令保持方法体简洁,复杂逻辑抽离到服务类
  • 运行 hello advance cli_advanced 查看完整 Thor 高级用法演示

线程与协程

上一章介绍了三种并发原语的基础用法。这一章深入到每个原语的内部机制,包括 GVL 的工作原理、Thread 生命周期管理、Fiber 的协作式调度、Ractor 的消息传递模型,以及它们组合使用的实际模式。理解这些底层细节是写出正确并发程序的前提。

运行 hello advance threads_fibers 可以查看完整演示代码。

GVL:全局虚拟机锁的深度解析

GVL(Global VM Lock,之前叫 GIL)是 Ruby 并发模型的最核心约束。它保证同一时刻只有一个线程在执行 Ruby 字节码,保护 Ruby 的内存管理机制不被并发破坏。

GVL 的影响取决于任务类型。CPU 密集型任务(如计算斐波那契数列、处理大数组排序)会被 GVL 序列化,多线程无法加速。I/O 密集型任务(如等待网络响应、读取文件、查询数据库)不会受到 GVL 限制,因为 I/O 操作期间 GVL 会被释放,其他线程可以继续运行。

验证这个差异:

def fib(n)
  return n if n < 2
  fib(n - 1) + fib(n - 2)
end

# CPU 密集 — 双线程不会更快
[:single, :multi].each do |mode|
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  if mode == :single
    fib(35)
  else
    threads = 2.times.map { Thread.new { fib(35) } }
    threads.each(&:join)
  end
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
  puts "#{mode}: #{(elapsed * 1000).round(1)}ms"
end
# single: ~270ms,  multi: ~280ms(几乎相同,GVL 限制)

# I/O 密集 — 双线程确实更快
[:single, :multi].each do |mode|
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  if mode == :single
    2.times { sleep(0.1) }
  else
    threads = 2.times.map { Thread.new { sleep(0.1) } }
    threads.each(&:join)
  end
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
  puts "#{mode}: #{(elapsed * 1000).round(1)}ms"
end
# single: ~200ms,  multi: ~100ms(并行执行,GVL 被释放)

Ruby 3.0 之后的版本中,I/O 操作和 GVL 的关系更加精细。大部分 I/O 系统调用都会释放 GVL,但某些 C 扩展可能不会。如果你在代码中发现多线程 I/O 仍然很慢,可以检查一下是否有 C 扩展没有正确释放 GVL。

Thread 生命周期

Thread 从创建到结束经历多个状态。理解这些状态对调试并发问题至关重要:

Thread.new do
  # 线程创建后进入运行状态
  sleep(0.5)
  # 休眠时状态为 sleep
  # 返回 "done" 后状态变为 false(终止)
  "done"
end

Thread 的状态通过 Thread#status 查询:

  • "run" — 线程正在运行
  • "sleep" — 线程休眠或等待 I/O
  • "aborting" — 线程正在被终止
  • false — 线程正常终止
  • nil — 线程因异常终止

Thread#value 等待线程完成并返回 block 的最后一个表达式的值。它等价于 join + 获取返回值:

t = Thread.new { "线程返回值" }
puts t.status    # "run"(线程可能还在运行)
result = t.value # 阻塞等待完成并获取返回值
puts t.status    # false(已终止)
puts result      # "线程返回值"

Thread#join 的超时参数允许你等一段时间就放弃:

slow = Thread.new { sleep(10) }
joined = slow.join(0.1)  # 最多等 0.1 秒

if joined
  puts "线程在超时前完成"
else
  puts "超时!线程仍在运行"
  slow.kill   # 终止慢线程
  slow.join   # 等待终止完成
end

Thread#raise 向另一个线程注入异常。用于中断执行中的线程:

worker = Thread.new do
  begin
    sleep(10)
  rescue Interrupt => e
    puts "线程收到中断: #{e.message}"
    "已清理"
  end
end

sleep(0.05)
worker.raise(Interrupt.new("强制中断"))
result = worker.join&.value
puts result  # "已清理"

线程局部变量是 Thread 内部的一个哈希空间,每个线程独立:

Thread.current[:request_id] = "request-42"
Thread.current[:user] = "alice"

Thread.new do
  Thread.current[:request_id] = "other"
  puts Thread.current[:request_id]  # "other"
end.join

puts Thread.current[:request_id]  # "request-42"(不受影响)

Web 框架广泛使用线程局部变量传递请求上下文,比如当前用户ID、请求追踪ID、事务ID 等。

Thread-Safe Queue

Queue 是 Ruby 标准库中线程安全的数据结构。多个线程可以同时 push 和 pop,保证不会丢失数据:

queue = Queue.new

# 生产者
producer = Thread.new do
  5.times do |i|
    item = "item-#{i}"
    queue.push(item)
    puts "生产者 → push #{item}"
    sleep(0.02)
  end
  queue.push(:stop)  # 停止信号
end

# 消费者
consumer = Thread.new do
  results = []
  loop do
    item = queue.pop  # 阻塞直到有数据
    break if item == :stop
    results << item
    puts "消费者 ← pop #{item}"
  end
  results
end

producer.join
puts consumer.value  # ["item-0", "item-1", "item-2", "item-3", "item-4"]

这是经典的生产者-消费者模式。push 向队列添加数据,pop 从队列取出数据(先进先出)。如果队列为空,pop 会阻塞直到有数据可用。Queue.num_waiting 可以查看当前有多少线程在等待数据。

SizedQueue 是 Queue 的变体,有最大容量限制。当队列满时 push 会阻塞:

queue = SizedQueue.new(3)  # 最多 3 个元素
queue.push(1)
queue.push(2)
queue.push(3)
# queue.push(4)  # 阻塞!队列已满,等消费者取出元素

SizedQueue 适合控制生产者速度,防止生产者比消费者快太多导致内存溢出。

Mutex:保护共享状态

当多个线程读写同一个变量时,竞态条件会导致数据不一致。Mutex 通过临界区保护解决这个问题:

# 没有保护 — 竞态条件
counter = { value: 0 }
10.times.map do
  Thread.new do
    1000.times do
      # 读 → 加 1 → 写:三步操作非原子性
      temp = counter[:value]
      counter[:value] = temp + 1  # 可能被其他线程的写覆盖
    end
  end
end.each(&:join)
puts counter[:value]  # 通常小于 10000,数据丢失

# 有保护 — 无竞态
counter = { value: 0 }
mutex = Mutex.new
10.times.map do
  Thread.new do
    1000.times do
      mutex.synchronize do  # 临界区:同时只有一个线程执行
        temp = counter[:value]
        counter[:value] = temp + 1
      end
    end
  end
end.each(&:join)
puts counter[:value]  # 正好 10000

Mutex 的使用规则:

  • 每次读写共享状态都要在 synchronize 块中
  • 不要在临界区内执行长时间操作(I/O、sleep)
  • 死锁的典型场景:线程 A 持有锁 X 要获取锁 Y,线程 B 持有锁 Y 要获取锁 X

Fiber:协作式并发详解

Fiber 是用户态协程,不能自动抢占。通过 Fiber#resume 恢复执行,Fiber.yield 交出控制权。这种协作式设计让 Fiber 极其轻量:

fiber = Fiber.new do
  puts "[Fiber] 开始"
  Fiber.yield 1       # 暂停,返回值 1
  puts "[Fiber] 恢复后继续"
  Fiber.yield 2       # 暂停,返回值 2
  puts "[Fiber] 最后一次"
  3                    # 终止,返回值 3
end

puts fiber.resume     # "[Fiber] 开始" → 1
puts fiber.resume     # "[Fiber] 恢复后继续" → 2
puts fiber.resume     # "[Fiber] 最后一次" → 3
puts fiber.alive?     # false

Fiber 最常见的实际用途是生成器。Enumerator 底层就是 Fiber:

# 无限偶数生成器
evens = Enumerator.new do |yielder|
  i = 0
  loop do
    i += 2
    yielder.yield i
  end
end

puts evens.first(5).inspect  # [2, 4, 6, 8, 10]

Fiber 还可以实现协作式数据管道,将数据依次通过多个处理阶段:

# 阶段1:生成数据
source = Fiber.new do
  [1, 2, 3, 4, 5].each { |n| Fiber.yield n }
end

# 阶段2:处理数据
values = []
loop do
  val = source.resume
  values << val * 2
  break unless source.alive?
end

puts values.inspect  # [2, 4, 6, 8, 10]

Ruby 3.0 后的 Fiber 调度器(Fiber Scheduler)允许你替换默认的 I/O 调度行为,实现类似 async gem 的协作式 I/O。这是现代 Ruby 并发编程的另一个重要方向。

Ractor:消息传递模型

Ractor 通过 sendreceive 交换消息。消息在传递时会做可分享性检查:不可分享的对象(被多个 Ractor 共享的可变对象)会被拒绝:

echo = Ractor.new do
  msg = Ractor.receive  # 阻塞等待消息
  "收到: #{msg}"
end

echo.send("Hello!")
puts echo.take  # "收到: Hello!"

多 Ractor 协作模式:

# 3 个 Ractor 分别处理不同数据范围
datasets = [(1..100), (101..200), (201..300)]
workers = datasets.map { |range| Ractor.new(range) { |r| r.sum } }
results = workers.map(&:take)  # [5050, 15100, 30150]
puts results.sum               # 50300

Ractor 的限制很严格。你不能在 Ractor 之间共享可变对象。如果你确实需要在 Ractor 之间共享数据,可以使用 Ractor.make_shareable 标记对象为不可变,或者用 Ractor::Queue 通过消息传递。

本章要点

  • GVL 序列化 Ruby 字节码执行,I/O 操作会释放 GVL 允许并发
  • Thread.status 返回 "run"、"sleep"、"aborting"、false 或 nil
  • Thread#value 等待完成并获取返回值,Thread#join(timeout) 支持超时
  • Thread#raise 向目标线程注入异常,Thread.current[] 存取线程局部变量
  • Queue 线程安全的生产者-消费者通道,SizedQueue 限制队列容量
  • Mutex 保护临界区,synchronize 保证原子执行
  • Fiber.yield/resume 实现协作式调度,Enumerator 底层是 Fiber
  • Ractor 通过 send/receive/take 做消息传递,不可分享对象会被拒绝
  • 运行 hello advance threads_fibers 查看完整线程与协程演示

性能优化

当你的 Ruby 程序运行缓慢时,你会本能地想去优化代码。但优化的前提是测量。没有数据支撑的优化往往是盲目的,甚至会让性能更差。这一章教你用 Ruby 标准库中的工具测量性能、分析内存分配、理解 GC 行为,然后给出经过验证的优化策略。

性能优化不是早期就应该做的事。先让代码正确运行,再用工具找到真正的瓶颈,最后才针对性地优化。提前优化往往是浪费精力。

运行 hello advance performance 可以查看完整演示代码。

Benchmark 基础

Ruby 标准库中的 Benchmark 模块提供三个方法:Benchmark.measureBenchmark.bmBenchmark.bmbm

require "benchmark"

# 简单的单次测量
time = Benchmark.measure do
  1_000_000.times { "hello" }
end
puts time  # 输出 user, system, total, real 时间

# 对比多个方案(带标签对齐)
n = 50_000
Benchmark.bm(16) do |x|
  x.report("sort")  { Array.new(n) { rand(n + 1) }.sort }
  x.report("sort!") { a = Array.new(n) { rand(n + 1) }; a.sort! }
  x.report("max")   { Array.new(n) { rand(n + 1) }.max }
end

Benchmark.measure 返回一个 Tms 对象,包含字段:u(用户态 CPU 时间)、s(系统态 CPU 时间)、r(实际墙钟时间)。r 是最重要的,因为它反映了真实的等待时间。

Benchmark.bm 在多个方案间做对比。参数 16 是标签列的宽度。输出按行对齐,方便对比。

Benchmark.bmbm 在做真实测量前先执行一轮 rehearse(排练),消除首次运行的 JIT 预热和缓存效应。这是最精确的对比方式:

Benchmark.bmbm(16) do |x|
  x.report("plus")   { sum = 0; 10_000.times { |i| sum += i } }
  x.report("reduce") { (0...10_000).reduce(:+) }
end

bmbm 输出两组数据,下面是正式的对比结果,排除了首次运行的偏差。

字符串拼接优化

字符串操作是最常见的性能瓶颈之一。Ruby 中有多种拼接方式:

n = 10_000
Benchmark.bm(18) do |x|
  # 方式1:+= 每次创建新字符串(最慢)
  x.report("+= concat") do
    s = ""
    n.times { |i| s += "line#{i} " }
  end

  # 方式2:Array#join 预缓冲后一次性连接
  x.report("array.join") do
    arr = Array.new(n) { |i| "line#{i}" }
    arr.join(" ")
  end

  # 方式3:<< 追加到原字符串(最快之一)
  x.report("<< append") do
    s = String.new(capacity: n * 7)
    n.times { |i| s << "line#{i} " }
  end
end

结果规律很明确。+= 最差,因为每次拼接都创建新字符串,旧字符串需要 GC 回收。Array#join<< 都远优于 +=String.new(capacity:) 预分配缓冲区能进一步减少内存分配。

实际建议:少量字符串拼接用 +,中等量用 <<,大量或循环中用 Array#join

Enumerable 性能对比

不同的 Enumerable 方法在性能上有差异:

n = 100_000
data = Array.new(n) { rand(1000) }

Benchmark.bm(22) do |x|
  # 创建新数组
  x.report("map (new array)") { data.map { |i| i * 2 } }

  # 原地修改
  x.report("map! (in-place)") { data.dup.map! { |i| i * 2 } }

  # 构建 hash
  x.report("each_with_object") { data.each_with_object({}) { |i, h| h[i] = i * 2 } }

  x.report("each + hash assign") { h = {}; data.each { |i| h[i] = i * 2 } }
end

map!map 快,因为不创建新数组。each_with_objecteach + hash assign 速度接近,但前者语义更清晰。

对象分配分析

ObjectSpace.count_objects 可以统计当前堆中各类型对象的数量。结合 GC.stat 可以分析 GC 行为:

require "objspace"

# 统计创建对象的数量
count_before = ObjectSpace.count_objects[:T_STRING]
10_000.times { |i| "object_#{i}".upcase }
count_after = ObjectSpace.count_objects[:T_STRING]
puts "循环创建字符串: 新增 ~#{count_after - count_before} 个 T_STRING"

# 复用 buffer 减少分配
buffer = String.new
count_before = ObjectSpace.count_objects[:T_STRING]
10_000.times do |i|
  buffer.clear
  buffer << "object_#{i}"
  buffer.upcase
end
count_after = ObjectSpace.count_objects[:T_STRING]
puts "复用 buffer:    新增 ~#{count_after - count_before} 个 T_STRING"

复用一个 buffer 字符串,比每次创建新的字符串减少大量 T_STRING 分配。对于热点路径上的代码,这种优化效果显著。

GC 统计

GC.stat 返回垃圾回收的详细统计:

stats = GC.stat
puts "GC 运行次数: #{stats[:count]}"
puts "GC 总耗时: #{stats[:total_time].round(4)}s"
puts "活跃对象数: #{stats[:heap_live_slots]}"
puts "空闲对象数: #{stats[:heap_free_slots]}"
puts "新分配对象数: #{stats[:heap_allocated_slots]}"

监控这些指标可以帮助判断性能瓶颈是否是 GC 压力过大。如果 total_time 占比很大,说明 GC 频繁运行,可能需要减少对象分配。

字符串冻结优化

Ruby 3.0 默认启用了 frozen_string_literal: true。冻结的字符串字面量在内存中只有一份拷贝,相同内容共享同一对象:

# frozen_string_literal: true

s1 = "hello_ruby"
s2 = "hello_ruby"
puts s1.object_id == s2.object_id  # true,共享内存

手动冻结也一样:
s3 = "manual_freeze".freeze
s4 = "manual_freeze".freeze
puts s3.object_id == s4.object_id  # true

冻结字符串减少内存占用,也避免了不必要的 dup 操作。这是 Ruby 3.x 最重要的内存优化之一。

惰性 vs 立即求值

当处理大数据集时,惰性的 lazy 可以大幅减少计算量:

n = 1_000_000

Benchmark.bm(22) do |x|
  # 立即求值
  x.report("eager chain") do
    (1..n).map { |i| i * 2 }.select(&:even?).take(10)
  end

  # 惰性求值
  x.report("lazy chain") do
    (1..n).lazy.map { |i| i * 2 }.select(&:even?).take(10).force
  end
end

立即求值的链路是:map 处理全部 100 万个元素 → select 过滤 → take 取 10 个。惰性求值的链路是:逐元素流过 map → select → take,取够 10 个后整个链路的计算全部停止。

处理大规模或无限数据时,lazy 带来的性能提升不是线性的,而是数量级的。

Array 预分配

动态扩容的数组在每次容量不足时都要重新分配内存并复制数据。预分配可以避免这个开销:

n = 50_000
Benchmark.bm(14) do |x|
  x.report("push dynamic") { arr = []; n.times { |i| arr << i } }
  x.report("pre-allocated") { arr = Array.new(n) { |i| i } }
end

预分配在数据量大于 10 万条时效果明显。数据量小时差异不大,可以优先考虑代码可读性而非性能。

map vs map!

map 创建新数组,map! 原地修改。两者的性能差异在于内存分配:

data = Array.new(100_000) { rand(1000) }

# map: 分配新数组 + 遍历
result = data.map { |i| i * 2 }  # 原始 data 不变

# map!: 原地修改,不分配新数组
data.map! { |i| i * 2 }          # data 被修改

如果不需要保留原始数组,用 map! 减少内存分配。如果需要保留原始数据,用 map。这是一个安全性和性能之间的权衡。

热路径优化原则

性能优化的黄金法则是:先测量,再优化。盲目猜测瓶颈位置往往猜错。以下是经过验证的通用优化策略:

  1. 减少中间对象分配。每次 mapselect 都创建新数组。用 map!lazy、原地操作减少分配。
  2. 字符串用 <<join。不要用 +=。大字符串用 String.new(capacity:) 预分配。
  3. 数组预分配。已知大小时用 Array.new(n) 而非空的 <<
  4. lazy 处理大数据集。当最终结果远小于数据集时,惰性求值减少大量不必要的计算。
  5. 冻结字符串frozen_string_literal: true 是默认推荐,共享内存减少分配。
  6. reduce(:+) 求和。比手动循环和 each 构建中间结果更快。

优化之前始终用 Benchmark.bmbm 测量,确保优化确实带来了提升。

本章要点

  • Benchmark.measure 测量单次执行时间,bm 对比多个方案,bmbm 带排练预热
  • ObjectSpace.count_objects 按类型统计堆内对象数,分析内存分配
  • GC.stat 提供 GC 运行次数、总耗时、活跃/空闲对象数
  • 字符串拼接<<Array#join 远优于 +=
  • 惰性求值 lazy 处理大数据集时只需计算到结果满足即可停止
  • 字符串冻结 共享相同内容的内存,减少重复对象
  • 数组预分配 Array.new(n) 减少动态扩容的内存重新分配
  • map! 原地修改 比 map 创建新数组少一次内存分配
  • 优化原则:先测量找到瓶颈,再针对性优化,最后再测量验证效果
  • 运行 hello advance performance 查看完整性能测量和对比演示

阶段复习:高级进阶

完成所有 Advance 章节后,你已经掌握了 Ruby 的核心高级特性。这一章不是新的知识点,而是带你把前面学过的内容串联起来,综合运用元编程、依赖注入、测试等技能,从零设计并实现一个小服务。

复习的目的是检验学习成果。如果你能独立完成本章的所有练习,说明你已经准备好进入 Awesome 层面的实战了。

复习目标

构建一个用户注册服务,综合运用以下技能。

  1. 使用元编程动态生成验证规则
  2. 使用 dry-system 的依赖注入模式管理服务依赖
  3. 使用 RSpec 编写完整的单元测试
  4. 使用 Thor 构建命令行接口
  5. 合理处理错误和异常

练习一:元编程验证器

从元编程章节我们知道 method_missingclass_eval 可以实现声明式 DSL。实现一个 Validator 类,支持如下语法:

class UserValidator
  extend ValidatorDSL

  requires :name, String
  requires :email, String
  validates_length :name, min: 2, max: 50
  validates_format :email, /@/

  def validate(attrs)
    errors = []
    self.class.validation_rules.each do |rule|
      error = rule.call(attrs)
      errors << error if error
    end
    errors.empty? ? nil : errors
  end
end

validator = UserValidator.new
result = validator.validate(name: "Al", email: "invalid")
puts result  # 包含两个错误信息

提示:ValidatorDSLextended 回调注入 requiresvalidates_length 等类方法。这些方法收集规则存储到 @validation_rules 数组中。

验证点:

  • extended 回调正确注入 DSL 方法
  • 验证规则按声明顺序保存和执行
  • 每个规则返回错误信息或 nil
  • 最终 validate 汇总所有错误

练习二:依赖注入

为练习一的验证器添加依赖注入。假设 UserValidator 依赖一个日志记录器:

module Import
  # 从你的容器中注入
  extend Application.injector
end

class CreateUser
  extend Import["validator", "logger", "storage"]

  def call(attrs)
    errors = validator.validate(attrs)
    return { success: false, errors: errors } if errors

    user = storage.create(attrs)
    logger.info("用户已创建: #{user[:name]}")
    { success: true, user: user }
  end
end

要求:

  • Import mixin 注入三个依赖:validatorloggerstorage
  • CreateUser 不包含任何 new 调用,所有依赖从容器注入
  • 调用流程:验证 → 存储 → 日志 → 返回结果

验证点:

  • Import 的方法正确解析容器中的组件
  • CreateUser 的调用结果正确区分成功和失败两种情况
  • 日志记录只在创建成功时执行

练习三:测试驱动

CreateUser 编写 RSpec 测试。使用 instance_double 创建测试替身:

RSpec.describe CreateUser do
  let(:validator) { instance_double(UserValidator) }
  let(:logger) { instance_double(Logger) }
  let(:storage) { instance_double(Storage) }
  let(:service) { CreateUser.new(container: double("validator" => validator, "logger" => logger, "storage" => storage)) }

  context "当输入有效时" do
    before do
      allow(validator).to receive(:validate).with({name: "Alice"}).and_return(nil)
      allow(storage).to receive(:create).with({name: "Alice"}).and_return({id: 1, name: "Alice"})
      allow(logger).to receive(:info)
    end

    it "创建用户并返回成功结果" do
      result = service.call(name: "Alice")
      expect(result[:success]).to be true
      expect(result[:user][:id]).to eq(1)
      expect(logger).to have_received(:info).with(/用户已创建/)
    end
  end

  context "当输入无效时" do
    before do
      allow(validator).to receive(:validate).with({name: ""}).and_return(["name 不能为空"])
    end

    it "返回错误且不创建用户" do
      result = service.call(name: "")
      expect(result[:success]).to be false
      expect(result[:errors]).to eq(["name 不能为空"])
      expect(storage).not_to have_received(:create)
    end
  end
end

验证点:

  • 分别测试成功路径和失败路径
  • instance_double 而非普通 double,获得类型安全
  • have_received spy 模式验证副作用
  • 失败路径确认 storage.create 没有被调用

练习四:Thor CLI

为服务添加命令行接口:

class UserCLI < Thor
  class_option :verbose, type: :boolean, default: false

  desc "create NAME EMAIL", "创建用户"
  method_option :email, required: true, desc: "用户邮箱"
  def create(name)
    email = options[:email]
    result = create_user.call(name: name, email: email)

    if result[:success]
      puts "用户创建成功: #{result[:user][:name]}"
    else
      puts "创建失败:"
      result[:errors]&.each { |e| puts "  - #{e}" }
    end
  end

  desc "list", "列出所有用户"
  def list
    users = storage.list
    users.each { |u| puts "  #{u[:id]}: #{u[:name]} <#{u[:email]}>" }
  end
end

运行方式:

user-cli create Alice --email alice@example.com
user-cli --verbose list

验证点:

  • class_option 定义全局的 --verbose 选项
  • method_option 定义命令专属选项
  • 位置参数 name 是必填的
  • 错误输出格式清晰,每条错误一行

练习五:错误处理

为整个服务添加健壮的错误处理:

class RegisterService
  extend Import["validator", "logger", "storage"]

  def call(attrs)
    validate(attrs).bind do |valid_attrs|
      save(valid_attrs)
    end
  rescue => e
    logger.error("注册服务异常: #{e.message}")
    { success: false, error: "服务内部错误" }
  end

  def validate(attrs)
    errors = validator.validate(attrs)
    errors ? failure(errors) : success(attrs)
  end

  def save(attrs)
    user = storage.with_transaction { storage.create(attrs) }
    success(user)
  rescue StorageError => e
    logger.error("存储失败: #{e.message}")
    failure(["保存用户失败,请稍后重试"])
  end
end

验证点:

  • 顶层 rescue 捕获所有未预期的异常,返回通用错误信息
  • validate 用 Result monad 模式处理验证失败
  • save 只捕获 StorageError,不捕获所有异常
  • 所有错误路径都有日志记录

综合评分标准

完成所有练习后,对照以下标准自评:

维度合格优秀
元编程DSL 方法能正确收集验证规则规则支持嵌套、组合,可复用
DI 设计所有依赖通过 Import 注入容器配置清晰,Provider 管理外部资源
测试覆盖成功路径和失败路径都有测试边界情况和异常路径也有测试
CLI 设计选项和参数正确解析帮助文档完整,用户体验友好
错误处理外部异常有顶层 catch分层错误处理,每层职责清晰

如果你的所有维度都达到"优秀"标准,你已经具备了进入 Awesome 层的能力。Awesome 层会用干系系统构建一个完整的生产级项目,包括 Rails 控制器、Sidekiq 后台任务、REST API、Docker 部署。那些内容会在这里的基础之上,进一步展示工业级的 Ruby 工程实践。

实战精选 (Awesome)

Awesome 层级展示生产环境级别的 Ruby 应用架构与工程实践。

本层级目标

  • 理解真实项目中的依赖管理与 DI 模式
  • 掌握 Web 服务、REST API、消息队列等生产架构
  • 学习 Docker 部署与 CI/CD 流水线配置

与 Basic / Advance 的区别

维度BasicAdvanceAwesome
目标读者初学者中级开发者高级工程师
代码复杂度单一文件,stdlib多模块,少量 gem完整工程,生产 gem 栈
运行方式hello basic TOPIChello advance TOPIC独立服务/部署

内容规划

🔧 Awesome 层级内容正在建设中,包含以下主题:

  • 数据库高级应用(连接池优化、迁移策略、复杂查询)
  • 微服务架构(服务拆分、API 网关、服务发现)
  • 依赖注入进阶(dry-system provider 定制、多环境配置)
  • 消息队列(Sidekiq 集成、Redis 队列、重试策略)
  • 模板引擎与视图渲染(ERB、Slim、布局系统)

运行前置要求

Awesome 层级示例需要完整的生产 gem 栈:

bundle install
bundle exec hello awesome

💡 提示: Awesome 层级当前为占位阶段。完成 Advance 层级后,可参考 hello-rust Awesome 部分 获取生产架构灵感。

继续学习

Sinatra — 轻量级 REST 微服务框架

Sinatra 是 Ruby 世界最著名的微框架。它相当于 Python 的 Flask/FastAPI——用最少的代码快速搭建 RESTful API。与 Rails 的"大而全"不同,Sinatra 只提供路由和请求/响应处理,让你从零开始组装自己的技术栈。

为什么选择 Sinatra?

  • 极简主义:一个文件、几行代码就能启动 HTTP 服务
  • 灵活性:不强制任何架构约定,你可以自由组合 ORM、模板引擎等
  • 微服务友好:轻量级,适合构建独立的小服务
  • 易于理解:没有 Rails 那样的魔法层,请求到响应的流程一目了然

运行示例

本章节的代码可以通过 CLI 运行完整演示:

hello awesome sinatra  # 查看 Sinatra REST API 完整演示

演示包含两部分:

  1. 真实 Sinatra REST API(Section 1):RealTaskAPI - Sinatra::Base + Sequel + Rack::Test,生产级用法参考(推荐)
  2. 内存模拟(Section 2):MemoryTaskStore + TaskApp 展示 REST API 内部实现模式,理解 Sinatra 路由原理

建议先运行演示代码,再看文档理解原理。

第一个 Sinatra 应用

require "sinatra"

get "/" do
  "Hello, Sinatra!"
end

get "/hello/:name" do
  "你好,#{params['name']}!"
end

post "/api/users" do
  request.body.rewind
  data = JSON.parse(request.body.read)
  content_type :json
  { user: data }.to_json
end

运行方式:

$ bundle exec ruby app.rb
# 默认启动 http://localhost:4567

路由系统

Sinatra 的路由非常直观,支持所有 HTTP 方法:

# GET 请求
get "/users" do
  # 列出所有用户
end

# POST 请求
post "/users" do
  # 创建用户
end

# PUT 请求
put "/users/:id" do
  # 更新用户
end

# DELETE 请求
delete "/users/:id" do
  # 删除用户
end

# 带参数的路由
get "/posts/:year/:month/:slug" do
  year  = params['year']
  month = params['month']
  slug  = params['slug']
end

# 正则表达式路由
get %r{/items/([\w]+)} do
  capture_groups[0]  # 访问正则匹配的第一个分组
end

# 带条件的路由
get "/api/v2/data", provides: :json do
  { version: "v2" }.to_json
end

请求处理

# 获取请求信息
get "/request-info" do
  request_method = request.request_method
  path           = request.path_info
  ip             = request.ip
  user_agent     = request.user_agent
  
  "Method: #{request_method}, Path: #{path}, IP: #{ip}"
end

# 处理 JSON 请求体
post "/api/data" do
  content_type :json
  request.body.rewind
  payload = JSON.parse(request.body.read)
  { status: "ok", received: payload }.to_json
end

# 重定向
get "/old-page" do
  redirect "/new-page"
end

# 停止执行
get "/forbidden" do
  halt 403, "你没有访问权限"
end

过滤器(Before/After)

# 请求前执行
before do
  content_type :json
  @start_time = Time.now
end

# 指定路径的过滤器
before "/api/*" do
  authenticate! unless request.path == "/api/health"
end

# 请求后执行
after do
  elapsed = Time.now - @start_time
  logger.info "请求耗时: #{elapsed}ms"
end

完整示例:REST API

require "sinatra"
require "json"

class App < Sinatra::Base
  set :bind, "0.0.0.0"
  set :port, 4567

  # 内存数据库(实际项目用 Sequel/ActiveRecord)
  @tasks = [
    { id: 1, title: "Learning Ruby", status: "in_progress" },
    { id: 2, title: "Building API",   status: "pending" }
  ]
  @next_id = 3

  # 列出所有任务
  get "/api/tasks" do
    { tasks: @tasks }.to_json
  end

  # 获取单个任务
  get "/api/tasks/:id" do
    task = @tasks.find { |t| t["id"] == params["id"].to_i }
    halt 404, { error: "Task not found" }.to_json unless task
    task.to_json
  end

  # 创建任务
  post "/api/tasks" do
    request.body.rewind
    body = JSON.parse(request.body.read)
    new_task = {
      "id" => (@next_id += 1),
      "title" => body["title"],
      "status" => "pending"
    }
    @tasks << new_task
    status 201
    new_task.to_json
  end

  # 更新任务
  put "/api/tasks/:id" do
    task = @tasks.find { |t| t["id"] == params["id"].to_i }
    halt 404, { error: "Task not found" }.to_json unless task
    request.body.rewind
    body = JSON.parse(request.body.read)
    task["title"] = body["title"] if body["title"]
    task["status"] = body["status"] if body["status"]
    task.to_json
  end

  # 删除任务
  delete "/api/tasks/:id" do
    task = @tasks.find { |t| t["id"] == params["id"].to_i }
    halt 404, { error: "Task not found" }.to_json unless task
    @tasks.delete(task)
    status 204
    ""
  end

  # 健康检查
  get "/health" do
    { status: "ok", version: "1.0.0" }.to_json
  end
end

App.run!

测试 API:

# 列出任务
$ curl http://localhost:4567/api/tasks

# 创建任务
$ curl -X POST http://localhost:4567/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "New Task"}'

部署

Sinatra 应用可通过多种方式部署:

  • Puma:推荐的生产服务器,支持多线程

    # config.ru
    require_relative "./app"
    run App
    
    $ bundle exec puma -p 3000
    
  • Passenger:Nginx 集成的部署方案

  • Docker:轻量级容器化部署,非常适合微服务

与 FastAPI 对比

特性Sinatra (Ruby)FastAPI (Python)
路由定义DSL get/post/put/delete装饰器 @app.get/post
请求参数params['name']函数参数 + Pydantic
JSON 响应手动 .to_json自动序列化
OpenAPI 文档需要 rswag gem自动生成
异步支持需要 async-sinatra原生 async/await
启动速度~1s~0.5s

本章要点

  • Sinatra 是 Ruby 微服务的首选,相当于 Python Flask/FastAPI
  • 路由系统直观,支持所有 HTTP 方法和参数模式
  • Before/After 过滤器实现中间件模式
  • 生产环境使用 Puma 作为服务器
  • 适合:小型服务、API 后端、原型快速验证
  • 不适合:需要完整 ORM、模板、邮件等功能的复杂 Web 应用 → 选择 Rails 或 Hanami

继续学习

💡 提示:Sinatra 的核心理念是"少即是多"。当你发现自己在 Sinatra 中手写越来越多的基础设施(数据库连接池、模板引擎、邮件发送),可能就是升级到 Rails 或 Hanami 的时候了。

Hanami — 干净架构的现代 Ruby 框架

Hanami 是 Ruby 世界中 Rails 最有力的竞争者。如果你认为 Rails 的"魔法"让代码变得难以追踪,Hanami 提供了另一种思路:干净架构、领域驱动设计、明确的依赖关系。它不是一个更小的 Rails,而是一个完全不同的哲学。

Rails 在 2004 年重新定义了 Web 开发,但近二十年后,许多开发者开始感到疲惫。ORM 隐含了 SQL 查询的行为,控制器混入了视图逻辑,全局状态让测试变得困难。Hanami 2.x 的回应是:拆分层,让每个部分只做一件事。

为什么选择 Hanami?

  • 比 Rails 快 3 倍:启动时间不到 1 秒,请求延迟更低,内存占用更少
  • 干净架构:Actions、Views、Repositories、Entities 严格分离,不存在"胖模型"
  • 领域驱动设计:Entities 是纯 Ruby 对象,不包含 ORM 行为,数据库访问通过 Repositories 完成
  • 原生 DI:深度集成 dry-system,依赖注入是一等公民
  • 类型安全:与 dry-validation 配合,参数验证不再是事后补充

选择 Hanami 的场景:你正在构建一个中长期项目,团队规模在 3 人以上,需要清晰的代码结构和可测试性。选择 Rails 的场景:你需要在一周内搭出 MVP,或者团队中很多人已经熟悉 Rails 约定。

运行示例

本章节的代码可以通过 CLI 运行完整演示:

hello awesome hanami  # 查看 Hanami + dry-validation 完整演示

演示包含两部分:

  1. 内存模拟(Section 1-7):MemoryTaskStore 展示 Hanami Action 内部实现模式,理解 Ruby 如何分离业务逻辑
  2. 真实 dry-validation + Hanami(Section 8):dry-validation Schema + Hanami::Action patterns + params validation,生产级用法参考

建议先运行演示代码,再看文档理解原理。

项目结构

Hanami 生成的项目结构与 Rails 类似,但每个目录的职责更加明确:

my_app/
├── config/
│   ├── settings.rb            # 应用设置
│   └── locales/               # 国际化
├── lib/
│   ├── my_app/
│   │   └── settings.rb        # 自定义设置类
│   └── tasks/                 # Rake 任务
├── apps/
│   └── web/                   # Web 层(可以多个 app)
│       ├── controllers/       # Actions:处理 HTTP 请求
│       │   ├── home/
│       │   │   └── index.rb
│       │   └── posts/
│       │       ├── create.rb
│       │       ├── index.rb
│       │       └── show.rb
│       ├── views/             # Views:渲染数据(不含业务逻辑)
│       │   ├── home/
│       │   │   └── index.rb
│       │   └── posts/
│       │       ├── index.rb
│       │       └── show.rb
│       ├── templates/         # ERB 模板
│       │   ├── home/
│       │   │   └── index.html.erb
│       │   └── posts/
│       │       ├── index.html.erb
│       │       └── show.html.erb
│       └── routes.rb          # 路由定义
├── config/
│   └── routes.rb              # 全局路由
├── db/
│   └── migrations/            # 数据库迁移
├── lib/
│   └── my_app/
│       ├── entities/          # Entities:纯 Ruby 领域对象
│       │   └── post.rb
│       └── repositories/      # Repositories:数据访问层
│           └── post_repository.rb
├── config.ru
├── Gemfile
└── config.ru

关键区别:

  • Controllers 和 Views 在不同目录。Rails 的 app/views/posts/show.html.erb 在 Hanami 中拆分为 apps/web/views/posts/show.rb(数据准备)和 apps/web/templates/posts/show.html.erb(HTML 渲染)
  • Entities 不在 app/models。领域对象是纯 Ruby 对象,通过 Repositories 持久化
  • 每个 app 独立。你可以在同一个项目中运行 web(HTML)和 api(JSON)两个独立应用

Actions(控制器)

Hanami 的 controller 叫 Action,每个 action 是一个独立的类。这种方式让每个 HTTP 端点的行为一目了然。

# typed: true
# frozen_string_literal: true

module Web
  module Controllers
    module Posts
      class Index
        include Web::Action

        expose :posts

        def call(params)
          @posts = repositories.posts.all
        end
      end
    end
  end
end

每个 action 类只做一件事:接收请求参数,调用仓库层获取数据,通过 expose 暴露给 View。没有实例变量泄漏,没有隐含的全局状态。

带参数验证的 action:

# typed: true
# frozen_string_literal: true

module Web
  module Controllers
    module Posts
      class Create
        include Web::Action
        include Web::ApiOnly  # 如果是 API,返回 JSON

        params do
          required(:title).filled(:string)
          required(:body).filled(:string)
          optional(:status).value(included_in?: %w[draft published archived])
        end

        def call(params)
          if params.valid?
            post = repositories.posts.create(params.to_h)
            self.status = 201
            self.body = post.to_json
          else
            self.status = 422
            self.body = { errors: params.errors }.to_json
          end
        end
      end
    end
  end
end

参数验证通过 params DSL 声明,Hanami 使用 dry-validation 在后台生成验证逻辑。无效参数自动返回结构化错误,不需要手动检查。

路由系统

Hanami 的路由定义在 apps/web/routes.rb 中,语法与 Rails 类似但更简洁:

# apps/web/routes.rb
get "/", to: "home#index"
resources :posts, only: [:index, :show, :create, :update, :destroy]
get "/posts/:id/edit", to: "posts#edit"

支持命名空间和版本控制:

namespace :admin do
  resources :users
  resources :posts
end

scope path: "api/v1", as: "api_v1" do
  resources :posts, only: [:index, :show]
end

Repositories(数据访问层)

这是 Hanami 与 Rails 最本质的区别。Rails 的 ActiveRecord 把查询和数据操作混在同一个对象里。Hanami 拆成了两半:Entity 是纯数据,Repository 负责所有数据库操作。

# lib/my_app/entities/post.rb
# typed: true
# frozen_string_literal: true

module My
  module Entities
    class Post < Hanami::Entity
      attribute :id,          Types::Int
      attribute :title,       Types::String
      attribute :body,        Types::String
      attribute :status,      Types::String
      attribute :created_at,  Types::Time
      attribute :updated_at,  Types::Time
    end
  end
end

Entity 只是数据容器,没有任何查询方法。查询通过 Repository 完成:

# lib/my_app/repositories/post_repository.rb
# typed: true
# frozen_string_literal: true

module My
  module Repositories
    class PostRepository
      include Hanami::Repository

      def published
        posts.where(status: "published").order(:created_at)
      end

      def find_by_slug(slug)
        posts.where(slug: slug).map_to(My::Entities::Post).one
      end

      def create_with_tags(post_attrs, tag_ids)
        # 事务内创建文章和标签关联
        transaction do
          post = posts.create(post_attrs)
          tag_ids.each do |tag_id|
            post_tags.create(post_id: post.id, tag_id: tag_id)
          end
          post
        end
      end

      private

      def posts
        relation(:posts)
      end

      def post_tags
        association_repo(:post_tags)
      end
    end
  end
end

Repository 使用 Sequel 作为底层 ORM,支持复杂的关联查询和事务操作。关联关系通过 associations 在 relation 层声明:

# db/migrations/20240101000000_create_posts.rb
# typed: true
# frozen_string_literal: true

ROM::SQL.migration do
  change do
    create_table :posts do
      primary_key :id
      column :title, String, null: false
      column :body, Text, null: false
      column :slug, String, null: false, unique: true
      column :status, String, null: false, default: "draft"
      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end

Views(视图层)

Hanami 的 View 层不包含业务逻辑,只负责数据格式化和模板渲染:

# apps/web/views/posts/index.rb
# typed: true
# frozen_string_literal: true

module Web
  module Views
    module Posts
      class Index
        include Web::View

        # 格式化日期
        def formatted_date(date)
          date.strftime("%Y年%m月%d日")
        end

        # 生成文章摘要
        def summary(post, max_length: 100)
          text = post.body.to_s
          if text.length > max_length
            "#{text[0...max_length]}..."
          else
            text
          end
        end
      end
    end
  end
end

对应的 ERB 模板只处理 HTML:

<%# apps/web/templates/posts/index.html.erb %>
<h1>文章列表</h1>

<ul>
<% @posts.each do |post| %>
  <li>
    <h2><a href="<%= url(:post, id: post.id) %>"><%= post.title %></a></h2>
    <p class="date"><%= formatted_date(post.created_at) %></p>
    <p class="summary"><%= summary(post) %></p>
  </li>
<% end %>
</ul>

依赖注入(Dry-System 集成)

Hanami 在底层使用 dry-system 管理所有依赖。这意味你可以明确控制每个对象如何创建、如何注入:

# config/settings.rb
# typed: true
# frozen_string_literal: true

module My
  class Settings < Hanami::Settings
    setting :database_url, constructor: Types::String
    setting :redis_url, constructor: Types::String, default: "redis://localhost:6379"
    setting :secret_key_base, constructor: Types::String
    setting :cache_store, constructor: Types::Symbol, default: :memory
  end
end

在 action 中注入自定义服务:

# apps/web/controllers/posts/ publish.rb
# typed: true
# frozen_string_literal: true

module Web
  module Controllers
    module Posts
      class Publish
        include Web::Action
        inject "services.publish_notifier"

        params do
          required(:id).filled(:integer)
        end

        def call(params)
          post = repositories.posts.find(params[:id])
          post.update(status: "published")
          services.publish_notifier.call(post)
          redirect_to url(:post, id: post.id)
        end
      end
    end
  end
end

服务组件通过 dry-system 自动注册:

# lib/my_app/services/publish_notifier.rb
# typed: true
# frozen_string_literal: true

module My
  module Services
    class PublishNotifier
      def call(post)
        # 发布通知逻辑
        puts "通知订阅者:新文章 #{post.title}"
      end
    end
  end
end

如果你对 dry-system 还不熟悉,建议先阅读 依赖注入进阶 章节。

完整 CRUD 示例

下面是用 Hanami 构建一个完整博客 CRUD 的最简配置:

1. 定义路由

# apps/web/routes.rb
get "/", to: "home#index"
resources :posts, only: [:index, :show, :new, :create, :edit, :update, :destroy]

2. Action 层

# apps/web/controllers/posts/index.rb
module Web
  module Controllers
    module Posts
      class Index
        include Web::Action
        expose :posts

        def call(params)
          @posts = repositories.posts.published
        end
      end
    end
  end
end
# apps/web/controllers/posts/create.rb
module Web
  module Controllers
    module Posts
      class Create
        include Web::Action

        params do
          required(:title).filled(:string)
          required(:body).filled(:string)
          optional(:status).value(included_in?: %w[draft published])
        end

        def call(params)
          if params.valid?
            post = repositories.posts.create(params.to_h)
            redirect_to url(:post, id: post.id)
          else
            @post = params.to_h
            halt 422
          end
        end
      end
    end
  end
end

3. View 层

# apps/web/views/posts/show.rb
module Web
  module Views
    module Posts
      class Show
        include Web::View
      end
    end
  end
end

4. 模板

<%# apps/web/templates/posts/show.html.erb %>
<article>
  <h1><%= post.title %></h1>
  <time><%= post.created_at.strftime("%Y-%m-%d") %></time>
  <div class="body"><%= post.body %></div>
  <p>Status: <%= post.status %></p>
</article>

<p>
  <a href="<%= url(:edit_post, id: post.id) %>">编辑</a> |
  <form action="<%= url(:post, id: post.id) %>" method="post" style="display:inline">
    <input type="hidden" name="_method" value="delete">
    <button type="submit">删除</button>
  </form>
</p>

性能对比

以下是社区基准测试的典型数据(单请求响应时间,越低越好):

框架启动时间简单请求数据库查询内存占用
Rails 7~2.5s~45ms~120ms~180MB
Hanami 2~0.4s~15ms~40ms~65MB
Sinatra~0.2s~8ms~35ms~30MB

Hanami 在启动速度和内存上比 Rails 有显著优势,同时保持了接近 Rails 的开发体验。Sinatra 更快更轻,但没有 Hanami 的结构化架构。

何时选择 Hanami

选择 Hanami 的情况:

  • 项目生命周期超过 6 个月
  • 团队 3 人以上,需要明确的代码组织规则
  • 领域模型复杂,需要 DDD 模式
  • 对性能有要求,尤其是容器化部署场景(快速启动很关键)

选择 Rails 的情况:

  • 项目需要在 1-2 周内上线
  • 团队中大多数成员熟悉 Rails
  • 需要大量现成的 gem 生态(后台管理、支付集成等)
  • 快速验证创业想法

本章要点

  • Hanami 是 Rails 的替代方案,采用干净架构和领域驱动设计
  • Actions、Views、Repositories、Entities 四层严格分离
  • Entities 是纯 Ruby 对象,不含 ORM 行为
  • Repositories 通过 Sequel 访问数据库,支持复杂查询和事务
  • 每个 Action 是独立的类,参数验证通过 dry-validation 声明
  • 底层使用 dry-system 实现依赖注入
  • 性能约为 Rails 的 3 倍,适合中长期项目和容器化部署

继续学习

💡 提示:Hanami 的学习曲线比 Rails 更陡,因为你需要理解 Repository 模式和干系统。但一旦跨过这个门槛,你会得到一个更容易测试、更容易重构的项目结构。

Grape — Ruby REST API 专用微框架

Grape 是 Ruby 世界里专门为 REST API 设计的微框架。它的目标和 Python 的 FastAPI 一样:让你用最少的代码写出规范的 RESTful API,自动处理参数验证、版本控制、错误格式化和文档生成。如果 Sinatra 是 Ruby 的 Flask,那 Grape 就是 Ruby 的 FastAPI。

Sinatra 能做 API,但它是一个通用 Web 框架,做 API 时很多事需要手动处理。Grape 从一开始就只为 API 而生:内置版本管理、自动 JSON 序列化、参数验证、Swagger 文档生成。它的 DSL 围绕 REST 资源设计,写出来的代码天然符合 REST 规范。

为什么选择 Grape?

  • API First 设计:所有 API 围绕 resource 分组,天然符合 REST 规范
  • 版本管理内置version DSL 一行搞定 v1/v2 并行
  • 参数验证声明式requires / optional 声明参数规则,自动校验并返回 422
  • 错误格式统一rescue_from 统一捕获异常,返回结构化 JSON 错误
  • Swagger 文档自动生成:集成 grape-swagger 后,访问 /swagger.json 即可得到完整的 OpenAPI 文档
  • 可独立运行,也可嵌入 Sinatra/Rails:作为 Rack 应用挂载到任何 Ruby Web 框架中

安装依赖

# Gemfile
gem "grape"
gem "grape-swagger"        # 自动生成 Swagger 文档
gem "grape-swagger-entity" # 实体文档支持(可选)
gem "puma"                 # 生产服务器
gem "sequel"               # ORM
gem "sqlite3"              # 数据库驱动
bundle install

API 类结构

Grape 的核心是继承 Grape::API 的类。每个 API 模块是一个独立的 Rack 应用:

# typed: true
# frozen_string_literal: true

require "grape"

class API < Grape::API
  prefix "api"
  format :json
  content_type :json, "application/json"

  rescue_from :all do |e|
    error!(
      {
        error: e.class.name,
        message: e.message
      },
      500
    )
  end

  mount API::V1::Root
end

几个关键点:

  • prefix "api" 给所有路由加 /api 前缀
  • format :json 让 Grape 自动处理 JSON 请求和响应
  • rescue_from :all 捕获所有未处理的异常,返回结构化错误

版本管理

Grape 的版本管理是它区别于其他 API 框架的核心特性。你可以并行运行多个 API 版本:

# lib/api/v1/root.rb
# typed: true
# frozen_string_literal: true

require_relative "users"
require_relative "posts"

module API
  module V1
    class Root < Grape::API
      version "v1", using: :path

      mount API::V1::Users
      mount API::V1::Posts

      get "/health" do
        {
          status: "ok",
          version: "v1",
          timestamp: Time.now.iso8601
        }
      end
    end
  end
end
# lib/api/v2/root.rb
module API
  module V2
    class Root < Grape::API
      version "v2", using: :path

      mount API::V2::Users
      mount API::V2::Posts

      get "health" do
        {
          status: "ok",
          version: "v2",
          timestamp: Time.now.iso8601
        }
      end
    end
  end
end

在根 API 中挂载两个版本:

class API < Grape::API
  prefix "api"
  format :json

  mount API::V1::Root
  mount API::V2::Root
end

访问路径:

  • GET /api/v1/health
  • GET /api/v2/health

Grape 也支持其他版本方式:using: :header(通过 Accept 头)、using: :param(通过 ?v=1 参数)。

参数验证

Grape 的参数验证 DSL 是最常用的功能之一。它比 Sinatra 中手动解析请求体优雅得多:

# lib/api/v1/users.rb
module API
  module V1
    class Users < Grape::API
      resource :users do
        # GET /api/v1/users?page=1&per_page=20
        params do
          optional :page, type: Integer, default: 1, range: 1..100
          optional :per_page, type: Integer, default: 20, range: 1..100
          optional :status, type: String, values: %w[active inactive banned]
        end
        get do
          users = User.all
          users = users.where(status: params[:status]) if params[:status]
          {
            users: users.paginate(page: params[:page], per_page: params[:per_page]),
            total: users.count
          }
        end

        # POST /api/v1/users
        params do
          requires :name, type: String, length: 2..50
          requires :email, type: String, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i
          optional :age, type: Integer, range: 0..150
          optional :role, type: String, values: %w[user admin moderator], default: "user"
        end
        post do
          user = User.create!(
            name: params[:name],
            email: params[:email],
            age: params[:age],
            role: params[:role]
          )
          status 201
          present user, with: API::V1::Entities::User
        end

        # PUT /api/v1/users/:id
        params do
          requires :id, type: String
          optional :name, type: String, length: 2..50
          optional :email, type: String, format: EMAIL_REGEX
          mutually_exclusive :name, :email, allow_blank: true
        end
        put ":id" do
          user = User.find(params[:id])
          user.update!(params.to_hash)
          present user, with: API::V1::Entities::User
        end

        # DELETE /api/v1/users/:id
        delete ":id" do
          user = User.find(params[:id])
          user.destroy
          status 204
        end
      end
    end
  end
end

验证 DSL 的关键功能:

  • requires vs optional:声明必填或可选参数
  • type:自动类型转换,字符串 "123" 转为 Integer 123
  • range / values:范围限制和枚举约束
  • format:正则表达式验证,比如邮箱
  • mutually_exclusive:参数互斥,比如不能同时传 nameemail 更新
  • group / as:参数分组,配合 as: :update 复用同一组验证逻辑

RESTful 路由

Grape 围绕 resource :name 组织路由,这和 Rails 的 resources 思维一致:

resource :posts do
  # GET /api/v1/posts
  get do
    present Post.all, with: API::V1::Entities::Post
  end

  # GET /api/v1/posts/:id
  route_param :id do
    get do
      present Post.find(params[:id]), with: API::V1::Entities::Post
    end

    # GET /api/v1/posts/:id/comments
    resource :comments do
      get do
        present Comment.where(post_id: params[:id]),
                with: API::V1::Entities::Comment
      end

      params do
        requires :body, type: String, length: 1..1000
      end
      post do
        comment = Comment.create!(
          post_id: params[:id],
          body: params[:body]
        )
        status 201
        present comment, with: API::V1::Entities::Comment
      end
    end
  end
end

嵌套资源通过 route_param 和嵌套 resource 实现。参数通过 params[:id] 自动从 URL 路径中提取。

响应格式化

Grape 支持多种格式,并可通过 HTTP Accept 头自动协商:

class API < Grape::API
  format :json            # 默认 JSON
  content_type :xml, "application/xml"
  content_type :yaml, "text/yaml"

  # 也可以针对特定端点指定格式
  get "/data", produces: [:json, :xml] do
    { name: "hello", items: [1, 2, 3] }
  end
end

客户端控制响应格式:

# 默认 JSON
$ curl http://localhost:9292/api/data

# 要求 XML
$ curl -H "Accept: application/xml" http://localhost:9292/api/data

使用 Entity 构建结构化响应:

# lib/api/v1/entities/user.rb
module API
  module V1
    module Entities
      class User < Grape::Entity
        expose :id, :name, :email, :role
        expose :created_at, format: :iso8601

        expose :avatar_url do |user, options|
          "https://cdn.example.com/avatars/#{user.id}.jpg"
        end

        # 嵌套关联
        expose :latest_post, using: API::V1::Entities::Post, if: -> (*) {
          object.respond_to?(:latest_post) && object.latest_post
        }
      end
    end
  end
end

Entity 的作用是把数据库模型转为 API 需要的 JSON 结构,避免直接暴露数据库字段。在 action 中通过 present object, with: Entity 自动序列化。

错误处理

Grape 的错误处理通过 rescue_from 实现,可以按异常类型精确捕获:

class API < Grape::API
  prefix "api"
  format :json

  # 捕获特定异常
  rescue_from Grape::Exceptions::ValidationErrors do |e|
    error!({
      error: "validation_error",
      messages: e.errors
    }, 422)
  end

  rescue_from Sequel::NotFound do
    error!({
      error: "not_found",
      message: "资源不存在"
    }, 404)
  end

  rescue_from Sequel::UniqueConstraintViolation do |e|
    error!({
      error: "duplicate",
      message: "记录已存在"
    }, 409)
  end

  # 兜底捕获所有异常
  rescue_from :all do |e|
    error!({
      error: "internal_error",
      message: "服务器内部错误"
    }, 500)
  end

  get "/users/:id" do
    user = User.find(params[:id])  # Sequel::NotFound 会被自动捕获
    present user, with: API::V1::Entities::User
  end
end

错误响应的格式统一为:

{
  "error": "validation_error",
  "messages": {
    "email": ["is invalid"]
  }
}

自定义错误处理函数:

helpers do
  def authenticate!
    token = request.env["HTTP_AUTHORIZATION"]&.split(" ")&.last
    unless token
      error!({ error: "unauthorized", message: "需要认证" }, 401)
    end
    @current_user = User.find_by(auth_token: token)
    error!({ error: "unauthorized", message: "无效的认证令牌" }, 401) unless @current_user
  end

  def require_admin!
    error!({ error: "forbidden", message: "需要管理员权限" }, 403) unless @current_user&.admin?
  end
end

# 在资源中使用 before 过滤器
resource :admin do
  before { authenticate!; require_admin! }

  get "/stats" do
    {
      users: User.count,
      posts: Post.count,
      comments: Comment.count
    }
  end
end

Swagger / OpenAPI 文档

grape-swagger 为你的 API 自动生成 Swagger UI,这是 Grape 最强大的功能之一:

require "grape-swagger"

class API < Grape::API
  prefix "api"
  format :json

  # ... 你的 API 定义

  add_swagger_documentation(
    mount_path: "swagger",
    api_version: "v1",
    hide_documentation_path: true,
    info: {
      title: "My API",
      version: "v1",
      description: "用户和文章管理 API"
    },
    security_definitions: {
      api_key: {
        type: "apiKey",
        name: "Authorization",
        in: "header"
      }
    }
  )
end

访问 http://localhost:9292/api/swagger 即可看到交互式文档。

更精细的端点文档:

desc "获取用户列表", {
  detail: "支持分页和状态筛选",
  success: API::V1::Entities::User,
  is_array: true
}
params do
  optional :page, type: Integer, default: 1, desc: "页码"
  optional :per_page, type: Integer, default: 20, desc: "每页数量"
  optional :status, type: String, values: %w[active inactive], desc: "用户状态"
end
get do
  present User.all, with: API::V1::Entities::User
end

desc "创建新用户", {
  detail: "需要 name 和 email,age 可选",
  success: API::V1::Entities::User,
  failure: [
    [422, "验证失败", API::V1::Entities::Error],
    [409, "重复记录", API::V1::Entities::Error]
  ]
}
params do
  requires :name, type: String, length: 2..50, desc: "用户名"
  requires :email, type: String, desc: "邮箱地址"
  optional :age, type: Integer, range: 0..150, desc: "年龄"
end
post do
  # ...
end

生成的 Swagger 文档包含端点描述、参数说明、返回类型、错误码,可以直接用于前端联调和客户端代码生成。

与 Rack 集成

Grape 应用是完整的 Rack 应用,可以独立运行,也可以嵌入其他框架:

独立运行

# config.ru
require_relative "./lib/api"

run API
$ bundle exec rackup config.ru
# Puma 启动在 http://localhost:9292

嵌入 Rails

# config/routes.rb
Rails.application.routes.draw do
  mount API => "/api"
end

嵌入 Sinatra

require "sinatra/base"
require_relative "./lib/api"

class MainApp < Sinatra::Base
  mount API => "/api"

  get "/" do
    "Hello from Sinatra!"
  end
end

这种灵活性让 Grape 可以作为 Rails 应用中的 API 模块存在,同时保留 REST API 专用 DSL 的优势。

完整示例结构

my_api/
├── config.ru                 # Rack 入口
├── Gemfile
├── lib/
│   └── api.rb                # 根 API 类,挂载所有版本
│   ├── api/
│   │   ├── v1/
│   │   │   ├── root.rb       # V1 入口
│   │   │   ├── users.rb      # 用户资源
│   │   │   ├── posts.rb      # 文章资源
│   │   │   └── entities/
│   │   │       ├── user.rb   # 用户响应格式
│   │   │       └── post.rb   # 文章响应格式
│   │   └── v2/
│   │       ├── root.rb       # V2 入口
│   │       └── users.rb      # V2 用户 API(可能有新字段)
│   ├── models/
│   │   ├── user.rb
│   │   └── post.rb
│   └── db.rb                 # 数据库连接

本章要点

  • Grape 是 Ruby 专用的 REST API 微框架,围绕 resource 组织路由
  • version DSL 支持 v1/v2 多版本并行,通过路径、Header 或参数切换
  • requires / optional 声明参数验证规则,自动返回结构化错误
  • Entity 层负责将数据库对象序列化为 JSON,控制 API 响应格式
  • rescue_from 按异常类型统一处理错误,避免重复的 begin/rescue
  • add_swagger_documentation 自动生成 OpenAPI/Swagger 文档
  • 可以作为独立 Rack 应用运行,也可以嵌入 Rails/Sinatra
  • 适合需要规范 REST API、版本管理和自动文档的后端服务

继续学习

💡 提示:Grape 的哲学是"API 应该像 API 一样被设计"。它不是为了替代 Sinatra 或 Rails 而生,而是补足了它们在 API 场景下的不足:版本管理、参数验证、自动文档。如果你的项目需要对外提供 REST API,Grape 是比 Sinatra 更专业的选择。

Sidekiq + Redis — 生产级后台任务处理

在 Ruby 生态中,Sidekiq 是后台任务处理的事实标准。从初创公司到 GitHub、Shopify、Airbnb,全球数万个生产环境依赖 Sidekiq 处理邮件、报表、数据同步等异步工作。

为什么需要后台任务?

在 Web 应用中,并非所有工作都应在请求-响应周期内完成。以下场景必须异步处理:

场景为什么异步延迟容忍
发送注册邮件SMTP 慢(2-5s),用户体验差秒级
生成月度报表可能耗时数分钟分钟级
调用第三方支付 API网络不确定性秒级
数据处理/ETLCPU 密集型,长时间运行分钟/小时
图片/视频缩略图CPU 密集型分钟级
Webhook 回调第三方服务可能宕机分钟级 + 重试

不使用后台任务的应用,用户会在每次操作中等待这些缓慢的外部操作完成。使用后台任务后,请求立即返回,繁重工作在后台由独立的 Worker 进程处理。

Sidekiq 架构

Sidekiq 采用 Redis 作为消息中间件多线程 Worker 的消费模型:

┌─────────────┐         ┌─────────────┐
│  Web/Redis  │ ─push─► │  Redis List │
│  应用服务器  │         │  (Job Queue)│
└─────────────┘         └──────┬──────┘
                               │
                      ┌────────▼────────┐
                      │   Sidekiq       │
                      │   Worker        │
                      │  (多线程)        │
                      │  默认 25 线程    │
                      └─────────────────┘

核心组件

  • Client(客户端):Web 应用将 Job 序列化后推入 Redis 队列
  • Redis Queue:作为持久化消息队列,保证 Job 不丢失
  • Server(Worker):Sidekiq 进程从 Redis 拉取 Job,在独立线程中执行
  • Retry Set:失败 Job 进入重试队列,按指数退避策略重新入队
  • Dead Set:超过重试上限的 Job 进入"死亡"状态,需要人工介入

安装与配置

安装 Sidekiq gem:

# Gemfile
gem "sidekiq", "~> 7.0"  # 需要 Redis 6+ 和 Ruby 2.7+
bundle add sidekiq

配置 Sidekiq 连接(在生产环境中通常使用环境变量的 Redis URL):

# config/sidekiq.rb
# frozen_string_literal: true

Sidekiq.configure_server do |config|
  config.redis = {
    url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/0" },
    network_timeout: 5,
    pool_size: Sidekiq.default_configuration.concurrency + 2
  }
end

Sidekiq.configure_client do |config|
  config.redis = {
    url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/0" }
  }
end

📌 注意:连接池大小应等于 Worker 并发数 + 缓冲。默认并发 25,连接池 25-27 是常见配置。

编写第一个 Worker

Sidekiq Worker 需要 include Sidekiq::Job 并实现 perform 方法:

# frozen_string_literal: true

require "sidekiq"

module Hello
  module Awesome
    # 邮件发送 Worker
    class SendWelcomeEmailWorker
      include Sidekiq::Job

      sidekiq_options(
        queue: "default",
        retry: 3,
        backtrace: 5
      )

      # 这个方法是 Worker 的核心入口
      # 参数必须是 JSON 可序列化的类型
      def perform(user_id)
        user = User.find(user_id)
        # 发送邮件的逻辑
        Mailer.welcome(user.email).deliver_now
        Rails.logger.info "欢迎邮件已发送到 #{user.email}"
      end
    end
  end
end

调用 Worker

# 入队——立即返回,Job 被推入 Redis
Hello::Awesome::SendWelcomeEmailWorker.perform_async(current_user.id)

# 延迟执行——5 分钟后入队
Hello::Awesome::SendWelcomeEmailWorker.perform_in(5.minutes, current_user.id)

# 指定时间入队
Hello::Awesome::SendWelcomeEmailWorker.perform_at(Time.tomorrow.noon, current_user.id)

参数序列化规则

这是 Sidekiq 最容易踩坑的地方:Worker 的参数必须是 JSON 可序列化的基本类型

# ✅ 正确——只传 ID
class ProcessReportWorker
  include Sidekiq::Job

  def perform(report_id, user_id)
    report = Report.find(report_id)
    user = User.find(user_id)
    report.generate_for(user)
  end
end

ProcessReportWorker.perform_async(report.id, user.id)

# ❌ 错误——传递了 ActiveRecord 对象
# Sidekiq 尝试序列化 User 实例时会失败或产生过期数据
class BadWorker
  include Sidekiq::Job

  def perform(user, report)  # 两个 ActiveRecord 对象 ❌
    report.generate_for(user)
  end
end

BadWorker.perform_async(user, report)  # 危险!

为什么不能传对象?

  1. Sidekiq 通过 JSON 序列化参数到 Redis,ActiveRecord 对象无法直接 JSON 序列化
  2. 从入队到执行有时间差,对象状态可能已过期
  3. Worker 运行在独立进程,无法共享 Active Record 实例的内存状态

唯一可接受的参数类型StringIntegerFloatBooleanArray(嵌套基本类型)、Hash(字符串或符号键)。

幂等性设计

Sidekiq 保证 at-least-once(至少执行一次) 投递语义。在网络异常、Worker 崩溃等情况下,同一个 Job 可能被重复执行。你的 Worker 必须设计为幂等的。

# ❌ 非幂等——多次执行导致重复扣款
class ChargeCustomerWorker
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find(order_id)
    # 如果这个 Job 被重新执行,会重复扣款!
    PaymentGateway.charge(order.amount, order.payment_token)
    order.update!(status: "paid")
  end
end

# ✅ 幂等——使用状态检查防止重复执行
class ChargeCustomerWorker
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find(order_id)

    # 幂等守卫:已支付的订单直接返回
    return if order.paid?

    PaymentGateway.charge(order.amount, order.payment_token)

    # 乐观锁:确保只有第一个成功的请求更新状态
    order.update!(status: "paid")
  end
end

幂等性模式

  1. 状态检查:执行前检查目标对象的业务状态
  2. 幂等键:在 Redis/Set 中维护已处理的 Job 指纹
  3. 数据库约束:利用唯一索引保证数据层面的一致性
  4. 事务:将业务操作放在数据库事务中,利用事务的原子性
# 使用 Redis Set 的幂等键模式
class IdempotentImportWorker
  include Sidekiq::Job

  def perform(import_id)
    idempotency_key = "import:processed:#{import_id}"

    # Redis SET NX:只有第一次能成功设置
    added = RedisClient.set(idempotency_key, "1", nx: true, ex: 86_400)

    unless added
      Rails.logger.info "Job #{import_id} 已处理过,跳过"
      return
    end

    # 执行实际导入逻辑
    Import.find(import_id).process!
  end
end

重试策略

Sidekiq 默认对失败 Job 进行 25 次重试,采用指数退避算法,覆盖约 21 天 的周期。这意味着一个 Job 在最坏情况下会在第 21 天做最后一次尝试。

class ImportCsvWorker
  include Sidekiq::Job

  # 自定义重试次数
  sidekiq_options retry: 5

  # 自定义重试间隔
  sidekiq_options retry_in: lambda { |count|
    # 前 3 次快速重试,之后指数退避
    count <= 3 ? (count * 0.5) : (count**4).to_i
  }

  def perform(csv_url)
    # 调用第三方 API 下载 CSV
    response = HTTParty.get(csv_url)
    raise "Download failed: #{response.code}" unless response.success?

    # 解析并导入数据
    CsvParser.import(response.body)
  rescue StandardError => e
    # 记录日志后 re-raise,让 Sidekiq 处理重试
    Rails.logger.error "CSV 导入失败: #{e.message}"
    raise
  end
end

重试间隔计算公式(Sidekiq 默认):

retry_in = (attempt ** 4) + 15 秒随机抖动

前几次重试间隔大约为:5s、31s、96s、271s、670s ... 直到最后一次(第 25 次)约在 21 天后。

不重试的错误:某些错误不应该重试(如数据校验失败、权限不足):

class ValidateAndProcessWorker
  include Sidekiq::Job

  # 业务校验错误不应该重试
  sidekiq_options discard_on ArgumentError
  sidekiq_options discard_on ActiveModel::ValidationError

  # 网络/基础设施错误使用自定义重试
  sidekiq_options retry_in: ->(count) { count * 5 }

  def perform(payload)
    record = validate!(payload)   # 校验失败 → ArgumentError → 不重试
    process!(record)              # 内部错误 → 标准重试
  end
end

队列策略与优先级

Sidekiq 支持多队列,不同队列对应不同的业务优先级。Worker 处理队列的顺序决定了高优先级任务的响应速度。

# 关键任务的 Worker
class PaymentNotificationWorker
  include Sidekiq::Job
  sidekiq_options queue: "critical"

  def perform(transaction_id)
    transaction = Transaction.find(transaction_id)
    Pusher.notify(:payments, :completed, { id: transaction.id })
  end
end

# 普通任务的 Worker
class SendDigestEmailWorker
  include Sidekiq::Job
  sidekiq_options queue: "default"

  def perform(user_id)
    user = User.find(user_id)
    Mailer.digest(user).deliver_now
  end
end

# 低优先级任务
class GenerateAnalyticsReportWorker
  include Sidekiq::Job
  sidekiq_options queue: "low"

  def perform(organization_id)
    org = Organization.find(organization_id)
    AnalyticsReport.generate_for(org)
  end
end

启动 Sidekiq 时指定队列优先级:

bundle exec sidekiq -q critical -q default -q low

# 队列权重:critical 每次拿 3 个 Job,default 拿 2 个,low 拿 1 个
bundle exec sidekiq -q critical,3 -q default,2 -q low

队列策略原则:

  • critical:支付、登录通知、安全事件——直接影响用户操作
  • default:邮件发送、数据处理——用户关心但不是阻塞性的
  • low:统计报表、日志归档、缓存预热——可以晚处理

监控与 Sidekiq Web UI

Sidekiq 提供内置的 Web 界面,可以查看队列状态、重试队列、死亡队列和统计数据。

# config/routes.rb(Rails 项目)
require "sidekiq/web"

Rails.application.routes.draw do
  authenticate :admin_user do
    mount Sidekiq::Web => "/sidekiq"
  end
end

# Sinatra 项目
require "sidekiq/web"

class App < Sinatra::Base
  use Rack::Auth::Basic do |username, password|
    username == ENV["SIDEKIQ_USER"] && password == ENV["SIDEKIQ_PASSWORD"]
  end

  mount Sidekiq::Web => "/sidekiq"
end

🔒 安全警告:Sidekiq Web 界面包含 Job 管理功能(删除、重试 Job),必须添加认证保护,绝不直接暴露到外网

Sidekiq Web UI 提供:

  • 队列概览:各队列当前积压的 Job 数量
  • 重试页面:查看和手动重试失败的 Job
  • Dead 页面:超过重试上限的 Job,需要人工处理
  • Busy 页面:当前正在执行的 Job
  • 调度器页面:待执行的 perform_in / perform_at Job
  • 统计信息:今日/总计的成功、失败 Job 数

生产监控集成

# 通过 sidekiq API 获取统计信息
stats = Sidekiq::Stats.new
puts "排队中: #{stats.enqueued}"
puts "重试中: #{stats.retries}"
puts "已死亡: #{stats.dead}"
puts "今天成功: #{stats.processed_successes}"
puts "今天失败: #{stats.failed}"

# Workers 进程健康检查
Sidekiq.redis do |conn|
  workers = conn.smembers("processes")
  workers.each do |worker_key|
    info = conn.hgetall(worker_key)
    puts "Worker: #{info['busy']} busy threads, #{info['quiet'] || 'active'}"
  end
end

与外部监控系统集成(Prometheus、Datadog、New Relic 等):

# config/initializers/sidekiq_metrics.rb
require "prometheus/client"

Sidekiq.configure_server do |config|
  registry = Prometheus::Client.registry

  config.server_middleware do |chain|
    chain.add ::Sidekiq::PrometheusMiddleware
  end
end

class Sidekiq::PrometheusMiddleware
  def call(worker_class, job, queue, redis_pool)
    yield
  rescue StandardError => e
    # 推送失败指标到 Prometheus
    registry.counter(:sidekiq_job_failures).increment(labels: { job: worker_class.name })
    raise
  end
end

生产环境最佳实践

1. 保持 Job 小而快

# ❌ 不好——一个 Job 做所有事,耗时长
class ProcessOrderWorker
  include Sidekiq::Job

  def perform(order_id)
    order = Order.find(order_id)

    # 1. 计算价格(可能调用数据库)
    order.calculate_totals

    # 2. 扣减库存(可能需要外部 API)
    InventoryService.decrement(order.items)

    # 3. 发送邮件(SMTP 很慢)
    Mailer.order_confirm(order).deliver_now

    # 4. 生成 PDF(CPU 密集型)
    PdfGenerator.create(order)

    # 5. 更新分析数据
    Analytics.track_order(order)

    # 6. 发送 Webhook(网络调用)
    WebhookDelivery.send(order.webhook_url, order.to_json)

    order.update!(status: "processed")
  end
end

# ✅ 好——拆分多个小 Job
class ProcessOrderWorker
  include Sidekiq::Job
  sidekiq_options queue: "critical"

  def perform(order_id)
    order = Order.find(order_id)
    order.calculate_totals
    InventoryService.decrement(order.items)
    order.update!(status: "processed")

    # 触发后续异步任务
    SendOrderConfirmationWorker.perform_async(order_id)
    GenerateOrderPdfWorker.perform_async(order_id)
    TrackOrderAnalyticsWorker.perform_async(order_id)
  end
end

class SendOrderConfirmationWorker
  include Sidekiq::Job
  sidekiq_options queue: "default"

  def perform(order_id)
    order = Order.find(order_id)
    Mailer.order_confirm(order).deliver_now
  end
end

经验法则:单个 Job 的执行时间应控制在 10 秒以内。超出时考虑拆分、检查点(checkpointing)或使用专用任务调度。

2. 长时间任务的检查点模式

class ProcessBatchReportWorker
  include Sidekiq::Job
  sidekiq_options retry: 5

  def perform(report_id, checkpoint = nil)
    report = Report.find(report_id)

    # 从上次的检查点继续处理
    items = report.items
    items = items.where("id > ?", checkpoint) if checkpoint

    # 每次处理 100 条
    batch = items.limit(100)
    batch.each(&:process)

    # 还有剩余?重新入队并记录检查点
    if batch.count == 100
      last_processed = batch.maximum("id")
      ProcessBatchReportWorker.perform_async(report_id, last_processed)
    else
      report.update!(status: "completed")
    end
  end
end

检查点模式的优势:

  • 每个 Job 实例快速完成(处理 100 条,约 2-3 秒)
  • Worker 崩溃后,仅丢失当前批次的进度
  • 天然并行化——检查点可以分配到不同的 Worker

3. 连接池配置

Worker 进程内多个线程共享数据库连接池和 Redis 连接池:

# 数据库连接池(database.yml)
production:
  adapter: postgresql
  pool: 30  # >= Sidekiq concurrency (25) + 缓冲

# Redis 连接池已在 sidekiq.rb 中配置
Sidekiq.configure_server do |config|
  config.redis = {
    url: ENV["REDIS_URL"],
    pool_size: 30  # concurrency + 5
  }
end

公式连接池 = Sidekiq 并发数 + 缓冲(2-5)。如果 Sidekiq 并发 25,Redis 连接池至少 27,数据库连接池也类似。

4. 硬超时与优雅关闭

Sidekiq 在收到 TERM 信号后,默认 25 秒timeout 参数)等待正在执行的 Job 完成后再退出。这是优雅关闭(graceful shutdown)。

# Docker / systemd 部署时,确保使用 SIGTERM 而非 SIGKILL
# Sidekiq 收到 SIGTERM → 停止拉取新 Job → 等待当前 Job 完成 → 退出

# 自定义超时时间(默认 25s)
bundle exec sidekiq --timeout 30

# 在 Procfile/Dockerfile 中确保信号正确传递
exec bundle exec sidekiq -q critical,3 -q default -q low

如果 Worker 中的 Job 可能超过 25 秒,需要:

  1. 增加 --timeout
  2. 在 Worker 内部处理信号:
class LongRunningWorker
  include Sidekiq::Job

  def perform(data_ids)
    @shutting_down = false
    trap(:TERM) { @shutting_down = true }

    data_ids.each do |id|
      break if @shutting_down  # 优雅退出当前 Job

      process_item(id)
    end
  end
end

5. Redis 持久化

Sidekiq 的可靠性依赖于 Redis 的持久化策略:

# Redis 配置(redis.conf)

# 推荐使用 AOF 模式
appendonly yes
appendfsync everysec  # 每秒刷盘,最多丢 1 秒数据

# 如果使用 RDB,确保足够频繁的快照
save 60 1000  # 60 秒内 1000 个 key 变化时保存

appendfsync everysec 是生产环境的推荐配置,在性能和数据安全性之间取得平衡。如果使用 appendfsync always,每次写入都刷盘,会显著降低 Redis 性能。

6. 日志规范

class ProcessPaymentWorker
  include Sidekiq::Job

  def perform(payment_id)
    payment = Payment.find(payment_id)
    logger = Rails.logger.tagged("PaymentWorker", payment_id: payment_id)
    logger.info "开始处理支付"

    gateway = PaymentGateway.new(payment)
    result = gateway.charge

    if result.success?
      payment.update!(status: "charged")
      logger.info "支付成功, transaction_id: #{result.transaction_id}"
    else
      logger.error "支付失败: #{result.error}"
      raise StandardError, result.error
    end
  end
end

与 hello-ruby 项目集成

在 hello-ruby 的 Awesome 层级中,Sidekiq Worker 放在 lib/hello/awesome/ 目录下:

# lib/hello/awesome/send_welcome_email_worker.rb
# frozen_string_literal: true

module Hello
  module Awesome
    # 示例:欢迎邮件 Worker
    class SendWelcomeEmailWorker
      include Sidekiq::Job

      sidekiq_options(
        queue: "default",
        retry: 3,
        backtrace: true
      )

      def perform(user_name, user_email)
        # 实际项目中通过 DI 容器注入邮件服务
        mailer = Hello::System::Container["mailer_service"]
        mailer.send_welcome(user_name, user_email)
      end
    end
  end
end
# lib/hello/awesome/import_data_worker.rb
# frozen_string_literal: true

module Hello
  module Awesome
    # 示例:数据导入 Worker(带检查点)
    class ImportDataWorker
      include Sidekiq::Job

      sidekiq_options(
        queue: "low",
        retry: 5,
        retry_in: ->(count) { count * 10 }
      )

      def perform(source_id, last_id = nil)
        source = DataSource.find(source_id)
      items = source.items.where("id > ?", last_id).limit(500)

      items.each do |item|
        item.transform_and_save!
      end

      if items.count == 500
        last_processed = items.maximum("id")
        ImportDataWorker.perform_async(source_id, last_processed)
      else
        source.update!(status: "imported")
      end
    end
  end
end

启动 Sidekiq Worker:

# 开发环境
bundle exec sidekiq -C config/sidekiq.yml

# sidekiq.yml 配置文件
---
:concurrency: 5
:queues:
  - [critical, 3]
  - [default, 2]
  - [low, 1]

Sidekiq vs 替代方案

特性SidekiqResqueDelayed JobGoodJob
后端RedisRedis数据库(PostgreSQL/MySQL)PostgreSQL
并发模型多线程(单个进程内)多进程多进程多线程
内存占用低(共享地址空间)高(每个 Worker 独立进程)
吞吐量中高
调度(定时任务)需要 sidekiq-cron gem需要 resque-scheduler不需要额外 gem内置(cron)
重试策略内置指数退避需要额外配置内置内置
Web 界面官方内置官方内置需要额外 gem
适用场景高吞吐、Redis 环境需要进程隔离的场景不想引入 RedisPostgreSQL 环境
维护者独立开源GitHub独立开源独立开源

选型建议

  • 已有 Redis?→ Sidekiq(Ruby 社区事实标准)
  • 不想引入 Redis?→ GoodJob(PostgreSQL 原生)或 Delayed Job(任意数据库)
  • 需要进程级隔离?→ Resque(每个 Worker 独立进程,一个 Worker 崩溃不影响其他)
  • 需要更高级特性(调度、去重)?Sidekiq Pro(付费)或 Sidekiq + 扩展 gem

完整示例:邮件发送流水线

这是一个生产环境可用的邮件发送 Worker,集成了幂等性、重试、限流等模式:

# frozen_string_literal: true

require "sidekiq"

module Hello
  module Awesome
    # 生产级邮件发送 Worker
    #
    # 特性:
    # - 幂等性(同一邮件不会重复发送)
    # - 指数退避重试
    # - SMTP 限流(防止触发第三方限制)
    # - 详细日志
    class MailingListWorker
      include Sidekiq::Job

      # 关键队列,高优先级
      sidekiq_options(
        queue: "critical",
        retry: 5,
        # 自定义重试间隔:10s, 30s, 90s, 270s, 810s (约 13.5 分钟)
        retry_in: ->(count) { 10 * (3**(count - 1)) },
        # SMTP 相关错误特殊处理
        dead: true  # 超过重试上限后进入 Dead Set
      )

      # 不对这几种错误进行重试(邮件地址或模板有问题,重试也不会成功)
      sidekiq_options discard_on Net::SMTPSyntaxError
      sidekiq_options discard_on InvalidEmailAddressError
      sidekiq_options discard_on TemplateNotFoundError

      def perform(email_id, template_id, user_id)
        # 幂等守卫:检查邮件是否已发送
        idempotency_key = "email:#{email_id}:#{user_id}"
        already_sent = RedisClient.set(idempotency_key, "1", nx: true, ex: 86_400)
        return unless already_sent

        email = Email.find(email_id)
        template = EmailTemplate.find(template_id)
        user = User.find(user_id)

        Rails.logger.info "发送邮件: user=#{user.id}, email=#{user.email}, template=#{template_id}"

        # 限流:每次发送前等待(防止触发 SMTP 速率限制)
        sleep_rate_limit

        # 渲染模板
        body = template.render(user: user)

        # 发送(Net::SMTP 错误会被 discard_on 拦截,不进入重试)
        Net::SMTP.start(ENV["SMTP_HOST"], ENV["SMTP_PORT"]) do |smtp|
          smtp.send_message(
            "From: #{email.from}\r\n" \
            "To: #{user.email}\r\n" \
            "Subject: #{email.subject}\r\n" \
            "Content-Type: text/html; charset=UTF-8\r\n\r\n" \
            "#{body}",
            email.from,
            user.email
          )
        end

        # 记录发送成功
        EmailLog.create!(
          email_id: email_id,
          user_id: user_id,
          status: "sent",
          sent_at: Time.current
        )

        Rails.logger.info "邮件发送成功: user=#{user.id}"

      rescue StandardError => e
        # 非 SMTP 语法错误(如网络超时)进入重试
        Rails.logger.error "邮件发送失败: user=#{user_id}, error=#{e.message}"
        raise  # 让 Sidekiq 处理重试
      end

      private

      def sleep_rate_limit
        # 根据环境配置发送频率
        rate = ENV.fetch("SMTP_RATE_LIMIT", 5).to_i
        sleep(1.0 / rate) if rate > 0
      end
    end
  end
end

本章要点

  • Sidekiq 是 Ruby 后台任务处理的事实标准,使用 Redis 作为消息队列
  • Worker 必须 include Sidekiq::Job 并实现 perform 方法
  • 参数必须是 JSON 可序列化类型(基本类型),不要传 ActiveRecord 对象
  • Sidekiq 保证 至少执行一次(at-least-once),Worker 必须设计为幂等
  • 默认重试 25 次、覆盖约 21 天,可通过 sidekiq_options 自定义
  • 使用 discard_on 声明不重试的错误类型
  • 多队列优先级策略(critical / default / low)保证关键任务优先执行
  • 生产环境最佳实践:Job 小而快(< 10s)、长任务使用检查点、连接池合理配置、优雅关闭
  • Sidekiq Web UI 提供队列监控、重试管理、死亡队列查看(务必添加认证保护
  • Redis 持久化推荐 AOF + appendfsync everysec(性能与安全性平衡)
  • 已有 Redis 环境 → 首选 Sidekiq;PostgreSQL 原生需求 → GoodJob;不想引入 Redis → GoodJob / Delayed Job

继续学习

💡 提示:Sidekiq 的核心理念是"可靠地做事"。通过 Redis 持久化、幂等 Worker 设计、合理的重试策略,你可以构建出即使在服务中断情况下也能恢复的异步任务系统。记住:Job 失败不是异常,是系统运行的一部分——让你的 Worker 能够从失败中恢复,而不是崩溃。

Falcon — 高性能异步 Ruby Web 服务器

Falcon 是一款高性能的 Rack 兼容 HTTP 服务器,基于 async/async-http 协程构建。它可以同时处理数千个并发连接,在 Ruby 生态中的地位类似于 Node.js 的 uvloop 或 Python 的 Uvicorn。原生支持 HTTP/1、HTTP/2 和 TLS,无需额外配置。

Falcon 的核心架构

Falcon 采用多进程加多 Fiber 的混合架构,这是它高性能的根源。

每个请求在一个轻量级的 Fiber(协程)中执行,Fiber 之间共享进程内存,切换成本极低。当请求需要等待 I/O(数据库查询、外部 HTTP 调用、文件读取)时,Falcon 的 Fiber 调度器自动切换到其他就绪的 Fiber,而不是阻塞整个线程。

┌─────────────────────────────────────────────────┐
│                  Falcon 架构                     │
│                                                  │
│  ┌──────────┐    ┌──────────┐    ┌───────────┐  │
│  │ Worker 1 │    │ Worker 2 │    │ Worker N   │  │
│  │ (进程)   │    │ (进程)   │    │ (进程)    │  │
│  │          │    │          │    │           │  │
│  │  ┌────┐  │    │  ┌────┐  │    │  ┌────┐  │  │
│  │  │Fiber│  │    │  │Fiber│  │    │  │Fiber│  │  │
│  │  │Fiber│  │    │  │Fiber│  │    │  │Fiber│  │  │
│  │  │Fiber│  │    │  │Fiber│  │    │  │Fiber│  │  │
│  │  │ ... │  │    │  │ ... │  │    │  │ ... │  │  │
│  │  └────┘  │    │  └────┘  │    │  └────┘  │  │
│  └──────────┘    └──────────┘    └───────────┘  │
│                                                  │
│  每个请求 = 一个 Fiber                            │
│  Fiber 遇到 I/O → 自动让出控制权                  │
│  非阻塞 I/O = 高并发能力                           │
└─────────────────────────────────────────────────┘

多进程部分用于利用多核 CPU,每个 Worker 进程拥有独立的 Fiber 池。这种设计既避免了 GVL(全局解释器锁)争用,又保证了故障隔离:一个 Worker 崩溃不影响其他 Worker。

快速开始

安装 Falcon

gem install falcon

或添加到 Gemfile:

gem "falcon"

最简单的 Rack 应用

创建一个 config.ru 文件:

# frozen_string_literal: true

run lambda { |_env|
  [200, { "Content-Type" => "text/plain" }, ["Hello Falcon!"]]
}

启动服务:

# HTTPS 绑定(自动生成自签名证书)
$ falcon serve --bind https://localhost:9292

# HTTP 绑定
$ falcon serve --bind http://localhost:9292

Falcon 支持 https:// 前缀自动绑定 HTTPS。首次启动时会自动生成自签名证书,仅适用于 localhost 开发环境。

开发模式

$ falcon serve --bind http://localhost:9292 --development

--development 模式会在代码变更时自动重启 Worker 进程,开发体验类似 nodemon 或 flask reload。

绑定多个地址

你可以同时绑定 HTTP 和 HTTPS:

$ falcon serve \
  --bind http://localhost:9292 \
  --bind https://localhost:9293

运行 Sinatra 应用

Falcon 完全兼容 Rack 接口,因此任何 Rack 应用都能直接运行。下面用 Sinatra 演示:

# frozen_string_literal: true

require "sinatra/base"

class MyApp < Sinatra::Base
  get "/" do
    "Served by Falcon!"
  end

  get "/hello/:name" do
    "你好,#{params['name']}!用 Falcon 提供服务。"
  end

  get "/fibonacci/:n" do
    n = params["n"].to_i
    # 纯 CPU 计算,展示 Falcon 也能处理
    result = (0...n).inject([0, 1]) { |acc, _| [acc.last, acc.sum] }.first
    "Fibonacci(#{n}) = #{result}"
  end

  get "/external-call" do
    # 发起外部 HTTP 请求(Falcon 的 Fiber 调度器会在这里让出控制)
    require "async/http/endpoint"
    endpoint = Async::HTTP::Endpoint.parse("https://api.github.com")
    connection = Async::HTTP::Client.connect(endpoint)
    response = connection.get("/").wait
    "GitHub API 响应状态: #{response.status}"
  end
end

run MyApp

用 Falcon 启动:

$ bundle exec falcon serve

注意,外部 HTTP 调用那段代码利用了 Falcon 内置的 Fiber 调度器。当 connection.get 发起网络请求后,Fiber 自动让出控制权,其他请求可以继续处理,等响应到达后再恢复执行。这就是非阻塞 I/O 的威力。

关键特性详解

Fiber 调度器集成(Ruby 3.0+)

从 Ruby 3.0 开始,Ruby 支持可插拔的 Fiber 调度器(Fiber Scheduler)。Falcon 注册了自己的调度器,使得标准库中的阻塞 I/O 操作(如 TCPSocket, Net::HTTP, IO.select)自动变成非阻塞。

# frozen_string_literal: true

# 这段代码在 Falcon 的 Fiber 调度器下自动变成非阻塞
require "net/http"
require "uri"

def fetch_all(urls)
  # Ruby 3.0+ 下,并发发起而非串行等待
  urls.map do |url|
    Thread.new do
      response = Net::HTTP.get(URI(url))
      puts "#{url}: #{response.length} bytes"
    end
  end.map(&:join)
end

在 Falcon 中运行时,Net::HTTP.get 不再阻塞线程,而是通过 event loop 等待。多个并发请求共享同一个线程,性能大幅超越传统多进程模型。

HTTP/1 和 HTTP/2 原生支持

Falcon 同时支持 HTTP/1.1 和 HTTP/2(通过 ALPN 协商),无需额外的反向代理或配置。

$ falcon serve --bind https://localhost:9292

# 访问时使用 HTTP/2
$ curl --http2 -k https://localhost:9292
HTTP/2 200
content-type: text/html; charset=utf-8

HTTP/2 的多路复用(Multiplexing)使得多个请求可以共享同一个 TCP 连接,减少了连接的开销。对于前端资源加载(多个 CSS、JS、图片文件)来说,这是巨大的性能提升。

TLS 支持

Falcon 内置 TLS。使用 https:// 绑定地址时,Falcon 会:

  1. 如果提供了证书文件,直接使用
  2. 如果没有证书,自动用 puma-dev 风格的证书生成工具创建

生产环境建议使用 Let's Encrypt 或其他 CA 提供的正式证书:

$ falcon serve \
  --bind https://localhost:9292 \
  --ssl-key /path/to/private.key \
  --ssl-cert /path/to/certificate.crt

WebSocket 支持

Falcon 通过 async-websocket gem 原生支持 WebSocket(需要 Rack 3 的 streaming response 协议):

# frozen_string_literal: true

require "async/websocket/protocol"
require "async/http/endpoint"

class ChatApp
  def call(env)
    if env["HTTP_UPGRADE"] == "websocket"
      # WebSocket 请求
      handle_websocket(env)
    else
      # 普通 HTTP 请求(返回聊天页面)
      [200, { "Content-Type" => "text/html" }, ["<h1>WebSocket Chat</h1>"]]
    end
  end

  def handle_websocket(env)
    # Falcon + async-websocket 的 WebSocket handshake
    protocol = env["async.websocket"]
    protocol.accept

    # 广播循环
    loop do
      message = protocol.read
      break if message.nil?
      # 处理消息...
    end
  end
end

run ChatApp.new

Rack 2 和 Rack 3 双兼容

Falcon 同时兼容 Rack 2 和 Rack 3,无论是 Rails 7(默认使用 Rack 2)还是最新的 Roda(Rack 3 优先),都能直接运行,无需任何修改。

# Rack 3 streaming response — Falcon 完美支持
# frozen_string_literal: true

run lambda { |_env|
  # 流式响应:适用于 SSE(Server-Sent Events)、大文件下载
  body = Object.new
  def body.each
    10.times do |i|
      yield "Chunk #{i}\n"
      sleep 0.5  # 模拟逐条推送
    end
  end

  [200, { "Content-Type" => "text/plain" }, body]
}

运行 Rails 应用

Falcon 可以通过 falcon-websocket gem 与 Rails 深度集成,支持异步 Rails 控制器、HTTP 流式传输、SSE 和 WebSocket。

基础集成

# Gemfile
gem "rails", "~> 7.1"
gem "falcon"
gem "falcon-websocket"
# config/application.rb
require "rails/all"

module MyApplication
  class Application < Rails::Application
    # 配置 Rails 使用 Falcon 特性
    config.hosts << "localhost"
  end
end

启动命令不变:

$ bundle exec falcon serve --bind http://localhost:3000

HTTP Streaming

Rails 支持将控制器输出以流的形式发送给客户端,这在 Falcon 下效果最佳:

# frozen_string_literal: true

class StreamingController < ApplicationController
  include ActionController::Live

  def index
    10.times do |i|
      sleep 1
      response.stream.write "数据项 #{i}\n"
    end
    response.stream.close
  end
end

客户端会看到每秒钟收到一条数据,而非等待所有计算完成后一次性返回。

Server-Sent Events (SSE)

# frozen_string_literal: true

class EventsController < ApplicationController
  include ActionController::Live

  def stream
    100.times do |i|
      begin
        response.stream.write("event: message\n")
        response.stream.write("data: Event #{Time.now}\n\n")
        sleep 2
      rescue ClosedError
        break  # 客户端断开连接
      end
    end
  ensure
    response.stream.close
  end
end

WebSockets with Action Cable

Falcon 支持 Rails 的 Action Cable WebSocket 实现。配置 config/cable.yml

production:
  adapter: async

性能对比:Falcon vs Puma vs Webrick

Falcon 在 I/O 密集型场景下表现突出。以下是典型的 benchmark 数据(使用 wrk 测试,10000 并发连接,30 秒):

服务器请求/秒 (并发 1K)请求/秒 (并发 10K)平均延迟 (ms)
Falcon~12,000~8,500~85
Puma~9,500~5,200~180
Webrick~1,800~400~2,400

关键差异:

  • Falcon:Fiber 调度器使得高并发下的延迟几乎不增长,适合 API 网关、实时数据推送
  • Puma:线程池模型,在高并发时线程争用导致延迟上升,但胜在 Rails 生态兼容最好
  • Webrick:Ruby 默认服务器,纯阻塞模型,仅适合开发调试,绝对不要用在生产环境

在纯 CPU 密集型任务(如大量数学运算、加密)上,Falcon 的优势不明显,因为这类任务无法通过 I/O 切换来提升吞吐量。

部署实践

Systemd 管理

生产环境推荐用 systemd 管理 Falcon 进程:

# /etc/systemd/system/falcon.service
[Unit]
Description=Falcon Ruby Web Server
After=network.target

[Service]
Type=forking
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/local/bin/falcon run --daemon --bind http://0.0.0.0:9292
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
$ sudo systemctl enable falcon
$ sudo systemctl start falcon
$ sudo systemctl status falcon

Kubernetes 部署

Falcon 支持优雅关闭(SIGTERM),非常适合容器化部署:

# k8s-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: falcon-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: falcon-app
  template:
    metadata:
      labels:
        app: falcon-app
    spec:
      containers:
      - name: app
        image: myapp:latest
        command: ["falcon", "run", "--bind", "http://0.0.0.0:9292"]
        ports:
        - containerPort: 9292
        livenessProbe:
          httpGet:
            path: /health
            port: 9292
          initialDelaySeconds: 10
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 9292
          initialDelaySeconds: 5
          periodSeconds: 10

Worker 配置:进程 vs 线程

Falcon 默认使用 --forked 模式(多进程 fork),每个 Worker 是一个独立的进程。也可以切换到 --threaded 模式:

# Fork 模式(默认,推荐)
$ falcon serve --bind http://0.0.0.0:9292 --workers 4

# Thread 模式
$ falcon serve --bind http://0.0.0.0:9292 --threaded --threads 4

选择建议:

  • Fork 模式:进程隔离,适合多核 CPU。每个 Worker 有独立的内存空间。Falcon 的默认和推荐模式。
  • Thread 模式:线程共享内存,适合内存受限的环境。但受限于 GVL,CPU 密集型任务性能会下降。

何时选择 Falcon

选择 Falcon 的场景:

  • 高并发 API:需要支撑大量并发连接的外部 API 服务,如 JSON API 网关、移动应用后端。Falcon 的 Fiber 调度器让数千个并发连接的延迟保持在低位。
  • 实时应用:WebSocket、SSE 等长连接场景。Falcon 的协程模型天然适合处理大量持久连接。
  • 现代 Ruby 3.0+ 项目:你的项目基于 Ruby 3.0+,希望充分利用 Fiber Scheduler 的非阻塞能力。
  • 微服务架构:每个服务需要最小的内存占用和最快的启动速度。

选择 Puma 而不是 Falcon 的理由:

  • Rails 默认:Puma 是 Rails 的默认服务器,社区支持最广,遇到问题更容易找到解决方案。
  • 简单部署:Puma 的配置更直观,与 Capistrano、Kubernetes 等工具的集成已经有成熟的模式。
  • 更广泛的使用量:Falcon 相对较新,生态圈和社区规模不如 Puma。如果你在寻找稳定的生产级服务器,Puma 是更成熟的选项。
  • Rails 深度集成:某些 Rails 插件和 middleware 专门为 Puma 做了优化,可能需要额外适配才能在 Falcon 下运行。

本章要点

  • Falcon 是基于 Fiber 的高性能 Ruby HTTP 服务器,适合高并发场景
  • 天然支持 HTTP/2、TLS、WebSocket,无需额外反向代理
  • 兼容 Rack 2 和 Rack 3,可以直接运行 Sinatra、Rails、Roda 等任何 Rack 应用
  • 部署支持 systemd、Kubernetes、多进程/多线程模式
  • 高并发选 Falcon,追求稳定成熟选 Puma

继续学习

提示:Falcon 是一个出色的补充知识。对于大多数 Ruby 项目来说,Puma 已经完全够用;但当你的应用面临高并发挑战、需要处理大量 WebSocket 连接、或者单纯追求极致性能时,Falcon 的 Fiber 调度器会让你眼前一亮。

术语表

Basic 基础术语

英文中文说明
Variable变量存储数据的命名容器
Local Variable局部变量作用域限定在代码块内
Instance Variable实例变量@ 开头,属于对象实例
Class Variable类变量@@ 开头,类及其子类共享
Global Variable全局变量$ 开头,全局可见
Constant常量以大写字母开头,值不应改变
String字符串字符序列
Array数组有序集合
Hash哈希键值对集合
Method方法可重复调用的代码块
Block{}do...end 包围的代码
Class创建对象的蓝图
Module模块方法与常量的命名空间
Symbol符号内部化字符串,轻量标识符
Scope作用域变量可访问的范围

Advance 进阶术语

英文中文说明
Enumerable可枚举提供迭代方法的模块 (map, select, reduce)
Metaprogramming元编程运行时动态生成/修改代码
Thread线程操作系统级并发单元
Fiber协程轻量级协作式并发
RactorRactorRuby 3+ 的 Actor 模型并行
GVL全局 VM 锁Ruby 限制同一时刻只有一个线程执行
Mutex互斥锁保护共享资源的同步原语
Queue队列线程安全的先进先出数据结构
ORM对象关系映射将数据库行映射为对象
Dependency Injection依赖注入将依赖关系外部化注入对象
Result MonadResult 单子函数式错误处理模式
Benchmark基准测试测量代码性能的工具

Awesome 实战术语

英文中文说明
Container容器dry-system 的依赖注入容器
Provider提供者dry-system 中注册组件的方式
Component组件自动注册的可注入对象
Service服务微服务架构中的独立部署单元
Pool连接池复用数据库连接以提高性能
Migration迁移数据库 schema 的版本控制变更
CI/CD持续集成/部署自动化构建、测试与部署流程
Gem宝石/库Ruby 的包分发格式

常见问题 FAQ

入门相关

Q: 如何安装 Ruby 3.2+?

推荐使用 rbenv 管理 Ruby 版本:

# 安装 rbenv
brew install rbenv ruby-build
rbenv install 3.2.1
rbenv global 3.2.1

或者使用 mise-en-place

mise use ruby@3.2.1

Q: bundle install 失败怎么办?

  1. 确认 Ruby 版本 ≥ 3.2.0: ruby -v
  2. 检查 .ruby-version 文件是否与安装版本匹配
  3. 尝试 gem update --system 升级 RubyGems
  4. 删除 Gemfile.lock 后重新 bundle install

Q: CLI 命令 hello 找不到?

确认已运行 bundle install 安装所有依赖。运行方式:

bundle exec hello version

如果希望全局使用,可运行 bundle binstubs hello 创建 binstub。

学习相关

Q: 没有编程基础可以从这里开始吗?

可以。Basic 层级从零开始,按照顺序学习即可。每个主题都是独立的,但按顺序学习效果最佳。

Q: 学完 Basic 就能做什么?

掌握 Basic 后,你可以:

  • 编写自动化脚本
  • 理解大多数 Ruby gem 的源码结构
  • 开始学习 Advance 级别的元编程、并发等主题

Q: Advance 和 Awesome 有什么区别?

  • Advance — 学习 Ruby 的高级特性和工程工具(RSpec、Sequel、dry-system)
  • Awesome — 生产环境级别的应用架构(Web 服务、消息队列、Docker 部署)

Q: 可以和 The Ruby Programming Language 或 Ruby Koans 配合使用吗?

可以。本项目的结构化教程与经典材料互补。推荐搭配:

技术问题

Q: 如何贡献内容?

  1. Fork 仓库
  2. 创建分支 feat/add-topic-TOPIC_NAME
  3. 编写 topic 代码 + 对应的 mdBook 文档
  4. 提交 PR

详见 贡献指南

Q: 如何在本地预览 mdBook 文档?

cargo install mdbook
cd docs
mdbook serve --open

💡 提示: mdBook 支持实时预览。编辑任何 .md 文件后自动刷新。

Q: 项目使用什么 Ruby 版本?

.ruby-version 指定为 3.2.1。gemspec 要求 >= 3.2.0

Q: 为什么选择 Thor 作为 CLI 框架?

Thor 是 Ruby 生态中最流行的 CLI 框架之一。它提供:

  • 子命令自动注册
  • 类型化选项解析
  • 内置帮助生成
  • 与 Rails、Vagrant 等大型项目相同的技术栈

Q: 这个项目与 hello-rust 是什么关系?

两者由同一作者开发,遵循相似的三层级教学结构(Basic → Advance → Awesome)和 mdBook 文档体系。hello-ruby 是 Ruby 语言版本。

更新日志

Unreleased

0.1.0 - 2026-04-03

新增

  • 💎 Ruby 3.2+ 教程项目初始化
  • 🔧 Thor CLI 入口 (hello 命令)
  • 💡 Basic 层级 15 个主题(变量 → 文件管理)
  • 🔬 Advance 层级 10 个主题(并发 → 性能优化)
  • 📚 mdBook 教程文档体系(Basic + Advance + Awesome)
  • 🧪 RSpec 测试基础设施
  • 🔍 RuboCop + Sorbet 代码质量保障
  • 📦 dry-system 依赖注入框架

技术栈

  • CLI: Thor ~> 1.1
  • DI: dry-system ~> 1.0
  • ORM: Sequel ~> 5.54
  • 类型: Sorbet + Steep
  • 测试: RSpec ~> 3.0 + FactoryBot
  • 文档: mdBook + pagetoc