はじめに
アドベントカレンダー2023の記事です。私はCrsytal言語を主に自分用の小さなコマンドラインツールを作るのに使っています。今日は、コマンドラインツールの作成方法についてまとめます。
挨拶するだけのコマンドラインツール
hello --morning
Good morning, Qiitan!
hello -h
Usage: hello [periodion]
-m, --morning Morning
-a, --afternoon Afternoon
-e, --evening Evening
-n, --name NAME Name
-v, --version Show version
-h, --help Show this help
ソースコード: https://github.com/kojix2/hello-qiitan
プロジェクトの作成
まずはいつもようにプロジェクトを用意します。
crystal init app hello
ライブラリを使わないので shard.yml
は編集しません。
プロジェクトの構成
tree src
src
├── hello
│ ├── greeter.cr # 処理の本体のクラス
│ ├── options.cr # コマンドラインオプションを格納する構造体
│ ├── parser.cr # OptionParserを継承したパーサークラス
│ └── version.cr # バージョン番号
└── hello.cr # main的な存在
とりあえずこんな感じの構成にしました。
サブコマンドを持たない単機能のツールの場合はこんな構成になると思います。ヘルプや、バージョンを表示するメソッドは、parser
の中に入れています。
ツールの規模が大きい場合は、サブコマンドを用意して、各処理のクラスを作ります。(今回は扱いません)
メインルーチン
エントリーポイントの src/hello.cr
では手続き的に処理を書きます。
小さなツールはこれで十分です。
require "./hello/parser"
require "./hello/greeter"
# コマンドラインパーサーを作成し、コマンドラインオプションをパースする
options = Hello::Parser.new.parse
# 処理をするクラスのインスタンスを生成する
greeter = Hello::Greeter.new
# オプションに基づき処理をさせる
greeter.greet(options.name, options.period)
オブジェクト指向にしたい場合は下記のようにすればよいでしょう。
module Hello
def self.run
options = Hello::Parser.new.parse
greeter = Hello::Greeter.new
greeter.greet(options.name, options.period)
end
end
Hello.run
Crystalでは require_relative
の代わりに、require "./"
を使います。
ここでは parse
が options
を返しています。
バージョン番号
module Hello
# shard.yml からバージョンを拾ってくれる
VERSION = {{ `shards version #{__DIR__}`.chomp.stringify }}
end
バージョン番号のハードコーディングを避けて、shard.yml
を読み取るようにします。コードと shard.yml
の2箇所にバージョン番号を記載すると必ずミスります。
オプション構造体
Crystalは静的型付け言語なので、コマンドラインオプションを格納するための専用の構造体を用意します。(RubyならばHashで済ませることもあります)
module Hello
struct Options
property name : String = "Qiitan"
property period : String?
end
end
デフォルト値
構造体は initialize
メソッドを書かなくてもデフォルト値を設定できます。
property name : String = "Qiitan"
Nil許容型
Crystalでは型名の最後に ?
をつけることで、Nil許容型をつかうことができます。
property period : String?
ただし、避けられるなら避けた方がいいと思います。(私は行儀が悪いので ""
をNilの代わりに使ったりしてしまいますが、これはこれでよくないかも)
場合分けに注意
コマンドラインオプションは、場合分けを考えるのが難しいです。
今回は、Booleanなオプションが少ないので簡単ですが、オプションが増えると場合の数は簡単に発散します。私は8ケースを超えたあたりから頭の中で把握するのが難しくなります。
特に厄介なのは、それぞれのオプションに依存関係があるようなケースです。
すべてのパターンが把握できていないと感じるときは、面倒臭がらずに紙に想定されるパターンを列挙します。すると大抵は、例外がたくさん見つかります。自分が想像しているよりもはるかに多くのケースと格闘していることに気が付きます。だからといって問題が解決するとは限らないのですが、一歩前進できます。
Parserクラス
ここでは、ParserはOptionParser
を継承して作っています。
require "option_parser"
require "./options"
require "./version"
module Hello
class Parser < OptionParser
getter options : Options
def initialize
super
@options = Options.new
self.banner = "Usage: hello [periodion]"
on("-m", "--morning", "Morning") { options.period = "morning" }
on("-a", "--afternoon", "Afternoon") { options.period = "afternoon" }
on("-e", "--evening", "Evening") { options.period = "evening" }
on("-n", "--name NAME", "Name") { |v| options.name = v }
on("-v", "--version", "Show version") { show_version }
on("-h", "--help", "Show this help") { show_help }
invalid_option { STDERR.puts(self); exit(1) }
end
def parse(args = ARGV) : Options
super
options
end
def show_version
puts VERSION; exit
end
def show_help
puts self; exit
end
end
end
OptionParser
を継承することで、show_version
や show_help
など打ち上げてすぐexit
する処理をメソッドとしてParserに追加しています。(しかし、別に継承しなくても delegate 的に書けばだいたい同じかもしれません)また parse
は Option
を戻り値として返すようにしています。
OptionParserの落とし穴
CrystalのOptionParserは、ショートオプションを合成することができないので注意が必要です。(まだまだRubyには追いついていない印象ですね)
hello -an Tanaka # hello -a -n Tanaka のように解釈してくれない
処理の本体のクラス
module Hello
class Greeter
def greet(name : String, period : String?)
case period
when "morning"
puts "Good morning, #{name}!"
when "afternoon"
puts "Good afternoon, #{name}!"
when "evening"
puts "Good evening, #{name}!"
else
puts "Hi #{name}! How are you?"
end
end
end
end
ここは特に難しいことはしていません。
けれども、こんなコードでも書いているときにはいろいろ感じます。
まず、case
文の when
に文字列を使っていまね。Rubyistであればシンボルを使うと思います。
Crystalでもシンボルを使うことは可能なのですが、個人的には「Crystalでは特に理由がない限り引数にシンボルを取るのは避けた方が無難だなー」というイメージがあります。
Rubyでは、文字列もシンボルも両方とも引数に取れるメソッドがお行儀が良いとされます。でも、Crystalの場合は文字列だけにした方がすっきりします。そのためシンボルの利用頻度がRubyよりも格段に低くなります。おまけにシンボルはコンパイル中に数値にすべて置き換えられてしまうそうですから、文字列と相互運用しやすい存在でもないんですよね。
同じような話で send
系のメソッドは使えません。上のようなクラスはRubyであれば public_send
を使うと見やすいなと思ったりしますが、
module Hello
class Greeter
def greet(name, period)
@name = name
public_send(period)
end
private
def morning
end
def afternoon
end
def evening
end
end
end
この書き方はCrystalでは難しいようです。それは、実行時に与えられた文字列を評価して動的にメソッドを呼ぶということができないからです。eval
系はCrystalではすべて使えません。1
型に関するモヤモヤふたたび
それから、greet
メソッドでは引数に型が指定されています。
def greet(name : String, period : String?)
このツールではGreeterを単なるツールとして使っていますからこれでOKでしょう。
しかし、コードは再利用が必要になることがしばしばあります。もしもライブラリとしてGreeterが提供されている場合、この型の指定はどうでしょうか?
Stringが指定されているので、String以外の型は受け付けなくなってしまいます。たとえば、Crystalには Colorize::Object(String)
という「ターミナル上の文字付きの文字列」のクラスが標準ライブラリにあります。これを使ってみましょう。
require "./src/hello/greeter"
require "colorize"
name = "赤井".colorize(:red) # Colorize::Object(String)
Hello::Greeter.new.greet(name, "morning")
すると crystal run col.cr
してもコンパイルが通らないではありませんか。
In col.cr:6:26
6 | Hello::Greeter.new.greet(name, "morning")
^---
Error: expected argument #1 to 'Hello::Greeter#greet' to be String, not Colorize::Object(String)
一方で、Greeterクラスの greet
メソッドから型の指定を外すと、コンパイルが通るようになります。
def greet(name, period)
Good morning 赤井
繰り返しますが greeter.cr
はライブラリではないですし、Colorize::Object(String)
は String
クラスではないですから、今はこの挙動が望ましいと言えます。
しかし、もしも greeter.cr
がライブラリであったならば話が変わってきます。私はきっとこの挙動にモヤモヤすると思います。なぜ greeter.cr
は受け付ける型を意味もなく絞っているんだろう?と感じることでしょう。2
こういったことがありますので、ライブラリにはなるべく型を書かないでほしいなぁと個人的には思っています。
しかし、その場合には、メソッドに未知の型が投げられる可能性を許容することになるので、ライブラリの作者にある種のノブレス・オブリージュ的な負担を強いる考え方にはなると思います。Rubyはこの考え方をかなり大切にしていると思います。代償としてRubyの標準ライブラリを書くのは非常に難しくなっており、素人が気軽に編集してPRを送れる存在ではなくなっているような気はします。
この小さなツールを作る上では、あまり気にならないポイントだったかもしれません。けれども、私は型付けに慣れていないので、型を見るたびに、どうしてもモヤモヤしてしまうんですよね。
型を指定したコード(自由度を下げた状態のコード)は、型の指定がないコード(自由度が高いコード)の完全上位互換であり、したがってCrystalはRubyより優れているとするナイーブなメッセージは、Crystal界隈でしばしば見かけます。でも、どうしても私は「それは違うんじゃないかな」と思ってしまうんですよね。
むしろ型の自由度こそが、CrystalがRustなど多くの静的型付言語に対して優位性を保っている箇所ではないかと思います。
バイナリのビルドと配布
Crystalのツールの素晴らしいところはLinux向けのバイナリの配布が容易なところです。
GitHub Actions を整備して、タグがついたときに release.yml
が発火するようにしておきます。
name: release
on:
push:
tags:
- "v*"
このあたりはテンプレートですが、
jobs:
build_release:
name: Build Release ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu]
runs-on: ${{ matrix.os }}-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: true
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest
ここで、Crystalの公式の推奨の方法、docker
を使って musl を用いたスタティックなバイナリを作成します。
- name: Run Linux Build
if: matrix.os == 'ubuntu'
run: |
mkdir -p bin
chmod 755 bin
docker run -d --name alpine -v $(pwd):/workspace -w /workspace crystallang/crystal:latest-alpine tail -f /dev/null
docker exec alpine shards install --without-development --release --static
docker exec alpine shards build --release --static
docker exec alpine chmod +x bin/hello
zip -j bin/hello-linux.zip bin/hello
ここでは、softprops/action-gh-release を用いて Release にアーチファクトをアップロードします。GitHub token permissions の設定が必要です。
- name: Upload Release Asset
uses: softprops/action-gh-release@v1
with:
files: |
bin/hello-linux.zip
LICENSE.txt
一方でMac向け、Windows向けのバイナリの配布は難儀します。Mac向けのスタティックなバイナリを作る方法はあまり確立されていないようです。Windows向けは、Releaseされたバイナリをダウンロードして動かそうとすると、ウイルス対策ソフトに自動的に除去されてしまったりします(苦笑)。もう少し利用者が増えてノウハウが蓄積されるまで時間が必要と思われます。
おわりに
今年はCrystal言語にお世話になり、現実的なスピードで小さなツールをいろいろ書けるようになりました。
来年以降もCrystalでミニコマンドラインツールを書く機会は多くあると思うので、あとから自分が振り返ることができるようにまとめてみました。
この記事は以上です。