- 投稿日:2020-05-26T23:30:49+09:00
[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を参照しました。
- 投稿日:2020-05-26T23:25:12+09:00
LocalStackを使用してS3の投稿をモックする
LocalStack は、AWS のサービスを開発環境において擬似的に使用できるモックフレームワークです。
定番の S3 から Lambda まで多くのサービスを開発環境で体験できます。本記事では、Railsチュートリアルの SampleApp を使用し、コンテナ環境で実装します。
コンテナの設定は、Railsチュートリアルの開発環境を Docker でもっと便利にしなイカ!? - Qiitaの続きとなります。今回の実装コードは右の通りです=>(実装コード --github)
- 実行環境
- Ruby: 2.4.9
- Rails: 5.1.2
- fog: 1.14.0
- carrierwave: 1.1.0
- references
□ 既存ファイルの修正
■ 保存場所を S3 に固定
app/uploaders/picture_uploader.rbclass PictureUploader < CarrierWave::Uploader::Base ... - if Rails.env.production? - storage :fog - else - storage :file - end + storage :fog ... end■ carrier_wave の初期設定を変更
- 変更内容
- 本番環境でのみ設定が有効化されていたため、常に有効化した。
- LocalStack にアップロードするための設定として、本番環境以外における設定を追加した。
- 参考 URL
config/initializers/carrier_wave.rbCarrierWave.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.shawslocal s3 mb s3://microposts□ テスト投稿
投稿前
投稿成功! □ 余談: 投稿画像の永続化
前述の通り
DATA_DIR
を追加すると、投稿画像の永続化が可能となるが、コンテナ停止再起動でのみ有効となる。
もし、コンテナ削除時でもデータを保持したい場合、次の通り設定すること。また、この設定の場合、意識的に volume を削除しないとデータ容量が膨れ上がるため、注意する必要あり。
./docker-compose.ymlservices: 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:
- 投稿日:2020-05-26T22:51:44+09:00
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.0Rubyの参照先を確認する
まずは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
- 投稿日:2020-05-26T22:39:53+09:00
オレオレRailsコーディング規約
あらすじ
吾輩は末端エンジニアである。名前はしょった。先日、急遽あるRailsアプリの引継ぎ業務に駆り出された。吾輩はここで始めて、アプリのコードを見た。しかもこのコードがまあ追いにくい。ブチギレ寸前である。
アンチパターン
というわけで本題。
先に、ここでいうアンチパターンというのは一般に流布されているそれとはまるで別物であるということは先にお伝えしておきたい。あくまで個人的なベストプラクティスであり、引き継ぐならこうなっていて欲しい(欲しかった)という願望である。1. 1つのディレクトリ内に無駄にファイルが多い
今、ここに猫がいる。猫はかわいい。神は猫をこう定義した。
./cat.rbclass Cat def comment puts "かわいい" end end猫は猫耳と尻尾、そして肉球でできている。
神はかわいい猫をこう定義し直した。./cat.rbclass Cat def initialize @parts = [ CatEar.new, CatTail.new, CatPad.new ] end def comment @parts.each(&:comment) end end./cat_ear.rbclass CatEar def comment puts "猫耳かわいい" end end./cat_tail.rbclass CatTail def comment puts "しっぽもかわいい" end end./cat_pad.rbclass 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.rbclass 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.rbclass Cat class Tail def comment puts "しっぽもかわいい" end end end./cat/pad.rbclass 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.rbclass 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.rbclass 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.rbclass Hoge < ApplicationRecord attribute :name, :string endviews/hoge.html.erb<%= Hoge.first.read_attribute(:name) %>この実装は問題なく動くだろう。
ここで、nameカラムがnull
または空文字だった場合、'Unknown'
と返そう、となった。models/hoge.rbclass 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.rbclass 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.rbclass 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文の後ろの中括弧はこうだ〜みたいなどっちでもいい議論は聞くけれど、こういう構造で書いた方がいいよ!とか、こうすれば管理がしやすいよ!みたいな議論の方が大事だと思うので、もっと盛んに議論してください。よろしくおねがいします。
- 投稿日:2020-05-26T22:07:08+09:00
renderとredirect_toの違い
- 投稿日:2020-05-26T21:32:22+09:00
Reactで右クリックした場所にメニューを表示してみたい。
Reactで右クリックメニュー
先日書いたReact+Railsで付箋っぽいアプリを作る中で、右クリックメニューを作ってみたいなぁ、と、調べた内容と実装方法を書いていきたいと思います。
oncontextmenuのイベントハンドラを作れば良い
JavaScriptのドキュメントによれば、oncontextmenuイベントをハンドルすることで、独自のメニューが作れそうです。
実装方針
ここでは、以下の方針で実装してみることにしました。
- 右クリックした場所にメニューを表示する。
- メニューの要素は、Reactのコンポーネントとして作ってみる。
- 環境は、React+Railsで付箋っぽいアプリを作るの環境をそのまま使いましたが、多分Reactの動く環境なら、どこでも大丈夫。
実装だー
作るのは、以下の3つです。
1. メニューを表示するコンポーネント(menu.js)
1. 右クリックを受け付ける親コンポーネント(parent.js)
1. スタイルシート(parent.css)メニューを表示するコンポーネント
適当なメニューと"close"が選べるポップアップを表示させます。
menu.jsimport 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.jsimport 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"のリンクでもメニューを消すことができます。
補足
今回の内容については、DBが関係ないので、Railsの環境は正直不要で、Reactの環境だけあれば実験できます。
npmとwebpackだけで動作させた際のwebpack.config.jsの内容と、ちょっとしたソースの修正内容を記載しておきます。ディレクトリ構成
特段特別なものはありません。
publicにindex.htmlを置いて、src配下にスクリプトとスタイルシートを置きました。shellapp ├── node_modules(配下は割愛) ├── package-lock.json ├── package.json ├── public │ └── index.html ├── src │ ├── menu.js │ ├── parent.js │ └── parent.scss └── webpack.config.jswebpack.config.js
app/webpack.config.jsmodule.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') );
- 投稿日:2020-05-26T20:55:22+09:00
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日
- 投稿日:2020-05-26T19:34:52+09:00
herokuでデプロイした後の更新の仕方(2回目以降)
前提 : 変更分をGitHubデスクトップでコミット、プッシュしておく。
① herokuにログイン
ターミナル$heroku login② herokuにデプロイ
ターミナル$git push heroku master③ herokuでのマイグレーション
ターミナル$heroku run rails db:migrate
- 投稿日:2020-05-26T19:34:52+09:00
herokuをでデプロイした後の更新の仕方
$git add -a $git commit -m "Update application" $git push heroku masterテーブル追加、内容変更をしたとき
$heroku run rails db:migrate
- 投稿日:2020-05-26T19:34:52+09:00
herokuでデプロイした後の更新の仕方
$git add -a $git commit -m "Update application" $git push heroku masterテーブル追加、内容変更をしたとき
$heroku run rails db:migrate
- 投稿日:2020-05-26T19:17:04+09:00
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
データベースの削除&作成、テーブルに初期データが追加
- 投稿日:2020-05-26T18:45:41+09:00
【Rails】ローカルのファイルをリモートロポジトリに上げない為のコマンド
環境
Mac OS
Rails5.2.4ローカルで使用しているファイルをリモートリポジトリ(例:Github等)に上げない様にする方法
terminaltouch .gitignore
.gitignore
という名前のファイルをtouchコマンドで作成。作成場所は一番先頭のディレクトリー(Gemfileが置いてあるディレクトリ)にする。
このファイルに、リモートリポジトリには上げたくないファイルのパスを記載。
(画像を保存しているフォルダやパスワード等個人情報が記載されているファイルなどが対象となるかと
思います。)すでにリモートリポジトリにpushしてしまったファイルをリモートリポジトリから削除・取り消したい場合
terminalgit rm -r --cached 【削除したいファイル名】これで指定したファイルが、リモートから消えているはずです!
- 投稿日:2020-05-26T18:29:11+09:00
【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導入
Gemfilegem '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.rbclass 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.rbclass 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%;"%>これでうまくいくはずです。
最後に
今回は基本的な機能だけ実装しました。時間を見つけ次第色々改善していきます。
- 投稿日:2020-05-26T17:38:01+09:00
RailsプロジェクトにCircleCIを導入したがGemキャッシュが効いていない時の対応方法
Railsプロジェクトで使用するCICDツールとしてCircleCIを導入・ドキュメントを参考にして設定ファイルを書いていましたが、キャッシュが効いていない動きを見せていました。
▼キャッシュが効いておらず、毎回
bundle install
をしているような動き
以下、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の方で「
すでにキャッシュファイルあるので、キャッシュ保存スキップしますわ
」となってしまうから。キャッシュ保存が実行されるよう、今までのキャッシュを使用しないように設定ファイルを書き換える対応をしました。
▼修正箇所
p
▶︎この時点のコミット
以上の対応を行った結果、キャッシュはちゃんと効くようになりました。
参考
- 投稿日:2020-05-26T17:07:56+09:00
[rails]新規登録ページ作成で苦労した点
チームでの共同開発にあたり、
新規登録フォームを担当したため
振り返りまとめです。主な使用技術
- devise
- active_hash
- ウィザード形式
工夫した点
- ウィザード形式を取り入れており、より多くの情報を見やすく入力
-都道府県の入力は「active_hash」を実装しており、入力をしなくても選択式となるように工夫
苦労した点
- フロント実装部分 最も苦労しました。一見、簡単そうに見えますが以下の項目は時間かかりました、、、
- 生年月日フォームの実装
- フォームの大きさ
- ▼のアイコンの設置(これも生年月日)
これに関しては、下記記事へフォームの記述を記載しました
https://qiita.com/ki-ku/items/040d84626f864ea2640e
バリデーション
正規表現を用いたバリデーションの設定は苦労しました。
- メールアドレスは@とドメインを含む必要がある
- ユーザー本名は全角で入力させる量が多い
正直、もっと簡単に終わるかなと思っていたのですが
ウィザード形式にしたのもあり、作成したビューはログインページ含めて4ページ、
また単体テストもusersモデルとaddressモデルを行う必要がありました。結果として、どのアプリでも使うものになりますので
とても勉強になりましたが、予定よりも時間をつかてしまったため、
他のチームメンバーには大変助けて頂きました!とっても感謝です!
- 投稿日:2020-05-26T16:02:33+09:00
[rails]過去にフォークしたレポジトリを最新にしたい
具体的な方法は下記漫画を参考にしました。
- 投稿日:2020-05-26T14:43:23+09:00
Rails チュートリアルを分解してみる
やったこと
- Railsチュートリアルを最初から読んで、最終的に作るものを把握する
- 最終成果物を作成する過程をissues として自分で切り出した
なぜやったか
- 実践では何を作るかが最初に提示されるから
- 作成する対象を分解する必要があるから
- 最初に全体を分割して把握することで進捗を見えるようにしたいから
- issue 毎にタスクをさらに細分化する練習が必要だから
まずは最小限の機能が作りたいので、途中のch9-ch12 までは飛ばした。余裕があればアサインしたissue にToDoを整理し追加した上で、テストを書いてから実装するほうが良い。
次にやること
- ローカルに開発環境を作る
issue 切り出すだけですごい時間がかかった。
- 投稿日:2020-05-26T14:40:35+09:00
[rails]共同開発で苦労した点
苦労した点のメモ書き。
margeができない
プルリクエストも作成できており、削除もできるのに
margeボタンだけなかった。【原因】
共有するための「collaborators」が、うまくできていなかった。参考:
Githubのプロジェクト(オリジナル・リポジトリ)を複数人で共有する(Collaborators権限)コンフリクトに戸惑う
一番初めは何をどうすればいいのかわからない。
開発が進むと、コンフリクトは起きているのにボタンが押せない、、、対応策は下記に記載(ローカルでマスターを取り込む事で解決)
→https://qiita.com/ki-ku/items/1416795cdaac7b7545ae
- 投稿日:2020-05-26T14:40:35+09:00
[rails]共同開発で苦労した点:github編
苦労した点のメモ書き。
margeができない
プルリクエストも作成できており、削除もできるのに
margeボタンだけなかった。【原因】
共有するための「collaborators」が、うまくできていなかった。参考:
Githubのプロジェクト(オリジナル・リポジトリ)を複数人で共有する(Collaborators権限)コンフリクトに戸惑う
一番初めは何をどうすればいいのかわからない。
開発が進むと、コンフリクトは起きているのにボタンが押せない、、、対応策は下記に記載(ローカルでマスターを取り込む事で解決)
→https://qiita.com/ki-ku/items/1416795cdaac7b7545ae
- 投稿日:2020-05-26T13:29:21+09:00
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 install
やrails s
やrspec
などのコマンドは全て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 install
やrails s
やrspec
などのコマンドは全て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アプリのファイルをイメージに含めない
CMD
やENTRYPOINT
による処理は定義せず、Railsアプリが動く環境を整える事にのみ特化してイメージのサイズを小さくしています。bundle install
やrails 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 /waitDockerfileの末尾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.ymlversion: "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.0docker-compose.ymlの
base
サービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待しているARG_RUBY_VERSION
ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は2.7.1
を」指定するようにargs
にて定義しています。
base
サービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。&base
とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています(後述)。ビルドに関する設定はこのbase
サービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。
base
サービスのポイントはcache_from
でrails6api-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さんの記事 で紹介されていた書き方を流用させてもらっています。
console
とserver
の両サービスがbackend
というサービス定義をマージしているのですが、このbackend
のvolumesで${GEMS_CACHE_DIR:-bundle-cache}:/bundle
と定義されているvolumeはbundle install
先のディレクトリで、「環境変数GEMS_CACHE_DIR
がセットされていればその内容で、セットされていなければbundle-cache
という名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。.env
./.envMYSQL_ROOT_PASSWORD=root MYSQL_ALLOW_EMPTY_PASSWORD=1 DB_HOST=db MYSQL_FORWARDED_PORT=3806 MYSQL_FORWARDED_X_PORT=38060docker-compose.ymlのdbサービス内の環境変数を.envの内容から展開 しています。
.github/workflows/ci.yml
./.github/workflows/ci.ymlon: 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で新規にイメージがビルドされます
- ※
--build-arg BUILDKIT_INLINE_CACHE=true
オプションを指定している理由は、BuildKitが有効化された状態でビルドされたイメージをcache_from
で取り込むには、元のイメージがこのオプション付きでビルドされている必要がある為です- 実際に
cache_from
が指定されているのは、docker-compose.ymlのbase
サービスのbuild設定の箇所(=rails6api-development-cache
)- 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 dbDockerfileの最後でインストールしてある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.ymlbackend: &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 run
でconsole
サービスを立ち上げてその中で実行するようにしています。アプリのテストと並列で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 serverrailsを起動したい時は?
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
- 投稿日:2020-05-26T13:29:21+09:00
開発も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 install
やrails s
やrspec
などのコマンドは全て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 install
やrails s
やrspec
などのコマンドは全て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 /waitRailsアプリのファイルをイメージ(Dockerfile)に含めない
CMD
やENTRYPOINT
による処理は定義せず、Railsアプリが動く環境を整える事にのみ特化してイメージのサイズを小さくしています。bundle install
やrails 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 /waitDockerfileの末尾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.ymlversion: "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.0docker-compose.ymlの
base
サービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待しているARG_RUBY_VERSION
ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は2.7.1
を」指定するようにargs
にて定義しています。
base
サービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。&base
とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています(後述)。ビルドに関する設定はこのbase
サービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。
base
サービスのポイントはcache_from
でrails6api-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さんの記事 で紹介されていた書き方を流用させてもらっています。
console
とserver
の両サービスがbackend
というサービス定義をマージしているのですが、このbackend
のvolumesで${GEMS_CACHE_DIR:-bundle-cache}:/bundle
と定義されているvolumeはbundle install
先のディレクトリで、「環境変数GEMS_CACHE_DIR
がセットされていればその内容で、セットされていなければbundle-cache
という名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。.env
./.envMYSQL_ROOT_PASSWORD=root MYSQL_ALLOW_EMPTY_PASSWORD=1 DB_HOST=db MYSQL_FORWARDED_PORT=3806 MYSQL_FORWARDED_X_PORT=38060docker-compose.ymlのdbサービス内の環境変数を.envの内容から展開 しています。
.github/workflows/ci.yml
./.github/workflows/ci.ymlon: 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で新規にイメージがビルドされます
- ※
--build-arg BUILDKIT_INLINE_CACHE=true
オプションを指定している理由は、BuildKitが有効化された状態でビルドされたイメージをcache_from
で取り込むには、元のイメージがこのオプション付きでビルドされている必要がある為です- 実際に
cache_from
が指定されているのは、docker-compose.ymlのbase
サービスのbuild設定の箇所(=rails6api-development-cache
)- 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 dbDockerfileの最後でインストールしてある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.ymlbackend: &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 run
でconsole
サービスを立ち上げてその中で実行するようにしています。アプリのテストと並行して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 serverrailsを起動したい時は?
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
- 投稿日:2020-05-26T13:09:35+09:00
【学習メモ】ネスト構造について
Railsの英語のドキュメントを自分なりに解釈してみた。自分が確認したい、ネスト構造・1対1関係について翻訳。
出典
Ruby on Rails 6.0.3.1
Module
ActiveRecord::NestedAttributes::ClassMethods
activerecord/lib/active_record/nested_attributes.rbActive 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 endEnabling 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 endNow, 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 # => nilNote 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
- 投稿日:2020-05-26T12:26:44+09:00
【初心者向け】Herokuでデプロイしたら code=H10 desc="App crashed" とか言われた時の解決法
環境
- Ruby 2.5.1
- Rails 5.2.3
エラー内容
Herokuで初めてデプロイした際に
「Application error」が解決できずに時間を取りましたので備忘録。デプロイは「Verifying deploy... done.」
と成功しているようなのに、ブラウザが添付画像のようになって開かない...?なんで...?エラー原因を特定したい
ターミナル$ 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.pidtmp/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の働きについてはまだまだ勉強していきます?
- 投稿日:2020-05-26T12:12:31+09:00
【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 endwhile版
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
- 投稿日:2020-05-26T00:55:42+09:00
【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' # endgemを記述できたら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.rbFactoryBot.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 endfactolesにモデルを先に作成して値を設定しておくことで、specファイルの中で特定のメソッドにより簡単にインスタンスを生成したり、DBに保存したりできるようになります。(1回1回テストを行うための値を入れて、データを作成しないで良くなる。)
user = FactoryBot.build(:user)facutoryBotに対して、buildメソッドまたはcreateメソッドを使い、引数にfacutoriesフォルダ内で追加したモデルを指定することで~s.rbファイルで設定した値となる。
user = FactoryBot.build(:user, email: "")このように、モデルの後にカラム名の値を追加することにより、値を上書きできる。
2テストの記述を追加
specディレクトリにmodelsフォルダを生成。その中に各モデルのファイルを生成し実際のテストを行う。
user_spec.rbrequire '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.rbit "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.rbrequire '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
- 投稿日:2020-05-26T00:55:42+09:00
【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' endgemを記述できたら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.rbFactoryBot.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 endfactolesにモデルを先に作成して値を設定しておくことで、specファイルの中で特定のメソッドにより簡単にインスタンスを生成したり、DBに保存したりできるようになります。(1回1回テストを行うための値を入れて、データを作成しないで良くなる。)
user = FactoryBot.build(:user)facutoryBotに対して、buildメソッドまたはcreateメソッドを使い、引数にfacutoriesフォルダ内で追加したモデルを指定することで~s.rbファイルで設定した値となる。
user = FactoryBot.build(:user, email: "")このように、モデルの後にカラム名の値を追加することにより、値を上書きできる。
2テストの記述を追加
specディレクトリにmodelsフォルダを生成。その中に各モデルのファイルを生成し実際のテストを行う。
user_spec.rbrequire '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.rbit "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.rbrequire '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
- 投稿日:2020-05-26T00:32:49+09:00
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 外部キー制約を削除する