Rails で category.stg.example.com のような URL に対応する

きっかけ

ステージング環境上のランディングページ(LP)を操作中、知らない間に本番環境のページに移動し、誤ってコンバージョンしてしまった。ビューに本番環境の URL が直書きされていたことが原因だった。

課題

文字列で直書きされていた URL を、URL ヘルパーに置き換えればいいが、現在の実装ではサブドメインを考慮して URL ヘルパーを利用しなければならず、少し面倒である。

サブドメインの考慮について説明するため、LP で使われる URL のパターンを以下に列挙する。

ステージング環境は、category.stg.example.com のように、カテゴリ(category)と環境(stg)のサブドメインから構成されているが、それ以外の環境は、環境を表すサブドメインはなく、カテゴリを表すサブドメインだけを持つ。

そうしたことから、ルーティングでは、環境を示すサブドメインは無視し、カテゴリのサブドメインだけをチェックする必要がある。これは以下のルーティングで実現できる。

# routes.rb

# 環境を表すサブドメインが category の後ろに付いていても一致する(先頭一致)
constraints ->(req) { req.subdomain.start_with?('category-a') } do
  # ...
end

constraints ->(req) { req.subdomain.start_with?('category-b') } do
  # ...
end

URL ヘルパーでは、このようにサブドメインを指定する。

link_to form_index_url(subdomain: 'category.stg')

しかし、このように URL ヘルパーを使うたび、subdomain オプションを指定するのは面倒である。

以下のようにサブドメインの文字列を返すヘルパーを定義し、URL の生成に使うこともできるが、面倒さはあまり緩和されない*1

# app/helpers/application_helper.rb
module ApplicationHelper
  def subdomain_for(category)
    "#{category}#{Rails.env.staging? ? '.stg' : ''}"
  end
end
<%= link_to form_index_url(subdomain: subdomain_for('category')) %>

解決方法

そもそも Rails がパースしたサブドメインに環境名(stg など)が入ってしまうことが問題なので、そうならないようにドメインのパース方法を変更すれば良い。

これには config.action_dispatch.tld_length を利用できる。Ralis がドメインサブドメインをパースするのに使用する設定である*2

例えば、ホスト名が category.stg.example.comtld_length が 1(デフォルト値)の場合

とパースされる。tld_length が 2 の場合は

とパースされる。

この性質を利用し、ステージング環境の場合に

config.action_dispatch.tld_length = 2

とすることで、ルーティングを以下のようにシンプルに書けるようになる。これは Rails がパースしたリクエストのサブドメインと、constraintssubdomain オプションで渡した文字列と完全一致するためだ。

constraints subdomain: 'category' do
  #  ブロック内のルーティング
end

同様に開発環境では

config.action_dispatch.tld_length = 0

にすることで、

とパースされるので、categoryサブドメインと認識させることができる。

副作用

config.action_dispatch.tld_length はアプリケーション中の URL のパースに関わる箇所すべてに適用されるため注意が必要である。

代替手段

副作用が気になる場合は、こういう代替手段を取ることができる。

# lib/routing_helper.rb
module RoutingHelper
  def subdomain_for(category)
    "#{category}#{Rails.env.staging? ? '.stg' : ''}"
  end
end
# config/routes.rb
include RoutingHelper

namespace :category, path: '' do
  constraints subdomain: subdomain_for(category) do
    # ...
  end
end
<%= link_to category_form_index_url %>

*1:URL ヘルパーをオーバーライドする方法も考えられるが、Rails のバージョンアップで動かなくなるリスクを避けたいので、採用しなかった

*2:tld_length はトップレベルドメインの長さを表す。ドメイン部をドットで区切った配列の末尾から tld_length - 1 までがトップレベルドメイン、それ以降がサブドメインとしてパースされる

RSpec でリクエストを実行するときは let が便利

before ブロック

RSpec でコントローラや API のテストを作るとき、リクエストの実行を before ブロックに記述することがある。

describe FooController do
  before { post :create }
  
  context 'with some data' do
    before { create_data }
    it { expect(response).to have_http_status(:ok) }
  end
end

create_data は、リクエストに必要なデータを作る前処理で、リクエスト実行前に走ることを想定している。

