Madogiwa Blog

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

Ruby: RSpec::Retryで失敗したテストをRetryさせるMEMO

個人で作成しているライブラリでDBのDocker Imageのbuild直後だけ、たまにコネクションが作れなくてエラーになる事象が発生し以下のRspec::Retryを導入してリトライして解消したので導入方法をメモしておきます📝

Rspec::Retryとは?

RSpec::Retry adds a :retry option for intermittently failing rspec examples. If an example has the :retry option, rspec will retry the example the specified number of times until the example succeeds.

上記の通り、RSpecでテストが落ちた際にリトライ回数や待ち時間等、いろんなオプションを付与してリトライすることが出来るgemです💎

github.com

Rspec::Retryの導入方法

Gemfileに以下を追加し、bundle installを実行します。

gem 'rspec-retry', group: :test 

その後、spec_helper.rbに任意のリトライ設定を行います⚙

require 'rspec/retry'

RSpec.configure do |config|
  # for RSpec::Retry
  config.verbose_retry = true # リトライ状況を表示
  config.display_try_failure_messages = true # リトライを実行した理由(例外)を表示
  config.around { |ex| ex.run_with_retry(retry: 3, retry_wait: 1) } # 最大3回リトライ(実行時に1秒待つ)
end

上記の設定は、私が個人のライブラリで設定したものです。設定値の詳細は以下をご確認ください。

github.com

おわりに

本来だったら、そもそも落ちないように出来ると良いのですがCIの実行時間が長時間化してくると全リトライしていると辛くなってきそうなので、こういうGemを入れて該当のテストだけリトライするようにするというのもありかもしれないですね。 ※あとRSpec::Retryはあまりアクティブじゃなさそうだったので、もっと他のよく使われているGemがあるかもですが🤔

参考

hai3.net

.railsrcとRailsテンプレートを使ってRails new関連の作業をコードで管理する

最近、割とRails newする機会があり.railsrcRailsテンプレートを使うといい感じにこのあたりの作業をコード管理して再現性と効率的に行えそうだったのでメモしておきます📝

.railsrcとは

~/.railsrcRails newで指定するオプションを書いておくと自動的にオプションを設定してくれます。

以下のように記載したファイルを配置するとテストライブラリとbundle installがスキップされます。

--skip-test-unit
--skip-bundle

また環境変数XDG_CONFIG_HOMEディレクトリを指定すると任意のディレクトリ配下のrails/railsrcを読み込むことも出来ます。

※こちらは.railsrcではなくrailsrcなので注意⚠   github.com

Railsテンプレートとは

Gemfileへの追加やapplication.rbのconfigの調整等をrubyファイルに記述することが出来る機能です。rails new -m template.rbで指定することでtemplate.rbに記述した任意の処理をrails new後に実行出来ます。

railsguides.jp

以下のような感じで色々行うことが出来ます。

# Gemの追加
gem 'lograge'
gem_group :development, :test do
  gem 'rubocop', require: false
end

# initializerの追加
initializer 'lograge.rb', <<~CODE
  Rails.application.configure do
    config.lograge.enabled = true
  end
CODE

# application.rbに設定を追加
environment <<~TEXT
  # For TZ
  config.time_zone = 'Tokyo'
  # config.active_record.default_timezone = :local
TEXT

railsrcとRailsテンプレートを使ってみる

