Rails で category.stg.example.com のような URL に対応する
きっかけ
ステージング環境上のランディングページ(LP)を操作中、知らない間に本番環境のページに移動し、誤ってコンバージョンしてしまった。ビューに本番環境の URL が直書きされていたことが原因だった。
課題
文字列で直書きされていた URL を、URL ヘルパーに置き換えればいいが、現在の実装ではサブドメインを考慮して URL ヘルパーを利用しなければならず、少し面倒である。
サブドメインの考慮について説明するため、LP で使われる URL のパターンを以下に列挙する。
- 本番環境
- example.com
- category.example.com
- ステージング環境
- stg.example.com
- category.stg.example.com
- 開発環境
ステージング環境は、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.com
で tld_length
が 1(デフォルト値)の場合
とパースされる。tld_length
が 2 の場合は
とパースされる。
この性質を利用し、ステージング環境の場合に
config.action_dispatch.tld_length = 2
とすることで、ルーティングを以下のようにシンプルに書けるようになる。これは Rails がパースしたリクエストのサブドメインと、constraints
の subdomain
オプションで渡した文字列と完全一致するためだ。
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 ブロックの外側から実行されるためだ。
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.parse
に symbolize_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
他のテストがタイムトラベルに巻き込まれないよう確実に Timecop.return する(for RSpec)
テストコード内で Timecop.return
し忘れると、後続のテストでも Timecop.freeze
や Timecop.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
でコミットの整理を行う。
- コンポーネント(クラス、モジュール、React コンポーネントなど)の追加と適用は同じコミットに混ぜない
- コミット全体の流れを機能やリファクタを完成する一連のストーリーとして作る
- 必要に応じてコミットの本文(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 は初めて触った上、画像処理の知識がないので雑なまとめです。
はじめに
書道で使う手本は文字が小さく、手本は何十ページもあるため、後で自分が書いたものと手本を見返そうとしても探すのが大変だったりする。
自分が書いた文字と予めスキャンしておいた手本の中の文字とを自動でマッチングし、手本の文字と自分の文字をあわせた合成画像を生成できれば、あとで見返すのに便利そうである*1。例えば以下のような感じ。このように並べてみればニュアンスの違いが一目瞭然だし、一枚の画像に合成しておけば、スマホでいつでも確認できる。
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()
結果は次のようになった。ひと目で見て分かる通り、うまくマッチングできていない。うまくマッチングできていれば文字を繋ぐ線が分散せず、一つの対応する文字に集中する。
以下は正しくマッチできている箇所。周辺の空間に特徴があれば、精度高くマッチングできそう。
以下はミスマッチの箇所。エッジの角度やコーナーの形が類似する部分でミスマッチが多発しているっぽい。
考察
- 特徴点マッチングは、エッジの角度や空間の形状をもとにしていると思われる
- 文字は単純な線や空間で構成されているため、エッジの角度や空間の形状をもとにした特徴点マッチングでは、文字同士 をマッチングするのが難しい
- 文字同士をマッチングするには、エッジの角度や空間の形状という細かい粒度ではなく、「文字」を塊として手本とマッチングする必要があると考えられる
おわりに
画像処理をちゃんと分かってなくてもそれっぽい結果が得られる OpenCV すごい。
次は、「文字」を塊として扱いやすいテンプレートマッチングを試してみたい。
*1:半紙一枚書くたびに写真を撮ればいいと思うかもしれないが、集中力が切れてしまう
Docker Compose で MySQL コンテナを立てるときに MYSQL_ROOT_PASSWORD 環境変数の指定でハマった
結論
- Docker Compose のサービスの環境変数は Hash 形式で設定するのがおすすめ
environment: MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' MYSQL_ROOT_PASSWORD: '' # **空文字列** がパスワードとして設定される
docker-compose down db
だけだと、db
コンテナに紐づく volume は削除されない。MYSQL_ROOT_PASSWORD
周りの環境設定を書き換えた後は、ボリュームを含めて削除する必要がある
背景
- docker-compose で開発環境用の MySQL サービスを用意した
- 開発環境用の MySQL サーバは、用途的に root ユーザのパスワードが不要
- Docker 公式 MySQL イメージは、コンテナ初回起動時に
MYSQL_ALLOW_EMPTY_PASSWORD
環境変数を設定しておけば、空文字列のパスワードで MySQL ルートユーザを作ってくれる- (厳密には)ボリュームに MySQL のデータディレクトリがなければ、ユーザを作る実装
- https://github.com/docker-library/mysql/blob/fc3e856313423dc2d6a8d74cfd6b678582090fc7/5.6/docker-entrypoint.sh#L98
MYSQL_ALLOW_EMPTY_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
しても、オプション無しではサービスがマウントしているボリュームが削除されない
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 にするのが良さそう
以下の記事に書かれていたことそのままだが備忘としてまとめた。
- ActiveRecord のコネクションプールは、各 Sidekiq ワーカスレッドと そのスレッドが動作するプロセス(Sidekiq プロセス) に一つずつ割り当てられる
- Sidekiq の並列数(ワーカスレッド数)と、コネクションプール上限数が等しいとき、ワーカスレッドに割り当てられるはずのプールが一つ不足する(Sidekiq プロセスに一つ割り当てられるため)
- この状態でかつ、database.yml の
checkout_timeout
に設定した秒数(デフォルトは 5 秒)以上かかるジョブを実行する場合- 2 つのワーカスレッドに処理を割り当てると、片方のワーカスレッドはコネクションを取得できるので処理が実行できる
- しかし、もう片方のワーカスレッドは、はじめのワーカスレッドと Sidekiq プロセスによってコネクションプールに空きがないため、
checkout_timeout
秒以内にコネクションを取得できず、ジョブの実行に失敗する
- したがって、ActiveRecord のコネクションプール数は、ワーカスレッド数 + 1 以上にするのが適切である
以上!