エンジニア備忘録

エンジニアを目指してる人のブログ

SPAでのログイン認証とCSRF対策の実装(JWT使用)

SPA(Vue + RailsAPI)で何とかログイン認証機能 + CSRF対策を実装したので、ブログにメモしておきます。

実装の概要

今回は、JWT + (WebStorage + Cookie)を使って実装しました。(後に用語説明します)

WebStorageとJWTによるセッションの管理(ログイン状態の管理)は、セキュリティ的な難点を指摘する記事がちらほらありましたが、クッキーも利用して「アクセストークン」と「リフレッシュトークン」の2つを利用するライブラリを採用し、XSS脆弱性のリスクを下げました。あとはそのライブラリにCSRFトークンも発行も担保してもらうことで、CSRF脆弱性にも対応しています。

ただし、この方法がSPAの認証として確実にベターなのかは定かではありません。「あくまでもこういう実装があるよ」という風に捉えていただければ幸いです。

あと、今回は「リフレッシュトークン」自体は発行せず、「アクセストークン」にその機能をもたせた形で実装しています。

基本的に、既存のライブラリの教えに従った実装なので、詳しくはそちらを参考にどうぞ。

JWTSession: GitHub - tuwukee/jwt_sessions: XSS/CSRF safe JWT auth designed for SPA

自分が詰まったところをメインで文章にしておこうと思います。

使用した技術たち

JWT(JsonWebToken)

改ざんされず、URLSafe(URLに含めることができる文字列のみで構成されている)JSON形式のトークンです。

Base64エンコードされているだけなので、鍵情報なしに復号可能です。

# JWTをデコードすると…
Base64.decode64('"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NjI5NDUzMzAsInVzZXJfaWQiOjczLCJ1aWQiOiI1M2U4ODdlMy0zYTE4LTQyNzgtYTU0MS1jMDMwNzA4OTUwMDEiLCJleHAiOjE1NjI5NDUzMzAsInJ1aWQiOiI4Yjg3MjFlYS1kZGNmLTQ3NTgtYTUwYi1iMDk0ZDhkODU5MWIifQ._ygcLPv8UiGAX3oNlomGmOKFfRQYDJZRjZiAYPfDSrk"')

# algで、改ざん防止のための署名を暗号化するアルゴリズムを指定します。あとはユーザー情報だったり有効期限だったりも内包してます。
=> "{\"alg\":\"HS256\"}{\"exp\":1562945330,\"user_id\":73,\"uid\":\"53e887e3-3a18-4278-a541-c03070895001\",\"exp\":1562945330,\"ruid\":\"8b8721ea-ddcf-4758-a50b-b094d8d8591b\"}\f\xA0p\xB3\xEF\xF1H\x86\x01}\xE86Z&\x1Ac\x8A\x15\xF4P`2YF6b\x01\x83\xDF\r*\xE4"

参考記事: JWT(JSON Web Token)の仕組みと使い方まとめ │ Web備忘録

アクセストークン、リフレッシュトークンって?

セッション機能を担保するためのJWTタイプのトークンです。アクセストークンは短時間で期限が切れるトークンで、リフレッシュトークンはその期限の切れたアクセストークンのかわりに新しいアクセストークンを発行するためのトークンです。当然ながらリフレッシュトークンの方が期限が長いです。どちらも、トークン自体にデータを内包しています。

WebStorage

ブラウザのデータストアです。4MBまで保存できるのが魅力です。Chromeデベロッパーツールでいう「Applicationタブ」で確認できます。

SessionStorageとLocalStorageがありますが、今回はCSRFトークンを保存するために後者を使ってます。このCSRFトークンはJWTではありません。

JWTSession

トークン作成・管理を担ってくれるライブラリ。

GitHub - tuwukee/jwt_sessions: XSS/CSRF safe JWT auth designed for SPA

遷移の概要(より正確な内容は要Gem参照)

クライアント側でユーザーがパスワード等の認証情報を入力し、ログインのためのリクエストを送る。

バックエンド側がその内容で認証に成功したら、「HttpOnly(JSで操作不能)でSecure(HTTPs通信のみで取引)なクッキーとしてのアクセストークン」と、「POST、PUTリクエスト等で使うCSRFトークン(LocalStorageで保存)」をレスポンスする。アクセストークンは、「ユーザー情報」を内包している。

その受け取ったトークンたちをクライアントサイドで保存し、各リクエストに応じてそれらを送信することで、「ログインしているユーザー情報」をセッション管理しつつ、CSRFXSS対策を行っている。

トークンストアの設定

Redisを使っています。Docker上で運用していて、下記のようにdocker-compose.ymlファイルを作ってます。

version: '3.3'
services:
  app:
    build:
      context: .
    stdin_open: true
    tty: true
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - bundle_data:/usr/local/bundle
    depends_on:
      - db
      - redis
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
  db:
    image: postgres:11.2
    ports:
      - '5432:5432'
    volumes:
      - postgresql_data:/var/lib/postgresql/data
  redis:
    image: redis:3.2.12-alpine
    ports:
    - 6379:6379
    volumes:
    - redis:/data
  
volumes:
  bundle_data:
    driver: local
  postgresql_data:
    driver: local
  redis:

今回使ったGem(JWTSession)は、環境変数にREDIS_URLをセットしておけばその場所をトークンストアとして使用します。

ローカルでは、REDIS_URL=redis://redis:6379、本番では実際のURLを指定しています。本番環境はherokuで運用してるのですが、REDISプラグインを導入すると自動で環境変数REDIS_URLをセットした気がします。(しなかった場合は、自分でセットしましょう)

JWT暗号化のアルゴリズムのための種は環境変数でセットしておきましょう。

(config/initializers/jwt_sessions.rb)

if Rails.env.production?
  JWTSessions.encryption_key = ENV['JWT_SESSION_KEY']
else
  JWTSessions.encryption_key = 'secret'
end

バックエンド側の仕事

トークン発行と管理を行います。

Signinコントローラー

以下は、説明コメント付きのSigninコントローラーです。ここで認証を行います。

class SigninController < ApplicationController
  # リクエストで送られたトークンの内容の検証し、検証失敗で例外を出す。(only: destroy(セッション破棄時))
  before_action :authorize_access_request!, only: [:destroy]

  # create は ユーザー作成ではなく、「トークン作成(セッション作成)」
  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])
      # payloadは、トークン自体に内包させられるユーザー情報。ここではuser_idを内包させている。
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      # このメソッドで、「Set-Cookieヘッダー」に、{jwt_access=アクセストークン; Secure; HttpOnly}をセットし、クライアントに送る。
      response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        # 開発環境ではHTTPs通信ではないので、secure: falseになるようにしないと、クライアントがクッキーを受け取れない
                        secure: Rails.env.production?)
      # LocalStorageに保存するためのcsrfトークンを返しておく。
      render json: { csrf: tokens[:csrf] }
    else
      not_found
    end
  end

  def destroy
    session = JWTSessions::Session.new(payload: payload)
    session.flush_by_access_payload
    render json: :ok
  end

  private

    def not_found
      render json: { error: "メールアドレスとパスワードの組み合わせが見つかりません"}, status: :not_found
    end
    
end

フロントエンド側の仕事

アクセストークンに関してはクッキーに保存しているので、とくに設定を行わなくてもバックエンド側と自動でやり取りします。(クッキーはリクエストで常に送信されるため。)

CSRFトークンの使用に関しては、明示的に設定を記述する必要があります。

axiosから作ったインスタンス(非同期通信時に使用する)に、その設定を書き、POSTやPUTリクエストのさい、CSRFトークンを遅れるように設定しておきましょう!

axiosのインスタンスの作成

(axiosのインスタンスを作成し、exportしたものをグローバルで使う。)

import axios from 'axios'

// インスタンスを作る
const securedAxiosInstance = axios.create({
  baseURL: process.env.VUE_APP_API_URL,
  // クッキーといった認証情報を送るためにCredentialsはtrueにする
  withCredentials: true,
  headers: {
  // もちろん送信するのはJSON
    'Content-Type': 'application/json'
  }
})

// リクエストのとき、動的にヘッダーにCSRFトークンをセットする
securedAxiosInstance.interceptors.request.use(config => {
  const method = config.method.toUpperCase()
  if (method !== 'OPTIONS' && method !== 'GET') {
    config.headers = {
      ...config.headers,
      // localStorageの値をセット!
      'X-CSRF-TOKEN': localStorage.csrf
    }
  }
  return config
})


// レスポンスで401エラーが帰ってきたとき、トークンの再発行を依頼する。
securedAxiosInstance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (error.response && error.response.config && error.response.status === 401) {
      return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
        .then(response => {
          localStorage.csrf = response.data.csrf
          localStorage.signedIn = true

          let retryConfig = error.response.config
          retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
          return plainAxiosInstance.request(retryConfig)
        }).catch(error => {
          console.log('failed refresh')
          delete localStorage.csrf
          delete localStorage.signedIn

          location.replace('/signin')
          return Promise.reject(error)
        })
    } else {
      return Promise.reject(error)
    }
  }
)

// exportしておこう
export { securedAxiosInstance }

main.jsでインスタンスの読み込み

main.jsで、そのインスタンスを読み込み、どのコンポーネントでも使用できるようグローバルにする。

Vue.use(VueAxios, {
  secured: securedAxiosInstance,
})

インスタンスの使用方法

signin () {
  this.$http.secured.post('/signin', {email: this.email, password: this.password})
    .then(response => this.signinSuccesful(response))
    .catch(error => this.signinFailed(error))
}

これでフロント側は大丈夫です。

ただ、ブラウザによって上手く動作しないものが…

ただ、こうやっても自分の場合、本番環境のSafariiOS版のChromeでログイン認証が上手くいかない、といった苦労がありました。

JWTSessionについてググりまくったり、ActionDispatchの挙動について調べたりして原因を探ったところ、どうやら、先に挙げたブラウザは、バックエンドが返したレスポンスに含まれる「「Set-Cookieヘッダー」の {jwt_access=アクセストークン; Secure; HttpOnly} を読み込めておらず、ブラウザにクッキーが保存できていない、ということがわかりました。

(サラッと書きましたがこの結論に辿り着くまで結構かかりました(涙目))

そしてクッキーが保存できない原因は、PC版Chrome以外は「3rd party cookiesを受け取らない」という設定がデフォルトであった、ということであると判明しました。

3rd party cookiesとは?

3rd party cookiesというのは、そのサイトの運営者以外のドメインが送信するクッキーのことです。たとえば誰かのブログを見てたりすると、「身につけるだけで腹筋が割れるベルトが新発売」みたいな広告が右端に表示されてることがありますが、そういう第三者の広告がブラウザにクッキーを保存することを防ぐために、PC版Chrome以外は3rd party cookieの受け取りを禁止しているようでした。

(ChromeGoogle Analyticsなどを使用して、ドメインに基づかずとも、そういうクッキーを弾いているみたいです。)

原因がわかったので、VueアプリとRailsAPIのドメインを共通化すると解決しました。(domain.comとwww.domain.comに、それぞれアプリを配置し、メインドメインサブドメインの関係にした)

