RubyのRefinements拡張

Rubyはモンキーパッチが簡単にできてしまうが、多用するとコードの複雑性が増してトレースが困難になるため、基本的に世の中一般のプロジェクトでは禁止されている(はず)。 それでも実装を進めていて拡張したほうが汎用性が増すケースもある。その場合はよく Refinements を使用している。

配列内の数値について平均を求める場合、以下のようになるが、

array = [1, 2, 3, 4]
array.sum / array.length.to_f

この処理をメソッドとして分離したとしても、引数として配列を渡すのがどうにもしっくりこないと感じているため、 refine Array を行うmoduleを作成して、定義が必要なクラスでのみ呼び出すようにしている。

def average(array)
  array.sum / array.length.to_f
end
module ArrayEx
  refine Array do
    def average
      sum / length.to_f
    end
  end
end

using ArrayEx

[1, 2, 3, 4].average

Rails 多対多テーブル構造をcollection_check_boxesで関連付ける

formを用いた際の多対多テーブルの関連付けを、collection_check_boxesを使用して行う。

railsdoc.com

テーブル構造

create_table "users", force: :cascade do |t|
  t.string   "name"
  t.datetime "created_at"
  t.datetime "updated_at"
end

create_table "services", force: :cascade do |t|
  t.string   "name"
  t.datetime "created_at"
  t.datetime "updated_at"
end

create_table "users_services", force: :cascade do |t|
  t.integer  "user_id"
  t.integer  "service_id"
  t.datetime "created_at"
  t.datetime "updated_at"
end

モデル

class User < ApplicationRecord
  has_many :users_services
  has_many :services, through: :users_services
end
class Service < ApplicationRecord
  has_many :users_services
  has_many :users, through: :users_services
end
class UsersService < ApplicationRecord
  belongs_to :user
  belongs_to :service
end

コントローラー

class UsersController < ApplicationController
  def new
    @user = User.new
    @services = Service.all
  end

  def create
    user = User.new(permit_params)
    user.save!
  end

  private

  def permit_params
    params.require(:user).permit(:name, service_ids: [])
  end
end

User モデルの service_ids メソッドに対して値を送信できるようにビューの定義を行う。

ビュー

※slim構文にて定義

= form_with scope: :user, mdoel: @user, url: users_path do |form|
  = form.text_field :name
  = form.collection_check_boxes :service_ids, @services, :id, :name, include_hidden: false
  = form.submit 'post'

collection_check_boxesservices テーブルに登録したレコードのID・名称を展開して表示を行う。 include_hidden は空白文字をhiddenで定義するかオプションで設定を変更できる(不要なので今回はfalse)。

任意の内容で入力を行いsubmitすると、関連も含めてレコードを保存できる。

web_1  | Started POST "/users" for 172.18.0.1 at 2023-05-03 07:46:45 +0000
web_1  | Cannot render console from 172.18.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
web_1  | Processing by UsersController#create as TURBO_STREAM
web_1  |   Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"name"=>"test", "service_ids"=>["1", "2"]}, "commit"=>"post"}
web_1  |   Service Load (0.3ms)  SELECT `services`.* FROM `services` WHERE `services`.`id` IN (1, 2)
web_1  |   ↳ app/controllers/users_controller.rb:8:in `create'
web_1  |   TRANSACTION (0.2ms)  BEGIN
web_1  |   ↳ app/controllers/users_controller.rb:9:in `create'
web_1  |   User Create (0.7ms)  INSERT INTO `users` (`name`, `created_at`, `updated_at`) VALUES ('test', '2023-05-03 07:46:45.726583', '2023-05-03 07:46:45.726583')
web_1  |   ↳ app/controllers/users_controller.rb:9:in `create'
web_1  |   UsersService Create (0.6ms)  INSERT INTO `users_services` (`user_id`, `service_id`, `created_at`, `updated_at`) VALUES (1, 1, '2023-05-03 07:46:45.730791', '2023-05-03 07:46:45.730791')
web_1  |   ↳ app/controllers/users_controller.rb:9:in `create'
web_1  |   UsersService Create (0.2ms)  INSERT INTO `users_services` (`user_id`, `service_id`, `created_at`, `updated_at`) VALUES (1, 2, '2023-05-03 07:46:45.732966', '2023-05-03 07:46:45.732966')
web_1  |   ↳ app/controllers/users_controller.rb:9:in `create'
web_1  |   TRANSACTION (7.5ms)  COMMIT
web_1  |   ↳ app/controllers/users_controller.rb:9:in `create'
web_1  | No template found for UsersController#create, rendering head :no_content
web_1  | Completed 204 No Content in 60ms (ActiveRecord: 10.1ms | Allocations: 11039)

Ruby3.2 + Rails7の環境構築(docker compose)

クィックスタート: Compose と Rails — Docker-docs-ja 20.10 ドキュメント

こちらのドキュメントが少し古かったので、他の記事を見ながら個人的に構築した際のメモ。

作業ディレクトリの作成

mkdir myapp

以下のファイルを作成

  • Dockerfile
  • docker-compose.yml
  • Gemfile
  • Gemfile.lock
  • entrypoint.sh

