blog.kotamiyake.me

為せば成る、為さねば成らぬ何事も

個人のRailsプロジェクトでDockerを使って環境構築をしていた際にハマったお話。

原因

  • Rackのバージョンが上がって、ホストとポートを分割する挙動が変わりアンダースコアのホストがマッチしなくなった

Rackの挙動の変更点は以下の通り。

対策

  • docker-composeのサービス名をハイフンで繋ぐよう修正する

経緯

元々以下のような構成でdocker-composeを使って環境を構築していました。

アプリ本体とwebpack-dev-serverを動かすアプリを分離する構成です。

services:
  app:
    <<: *app
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    environment:
      WEBPACKER_DEV_SERVER_HOST: webpack_dev_server
    ports:
      - 3000:3000
    depends_on:
      - db
  webpack_dev_server:
    <<: *app
    command: ["bin/webpack-dev-server"]
    environment:
      WEBPACKER_DEV_SERVER_HOST: 0.0.0.0
    ports:
      - 3035:3035

それから開発中にRackのバージョンをアップデートしたところ、以下のエラーが吐き出されてjsが参照できなくなりました。

#<SocketError: Failed to open TCP connection to webpack_dev_server:3035:80 (getaddrinfo: Name or service not known)>

今回は開発環境での出来事だったので特に問題はなかったのですが、みなさんもgemのアップデートの際にはくれぐれもご注意を…。

Railsでサービスを開発していると、バックグラウンドで処理を実行するジョブを実装することがあるかと思います。

そんなとき、いつもテストの書き方を忘れてしまうので、内容の理解と整理のために備忘録として記事を書こうと思います。

まずはそもそもどういったものをテストするべきかを整理してみたいともいます。

ジョブ自身は基本的に同期的に実行しているモデルまたはメール送信などの処理をバックグラウンドで非同期に実行することが多いと思います。

なので、ユニットテストでは基本的には正確に意図したジョブが呼び出されているか、ということさえ確認できればよいのではないかと思います。

もちろんこれはジョブ実行メソッドの中に余計な処理が書かれていないことが前提となります。

それでは簡単なサンプルと解説を書いていきたいと思います。

まずは config.active_job.queue_adapter を設定します。ここでは :test を指定していますが、ご自身の環境に合わせて設定してください。

Rails.application.configure do
  # snip...
  config.active_job.queue_adapter = :test
end

それではテストコードを書いていきたいと思います。モデル、ジョブ、テストの順番にコードを書いていきます。

class Thing < ApplicationRecord
  after_commit :do_something_job

  def do_something
    # Do something...
  end

  private

  def do_something_job
    ThingJob.perform_later(self)
  end
end
class ThingJob < ActiveJob::Base
  def perform(thing)
    thing.do_something
  end
end
require 'rails_helper'

RSpec.describe Thing, type: :model do
  include ActiveJob::TestHelper

  describe '#do_something_job' do
    it 'enqueues `ThingJob`' do
      expect {
        Thing.create!
      }.to have_enqueued_job(ThingJob)
    end
  end
end

普段からRailsを利用している開発者からしたらどうということのないコードかと思います。

処理の流れとしては、Thing の作成(または更新)処理が行われると、commit をフックとして do_something_job の中で ThingJob が実行されます。

ThingJob では Thing#do_something が実行されます。

最初に書いたとおり、ここではコールバックの実行とともに想定通りのジョブがキューに追加されるかどうか、という部分を確認しています。

ActiveJob::TestHelper は実際にはなくても動くのですが、モジュールの中でジョブの初期化処理をやってくれているので、特に問題がなければインクルードしておくと良いかと思います。

勝手に初期化して欲しくないということであれば、上記のヘルパーを参考にして初期化処理をかくとよいかと思います。

と、ここまで書いておいてアレなのですが、実はRSpecのドキュメントにその他の細かいマッチャーなどもしっかり記載されています。

さらに詳細な条件でテストをしたい場合には、RSpecのドキュメントを参考にしてみてください。

