MySQLは設定によってはVARCHARの比較時にアルファベットの大文字小文字を区別しない

最近MySQLのVARCHARがデフォルトの設定で大文字と小文字を区別してくれていない事を知らずにバグを仕込んでしまったので軽く押さえてみた

検証

Dockerコンテナ

$ docker run -it --name mysql -e MYSQL_ROOT_PASSWORD=secret -d mysql:8.0
$ docker exec -it mysql bash

MySQLにログインしてテーブル作成

$ mysql -uroot -p
$ CREATE DATABASE sample_db;
$ CREATE TABLE sample_db.user (id int, name varchar(10));

nameを大文字でレコードを登録

$ INSERT INTO sample_db.user VALUES(1,'JHON');

WHEREでnameを指定して絞り込み

大文字

$ SELECT id,name FROM sample_db.user WHERE name = 'JHON';
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set (0.00 sec)

小文字

$ SELECT id,name FROM sample_db.user WHERE name = 'jhon';
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set (0.00 sec)

whereでの指定が小文字のjhonにも関わらずnameが大文字のJHONであるレコードがselectされる

なぜこんなことになるかドキュメントをひいてみる

10.1 一般の文字セットおよび照合順序

文字セットとは、記号とエンコーディングのセットです。 照合順序とは、文字セット内の文字を比較するためのルールを集めたものです。 架空の文字セットを例にして、文字セットと照合順序の違いを見てみましょう。

B.3.4.1 文字列検索での大文字/小文字の区別

非バイナリ文字列の場合 (CHARVARCHARTEXT)、文字列検索では比較オペランドの照合順序が使用されます。 バイナリ文字列 (BINARYVARBINARYBLOB) の場合、比較ではオペランド内のバイトの数値が使用されます。つまり、アルファベット文字の場合、比較では大文字と小文字が区別されます。

B.3.4.1 文字列検索での大文字/小文字の区別

デフォルトの文字セットおよび照合順序は utf8mb4 および utf8mb4_0900_ai_ci であるため、非バイナリ文字列比較ではデフォルトで大文字と小文字が区別されません。 これは、_`col_name`_ LIKE 'a%' を使用して検索した場合、A または a で始まるすべてのカラム値が取得されることを意味します。

かいつまんで言うとMySQLでは文字を扱うために文字セットと文字を比較するための照合順序(collate)の設定があってクエリを実行した際の結果はこの設定に左右されるらしいが、デフォルトでは大文字と小文字が区別されない設定になっているようだ。

ただし非バイナリ文字列比較とあるのでBINARY演算子によるCASTを試してみる。

CAST付きの絞り込み

小文字

SELECT id,name FROM sample_db.user WHERE BINARY name = 'jhon';
Empty set, 1 warning (0.00 sec)

大文字

SELECT id,name FROM sample_db.user WHERE BINARY name = 'JHON';
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set, 1 warning (0.00 sec)

今度は小文字ではヒットしなくなった

またクエリの実行時にcollateを指定する事も出来る

collateを使用した比較

まずは文字セットを指定したいcollateに対応したものに設定しなおす

SET character_set_connection=utf8mb4;

小文字

mysql> SELECT id,name FROM sample_db.user WHERE name = 'jhon' COLLATE utf8mb4_0900_bin;
Empty set (0.01 sec)

大文字

mysql> SELECT id,name FROM sample_db.user WHERE name = 'JHON' COLLATE utf8mb4_0900_bin;
+------+------+
| id   | name |
+------+------+
|    1 | JHON |
+------+------+
1 row in set (0.00 sec)

テーブルが使用するcollateの設定ごと変えてやればクエリごとに指定をしなくても良くなる

テーブルの設定

現在の設定の確認

SELECT TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='sample_db' AND TABLE_NAME='user';
+--------------------+
| TABLE_COLLATION    |
+--------------------+
| utf8mb4_0900_ai_ci |
+--------------------+
1 row in set (0.00 sec)

collateの変更

ALTER TABLE sample_db.user COLLATE utf8mb4_0900_bin;

create table時にcollateを指定しても良い

CREATE TABLE sample_db.user (id int, name varchar(10)) COLLATE utf8mb4_0900_bin;

