0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CrystalAdvent Calendar 2023

Day 22

挨拶を表示するツールを作りながら Crystalにおけるコマンドラインツールの作成 を考える

Last updated at Posted at 2023-12-22

はじめに

アドベントカレンダー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 "./" を使います。
ここでは parseoptions を返しています。

バージョン番号

src/hello/version.cr
module Hello
  # shard.yml からバージョンを拾ってくれる
  VERSION = {{ `shards version #{__DIR__}`.chomp.stringify }}
end

バージョン番号のハードコーディングを避けて、shard.yml を読み取るようにします。コードと shard.yml の2箇所にバージョン番号を記載すると必ずミスります。

オプション構造体

Crystalは静的型付け言語なので、コマンドラインオプションを格納するための専用の構造体を用意します。(RubyならばHashで済ませることもあります)

src/hello/options.cr
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を継承して作っています。

src/hello/parser.cr
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_versionshow_help など打ち上げてすぐexitする処理をメソッドとしてParserに追加しています。(しかし、別に継承しなくても delegate 的に書けばだいたい同じかもしれません)また parseOption を戻り値として返すようにしています。

OptionParserの落とし穴

CrystalのOptionParserは、ショートオプションを合成することができないので注意が必要です。(まだまだRubyには追いついていない印象ですね)

hello -an Tanaka # hello -a -n Tanaka のように解釈してくれない

処理の本体のクラス

src/hello/greeter.cr
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) という「ターミナル上の文字付きの文字列」のクラスが標準ライブラリにあります。これを使ってみましょう。

col.cr
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 が発火するようにしておきます。

release.yml
name: release

on:
  push:
    tags:
      - "v*"

このあたりはテンプレートですが、

release.yml
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 を用いたスタティックなバイナリを作成します。

release.yml
      - 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 の設定が必要です。

release.yml
      - 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でミニコマンドラインツールを書く機会は多くあると思うので、あとから自分が振り返ることができるようにまとめてみました。

この記事は以上です。

  1. Anyoliteでmrubyを使ったり、Crystalのinterpreterをライブラリとして使うなど抜け道はある

  2. Colorize::Object(String) の実装の方を変えるという考え方もありえる

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?