しかし、このコードは想定通りの動きにならない。実際には、外側の before ブロックでリクエストが実行された後、内側のブロックで create_data が実行される。これは、RSpec の仕様で、before ブロック は describe や context ブロックの外側から実行されるためだ。

relishapp.com

before ブロックでリクエストを実行する方法は、気軽でよく使ってしまいがちだが、このようにリクエストの前に何らかの前処理を差し込みたい場合に不便である。

let ヘルパー

そこで、以下のようなリクエストを実行する let を定義する。fetch_response を呼び出すと、リクエストが走り、レスポンスオブジェクトを返す*1

# controller spec の `response` ヘルパーと重複するので、それっぽい別の名前をつけた
let(:fetched_response) { post :create }

このようにリクエストの実行を let で定義しておけば、好きなタイミングでリクエストを実行できるようになるので、リクエストの前に前処理を差し込むが簡単になる。

describe FooController do  
  context 'with some data' do
    before { create_data }
    it { expect(fetched_response).to have_http_status(:ok) }
  end
end

好きなタイミングでリクエストを実行できるので、change マッチャーも簡単に使える。

it { expect { fetched_response }.to change(Post, :count).by(1) }

JSON API のテストならこんなふうに書くのが便利。JSON.parsesymbolize_names: true を渡せば、parse した Hash のキーをシンボルに自動で変換してくれる。

describe FooController do
  let(:fetched_response) { post :create }  
  let(:parsed_response) do
    JSON.parse(fetched_response.body, symbolize_names: true)
  end

  context 'with invalid request' do
    before { create_data }

    it do
      expect(parsed_response).to match(
        type: 'validation_failed',
        errors: [
          { field: 'xxx', code: 'xxxx' }
        ]
      )
    end
  end
end

こんな感じに、1 example に複数の expect を書いても、実行されるリクエストはただ一回。そう、let ならね。

describe FooController do  
  let(:fetched_response) { post :create }  

  context 'with invalid request' do
    before { create_data }

    it do
      expect(fetched_response).to have_http_status(:ok)
      expect(fetched_response.body).to eq({ status: 'ok' }.to_json) 
    end
  end
end

*1:controller spec の get や post のようなリクエストを実行するヘルパーは、レスポンスオブジェクトをそのまま返すため

他のテストがタイムトラベルに巻き込まれないよう確実に Timecop.return する(for RSpec)

テストコード内で Timecop.return し忘れると、後続のテストでも Timecop.freezeTimecop.travel によるタイムトラベルを続行してしまうため、正常な時刻に戻らず、全く関係のないテストが落ちることがある。

例えば、以下のように Timecop.return を記述し忘れるケースがある。 今日これに遭遇してしまったので、Timecop.return するいくつかの方法を知識整理も兼ねてまとめてみたいと思う。

describe Foo do
  before do
    Timecop.freeze(2000, 10, 10, 9, 0, 0)
  end

  it { some_code }
end

一番単純な方法は、after ブロックで return することである。これで example 実行後にちゃんと時間がもとに戻る。

describe Foo do
  before do
    Timecop.freeze(2000, 10, 10, 9, 0, 0)
  end

  after do
    Timecop.return
  end

  it { some_code }
end

あるいは、メソッドブロックで freeze させたいコード片を囲っても良い。こっちのほうが Ruby らしい。

describe Foo do
  it do
    Timecop.freeze(2000, 10, 10, 9, 0, 0) { some_code }
  end
end

このようなイケてる shared context を定義すれば、spec から Timecop を隠蔽できる。美しい。

RSpec.shared_context 'with frozen time' do |options|
  let(:frozen_time) { options[:at] } if options && options[:at]

  around do |e|
    Timecop.freeze(frozen_time) { e.run }
  end
end
describe Foo do
  include_context 'with frozen time', at: Time.current
  it { some_code }
end

このように Timecop.return する方法はいくつかあるが、今回は example 実行後に必ず Timecop.return を呼び出す Helper を作った。

module RSpec
  module TimecopReturnHelper
    def self.included(rspec)
      rspec.after(:each) do |_|
        Timecop.return
      end
    end
  end
end
RSpec.configure do |config|
  config.include RSpec::TimecopReturnHelper
end

