第3章 创建命令
把ARGV、stdin、文件读取、正则筛选、方法定义与require连接为可测试的命令行工具,明确内存、资源、输出通道和退出状态。
学习目标
- 能解释命令行的输入数据与从文件中读取内容并输出在“第3章 创建命令”中的责任边界
- 能围绕“怎样让命令行参数、文件读取、模式匹配和方法返回形成一个可重放命令?”运行正常与故障轨迹并定位首个分岔
- 能用“第3章 创建命令的输入样本、接收者与方法、关键状态前后值、正常与失败输出、异常或退出状态,以及复位后的再次运行记录。”证明“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”
来源、版次与运行边界
“第3章 创建命令”以作者维护的第 5 版支持页核定 2016 年 3 月 12 日首刷、两位作者、松本行弘监修以及四部分 23 章目录;逐章程序清单、练习答案和勘误只作为公开支持材料,不被冒充为原书全文。
对“第3章 创建命令”而言,中文解释、示例、交互、练习和答案均为独立教学重写;站内中文章名是与官方 23 章顺序对应的课程映射,不宣称是日文小节的逐字翻译,也不从公开程序清单复制整段实现。
“第3章 创建命令”固定在 Ruby 2.3 语境;Ruby 2.3.0 官方文档与稳定版发布说明用于核对当时可用的语言和标准库行为。现代 Ruby 的差异只能另列迁移说明,不能静默改变本页示例的版本结论。
围绕“怎样让命令行参数、文件读取、模式匹配和方法返回形成一个可重放命令?”,本页验收“第3章 创建命令的输入样本、接收者与方法、关键状态前后值、正常与失败输出、异常或退出状态,以及复位后的再次运行记录。”。先预测正常轨迹,再只注入“文件读取失败后仍沿用旧内容并输出成功状态”;若无法定位第一处状态分岔,就拒绝当前解释。
从“能跑的脚本”开始构造命令
脚本只要在作者机器上执行一次即可;命令则需要稳定接受输入、产生可解释输出、用exit status说明结果,并在不同当前目录与错误场景下保持一致。是本章的主线。
我们实现一个simple_grep.rb:第一个参数是pattern,之后是一个或多个文件;若文件写-则读取stdin。正常匹配写stdout,参数/读取错误写stderr;找到匹配返回0,没有匹配返回1,调用错误返回2。真实grep的细节更多,这里先建立可测试边界。
先预测:用户少传文件时,让ARGV[1]得到nil并交给File.foreach是否足够?不够。那会在深层抛TypeError,丢失正确usage和status。参数形状应在任何I/O前一次验证。
命令行的输入数据
Ruby脚本通过ARGV获得位置参数,每个元素都是String。ARGV.shift方便消费参数,却会修改global array,使后续方法和测试依赖调用顺序。更稳妥的边界先复制或用index解析,再把普通value传给domain method。能消除隐藏global state。
Command = Struct.new(:pattern, :paths)
def parse_arguments(argv)
pattern_text, *paths = argv
raise ArgumentError, "usage: simple_grep PATTERN FILE..." if pattern_text.nil? || paths.empty?
Command.new(Regexp.new(pattern_text), paths.freeze)
rescue RegexpError => error
raise ArgumentError, "invalid pattern: #{error.message}"
end示例使用Ruby 2.3已有的Struct.new;较新Ruby可以考虑Data.define或keyword-init Struct。教程代码必须标明runtime baseline,不能把现代API悄悄投射回旧版本。本项目讲解以第五版目录和Ruby 2.3语义为基线,同时在适当位置指出现代替代品。
CLI option变多时使用标准库OptionParser,不要手写一串shift。无论使用哪种parser,都要测试空参数、帮助、未知option、缺value、重复option、--后的路径以及包含空格的shell argument。Shell quoting发生在Ruby启动前,程序只看到解析后的String数组。
文件的读取:先确定大小和资源owner
文件API的选择取决于输入上界和处理方式。File.read(path)一次返回整个String,适合配置和有明确大小限制的小文件;File.foreach(path)逐行yield,适合日志与筛选。必须在实现前确定。
Path来自用户时,还要说明允许根目录、符号链接策略、普通文件要求和最大size;CLI本地工具与服务器上传接口的安全边界不同。不要用字符串前缀判断路径是否在目录内,应canonicalize后比较路径层次,并防止检查后替换文件的race。第18章会系统展开File与Dir。
从文件中读取内容并输出
def print_file(path, output: $stdout)
content = File.read(path, mode: "r:BOM|UTF-8")
output.write(content)
end整文件读取代码短,但memory约为文件size再加上后续变换产生的String。读取前可用File.size做初筛,却仍需处理文件在检查后增长的情况;强边界应通过limited read或隔离实现。output:依赖注入让测试传StringIO,不必捕获真实stdout。
从文件中逐行读取内容并输出
def copy_lines(path, output: $stdout)
File.foreach(path, mode: "r:BOM|UTF-8") do |line|
output.write(line)
end
end逐行模式把峰值内存限制在line size附近,但超长单行仍可能很大。Line默认保留separator,chomp返回去掉record separator的新String,chomp!原地修改。若工具要原样复制,就不要先chomp再盲目补\n,否则最后一行是否有换行的信息会丢失。
从文件中读取指定模式的内容并输出
筛选器不需要知道line来自文件还是stdin。让它接收任何可each_line/each的enumerable,并返回匹配数量;CLI adapter再负责打开source与映射status。使同一核心能在单元测试、文件和pipeline中复用。
def emit_matches(lines, pattern, output: $stdout)
count = 0
lines.each do |line|
next unless pattern.match?(line)
output.write(line)
count += 1
end
count
end
def with_source(path)
return yield($stdin) if path == "-"
File.open(path, "r:BOM|UTF-8") { |file| yield(file) }
endRuby 2.3没有Regexp#match?,兼容实现使用pattern.match(line);match?的优势是不更新某些全局match状态并减少对象分配。再一次,版本基线决定可用API。筛选输出若要求path:line_number:text,应在enumeration处用with_index(1)显式维护行号,不能搜索完再重读文件猜位置。
错误策略分层:单个路径不可读,是立即终止还是继续其它文件并最终返回2?两种都可,但command contract必须固定。若继续处理,stderr应逐文件带path和异常class,最终status不能因为后一个文件成功就覆盖之前错误。
方法的定义:把政策放在参数和返回值里
方法的参数表达输入,返回值表达结果;异常表达调用者必须处理的失败。隐藏读取ARGV、写global变量或直接exit的方法很难组合。让测试矩阵小而完整。
本例可分三层:parse_arguments只解析;emit_matches只筛选;run(argv, stdin:, stdout:, stderr:)编排路径并返回整数status。只有文件末尾adapter调用exit run(...)。这样require该文件时不会意外结束测试进程,也能对每一层单独注入失败。
方法名应表达side effect:matches像返回collection,emit_matches明确会输出。返回匹配数量比返回true提供更多信息,又不泄漏I/O细节。若结果可能无限,不要返回完整Array,改返回Enumerator或逐项yield。
其他文件的引用
require "simple_grep/filter"通过$LOAD_PATH查找feature并通常只加载一次;require_relative "filter"相对当前source file解析,不受process working directory影响。load会每次重新执行文件,适合受控reload场景,不是普通依赖机制。
文件被require时,顶层定义和side effect都会执行。Library file不要在顶层读取ARGV、打开业务文件或打印banner;将可执行入口放在if $PROGRAM_NAME == __FILE__保护下。是小型Ruby项目最重要的composition边界之一。
正式节点与章专属证据
- ↡在“第3章 创建命令”中,命令行的输入数据必须连接输入、状态变化与可复核结果。 :第 1 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
- ↡在“第3章 创建命令”中,从文件中读取内容并输出必须连接输入、状态变化与可复核结果。 :第 2 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
- ↡在“第3章 创建命令”中,从文件中逐行读取内容必须连接输入、状态变化与可复核结果。 :第 3 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
- ↡在“第3章 创建命令”中,从文件中读取指定模式必须连接输入、状态变化与可复核结果。 :第 4 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
- ↡在“第3章 创建命令”中,方法的定义必须连接输入、状态变化与可复核结果。 :第 5 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
- ↡在“第3章 创建命令”中,其他文件的引用必须连接输入、状态变化与可复核结果。 :第 6 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
对象与状态模型
从输入、接收者到可观察证据
怎样让命令行参数、文件读取、模式匹配和方法返回形成一个可重放命令?
输入与接收者
固定命令行的输入数据所需的原始值、Ruby 版本和调用入口。
状态变化
在执行前记录接收者身份,并声明从文件中读取内容并输出的允许状态。
观察证据
保存第3章 创建命令的初值、参数、编码或资源位置。
正式节点:命令行的输入数据、从文件中读取内容并输出、从文件中逐行读取内容、从文件中读取指定模式、方法的定义、其他文件的引用
控制与消息轨迹
在相同初值下定位首个分岔
- 01固定命令行的输入数据的输入和接收者
- 02执行从文件中读取内容并输出并记录状态
- 03观察从文件中逐行读取内容的返回或副作用
- 04用其他文件的引用核对不变量并复位
运行不变量:输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。
边界故障探针
一次只破坏一个前提
本章回顾:命令只是薄适配层
- 参数在I/O前一次解析,ARGV只留在最外层;usage、输出通道和status属于command contract。
File.read适合有界小文件,File.foreach适合stream,File.openblock确保异常时关闭handle。- 指定模式筛选只依赖enumerable、Regexp与output,文件/stdin差异由adapter处理。
- 方法使用显式依赖、返回值和异常,不在深层调用
exit或读取process globals。 require按load path且通常一次,require_relative按source file;main guard阻止library import触发CLI。
练习与答案
练习
- 问题 1:建立正常轨迹。 回答“怎样让命令行参数、文件读取、模式匹配和方法返回形成一个可重放命令?”,并写出四步执行记录。
- 问题 2:注入单一故障。 只制造“文件读取失败后仍沿用旧内容并输出成功状态”,应从哪里开始定位?
- 问题 3:覆盖正式节点。 用一个证据包串联命令行的输入数据、从文件中读取内容并输出、从文件中逐行读取内容、从文件中读取指定模式、方法的定义、其他文件的引用,说明为什么结论可由另一位读者独立复核。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 命令行的输入数据
“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。
- 从文件中读取内容并输出
“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。
- 从文件中逐行读取内容
“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。
- 从文件中读取指定模式
“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。
- 方法的定义
“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。
- 其他文件的引用
“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。