Madogiwa Blog

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

Ruby: proc(lamda)を定義時ではなく呼び出したインスタンスのコンテキストで実行する方法MEMO

Proc(lamda)を定義時ではなく実行時のコンテキスで実行する際にちょっとハマったのでメモ📝

結論: instance_exec(&proc)で実行すれば良かった。

以下のような外部で定義したprocを受け取って実行した際にhello! by Caller.と出ることを期待したのですが実行するとNameErrorが発生します。 下記の通りProcは定義時のコンテキストで作成されますが、

class Caller
  def initialize(msg)
    @msg = msg
  end

  attr_reader :msg

  def callProc(targetProc)
    puts targetProc.call
  end
end

sampleProc = -> { "hello! by #{msg}." }
puts Caller.new('Caller').callProc(sampleProc)
# => undefined local variable or method `msg' for main:Object (NameError)

これは以下の通りProcが定義時のコンテキストを保持しており、そのコンテキストで実行されるためmainのmsgを探索してしまい変数が見つからずエラーになってしまいます。

class Proc ブロックをコンテキスト(ローカル変数のスコープやスタックフレーム)とともにオブジェクト化した手続きオブジェクトです。 class Proc (Ruby 3.2 リファレンスマニュアル)

そのためinstance_execで呼び出したインスタンスのコンテキストで実行すれば変数が見つかり期待通りの出力を得ることが出来ました!

class Caller
  def initialize(msg)
    @msg = msg
  end

  attr_reader :msg

  def callProc(targetProc)
    instance_exec(&targetProc)
  end
end

sampleProc = -> { "hello! by #{msg}." }
puts Caller.new('Caller').callProc(sampleProc)
#=> hello! by Caller.

docs.ruby-lang.org

ちなみにinstance_eval(&targetProc)の場合には、instance_evalがデフォルトでobj(実行時のself)をblock引数として渡してしまうので、lamdaだと引数が合わずにArgumentErrorが発生します。

docs.ruby-lang.org

参考

secret-garden.hatenablog.com

qiita.com

Ruby: RSpecのカスタムマッチャを作る方法MEMO📝

RSpecのカスタムマッチャの作り方をいろいろ調べたのでメモ📝

作り方

RSpec::Matchers.defineを使う

カジュアルな追加方法としてRSpec::Matchers.defineで追加する方法があります。 以下の公式ドキュメントの通りにRSpec::Matchers.defineの引数にmatcher名を渡してdefineのブロック引数に期待する値、matchの引数に実際の値を指定してあげれば良いようです📝

You can create such a matcher like so:

RSpec::Matchers.define :be_in_zone do |zone|
  match do |player|
    player.in_zone?(zone)
 end
end

Module: RSpec::Matchers — Documentation for rspec-expectations (3.12.3)

実際に以下のような単純に同一の値になるか確認するカスタムマッチャを追加して、

RSpec::Matchers.define :my_eq do |expected|
  match do |actual|
    expected == actual
  end
end

以下のようなテストを流すと、

describe 'test' do
  it 'check my_eq' do
    expect(true).to my_eq(false)
  end
end

以下のようにテスト結果が表示されます🙌

Failures:

  1) test check my_eq
     Failure/Error: expect(true).to my_eq(false)
       expected true to my eq false

moduleで定義する

RSpec::Matchers.defineを使うとカジュアルに追加できますが実際にマッチャのテストコードを書きたいといった場合にはmoduleとして定義するとやりやすいようです。

以下のような検証用のロジックをもつclassとマッチャの呼び出すテストコード内で呼び出す名称のメソッドを持つmoduleを定義してあげて、

module MyEqMatcher
  class Matcher
    def initialize(expected)
      @expected = expected
    end

    def matches?(_expected)
      @actual = actual
      @expected == @actual
    end

    def failure_message
      "#{@expected} expected, but got #{@actual}"
    end
  end

  def my_eq(expected)
    Matcher.new(expected)
  end
end

以下の通りにconfig.includeでテストコード内で利用できるようにすることができます。