というわけで.railsrcを使ってRails new時のオプションを、 Railsテンプレートを使ってRails new後に実施するGemfileapplication.rb等の調整、各種ファイル(.editorconfig.dockerignore等が出来るように自分も作ってみました。

.railsrcは以下のような形で、

--skip-bundle
--skip-action-mailbox
--skip-action-text
--skip-active-storage
--skip-action-cable
--skip-test-unit
--skip-jbuilder
--skip-spring
--skip-turbolinks
--skip-sprockets
--skip-javascript
--database=postgresql

Railsテンプレートは以下のような感じです。

# frozen_string_literal: true

def source_paths
  [__dir__]
end

gem 'okcomputer'
gem 'lograge'
gem 'simpacker'

gem_group :development, :test do
  gem 'brakeman', require: false
  gem 'rspec-rails', require: false
  gem 'factory_bot_rails'
  gem 'rubocop', require: false
  gem 'rubocop-performance', require: false
  gem 'rubocop-rails', require: false
  gem 'rubocop-rspec', require: false
end

initializer 'ok_computer.rb', <<~CODE
  OkComputer::Registry.register 'ruby version', OkComputer::RubyVersionCheck.new
  # OkComputer::Registry.register 'version', OkComputer::AppVersionCheck.new(env: 'SOURCE_VERSION')
CODE

initializer 'lograge.rb', <<~CODE
  Rails.application.configure do
    config.lograge.enabled = true
    config.lograge.custom_options = ->(event) { { time: Time.current } }
  end
CODE

environment <<~TEXT
  # For TZ
  config.time_zone = 'Tokyo'
  # config.active_record.default_timezone = :local

  # For custom settings
  config.settings = config_for(:settings)

  # For generator
  config.generators do |g|
    g.assets false
    g.helper false
    g.test_framework :rspec,
      fixtures:         true,
      view_specs:       false,
      helper_specs:     false,
      routing_specs:    false,
      controller_specs: false,
      request_specs:    false
    g.fixture_replacement :factory_bot, dir: 'spec/factories'
    g.after_generate do |files|
      system('bundle exec rubocop --auto-correct-all ' + files.join(' '), exception: true)
    end
  end
TEXT

copy_file '.rubocop.yml', '.rubocop.yml'
copy_file '.editorconfig', '.editorconfig'
copy_file 'config/simpacker.yml', 'config/simpacker.yml'
copy_file 'config/settings.yml', 'config/settings.yml'

run "cp .gitignore .dockerignore"

リポジトリで管理するようにしてみたので、

github.com

以下のような感じで使うことも出来ます。XDG_CONFIG_HOMEを指定してリポジトリ内の.railsrcを参照してRails newを行うようにしています。

$ clone git@github.com:Madogiwa0124/my_rails_template.git
$ cd my_rails_template
$ bundle install
$ XDG_CONFIG_HOME=./ bundle exec rails new sample_app -m ./rails/template.rb

Using --skip-bundle --skip-action-mailbox --skip-action-text --skip-active-storage --skip-action-cable --skip-test-unit --skip-jbuilder --skip-spring --skip-turbolinks --skip-sprockets --skip-javascript --database=postgresql from my_rails_template/rails/railsrc
      create  
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
      create  .gitattributes
      create  Gemfile
         run  git init from "."
Initialized empty Git repository in my_rails_template/sample_app/.git/
      create  app
      create  app/assets/config/manifest.js
      create  app/assets/stylesheets/application.css
      create  app/channels/application_cable/channel.rb
      create  app/channels/application_cable/connection.rb
      create  app/controllers/application_controller.rb
      create  app/helpers/application_helper.rb
      create  app/javascript/channels/consumer.js
      create  app/javascript/channels/index.js
      create  app/javascript/packs/application.js
      create  app/jobs/application_job.rb
      create  app/mailers/application_mailer.rb
      create  app/models/application_record.rb
      create  app/views/layouts/application.html.erb
      create  app/views/layouts/mailer.html.erb
      create  app/views/layouts/mailer.text.erb
      create  app/assets/images
      create  app/assets/images/.keep
      create  app/controllers/concerns/.keep
      create  app/models/concerns/.keep
      create  bin
      create  bin/rails
      create  bin/rake
      create  bin/setup
      create  bin/spring
      create  bin/yarn
      remove  bin/spring
      remove  bin/yarn
      create  config
      create  config/routes.rb
      create  config/application.rb
      create  config/environment.rb
      create  config/puma.rb
      create  config/environments
      create  config/environments/development.rb
      create  config/environments/production.rb
      create  config/environments/test.rb
      create  config/initializers
      create  config/initializers/application_controller_renderer.rb
      create  config/initializers/assets.rb
      create  config/initializers/backtrace_silencers.rb
      create  config/initializers/content_security_policy.rb
      create  config/initializers/cookies_serializer.rb
      create  config/initializers/cors.rb
      create  config/initializers/filter_parameter_logging.rb
      create  config/initializers/inflections.rb
      create  config/initializers/mime_types.rb
      create  config/initializers/new_framework_defaults_6_1.rb
      create  config/initializers/permissions_policy.rb
      create  config/initializers/wrap_parameters.rb
      create  config/locales
      create  config/locales/en.yml
      create  config/master.key
      append  .gitignore
      create  config/boot.rb
      create  config/database.yml
      create  db
      create  db/seeds.rb
      create  lib
      create  lib/tasks
      create  lib/tasks/.keep
      create  lib/assets
      create  lib/assets/.keep
      create  log
      create  log/.keep
      create  public
      create  public/404.html
      create  public/422.html
      create  public/500.html
      create  public/apple-touch-icon-precomposed.png
      create  public/apple-touch-icon.png
      create  public/favicon.ico
      create  public/robots.txt
      create  tmp
      create  tmp/.keep
      create  tmp/pids
      create  tmp/pids/.keep
      create  tmp/cache
      create  tmp/cache/assets
      create  vendor
      create  vendor/.keep
      create  test/fixtures/files
      create  test/fixtures/files/.keep
      create  test/controllers
      create  test/controllers/.keep
      create  test/mailers
      create  test/mailers/.keep
      create  test/models
      create  test/models/.keep
      create  test/helpers
      create  test/helpers/.keep
      create  test/integration
      create  test/integration/.keep
      create  test/channels/application_cable/connection_test.rb
      create  test/test_helper.rb
      create  test/system
      create  test/system/.keep
      create  test/application_system_test_case.rb
      remove  app/javascript
      remove  config/initializers/assets.rb
      remove  app/javascript/channels
      remove  app/channels
      remove  test/channels
      remove  config/initializers/cors.rb
      remove  config/initializers/new_framework_defaults_6_1.rb
       apply  my_rails_template/rails/template.rb
     gemfile    okcomputer
     gemfile    lograge
     gemfile    simpacker
     gemfile    group :development, :test
        gsub    Gemfile
     gemfile    brakeman
     gemfile    rspec-rails
     gemfile    factory_bot_rails
     gemfile    rubocop
     gemfile    rubocop-performance
     gemfile    rubocop-rails
     gemfile    rubocop-rspec
        gsub    Gemfile
 initializer    ok_computer.rb
 initializer    lograge.rb
      create    .rubocop.yml
      create    .editorconfig
      create    config/simpacker.yml
      create    config/settings.yml
         run    cp .gitignore .dockerignore from "."

おわりに

.railsrcRailsテンプレートを使うと全部ではないですが、rails newするときの諸々の作業をコードで管理出来そうで良いですね。

参考

blog.kamipo.net

blog.willnet.in

Ruby: 対象の特徴を座標化し類似度を算出してレコメンド機能っぽいものを作ってみるMEMO

最近以下の本を読んで、なんかしらのデータの特徴をグラフにしてピタゴラスの定理を使って2点間の距離を算出すると、簡単なレコメンドシステムを作れることを勉強したので、実際にピュアなRubyのコードで作ってみました。自分の学びのためにも流れをメモしておきます✍

作るもの

今回は以下のようなNEWSサイトを想定し、類似ユーザーの「いいね」をもとに記事をレコメンドするような機能を作ってみようと思います。

  • ユーザーは登録時に年代、性別、好きなジャンルを入力する
  • ユーザーは記事を閲覧出来る
  • ユーザーは記事を閲覧を気にったら「いいね」出来る
  • ユーザーに対して記事をレコメンドする。条件は以下の通り
    • 対象ユーザーと類似度が高い上位2名のユーザーが「いいね」している 記事

実際のNewsとUserのモデルのコードは以下のようなものを想定してます。

class News
  GENRE = { sports: 1, entertainment: 2, politics: 3, medical: 4, incident: 5 }

  def initialize(id:, title:, genre:)
    @id = id
    @title = title
    @genre = genre
    @liked_user_ids = []
  end

  attr_reader :id, :title, :liked_user_ids

  def like(user_id:)
    @liked_user_ids << user_id
  end
end

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = favorite_genre
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre
end

ユーザーの特徴を座標に変換する

まずはユーザーの特徴を座標に変換します。

今回はユーザーが登録時に入力した年代、性別、好きなジャンルをもとに座標を算出するcoordinatesメソッドを定義します。 ※年代については10代、20代というような形で行うように10で割った値を設定しています。好きなジャンルは優先度を上げるために1.2掛けして重みを増しています。

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = favorite_genre
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre

  def coordinates
    [sex.to_i, (age / 10).to_i, (favorite_genre * 1.2)]
  end
end

これを使うことによって、以下のように各ユーザーの特徴を3次元の座標に変換することが出来ました。

users = [
  User.new(id: 1, name: "Tom", sex: 1, age: 20, favorite_genre: :sports),
  User.new(id: 2, name: "Jesica", sex: 2, age: 15, favorite_genre: :entertainment),
  User.new(id: 3, name: "Bob", sex: 1, age: 30, favorite_genre: :medical),
  User.new(id: 4, name: "Terry", sex: 1, age: 40, favorite_genre: :sports),
  User.new(id: 5, name: "Ami", sex: 2, age: 25, favorite_genre: :incident)
]

users.map { |user| { id: user.id, coordinates: user.coordinates } }
#=>
# [{:id=>1, :coordinates=>[1, 2, 1.2]},
# {:id=>2, :coordinates=>[2, 1, 2.4]},
# {:id=>3, :coordinates=>[1, 3, 4.8]},
# {:id=>4, :coordinates=>[1, 4, 1.2]},
# {:id=>5, :coordinates=>[2, 2, 6.0]}]

ユーザーの座標を元に類似度を算出する

この値をもとにピタゴラスの定理を用いてレコメンドしたいユーザーの座標から、それぞれのユーザーの座標への距離を算出することで、ユーザーの類似度を算出することが出来ます。

User側からはどういうロジックで距離を算出するかは気にしたくないので、2点間の距離を算出するために以下のClassを用意しました。 後に上位2名を取得することを考慮して、Comparableを使用して直接Distanceのオブジェクトを比較出来るようにしています。

require 'complex'

class Distance
  include Comparable

  def initialize(from:, to:)
    @from = from
    @to = to
  end

  attr_reader :from, :to

  def value
    @value ||= euclidean_distance(from: from, to: to)
  end

  def <=>(other)
    value - other.value
  end

  private

  # ピタゴラスの定理を用いて2点間の距離(ユークリッド距離)を算出
  def euclidean_distance(from:, to:)
    sum = from.each.with_index.inject(0) { | sum, (v, i) | sum += (v - to[i])**2 }
    Math.sqrt(sum)
  end
end

そして上記のClassを使ってユーザー同士の距離を測定出来るようにします。

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = News::GENRE[favorite_genre].to_i
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre

  def distance(to:)
    Distance.new(from: coordinates, to: to.coordinates)
  end

  def coordinates
    [sex.to_i, (age / 10).to_i, (favorite_genre * 1.2)]
  end
end

対象ユーザーからその他のユーザーの距離を測定してみた結果が以下の通りです。 ※今回はユーザー(d: 1, name: "Tom")をレコメンドを提示するユーザーとしました。

users = [
  User.new(id: 1, name: "Tom", sex: 1, age: 20, favorite_genre: :sports),
  User.new(id: 2, name: "Jesica", sex: 2, age: 15, favorite_genre: :entertainment),
  User.new(id: 3, name: "Bob", sex: 1, age: 30, favorite_genre: :medical),
  User.new(id: 4, name: "Terry", sex: 1, age: 40, favorite_genre: :sports),
  User.new(id: 5, name: "Ami", sex: 2, age: 25, favorite_genre: :incident)
]

users.map { |user| { id: user.id, coordinates: user.coordinates } }
#=>
# [{:id=>1, :coordinates=>[1, 2, 1.2]},
# {:id=>2, :coordinates=>[2, 1, 2.4]},
# {:id=>3, :coordinates=>[1, 3, 4.8]},
# {:id=>4, :coordinates=>[1, 4, 1.2]},
# {:id=>5, :coordinates=>[2, 2, 6.0]}]

target = users[0]
users.select { |user| user != target }.map { |user| { id: user.id, distance: target.distance(to: user).value } }
#=>
# [{:id=>2, :distance=>1.8547236990991407},
# {:id=>3,  :distance=>3.7363083384538807},
# {:id=>4,  :distance=>2.0},
# {:id=>5,  :distance=>4.903060268852505}]

この結果から対象のユーザーと類似度が高い上位2名のユーザーは以下であることが分かります。

  • ユーザー(id: 2, name: "Jesica")
  • ユーザー(id: 4, name: "Terry")

ピタゴラスの定理の詳細はこちら

ja.wikipedia.org

類似度の高いユーザーが「いいね」した記事を取得する

まず元データが無いとレコメンドすることも出来ないので、以下のような記事があり、それぞれ引数で渡したユーザーによって「いいね」されている状況だとします。

news = [
  News.new(id: 1, title: "念願の優勝!", genre: :sports),
  News.new(id: 2, title: "あの人がCMに?", genre: :entertainment),
  News.new(id: 3, title: "ついに逮捕!?", genre: :incident),
  News.new(id: 4, title: "期待の新薬誕生か?", genre: :medical),
  News.new(id: 5, title: "ついにあの条約が結ばれる", genre: :politics),
]

news[0].like(user_id: 1)
news[1].like(user_id: 2)
news[1].like(user_id: 3)
news[2].like(user_id: 1)
news[2].like(user_id: 5)
news[3].like(user_id: 4)

ここまでくればあとは単純で以下のような形で算出した類似度が高い上位2名を取得して、そのユーザーが「いいね」している記事を取得すればOKです🙆‍♂️

ids = distances.sort_by {|v| v[:distance] }.first(2).map { |v| v[:id] }
# => [2, 4]

recommends = news.select do |news|
  ids.any? { |id| news.liked_user_ids.include?(id) }
end
# =>
# [#<News:0x00007fe3339ab280
#  @genre=:entertainment,
#  @id=2,
#  @liked_user_ids=[2, 3],
#  @title="あの人がCMに?">,
# #<News:0x00007fe3339aaf38
#  @genre=:medical,
#  @id=4,
#  @liked_user_ids=[4],
#  @title="期待の新薬誕生か?">]

最終的なコード

最終的なコードを以下に記載しておきます。

require 'complex'

class News
  GENRE = { sports: 1, entertainment: 2, politics: 3, medical: 4, incident: 5 }

  def initialize(id:, title:, genre:)
    @id = id
    @title = title
    @genre = genre
    @liked_user_ids = []
  end

  attr_reader :id, :title, :liked_user_ids

  def like(user_id:)
    @liked_user_ids << user_id
  end
end

class Distance
  include Comparable

  def initialize(from:, to:)
    @from = from
    @to = to
  end

  attr_reader :from, :to

  def value
    @value ||= euclidean_distance(from: from, to: to)
  end

  def <=>(other)
    value - other.value
  end

  private

  def euclidean_distance(from:, to:)
    sum = from.each.with_index.inject(0) { | sum, (v, i) | sum += (v - to[i])**2 }
    Math.sqrt(sum)
  end
end

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = News::GENRE[favorite_genre].to_i
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre

  def distance(to:)
    Distance.new(from: coordinates, to: to.coordinates)
  end

  def coordinates
    [sex.to_i, (age / 10).to_i, (favorite_genre * 1.2)]
  end
end

# Seed

users = [
  User.new(id: 1, name: "Tom", sex: 1, age: 20, favorite_genre: :sports),
  User.new(id: 2, name: "Jesica", sex: 2, age: 15, favorite_genre: :entertainment),
  User.new(id: 3, name: "Bob", sex: 1, age: 30, favorite_genre: :medical),
  User.new(id: 4, name: "Terry", sex: 1, age: 40, favorite_genre: :sports),
  User.new(id: 5, name: "Ami", sex: 2, age: 25, favorite_genre: :incident)
]

news = [
  News.new(id: 1, title: "念願の優勝!", genre: :sports),
  News.new(id: 2, title: "あの人がCMに?", genre: :entertainment),
  News.new(id: 3, title: "ついに逮捕!?", genre: :incident),
  News.new(id: 4, title: "期待の新薬誕生か?", genre: :medical),
  News.new(id: 5, title: "ついにあの条約が結ばれる", genre: :politics),
]

news[0].like(user_id: 1)
news[1].like(user_id: 2)
news[1].like(user_id: 3)
news[2].like(user_id: 1)
news[2].like(user_id: 5)
news[3].like(user_id: 4)


# Reccomend
target = users[0]
other_users = users.select { |user| user != target }
distances = other_users.map { |user| { id: user.id, distance: target.distance(to: user) } }
source_user_ids = distances.sort_by {|v| v[:distance] }.first(2).map { |v| v[:id] }
recommends = news.select { |news| ids.any? { |id| news.liked_user_ids.include?(id) } }
pp recommends

おわりに

対象の特徴をグラフの座標に変換して距離で類似度を測定するというのは色々なところで使えそうですね👀

プログラミングと数学、現在の自分の業務ではそこまで利用することも多くなかったのですが、こういうレコメンドとか計算量を気にするような実装等を行うときは、数学的な知識があると良いですね・・・!

参考

qiita.com

www.xmisao.com

rubytips86.hatenablog.com

Ruby on Rails: 今後標準で使えるようになるかもしれないViewComponentを試してみる

ViewComponentとは?

3rd Party製のコンポーネントフレームワークのためにレンダー済みのHTMLを返すrender_inが定義されているObjectをActionView:: Helpers::RenderingHelper#renderに渡せるようになりました。

github.com

上記対応は当初ViewComponentの前準備としてあげられたPRだったので、Rails 6.1で標準になるのでは?と期待があったのですが、

techracho.bpsinc.jp

特に入らず現状はGitHub社がViewComponentというgemとして公開しています。

github.com

ViewComponentの使い方

Gemfileに以下を追加して、bundle install を実行します。

gem "view_component", require: "view_component/engine"

以下のコマンドでViewComponentで利用するファイルを作成出来ます。

$ bin/rails generate component Example title

主な生成ファイルと主な用途を以下に記載します。

  • app/components/example_component.rb : ロジック、引数をもとにインスタンス変数を生成
  • app/components/example_component.html.erb : テンプレート、ロジックで生成したインスタンス変数を用いた描画部分を担当

サンプル

app/components/example_component.rb

class ExampleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end
end

app/components/example_component.html.erb

<span title="<%= @title %>"><%= @title %></span>

またcallメソッドにインラインでテンプレートを定義することも出来ます。

class ExampleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end

  def call
    ERB.new('<span title="<%= @title %>"><%= @title %></span>').result(binding)
  end
end

※inlineとテンプレートファイルを併用すると、ViewComponent::TemplateErrorになるので併用は出来ません。

ViewComponentをテストする

公式ドキュメントはこちら

viewcomponent.org

ViewComponentはbin/rails generate component時にconfig.generators .test_frameworkを参照し、適切なテストファイルを生成してくれます。

今回はRSpecでテストを実行するケースで実行方法をメモしておきます。

以下に記載されているとおりですが、rails_helperに以下を追記するだけでテストの準備はOKです🙆‍♂️

require "view_component/test_helpers"

RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :component
end

あとは以下のような形でレンダリング結果をテスト出来ます✨

require "rails_helper"

RSpec.describe ExampleComponent, type: :component do
  it "renders component" do
    result = render_inline(ExampleComponent.new(title: "my title"))
    expect(result.to_html).to eq <<~HTML
        <span title="my title">my title</span>
    HTML
  end
end

おまけ:VueのSFCみたいなのは実現できるか?

個人的には、結論から言うと一部は出来そうですが、厳しそうな感じかなと。。。

HTML5.2からbodyタグの中にstyleタグが記載できるようになったので、VueComponent内で指定したスラグをCSSセレクタとテンプレートのTOPレベルの要素のclassに指定すれば擬似的にScoped CSSみたいなのが実現出来るかなぁとか妄想したのですが、

vanillaice000.blog.fc2.com

実際にコードに起こすと以下のような感じになったのですが、

  • inlineでCSSを書いてしまうと、stylelint、autoprefixer等の既存CSS周りのツール群が使えない
  • エディターの自動補完が機能しない

と結構厳しい感じにですね、、、webpackにvue-component-loaderみたいなのを作ってcssをbuild時に生成するみたいなことをやると、autoprefixerを通すとかはいけたりするんですかね?

# frozen_string_literal: true

class ExampleComponent < ViewComponent::Base
  def initialize(title:, slug:)
    @title = title
    @slug = slug.presence || build_slug
  end

  def call
  ERB.new(style+template).result
  end

  private

  attr_reader :title, :slug

  def template
    <<~ERB
      <div class="<%= @slug %>">
        <h1><%= @title %></h1>
      </div>
      ERB
  end

  def style
    <<~ERB
      <style>
      .<%= @slug %> h1 {
          font-size: 50px;
        }
      </style>
      ERB
  end

  def build_slug
    SecureRandom.urlsafe_base64(8)
  end
end

おわりに

ViewComponent、さすがのGitHub社製ということもあり、configの値をちゃんと見れくれたりとかゆいところに手が届いてる感じが良いですね✨

公式ドキュメントには、他にもいろいろな機能が記載されているのでぜひ気になる方は見てみてください📚

viewcomponent.org

OK Computerのヘルスチェックをブラウザで見やすくするGemを作りました💎

OK Computerのヘルスチェックをブラウザで見やすくするGemを作ったので、使い方とかをメモしておきます📝

作ったGem

github.com

モチベーション

github.com

OK Computerはヘルスチェックをしてくれる便利なGemなのですが、Viewがテキスト or JSONのみの用意となっており、ブラウザで確認したときの表示が以下のようにちょっと成功しているか等がパット見でわかりにくかったので、

f:id:madogiwa0124:20210626155506p:plain

HTML用のViewを作成して以下のような形で見やすくしたかったのがモチベーションです👩‍🎨

f:id:madogiwa0124:20210626155533p:plain

f:id:madogiwa0124:20210626163741p:plain

使い方

GitHubリポジトリを指定してbundle installをして、

git_source(:github) { |repo| "https://github.com/#{repo}.git" }

gem 'okcomputer_html_view', github: 'Madogiwa0124/okcomputer_html_view'

以下のようにconfig/application.rbload OkcomputerHtmlView.load_pathを実行するだけです。

module YourApplication
  class Application < Rails::Application
    config.to_prepare do
      load OkcomputerHtmlView.load_path
    end
  end
end

OK Computerをmountしたパスにアクセスすると以下のような感じで表示されるはずです!

f:id:madogiwa0124:20210626155533p:plain

仕組み

OK ComputerのViewはRails engineで作られているので、以下のRails Guideを参考に

railsguides.jp

Controllerをオーバーライドしてhtml用のrespoonseを定義して、

OkComputer::OkComputerController.class_eval do
  self.view_paths = "#{::OkcomputerHtmlView.gem_dir}/app/views/ok_computer"

  def respond(data, status)
    respond_to do |format|
      format.text { render plain: data, status: status, content_type: "text/plain" }
      format.html { render "/#{action_name}", locals: {data: data, status: status}, content_type: "text/html" }
      format.json { render json: data, status: status }
    end
  end
end

あとはself.view_pathsで指定されたディレクトリにhtml.erbを用意してあげた感じです。

またGemのinstallパスは固定値で書いてしまうと参照できなくなってしまうため、Gem::Specification.find_by_name("okcomputer_html_view").gem_dirを使用してinstlalパスを取得して、それをGem内のコードで使用するようにしています。

# frozen_string_literal: true

require_relative "okcomputer_html_view/version"

module OkcomputerHtmlView
  def self.load_path
    "#{gem_dir}/app/controllers/ok_computer/ok_computer_controller.rb"
  end

  def self.gem_dir
    Gem::Specification.find_by_name("okcomputer_html_view").gem_dir
  end
end

おわりに

Rails engineまわり、あまり触ったことなかったのですが意外と拡張もしやすくて良いですね✨

参考

stackoverflow.com

Ruby: JSON.parse時に任意のClassのオブジェクトとして取得する方法

JSON.parseの結果は基本的にはHashになると思うのですが、任意のClassのオブジェクトで取得したい場合に、JSON.parseの引数object_classを利用すると簡単に実現出来ることを知らなかったのでメモしておきます📝

やり方は以下の通りでdef []=(key, value)を持つ任意のClassを作成してあげて、JSON.parseの引数object_classにそのClassを渡して上げるだけです。

require 'json'

json = <<~JSON
[
  {
    "title": "foo 1",
    "body": "bar 1"
  },
  {
    "title": "foo 2",
    "body": "bar 2"
  }
]
JSON

class Article
  attr_accessor :title, :body

  def initializer(title, body)
    @title = title
    @body = body
  end

  def []=(key, value)
    instance_variable_set("@#{key}", value)
  end
end

p JSON.parse(json, object_class: Article)
# => [#<Article:0x00007fcad21f46c8 @title="foo 1", @body="bar 1">, #<Article:0x00007fcad21f44c0 @title="foo 2", @body="bar 2">]

Ruby便利ですね✨

参考

docs.ruby-lang.org

Ruby on Rails: Logrageを使ってRailsのログ出力をいい感じにするMEMO📝

個人のWebサービスにLogrageを入れてみたので導入方法とか使い方をメモしておきます📝

github.com

Logrageとは?

Lograge is an attempt to bring sanity to Rails' noisy and unusable, unparsable and, in the context of running multiple processes and servers, unreadable default logging output. GitHub - roidrage/lograge: An attempt to tame Rails' default policy to log everything.

だいぶ強い文言ですが、Railsのログ出力をいい感じにしてくれるgemです🚃

以下にREADMEに記載されている使用前と使用後のログを載せましたが、使用後のほうが1行で表示されており見やすいですね👀

使用前

Started GET "/" for 127.0.0.1 at 2012-03-10 14:28:14 +0100
Processing by HomeController#index as HTML
  Rendered text template within layouts/application (0.0ms)
  Rendered layouts/_assets.html.erb (2.0ms)
  Rendered layouts/_top.html.erb (2.6ms)
  Rendered layouts/_about.html.erb (0.3ms)
  Rendered layouts/_google_analytics.html.erb (0.4ms)
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)

