Madogiwa Blog

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

Ruby: camelCase🐫を使ってRubyを書くことは出来そうなのか?色々試してみたのでMEMO🤔

フロントエンドでJavaScriptを採用してバックエンドをRubyで書いていると、snake_case🐍とcamelCase🐫を行ったり来たりして、混乱したりするのでRubyでcamelCase🐫で書けたりしないのか、ちょっと試してみたのでMEMO📝

記事内のコードはこちらにあげています🐫💎

github.com

やりたいこと

自分で生やすメソッドに関しては単純にcamelCase🐫で書けばいいだけなんですが、

class Book
  def initialize(name, publishedAt)
    @name = name
    @publishedAt = publishedAt
  end

  attr_reader :name, :publishedAt
end

book = Book.new('foo', Time.now)
p book.name
p book.publishedAt

ネックなのは組み込みライブラリのメソッドはsnake_case🐍なので、単純に書くと🐍と🐫が混在してしまうのが辛いところですね😓

class Book
  def initialize(name, publishedAt)
    @name = name
    @publishedAt = publishedAt
  end

  attr_reader :name, :publishedAt
end

book = Book.new('foo', Time.now)
p book.name
p book.publishedAt.to_s

標準ライブラリのメソッドも含めてcamelCase🐫で書けないかと思った次第です。

ちなみにrubocopでcamelCaseのstyleを矯正することもできます👮

github.com

GithubでcamelCaseのルールを採用しているコードが無いか、検索してみたところ殆どないけど、全く無いわけではないっぽい👀

https://github.com/search?l=YAML&q=EnforcedStyle%3A+camelCase&type=Code

method_missingを使って無理やり🐫で書いてみる

以下のようなmethod_missingをオーバーライドして、method.to_s.underscore.to_symを実行して🐫→🐍してからpublic_sendしてメソッドを実行するようなActiveSupport::Concernをextendしたmodule CameRubyを用意してみました。

require 'active_support/all'

module CameRuby
  module MethodMissing
    extend ActiveSupport::Concern
    module ClassMethods
      def method_missing(method, *args)
        snaky_name = method.to_s.underscore.to_sym
        if public_methods.include?(snaky_name)
          public_send(snaky_name, *args)
        else
          super
        end
      end

      def respond_to_missing?(method, include_private)
        snaky_name = method.to_s.underscore.to_sym
        public_methods.include?(snaky_name) ? true : super
      end
    end

    def method_missing(method, *args)
      snaky_name = method.to_s.underscore.to_sym
      if public_methods.include?(snaky_name)
        self.public_send(snaky_name, *args)
      else
        super
      end
    end


    def respond_to_missing?(method, include_private)
      snaky_name = method.to_s.underscore.to_sym
      public_methods.include?(snaky_name) ? true : super
    end
  end
end

これをObjectクラスにincludeして使うと以下のように🐫で書くことが出来ます。

require_relative './lib/cameRuby/methodMissing'
class Object
  include CameRuby::MethodMissing
end

class Book
  def initialize(name, publishedAt)
    @name = name
    @publishedAt = publishedAt
  end

  attr_reader :name, :publishedAt
end

book = Book.new('foo', Time.now)
p book.name
#=> "foo"
p book.publishedAt.toS
#=> "2020-11-14 14:00:54 +0900"

alias_methodを使って🐫でも呼び出し可能にする

以下のようなObjectSpace.each_object(Class)を使ってClassを継承している全Objectを取得し、method_name.to_s.camelize(:lower).to_symを実行して🐍→🐫をしてからalias_methodで🐫で呼び出し可能にするような処理をCameRuby::AliasMethod.callで呼び出せるようにして、

# fornzen_string_literal: true

require 'active_support/all'

module CameRuby
  class AliasMethod
    def self.call
      ObjectSpace.each_object(Class) do |klass|
        klass.instance_methods.each do |method_name|
          camelized_name = method_name.to_s.camelize(:lower).to_sym
          next if camelized_name == method_name

          klass.instance_eval do
            alias_method(camelized_name, method_name)
          end
        rescue FrozenError => err
          next
        end
      end
    end
  end
end

以下のように呼び出すと、🐫で書くことが出来ます。

require_relative './lib/cameRuby/aliasMethod'
CameRuby::AliasMethod.call

puts 'foo'.respond_to?(:toS, false)
puts 'foo'.toS
puts String.respond_to?(:tryConvert, false)
puts String.tryConvert('str')

class Book
  def initialize(name, publishedAt)
    @name = name
    @publishedAt = publishedAt
  end

  attr_reader :name, :publishedAt
end

book = Book.new('foo', Time.now)
p book.name
#=> "foo"
p book.publishedAt.toS
#=> "2020-11-14 14:23:24 +0900"

