跳至内容

命令行界面应用程序

编写命令行界面应用程序(CLI 应用程序)是开发人员最有趣的任务之一。所以,让我们在 Crystal 中构建第一个 CLI 应用程序,享受乐趣吧。

构建 CLI 应用程序时有两个主要主题

输入

本主题涵盖所有与以下内容相关的知识

选项

传递选项给应用程序是一种非常常见的做法。例如,我们可以运行 crystal -v,Crystal 会显示

$ crystal -v
Crystal 1.14.0 [dacd97bcc] (2024-10-09)

LLVM: 18.1.6
Default target: x86_64-unknown-linux-gnu

如果我们运行:crystal -h,那么 Crystal 会显示所有可接受的选项以及如何使用它们。

所以现在问题是:我们是否需要实现选项解析器? 不需要,Crystal 为我们提供了 OptionParser 类。让我们使用此解析器构建一个应用程序吧!

在开始时,我们的 CLI 应用程序有两个选项

  • -v / --version:它将显示应用程序版本。
  • -h / --help:它将显示应用程序帮助信息。
help.cr
require "option_parser"

OptionParser.parse do |parser|
  parser.banner = "Welcome to The Beatles App!"

  parser.on "-v", "--version", "Show version" do
    puts "version 1.0"
    exit
  end
  parser.on "-h", "--help", "Show help" do
    puts parser
    exit
  end
end

那么,这一切是如何运作的呢?嗯……魔法!不,这并不是真正的魔法!只是 Crystal 让我们的生活更轻松而已。当我们的应用程序启动时,传递给 OptionParser#parse 的块会被执行。在这个块中,我们定义了所有选项。在块执行完毕后,解析器会开始使用传递给应用程序的参数,尝试将每个参数与我们定义的选项进行匹配。如果选项匹配,那么传递给 parser#on 的块就会被执行!

我们可以在 官方 API 文档 中了解关于 OptionParser 的所有内容。从那里,只需点击一下,我们就可以找到源代码……这是它不是魔法的真实证明!

现在,让我们运行我们的应用程序。我们有两种方法可以 使用编译器

  1. 构建应用程序,然后运行它。
  2. 编译并 运行应用程序,所有操作都在一个命令中完成。

我们将使用第二种方法

$ crystal run ./help.cr -- -h

Welcome to The Beatles App!
    -v, --version                    Show version
    -h, --help                       Show help

让我们再构建一个很棒的应用程序,它具有以下功能

默认情况下(即没有指定选项),应用程序会显示 Fab Four 的名字。但是,如果我们传递选项 -t / --twist,它会以大写字母显示名字

twist_and_shout.cr
require "option_parser"

the_beatles = [
  "John Lennon",
  "Paul McCartney",
  "George Harrison",
  "Ringo Starr",
]
shout = false

option_parser = OptionParser.parse do |parser|
  parser.banner = "Welcome to The Beatles App!"

  parser.on "-v", "--version", "Show version" do
    puts "version 1.0"
    exit
  end
  parser.on "-h", "--help", "Show help" do
    puts parser
    exit
  end
  parser.on "-t", "--twist", "Twist and SHOUT" do
    shout = true
  end
end

members = the_beatles
members = the_beatles.map &.upcase if shout

puts ""
puts "Group members:"
puts "=============="
members.each do |member|
  puts member
end

使用 -t 选项运行应用程序将输出

$ crystal run ./twist_and_shout.cr -- -t

Group members:
==============
JOHN LENNON
PAUL MCCARTNEY
GEORGE HARRISON
RINGO STARR

带参数的选项

让我们创建一个新的应用程序:当传递选项 -g / --goodbye_hello 时,应用程序会向给定的姓名问好,该姓名作为参数传递给选项

hello_goodbye.cr
require "option_parser"

the_beatles = [
  "John Lennon",
  "Paul McCartney",
  "George Harrison",
  "Ringo Starr",
]
say_hi_to = ""

option_parser = OptionParser.parse do |parser|
  parser.banner = "Welcome to The Beatles App!"

  parser.on "-v", "--version", "Show version" do
    puts "version 1.0"
    exit
  end
  parser.on "-h", "--help", "Show help" do
    puts parser
    exit
  end
  parser.on "-g NAME", "--goodbye_hello=NAME", "Say hello to whoever you want" do |name|
    say_hi_to = name
  end
