- 投稿日:2019-12-22T23:37:38+09:00
初学者向け「Deviseの導入の仕方」
deviseの導入で、一番最初にやる手順の紹介です。
deviseとは
Rubyのgemのひとつ。
新規登録機能やログイン機能の実装をとても簡単にしてしまうgem。まずはGemfile&インストール
Gemfilegem 'devise'追記したら、bundle install実行
$bundle installdeviseを使うためのコマンド
$rails g devise:installターミナルに以下の表示が出ればOKです
create config/initializers/devise.rb create config/locales/devise.en.yml =============================================================================== Some setup you must do manually if you haven't yet: 1. Ensure you have defined default url options in your environments files. Here is an example of default_url_options appropriate for a development environment in config/environments/development.rb: config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } In production, :host should be set to the actual host of your application. 2. Ensure you have defined root_url to *something* in your config/routes.rb. For example: root to: "home#index" 3. Ensure you have flash messages in app/views/layouts/application.html.erb. For example: <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p> 4. You can copy Devise views (for customization) to your app by running: rails g devise:views ===============================================================================簡単にまとめると、
1.デフォルトURLの設定
config/environment/development.rbにURLを記載してください。
※アクションメーラーを使う時に必要になります。2.rootURLの設定
config/routes.rbにroot URLを記載してください。
※deviseではデフォルトの挙動としてroot_urlにリダイレクトされるようになっているため、設定が必要になります。3.flashメッセージの設定
登録時やログイン時のflashメッセージを表示させるためには、views/layouts/application.rbに<p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p>と記載すると、表示されるようになります。
4.viewsのカスタマイズ
deviseではデフォルトでviewsが用意されていますが、それらを変えるには以下のコマンドを実行。$rails g devise:views以上、4つの内容が表示されています。
userモデルの生成
まずは、userモデルを生成する
$rails g devise usersmigrationファイルを見てみると、
db/migrate/create_devise_users.rb# frozen_string_literal: true class DeviseCreateUsers < ActiveRecord::Migration[5.2] def change create_table :users do |t| ## Database authenticatable t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" ## Recoverable t.string :reset_password_token t.datetime :reset_password_sent_at ## Rememberable t.datetime :remember_created_at ## Trackable # t.integer :sign_in_count, default: 0, null: false # t.datetime :current_sign_in_at # t.datetime :last_sign_in_at # t.string :current_sign_in_ip # t.string :last_sign_in_ip ## Confirmable # t.string :confirmation_token # t.datetime :confirmed_at # t.datetime :confirmation_sent_at # t.string :unconfirmed_email # Only if using reconfirmable ## Lockable # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at t.timestamps null: false end add_index :users, :email, unique: true add_index :users, :reset_password_token, unique: true # add_index :users, :confirmation_token, unique: true # add_index :users, :unlock_token, unique: true end endこういったファイルが生成され、自動でemailやpasswordなど、登録やログインに必要なカラムは追加されます。
追加で、nameなど必要なカラムがある場合は追記する。$rails db:migrateマイグレーションファイルを実行。
この時点で、routes.rbに
devise_for :users
が自動追加され、
http://localhost:3000/users/sign_in
http://localhost:3000/users/sign_up
にアクセスすれば、デフォルトのviewが見れるようになる。userビューの生成
デフォルトのviewはかなり質素なものなので、viewのカスタマイズしたい場合は、
$rails g devise:views usersここで、viewsが生成されますが、
rails routes
でルーティングを確認すると、Prefix Verb URI Pattern Controller#Action new_user_session GET /users/sign_in(.:format) devise/sessions#new user_session POST /users/sign_in(.:format) devise/sessions#create destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy new_user_password GET /users/password/new(.:format) devise/passwords#new edit_user_password GET /users/password/edit(.:format) devise/passwords#edit user_password PATCH /users/password(.:format) devise/passwords#update PUT /users/password(.:format) devise/passwords#update POST /users/password(.:format) devise/passwords#create cancel_user_registration GET /users/cancel(.:format) devise/registrations#cancel new_user_registration GET /users/sign_up(.:format) devise/registrations#new edit_user_registration GET /users/edit(.:format) devise/registrations#edit user_registration PATCH /users(.:format) devise/registrations#update PUT /users(.:format) devise/registrations#update DELETE /users(.:format) devise/registrations#destroy POST /users(.:format) devise/registrations#createとなっているため、コントローラーがdeviseになっているため、viewの中身を編集しても、反映されません。
viewのコードを反映させるためには、route.rbに
routes.rbdevise_for :users, controllers: { registrations: 'users/registrations', sessions: 'users/sessions', passwords: 'users/passwords' }を追記する。
rails routes
で確認。Prefix Verb URI Pattern Controller#Action new_user_session GET /users/sign_in(.:format) users/sessions#new user_session POST /users/sign_in(.:format) users/sessions#create destroy_user_session DELETE /users/sign_out(.:format) users/sessions#destroy new_user_password GET /users/password/new(.:format) users/passwords#new edit_user_password GET /users/password/edit(.:format) users/passwords#edit user_password PATCH /users/password(.:format) users/passwords#update PUT /users/password(.:format) users/passwords#update POST /users/password(.:format) users/passwords#create cancel_user_registration GET /users/cancel(.:format) users/registrations#cancel new_user_registration GET /users/sign_up(.:format) users/registrations#new edit_user_registration GET /users/edit(.:format) users/registrations#edit user_registration PATCH /users(.:format) users/registrations#update PUT /users(.:format) users/registrations#update DELETE /users(.:format) users/registrations#destroy POST /users(.:format) users/registrations#create無事、usersコントローラーになりました。
userコントローラーの生成
最後に、
$rails devise:controllers users各コントローラーが生成されます。
今回は、以上です。
deviseでは、デフォルトで様々なメソッド等が用意されているので、挙動をよく理解しつつ、使っていくことが大切ですね。
最後に
今回、Qiita初投稿です。
誤り等ありましたら、ご指摘ください。プログラミングを始めたての頃、deviseの挙動が全然分からず、苦い想い出があって、、、
まずは、deviseの記事を書いてみようと思いました。
- 投稿日:2019-12-22T22:44:37+09:00
Ovtoの課題
このエントリはOpal Advent Calendar 2019の8日目のエントリです。
Ovtoとは
私が作っているOpal用のWebフレームワークです。
Ovtoの課題
設計時にあまり考えてなかったのですが、ミドルウェアを作るときに記述が煩雑になるという問題がありそうです。
例えばpockeさんが作ってくれたovto-routerを見ると、stateとactionのそれぞれに
ovto_router
というprefixを付けていることが分かります。現状のOvtoではstate・actionの名前空間が一つしかないので、衝突を回避するためにprefixを付ける必要があります。このへんを簡潔に書けるようになるとミドルウェアがより作りやすくなるかなと思っています。何かうまくやる方法があるといいですね(まだノーアイデア)。
- 投稿日:2019-12-22T22:37:38+09:00
2019年のOpal
このエントリはOpal Advent Calendar 2019の1日目のエントリです。
2019年のOpal
今年のニュースはなんといってもOpal 1.0.0がリリースされたことでしょう
0.11の頃から、もう1.0と言っていいくらい安定して動いていましたが、正式に1.0が出るとやはり嬉しいですね。そのあと小さな修正が入って、最新版はOpal 1.0.2となっています。
2019年のOpalと私
個人的なところでは、4月のRubyKaigi 2019 福岡でOvtoというOpal用フレームワークを発表しました。
Ovtoについてはこのカレンダーの別の日にまた書くかもしれません。
- 投稿日:2019-12-22T22:30:45+09:00
GAE/Ruby SEでつくるDeployプルリク作成Bot
やること
デプロイ用のプルリクを作るときにmerge済みプルリクの一覧がみたいなというのとslackからコマンドでプルリクを作成したいなというモチベーションからslackコマンドでデプロイ用のプルリクを作成するBotを作ることにしました。
成果物
https://github.com/konchanxxx/pr-bot
要件
- 特定環境にdeployするためのプルリクを作成できる
- プルリクのdescriptionにmerge元ブランチにmergeされた差分プルリクが一覧化される
- slackからコマンド実行できる
技術選定
普段業務でGAE/Go SEは扱っているが、GAE/Ruby SEがベータ版になって使っていなかったので試しに採用してみました。またSinatraを使ったことがなかったのと簡単なツールを作るための軽量フレームワークが良さそうだなと思い使ってみることにしました。slackのコマンドはSlashCommandsというプラグインを使っています。
- GAE/Ruby SE
- Sinatra
- Slash Commands
実装
1. GAEの設定ファイルをかく
まずはGAEにdeployするための設定ファイルを作成します。ベータ版は2.5系のRubyがランタイムになっているのでruby25を指定してサービス名はリポジトリ名に合わせておきます。この時初めてGCPプロジェクトにdeployする場合はデフォルトサービスに何かしらdeployしておく必要があります。なので当該サービス以外をdeployする予定がない場合は指定しなくて良いです。
app.yamlruntime: ruby25 service: pr-bot entrypoint: bundle exec ruby app.rb includes: - env.yaml2. envにGITHUBのアクセストークンを設定する
app.yamlで指定したincludes部分は環境変数を設定するファイルを定義しています。GitHubで取得したアクセストークンをこちらに定義します。またcommitされないようにgitignoreに追加しておきましょう。
env.yamlenv_variables: GITHUB_ACCESS_TOKEN: xxxxxxxxxxx$ echo "env.yaml" >> .gitignore3. Rubyバージョンを2.5系にする
ランタイムに合わせてRubyのバージョンを設定しておきます。rbenvを使っていたので次の通り設定しました。
$ rbenv local 2.5.54. gem追加
今回使ってみることにしたsinatraと定数管理はconfigを使ってGithubクライアントようにoctokitを使っています。
Gemfilesource 'https://rubygems.org' gem 'sinatra' gem 'sinatra-contrib' gem 'config' gem "octokit", "~> 4.0"5. 定数管理
コマンドでprを作るmerge元ブランチとmerge先ブランチを定義しておきます。
config/settings.ymlorganization: name: konchanxxx repos: pr-bot: from: master to: release/production6. リクエストハンドラ
ここから急に説明が雑になりますがリクエストハンドラを実装します。主にやっているのはslackコマンドで受け取った引数をパースしてリポジトリとmerge先、merge元を設定してプルリクを作成しています。丁寧に実装するならハンドラにロジックを書かずに業務ロジックを集約するアプリケーション層を実装してあげると良いと思います。app.rbrequire 'sinatra' require 'sinatra/reloader' require 'octokit' require 'config' require_relative 'src/client' require_relative 'src/repository' require_relative 'src/pull_request' set :root, File.dirname(__FILE__) register Config get '/' do repository_name, from, to = params[:text].split organization = Settings.organization.name repository = Repository.new(organization, repository_name) repository_full_name = repository.repository_full_name from ||= repository.default_merge_from to ||= repository.default_merge_to begin res = PullRequest.create(repository_full_name, to, from) status 200 text = "Successfully created a pull request!! :rocket:\n#{res['url']}" rescue Octokit::UnprocessableEntity => e status 200 STDOUT.puts "Failed to create pull request. err=#{e}" text = 'Failed to create pull request. pull request already exists. :poop:' rescue StandardError => e status 500 text = "failed to create pull request. err=#{e}" end headers \ 'Content-Type' => 'application/json' body res(text).to_json end def res(text) { text: text, response_type: 'in_channel' } end7. 各モジュールの追加
ハンドラで利用しているロジックを追加しておきます。
src/client.rbrequire 'octokit' class Client class << self def new @client ||= Octokit::Client.new access_token: ENV['GITHUB_ACCESS_TOKEN'] end end endプルリクを扱うモジュール。ここでmergeされたプルリクエストの差分だけを抽出してプルリクのdescriptionに設定するようにしています。
src/pull_request.rbrequire_relative 'client' class PullRequest MERGE_PR_MESSAGE_REGEXP = /Merge pull request #(?<number>\d+) .*/.freeze attr_reader :number, :title, :link def initialize(number, title, link) @number = number @title = title @link = link end class << self def create(repo, to, from) title = deployment_title(to, from) body = deployment_description(repo, to, from) client.create_pull_request(repo, to, from, title, body) end def merged_pull_requests(repo, to, from) client.compare(repo, to, from).attrs[:commits].map do |d| m = d.attrs[:commit][:message].match(MERGE_PR_MESSAGE_REGEXP) next if m.nil? pull_request = client.pull_request(repo, m[:number]).attrs new(pull_request[:number], pull_request[:title], pull_request[:html_url]) end.compact end def deployment_title(to, from) "deploy #{from} to #{to}" end def deployment_description(repo, to, from) pull_requests = merged_pull_requests(repo, to, from) links = pull_requests.map do |pr| "- [#{pr.title}](#{pr.link})" end.join("\n") <<~DESCRIPTION deploy #{repo} from #{from} to #{to} as follows... :rocket: #{links} DESCRIPTION end private def client @client ||= Client.new end end endsrc/repository.rbclass Repository attr_accessor :organization, :repository_name def initialize(organization, repository_name) @organization = organization @repository_name = repository_name end def repository_full_name "#{organization}/#{repository_name}" end def default_merge_from Settings.organization.repos.send(repository_name).from end def default_merge_to Settings.organization.repos.send(repository_name).to end end8. GAE/Rubyにデプロイ
gcloud SDKを使ってGAEにデプロイします。デプロイに必要な情報はapp.yamlに定義しているので下記のコマンドを実行するだけで大丈夫です。
$ gcloud app deploy
9. Slash Commandsの設定
slackからコマンド実行するためにslash commandsというアプリを追加します。
URLの箇所にデプロイしたGAEインスタンスのhostを追加します。10. slackから実行してみる
slask commandsの設定でコマンドは自由に設定することができます。今回はdeployというコマンドにしました。
11. プルリクが作成されたことを確認する
GitHubの対象リポジトリでプルリクが作成されていることを確認します。mergeされたプルリクのリンクもつけているので良い感じにmerge対象のものを確認することができます。
感想
GAE/RubyとSinatraを作ってかなり手軽にBotを作成することができました。簡単なツールはこの組み合わせで作ると楽かもしれません。以前Cloud Runで似たようなツールを作ったことがありましたがdockerイメージを作らないような場合だとこちらの方が手軽かもしれません。あと次はHanamiとかも使ってみたいと思います
- 投稿日:2019-12-22T22:24:01+09:00
#Ruby で gem install や #Rails で bundle install すると Could not find xxx in any of the sources とエラーが出る場合は gem 名をタイポしてない?
どんな時のエラー?
gem が見つからなかった時のエラー。
例
"Could not find gem 'email-spec' in any of the gem sources listed in your Gemfile."
解決
例えばこのgem
例えばgithub での レポジトリ名がハイフン区切りだというからと言って安心してはいけない。
https://github.com/email-spec/email-specgem名はアンダースコア区切りだ。
名前を間違っていたらinstallできるはずないよね。
Ref
"Could not find gem 'email-spec' in any of the gem sources listed in your Gemfile."
Original by Github issue
- 投稿日:2019-12-22T22:19:40+09:00
opal-rspecのOpal 1.0対応状況
このエントリはOpal Advent Calendar 2019の23日目の記事です。
2019年はOpalの1.0がリリースされました(めでたい!)。それに伴いopal-rails等は1.0対応した新バージョンが出ているのですが、opal-rspecはいまのところ未対応となっています。
が、アルファ版であるopal-rspec v0.8.0.alpha2は、Opal 1.0と混ぜて使えるようバージョン指定が緩められています。まだ1.0完全対応ではない?ようですが、Ovtoのテストくらいなら普通に動いています。
ということでopal-rspecのOpal 1.0対応状況でした。
- 投稿日:2019-12-22T21:25:16+09:00
ダックタイピングの何が良いのか
CBcloud Advent Calender 23日目の記事です。
普段動的型付け言語を使っていて、ダックタイピングの何が良いのかいまいちわからない、という声を聞いたのでRubyを例に解説してみます。ダックタイピングとは
ダックタイピングについては以下のフレーズとともに語られることが多いかと思います。
"If it walks like a duck and quacks like a duck, it must be a duck"
(もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルに違いない)これをプログラミングの用語で言い換えると、「オブジェクトがアヒルのように歩き、アヒルのように鳴くメソッドを実装しているなら、そのオブジェクトのクラスが何であれ、アヒルと同じ役割を果たせる」と言えるかと思います。
「そのオブジェクトのクラスが何であれ」という表現をしたように、ダックタイピングとは、どんな特定のクラスとも紐付かないインタフェースを定義することです。
定義されたダックタイプのインタフェースの存在は暗黙的です。下記はダックタイプDuckを定義した(と言える)コードです。def check_health_condition(duck) duck.quack endこのメソッドは、引数がquackメソッドを実装していることを期待しています。言い換えると「この引数はアヒルのように振る舞うオブジェクトであれば、クラスは何でも良い」ということを伝えています。
ダックタイピングとは、どんな特定のクラスとも紐付かないインタフェースを定義することだと言った通り、このquackメソッドはどのクラスのオブジェクトが実装していても良いのです。このcheck_health_conditionメソッドに引数を渡すために、クラスやオブジェクトを定義してみます。
ここで定義したanimal、foo、barのいずれのオブジェクトもcheck_health_conditionメソッドに渡して正常に動作することができます。class Foo def quack "quack" end end class Bar def quack "quack" end end animal = Object.new animal.define_singleton_method(:quack) { "quack" } # このオブジェクトにだけquackメソッドを特異メソッドとして定義 foo = Foo.new bar = Bar.newダックタイピングの効果
上記ではダックタイプDuckのquackメソッドというインタフェースを定義しました。
これにより、コードの柔軟性が向上します。
それだけでなく、個人的に大きな効果だと思っているのが、「オブジェクト間で送受信されるメッセージに設計がフォーカスされる」ことです。
メソッドを定義するときに「引数は何クラスにすべきかな?」という考えではなく、「このメソッドの引数はどんなメソッドを持っているべきかな?」という振る舞いに着目した考えに自然となっていきます。その視点は、オブジェクト同士がメッセージを送り合って連携するオブジェクト指向設計においては非常に重要です。
アプリケーション内のあらゆるオブジェクトが、他のオブジェクトが自身の期待する振る舞いをしてくれると信頼することができれば、設計の柔軟性は非常に高くなります。ただし、この柔軟性はうまく使わなければ恐ろしいほど複雑な設計になってしまうことに注意しなくてはなりません。
そうならないためにも、ダックタイプのインタフェースは慎重に設計しなければなりません。ダックタイピングの例
最初のアヒルの例はだいぶ苦しかったのでもっと具体的な例を上げて説明していきます。
ダックタイプを意識せずにコードを書いてみる
物流倉庫の業務アプリケーションを例にしてダックタイプを意識しないコードを書いてみます。
このアプリケーションでは、オペレーターが荷主から輸送の依頼を受けて荷物の輸送内容をフォームに入力します。入力された内容は、輸送するドライバーや倉庫作業者に指示として送られます。class Transport def prepare(preparers) preparers.each do |p| case p when Driver p.find_available_vehicle p.move_vehicle when WarehousePicker p.pick_cargos end end end endこのコードでは引数の中のクラスを想像して実装していることは明白です。
この時点でprepareメソッドは下記の依存を抱え込んでいます。eachはコレクションの一般的な操作のため、カウントしませんでした。
- Driver
- find_available_vehicle
- move_vehicle
- WarehousePicker
- pick_cargos
prepareメソッドはこれらに変更があれば影響を受ける可能性があるということです。さらに今後アプリケーションが拡張され、車両の駐車スペース管理者クラスや、輸送先倉庫の担当者クラスが追加されたとき、このcase文が膨れ上がっていくことになります。当然、その分の依存を抱え込んでいくことになります。依存を抱えるほど、変更コストは高くなりますし、理解するのも困難になります。
当然のことながら、prepareメソッドにはDriverクラスかWarehousePickerクラスのインスタンスしか渡せません。
以降で、ダックタイピングを用いてprepareメソッドから依存を取り除いて柔軟性を上げていきます。ダックタイプを使って依存を減らす
prepareメソッドの目的は、「輸送の準備をすること」です。引数はその目的を達成するためにここに渡されてきます。prepareメソッドが引数に期待するのは、「輸送に必要な準備をしてくれること」です。引数の具体的なクラスが何であるかは関心がありません。引数が期待する動作をすると信頼してコードを書いてみると下記のようになります。
class Transport def prepare(preparers) preparers.each do |p| p.prepare_transport end end end非常にシンプルになりました。のちに車両の駐車スペース管理者クラスや、輸送先倉庫の担当者クラスが追加されたとしても、それらのクラスがprepare_transportを実装していればprepareメソッドを変更する必要はありません。
prepareメソッドが依存するのはprepare_transportメソッドだけです。引数の動作については信頼し切っているため、期待される動作をこなすことは引数オブジェクトの責任になります。
さて、仮にこの引数に型名をつけるとしたら何が適切でしょうか?
「輸送の準備をすること」がprepareメソッドの目的なので、「Preparer(準備者)」が良いでしょう。
このPreparerこそが隠れていたダックタイプです。このダックタイプを見つけたことで、prepareメソッドに柔軟性がもたらされ、変更に強い設計となりました。ダックタイピングあるいは動的型付け言語の問題点
ダックタイピングや動的型付け言語でよく聞く問題について考えます。
コードが理解しにくくなる
Rubyを例にダックタイピングを説明してきましたが、「Javaのインタフェースでも同じことができるでしょ?明示的な型として見えないダックタイピングよりも明示的に型を宣言できる方がわかりやすくない?」という疑問を持つ方も多いと思います。
その疑問は全くその通りだと思います。Preparerインタフェースを定義して、prepareメソッドに渡す可能性のあるクラスにimplementsしてprepare_transportを実装すれば同じことができます。また、型が明示的であれば、引数が何のメソッドを実装していなければならないかの理解が容易です。
しかし、その調子でインタフェースを定義していくとものすごい数になるのではないでしょうか。それを続けるだけでも大きなコストになりそうです。
確かに、ダックタイピングでは抽象に依存しているため、コードを理解するのに少し時間がかかります。しかし、理解できればその柔軟性により変更コストを少なくできる場面が出てくると思います。型に起因する実行時エラーが起きる
動的型付け言語において、引数のクラスを理解していないと正しく動かないメソッドは、新しいクラスが現れると当然失敗します(想定しない型に起因するエラーで)。そのとき、「静的型付け言語のように型のエラーを事前に教えてくれないから、編集したコードから離れたところに問題があっても実行するまで気づけなかった。だから動的型付け言語は苦手だ。新しいクラスに対応するロジックを追加しなくては...」と考えたことがある方もいるのではないでしょうか?
実行時エラーに注意を逸らされそうになりますが、本当の問題はそのメソッドが具体的なクラスに依存していることです。その依存を剥がすリファクタリングをしていくと、最終的に抽象(ダックタイプ)に依存するようになります。そのダックタイプこそが、依存すべき安定したインタフェースです。型に起因するエラーは、設計が具体的なクラスに依存していることへの警告として捉えるべきなのかも知れません。おわりに
偉そうなことを書いてしまいましたが、オブジェクト指向設計実践ガイドに影響されまくっていますので、ぜひそちらも読んでみてください。
ダックタイピングはうまく使えば柔軟な設計が実現できますが、宣言的に見づらい部分もあるのでその問題への対策をテスト等で工夫する必要があります。その方法についても書籍では紹介されています。
- 投稿日:2019-12-22T21:02:46+09:00
超初心者がrailsで躓いた基礎的コード5選
はじめに
プログラミング超初心者だった私(ビズサイドでIT業界に関わった経験もなし)ですが、プログラミングを身につけ、IT業界に飛び込んでいくために、今年の9月からrailsをメインで扱っているRUNTEQというプログラミングスクールに通い始めました。
RUNTEQは、ツイッターなどで最近流行りの未経験者向けのプログラミングスクールとは一味違ったスクールで、通学者の方の多くは、もともと違う言語などで実務を経験していたり、実務未経験でも何かしら別の教室でプログラミングを触った経験があったり、独学でチュートリアルをやっていたりする人も多く、そういった方向けのカリキュラムになっています。
そんなことで、私自身も「入学する前に、チュートリアルは最低限やっといて!」って、講師の方に言われていたにも関わらず、時間が足りず、プロゲートを一周しかせず(gemすら使ったことない)、突撃していってしまった私の贖罪も込めて、これからプログラミングを学びたいと思っている誰かのためになればと筆を取りました!
そんな基本的なコードにしか触れていない初心者が、現役エンジニアからコードレビューなどを受ける中で、詰まった(理解に苦しんだ)コードを、徒然なるままに、備忘録的に記録する目的も兼ねて、紹介していきたいと思います!
※カリキュラムのネタバレにならないように書いていますが、不都合あれば修正します!
1、form_with
初学者の自分にとって一番ブラックボックスに感じたのはform_withでした。RANTEQの課題にも頻繁に出てきますし、避けては通れません。
「なんで、この書き方でちゃんとデータがDBに登録されるの?」
「何も書いてないのに、新規投稿の時と、編集の時で出力先がきちんと区別されてる!」
「入力項目にないboard_id(外部キー)をURLへ載せてDBに送りたいときはどうするの?」
...etc
私が躓いたポイントを紐解いていきます!
form_withの第一引数 model: @○○
最初は見よう見まねで実装していましたが、「いきなり@userって出てきたけど、どういうこと!?」ってなりました。
form_withの性質として、データーベースに保存する場合、form_withの引数にはモデルクラスのインスタンスを指定するとなっているようです。
モデルクラスのインスタンスとは保存したいテーブルのクラスのインスタンスのことです。
で!!!ここからがミソなんですが、例えば、掲示板アプリの場合は、form_withを使って入力画面を作る時、「掲示板を作成する」、「掲示板を編集する」の2パターン出てきます。この時の入力フォームはどうなるのかというと、なんと「全く同じ書き方」です!
出力先も違うし、編集する場合は編集前のデータを入力欄に表示しておいたりしなければいけないと思いますが、これをform_withは良しなにやってくれるようです。
以下、事例です。①usersテーブルに新たにレコードを作成。
controllerは以下になります。
users_controller.rbdef new @user = User.new endこの「@user」をform_withの引数に指定するわけです。
viewの書き方は、
<%= form_with model: @user do |form| %> <%= form.text_field :name %> <%= form.submit %> <% end %>新規作成の場合は、コントローラーで作成したインスタンスがnewメソッドで新たに作成されて何も情報を持っていないので、自動的にcreateアクションへ送られます!pathの指定も必要ありません。
ブラウザで見るともちろん入力フォームは空欄になっています。すごく便利ですね!
②usersテーブルのレコードを編集する場合
controllerは以下になります。
def edit @user = User.find(params[:id]) endviewの書き方は新規投稿の時と全く同じ。
<%= form_with model: @user do |form| %> <%= form.text_field :name %> <%= form.submit %> <% end %>findメソッドなどで作成され、すでに情報を持っている場合はupdateアクションへ自動的に振り分けてくれます。
そして、ブラウザで見ると、入力項目には、編集前の記載内容がデフォルトで入った状態で表示され、そこから編集をして投稿できる仕様になっています。
最初、ここの部分があまりよく理解できていませんでした。
ちなみに、さらっと書きますが、ルーティングでネストしている時の書き方は、model: [@board, @comment]みたいな感じになります!さらっと書いてますが、ここもハマるポイントかもです!
urlを指定する場合
form_withはpathを指定しなくても良いと書きましたが、ルーティングがうまくいかない時などは直接、pathを指定することもできます。方法は2通りです。
ルーティングのpathをそのまま指定する、コントローラー名とアクション名を書く方法です。
以下はルーティングのpathをそのまま指定する方法です。<%= form_with @user, url: login_path do |form| %> <%= form.text_field :name %> <%= form.submit %> <% end %>form_withで入力項目にないboard_idとか、user_idを送る方法
form_withのinput部分の話です。
通常、form_withは、入力項目に入力した情報をHTTPリクエストのbody要素に格納してDBへ送信するイメージを持っていたのですが、外部キーを設定しているDBへ、データを送る場合は、入力項目の情報だけを送信しようとして、「user_idがありません」みたいなエラーが出ました。
この場合に、何とかしてパラメーターに外部キーのidを送りたいって時に出てきたのが、
<%= form.hidden_field :カラム名, value: "値" %>って書き方です!
例えばユーザーidに現在ログインしているユーザーのidを入れたい場合は下記のように記述します。
<%= form.hidden_field :user_id, value: current_user.id %>ただ、課題では、ストロングパラメーターを使って書く方法を学んだので、課題ではこの書き方は使っていません!
2、renderの引数
部分テンプレートを呼び出す時にrenderを使うと思いますが、ここも引数の渡し方が色々あり、よく詰まりました。知って入れば、何の事は無いのですが、知らないと結構詰まりました。
部分テンプレート内でローカル変数を使うための引数の指定
部分テンプレートは、汎用性を上げるために、部分テンプレート内では、インスタンス変数をなるべく使わないようにしなければならないということをコードレビューの中で学びました。
最初は「ん??」って、感じでしたが、こう理解しました。
例えば、以下のような検索フォームの部分テンプレートを作る場合、ローカル変数のみで書くと、以下のようになります。
_search_form.html.erb<%= search_form_for search do |f| %> <div class='input-group mb-3'> <%= f.search_field :title_or_body_cont, placeholder: "検索ワード", class: 'form-control' %> <div class='input-group-append'> <%= f.submit value: "検索", class: 'btn btn-primary' %> </div> </div> <% end %>serachというローカル変数は、@searchというインスタンス変数でも書けますが、あえてローカル変数で書きます。
この場合、呼び出し元でのrenderの書き方はどうなるかというと
view<%= render 'search_form', search: @search %>こんな感じになります。
オプションをきちんと書くなら、
render partial: 'search_form', locals: { search: @search }
です。
ここの、「locals: { search: @search }」の部分は、私は言葉でこういう風に宣言しているものと理解しています。
「部分テンプレート内にあるsearchは@searchとして扱いますのでよろしく!」
要は、@searchを部分テンプレート内に引き渡しますよと。部分テンプレート内にpathを引き渡す
部分テンプレート内にpathを引き渡さなければならない場合はどのように書けばいいのか一瞬詰まりました。
これもローカル変数引き渡しの時と同様の考えで解決できます。
例えば、以下の部分テンプレートにboards_pathを引き渡したいとします
_search_form.html.erb<%= search_form_for search url: url do |f| %> <div class='input-group mb-3'> <%= f.search_field :title_or_body_cont, placeholder: "検索ワード", class: 'form-control' %> <div class='input-group-append'> <%= f.submit value: "検索", class: 'btn btn-primary' %> </div> </div> <% end %>呼び出し元では、
view<%= render 'search_form', url: boards_path, search: @search %>こうすることで、「部分テンプレート内にあるurlを、boards_pathとして扱います。」
要は、部分テンプレート内にboards_pathを引き渡しています。で、部分テンプレート側では、urlで、pathを受け入れています。
こうすることで、pathを部分テンプレート内にいちいち書かなくても、呼び出し元で自由に調整でき、部分テンプレートは一つで済みます。
3、Userモデルのバリデーション
Userモデルのバリデーションの中で、この辺のとこが意味プー(死語)になってました。
validates :password, length: { minimum: 3 }, if: -> { new_record? || changes[:crypted_password] } validates :password, confirmation: true, if: -> { new_record? || changes[:crypted_password] } validates :password_confirmation, presence: true, if: -> { new_record? || changes[:crypted_password] }if: -> { new_record? || changes[:crypted_password] } ってなんやねん!?
こう理解しました。
例えば、ユーザーの名前を変えたいが、パスワードはそのままにしたいって場合を想定します。
この場合、if: -> { new_record? || changes[:crypted_password] } の記述がないと、毎回以下の余計なバリデーションが実行されることになっちゃいます!
validates :passwordについては、
→length: { minimum: 3 }, presence: true, confirmation: truevalidates :password_confirmation,については、
→presence: trueユーザーの名前しか変更しないのに、新規ユーザーを作ると勘違いして、パスワードのバリデーションが実行されてしまうので、「同じパスワードが存在する」と誤検知して、エラーが起きます。
これを解消するには、
if: -> { new_record? || changes[:crypted_password] }のnew_record?の部分で、新しいパスワードが発行されているかを判断し、changes[:crypted_password]の部分で
、フォームから送られてきたパスワードと、usersテーブルに保存されているパスワードに変更があるかどうかを比較しています。もし、変更があるなら、パスワードのバリーデーションが実行されるし、新しいパスワードが発行されておらず、パスワードの変更が無い場合は、Userモデルのパスワード部分のバリデーションは実行されないので、名前だけ変更してもエラーにならないようになります!
4、sorceryで良しなにやってくれてるけど、current_userって何?
ここを詳しく厳密に考えると、ややこしくなりそうなんで、もし、gemを使わず、login機能を実装したらどうなるのか簡単に調べてみました。
current_user自体は以下のように定義します。application_controller.rbdef current_user @current_user = User.find_by(id: session[:user_id]) endsession[:user_id]は、ページを移動してもユーザー情報を保持し続けるために、sessionという特殊な変数を使うようrailsに実装されているようです。
sessionに値を代入すると、ページを移動してもブラウザに残り続けて、ブラウザはそれ以降のアクセスでsessionの値をRailsに送信します。このsessionが、ページが遷移してもユーザー情報を保持し続けることができるキーマンのようですね!ついでに、この先にも簡単に触れると、色々省略してますが、受けるcontrollerはこうなります。
user_sessions_controller.rbclass User_SessionsController < ApplicationController skip_before_action :current_user, only: [:login] def login @user = User.find_by(name: params[:name], email: params[:email]) if @user session[:user_id] = @user.id redirect_to user_path(session[:user_id]) else render 〜 色々省略 end end endログインすることでsessionに値が入るので、定義した@current_userが使えるようになるわけですね!
5、sorceryのrequire_login
これもgemを想定せずに実装してみると、こうなります。
application_controller.rbdef authenticate_user if @current_user == nil redirect_to login_path end endすごく単純なんですが、gemを使っているとこの辺色々省略されているので、ちゃんと考えることも重要かもしれません。
6、他人の掲示板の編集や削除ができなくする場合の書き方
ここでの話は、viewで、分岐を使って、ログインユーザー自身が作成した掲示板にしか編集ボタン、削除ボタンを表示させない実装はやった上で、直接、URLからアクセスすることもできないようにする方法です。
普通に書くと、
@board = Board.new( title: board_params[:title], body: board_params[:body], user_id: current_user.id )って感じの実装になると思うのですが、ここでもっとスマートにかけます。ストロングパラメーターとも組み合わせて、
@board = current_user.boards.new(board_params)こんなにシンプルになります。
こんな書き方があるのは知らなかった・・・実はこの感じの書き方が、エンジニアの実務している方々は普通に書いているみたいなのですが、プロゲートでは全く触れらていないし、深いなーと思いました。
最後に
他にも色々と詰まりまくってますが、力尽きたので、この辺にしておきます。
今後も頑張っていきましょう!!
- 投稿日:2019-12-22T20:47:40+09:00
三項演算子
学習を進めていて、三項演算子の文法がなかなか覚えられなかったので、ノートにまとめておきます。
def show_post last_post = posts.last if last_post.present? last_post.text? ? last_post.text : '画像が投稿されています' else 'まだ投稿はありません' end end↓この部分を「三項演算子」という.
last_post.text? ? last_post.text : '画像が投稿されています'文法は、以下の通り。
条件式 ? trueの時の値 : falseの時の値上記の文では、
条件式:もしlast_postが存在していたら?
trueの時の値:last_post.text
falseの時の値:'画像が投稿されています'というテキスト
となっています。
最初の文をifを用いて書き直すとこうなります。def show_post if (last_post = posts.last).present? if last_post.text? last_post.text else '画像が投稿されています' end else 'まだ投稿はありません。' end end使いこなすと色々記述が簡単に書けそうですね^^
- 投稿日:2019-12-22T20:11:37+09:00
Amazon Linux 2 初期設定 & Rails5.2 + Capistrano3.11で自動デプロイ
はじめに
EC2にRails環境を構築、Capistranoで自動デプロイするための初期設定を紹介していきます。
主に個人開発やテスト環境で扱う最低限の設定のみ記載します。
コピペで大体できるはず、、!環境
OS : Amazon Linux 2
Webサーバー : Nginx
アプリケーションサーバー : puma
メールサーバー : postfix
DB : RDS(MySQL)前提
macOS
AWSにてEC2、RDSを構築済みEC2に接続
ローカルPCのターミナル."sudo ssh -i [keyペアファイル] ec2-user@[パブリックIP]" #確認が出るので "yes" #PCのパスワードを要求されるので、入力。初期設定
- yumをアップデート
SSH接続したEC2インスタンス内.yumをまとめてアップデート $ sudo yum update -y $ sudo yum install -y yum-cron EPELを有効化 # sudo amazon-linux-extras install epel セキュリティのみ自動でパッチがあたるようにcronの設定ファイルを変更します。 $ vi /etc/yum/yum-cron.conf 変更点 update_cmd = security apply_updates = yes email_to = [任意] 設定したら起動及び再起動自動の自動実行を設定 $ systemctl start yum-cron $ systemctl enable yum-cron
- タイムゾーンを日本時間に変更します。
タイムゾーンをJSTに設定 $ sudo ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime タイムゾーンがJSTに変更されたか確認 $date /etc/sysconfig/clockの設定変更を変更 $ sudo vi /etc/sysconfig/clock/etc/sysconfig/clock.以下の内容を変更 ZONE="Asia/Tokyo" UTC= true
- EC2インスタンスを再起動し、全てのプロセスが新しいタイムゾーンを設定を利用するようにします。
SSH接続したEC2インスタンス内.$ sudo rebootユーザー追加
セキュリティ強化のため、ec2-userのアカウント名をそのまま使うのではなく、
ec2-userを停止にして、新しいユーザでログインできるようにします。SSH接続したEC2インスタンス内.新規ユーザー作成 $ sudo adduser my-user wheelグループに追加 $ sudo usermod -G wheel my-user sudo権限を付与 $ sudo visudoviが開くので、行末に「my-user ALL=(ALL) NOPASSWD: ALL」を追加
visudo.my-user ALL=(ALL) NOPASSWD: ALL:wqで保存
ec2-userのauthorized_keysをmy-userの.sshディレクトリにコピーし、適切なパーミッションを設定します。
SSH接続したEC2インスタンス内.$ sudo rsync -a ~/.ssh/authorized_keys ~my-user/.ssh/ $ sudo chown -R my-user:my-user ~my-user/.ssh $ sudo chmod 700 ~my-user/.ssh/ $ sudo chmod 600 ~my-user/.ssh/**SSH接続設定
EC2のシークレットキーをローカルPCの以下に配置
/Users/[ユーザー]/.ssh/***.pemローカルPCのターミナル..sshディレクトリに移動 cd .ssh シークレットキーを持っている他のユーザーもログインできるように権限を変更 $ chmod 600 ***.pem 権限が変更されているか確認 $ ls -al -rw------- になっていればOK configに接続設定を記載 $ vi configローカルにショートカットを作成
/.ssh/config.Host [ホスト名] HostName [パブリックIP] Port 22 User my-user IdentityFile ~/.ssh/***.pem:wqで保存
my-userで接続できるか確認します。
ローカルPCのターミナル.$ ssh [ホスト名]接続できたら、rootになれるか確認
SSH接続したEC2インスタンス内.$ sudo su -SSHのセキュリティ設定
rootとec2-userでのSSHを禁止する
SSH接続したEC2インスタンス内.$ sudo vi /etc/ssh/sshd_config/etc/ssh/sshd_config.以下を追加 #ec2-userでのログインを禁止 DenyUsers ec2-user #暗号化アルゴリズムの指定 Ciphers aes128-ctr,aes192-ctr,aes256-ctr #鍵付きハッシュのアルゴリズム MACs hmac-sha2-256,hmac-sha2-512 以下を変更 AuthorizedKeysFile .ssh/authorized_keys PermitRootLogin no:wqで保存
SSH接続したEC2インスタンス内.設定を反映 $ service sshd reloadRVMとRubyをインストール
SSH接続したEC2インスタンス内.Rootに変更 $ sudo su - RVMをインストール # gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 # curl -sSL https://get.rvm.io | bash Rubyのインストールに必要なライブラリをインストール # yum install -y gcc-c++ patch readline readline-devel zlib zlib-devel libyaml-devel libffi-devel openssl-devel make bzip2 autoconf automake libtool bison iconv-devel wget libxml2 libxml2-devel libxslt libxslt-devel # yum install -y bzip2-devel lcms-devel libjpeg-devel libX11-devel libXt-devel libtiff-devel ghostscript-devel libXext-devel libpng-devel ImageMagickを使うのでインストール(任意) # yum install -y ImageMagick ImageMagick-devel RVMを有効にするために一旦ログアウトし、もう一度SSH接続してrootになります。 Rubyをインストール 今回は2.3.8を使用 # rvm install 2.3 # rvm use 2.3.8 # ruby -v bundllerをcapistranoが使うのでインストール # gem install bundlerMySQLをインストール
SSH接続したEC2インスタンス内.mariaDB削除 $ sudo yum remove mariadb-libs $ sudo rm -rf /var/lib/mysql mysqlインストール $ sudo yum install mysql Amazon RDSヘ接続 $ mysql -h {ENDPOINT} -P 3306 -u {Username} –p パスワードが要求されるので、RDSで設定したパスワードを入力posfixのインストール
SSH接続したEC2インスタンス内.# yum install postfix 設定ファイルを変更 # vi /etc/postfix/main.cf 適宜各要素を以下のように変更 inet_interfaces = all mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain home_mailbox = Maildir/ smtpd_banner = $myhostname ESMTP unknown allow_min_user = yes 自動起動設定 # systemctl enable postfix # systemctl start postfixNginxをインストール
SSH接続したEC2インスタンス内.# yum -y install nginx NginxでSSL通信する場合は鍵を配置 # vi /etc/nginx/conf.d/server.key サーバー証明書、中間証明書を配置 # vi /etc/nginx/conf.d/server_geotrustca.crt 自動起動設定 # systemctl start nginx # systemctl enable nginx 設定ファイルの文法チェック # nginx -tredisのインストール
SSH接続したEC2インスタンス内.$ rpm -ivh http://rpms.famillecollet.com/enterprise/remi-release-7.rpm $ yum install --enablerepo=epel,remi redis $ systemctl enable redis $ systemctl start redisRailsディレクトリとShared内ファイルの作成
Capistranoのディレクトリ構成に合わせます。
SSH接続したEC2インスタンス内.sharedディレクトリを作成 mkdir -p /usr/local/rails/[デプロイするアプリ名]/shared/ mkdir -p /usr/local/rails/[デプロイするアプリ名]/shared/public/assets/ mkdir -p /usr/local/rails/[デプロイするアプリ名]/shared/config ※Webpackerを使う場合、アセットパイプラインでエラーが出る場合は空ファイルを配置 touch /usr/local/rails/[デプロイするアプリ名]/shared/public/assets/.manifest.json touch /usr/local/rails/[デプロイするアプリ名]/shared/public/assets/.sprockets-manifest.json secret_key_baseを.envで管理(デモ環境なのでmaster.keyは使わない) vi /usr/local/rails/[デプロイするアプリ名]/shared/.envRuntimeErrorになるので、rake secretで生成した文字列を貼り付けます。
bundle exec rake secret ff9906beb21fd77ca11aa433d42b7caa720f37641a27bf1617e67894a4fca2c64526af874d1ef1cfac4372cabdf18127110b31bb766c46ef35dbab62736b04e3shared/.envSECRET_KEY_BASE: ff9906beb21fd77ca11aa433d42b7caa720f37641a27bf1617e67894a4fca2c64526af874d1ef1cfac4372cabdf18127110b31bb766c46ef35dbab62736b04e3SSH接続したEC2インスタンス内.ファイルの所有者を変更 chown -R my-user:my-user /usr/local/rails※AWS認証情報を扱う場合は参考にしてください(CLI設定がおすすめ)
https://dev.classmethod.jp/cloud/aws/how-to-configure-aws-cli/node.jsとyarnを使う場合はインストール
SSH接続したEC2インスタンス内.node.jsのインストール $ curl --silent --location https://rpm.nodesource.com/setup_8.x | sudo bash - sudo yum install nodejs yarnのインストール $ curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | sudo tee /etc/yum.repos.d/yarn.repo $ sudo rpm --import https://dl.yarnpkg.com/rpm/pubkey.gpg $ sudo yum install yarnCapistranoでのデプロイ
参考にさせていただきました。
https://qiita.com/ea54595/items/12ab7b3a8213b35cca10pumaの設定のみ追記しました。
config/deploy.rb#Capistranoのバージョンを固定 lock '3.11.0' #アプリケーション名 set :application, "[アプリ名]" #レポジトリURL set :repo_url, "ssh://git@github.com:****/[アプリ名].git" #gitでバージョン管理方法 set :scm, :git #対象ブランチ masterに固定 set :branch, 'master' #デプロイ先ディレクトリ フルパスで指定 set :deploy_to, "/usr/local/rails/[アプリ名]" #sharedに入るものを指定 set :linked_dirs, %w{bin log tmp/pids tmp/cache tmp/sockets bundle public/system public/assets public/uploads} #rvmのパス set :default_env, { rvm_bin_path: '~/.rvm/bin' } # sudo に必要 set :pty, true #5回分のreleasesを保持する set :keep_releases, 5 #puma設定 set :puma_threds, [4, 16] set :puma_workers, 0 set :puma_bind, "unix://#{shared_path}/tmp/sockets/#{fetch(:application)}-puma.sock" set :puma_state, "#{shared_path}/tmp/pids/puma.state" set :puma_pid, "#{shared_path}/tmp/pids/puma.pid" set :puma_access_log, "#{release_path}/log/puma.error.log" set :puma_error_log, "#{release_path}/log/puma.access.log" set :puma_preload_app, true set :puma_worker_timeout, nil set :puma_init_active_record, true SSHKit.config.command_map[:rake] = 'bundle exec rake' namespace :deploy do #puma再起動タスク desc "Restart Application" task :restart do on roles(:app), in: :sequence, wait: 5 do invoke 'puma:restart' end end after :finishing, 'deploy:cleanup' endおまけ logのローテーション
SSH接続したEC2インスタンス内.$ sudo vi /etc/logrotate.d/[アプリ名]/etc/logrotate.d/[アプリ名]/usr/local/rails/[アプリ名]/shared/log/*.log { daily #ログを毎日ローテーションする missingok #ログファイルが存在しなくてもエラーを出さずに処理を続行 rotate 10 #10世代分古いログを残す dateext #日次ローテート+日付文字列 compress #ローテーションしたログをgzipで圧縮 sharedscripts #複数指定したログファイルに対し、postrotateで記述したコマンドを実行 su my-user my-user #rotateするユーザを指定 postrotate puma_pid=/usr/local/rails/[アプリ名]/shared/tmp/pids/puma.pid test -e $puma_pid && kill -USR2 $(cat $puma_pid) || true endscript }SSH接続したEC2インスタンス内.エラーが出ていないか確認 $ logrotate -d /etc/logrotate.d/[アプリ名]
- 投稿日:2019-12-22T19:03:51+09:00
hamlインストールエラー
実行したい内容
bundle
してhtmlをhamlにしたい。起こっている現象
Gemfile最下部に
gem "haml-rails", "~> 2.0"
と記述しbundle
を行うがエラーとなるエラー内容
ターミナルMacBook-Air:a PC$ bundle The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for (--中省略--) Fetching mysql2 0.5.3 Installing mysql2 0.5.3 with native extensions Gem::Ext::BuildError: ERROR: Failed to build gem native extension. (--中省略--) ----- checking for mysql.h... yes checking for errmsg.h... yes checking for SSL_MODE_DISABLED in mysql.h... no checking for MYSQL_OPT_SSL_ENFORCE in mysql.h... no checking for MYSQL.net.vio in mysql.h... yes checking for MYSQL.net.pvio in mysql.h... no checking for MYSQL_ENABLE_CLEARTEXT_PLUGIN in mysql.h... yes checking for SERVER_QUERY_NO_GOOD_INDEX_USED in mysql.h... yes checking for SERVER_QUERY_NO_INDEX_USED in mysql.h... yes checking for SERVER_QUERY_WAS_SLOW in mysql.h... yes checking for MYSQL_OPTION_MULTI_STATEMENTS_ON in mysql.h... yes checking for MYSQL_OPTION_MULTI_STATEMENTS_OFF in mysql.h... yes checking for my_bool in mysql.h... yes ----- Don't know how to set rpath on your system, if MySQL libraries are not in path mysql2 may not load ----- ----- Setting libpath to /usr/local/opt/mysql@5.6/lib ----- creating Makefile current directory: /Users/itsumi/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/mysql2-0.5.3/ext/mysql2 make "DESTDIR=" clean current directory: /Users/itsumi/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/mysql2-0.5.3/ext/mysql2 make "DESTDIR=" compiling client.c compiling infile.c compiling mysql2_ext.c compiling result.c compiling statement.c linking shared-object mysql2/mysql2.bundle ld: library not found for -lssl clang: error: linker command failed with exit code 1 (use -v to see invocation) make: *** [mysql2.bundle] Error 1 make failed, exit code 2 Gem files will remain installed in /Users/itsumi/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/mysql2-0.5.3 for inspection. Results logged to /Users/itsumi/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/extensions/x86_64-darwin-18/2.5.0-static/mysql2-0.5.3/gem_make.out An error occurred while installing mysql2 (0.5.3), and Bundler cannot continue. Make sure that `gem install mysql2 -v '0.5.3' --source 'https://rubygems.org/'` succeeds before bundling. In Gemfile: mysql2この部分がエラー内容で重要そう
``gem install mysql2 -v '0.5.3' --source 'https://rubygems.org/'` succeeds before bundling.
An error occurred while installing mysql2 (0.5.3), and Bundler cannot continue.
Make sure thatIn Gemfile:
mysql2
```色々サイト検索を行い、下記にたどり着く
打ち込む
ターミナル$ brew info openssl出てきた内容
ターミナルexport LDFLAGS="-L/usr/local/opt/openssl@1.1/lib" export CPPFLAGS="-I/usr/local/opt/openssl@1.1/include"再び打ち込む
ターミナル$gem install mysql2 -v '0.5.3' --source 'https://rubygems.org/' -- --with-cppflags=-I/usr/local/opt/openssl@1.1/include --with-ldflags=-L/usr/local/opt/openssl@1.1/lib出てきた内容
ターミナル1 gem installed再び試す
ターミナル$bundle
エラーなし!!
通常通りターミナルで
$ rails haml:erb2haml
実行ターミナルWould you like to delete the original .erb files? (This is not recommended unless you are under version control.) (y/n) #yを入力してエンターhaml変換成功!
お世話になったページたち
mysql2 gemインストール時のトラブルシュート
【Rails】MySQL2がbundle installできない時の対応方法
RailsプロジェクトでMySQLがbundle installできなかった
bundle install 時、mysql2でエラー終わりに
チーム開発スタート初っ端の出来事。
いきなりのエラーです。自分一人じゃないから適当にできない。。。
最初のうちに経験できてよかった(が、エラーはまだまだ続く予感。笑)
完璧ではなくていいから完成させるように頑張ります!
(bundle exec installも試してみればよかったと今更ながら思う←)
- 投稿日:2019-12-22T19:00:06+09:00
RubyからCプログラムを利用する
FFI
Foreign function Interface の略で、別のプログラミング言語で記述された関数を利用するための機能です。
Rubyでは、ffiというgemや、Rubyに梱包される標準ライブラリのFiddleなどが同等の機能を提供しています。今回は、gemである提供するffiを使って、RubyからCプログラムを利用してみたいと思います。
値渡し
まずはffiに慣れていきましょう。
呼び出し元のコードは以下です。lib.cint add(int a, int b) { return a + b; }int型の引数を2つ取って、それらの加算結果を返す
add
を定義しました。このaddをRubyから呼び出すために、共有ライブラリ(*.so)の形式に変換する必要があります。
以下のコマンドを使って変換します。$ gcc -shared lib.c -o libadd.sogccコンパイラに
-shared
オプションを渡すと、共有ライブラリとしてオブジェクトファイルを吐き出してくれます。
Rubyから呼び出すファイルはlibadd.so
です。add.rbrequire 'ffi' module AddFFI extend FFI::Library ffi_lib 'libadd.so' attach_function :add, [:int, :int], :int end puts AddFFI.add(1, 2)
ffi_lib
メソッドで、先程生成した共有ライブラリを読み込みます。そして
attach_function
で、Cプログラムで定義した関数を、Rubyから扱えるようにマッピングします。
第1引数にメソッド名
、第2引数にCで定義した関数の引数の型
を、第3引数に返り値の型
を与えてあげます。
メソッド名は、Cプログラム内で定義した関数と同じである必要があります。このRubyファイルを実行します。
$ ruby add.rb 3無事FFIを介してCプログラムを呼び出すことができました。
ポインタを使って渡す
参照渡しとは変数のメモリ番地を渡すことで、変数の値を共有する仕組みです。値はコピーされないので、参照渡し先でその変数についての情報を書き換えると、元の保持していた変数の値も変化します。
[追記]
参照渡しとは変数のメモリ番地を渡すことで、変数の値を共有する仕組みです。値はコピーされないので、参照渡し先でその変数についての情報を書き換えると、元の保持していた変数の値も変化します。
筆者の参照渡しの理解が誤っており、@shiracamus さん および @c-yan さんからご指摘を頂きました。
参照渡しという言葉の定義については本記事のコメント欄に、お二人からのご教示があるので、そちらをご覧ください。(ご指摘ありがとうございました!)例
sansyo.c#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { int *p, *q; p = (int *)malloc(sizeof(int)); *p = 1; printf("%d\n", *p); // ここで参照渡し // [追記] ここは参照渡しではなく参照の値渡し(詳細はコメントで) q = p; *q = 2; printf("%d\n", *p); }これを実行すると以下のようになり、
*p
の値が、*q
への代入後に変化していることがわかります。$ gcc -o sansyo sansyo.c $ ./sansyo 1 2やりたいことはCプログラム内で、アドレス値(ポインタ)を返す関数を定義して、Rubyからはそのアドレスを参照することで値を取得する、ということです。
作成したCプログラムは以下です。
add_sansyo.c#include <stdio.h> #include <stdlib.h> int *calc(int a, int b) { int *p; p = (int *)malloc(sizeof(int)); *p = a + b; printf("sansyo function address = %p\n", p); return p; } int main(int argc, char *argv[]) { int *q = calc(1, 2); printf("calc(1, 2) = %d\n", *q); printf("calc(1, 2)'s address = %p\n", q); }デバッグも兼ねて、
main
の中でcalc
を呼び出しています。
Rubyへアドレス値を返したいので、calcはint型のポインタを返すようにしています。実行結果は以下です。意図したとおりに動いていそうです。
$ gcc -o add_sansyo add_sansyo.c $ ./add_sansyo sansyo function address = 0x7f94d54026b0 calc(1, 2) = 3 calc(1, 2)'s address = 0x7f94d54026b0このCプログラムを
add_sansyo.so
として共有ライブラリへ変換して、Rubyがロードします。sansyo.rbrequire 'ffi' module AddFFI extend FFI::Library ffi_lib "add_sansyo.so" attach_function :sansyo, [:int, :int], :pointer end result = AddFFI.sansyo(1, 2) puts result puts result.read(:int)
attach_function
で行うマッピングについては、その名もズバリ、:pointer
という形で、返り値を指定してあげます。
AddFFI.sansyo
メソッドの返り値は、FFI::Pointer
クラスが返ってくるので、read
というインスタンスメソッドで、そのアドレスの値を取得しています。実行すると以下のようになり、Cプログラムで参照しているアドレスをRubyから触ることで、関数の出力結果を意図したように取得できていることが理解できます。
$ ruby sansyo.rb sansyo function address = 0x7fa1aad31860 #<FFI::Pointer address=0x00007fa1aad31860> 3まとめ
Rubygemsのffiを使って、Cプログラムで書かれた関数を値やポインタを使ってRubyから呼び出すことができました。
言語の特徴を組み合わせすることでプログラムを最適化する、という考え方は個人的にとても気に入りました。
- 投稿日:2019-12-22T19:00:06+09:00
参照渡しを使ってRubyからCプログラムを利用する
FFI
Foreign function Interface の略で、別のプログラミング言語で記述された関数を利用するための機能です。
Rubyでは、ffiというgemや、Rubyに梱包される標準ライブラリのFiddleなどが同等の機能を提供しています。今回は、gemである提供するffiを使って、参照渡しをしてみたいと思います。
呼び出し元の関数はC言語を使って記述しました。値渡し
まずは値渡しでffiに慣れていきましょう。
呼び出し元のコードは以下です。lib.cint add(int a, int b) { return a + b; }int型の引数を2つ取って、それらの加算結果を返す
add
を定義しました。このaddをRubyから呼び出すために、共有ライブラリ(*.so)の形式に変換する必要があります。
以下のコマンドを使って変換します。$ gcc -shared lib.c -o libadd.sogccコンパイラに
-shared
オプションを渡すと、共有ライブラリとしてオブジェクトファイルを吐き出してくれます。
Rubyから呼び出すファイルはlibadd.so
です。add.rbrequire 'ffi' module AddFFI extend FFI::Library ffi_lib 'libadd.so' attach_function :add, [:int, :int], :int end puts AddFFI.add(1, 2)
ffi_lib
メソッドで、先程生成した共有ライブラリを読み込みます。そして
attach_function
で、Cプログラムで定義した関数を、Rubyから扱えるようにマッピングします。
第1引数にメソッド名
、第2引数にCで定義した関数の引数の型
を、第3引数に返り値の型
を与えてあげます。
メソッド名は、Cプログラム内で定義した関数と同じである必要があります。このRubyファイルを実行します。
$ ruby add.rb 3無事FFIを介してCプログラムを呼び出すことができました。
参照渡し
参照渡しとは変数のメモリ番地を渡すことで、変数の値を共有する仕組みです。値はコピーされないので、参照渡し先でその変数についての情報を書き換えると、元の保持していた変数の値も変化します。
例
sansyo.c#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { int *p, *q; p = (int *)malloc(sizeof(int)); *p = 1; printf("%d\n", *p); // ここで参照渡し q = p; *q = 2; printf("%d\n", *p); }これを実行すると以下のようになり、
*p
の値が、*q
への代入後に変化していることがわかります。$ gcc -o sansyo sansyo.c $ ./sansyo 1 2やりたいことはCプログラム内で、アドレス値(ポインタ)を返す関数を定義して、Rubyからはそのアドレスを参照することで値を取得する、ということです。
作成したCプログラムは以下です。
add_sansyo.c#include <stdio.h> #include <stdlib.h> int *calc(int a, int b) { int *p; p = (int *)malloc(sizeof(int)); *p = a + b; printf("sansyo function address = %p\n", p); return p; } int main(int argc, char *argv[]) { int *q = calc(1, 2); printf("calc(1, 2) = %d\n", *q); printf("calc(1, 2)'s address = %p\n", q); }デバッグも兼ねて、
main
の中でcalc
を呼び出しています。
Rubyへアドレス値を返したいので、calcはint型のポインタを返すようにしています。実行結果は以下です。意図したとおりに動いていそうです。
$ gcc -o add_sansyo add_sansyo.c $ ./add_sansyo sansyo function address = 0x7f94d54026b0 calc(1, 2) = 3 calc(1, 2)'s address = 0x7f94d54026b0このCプログラムを
add_sansyo.so
として共有ライブラリへ変換して、Rubyがロードします。sansyo.rbrequire 'ffi' module AddFFI extend FFI::Library ffi_lib "add_sansyo.so" attach_function :sansyo, [:int, :int], :pointer end result = AddFFI.sansyo(1, 2) puts result puts result.read(:int)
attach_function
で行うマッピングについては、その名もズバリ、:pointer
という形で、返り値を指定してあげます。
AddFFI.sansyo
メソッドの返り値は、FFI::Pointer
クラスが返ってくるので、read
というインスタンスメソッドで、そのアドレスの値を取得しています。実行すると以下のようになり、Cプログラムで参照しているアドレスをRubyから触ることで、関数の出力結果を意図したように取得できていることが理解できます。
$ ruby sansyo.rb sansyo function address = 0x7fa1aad31860 #<FFI::Pointer address=0x00007fa1aad31860> 3まとめ
Rubygemsのffiを使って、Cプログラムで書かれた関数を値渡し、参照渡しそれぞれで呼び出してみました。
言語の特徴を組み合わせすることでプログラムを最適化する、という考え方は個人的にとても気に入りました。
- 投稿日:2019-12-22T17:44:02+09:00
VPSにdokku入れてできるだけ楽にデプロイ
どうも、しゅーいっちです。
why
- かなり昔にsinatraで作った地味サービスをそろそろをSSL化したい
- メンテされてない dokku-alt やめて dokku に切り替えたい。(そもそも dokku がメンテされてなくて dokku-alt が生まれたのに本家が復活してた)
- なんかSSHの設定おかしくなってpushできなくなった(けど現行サーバであれこれやるのめんどくさい)
- 眠っているVPSがあった。
dokku
https://github.com/dokku/dokku/
Docker powered mini-Heroku. The smallest PaaS implementation you've ever seen.
これを自前のサーバに入れると、Herokuみたいな
git push
でデプロイできる仕組みを自前で構築できる。さらにdokku letsencrypt(beta)というのもあってSSL化も簡単そう。
さあ始めよう
サーバ基本設定
さくらVPSです。Ubuntu 18.04
こちらを参考にさせていただきました。
https://loumo.jp/wp/archive/20190302120011/80, 443 の portを開ける。
$ sudo ufw allow http
$ sudo ufw allow https
(↑ これやってなくて、いろいろ遠回りしました)
確認
$ sudo ufw status Status: active To Action From -- ------ ---- OpenSSH ALLOW Anywhere 80/tcp ALLOW Anywhere 443/tcp ALLOW Anywhere OpenSSH (v6) ALLOW Anywhere (v6) 80/tcp (v6) ALLOW Anywhere (v6) 443/tcp (v6) ALLOW Anywhere (v6)dokku 入れる
officialの仰せの通りに。
https://github.com/dokku/dokku/#installation
$ wget https://raw.githubusercontent.com/dokku/dokku/v0.19.11/bootstrap.sh $ sudo DOKKU_TAG=v0.19.11 bash bootstrap.shdockerが入ったりします。時間がかかるということで途中で星空を眺めます。
This is going to take a long time ..................................................................... ............+...............................................+........ ................+.................................................... ..................................................................... ..................................................................... ..................................................................... +..+................................................................. ...............................................+..................... ..................................................................... ..................................................................... ..................................................................... ...........................+.......+...............................+. .........+.................................+......................... ...........+..............................+..........................入りました。
$ dokku -v dokku version 0.19.11dokku 設定
(このまま現行ドメインのDNS変えるとサービス表示されなくなることに気づき別に検証用ドメインを取得?)
ドメインがすでにサーバーIPに向いた状態です。
http://ドメイン
にアクセスしたら表示される設定画面で設定を完了させる。
- Public SSH Keys
- HostnameあとからSSH keyの設定する時は
dokku ssh-keys
コマンド使う
http://dokku.viewdocs.io/dokku~v0.7.1/deployment/user-management/#adding-deploy-usersあとから、ドメイン関連の設定する時は
dokku domains
コマンド使う
https://github.com/dokku/dokku/blob/master/docs/configuration/domains.mdsample app を push してみる
これそのままやった
http://dokku.viewdocs.io/dokku/deployment/application-deployment/http://ruby-getting-started.dracarysme.com で表示された。(ドラカリス!?)
自分の app を push
rubyのバージョン古過ぎてコケるぽいのでスキップ。
! An error occurred while installing ruby-2.1.0 ! Heroku recommends you use the latest supported Ruby version listed here: ! https://devcenter.heroku.com/articles/ruby-support#supported-runtimessample app を SSL化
officialの仰せのままに
# dokku 0.5+ $ sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.gitメアド設定して実行
$ dokku config:set --no-restart ruby-getting-started DOKKU_LETSENCRYPT_EMAIL=メアド $ dokku letsencrypt ruby-getting-startedリダイレクトもきちんとされつつ、あっさりとSSL化
https://ruby-getting-started.dracarysme.com ?♀️???作りかけの nuxt app を push してみる
officialの仰せのままに設定
https://nuxtjs.org/faq/dokku-deployment/できた。
感想
よかったこと
- DNSやnginxの設定することなしに、アプリごとに勝手にサブドメイン増えてくのが楽しい
- nuxt app を firebase に上げると SSRのためのfunctionsが必要だけど、dokkuだと不要ぽい。
イマイチだったこと
- やはりサーバ基本設定がめんどくさい
- mini-Herokuと言ってはいるが、ドキュメント量見るとあまりmini感を感じられない。
- 投稿日:2019-12-22T17:43:02+09:00
Amazon ElastiCache Rails設定
AWS側の設定
https://qiita.com/leomaro7/items/f031cfdd7d12d5d5ccc5
https://lab.sonicmoov.com/development/aws/elasticache/Rails側の設定
config/environments/staging.rbconfig.session_store :redis_store, { servers: { host: '[プライマリエンドポイント]', port: 6379, db: 0, namespace: 'sessions' }, expire_after: 60.minutes }config/initializers/sidekiq.rbSidekiq.configure_server do |config| case Rails.env when 'staging' then redis_conn = proc { Redis.new(host: 'プライマリエンドポイント', port: 6379, db: 2) } config.redis = ConnectionPool.new(size: 27, &redis_conn) else config.redis = { url: 'redis://127.0.0.1:6379' } end end Sidekiq.configure_client do |config| case Rails.env when 'staging' then redis_conn = proc { Redis.new(host: 'プライマリエンドポイント', port: 6379, db: 2) } config.redis = ConnectionPool.new(size: 27, &redis_conn) else config.redis = { url: 'redis://127.0.0.1:6379' } end end
- 投稿日:2019-12-22T17:43:02+09:00
【Amazon ElastiCache】 Rails設定
AWS側の設定
参考
https://qiita.com/leomaro7/items/f031cfdd7d12d5d5ccc5
https://lab.sonicmoov.com/development/aws/elasticache/Rails側の設定
config/environments/staging.rbconfig.session_store :redis_store, { servers: { host: '[プライマリエンドポイント]', port: 6379, db: 0, namespace: 'sessions' }, expire_after: 60.minutes }config/initializers/sidekiq.rbSidekiq.configure_server do |config| case Rails.env when 'staging' then redis_conn = proc { Redis.new(host: 'プライマリエンドポイント', port: 6379, db: 2) } config.redis = ConnectionPool.new(size: 27, &redis_conn) else config.redis = { url: 'redis://127.0.0.1:6379' } end end Sidekiq.configure_client do |config| case Rails.env when 'staging' then redis_conn = proc { Redis.new(host: 'プライマリエンドポイント', port: 6379, db: 2) } config.redis = ConnectionPool.new(size: 27, &redis_conn) else config.redis = { url: 'redis://127.0.0.1:6379' } end end
- 投稿日:2019-12-22T17:29:18+09:00
ruby on rails�をcircle ciでテストする
CircleCiの設定ファイル
circleci/config.yml# Ruby CircleCI 2.0 configuration file # # Check https://circleci.com/docs/2.0/language-ruby/ for more details # version: 2 jobs: build: docker: # specify the version you desire here - image: circleci/ruby:2.6.5-node-browsers environment: BUNDLER_VERSION: 2.0.1 # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ # - image: circleci/postgres:9.4 working_directory: ~/repo steps: - checkout - run: name: setup bundler command: | sudo gem update --system sudo gem uninstall bundler sudo rm /usr/local/bin/bundle sudo rm /usr/local/bin/bundler sudo gem install bundler # Download and cache dependencies - restore_cache: keys: - v1-dependencies-{{ checksum "Gemfile.lock" }} # fallback to using the latest cache if no exact match is found - v1-dependencies- - run: name: install dependencies command: | yarn install --check-files bundle install --jobs=4 --retry=3 --path vendor/bundle - save_cache: paths: - ./vendor/bundle key: v1-dependencies-{{ checksum "Gemfile.lock" }} # Database setup - run: bundle exec rake db:create - run: bundle exec rake db:schema:load # run tests! - run: name: run tests command: | mkdir /tmp/test-results TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | \ circleci tests split --split-by=timings)" sudo gem install bundler sudo gem install rspec sudo gem install rspec-core bundle exec rspec \ --format progress \ --format RspecJunitFormatter \ --out /tmp/test-results/rspec.xml \ --format progress \ $TEST_FILES # collect reports - store_test_results: path: /tmp/test-results - store_artifacts: path: /tmp/test-results destination: test-resultsGemFileの設定
group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] gem 'sqlite3', '~> 1.4' gem 'rspec' gem 'rspec-core' gem 'rspec_junit_formatter' endbundle installする
bundle install --without productionすべてコミットしてPUSH
git add . git push origin master
- 投稿日:2019-12-22T16:55:33+09:00
【AWS S3 + EC2 + CarrierWave + Fog】RailsからS3へ画像アップロード手順
はじめに
Ruby on Rails 5.2からAWS S3へ画像をアップロードをするため、CarrierWave+fogを使って実装を進めました。
対象
EC2構築、デプロイ経験のある方
EC2へ画像をアップロードしていた方
はじめてS3を利用する方AWSの設定
S3作成
S3 バケットを作成する方法
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/user-guide/create-bucket.html
- 東京リージョン
- バージョニング有効
- Static website hosting有効
- パブリックアクセスをすべてブロック
バケットポリシー{ "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::[バケット名]", "arn:aws:s3:::[バケット名]/*" ], "Condition": { "StringEquals": { "aws:SourceIp": "[EC2のパブリックIP]" } } } ] }VPCエンドポイントの設定
EC2とS3の間でファイルの転送を行います。
カスタムポリシーの例{ "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::[バケット名]", "arn:aws:s3:::[バケット名]/*" ] } ] }作成が完了すると、ダッシュボードにエンドポイントが表示されます。
紐付けたサブネットのルートテーブルを確認すると、エンドポイントを介したS3への経路が追加されています。AWS CLIを使ってS3へデータを送信テスト
EC2へ接続 $ ssh -i [keyペアファイル] ec2-user@[パブリックIP] テストファイルを転送 $ touch test.txt $ aws s3 mv test.txt s3://YOUR_S3_BUCKET/uploads s3バケットが閲覧できるか確認 $ aws s3 ls s3://YOUR_S3_BUCKET/ --recursiveAmazon Linux 2の設定
ImageMagickを導入
$ yum install -y ImageMagick ImageMagick-develアクセスキーとシークレットアクセスキーの設定
aws configure コマンドが、AWS CLI のインストールをセットアップするための最も簡単な方法です。
詳しくは
https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-configure.html
https://dev.classmethod.jp/cloud/aws/how-to-configure-aws-cli/$ aws configure AWS Access Key ID [None]: AKIAXXXXXXXXXXXXXXXX AWS Secret Access Key [None]: 5my9xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Default region name [None]: ap-northeast-1 Default output format [None]:設定の確認
$ aws configure listRailsの設定
Gemの設定
Gemfilegem 'carrierwave' gem 'rmagick' gem 'fog-aws'ImageUploaderの設定
$ rails g uploader Image/uploaders/image_uploader.rbclass ImageUploader < CarrierWave::Uploader::Base # 画像サイズを取得するためにRMagick使用 include CarrierWave::RMagick # developmentとtest以外はS3を使用 if Rails.env.development? || Rails.env.test? storage :file else storage :fog end # 画像ごとに保存するディレクトリを変える def store_dir "uploads/#{model.class.to_s.underscore}/#{model.id}/#{mounted_as}" end # 許可する画像の拡張子 def extension_whitelist %w(jpg jpeg gif png) end # ファイル名を書き換える def filename "#{Time.zone.now.strftime('%Y%m%d%H%M%S')}.#{file.extension}" if original_filename end endCarrierWaveの設定
/config/initializers/carrierwave.rb# CarrierWaveの設定呼び出し require 'carrierwave/storage/abstract' require 'carrierwave/storage/file' require 'carrierwave/storage/fog' # 画像名に日本語が使えるようにする CarrierWave::SanitizedFile.sanitize_regexp = /[^[:word:]\.\-\+]/ # 保存先の分岐 CarrierWave.configure do |config| # 本番環境はS3に保存 if Rails.env.production? config.storage = :fog config.fog_provider = 'fog/aws' config.fog_directory = '[S3のバケット名]' config.asset_host = 'https://s3-ap-northeast-1.amazonaws.com/[S3のバケット名]' # iam_profile config.fog_credentials = { provider: 'AWS', # credentialsで管理する場合 aws_access_key_id: Rails.application.credentials.aws[:access_key_id], aws_secret_access_key: Rails.application.credentials.aws[:secret_access_key], # 環境変数で管理する場合 # aws_access_key_id: ENV["AWS_ACCESS_KEY_ID"], # aws_secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"], region: 'ap-northeast-1' #東京リージョン } # キャッシュをS3に保存 # config.cache_storage = :fog else # 開発環境はlocalに保存 config.storage :file config.enable_processing = false if Rails.env.test? #test:処理をスキップ end endアップローダーの使い方
作成したUploaderをModelに紐付けます。Userモデルのimageカラムに紐付ける例
User.rbclass User < ApplicationRecord mount_uploader :image, ImageUploader end
- 投稿日:2019-12-22T16:48:32+09:00
HelloWorldとはなにか 〜第1章〜
先日MatzのRuby誕生秘話を聞いたとき、Hello Worldを表示するまでに半年かかったという話を聞きました。
それに触発されて自分でも言語作ってHello Worldしよう!と息巻いているんですが、ちょっと記事にするには期間的に厳しかったので、ひとまずRubyでHello Worldが出力される過程が、どんな流れになっているかを解き明かそうと思います。が、正直ちょろっと見ただけで解き明かせるものでもないので、この記事ではその入口を整理するくらいに留め、あとは第2章以降でがんばります。
環境
- Ruby: v2_7_0_rc2
main関数から辿ってみる
ちゃんと処理の入り口から辿ってみようということで、main関数から辿ってみることに着手。
すると、、、main.cint main(int argc, char **argv) { #ifdef RUBY_DEBUG_ENV ruby_set_debug_option(getenv("RUBY_DEBUG")); #endif #ifdef HAVE_LOCALE_H setlocale(LC_CTYPE, ""); #endif ruby_sysinit(&argc, &argv); { RUBY_INIT_STACK; ruby_init(); return ruby_run_node(ruby_options(argc, argv)); } }↓
↓
↓vm_exec.hstatic VALUE vm_exec_core(rb_execution_context_t *ec, VALUE initial) { #if OPT_STACK_CACHING #if 0 #elif __GNUC__ && __x86_64__ DECL_SC_REG(VALUE, a, "12"); DECL_SC_REG(VALUE, b, "13"); #else register VALUE reg_a; register VALUE reg_b; #endif #endif #if defined(__GNUC__) && defined(__i386__) DECL_SC_REG(const VALUE *, pc, "di"); DECL_SC_REG(rb_control_frame_t *, cfp, "si"); #define USE_MACHINE_REGS 1 #elif defined(__GNUC__) && defined(__x86_64__) DECL_SC_REG(const VALUE *, pc, "14"); DECL_SC_REG(rb_control_frame_t *, cfp, "15"); #define USE_MACHINE_REGS 1 #elif defined(__GNUC__) && defined(__powerpc64__) DECL_SC_REG(const VALUE *, pc, "14"); DECL_SC_REG(rb_control_frame_t *, cfp, "15"); #define USE_MACHINE_REGS 1 #else register rb_control_frame_t *reg_cfp; const VALUE *reg_pc; #endif #if USE_MACHINE_REGS #undef RESTORE_REGS #define RESTORE_REGS() \ { \ VM_REG_CFP = ec->cfp; \ reg_pc = reg_cfp->pc; \ } #undef VM_REG_PC #define VM_REG_PC reg_pc #undef GET_PC #define GET_PC() (reg_pc) #undef SET_PC #define SET_PC(x) (reg_cfp->pc = VM_REG_PC = (x)) #endif #if OPT_TOKEN_THREADED_CODE || OPT_DIRECT_THREADED_CODE #include "vmtc.inc" if (UNLIKELY(ec == 0)) { return (VALUE)insns_address_table; } #endif reg_cfp = ec->cfp; reg_pc = reg_cfp->pc; #if OPT_STACK_CACHING reg_a = initial; reg_b = 0; #endif first: INSN_DISPATCH(); /*****************/ #include "vm.inc" /*****************/ END_INSNS_DISPATCH(); /* unreachable */ rb_bug("vm_eval: unreachable"); goto first; }vm_exec_coreに到達したあたりで、どう深掘っていけばいいのかわからなくなり、このルートは一旦取りやめ。
vmってVirtual Machineの略かな?インタープリター言語でも、実装は中ではvm的なテンションでやっているのか、と、理解したのかしてないのか分からない状態のまま、次の方法へ。Rubyのオブジェクトを表現しているところを特定する
ファイルをバーっと眺めてみたところ、
object.cVALUE rb_cBasicObject; /*!< BasicObject class */ VALUE rb_mKernel; /*!< Kernel module */ VALUE rb_cObject; /*!< Object class */ VALUE rb_cModule; /*!< Module class */ VALUE rb_cClass; /*!< Class class */ VALUE rb_cData; /*!< Data class */ VALUE rb_cNilClass; /*!< NilClass class */ VALUE rb_cTrueClass; /*!< TrueClass class */ VALUE rb_cFalseClass; /*!< FalseClass class */こんなものを発見!すごくそれっぽい。
これが最低限のクラスの種類の定義なのかな??なんかちょっと感動!
まぁそれはいいとして、お目当てのString型がないので読み進める。ここで、そもそもstringという名でファイル名検索してなくない?ということで、やってみると、
あるやんけ
して、中を見てみると、、、string.cstatic VALUE str_new0(VALUE klass, const char *ptr, long len, int termlen) { VALUE str; if (len < 0) { rb_raise(rb_eArgError, "negative string size (or size too big)"); } RUBY_DTRACE_CREATE_HOOK(STRING, len); str = str_alloc(klass); if (!STR_EMBEDDABLE_P(len, termlen)) { RSTRING(str)->as.heap.aux.capa = len; RSTRING(str)->as.heap.ptr = ALLOC_N(char, (size_t)len + termlen); STR_SET_NOEMBED(str); } else if (len == 0) { ENC_CODERANGE_SET(str, ENC_CODERANGE_7BIT); } if (ptr) { memcpy(RSTRING_PTR(str), ptr, len); } STR_SET_LEN(str, len); TERM_FILL(RSTRING_PTR(str) + len, termlen); return str; }あった!
ただこのメソッドは今は直接は使われていないようで、今はrb_str_new2
というメソッドが使われている模様。で、このメソッドが呼ばれているところから辿っていけばいけるのでは?と思ったものの、ここで行き詰まる。
ちょっとこの先は長くなりそうなので、第1章はこのあたりで終わり。
第2章は気が向いたら書きます。
- 投稿日:2019-12-22T13:56:51+09:00
Railsで制御結合、スタンプ結合、データ結合、メッセージ結合
# 制御結合 # @param [User] user # @param [Bool] active_flg # @return [Hash] def shape_user_info(user, active_flg) # @type [Hash] user_info if active_flg user_info = user_info(user) else user_info = default_user_info end user_info end # スタンプ結合 # @param [User] user # @return [Hash] def user_info(user) # @type [String] first_name = user.first_name # @type [String] last_name = user.last_name # @type [String] fullname = fullname(first_name, last_name) # @type [Integer] age = user.age # @type [String] mail = user.mail { fullname: fullname, age: age, mail: mail } end # データ結合 # @param [String] first_name # @param [String] last_name # @return [String] def fullname(first_name, last_name) first_name + last_name end # メッセージ結合 # @return [Hash] def default_user_info { fullname: "fullname", age: 30, mail: "mail" } end間違ってたらごめんなさい?
- 投稿日:2019-12-22T12:05:35+09:00
【Ruby】配列の要素を半角空白区切りで出力
配列の要素を半角空白区切りで出力したいときは、次のようにします。
puts array.join(' ')例:
array = ["apple", "grape", "orange"] puts array.join(' ') # => apple grape orangearray = ["1", "2", "3"] puts array.join(' ') # => 1 2 3以上です。
- 投稿日:2019-12-22T12:00:30+09:00
【Ruby】配列の要素の平均値を求める
配列
array
の平均値を求めるには、次のようにします。array.sum.fdiv(array.length)浮動小数点数の商が得られる
Numeric#fdiv
メソッドを用いて、配列の要素の合計(sum)を配列の要素数(length)で割ることで平均値が算出できます。※単純に
/
を用いて商を計算しようとすると、整数 / 整数
の場合、整商(整数の商)になってしまい正しい計算ができません。例えば
array = [2, 5]
のとき、array.sum.fdiv(array.length) # => 3.5ちなみに要素が文字列になっている場合は、
to_i
で整数型に変換してから計算すればOK。例えば
array = ["2", "5"]
のとき、array.map!(&:to_i) # 配列の要素を整数型に変換 # => [2, 5] array.sum.fdiv(array.length) # => 3.5以上です。
※いただいたコメントを元に内容を修正させていただきました。
@scivolaさん、ご指摘いただきありがとうございました。
- 投稿日:2019-12-22T11:28:44+09:00
GCPでのロールをsuコマンドみたいに切り替える
TL;DR
- Cloud Identityを有効にしてgroupで権限管理をする。
- プロジェクトは環境とサービス毎にいっぱい作る。共有VPC統一管理
- adminとviewerで権限を分ける。必要に応じてコマンドで切り替える
- 今回作ったgsuコマンドはこちら => https://github.com/koduki/gsu
はじめに
最近、GCPというかクラウドに本格的に入門したのですがIAM便利ですね。
きめ細やかにアカウントが管理出来ますし、Cloud Identityのロールと紐付ければ個人では無くロールでアカウント管理が出来て非常に便利です。ただし、少し不満を言えば権限のエスカレーション、つまりLinuxでいうsuコマンドのように特権アカウントの昇格への方法が無い事です。
大人数で運用するなら人毎にロールを設定して弱い権限の人と強い権限の人を分けれますが、少人数の場合は強い権限を持たざる得ません。というか規模によらず普段からProject Ownerみたいな特権アカウントで作業したくありません。オペミスが怖すぎる。。というわけで、今回は中規模のシステムを少人数で運用するために考えた事と、それをサポートするために権限を切り替える仕組みを作ったのでそちらを紹介していきます。
実現したい事/前提条件
以下が前提条件です。
- 複数プロジェクトの運用
- 少人数での運用
- 共有アカウント、ダメ、絶対
常に全プロジェクトをProject Ownerで操作するという案でもこの課題は解決できますが「さいきょう」の権限での常時運用は怖すぎるので0番目の前提としてそれはしません。
あと特に共有アカウントの日常的な運用は絶対に避けたいところです。Cloud Identity
GCPは「プロジェクト」が最小単位なのです。ただ、それを束ねる上位概念に「組織」があります。
VPC Service Controlsとか、AzureADやその他SAMLでのSSOとか便利なセキュリティ周りの仕組みは組織を前提にしていますので、取り敢えず作ったほうが良いです。これを使うにはG SuiteかCloud Identityを使うことになります。Cloud Identityは無料なのでGMailを独自ドメインで運用したいとかでなければこちらを使えば良いと思うのですが注意点として自分で管理しているドメインが必要です。
HTTPS周りとか含めてなんだかんだで持っておくと便利なので、個人利用だとしても 「Google Domains」か「お名前.com」あたりで取っておくのが良いと思います。年間2,000円前後ですし安いのなら。Google Domainsの場合はいくつかの設定をGCP/Cloud Identityと連携するときには省けるのでおすすめです。
GCPに置けるプロジェクトの考え方
GCPでのプロジェクトはかなり柔軟なリソース管理の単位です。一見すると1社1プロジェクトとかで良い気がしてしまうのですが、以下の特徴があるので目的別にざくざく作るのがベストプラクティスだと思います。
- Cloud Identityによる統一的なアカウント管理
- プロジェクト間の通信でのパフォーマンスペナルティ無し
- 共有VPCによりプロジェクトを超えた一貫したネットワーク/ACLが構築可能
なので単純に権限や管理を分けたい単位で作れば良さそうです。柔軟過ぎてどう作るかが悩ましいですが、私は「環境(Prod, STG, DEV および個人情報の有無)」と「サービス」でマトリックスを作ってそれ毎にプロジェクトを作っています。
で、環境毎に共有VPCを作って単一のホストプロジェクトで集中管理させています。
- hosted-network-prj
- prd-std-blog-prj
- prd-sec-payment-prj
- ...
こうする事でサービスや環境にアクセスする人およびその権限をシンプルに管理出来ますし、情報漏洩など重大な問題に関わるネットワーク周りの設定を多くのメンバーに解放する必要がありません。
正直、数名での運用になるので少し過剰かとも考えたのですが、Cloud Identityと共有VPCがあればさほど管理が複雑では無さそうだと感じたので、スケール性と小規模時の運用コストのバランスが取れそうだったのでこの形式にしました。後から変えれ良いだけですが後から変えるパワーが要るところですし。
Google Groupによる権限管理
GCPの権限管理は色々ありますが、オペレータの権限としてCloud IAMでGoogleアカウントに直接権限を付与するのは、管理が煩雑になるのでお勧めできません。
カスタムロールを使うことも出来ますが、Cloud Identity/G Suiteの機能であるGoogle Groupを作ってそのメールアドレスを各プロジェクトのIAMで必要な権限を付与することが出来ます。
こうする事でProject Ownerの権限も含めて、単にGroupへの所属の有無でロール管理ができるので運用がシンプルになります。
Groupは下記のページから 管理できます。
https://admin.google.com/ac/groupsこれによってOwner権限が欲しい時はOwnerグループに入れば良いだけなので、共有アカウントを運用する必要は無くなります。
Groupはプロジェクト毎にRead Onlyの
-viewer
とOwner権限の-admin
を作成して通常には-viewer
、構築や設定変更を行う時は一時的に例えばgroup-prd-std-blog-admin
に所属する、と行った運用ににしています。
これによって「普段は低めに運用して必要な時だけエスカレーション」という運用を実践できます。現時点では
-admin
はProject Ownerですが、将来的にはIAM管理権限を分離してProject Ownnerをなくす形での運用を考えています。gsuによる切り替え
さて、ようやく本題なのですがこのオペレーションを支援するためにgsuコマンドを作成しました。
というのもGCPの標準機能だけでは都度Admin Consoleに入って権限を付与する必要があるからです。これは日常的な作業としては厳しいです。これを解決するためにDirecotry APIを裏で叩いて権限の昇格を行えるスクリプトがgsuです。
https://github.com/koduki/gsu使い方は簡単です。以下のように対象ユーザとグループを指定して、
attach
で権限の付与を行い不要になればdetach
でグループから抜けます。
attachのタイミングでは-admin
のグループからは一旦全部外すようにしてあり、特権アカウントは同時に複数持てないようにしています。$ gsu attach {user_name} {group_name} $ gsu detach {user_name} {group_name}また、以下のように
gsu -l
を使うことで自分に紐づいてる権限を取得できます。$ gsu -l {user_name}gsuの実装方法
ソースコードはこちらにあります。
主なロジックとしてはAdminDirectoryをラッピングしたGCPAdminAPIです。class GCPAdminAPI def initialize scope = [ 'https://www.googleapis.com/auth/admin.directory.group.readonly', 'https://www.googleapis.com/auth/admin.directory.group.member', 'https://www.googleapis.com/auth/admin.directory.user.readonly' ] ENV['GOOGLE_APPLICATION_CREDENTIALS'] = ENV['SA_KEY'] if ENV['SA_KEY'] @domain = ENV['GCP_DOMAIN'] @service = Google::Apis::AdminDirectoryV1::DirectoryService.new authorization = Google::Auth.get_application_default(scope).dup authorization.sub = ENV["GCP_ADMIN_USER"] @service.authorization = authorization end def attach_group(user_name, group_name) member = @service.list_users(domain: @domain, query: "email:" + user_name).users.first clear_privileges(user_name) @service.insert_member(group_name, member) end def detach_group(user_name, group_name) @service.delete_member(group_name, user_name) end def list_groups_all() @service.list_groups(domain: @domain).groups.map{|g| g.email} end def list_groups(user_name) @service.list_groups(domain: @domain, user_key: user_name) .groups .map{|g| g.email} end def clear_privileges(user_name) admins = @service.list_groups(domain: @domain, user_key: user_name).groups .find_all{|x| x.email.include?("-admin@")} admins.each{|g| @service.delete_member(g.email, user_name) } end end割と見たままだと思うので中身に関してはあまり説明はしないですが、グループへの追加や削除をラッピングした処理になります。こちらをsinatraでラップしてCloud Runにデプロイします。
外部に公開する意味は無いので、Cloud Runの公開設定は非公開を選びます。そのためトークンを渡してやるなどをして認証する必要があります。また、コマンドライン側は以下の通りシンプルにcurlでAPI呼び出しを行なっています。プロトタイプなので今はエラー処理等は無い状態です。
API_URL=$(cat ${HOME}/.gsu_config|awk '{print $2}') TOKEN="Authorization: Bearer $(gcloud auth print-identity-token)" CONTENT_TYPE="Content-Type: application/json" if [ "$1" = "attach" ]; then curl -d {} -H "${TOKEN}" -H "${CONTENT_TYPE}" -X POST ${API_URL}/attach/$2/$3 elif [ "$1" = "dettach" ]; then curl -d {} -H "${TOKEN}" -H "${CONTENT_TYPE}"-X POST ${API_URL}/detach/$2/$3 elif [ "$1" = "-la" ]; then curl -H "${TOKEN}" -H "${CONTENT_TYPE}" -X GET ${API_URL}/groups elif [ "$1" = "-l" ]; then curl -H "${TOKEN}" -H "${CONTENT_TYPE}" -X GET ${API_URL}/groups/$2 else echo "gsu: Switch GCP user role." echo "usage:" echo " gsu attach {user_name} {group_name}" echo " gsu detach {user_name} {group_name}" echo " gsu -la" echo " gsu -l {user_name}" figcloudコマンドからtokenを取得しているので事前にインストールする必要があります。
サービスアカウントへの権限委譲
少し面倒なのがサービスアカウントへの権限委譲です。AdminDrectoryを含むAdmin SDKはGCPの外側の設定なので、GCP上でサービスアカウントを作成した上で、以下の設定が必要です。
まずAdomin Consoleにログインします。
続いて、「セキュリティ」 -> 「詳細設定」 -> 「認証」 -> 「API クライアント アクセスを管理する」を選んで「APIスコープ」にhttps://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.user.readonly
を追加します。その後、サービスアカウントの秘密鍵をJSONでダウンロードしGOOGLE_APPLICATION_CREDENTIALSに指定して権限をアプリに付与します。
この辺りは以下を読むと分かりやすいです。
サービスアカウントとCloud Run
さて上記の設定でローカルでは問題無く動くのですがCloudRunへのデプロイ時に少し罠がありました。
通常、GCPの本番環境で動かすときはGOOGLE_APPLICATION_CREDENTIALS
に秘密鍵を指定するのでは無く、サービスアカウントをデフォルトの実行ユーザとしておく事で対応するかと思います。
ライブラリ側でhttp://metadata.google.internal
にアクセスして情報を取得してごにょごにょしてくれて上手く繋がるアレです。しかし、厳密にはAdmin SDKはIAM範疇では無いせいなのか
GOOGLE_APPLICATION_CREDENTIALS
を使う分には問題ないのですが、デフォルトサービスアカウントでは適切に動作しませんでした。私の設定ミスなのか仕様なのか分からないのですがとりあえずそうらしい。なので、ちょっと変則的ですがBerglasを使ってgsu実行用のサービスアカウントの秘密鍵を暗号化してGCSに保存し、自分自身の秘密鍵をberglas使って取り出してGOOGLE_APPLICATION_CREDENTIALSにセットするというハックをしています。
BerglasはGoogle謹製の暗号化ツールでk8sのシークレットやHashiCorpのValtみたいなもんです。バックエンドとしてCloudKMSとGCSを使うのでサーバレスで運用できます。細かな使い方は以下が詳しいです。
秘密鍵の登録
以下の手順で秘密鍵の登録ができます。なお、Berglas自体もGCSバケット作ったりKMS設定したりとGCPの権限が必要なので、ローカルで作業するときはBerglas向けにサービスアカウント作って権限を付与しておくのが良いと思います。
この例では
sa-key
という名前で秘密鍵を暗号化して保存しています。また、指定したサービスアカウントに読み取り権限を付与しています。export PROJECT_ID=${YOUR_PROJECT_ID} export BUCKET_ID=${YOUR_BUCKET_ID} export KMS_KEY=projects/${PROJECT_ID}/locations/global/keyRings/berglas/cryptoKeys/berglas-key export SA=${YOUR_SEARVICE_ACCOUNT} $ gcloud services enable --project ${PROJECT_ID} \ cloudkms.googleapis.com \ storage-api.googleapis.com \ storage-component.googleapis.com $ berglas bootstrap --project $PROJECT_ID --bucket $BUCKET_ID $ berglas create ${BUCKET_ID}/sa-key "$(cat secreat.json)" --key ${KMS_KEY} $ berglas grant ${BUCKET_ID}/sa-key --member serviceAccount:${SA}Cloud Run向けDockerfileの設定
暗号化したファイルをメモリに値として取得する方法と、一時ファイルとして保存してそのパスを取得する方法があるようです。
今回は、GOOGLE_APPLICATION_CREDENTIALS
に保存したいので一時ファイルにします。FROM ruby RUN gem install google-api-client googleauth sinatra COPY --from=gcr.io/berglas/berglas:latest /bin/berglas /bin/berglas ADD ./ /app WORKDIR /app CMD ["/bin/berglas", "exec", "ruby", "app.rb"]実行時に
berglas
でフックする事で複合周りのごにょごにょを自動でしてくれるようです。そのトリガーになるのは環境変数です。SA_KEY=berglas://${BUCKET_ID}/sa-key?destination=tempfile
berglas://
で始まる環境変数の値をトリガーにアクションがされます。この場合はsa-keyを複合してtempファイルに保存しSA_KEYという環境変数にその値を渡します。なのでRuby側では単純に以下のように環境変数を読むだけです。ENV['GOOGLE_APPLICATION_CREDENTIALS'] = ENV['SA_KEY'] if ENV['SA_KEY']ライブラリなどが不要なのでPG本体のコードはほぼ書き換えなくて良いのが良いですね!
Cloud Runへのデプロイ
さて、いよいよCloud Runへのデプロイです。
export PROJECT_ID=${YOUR_PROJECT_ID} export GCP_ADMIN_USER=${YOUR_ADMIN_MAIL} export GCP_DOMAIN=${YOUR_ADMIN_MAIL} export BUCKET_ID=${YOUR_BUCKET_ID} $ gcloud builds submit --tag gcr.io/${PROJECT_ID}/gsu . $ gcloud run deploy \ --image gcr.io/${PROJECT_ID}/gsu \ --set-env-vars "GCP_ADMIN_USER=${GCP_ADMIN_USER},GCP_DOMAIN=${GCP_DOMAIN},SA_KEY=berglas://${BUCKET_ID}/sa-key?destination=tempfile" \ --platform=managed --region us-east1 \ --service-account gsu-455@${PROJECT_ID}.iam.gserviceaccount.com
service-account
を指定するのを忘れないでください。あと、GCP_ADMIN_USERとGCP_DOMAINはCloudIdentityの管理者メールアドレスとドメインを指定しておけば一旦大丈夫です。最後にデプロイされたURLでローカルの
~/.gsu_config
を変更します。$ cat ~/.gsu_config URL: http://localhost:8080こちらを修正すればgsuコマンドの向け先が変わります。
まとめ
あんまり類似のツールや運用を見つけれなかったのでクラウド流のベストプラクティスは他にあるのかもですが、コンテキストによって権限を変えたいときは多いと思います。
GCPの機能としてもロケーションやアクセス時間帯で権限がコントロールできますが、こういった任意のタイミングで出来るのも利便性は高いかと。今後はCLI側を真面目に作り込むのと、今は誰でも好きな権限になれてしまうのでその辺の対策や24時間で自動的に一度特権が外される機能とかを実装していきたいと思います。
それにしても個人で使ってた時と違ってお仕事で使う場合はIAM管理の便利さが身に染みます。
オンプレでもこういうの欲しい。。。それではHappy Hacking!
参考
- 投稿日:2019-12-22T09:57:28+09:00
I18nのtranslateメソッドを調べてみた
この記事は SmartHR Advent Calendar 2019 22日目の記事です。
こんにちは、SmartHRでサーバーサイドエンジニアをしているwakasaです。
SmartHRでは社会保険や雇用保険などの行政手続きを扱っており、各種手続きには様々な書類が必要になります。その際に、
I18n
を使って、項目を日本語化したりすることが多いのですが、I18n
を普段から何気なく使っているけど、どういう仕組みかを全く理解していないなと思ったので、ソースコードリーディングしてみました!環境
Ruby: 2.6.5
I18nの基本
I18nの機能は主に二つで、Railsガイドに記載があります。
パブリックI18n APItranslate # 訳文を参照する
localize # DateオブジェクトやTimeオブジェクトを現地のフォーマットに変換する今回はこのうち、
translate
に絞ってみていきます。
試しに、Gem
をインストールして使ってみましょう。まず下記のようなyaml
を用意します。food.ymlja: sushi: 寿司続いて、
I18n
のGemをインストールし(gem install i18n
)、irb
を立ち上げます。
あとは、I18nのレポジトリに書いてあるように操作します。console$ irb irb(main):001:0> require 'i18n' => true irb(main):002:0> I18n.load_path << 'food.yml' => ["food.yml"] irb(main):003:0> I18n.default_locale = :ja => :ja irb(main):004:0> I18n.t(:sushi) => "寿司"このように
translate
メソッドでは、指定した訳文を参照することができます。
translate
メソッドそれでは早速、
I18n.translate
メソッドから読んでいきたいと思います。I18n.translate
メソッドはlocale
等の設定をしたあと、config.backend.translate
メソッドを呼んでいます。
lib/i18n.rblib/i18n.rbdef translate(key = nil, *, throw: false, raise: false, locale: nil, **options) # TODO deprecate :raise locale ||= config.locale raise Disabled.new('t') if locale == false enforce_available_locales!(locale) backend = config.backend result = catch(:exception) do if key.is_a?(Array) key.map { |k| backend.translate(locale, k, options) } else backend.translate(locale, key, options) end end if result.is_a?(MissingTranslation) handle_exception((throw && :throw || raise && :raise), result, locale, key, options) else result end end alias :t :translateそのため、
I18n::Backend::Base#translate
をみてみます。下記がそのメソッドになります。
lib/i18n/backend/base.rblib/i18n/backend/base.rbdef translate(locale, key, options = EMPTY_HASH) raise I18n::ArgumentError if (key.is_a?(String) || key.is_a?(Symbol)) && key.empty? raise InvalidLocale.new(locale) unless locale return nil if key.nil? && !options.key?(:default) entry = lookup(locale, key, options[:scope], options) unless key.nil? if entry.nil? && options.key?(:default) entry = default(locale, key, options[:default], options) else entry = resolve(locale, key, entry, options) end count = options[:count] if entry.nil? && (subtrees? || !count) if (options.key?(:default) && !options[:default].nil?) || !options.key?(:default) throw(:exception, I18n::MissingTranslation.new(locale, key, options)) end end entry = entry.dup if entry.is_a?(String) entry = pluralize(locale, entry, count) if count if entry.nil? && !subtrees? throw(:exception, I18n::MissingTranslation.new(locale, key, options)) end deep_interpolation = options[:deep_interpolation] values = options.except(*RESERVED_KEYS) if values entry = if deep_interpolation deep_interpolate(locale, entry, values) else interpolate(locale, entry, values) end end entry endとても長いですが、重要な部分は
lookup
メソッドを呼び出している部分と、resolve
メソッドを呼び出している部分です。その名の通り、lookup
メソッドで、指定したkeyで中身を参照し、resolve
で参照してきた中身の解決を行います。あとの部分は、options
次第で処理が追加されていきます。
lookup
メソッドそれでは
lookup
メソッドをみていきます。
lib/i18n/backend/simple.rblib/i18n/backend/simple.rbdef lookup(locale, key, scope = [], options = EMPTY_HASH) init_translations unless initialized? keys = I18n.normalize_keys(locale, key, scope, options[:separator]) keys.inject(translations) do |result, _key| return nil unless result.is_a?(Hash) unless result.has_key?(_key) _key = _key.to_s.to_sym return nil unless result.has_key?(_key) end result = result[_key] result = resolve(locale, _key, result, options.merge(:scope => nil)) if result.is_a?(Symbol) result end endここで重要になるのは、
init_translations
と、normalize_keys
とinject
している部分です。各メソッドの役割をざっくりまとめると下記になります。
init_translation:ファイルを読み込み、 巨大なHashデータに変換。 normalize_keys: food.sushi のようなkeyを [:ja, :food, :sushi] のようなSymbolの配列に変換 inject部分:ハッシュデータを keysで繰り返し参照し、中身を取り出す。それでは各部分を細かくみていきます。
init_translation
ここでは、
load_translations
が呼ばれており、load_translations
は下記のようになります。
lib/i18n/backend/base.rblib/i18n/backend/base.rbdef load_translations(*filenames) filenames = I18n.load_path if filenames.empty? filenames.flatten.each { |filename| load_file(filename) } end最初に、
I18n.load_path
を参照しています。
これは最初の例で、I18n.load_path << 'food.yml'
のようにファイル名を追加した配列ですね。これらに対して一律に、load_file
メソッドを呼んでいます。
load_file
メソッドは下記です。
ruby:lib/i18n/backend/base.rbrubydef load_file(filename) type = File.extname(filename).tr('.', '').downcase raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}", true) data = send(:"load_#{type}", filename) unless data.is_a?(Hash) raise InvalidLocaleData.new(filename, 'expects it to return a hash, but does not') end data.each { |locale, d| store_translations(locale, d || {}) } endこのメソッドでは、ファイルの拡張子で
type
を判別し、それによって、send
で呼び出しメソッドを変えています。ちなみに
I18n
で読み込み可能なファイルは、rb/yaml/json
の3種類です。
yaml
は拡張子が、yml
でも、yaml
でもどちらでもOKです。
(alias_method :load_yaml, :load_yml
になっている)
rb
ファイルが指定可能なので、Hash
のvalue
にProc
オブジェクトを指定することもできます。実際の参考例は、下記の記事が参考になります。
あなたはいくつ知っている?Rails I18nの便利機能大全!また
yaml
の書き方は下記が参考になりました。配列も表せるとは知らなかったです。
プログラマーのための YAML 入門 (初級編)
normalize_keys
lib/i18n.rbdef normalize_keys(locale, key, scope, separator = nil) separator ||= I18n.default_separator keys = [] keys.concat normalize_key(locale, separator) keys.concat normalize_key(scope, separator) keys.concat normalize_key(key, separator) keys endここでは、与えられた各種引数を
normalize_key
に渡しlocale
scope
key
の順番で配列にconcat
していきます。I18n.default_separator
はご存知の通り.
です。
normalize_key
メソッドでは、separator
で分割した値のうち数字や真偽値の場合はそれぞれInteger
やBoolean
に、その他はSymbol
に変換しています。
inject
部分result = result[_key] result = resolve(locale, _key, result, options.merge(:scope => nil)) if result.is_a?(Symbol) resultここではファイルから取り出した巨大なHashに対して、分割された各
key
で参照していきます。基本的には途中段階であればHash
が、最終的な値であれば文字列が返ってきます。しかしif
文で書かれている部分がある通り、Symbol
が返ってくる可能性も考慮されています。Symbol
が返ってきた場合はresolve
の中で再度、translate
メソッドが呼び出されます。例えば下記のように
yaml
を書いたとします。food.ymlja: sushi: 寿司 ramen: :noodle noodle: 麺類この時、
ramen
を参照すると、:noodle
ではなく、きちんと麺類
が返ってきます。consoleirb(main):005:0> I18n.t(:ramen) => "麺類" irb(main):006:0> I18n.t(:noodle) => "麺類"
resolve
メソッドさて、
lookup
メソッドでkey
を元に巨大なHash
を参照し、目的の値を取り出すことができました。その値が文字列であれば良いですが、先の例にもある通り、Symbol
やProc
が返ってくることがあります。それを処理するのがresolve
メソッドです。
lib/i18n/backend/base.rblib/i18n/backend/base.rbdef resolve(locale, object, subject, options = EMPTY_HASH) return subject if options[:resolve] == false result = catch(:exception) do case subject when Symbol I18n.translate(subject, **options.merge(:locale => locale, :throw => true).except(:count)) when Proc date_or_time = options.delete(:object) || object resolve(locale, object, subject.call(date_or_time, options)) else subject end end result unless result.is_a?(MissingTranslation) end上記のように、
subject
がSymbol
かProc
かそれ以外かで処理を分岐しています。
Symbol
の場合は、先ほども書いたように、再度、translate
メソッドを呼び出しています。
Proc
の場合は、call
して実行して、再度、resolve
メソッドを呼び出します。
それ以外の単純な文字列等の場合は、そのまま返します。このようにしてようやく、求める値が返ってくる形になっています。
最後に
普段何気なく使っている、
I18n
ですが、ソースコードを呼んでみると様々な発見がありました!
yaml
だけではなく、rb
やjson
もかける
yaml
の書き方は色々とあり、配列も表現できる
Symbol
を値に設定するとエイリアスっぽく作用する
Proc
オブジェクトを値に設定することもできる
今回の解説では出していませんが、count
というオプションがあるなどなど発見がたくさんあり面白かったです。
最後までお読みいただき、ありがとうございます!
少しでも読まれた方に参考になる部分があれば幸いです。
- 投稿日:2019-12-22T09:54:52+09:00
Railsエンジニアのための実践!テストことはじめ
最近、会社の後輩や、業務としてRails未経験の友人から、テストの書き方について質問されることがありました。プロダクトコードは実際に実現したいことがあるのでそれを実現するために実装すれば良いため、イメージしやすいのですが、テストコードは最初はイメージしにくいのでしょう。
そこで、これまでの経験を踏まえ、テストを書き始めるための基本的に知っておくべきことについて記述したいと思います。
以下では、テストのフレームワークとしてRSpecを利用する前提で書きます。
また、モデルを生成には、FactoryBotというライブラリを利用します。FactoryBotはRailsではモデル生成で広く利用されているライブラリです。FactoryBotの導入は他の記事に任せます。テストを書きつづけるための準備
これから、Railsにおけるテストについてのことはじめを述べていきますが、その前に、テストを書き続けるための準備をしておきましょう。テストは書いて終わりではありません。ソースコードを書いてクラウド上(GitHubやGitLab、Bitbucketなど)にpushしたら、自動的にこれまで書いたテストが回る様にしてそれをエンジニアが気づく仕組みを作る必要があります。そのためのツールを紹介します。
まずは、継続的インテグレーション(CI)サービスです。有名なのはCircleCI、TravisCIなどです。自前で立ち上げる場合はJenkinsなどのオープンソースもあります。チームメンバーと相談して、どのサービスを利用するかを確認しましょう。ちなみに私は自前で立ち上げる手間を省くため、CircleCIやTravisCIを利用することが多いです。
継続的にテストを回す仕組みができたのであれば、テストのカバレッジも継続的に測定してあげる必要があります。Railsであれば、simplecovというgemを利用することが多いでしょう。そして、カバレッジを可視化するためにCodeCovやCode Climateといったサービスを利用するとよいでしょう。この時、CircleCIを使っている場合はconfigファイルの追記などが必要になりますが、その辺りは公式のドキュメントやその他の解説サイトに委ねます。テストを書く範囲
全てのコードに対してテストを書くことができればそれに越したことはありません。しかし、それは事実上不可能です。テストカバレッジが100%とすることのコストに見合うほどのメリットはないと言って良いでしょう。そこで、どういうところからテストを書き始めれば良いのかということについて考えてみます。
ビューがある場合
ビューがある場合のRailsのアーキテクチャを大まかにレイヤーに分けると以下のようになります。
ビュー コントローラー モデル 基本的な考え方は、この中で、一番下のレイヤー(モデル)と一番上のレイヤー(ビュー)を中心にテストを書くということとです。
その中でも、真っ先に書かなければいけないのがモデルに対するテストです。ここではモデルという言葉はActiveRecordないし、プレーンなRubyオブジェクト等とします。Railsにおけるモデルには、業務ロジックが記載されます。例えば、請求書を作る時にBillというモデルがあって、そこでは、注文履歴から、請求明細を作るという業務ロジックが書かれます。このロジックは、実際の業務のソースコードの焼き写しですから、複雑になりがちです。また、この場合、ユーザーに対して請求を行うわけですから、絶対に間違ってはいけません。コードの中でも重要度は高い部分と言えるでしょう。こういうところは絶対にテストを十分に書くべきです。経験上、テストを十分に書いたと思ったとしても、実際には考慮漏れはあることでしょう。そしてその修正をしたと思ったら既存の挙動が壊れてしまう。テストがあれば壊れたことに気づけますが、テストがなければ気づくことができません。
一方で、一気通貫的な振る舞いをテストも書くべきです。例えば何かを購入するサイトであれば、商品を検索し、商品を選んでもらい、カートに保存、決済に進み、完了するまでの一連の流れです。Railsでは、SystemSpec(以前はFeatureSpec)に該当するものです。この一連んお流れをきちんと書いておくことができれば、コントローラーの実装も担保できますし、モデルのロジックも全部ではないとはいえ、担保できます。ただ、SystemSpecでありとあらゆるパターンを網羅するのは必ずしも得策ではない場合があります。SystemSpecはモデルのテストよりも実行時間がかかるのが一般的ですし、細かなパターンの違いはモデル側のテストで吸収するのも一つの方法です。一方で、SystemTestのいいところはホワイトボックス的にテストをかけることです。内部実装を知らない人でも、業務に精通していればシナリオを洗い出せます。テストにかける余裕がある場合は業務に詳しいQAエンジニアにSystemSpecを書いてもらって、実装者に実装漏れを気づかせることもできます。APIを提供する場合
APIの場合は、ビューが無くなりますが、代わりにJSONやXMLなどの形式で値を返すことになるでしょう。
JSON等 コントローラー モデル この場合、SystemSpecは書けませんので、RequestSpecをかくことになります。しかし、考え方は同じで、ロジックが書かれているモデルのテストはきちんと書き、APIのインターフェースもきちんとテストを書くということです。
まとめますと、業務ロジックがコテコテに書かれているモデルに関しては面倒臭がらずにきちんとテストを書き、大きな挙動・流れの確認はSystemSpec(APIの場合はRequestSpec)で書くことで、下のレイヤーと上のレイヤーの挙動を担保し、全体としての挙動を担保すると考えれば良いでしょう。
逆に、ControllerやControllerのconcernのテストを書かなければと思った場合は、設計が間違っていると思ったほうがいいでしょう。controllerに業務ロジックは書く必要はないし、そのような場合は、既存のモデルが持つべきロジックであればそちらに実装すべきですし、そうでなければプレーンなRubyのクラスとして実装することも検討したほうが良いでしょう。このようにして、テストを書くという観点からシステム全体を見直すと、設計が良くないということに気づくことができます。これもテストを書くことの一つの利点です。モデルのテスト
それでは、モデルのテストについてはどういう観点でテストを書けば良いのでしょうか。
私は、モデルのテストの観点は大きく分けて2つあると考えています。一つめはバリデーションのテスト、二つ目はモデルの外で使用される可能性のあるメソッド(publicメソッド)のテストです。モデルの外で利用される可能性があれば、インスタンスメソッドであれ、クラスメソッドであれ、テストを書くべきでしょう。ActiveRecordのscope
もクラスメソッドに含まれるので、テストを書きます。以下の例では、次のようなActiveRecordのモデルで説明をします。
バリデーションのテスト
例えば、次の様なバリデーションがあったとしましょう。OrderItemモデルには、
family_name
(苗字)とgiven_name
(名前)の属性を持っており、その両方を合わせたもの(フルネーム)の長さの制限を20文字とするバリデーションです。
机上で構いませんので、モデルのテストを書いてみましょう。class OrderItem < ApplicationRecord validates :full_name, length: { maximum: 20, message: :too_long_sum } def full_name "#{family_name}#{given_name}" end endリスト1. OrderItemにfull_nameのバリデーションを追加
今回は、full_nameのバリデーションのテストを以下の様に実装してみました。
RSpec.describe OrderItem, type: :model do describe '#valid?' do let(:order_item) { FactoryBot.build(:order_item, attributes) } let(:attributes) { {} } subject { order_item } describe 'full_name' do context '20文字の場合' do let(:attributes) { { family_name: 'あ' * 10, given_name: 'い' * 10 } } it { is_expected.to be_valid } end context '21文字の場合' do let(:attributes) { { family_name: 'あ' * 10, given_name: 'い' * 11 } } it { is_expected.to be_invalid expect(subject.errors.keys).to contain_exactly(:full_name) expect(subject.errors.full_messages).to contain_exactly( 'お名前は合計20文字以内で入力してください。' ) } end end end endリスト2. OrderItemにfull_nameのバリデーションのテストの実装例
バリデーションのテストの時のポイントは、テストしたいバリデーションにフォーカスをあてるということです。モデルのデフォルト値は
FactoryBot
の定義されますが、このとき基本的にはvalidな値で定義しておきます。その上で、FactoryBot.build
する際にテストしたい属性のみ値を変更させ、バリデーションを実行させます。リスト2では、family_name
とgiven_name
の値を変更しています。
次に、validな場合のテストですが、こちらは簡単でvalidな値をattributes
に定義してあげ、it
ブロックのかでbe_valid
を呼んであげるだけです。RSpecのsubject
を利用するかどうかは流派によりますが、次の様にしても良いでしょう。it { expect(order_item).to be_valid }リスト3.
subject
を利用しない場合の実装例そして、ポイントは、invalidな場合のテストです。invalidであることをテストしたいので
be_invalid
を確認するのは問題ないでしょう。大事なのは、ここで終わらせてはいけないということです。まず、注目している属性のみにエラーが入っていることを確認する必要があります。今回の場合、一つの属性だけのテストなので、他の属性のエラーが入ることはありませんが、複雑なバリデーションを実装すると、ある属性がinvalidになると、他の属性もinvalidになることがあります。例えば、full_name
の最大文字数は20字だが、family_name
単体での文字数は15字である場合を考えます。この時、family_name
がinvalidな場合は、まずはfamily_name
のエラーをユーザーに解消してもらいたいためにfull_name
のvalidationは行わないという様にしたいとします。実装はリスト4の様にします。validates :family_name length: { maximum: 15 } validates :full_name, length: { maximum: 20, message: :too_long_sum }リスト4.
family_name
にバリデーションを追加この時、
family_name
がinvalidな場合はfull_name
のバリデーションエラーは出したくないので、テストはリスト5のようになります。context '苗字が16文字の場合' do let(:attributes) { { family_name: 'あ' * 16, given_name: 'い' * 10 } } it { is_expected.to be_invalid expect(subject.errors.keys).to contain_exactly(:family_name) expect(subject.errors.full_messages).to contain_exactly( '苗字は15文字以内で入力してください。' ) } endリスト5.
family_name
にバリデーションを追加した時のテストの例しかし、リスト4の実装では、
full_name
のバリデーションも実行されてしまい、このテストはfailします。この様に、ある属性のバリデーションを追加したことで、他のバリデーションにも引っかかってしまい、期待しない挙動となってしまうことがあります。そのために、expect(subject.errors.keys).to contain_exactly(:family_name)
として、errros.key
が:family_name
のみであることを明確にしています。ここでexpect(subject.errors.keys).to include(:family_name)
としている方を見かけますが、これでは、errors.keys
の配列に:family_name
が含まれていることしかテストできておらず、:family_name
以外が存在しないということができていません。contain_exacly
を使うことで他の属性にエラーがないということを担保できます。今回は、errors.keys
の要素の順序は気にしなくても良いため、contain_exactly
を使いましたが、順序も重要な場合はmatch
マッチャ等を使い、順序性も担保してあげましょう。
ちなみに、family_name
がinvalidな場合にfull_name
のバリデーションを実行させないためには、例えば以下の様な実装になります。ちょっと汚いので、もう少し良い実装方法があるかもしれません。validates :family_name, length: { maximum: 15 } validates :full_name, length: { maximum: 20, message: :too_long_sum }, if: -> { !errors.include?(:family_name) }リスト6. ifを利用して、family_nameがvalidな場合のみfull_nameのバリデーションを実行させる例
モデルのバリデーションの最後のテストは、エラーメッセージです。特に日本人向けのサービスの場合、i18nを利用してエラーメッセージを日本語化することがほとんどでしょう。モデルのバリデーションエラーののテストでは、エラーメッセージもテストするべきです。
前述のfamily_name
が15文字以下であることのバリデーションでは、length: { maximum: 15 }
としました。一方、full_name
が20文字以下であることのバリデーションでは、length: { maximum: 20, message: :too_long_sum }
のようにmessage
を追加しています。これは、family_name
とfull_name
それぞれでエラーメッセージを出し分けたいためです。family_name
は一つの属性であるため、苗字は15文字以内で入力してください。
というメッセージで良いのに対し、full_name
は二つの属性であるため、お名前は合計20文字以内で入力してください。
のように合計という言葉を追加したいわけです。
詳細な内部の仕様は追っていませんが、message
を追加しないとja.activerecord.errors.models.order_item.attributes.family_name.too_long
が呼ばれ、message
にtoo_long_sum
を追加すると、ja.activerecord.errors.models.order_item.attributes.full_name.too_long_sum
が呼ばれる様になります。
この辺りの挙動は、個人的にはやってみないとわからない様な気がしています。どのバリデーションエラー時にymlで指定したどのキーが使われるのかというのは、毎回調べるよりも実際に実行してみて確認する方が早いからです。そのためにもテストで確認しておくと実装が非常にスピードアップします。i18nが正しく設定されているかどうかを確認するという意味も込めて、エラーメッセージもきちんとチェックしておきましょう。そして、ここでも他の属性にエラーがないことを担保するためにcontain_exactly
を用いています。以上が、モデルのバリデーションのテストについての簡単な説明です。今回は、最大桁数が20文字というところだけを注目して、
full_name
が20文字および21文字の場合のみのテストを書きました。しかし、実際には、例えば、
・nil
のときはどうなのだろう。
・空文字のときは?
・最小値は1文字でよいのだろうか?
・family_name
が許容する文字は全てなのだろうか?髙
や?
といったJISの第1水準、第2水準以外の文字も許容されるのだろうか?
などといった仕様が存在するはずです。もし仕様が明示的でない場合でも、テストを書くことで、仕様が明示的になっていないことに気づくことができます。テストはきちんと場合分けをして書く必要があるため、自然と頭の中が整理されるからです。1そのためにもテストを書くことは非常に大事なことなのです。モデルの外で使用される可能性のあるメソッド(publicメソッド)のテスト
これまでの例で、インスタンスメソッド
full_name
のテストを書いてみましょう。ただし、単純にfamily_name
とgiven_name
を連結させただけではあまり面白みがないので、少しメソッドを拡張します。def full_name(space: '') "#{family_name}#{space}#{given_name}" endリスト7. full_nameメソッドに引数を追加し拡張
リスト7の様に
space
というキーワード引数を追加します。インスタンスメソッドfull_name
は変数space
の値でfamily_name
とgiven_name
が連結された値を解します。引数がなければデフォルト値が空文字となります。この場合のテストを書いてみましょう。開発者が書くモデルのテストは、ホワイトボックステストと呼ばれます。ホワイトボックステストとは、内部の論理構造を把握した上で、このメソッドの場合、if文はありませんが、例えば引数の有無でデフォルト値を使うかどうかが変わってくるので、その辺りを考慮すると、full_name
メソッドのテストは例えば以下の様になります。2describe '#full_name' do context '引数がない場合' do subject { order_item.full_name } it { is_expected.to eq 'てすと太郎' } end context '引数がある場合' do subject { order_item.full_name(space: space) } context 'spaceが全角スペースの場合' do let(:space) { ' ' } it { is_expected.to eq 'てすと 太郎' } end context 'spaceが半角スペースの場合' do let(:space) { ' ' } it { is_expected.to eq 'てすと 太郎' } end context 'spaceがnilの場合' do let(:space) { nil } it { is_expected.to eq 'てすと太郎' } end end endリスト8. full_nameメソッドのテスト
今回の例では、
subject
にテストしたいメソッドをセットしています。
インスタンスメソッド以外にも、クラスメソッドなども同様にテストしていきます。3SystemSpec
SystemSpecは、モデルのスペックと違って、ブラックボックス的な意味合いが強くなります。Capybaraを使ってブラウザ上での操作を模擬するため、モデルの関数名などの内部実装を知る必要はありません。したがって、ある一面では非常に簡単にテストを書くことができます。実際のブラウザのHTMLコードを確認しながらタグやname属性をみてspecを書いていけばいいからです。
ここで「ある一面では」と書いたのは、SystemSpecを書き始めるための下準備が非常に大変であるからです。ここの準備ができていれば、テストを書き始めることは非常に簡単ですが、ここの準備が大変なのです。画面を表示させるためには、多くの場合、事前にレコードを準備していく必要があります。そしてそのレコードは一つや二つですまない場合もあります。specを書くためにそのページを書く上での前提条件を把握していないといけないわけです。そのほかにも、テストしたい画面に行くまでが大変であることもあります。例えば、事前にログイン処理をしてページを数枚挟まないといけなかったり、検索機能がある場合は、ElasticSearchなどの外部サービスへAPIを叩く必要があったりします。テスト用のElasticSearchを準備するのか、はたまたモックで対応するのかなどといった追加の作業が発生します。しかし、ここさえクリアできれば、SystemSpecはレグレッションテストとして強力な武器になります。最初は準備するのは大変かもしれませんが、あとで幸せになるためにシステムが小さいうちに苦労をしておきましょう。レコードの保存や更新まで確認するべきか
さて、SystemSpecではどこまでテストをすれば良いのでしょうか。最低限は、画面の挙動として、次のページに進めて正しく表示されることを確認します。このとき、ユーザーの一連の流れを考えてテストを書いていきます。つまりはシナリオテストです。SystemSpecの場合、モデルのspecでは
it
を使っていた箇所をscenario
と書くようになります。これは、SystemSpecがシナリオテストであるということの表れです。すなわち、ユーザーがどのような振る舞いをするかをテストするということです。そうして書かれたSystemSpecでは、大抵の場合、何かしらのレコードが保存されたり、更新されたりします。このレコードの保存や更新はSystemSpecにおいて確認するべき項目となるの
レコードの保存や更新まで確認すべきではないという見解の方もいます。SystemSpecでは、画面の振る舞いをテストするべきで、レコードが正しく保存されたかどうかはSystemSpecの範疇ではないという意見です。前述の様に私は、SystemSpecでもレコードの保存まで確認するべきだという立場です。理由としては、昨今のフロントエンドの挙動は年々複雑になっており、意図した値がサーバサイドに渡ってきているかどうかが分かりにくくなっているからです。
例えば、図1のように、個人名義と法人名義でフォームの形式が異なる場合を考えてみましょう。そして、OrderItem
には、個人名義を保存する属性としてfamily_name
、given_name
、family_name_kana
、given_name_kana
があり、法人名義を保存する属性としてcorporate_name
、corporate_name_kana
があるとします。
図1. 契約名義で個人名義と法人名義でフォームの形式が異なる場合
このフォームのHTMLをみてみると次のようになっています。<%# 個人が選択された場合 %> <input name="order_item[family_name]" placeholder="苗字"> <input name="order_item[given_name]" placeholder="名前"> <input name="order_item[family_name_kana]" placeholder="ミョウジ"> <input name="order_item[given_name_kana]" placeholder="ナマエ"> <%# 法人が選択された場合 %> <input name="order_item[corporate_name]" placeholder="法人名"> <input name="order_item[corporate_name_kana]" placeholder="ホウジンメイ">リスト9. 名義入力フォームのHTML
Railsでは、inputタグのname属性によって、フォームがモデルのどの属性に対応されるかを判別するので、name属性は非常に大事です。個人名義か法人名義かによって保存するモデルの属性もかわってくるので、name属性も変わってきます。この挙動の実装としては、例えば、個人が選択されたら、JavaScriptを用いて
order_item[family_name]
〜order_item[given_name_kana]
のフィールドを表示にして、order_item[corporate_name]
とorder_item[corporate_name_kana]
のフィールドが表示されている場合は、非表示にするという制御を行います。今回は、Vue.jsを用いてこの挙動を実装しました。具体的には、v-show
やv-if
を用いて表示の制御を行います。
では、図2のように、個人が選択された場合のフィールドに値が入力された時、サーバーサイドにはどのような値が送られてくるかわかりますでしょうか。
図2. 個人のフィールドに値を入力した場合当然ですが、family_name, given_name, family_name_kana, given_name_kanaは送られてきます。ログで確認するとリスト10の様になります。
Parameters: {"order_item"=>{"family_name"=>"てすと", "given_name"=>"太郎", "family_name_kana"=>"テスト", "given_name_kan"=>"タロウ"}}リスト10 図2の状態でサブミットしたときのParameters(一部省略)
では、
corporate_name
やcorporate_name_kana
はどうでしょうか。サーバーサイドに値は渡ってくるでしょうか。
答えは、Vue.js側の実装によります。v-if
で実装されている場合は渡ってきませんし、v-show
で実装されている場合は値が渡ってきます。(未入力の場合は空文字(""
)で渡ってきます)したがって、v-show
で実装した場合、個人名義なのにcorporate_name
やcorporate_name_kana
が保存されてしまうことがあるわけです。この様な挙動のテストは画面上だけのテストでは担保することができません。これを担保するにはレコードの保存状態まで確認する必要があると私は考えています。テストケースはどの様に洗い出せばいいか
モデルのテストの場合、ホワイトボックステストとして、実装の中身がわかっているという前提でテストを書けばいいので、言ってしまえばソースコードをみて分岐を確認し、C0網羅、C1網羅、C2網羅など4を考慮してテストを書いていけば良いです。では、SytemSpecの場合、どうすれば良いでしょうか。
ここでは、ユーザーは申し込み画面で必要事項を入力したあと、確認画面へ進み、確認画面後は、ユーザーの入力した値に応じて画面の遷移が変わる場合を考えてみましょう。そして、このお申し込みには、付帯サービスなるものが存在し、離島でない場合は付帯サービスの説明を確認画面で表示し、離島の時は表示しません。また、申込者が本人または配偶者の場合のみ付帯サービスの説明を表示しますが、本人・配偶者以外の場合は表示しないという制御が必要だとします。
また、付帯サービスの説明表示とは別に、申し込み画面ではポイントカード登録をするかどうかを聞く項目があり、ポイントカード登録をするを選んだ場合は、確認画面後にポイントカードを登録する画面へ遷移するものとします。これをまとめたものが表1になります。rspec対象と書かれたカラムは後ほど説明します。さて、この12パターンのうち、どのくらいまでテストをする必要があるでしょうか。もちろん、12パターン全てテストをするに越したことはありません。しかし、今回は3項目でそれぞれ2つまたは3つの値しか取らないので、2 x 3 x 2 = 12パターンしかありませんが、項目数や取りうる値が増えた場合、この数は文字通り指数関数的に増えていきます。全てをテストすることはたとえ自動テストであっても事実上不可能です。そこで適度に間引いたテストケースを作る必要があります。その間引き方の考え方が組み合わせテストという考え方になります。組み合わせテストでは、2因子間網羅をすれば、妥当なテストケースが作成されていると見做すことが多いです。「因子」とは、前述した表1の例であれば、「契約場所」「申込者」「ポイントカード登録」の3つの項目を指します。2因子間網羅とは、3因子のうち2つの因子の組み合わせを網羅できているということです。具体的には、「契約場所」「申込者」の2因子では、「離島でない、本人」「離島でない、配偶者」「離島でない、本人・配偶者以外」「離島、本人」「離島、配偶者」「離島、本人・配偶者以外」の6つを網羅しているということです。
表1の場合、2因子間網羅となるように間引いてテストケースとして選んだのがrspec対象と書かれたカラムに◯をつけたパターンになります。この様にしてテストケースを作成していきます。組み合わせテストの技法については、組み合わせテストの用語「2因子間網羅」「直交表」「All-Pairs法」に詳しく書かれています。なぜ、2因子間網羅で妥当なテストといえるのかということについての言及もあります。ふるまいのテストとテストコードの共通化
これまで述べてきた様に、SystemSpecではユーザーのリアルに近い挙動をテストすることができます。ユーザーは、例えば申し込み画面があれば最初からなんの迷いもなくスムーズに申し込むとは限りません。入力する値を間違えてしまったり、カナを入れる箇所に漢字を入れてしまったりということがあります。SystemSpecではこうした挙動も確認できるのが非常に強みです。そして、忘れがちなのが、実装していない挙動に対するテストです。たとえば、ブラウザバック。ブラウザの戻るボタンを押された時に挙動がおかしくならないか。あるいは、リロード。例えばリロードすると入力していた文字が消えてしまっていたり、postとgetで挙動が変わってきたりすることも確認が必要です。私が過去にみてきたソースコードでは、完了画面にてリロードをするとレコードが次々と挿入される実装になっていたり、同じ内容で何度もAPIコールされているといった実装がありました。5こうした挙動も忘れずに確認しておきましょう。リロードするとレコードが増えていないかというのも、レコードの保存まで確認しておけばテストすることが可能です。加えて、自分たちで実装しておきながらテストし忘れるのが、戻るボタンです。例えば入力項目を入力した後の確認画面で、一つ前のページに戻って修正できるようにした画面上のボタン。この挙動も忘れがちです。ここもpostでの実装なのか、getでの実装なのか、ケースによると思いますが、悩みどころの一つだと思います。申し込みフォームが複数ページにまたがる場合、前からの遷移の時は問題なく動いていても、戻ってきた場合は挙動がおかしいということもありえます。しっかりと確認しましょう。
そして、こうしたふるまいのテストを行う場合、全てのケースにおいてリロードだったり、ブラウザバックだったりのテストを書く必要は必ずしもありません。どこか1ケースでこうした挙動を紛れ込ませれば十分です。そこでしばしば問題となってくるのがテストコードが共通化されている場合です。例えばSharedExampleやメソッド化して切り出すなどしてテストコードが共通化されていると、このテストコードの場合だけこの挙動を差し込みたいと言ったことが難しくなります。無理やり行なった結果、SharedExampleやメソッドの引数を増やして対応し、共通化したコードの方は分岐がたくさんといったことになってしまいます。こうなると、テストコードの可読性が極端に低下します。テストコードの良さは、ユーザーの行動が上から読み下すことができるという点にあります。テストコードに複雑な分岐が入っていないということで、読み手は自信をもって実装の意図や使用を理解することができます。特にSystemSpecにおいてはテストコードの過度な共通化は避けたほうが無難です。逆に、共通化をしようと思うエンジニアは、前述の様なユーザーのふるまいに対して鈍感なエンジニアと言えるかもしれません。SystemSpecは現実の複雑な仕様を反映させたものです。共通化するということは実装者がその複雑な仕様を理解しているということです。そして、それを確実にあとから参画したエンジニアにも伝える自信が必要です。我々はそこまで仕様を理解しているのでしょうか。今後変化していく仕様に対して追従できる柔軟なテストコードになっているでしょうか。テストコードはプロダクションコードとは明確に役割が違います。テストコードは簡潔でわかりやすく書くことが最優先です。テストコードを過度に共通化して満足しているのは一人のエンジニアのエゴと言えるかもしれません。テストは愛
ここまで、モデルのテスト、SystemSpecについて、Railsでテストを書くにあたっての考え方、注意点を記載してきました。最後に伝えたいこと。テストを書くということは愛であるということです。プロダクションコードは実装したその瞬間は完璧に理解しているかもしれません。しかし、半年後、1年後の自分がそのコードの意味を理解できていると言えるでしょうか。半年後、1年後に修正しなければならくなったときに、求められている仕様を間違えずに修正できるでしょうか。テストコードを書くということは、半年後、1年後への自分への愛です。そして、それは同時にそのコードを触る他のメンバーへの愛でもあります。例えば、リリースしてから実際にバグがおきてしまったとしましょう。バグが起きればそれは修正されます。そのときにバグが起きるケースをテストで書き残しておくのです。こうすることで、一度自分が通ったバグを他のメンバーが踏まないようにすることができます。他のメンバーが同じバグを生み出さない様に。これはすなわち愛です。
また、テストコードは非エンジニアのためにも重要です。1年前に意図して実装したコードがあり、その意図をディレクターやデザイナーなどの非エンジニアも忘れているかもしれません。仕様書に書かれていたからと言ってそれを記憶しているとは限りません。その仕様書の存在すら忘れ去られていることもあります。テストコードはまさしく実際に動くコードにて、記憶を記録するためのものであると言えます。人はどんどん忘れていく生き物です。それが人間の性です。忘れていくことで新しいことを記憶することができます。そして新たな価値を生み出すことができるのです。なので、過去のことは記録してどんどん忘れていってよいのです。テストコードを書くことで記録していきましょう。
自動テストは愛なのです。
ここの整理がきちんとできているエンジニアは意外と多くはありません。例えば高校数学で二次関数の最大最小を求める時に場合分けをした経験はありますでしょうか。あの場合分けを思い出してください。状況によって、二つの場合分けでよかったり、三つだったりしたと思います。実際の仕様ではもっと複雑になります。この複雑な場合分けを着実にかつ素早くできることがエンジニアの力量の一つの指標であると私は思います。 ↩
ホワイトボックステストについては、多くの文献がありますので、説明はそちらに譲ります。例えばQiitaの記事ではこちらの記事がわかりやすかったです。 ↩
describeには、多くの場合、テスト対象のメソッド名を記述しますが、Rubyでは、インスタンスメソッドには
#
を、クラスメソッドには.
を付ける文化があります。 ↩40台のベテランエンジニアの書いたコードでした。ベテランエンジニアですら、こうした実装ミスを犯すということは私のような新米エンジニアはもっと実装をミスしているということを肝に命じておかなければなりません。 ↩
- 投稿日:2019-12-22T08:07:18+09:00
BOM 付き UTF-8 csv ファイルのカラムを RSpec で検証する時は BOM を取り除く必要があった
はじめに
BOM 付き UTF-8 の csv ファイルのカラム名を RSpec で検証しようと思ったときにハマったので、備忘録を残しておきます。
tl;dr
BOM 付き UTF-8 の csv ファイルのカラム名を RSpec で検証するときは、
CSV.parse
するときにBOM を取り除く必要がありました。csv = CSV.parse(csv_content.sub(/^\xEF\xBB\xBF/, ''))背景
RSpec でアプリ側で作成した csv ファイルが適切に出力されていたか確認するために、csv のカラム名を検証しようとしていました。
サンプルは以下のような形です。
require 'rails_helper' RSpec.describe Sample do describe '#method' do it 'checks if csv columns is valid' do # Sample.csv_content が csv ファイルを出力するメソッド csv_content = Sample.csv_content csv = CSV.parse(csv_content) expect(csv[0][0]).to eq '日付' expect(csv[0][1]).to eq '時刻' end end end
Sample.csv_content
は以下のような形です。方法については以下を参考にしました。
(実際の処理は BOM 付き csv ファイルをそのままダウンロードして出力するような処理だったので、BOM を無理やりつけてはいません)BOM付きUTF-8のCSVを作成する(Excel文字化け対策) - Qiita
class Sample def self.csv_content bom = %w(EF BB BF).map { |e| e.hex.chr }.join content = CSV.generate do |csv| csv << ['日付', '時刻'] csv << ['2019/12/22', '14:00'] end content end end出力する csv ファイルは以下の通りです。こちらが BOM つき UTF-8 のエンコーディングになっています。
日付,時刻 2019/12/22,14:00BOM 付きのままだと検証が失敗する
しかし実際に RSpec を実行しようとすると、
expect(csv[0][0]).to eq '日付'
の部分で検証チェックに失敗します。エラーは以下のような形です。expect(csv[0][0]).to eq '日付' expected: "日付" got: "日付" (compared using ==)<一見、expected と got が同じ値に見えるので、何が原因なのか最初はわかりづらいですね。。
RSpec 実行中にデバッガーで処理を止め、文字列エンコードを ascii-8bit にして確認すると、
expected: "\xE6\x97\xA5\xE4\xBB\x98" got: "\xEF\xBB\xBF\xE6\x97\xA5\xE4\xBB\x98"got のほうが
\xEF\xBB\xBF
分だけ長いことがわかりました。これは BOM そのもので、CSV.parse
しても、文字列先頭の BOM はそのままついてくるということでした。。解決方法
CSV.parse
する対象の csv 文字列から、BOM を取り除く処理を書きます。文字列の最初に表示される BOM のみsub
で空文字に置換するイメージです。csv_content = Sample.csv_content csv = CSV.parse(csv_content.sub(/^\xEF\xBB\xBF/, ''))そもそも BOM とは
Byte Order Mark の略です。Wikipedia から引用します。
プログラムがテキストデータを読み込む時、その先頭の数バイトからそのデータがUnicodeで表現されていること、また符号化形式(エンコーディング)としてどれを使用しているかを判別できるようにしたものである。
UnicodeがはじまったころはアメリカではASCII、ヨーロッパなどではISO-8859、日本ではShift_JISやEUC-JPが主流であり、使用されている符号化方式がUnicodeであることを明確に区別する必要があった。その方法として、先頭のデータにテキスト以外のデータを入れることが発案された。
復数の国で異なるエンコーディングが使われていた中で、世界共通の文字コードとして扱われる Unicode との区別し、ファイルを開く際に Unicode のエンコーディングかどうかを判別できるようにしたのですね。
終わりに
なんとなく言葉だけ知っていた BOM について、この機会に改めて調べられてよかったです。
参考
- 投稿日:2019-12-22T04:11:25+09:00
No route matches [GET] 〜 について
学習初期段階のこういったエラーは見るだけでやる気無くなりますよね...
というわけでこれまで筆者が経験で見つけたエラー原因を羅列します!
(筆者のようにあまり他人に聞けない性格の初学者の方に向けて記載しています。そんなの当たり前だなどのツッコミはご遠慮ください。)
①~/railsのファイル名/config/routes.rb
このファイルの中のroot to:'posts#index'が書かれていなかった
→下記のテンプレートを使ってください
Rails.application.routes.draw do
root to: 'posts#index'
end②~/railsのファイル名/assets/viwes/posts/index.html
このファイルがないまたは内容がないょ..ゴホンゴホン
→ファイルを右クリックで作りましょう。(エディター上でもいいしファインダーからファイルの箇所に潜ってもよいです。)名前をつけるときはフォルダ名が①の#より前、ファイル名が①の#より後とぴったり同じようになるようにしてくださいあと内容の例は下記の通りです
<div class="contents row">
<% @tweets.each do |tweet| %>
<div class="content_post" style="background-image: url(<%= tweet.image %>);">
<div class="more">
<span><%= image_tag 'arrow_top.png' %></span>
<ul class="more_list">
<li>
<%= link_to '詳細', "/tweets/#{tweet.id}", method: :get %>
</li>
<% if user_signed_in? && current_user.id == tweet.user_id %>
<li>
<%= link_to '編集', "/tweets/#{tweet.id}/edit", method: :get %>
</li>
<li>
<%= link_to '削除', "/tweets/#{tweet.id}", method: :delete %>
</li>
<% end %>
</ul>
</div>
<%= simple_format(tweet.text) %>
<span class="name">
<a href="/users/<%= tweet.user.id %>">
<span>投稿者</span><%= tweet.user.nickname %>
</a>
</span>
</div>
<% end %>
</div>③誘導式の教材(問題)を解いていた時に、localhost3000につなげれば正常に接続できる(問題が解けている)状態だったのに、localhost3000/postsで接続してしまったためエラーが起きてしまっていた。
https://localhost:3000 でlocalserver(公開していない自分のパソコン内のウェブページ)に接続できるのですが、筆者はそのURLがわからず、教材のちょっと前に使ったURLをそのまま打ち込んでいたためこういった間違いを犯しました。
教材が想定していたミスの箇所ではないミスであると気づけなければ永遠に彷徨えます。なのでこういったミスを犯すこともあると知っておこうというお話です。エラー発見問題では芋づる式になっていることが多いため、接続先を間違えるとその問いを解くことができず、そのことによりその先の問題が全て解けなくなってしまうことがよく起きます。(これで筆者は丸3日間悩んでました。笑)
とりあえずこの記事は③を一番主張したくて書きました
何かの参考になればと思います