ただテーブルごとの設定もそれぞれに付与するのは手間だしそのデータベースを扱うサービス全体で大文字と小文字を区別して欲しいことも多いだろうから、その場合データベース単位で設定しまう方がラクだろう。

データベースの設定

ALTER DATABASE sample_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
CREATE DATABASE sample_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;

最後に

今まで既存のアプリケーションの改修が多かったことも原因かもしれないがが、MySQLを触りだして数年たつのにこの事を知らずに結構衝撃だった。 幸いリリース前にチーム内の人が指摘してくれてことなきを得たが、調べても最初BINARYのキャストの話が多く出てきてピンと来ずcollateという馴染みの薄い単語覚えておかないと調べるのに少し不便だったので記録を残しておく

おまけ

文字セットの確認

SHOW VARIABLES LIKE "chara%";
+--------------------------+--------------------------------+
| Variable_name            | Value                          |
+--------------------------+--------------------------------+
| character_set_client     | latin1                         |
| character_set_connection | latin1                         |
| character_set_database   | utf8mb4                        |
| character_set_filesystem | binary                         |
| character_set_results    | latin1                         |
| character_set_server     | utf8mb4                        |
| character_set_system     | utf8mb3                        |
| character_sets_dir       | /usr/share/mysql-8.0/charsets/ |
+--------------------------+--------------------------------+
8 rows in set (0.01 sec)

GoのStructをJSONに変換する場合fieldは大文字でなければならない

GoでJSONを扱いたかったのだがStructから変換する際に変換の対象にならないFieldがあった。

type User struct {
    No int
    Name string
    tel string
}

func main() {
    user := User{No: 0, Name: "Bob", tel: "000-1111-2222"}
    json, _ := json.Marshal(user)
    fmt.Println(string(json)) // {"No":0,"Name":"Bob"}
}

要はStructのFIeldが小文字で始まる場合はJSONへのコンバートの対象にならないようだ。

どうやらGoは定数なども頭文字を大文字にしないと外部のパッケージからアクセス出来ず、その関係なのかJSONに変換させる際にも大文字で始める必要があるらしい。

pkg.go.dev

users []string // not exported

JSONに変換したい時にはStructのKeyを全部大文字にしないといけないのは無茶だろうと思ったが、StructにはTagオプションがありJSONに変換する際のKeyの指定や値が空の場合に変換の候補から外すオプション(omitempty)などが指定出来るようなのでこれを使うという事のようだ。

Name string json:"name,omitempty"

Tagをつければ小文字始まりのKeyでもJSONとして出力できる。

type User struct {
    No   int
    Name string
    Tel  string `json:"tel"`
}

func main() {
    user := User{No: 0, Name: "Bob", Tel: "000-1111-2222"}
    json, _ := json.Marshal(user)
    fmt.Println(string(json)) // {"No":0,"Name":"Bob","tel":"000-1111-2222"}
}

この状態でStructに値を指定しなければJSONに変換した際に空文字で出力されるが

user := User{No: 0, Name: "Bob"}
Tel string `json:"tel"` // {"No":0,"Name":"Bob","tel":""}

TagにomitemptyのオプションをつければJSONのKey自体が省略される

Tel string `json:"tel,omitempty"` // {"No":0,"Name":"Bob"}

Goをそんなに書き慣れていないのでJSONにした際に一部だけ落ちるという状況に最初?となったが、頭文字が大文字か小文字かでエクスポートの対象にするか分けるという事が分かればルールとしてなるほどと思うで言語の仕様としてちょっと面白いと感じた。

言語を調べる際にはなるべく公式にあたりたいと思うのだがGoのpkgは日本語のドキュメントがあるがStructに関しては適当なものが見つからず途中ちょっと困った。

ActiveRecordのlockメソッド(Postgresql)

概要

これまであまり使っていなかったActiveRecordのlock機能に触れる機会があったので少し試したことを記録しておく

Version

詳細

lock(with_lock)メソッド

AcitveRecordのロック機能のについてはRailsガイドに解説があるが、今回調べたlockメソッドはデータベースの機能を利用した悲観的ロックを扱う。transactionで囲まれたブロック内でlockを経由してからクエリメソッドを実行すると対象のレコードにロックが実行され、このトランザクションが完了するまではその他のトランザクションから更新を防ぐ事が出来る。