すべてのケースをテストでカバーできるということはあり得ないのですが、それでも基本的なケースをいくつか抑えておくだけで変更の際の安心感が変わってきます。

苦手意識を持たず、満遍なくテストを書くことができるようにしていきましょう。

Railsはデフォルトで基本的なバリデーション機能を提供してくれています。

ただ提供されていないバリデーションを追加したいこともあります。そんなとき、一度しか登場しない機能であれば validate などで直にバリデーションを書いてしまいます。

ただ複数箇所で同じようなバリデーションを使いまわしたい場合があります。そこで登場するのが Custom Validators です。

Custom Validator の実装方法は Rails Guides を読めばわかるので割愛しますが、今回はそのテスト方法を紹介したいと思います。

今回する方法は以下の環境で試していますが、それほど古い環境でなければ同じように動作するかと思います。

  • ruby 2.5.3p105
  • Rails 5.2.2
  • RSpec 3.8

単純に実際に使用しているモデルをテストすればいいかと思うかもしれませんが、それではモデルと密に繋がってしまい、Custom Validator のテストがモデルに依存してしまいます。

そこで今回はバリデーションを定義したモックとなるモデルを生成してテストします。

まずはテスト対象となる Custom Validator を作成します。バリデーションの要件は「全角カタカナ」のみを含む、とします。

全角カタカナのチェックは 全角カタカナにのみマッチする正規表現 – Qiita を参考にしました。

class KatakanaValidator < ActiveModel::EachValidator
  def validate_each(record, attr_name, value)
    unless value =~ /\A[\p{katakana} ー-&&[^ -~。-゚]]+\z/i
      record.errors.add(attr_name, :katakana)
    end
  end
end

テストは 【Rails】まだValidatorのテストで消耗してるの? – Qiita を参考にしています。

上記の筆者の方がgemを作成していただいているのですが、それほど規模が大きくないこと、メンテナンスの継続性なども含めて自身で実装することにしました。

RSpecの使い方などは理解しているという前提で話を進めさせていただきます。そのあたりはいろいろな記事で紹介されているかと思いますので、そちらをご参照ください。

複数のテストで使うことからsupport/以下に共通モジュールとしてモック生成モジュールを作成します。

module CustomValidatorHelper
  def build_mock(attr_name , validator: )
    raise ArgumentError if attr_name.blank? || validator.blank?

    Struct.new(attr_name) do
      include ActiveModel::Validations

      def self.name
        'DummyModel'
      end

      validates attr_name, validator => true
    end
  end
end

RSpec.configure do |config|
  config.include CustomValidatorHelper, type: :model
end

この記事を書いていて、メソッドの名前は考え直したほうが良いなと思いました…が、とりあえずそこはスルーして読み進めてください。

それでは実際のテストコードです。

require 'rails_helper'

RSpec.describe KatakanaValidator, type: :model do
  describe '#validate_each' do
    it 'is valid' do
      mock_model = build_mock(:name_kana, validator: :katakana).new('カタカナ')
      expect(mock_model).to be_valid
    end

    it 'is not valid' do
      mock_model = build_mock(:name_kana, validator: :katakana).new('カタカナa')
      expect(mock_model).not_to be_valid
      expect(mock_model.errors).to be_added(:name_kana, :katakana)
    end
  end
end

これでモデルに依存せずに Custom Validator のテストを書けるようになりました。

テストはできるだけ依存を減らして、単体で機能するようにすると影響を受けずに安定したテストになるかと思います。

なのでそういったことも意識してテストを書くようにすると安定したサービスを開発できるのではないでしょうか。

また上記のコードは色々とリファクタリングできる要素があるかと思いますが、テストはあまりDRYを意識するよりは愚直な実装のほうが後から見た時にわかりやすくて良いかなと思うので、その辺のバランスも考えてテストコードを書いていくと、よりメンテナンスしやすいテストになりそうです。

以上、Rails の Custom Validator をテストする方法を紹介しました。

以下のコードをinitializersあたりに設置して、その定義の下にwhitelistを追加する。

