第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) }
end

Ruby 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边界之一。

正式节点与章专属证据

  • :第 1 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
  • :第 2 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
  • :第 3 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
  • :第 4 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
  • :第 5 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。
  • :第 6 个正式节点要能回到“输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。”,并说明故障发生前后的第一处差异。

对象与状态模型

从输入、接收者到可观察证据

怎样让命令行参数、文件读取、模式匹配和方法返回形成一个可重放命令?

输入与接收者

固定命令行的输入数据所需的原始值、Ruby 版本和调用入口。

状态变化

在执行前记录接收者身份,并声明从文件中读取内容并输出的允许状态。

观察证据

保存第3章 创建命令的初值、参数、编码或资源位置。

正式节点:命令行的输入数据、从文件中读取内容并输出、从文件中逐行读取内容、从文件中读取指定模式、方法的定义、其他文件的引用

控制与消息轨迹

在相同初值下定位首个分岔

  1. 01固定命令行的输入数据的输入和接收者
  2. 02执行从文件中读取内容并输出并记录状态
  3. 03观察从文件中逐行读取内容的返回或副作用
  4. 04用其他文件的引用核对不变量并复位

运行不变量:输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。

边界故障探针

一次只破坏一个前提

基线满足:输入路径和模式先验证,资源始终关闭,正常数据与诊断通道分离。

本章回顾:命令只是薄适配层

  1. 参数在I/O前一次解析,ARGV只留在最外层;usage、输出通道和status属于command contract。
  2. File.read适合有界小文件,File.foreach适合stream,File.open block确保异常时关闭handle。
  3. 指定模式筛选只依赖enumerable、Regexp与output,文件/stdin差异由adapter处理。
  4. 方法使用显式依赖、返回值和异常,不在深层调用exit或读取process globals。
  5. require按load path且通常一次,require_relative按source file;main guard阻止library import触发CLI。

练习与答案

练习

  1. 问题 1:建立正常轨迹。 回答“怎样让命令行参数、文件读取、模式匹配和方法返回形成一个可重放命令?”,并写出四步执行记录。
  1. 问题 2:注入单一故障。 只制造“文件读取失败后仍沿用旧内容并输出成功状态”,应从哪里开始定位?
  1. 问题 3:覆盖正式节点。 用一个证据包串联命令行的输入数据、从文件中读取内容并输出、从文件中逐行读取内容、从文件中读取指定模式、方法的定义、其他文件的引用,说明为什么结论可由另一位读者独立复核。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

命令行的输入数据

“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。

从文件中读取内容并输出

“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。

从文件中逐行读取内容

“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。

从文件中读取指定模式

“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。

方法的定义

“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。

其他文件的引用

“第3章 创建命令”中的正式节点;必须说明它接收什么、改变什么,以及用什么结果复核。

资料与写作方式声明

本章以Ruby 基础教程第 5 版作者支持资料合法公开试读核定可见范围,并以目录限定未公开部分,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…