シュッと開発日記

学んだことのアウトプット。ポートフォリオ作ってます。

マジックナンバーをSettingsLogicを使ってまとめて定数化する

技術系YouTuberの勝又さんのサロンに私も入っているのですが、 そこで初学者向けに「マジックナンバーは定数化した方がいいよ!」という話が出ていたので、 調べてみて、実装してみました。

マジックナンバーとは

マジックナンバー」とはまずなんなのかと思い、ググったところ、 以下の記事が勉強になりました。

マジックナンバーの回避の手引き

まとめると

「実装した本人以外がソースをみたときに、 その数値がなんの為に使われているのかが分からない数字」

マジックナンバーとなるようですね。

enumとかでやっていることが、マジックナンバーの定数化というところでしょうか。

SettingLogic

configというgemもあるようですが、環境ごとにファイルを作成するみたいで、 今回の実装では、マジックナンバーをただ定数化したいだけで (なら直書きで定数を定義した方がいい気もしますが、) そんなに多くもないので、 SettingsLoigicを使って管理したいと思います。

# Gemfile

gem 'settingslogic'

bundle install

公式に設定ファイルはapp/models/settings.rbに書くべしとあったので、

# app/models/settings.rb

class Settings < Settingslogic
  config_source = Rails.root / 'config' / 'application.yml'
  source config_source.to_path
  namespace Rails.env
end

あとはconfig/application.ymlに定数を定義していきます。

# config/application.yml

defaults: &defaults
  page: 10
  post:
    long_name: 8
    long_description: 12
  ogp:
    base_image_path: './app/assets/images/base.png'
    gravity: 'center'
    text_position: '0,0'
    font: './app/assets/fonts/ipag.ttf'
    font_size: 30
    idention_count: 8
    row_limit: 8
development:
  <<: *defaults
test:
  <<: *defaults
production:
  <<: *defaults

マジックナンバー以外にも定数として使用していたものをまとめて定義しておきました。

以上でマジックナンバー等をまとめて定数化することができました。

Active StorageのN+1問題に対処する

まず最初に最近データベースとかのあたりを勉強していると見かけた、 N+1問題というのを知って調べてみた。

N+1問題

繰り返し処理の中で関連があるときに普通にeachなどを使うと、 その関連を探しにデータベースにその関連がある回数だけアクセスしてしまう。 ⇨(SQLが実行されまくる) というような理解でいいのかな?

対策

関連のデータベースをいちいち参照しに行くのではなく、 includesを使ってデータベースをくっつけてしまうことで、 最小の回数データベースをみに行くようにする ってな感じでしょうか。

ここまではこの記事をみて理解しました。

N+1問題

Active Storageの対策

というわけでここまでで自分のプロジェクトを確認すると、 私の場合では、プロフィール画像のところでN+1問題がおきていました。

失敗

# app/models/user.rb

has_one_attached :avatar

# app/views/users/index.html.erb

<% @users.each do |user| %>

---------------------

  <%= image_tag user.user_icon, class: "icon_users" %> # user_iconはuser.avatarを使ってユーザーのプロフィール画像を返す自作メソッド

----------------------

# app/controllers/users_controller.rb

  def index
    @users = User.all
  end

というようなことをしていたため、

def index
  @users = User.all.includes(:avatar)
end

としたんですが、user.avatarはカラムを参照しているわけではなく、 単なる便利メソッドだということをにエラーが出て気づきました。

成功パターン

というわけで、ActiveStorageを使用する時にどうやってincludesしたらいいか調べてみると

with_attached_attachment_nameを使用するといいということが下の記事からわかりました。

Active StorageのN+1問題を解決する

よってこれを使って

def index
  @users = User.with_attached_avatar
end

として解決しました。

関連の関連がActiveStorageを使用していた時

問題発生

ここまではよかったんですが、 他のところでもN+1問題おきてないかなと探していると、 もう一つ見つけました。

# app/models/post.rb

belongs_to :user

postがuserを繰り返しの中で使っていて、 そのuserがまたしても プロフィール画像を使用していたため、 そこでもN+1問題がおきていました。

「あ、また、with_attached_avatar使えばいいのか」 と思っていましたが、 コントローラーをみてみると

# app/controllers/posts_controller.rb

  def index
    @posts = Post.page(params[:page]).per(9) # kaminariのページネーションを使用
  end

となっていて、どう書けばいいかわからなくなってしまいました。

問題解決

with_attached_avatarをなんとか書ければできるなと考えて、 何をしているかソースを調べてみると

# activestorage/lib/active_storage/attached/macro.rb

scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }

というのがありました…!!!

