20200526のRailsに関する記事は27件です。

[rails] Sorceryを使ってログインしているかによって、表示を変える方法

オリジナルアプリ作成中の者です。
ナビバーをログインしているかによって、表示の切り替えをしたいと考えました。
CSSフレームワークはBootstrapを使用中です。

ナビバー作成中
      <%= link_to "Yes, we can", root_path, id: "logo", class: "navbar-brand" %>
        <ul class="navbar-nav ">
          <% if logged_in? %>
            <li class="nav-item text-center">
              <%= link_to "Log out", logout_path, class: "nav-link" %>
            </li> 
          <% else %>
            <li class="nav-item text-center">
              <%= link_to "Sign up", signup_path, class: "nav-link" %>
            </li>
            <li class="nav-item text-center">
              <%= link_to "Log in", login_path, class: "nav-link" %>
            </li>
          <% end %>   
        </ul>


コード汚くてすいません。
if or elseの分岐で簡単に作れますね。
ポイントとしては、ログインしている場合は、logged_in?を使用すること

ちなみに、以下でもいけるみたいです。

<% if current_user %>

以下のAPI Summaryを参照しました。

Sorcery Github

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LocalStackを使用してS3の投稿をモックする

LocalStack は、AWS のサービスを開発環境において擬似的に使用できるモックフレームワークです。
定番の S3 から Lambda まで多くのサービスを開発環境で体験できます。

本記事では、Railsチュートリアルの SampleApp を使用し、コンテナ環境で実装します。
コンテナの設定は、Railsチュートリアルの開発環境を Docker でもっと便利にしなイカ!? - Qiitaの続きとなります。

今回の実装コードは右の通りです=>(実装コード --github)



□ 既存ファイルの修正

■ 保存場所を S3 に固定

app/uploaders/picture_uploader.rb
class PictureUploader < CarrierWave::Uploader::Base
  ...
- if Rails.env.production?
-   storage :fog
- else
-   storage :file
- end
+ storage :fog
  ...
end

■ carrier_wave の初期設定を変更

config/initializers/carrier_wave.rb
CarrierWave.configure do |config|
  config.fog_credentials = {
    :provider              => 'AWS',
    :region                => ENV['S3_REGION'],
    :aws_access_key_id     => ENV['S3_ACCESS_KEY'],
    :aws_secret_access_key => ENV['S3_SECRET_KEY'],
  }
  config.fog_directory     =  ENV['S3_BUCKET']

  unless Rails.env.production?
    config.fog_credentials.merge!(
      {
        # [app -> localstack] コンテナ間の通信用に設定 ( http://localstack:4566 )
        :endpoint          => ENV['S3_ENDPOINT'],

        # デフォルトだと S3_BUCKET がサブドメインとなり接続できないため true に設定
        :path_style        => true,
      }
    )

    # endpoint がコンテナ間の通信用であるため、ホスト側から画像にアクセスするための URL ( http://localhost:4566 )
    config.asset_host = "#{ENV['S3_ASSET_HOST']}/#{ENV['S3_BUCKET']}"
  end
end

■ 環境変数の設定

./docker-compose.yml
  ...
  app:
    ...
    environment:
      APP_DATABASE_HOST: db
      APP_DATABASE_USERNAME: root
      APP_DATABASE_PASSWORD: pass
+     S3_REGION: ap-northeast-1
+     S3_ACCESS_KEY: dummy
+     S3_SECRET_KEY: dummy
+     S3_BUCKET: microposts
+     S3_ENDPOINT: http://localstack:4566
+     S3_ASSET_HOST: http://localhost:4566
    ...

□ LocalStack の設定

■ コンテナの追加

  • version 0.11.0 からhttp://localhost:4566で全ての endpoint を受け付けている。
  • DATA_DIR: /tmp/localstack/dataにより、投稿画像を永続化する。
  • volumesで設定したディレクトリにスクリプトを配置すると、コンテナ生成時に実行してくれる( スクリプトファイルは後述 )。
./docker-compose.yml
  ...
  smtp:
    ...
+ localstack:
+   image: localstack/localstack
+   ports:
+     - 8080:8080 # dashboard
+     - 4566:4566 # edge port
+   environment:
+     SERVICES: s3
+     AWS_DEFAULT_REGION: ap-northeast-1
+     DATA_DIR: /tmp/localstack/data
+   volumes:
+     - ./docker/localstack/:/docker-entrypoint-initaws.d
  volumes:
    ...

■ コンテナ起動時の初期設定スクリプト

./docker/localstack/setup_s3.sh
awslocal s3 mb s3://microposts

□ テスト投稿

投稿前
スクリーンショット 2020-05-26 22.44.38.png
投稿成功!
スクリーンショット 2020-05-26 22.46.07.png

□ 余談: 投稿画像の永続化

前述の通りDATA_DIRを追加すると、投稿画像の永続化が可能となるが、コンテナ停止再起動でのみ有効となる。
もし、コンテナ削除時でもデータを保持したい場合、次の通り設定すること。

また、この設定の場合、意識的に volume を削除しないとデータ容量が膨れ上がるため、注意する必要あり。

./docker-compose.yml
services:
  datastore:
    image: busybox
    volumes:
      - bundle_install:/usr/local/bundle
      - db_data:/var/lib/postgresql/data
+     - localstack_data:/tmp/localstack
...
  localstack:
    image: localstack/localstack
    ports:
      - 8080:8080 # dashboard
      - 4572:4572 # s3
    environment:
      SERVICES: s3
      AWS_DEFAULT_REGION: ap-northeast-1
+     DATA_DIR: /tmp/localstack/data
    volumes:
      - ./docker/localstack/:/docker-entrypoint-initaws.d
+     - localstack_data:/tmp/localstack
volumes:
  bundle_install:
  db_data:
+ localstack_data:
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rubyがrbenvからではなく、Macにデフォルトでインストールされているバージョンが参照されていた

rbenvとMacのデフォルトでインストールされているRubyのバージョンの相違

作業を開始しようとしていつものようにbin/rails sをしたら以下のようなメッセージが出てきました。

$ bin/rails s
Your Ruby version is 2.6.3, but your Gemfile specified 2.6.5

システムで設定されているRubyのバージョンを確認するとrbenvで2.6.5をインストールしているはずなのに2.6.3になってしまっています。

$ ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [universal.x86_64-darwin19]

念のためrbenvで指定しているバージョンも確認しましたが、2.6.5で間違いはないです。

$ rbenv versions
  system
* 2.6.5 (set by /Users/ユーザー名/desktop/ディレクトリ名/.ruby-version)
  2.7.0

Rubyの参照先を確認する

まずはrbenvのパスが通っているかを確認しますが特には問題なしでした。

$ cat ~/.bash_profile
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"

rubyコマンドの参照先を確認すると以下の通り、 rbenvから参照されていませんでした。bundlerコマンドも同様でした。

$ which ruby
/usr/local/bin/ruby
$ which bundler
/usr/local/bin/bundler

パスが読み込まれる順番も確認しましたが、読み込み順に関しても問題はなかったです。
そこで、そもそもrbenvのインストール状況側に問題があるのではないかと推測しました。まずはrbenvのshimsを確認しました。
shimsはrbenvの実行可能なirb, gem, rake, rails, rubyなどのコマンドを管理するファイルです。

$ ls -l ~/.rbenv/shims
# 空でした。

なんと中身が空だったため、rbenvで管理しているバージョンのRubyが参照されていなかたっということでした。
何かしらの拍子に削除されてしまったのでしょうか...

別のバージョンのRubyをインストールする

当初は特に別のバージョンのRubyをインストールするつもりはなかったのでshimsに一通りのコマンドを追加するためrbenvの機能であるrehashを実行。

$ rbenv rehash

# コマンドが追加されているかshimsを確認
$ ls -l ~/.rbenv/shims
# 空のまま...

rehashを実行してもコマンドが追加されませんでした。色々と調査をしましたが、原因解明には至らず。
この際なのでこの記事を執筆した2020年5月26日現在の安定版であるRuby2.6.6をインストールすることにしました。

Ruby2.6.6をインストール

$ rbenv install 2.6.6
$ rbenv rehash  # コマンドを追加する
$ rbenv global 2.6.6 # システム全体で使用するバージョンを指定

rubyコマンドの参照先を確認

$ which ruby
/Users/ユーザー名/.rbenv/shims/ruby
$ which bundler
/Users/kawafujimasashi/.rbenv/shims/bundler # 念のためbundlerも確認。問題なく追加されていました。

RailsにインストールしたバージョンのRubyを適用する

# Railsアプリケーションの一番上の階層のディレクトリに移動
$ rbenv local 2.6.6 # 使用するRubyのバージョンを明記している.ruby-versionファイルを書き換えます
$ bundle install

そしてbin/rails sを実行するとrbenvで指定したバージョンが参照され動いてくれました。

参考にしたページ

公式のgithubのREADME
https://github.com/rbenv/rbenv#how-rbenv-hooks-into-your-shell

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

オレオレRailsコーディング規約

あらすじ

吾輩は末端エンジニアである。名前はしょった。先日、急遽あるRailsアプリの引継ぎ業務に駆り出された。吾輩はここで始めて、アプリのコードを見た。しかもこのコードがまあ追いにくい。ブチギレ寸前である。

アンチパターン

というわけで本題。
先に、ここでいうアンチパターンというのは一般に流布されているそれとはまるで別物であるということは先にお伝えしておきたい。あくまで個人的なベストプラクティスであり、引き継ぐならこうなっていて欲しい(欲しかった)という願望である。

1. 1つのディレクトリ内に無駄にファイルが多い

今、ここに猫がいる。猫はかわいい。神は猫をこう定義した。

./cat.rb
class Cat
  def comment
    puts "かわいい"
  end
end

猫は猫耳と尻尾、そして肉球でできている。
神はかわいい猫をこう定義し直した。

./cat.rb
class Cat
  def initialize
    @parts  = [
      CatEar.new,
      CatTail.new,
      CatPad.new
    ]
  end

  def comment
    @parts.each(&:comment)
  end
end
./cat_ear.rb
class CatEar
  def comment
    puts "猫耳かわいい"
  end
end
./cat_tail.rb
class CatTail
  def comment
    puts "しっぽもかわいい"
  end
end
./cat_pad.rb
class CatPad
  def comment
    puts "肉球までかわいい"
  end
end

これで猫を無限に愛することができる...猫はかわいい...猫は至高....................

...

......

うるせーーーーーーーーーーーーーーーーーーーーーーーーーー!!!!!!!!!!!!
猫を愛でる前にコードを片付けやがれ!!!!!!!!!

というわけで茶番はさておき解説。
現在のディレクトリ構造を図示するとこうだ。

./
├ cat.rb
├ cat_ear.rb
├ cat_tail.rb
└ cat_pad.rb

猫クラスと猫のパーツが同居した状態で入れられている。
例えば、ここに犬が増えたらどうなるだろうか。

./
├ cat.rb
├ cat_ear.rb
├ cat_tail.rb
├ cat_pad.rb
├ dog.rb
├ dog_ear.rb
├ dog_tail.rb
└ dog_pad.rb

ファイルの数がめっちゃ増えた...
さらにここに他の動物を加えていくと...まあ地獄が生まれるのは容易に想像できる。

じゃあどうするのか。そう、パーツをCatクラス以下に作るのである。

./cat.rb
class Cat
  def initialize
    @parts  = [
      Ear.new,
      Tail.new,
      Pad.new
    ]
  end

  def comment
    @parts.each(&:comment)
  end
end
./cat/ear.rb
# rails的にディレクトリ内に入ってるものはモジュールまたはクラスでネームスペースを切るのが一般的
class Cat
  class Ear
    def comment
      puts "猫耳かわいい"
    end
  end
end
./cat/tail.rb
class Cat
  class Tail
    def comment
      puts "しっぽもかわいい"
    end
  end
end
./cat/pad.rb
class Cat
  class Pad
    def comment
      puts "肉球までかわいい"
    end
  end
end

となる。
ディレクトリ構造は

./
├ cat.rb
└ cat
  ├ ear.rb
  ├ tail.rb
  └ pad.rb

これによって、それぞれのパーツはcatに関わるものとしてまとめられた。
カレントディレクトリから見ると、catの定義とcatに関わるものが入ってるのみなので、このアプリは猫に関するものだということが0秒でわかる。素晴らしい。

と、少々無茶な説明をしたが、ちょっと具体的に。
今あなたはネットバンキングのサービスを作っている。このサービスでは以下の3つのモデルがある。

  • 口座の情報
  • 口座の入出金履歴
  • 口座の振込履歴