これによって、他のテストのタイムトラベルに巻き込まれることを確実に防げるし、二度と Timecop.return を書き忘れない。

spec 側ですでに Timecop.return している場合は、二重に Timecop.return への呼び出しが発生するが、これによってテスト時間が延びることは今のところなさそうである。

最初から git rebase しやすくするためのコミットの作り方

概要

GitHub 上でレビューしやすい状態にするため、いつも git rebase でコミットの整理を行うようにしているが、時々 rebase 時に conflict を起こして面倒になることがあった。最近思いついた方法で予めコミットを作っておけば git rebase がしやすくなりそうだと思ったのでその方法を書いた。

ルール

以下のルールをブランチを切った時点から意識する。あらかた実装が終わり微調整の段階になった時点で git rebase でコミットの整理を行う。

  1. コンポーネント(クラス、モジュール、React コンポーネントなど)の追加と適用は同じコミットに混ぜない
    • ファイルの追加・削除は、1 ファイルごとに 1 コミット
    • 追加したコンポーネントを別のコンポーネントから利用するためのコミットは、別のコミットとして切り出す
    • 同じコミットに混ぜてしまうとあとで分離したいときに面倒だし、コミット順を変えたりするとよく conflict してうざいので、最初から分けてしまう。
  2. コミット全体の流れを機能やリファクタを完成する一連のストーリーとして作る
    • コンポーネント作成・削除: 機能実現やリファクタに必要な部品を追加、削除を行う
    • コンポーネント統合: 作成したコンポーネント群を利用し、機能が働く状態にする
    • 調整: 本筋とは関係の薄い修正(Linter の警告の抑制や見た目の調整など)を行う
  3. 必要に応じてコミットの本文(body)にコミットの意図や詳細を書く
    • あとで rebase するときの助けになるし、コードレビューもしやすくなる
    • コンポーネント統合のコミットは、時間がないときはどうしても一つにまとめてしまいがちなので、コミットの本文に変更の詳細を書いてごまかす

コミット例

業務中のコミットを一部修正して抜粋した。

1. サーバサイド: コンポーネント作成・削除

デプロイ後に走らせるためのスクリプトの追加と、既存コードへのメソッド追加。