あとはこれにしたがってwith_attached_avatarを includes({avatar_attachment: :blob)に書き換えて

下の記事にあるように実装してしまうだけです。

Rails で includes して N+1 問題対策

# app/controllers/posts_controller.rb

  def index
    @posts = Post.page(params[:page]).includes(user: { avatar_attachment: :blob }).per(9)
  end

これでActiveStorageのN+1問題を解決することができました。

CircleCIのビルドにrubocopを導入する

静的コード解析ツールであるrubocopを 導入するだけなら簡単にできたので記録に残しておきたいと思います。

.rubocop.ymlの準備

# Gemfile

gem 'rubocop'

bundle install

プロジェクトのルートに.rubocop.ymlを配置

# .rubocop.yml

AllCops:
  Exclude:
    - "tmp/**/*"
    - "config/initializers/*"
    - "vendor/**/*"
    - "db/schema.rb"
    - "node_modules/**/*"
    - "db/migrate/*.rb"
    - "bin/*"
  DisplayCopNames: true
  TargetRubyVersion: 2.6.3

Rails:
  Enabled: true

Style/AndOr:
  EnforcedStyle: conditionals

Style/AsciiComments:
  Enabled: false

Style/Documentation:
  Enabled: false

Style/NumericLiterals:
  Enabled: false

Style/ClassAndModuleChildren:
  Enabled: false

Bundler/OrderedGems:
  Enabled: false

Lint/ShadowedException:
  Enabled: false

LineLength:
  Enabled: false

Metrics/BlockLength:
  Exclude:
    - 'spec/**/*'
  Max: 40

Metrics/AbcSize:
  Enabled: true
  Max: 20

Metrics/MethodLength:
  Enabled: true
  Max: 15

ほぼほぼ、以下のサイトのかたの設定で作ってしまいました。(ありがとうございます。)

【Rails】GithubとCircleCIを連携してcommit時にrspecとrubocopを動かす

specファイルはブロックが長くなりがちなので、 Metrics/BlockLengthでひっかりまくってしまいます。 そのため、

Exclude:
    - 'spec/**/*'

でspecファイル諸々を対象から除外してしまっています。

CircleCI

あとは普通に実行するには bundle exec rubocopとすれば、 先ほど作成した.rubocop.ymlの設定に基づいて、 ファイルを自動でチェックしてくれます。

bundle exec rubocop -aでできる範囲で自動整形してくれるのが嬉しい…)

CircleCIも普通に実行するだけです。 私は、テストとrubocopのチェックを同じタイミングでするようにしてしまいました。

version: 2
jobs:
  build:
    machine:
      image: circleci/classic:edge
    steps:
      - checkout
      - run:
          name: Decode dotenv file
          command: echo ${ENV} | base64 --decode > /home/circleci/project/.env
      - restore_cache:
          keys:
            - gem-cache-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
            - gem-cache-{{ arch }}-{{ .Branch }}
            - gem-cache
      - run:
          name: docker-compose build
          command: docker-compose build
      - run:
          name: bundle install
          command: docker-compose run web bundle install --jobs=4 --retry=3
      - save_cache:
          key: gem-cache-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
          paths:
            - ~/.bundle
      - run:
          name: docker-compose up
          command: docker-compose up -d
      - run:
          name: install dockerize
          command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
          environment:
            DOCKERIZE_VERSION: v0.3.0
      - run:
          name: Wait for DB
          command: dockerize -wait tcp://localhost:5432 -timeout 1m
      - run:
          name: before_test: setup db
          command: docker-compose run web bin/rails db:create db:migrate
      - run:
          name: rubocop
          command: docker-compose run web bundle exec rubocop
      - run:
          name: rspec
          command: docker-compose run web bin/rspec
      - run:
          name: docker-compose down
          command: docker-compose down

今日は以上です。 (にしてもいつもこれ、キャッシュ効いてる気がしない)

OmniAuthでTwitterのプロフ画像を取得する

OmniAuthでTwitterのログインを実装する記事なんかはたくさんあるのですが、 Twitterアバター画像をOmniAuth経由で取得する記事はあまりなかったので書いていきたいと思います。

前提

前提として、OmniAuthを通じてログインができる状態を作っておきます。 私は以下の記事を参考に実装しました。

画像アップロードにはActive Storageを使用しています

実装

ログイン機能ができたら、早速画像の取得も実装していきます。

# app/controllers/users/omniauth_callbacks_controller.rb

  def twitter
    callback_from :twitter
  end

  def callback_from(provider)
    provider = provider.to_s
    @user = User.find_for_oauth(request.env['omniauth.auth'])

    if @user.persisted?
      flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider.capitalize
      sign_in_and_redirect @user, event: :authentication
      session[:user_id] = @user.id

    else
      session["devise.#{provider}_data"] = request.env['omniauth.auth']
      redirect_to new_user_registration_url
    end
  end


# app/models/user.rb