余談

かなり難しかった…。でも、おかげで知識量は増えた気がします。

ただ、今回のクッキーに関するエラーって、先に「3rd party cookieの取扱い」に知っていれば、もっと早く原因わかったかもな、と思いました。

自分の場合、そもそもこういうエラーを前にして初めてそういう「3rd party cookie」概念があることを知ったのですが、そういった風に「実際にエラーが出てからそのエラーの根本原因について調べる」ような実装をしていると、めちゃくちゃ時間吸われるんですよね。(検索ワードが「クッキー 受け取れない safari」とか「set-cokkie not working」とかぼんやりしたものになって、なかなか欲しい情報にたどり着けない)

もちろん、そのおかげで色々知識は身につくのですが、回り道してる感も否めないので、広く浅く知識の網は広げておいて、エラーが起きたときにすぐ適切なワードでググれるようになれば、時間の節約になりそうです。

Vueでパスワードの強度を動的に判定できる送信フォームを作りました。

タイトルの通り、Vueでパスワードの強度をクライアントサイドで判定しながら送信できるフォームを作りました。

f:id:fujiten3:20190711142645g:plain
PasswordStorengthChecker

JSFiddle上に置いておいたので、欲しい方はどうぞ。編集自由ですが、ブログ等での再配布はご遠慮ください。

jsfiddle.net

挙動については本当にgifで見てもらった通りなのですが、4つの条件(文字数、小文字、数字、大文字)を満たしたパスワードのみ送信を行うというものです。

多すぎる条件はユーザー体験を損ないかねないので、4つも条件が必要かは微妙なところですが、セキュリティに関するところというところもあり、判定が甘すぎるよりはいいと思います。

以下、コードの解説です。Vueに興味のない人は最後の余談だけでもよければ御覧ください。(大したことは話してません)

HTML側

<div id="app">
  <div class="container">

    <div class="input_container">
      <label for="name" class="label">パスワード</label>
      <input type="password" @input="onCheckedBeValid" v-model="password" placeholder="6文字以上" />
      <span v-bind:class="{ valid_password_length: validPasswordLength }" class="password_length">{{passwordLength}}</span>
    </div>
    
    <div class="validation_container">
      <p v-bind:class="{ lowercase_valid: containsLowercase }">小文字</p>
      <p v-bind:class="{ number_valid: containsNumber }">数字</p>
      <p v-bind:class="{ uppercase_valid: containsUppercase }">大文字</p>
    </div>

    <div class="button_container">
      <button @click="signUp">登録</button>
      <p>{{ message }}</p>
    </div>

  </div>
</div>

inputタグについて

<input type="password" @input="onCheckedBeValid" v-model="password" placeholder="6文字以上" />

@inputで「inputイベント」が起こるたびに着火されるメソッドを指定し、入力された値(value)については、v-model="password"を使って、双方向バインディングしています。

spanタグについて

<span v-bind:class="{ valid_password_length: validPasswordLength }" class="password_length">{{passwordLength}}</span>

属性の値を動的にセットできるv-bindを使い、今回はclass属性の値を動的にセットしています。

「v-bind:class="{ valid_password_length: validPasswordLength }"」というのは、validPasswordLengthがtrueのとき、valid_password_lengthというクラスをこのタグにセットするという意味になります。これを使い、文字数が条件を満たしたときだけに、動的にクラス名をセットして、付与されるスタイルを変更しています。

JS(Vue)側

new Vue({
  el: "#app",
  data () {
    return {
      password: '',
      message: '',
      passwordLength: 0,
      containsLowercase: false,
      containsNumber: false,
      containsUppercase: false,
      validPasswordLength: false,
      isValidPassword: false
    }
  },
  methods: {
    onCheckedBeValid () {
      this.passwordLength = this.password.length

      this.passwordLength > 5 ? this.validPasswordLength = true : this.validPasswordLength = false
      this.containsLowercase = /[a-z]/.test(this.password)
      this.containsNumber = /\d/.test(this.password)
      this.containsUppercase = /[A-Z]/.test(this.password)
    },
    checkAllValidations () {
      if (this.containsLowercase 
          && this.containsNumber 
          && this.containsUppercase
          && this.validPasswordLength) {
        this.isValidPassword = true 
      } else {
        this.isValidPassword = false
      }
    },
    signUp () {
      this.checkAllValidations()
      this.isValidPassword ? this.message = '送信しました' : this.message = 'パスワードに問題があります'
    }
  }
})

メソッドの簡単な説明を残しておきます。

onCheckedBeValidメソッド

Inputイベントで着火するメソッドで、入力されたパスワードが先に述べた4つの条件を満たすかどうかそれぞれ判定します。ここでtrueになった条件たちは、HTML側で:classにバインドされているので、それぞれスタイル付与に貢献します。

checkAllValidationsメソッド

メソッド名通り、全てのバリデーションをチェックします。

signUpメソッド

ここにバックエンドにアクセスするための非同期通信の処理を書きます。axios等を使いましょう。ここでは記述を割愛しています。

CSS

.container {
  margin: 30px auto;
  height: auto;
}

.input_container, .validation_container, .button_container {
  display: block;
  margin: 0 auto;
  height: auto;
  display: flex;
  justify-content: center;
}

.password_length {
  padding: 2px 10px;
  margin-left: 120px; 
  background: rgb(236, 57, 57);
  color: white;
  border-radius: 10px;
  font-size: 13px;
  transition: all .1s;
  position: absolute;
}

.valid_password_length {
  background: green;
}