ベンチマーク

method_missingを使う方法

大分パフォーマンスへの影響が大きい😓

require_relative './lib/cameRuby/methodMissing'
class Object
  include CameRuby::MethodMissing
end

n = 100000
Benchmark.bm(7, ">total:", ">avg:") do |x|
  x.report("to_s:") { "foo".to_s }
  x.report("toS:") { "foo".toS }
end

              user     system      total        real
to_s:     0.000008   0.000007   0.000015 (  0.000008)
toS:      0.000131   0.000001   0.000132 (  0.000132)

alias_methodを使う方法

意外と速い🤔(けど、メモリ上の全Classの各メソッドにalias_methodを実行するのでその時間は掛かる)

require_relative './lib/cameRuby/aliasMethod'
CameRuby::AliasMethod.call

n = 100000
Benchmark.bm(7, ">total:", ">avg:") do |x|
  x.report("to_s:") { "foo".to_s }
  x.report("toS:") { "foo".toS }
end

              user     system      total        real
to_s:     0.000009   0.000006   0.000015 (  0.000008)
toS:      0.000003   0.000001   0.000004 (  0.000002)

おわりに

軽く試してみた感じ🐫で書けそうな気配を感じましたが、大分パフォーマンスへの影響が大きく、method_missing or instance_eval,alias_methodで割と無理やりな感じになっているので、本番運用に耐えられそうにはないですし、使えたとしても趣味レベルな感じですね😅

あとはIDEのインテリセンスとかもかなり影響を受けそうなので、結果素直に🐍で書いたほうが良いということがわかった。。。

参考

qiita.com

docs.ruby-lang.org

Ruby: `pdf-reader`gemを使ってPDFをパースしてゴニョゴニョする

国が公開している資料が割とPDFで公開されていることが多く、このようなデータをアプリケーション内で使いたいと思ったときにPDFを頑張ってゴニョゴニョする必要があると思うのですが。。。

そういうときにpdf-readergemがシンプルにつかえて便利だったので使い方とかをMEMOしておきます。

github.com

pdf-readerの使い方

pdf-readerの使い方は簡単で、以下のようにgemをinstallするか、

 gem install pdf-reader

以下をGemfileに追記してbundle installします。

gem 'pdf-reader'

あとは以下のような形で本文にアクセス出来ます。

reader = PDF::Reader.new("somefile.pdf")

reader.pages.each do |page|
  puts page.text # 本文
end

readerをカスタマイズする

自分は以下のような形でカスタマイズして使ってみました。

単純にpdfをパースすると、空白文字や空行が大量にあったり、メタ情報が上部に毎回表示されたりするので、CustomReader::Page#formatted_recordsで、それらを除去して扱いやすいようにしています。

require 'pdf-reader'

class BaseReader
  def initialize(file_path:)
    @client = PDF::Reader.new(file_path)
  end

  attr_reader :client

  def pages
    client.pages.map { |page| Page.new(page) }
  end

  class Page
    def initialize(page)
      @page = page
    end

    attr_reader :page

    def text
      page.text
    end
  end
end


class CustomReader < BaseReader
  def pages
    client.pages.map { |page| Page.new(page) }
  end

  class Page < BaseReader::Page
    START_RECORD_INDEX = 18
    END_RECORD_INDEX = -2
    CLUMN_DELIMITER = "\t"

    def formatted_records
      # NOTE: 以下を行い整形
      # * 2文字以上の空白文字を列の区切りとみなし区切り文字に変換
      # * 空白な行を削除
      # * 各ページのheader部分の行を削除
      text.gsub(/\x20{2,}/, CLUMN_DELIMITER).split(/\R/).reject(&:empty?)[START_RECORD_INDEX..END_RECORD_INDEX]
    end
  end
end

またBaseReaderでclinetをインスタンス変数で持つようにしたので、client.pagesにアクセスできる、かつ、それが本文を返すtextメソッドを持つオブジェクトの配列であれば任意のクライアントに変更できるので、別のgemを使いたくなったときにも変更しやすいのかなと・・・!

おわりに

pdfのパースやる前はかなり大変なのかなと思っていたのですが、pdf-readerシンプルに本文を取り出せるので便利ですね✨

しかし、pdf内の表のセルの中で改行が入っていたりすると、同一セル内でも別の行と判断されフォーマットが激しく崩れるのでpdfのデータをいい感じにパースするのは厳しい。。。😢

CSV等の扱いやすいデータ形式で提供されておらず、傾向だけとか、一部データだけでも取り出したいようなケースではpdfをパースしてゴニョゴニョするのは良さそうですね👍

参考

qiita.com

