Ruby 实战项目

学了 16 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——待办 CLI、网页爬虫、Sinatra API,每个都给出完整代码、运行结果和要点讲解。三个项目对应 Ruby 最常见的三个方向:命令行工具(自动化)、网络采集(抓数据)、Web 服务(做后端 API)。建议你把代码复制到本地亲手跑一遍。

实战 1:待办 CLI(class + YAML + OptionParser)

需求:做一个待办管理 CLI,支持 add / list / done / delete,数据用 YAML 持久化。

涉及知识:Struct、class、YAML 序列化、OptionParser(标准库)、case/when、异常 rescue。

# todo.rb — 待办管理 CLI(支持 add / list / done / delete)
# 用法:
#   ruby todo.rb add 买牛奶
#   ruby todo.rb list
#   ruby todo.rb done 1
#   ruby todo.rb delete 2
# 知识点:类、YAML 持久化、OptionParser、Block、异常处理
require "yaml"
require "optparse"
require "time"

# 单条待办:用 Struct 一行定义数据类(类似 attr_accessor 的精简版)
Todo = Struct.new(:id, :title, :done, :created_at) do
  def to_s
    mark = done ? "[x]" : "[ ]"
    "#{mark} #{id}. #{title}  (#{created_at})"
  end
end

# 待办列表:封装增删查改与持久化
class TodoList
  FILE = "todos.yml"

  def initialize
    @items = load
  end

  def add(title)
    id = @items.empty? ? 1 : @items.map(&:id).max + 1
    t = Todo.new(id, title, false, Time.now.strftime("%Y-%m-%d %H:%M"))
    @items << t
    save
    puts "已添加: #{t}"
  end

  def list(all: false)
    items = all ? @items : @items.reject(&:done)
    if items.empty?
      puts "(空空如也)"
    else
      items.each { |t| puts t }
    end
  end

  def done(id)
    t = @items.find { |x| x.id == id }
    if t
      t.done = true
      save
      puts "已完成: #{t.title}"
    else
      puts "未找到 id=#{id}"
    end
  end

  def delete(id)
    before = @items.size
    @items.reject! { |x| x.id == id }
    if @items.size < before
      save
      puts "已删除 id=#{id}"
    else
      puts "未找到 id=#{id}"
    end
  end

  private

  def load
    return [] unless File.exist?(FILE)
    YAML.load_file(FILE) || []      # YAML 反序列化,直接还原成 Todo 对象数组
  rescue => e
    warn "读取失败: #{e.message}"
    []
  end

  def save
    File.write(FILE, YAML.dump(@items))   # 序列化成 YAML 写文件
  end
end

# === 命令行入口 ===
list = TodoList.new

options = { all: false }
OptionParser.new do |opts|
  opts.banner = "用法: ruby todo.rb [命令] [参数]"
  opts.on("-a", "--all", "显示全部(含已完成)") { options[:all] = true }
  opts.on("-h", "--help", "显示帮助") { puts opts; exit }
end.parse!

command = ARGV.shift
case command
when "add"    then list.add(ARGV.join(" "))
when "list"   then list.list(all: options[:all])
when "done"   then list.done(ARGV.shift.to_i)
when "delete" then list.delete(ARGV.shift.to_i)
else
  warn "未知命令: #{command}(可用:add / list / done / delete)"
end

# === 运行示例 ===
# $ ruby todo.rb add 买牛奶
# 已添加: [ ] 1. 买牛奶  (2024-06-01 10:30)
# $ ruby todo.rb add 写季度报告
# 已添加: [ ] 2. 写季度报告  (2024-06-01 10:31)
# $ ruby todo.rb list
# [ ] 1. 买牛奶  (2024-06-01 10:30)
# [ ] 2. 写季度报告  (2024-06-01 10:31)
# $ ruby todo.rb done 1
# 已完成: 买牛奶
# $ ruby todo.rb list --all
# [x] 1. 买牛奶  (2024-06-01 10:30)
# [ ] 2. 写季度报告  (2024-06-01 10:31)

要点讲解:

实战 2:网页爬虫(net/http + Nokogiri)

需求:抓取 quotes.toscrape.com 首页全部名言和作者,保存到 CSV。先装依赖:gem install nokogiri。

涉及知识:Net::HTTP(标准库)、URI、Nokogiri 解析、CSS 选择器、CSV、超时与异常。

# spider.rb — 网页爬虫:抓取 quotes.toscrape.com 的名言
# 依赖:gem install nokogiri
# 知识点:net/http(标准库)、Nokogiri(HTML 解析)、CSS 选择器、异常处理
require "net/http"
require "uri"
require "nokogiri"
require "csv"
require "fiber"

URL = "https://quotes.toscrape.com/"

# 封装 HTTP 请求:超时、User-Agent、异常
def fetch(url)
  uri = URI(url)
  req = Net::HTTP::Get.new(uri)
  req["User-Agent"] = "Mozilla/5.0 (compatible; MyBot/1.0)"
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true if uri.scheme == "https"
  http.open_time = 10
  http.read_time = 10
  http.request(req)                  # 返回 Net::HTTPResponse
rescue => e
  warn "[错误] #{e.class}: #{e.message}"
  nil
end

# 用 CSS 选择器解析,返回哈希数组
def parse(html)
  doc = Nokogiri::HTML(html)
  doc.css(".quote").map do |q|
    {
      text:   q.at_css(".text").text.strip,
      author: q.at_css(".author").text.strip,
    }
  end
end