User.transaction do
  user = User.lock.first
  user.update(name: "change")
end

もしくは

user = User.lock.first
user.with_lock do
  user.update(name: "change")
end

動作の検証

デバッガのブレイクポイントを貼ってトランザクションの途中で停止させた状態で

User.transaction do
  user = User.lock.first
  binding.pry
  user.update(name: "change")
end

別のRails consoleなどからレコードの更新を行うとupdateの実行が待たされる。

# 別のRails console
user = User.first
user.update(name: "other name")
=> true # デバッガを進めてlockしたtransactinoを抜けると処理が実行され結果が返ってくる

注意しないといけない点としてトランザクションを貼った中でなければロックが実行されないのだが、その状態でlockメソッドを使用してもなんの警告もエラーもでない事だ。User.transactionコメントアウトした以下のコードはこの箇所のupdateだけであれば問題なく実行されるが、先ほど同様にlock直後をデバッガで停止させた状態で別のコンソールなどからupdateを実行しても即座に更新出来てしまう。

#User.transaction do
  user = User.lock.first
  binding.pry
  user.update(name: "change")
#end

updateの実行だけなら一見正常に実行出来たように見えてしまうのでバグを防ぐためにはきちんとロックを想定したテストを書くなどの対策が必要だろう。

行ロック・テーブルロック

Postgresqlには行ロック・テーブルロックがある。 SQL文の中身を見るとわかるがActiveRecordのlockメソッドで実行されているのは行ロックだ。

User.lock.to_sql
=> "SELECT \"users\".* FROM \"users\" FOR UPDATE"

また

異なる種類のロックを使いたい場合は、lockメソッドに生SQLを渡すことも可能です。たとえば、MySQLにはLOCK IN SHARE MODEという式があります(レコードのロック中にも他のクエリからの読み出しを許可します)。この式を指定するには、以下のように単にlockオプションの引数で渡します。 Railsガイド/Active Record クエリインターフェイス

という事なので引数にモードの指定を渡せばRailsガイドの例にあるMySQLと同様にPostgresでも行ロックのモードを指定する事が出来る。

User.transaction do
    # User.lock('FOR SHARE').to_sql
    # => "SELECT \"users\".* FROM \"users\" FOR SHARE"
    user = User.lock('FOR SHARE').first
    user.update(name: "change")
end

一方のテーブルロックだがPostgresqlではLOCKコマンドを実行する必要があるので引数でなにも指定しない場合でもSELECTメソッドが実行されるActiveRecordのlockメソッドでは使用できない。もしテーブルロックが必要であるならModel経由ではなく別に生のSQLを実行する必要がある。

User.transaction do
    ActiveRecord::Base.connection.execute('LOCK users IN ACCESS EXCLUSIVE MODE')
    user = User.first
    binding.pry # ここで止めると別コンソールで User.first の時点で結果が返ってこない
    user.update(name: "change")
end

lockメソッドを使用していないがtransactionを抜ければ待たされていた実行結果は返ってくるようだ。

参照

Railsガイド/Active Record クエリインターフェイス PostgreSQL 13.1文書

DockerのChromeでCupriteの動作を検証してみる

はじめに

Seleniumの調査だったり開発環境がApple Siliconだったりで考慮しないといけない事が多いのでSystemTestの設定を後回しにしていたRailsアプリがあったのだが、CupriteというSeleniumではなくCDP(Chrome DevTools Protocol)を使用したGemの存在を知ったのがきっかけでやっぱりきちんとやろうという事になり、まずは勉強がてら一旦Railsから離れてCupriteとDockerのChormeで環境を作って試してみた。

詳細

リポジトリ

Gemなど

Ferrum

RubyでCDPを使うためのGem。Ferrumとはラテン語で鉄の意味らしい。
名前がラテン語である理由やGItHubのトップにあるイラストがMUCのエンドゲームでサノスが使っていたインフェニティティガントレットっぽく見えるのが気になる。

Cuprite

CapybaraでFerrumを使うためのGem。

CDP(Chrome DevTools Protocol)