MarkdownからRspecにリアルタイムで変換するWebエディタを作った + ActionCableの使い方メモ📝

ActionCableと前に作ったMarkdown形式のテキストをRSpec形式のテキストに変換するgemを使ってリアルタイムに変換できるWebエディタを作りました📝

f:id:madogiwa0124:20201031171112g:plain

https://markdown-to-rspec-web.herokuapp.com/

Markdown形式のテキストをRSpec形式のテキストに変換するgemに関してはこちらから

madogiwa0124.hatenablog.com

Action Cableを使ったのが初めてだったので、そのへんを含めて内部実装を少しメモしておきます。

アプリケーションの概要

以下が今回作ったリアルタイムでMarkdownからRspecに変換するWebエディタの内部実装の概要です。

f:id:madogiwa0124:20201031172602p:plain

エディタに入力されたMarkdown形式の文字列をActionCableを使ったWebSocket通信でバックエンドに投げて、バックエンドでMarkdownToRspecを使って変換をかけてフロントエンドに返しています。

WebSocketとは?

そもそも今回使ったWebsocket通信について、あんまり良くわかってなかったので概要をメモしておきます。

WebSocket(ウェブソケット)は、コンピュータネットワーク用の通信規格の1つである。ウェブアプリケーションにおいて、双方向通信を実現するための技術規格である。
サーバとクライアントが一度コネクションを行った後は、必要な通信を全てそのコネクション上で専用のプロトコルを用いて行う。従来の手法に比べると、新たなコネクションを張ることがなくなる・HTTPコネクションとは異なる軽量プロトコルを使うなどの理由により通信ロスが減る、一つのコネクションで全てのデータ送受信が行えるため同一サーバに接続する他のアプリケーションへの影響が少ないなどのメリットがある

ja.wikipedia.org

通常HTTPではリクエスト単位でTCPコネクションの確立/切断を行う必要があると思うのですが、WebSocketではその必要がなく、軽量なプロトコルでやり取りで来るので、効率よく双方向通信が行えるプロトコルといった感じなのでしょうか👀

ActionCableとは?

このWebSocketを用いた双方向通信をRuby on Rails上で簡単に実装できるようにしてくれるのがAction Cableです🚃

Action Cableは、 WebSocketとRailsのその他の部分をシームレスに統合するためのものです。 パブリッシャ側(Publisher)が、サブスクライバ側(Subscriber)の抽象クラスに情報を送信します。 このとき、個別の受信者を指定しません。Action Cableは、サーバーと多数のクライアント間の通信にこのアプローチを採用しています。 Action Cable の概要 - Railsガイド

ActionCableは、以下のような構成になっています。

  • フロントエンド
  • バックエンド
    • ApplicationCable::Connection: WebSocketコネクションの管理
    • ApplicationCable::Channelを継承した具象クラス: リクエストに対して処理を実行しレスポンスを返す。

大体の流れとしては、

  • foo_channelconsumerを使ってWebSocketのコネクションを確立をリクエス
  • ApplicationCable::Connectionがコネクションを承認
  • foo_channelが確立したコネクションを使ってリクエス
  • ApplicationCable::Channelがリクエストを受けて処理を実行し結果をブロードキャスト

上記のような流れになっているのかなと思っています🙇‍♂️(あんまり自信はない。。。)

またActionCableで使うadapterの設定等はconfig/cable.ymlで管理することが出来ます。 ※herokuにサクッとデプロイするときにproductionadapterの設定をredisからインメモリのasyncに変更する等で使用しました。

ActionCableを使った双方向通信の実装

今回は作成したリアルタイム変換エディタの実際のコードをもとに実装イメージをメモしておきます。

consumerを使ったフロントエンド側の実装

以下がconsumerの実装です。consumer.subscriptions.createの引数に紐づくバックエンド側のclass名と、バックエンド側に渡す引数を指定してサブスクリプション(WebSocket通信を行うクライアント側)を生成します。

今回は、変換リクエストを行うconvert内にマークダウン形式のテキストを引数にバックエンド側に変換リクエストを投げる処理、結果を受け取るreceivedに変換結果とともに完了イベントを発行する処理を追加しています。

this.perform("action_name", args)でバックエンド側のDocumentChannel#action_nameargsを引数に呼び出すような仕組みになっています。

import consumer from "@js/channels/consumer.ts";

// 複数画面開いた時に同一のChannelを参照して変更を共有しないように乱数で分けてる
const documentId = window.crypto.getRandomValues(new Uint16Array(1))[0];

export const documentChannel = consumer.subscriptions.create(
  { channel: "DocumentChannel", documentId: documentId },
  {
    connected: function () {},
    disconnected: function () {},
    // バックエンド側の変換処理の呼び出し
    convert: function (data: object) {
      return this.perform("convert", data);
    },
    // バックエンド側の変換結果の受け取り
    received: function (data: object) {
      const event = new CustomEvent("converted-document", { detail: data });
      document.dispatchEvent(event);
    },
  }
);