Dockefile

FROM ruby:3.2.1
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
    echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
    apt-get update && apt-get install -y nodejs yarn default-mysql-client libpq-dev vim
WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock
RUN gem update --system
RUN bundle update --bundler
RUN gem pristine --all
RUN bundle install
COPY . /myapp

# Add a script to be executed every time the container starts.
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

# Start the main process.
CMD ["rails", "server", "-b", "0.0.0.0"]

docker-compose.yml

version: '3.7'
services:
  db:
    image: mysql:8.0
    platform: linux/x86_64
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3306:3306"
    volumes:
      - db:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: password
    security_opt:
      - seccomp:unconfined
  web:
    build: .
    stdin_open: true
    tty: true
    volumes:
      - .:/myapp
      - bundle:/usr/local/bundle
    command: bash -c "rm -rf tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    depends_on:
      - db
    ports:
      - "3000:3000"
volumes:
  db:
    driver: local
  bundle:
    driver: local

Gemfile

source 'https://rubygems.org'
gem 'rails', '~>7.0.4'

entrypoint.sh

#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

コマンド実行

docker-compose build
docker-compose run web rails new . --force --database=mysql --skip-bundle

ここまで実行すればmyqpp配下にファイルが展開される。 config\database.yml の内容を編集する。

default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # パスワードの追加
  host: db # ホストの情報をdocker-composeに記載したDBに変更

再度コマンド実行。

docker-compose run rails rails db:create
docker-compose up -d

http://127.0.0.1:3000/ にアクセスが可能になる。

起動時のエラー

Ignoring racc-1.6.0 because its extensions are not built. Try: gem pristine racc --version 1.6.0
/usr/local/lib/ruby/site_ruby/3.2.0/rubygems/resolver/conflict.rb:47:in `conflicting_dependencies': undefined method `request' for nil:NilClass (NoMethodError)

Dockerfileに下記を追加して更新を図ったが解決せず…

RUN gem update --system
RUN bundle update --bundler
RUN gem pristine --all

一度手元の環境をすべて削除したところ解消した。

docker-compose down --rmi all --volumes --remove-orphans

AWS SDK for Rubyでlocalstack + OpenSearch環境に接続する

ローカルのdocker環境に構築したlocalstack + OpenSearchAWS SDK for Rubyを用いた接続方法です。 接続方法自体はAWS公式のデベロッパーガイドに記載されていますが、クライアントクラス化して使用します。 docs.aws.amazon.com

docker-compose.yml

# rubyを実行するコンテナに環境変数を追加
environment:
  OPENSEARCH_ENDPOINT: http://localstack:4566/my-custom-endpoint
  AWS_ACCESS_KEY_ID: dummy
  AWS_SECRET_ACCESS_KEY: dummy

クライアントクラス

require 'aws-sdk-opensearchservice'

class OpenSearchClient
  def initialize
  end

  def request(http_method, api_path, document)
    uri = URI(endpoint + '/' + api_path)
    signature = generate_signature(http_method, api_path, document)
    http_client = Net::HTTP.new(uri.host, uri.port)
    http_client.use_ssl = Rails.env.production?
    http_client.start do |http|
      request = request_http_method(http_method, uri)
      request.body = document.to_json
      request['Host'] = signature.headers['host']
      request['X-Amz-Date'] = signature.headers['x-amz-date']
      request['X-Amz-Security-Token'] = signature.headers['x-amz-security-token']
      request['X-Amz-Content-Sha256']= signature.headers['x-amz-content-sha256']
      request['Authorization'] = signature.headers['authorization']
      request['Content-Type'] = 'application/json'
      http.request(request)
    end
  end

  private

  def endpoint
    @endpoint ||= ENV['OPENSEARCH_ENDPOINT']
  end

  def request_http_method(http_method, uri)
    case http_method
    when 'GET'
      Net::HTTP::Get.new(uri)
    when 'POST'
      Net::HTTP::Post.new(uri)
    when 'PUT'
      Net::HTTP::Put.new(uri)
    when 'DELETE'
      Net::HTTP::Delete.new(uri)
    end
  end

  def generate_signature(http_method, api_path, document)
    signer.sign_request(
      http_method: http_method,
      url: endpoint + '/' + api_path,
      body: document.to_json
    )
  end

  def signer
    @signer ||= Aws::Sigv4::Signer.new(
      service: 'es',
      region: 'ap-northeast-1',
      access_key_id: ENV['AWS_ACCESS_KEY_ID'],
      secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
    )
  end
end

インデックスの作成

document = { 
  settings: {
    index: {
      analysis: {
        tokenizer: {
          kuromoji_tokenizer: {
            type: 'kuromoji_tokenizer'
          }
        },
        analyzer: {
          analyzer: {
            type: 'custom',
            tokenizer: 'kuromoji_tokenizer'
          }
        }
      }
    }
  }
}

OpenSearchClient.new.request('PUT', 'sample-index', document)
=> "{\"acknowledged\":true,\"shards_acknowledged\":true,\"index\":\"sample-index\"}"

docker-composeでlocalstack + opensearchの環境構築

