Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: BulletからRails標準のstrict loadingに乗り換えるMEMO📝

今までN+1の検知にBulletを使っていたのですが🔫

github.com

Rails 6.1から導入された新機能strict loadingでも同じようなことが実現できそうだったので、個人のWebサービスをBulletからstrict loadingに載せ替える手順をメモしておきます📝

strict loadingの概要は以前まとめているので、興味がある人はこちらからどうぞ

madogiwa0124.hatenablog.com

strict_loadingをグローバルに導入する

今回はBulletの代用としてアプリケーション全体でN+1の検知を有効にしたかったので、ApplicationRecoredのstrict_loading_by_defaultをtrueに設定し、ApplicationRecordを継承したModelではstrict loadingによるN+1の検知を有効にしました。

class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true
end

これでeager_loadingをせずに関連モデルを取得しようとした際にActiveRecord::StrictLoadingViolationErrorが発生するようになりました🔫👋

strict loadingを無効化する

ActiveRecordインスタンスにstrict_loadingを無効化するメソッドを追加

先程の手順でstrict loadingによるN+1の検知が可能になったのですが、以下のような作成したインスタンスをそのまま参照して関連するmodelのインスタンスを取得したいケース等でもstrict loadingによるN+1の検知によりActiveRecord::StrictLoadingViolationErrorが発生してしまいます。

  describe '#foo' do
    let(:foo) { create(:foo) }

    before { create(:bar, foo: foo) }

    # eager_loadingせずに参照するためActiveRecord::StrictLoadingViolationErrorが発生する。
    it { expect(foo.bars.length).to eq 1 }
  end

しかし、現在(2021/02/27)のstableの最新Rails 6.1.3ではActiveRecordインスタンスに対してstrict loadingを有効にするstrict_loading!メソッドは実装されていますが、無効にすることはできません😢

Foo.preload(:bars).find(foo)のようにeagar_loadingして取得し直すような対応も考えられますが、そもそも1回アクセスするだけだったら再取得する分、かえってSQLの発行回数が増えてしまう恐れがあるのと、既存のテストコードに与える影響が大きいので、N+1を許容したいケースもあるのかなと思います。

今回は以下のようなメソッドをApplicationRecordに追加してstrict loadingを無効化出来るようにしました。

class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true

  def strict_loading!(value = true)
    @strict_loading = value
  end
end

上記実装はrailsのmasterにマージされている実装をもってきたものです🚃

github.com

FactoryBotで作成したインスタンスはstrict_loadingを無効化する

先程紹介したメソッドを以下のように使ってあげるとActiveRecord::StrictLoadingViolationErrorを回避することができます。

describe '#foo' do
  let(:foo) { create(:foo) }

  before do 
    foo.strict_loading!(false)
    create(:bar, foo: foo)
  end

  it { expect(foo.bars.length).to eq 1 }
end

FactoryBotのglobalなtraitを定義して以下のようにするもの良さそうですね🤖

FactoryBot.define do
  trait :strict_loaded do
    after(:build) { |record| record.strict_loading!(false) }
  end
end

describe '#foo' do
  let(:foo) { create(:foo, :strict_loaded) }

  before do 
    create(:bar, foo: foo)
  end

  it { expect(foo.bars.length).to eq 1 }
end

または以下のような形でfactory内でtraitをデフォルトで呼び出すこともできそうです👀

FactoryBot.define do
  trait :strict_loaded do
    after(:build) { |record| record.strict_loading!(false) }
  end

  factory :foo do
    strict_loaded
  end
end

describe '#foo' do
  let(:foo) { create(:foo) }

  before do 
    create(:bar, foo: foo)
  end

  it { expect(foo.bars.length).to eq 1 }
end

console時にstrict loadingを無効化する

console時にも毎回strict_loading!(false)を実行するのは面倒なので、application.rb内で以下のように記述するとconsole時のみstrict loadingを無効化することができます。

# config/application.rb
module MyApp
  class Application < Rails::Application
    # rails consoleではstrict loadingを無効化
    console { ApplicationRecord.strict_loading_by_default = false }
  end
