rails コマンドへ独自コマンドを組み込む方法

Rake タスクって何だか変ですよね。テストも書きづらいし、できることなら書きたくないですよね。

Ruby には [Thor](https://github.com/erikhuda/thor) というイケてる Gem があり、これを利用するとイケてるコマンドライン・ユーティリティを書くことができます。Thor はイケてるので、サブコマンドも書くことができます。Thor を利用する場合、コマンドライン・ユーティリティは、Thor クラスを継承したクラスとして作成するので、テストも簡単で、特別な知識は不要です。

Rake タスクではなくて、Thor を利用できたら素敵ですよね。

実は rails コマンドは Thor をすでに利用しているんです。そして、Rails エンジンの場合、Thor を使って独自コマンドを提供する標準的な方法が用意されているようですが、Rails アプリケーションの場合、標準的な方法はありません。自分でなんとかするしかありません。

以降では自分でなんとかする方法を説明します。generator のアシストは受けられないので、全て手作業でファイルを修正したり、ファイルを作成したりする必要があります。

## 前提

本記事で作成したサンプルは <https://github.com/sunny4381/rails_command_extension> に置いておきます。
このサンプルは [Rails チュートリアル](https://railstutorial.jp/chapters/beginning?version=6.0)の[第14章のソースコード](https://github.com/yasslab/sample_apps/tree/master/6_0_0/ch14)を元にしています。Rails チュートリアルでは `User` と `Micropost` の二つのモデルが登場しますので、本記事もこの2つのモデルを操作してみたいと思います。

## Rails::Command::Base

早速、独自コマンドを作成していきます。独自コマンドは、直接 Thor を継承せずに、Rails が Thor をラップしたクラス Rails::Command::Base を提供しているので、このクラスを継承するようにします。

早速、独自コマンドを実装しましょう。以下のような `main_command.rb` ファイルを作成し、このファイルに独自コマンドを実装していきます。

~~~ruby:lib/commands/main/main_command.rb
require "rails/command"

module SampleApp
  module Command
    class MainCommand < Rails::Command::Base
      namespace "sample"
      @command_name = "sample"

      def hello
        say "hello"
      end
    end
  end
end
~~~

`namespace` と `@command_name` を指定して、コマンド名を明示的に指定しています。この 2 つの指定がなければ `sample_app:main` なんていう冗長なコマンド名になってしまいます。

そして、`hello` というコマンドを実装しており、`bin/rails sample_app:hello` と実行することを意図しています。

コマンドの実装方法の詳細については、[Thor の Wiki](https://github.com/erikhuda/thor/wiki) を参照ください。

## bin/rails の変更

次に独自コマンドを rails コマンドに認識させる必要があります。このため `bin/rails` を修正して、独自コマンドを組み込みます。次のように修正します。

~~~ruby:bin/rails
#!/usr/bin/env ruby
APP_PATH = File.expand_path('../config/application', __dir__)
require_relative '../config/boot'

#### ↓↓↓↓↓↓↓↓追加
# 独自コマンドの組み込み
require_relative '../lib/commands/main/main_command'
#### ↑↑↑↑↑↑↑↑追加

# run rails command
require 'rails/commands'
~~~

独自コマンドを実装したファイル `lib/commands/main/main_command.rb` を  `require_relative` で読み込んでいます。独自コマンドを rails コマンドのコマンド一覧へ登録する処理が `Rails::Command::Base` にありますので、読み込むだけで rails コマンドに登録されます。

試しに `bin/rails` を実行してみます。

~~~shell
$ bin/rails
  ...
  routes
  runner
  sample_app:hello
  secret
  secrets:edit
  ...
~~~

多数のコマンドが出力されるので少しわかりづらいですが、rails の標準コマンドに混じって `sample_app:hello` と独自コマンドが表示されています。

試しに独自コマンドを実行してみます。

~~~shell
$ bin/rails sample_app:hello
hello
~~~

## モデルの操作とサブコマンド

コンソールに文字列を表示するような単純な処理ならこのままでも問題ありませんが、Rails アプリケーションが初期化されていないので、モデルを検索したり、作成したり、削除したりすることはできません。

Rails アプリケーションの初期化方法と合わせて、モデルを作成する独自コマンドを `sample_app` のサブコマンドとして追加する方法をみていきます。

まず、`main_command.rb` を修正してサブコマンドを追加します。

~~~ruby:lib/commands/main/main_command.rb
require "rails/command"
require_relative '../user/user_command'
require_relative '../micropost/micropost_command'

module SampleApp
  module Command
    class MainCommand < Rails::Command::Base
      namespace "sample_app"
      @command_name = "sample_app"

      subcommand "user", SampleApp::Command::UserCommand
      subcommand "micropost", SampleApp::Command::MicropostCommand
    end
  end
end
~~~

Rails アプリケーションが初期化されていないので、クラスのオートロードは効きません。`require_relative` を用いて明示的に `user_command.rb` と `micropost_command.rb` を読み込む必要があります。クラスを読み込んだ後、Thor の `subcommand` 命令で `user` と `micropost` という2つのサブコマンドを追加しています。

`user` サブコマンドの実体 `user_command.rb` は次のように実装します。

~~~ruby:lib/commands/user/user_command.rb
module SampleApp
  module Command
    class UserCommand < Rails::Command::Base
      desc "list", "list users."
      def list
        require_application_and_environment!

        say
        say "#{'Name'.ljust(14)}  #{'Email'.ljust(32)}  Updated At"
        say "-" * 80

        User.all.each do |user|
          say "#{user.name.ljust(14)}  #{user.email.ljust(32)}  #{user.updated_at.iso8601}"
        end
      end
    end
  end
end
~~~

`user_command.rb` では、ユーザー一覧を表示する `list` というコマンドを定義しています。このコマンドは `bin/rails sample_app:user list` と実行することを意図しています。

`list` の先頭で `require_application_and_environment!` を呼び出し Rails アプリケーションを初期化し、続いて User をデータベースから読み込み、コンソールに出力しています。
なお、Rails アプリケーションの初期化後は、オートロードが効くようになるので、User モデルを明示的に読み込む必要はありません。

`micropost` サブコマンドの実体 `micropost_command.rb` を次のように実装します。

~~~ruby:lib/commands/micropost/micropost_command.rb
module SampleApp
  module Command
    class MicropostCommand < Rails::Command::Base
      desc "list", "list microposts."
      def list
        require_application_and_environment!

        say
        say "#{'Name'.ljust(14)}  #{'Content'.ljust(14)}  Created At"
        say "-" * 80

        Micropost.all.each do |post|
          say "#{post.user.name.ljust(14)}  #{post.content.ljust(14)}  #{post.created_at.iso8601}"
        end
      end
    end
  end
end
~~~

ほぼ `user_command.rb` と同じで、こちらの方は Micropost モデルの一覧をコンソールに出力しています。

サブコマンドを追加できたら試しに実行してみます。

~~~
$ bin/rails sample_app:user list

Name            Email                             Updated At
--------------------------------------------------------------------------------
sample          sample@example.jp                 2020-12-05T05:33:11Z

~~~

Rails チュートリアルを少し進め、ユーザーを登録したら、上のように出力されます。

## rails コマンドのその他の実行方法

単に `rails` と実行した場合も `bundle exec rails` などと実行した場合も `bin/rails` ファイルが実行されますので、`bin/rails sample_app:user list` に代えて `bundle exec rails sample_app:user list` と実行することもできます。

## 要検討・改善点など

- Rails の作法にならって `Rails::Command::Base` を継承した `ApplicationCommand` というクラスを作成し、`ApplicationCommand` クラスを継承するようにした方が良いのかも？
- `help` コマンドがなからず追加されるが、`help` コマンドを実行するとエラーになる。例 `bin/rails sample_app:help` や `bin/rails sample_app:user help` など。
  - 理由は標準の `help` コマンドが Rails エンジンしかサポートしてない。Rails アプリケーションは全く考慮されていない。
  - 改善方法としては `ApplicationCommand` クラスで `help` コマンドを独自実装するのが良いのかなと考えています。