RSpec.configure do |config|
  config.include MyEqMatcher
end

以下の通り実行するといい感じにカスタムマッチャーを使ってエラーになることが確認できます 🙌

describe 'test' do
  it 'check my_eq' do
    expect(true).to my_eq(false)
  end
end
  1) test check my_eq
     Failure/Error: expect(true).to my_eq(false)
       false expected, but got true

おわりに

RSpecのカスタムマッチャ、意外と簡単に追加できて便利ですね!!

参考

semaphoreci.com

tabakazu.hateblo.jp

thoughtbot.com

Ruby on Rails: Gem等の外部から`config.x`を使わずにネストしたカスタム設定を追加する方法MEMO📝

Ruby on Railsを拡張するGemを作成する等、デフォルトの振る舞いといったものRails.application.configureで設定できるようにしたいなぁと思ったのですが、config.xは使わずにネストしたカスタム設定をいい感じに追加する方法をメモ🗒

Railsガイドを見ると以下のような記載があり、基本的にはconfig.fooの形で直接記載すればいいのですがネストする場合にはhashのkeyとしてアクセスしないといけないので、そういう場合にはconfig.x.fooみたいな形で定義してあげるのが推奨なのですが、Gem側等の外部からの設定でconfig.xを使うのはちょっとイマイチかなとも思ったのでいい感じに実装できないのかなぁと思ったのですが、

Railsの設定オブジェクトに独自のコードを設定するには、config.x名前空間またはconfigに直接コードを書きます。両者の重要な違いは、ネストした設定(config.x.nested.nested.hiなど)の場合はconfig.xを使うべきで、単一レベルの設定(config.helloなど)ではconfigだけを使うべきであるという点です。 Rails アプリケーションを設定する - Railsガイド

以下のようなコードを用意して、initializer等々で初期化時に読み込んであげればconfig.fooの形式でネストしたカスタム設定をいい感じに追加できそうでした🙆‍♂️

# frozen_string_literal: true

require 'active_support'
require 'active_support/ordered_options'
require 'rails/railtie'

module Foo
  class Configration < ActiveSupport::OrderedOptions
    def custom_payload(&block)
      self.custom_payload_method = block
    end
  end

  class Railtie < Rails::Railtie
    config.foo = Configration.new

    config.after_initialize do |_app|
      # NOTE: ユーザーが書き換えた設定値に依存した処理はinitializerによる設定が行われた後に実行するためにここで読み込むなり実行するなりする。
    end
  end
end

Hashを継承した動的にaccessor メソッドが定義されるActiveSupport::OrderedOptionsインスタンスをtopレベルの名前空間config.fooに渡すことでネストした値でもいい感じにkey形式じゃなくても取得できるようになるということですね✨

ActiveSupport::OrderedOptions < Hash OrderedOptions inherits from Hash and provides dynamic accessor methods. ActiveSupport::OrderedOptions

config.xの実態であるRails::Application::Configuration::Custommethod_missingActiveSupport::OrderedOptions.newが設定されるようになってるんですね。

module Rails
  class Application
    class Configuration
      class Custom # :nodoc:
        def initialize
          @configurations = Hash.new
        end

        def method_missing(method, *args)
          if method.end_with?("=")
            @configurations[:"#{method[0..-2]}"] = args.first
          else
            @configurations.fetch(method) {
              @configurations[method] = ActiveSupport::OrderedOptions.new
            }
          end
        end

        def respond_to_missing?(symbol, *)
          true
        end
      end

rails/configuration.rb at v7.0.4.3 · rails/rails · GitHub

便利!

参考

github.com

railsguides.jp

Ruby on Rails: Viewを作らずにControllerからVue.jsのSFCを指定してrenderできないか試したMEMO

はじめに

RailsでVue.jsを採用している場合、Vue SFCscoped cssを利用してCSS周りの統制と取りたくなりますが、RailsのView層に色々要素を書いてしまうとそれも難しくなってきます。

vue-loader-v14.vuejs.org

