20191222のRubyに関する記事は27件です。

初学者向け「Deviseの導入の仕方」

deviseの導入で、一番最初にやる手順の紹介です。

deviseとは

Rubyのgemのひとつ。
新規登録機能やログイン機能の実装をとても簡単にしてしまうgem。

まずはGemfile&インストール

Gemfile
gem 'devise'

追記したら、bundle install実行

$bundle install

deviseを使うためのコマンド

$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 users

migrationファイルを見てみると、

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.rb
devise_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の記事を書いてみようと思いました。

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

Ovtoの課題

このエントリはOpal Advent Calendar 2019の8日目のエントリです。

Ovtoとは

私が作っているOpal用のWebフレームワークです。

Ovtoの課題

設計時にあまり考えてなかったのですが、ミドルウェアを作るときに記述が煩雑になるという問題がありそうです。

例えばpockeさんが作ってくれたovto-routerを見ると、stateとactionのそれぞれにovto_routerというprefixを付けていることが分かります。現状のOvtoではstate・actionの名前空間が一つしかないので、衝突を回避するためにprefixを付ける必要があります。

このへんを簡潔に書けるようになるとミドルウェアがより作りやすくなるかなと思っています。何かうまくやる方法があるといいですね(まだノーアイデア)。

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

2019年のOpal

このエントリはOpal Advent Calendar 2019の1日目のエントリです。

2019年のOpal

今年のニュースはなんといってもOpal 1.0.0がリリースされたことでしょう:tada:
0.11の頃から、もう1.0と言っていいくらい安定して動いていましたが、正式に1.0が出るとやはり嬉しいですね。

そのあと小さな修正が入って、最新版はOpal 1.0.2となっています。

2019年のOpalと私

個人的なところでは、4月のRubyKaigi 2019 福岡でOvtoというOpal用フレームワークを発表しました。

Ovtoについてはこのカレンダーの別の日にまた書くかもしれません。

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

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というプラグインを使っています。:rocket:

  • GAE/Ruby SE
  • Sinatra
  • Slash Commands

実装

1. GAEの設定ファイルをかく

まずはGAEにdeployするための設定ファイルを作成します。ベータ版は2.5系のRubyがランタイムになっているのでruby25を指定してサービス名はリポジトリ名に合わせておきます。この時初めてGCPプロジェクトにdeployする場合はデフォルトサービスに何かしらdeployしておく必要があります。なので当該サービス以外をdeployする予定がない場合は指定しなくて良いです。

app.yaml
runtime: ruby25

service: pr-bot

entrypoint: bundle exec ruby app.rb

includes:
  - env.yaml

2. envにGITHUBのアクセストークンを設定する

app.yamlで指定したincludes部分は環境変数を設定するファイルを定義しています。GitHubで取得したアクセストークンをこちらに定義します。またcommitされないようにgitignoreに追加しておきましょう。

GitHubアクセストークン

env.yaml
env_variables:
  GITHUB_ACCESS_TOKEN: xxxxxxxxxxx
$ echo "env.yaml" >> .gitignore

3. Rubyバージョンを2.5系にする

ランタイムに合わせてRubyのバージョンを設定しておきます。rbenvを使っていたので次の通り設定しました。

$ rbenv local 2.5.5

4. gem追加

今回使ってみることにしたsinatraと定数管理はconfigを使ってGithubクライアントようにoctokitを使っています。

Gemfile
source 'https://rubygems.org'

gem 'sinatra'
gem 'sinatra-contrib'

gem 'config'

gem "octokit", "~> 4.0"

5. 定数管理

コマンドでprを作るmerge元ブランチとmerge先ブランチを定義しておきます。

config/settings.yml
organization:
  name: konchanxxx
  repos:
    pr-bot:
      from: master
      to: release/production

6. リクエストハンドラ

ここから急に説明が雑になりますが リクエストハンドラを実装します。主にやっているのはslackコマンドで受け取った引数をパースしてリポジトリとmerge先、merge元を設定してプルリクを作成しています。丁寧に実装するならハンドラにロジックを書かずに業務ロジックを集約するアプリケーション層を実装してあげると良いと思います。

app.rb
require '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'
  }
end

7. 各モジュールの追加

ハンドラで利用しているロジックを追加しておきます。