エディタの入力と変換完了のイベントを検知して、上記の実装をそれぞれ実行するようなJSの実装も合わせて用意します。

const markdownEditor = document.querySelector('#markdown-editor')
const convertedArea = document.querySelector('#converterd-area)

// Editor変更時にdocumentChannelの変換リクエスト送信を実行
markdownEditor.addEventListener('input', (markdown: string) => {
  documentChannel.convert({ to: "rspec", markdown: markdownEditor.value
});

// documentChannelのレスポンス受信時に発行される変換完了イベントを検知して結果を反映
document.addEventListener("converted-document", ((event: CustomEvent) => {
  convertedArea.value = event.detail.convertedDocument;
}) as EventListener);

これでフロントエンドからコネクション確立のリクエスト、MarkdownからRspec形式への変換リクエストの発行/レスポンスの受領ができるようになりました🙌

ApplicationCable::Channelを使ったバックエンド側の実装

今回は認証等を行わないので、ApplicationCable::Connectionは使用せず、 ApplicationCable::Channelのみを使用します。

ちなみに、WebSocketではセッションを参照できないのでログイン中のuserのidを署名付きcookieを使って認証するのが推奨されているようです。

greg.molnar.io

以下が今回実装したApplicationCable::Channelを継承した具象クラスです。

フロントエンド側からのコネクション確立リクエストに対してsubscribedが実行され、双方向通信のストリームを"document_channel_#{params[:documentId]}"という名前で作成します。 ※このストリームを指定することによって、ストリームにつながっているクライアントに対してブロードキャストすることが出来ます。

フロントエンド側からの変換リクエストに対してconvertが実行され、MarkdownからRspecへの変換をかけて結果を返却しています。

class DocumentChannel < ApplicationCable::Channel
  def subscribed
    stream_from "document_channel_#{params[:documentId]}"
  end

  def unsubscribed; end

  def convert(data)
    converted = converted_document(to: data['to'], markdown: data['markdown'])
    ActionCable.server.broadcast "document_channel_#{params[:documentId]}", convertedDocument: converted
  end

  private

  def converted_document(to:, markdown:)
    converters = { rspec: -> { ::MarkdownToRspec.to_rspec(markdown) } }
    converters[to.to_sym]&.call
  end
end

これでWebSocketを使って、エディタに入力されたMarkdown形式の文字列をRspec形式に変換する処理をリアルタイム呼び出して結果を反映できるようになりました📝

おわりに

今回はAction Cableの概要とそれを使って実際に作ってみたエディタに関するメモを書いてみました📝

ActionCableは今まで使ったことがなかったのですが、使ってみるとサクッとControllerライクに双方向通信が実装できて使いやすいですね🚃

参考

qiita.com

Ruby: ActiveSupportコア拡張を使って任意の定数を持たない特定のクラスを継承したクラスを見つける

ライブラリのバージョンアップ等で親クラスが子クラスが持つ特定の定数に依存するようになりエラーが発生することがあり。。。

特定のクラスを継承したクラスの中で特定の定数を持たないクラスを探すコードを書いたのメモしておきます📝

サンプルコード

例えば、以下のような子クラスに定義されたATTRIBUTESを参照して、hashを作るようなメソッドが親クラスに実装されていて、一部の子クラスにATTRIBUTESが定義されていないケースをサンプルにしています。

今回のケースではEnglishNameATTRIBUTESを持たないのでEnglishName.new.attributes実行時にuninitialized constant EnglishName::ATTRIBUTESが発生しています。

class BaseName
  def attributes
    self.class::ATTRIBUTES.inject({}) do |result, attr|
      result.tap { result[attr] = public_send(attr) }
    end
  end
end

class JapaneseName < BaseName
  ATTRIBUTES = [:first_name, :last_name]

  def first_name
    'Taro'
  end

  def last_name
    'Yamada'
  end
end

class EnglishName< BaseName
  def first_name
    'John'
  end

  def middle_name
    'Fitzgerald'
  end

  def last_name
    'Smith'
  end
end

JapaneseName.new.attributes
#=> {:first_name=>"Taro", :last_name=>"Yamada"}
EnglishName.new.attributes
#=> `attributes': uninitialized constant EnglishName::ATTRIBUTES (NameError)

任意の定数を持たない特定のクラスを継承したクラスを見つけるコード

今回はActiveSupportのコア拡張を使ってコードを作成します🚃

railsguides.jp

以下がエラーが発生する、BaseNameを継承していて定数ATTRIBUTESを持たないクラスを取得するスクリプトです。

parent_class = Base
target_const = :ATTRIBUTES

puts parent_class.subclasses.select do |sub|
  sub.constants.exclude?(target_const)
end
#=> EnglishName

subclassesで子クラスの配列を取得してconstantsでそれぞれの子クラスの持つ定数を取得して、ATTRIBUTESを持たないクラスを取得しています💻

おわりに

RubyActiveSupportのコア拡張を使うと割と自由にクラスを探索出来て便利ですね✨

CircleCIのorbsを使って設定ファイルを整理するMEMO

CircleCIのorbsをあまり使ってことなかったのですが、使ってみるとだいぶ便利だったので使い方をMEMOしておきます📝

CircleCI orbsとは?

CircleCI Orbs は、ジョブ、コマンド、Executor などの構成要素をまとめた共有可能なパッケージです。 CircleCI では承認済み Orbs に加え、CircleCI パートナーによってオーサリングされたサードパーティ製の Orbs も提供しています。 https://circleci.com/docs/ja/2.0/orb-intro/

CircleCIでの処理をまとめたパッケージがorbsという感じのようです。

実際に使ってみる

まずorbsを使うには設定ファイルでorbs配下に使用するorbsを羅列します。

version: 2.1

orbs:
  ruby: circleci/ruby@1.1.1

これでcircleci/ruby@1.1.1rubyという名前で設定ファイル上にて使用できるようになりました。

circleci/ruby@1.1.1は何が出来るのかというのは下記のドキュメントに記載があります📘

https://circleci.com/orbs/registry/orb/circleci/ruby

このorbsinstall-depsを使用して依存関係を解決するようにしてみます💎

今回は以下のようなcommandsを用意してorbsの機能を利用するようにしてみました。

commands:
  install_ruby_deps:
    steps:
      - ruby/install-deps:
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
          path: ./vendor/bundle
          with-cache: true

keyにcacheのkey、pathにcacheするパス、with-cacheにcacheするかどうかを渡しています。

orbsを使わないとcacheのrestoreやbundlerのinstall等を自分で書かないといけないと思うのですが、orbsを使うと便利ですね✨

commands:
 configure_bundler:
    steps:
      - run:
          name: Configure Bundler
          command: |
            echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
            source $BASH_ENV
            gem install bundler -v $BUNDLER_VERSION
  install_ruby_deps:
    steps:
      - run:
          name: install dependencies
          command: bundle install --jobs=4 --clean --path ./vendor/bundle
  cache_ruby_deps:
    steps:
      - save_cache:
          name: Cache ruby dependencies
          paths:
            - ./vendor/bundle
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
  restore_ruby_deps:
    steps:
      - restore_cache:
          name: Restore ruby dependencies
          keys:
            - v1-dependencies-{{ checksum "Gemfile.lock" }}
            - v1-dependencies-

※commandsについては以前使い方をまとめたので、よろしければこちらも参考にしてください。

madogiwa0124.hatenablog.com

以下にrubocoprspecを実行するサンプルを書いてみました。orbsを使うと非常にスッキリかけますね✨

version: 2.1

orbs:
  ruby: circleci/ruby@1.1.1

web: &web
  - image: circleci/ruby:2.7.1-node-browsers

executors:
  web:
    docker:
      - <<: *web

commands:
  attach_current:
    steps:
      - attach_workspace:
          at: .
  install_ruby_deps:
    steps:
      - ruby/install-deps:
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
          path: ./vendor/bundle
          with-cache: true

jobs:
  build:
    executor:
      name: web
    steps:
      - checkout
      - persist_to_workspace:
          root: .
          paths:
            - .
  ruby_build:
    executor:
      name: web
    steps:
      - attach_current
      - install_ruby_deps
  ruby_lint:
    executor:
      name: web
    steps:
      - attach_current
      - install_ruby_deps
      - run:
          name: run rubocop
          command: bundle exec rubocop --parallel
  ruby_test:
    executor:
      name: web
    steps:
      - attach_current
      - install_ruby_deps
      - run:
          name: run tests
          command: bundle exec rspec spec/
workflows:
  version: 2
  build:
    jobs:
      - build
      - ruby_build:
          requires:
            - build
      - ruby_lint:
          requires:
            - ruby_build
      - ruby_test:
          requires:
            - ruby_build

今回は使っていませんが、circleci/rubyにはrubocoprspecを実行する機能もあるので、気になった方はそちらも使ってみてはいかがでしょうか💎

それでは。

参考

https://circleci.com/docs/ja/2.0/orb-intro/

https://circleci.com/orbs/registry/orb/circleci/ruby

Ruby on Rails: WebpackerからピュアなWebpack環境に置き換えるメモ📝

最近個人のRailsアプリケーションをWebpackerからSimpackerを使ったピュアなWebpack環境に切り替えたので、その手順をメモしておきます📝

今回はWebpackerを導入しときのデフォルトなjs/scssなbuild環境を削除して、Webpackで置き換えていきます。

Webpackerを辞める

まずは既存のRailsアプリケーションからWebpackerを外していきます。

新規でRailsアプリケーション作る場合は当該手順は不要で単にrails new –skip-javascriptを実行して、js環境をrails new時に作るのをやめてあげると良いかと思います。
※Webpackで管理するならsproketsturbolinksもいらない気がするので、上記に加えて--skip-sprockets--skip-turbolinksもつけておくと良さそうです。

Webpackerをuninstallする

Gemfileからwebpackerpackage.jsonから@rails/webpackerを削除します。 ※webpack --watchで十分な場合はwebpack-dev-serverpackage.jsonから削除します。

その後bundle installyarn installを実行してlockファイルからも消します。

Webpacker関連ファイルを削除する

unistallが完了したあと、Webpackerが作成した各種ファイルを削除します。

  • .browserslistrc
  • babel.config.js
  • bin/webpack
  • postcss.config.js
  • config/webpack配下の各種ファイル

webpack-dev-serverをuninstallした場合はbin/webpack-dev-serverも削除します。

これでWebpackerを辞めることが出来ました。

ピュアなWebpack環境を作る

Webpackの導入

Webpackの導入については、過去に基本的な使い方をまとめた記事を書いているので、そちらを参考にしてください。

madogiwa0124.hatenablog.com

一応私が作ったjs/scssのbuild環境のwebpack.config.jsをサンプルとして記載しておきます。 ポイントは、Rails側で読み込むべきbuild後のjs及びcssを判定するためにWebpackAssetsManifestで結果をjsonファイルで出力する必要があるところです👷‍♀️

const glob = require("glob");
const path = require("path");

function entryName(RootPath, filePath) {
  const dirName = filePath
    .replace(RootPath, "")
    .replace(path.basename(filePath), "");
  return `${dirName}${path.basename(filePath, path.extname(filePath))}`;
}

/**
 * 指定したentryのルートディレクトリ配下のjsまたはtsファイルのファイル名とパスのobjectを取得
 * 例)
 *  - /entries/foo.ts => { foo: "/entries/foo.ts" }
 *  - /entries/bars/bar.ts => { "bars/bar": "/entries/bars/bar.ts" }
 * @param {string} entryRoot entryのルートディレクトリ
 */
const getEntries = function getEntries(entryRoot) {
  const ret = {};
  const filePaths = glob.sync(`${entryRoot}/**/*.{js,ts}`);
  filePaths.forEach((filePath) => {
    ret[entryName(entryRoot, filePath)] = filePath;
  });
  return ret;
};

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const WebpackAssetsManifest = require("webpack-assets-manifest");

const JAVASCRIPT_ENTRY_PATH = "./app/javascript/packs/";
const entries = getEntries(JAVASCRIPT_ENTRY_PATH);
const { NODE_ENV } = process.env;
const isProd = NODE_ENV === "production";

module.exports = {
  mode: isProd ? "production" : "development",
  entry: entries,
  output: {
    path: `${__dirname}/public/packs`,
    publicPath: "/packs/",
    filename: "[name]-[hash].js",
  },
  module: {
    rules: [
      {
        test: /\.scss|\.css/,
        use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
            plugins: ["@babel/plugin-transform-runtime"],
          },
        },
      },
    ],
  },
  resolve: {
    alias: {
      "@js": `${__dirname}/app/javascript`,
      "@css": `${__dirname}/app/javascript/stylesheets`,
    },
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: "[name]-[hash].css" }),
    new CleanWebpackPlugin(),
    new WebpackAssetsManifest({ publicPath: true })
  ],
};