使用後

method=GET path=/jobs/833552.json format=json controller=JobsController  action=show status=200 duration=58.33 view=40.43 db=15.26

Logrageの使い方

Logrageを導入する

Logrageを単に導入するのは簡単で以下の通りGemfileに記載後にbundle installを実行し、

gem "lograge"

config/initializer配下に以下を記載したrubyファイルを配置します。

Rails.application.configure do
  config.lograge.enabled = true
end

これでLogrageによってフォーマットされたログが出力されるようになります✨

※デフォルトではLograge::Formatters::KeyValueを使ったフォーマットが行われます。   lograge/key_value.rb at master · roidrage/lograge · GitHub

ログ出力項目を追加する

ログ出力項目を追加する場合には以下のような形でconfig.lograge.custom_optionsに追加したい項目のhashを返却するlamda(引数にeventを取ってcallが定義されているインスタンスであれば何でも良さそう)を渡してあげます。

Rails.application.configure do
  config.lograge.enabled = true
  config.lograge.custom_options = ->(event) { { time: Time.current } }
end

以下のようにtime=2021-05-23 17:15:47 +0900が追加されています🕛

method=GET path=/jobs/833552.json format=json controller=JobsController  action=show status=200 duration=58.33 view=40.43 db=15.26 time=2021-05-23 17:15:47 +0900