end

unless say_hi_to.empty?
  puts ""
  puts "You say goodbye, and #{the_beatles.sample} says hello to #{say_hi_to}!"
end

在这种情况下,块接收一个参数,该参数代表传递给选项的参数。

让我们试试吧!

$ crystal run ./hello_goodbye.cr -- -g "Penny Lane"

You say goodbye, and Ringo Starr says hello to Penny Lane!

太棒了!这些应用程序看起来很棒!但是,如果我们传递了一个未声明的选项会发生什么? 例如 -n

$ crystal run ./hello_goodbye.cr -- -n
Unhandled exception: Invalid option: -n (OptionParser::InvalidOption)
  from ...

哦,不!它坏了:我们需要处理无效选项传递给选项的无效参数!对于这两种情况,OptionParser 类有两个方法:#invalid_option#missing_option

所以,让我们添加这个选项处理程序,并将所有这些 CLI 应用程序合并成一个很棒的 CLI 应用程序!

我的所有 CLI:完整的应用程序

以下是最终结果,包括无效/缺少选项处理,以及其他新选项

all_my_cli.cr
require "option_parser"

the_beatles = [
  "John Lennon",
  "Paul McCartney",
  "George Harrison",
  "Ringo Starr",
]
shout = false
say_hi_to = ""
strawberry = false

option_parser = OptionParser.parse do |parser|
  parser.banner = "Welcome to The Beatles App!"

  parser.on "-v", "--version", "Show version" do
    puts "version 1.0"
    exit
  end
  parser.on "-h", "--help", "Show help" do
    puts parser
    exit
  end
  parser.on "-t", "--twist", "Twist and SHOUT" do
    shout = true
  end
  parser.on "-g NAME", "--goodbye_hello=NAME", "Say hello to whoever you want" do |name|
    say_hi_to = name
  end
  parser.on "-r", "--random_goodbye_hello", "Say hello to one random member" do
    say_hi_to = the_beatles.sample
  end
  parser.on "-s", "--strawberry", "Strawberry fields forever mode ON" do
    strawberry = true
  end
  parser.missing_option do |option_flag|
    STDERR.puts "ERROR: #{option_flag} is missing something."
    STDERR.puts ""
    STDERR.puts parser
    exit(1)
  end
  parser.invalid_option do |option_flag|
    STDERR.puts "ERROR: #{option_flag} is not a valid option."
    STDERR.puts parser
    exit(1)
  end
end

members = the_beatles
members = the_beatles.map &.upcase if shout

puts "Strawberry fields forever mode ON" if strawberry

puts ""
puts "Group members:"
puts "=============="
members.each do |member|
  puts "#{strawberry ? "🍓" : "-"} #{member}"
end

unless say_hi_to.empty?
  puts ""
  puts "You say goodbye, and I say hello to #{say_hi_to}!"
end

请求用户输入

有时,我们可能需要用户输入一个值。我们如何读取该值?太简单了!让我们创建一个新的应用程序:Fab Four 将与我们一起唱出我们想要的任何短语。运行应用程序时,它会向用户请求一个短语,然后魔法就会发生!

let_it_cli.cr
puts "Welcome to The Beatles Sing-Along version 1.0!"
puts "Enter a phrase you want The Beatles to sing"
print "> "
user_input = gets
puts "The Beatles are singing: 🎵#{user_input}🎶🎸🥁"

方法 gets暂停应用程序的执行,直到用户完成输入(按下Enter键)。当用户按下Enter键时,执行将继续,user_input 将包含用户的值。

但是,如果用户没有输入任何值会发生什么?在这种情况下,我们会得到一个空字符串(如果用户只按了Enter键),或者可能得到一个Nil值(如果输入流已关闭,例如按下Ctrl+D)。为了说明这个问题,让我们尝试以下操作:我们希望用户输入的内容以响亮的方式演唱