# from https://github.com/alexspeller/non-stupid-digest-assets/issues/48#issuecomment-365126225
module NonStupidDigestAssets
  mattr_accessor :whitelist
  @@whitelist = []

  class << self
    def files(files)
      return files if whitelist.empty?
      whitelisted_files(files)
    end

    private

    def whitelisted_files(files)
      files.select do |file, info|
        whitelist.any? do |item|
          case item
          when Regexp
            info['logical_path'] =~ item
          else
            info['logical_path'] == item
          end
        end
      end
    end
  end
end

module NonDigest
  def compile *args
    super *args
    
    NonStupidDigestAssets.files(files).each do |(digest_path, info)|
      full_digest_path = File.join dir, digest_path
      full_digest_gz_path = "#{full_digest_path}.gz"
      full_non_digest_path = File.join dir, info['logical_path']
      full_non_digest_gz_path = "#{full_non_digest_path}.gz"

      if File.exists? full_digest_path
        # logger.info "Writing #{full_non_digest_path}"
        FileUtils.rm full_non_digest_path if File.exists? full_non_digest_path
        FileUtils.cp full_digest_path, full_non_digest_path
      else
        logger.warn "Could not find: #{full_digest_path}"
      end
      if File.exists? full_digest_gz_path
        # logger.info "Writing #{full_non_digest_gz_path}"
        FileUtils.rm full_non_digest_gz_path if File.exists? full_non_digest_gz_path
        FileUtils.cp full_digest_gz_path, full_non_digest_gz_path
      else
        logger.warn "Could not find: #{full_digest_gz_path}"
      end
    end
  end
end

module Sprockets
  class Manifest
    prepend NonDigest
  end
end

対象バージョン

  • Rails 5.2

ActionMailerはAbstractControllerを継承しているのでActionControllerと同じ汎用のヘルパーのみ使用できます。

Action Mailer now just inherits from AbstractController, so you have access to the same generic helpers as you do in Action Controller.

5 Using Action Mailer Helpers

独自のヘルパーをMailerで使用するためには以下のように、helper 使って必要なヘルパーを読み込む必要があります。

class SomeMailer < ApplicationMailer
  helper SomeHelper
end

または

class SomeMailer < ApplicationMailer
  helper :some
end

または

class SomeMailer < ApplicationMailer
  helper 'some'
end

helper
https://api.rubyonrails.org/classes/AbstractController/Helpers/ClassMethods.html#method-i-helper

個人的に動かしているアプリでsidekiqを利用しているのですが、先日以下のようなエラーが発生しました。

Redis::CommandError: ERR Error running script (call to f_7b91ed9f4cba40689cea7172d1fd3e08b2efd8c9): @user_script:7: @user_script: 7: -MISCONF Redis is configured to save RDB snapshots, but is currently not able to persist on disk. Commands that may modify the data set are disabled. Please check Redis logs for details about the error.
  File "/home/deploy/apps/commerce/shared/bundle/ruby/2.5.0/gems/redis-4.0.1/lib/redis/client.rb", line 119, in call
# snip...

Googleで検索してみると、結論としてはどうやらメモリ不足だった模様。

Redisで発生したメモリ不足エラーの調査メモ | ソシャゲの作り方
http://www.24w.jp/blog/

RailsではSQLを発行する際に同じクエリがリクエストされるとSQLキャッシュが利用されます。

Rails のキャッシュ: 概要 | Rails ガイド
https://railsguides.jp/caching_with_rails.html#sql-%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5

キャッシュを利用すると高速化されますが、その分メモリを多く使用します。

今回はそれほどスピードを優先していないため、省メモリを優先してSQLキャッシュをオフにすることにしました。

def perform(shop, url)
  ActiveRecord::Base.connection.uncached do
    # code...
  end
end

ActiveJob Memory bloat · Issue #27002 · rails/rails
https://github.com/rails/rails/issues/27002#issuecomment-260086170