end

config.consoleの説明は以下を参照してください。

config.console: これを用いて、コンソールでrails consoleを実行する時に使われるクラスをカスタマイズできます。このメソッドはconsoleブロックで使うのが最適です。

Rails アプリケーションを設定する - Railsガイド

おわりに

単純にN+1を検知するだけだったらRails標準機能の範囲でお手軽に実現出来るのは便利ですね✨

追記:2021/04/05

N+1が発生したときだけraiseするn_plus_one_onlyオプションが追加される(まだリリース予定は未定っぽい)ようなのでこちらを利用すると、より実用的な感じになりそうですね✨

github.com

参考

github.com

stackoverflow.com

Ruby on Rails: ダイレクトルーティング(Direct routes)を使って外部サイトのURLをroutesで管理する

OAuthを利用した外部サイトへの認証時のリダイレクトやLPへの導線等、Webサービス内で外部サイトのURLを扱いたいときにRailsのダイレクトルーティング機能を使うとURLの管理をRoutesに統一できて便利だったのでMEMOしておきます📝

ダイレクトルーティング機能とは?

The direct method allows creation of custom URL helpers. https://guides.rubyonrails.org/5_1_release_notes.html#direct-resolved-routes

Rails 5.1で導入された、Railsのダイレクトルーティングは以下のような形でroutes内で外部サイトのURLをdirectの引数に名前、block引数にURL文字列を渡すことでfoo_urlのようなhelperを定義出来る機能です。

direct(:homepage) { "http://www.rubyonrails.org" }
homepage_url # => "http://www.rubyonrails.org"

以下のPRで導入されているので内部実装が気になる方はこちらから参照してください。

github.com

使い方

基本

普通に使う分には先程記載した以下の通りです。

direct(:homepage) { "http://www.rubyonrails.org" }
homepage_url # => "http://www.rubyonrails.org"

引数で任意のクエリ文字列を付与する

例えばhomepage_url(from: 'top')で実行したときにhttp://www.rubyonrails.org?from='top' のようなURLを生成したいとします。

通常のdirectを使用しないroutesだと以下のような形でかけますが、

foo_path(bar: 'baz')
#=> /foo?bar=baz

directを利用したRoutesの場合はblockが評価された結果がそのまま返却されるようなので、定義した文字列が帰ってしまいます。

direct(:homepage) { "http://www.rubyonrails.org" }
homepage_url(for: 'bar') # => "http://www.rubyonrails.org"

このようなケースでは以下のような形でblock引数を利用して引数で渡されたオプションをクエリ文字列に指定してあげると、

  direct(:homepage) { |options|
    uri = URI.parse("http://www.rubyonrails.org")
    uri.query = options.to_param unless options.empty?
    uri.to_s
  }

以下のようにいい感じの使い心地になります✨

Rails.application.routes.url_helpers.homepage_url(from: "top", to: "bar")
#=> "http://www.rubyonrails.org?from=top&to=bar"

おわりに

知らなかったですが、外部サイト含めてRoutesでまとめて管理出来るdirect便利ですね!!

参考

qiita.com

webpack4系から5系にアップデートした際の対応事項とかメモ

個人で開発しているサービスでフロントエンドのbuildにwebpackを利用しているのですが、4系から5系にアップデートしたので、そのあたりで対応したことをメモしておきます📝

利用していたwebpackのビルド構成

基本的には以下のようなライブラリを利用したBabel + TypeScript + Vueで、 mini-css-extract-plugincssを別ファイルで生成するようにして、webpack-assets-manifestでmanifestを吐き出すような構成です。

  "devDependencies": {
    "@babel/core": "^7",
    "@babel/plugin-transform-runtime": "^7.12.15",
    "@babel/preset-env": "^7.12.1",
    "babel-loader": "^8.1.0",
    "css-loader": "^5.0.0",
    "mini-css-extract-plugin": "^1.3.5",
    "prettier": "2.2.1",
    "rollbar-sourcemap-webpack-plugin": "^3.2.0",
    "sass": "^1.32.6",
    "sass-loader": "^10.1.1",
    "style-loader": "^2.0.0",
    "ts-loader": "^8.0.14",
    "typescript": "^4.1.3",
    "vue-loader": "^15.9.6",
    "vue-template-compiler": "^2.6.12",
    "webpack": "^4.46.0",
    "webpack-assets-manifest": "~4",
    "webpack-cli": "^4.5.0",
    "webpack-merge": "^5.7.3"
  }