src/client.rb
require '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.rb
require_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
end
src/repository.rb
class 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
end

8. GAE/Rubyにデプロイ

gcloud SDKを使ってGAEにデプロイします。デプロイに必要な情報はapp.yamlに定義しているので下記のコマンドを実行するだけで大丈夫です。

$ gcloud app deploy

9. Slash Commandsの設定

slackからコマンド実行するためにslash commandsというアプリを追加します。
URLの箇所にデプロイしたGAEインスタンスのhostを追加します。

10. slackから実行してみる

slask commandsの設定でコマンドは自由に設定することができます。今回はdeployというコマンドにしました。

スクリーンショット 2019-12-22 21.55.26.png

11. プルリクが作成されたことを確認する

GitHubの対象リポジトリでプルリクが作成されていることを確認します。mergeされたプルリクのリンクもつけているので良い感じにmerge対象のものを確認することができます。

スクリーンショット 2019-12-22 22.19.03.png

感想

GAE/RubyとSinatraを作ってかなり手軽にBotを作成することができました。簡単なツールはこの組み合わせで作ると楽かもしれません。以前Cloud Runで似たようなツールを作ったことがありましたがdockerイメージを作らないような場合だとこちらの方が手軽かもしれません。あと次はHanamiとかも使ってみたいと思います:bow:

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

#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-spec

gem名はアンダースコア区切りだ。

名前を間違っていたらinstallできるはずないよね。

image

Ref

"Could not find gem 'email-spec' in any of the gem sources listed in your Gemfile."

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2861

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

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対応状況でした。

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

ダックタイピングの何が良いのか

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を実装すれば同じことができます。また、型が明示的であれば、引数が何のメソッドを実装していなければならないかの理解が容易です。
しかし、その調子でインタフェースを定義していくとものすごい数になるのではないでしょうか。それを続けるだけでも大きなコストになりそうです。
確かに、ダックタイピングでは抽象に依存しているため、コードを理解するのに少し時間がかかります。しかし、理解できればその柔軟性により変更コストを少なくできる場面が出てくると思います。

型に起因する実行時エラーが起きる

動的型付け言語において、引数のクラスを理解していないと正しく動かないメソッドは、新しいクラスが現れると当然失敗します(想定しない型に起因するエラーで)。そのとき、「静的型付け言語のように型のエラーを事前に教えてくれないから、編集したコードから離れたところに問題があっても実行するまで気づけなかった。だから動的型付け言語は苦手だ。新しいクラスに対応するロジックを追加しなくては...」と考えたことがある方もいるのではないでしょうか?
実行時エラーに注意を逸らされそうになりますが、本当の問題はそのメソッドが具体的なクラスに依存していることです。その依存を剥がすリファクタリングをしていくと、最終的に抽象(ダックタイプ)に依存するようになります。そのダックタイプこそが、依存すべき安定したインタフェースです。型に起因するエラーは、設計が具体的なクラスに依存していることへの警告として捉えるべきなのかも知れません。

おわりに

偉そうなことを書いてしまいましたが、オブジェクト指向設計実践ガイドに影響されまくっていますので、ぜひそちらも読んでみてください。
ダックタイピングはうまく使えば柔軟な設計が実現できますが、宣言的に見づらい部分もあるのでその問題への対策をテスト等で工夫する必要があります。その方法についても書籍では紹介されています。

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

超初心者が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.rb
def 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])
end

viewの書き方は新規投稿の時と全く同じ。

<%= 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: true

validates :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.rb
def current_user
    @current_user = User.find_by(id: session[:user_id])
  end

session[:user_id]は、ページを移動してもユーザー情報を保持し続けるために、sessionという特殊な変数を使うようrailsに実装されているようです。
sessionに値を代入すると、ページを移動してもブラウザに残り続けて、ブラウザはそれ以降のアクセスでsessionの値をRailsに送信します。このsessionが、ページが遷移してもユーザー情報を保持し続けることができるキーマンのようですね!

ついでに、この先にも簡単に触れると、色々省略してますが、受けるcontrollerはこうなります。

user_sessions_controller.rb
class 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.rb
def 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)

こんなにシンプルになります。
こんな書き方があるのは知らなかった・・・

実はこの感じの書き方が、エンジニアの実務している方々は普通に書いているみたいなのですが、プロゲートでは全く触れらていないし、深いなーと思いました。