Date:   Mon Aug 13 16:35:19 2018 +0900

    [#4270] Add script to create document
    この機能のデプロイ時に script を実行することで、最新の件数を保存する

A   script/2018/20180813_create_document.rb
Date:   Mon Aug 13 16:58:23 2018 +0900

    [#4270] Add RestClient#update_document

M   app/models/rest_client.rb

2. サーバサイド: コンポーネント統合

1 で追加したコードを利用して、サーバサイドの機能を実現する。

これは好みだが、実装とテストは同じコミットに含めたほうが、レビュー時に実装とテストを見比べやすくなって良いと思う。

Date:   Mon Aug 13 17:07:29 2018 +0900

    [#4270] Implement server-to-client notification

M   app/models/history.rb
M   spec/models/history_spec.rb

3. クライアントサイド: コンポーネント作成・削除

既存の React コンポーネントへの機能追加。

Date:   Mon Aug 13 19:43:04 2018 +0900

    [#4270] Add status badge

M   spa/src/Components/Common/CategoryMenu.tsx
Date:   Tue Aug 14 12:11:01 2018 +0900

    [#4270] Add requestKey props to reload current request

M   spa/src/Components/Common/Query.tsx

4. クライアントサイド: コンポーネント統合

ここまでの変更を統合して、全体の機能を実現する。

ここはもう少しコミットを分けても良かったかもしれない。コミット分割が面倒になったときは、コミット本文に変更の詳細を書いてごまかす。

Date:   Mon Aug 13 21:47:46 2018 +0900

    [#4270] Implement real-time status badge

M   spa/src/Components/List.tsx
M   spa/src/Containers/Common/List.ts
M   spa/src/Containers/XXXList.tsx
M   spa/src/Containers/YYYList.tsx

5. 調整

本筋と関係のない spec や linter まわりの修正を行った。

Date:   Tue Aug 14 18:05:27 2018 +0900

    [#4270] Fix rubocop offences

M   spec/models/history_spec.rb
Date:   Wed Aug 15 11:50:49 2018 +0900

    [#4270] Stub out RestClient

M   spec/models/history_spec.rb
Date:   Wed Aug 15 14:24:36 2018 +0900

    [#4270] Reset mock object

M   spec/models/history_spec.rb
Date:   Wed Aug 15 16:26:33 2018 +0900

    [#4270] Disable model callback in test env

M   app/models/history.rb

最後に

皆さんが普段実践している「git rebase しやすくするためのコミットの作り方」や、それに限らずもっと良さげなコミットの作り方があれば教えてほしいです!

OpenCV で手本の文字と自分が書いた文字をマッチングしてみた(その1:特徴点マッチング)

OpenCV は初めて触った上、画像処理の知識がないので雑なまとめです。

はじめに

書道で使う手本は文字が小さく、手本は何十ページもあるため、後で自分が書いたものと手本を見返そうとしても探すのが大変だったりする。

f:id:nyamadori:20180603003552j:plainf:id:nyamadori:20180603003604j:plain
左: 手本をもとに自分が書いたもの、右: 手本の一部。手本の真ん中の行が書いた場所

自分が書いた文字と予めスキャンしておいた手本の中の文字とを自動でマッチングし、手本の文字と自分の文字をあわせた合成画像を生成できれば、あとで見返すのに便利そうである*1。例えば以下のような感じ。このように並べてみればニュアンスの違いが一目瞭然だし、一枚の画像に合成しておけば、スマホでいつでも確認できる。

f:id:nyamadori:20180603021624p:plain

OpenCV の特徴点マッチング

上のような合成画像を作るには、いろいろやることがありそうだが、ひとまず OpenCV の特徴点マッチングを用いて、手本の文字と自分の文字のマッチングを試みた。特徴点マッチングについては、以下を参照してほしい。雰囲気がつかめると思う。

特徴点マッチングはこのように実装した。このプログラムを実行すると、左側は自分が書いたもの、右側は手本に合成された一枚の画像が GUI で表示される。結果画像の左側と右側の間には、マッチした特徴点同士が線で結ばれる。

import numpy as np
import cv2 as cv
import matplotlib
from matplotlib import pyplot as plt

# 手本をもとに自分が書いたもの
origin1 = cv.blur(cv.imread('a_reproduce.jpg', cv.IMREAD_GRAYSCALE), (10, 10))

# 手本
origin2 = cv.blur(cv.imread('a_model.jpg', cv.IMREAD_GRAYSCALE), (10, 10))

# リサイズしないと処理速度的に厳しかった
resized_image_1 = cv.resize(origin1, None, fx=0.2, fy=0.2, interpolation=cv.INTER_CUBIC)
resized_image_2 = cv.resize(origin2, None, fx=0.2, fy=0.2, interpolation=cv.INTER_CUBIC)

# 二値画像に変換
ret1, preprocessed_image_1 = cv.threshold(resized_image_1, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)
ret2, preprocessed_image_2 = cv.threshold(resized_image_2, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)

# 特徴点検出器をインスタンス化
detector = cv.AKAZE_create()

# 特徴点を検出
kp1, des1 = detector.detectAndCompute(preprocessed_image_1, None)
kp2, des2 = detector.detectAndCompute(preprocessed_image_2, None)

bf = cv.BFMatcher()
matches = bf.knnMatch(des1, des2, k=2)
good = []
for m,n in matches:
    if m.distance < 0.75 * n.distance:
        good.append([m])

img3 = cv.drawMatchesKnn(preprocessed_image_1, kp1, preprocessed_image_2, kp2, good, None, flags=2)

plt.imshow(img3) # 結果画像を表示
plt.show()

結果は次のようになった。ひと目で見て分かる通り、うまくマッチングできていない。うまくマッチングできていれば文字を繋ぐ線が分散せず、一つの対応する文字に集中する。

f:id:nyamadori:20180603010004p:plain

以下は正しくマッチできている箇所。周辺の空間に特徴があれば、精度高くマッチングできそう。

f:id:nyamadori:20180603010258p:plain

以下はミスマッチの箇所。エッジの角度やコーナーの形が類似する部分でミスマッチが多発しているっぽい。

f:id:nyamadori:20180603010308p:plain

考察

  • 特徴点マッチングは、エッジの角度や空間の形状をもとにしていると思われる
  • 文字は単純な線や空間で構成されているため、エッジの角度や空間の形状をもとにした特徴点マッチングでは、文字同士 をマッチングするのが難しい
  • 文字同士をマッチングするには、エッジの角度や空間の形状という細かい粒度ではなく、「文字」を塊として手本とマッチングする必要があると考えられる

おわりに

画像処理をちゃんと分かってなくてもそれっぽい結果が得られる OpenCV すごい。

次は、「文字」を塊として扱いやすいテンプレートマッチングを試してみたい。

*1:半紙一枚書くたびに写真を撮ればいいと思うかもしれないが、集中力が切れてしまう

Docker Compose で MySQL コンテナを立てるときに MYSQL_ROOT_PASSWORD 環境変数の指定でハマった

結論

  1. Docker Compose のサービスの環境変数は Hash 形式で設定するのがおすすめ
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_ROOT_PASSWORD: '' # **空文字列** がパスワードとして設定される
  1. docker-compose down db だけだと、db コンテナに紐づく volume は削除されない。MYSQL_ROOT_PASSWORD 周りの環境設定を書き換えた後は、ボリュームを含めて削除する必要がある

背景

罠1. 配列形式で環境変数を指定したときの解釈

配列形式の環境変数指定の場合、""空文字列ではなく、ダブルクオーテーション 2 文字("")の文字列として解釈されて、MySQL ユーザが作成される。

environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
- MYSQL_ROOT_PASSWORD="" # 「""」がパスワードとして設定される

この挙動はパット見ではすぐ気づけないので、以下のような Hash 形式で指定するのがおすすめ。

environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_ROOT_PASSWORD: '' # **空文字列** がパスワードとして設定される

そもそもこのケースの場合、パスワードが空なので MYSQL_ROOT_PASSWORD 環境変数の指定は不要である。

environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'

罠2. docker-compose down db だと volume は削除されない

  • docker-compose down db しても、オプション無しではサービスがマウントしているボリュームが削除されない
    • MySQL コンテナのデータは、MySQL ユーザも含めボリュームに永続化されるため、 docker-compose down しても作成した MySQL ユーザは削除されない
    • MYSQL_ROOT_PASSWORD 周りの環境設定を書き換えた後は、ボリュームを含めて削除する必要がある

docker-compose down docker-compose up して試行錯誤中の環境変数を試してみるも、docker-compose down だけだと MySQL ユーザが削除されない。環境変数の設定値を試行錯誤するうちに、MYSQL_ALLOW_EMPTY_PASSWORD が正しく設定された MySQL ユーザが知らないうちに作られてしまっていたため、以下の記述をすればまっさらな環境でも空パスワードの MySQL ユーザができると勘違いしていた。

# 誤った記述
environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
- MYSQL_ROOT_PASSWORD="" # 「""」がパスワードとして設定される

Sidekiq を使うとき、ActiveRecord のコネクションプール数 >= Sidekiq のスレッド数 + 1 にするのが良さそう

以下の記事に書かれていたことそのままだが備忘としてまとめた。

repl.info

  • ActiveRecord のコネクションプールは、各 Sidekiq ワーカスレッドと そのスレッドが動作するプロセス(Sidekiq プロセス) に一つずつ割り当てられる
  • Sidekiq の並列数(ワーカスレッド数)と、コネクションプール上限数が等しいとき、ワーカスレッドに割り当てられるはずのプールが一つ不足する(Sidekiq プロセスに一つ割り当てられるため)
  • この状態でかつ、database.yml の checkout_timeout に設定した秒数(デフォルトは 5 秒)以上かかるジョブを実行する場合
    • 2 つのワーカスレッドに処理を割り当てると、片方のワーカスレッドはコネクションを取得できるので処理が実行できる
    • しかし、もう片方のワーカスレッドは、はじめのワーカスレッドと Sidekiq プロセスによってコネクションプールに空きがないため、checkout_timeout 秒以内にコネクションを取得できず、ジョブの実行に失敗する
  • したがって、ActiveRecord のコネクションプール数は、ワーカスレッド数 + 1 以上にするのが適切である

以上!