またbuild用のscriptもpackage.json内に以下のような感じで定義しました。

  "scripts": {
    "webpack": "webpack",
    "webpack-prod": "webpack --mode=production",
    "webpack-watch": "webpack --watch",
  },

ビルドしたjsを読み込むhelperを作る

Webpackerを使うとapplication_pack_tagのようなhelperが自動で使えるようになりますが、ピュアなWebpack環境では自身でWebpackのbuild時に作成されたmanifestを読み込んでファイルパスを返すようなhelperを独自で作る必要があります。 ※またアクセス時のbuildチェックもして、自動でbuildを走らせるような処理も行われません。

そのため今回はSimpackerというgemを使ってapplication_pack_tagを再現します。

github.com

Simpackerの導入は、 Gemfileに下記を追加してbundle installします。

gem 'simpacker'

まだwebpack環境を作っていない人はrails simpacker:installを実行するとSimpackerがシンプルな環境を作ってくれます。

作成される環境はこちらを参照

simpacker/lib/install at master · hokaccha/simpacker · GitHub

yarnではなくnpmで環境が作成されるので注意してください。

すでにWebpack環境を作成済みの人は、下記のsimpacker.ymlconfig配下に配置して自身の設定に合わせて編集すれば問題ないと思います🙆‍♂️