最後に

他にも色々と詰まりまくってますが、力尽きたので、この辺にしておきます。

今後も頑張っていきましょう!!

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

三項演算子

学習を進めていて、三項演算子の文法がなかなか覚えられなかったので、ノートにまとめておきます。

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

使いこなすと色々記述が簡単に書けそうですね^^

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

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 visudo

viが開くので、行末に「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 reload

RVMと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 bundler

MySQLをインストール

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 postfix

Nginxをインストール

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 -t

redisのインストール

SSH接続したEC2インスタンス内.
$ rpm -ivh http://rpms.famillecollet.com/enterprise/remi-release-7.rpm
$ yum install --enablerepo=epel,remi redis
$ systemctl enable redis
$ systemctl start redis

Railsディレクトリと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/.env

RuntimeErrorになるので、rake secretで生成した文字列を貼り付けます。

bundle exec rake secret
ff9906beb21fd77ca11aa433d42b7caa720f37641a27bf1617e67894a4fca2c64526af874d1ef1cfac4372cabdf18127110b31bb766c46ef35dbab62736b04e3
shared/.env
SECRET_KEY_BASE: ff9906beb21fd77ca11aa433d42b7caa720f37641a27bf1617e67894a4fca2c64526af874d1ef1cfac4372cabdf18127110b31bb766c46ef35dbab62736b04e3
SSH接続した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 yarn

Capistranoでのデプロイ

参考にさせていただきました。
https://qiita.com/ea54595/items/12ab7b3a8213b35cca10

pumaの設定のみ追記しました。

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/[アプリ名]
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