独自に作成したフォーマッターを使用する

独自に定義したフォーマッターも使用するこができ、以下は私が作ったHTTPステータスコードを表すアイコンを表示するログフォーマッターです。

# frozen_string_literal: true

require 'lograge'

class Lograge::Formatters::MarkedKeyValue < Lograge::Formatters::KeyValue
  INFO_MARK = '🙂'
  SUCCES_MARK = '😃'
  REDIRECT_MARK = '😗'
  CLIENT_ERROR_MARK = '🤔'
  SERVER_ERROR_MARK = '😱'

  def call(data)
    result = super(data)
    mark = status_to_mark(data[:status].to_i)
    "#{mark} #{result}"
  end

  def status_to_mark(status)
    case status
    when 100...200 then INFO_MARK
    when 200...300 then SUCCES_MARK
    when 300...400 then REDIRECT_MARK
    when 400...500 then CLIENT_ERROR_MARK
    when 500...    then SERVER_ERROR_MARK
    else ' '
    end
  end
end

以下のような形でconfig.lograge.formatterインスタンスを渡して上げると、

require Rails.root.join('lib/lograge/formatters/marked_key_value')

Rails.application.configure do
  config.lograge.enabled = true
  config.lograge.formatter = Lograge::Formatters::MarkedKeyValue.new
  config.lograge.custom_options = ->(event) { { time: Time.current } }
end

以下のような形で出力されます🙌

😃 method=GET path=/api/feeds format=html controller=Api::FeedsController action=index status=200 duration=5.92 view=0.16 db=0.33 time=2021-05-23 16:21:12 +0900
😃 method=GET path=/feeds/533 format=html controller=FeedsController action=show status=200 duration=13.04 view=9.63 db=0.00 time=2021-05-23 16:21:24 +0900
😱 method=GET path=/api/feeds/533 format=html controller=Api::FeedsController action=show status=500 error='RuntimeError: ' duration=0.44 view=0.00 db=0.00 time=2021-05-23 16:21:24 +0900

おわりに

lograge、rackプロトコルライクな感じでフォーマッターも作りやすく良いですね✨