Cupriteを知るまでその存在を知らなかったのだがプログラムから呼び出せるChromeに実装されたAPI。E2ETestなどではSeleniumを代替出来るのでCuprite & Ferrumはそのアプローチを取っている。

やったこと

疎通のテストなので単純なリクエストを返してくれるRackアプリをテスト用に作成。 最終的にCapybaraを通してJavaScriptの実行を試したいので検証用のボタンも追加しておく。

config.ru

class App
  def call(env)
    html = "<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>"
    [200,{ "Content-Type" => "text/html" },[html]]
  end
end
run App.new

DockerはWebアプリを起動するためのRubyChromeを別コンテナで立ち上げている。

version: "3.9"
services:
  web:
    build:
      context: .
    command: ["bundle", "exec", "rackup", "-p", "80", "-o", "0.0.0.0"]
    ports:
      - "80:80"
    volumes:
      - ./app:/app
    depends_on:
      - chrome
  chrome:
    image: browserless/chrome
    ports:
      - "3333:3333"
    volumes:
      - .:/app:cached
    environment:
      PORT: 3333
      CONNECTION_TIMEOUT: 600000

Rubyのコンテナでは必要なGemをbuild時にインストールしておりRuby3からWEBRickが標準ライブラリでなくなったのでPumaを追加している。

# frozen_string_literal: true

source "https://rubygems.org"

gem "rack"
gem "puma"
gem "cuprite"
gem "test-unit"
gem "debug"
FROM ruby:3.1.1
RUN mkdir /app
WORKDIR /app
ADD ./app /app
RUN gem install bundler
RUN bundle install
$ docker compose build
$ docker compose up -d

Dockerのビルドとコンテナの起動が済んだら動作を一つずつ確認していく。

Webアプリコンテナ <-> Chromeコンテナ

Rackアプリの起動の確認とWebコンテナからChormeのコンテナにアクセス出来るかを確認しておく

$ docker compose exec web curl 'http://localhost:80'
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>%
$ docker compose exec web curl 'http://chrome:3333'
<!doctype html><html><head><meta charset="utf-8"/><title>browserless debugger</title>...

Chrome経由でWebアプリに接続するのでChromeのコンテナからホストネームでアクセス出来るかも確認しておく

$ docker compose exec chrome curl 'http://web:80'
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>%

Ferrum -> Chromeコンテナ

WebコンテナからFerrum経由でChromeにアクセス出来るか確認

options = {
    url: 'http://chrome:3333',
    browser_options: { 'no-sandbox': nil }
}
browser = Ferrum::Browser.new(options)
browser.go_to("https://google.com")
puts browser.current_title
browser.quit
$ docker compose exec web ruby ferrum.rb
Google

Capybra -> Chromeコンテナ -> Webアプリ

Capybara経由でChromeを通してRackアプリに接続出来るか確認

require "capybara/cuprite"
require 'capybara/dsl'

options = {
    url: 'http://chrome:3333',
    browser_options: { 'no-sandbox': nil },
    base_url: "http://web"
}

Capybara.register_driver(:cuprite) do |app|
    Capybara::Cuprite::Driver.new(app,options)
end

Capybara.javascript_driver = :cuprite
Capybara.server_host = "0.0.0.0"
Capybara.app_host = "http://web:80"

session = Capybara::Session.new(:cuprite)
driver = session.driver
browser = driver.browser
page = browser.page
browser.visit "/"
puts page.body

今回ChromeもDockerにした上に似た名前の項目が多いので設定が紛らわしいが Capybara::Cuprite::Driver.newのurlにはChromeの宛先をCapybara.app_hostにはWebアプリケーションの宛先を設定する。
その他はCapybaraからvisit "/"のようにホスト名を省略してページを選択したければ Capybara::Cuprite::Driver.newbasu_url を渡す。
browser_options: { 'no-sandbox': nil }Docker経由の場合必要とのこと。 Capybara.server_hostもDocker経由の場合 "0.0.0.0" を指定して外部からのアクセスを許可してやる必要がある。
この内容を保存して実行してやるとCapybara経由でRackアプリに接続出来る事が確認できる。

$ docker compose exec web bundle exec ruby capybara.rb
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id="text">Hello World!</h1><button onclick="changeText()">Click</button></body></html>