この部分がエラー内容で重要そう
``
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
```

色々サイト検索を行い、下記にたどり着く

打ち込む

ターミナル
$ 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も試してみればよかったと今更ながら思う←)

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

RubyからCプログラムを利用する

FFI

Foreign function Interface の略で、別のプログラミング言語で記述された関数を利用するための機能です。
Rubyでは、ffiというgemや、Rubyに梱包される標準ライブラリのFiddleなどが同等の機能を提供しています。

今回は、gemである提供するffiを使って、RubyからCプログラムを利用してみたいと思います。

値渡し

まずはffiに慣れていきましょう。
呼び出し元のコードは以下です。

lib.c
int add(int a, int b) {
    return a + b;
}

int型の引数を2つ取って、それらの加算結果を返すaddを定義しました。

このaddをRubyから呼び出すために、共有ライブラリ(*.so)の形式に変換する必要があります。
以下のコマンドを使って変換します。

$ gcc -shared lib.c -o libadd.so

gccコンパイラに-sharedオプションを渡すと、共有ライブラリとしてオブジェクトファイルを吐き出してくれます。
Rubyから呼び出すファイルはlibadd.soです。

add.rb
require '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.rb
require '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)

ffiリポジトリrubydocを参考にしています。

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から呼び出すことができました。
言語の特徴を組み合わせすることでプログラムを最適化する、という考え方は個人的にとても気に入りました。

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

参照渡しを使ってRubyからCプログラムを利用する

FFI

Foreign function Interface の略で、別のプログラミング言語で記述された関数を利用するための機能です。
Rubyでは、ffiというgemや、Rubyに梱包される標準ライブラリのFiddleなどが同等の機能を提供しています。

今回は、gemである提供するffiを使って、参照渡しをしてみたいと思います。
呼び出し元の関数はC言語を使って記述しました。

値渡し

まずは値渡しでffiに慣れていきましょう。
呼び出し元のコードは以下です。

lib.c
int add(int a, int b) {
    return a + b;
}

int型の引数を2つ取って、それらの加算結果を返すaddを定義しました。

このaddをRubyから呼び出すために、共有ライブラリ(*.so)の形式に変換する必要があります。
以下のコマンドを使って変換します。

$ gcc -shared lib.c -o libadd.so

gccコンパイラに-sharedオプションを渡すと、共有ライブラリとしてオブジェクトファイルを吐き出してくれます。
Rubyから呼び出すファイルはlibadd.soです。

add.rb
require '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.rb
require '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)

ffiリポジトリrubydocを参考にしています。

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プログラムで書かれた関数を値渡し、参照渡しそれぞれで呼び出してみました。
言語の特徴を組み合わせすることでプログラムを最適化する、という考え方は個人的にとても気に入りました。

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

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.sh

dockerが入ったりします。時間がかかるということで途中で星空を眺めます。

This is going to take a long time
.....................................................................
............+...............................................+........
................+....................................................
.....................................................................
.....................................................................
.....................................................................
+..+.................................................................
...............................................+.....................
.....................................................................
.....................................................................
.....................................................................
...........................+.......+...............................+.
.........+.................................+.........................
...........+..............................+..........................

入りました。

$ dokku -v
dokku version 0.19.11

dokku 設定

(このまま現行ドメインの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.md

sample 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-runtimes

sample app を SSL化

dokku-letsencrypt (Beta)

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感を感じられない。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Amazon ElastiCache Rails設定

AWS側の設定

https://qiita.com/leomaro7/items/f031cfdd7d12d5d5ccc5
https://lab.sonicmoov.com/development/aws/elasticache/

Rails側の設定

config/environments/staging.rb
  config.session_store :redis_store, {
    servers: {
        host: '[プライマリエンドポイント]',
        port: 6379,
        db: 0,
        namespace: 'sessions'
    },
   expire_after: 60.minutes
  }
config/initializers/sidekiq.rb
Sidekiq.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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Amazon ElastiCache】 Rails設定

AWS側の設定

参考
https://qiita.com/leomaro7/items/f031cfdd7d12d5d5ccc5
https://lab.sonicmoov.com/development/aws/elasticache/

Rails側の設定

config/environments/staging.rb
  config.session_store :redis_store, {
    servers: {
        host: '[プライマリエンドポイント]',
        port: 6379,
        db: 0,
        namespace: 'sessions'
    },
   expire_after: 60.minutes
  }
config/initializers/sidekiq.rb
Sidekiq.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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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-results

GemFileの設定

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'
end

bundle installする

bundle install --without production

すべてコミットしてPUSH

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

【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の間でファイルの転送を行います。

  1. VPCのダッシュボードを開き、 エンドポイント の作成ボタンをクリックします。
    aws_vpc_endpoint_01.png

  2. 宛先の選択をします。 AWS services と com.amazonaws.ap-northeast-1.s3 にチェックを入れます。
    aws_vpc_endpoint_02.png

  3. 通信したいEC2インスタンスが置かれているVPC と そのサブネットに当てられているルートテーブル を選択します。
    スクリーンショット 2019-12-22 16.17.56.png

  4. カスタムポリシーの設定

カスタムポリシーの例
{
    "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/ --recursive

Amazon 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 list

Railsの設定

Gemの設定

Gemfile
gem 'carrierwave'
gem 'rmagick'
gem 'fog-aws'

ImageUploaderの設定

$ rails g uploader Image
/uploaders/image_uploader.rb
class 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
end

CarrierWaveの設定

/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.rb
class User < ApplicationRecord
  mount_uploader :image, ImageUploader
end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HelloWorldとはなにか 〜第1章〜

先日MatzのRuby誕生秘話を聞いたとき、Hello Worldを表示するまでに半年かかったという話を聞きました。
それに触発されて自分でも言語作ってHello Worldしよう!と息巻いているんですが、ちょっと記事にするには期間的に厳しかったので、ひとまずRubyでHello Worldが出力される過程が、どんな流れになっているかを解き明かそうと思います。

が、正直ちょろっと見ただけで解き明かせるものでもないので、この記事ではその入口を整理するくらいに留め、あとは第2章以降でがんばります。

環境

  • Ruby: v2_7_0_rc2

main関数から辿ってみる

ちゃんと処理の入り口から辿ってみようということで、main関数から辿ってみることに着手。
すると、、、

main.c
int
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.h
static 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.c
VALUE 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という名でファイル名検索してなくない?ということで、やってみると、
スクリーンショット 2019-12-22 15.52.47.png
あるやんけ
して、中を見てみると、、、

string.c
static 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章は気が向いたら書きます。

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

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

間違ってたらごめんなさい?

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

【Ruby】配列の要素を半角空白区切りで出力

配列の要素を半角空白区切りで出力したいときは、次のようにします。

puts array.join(' ')

例:

array = ["apple", "grape", "orange"]

puts array.join(' ')
# => apple grape orange
array = ["1", "2", "3"]

puts array.join(' ')
# => 1 2 3

以上です。

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

【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さん、ご指摘いただきありがとうございました。

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

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}"
fi

gcloudコマンドから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!

参考

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

I18nのtranslateメソッドを調べてみた

この記事は SmartHR Advent Calendar 2019 22日目の記事です。

こんにちは、SmartHRでサーバーサイドエンジニアをしているwakasaです。
SmartHRでは社会保険や雇用保険などの行政手続きを扱っており、各種手続きには様々な書類が必要になります。

その際に、 I18n を使って、項目を日本語化したりすることが多いのですが、 I18n を普段から何気なく使っているけど、どういう仕組みかを全く理解していないなと思ったので、ソースコードリーディングしてみました!

環境

Ruby: 2.6.5

I18nの基本

I18nの機能は主に二つで、Railsガイドに記載があります。
パブリックI18n API

translate # 訳文を参照する
localize # DateオブジェクトやTimeオブジェクトを現地のフォーマットに変換する

今回はこのうち、 translate に絞ってみていきます。
試しに、 Gem をインストールして使ってみましょう。まず下記のような yaml を用意します。

food.yml
ja:
  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.rb

lib/i18n.rb
def 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.rb

lib/i18n/backend/base.rb
def 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.rb

lib/i18n/backend/simple.rb
def 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_keysinject している部分です。

各メソッドの役割をざっくりまとめると下記になります。

init_translation:ファイルを読み込み、 巨大なHashデータに変換。
normalize_keys: food.sushi のようなkeyを [:ja, :food, :sushi] のようなSymbolの配列に変換
inject部分:ハッシュデータを keysで繰り返し参照し、中身を取り出す。

それでは各部分を細かくみていきます。

init_translation

ここでは、 load_translations が呼ばれており、 load_translations は下記のようになります。
lib/i18n/backend/base.rb

lib/i18n/backend/base.rb
def 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.rb

ruby
def 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 ファイルが指定可能なので、 HashvalueProc オブジェクトを指定することもできます。

実際の参考例は、下記の記事が参考になります。
あなたはいくつ知っている?Rails I18nの便利機能大全!

また yaml の書き方は下記が参考になりました。配列も表せるとは知らなかったです。
プログラマーのための YAML 入門 (初級編)

normalize_keys

lib/i18n.rb

lib/i18n.rb
def 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 で分割した値のうち数字や真偽値の場合はそれぞれ IntegerBoolean に、その他は 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.yml
ja:
  sushi: 寿司
  ramen: :noodle
  noodle: 麺類

この時、 ramen を参照すると、 :noodle ではなく、きちんと 麺類 が返ってきます。

console
irb(main):005:0> I18n.t(:ramen)
=> "麺類"
irb(main):006:0> I18n.t(:noodle)
=> "麺類"

resolve メソッド

さて、 lookup メソッドで key を元に巨大な Hash を参照し、目的の値を取り出すことができました。その値が文字列であれば良いですが、先の例にもある通り、 SymbolProc が返ってくることがあります。それを処理するのが resolve メソッドです。
lib/i18n/backend/base.rb

lib/i18n/backend/base.rb
def 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

上記のように、 subjectSymbolProc かそれ以外かで処理を分岐しています。

Symbol の場合は、先ほども書いたように、再度、 translate メソッドを呼び出しています。
Proc の場合は、 call して実行して、再度、 resolve メソッドを呼び出します。
それ以外の単純な文字列等の場合は、そのまま返します。

このようにしてようやく、求める値が返ってくる形になっています。

最後に

普段何気なく使っている、 I18n ですが、ソースコードを呼んでみると様々な発見がありました!

yaml だけではなく、 rbjson もかける
yaml の書き方は色々とあり、配列も表現できる
Symbol を値に設定するとエイリアスっぽく作用する
Proc オブジェクトを値に設定することもできる
今回の解説では出していませんが、 count というオプションがある

などなど発見がたくさんあり面白かったです。

最後までお読みいただき、ありがとうございます!
少しでも読まれた方に参考になる部分があれば幸いです。

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

Railsエンジニアのための実践!テストことはじめ

 最近、会社の後輩や、業務としてRails未経験の友人から、テストの書き方について質問されることがありました。プロダクトコードは実際に実現したいことがあるのでそれを実現するために実装すれば良いため、イメージしやすいのですが、テストコードは最初はイメージしにくいのでしょう。
 そこで、これまでの経験を踏まえ、テストを書き始めるための基本的に知っておくべきことについて記述したいと思います。
 以下では、テストのフレームワークとしてRSpecを利用する前提で書きます。
 また、モデルを生成には、FactoryBotというライブラリを利用します。FactoryBotはRailsではモデル生成で広く利用されているライブラリです。FactoryBotの導入は他の記事に任せます。

テストを書きつづけるための準備

 これから、Railsにおけるテストについてのことはじめを述べていきますが、その前に、テストを書き続けるための準備をしておきましょう。テストは書いて終わりではありません。ソースコードを書いてクラウド上(GitHubやGitLab、Bitbucketなど)にpushしたら、自動的にこれまで書いたテストが回る様にしてそれをエンジニアが気づく仕組みを作る必要があります。そのためのツールを紹介します。
 まずは、継続的インテグレーション(CI)サービスです。有名なのはCircleCI、TravisCIなどです。自前で立ち上げる場合はJenkinsなどのオープンソースもあります。チームメンバーと相談して、どのサービスを利用するかを確認しましょう。ちなみに私は自前で立ち上げる手間を省くため、CircleCITravisCIを利用することが多いです。
 継続的にテストを回す仕組みができたのであれば、テストのカバレッジも継続的に測定してあげる必要があります。Railsであれば、simplecovというgemを利用することが多いでしょう。そして、カバレッジを可視化するためにCodeCovCode 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_namegiven_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_namefull_nameそれぞれでエラーメッセージを出し分けたいためです。family_nameは一つの属性であるため、苗字は15文字以内で入力してください。というメッセージで良いのに対し、full_nameは二つの属性であるため、お名前は合計20文字以内で入力してください。のように合計という言葉を追加したいわけです。
 詳細な内部の仕様は追っていませんが、messageを追加しないとja.activerecord.errors.models.order_item.attributes.family_name.too_longが呼ばれ、messagetoo_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_namegiven_nameを連結させただけではあまり面白みがないので、少しメソッドを拡張します。

  def full_name(space: '')
    "#{family_name}#{space}#{given_name}"
  end

リスト7. full_nameメソッドに引数を追加し拡張

 リスト7の様にspaceというキーワード引数を追加します。インスタンスメソッドfull_nameは変数spaceの値でfamily_namegiven_nameが連結された値を解します。引数がなければデフォルト値が空文字となります。この場合のテストを書いてみましょう。開発者が書くモデルのテストは、ホワイトボックステストと呼ばれます。ホワイトボックステストとは、内部の論理構造を把握した上で、このメソッドの場合、if文はありませんが、例えば引数の有無でデフォルト値を使うかどうかが変わってくるので、その辺りを考慮すると、full_nameメソッドのテストは例えば以下の様になります。2

  describe '#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にテストしたいメソッドをセットしています。
 インスタンスメソッド以外にも、クラスメソッドなども同様にテストしていきます。3

SystemSpec

 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_namegiven_namefamily_name_kanagiven_name_kanaがあり、法人名義を保存する属性としてcorporate_namecorporate_name_kanaがあるとします。
image.png
図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-showv-ifを用いて表示の制御を行います。
 では、図2のように、個人が選択された場合のフィールドに値が入力された時、サーバーサイドにはどのような値が送られてくるかわかりますでしょうか。
image.png
図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_namecorporate_name_kanaはどうでしょうか。サーバーサイドに値は渡ってくるでしょうか。
 答えは、Vue.js側の実装によります。v-ifで実装されている場合は渡ってきませんし、v-showで実装されている場合は値が渡ってきます。(未入力の場合は空文字("")で渡ってきます)したがって、v-showで実装した場合、個人名義なのにcorporate_namecorporate_name_kanaが保存されてしまうことがあるわけです。この様な挙動のテストは画面上だけのテストでは担保することができません。これを担保するにはレコードの保存状態まで確認する必要があると私は考えています。

テストケースはどの様に洗い出せばいいか

 モデルのテストの場合、ホワイトボックステストとして、実装の中身がわかっているという前提でテストを書けばいいので、言ってしまえばソースコードをみて分岐を確認し、C0網羅、C1網羅、C2網羅など4を考慮してテストを書いていけば良いです。では、SytemSpecの場合、どうすれば良いでしょうか。

 ここでは、ユーザーは申し込み画面で必要事項を入力したあと、確認画面へ進み、確認画面後は、ユーザーの入力した値に応じて画面の遷移が変わる場合を考えてみましょう。そして、このお申し込みには、付帯サービスなるものが存在し、離島でない場合は付帯サービスの説明を確認画面で表示し、離島の時は表示しません。また、申込者が本人または配偶者の場合のみ付帯サービスの説明を表示しますが、本人・配偶者以外の場合は表示しないという制御が必要だとします。
また、付帯サービスの説明表示とは別に、申し込み画面ではポイントカード登録をするかどうかを聞く項目があり、ポイントカード登録をするを選んだ場合は、確認画面後にポイントカードを登録する画面へ遷移するものとします。これをまとめたものが表1になります。rspec対象と書かれたカラムは後ほど説明します。

表1. パターンを網羅したもの
image.png

 さて、この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年前に意図して実装したコードがあり、その意図をディレクターやデザイナーなどの非エンジニアも忘れているかもしれません。仕様書に書かれていたからと言ってそれを記憶しているとは限りません。その仕様書の存在すら忘れ去られていることもあります。テストコードはまさしく実際に動くコードにて、記憶を記録するためのものであると言えます。人はどんどん忘れていく生き物です。それが人間の性です。忘れていくことで新しいことを記憶することができます。そして新たな価値を生み出すことができるのです。なので、過去のことは記録してどんどん忘れていってよいのです。テストコードを書くことで記録していきましょう。
 自動テストは愛なのです。


  1. ここの整理がきちんとできているエンジニアは意外と多くはありません。例えば高校数学で二次関数の最大最小を求める時に場合分けをした経験はありますでしょうか。あの場合分けを思い出してください。状況によって、二つの場合分けでよかったり、三つだったりしたと思います。実際の仕様ではもっと複雑になります。この複雑な場合分けを着実にかつ素早くできることがエンジニアの力量の一つの指標であると私は思います。 

  2. ホワイトボックステストについては、多くの文献がありますので、説明はそちらに譲ります。例えばQiitaの記事ではこちらの記事がわかりやすかったです。 

  3. describeには、多くの場合、テスト対象のメソッド名を記述しますが、Rubyでは、インスタンスメソッドには#を、クラスメソッドには.を付ける文化があります。 

  4. ソフトウェアテストにおけるカバレッジ(C0/C1/C2) 

  5. 40台のベテランエンジニアの書いたコードでした。ベテランエンジニアですら、こうした実装ミスを犯すということは私のような新米エンジニアはもっと実装をミスしているということを肝に命じておかなければなりません。 

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

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:00

BOM 付きのままだと検証が失敗する

しかし実際に 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 について、この機会に改めて調べられてよかったです。

参考

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

No route matches [GET] 〜 について

スクリーンショット 2019-12-22 3.16.28.png

学習初期段階のこういったエラーは見るだけでやる気無くなりますよね...

というわけでこれまで筆者が経験で見つけたエラー原因を羅列します!

(筆者のようにあまり他人に聞けない性格の初学者の方に向けて記載しています。そんなの当たり前だなどのツッコミはご遠慮ください。)

①~/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で接続してしまったためエラーが起きてしまっていた。

スクリーンショット 2019-12-22 3.50.39.png

https://localhost:3000 でlocalserver(公開していない自分のパソコン内のウェブページ)に接続できるのですが、筆者はそのURLがわからず、教材のちょっと前に使ったURLをそのまま打ち込んでいたためこういった間違いを犯しました。
教材が想定していたミスの箇所ではないミスであると気づけなければ永遠に彷徨えます。なのでこういったミスを犯すこともあると知っておこうというお話です。

エラー発見問題では芋づる式になっていることが多いため、接続先を間違えるとその問いを解くことができず、そのことによりその先の問題が全て解けなくなってしまうことがよく起きます。(これで筆者は丸3日間悩んでました。笑)

とりあえずこの記事は③を一番主張したくて書きました

何かの参考になればと思います

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