他にもやることはあるとは思いますが、一旦はこれで様子を見ることにします。

 

 

以前MessageEncryptorを使った暗号化を紹介したのですが、Rails 5.2では若干使い方が変わっていたので紹介します。

ドキュメントに従い、以下のように書き直します。

pry(main)> len = ActiveSupport::MessageEncryptor.key_len
=> 32
pry(main)> salt = SecureRandom.random_bytes(len)
=> "\x10\x8D\xCC\x19%\xD0\x9F{_\xB9\x9C\xDF\xEC\xE9\x10\xA3\xF9\x82\"\r\xE1\xAF\x01.\x17\x00\xAD\xA3=\xA6\xD2\x00"
pry(main)> key = ActiveSupport::KeyGenerator.new('salary').generate_key(salt, len)
=> "\"<\xE7\xC7-Z\xB6\xEA\xB29\x8B\xEBY\x9E\xEF!2\xFCL\x98\x02\xC7\x98;p'@6\xE0gV\xE9"
pry(main)> crypt = ActiveSupport::MessageEncryptor.new(key)
=> #<ActiveSupport::MessageEncryptor:0x00007fe0bd1540f0
 @aead_mode=true,
 @cipher="aes-256-gcm",
 @options={},
 @rotations=[],
 @secret="\"<\xE7\xC7-Z\xB6\xEA\xB29\x8B\xEBY\x9E\xEF!2\xFCL\x98\x02\xC7\x98;p'@6\xE0gV\xE9",
 @serializer=Marshal,
 @sign_secret=nil,
 @verifier=ActiveSupport::MessageEncryptor::NullVerifier>
pry(main)> encrypted_data = crypt.encrypt_and_sign(10000)
=> "vIhnJ/+b--dVcRCewozcXpKK8c--4UyU9gWcUa46EdjtLBx3vQ=="
pry(main)> crypt.decrypt_and_verify(encrypted_data) 
=> 10000

なぜ使い方が変わったのかと言うと、

これまではkeyに長過ぎる値を渡した場合は勝手に正しい長さに切り取ってくれたが、「想定外の結果(unexpected encryption/decryption results)」を引き起こすとして、正しい長さのkeyを渡さないと例外がraiseされるようになった。

ActiveSupport::MessageEncryptorを慎重に使う – nisshieeのブログ – https://nisshiee.hatenablog.jp/entry/2017/04/17/192703

ということらしい。

そのため以前のコードを実行するとエラーになります。

pry(main)> salery = 10000
=> 10000
pry(main)> secret = SecureRandom.hex(128)
=> "b991b09ab1a52e6649e5f836c4efcb28ec0d7e899e88be7b072cf752bf2194e8d2600dc524748f67d400a831f967a3a7b7c0b64d0af7b19cc2ee92300aa8c028699ca35ed9bb28f45d6238bf37476832e89638c200efb9ef8dccdf368f920d9d4a6ad941402a6c0ebb078e5b80d2ce76e3dafe45c3d44389c9c3633e710afde2"
pry(main)> encryptor = ::ActiveSupport::MessageEncryptor.new(secret)
=> #<ActiveSupport::MessageEncryptor:0x00007fe0c1bd3f68
 @aead_mode=true,
 @cipher="aes-256-gcm",
 @options={},
 @rotations=[],
 @secret=
  "b991b09ab1a52e6649e5f836c4efcb28ec0d7e899e88be7b072cf752bf2194e8d2600dc524748f67d400a831f967a3a7b7c0b64d0af7b19cc2ee92300aa8c028699ca35ed9bb28f45d6238bf37476832e89638c200efb9ef8dccdf368f920d9d4a6ad941402a6c0ebb078e5b80d2ce76e3dafe45c3d44389c9c3633e710afde2",
 @serializer=Marshal,
 @sign_secret=nil,
 @verifier=ActiveSupport::MessageEncryptor::NullVerifier>