class << self
    def find_for_oauth(auth)
      provider = auth[:provider]
      uid = auth[:uid]
      user_name = auth[:info][:name]
      image_url = auth[:info][:image]
      uri = URI.parse(image_url) # パースする必要がある
      image = uri.open
      email = User.dummy_email(auth)
      password = Devise.friendly_token[0, 20]

      find_or_create_by(provider: provider, uid: uid) do |user|
        user.name = user_name
        user.email = email
        user.password = password
        user.avatar.attach(io: image, filename: "#{user.name}_profile.png")
      end
    end
  end

# app/config/initializers/devise.rb

Devise.setup do |config|
  config.omniauth :twitter, ENV['TWITTER_KEY'], ENV['TWITTER_SECRET'], :image_size => 'original'

  • TwitterAPIからの情報を取得(ここで画像のURLも取得)
  • URLをパースしてimageに挿入
  • 一意の値になるproviderとuidの組み合わせでユーザーがいるか確認し、いなければユーザーを作る(この中で画像をアバターとしてアタッチする)

というような流れになっています。

ポイント

ポイントは

  • 画像URL等を直で叩く場合は、parseしなければならない
  • Twitterのデフォルトの画像サイズではかなり小さいため、画像サイズを設定しなければならない

の2点です。

画像のパース

image_url = auth[:info][:image]
uri = URI.parse(image_url) # パースする必要がある
image = uri.open

の部分を

image_url = auth[:info][:image]
image = open(image_url)

としてしまうと 下記のように、コマンドラインで直接コマンドを叩かれてしまう可能性があります。 open('|/bin/sleep 20;').read #20秒停止するコマンド

Twitterの画像のサイズの設定

image_urlで取得できるurlにある画像は_normalというサフィックス(?)がついていてかなり小さいです。 (000000000000000000_normal.jpgのような感じ)

そのため、オリジナル(設定された時の大きさ)で取得するには app/config/initializers/devise.rbのなかで、

Devise.setup do |config|
  config.omniauth :twitter, ENV['TWITTER_KEY'], ENV['TWITTER_SECRET'], :image_size => 'original'

とする必要があります。

これで、Twitterでログイン時にアイコンが取得できるようになりました。

参考

OmniAuthでのログインをテストする

OmniAuthとdeviseを使ってTwitterGoogleのログイン機能を実装しましたが、 ログインのテストができずにいたので、今回system specの実装をしてみました。

準備

テストモードを有効にする

まずOmniAuthのテストモードを有効にする為に、

OmniAuth.config.test_mode = true

rails_helper.rbに記載しました。

こうすることで、テスト時にOmniAuthと実際に通信することが無くなります。

モックを作成する

OmniAuth.config.mock_authを使うことで、テスト時にOmniAuthでログインしようとすると ここで作られたモックを返すことができます。

# spec/support/omniauth_helpers.rb

module OmniAuthHelpers
  def set_omniauth(service = :twitter)
    OmniAuth.config.test_mode = true

    OmniAuth.config.mock_auth[service] = OmniAuth::AuthHash.new({
      provider: service.to_s,
      uid: '1234',
      info: {
        name: 'mockuser',
        image: "https://test.com/test.png"
      }
      })
  end
end

モックを実装したヘルパーを呼び出す

このモジュールをrails_helperincludeすることで、 先ほどのモックがスペックの中で実際に使えるようになります。

# spec/rails_helper.rb

OmniAuth.config.test_mode = true
  config.include OmniAuthHelpers

テストの実装

あとは実際のテストの実装になります。

# spec/system/omniauth_users_spec.rb

require 'rails_helper'

RSpec.describe "Users through OmniAuth", type: :system do

  describe "OmniAuthのログイン" do
    context "twitterでのログイン" do

      before do
        OmniAuth.config.mock_auth[:twitter] = nil
        Rails.application.env_config['omniauth.auth'] = set_omniauth
        visit root_path
      end

      it "ログインをするとユーザー数が増える", js: true do
        expect {
          click_link 'Twitterでログイン'
        }.to change(User, :count).by(1)
        expect(page).to have_content 'プロフィール設定'
        expect(page).to have_content 'Twitter アカウントによる認証に成功しました'
      end
    end

    context "googleでのログイン" do

      before do
        OmniAuth.config.mock_auth[:google] = nil
        Rails.application.env_config['omniauth.auth'] = set_omniauth :google
        visit root_path
      end

      it "ログインをするとユーザー数が増える", js: true do
        expect {
          click_link 'Googleでログイン'
        }.to change(User, :count).by(1)
        expect(page).to have_content 'プロフィール設定'
        expect(page).to have_content 'Google アカウントによる認証に成功しました'
      end
    end
  end
end

今回は以上です。

参考

Active Storageの簡単なバリデーションの実装とテスト

今日はActive Storageにバリデーションを設定したので、 その内容をまとめておきます。