.validation_container p {
  width: 80px;
  margin: 5px;
  height: auto;
  font-size: 12px;
  text-align: center;
  border-radius: 2px;
  color: rgba(71, 87, 98, .8);
  background: linear-gradient(to right, green 50%, #eee 50%);
  background-color: #eee;
  background-size: 200% 100%;
  background-position: right;
  transition: background .3s;
}

.lowercase_valid,
.number_valid,
.uppercase_valid {
  background-position: left !important;
  color: white !important;
}

p {
  margin: 0;
}

一番のポイントは緑色のバーが動的に動くところだと思うので、そこの部分だけ抜粋します。

 .validation_container p {
  background: linear-gradient(to right, green 50%, #eee 50%);
  background-color: #eee;
  background-size: 200% 100%;
  background-position: right;
  transition: background .3s;
}

.lowercase_valid,
.number_valid,
.uppercase_valid {
  background-position: left !important;
  color: white !important;
}

こちらがその部分です。文章だけで解説しますが、まず、2行目のbackground: linear-gradient(to right, green 50%, #eee 50%);で「左50%が緑、右側50%が白色の背景」をセットしています。そして、background-position: right;で、「その背景の右側(白色)を自分のポジション」として固定します。

次に、「パスワードが条件を満たしたときのクラスの追加」で、background-position: left !important;というスタイルを付与することで、今まで右側(白色)にいた自分が、左側(緑色)に移ります。その移動をトランジション要素で動的に行うことで、白色と緑色の境界線が、まるでバーの動きのように表現されているというわけです。

コンポーネント・モジュール化

新規登録画面が2ページ以上ある場合は、このフォームをコンポーネント化しておいた方がいいと思われますが、2ページ以上あるサイトはあまりないと思われますので、必要になるまでは共通化しなくてもいい気がします。自分は共通化せずにそのまま使ってます。

「条件を満たすと満タンになるバー」は、他の部分で再利用できそうなので、そこはコンポーネントにしてもいいかもしれまん。

余談

4つの条件をわざわざ満たさせるフォームはユーザにとって煩わしさはあるかもしれませんが、認証情報はサイトの核なので、特に「決済システム」などの重大な機能をもつサイトを運用する場合は、このようなフォームでパスワード強度を動的に確認してあげるとよいと思います。

バックエンドに渡す前にバリデーションにかけれるので、無駄な通信も防げてエコですしね。(だいぶ微々たるものでしょうけど)

おしまい。

Vueで画像アップロード + プレビュー機能付きフォームを作りました。(Base64エンコード利用)

タイトルの通り、Vueで簡単な画像アップロードページを作ったので、JSFiddleで公開してみます。

今回はBase64エンコードを使って、画像を文字列情報として扱う方法を採用しています。

バックエンドとのやり取りをJSONベースで行っている場合は、この方法がシンプルで扱いやすいと思います。

jsfiddle.net

挙動については上のリンクに飛んで触っていただければすぐにわかると思います。簡易的な画像アップロード機能と、画像送信前のプレビュー機能のついたフォームです。

ほしい方は勝手に使っていただいて大丈夫です。編集も自由です。変な所あったら勝手に直しちゃって下さい。(そしてこっそり教えて下さい。)

「なんでBase64エンコード使ったん?」という件については、一番最後の余談で書いたので、Vue興味ない方はそこまで飛ばして下さい!!

一応、それぞれのコードの解説を書いておきます。

HTML側

<div id="app">
  <p id="error" v-show="error">{{ error }}</p>
  <label>
    <p>クリックで画像を変更できます。</p>
    <img :src="avatar" alt="Avatar" class="image">
    <div>
    <input
           type="file"
           id="avatar_name"
           accept="image/jpeg, image/png"
           @change="onImageChange"
           />
    </div>
  </label>
  <button @click="upload()">アップロード</button>
  <p>{{ message }}</p>
</div>

labelタグについて

これでimgタグとinputタグを囲めば、inputタグのクリック可能範囲を広げ、画像クリックに判定が付きます。

imgタグについて

:src="avatar"で、Vue側がもつdataの「avatar」をバインドしてきています。Vue側のavatarの値が変更されれば、src属性の値は自動で更新されます。

avatarのデータ型はStringで、画像をBase64エンコードしたものを渡しています。Base64形式でsrc属性に値を渡せば、特別な処理を書かずとも画像を表示できます。

Base64エンコードとは?

データを文字列として表す変換方式です。メールの添付等でよく使われるようです。

inputタグについて

@change="onImageChange"は、inputでファイルが変更されるたびに、onImageChangeメソッドが実行されるという意味です。

ここは「v-model="avatar"」でも動きそうに見えますが、inputのtype="file"はvalueをもつことが出来ないので、v-modelでavatarをセットしても、その値をinputタグが保持しておくことができずにエラーが出ます。

JavaScript(Vue)側

new Vue({
  el: "#app",
  name: 'ImageUploder',
  data () {
    return {
      avatar: '',
      message: '',
      error: ''
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      this.getBase64(images[0])
        .then(image => this.avatar = image)
        .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
    },
    upload () {
        if (this.avatar) {
        /* postで画像を送る処理をここに書く */
        this.message = 'アップロードしました' 
        this.error = ''
      } else {
        this.error = '画像がありません'
      }
    }
  }
})

各メソッドについて、簡単に解説します。

getBase64メソッドについて

getBase64 (file) {
  /* Promiseにアロー関数を渡して非同期処理を書く。*/
  return new Promise((resolve, reject) => {
    
    /* 組み込みオブジェクトを使い、Base64エンコードされた画像データを取得  */
    const reader = new FileReader()
    reader.readAsDataURL(file)

    /* 成功時と失敗時の処理。resolveの引数に結果を渡して、のちの.then(result => ...)で受け取る  */
    reader.onload = () => resolve(reader.result)
    reader.onerror = error => reject(error)
  })
}

onImageChangeメソッドについて

inputでファイルが入力されると着火されるメソッドです。イベントオブジェクトからファイルを貰い、そのもらったファイルをBase64エンコードしたものを、avatarにセットしています。これでavatarの値が変更されるのですが、そのとき、その変更を検出したimgタグがsrc属性に新たなavatarの値を当てはめます。すると、選択したファイルの画像が表示されます。これがプレビュー機能に当たる部分です。

uploadメソッド

バックエンドにBase64エンコードされた画像を送る処理を書くところです。今回は詳しい処理については割愛していますが、axiosでバックエンド側のAPIにpostまたはpatchする処理を書きましょう。

CSS

input {
  display: none;
}

img:hover {
  opacity: 0.7;
}

#error {
  color: red;
}

特筆すべき点はないですが、imgにhoverしたら透明度を上げるようにしてクリック判定をわかりやすくしてます。

コンポーネント・モジュール可する

せっかく作ったフォームなので、コンポーネントにして取扱いを良くしたり、他で使いそうなメソッドはモジュール化した方がいいかもしれません。(設計に関する一番むずかしいところ…)

とりあえず自分は、以下のように親子関係を作ってみました。

親側のフォーム

<label>
  <img class="w-24 h-24 rounded-full mr-4 bg-hover" v-lazy="avatar" alt="Avatar">
  <UserImageUploader
    v-bind="user"
    :params="{ limit: 1000, unit: 'kb', allow: 'jpg,png' }"
    v-model="avatar"
   />
</label>

3行目が子コンポーネントです。(UserImageUploader)。親側で呼び出すときに必要なデータをバインドして子供側で扱えるようにしてます。

今回の場合だと、ユーザー情報、バリデーション情報、画像情報を渡してます。

子供側(コンポーネント名:UserImageUploader)

<template>
  <div>
    <p id="error" v-show="error">{{ error }}</p>
      <div class="m-6">
        <input
          type="file"
          id="avatar_name"
          class="input_image"
          accept="image/jpeg, image/png"
          @change="onImageChange"
        />
      </div>
  </div>
</template>

<script>
import { FileEvaluable } from '@/components/mixins/FileEvaluable'
export default {
  name: 'UserImageUploder',
  mixins: [ FileEvaluable ],
  props: {
    id: Number,
    currentImage: String
  },
  model: {
    // このcurrentImageは親で指定した(v-model="avatar")と同値。
    prop: 'currentImage',
    event: 'change'
  },
  data () {
    return {
      message: '',
      error: ''
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    setImage (currentImage) {
      this.$emit('change', currentImage)
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      if (this.validation(images[0])) {
        this.getBase64(images[0])
          .then(image => this.setImage(image))
          .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
      } else {
      }
    },
    validation (file) {
      if (!this.isAllowFileType(file.type)) {
        this.error = this.getErrorMessageType()
        this.canBeUploaded = false
        return false
      }
      if (!this.isAllowFileSize(file.size)) {
        this.error = this.getErrorMessageSize()
        this.canBeUploaded = false
        return false
      }
      this.error = ''
      this.canBeUploaded = true
      return true
    }
  }
}
</script>

<style scoped>
.input_image {
  display: none;
}
</style>

読みづらいですが、propだったりmodelだったりで、親からバインドされた値を受け取ってます。

子供側でmixinsされてるモジュールは、今回の記事とは関係ないバリデーション処理のものですが、わざわざ取り除くのも大変だったのでべた張りしました。

機能としての改善点としては、プレビューの画像サイズが今のままだとユーザー依存なのでそれを変更したり、選択した画像の一部分だけ使うよう編集できる機能についても必要かな、と思いますが、長くなりますので今回はここまでとします。

余談

画像の送信方法について、最初はBase64エンコードを使わず、JSにおけるFormData型の画像を「Content-Type: multipart/form-data」でバックエンドに送る方式をとっていました。ただ、自分がバックエンドで使用しているRailsAPIとのかみ合わせが悪く、リクエストではファイルをそのまま受け取れるものの、レスポンスにおいてはJSONじゃないファイル型を返せるコントローラーを新たに作ったりする必要がある(ActionController::APIを継承したコントローラじゃファイルをrender出来ない。)というデメリットがあり、一度実装してみたものの、取り回しが悪そうなので削除しました。

(こういうの実装する前に気づけるようになりてえ〜)

ちなみに、Railsでは受け取ったエンコード済み画像をデコードしてFileオブジェクトに戻し、それをAWSのS3に保存してます。

その処理もまた後にブログにするかもしれません。(しないかもしれない! 読みたい方がいたら書きます)

おしまい。

Dockerの基礎

自分用です。

コマンド:

// dockerバージョンのチェック
docker version

// dockerの情報を詳しく知る
docker info

// dockerコマンドの一覧表示
docker

imageは動かしたいアプリケーションの設定。コンテナはそれに従って動く。

同じimageを共有し、複数のコンテナを動かせる。

以下はnginxイメージに則ったコンテナを起動する例。

docker container run --publish 80:80 nginx
  1. nginxをDockerHubから入手
  2. imageから新たなコンテナを起動
  3. port80をHostIPとして開く
  4. 80へのアクセスをcontainerのポートである80へとつなぐ。(8080:80 なら 8080をコンテナの80につなぐ)

--detachオプションをつければ、バックグラウンドで起動できる。

docker container run --publish 80:80 --detach nginx

止めたいときは、ユニークIDを指定しながら、stopすればいい。

//起動しているコンテナの確認
docker container ls 

// ユニークIDは最初の数文字で大丈夫
docker container stop f60 

runは新たなコンテナの起動。startは一度止めたコンテナの再起動。

// コンテナのログの確認(detach等でバックグランド起動したときはこれでログを確認できる)
docker container logs <container's name>

dockerコンテナに関するコマンドを見たいときは、以下。

docker container --help

例えばtopコマンドは、コンテナのpidを確認できる。

邪魔なコンテナは以下で削除。

docker container rm <UID>

コンテナはただのプロセスである

仮想マシンとの違いはそこ。

//起動しているすべてのコンテナのパフォーマンスを調べられる(消費しているリソース量など)
docker container stats 

//起動しているコンテナを指定すると、設定詳細が確認できる(JSON形式)
docker container inspect

//イメージに基づき、インタラクティブにコンテナを起動できる(exitで脱出)
docker container run -it <Container's name> bash

//脱出したあと、また潜るためには以下。
docker container start -ai <Container's name>

//すでに起動しているコンテナについて、bashで潜れる
docker container exec -it <Container's name> bash

ネットワークについて。

// Dockerが保持しているnetworkの参照
docker network ls

// networkの深掘り
docker network inspect <network's name>

//ネットワーク同士の接続
docker network connect <network1> <network2>

静的なIPを設定したり、IPを使ってコンテナに接続しにかかるのはアンチパターン。コンテナの名前を使おう。

イメージについて

アプリケーションバイナリと、依存関係のこと。OSやカーネルカーネルモジュールは準備しない。

イメージはdocker.hubで確認できる。公式のものはドキュメントが詳細に書かれている。ただ、公式より人気のものがあったりするので、その理由を確認するものよい。

イメージレイヤとは

イメージはファイルシステムメタデータ。イメージのそれぞれのレイヤーはホストに一度だけ保存され、それは流用される。コンテナはイメージレイヤの最上部に位置される読み書き可能な単一のレイヤーである。historyコマンドやinspectコマンドで、イメージの詳細について調べられる。

Dockerfileの作り方

FROMから始まり、Dockerhubにあるイメージを選択する。

ENVで環境変数を設定する。

RUNはシェルコマンド。パッケージレポジトリを利用してファイルをインストールすることができる。

EXPOSEは仮想ネットワークに公開するポート番号。

CMDはイメージから新しいコンテナを起動するさいや、コンテナの再起動の際に実行される最後のコマンド。

DockerFileはキャッシュを使いビルドを高速化しているので、Dockerfile内のコード変更は出来る限り下部のものを多く変更するようにしたほうが、処理が高速化する。(上部を変更すると、下部はキャッシュを使えず、新たにビルドする)

コンテナについて

コンテナは不変だが、長く保持されるものではない。

データベースやユニークデータを保持するためには、永続データとして保持する必要があり、そのための方法として以下の2つのものがある。

  • Volumes  コンテナのユニークファイルシステムの外部に特別なロケーションを設定し、そこに保持する。
  • Bind Mounts ホストディレクトリをコンテナ内にマウントしておく。

Volumeについて:Dockerfileにて、VOLUMEコマンドを使えば、Volumes先を指定できるが、コンテナが削除されても保持されているため、削除したい場合は手動で削除する必要がある。

コンテナ起動の際に、docker container run -v :mysql/var/libといった形で指定すれば、docker volume ls コマンドでVolumeNameを確認した際、ほかのボリュームと判別しやすい。(また、同じドライバ(Mysql)だが異なるコンテナを起動したさい、このように指定しておかないと、ドライバがそれぞれ別のボリュームを作成する。)

Bind Mountsについて:ホストのファイルやディレクトリをコンテナ内にマッピングする。基本的にそれらは同じ場所を指している。マッピングしたさい、ホストファイリルがコンテナ内のファイルを上書きする(同場所にあった場合)。Dockerfile内で使用することができず、「docker container run -v $(pwd):/usr/share/nginx/html」といった形で指定する。このコマンドを使うと、ホストの現在のワーキングディレクトリ内の内容を、コンテナ内の/usr/share/nginx/htmlにマッピングされ続ける。(確認したい場合は、「docker container exec -it nginx bash」を使い、bashを使って内部に潜り込んでみよう。)

Docker compose について

compose.ymlに基づいてコンテナ同士の関係を作る。

公式ドキュメントを見ると詳細が載っているので、困ったときは参照しよう。 Compose file version 3 reference | Docker Documentation

docker-compose CLIは、本番環境上よりも、「開発環境とテスト環境」において力を発揮する。

もっとも基本的なコマンドは以下。

//volumesとnetworksをセットアップし、すべてのコンテナを起動する。
docker-compose up

//すべてのコンテナを止め、コンテナとボリュームとネットワークを削除する。
docker-compose down

docker-compose ps

docker-compose logs

docker-compose top

Ruby on Railsガイドを通読してまとめる Part.4

第四弾です。

 

コントローラー

Action Controller の概要

リクエストを受け取るコントローラーがルーティングによって指名されると、コントローラーはリクエストの意味を理解し、適切な出力を行うための責任を持つ。

これらの一連の処理はAction Controllerによって保証されている。

パラメータ(4)

一般的なWebアプリケーションと同じく、Railsでも2種類のパラメータが受け取れる

  • URLの一部としてのクエリ文字列(GET)
  • POSTデータ

JSONパラメータ(4.2)

リクエストのcontent-typeに「application/json」が指定されていたら、RailsJSONコンテンツを受け取れる。

# このJSONコンテンツは
{ "company": { "name": "acme", "address": "123 Carrot Street" } }

# パラメータでこう受け取れる
params[:company] => { "name" => "acme", "address" => "123 Carrot Street" }

ストロングパラメーター(4.5)

マスアサインメントを防ぐ技術。詳細は何度も出てくるので省略。

Strong Parametersのスコープの外のものをホワイトリスト可(許可)するコードとして、以下のようなものがある。

def product_params
  params.require(:product).permit(:name, data: params[:product][:data].try(:keys))
end

data: でスコープ外のものを指定。.try(:keys)で、中身のキーを取り出して許可、という挙動かな。

セッション(5)

セッションは遅延読み込みなので、アクセスしなかったら無効であるのと変わらない。

class ApplicationController < ActionController::Base
 
  private
 
  # キー付きのセッションに保存されたidでユーザーを検索する
  # :current_user_id はRailsアプリケーションでユーザーログインを扱う際の定番の方法。
  # ログインするとセッション値が設定され、
  # ログアウトするとセッション値が削除される。
  def current_user
    @_current_user ||= session[:current_user_id] &&
      User.find_by(id: session[:current_user_id])
  end
end

session[:current_user_id]に値があるときだけ現在のユーザーを返すというメソッドの基本形。

Flash(5.2)

redirect_toにわたすことも出来る。

redirect_to root_url, notice: "You have successfully logged out."
redirect_to root_url, alert: "You're stuck here!"
redirect_to root_url, flash: { referral_code: 1234 }

flashは通常、次のリクエストまでしか持たないが、keepメソッドを使用することで、保たせることが可能。

class MainController < ApplicationController
  # このアクションはroot_urlに対応しており、このアクションに対する
  # すべてのリクエストをUsersController#indexにリダイレクトしたいとします。
  # あるアクションでflashを設定してこのindexアクションにリダイレクトすると、
  # 別のリダイレクトが発生した場合にはflashは消えてしまいます。
  # ここで'keep'を使うと別のリクエストでflashが消えないようになります。
  def index
    # すべてのflash値を保持する
    flash.keep
 
    # キーを指定して値を保持することもできる
    # flash.keep(:notice)
    redirect_to users_url
  end
end

flash.nowを使えば、そのリクエストへのレンダーで評価できる。(リダイレクトで他のところに渡す場合はnowをつけない)

Cookie(6)

セッションのようにアクセス出来る。クッキーの保持期間などを設定できるらしい。

class CookiesController < ApplicationController
  def set_cookie
    cookies.encrypted[:expiration_date] = Date.tomorrow # => Thu, 20 Mar 2014
    redirect_to action: 'read_cookie'
  end
 
  def read_cookie
    cookies.encrypted[:expiration_date] # => "2014-03-20"
  end
end

クッキーの中身はユーザーごとに、ローカルにおけるそのブラウザのクッキーフォルダに保存される。

Chromeの場合はSQlite型でローカルに保存しているので、直接中身を見に行っても読み取ることは無理。(1敗)

調べた所、「Set-Cookie: クッキー名=クッキー値; expires=有効期限; domain=ドメイン名(サーバ名); path=パス; secure」というレスポンスヘッダーでクッキーをユーザーに保存させられるそう。

secureはSSL/TLS通信のときのみそのクッキーを使用するように強制する。

フィルタ(8)

before_action, after_action, around_action といったもの。

# こう設定すれば、特定のコントローラーの特定のアクションでフィルタをスキップできる
skip_before_action :require_login, only: %i(new create)

around系は、yieldで評価することで、そのアクションを実行する。

class ChangesController < ApplicationController
  around_action :wrap_in_transaction, only: :show
 
  private
 
  def wrap_in_transaction
    ActiveRecord::Base.transaction do
      begin
        yield
      ensure
        raise ActiveRecord::Rollback
      end
    end
  end
end

リクエストフォージェリからの保護(9)

CSRFからアプリケーションを守る。forgeryは「偽造」という意味。

railsではformヘルパーが自動的にトークンを作ってくれる。

ただ、フォームヘルパーを使わないケース(RailsAPI)も増えてると思うので、そのときはおそらく別の何かを用意すると思われる。

requestオブジェクト(10.1)

requestオブジェクトはリクエストに関する情報を内包している。host,domain,formatなどを使って、その内容を読み取ることが可能。

requestのパラメータに関する情報はparamsハッシュに集約してくれている。

responseオブジェクト(10.2)

responseオブジェクトはアクションが実行されるときにビルドされ、クライアントに送り返されるデータをレンダリングするため、通常はアクセスしない。

ただ、「after系」フィルタで参照できるため、セッターメソッドがあれば値を代入できる。

HTTPダイジェスト認証

https化していない状態のBASIC認証はパスワードを平文で送る。それを避けたい場合は、HTTPダイジェスト認証を使う手がある。

class AdminsController < ApplicationController
  USERS = { "lifo" => "world" }
 
  before_action :authenticate
 
  private
 
    def authenticate
      authenticate_or_request_with_http_digest do |username|
        USERS[username]
      end
    end
end

USERSという定数に「ユーザー名 => パスワード」のハッシュを与える、あとは認証のさいに入力するだけ。

ストリーミングとファイルダウンロード(12)

send_dataメソッドを使うと、クライアントにファイルをストリーミング送信できる。

サーバー上にすでにあるファイルを送りたい場合は、send_fileメソッド。ただし、Rails経由でストリーミング送信するより、Webサーバーのpublicフォルダに置いてダウンロードさせるほうがよい。

ログフィルタのカスタマイズ(14)

パラメータとリダイレクのフィルタ

# パラメータのフィルタ
config.filter_parameters << :password

# リダイレクトのフィルタ
config.filter_redirect << 's3.amazonaws.com'

パラメータのフィルタのケースは、/password/にマッチするとフィルタされる。なのでparams[:password_confirmation]の中身もフィルタ対象。

リダイレクト先のフィルタは300返したあとのlocationについてログに出さない。ヘッダーのlocationを読まれたら無意味な気もするが…。

Rescue(14)

例外ハンドリングを行い、適切なステータスを返したり、処理を行う。

rescue_fromを使えば、特定の例外(カスタム例外クラスを含む)に対して、柔軟に対応できる。

class ApplicationController < ActionController::Base
  # カスタム例外が起きたときに、with:以下のメソッドで対応
  rescue_from User::NotAuthorized, with: :user_not_authorized
 
  private
 
    def user_not_authorized
      flash[:error] = "You don't have access to this section."
      # 認証失敗した場合は、前の画面またはルートに戻る。
      redirect_back(fallback_location: root_path)
    end
end
 
class ClientsController < ApplicationController
  # ユーザーがクライアントにアクセスする権限を持っているかどうかをチェックする
  before_action :check_authorization
 
  # このアクション内で認証周りを心配する必要がない
  def edit
    @client = Client.find(params[:id])
  end
 
  private
 
    # ユーザーが認証されていない場合は単に例外をスローする
    def check_authorization
      raise User::NotAuthorized unless current_user.admin?
    end
end

Railsのルーティング

Railsルーターの目的(1)

受け取ったURLを認識し、適切なアクションやRackアプリケーションに割り当てる。

浅いネスト(2.7.2)

ネストを深くしすぎるとURLが読みづらい。そこで、コレクション(index/new/createのようなidを持たないアクション)だけを、親のスコープで生成する方法がある。

:shallow オプションを付ける。

resources :articles do
  resources :comments, shallow: true
end

# 上の:shallowオプションが付いているものと同義
resources :articles do
  resources :comments, only: [:index, :new, :create]
end
resources :comments, only: [:show, :edit, :update, :destroy]

concernでの共通化(2.8)

ネストさせるルートに名前をつけておくような機能。

concern :commentable do
  resources :comments
end
 
concern :image_attachable do
  resources :images, only: :index
end

上で定めたconcernを

resources :messages, concerns: :commentable
 
resources :articles, concerns: [:commentable, :image_attachable]

というように使える。

memberルーティングを追加する(2.10.1)

:idをもらいながらそのコントローラにルーティングを追加したいときは

# params[:id]で取れる
resources :photos do
  member do
    get 'preview'
  end
end

# この場合はparams[:photo_id]で取る
resources :photos do
  get 'preview', on: :member
end

collectionでのルーティングはmenberと似ているが、:idをパスに置かない。

デフォルト設定を定義する(3.5)

defaults: { format: 'hoge' } でリクエストのフォーマットを定義できる。

get 'photos/:id', to: 'photos#show', defaults: { format: 'jpg' }

photos/3はphotos/3.jpgというパスでのリクエストと解釈する

セグメントを制限する(3.8)

:constraintsオプションによりセグメント(:id)の値を制限できる。

get 'photos/:id', to: 'photos#show', constraints: { id: /[A-Z]\d{5}/ }

高度な制限(3.10)

特定ユーザーのIPをブラックリストに入れ、ブラックリストIPはサイトに入れない設定。

matches?(request)に応答できるオブジェクトを渡せば、それを実行し、trueを返したときのみ特定のコントローラーに誘導できる。

class BlacklistConstraint
  def initialize
    @ips = Blacklist.retrieve_ips
  end
 
  def matches?(request)
    @ips.include?(request.remote_ip)
  end
end
 
Rails.application.routes.draw do
  get '*path', to: 'blacklist#index',
    constraints: BlacklistConstraint.new
end

リソースフルに制限を指定する(4.2)

/photos/:id の:idについて、constraintsに正規表現を渡すことで制限する。

resources :photos, constraints: {id: /[A-Z][A-Z][0-9]+/}

感想

とりあえず、「基礎部分」については、全て読みきりました。

クエリ発行のところが一番おもしろかったです。

応用編も、興味のある部分だけやろうと思ってます!

Ruby on Railsガイドを通読してまとめる Part.3

第三弾です。前回はこちら。Ruby on Railsガイドを通読してまとめる Part.2 - エンジニア備忘録

railsguides.jp

なんだかんだPart.3まで続けられました。

これもひとえに自分の努力の成果です。自分すげえ。(ただまとめてるだけなので大した努力ではない)

読みやすいようにまとめたいと思うんですが、やはり量が膨大になってしまうので、さらっと流し見して気になるところがあれば本家Railsガイドさんを読んでいただければと思います。

モデル

ActiveRecord クエリインターフェイス

Active Recordは、ユーザーに代わってデータベースにクエリを発行できる。勉強初期の頃は全くピンとこない概念。(今は何とかわかる)

発行されるクエリは多くのデータベースシステム (MySQLMariaDBPostgreSQLSQLiteなど) と互換性がある。

ActiveRecordはORマッパーとしてとても評判がいい。(と聞いたことがある。ソースはないです)

以下、様々なメソッドについての説明。

DBからオブジェクトを取り出す(1)

ActiveRecord::Baseを継承したクラスからオブジェクトを取り出すためのメソッド。たくさんありますね。

find
create_with
distinct
eager_load
extending
from
group
having
includes
joins
left_outer_joins
limit
lock
none
offset
order
preload
readonly
references
reorder
reverse_order
select
where

気になったやつだけ抜粋。

単一のオブジェクトを取り出す(1.1)

take

ランダムに1つ取り出す。(例:Client.take)

SQLとしては以下。

SELECT * FROM clients LIMIT 1

take(2)といった形にすれば取り出す数を増やせる。

firstとかはこのSQLにORDER BY idついてるだけなので、そっちでもいい。

(idにインデックス貼ってないことはないだろしtakeとfirstで早さほぼ変わらないはず)

複数のオブジェクトをバッチで取り出す(1.2)

find_each

DBから取り出したデータをメモリを圧迫しないサイズにして、ブロック引数に「取り出したレコード1つ」を格納して処理する。

User.find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

デフォルトでは1000件がバッチとなり、|user|に1つずつレコードが格納されている(はじめに1000回繰り返し、終わったら次。)

オプションによってバッチとする件数を3000とかにカスタマイズ出来たり、どのidからどのidまでで処理を止めるか設定できる。

find_in_batches

DBから取り出したデータをメモリを圧迫しないサイズにして、ブロック引数に「取り出したレコード全て」を格納して処理する。

# 1回あたりadd_invoicesに納品書1000通の配列を渡す
Invoice.find_in_batches do |invoices|
  export.add_invoices(invoices)
end

invoicesというブロック引数に1000件分のレコードが詰まっている。その1000件分の情報はたぶんActiveRecord::Relationオブジェクトという形にして保持されていると思うけど、書いていないので詳細は謎。

気になる人は調べてね! 

文字列だけで表した条件で取り出す(2.1)

SQLインジェクションを発生させないため、パラメーターを使うときは配列(?を使った表現)を使用しましょう。

Client.where("orders_count = ?", params[:orders])

パラメータを使わないなら可読性上げるために文字列でもいいと思います。

プレースホルダーを使用した条件で取り出す(2.2.1)

配列(引数の順番で渡す)以外にも、ハッシュを使える。

Client.where("created_at >= :start_date AND created_at <= :end_date",
  {start_date: params[:start_date], end_date: params[:end_date]})

読みづらくね?

条件の上書き(8)

unscope

条件を外せる。カスタムしたスコープから一つだけ条件外したいときとかに使うのかな?

Article.where('id > 10').limit(20).order('id asc').unscope(:order)

reorder

デフォルトスコープの並び順を上書きできる。

class Article < ApplicationRecord
  has_many :comments, -> { order('posted_at DESC') }
end
 
Article.find(10).comments.reorder('name')
#上書きしたSQL
SELECT * FROM articles WHERE id = 10
SELECT * FROM comments WHERE article_id = 10 ORDER BY name

#上書きする前のSQL

SELECT * FROM articles WHERE id = 10
SELECT * FROM comments WHERE article_id = 10 ORDER BY posted_at DESC

基本unscopeでいけそうだけど、デフォルトスコープに他の条件がたくさんあってorderだけ変えたいときに使える。(あるのか?)

Nullリレーション

noneメソッドはチェイン可能なリレーションオブジェクトを生み出すことができる。

# visible_articles メソッドはリレーションを返すことが期待されている
@articles = current_user.visible_articles.where(name: params[:name])
 
def visible_articles
  case role
  when 'Country Manager'
    Article.where(country: country)
  when 'Reviewer'
    Article.published
  when 'Bad User'
    Article.none # => []またはnilを返すと、このコード例では呼び出し元のコードを壊してしまう
  end
end

nilじゃなくてActiveRecord::Relation(中身無し)を返したいときに使う。便利そう。

楽観的ロック(optimistic lock)(11.1)

複数のユーザーが同じレコードを編集することを認める。 このロックを使用するためには、テーブルに「lock_version」という名前のInteger型カラムが必要である。

レコードが更新されるたび、lock_versionカラムの数値が1ずつ増える。 ユーザーがレコードを更新するとき、DBのlock_versionが自分が編集しているlock_versionより大きかったらエラー。

楽観的と言うとるわりに普通にエラーは出す。

悲観的ロック(pessinistic lock)(11.2)

DBのロック機構を使用する。 絶対に他のユーザーにUPDATEさせないという前提のもと、「読み出し許可」するかしないか設定できる。

# MySQLの場合は、lockメソッドに「LOCK IN SHARE MODE」を与えれば読み出しは許可できる。
Item.transaction do
  i = Item.lock("LOCK IN SHARE MODE").find(1)
  i.increment!(:views)
end

たくさんのユーザーが1つのリソースを触るんじゃなく、決まったチームメンバーが1つのリソースを丁寧に時間かけて編集するときは悲観的ロックのほうが親切だろう。(楽観的ロックだと「え?hogeさんも編集してたんすか?あーだったら俺は編集しなかったのに」みたいなのが起き得る)

joinによる結合(12)

内部結合。SQL文を引数に渡すこともできるが、関連付けしてあれば結合クエリを簡単に作れる。

left_outer_joins

外部結合。関連レコードがない場合でもレコードをセットを取得できる。

関連付けを一括読込する(13)

eager loading! ググってもはっきり出てこないやつ。

日本語には「一括読み込み」と訳すようですね。

N+1クエリ問題を解決する

includeで予め読み込んでおく解決策

clients = Client.includes(:address).limit(10)
 
clients.each do |client|
  puts client.address.postcode
end

この場合は、addressesテーブルの外部キーをINで指定しておくことで、先に読み込んでいるようです。

SELECT * FROM clients LIMIT 10
SELECT addresses.* FROM addresses
  WHERE (addresses.client_id IN (1,2,3,4,5,6,7,8,9,10))

複数の関連付けを予め読み込む(13.1)

2つ引数を指定すればオーケー。

Article.includes(:category, :comments)

ネストした関連付けハッシュ(13.1.2)

Category.includes(articles: [{ comments: :guest }, :tags]).find(1)

上のコードは、id=1のカテゴリを検索し、関連付けられたすべての記事とそのタグやコメント、およびすべてのコメントのゲスト関連付けを一括読み込みする。

「先の先」を取りに行く場合は、ハッシュ構造を使うということですね。「Category.include({article: commets})」で、「categoryに紐づくarticlesを全てとる。そして取ってきたarticleにcommentsを紐づけておく」という動き。

関連付けの一括読み込みで条件を指定する(13.2)

ガイドに書いてあった日本語が難解だった。

「Active Recordでは、joinsのように事前読み込みされた関連付けに対して条件を指定することができますが、joins という方法を使用することをお勧めします。」とのこと。

おそらく意図は「通常、内部結合しにかかりたいならjoinsを使うのが一番いい」ってことかな…?

ただjoin使ってダメなときはincludesにwhereを使って外部結合表現することも可能、とのこと。

Article.includes(:comments).where(comments: { visible: true })

上のコードは下のクエリを発行する。外部結合していますね。

SELECT "articles"."id" AS t0_r0, ... "comments"."updated_at" AS t1_r5 
FROM "articles" 
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id" 
WHERE (comments.visible = 1)

ハッシュを渡さず文字列でwhereの条件を指定した時、リファレンスを指定して強制的にテーブルをjoinし、SQL断片化を防ぐ必要があるとのこと。

Article.includes(:comments).where("comments.visible = true").references(:comments)

SQL断片化…ってなにって思って調べてもよくわからん…。親が消えたのに残った外部キーのインデックスのことですかねえ。

そしてreferences(:commnets)を付けることでクエリにどう変化がうまれるのかサッパリ。

ここは宿題にさせて下さい(涙目)

スコープ(14)

scopeメソッドにシンボルとlambdaブロックを渡すことで、カスタムクエリ(スコープ)を作れる。

条件を組み合わせてあなただけのクエリを発行しよう!

class Article < ApplicationRecord
  scope :published, -> { where(published: true) }
end

上のコードは、下と同義らしいです。

class Article < ApplicationRecord
  def self.published
    where(published: true)
  end
end

つまりscopeなんてかっこつけたところで本質は「ただのクラスメソッド」ってことですね!!なんだビビらせやがって…。(ただ、戻り値の観点で少し差が生まれることがある。少し下の#14.2参照)

スコープ内でスコープをチェインすることも可能。

引数を渡す(14.1)

scopeでも引数を渡せるらしい。

class Article < ApplicationRecord
  scope :created_before, ->(time) { where("created_at < ?", time) }
end

ただ引数を渡す場合は、クラスメソッドとして定義することが推奨されているだとか。「scopeはクラスメソッドを複製した機能である」ということを強調するためらしいですが、scopeの方が明示的に「クエリ用」ってわかりやすいから、こっちでも良い気がしますけど、どうなんでしょう。

条件文を使う(14.2)

scopeとクラスメソッドで挙動が違う。

# これの戻り値は常に「ActiveRecord::Relationオブジェクト」
class Article < ApplicationRecord
  scope :created_before, ->(time) { where("created_at < ?", time) if time.present? }
end

# これは条件が「false」のとき、戻り値はnil
class Article < ApplicationRecord
  def self.created_before(time)
    where("created_at < ?", time) if time.present?
  end
end

デフォルトスコープ(14.3)

前回の記事で、「関連付けのさいの(相手側への)デフォルトスコープ」を学んだが、これは「自分に設定するデフォルトスコープ」

class Client < ApplicationRecord
  default_scope { where("removed_at IS NULL") }
end

こうすると、このモデルへのクエリに「WHERE removed_at IS NULL」がデフォルトで設定される。

デフォルトスコープの条件を複雑に設定したいなら、「self.default_scope」で定義してもOK。

スコープの解除(14.5)

デフォルトスコープ等を完全に解除したいときはunscopedしましょう。

Client.unscoped.all
# SELECT "clients".* FROM "clients"
 
Client.where(published: false).unscoped.all
# SELECT "clients".* FROM "clients"

大事なことなので2回言いました。

Enums(16)

整数型のカラムを設定可能な値の集合にマッピングしてくれる上に、対応するスコープまで自動的に作成してくれる。

class Book < ApplicationRecord
  enum availability: [:available, :unavailable]
end

0で:available、1で:unavailable。

メソッドの例が以下。

# 下の両方の例で、利用可能な本を問い合わせている
Book.available
# または
Book.where(availability: :available)
 
book = Book.new(availability: :available)
book.available?   # => true
book.unavailable! # => true
book.available?   # => false

詳細はRailsAPIへ!

ActiveRecord::Enum

メソッド

find_or_create_by(18.1)

レコードを検索し、なければ作成するメソッド。

レコード作成時の各カラムの値については、ブロックを渡すか、create_withメソッドでの指定で対応できる。ブロックのほうが勝手が効きそうなので、こちらだけ紹介。

Client.find_or_create_by(first_name: 'Andy') do |c|
  c.locked = false
end

Clientモデルのlockedカラムをfalseにする処理ができます。

find_or_initialize_by

レコードを検索し、なければ「new」でインスタンスを作成するメソッド。保存処理はしない。

find_by_sql

引数に文字列を渡すと、それをSQLとして実行してくれる。戻り値は常に配列(内部にオブジェクト格納)

select_all

引数のSQLを実行し、結果としてActiveRecord::Resultオブジェクトを返す。to_hashを打つとハッシュが格納された配列が得られる。

pluck

英語の意味は「摘む」。

テーブルから特定のカラムの値だけを配列等で取り出したいときに使える。

mapなどで無理やり取り出すより、pluckで取り出したほうがパフォーマンスが優れている。

Client.select(:id).map { |c| c.id }
# または
Client.select(:id).map(&:id)
# または
Client.select(:id, :name).map { |c| [c.id, c.name] }

上の例はAvtiveRecordオブジェクトへの#mapだが、下のようにpluckを使えば、ActiveRecordオブジェクトを準備しなくてもいいし、その上スマートに書ける。最高ですね。

Client.pluck(:id)
# または
Client.pluck(:id, :name)

ただし、オブジェクトを介していないため、オーバーライドは出来ない。

class Client < ApplicationRecord
  def name
    "私は#{super}"
  end
end
 
# Client.select(:name)で、Clientクラスのインスタンスが生まれている。それにnameを打つためオーバーライド出来る。
Client.select(:name).map &:name
# => ["私はDavid", "私はJeremy", "私はJose"]
 
# pluckはインスタンス作成を行わない。
Client.pluck(:name)
# => ["David", "Jeremy", "Jose"]

他にも、あくまで配列で取り出すため、limitなどを打つことはできないといった注意点がある。

# Arrayにlimitは打てない
Client.pluck(:name).limit(1)
# => NoMethodError: undefined method `limit' for #<Array:0x007ff34d3ad6d8>
 
# 先にlimitしてればOK
Client.limit(1).pluck(:name)
# => ["David"]

exists?

引数がDBに存在するか調べる。1つでもあればtrue、なければfalse

ライバルとして「present?」、「any?」が存在する。そしてそれぞれパフォーマンスが異なるそう。

そして「そのスコープによって検索に引っかかるレコードが存在するか?」という条件分岐だけをしたい際は、下の記事いわくパフォーマンス最強が「exists?」とのこと。

参考記事: Present? Vs Any? Vs Exists? - The Lean Software Boutique

この参考記事だとpresent?は内部結合したあと、レコード自体を取得しにいってるので、「存在するかどうか」だけを調べる場合パフォーマンスが悪いとのこと。

any?、exists?は内部結合するところまでは同じだけど、COUNT(*)でレコード数を取りに言ってるのでパフォーマンス的にpresent?に勝ってる。記事ではany?はLIMIT(1)を付けてないのでその分遅いらしいが、調べてたらRails5.2(?)からLimit付けてて同着らしい。どっちや。

ここまできたら内部のコード見に行った方がいいけどActiveRecord潜りにいく元気は今はねえ!! ということで、これも要検証認定です。おめでとう!!

count, average, minimum, maximum, sum(21)

SQLの集計関数を呼び出す者たち。

EXPLAIN

explainメソッドにより、標準出力などで以下のようなものが得られる

# これを打てば…
User.where(id: 1).joins(:articles).explain

↓が得られるそうです

EXPLAIN for: SELECT `users`.* FROM `users` INNER JOIN `articles` ON `articles`.`user_id` = `users`.`id` WHERE `users`.`id` = 1
+----+-------------+----------+-------+---------------+
| id | select_type | table    | type  | possible_keys |
+----+-------------+----------+-------+---------------+
|  1 | SIMPLE      | users    | const | PRIMARY       |
|  1 | SIMPLE      | articles | ALL   | NULL          |
+----+-------------+----------+-------+---------------+
+---------+---------+-------+------+-------------+
| key     | key_len | ref   | rows | Extra       |
+---------+---------+-------+------+-------------+
| PRIMARY | 4       | const |    1 |             |
| NULL    | NULL    | NULL  |    1 | Using where |
+---------+---------+-------+------+-------------+
 
2 rows in set (0.00 sec)

使うのかな…?

ビュー

ActionViewの概要

ビュー生成を担当するライブラリ。

Jbuilder(3.1.3)

Railsにデフォルトで含まれるGemの1つ。JSONを生成するのに使用する。

.jbuilderという拡張子を持つテンプレートでは、jsonという名前のJbuilderオブジェクトが自動的に利用できるようになる。

詳しい挙動は公式ドキュメントで。

GitHub - rails/jbuilder: Jbuilder: generate JSON objects with a Builder-style DSL

パーシャルレイアウト(4)

全体のレイアウトとは異なり、ローカル変数を渡せる。パーシャルテンプレートをyieldで評価しながらテンプレートを作る。

articles/show.html.erb

<%= render partial: 'article', layout: 'box', locals: { article: @article } %>

artcicles/_box.html.erb

<div class='box'>
  <%= yield %>
</div>

これでパーシャルテンプレートである_articleを、パーシャルレイアウトである_box内で使ってテンプレートを作成できる。

ActionViewのヘルパーメソッド(6)

大量にありますが、必要に応じて参照すれば良さそうです。(Rails側でテンプレートを作る技術が、今後フロントエンドのフレームワークに置き換わっていく可能性も考え、ここは一旦スルーします。)

レイアウトとレンダリング

コントローラからビューへの結果の渡し方について解説するトピック。

レスポンスを作成する(2)

コントローラー側から見ると、HTTPレスポンスの作成方法は以下の3通り。

  • renderを呼び出し、ブラウザに返す完全なレスポンスを作成する
  • redirect_toを呼び出し、HTTPリダイレクトコードステータスをブラウザに送信する
  • headを呼び出し、HTTPヘッダーのみで構成されたレスポンスを作成してブラウザに送信する

設定より規約(2.1)

コントローラーはrenderを指定しなくても命名規則に従ったものをrenderする。

そういえば、この規約って、「convention」の訳だと思うんですが、どちらかと言えば「慣習」という意図のほうが意図にマッチしてる気がする

別のコントローラからアクションのテンプレートを出力する(2.2.1)

app/views以下のパスを指定すればOK

render "products/show"

appディレクトリ外のテンプレートを使いたい場合は、"/"から始まるフルパスで指定する。

renderのオプション(2.2.12)

以下の5つが一般的。

  • content_type
  • layout
  • location
  • status
  • formats

content_type

レスポンスのcontent-typeを指定できる。:jsonを渡せば、application/json、:xmlを渡せばapplication/mlなど。

layout

現在のアクションに対して、特定のファイルをレイアウト指定できる。

出力時に、デフォルトのレイアウトを使用しないよう設定も可能。

render layout :false

location

HTTPのlocationヘッダーを設定できる。300などで飛ばす場所のこと。

render xml: photo, location: photo_url(photo)

status

HTTPステータスコードを明示的に指定したい場合に使用する。

formats

リクエストで指定されたフォーマットに応じてレスポンスを返す。

render formats: [:json, :xml]

レイアウトの探索経路(2.2.13)

レイアウトの探索経路は、「そのコントローラ管轄のviewsにlayoutディレクトリがあるか探し、なければ共通のレイアウトを使用する」という流れです。

たとえば、PhotosControllerクラスのアクションから出力するのであれば、app/views/layouts/photos.html.erbまたはapp/views/layouts/photos.builderを探します。該当のコントローラに属するレイアウトがない場合、app/views/layouts/application.html.erbまたはapp/views/layouts/application.builderを使用します。

設定次第で、任意のレイアウトを指定できる。

例えば、そのコントローラーのデフォルトのレイアウトを変更したい場合は

class ProductsController < ApplicationController
  layout "inventory"
  #...
end

シンボルで指定すれば、レイアウトを遅延させて決定できる。

class ProductsController < ApplicationController
  layout :products_layout
 
  def show
    @product = Product.find(params[:id])
  end
 
  private
    def products_layout
      @current_user.special? ? "special" : "products"
    end
 
end

メソッドの戻り値に文字列をセットしておいて、あとで評価するということですね。

Procオブジェクトを渡せば、リクエストの内容に応じてレイアウトを指定することもできる。

class ProductsController < ApplicationController
  layout Proc.new { |controller| controller.request.xhr? ? "popup" : "application" }
end

コントローラーインスタンスがリクエスト内容を保持しているようです。

二重レンダリングエラーを避ける(2.2.14)

and returnを使えば、レンダー先を指定したところで処理を終えるのが簡単。

def show
  @book = Book.find(params[:id])
  if @book.special?
    render action: "special_show" and return
  end
  render action: "regular_show"
end

redirect_toを使用する(2.3)

redirect_toメソッドは、別のURLに対して改めてリクエストを再送信するよう、ブラウザに指令を出すためのもの。

redirect_backを使う場合、戻り先の保証はHTTP_REFERERヘッダーによって行われるが、ブラウザが常に保証しているとは限らないので以下のようにして、失敗時どこに飛ぶかの設定は必要。

redirect_back(fallback_location: root_path)

headを使用する(2.4)

headを指定し、bodyなしのレスポンスを返すことが出来る。

head :bad_request

以下のようにヘッダーが生成される。

HTTP/1.1 400 Bad Request
Connection: close
Date: Sun, 24 Jan 2010 12:15:53 GMT
Transfer-Encoding: chunked
Content-Type: text/html; charset=utf-8
X-Runtime: 0.013483
Set-Cookie: _blog_session=...snip...; path=/; HttpOnly
Cache-Control: no-cache

感想

ActiveRecordについてのトピックが終わって、ActionViewに関するトピックに入りました。

ビューに関するトピックはもう1つだけあるのですが、飛ばします!!

というのが、次に作ろうとしてるアプリはRailsAPI+Vue.jsの予定なので、ビューの知識がすぐに活きないからです。

そして、知識としても結構細々してるので、必要に応じて参照すればいいかな、と。

コントローラーはガッツリ使うので、読んでいこうと思います。

要検証

  • present?, any?, exists? の違い
  • where説を文字列で指定するincludeについて、referencesメソッドを打つか打たないかでのSQL文の違い
Article.includes(:comments).where("comments.visible = true").references(:comments)

Ruby on Railsガイドを通読してまとめる Part.2

第二弾です。前回はこちら。

fujiten3.hatenablog.com

railsguides.jp

これのコールバック〜関連付け(アソシエーション)を読んでまとめました。

めちゃくちゃ長いです。

モデル

AvtiveRecord コールバック

ActiveRecordコールバックとは、ActiveRecordを継承したクラスのインスタンス(User.new等)について、それにsaveやupdateを実行したさいにトリガーされる処理のこと。

before_saveといったトリガと、それに呼び出されるメソッドのことですね。

ActiveRecordのオブジェクトライフサイクルへのフック」と言ってもよい。

コールバックの登録(2.1)

一般的に以下のような記述でコールバックを定めておく。

class User < ApplicationRecord
  #バリデーションの前にコールバックメソッドを実行
  before_validation :normalize_name,
 
  # :onは配列を取ることもできる。この場合はcreateアクションとupdateアクションのみにトリガし、destroyではスルー。
  after_validation :set_location, on: [ :create, :update ]
 
  # コールバックはprivateにしておくことが望ましい
  private
    def normalize_name
      self.name = name.downcase.titleize
    end
 
    def set_location
      #selfはUserクラスのインスタンス自身を表す。
      self.location = LocationService.query(self)
    end
end

個人的に気になった細かい注意点としては、コールバックはUserクラスの特異メソッドとしてでなく、インスタンスメソッドとして定めるというところかな。バリデーションをするかしないか決定するのはUserクラスだけど、その処理自体の内容はインスタンス自身が知っているように定めておく、 ということらしい。

利用可能なコールバック(3)

before_validation、before_saveといったもの。

結構量があるけど、挙動が複雑なのはaround_saveやaround_createといったaround_*系のみだろうか。ガイドで詳しい説明はなかったけど、ググってみた所どうやらサンドイッチ的に処理を書けるようです。

以下、実際に挙動を確認してはいないけど、コードに起こしてみました。たぶんちゃんと動いてくれる。

class User < ApplicationRecord
  around_save :do_around_save
  
  def do_around_save
     #save前の処理を記述
     yield #saves
     #save後の処理を記述
  end

end

この場合は yield によってインスタンスにsaveが実行されるってことですね。

参考記事:

ruby - Rails: around_* callbacks - Stack Overflow

ActiveRecord::Callbacks

after_initializeとafter_find

初めて見たコールバック。こんなのあるんですね。

after_initializeコールバックは、Active Recordオブジェクトが1つインスタンス化されるたびに呼び出される。インスタンス化は、直接newを実行する他にデータベースからレコードが読み込まれるときにも行われる。

インスタンス自体が生まれたときに使えるわけだから、インスタンスに対して最初に実行しておきたい処理とかを記述できるということだろう。

いやあActive_Record先輩すごいぜ。何でもやってくれるな。

挙動がそのまま乗ってたので掲載します。

class User < ApplicationRecord
  after_initialize do |user|
    puts "オブジェクトは初期化されました"
  end
 
  after_find do |user|
    puts "オブジェクトが見つかりました"
  end
end
 
>> User.new
オブジェクトは初期化されました
=> #<User id: nil>
 
>> User.first
オブジェクトが見つかりました
オブジェクトは初期化されました
=> #<User id: 1>

after_touch(3.5)

touchメソッドの後に実行。touch自体知らなかったけど、update_atを更新したい場合に使えるらしい。そのインスタンスにタッチするってことですね。

挙動例にスマートなコードがあったので掲載。親子関係のモデルで、子どもにタッチしたときの動き。

class Employee < ApplicationRecord
  belongs_to :company, touch: true
  after_touch do
    puts 'Employeeモデルにtouchされました'
  end
end
 
class Company < ApplicationRecord
  has_many :employees
  after_touch :log_when_employees_or_company_touched
 
  private
  def log_when_employees_or_company_touched
    puts 'Employee/Companyにtouchされました'
  end
end
 
>> @employee = Employee.last
=> #<Employee id: 1, company_id: 1, created_at: "2013-11-25 17:04:22", updated_at: "2013-11-25 17:05:05">
 
# @employee.company.touchをトリガーする
>> @employee.touch
Employeeにtouchされました
Employee/Companyにtouchされました
=> true

子どもにタッチしたとき、子ども側で「belongs_to :parent, touch: true」といったオプションがあれば、親側もタッチされる。(そして親のupdated_atも更新される)。親側がタッチされたとき、それにコールバックする処理を書いておくことも可能ということですね。連鎖するタッチ。優しさの連鎖。

コールバックの実行(4)

コールバックを実行するメソッドのうち「toggle!」だけ知らなかったので調べたら、true/falseを反転させて保存という処理を行うそうです。保存処理(save)にトリガしてコールバックがかかるんでしょうね。「toggle」単体だとただtrue/falseを変えるだけ。

リレーションシップのコールバック(7)

インスタンスのdestroyにフックして子インスタンスのdestroyを呼び出す処理にしていたとき、子側でコールバックを定められる。

class User < ApplicationRecord
  has_many :posts, dependent: :destroy
end
 
class Post < ApplicationRecord
  after_destroy :log_destroy_action
 
  def log_destroy_action
    puts 'Post destroyed'
  end
end
 
>> user = User.first
=> #<User id: 1>
>> user.posts.create!
=> #<Post id: 1, user_id: 1>
>> user.destroy
Post destroyed
=> #<User id: 1>

コールバックで複数の条件と指定する(8.3)

記事にコメントをするさい、記事の作者が「メール希望」であり、かつ「その記事がコメント無視する設定にしていない」ときにだけ作者にメールを送りたいときのコードが以下。

class Comment < ApplicationRecord
  after_create :send_email_to_author, if: :author_wants_emails?,
    unless: Proc.new { |comment| comment.post.ignore_comments? }
end

Proc.new{}の処理はメソッドにしてもよい。

コールバッククラス(9)

この章で一番難しいな、これな。

バリデーションをクラスで切り取れるように、コールバックの処理自体もクラスで切り取り、いろいろなモデルに流用できるよという概念。

ただコードへの解説の少なさがハンパなかったので自分で調べました。

# コールバッククラスに処理を委譲する(今回の場合はPictureFileCallbacks.newに委譲)
class PictureFile < ApplicationRecord
  after_destroy PictureFileCallbacks.new
end


class PictureFileCallbacks
  #トリガした側が「after_destroy」で委譲した場合、委譲された側では「after_destroy(record)」が実行される。
  #recordとはつまり、トリガした側のインスタンスのこと。ここでは「PictureFile.find(id: n)」が渡されるようになっている。
  def after_destroy(picture_file)
    if File.exist?(picture_file.filepath)
      File.delete(picture_file.filepath)
    end
  end
end

参考記事読みながら頑張って理解しましょう! 実務でもガンガン出てきそうです(想像です)

てめえらのRailsはオブジェクト指向じゃねえ!まずはCallbackクラス、Validatorクラスを活用しろ! - Qiita

トランザクションコールバック(10)

もっと難しいとこきた!!

after_crate_commit と after_createの違いについて話してくれていますが…かなり難解。

自分の解釈を書きますが、怪しいところもあるので自分で確認してください。

2つの違いはトランザクションの範囲(例外が出たさい、ロールバックする範囲)がポイントに見える。「after_crate_commit」の場合は、それが指定するコールバック内で例外が起きても、コミットした内容までロールバックすることはなさそう。

つまり、「Aを作成したあと、Bを削除する処理」を書く場合、after_crate_commit でAを作成したときは、Bの削除で例外が起きるとAの作成自体までロールバックされることはない。一方、after_createの場合は一つのトランザクション内に存在している動作だから、Bの削除における例外でAの作成自体もロールバックする(Aは生まれない)という処理になる、ということかなあ。

正直、難しい。これは自分でコード起こさないと理解できなさそうです。またのちほど検証します。

参考記事:

ruby - Difference between after_create, after_save and after_commit in rails callbacks - Stack Overflow

ruby on rails - after_create :foo vs after_commit :bar, :on => :create - Stack Overflow

ActiveRecord アソシエーション

関連付けの種類(2)

Railsでサポートされている関連付けは以下の6種類。

  • belongs_to
  • has_one
  • has_many
  • has_many :through
  • has_one :through
  • has_and_belongs_to_many

6種類しかなかったのね。もっとあるかと思ってた。

belongs_to と has_one どちらを選ぶか(2.7)

区別の決め手の一つは「外部キー」をどちらに置くのか、ということ。ほかにも、どちらがどちらに所属するのか考えるのも大事。たとえば供給者とアカウントの関係を考えたとき、アカウントが供給者に属すると見たほうが自然。ということで、以下のようなマイグレーションファイルが望ましい。

マイグレーションファイルの例。


class CreateSuppliers < ActiveRecord::Migration[5.0]
  def change
    create_table :suppliers do |t|
      t.string :name
      t.timestamps
    end
 
    create_table :accounts do |t|
      t.integer :supplier_id
      t.string  :account_number
      t.timestamps
    end
 
    add_index :accounts, :supplier_id
  end
end

t.integer :supplier_id は t.references :supplier と書いてもよい。同じ意味。 indexは自分で貼りましょう。

referencesで関連付けると、foreign_key: trueオプションをつかって、DB自体に参照整合性を設定できる。強い。大事ですね。

参考記事:

Railsの外部キー制約とreference型について - Qiita

has_many :throughとhas_and_belongs_to_manyのどちらを選ぶか(2.8)

似てるように見えて、違いがあります。どちらも中間テーブルが必要ですが、has_and_belongs_to_manyはその中間テーブルをモデルとして扱わない。「ただつなげる」だけ。

has_many :throughは、結合モデルを作るので、バリデーションやコールバック、追加の属性の設定などができる。

DBは一度作ると変更コストが大きいので、しっかり考えて作りたいですね。ここテキトーに選ぶと負債が大きそう。

ポリモーフィック関連付け(2.9)

高等テクニック。1つのモデルが他の複数のモデルに属すことを、1つの関連付けだけで表現できる。

SQLアンチパターン読んでたときにアンチパターン気味に紹介されてはいたけど、「のちのちへの対応力が高い」ということで、たしか拡張性の面では評価されてた。

従業員、製品から「Pictureモデル」にアクセスしたいときのポリモーフィック関連の記述法。

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end
 
class Employee < ApplicationRecord
  has_many :pictures, as: :imageable
end
 
class Product < ApplicationRecord
  has_many :pictures, as: :imageable
end

これで@employee.picturesや@product.picturesで写真を引っ張ってこれるし、逆に@picture.emageableで親モデルのタイプを知ることなく親を引っ張ってこれる。あとはEmployeeとProductに同名のメソッドを作って、それぞれの振る舞いさえ記述して上げれば、まさにオブジェクト指向的なコードになりそう。

つまりこんな感じですね。

class Employee < ApplicationRecord
  has_many :pictures, as: :imageable

  def greet
    puts "こんにちは!"
  end

end
 
class Product < ApplicationRecord
  has_many :pictures, as: :imageable

  def greet
    puts "コンニチハ"
  end

end

マイグレーションファイルはこのようにしましょう。

class CreatePictures < ActiveRecord::Migration[5.0]
  def change
    create_table :pictures do |t|
      t.string  :name
      t.references :imageable, polymorphic: true, index: true
      t.timestamps
    end
  end
end

これで、picturesテーブルのカラムに「imageable_type」と「imageable_id」カラムが追加されます。 imageable_typeはモデル名がString型で格納されてて、それでどの親モデルのidに属してるか判断します。

自己結合(2.10)

1つのテーブル内での関連付け。Employeeテーブルがマネージャと部下をもつ場合などに使う。

関連付けで知っておくべき注意事項(3)

  • キャッシュ制御
  • 名前衝突の回避
  • スキーマの更新
  • 関連付けのスコープ制御
  • 双方向関連付け

キャッシュ制御(3.1)

オブジェクトは実行したクエリの結果を保持する。再読込したい場合はreloadメソッドを実行しよう。

author.books                 # データベースからbooksを取得する
author.books.size            # booksのキャッシュコピーが使用される
author.books.reload.empty?   # booksのキャッシュコピーが破棄される
                             # その後データベースから再度読み込まれる

名前衝突の回避(3.2)

関連付け名はメソッドとして使われるため、ActiveRecord::Baseが持つメソッドと被らないように。「belongs_to :save」みたいな関連付けしたらダメだよってことですね。

スキーマの更新(3.3)

コード上で関連付けてもDB自体に必要な設定を施してないとうまく働かない。

  1. belongs_to関連付けを使用する場合は、外部キーを作成する
  2. has_and_belongs_to_many関連付けを使用する場合は、適切な結合テーブルを作成する

2について、中間テーブルにはプライマリキーを設定しない。設定すると動作不良を起こします。

関連付けのスコープ制御(3.4)

関連付けのさい、オブジェクトは現在のモジュールのスコープ内で探索を行う。 別の名前空間同士での関連付けの場合はclass_nameとして、完全なクラス名を宣言しなければならない。

module MyApplication
  module Business
    class Supplier < ApplicationRecord
      has_one :account,
      class_name: "MyApplication::Billing::Account"
    end
  end
 
  module Billing
    class Account < ApplicationRecord
      belongs_to :supplier,
      class_name: "MyApplication::Business::Supplier"
    end
  end
end

双方向の関連付け

基本的に、親モデル側からのあるインスタンスに対して、子側からアクセスしにかかっても、同じインスタンスを参照するようにActiveRecordは働く。これが双方向の関連付け。

しかし子側で:through、または:foreign_keyオプションを使ったさいは、inverse_ofオプションを使って親側からも子側を指定しないと、双方向にならない。

class Author < ApplicationRecord
  has_many :books, inverse_of: 'writer'
end
 
class Book < ApplicationRecord
  belongs_to :writer, class_name: 'Author', foreign_key: 'author_id'
end

詳しい説明見たい場合はガイドへGo!

アソシエーションの詳細情報

アソシエーションすることで得られるメソッドの解説。

AvtiveRecordっていろんなことしてくれるんですね(ActiveRecordへの感謝定期)

belongs_toで得られるメソッド(4.1.1)

全部で6つ。よく見るのばかり。

#association という言葉は、関連付け相手のテーブル名に動的に代わります

association
association=(associate)
build_association(attributes = {})
create_association(attributes = {})
create_association!(attributes = {})
reload_association

build_associationだけ解説。(associationという言葉はbelongs_to :author といった関連付けをすればauthorに置き換わる)

@author = @book.build_author(author_number: 123,
                                  author_name: "John Doe")

子側から親のインスタンスを作る動きですね。作った時点ではDBに保存されない。

create_asociationで親のインスタンスを作れば、作ったときに保存処理が行われる(なのでもちろんバリデーション等に弾かれる可能性もある)

belongs_toのオプション(4.1.2)

全部で11個!  多いぜ…。

  • :autosave
  • :class_name
  • :counter_cache
  • :dependent
  • :foreign_key
  • :primary_key
  • :inverse_of
  • :polymorphic
  • :touch
  • :validate
  • :optional

知らなかったもの、かつ重要そうなものをまとめていきます。

:autosave

APIガイド読みましたが、挙動の詳細がいまいちつかめません。 Railsガイドといろいろなサイト見たところ、つまりこういうことかもしれない。

  • autosave: true → 親オブジェクトのsaveで、「子モデルの作成・更新」をともに保存する。
  • autosave: false → 親オブジェクトのsaveで、「子モデルの作成・更新」をともに保存しない。
  • autosave未設定 →親オブジェクトのsaveで、「子モデルの作成」は保存し、「子モデルの更新」は保存しない。

挙動チェックまではしてないので、実装の際はご注意を!

参考記事: ActiveRecord::AutosaveAssociation

:class_name

実際のモデル名を指定するオプション。 メソッドとしてauthorで呼びたいが、モデル名がPersonのときは以下の設定。

class Book < ApplicationRecord
  belongs_to :author, class_name: "Patron"
end

:counter_cache

親モデルが持つ子モデルの数をキャッシュしておく設定。

詳細、結構調べましたが、古い記事が多く難しかったです。とりあえずクエリ発行の結果をキャッシュするという挙動はわかったんですが、クエリ結果のIntegerを普通に変数に保持しておいてアレコレするっていう動作の方が安全に思えるんですけどどうなんでしょうね。

実務で見かけたらまた調べようと思います。

counter_cacheを強化したcounter_cultureというgemもあるみたいです。

Rails tips: ActiveRecord count系機能の基本と応用(翻訳)

Railsで関連レコード数の集計(カウンターキャッシュ) | 株式会社ランチェスター

:dependent

:destroyで関連付けのdestroyメソッドを実行。:deleteだと直接DBから削除。

子側の削除で親側の削除をフックすることはあまりなさそう。

:foreign_key

自分が持つ外部キーのカラム名を指定。

:primary_key

親側のprimary_keyがself.primary_key=hogeで変更されてる場合は、子側でも明示しないと参照できない。

:inverse_of

前述した通り。inverseは逆側という意味で、関連付けしているモデル目線から見た自分の呼び出し方を記述する。

:polymorphic

前述した通り。

:touch

上でも解説したが、足りないので補足。

関連しているオブジェクトの保存または削除に合わせて自分のupdated_atを更新する。

:validateと:optional

説明が薄かったので割愛。

belongs_toのスコープ(4.1.3)

アソシエーションが自動的につくるクエリをカスタマイズしたいとき、独自に設定することが出来る。

class Book < ApplicationRecord
  belongs_to :author, -> { where active: true },
                        dependent: :destroy
end

この記述をしておけば、book.authorで発行するクエリの条件に、active: trueが付随してくれる。 他にも状況に応じて3つほどオプションがあったけど、このwhereの例が一番頻出しそう(想像)。

has_oneで得られるメソッド(4.2.1)

belongs_toとほとんど同じ。

has_oneのオプション(4.2.2)

belongs_toとほぼ同じだが、大きく違うものだけ見ていく。

:dependent

親側オブジェクトの削除に対して、それに紐づく子側の挙動を設定できる。

  • :destroyを指定すると、関連付けられたオブジェクトも同時にdestroyされます。
  • :deleteを指定すると、関連付けられたオブジェクトはデータベースから直接削除されます。このときコールバックは実行されません。
  • :nullifyを指定すると、外部キーがNULLに設定されます。このときコールバックは実行されません。
  • :restrict_with_exceptionを指定すると、関連付けられたレコードがある場合に例外が発生します。
  • :restrict_with_errorを指定すると、関連付けられたオブジェクトがある場合にエラーがオーナーに追加されます。

:nullifyはDBのNOT NULL制約に弾かれるので注意。というか:nullifyオプション自体使用頻度低そう。

:primary_key

詳細が書いてなかったけど、子側のprimary_keyが変わってたらこのオプションで明示しないとダメなんだろう。 おそらくそういうことは滅多にない。

has_manyで追加されるメソッド(4.3.1)

全部で17個!!?マジ?? belongs_toは6個だったのに… 

#collection という単語は関連付け先のテーブル名に動的に代わります。

collection
collection<<(object, ...)
collection.delete(object, ...)
collection.destroy(object, ...)
collection=(objects)
collection_singular_ids
collection_singular_ids=(ids)
collection.clear
collection.empty?
collection.size
collection.find(...)
collection.where(...)
collection.exists?(...)
collection.build(attributes = {}, ...)
collection.create(attributes = {})
collection.create!(attributes = {})
collection.reload

collection.delete(object, ...)とcollection.destroy(object, ...)の違い

うーん、ここガイドが逆なように感じた。

ただAPIリファレンスからすぐに見つけられなかったから、一旦そのまままとめておきます。

書いてあることとしては「collection.delete(obj,...)はデフォルトでは外部キーにNULLセットで子モデルを削除扱い、親モデルにdependentがあればそれに応じてdestroyなりdelete」。一方「collection.destroy(obj,...) は問答無用でDBから削除」という挙動だそうです。

deleteがモデル層見つつ削除して、destroyが直接クエリ打つって、なんか逆に感じる。。

うーむ、また調べておきたい。

collection.clear(4.3.1.8)

親モデルの:dependentオプションに応じながら全削除。

@author.books.clear

@authorをdestroyするわけじゃないけど、まるでdestroyしたような挙動になりますね。 親モデルを一旦論理削除しておくけど、それに紐づく子モデルはDBから物理削除しておきたい、みたいなときに使うのかな。

collection.where(...)(4.3.1.12)

条件を指定して検索できるが、遅延読み込みされる点に注意。

#クエリはまだ発行されない
@available_books = @author.books.where(available: true) 

#クエリが発行される
@available_book = @available_books.first

has_manyのオプション(4.3.2)

has_oneとほぼ同じ。

has_manyのスコープ

has_oneより種類がある。重要そうなものを抜粋。

includes(4.3.3.4)

第2関連付けをeagar_loadしたいときに使う。

class Author < ApplicationRecord
  has_many :books, -> { includes :line_items }
end
 
class Book < ApplicationRecord
  belongs_to :author
  has_many :line_items
end
 
class LineItem < ApplicationRecord
  belongs_to :book
end

@author.books.line_itemsを取り出す頻度が多いときに予めincludeしておける、とのこと。

@author.booksでクエリ発行したときに、line_itemsのことも考えてincludeしてキャッシュしておくということでしょうかね。

@author.books.line_items単体のクエリのパフォーマンスが上がる(SQLの効率化)ってわけではなさそうなんだけどどうなんだろう? 要検証。

limitやoffsetやorderも

あらかじめ設定しておけるみたいだけど、author.booksに暗黙的にlimit(100)させたりするより、コントローラー側でlimit(100)するか、クラスで使用頻度高いクエリをまとめたscopeを作っておいた方が良さげに思えますね。

まあ、あくまで状況に応じてということでしょうが。

distinct

中間テーブルを介して多:多で、相手のレコードを探しにいくとき、中間テーブルの一意性を検証して、「参照前と参照後が同じであるレコード」が合った場合に、それが1つになるよう弾いてくれる。

DB自体で「その中間テーブルの一意性」を保証した場合は、以下の設定をしておこう。

add_index :readings, [:person_id, :article_id], unique: true

これをしておけば、「著者と記事のidが全く同じ中間テーブルが2つ以上ある」という状況が起きない。

include?などで一意性チェックしにかかるのはアンチパターンなのでやめましょう。

シングルテーブル継承 (STI

あるテーブルにtypeカラムを作り、そのテーブルを他に継承させる。 そうすることで、共通のメソッドやテーブルのカラムを再利用できる。

が…アンチパターン気味にしか見えない…。

現場Railsでも「STIはよっぽど同じ条件のモデルたちがあるときじゃないと使うべきじゃない(モジュールとかで対応できるよね)」って書いてた気がするし、安易に作らないようにしたい。

感想

大事だなあと思う所が多くて長くなりました。ぜんぜんまとめれてねえ。

内容はめちゃくちゃ為になるので、Part.3も引き続きやっていこうと思います。

要検証

  • トランザクションコールバック
  • collection.delete(object, ...)とcollection.destroy(object, ...)の違い
  • has_manyのオプションにおけるincludesのクエリの発行の仕方