あなたはこれをこういう構造で実装するかもしれない。

./
├ account.rb
├ money_history.rb
└ deposited_history.rb

これでも十分、客先には喜ばれ、上司には褒められるだろう。
ただ、ここでこれを

./
├ account.rb
└ account
  ├ money_history.rb
  └ deposited_history.rb

としておくだけで、ベターになれる。きっとあなたのプロジェクトに後から入ってきたメンバーにも尊敬されるだろうし、末代まで感謝され続けるだろう。私ならこうして欲しい。こうして欲しかった。ただそれだけのことである。

2. オブジェクトのインターフェースが整っていない

rubyはオブジェクト指向言語である。オブジェクト指向は素晴らしい。オブジェクトから枝葉のように伸びるメソッドを叩くことで、やりたいことが完結できるからである。

例えるならこうだ。
ここにボルトがある。

オブジェクト指向でない場合、このボルトを締めるにはレンチが必要だ。

だがしかし、オブジェクト指向だと...

STARTボタンを押すだけレンチが勝手にボルトを締めてくれる。素晴らしい...素晴らしい......

ではこれがアンチパターンになる場合をお見せしよう。
あなたは洗濯機を作った。次のような実装である。

washing_machine.rb
class WashingMachine
  def wash
    locking do
      inject_water
      ventilate
    end
  end

  def dry
    locking do 
      ventilate
    end
  end

  def locking
    puts "扉をロックします"
    yield
    puts "扉のロックを解除します"
  end

  def inject_water
    puts "注水します"
  end

  def ventilate
    puts "風を送ります"
  end
end
> WashingMachine.new.wash
扉をロックします
注水します
扉のロックを解除します

この実装の問題は、以下のようなことが容易に起こり得ることにある。

> WashingMachine.new.inject_water
注水します

いや、扉閉まってないやん!!!

まあこれは初歩的な話なので、 今更かよ...って話だけど。
オブジェクト指向で大事なことはただ一つ、インターフェースを整えることだ。インターフェースが整ってないオブジェクトは洗練されていないリモコンと同じ。使い勝手は手続き型言語より悪いだろう。
オブジェクトのインターフェースの整える指針は以下

  • メソッド名、必要な引数はわかりやすくする
  • 必要なメソッド以外は隠す

先ほどの実装であれば、

washing_machine.rb
class WashingMachine
  def wash
    ...
  end

  def dry
    ...
  end

  private # これ以降は内部仕様なので隠す
  def locking
    ...
  end

  def inject_water
    ...
  end

  def ventilate
    ...
  end
end

こうしておけば先ほどのような操作ミスは起きないだろう。

余談だが、同じようなミスを実はRoRでもやってたりする。

ActiveRecord::Base#read_attribute(attr_name) というメソッドがある。
このメソッドは、「sqlが取ってきた[attr_name]の値をそのまま取得する」という挙動をし、任意のゲッターを作るときに利用価値がある。痒いところに手がとどくメソッドである。
ただこのメソッドは公開範囲がpublicなのが問題だ。例えば、以下のような実装ができる。

models/hoge.rb
class Hoge < ApplicationRecord
  attribute :name, :string
end
views/hoge.html.erb
<%= Hoge.first.read_attribute(:name) %>

この実装は問題なく動くだろう。
ここで、nameカラムが null または空文字だった場合、 'Unknown' と返そう、となった。

models/hoge.rb
class Hoge < ApplicationRecord
  def name
    read_attribute(:name).presence || 'unknown'
  end
end

すると views/hoge.html.erb をレンダリングしたときにnameが null だった場合、出力される値はnilである。いや、viewではnameを表示してるはずなのに???なんで???ってなる。なった。は〜〜〜〜〜〜〜〜〜????????

read_attributeメソッドは本来であればprotectedメソッドであるべきだ。なぜなら、クラスの外から使うにはあまりに抽象的すぎるからである。また、クラス外で使うとプロジェクト内検索では見落としやすくなるだろうし、変更にも弱い。マジで使うべきではない。

ちなみに、この話は実際に弊社の尊敬する先輩がやってたコードである。先輩のことは恨んでいない。RoRのことは恨んでいる。許さん。

3. ロジックが分散している

washing_machine.rb
class WashingMachine < Machine
  include DoorModule
  include ValveModule
  include AirValveModule

  def run
    locking do
      inject_water
      ventilate
    end
  end
end

このクラスを見せられたときに、送風する部分を改修して欲しいと言われたら、あなたはどこを探すだろうか?
送風(ventilate)ということは、ventilateメソッドを探せばいいのだろう。ということはventilateメソッドを定義している場所を探せばいいのだな。と、ここであなたはventilateでプロジェクト内を検索するだろう。