なので、RailsのControllerから直接Vue.jsのSFCを指定してrenderするような仕組みができないか色々試してみたのでメモしておく📝

基本方針

以下のようにrender_vue_componentの引数にコンポーネントpropsを渡すと<index-post-page />< show-post-page :post-id="id">みたいなVueコンポーネントの要素を差し込んだhtmlを返却するような仕組みを検討します。

class PostsController < ApplicationController
  def index
    render_vue_component('index-post-page')
  end

  def show
    render_vue_component('show-post-page', props: { ':post-id': params[:id] })
  end
end

このようなケースだとinertiajs.jsを使えば良さそうにも思いましたが、単純にscoped cssCSSの統制を取りたいという要求に対して少し過剰な印象を持ったので、Railsの仕組みに乗りつつ直接Vue SFC指定してcontrollerからviewをrenderするような感覚で実現できないか検討してみる。

inertiajs.com

github.com

実装したコード

render_vue_componentでVue SFCを直接指定できるように以下のようなtemplateとconcernを実装してみました。

render_vue_componentコンポーネント名とpropsmount_element_idを渡すとmount_element_id(default: 'vue-root')をidとして指定したdivタグの中にhelpers.content_tag(@component, '', @props)で生成したタグ(ex. <show-post-page :post-id="id" >)をcomponent/template.erbに差し込んで返却します。

simpacker等を採用しておりjavascript_pack_tagでentryのjs、stylesheet_pack_tagでentryのcssを読み込むことが前提になってますがjavascript_entry_tagstylesheet_entry_tagをoverrideすれば任意のタグに差し替えることができます。