simpacker/simpacker.yml at master · hokaccha/simpacker · GitHub

これでapplication_pack_tagが定義されて、Webpackでbuildしたjs/cssrails側で読み込めるようになりました🙌

おわりに

今回はWebpackerからSimpackerを使ってピュアなWebpack環境に置き換える手順をメモしました📝

素のWebpackerのまま使う時は非常に導入も楽なのですが、ちょっとカスタマイズが必要になってくるとカオスになってくるので、自分もまだフロントエンドまわりは分からないことが多いですが、複雑になったらWebpackerからWebpackに乗り換えるといったことや、最初からWebpackを選択するようなことが出来るようになっておきたいですね💦(実際のサービスを置き換えるときには、こんなにすんなりとはいかないと思いますが。。。)

参考

inside.pixiv.blog

techlife.cookpad.com

Ruby on Rails: deviseが使用する項目とユーザー情報のモデルを分離して肥大化を抑制するMEMO

最近deviseをちょっと触ってて自分が特に何も考えずに使っていくとUserモデルがどんどん肥大化していってしまうなぁと思い、、、

deviseのために追加する項目(email, encrypted_password等)とプロフィール的な項目(nickname, birthday等)でモデルを分けると下記のようなメリットがあって良さそうかなと思ったので、

  • 認証とプロフィール的な部分が分離出来るのでUserモデルの肥大化が抑えられる
  • 認証システムをdeviseから変更する場合にもアプリケーションで使用するユーザーの情報への影響を防げる
  • 認証部分をユーザー情報のカラム定義変更等によるテーブルへのロックの影響から防げる