# 写 CSV
def save(items, path = "quotes.csv")
  CSV.open(path, "wb") do |csv|
    csv << %w[text author]
    items.each { |it| csv << [it[:text], it[:author]] }
  end
  puts "[完成] 已保存 #{items.size} 条到 #{path}"
end

puts "[开始] 爬取 #{URL}"
resp = fetch(URL)
if resp && resp.code == "200"
  items = parse(resp.body)
  puts "[预览] 共 #{items.size} 条,前 3 条:"
  items.first(3).each { |it| puts "  - #{it[:author]}: #{it[:text][0, 40]}" }
  save(items)
end

# === 输出 ===
# [开始] 爬取 https://quotes.toscrape.com/
# [预览] 共 10 条,前 3 条:
#   - Albert Einstein: The world as we have created it is a proc
#   - J.K. Rowling: It is our choices, Harry, that show what we
#   - Albert Einstein: There are only two ways to live your life
# [完成] 已保存 10 条到 quotes.csv

# === 不装 Nokogiri 也能用正则(应急方案,不推荐长期用)===
# HTML 结构复杂,正则容易在嵌套标签上踩坑,生产环境还是用 Nokogiri
def parse_with_regex(html)
  html.scan(/<span class="text" itemprop="text">(.+?)<\/span>/m).flatten.map(&:strip)
end

要点讲解:

实战 3:Sinatra 简易 API(gem + route)

需求:用 Sinatra 写一个待办 REST API,支持 GET / POST / PUT / DELETE。启动后能用 curl 测试。先装依赖:gem install sinatra。

涉及知识:Sinatra DSL、HTTP 方法路由、JSON 返回、halt 中断、参数 params、状态码。

# api.rb — 用 Sinatra 写一个待办 REST API
# 依赖:gem install sinatra
#   gem install thin          # 推荐搭配 thin 服务器,默认 WEBrick 也行
# 启动:ruby api.rb
#   默认监听 http://localhost:4567
# 知识点:Sinatra 路由、JSON 返回、HTTP 方法、参数解析
#
# 测试(开另一个终端):
#   curl http://localhost:4567/todos
#   curl -X POST -d '{"title":"买牛奶"}' -H "Content-Type: application/json" http://localhost:4567/todos
#   curl -X PUT  -d '{"done":true}'     -H "Content-Type: application/json" http://localhost:4567/todos/1
#   curl -X DELETE http://localhost:4567/todos/1
require "sinatra"
require "json"

# 内存存储(重启清空);真实项目换 PostgreSQL + Sequel
$todos = []
$next_id = 1

before "/todos*" do
  content_type :json              # 所有 /todos* 路由都返回 JSON
end

# 列出全部 — GET /todos
get "/todos" do
  $todos.to_json
end

# 查一条 — GET /todos/:id
get "/todos/:id" do
  t = $todos.find { |x| x[:id] == params[:id].to_i }
  halt 404, { error: "未找到" }.to_json unless t
  t.to_json
end

# 新增 — POST /todos,请求体是 JSON
post "/todos" do
  body = JSON.parse(request.body.read) rescue {}
  halt 400, { error: "缺少 title" }.to_json unless body["title"]

  todo = { id: $next_id += 1, title: body["title"], done: false }
  $todos << todo
  status 201                       # 201 Created
  todo.to_json
end

# 改一条 — PUT /todos/:id
put "/todos/:id" do
  t = $todos.find { |x| x[:id] == params[:id].to_i }
  halt 404, { error: "未找到" }.to_json unless t

  body = JSON.parse(request.body.read) rescue {}
  t[:title] = body["title"] if body["title"]
  t[:done]  = body["done"]  if body.key?("done")
  t.to_json
end

# 删一条 — DELETE /todos/:id
delete "/todos/:id" do
  before_size = $todos.size
  $todos.reject! { |x| x[:id] == params[:id].to_i }
  halt 404, { error: "未找到" }.to_json if $todos.size == before_size
  status 204                       # 204 No Content
end

# === 同样的 API 用 Rails 写要 5 个文件,用 Sinatra 一个文件就够 ===
# 这正是 Ruby 的魅力:DSL + 约定 + 极少样板,适合快速原型/内部工具

# 启动后用 curl 测试:
# $ curl http://localhost:4567/todos
# []
# $ curl -X POST -d '{"title":"买牛奶"}' -H "Content-Type: application/json" http://localhost:4567/todos
# {"id":2,"title":"买牛奶","done":false}
# $ curl http://localhost:4567/todos
# [{"id":2,"title":"买牛奶","done":false}]
# $ curl -X PUT -d '{"done":true}' -H "Content-Type: application/json" http://localhost:4567/todos/2
# {"id":2,"title":"买牛奶","done":true}

要点讲解:

小结

三个项目分别对应 Ruby 的三大主力场景:CLI 工具(自动化日常)、网络采集(抓数据)、Web 服务(做后端)。它们都用到了前面学的基础——类、块、文件 I/O、异常——只是组合起来解决真实问题。建议你至少把其中一个改造成自己的版本(给 CLI 加优先级、给爬虫加分页、给 API 加用户认证),做过一遍比读十遍更扎实。

恭喜!到这里 Ruby 系列就真正完结了。接下来可以挑一个方向深入——Ruby on Rails(全栈 Web 框架)、Hanami(现代化 Ruby 框架)、Jekyll(静态博客)、Hotwire(现代 Web 交互)、给开源项目贡献(Rails、Sinatra 都是 Ruby 写的)——Ruby 的世界以"写得开心"为核心,边做边玩进步最快。

← 上一篇 Ruby 文件与 IO

返回 Ruby 教程目录

✈️💬