Amazon OpenSearch Serviceとの連携を行う要件に対して開発を行う場合、クラウド環境との疎通を行う前に、docker開発環境内でlocalstackを用いて検証することができる。

docs.localstack.cloud

設定ファイル

docker-compose.yml

version: '3.7'
services:
  opensearch:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - node.name=opensearch
      - cluster.name=opensearch-docker-cluster
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
      - "DISABLE_SECURITY_PLUGIN=true"
    ports:
      - "9200:9200"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - opensearch:/usr/share/opensearch/data
  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"
    depends_on:
      - opensearch
    environment:
      - OPENSEARCH_CUSTOM_BACKEND=http://opensearch:9200
      - DEBUG=${DEBUG- }
      - PERSISTENCE=${PERSISTENCE- }
      - DOCKER_HOST=unix:///var/run/docker.sock
      - HOSTNAME_EXTERNAL=localstack
    volumes:
      - ./docker/localstack/:/docker-entrypoint-initaws.d # 起動時に実行する初期化ファイル
volumes:
  opensearch:
    driver: local

Dockerfile

  • command でのインストールは不可なので Dockerfile でインストール
  • opensearch-plugin で日本語全文検索プラグインをインストール
FROM opensearchproject/opensearch:latest
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-kuromoji
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu

カスタムドメイン作成用シェル

  • ./docker/localstack/init.sh ファイルを作成
    • ./docker/localstack/:/docker-entrypoint-initaws.d の設定で起動時に実行したい内容を記載
  • docker-compose終了時に作成したドメインが消えてしまうため、起動時に毎回ドメインを作成する
awslocal opensearch create-domain \
  --domain-name my-domain \
  --domain-endpoint-options '{ "CustomEndpoint": "http://localstack:4566/my-custom-endpoint", "CustomEndpointEnabled": true }'

起動確認

docker exec -it [localstackコンテナ名] /bin/bash

curl http://localhost:4566/my-custom-endpoint/_cluster/health?pretty
{
  "cluster_name" : "opensearch-docker-cluster",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 1,
  "number_of_data_nodes" : 1,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 0,
  "active_shards" : 0,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

Rails6 + Webpacker環境のVue.js3導入と、Viewからpropsへの受け渡し

環境

※Docker Desktop環境

Vueのインストール

yarn add vue@next vue-loader@next @vue/compiler-sfc

package.json

{
  "name": "myapp",
  "private": true,
  "dependencies": {
    "@rails/actioncable": "^6.0.0",
    "@rails/activestorage": "^6.0.0",
    "@rails/ujs": "^6.0.0",
    "@rails/webpacker": "5.4.3",
    "@vue/compiler-sfc": "^3.2.26", // 追加項目
    "turbolinks": "^5.2.0",
    "vue": "^3.2.26", // 追加項目
    "vue-loader": "^17.0.0", // 追加項目
    "webpack": "^4.46.0",
    "webpack-cli": "^3.3.12"
  },
  "version": "0.1.0",
  "devDependencies": {
    "webpack-dev-server": "^3"
  }
}

config\webpack\environment.jsの編集

初期状態

const { environment } = require('@rails/webpacker')

module.exports = environment

編集後

const { environment } = require('@rails/webpacker')

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const { DefinePlugin } = require('webpack')

// アプリケーションのalias
const customConfig = {
  resolve:{
    alias: {
      "@": path.resolve(__dirname, '..', '..', 'app/javascript')
    }
  }
}

environment.plugins.prepend(
  'VueLoaderPlugin',
  new VueLoaderPlugin()
)

environment.loaders.prepend('vue', {
  test: /\.vue$/,
  use: [{
      loader: 'vue-loader'
  }]
})
environment.plugins.prepend(
  'Define',
  new DefinePlugin({
      __VUE_OPTIONS_API__: false,
      __VUE_PROD_DEVTOOLS__: false
  })
)

environment.config.merge(customConfig)

module.exports = environment

config\webpacker.ymlの編集

- extensions:
  - .vue # 追加

Controller、View、JavaScriptの作成

config\routes.rb

Rails.application.routes.draw do
  resources :users
end

app\controllers\users_controller.rb

class UsersController < ApplicationController
  def index
    @users = [{ id: 1, name: 'hoge' }, { id: 2, name: 'fuga' }]
  end
end

app\views\users\index.html.erb

<div id="user" data-users="<%= @users.to_json %>">
  <ul>
    <li v-for="item in items">
     {{ item.name }}
    </li>
  </ul>
</div>

<%= javascript_pack_tag('users') %>

app\javascript\packs\users.js

import { createApp } from 'vue/dist/vue.esm-browser'
import Index from '@/users/index'

document.addEventListener('DOMContentLoaded', () => {
  // カスタムデータからの値取得と、propsへの受け渡し
  const { users } = document.getElementById('user').dataset
  createApp(Index, { users: JSON.parse(users) }).mount('#user')
})

app\javascript\users\index.js

export default {
  props: {
    users: {
      type: Array,
      default: [],
    }
  },

  data() {
    return {
      items: []
    }
  },

  mounted() {
    this.items = Object.assign({}, this.users)
  }
}