ここでの問題は、ventilateというメソッドがどこからきたのか、このクラスだけでは判断しにくいことだ。基本的にinclude、excludeに加えて、継承にも言えることだが、それらを経由してやってきたメソッドは往往にして所在が不明瞭になりやすい。
使わないべきとは言わないまでも、ある程度の制限を設けて、コードの可読性を損なわないように使って欲しい(願望

例えば上記の場合、モジュールを使わない代わりに以下のようにするのをオススメしたい。

washing_machine.rb
class WashingMachine < Machine
  def initialize
    @door = Door.new
    @valve = Valve.new
    @air_valve = AirValve.new
  end

  def run
    @door.locking do
      @valve.inject_water
      @air_valve.ventilate
    end
  end

  # delegate :locking, to: :@door
  # などでメソッド定義しても良い。とにかくメソッドの定義がファイル内に明示されていることが重要
end

これだけで、 AirValve#ventilate を見ればいいことは一目瞭然である。勝ち申した。

本当はこれが一番書きたいところなんだけど、疲れたのでこの辺で。

おわりに

つらつらと好き勝手述べてきたが、結局言いたいのはもっとコードの可読性を上げてくれ、というところだ。
どの言語でも言えることだけど、キャメルがどうだ〜、if文の後ろの中括弧はこうだ〜みたいなどっちでもいい議論は聞くけれど、こういう構造で書いた方がいいよ!とか、こうすれば管理がしやすいよ!みたいな議論の方が大事だと思うので、もっと盛んに議論してください。よろしくおねがいします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

renderとredirect_toの違い

render

次に指定するViewを指定している。
今持っている変数で新しいViewを構成する。

redirect_to

次に指定するメソッドを指定している。
ブラウザに対して指定したメソッドにHTTPリクエストを送るように司令する。
リクエストが二回発生してしまうことになる。

例:
=> GET home/show
<= redirect_to
=> Get home/index
<= home/index

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで右クリックした場所にメニューを表示してみたい。

Reactで右クリックメニュー

先日書いたReact+Railsで付箋っぽいアプリを作る中で、右クリックメニューを作ってみたいなぁ、と、調べた内容と実装方法を書いていきたいと思います。

oncontextmenuのイベントハンドラを作れば良い

JavaScriptのドキュメントによれば、oncontextmenuイベントをハンドルすることで、独自のメニューが作れそうです。

実装方針

ここでは、以下の方針で実装してみることにしました。

  1. 右クリックした場所にメニューを表示する。
  2. メニューの要素は、Reactのコンポーネントとして作ってみる。
  3. 環境は、React+Railsで付箋っぽいアプリを作るの環境をそのまま使いましたが、多分Reactの動く環境なら、どこでも大丈夫。

実装だー

作るのは、以下の3つです。
1. メニューを表示するコンポーネント(menu.js)
1. 右クリックを受け付ける親コンポーネント(parent.js)
1. スタイルシート(parent.css)

メニューを表示するコンポーネント

適当なメニューと"close"が選べるポップアップを表示させます。

menu.js
import React from 'react';
import PropTypes from 'prop-types';

class Menu extends React.Component {
  // コンストラクタ
  constructor(props){
    super(props);        // おまじないですね。

    // メニューを表示するdiv要素を参照するための変数です。
    this.menuElm = null;

    // イベントハンドラのバインド
    this.onCloseButtonClick = this.onCloseButtonClick.bind(this);
    this.onMenuItemClick = this.onMenuItemClick.bind(this);
    this.onKeyUp = this.onKeyUp.bind(this);

    // メニューに表示するアイテムの配列です。(適当すぎてやばい。)
    this.messages = ["You", "are", "incredible"];

  }

  // メニュー要素を表示(visibility="visible")します。
  // 親要素から、呼び出されるメソッドです。
  show(clientX, clientY){
    // 以下のようにstyle.top,style.leftを指定することで、好きな場所にメニューを表示できます。
    this.menuElm.style.top = clientY + "px";
    this.menuElm.style.left = clientX + "px";
    this.menuElm.style.visibility = "visible";

    // 表示したらフォーカスを割り当てます。
    // (これで、keyイベントを受け付けてくれるようになります。)
    this.menuElm.focus();
  }

  // メニューを閉じ(visibility="hidden"に変更し)ます。
  close(){
    this.menuElm.style.visibility = "hidden";
  }

  // "close"クリック時のイベントハンドラ
  onCloseButtonClick() {
    // close()を呼び出します。
    this.close();
  }

  // メニューアイテムクリック時のイベントハンドラ
  onMenuItemClick(event) {
    // 自分自身を閉じて
    this.close();

    // 親要素から渡されたコールバック関数を呼び出します。
    // 引数には、アイテムに表示されているテキストを渡します。
    this.props.onMenuItemClick(event.target.innerHTML);
  }

  // エスケープキーで閉じるためのイベントハンドラです。
  onKeyUp(event) {
    event.preventDefault();
    // 文字列で比較できるとは思いませんでした。。
    if ("Escape" == event.key) {
      this.close();
    }
  }

  // レンダラー
  render(){
    return (
      <React.Fragment>
        { /* refで要素を参照することで、styleの変更ができるようになります。 */ }
        <div className="MenuBox" ref={(node) => this.menuElm = node} onKeyUp={this.onKeyUp} tabIndex="0" >
          {
            this.messages.map((message) => 
              <div className="MenuItem" onClick={this.onMenuItemClick} key={ message }>{ message }</div> 
            )
          }
          <div className="MenuItem" onClick={this.onCloseButtonClick}>Close</div>
        </div>
      </React.Fragment>
    );
  }
}

Menu.propTypes = {
  onMenuItemClick: PropTypes.func
};

export default Menu;

右クリックを受け付ける親コンポーネント

自身の領域が右クリックされたら、メニューを表示させます。
さらに、メニューで選択された内容に応じて、自身の表示内容を変化させてみました。

parent.js
import React from 'react';
import Menu from './menu';

class ParentPage extends React.Component {
  // コンストラクタ
  constructor(props){
    super(props);

    // stateを初期化
    this.state = {message: "Please Click Anywhere you like."}

    // メニュー要素への参照を初期化(後ほどレンダラーの中でrefを割り当てます。)
    this.menu = null;

    // イベントハンドラのバインド
    this.onContextMenu = this.onContextMenu.bind(this);
    this.onMenuItemClick = this.onMenuItemClick.bind(this);
  }

  // 右クリックイベントハンドラ
  onContextMenu(event) {
    // preventDefault()を忘れると、普通の右クリックメニューが表示されますよ。
    event.preventDefault();

    // メニュー要素の"show()"メソッドを呼び出します。
    // 引数にはマウスポインタの位置情報を渡してあげます。
    this.menu.show(event.clientX, event.clientY);
  }

  // 右クリックメニューでメニューが選択された際にコールバックしてもらうメソッドです。
  // 選択されたメニューの内容(innnerHTML)をstateに設定しています。
  // (これにより、画面左上のメッセージが切り替わるはず。)
  onMenuItemClick(message) {
    this.setState({message: message});
  }

  // レンダラー
  render(){
    return (
      <React.Fragment>
        { /* 自身の右クリックイベントハンドラをonContextMenu=で指定 */ }
        <div className="ParentBox" onContextMenu={this.onContextMenu} >
         { this.state.message }
         { /* コンポーネントもrefで参照できるので、子要素のメソッドを呼び出すことが可能になります。 */ }
         <Menu onMenuItemClick={this.onMenuItemClick} ref={(node) => this.menu = node} />
        </div>
      </React.Fragment>
    );
  }
}

スタイルシート

ブラウザのUIは、結局のところスタイルシートで「それっぽく見せている」だけなんですね。
何かの本にこんなことが書いてありました。
「コンピュータ使って実現しているものっていうのは、『そのように見える』だけのハリボテに過ぎない」
まさしくその通りだなぁ、と、改めて思うのでした。

parent.scss
// 親要素のスタイル
div.ParentBox {
  font-weight: bold;
  position: relative;
  width: 100vw;
  height: 100vh;
  background-color: #FFFFFF;
  border: 1px solid #000000;
}

// メニュー要素のスタイル
// position: absoluteにしないと、指定した場所に表示できないのでご注意を。
div.MenuBox {
  position: absolute;
  margin: 0px;
  padding: 5px;
  font-size: 10px;
  font-weight: thin;
  width: 100px;
  background-color: #888888;
  color: #000000;
  visibility: hidden;
  border-radius: 5px;
}

// focusをあてたときに周りが光らないようにしました。(気分の問題)
div.MenuBox:focus {
  outline: none;
}

// 各メニューにマウスポインタが乗っかった時のスタイルです。
div.MenuItem:hover {
  cursor: pointer;
  background-color: #E0E0FF;
}

実験

殺風景ですが、クリックしたところにメニューが表示される感じが実現できたはずです。
メニューを選択すると、メニューに表示されていた文字列が、そのまま親ページの右肩に表示されると思います。
Escapeボタンでも、"Close"のリンクでもメニューを消すことができます。
スクリーンショット 2020-05-26 20.44.32.png

補足

今回の内容については、DBが関係ないので、Railsの環境は正直不要で、Reactの環境だけあれば実験できます。
npmとwebpackだけで動作させた際のwebpack.config.jsの内容と、ちょっとしたソースの修正内容を記載しておきます。

ディレクトリ構成

特段特別なものはありません。
publicにindex.htmlを置いて、src配下にスクリプトとスタイルシートを置きました。

shell
app
├── node_modules(配下は割愛)
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── src
│   ├── menu.js
│   ├── parent.js
│   └── parent.scss
└── webpack.config.js

webpack.config.js

app/webpack.config.js
module.exports = {
  mode: "development",
  entry: {
    app: "./src/parent.js"
  },
  output: {
    path: __dirname + '/public/js',
    filename: "[name].js"
  },
  devServer: {
    contentBase: __dirname + '/public',
    port: 8080,
    publicPath: '/js/'
  },
  devtool: "#inline-source-map",
  module: {
    rules: [{
      test: /\.js$/,
      enforce: "pre",
      exclude: /node_modules/,
      loader: "eslint-loader"
    },{
      test: /\.css$/,
      loader: ["style-loader","css-loader"]
    },{
      test: /\.scss$/,
      loader: ["style-loader","css-loader","sass-loader"]
    },{
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader'
    }]
  }
};

public/index.html

ブラウザからアクセスするためのトップページです。

public/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1,shrink-to-fit=no" />
    <meta http-equiv="X-UA-Compatible" content="IE=Edge, chrome=1" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/javascript" src="js/app.js" charset="utf-8"></script>
  </body>
</html>

src/parent.js

最後に、メインエントリポイントになるparent.jsの修正部分を書いておきます。
明示的にimportしないとスタイルが読み込まれなかったので、その対処と、
ReactDom.renderの呼び出しを追加しました。

src/parent.js
// 1,2はファイルの先頭(import文の並び)に追加
// 1. ReactDomの追加
import ReactDom from 'react-dom';

// 2. スタイルの参照を追加
import './parent.scss';

// : (中略)

// 3はファイルの最後に追加
// 3. レンダラーの呼び出し
ReactDom.render(
  <ParentPage />,
  document.getElementById('root')
);

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

100日後に1人前になる新人エンジニア(6日目)

100日後に1人前になる新人エンジニア(6日目)

どうもこんばんは。今日のお題はテストです。
Railsってテストがいくつかあってそれらの特徴についてまとめてみたいと思います。

今日取り上げるテストは2つです。
● Minitest
● RSpec
の2つです。私はRails Tutorial と プロを目指す人のためのRubyという書籍を読んでいたのですが、これらで取り上げられていたのはどちらもMinitestでした。

しかしながら実際の私の業務ではRSpecを使用するため
双方の特徴を知りたいなと思い本日まとめたいと思います。

Minitest

minitestはRubyと一緒にインストールしてありますので、特にセットアップなどは必要なく、またRuby on Railsにおいてもデフォルトで入っているテストフレームワークであります。とのこと。
実際requireでMinitestを記述するだけで使える様になります(簡単!)

その他の特徴としては
● RSpecよりもロード時間が短い
● 必要最低限の機能を備えている(プラグインで追加する)
● Rubyの文法がわかれば習得が用意
● 検証メソッドは assert_equal A,B
● クラス名やメソッド名の重複に注意が必要
● 凝ったコードを書くのにはあまり向いていない(解析が難しい)
● RSpecよりは早い

以上の様な特徴が挙げられるみたいです。
確かに勉強してみたけどすんなり理解できた感じはあったかも
syntaxがRubyと同じだからだったのかな...

RSpec

RSpecはドメイン特化言語 (DSL)を使ったフレームワークです。
つまりテスト専用のプログラミング言語ってことです。
その他の特徴としては

● DSLを覚える必要あり(これは上でも言っていますね。)
● デフォルトの機能が多い
● 検証メソッドはexpect(B).to eq A または B.must_equal A
● テストの命名が比較的自由
● 保守性が高い
● Minitestに比べると遅い

また1から勉強するのはちょっとつらい...笑
けど色々とメリットが存在するんですね

どっちがいいの

これに答えはないと思いますが(ないんかい)
それぞれを比較してみると現場ではRSpecの方が多く使用されているみたいです。
RSpecの方が多機能で便利なのが現場で選ばれてる理由の一つなんですかね。

なるほどねー。ってんじでした。
実務でこれを使ってるのでとりあえず勉強しなきゃってモチベーションより
なんでこのテストフレームワークを使ってるんだろうって考えて
そこから勉強した方が楽しんで勉強できるんじゃないかと思っています。

ということでRSpecの勉強も初めていきたいと思います。
またRSpecの記事を書くかもしれません。

今日のところは以上です。

1人前のエンジニアになるまであと94日

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

herokuでデプロイした後の更新の仕方(2回目以降)

前提 : 変更分をGitHubデスクトップでコミット、プッシュしておく。

① herokuにログイン

ターミナル
$heroku login

② herokuにデプロイ

ターミナル
$git push heroku master

③ herokuでのマイグレーション

ターミナル
$heroku run rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

herokuをでデプロイした後の更新の仕方

$git add -a
$git commit -m "Update application"
$git push heroku master

テーブル追加、内容変更をしたとき

$heroku run rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

herokuでデプロイした後の更新の仕方

$git add -a
$git commit -m "Update application"
$git push heroku master

テーブル追加、内容変更をしたとき

$heroku run rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails db:〇〇 まとめ

rails db:migrate

DBにmigrationファイルの実行
自動的に db:schema:dump も実行され、schema.rbも更新される

rails db:rollback

migrationファイルの実行前にDBを戻す

rails db:schema:load

schema.rbの内容を現在参照しているDBに適用
空のDBにロードする時に使う

rails db:schema:dump

DBをschema.rbに反映

rails db:create

DBの作成

rails db:drop

DBの削除

rails db:reset

下記の省略
rails db:drop
rails db:create
rails db:schema:load
スキーマファイルだけを利用

rails db:migrate:reset

下記の省略
rails db:drop
rails db:create
rails db:schema:load
マイグレーションファイルを直接利用

rails db:seed

テーブルに初期データが追加

rails db:migrate:reset db:seed

データベースの削除&作成、テーブルに初期データが追加

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】ローカルのファイルをリモートロポジトリに上げない為のコマンド

環境

Mac OS
Rails5.2.4

ローカルで使用しているファイルをリモートリポジトリ(例:Github等)に上げない様にする方法

terminal
touch .gitignore

.gitignoreという名前のファイルをtouchコマンドで作成。

作成場所は一番先頭のディレクトリー(Gemfileが置いてあるディレクトリ)にする。
このファイルに、リモートリポジトリには上げたくないファイルのパスを記載。
(画像を保存しているフォルダやパスワード等個人情報が記載されているファイルなどが対象となるかと
思います。)

すでにリモートリポジトリにpushしてしまったファイルをリモートリポジトリから削除・取り消したい場合

terminal
git rm -r --cached 【削除したいファイル名】

これで指定したファイルが、リモートから消えているはずです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】ユーザーに「トプ画」の情報を付与する【devise使用】

はじめに

Ruby on Railsでユーザーに「トプ画」の情報を追加する方法を記事にしました。

ソースコードはこちらのGitHubに載せておきます。

前提

  • ユーザーログイン機能にはgemの「devise」を使用
  • Userモデルの作成は本記事の対象外です。ユーザーモデルがある前提で話を進めます
  • 本記事で扱うUserモデルの追加カラムは「username」「profile」「image」の3つです。

開発環境

内容 バージョン
Ruby 2.5.1
Ruby on Rails 5.2.4.2
bundler 2.1.4
MySQL 5.6.47

手順❶:gem導入

Gemfile
gem 'carrierwave'

「carrierwave」を導入したらbundle installをしましょう。

$ bundle install

手順❷:ファイル生成 & マイグレーション操作

次に以下コマンドをターミナルに打ち込みます。

$ rails g uploader image

すると以下のように表示されるかと思います。

Running via Spring preloader in process 97158
      create  app/uploaders/image_uploader.rb

こうなっていれば成功です。

次にユーザーにトプ画の情報を追加していきます。

$ rails g migration AddImageToUsers image:string

すると以下のファイルが生成されるので「rails db:migrate」を実行。

Running via Spring preloader in process 97297
      invoke  active_record
      create    db/migrate/20200526085656_add_image_to_users.rb
$ rails db:migrate

手順❸:Userモデル編集

user.rbに以下の記述を加えます。

user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

         # 以下を追加する
         mount_uploader :image, ImageUploader
end

手順❹:コントローラの編集

次にトプ画情報をMySQLに保存するための記述をしていきます。

application_controller.rb
class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  def after_sign_in_path_for(resource)
    "/posts/"
  end

  protected

  def configure_permitted_parameters
    # 下記に「image」を追加する。ユーザー用のコントローラーがある人はそちらに記述してもOKです。
    devise_parameter_sanitizer.permit(:sign_up, keys: [:username,:profile,:image])
  end

end

手順❹:viewファイルの編集

では最後にviewファイルを編集していきます。

まずは格納用です。

registrations/new.html.erb
# 好きな箇所(変数が使える範囲内)に以下記述を加えてください。
<div class="field">
  <%= f.label :image %><br />
  <%= f.file_field :image %>
</div>

次に出力用です。

posts/index.html.erb
# トプ画を表示したいビューに以下記述を加えてください。current_userというヘルパーメソッドを使うので、どこのビューでも表示されるかと思います。
<%= image_tag current_user.image.url ,size:'100x100',style:"border-radius:50%;"%>

これでうまくいくはずです。

最後に

今回は基本的な機能だけ実装しました。時間を見つけ次第色々改善していきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsプロジェクトにCircleCIを導入したがGemキャッシュが効いていない時の対応方法

Railsプロジェクトで使用するCICDツールとしてCircleCIを導入・ドキュメントを参考にして設定ファイルを書いていましたが、キャッシュが効いていない動きを見せていました。

▼キャッシュが効いておらず、毎回bundle installをしているような動き
スクリーンショット 2020-05-26 17.03.54.png

▶︎この時点のジョブログ
▶︎この時点の設定ファイル

以下、CircleCIでbundle installのキャッシュが効いていない時の対処方法についてです。

解消方法

  • bundle config set --local pathコマンドで、Gemインストール先指定
  • 今までのキャッシュを使用しないよう設定ファイルを書き換え

下記をbundle installの前に追記して、対応しました。

command: bundle config set --local path 'vendor/bundle'

Githubでも見れるようにしてあります。

  • キャッシュの対象をvender/bundleに指定しているが、そこにGemが見つからず毎回インストールが走ってしまっている(キャッシュが効いていない)
  • bundle installコマンドはグローバルにGemをインストールする、プロジェクトローカルにGemたちをインストールしたいときは--path指定を入れる

以上から、CircleCI上でキャッシュがあるかどうかを確認しに行く先であるvender/bundleにGemたちがインストールされるようbundle config set --local path 'vendor/bundle'を追記。

ここまでの対応でキャッシュは効くかなと思いましたが、効きませんでした。理由としては、CircleCIの方で「すでにキャッシュファイルあるので、キャッシュ保存スキップしますわ」となってしまうから。

▼キャッシュ保存がスキップされる様子
スクリーンショット 2020-05-26 17.24.21.png

▶︎この時点のCircleCIログ

キャッシュ保存が実行されるよう、今までのキャッシュを使用しないように設定ファイルを書き換える対応をしました。

▼修正箇所
スクリーンショット 2020-05-26 17.29.16.png
p
▶︎この時点のコミット


以上の対応を行った結果、キャッシュはちゃんと効くようになりました。

▼キャッシュが効いている様子
スクリーンショット 2020-05-26 17.31.26.png

▶︎この時点のCircleCIログ


参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[rails]新規登録ページ作成で苦労した点

チームでの共同開発にあたり、
新規登録フォームを担当したため
振り返りまとめです。

主な使用技術

  • devise
  • active_hash
  • ウィザード形式

工夫した点

  • ウィザード形式を取り入れており、より多くの情報を見やすく入力

-都道府県の入力は「active_hash」を実装しており、入力をしなくても選択式となるように工夫

苦労した点

  • フロント実装部分 最も苦労しました。一見、簡単そうに見えますが以下の項目は時間かかりました、、、
    • 生年月日フォームの実装
    • フォームの大きさ
    • ▼のアイコンの設置(これも生年月日)

これに関しては、下記記事へフォームの記述を記載しました
https://qiita.com/ki-ku/items/040d84626f864ea2640e

  • バリデーション
    正規表現を用いたバリデーションの設定は苦労しました。
     - メールアドレスは@とドメインを含む必要がある
     - ユーザー本名は全角で入力させる

  • 量が多い
    正直、もっと簡単に終わるかなと思っていたのですが
    ウィザード形式にしたのもあり、作成したビューはログインページ含めて4ページ、
    また単体テストもusersモデルとaddressモデルを行う必要がありました。

結果として、どのアプリでも使うものになりますので
とても勉強になりましたが、予定よりも時間をつかてしまったため、
他のチームメンバーには大変助けて頂きました!

とっても感謝です!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[rails]過去にフォークしたレポジトリを最新にしたい

具体的な方法は下記漫画を参考にしました。

https://next.rikunabi.com/journal/20180322_t12_iq/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails チュートリアルを分解してみる

やったこと

  • Railsチュートリアルを最初から読んで、最終的に作るものを把握する
  • 最終成果物を作成する過程をissues として自分で切り出した

なぜやったか

  • 実践では何を作るかが最初に提示されるから
  • 作成する対象を分解する必要があるから
  • 最初に全体を分割して把握することで進捗を見えるようにしたいから
  • issue 毎にタスクをさらに細分化する練習が必要だから

まずは最小限の機能が作りたいので、途中のch9-ch12 までは飛ばした。余裕があればアサインしたissue にToDoを整理し追加した上で、テストを書いてから実装するほうが良い。

次にやること

  • ローカルに開発環境を作る

issue 切り出すだけですごい時間がかかった。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[rails]共同開発で苦労した点

苦労した点のメモ書き。

margeができない

プルリクエストも作成できており、削除もできるのに
margeボタンだけなかった。

【原因】
共有するための「collaborators」が、うまくできていなかった。

参考:
Githubのプロジェクト(オリジナル・リポジトリ)を複数人で共有する(Collaborators権限)

コンフリクトに戸惑う

一番初めは何をどうすればいいのかわからない。
開発が進むと、コンフリクトは起きているのにボタンが押せない、、、

対応策は下記に記載(ローカルでマスターを取り込む事で解決)
https://qiita.com/ki-ku/items/1416795cdaac7b7545ae

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[rails]共同開発で苦労した点:github編

苦労した点のメモ書き。

margeができない

プルリクエストも作成できており、削除もできるのに
margeボタンだけなかった。

【原因】
共有するための「collaborators」が、うまくできていなかった。

参考:
Githubのプロジェクト(オリジナル・リポジトリ)を複数人で共有する(Collaborators権限)

コンフリクトに戸惑う

一番初めは何をどうすればいいのかわからない。
開発が進むと、コンフリクトは起きているのにボタンが押せない、、、

対応策は下記に記載(ローカルでマスターを取り込む事で解決)
https://qiita.com/ki-ku/items/1416795cdaac7b7545ae

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerで構築したRailsアプリをGitHub Actionsで高速にCIする為のプラクティス(Rails 6 API編)

Rails on GitHub Actions(或いは {Django,Laravel} on GitHub Actions)のCI事例として、

  • ホストランナー上にRuby(Python, PHP)をセットアップ
  • MySQLやRedisはサービスコンテナで立ち上げ
  • 依存ライブラリのインストール(bundle install) や ユニットテスト(rspec) もホストランナー上で直接実行

という事例は多く見かけるのですが、開発をDockerベースで行っていて、GitHub ActionsのCI Pipelineも同じくDockerベースで構築したい...というケースの事例があまり見当たらなかったので、自分が関わったプロジェクト(Rails 6 API mode)での事例を紹介します。

Requirements

  • 開発環境は全てDocker(Dockerfile/docker-compose.yml)で構築&管理したい
    • bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
  • Dockerイメージのサイズを小さくしたい
    • Dockerイメージ自体でファイルを抱え込むような処理はなるべく書かない。実行したいコマンドはコンテナ起動時に都度コマンドとして付与し、成果物を保存・永続化したければvolumesを活用する
    • bundle install, rails db:prepareを実行して環境をセットアップする為のコンテナ」「rails sする為のコンテナ」という具合に用途でコンテナを分ける "1イメージ : Nコンテナ" 想定で
  • GitHub ActionsでのCIも開発環境と同じ Dockerfile/docker-compose.yml をそのまま活用してセットアップしたい
    • CIでも bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
    • 「CI用のDockerfile」とか「CI時だけoverrideする為のdocker-compose.yml」は極力作りたくない
  • GitHub ActionsではDockerイメージや依存ライブラリのキャッシュを有効活用してCIを高速化したい

Version

  • Ruby 2.7.1
    • gem 3.1.3
    • bundler 2.1.4
  • Rails 6.0.3 (API mode)
  • Docker (Docker for Mac)
    • Engine 19.03.8
    • Compose 1.25.5
  • MySQL 8.0.20

Dockerfile

./Dockerfile
# このDockerfileとdocker-compose.ymlの書き方については、
# TechRachoさんの記事 https://techracho.bpsinc.jp/hachi8833/2019_09_06/79035
# で紹介されていた手法をベースにしています。(感謝)

ARG ARG_RUBY_VERSION

FROM ruby:${ARG_RUBY_VERSION}-alpine3.11

# hadolint ignore=DL3008,DL3018
RUN apk update && \
  apk add --update --no-cache \
    build-base \
    bash \
    curl \
    git \
    less \
    tzdata \
    mysql-client \
    mysql-dev && \
  rm -rf /var/cache/apk/*

SHELL ["/bin/bash", "-eo", "pipefail", "-c"]

ARG ARG_TZ=Asia/Tokyo
RUN cp /usr/share/zoneinfo/${ARG_TZ} /etc/localtime

ARG ARG_GEM_HOME=/bundle
ENV GEM_HOME=${ARG_GEM_HOME}

ENV BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3 \
  BUNDLE_PATH=${GEM_HOME} \
  BUNDLE_BIN=${GEM_HOME}/bin \
  BUNDLE_APP_CONFIG=${GEM_HOME} \
  LANG=C.UTF-8 \
  LC_ALL=C.UTF-8

ARG ARG_BUNDLER_VERSION="2.1.4"
RUN gem update --system && \
    gem install bundler:${ARG_BUNDLER_VERSION}

ENV APP_ROOT=/app
RUN mkdir ${APP_ROOT}
ENV PATH=${APP_ROOT}/bin:${BUNDLE_BIN}:${GEM_HOME}/gems/bin:${PATH}
WORKDIR ${APP_ROOT}

ENV RAILS_ENV=development

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

アプリのファイルをイメージに含めない

CMDENTRYPOINTによる処理は定義せず、Railsアプリが動く環境を整える事にのみ特化してイメージのサイズを小さくしています。bundle installrails s といった処理は docker-compose {run,exec}を介してコンテナ内で実行し、ライブラリやアプリのファイルはvolumesでマウントしてコンテナにコピーする(イメージには含めないようにする)事を意図しています。

ufoscout/docker-compose-wait でDB起動を待つ

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

Dockerfileの末尾3行部分で ufoscout/docker-compose-wait をインストールしています。これはmysql等のミドルウェア・コンテナのポートがLISTEN状態になるのを待ってくれるRust製のツールで、名前の通りdocker-compose.ymlとの併用が意図されています。
依存ミドルウェア起動をどうやって待つか?については、netcatやdockerizeを使った例だったり、公式のPostgresの例ではシェルスクリプトを書いて頑張る例が紹介されていたりしますが(Badの反応が多いのが気になりますが...)、このツールはミドルウェアの追加・削除時もdocker-compose.ymlに少し記述を追加するだけで対応できますし動作も確実性が高くて使い勝手が良かったです。具体的な使い方は後述のdocker-compose.ymlやGitHub Actionsに関する節で解説します。

docker-compose.yml

./docker-compose.yml
version: "3.7"

services:
  db:
    image: mysql:8.0.20
    command: --default-authentication-plugin=mysql_native_password
    environment:
      - MYSQL_ROOT_PASSWORD
      - MYSQL_ALLOW_EMPTY_PASSWORD
    volumes:
      - mysql-data:/var/lib/mysql
    ports:
      - ${MYSQL_FORWARDED_PORT:-3306}:3306
      - ${MYSQL_FORWARDED_X_PORT:-33060}:33060

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0
    tmpfs:
      - /tmp

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

1つのDockerfileでdocker-composeの複数サービスを定義する

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0

docker-compose.ymlのbaseサービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待しているARG_RUBY_VERSION ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は2.7.1を」指定するようにargsにて定義しています。

baseサービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。&base とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています(後述)。ビルドに関する設定はこのbaseサービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。

baseサービスのポイントはcache_fromrails6api-development-cacheを指定している事です。この設定は開発作業時ではなくCI時での利用を想定したものです。詳細は後述します。

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

このサービス定義が、Dockerfileの最後でインストールしたufoscout/docker-compose-waitを使ってdbサービスの起動を待つ為のサービスです。ymlの定義方法は公式を参照ください。先に定義したbaseサービスをmergeし、docker-compose-waitで必要な設定とdbサービスとの関連を定義しています。単独で実行したい場合はdocker-compose runすればOKです。

$ docker-compose run --rm wait-middleware

Creating network "rails6api_default" with the default driver
Creating rails6api_db_1    ... done
--------------------------------------------------------
 docker-compose-wait 2.7.3
---------------------------
Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds
 - TCP connection timeout before retry: 5 seconds
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!

上記実行例は筆者のMacでのもので、ほとんど待ちが発生せずdbが立ち上がります。この速さならdepends_on で起動順さえ意識しておけば「DBが立ち上がっていない状態でアプリが動きそうになってエラー」という状況はほぼ発生しないのですが、GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります。これについても後述します。

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

bashログインしてのプロンプト作業や rails s する為のサービス定義と、volumeの定義部分です。TechRachoさんの記事 で紹介されていた書き方を流用させてもらっています。

consoleserverの両サービスがbackendというサービス定義をマージしているのですが、このbackendのvolumesで ${GEMS_CACHE_DIR:-bundle-cache}:/bundle と定義されているvolumeはbundle install先のディレクトリで、「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。

.env

./.env
MYSQL_ROOT_PASSWORD=root
MYSQL_ALLOW_EMPTY_PASSWORD=1
DB_HOST=db

MYSQL_FORWARDED_PORT=3806
MYSQL_FORWARDED_X_PORT=38060

docker-compose.ymlのdbサービス内の環境変数.envの内容から展開 しています。

.github/workflows/ci.yml

./.github/workflows/ci.yml
on:
  push:
    branches:
      - master
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}


  test-app:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"


  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

BuildKitでビルドをちょっと高速化

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1

グローバルな環境変数でdocker-compose向けにBuildKitを有効化しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった(12%程度削減)ので有効化しています。

先にイメージのキャッシュ・リストアを実行し、このキャッシュで後続ジョブを並列に動かす

jobs:
  # Dockerイメージのキャッシュ・リストア
  image-cache-or-build:

  # アプリのテスト
  test-app:
    needs: image-cache-or-build

  # イメージの脆弱性スキャン
  scan-image-by-trivy:
    needs: image-cache-or-build

最初に必ずDockerイメージのキャッシュリストア(キャッシュが無ければ新規ビルド→キャッシュ生成)を行い、後続のアプリテスト&イメージスキャンはこのキャッシュからリストアしたイメージを使って実行するようにします。アプリテストとイメージスキャンは並列実行でも構わないので並列にしています。

docker image save, docker image load, cache_from with BuildKit でイメージのキャッシュ・リストアとビルド

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

Dockerイメージのキャッシュリストアは、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Dockerfile') }} を含めているのは、Dockerfileに変更があった際にキャッシュHITさせないようにする事を意図したものです。

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}

上記step群の処理をまとめると、「ビルドされたイメージに rails6api-development-cache というタグを付与してtarに保存し、actions/cache のキャッシュ先ディレクトリに image.tar という名前で保存する」という処理を行っています。

キャッシュHITの有無で処理の流れは下記のように変わります。

  • キャッシュがHITしなかった場合
    • docker-build のstepで新規にイメージがビルドされます
    • actions/cacheでキャッシュ先として指定した${IMAGE_CACHE_DIR}をmkdirします
    • ビルド結果のイメージに別途「キャッシュ用のタグ」を付与します
      • = APP_IMAGE_CACHE_TAG = rails6api-development-cache
    • 「キャッシュ用のタグ」 = rails6api-development-cache を付与したイメージをdocker image saveで保存します。この保存先にactions/cacheでのキャッシュ先ディレクトリを指定します(ファイル名は image.tar)
  • キャッシュがHITした場合
    • docker-load のstepで、キャッシュからリストアされたimage.tarが docker image load によって展開されます
      • 展開されるイメージには「キャッシュ用のタグ」 = rails6api-development-cache が付与されています
    • docker-build のstepでイメージがビルドされますが、先のloadのstepで展開されたイメージによって cache_from rails6api-development-cache の指定が効き、このビルドはすぐに終わります
    • tagとsaveのstepは if: steps.cache-docker-image.outputs.cache-hit != 'true' の指定によりSKIPされます

キャッシュ・リストアしたイメージを使って高速CI

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

jobs:

# 略

  test-app:
    needs: image-cache-or-build

    # 略

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

先述のイメージのビルド&キャッシュjobが完了すると、アプリのテストを行うtest-appが起動します。docker-loadのstepでは「キャッシュ用のタグ」 = rails6api-development-cache が付与されているイメージが展開され、docker-buildのstepでこのイメージをcache_fromによって取り込んでbaseサービスのイメージ(= rails6api-development:0.1.0 タグが付与されたイメージ)をビルドします。

ufoscout/docker-compose-wait でMySQLコンテナの起動を待つ

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

Dockerfileの最後でインストールしてあるufoscout/docker-compose-waitを使ってdbサービスの起動を待ちます。

Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds 
 - TCP connection timeout before retry: 5 seconds 
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!
--------------------------------------------------------

以前に「GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります」と書きましたが、上記ログがGitHub Actionsのrunnerインスタンス上での実行例で(Host db:3306 not yet available... でsleepを1秒挟んでいます)、ポート3306のLISTENまでに10秒以上掛かっています。このログ例のみならず、何度実行しても平均的に10秒超は掛かっていました。仮にこの所要時間でwaitするstepを挟まない(depends_onを指定するのみ)とすると、MySQL起動前に後続のRailsアプリに関するstepが走ってしまいエラーになるでしょう。

余談: MySQLコンテナの起動プロセスと処理時間

db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Initializing database files
db_1               | 2020-05-25T16:36:40.990281Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:40.990349Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.20) initializing of server in progress as process 45
db_1               | 2020-05-25T16:36:40.996092Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:42.100882Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:43.312805Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Database files initialized
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Starting temporary server
db_1               | 2020-05-25T16:36:46.494377Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:46.494485Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 92
db_1               | 2020-05-25T16:36:46.507413Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:46.819578Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:46.915827Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock'
db_1               | 2020-05-25T16:36:47.015509Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:47.017398Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:47.034485Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 0  MySQL Community Server - GPL.

db_1               | 2020-05-25 16:36:47+00:00 [Note] [Entrypoint]: Temporary server started.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/iso3166.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/leap-seconds.list' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone1970.tab' as time zone. Skipping it.
db_1               | 
db_1               | 2020-05-25 16:36:49+00:00 [Note] [Entrypoint]: Stopping temporary server
db_1               | 2020-05-25T16:36:49.538277Z 10 [System] [MY-013172] [Server] Received SHUTDOWN from user root. Shutting down mysqld (Version: 8.0.20).
db_1               | 2020-05-25T16:36:51.341063Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.20)  MySQL Community Server - GPL.
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: Temporary server stopped
db_1               | 
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db_1               | 
db_1               | 2020-05-25T16:36:51.806387Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:51.806498Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 1
db_1               | 2020-05-25T16:36:51.816008Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:52.191532Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:52.286349Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock' bind-address: '::' port: 33060
db_1               | 2020-05-25T16:36:52.341936Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:52.345030Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:52.363785Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

上記はMySQLコンテナ(dbサービス)起動時のログを docker-compose logs dbで確認した際の例です。

  • Initializing database files から Database files initialized で6秒
  • Starting temporary server から Temporary server stopped で5秒

この2処理で所要時間をほぼ半分ずつ要しています。

依存gemのvolumeマウントはnamed volumeではなく書き込み可能なディレクトリを使う

    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    # 略

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

依存Gemのキャッシュリストアも、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Gemfile.lock') }} を含めているのは、Gemfile.lock(Gemfile)に変更があった際にキャッシュHITさせないようにする事を意図したものです。

./docker-compose.yml
  backend: &backend

    # 略

    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache

# 略

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

全てをDockerで行おうとしているので、bundle installもコンテナ内で行います。つまりインストールされたgemをキャッシュしたければ、volume mount先からインストール結果を取り出さなければなりません。

↑のdocker-compose.ymlを紹介した際に「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」と書いたのですが、このcache-bundle-gemsのstepがまさに「環境変数GEMS_CACHE_DIRがセットされていれば」なケースに該当します。これは「named volumeではなくマウント先のパスを環境変数で明示する」のが意図です。

公式のvolumesのSHORT SYNTAXによると、パスが指定されていればそのパスが、固定文字列が指定されていればその名前のnamed volumeが、それぞれマウントされます。開発作業時はGEMS_CACHE_DIRを明示せずデフォルトの固定文字列(= named volume =bundle-cache)を使用しても良いですが、actions/cache で内容をキャッシュしようとした場合、そのディレクトリとしてnamed volumeの実体(具体的には /var/lib/docker/volumes/xxx というパス)を指定するとpermission deniedエラーでキャッシュに失敗してしまいます。なのでこれを回避する為にCI時のみGEMS_CACHE_DIRとして /tmp/cache/bundle という(permission deniedにならない)ディレクトリを明示しています。これによりCI時のbundle install結果はこのGEMS_CACHE_DIRディレクトリに出力され、actions/cacheでディレクトリが丸ごとキャッシュされます。

この記述は正直分かりやすいとは言えないので、LONG SYNTAXを利用して分かりにくさを軽減したいところなのですが、今回は「環境変数の中身によってvolume typeが変えられる」「1つのdocker-compose.ymlを開発作業とCIで併用しやすい」というメリットを優先してSHORT SYNTAXを採用しました。

アプリのセットアップ&テストもDockerコンテナ内で実行

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"

このstepは構築するアプリの仕様ややりたい事次第で変わると思いますが、一応今回の例を紹介しておくと bundle install(結果は先述の通りキャッシュされる) → db:prepareでDBセットアップ(参考) → テスト(今回使ったアプリではrspecを使用しています)、という順にテストまで実施しています。それぞれのコマンドをrunnerインスタンス上で直接実行するのではなく、docker-compose runconsoleサービスを立ち上げてその中で実行するようにしています。

アプリのテストと並列でDockerイメージの脆弱性スキャンも実行

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

# 略

  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

開発〜CIをDockerで完結させようとしているので、折角なのでCI時にDockerイメージの脆弱性スキャンも行っておきたいです。今回は aquasecurity/trivy を使わせてもらいました。Docker完結を目指しているので、trivyによるスキャンも公式に提供されているDockerで行います。

needs: image-cache-or-build によってイメージビルド&キャッシュが完了済なので、スキャンもそのキャッシュをload・展開したイメージに対して実施して高速化します(スキャン対象として ${APP_IMAGE_CACHE_TAG} = rails6api-development-cache を指定)。
毎回 aquasec/trivy をpullする事でスキャンそのものの仕様を常に最新化しているので、Dockerfileに変更がなくてもスキャンを実行するようにしています。

開発作業のユースケース例

新規参画エンジニアの環境構築手順は?

# ビルド
docker-compose build base

# セットアップ
docker-compose run --rm console bash -c "bundle install && rails db:prepare && rails db:seed"

# 起動
docker-compose up -d server

railsを起動したい時は?

docker-compose up -d server

起動中のRailsログをコンソールに流しておきたい時は?

# 下記コマンドでattachすれば、server コンテナの標準出力をtail風に確認可能
docker attach `docker-compose ps -q server`

# attach状態を終了したければ Ctrl+P => Ctrl+Q する

テスト(rspec)を実行したい時は?

起動中のserverサービスで

docker-compose exec server rspec [SPEC_FILES]

consoleサービスで

docker-compose run --rm console rspec [SPEC_FILES]

Rails consoleに接続したい時は?

起動中のserverサービスで

docker-compose exec server rails c

マイグレーションを追加・修正・適用したい時は?

起動中のserverサービスで

# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME

# :
# マイグレーションファイルを適宜修正
# :

# マイグレーションの適用
docker-compose exec server rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

開発もGitHub ActionsでのCIも全てDockerで行う (Rails 6 API編)

Rails on GitHub Actions(或いは {Django,Laravel} on GitHub Actions)のCI事例として、

  • ホストランナー上にRuby(Python, PHP)をセットアップ
  • MySQLやRedisはサービスコンテナで立ち上げ
  • 依存ライブラリのインストール(bundle install) や ユニットテスト(rspec) もホストランナー上で直接実行

という事例は多く見かけるのですが、開発をDockerベースで行っていて、GitHub ActionsのCI Pipelineも同じくDockerベースで構築したい...というケースの事例があまり見当たらなかったので、自分が関わったプロジェクト(Rails 6 API mode)での事例を紹介します。

Requirements

  • 開発環境は全てDocker(Dockerfile/docker-compose.yml)で構築&管理したい
    • bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
  • Dockerイメージのサイズを小さくしたい
    • Dockerイメージ自体でファイルを抱え込むような処理はなるべく書かない。実行したいコマンドはコンテナ起動時に都度コマンドとして付与し、成果物を保存・永続化したければvolumesを活用する
    • bundle install, rails db:prepareを実行して環境をセットアップする為のコンテナ」「rails sする為のコンテナ」という具合に用途でコンテナを分ける "1イメージ : Nコンテナ" 想定で
  • GitHub ActionsでのCIも開発環境と同じ Dockerfile/docker-compose.yml をそのまま活用してセットアップしたい
    • CIでも bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
    • 「CI用のDockerfile」とか「CI時だけoverrideする為のdocker-compose.yml」は極力作りたくない
  • GitHub ActionsではDockerイメージや依存ライブラリのキャッシュを有効活用してCIを高速化したい

Version

  • Ruby 2.7.1
    • gem 3.1.3
    • bundler 2.1.4
  • Rails 6.0.3 (API mode)
  • Docker (Docker for Mac)
    • Engine 19.03.8
    • Compose 1.25.5
  • MySQL 8.0.20

Contents

Dockerfile

./Dockerfile
# このDockerfileとdocker-compose.ymlの書き方については、
# TechRachoさんの記事 https://techracho.bpsinc.jp/hachi8833/2019_09_06/79035
# で紹介されていた手法をベースにしています。(感謝)

ARG ARG_RUBY_VERSION

FROM ruby:${ARG_RUBY_VERSION}-alpine3.11

# hadolint ignore=DL3008,DL3018
RUN apk update && \
  apk add --update --no-cache \
    build-base \
    bash \
    curl \
    git \
    less \
    tzdata \
    mysql-client \
    mysql-dev && \
  rm -rf /var/cache/apk/*

SHELL ["/bin/bash", "-eo", "pipefail", "-c"]

ARG ARG_TZ=Asia/Tokyo
RUN cp /usr/share/zoneinfo/${ARG_TZ} /etc/localtime

ARG ARG_GEM_HOME=/bundle
ENV GEM_HOME=${ARG_GEM_HOME}

ENV BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3 \
  BUNDLE_PATH=${GEM_HOME} \
  BUNDLE_BIN=${GEM_HOME}/bin \
  BUNDLE_APP_CONFIG=${GEM_HOME} \
  LANG=C.UTF-8 \
  LC_ALL=C.UTF-8

ARG ARG_BUNDLER_VERSION="2.1.4"
RUN gem update --system && \
    gem install bundler:${ARG_BUNDLER_VERSION}

ENV APP_ROOT=/app
RUN mkdir ${APP_ROOT}
ENV PATH=${APP_ROOT}/bin:${BUNDLE_BIN}:${GEM_HOME}/gems/bin:${PATH}
WORKDIR ${APP_ROOT}

ENV RAILS_ENV=development

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

Railsアプリのファイルをイメージ(Dockerfile)に含めない

CMDENTRYPOINTによる処理は定義せず、Railsアプリが動く環境を整える事にのみ特化してイメージのサイズを小さくしています。bundle installrails s といった処理は docker-compose {run,exec}を介してコンテナ内で実行し、ライブラリやアプリのファイルはvolumesでマウントしてコンテナにコピーする(イメージには含めないようにする)事を意図しています。

DB起動を待つ処理用途で ufoscout/docker-compose-wait を導入

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

Dockerfileの末尾3行部分で ufoscout/docker-compose-wait をインストールしています。これはmysql等のミドルウェア・コンテナのポートがLISTEN状態になるのを待ってくれるRust製のツールで、名前の通りdocker-compose.ymlとの併用が意図されています。
依存ミドルウェア起動をどうやって待つか?については、netcatやdockerizeを使った例だったり、公式のPostgresの例ではシェルスクリプトを書いて頑張る例が紹介されていたりしますが(Badの反応が多いのが気になりますが...)、このツールはミドルウェアの追加・削除時もdocker-compose.ymlに少し記述を追加するだけで対応できますし動作も確実性が高くて使い勝手が良かったです。具体的な使い方は後述のdocker-compose.ymlやGitHub Actionsに関する節で解説します。

docker-compose.yml

./docker-compose.yml
version: "3.7"

services:
  db:
    image: mysql:8.0.20
    command: --default-authentication-plugin=mysql_native_password
    environment:
      - MYSQL_ROOT_PASSWORD
      - MYSQL_ALLOW_EMPTY_PASSWORD
    volumes:
      - mysql-data:/var/lib/mysql
    ports:
      - ${MYSQL_FORWARDED_PORT:-3306}:3306
      - ${MYSQL_FORWARDED_X_PORT:-33060}:33060

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0
    tmpfs:
      - /tmp

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

1つのDockerfileでdocker-composeの複数サービスを定義する

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0

docker-compose.ymlのbaseサービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待しているARG_RUBY_VERSION ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は2.7.1を」指定するようにargsにて定義しています。

baseサービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。&base とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています(後述)。ビルドに関する設定はこのbaseサービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。

baseサービスのポイントはcache_fromrails6api-development-cacheを指定している事です。この設定は開発作業時ではなくCI時での利用を想定したものです。詳細は後述します。

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

このサービス定義が、Dockerfileの最後でインストールしたufoscout/docker-compose-waitを使ってdbサービスの起動を待つ為のサービスです。ymlの定義方法は公式を参照ください。先に定義したbaseサービスをmergeし、docker-compose-waitで必要な設定とdbサービスとの関連を定義しています。単独で実行したい場合はdocker-compose runすればOKです。

$ docker-compose run --rm wait-middleware

Creating network "rails6api_default" with the default driver
Creating rails6api_db_1    ... done
--------------------------------------------------------
 docker-compose-wait 2.7.3
---------------------------
Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds
 - TCP connection timeout before retry: 5 seconds
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!

上記実行例は筆者のMacでのもので、ほとんど待ちが発生せずdbが立ち上がります。この速さならdepends_on で起動順さえ意識しておけば「DBが立ち上がっていない状態でアプリが動きそうになってエラー」という状況はほぼ発生しないのですが、GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります。これについても後述します。

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

bashログインしてのプロンプト作業や rails s する為のサービス定義と、volumeの定義部分です。TechRachoさんの記事 で紹介されていた書き方を流用させてもらっています。

consoleserverの両サービスがbackendというサービス定義をマージしているのですが、このbackendのvolumesで ${GEMS_CACHE_DIR:-bundle-cache}:/bundle と定義されているvolumeはbundle install先のディレクトリで、「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。

.env

./.env
MYSQL_ROOT_PASSWORD=root
MYSQL_ALLOW_EMPTY_PASSWORD=1
DB_HOST=db

MYSQL_FORWARDED_PORT=3806
MYSQL_FORWARDED_X_PORT=38060

docker-compose.ymlのdbサービス内の環境変数.envの内容から展開 しています。

.github/workflows/ci.yml

./.github/workflows/ci.yml
on:
  push:
    branches:
      - master
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}


  test-app:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"


  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

BuildKitを有効化して(ちょっとだけ)ビルドを高速化

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1

グローバルな環境変数でdocker-compose向けにBuildKitを有効化しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった(12%程度削減)ので有効化しています。

Dockerイメージのキャッシュ・リストア, アプリのテスト, イメージの脆弱性スキャン, の3ジョブ構成

jobs:
  # Dockerイメージのキャッシュ・リストア
  image-cache-or-build:

  # アプリのテスト
  test-app:
    needs: image-cache-or-build

  # イメージの脆弱性スキャン
  scan-image-by-trivy:
    needs: image-cache-or-build

最初に必ずDockerイメージのキャッシュリストア(キャッシュが無ければ新規ビルド→キャッシュ生成)を行い、後続のアプリテスト&イメージスキャンはこのキャッシュからリストアしたイメージを使って実行するようにします。アプリテストとイメージスキャンは並列実行でも構わないので並列にしています。

docker image save + docker image load + cache_from with BuildKit を駆使したDockerイメージのキャッシュ・リストア

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

Dockerイメージのキャッシュリストアは、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Dockerfile') }} を含めているのは、Dockerfileに変更があった際にキャッシュHITさせないようにする事を意図したものです。

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}

上記step群の処理をまとめると、「ビルドされたイメージに rails6api-development-cache というタグを付与してtarに保存し、actions/cache のキャッシュ先ディレクトリに image.tar という名前で保存する」という処理を行っています。

キャッシュHITの有無で処理の流れは下記のように変わります。

  • キャッシュがHITしなかった場合
    • docker-build のstepで新規にイメージがビルドされます
    • actions/cacheでキャッシュ先として指定した${IMAGE_CACHE_DIR}をmkdirします
    • ビルド結果のイメージに別途「キャッシュ用のタグ」を付与します
      • = APP_IMAGE_CACHE_TAG = rails6api-development-cache
    • 「キャッシュ用のタグ」 = rails6api-development-cache を付与したイメージをdocker image saveで保存します。この保存先にactions/cacheでのキャッシュ先ディレクトリを指定します(ファイル名は image.tar)
  • キャッシュがHITした場合
    • docker-load のstepで、キャッシュからリストアされたimage.tarが docker image load によって展開されます
      • 展開されるイメージには「キャッシュ用のタグ」 = rails6api-development-cache が付与されています
    • docker-build のstepでイメージがビルドされますが、先のloadのstepで展開されたイメージによって cache_from rails6api-development-cache の指定が効き、このビルドはすぐに終わります
    • tagとsaveのstepは if: steps.cache-docker-image.outputs.cache-hit != 'true' の指定によりSKIPされます

キャッシュ済のイメージでアプリのCI(テスト)を実行する事で高速化を図る

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

jobs:

# 略

  test-app:
    needs: image-cache-or-build

    # 略

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

先述のイメージのビルド&キャッシュjobが完了すると、アプリのテストを行うtest-appが起動します。docker-loadのstepでは「キャッシュ用のタグ」 = rails6api-development-cache が付与されているイメージが展開され、docker-buildのstepでこのイメージをcache_fromによって取り込んでbaseサービスのイメージ(= rails6api-development:0.1.0 タグが付与されたイメージ)をビルドします。

ufoscout/docker-compose-wait でMySQLコンテナの起動を待つ

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

Dockerfileの最後でインストールしてあるufoscout/docker-compose-waitを使ってdbサービスの起動を待ちます。

Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds 
 - TCP connection timeout before retry: 5 seconds 
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!
--------------------------------------------------------

以前に「GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります」と書きましたが、上記ログがGitHub Actionsのrunnerインスタンス上での実行例で(Host db:3306 not yet available... でsleepを1秒挟んでいます)、ポート3306のLISTENまでに10秒以上掛かっています。このログ例のみならず、何度実行しても平均的に10秒超は掛かっていました。仮にこの所要時間でwaitするstepを挟まない(depends_onを指定するのみ)とすると、MySQL起動前に後続のRailsアプリに関するstepが走ってしまいエラーになるでしょう。

余談: MySQLコンテナ起動プロセスのどの処理が遅いのか?
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Initializing database files
db_1               | 2020-05-25T16:36:40.990281Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:40.990349Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.20) initializing of server in progress as process 45
db_1               | 2020-05-25T16:36:40.996092Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:42.100882Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:43.312805Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Database files initialized
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Starting temporary server
db_1               | 2020-05-25T16:36:46.494377Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:46.494485Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 92
db_1               | 2020-05-25T16:36:46.507413Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:46.819578Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:46.915827Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock'
db_1               | 2020-05-25T16:36:47.015509Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:47.017398Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:47.034485Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 0  MySQL Community Server - GPL.

db_1               | 2020-05-25 16:36:47+00:00 [Note] [Entrypoint]: Temporary server started.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/iso3166.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/leap-seconds.list' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone1970.tab' as time zone. Skipping it.
db_1               | 
db_1               | 2020-05-25 16:36:49+00:00 [Note] [Entrypoint]: Stopping temporary server
db_1               | 2020-05-25T16:36:49.538277Z 10 [System] [MY-013172] [Server] Received SHUTDOWN from user root. Shutting down mysqld (Version: 8.0.20).
db_1               | 2020-05-25T16:36:51.341063Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.20)  MySQL Community Server - GPL.
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: Temporary server stopped
db_1               | 
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db_1               | 
db_1               | 2020-05-25T16:36:51.806387Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:51.806498Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 1
db_1               | 2020-05-25T16:36:51.816008Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:52.191532Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:52.286349Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock' bind-address: '::' port: 33060
db_1               | 2020-05-25T16:36:52.341936Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:52.345030Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:52.363785Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

上記はMySQLコンテナ(dbサービス)起動時のログを docker-compose logs dbで確認した際の例です。

  • Initializing database files から Database files initialized で6秒
  • Starting temporary server から Temporary server stopped で5秒

この2処理で所要時間をほぼ半分ずつ要しています。

依存gemのキャッシュ・リストアで named volume のディレクトリをそのまま使わない(使えない)

    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    # 略

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

依存Gemのキャッシュリストアも、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Gemfile.lock') }} を含めているのは、Gemfile.lock(Gemfile)に変更があった際にキャッシュHITさせないようにする事を意図したものです。

./docker-compose.yml
  backend: &backend

    # 略

    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache

# 略

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

全てをDockerで行おうとしているので、bundle installもコンテナ内で行います。つまりインストールされたgemをキャッシュしたければ、volume mount先からインストール結果を取り出さなければなりません。

↑のdocker-compose.ymlを紹介した際に「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」と書いたのですが、このcache-bundle-gemsのstepがまさに「環境変数GEMS_CACHE_DIRがセットされていれば」なケースに該当します。これは「named volumeではなくマウント先のパスを環境変数で明示する」のが意図です。

公式のvolumesのSHORT SYNTAXによると、パスが指定されていればそのパスが、固定文字列が指定されていればその名前のnamed volumeが、それぞれマウントされます。開発作業時はGEMS_CACHE_DIRを明示せずデフォルトの固定文字列(= named volume =bundle-cache)を使用しても良いですが、actions/cache で内容をキャッシュしようとした場合、そのディレクトリとしてnamed volumeの実体(具体的には /var/lib/docker/volumes/xxx というパス)を指定するとpermission deniedエラーでキャッシュに失敗してしまいます。なのでこれを回避する為にCI時のみGEMS_CACHE_DIRとして /tmp/cache/bundle という(permission deniedにならない)ディレクトリを明示しています。これによりCI時のbundle install結果はこのGEMS_CACHE_DIRディレクトリに出力され、actions/cacheでディレクトリが丸ごとキャッシュされます。

この記述は正直分かりやすいとは言えないので、LONG SYNTAXを利用して分かりにくさを軽減したいところなのですが、今回は「環境変数の中身によってvolume typeが変えられる」「1つのdocker-compose.ymlを開発作業とCIで併用しやすい」というメリットを優先してSHORT SYNTAXを採用しました。

アプリのセットアップ&テストもDockerコンテナ内で実行

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"

このstepは構築するアプリの仕様ややりたい事次第で変わると思いますが、一応今回の例を紹介しておくと bundle install(結果は先述の通りキャッシュされる) → db:prepareでDBセットアップ(参考) → テスト(今回使ったアプリではrspecを使用しています)、という順にテストまで実施しています。それぞれのコマンドをrunnerインスタンス上で直接実行するのではなく、docker-compose runconsoleサービスを立ち上げてその中で実行するようにしています。

アプリのテストと並行してDockerイメージの脆弱性スキャンも実行

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

# 略

  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

開発〜CIをDockerで完結させようとしているので、折角なのでCI時にDockerイメージの脆弱性スキャンも行っておきたいです。今回は aquasecurity/trivy を使わせてもらいました。Docker完結を目指しているので、trivyによるスキャンも公式に提供されているDockerで行います。

needs: image-cache-or-build によってイメージビルド&キャッシュが完了済なので、スキャンもそのキャッシュをload・展開したイメージに対して実施して高速化します(スキャン対象として ${APP_IMAGE_CACHE_TAG} = rails6api-development-cache を指定)。
毎回 aquasec/trivy をpullする事でスキャンそのものの仕様を常に最新化しているので、Dockerfileに変更がなくてもスキャンを実行するようにしています。

開発作業のユースケース例

新規参画エンジニアの環境構築手順は?

# ビルド
docker-compose build base

# セットアップ
docker-compose run --rm console bash -c "bundle install && rails db:prepare && rails db:seed"

# 起動
docker-compose up -d server

railsを起動したい時は?

docker-compose up -d server

テスト(rspec)を実行したい時は?

起動中のserverサービスで

docker-compose exec server rspec [SPEC_FILES]

consoleサービスで

docker-compose run --rm console rspec [SPEC_FILES]

マイグレーションを追加・修正・適用したい時は?

起動中のserverサービスで

# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME

# :
# マイグレーションファイルを適宜修正
# :

# マイグレーションの適用
docker-compose exec server rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【学習メモ】ネスト構造について

Railsの英語のドキュメントを自分なりに解釈してみた。自分が確認したい、ネスト構造・1対1関係について翻訳。

出典

Ruby on Rails 6.0.3.1
Module
ActiveRecord::NestedAttributes::ClassMethods
activerecord/lib/active_record/nested_attributes.rb

Active Record Nested Attributes

Nested attributes allow you to save attributes on associated records through the parent.

→ネストされた属性(:nameとか:created_atとかそーいう類)を使用すると、親レコードを介して関連づけられた属性を保存できます。

Cf.ネストとは入れ子構造とも呼ばれ、ある記述の中に入れ子構造で別の記述をする方法です。
ルーティングでいうと、あるコントローラへのルーティングの記述の中に、別のコントローラへのルーティングを記述するということを指します。

By default nested attribute updating is turned off and you can enable it using the accepts_nested_attributes_for class method.

→デフォルトでは、ネストされた属性は更新されず、クラスメソッドである、accepts_nested_attributes_forを使うことで更新できます。

When you enable nested attributes an attribute writer is defined on the model.

→ネストされた属性を有効にすると、attribute writerがモデルで定義されます。

The attribute writer is named after the association, which means that in the following example, two new methods are added to your model:

→attribute writerは、アソシエーション後に命名されます。つまり、次の例では2つの新しいメソッドがモデルに追加されます。

class Book < ActiveRecord::Base
  has_one :author
  has_many :pages

  accepts_nested_attributes_for :author, :pages
end

追加されるメソッド
1.author_attributes=(attributes) 2.pages_attributes=(attributes)

Note that the :autosave option is automatically enabled on every association that accepts_nested_attributes_for is used for.

→ただし、autosaveオプションは、accepts_nested_attributes_forが使われるすべてのアソシエーションで自動的に有効になることをご注意ください。

1対1関係の場合(One-to-one)

Consider a Member model that has one Avatar:

→Avatar属性を持つ、Memberモデルの例

class Member < ActiveRecord::Base
  has_one :avatar
  accepts_nested_attributes_for :avatar
end

Enabling nested attributes on a one-to-one association allows you to create the member and avatar in one go:

→1対1の関連付けでネストされた属性を有効にすると、メンバーとアバターを一度に作成できます。

params = { member: { name: 'Jack', avatar_attributes: { icon: 'smiling' } } }
member = Member.create(params[:member])
member.avatar.id # => 2
member.avatar.icon # => 'smiling'

It also allows you to update the avatar through the member:

→また、memberを通して属性avatarを更新することもできます。

If you want to update the current avatar without providing the id, you must add :update_only option.

→IDを指定せずに現在の属性avatarを更新する場合、:update_onlyオプションを追加する必要があります。

class Member < ActiveRecord::Base
  has_one :avatar
  accepts_nested_attributes_for :avatar, update_only: true
end

params = { member: { avatar_attributes: { icon: 'sad' } } }
member.update params[:member]
member.avatar.id # => 2
member.avatar.icon # => 'sad'

By default you will only be able to set and update attributes on the associated model. If you want to destroy the associated model through the attributes hash, you have to enable it first using the :allow_destroy option.

→デフォルトでは、関連付けられたモデルの属性のみを設定および更新できます。属性ハッシュを使用してアソシエーションモデルを破棄する場合は、最初に:allow_destroyオプションを使用してモデルを有効にする必要があります。

class Member < ActiveRecord::Base
  has_one :avatar
  accepts_nested_attributes_for :avatar, allow_destroy: true
end

Now, when you add the _destroy key to the attributes hash, with a value that evaluates to true, you will destroy the associated model:

→ここで、_destroyキーを属性ハッシュに追加し、値がtrueと評価されると、関連するモデルが破棄されます。

member.avatar_attributes = { id: '2', _destroy: '1' }
member.avatar.marked_for_destruction? # => true
member.save
member.reload.avatar # => nil

Note that the model will not be destroyed until the parent is saved.

Also note that the model will not be destroyed unless you also specify its id in the updated hash.

→親モデルが保存されるまで、子モデルは破棄されないことに注意してください。 また、更新されたハッシュでそのIDを指定しない限り、モデルは破棄されないことにも注意してください。

多対多は、時間ある時に。

参照

RailsAPI(ActiveRecord::NestedAttributes::ClassMethods)
https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html#method-i-accepts_nested_attributes_for

ネストとアソシエーション
https://qiita.com/chopesu_se/items/c7362380865cf978b158

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初心者向け】Herokuでデプロイしたら code=H10 desc="App crashed" とか言われた時の解決法

環境

  • Ruby 2.5.1
  • Rails 5.2.3

エラー内容

Herokuで初めてデプロイした際に
「Application error」が解決できずに時間を取りましたので備忘録。

デプロイは「Verifying deploy... done.」
と成功しているようなのに、ブラウザが添付画像のようになって開かない...?なんで...?

Application error

エラー原因を特定したい

ターミナル
$ heroku logs --tail

言われた通り実行してみる。

すると大量のコードが...
ただ、最後に目立つエラーコードが。

ターミナル
heroku[router]: at=error code=H10 desc="App crashed" method=GET path="/" host=XXXX.herokuapp.com request_id=82c4af6d-f12b-4348-a654-1238f7b24e67 fwd="60.86.220.52" dyno= connect= service= status=503 bytes= protocol=https

先人たちの知恵をお借りする

ググってみるとこちらの記事を見つけました。
https://qiita.com/Oakbow/items/1565922ddcdea0ce9ab5%E3%80%80

ターミナル
$ heroku restart

一通り実践してみますが解決せず?

改めてエラーコードを見てみる

errorが赤くなるのでそこばかりに目がいってしまいましたが、
全部見るとずっと上の方に何かが書いてある!

ターミナル
app[web.1]: bundler: failed to load command: puma (/app/vendor/bundle/ruby/2.5.1/bin/puma)
app[web.1]: Errno::ENOENT: No such file or directory @ rb_sysopen - tmp/pids/server.pid

tmp/pids/server.pidというファイルがないと怒られました。

そもそもなぜtmp/pids/server.pidが必要?

puma.rbを見てみたら「pidfile」というのが指定されている?

config/puma.rb
# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }

ちょっと調べてみるとRails6から追加された新機能とのことですが、
特に指定なく使えている人もいるので試しにコメントアウト。

⇒ 再度デプロイを試みてみると無事成功!

結論

特にデプロイ時はエラーログが大量ですが思わぬところにヒントがありますので
一通り目を通した方が良いですね。

pumaの働きについてはまだまだ勉強していきます?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Ruby/Rails】クラスでユニークな値を設定する

rubocopでは、loop + break を推奨されます。

loop + break版

class SomeClass
  def set_state
    return true if state.present?

    self.state = loop do
      random_str = SecureRandom.urlsafe_base64
      break random_str unless self.class.exists?(state: random_str)
    end
  end
end

while版

class SomeClass
  def set_state
    return true if state.present?

    begin
      self.state = SecureRandom.urlsafe_base64
    end while self.class.exists?(state: state)
  end
end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rialis】単体テストについて

最初に

Rspcを用いてモデルのバリデーションの単体テストを行った際の手順を備忘録として残します。

Rspecとは

RubyやRailsの代表的なテストツールのことで、クラスやメソッド単位でテストするために使用するためのgem。

環境

・Rails 5.0.7.2
・Ruby 2.5.1
・rspec-rails 3.9.0
・factory_bot_rails 5.1.1

事前準備

1.rspecとFactory_bot gemの導入

#gemfile

group :development, :test test do
#開発環境とテスト環境で使用したいのでこの中に記述。
gem 'rspec_rails'
gem 'factory_bot_gem' #

end

gemを記述できたらbundle install

 2.rspecファイルの作成と動作確認

ターミナルで、

$ rails g rspec:install

コマンド実行後下記のようにファイルが作成されればok。
~~~
create .rspec
create spec
create spec/spec_helper.rb
create spec/rails_helper.rb
~~~

ファイルが作成されたのでターミナルで、

--require spec_helper
--format documentation

この2文を.specファイルに追加してターミナルでrspec実行コマンドを行い動作確認。

$ bundle exec rspec
#ターミナル

No examples found.

Finished in 0.00031 seconds (files took 0.19956 seconds to load)
0 examples, 0 failures

このような記述がターミナルで出れば正常にRSpecが起動しているので、この後rspec/下のファイルにテストコードの記述を追加していく。

テスト手順

1.rspecディレクトリ直下にfactoriesフォルダを追加して、その中にusers.rbファイルを作成

users.rb
FactoryBot.define do

  factory :user do
    nickname        {"test"}
    email           {"test@gmail.com"}
    password        {"00000000"}
    family_name      {"test"}
    first_name      {"test"}
    family_name_kana    {"test"}
    first_name_kana    {"test"}
    birthday          {"2000-01-01"}
  end

end

factolesにモデルを先に作成して値を設定しておくことで、specファイルの中で特定のメソッドにより簡単にインスタンスを生成したり、DBに保存したりできるようになります。(1回1回テストを行うための値を入れて、データを作成しないで良くなる。)

user = FactoryBot.build(:user)

facutoryBotに対して、buildメソッドまたはcreateメソッドを使い、引数にfacutoriesフォルダ内で追加したモデルを指定することで~s.rbファイルで設定した値となる。

user = FactoryBot.build(:user,  email: "")

このように、モデルの後にカラム名の値を追加することにより、値を上書きできる。

2テストの記述を追加

specディレクトリにmodelsフォルダを生成。その中に各モデルのファイルを生成し実際のテストを行う。

user_spec.rb
require 'rails_helper'
describe User do
  describe 'registrations#create' do

    it "nicknameが空欄の場合,登録できないこと" do
      user = FactoryBot.build(:user, nickname: "" )  
      user.valid?
      expect(user.errors[:nickname]).to include("を入力してください") #include("can't be blank")が正しい。日本語に変換されているため左のような形にしている。
  end
end

これで1つのテストのまとまりとなる。

1行目で、 spec/rails_helper.rb を読み込むよう設定。
2,3行目、describe直後のdo~endのまとまりが式を表す。ユーザーモデルのregistrations#createアクションのテスト式ということ。
it直後は動作するテストコードのまとまりを表し、itの後に続く""の中にはその説明を書きます。(exampleと呼ぶ)
次行でハッシュを生成して値をuser変数に代入。
ここでFactoryBotメソッドを使用。buildの引数にモデル名を指定して、nicknameカラムのみ値を上書き。
FactoryBotメソッドを使用すると下記は同じ値となる。

usre_spec.rb
it "nicknameが空欄の場合,登録できないこと" do
      user = User.new(nickname: "", email: "test@gmail.com", password: "00000000", family_name: "test", first_name: "test", famliy_name_kana: "test", first_name_kana: "test", birthday: "2020-01-01") #gem未使用

      user = FactoryBot.build(:user, nickname: "" ) #FactoryBot使用時

毎回値をハッシュを生成しないようにFactoryBotmを使用する。
値を代入したuser変数に.valid?メソッドを使用。
ActiveRecord::Baseを継承しているクラスのインスタンスを保存する際に「バリデーションにより保存ができないか?」を確認。
Userモデルには今回全てのカラムに対して、presence: trueがバリデーションされています。

expect(X).to eq Yをしようして、xの部分に入れた式の値がYの部分の値と等しいかを確認。
ここではerrorsメソッドを利用。valid?メソッドの返り値はtrue/falseだが、valid?メソッドを利用したインスタンスvalid?メソッドを利用したインスタンスに対してerrorsメソッドを利用すると、バリデーションにより保存ができない状態である場合なぜできないのかを確認することができる。

今回であれば、nicknameカラムに値が入っていないので、”cant be blank” と出る。
includeマッチャを使用して、引数にとった値がexpectの引数である配列に含まれているかをチェック。
今回の場合、「nicknameが空の場合はcan't be blankというエラーが出るということがわかっているため、include("can't be blank")のように書く。実際にその通りになればこちらのエクスペクテーションは通過し、
"nicknameが空欄の場合,登録できないこと"というテストがこれで完了。

あとはひたすら単体テストの記述を追加して、1つ1つテストを行なっていく。

user_rspec.rb
require 'rails_helper'
describe User do
  describe 'registrations#create' do

    it "nicknameが空欄の場合,登録できないこと" do
      user = FactoryBot.build(:user, nickname: "" )  
      user.valid?
      expect(user.errors[:nickname]).to include("を入力してください") #include("can't be blank")が正しい。日本語に変換されているため右のような形にしている。
    end

    it "emailが空欄の場合、登録できないこと" do
      user = FactoryBot.build(:user, email: "")
      user.valid?
      expect(user.errors[:email]).to include("を入力してください")
    end

    it "emailに「@」がない場合、登録できないこと" do
      user = FactoryBot.build(:user, email: "test.gmail.com")
      user.valid?
      expect(user.errors[:email]).to include("は不正な値です")
    end   

    it "重複したメールアドレスの場合、無効である" do
      user1 = FactoryBot.create(:user)
      user2 = FactoryBot.build(:user)
      user2.valid?
      expect(user2.errors[:email]).to include("はすでに存在します")
    end

    it "passwordが空欄の場合、登録ができないこと"  do
      user = FactoryBot.build(:user, password: "" )
      user.valid?
      expect(user.errors[:password]).to include("を入力してください")
    end


    it "passwordが6文字以下の場合、登録ができないこと"  do
      user = FactoryBot.build(:user, password: "00000")
      user.valid?
      expect(user.errors[:password]).to include("は6文字以上で入力してください")
    end


    it "family_nameが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, family_name: "" )  
      user.valid?
      expect(user.errors[:family_name]).to include("を入力してください")
    end

    it "first_nameが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, first_name: "" )  
      user.valid?
      expect(user.errors[:first_name]).to include("を入力してください")
    end

    it "family_name_kanaが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, family_name_kana: "" )  
      user.valid?
      expect(user.errors[:family_name_kana]).to include("を入力してください")
    end

    it "first_name_kanaが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, first_name_kana: "" )  
      user.valid?
      expect(user.errors[:first_name_kana]).to include("を入力してください")
    end

    it "birthdayが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, birthday: "" )  
      user.valid?
      expect(user.errors[:birthday]).to include("を入力してください")
    end

  end
end

上記でuserモデルのバリデーションに対するテストが完成。

参考文献

https://programming-beginner-zeroichi.jp/articles/61
https://www.sejuku.net/blog/47847
https://note.com/syojikishindoi/n/n5019bc64c254

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Ralis】単体テストについて

最初に

Rspcを用いてモデルのバリデーションの単体テストを行った際の手順を備忘録として残します。

Rspecとは

RubyやRailsの代表的なテストツールのことで、クラスやメソッド単位でテストするために使用するためのgem。

環境

・Rails 5.0.7.2
・Ruby 2.5.1
・rspec-rails 3.9.0
・factory_bot_rails 5.1.1

事前準備

1.rspecとFactory_bot gemの導入

#gemfile

group :development, :test test do
#開発環境とテスト環境で使用したいのでこの中に記述。
gem 'rspec_rails'
gem 'factory_bot_gem' 

end

gemを記述できたらbundle install

 2.rspecファイルの作成と動作確認

ターミナルで、

$ rails g rspec:install

コマンド実行後下記のようにファイルが作成されればok。
~~~
create .rspec
create spec
create spec/spec_helper.rb
create spec/rails_helper.rb
~~~

ファイルが作成されたのでターミナルで、

--require spec_helper
--format documentation

この2文を.specファイルに追加してターミナルでrspec実行コマンドを行い動作確認。

$ bundle exec rspec
#ターミナル

No examples found.

Finished in 0.00031 seconds (files took 0.19956 seconds to load)
0 examples, 0 failures

このような記述がターミナルで出れば正常にRSpecが起動しているので、この後rspec/下のファイルにテストコードの記述を追加していく。

テスト手順

1.rspecディレクトリ直下にfactoriesフォルダを追加して、その中にusers.rbファイルを作成

users.rb
FactoryBot.define do

  factory :user do
    nickname        {"test"}
    email           {"test@gmail.com"}
    password        {"00000000"}
    family_name      {"test"}
    first_name      {"test"}
    family_name_kana    {"test"}
    first_name_kana    {"test"}
    birthday          {"2000-01-01"}
  end

end

factolesにモデルを先に作成して値を設定しておくことで、specファイルの中で特定のメソッドにより簡単にインスタンスを生成したり、DBに保存したりできるようになります。(1回1回テストを行うための値を入れて、データを作成しないで良くなる。)

user = FactoryBot.build(:user)

facutoryBotに対して、buildメソッドまたはcreateメソッドを使い、引数にfacutoriesフォルダ内で追加したモデルを指定することで~s.rbファイルで設定した値となる。

user = FactoryBot.build(:user,  email: "")

このように、モデルの後にカラム名の値を追加することにより、値を上書きできる。

2テストの記述を追加

specディレクトリにmodelsフォルダを生成。その中に各モデルのファイルを生成し実際のテストを行う。

user_spec.rb
require 'rails_helper'
describe User do
  describe 'registrations#create' do

    it "nicknameが空欄の場合,登録できないこと" do
      user = FactoryBot.build(:user, nickname: "" )  
      user.valid?
      expect(user.errors[:nickname]).to include("を入力してください") #include("can't be blank")が正しい。日本語に変換されているため左のような形にしている。
  end
end

これで1つのテストのまとまりとなる。

1行目で、 spec/rails_helper.rb を読み込むよう設定。
2,3行目、describe直後のdo~endのまとまりが式を表す。ユーザーモデルのregistrations#createアクションのテスト式ということ。
it直後は動作するテストコードのまとまりを表し、itの後に続く""の中にはその説明を書きます。(exampleと呼ぶ)
次行でハッシュを生成して値をuser変数に代入。
ここでFactoryBotメソッドを使用。buildの引数にモデル名を指定して、nicknameカラムのみ値を上書き。
FactoryBotメソッドを使用すると下記は同じ値となる。

usre_spec.rb
it "nicknameが空欄の場合,登録できないこと" do
      user = User.new(nickname: "", email: "test@gmail.com", password: "00000000", family_name: "test", first_name: "test", famliy_name_kana: "test", first_name_kana: "test", birthday: "2020-01-01") #gem未使用

      user = FactoryBot.build(:user, nickname: "" ) #FactoryBot使用時

毎回値をハッシュを生成しないようにFactoryBotmを使用する。
値を代入したuser変数に.valid?メソッドを使用。
ActiveRecord::Baseを継承しているクラスのインスタンスを保存する際に「バリデーションにより保存ができないか?」を確認。
Userモデルには今回全てのカラムに対して、presence: trueがバリデーションされています。

expect(X).to eq Yをしようして、xの部分に入れた式の値がYの部分の値と等しいかを確認。
ここではerrorsメソッドを利用。valid?メソッドの返り値はtrue/falseだが、valid?メソッドを利用したインスタンスvalid?メソッドを利用したインスタンスに対してerrorsメソッドを利用すると、バリデーションにより保存ができない状態である場合なぜできないのかを確認することができる。

今回であれば、nicknameカラムに値が入っていないので、”cant be blank” と出る。
includeマッチャを使用して、引数にとった値がexpectの引数である配列に含まれているかをチェック。
今回の場合、「nicknameが空の場合はcan't be blankというエラーが出るということがわかっているため、include("can't be blank")のように書く。実際にその通りになればこちらのエクスペクテーションは通過し、
"nicknameが空欄の場合,登録できないこと"というテストがこれで完了。

あとはひたすら単体テストの記述を追加して、1つ1つテストを行なっていく。

user_rspec.rb
require 'rails_helper'
describe User do
  describe 'registrations#create' do

    it "nicknameが空欄の場合,登録できないこと" do
      user = FactoryBot.build(:user, nickname: "" )  
      user.valid?
      expect(user.errors[:nickname]).to include("を入力してください") #include("can't be blank")が正しい。日本語に変換されているため右のような形にしている。
    end

    it "emailが空欄の場合、登録できないこと" do
      user = FactoryBot.build(:user, email: "")
      user.valid?
      expect(user.errors[:email]).to include("を入力してください")
    end

    it "emailに「@」がない場合、登録できないこと" do
      user = FactoryBot.build(:user, email: "test.gmail.com")
      user.valid?
      expect(user.errors[:email]).to include("は不正な値です")
    end   

    it "重複したメールアドレスの場合、無効である" do
      user1 = FactoryBot.create(:user)
      user2 = FactoryBot.build(:user)
      user2.valid?
      expect(user2.errors[:email]).to include("はすでに存在します")
    end

    it "passwordが空欄の場合、登録ができないこと"  do
      user = FactoryBot.build(:user, password: "" )
      user.valid?
      expect(user.errors[:password]).to include("を入力してください")
    end


    it "passwordが6文字以下の場合、登録ができないこと"  do
      user = FactoryBot.build(:user, password: "00000")
      user.valid?
      expect(user.errors[:password]).to include("は6文字以上で入力してください")
    end


    it "family_nameが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, family_name: "" )  
      user.valid?
      expect(user.errors[:family_name]).to include("を入力してください")
    end

    it "first_nameが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, first_name: "" )  
      user.valid?
      expect(user.errors[:first_name]).to include("を入力してください")
    end

    it "family_name_kanaが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, family_name_kana: "" )  
      user.valid?
      expect(user.errors[:family_name_kana]).to include("を入力してください")
    end

    it "first_name_kanaが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, first_name_kana: "" )  
      user.valid?
      expect(user.errors[:first_name_kana]).to include("を入力してください")
    end

    it "birthdayが空欄の場合、登録できないこと"  do
      user = FactoryBot.build(:user, birthday: "" )  
      user.valid?
      expect(user.errors[:birthday]).to include("を入力してください")
    end

  end
end

上記でuserモデルのバリデーションに対するテストが完成。

参考文献

https://programming-beginner-zeroichi.jp/articles/61
https://www.sejuku.net/blog/47847
https://note.com/syojikishindoi/n/n5019bc64c254

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

railsのmigrateファイルメソッド一覧

メソッド 用途
add_column カラムを追加する
remove_column カラムを削除する
remove_columns 複数のカラムを削除する
rename_column カラムの名前を変更する
change_column カラムの情報を変更する
create_table テーブルを作成する
drop_table テーブルを削除する
rename_table テーブル名を変更する
add_index インデックスを追加する
remove_index インデックスを削除する
add_reference 外部キーを作成する
remove_reference 外部キーを削除する
add_foreign_key 外部キー制約を作成する
remove_foreign_key 外部キー制約を削除する
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む