webpackアップデート時の対応事項メモ

modeを明示的に指定するようにした

build時に以下の警告が発生するようになり、

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value.
Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

test用のbuildでmodeが指定できていなかったので、以下の通り明示的に指定するようにしました。

process.env.NODE_ENV = "test";

const { merge } = require("webpack-merge");
const common = require("../../webpack.common.js");

module.exports = merge(common, {
  mode: 'production'
});

デフォルトでinstallされなくなったNode.js系のpolyfilをinstallした

webpack5からNode.js関連のpolyfillがinstallされなくなり、以下のようなエラーが発生しました。

BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
       - add a fallback 'resolve.fallback: { "querystring": require.resolve("querystring-es3") }'
       - install 'querystring-es3'
If you don't want to include a polyfill, you can use an empty module like this:
       resolve.fallback: { "querystring": false }
resolve 'querystring'

私の環境ではquerystring,processといったNode.js由来のmoduleの解決ができなくなっていたので、 以下の通り"querystring-es3",processをinstallして

yarn add querystring-es3 process

もともとのquerystringprocessで参照出来るようにwebpack.config.jsaliasを定義しました。

  resolve: {
    alias: {
      querystring: "querystring-es3",
      process: "process/browser",
    },
  },

そして利用している箇所で適切にimportするようにしてあげて解決しました✨

import process from "process";
import { parse } from "querystring";

[hash]ではなく[contenthash]を利用するようにした

build時に以下の警告が出ていて、

[DEP_webpack_TEMPLATE_PATH_PLUGIN_REPLACE_PATH_VARIABLES_HASH] DeprecationWarning: [hash] is now [fullhash] (also consider using [chunkhash] or [contenthash], see documentation for details)

公式のドキュメントを見ると以下の記述があり、

When using [hash] placeholder in webpack configuration, consider changing it to [contenthash]. It is not the same, but proven to be more effective. https://webpack.js.org/migrate/5/#clean-up-configuration