entryはデフォルトでcontroller名/action名(ex. PostsController#show -> posts/show)が指定されるようにしてみました。

<div id="<%= mount_element_id %>">
  <%= component_tag %>
</div>
<% content_for :stylesheet do %>
  <%= stylesheet_entry_tag %>
<% end %>
<% content_for :javascript do %>
  <%= javascript_entry_tag %>
<% end %>
# frozen_string_literal: true

module ComponentRenderable
  extend ActiveSupport::Concern

  included do
    helper_method :component_tag, :javascript_entry_tag, :stylesheet_entry_tag
  end

  def render_vue_component(component, entry: nil, props: {}, mount_element_id: nil)
    @component = to_kebab_case(component)
    @props = props
    @entry = entry.presence || default_entry
    mount_element_id = mount_element_id.presence || default_mount_element_id
    render defalt_template_path,
           locals: { mount_element_id: mount_element_id, component: @component, entry: entry, props: @props }
  end

  def component_tag
    helpers.content_tag(@component, '', @props)
  end

  def javascript_entry_tag
    helpers.javascript_pack_tag(@entry, defer: true)
  end

  def stylesheet_entry_tag
    helpers.stylesheet_pack_tag(@entry)
  end

  private

  def default_mount_element_id
    'vue-root'
  end

  def defalt_template_path
    'component/template'
  end

  def default_entry
    File.join(controller_name, action_name)
  end

  def to_kebab_case(text)
    text.underscore.tr('_', '-')
  end
end

(追記) 以下のようなrender inlineを使うとcomponent/temaplete.erb消せるのでよりシンプルに実現できそうだった。

# frozen_string_literal: true

module ComponentRenderable
  extend ActiveSupport::Concern

  included do
    helper_method :component_tag, :javascript_entry_tag, :stylesheet_entry_tag
  end

  def render_vue_component(component, entry: nil, mount_element_id: nil, props: {})
    @component = to_kebab_case(component)
    @options = props
    @entry = entry.presence || default_entry
    @mount_element_id = mount_element_id.presence || default_mount_element_id
    render inline: template, # rubocop:disable Rails/RenderInline
           locals: { mount_element_id: @mount_element_id, component: @component, entry: entry, options: @options },
           layout: true
  end

  def component_tag
    helpers.content_tag(@component, '', @options)
  end

  def javascript_entry_tag
    helpers.javascript_pack_tag(@entry, defer: true)
  end

  def stylesheet_entry_tag
    helpers.stylesheet_pack_tag(@entry)
  end

  private

  def template
    <<~HTML
      <div id="<%= mount_element_id %>">
        <%= component_tag %>
      </div>
      <% content_for :stylesheet do %>
        <%= stylesheet_entry_tag %>
      <% end %>
      <% content_for :javascript do %>
        <%= javascript_entry_tag %>
      <% end %>
    HTML
  end

  def default_mount_element_id
    'vue-root'
  end

  def default_entry
    File.join(controller_name, action_name)
  end

  def to_kebab_case(text)
    text.underscore.tr('_', '-')
  end
end

利用例

以下のようなentryを実装してある前提で、

import { createApp } from "vue";
import ShowPostPage from "@js/components/pages/ShowPostPage.vue";

const app = createApp(ShowPostPage);
app.mount("#vue-root");

以下のようにcontroller内でrender_vue_componentを利用することで、

class PostsController < ApplicationController
  include ComponentRenderable

  def show
    render_vue_component('show-post-page', props: { ':post-id': params[:id] })
  end
end

以下のようなhtmlが返却され、Vueインスタンスのmount及び指定したpropsを指定してComponentがレンダリングされます。

<div id="vue-root">
  <show-post-page :post-id="9"></show-post-page>
</div>

おわりに

とりあえず、そこまで多くのコードを書かずにViewを作らずControllerからVue.jsのSFCを指定してrenderするようなことは出来そうだなと思いましたが、多少なりとも独自の仕組みを作るとトレンドに合わせて独自の仕組みをメンテナンスするコストが発生するのが難しいところ。。。

CSSの統制とりたいというニーズにおいてはTailwind CSSをしたり、必ずしも独自の仕組みを用意する必要はないかもしれないのとView Componentのようなメジャーなライブラリでもscoped cssののような機能が議論されたりしてるので、こちらを期待するのも良いかもしれない。

github.com

`ruby-openai`を使ってRubyでChat GPTのAPIを呼び出すMEMO

Ruby on Rails製の個人のWebサービスにOpen APIのChat GPTを使った機能を盛り込んだ時にruby-openaiを使うと簡単にリクエス部分を実装できたので、ほとんど公式READMEの通りですがメモ📝

github.com

API KEYの取得方法等は以下の記事を参考にしてください。

madogiwa0124.hatenablog.com

使い方は簡単で以下の通りに実装すればいい感じにOpenAIのChat GPTのレスポンスを受け取ることができます。

client = OpenAI::Client.new(access_token: "access_token_goes_here")

response = client.chat(
    parameters: {
        model: "gpt-3.5-turbo", # Required.
        messages: [{ role: "user", content: "Hello!"}], # Required.
    })
puts response.dig("choices", 0, "message", "content")
# => Hello! How can I assist you today?

以下にサンプルとして適当な文字列の分かりやすい要約を生成するclassを作ってみました。

# frozen_string_literal: true

class SummaryText
  def initialize(text: nil)
    @original = text
  end

  def summary
    @summary ||= summarize!
  end

  attr_reader :original

  private

  def summarize!
    fetch_summary!
  end

  def fetch_summary!
    fetch_from_open_ai_chat(
      '渡された文字列を小学生でも理解できるように150文字で説明した文章を出力してください。',
      original
    )
  end

  def fetch_from_open_ai_chat(system_prompt, text)
    response = client.chat(
      parameters: {
        model: 'gpt-3.5-turbo',
        messages: [
          { role: 'system', content: system_prompt },
          { role: 'user', content: text }
        ]
      }
    )
    response.dig('choices', 0, 'message', 'content')
  end

  def client
    @client ||= OpenAI::Client.new(access_token: open_ai_token)
  end

  def open_ai_token
    Rails.configuration.settings.open_ai[:secret]
  end
end

以下のような感じで結果を取得できます🙌

SummaryText.new(text: "検索(けんさく、英: search)とは、データの集合の中から目的とするデータを探し出すことである。古くは図書館の所蔵物を探し出したり、辞書の項目を引いたりといった人手で行うのが主だったが、コンピューターの発達により、テキスト文字列の検索(文書検索、文字列探索)、画像データの検索(画像検索)、音声データの検索(音声検索)など、大規模かつマルチメディアの情報に関する検索技術が発展した。さらにデータベースの発展とインターネットの普及に伴い、分散保管されているデータに対する検索技術が研究されている。ファイルの内容に対して文字列探索を行う機能も検索と呼ばれる。").summary
=> "「検索」とは、目的のデータを見つけることです。昔は本を探したり辞書を引くなど、人が手で行っていました。しかし、コンピューターが発達し、文章や画像、音声など、大量の情報を探すことができるようになりました。また、データが分散保管されている場合でも検索できるようになっています。ファイルの中から特定の文字列を探す機能も「検索」と呼びます。"   

Ruby 3.2.0で導入されたYJITをHerokuで有効化する

Ruby 3.2.0で導入されたYJITをHerokuで有効化してみたのでやり方をメモ📝

github.com

HerokuでYJITを有効化する

結論だけ言うと、以下のコマンドを実行して環境変数RUBYOPT--enable-yjitを指定してあげれば🆗です✨

heroku config:set RUBYOPT="--enable-yjit"

以下のようなコマンドで有効になっているかどうか確認することも出来ます👍

# 有効確認
heroku run bash
ruby -v --yjit
ruby 3.2.1 (2023-02-08 revision 31819e82c8) +YJIT [x86_64-linux]

# 状況確認
heroku run irb
irb(main):001:0> RubyVM::YJIT.runtime_stats
=> 
{:inline_code_size=>178276,
 :outlined_code_size=>177092,
 :freed_page_count=>0,
 :freed_code_size=>0,
 :live_page_count=>22,
 :code_gc_count=>0,
 :code_region_size=>360448,
 :object_shape_count=>602}

参考

techracho.bpsinc.jp

www.reddit.com

Mac OS: rbenvでRuby 3.2.0をinstallが失敗したので対応方法をメモ📝

昨年末Ruby 3.2.0がリリースされましたね🎅🎄✨

www.ruby-lang.org

今更ながらローカルのRubyのバージョンをrbenvで3.2.0に上げようと思ったのですがエラーが発生したのでその対応方法とかをメモ📝

発生した問題

いつもどおり以下のコマンドでRuby 3.2.0をisntallしようとしたのですが、以下の通りエラーが発生し失敗しました😵

$ brew update && brew upgrade ruby-build
$ rbenv install 3.2.0

ruby-build: using readline from homebrew

BUILD FAILED (macOS 12.6.3 using ruby-build 20230124)

Inspect or clean up the working tree at /var/folders/59/r452z9h94xn9py1wt_ffj8qh0000gn/T/ruby-build.20230128163250.53460.gt7TJp
Results logged to /var/folders/59/r452z9h94xn9py1wt_ffj8qh0000gn/T/ruby-build.20230128163250.53460.log

Last 10 log lines:
        Check ext/psych/mkmf.log for more details.
*** Fix the problems, then remove these directories and try again if you want.
Generating RDoc documentation
/private/var/folders/59/r452z9h94xn9py1wt_ffj8qh0000gn/T/ruby-build.20230128163250.53460.gt7TJp/ruby-3.2.0/lib/yaml.rb:3: warning: It seems your ruby installation is missing psych (for YAML output).
To eliminate this warning, please install libyaml and reinstall your ruby.
uh-oh! RDoc had a problem:
cannot load such file -- psych

run with --debug for full backtrace
make: *** [rdoc] Error 1

解決方法

以下のrbenv/ruby-buildのDiscussion記載の通り、

github.com

libyamlをinstallしてからRuby 3.2.0をinstallすることで解決しました🎉

$ brew install libyaml
$ rbenv install 3.2.0
$ rbenv global 3.2.0
$ ruby -v
ruby 3.2.0 (2022-12-25 revision a528908271) [arm64-darwin21]