TestCase

最後にテストケースを作成してSystemTestでの実行を試す。

# 必要な設定は上のCapybaraのテストとほぼ同じだが、そのままだとRackサーバーを立ち上げようとするので以下の設定を追記
Capybara.default_driver = :cuprite

require 'test/unit'
require 'capybara/dsl'

class SystemTestCase < Test::Unit::TestCase
  include Capybara::DSL
  def test_foo
    visit '/'
    assert_equal(page.find('h1').text, 'Hello World!')
    page.click_on 'Click'
    assert_equal(page.find('h1').text, 'Change Text')
  end
end
$ docker compose exec web ruby test_case.rb
Loaded suite test_case
Started
.
Finished in 0.319190042 seconds.
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
3.13 tests/s, 6.27 assertions/s

JavaScriptの実行も確認出来た

つまずいたところ

途中上記の設定で実行しても以下のような空のHTMLで返ってきて困った。

<html><head></head><body></body></html>

原因は意外なところでDockerComposeで指定していたサービス名が理由だった。 こちらを参考にしてapp -> webに変えたらうまく動いた。エラー内容も違うし詳細な理由はわからないのが、とりあえずはDockerComposeのService名はappにしないほうが無難なのだろうか

まとめ

SystemTestはブラウザが必要だったりUnitTestよりも複雑でそこにDockerや新しく使うGemが絡んでくると確信のないまま設定をあれこれいじくる事になりがちなので一旦小さな構成で試してみた。 とりあえず迷った時に立ちかえられる構成が出来たので次はRailsアプリに組み込んでいきたい。

参照

RailsでリクエストをWebサーバーから受け取った直後にBreakpointを貼ってデバッグする

RailsでPumaの挙動を確認したかったのだがデバッグするのにRackのMiddlewareが邪魔だったので回避策を検討してみた。

環境

詳細

Railsから見て先頭付近でリクエストを受け付けるところとして最初にApplicationControllerにデバッガを貼ってみたがBacktraceを見ると60行ほどのスタックでPumaからBreakpointまで結構な量のMiddlewareを通過していた。

class ApplicationController < ActionController::Base
    binding.break
end

rails middleware を打つとnewしたてのRailsアプリでもこれだけのMIddlewareが表示される。これをいちいち遡ってPumaまでたどりくのは面倒だ。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActionDispatch::ServerTiming
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run MyApp::Application.routes

アプリケーションとしてのRailsにリクエストが届くのがMiddlewareを経過した後なら、Middlewareの中にBreakpointを貼って然るべき場所に登録してやれば良いだろうという事で↓のようなただリクエストを通過させるMiddlewareを作成してBreakpointを設定。

class MyMiddleware
    def initialize(app)
        @app = app
    end
    
    def call(env)
        status, headers, body = @app.call(env)
        binding.break
        [status, headers, body]
    end
end

このMiddlewareをconfig/application.rbから登録すれば良いのだがconfig.middleware.useを使うとMiddlewareの最後、アプリケーションの直前に挿入されてしまうのでconfig.middleware.insert_beforeを使って一番上のMiddlewareの前に登録してやる。

config.middleware.insert_before ActionDispatch::HostAuthorization, MyMiddleware
$ rails middleware
use MyApp::Application::MyMiddleware
use ActionDispatch::HostAuthorization
use Rack::Sendfile
...
run MyApp::Application.routes

この状態でRailsのseverを起動して適当なリクエストを送ると先頭に追加したMiddlewareの中でデバッガが実行されてPumaの直後ぐらいにBreakpointが止まってくれる。あとは少しスタックを昇っていけばさほど労せずに目的のPumaまでたどり着ける。

$ backtrace
#0 MyApp::Application::MyMiddleware#call(env={"rack.version"=>[1, 6], "rack.errors"=>...)
#1 Rails::Engine#call(env={"rack.version"=>[1, 6], "rack.errors"=>...)
#2 Puma::Configuration::ConfigMiddleware#call(env={"rack.version"=>[1, 6], "rack.errors"=>...)

感想