[hash]ではなく[contenthash]が推奨されているようだったので以下の通り、[contenthash]を利用するように修正しました。

  plugins: [
    new MiniCssExtractPlugin({ filename: "[name]-[contenthash].css" }),

[contenthash]はassetの内容に基づいてhashが計算されるようです👀

The [contenthash] substitution will add a unique hash based on the content of an asset. When the asset's content changes, [contenthash] will change as well. https://webpack.js.org/guides/caching/

[fullhash]はbuild時の完全なhashのようなので、[contenthash]の方がassetの内容が変化していなければ、 取得URLが変わらずユーザーのブラウザキャッシュが効くので良いとかそういう話なんですかね?🤔

その他の対応事項

webpack-assets-manifestのデフォルトのファイルパスが変わる

webpack-assets-manifestのバージョンも合わせて5系に上げたのですが、5系からデフォルトのファイル名がmanifest.jsonから、 assets-manifest.jsonに変更になっているようなので、それに合わせてmanifestを参照している箇所をassets-manifest.jsonを参照するにように修正しました。

New in version 5 ⚠️ Updated default value of the output option to be assets-manifest.json. This is to prevent confusion when working with Web app manifests or WebExtension manifests. https://github.com/webdeveric/webpack-assets-manifest/commit/ea293083beda4ddd331246c5f9b76af6d8739f08

そのままmanifest.jsonで読み込みたい場合にはbuild時のオプションでも指定出来るので、そちらを編集するでも良さそうです。

output Type: string Default: assets-manifest.json This is where to save the manifest file relative to your webpack output.path.

おわりに

メジャーバージョンアップということもあり意外と変更点があるなという印象だったのですが、 公式がドキュメントも用意してくれていて非常に助かりました🙏

webpack.js.org

個人のWebサービスをRuby 3.0アップデートしたので対応したこととかMEMO

2020/12/25にRuby 3.0.0がリリースされました🎄🎅🎁✨

www.ruby-lang.org

Ruby on Rails(6.1.1)製の個人のWebサービスを今更ながらRuby 3.0にアップデートしました。

update後に一部CI等が落ちて対応した点があったので対応したことをMEMOしておきます📝

対応したこと

非推奨だったキーワード引数が動かなくなったので**で展開して渡すように

キーワード引数が通常の引数から分離されました。 原則として、2.7 で警告の出ていたコードは動かなくなります。詳細は別ドキュメントを参照してください。

# before
@feeds = Feed.preload(:last_entry, :tags).search(search_params).recent

# after
@feeds = Feed.preload(:last_entry, :tags).search(**search_params).recent

2.7に上げたときの対応漏れがあり、3.0.0で動かなくなるキーワード引数の挙動を使った実装が残っていたので対応しました。

対応方法はRubyの公式のドキュメントが出ていて、

www.ruby-lang.org

Here is the most typical case. You can use double splat operator (**) to pass keywords instead of a Hash.

今回は上記の通り典型的なケースだったので**を付与し展開して渡すようにしました。

標準添付ライブラリからbundled gemに変更になったのでGemfileに追加

Rssを読み込むような機能でRubyの標準添付ライブラリだったrssを利用していたのですが、

docs.ruby-lang.org

以下のライブラリが新たに bundled gems になりました。Bundler から利用する場合は Gemfile に明示的に指定してください。
rexml
rss

上記の通り、bundled gemに変更されGemfileに明示的に指定する必要があり、Gemfileに追記する対応をしました。

# Gemfile
gem 'rss' # add

また以下はbundled gemにならず標準添付ライブラリから削除されているので、同様にGemfileに追記したinstallするような対応が必要そうです。

以下のライブラリは標準添付ライブラリから削除されました。3.0 以降で使いたい場合は rubygems から利用してください。
sdbm
webrick
net-telnet
xmlrpc

ちなみにbundled gemとかdefault gemとかの話は以下がわかりやすかったです💎

blog.n-z.jp

おわりに

Rubyのメジャーアップデートということでしたが、個人の規模が小さいアプリケーションということもあり、そこまでハマるようなこともなくバージョンが挙げられました🙏✨

Ruby 3.0はrbsを始めとした静的型付け系の機能が入っていたり、パフォーマンス向上 etc...様々な改善がされているなか、利用者がスムーズにアップデートできるというのは非常にありがたいですね🙇‍♂️

JavaScript: class内でsetIntervalしたときにthisをclassのinstanceにするMEMO

setIntervalsetTimeout系の処理をclass内で呼び出したときに、thisの値がおかしくなり、対応法でハマったのでメモしておきます📝

結論

setInterval等のロジックで呼び出す関数にbind(this)をつけて、thisを明示する

bind() メソッドは、呼び出された際に this キーワードに指定された値が設定される新しい関数を生成します。

developer.mozilla.org

コード例

うまく動かないコードの例

以下のようなタイマーを管理するようなclassがあるとします。 start()実行時にsetIntervalで内部で保持する値を1秒おきにカウントアップします。

class Timer {
  constructor() {
    this.value = 0
    this.timerId = 0
  }

  countUp() {
    this.value += 1
    console.log("value", this.value)
  }

  start() {
    this.timerId = setInterval(this.countUp, 1000)
  }

  stop() {
    clearInterval(this.timerId)
  }
}
let timer = new Timer();
timer.start()

しかしこのコードはうまく動かず結果はvalue NaNが毎秒表示されるような結果になってします。

これはsetIntervalの実行コンテキストが以下の通り、window (または global) オブジェクトに設定され、インスタンスの値が読み出せなくなってしまうからです。。。

setInterval() によって実行されるコードは、setInterval が呼び出された関数とは別の実行コンテキスト内で実行されます。その結果、呼び出された関数の this キーワードは window (または global) オブジェクトに設定されます。

WindowOrWorkerGlobalScope.setInterval() - Web API | MDN

bindを使ってthisを明示する

以下のようにsetInterval(this.countUp.bind(this), 1000)とするとbindによって、thisが明示的にclassのインスタンスとなるため、想定通りの挙動になります 👍

class Timer {
  constructor() {
    this.value = 0
    this.timerId = 0
  }

  countUp() {
    this.value += 1
    console.log("value", this.value)
  }

  start() {
    // bindでthisを明示的にclassのインスタンスになるようにする
    this.timerId = setInterval(this.countUp.bind(this), 1000)
  }

  stop() {
    clearInterval(this.timerId)
  }
}

let timer = new Timer();

// `value 1`とちゃんと取れる
timer.start()

参考

foreignkey.toyao.net

Deviseで内部でセキュアなパスワードを設定してアカウントを作成するMEMO

管理画面のアカウント等、管理者が生成して利用者にアカウントを配布するような運用はあると思うのですが、管理者がアカウントを作成する際にパスワードまで指定するようにしてしまうと、管理者によるなりすましの懸念があります。。。

そのためアカウント作成時には利用者のメールアドレスのみを指定して、パスワードは内部で生成した予測不能な文字列を設定したほうがセキュアです。

やり方は以下のような形で、Devise.friendly_tokenが渡された引数の文字数で予測不能なセキュアな文字列を生成してくれます。  

generated_password = Devise.friendly_token(12)
admin = Admin.create!(:email => email, :password => generated_password)

内部的にはSecureRandom を使ってURLSafeな文字列を生成して、紛らわしい文字列は置換しているようですね👀

  def self.friendly_token(length = 20)
    # To calculate real characters, we must perform this operation.
    # See SecureRandom.urlsafe_base64
    rlength = (length * 3) / 4
    SecureRandom.urlsafe_base64(rlength).tr('lIO0', 'sxyz')
  end

github.com

Deviseは便利なメソッドがありますね✨

参考

How To: Automatically generate password for users (simpler registration) · heartcombo/devise Wiki · GitHub

Ruby on Rails: 6.1.0の新機能strict_loadingを使ってN+1を防ぐMEMO

Rails 6.1.0で導入されたstrict_loadingを使うと手軽にN+1のチェックが出来て便利そうなのでMEMO📝

基本的には以下のような形でActiveRecord::Releationを作るメソッドチェイン内でstrict_loadingを呼び出すようにすると、

  def index
    @feeds = Feed.strict_loading
                 .search(params.dig(:query, :keyword)).recent
                 .pager(page: params[:page], per: PER_PAGE)
    response = @feeds.map { |feed| ::Api::Feed.new(feed: feed).attributes }
    render json: response
  end

eager_loadingされてないと以下のようなエラーが発生します🚨

ActiveRecord::StrictLoadingViolationError (`Entry` called on `Feed` is marked for strict_loading and cannot be lazily loaded.):
  
app/models/api/feed.rb:5:in `initialize'
app/controllers/api/feeds_controller.rb:8:in `new'
app/controllers/api/feeds_controller.rb:8:in `block in index'
app/controllers/api/feeds_controller.rb:8:in `map'
app/controllers/api/feeds_controller.rb:8:in `index'

以下のようにpreloadを入れるとエラーが発生しなくなります✨

  def index
    @feeds = Feed.strict_loading.preload(:last_entry, :tags)
                 .search(params.dig(:query, :keyword)).recent
                 .pager(page: params[:page], per: PER_PAGE)
    response = @feeds.map { |feed| ::Api::Feed.new(feed: feed).attributes }
    render json: response
  end

Model単位でデフォルトを指定することもできるので、ApplicationRecordに設定を入れておいて、N+1を許容するときだけFeed.strict_loading(false)みたいな形で明示的にわかるようにするといい感じな気もしました👍

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  self.strict_loading_by_default = true
end

今まではbulletでこういうのを検知していましたが、Rails標準でN+1を検知できる仕組みが出来たのは便利ですね✨

参考

qiita.com

Rails6.1で新しく入る機能について - Speaker Deck

github.com