deviseのコードとかを読んだりしながらいい方法かは微妙ですが、実現方法等を色々考えたことをMEMOしておきます📝

実現したいこと

下記のようにUserにdeviseに必要な項目を持たせて、User::Profileにアプリケーションで必要な情報をもたせるような構成を目指していこうと思います。

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable, :timeoutable

  has_one :profile, class_name: 'User::Profile', dependent: :destroy inverse_of: :user
end

class User::Profile < ApplicationRecord
  belongs_to :user

  validates :nickname, presence: true
end

なぜdeviseを使っているとUserモデルにカラムを追加したくなってしまうのかの個人的な考え

一旦そもそもdeviseを使っているとなぜUserモデルにすべてを入れてしまいたくなってしまうのかをdeviseのコードを読んで考えてみました、下記がdeviseのユーザー作成時のコードを抜粋したものになっています。

class Devise::RegistrationsController < DeviseController
  def create
    build_resource(sign_up_params)

    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

一見メソッド内にyield resource if block_given?があるのでForm必要な項目を追加 + strong_parameterを調整した上で下記のようにControllerのアクションをオーバーライドしてあげれば一応、Userの作成時にUser::Profileも作成出来そうなのですが、

yield resource if block_given?前にresource.saveが実行されているため、UserUser::Profileトランザクションが別になってしまうので、バリデーションが必要な項目等がUser::Profileに含まれているとUserは作成されるけどUser::Profileは作成されないといったことが発生する恐れがある気もしました😢

class Users::RegistrationsController < Devise::RegistrationsController
  def create
    super do |user|
      user.create_profile(nickname: sign_up_params[:nickname])
    end
  end
end

こんな感じでやるとトランザクションが同じになり、User::Profileの作成に失敗したときにUserの作成をロールバックできそうですが、トランザクションの範囲が広いのと、エラーハンドリング周り等は作り込みが必要になります。。。

class Users::RegistrationsController < Devise::RegistrationsController
  def create
    ActiveRecord::Base.transaction do
      super do |user|
         user.create_profile!(nickname: sign_up_params[:nickname])
      end
    end
  end
end

このような感じでdevise実装を活かしつつ、複数のモデルを扱うのは、deviseの既存実装を追わないとなかなか難しそうな気がしたのでUserにカラムを追加していくという判断がされやすいのかなと思いました🙇‍♂️

どうやるのがよさそうか個人的に考えた方法

nested_attributes_forを使う

nested_attributes_forは、結構ハマりどころが多いのですが今回のケースだと複数のモデルを同一トランザクションで扱いかつ割とスッキリ書けそうかなと・・・!(ちょっとdevise_parameter_sanitizerのオーバーライドの箇所だけあれですが💦)