背景

Active Storageにはデフォルトのバリデーションというのがないようです。 (そのせいで、みんなCarrierWaveで実装しているのかな?)

Rails5.2から入る新機能ActiveStorageを使うべきか?

うーん、なんか移行した方が良さそうなのかな。。。

とはいえ、せっかくS3、clodfrontの設定までしたばかりなので、 ひとまずは簡単なバリデーションを実装しておこうと思います。

実装

今回はこちらの記事を参考に(ほぼまるパクリですみません)で実装しました。

Active Storage移行記:バリデーション編

validate :validate_avatar

  def validate_avatar
    return unless avatar.attached? # ファイルがアタッチされていない場合は何もしない
    if avatar.blob.byte_size > 10.megabytes
      avatar.purge # アタッチされたファイルの削除
      errors.add(:avatar, 'ファイルのサイズが大きすぎます')
    elsif !image?
      avatar.purge # アタッチされたファイルの削除
      errors.add(:avatar, 'ファイルが対応している画像データではありません')
    end
  end

  private

  def image?
    %w[image/jpg image/jpeg image/gif image/png].include?(avatar.blob.content_type)
  end

purgeというのがActiveStorageのAPIでアタッチされたファイルの削除を行います。

これとは別にdetachという実体ファイルは残したまま、関連を削除するといったものもあるようです。 (attachmentで関連を、blobで実体を管理しているというようなイメージかな?)

これで無事実装できました。 ただこれだけだとただの丸パクリなので、テストも実装しておきます。

テスト

単体テストを実装していきます。

始める前にファクトリで添付ファイルを扱えるように設定します。

# spec/rails_helper.rb

FactoryBot::SyntaxRunner.class_eval do
  include ActionDispatch::TestProcess
end

# spec/factories/users.rb

FactoryBot.define do
...

  trait :with_avatar do
      avatar { fixture_file_upload(Rails.root.join('spec', 'support', 'assets', 'test.png'))
      }
    end

...
end

これでファクトリでActiveStorageの画像をテストすることができるようになりました。

この他にも

  • 10MBを超える画像ファイルを添付したトレイト
  • jpeg, jpg, png, gif 以外のデータを添付したトレイト

を用意しました。

あとはテストを書いていきます。

# spec/models/user_spec.rb

require 'rails_helper'

RSpec.describe User, type: :model do
  let(:user) { FactoryBot.create(:user) }

  let(:user_attached_image) { FactoryBot.create(:user, :with_avatar) }

  let(:user_too_large_avatar) {
    FactoryBot.build(:user, :with_too_large_avatar)
  }

  let(:user_attached_not_image) { FactoryBot.build(:user, :with_not_image) }

  describe "avatarのバリデーション", :focus do
    context "ファイルサイズが10MBを越える時" do
      it "ファイルサイズが大きすぎますというエラーメッセージが返る" do
        user_too_large_avatar.valid?
        expect(user_too_large_avatar.errors[:avatar]).to include 'ファイルのサイズが大きすぎます'
      end
    end

    context "ファイルの種類がjpeg, jpg, png, gif以外の時" do
      it "ファイルが対応している画像データではありませんというエラーメッセージが返る" do
        user_attached_not_image.valid?
        expect(user_attached_not_image.errors[:avatar]).to include 'ファイルが対応している画像データではありません'
      end
    end
  end
end

これで(否定にする検証も込みで)テストがグリーンになったので、実装完了とします。

参考

CircleCIで.envファイルを使用する

CircleCIを始める時に

環境変数全部一つずつ登録するのめんどくさ…」 ってなったので、調べてみた。

手順

コマンドラインから自分の.envファイルと同じディレクトリで

base64 .env

とすれば、.envファイルをbase64エンコードしたものがechoされます。

base64って?」となり、調べてみると、

Base64は、データを64種類の印字可能な英数字のみを用いて、 それ以外の文字を扱うことの出来ない通信環境にてマルチバイト文字やバイナリデータを扱うためのエンコード方式である。 (Wikipedia参照)

なるほど。これで改行なんかが入った.envファイルを一行にまとめて、CircleCIの環境変数に入れればいいわけですね。

これは公式サイトを見ればできます。

circleci.com

config.yml上で環境変数を直接書き込むような方法もあるようですが、私の場合APIのKEY等が入っていたため、素直にGUIから登録しました。

  • config.yml内でbase64で書かれた元の.envをデコードして、CircleCIのコンテナ内の.envに書き込む

これはconfig.ymlで

run: 
  name: Decode dotenv file
  command: echo ${ENV} | base64 --decode > /your/root/directory/.env

といった感じにします。

(/your/root/directory/ のところは自分の環境に合わせてください。)

これで、docker-composeやアプリケーションから.envを参照できるようになりました。

参考