let_it_cli.cr
puts "Welcome to The Beatles Sing-Along version 1.0!"
puts "Enter a phrase you want The Beatles to sing"
print "> "
user_input = gets
puts "The Beatles are singing: 🎵#{user_input.upcase}🎶🎸🥁"

运行示例时,Crystal 会回复

$ crystal run ./let_it_cli.cr
Showing last frame. Use --error-trace for full trace.

In let_it_cli.cr:5:46

 5 | puts "The Beatles are singing: 🎵#{user_input.upper_case}
                                                  ^---------
Error: undefined method 'upper_case' for Nil (compile-time type is (String | Nil))

啊!我们应该早点知道:用户输入的类型是 联合类型 String | Nil。所以,我们必须测试Nilempty,并针对每种情况自然地进行处理

let_it_cli.cr
puts "Welcome to The Beatles Sing-Along version 1.0!"
puts "Enter a phrase you want The Beatles to sing"
print "> "
user_input = gets

exit if user_input.nil? # Ctrl+D

default_lyrics = "Na, na, na, na-na-na na" \
                 " / " \
                 "Na-na-na na, hey Jude"

lyrics = user_input.presence || default_lyrics

puts "The Beatles are singing: 🎵#{lyrics.upcase}🎶🎸🥁"

输出

现在,我们将重点关注第二个主要主题:应用程序的输出。首先,我们的应用程序已经显示了信息,但是(我认为)我们可以做得更好。让我们为输出添加更多活力(即颜色!)。

为了实现这一点,我们将使用 Colorize 模块。

让我们构建一个非常简单的应用程序,显示带有颜色的字符串!我们将使用黄色字体,黑色背景

yellow_cli.cr
require "colorize"

puts "#{"The Beatles".colorize(:yellow).on(:black)} App"

太棒了!这太容易了!现在想象一下,将这个字符串用作我们 All My CLI 应用程序的横幅,如果你尝试一下,就很容易

parser.banner = "#{"The Beatles".colorize(:yellow).on(:black)} App"

对于第二个应用程序,我们将添加一个文本装饰(在本例中为blink

let_it_cli.cr
require "colorize"

puts "Welcome to The Beatles Sing-Along version 1.0!"
puts "Enter a phrase you want The Beatles to sing"
print "> "
user_input = gets

exit if user_input.nil? # Ctrl+D

default_lyrics = "Na, na, na, na-na-na na" \
                 " / " \
                 "Na-na-na na, hey Jude"

lyrics = user_input.presence || default_lyrics

puts "The Beatles are singing: #{"🎵#{lyrics}🎶🎸🥁".colorize.mode(:blink)}"

让我们尝试一下更新后的应用程序……并聆听其中的差异!!现在我们拥有了两个很棒的应用程序!!

您可以在 API 文档 中找到可用颜色文本装饰的列表。

测试

与任何其他应用程序一样,在某个时刻,我们希望 为不同的功能编写测试

目前,包含每个应用程序逻辑的代码总是与OptionParser一起执行,也就是说,没有办法在不运行整个应用程序的情况下包含该文件。因此,首先我们需要重构代码,将解析选项所需的代码与逻辑代码分离。重构完成后,我们可以开始测试逻辑,并在我们需要的测试文件中包含包含逻辑的文件。我们把这留给读者作为练习。

使用ReadlineNCurses

如果我们想要构建更丰富的 CLI 应用程序,有一些库可以帮助我们。这里我们将介绍两个众所周知的库:ReadlineNCurses

GNU Readline 库 文档中所述,Readline是一个库,它提供了一组函数,供应用程序使用,允许用户在输入时编辑命令行。Readline具有一些很棒的功能:开箱即用的文件名自动完成;自定义自动完成方法;键绑定,仅举几例。如果我们想尝试它,那么 crystal-lang/crystal-readline shard 将为我们提供一个简单的 API 来使用 Readline

另一方面,我们有NCurses(新 Curses)。这个库允许开发人员在终端中创建图形用户界面。顾名思义,它是名为Curses库的改进版本,该库最初是为了支持一个名为 Rogue 的基于文本的地下城探险游戏而开发的!正如你所想象的,生态系统中已经有一些 shard 允许我们在 Crystal 中使用 NCurses

就这样,我们到达了结尾 😎🎶