ポイントはnested_attributes_forを使うことで既存実装のresouce.saveで関連モデルも作成出来るとこでしょうか。

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable, :timeoutable

  has_one :profile, class_name: 'User::Profile', dependent: :destroy, required: true, inverse_of: :user
  accepts_nested_attributes_for :profile
end

class User::Profile < ApplicationRecord
  belongs_to :user

  validates :nickname, presence: true
end
class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]
  before_action :configure_account_update_params, only: [:update]

  def new
    super do |user|
      user.build_profile # formにprofileの要素を表示するためにbuildしとく
    end
  end

  # If you have extra params to permit, append them to the sanitizer.
  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up) do |user|
      user.permit(
        *Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES[:sign_up],
        :email, profile_attributes: [:nickname, :id]
      )
    end
  end

  # If you have extra params to permit, append them to the sanitizer.
  def configure_account_update_params
    devise_parameter_sanitizer.permit(:account_update) do |user|
      user.permit(
        *Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES[:account_update],
        :email, profile_attributes: [:nickname, :id]
      )
    end
  end
end

UserとUser::Profileの更新が同一トランザクションになってます👀

Started POST "/users" for 172.29.0.1 at 2020-08-30 07:34:45 +0000
Processing by Users::RegistrationsController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"profile_attributes"=>{"nickname"=>"testaaaa"}, "email"=>"test1@example.com", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Sign up"}
   (0.7ms)  BEGIN
  User Exists? (1.4ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "test1@example.com"], ["LIMIT", 1]]
  User Create (4.2ms)  INSERT INTO "users" ("email", "encrypted_password", "confirmation_token", "confirmation_sent_at", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["email", "test1@example.com"], ["encrypted_password", "$2a$12$2dlJsGlGxCauby1HwZ8VkOmlrVQHHrgdRsm8t2nPCmS6eMujijnyW"], ["confirmation_token", "gezndhZwPZayruyXoxsQ"], ["confirmation_sent_at", "2020-08-30 07:34:46.193200"], ["created_at", "2020-08-30 07:34:46.192922"], ["updated_at", "2020-08-30 07:34:46.192922"]]
  User::Profile Create (3.2ms)  INSERT INTO "user_profiles" ("user_id", "nickname", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["user_id", 118], ["nickname", "testaaaa"], ["created_at", "2020-08-30 07:34:46.202306"], ["updated_at", "2020-08-30 07:34:46.202306"]]
   (3.1ms)  COMMIT

またdeviseで扱うscopeをUserではなくてAccountにして、アプリケーションで管理する情報をUserに保存してあげてcurrent_userを下記のようにしてあげると、current_user.attributesとかをしてもemailとか認証用に保持している情報が出力されにくくなるので良いのかもしれない🤔

class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  def current_user
    current_account.user
  end
end

deviseのregistrationとは別画面でアプリケーションに必要な情報を入力させる

deviseのユーザー作成ページではあくまで認証で使用するemailpasswordの入力だけにしておく(こうしておくとSNS認証とかページのレイアウトを制御出来ない場合にも対応できそう)とdeviseのcontollerをいじらなくて済みますし、ログイン出来るようになるまでに入力項目がたくさんあると、そもそもアカウントを作ってもらえなくなってしまう恐れもあるので入力項目が多い場合は分けたほうがいいのかなと思いました😓

下記のような感じのUser::Profileに値がなかったらプロフィール入力画面に遷移させるようなメソッドを定義しておいて、before_actionで遷移させるとかするといいのかも知れないですね👀

class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  private

  def required_profile
    redirect_to new_profile_path if current_user.profile.nil?
  end
end

※deviseのcontrollerはデフォルトだとApplicationControllerを継承しているのでrequired_profileを実行する場所は注意

頑張って既存のメソッドをオーバーライドする

既存のメソッドを頑張ってオーバーライドするのはセキュリティパッチ等があった場合にも自分で対応しないと行けないので割と大変そうなので、なんとか最終手段にしときたいですね😓

おわりに

個人的には最初は既存の作成/更新ロジックに手を入れなくて済むので追加項目が非常に少ないならnested_attributes_forにしておいて、項目がある程度あるなら画面を分けて、要件的にかなり独自色があり厳しくなってきたら、deviseのコードを理解した上で、メソッドを適切にオーバーライドしつつFormObjectとかにうまいこと切り出してあげるのがいいのかなぁと思いました。

deviseは非常に便利な反面、deviseが想定していないような作りのものを実現しようとすると、割と慎重な判断が求められる部分でもありますし、どう実装するのが良いか適切に判断してくのが難しいですね💦

参考

medium.com

qiita.com

rubydoc.info