pry(main)> encrypt_salery = encryptor.encrypt_and_sign(salery)
ArgumentError: key must be 32 bytes
from /Users/kotamiyake/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.0/lib/active_support/message_encryptor.rb:169:in `key='

 

 

日頃の開発でpumaを使うことが多くなってきたので新たにpuma-devを使い始めることにしました。

puma/puma-dev: A tool to manage rack apps in development with puma
https://github.com/puma/puma-dev

以前使ったことがあったので、とりあえず手元の環境を新しくします。

$ brew upgrade puma-dev
$ puma-dev -uninstall
$ puma-dev -install -d test
$ sudo puma-dev -setup

これでシンボリックリンクを貼り直すだけで.testドメインでアプリを動かすことができるようになりました。

puma-devのREADMEにはHSTSの影響で.testがデフォルトになっていると書かれていたのですが、どうやらhomebrew経由のインストールだと最新版になっていない模様です。

Homebrew pulling older version (default domain is .dev, not .test) · Issue #168 · puma/puma-dev
https://github.com/puma/puma-dev/issues/168

HSTSとはなんぞやというのはWikipediaを参考にしました。

HTTP Strict Transport Security (エイチティーティーピー・ストリクト・トランスポート・セキュリティ、略称 HSTS)とは、WebサーバーがWebブラウザに対して、現在接続しているドメイン(サブドメインを含む場合もある)に対するアクセスにおいて、次回以降HTTPの代わりにHTTPSを使うように伝達するセキュリティ機構である。RFC 6797 で規定されている。

要はセキュリティ上の理由から強制的にHTTPSへリダイレクトする機能らしいです。

その対象に.devドメインも含まれていたので、利便性の理由から.testをデフォルトにしたようです。

$ bin/rails -v
Rails 5.2.0.rc1

/Rails5のアプリケーションでWerckerを使ってCIを利用としたところ、bin/rails testしたところで以下のようなエラーが発生しました。

/usr/local/lib/ruby/site_ruby/2.4.0/bundler/spec_set.rb:88:in `block in materialize': Could not find rake-12.3.0 in any of the sources (Bundler::GemNotFound)

bundle install は成功しているのになぜかrakeが見つからないと言われてしまいます。

wercker.ymlの設定は以下のようになっています。(一部省略)

box: ruby:2.4.3

services:
  - id: mysql
    env:
      MYSQL_ROOT_PASSWORD: root_password
      MYSQL_USER: test_user
      MYSQL_PASSWORD: test_password
      MYSQL_DATABASE: test_database

build:
  steps:
    - install-packages:
      name: Install build-essential
      packages: build-essential

    - script:
      name: install node
      code: |
        curl -sL https://deb.nodesource.com/setup_7.x | sudo bash -
        sudo apt-get install -y nodejs

    - script:
      name: install yarn
      code: |
        curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
        echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
        sudo apt-get update && sudo apt-get install yarn

    - bundle-install:
      jobs: 4

    - script:
      name: install npm packages
      code: bin/yarn

    - rails-database-yml

    - create-file:
        name: write master.key
        filename: ./config/master.key
        overwrite: true
        hide-from-log: true
        content: $WERCKER_MASTER_KEY

    - script:
      name: Set up db
      code: |
        RAILS_ENV=test bin/rails db:schema:load

    - script:
      name: Run Test
      code: bin/rails test

原因はどうやらbinstubsの内容と実際に実行ファイルが置かれている場所が異なるからのようです。

そこでbundle-installステップの後にbinstubsを更新するコマンドを追加します。

    - script:
      name: Update bin
      code: bin/rails app:update:bin

これで無事にテストが実行されるようになりました。

参考

導入メモです。

webpacker 3.2.1時点ではコマンドからstimulusをインストールできないのでmasterブランチを使います。

gem 'webpacker', github: 'rails/webpacker', branch: :master

先にwebpackerをインストールする必要があるので以下のコマンドを叩きます。

$ bundle exec rails webpacker:install

その後stimulusを指定して再度コマンドを実行します。

$ bundle exec rails webpacker:install:stimulus

これでRails上でStimulusが使えるようになります。