CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
综合练习
这一课你将能做到
用 Bundler 创建一个标准 Gem 项目结构,理解 gemspec 和 lib/exe 目录分工
写一个完整的 CLI 工具,包含参数解析、文件处理和错误处理
为项目配置 RSpec 测试和 RuboCop 检查,达到可发布的质量标准
为什么要学
前面 11 课学了 Ruby 的语法、OOP、元编程、并发、生态——但「知道」和「做到」之间隔着一条鸿沟。这一课把全部知识串起来,写一个真实可用的 CLI 工具,体验从「零」到「可发布 Gem」的完整流程。
人话:前面学的是「单词和语法」,这一课是「写作文」。Bundler 是稿纸格式,CLI 是文章结构,测试是校对,RuboCop 是排版——一篇能发表的文章,每个环节都不能少。
先建立图像
标准 Gem 项目结构
my_tool/
├── Gemfile # Bundler 依赖声明
├── Gemfile.lock # 锁定版本
├── Rakefile # 任务定义(测试、发布)
├── my_tool.gemspec # Gem 元数据(名称、版本、依赖)
├── lib/
│ ├── my_tool.rb # 主入口
│ └── my_tool/
│ ├── version.rb # 版本常量
│ └── cli.rb # CLI 逻辑
├── exe/
│ └── my_tool # 可执行文件(binstub)
├── spec/
│ ├── spec_helper.rb # RSpec 配置
│ └── my_tool_spec.rb # 测试
└── .rubocop.yml # 代码风格配置
跟着做
步骤 1:用 Bundler 创建 Gem 骨架
# 创建项目
bundle gem my_tool --test=rspec --ci=github
cd my_tool
# 生成的关键文件:
# my_tool.gemspec # Gem 元数据
# lib/my_tool.rb # 主入口
# exe/my_tool # 可执行文件
# spec/ # RSpec 测试
编辑 my_tool.gemspec:
Gem::Specification.new do |spec|
spec.name = "my_tool"
spec.version = MyTool::VERSION
spec.authors = ["Your Name"]
spec.email = ["you@example.com"]
spec.summary = "文件批量重命名工具"
spec.description = "按规则批量重命名文件的 CLI 工具"
spec.homepage = "https://github.com/yourname/my_tool"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.0"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
spec.files = Dir["lib/**/*", "exe/*", "README.md"]
spec.bindir = "exe"
spec.executables = ["my_tool"]
spec.require_paths = ["lib"]
spec.add_dependency "thor", "~> 1.3" # CLI 框架
end
步骤 2:写 CLI 核心逻辑
lib/my_tool.rb:
# frozen_string_literal: true
require_relative "my_tool/version"
require_relative "my_tool/renamer"
require_relative "my_tool/cli"
module MyTool
class Error < StandardError; end
end
lib/my_tool/version.rb:
# frozen_string_literal: true
module MyTool
VERSION = "0.1.0"
end
# frozen_string_literal: true
module MyTool
class Renamer
def initialize(pattern:, replacement:, dry_run: false)
@pattern = pattern
@replacement = replacement
@dry_run = dry_run
end
def rename_files(directory)
files = Dir.glob(File.join(directory, "*"))
renamed = []
files.each do |file|
next unless File.file?(file)
basename = File.basename(file)
new_name = basename.gsub(/#{@pattern}/, @replacement)
next if new_name == basename
new_path = File.join(directory, new_name)
renamed << { from: file, to: new_path }
unless @dry_run
File.rename(file, new_path)
end
end
renamed
end
end
end
# frozen_string_literal: true
require "thor"
module MyTool
class CLI < Thor
desc "rename DIRECTORY", "批量重命名目录中的文件"
option :pattern, type: :string, required: true, desc: "匹配模式(正则)"
option :replacement, type: :string, required: true, desc: "替换内容"
option :dry_run, type: :boolean, default: false, desc: "预览模式,不实际执行"
def rename(directory)
unless Dir.exist?(directory)
puts "错误:目录 #{directory} 不存在"
exit 1
end
renamer = Renamer.new(
pattern: options[:pattern],
replacement: options[:replacement],
dry_run: options[:dry_run]
)
results = renamer.rename_files(directory)
if results.empty?
puts "没有匹配的文件"
else
mode = options[:dry_run] ? "[预览] " : ""
results.each do |r|
puts "#{mode}#{File.basename(r[:from])} → #{File.basename(r[:to])}"
end
puts "#{mode}共处理 #{results.size} 个文件"
end
rescue RegexpError => e
puts "正则表达式错误:#{e.message}"
exit 1
end
end
end
#!/usr/bin/env ruby
# frozen_string_literal: true
require "my_tool"
MyTool::CLI.start(ARGV)
步骤 3:写 RSpec 测试 spec/my_tool/renamer_spec.rb:
# frozen_string_literal: true
require "tmpdir"
require "fileutils"
RSpec.describe MyTool::Renamer do
let(:tmpdir) { Dir.mktmpdir }
after do
FileUtils.rm_rf(tmpdir)
end
describe "#rename_files" do
it "按模式重命名文件" do
File.write(File.join(tmpdir, "test_001.txt"), "content")
File.write(File.join(tmpdir, "test_002.txt"), "content")
renamer = MyTool::Renamer.new(pattern: "test", replacement: "prod")
results = renamer.rename_files(tmpdir)
expect(results.size).to eq(2)
expect(File.exist?(File.join(tmpdir, "prod_001.txt"))).to be true
end
it "dry_run 模式不实际执行" do
File.write(File.join(tmpdir, "test_001.txt"), "content")
renamer = MyTool::Renamer.new(pattern: "test", replacement: "prod", dry_run: true)
results = renamer.rename_files(tmpdir)
expect(results.size).to eq(1)
expect(File.exist?(File.join(tmpdir, "test_001.txt"))).to be true
expect(File.exist?(File.join(tmpdir, "prod_001.txt"))).to be false
end
it "无匹配时返回空数组" do
File.write(File.join(tmpdir, "hello.txt"), "content")
renamer = MyTool::Renamer.new(pattern: "xyz", replacement: "abc")
results = renamer.rename_files(tmpdir)
expect(results).to be_empty
end
end
end
步骤 4:配置 RuboCop 与运行 # .rubocop.yml
AllCops:
TargetRubyVersion: 3.0
NewCops: enable
Exclude:
- "vendor/**/*"
- "tmp/**/*"
Style/Documentation:
Enabled: false
Metrics/MethodLength:
Max: 20
# 运行测试
bundle exec rspec
# 运行 RuboCop
bundle exec rubocop
# 自动修复
bundle exec rubocop -a
# 本地安装测试
gem build my_tool.gemspec
gem install ./my_tool-0.1.0.gem
# 测试 CLI
my_tool rename /tmp/test --pattern=test --replacement=prod --dry-run
步骤 5:发布到 RubyGems(可选) # 注册 rubygems.org 账号
gem signin
# 发布
rake release
# 或手动
gem build my_tool.gemspec
gem push my_tool-0.1.0.gem
讲清楚要点
Bundler 的 bundle gem 是标准起点 :它生成 gemspec、Gemfile、Rakefile、测试框架配置——比手动创建更规范。--test=rspec 选 RSpec,--ci=github 加 GitHub Actions 配置。
lib/ 放库代码,exe/ 放可执行文件 :lib/my_tool.rb 是主入口(require "my_tool" 加载);exe/my_tool 是终端命令。gemspec 的 spec.executables 声明可执行文件。
Thor 是 CLI 框架的标准选择 :比 Ruby 自带的 OptionParser 更声明式。desc 定义命令描述,option 定义参数,Thor 自动生成 --help。
dry_run 模式是 CLI 工具的专业素养 :文件操作前预览结果,避免误删。用 --dry-run 参数控制,内部逻辑只是跳过 File.rename。
测试用 Dir.mktmpdir 隔离 :文件系统测试必须创建临时目录,测试后清理,避免污染真实环境。
你可能会懵的地方
gemspec 和 Gemfile 有什么区别?
gemspec 定义「这个 Gem 本身是什么」(名称、版本、依赖、文件列表);Gemfile 定义「开发这个 Gem 需要什么」(测试框架、调试工具)。Gemfile 里 gemspec 一行把 gemspec 的依赖合并进来。
为什么 lib/ 下要嵌套 my_tool/ 目录?
Ruby 的 require 路径约定:lib/my_tool.rb 是入口,lib/my_tool/*.rb 是内部模块。这样 require "my_tool" 只加载入口,入口再 require_relative 加载子模块,避免命名冲突。
Thor 和 OptionParser 怎么选?
Thor:多命令 CLI(如 rails new、rails server),声明式,自动生成 help;OptionParser:单命令、参数复杂、需要精细控制。新项目用 Thor。
Gem 的版本号怎么管理?
lib/my_tool/version.rb 里定义 VERSION = "0.1.0",gemspec 引用它。发布时改版本号、打 git tag、rake release 一键发布。语义化版本:MAJOR.MINOR.PATCH。
发布 Gem 到 rubygems.org 需要什么?
注册账号、gem signin 本地认证、确保 allowed_push_host 配置正确。发布后任何人 gem install my_tool 就能用。
错法对照 错法 问题 正解 gemspec 里 files 写 Dir["**/*"] 包含测试、临时文件 精确指定 Dir["lib/**/*", "exe/*"] CLI 直接 exit 不返回码 调用方无法判断成败 exit 0 成功,exit 1 失败文件操作不检查目录存在 NoMethodError 或 Errno::ENOENT Dir.exist? 前置检查正则用户输入不处理特殊字符 正则注入或意外匹配 Regexp.escape 转义,或捕获 RegexpError测试写真实文件不清理 测试间互相污染 Dir.mktmpdir + after { FileUtils.rm_rf }
卡点急救
bundle exec ruby exe/my_tool 报 LoadError :lib 不在 load path。bundle exec ruby -Ilib exe/my_tool,或确保 gemspec 的 require_paths 包含 lib。
Thor 参数解析失败 :option :pattern, type: :string, required: true 确保必填参数有校验。用户没传时 Thor 自动报错。
RSpec 测试里文件创建失败 :Dir.mktmpdir 需要 require "tmpdir"。Windows 上路径分隔符用 File.join 兼容。
面试常问
Gem 的目录结构和关键文件?
标准结构:lib/(库代码)、exe/(可执行文件)、spec/ 或 test/(测试)、gemspec(元数据)、Gemfile(开发依赖)、Rakefile(任务)。Bundler 的 bundle gem 一键生成。
如何发布一个 Gem 到 RubyGems?
写代码和测试;2. 配置 gemspec;3. gem build 打包;4. gem push 发布。rake release 自动化版本号、git tag、push 三步。
CLI 工具的错误处理最佳实践?
用户输入验证(文件存在、参数合法);清晰的错误信息(用户能看懂);正确的退出码(0 成功,非 0 失败);dry_run 模式预览危险操作;日志输出到 stderr,结果输出到 stdout。
自测三问
bundle gem 生成的目录结构中,lib/ 和 exe/ 的分工是什么?
gemspec 的 spec.files 和 spec.executables 分别控制什么?
为什么 CLI 工具要提供 --dry-run 模式?实现上怎么设计?
小结一句话 综合练习的核心是「把语法变成产品」——Bundler 搭骨架、Thor 做 CLI、RSpec 保质量、RuboCop 统一风格、RubyGems 发出去。
延伸