軽い気持ちでPumaの挙動を確認しようと思ったらMiddlewareが結構邪魔で、当初回避方法を思いつかずInitializerとして登録したりして右往左往したので同じ轍を踏まないように記録しておく。 最終的にはシンプルな形で解決出来たのでPumaに限らずinsert_beforeの対象を適当なMiddlewareにすれば実際に開発しているアプリでMiddlewareのデバッグもしやすくなったので結果オーライか。

ActiveRecordを単体で試すためのテンプレートを作る

はじめに

勉強だったり仕事でRailsActiveRecordのソースを読んでるうちに手元で動かしたくなる事が多いのでActiveRecordを単体で扱えるテンプレートを作成してみた。

やった事

詳細はGithub

動作確認が主な目的でデータを永続化する必要がないので手軽さを優先してDatabaseはSQLite3をin memoryで使用。
Modelを動かすのに必要な処理を分けておいたので必要なテストデータを順次追加していく。 手順の流れはRailsと大体同じでschemaに定義をmodels以下にmodelファイル、テストデータはseed.rbに追加する。

  • schem.rb
  • models/
  • seed.rb

とりあえずhas_one、has_many、valiadtionあたりを確認出来るようにファイルとseedを追加しておいた。

デバッグして挙動も見たいのでデバッガを追加。
Ruby3からは標準のデバッガが改善されているがRuby2以下を使いたくなる場合を考えてpry-byebugにした。

pry-byebugのbinding.pryはファイルの最終行になるとBreakpointとして働いてくれなくなるので

puts 'finish'

を挿入。標準の新しいデバッガのbinding.breakだと最終行でも止まってくれるので後々移行するかも。

あとは puts 'finish' の上に試したいコードを書くなりデバッガを貼るなりして実行する。

bundle exec ruby main.rb

まとめ

has_manyやhas_oneだったりActiveRecordの挙動を確認したくなった際にModelを書いて、テストデータを作成してーというの都度考えるのが面倒なのでまとめてしまった。 CallbackとかObserverとか他にもあるがまた試したくなった時に追加する予定。

Rubyでコマンドを作る環境を整えてみる

はじめに

普段の仕事でほぼ毎日Rubyを書いているがもっぱらRailsばかりなのでもっと気軽にスクリプトを書いていこうと思い。Rubyで書いたプログラムをコマンドとして呼び出せる環境を考えてみた。

やった事

RubyCLIツールを作りたいという事でThorを追加

gem i thor

コマンドとして実行したいので実行ファイルにはshebangをつけて拡張子なしで/binに、本体は/lib以下に分けて置くディレクトリ構成にする

~/scripts/lib/thor_sample/thor_sample.rb
~/scripts/bin/thor_sample

最低限やりたい事を試すためにサンプルのスクリプトを作成

thor_sample.rb

require "thor"

module ThorSample
    class CLI < Thor
        desc "command", "print 'thor sample'"
        def command
            puts "thor sample"
        end
        desc "command2", "print 'hulk sample'"
        def command2
            puts "hulk sample"
        end
    end
end

bin/thor_sample

#!/usr/bin/env ruby
require 'thor'
require_relative '../lib/thor_sample/thor_sample'
ThorSample::CLI.start(ARGV)

pathを通して実行権限を付与

echo 'export PATH="$HOME/scripts/bin:$PATH"' >> ~/.zprofile
source ~/.zprofile
chmod 755 ~/scripts/bin/thor_sample
$thor_sample command
thor sample
$thor_sample command2
hulk sample

気軽にスクリプトを呼び出すのが目的なのに毎回必ず引数を渡さないといけないは面倒なのでデフォルトのコマンドを設定する。
最初はbinファイルの中でARGVが空ならデフォルトコマンドを入れた配列を渡そうと思ったがThorでdefault_taskが設定出来たので素直にこれを使用する。

class CLI < Thor
    default_task :command
    ~
end
$thor_sample
thor sample

これでRubyで書いたプログラムをコマンドとして実行出来るようになった。あとは自分で作りたいと思ったプログラムを追加していくだけ。

仕事でフレームワークを使っていると書いているプログラムの範囲が限定される。
それはそれで良いのだがたまに自分の頭で思いついたアィディアでなにかが実現出来た時は嬉しいので、これでプログラムを楽しんで書く事が捗ればよいと思う。