Madogiwa Blog

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

Ruby on Rails × LighthouseをCircleCIからGithub Actionに移行するときのMEMO

今回は以下で作成したCircleCI上で実行するRuby on Rails × LighthouseをGithub Actionsに移行したので、その辺のハマったところとかのポイントをメモしておきます📝

madogiwa0124.hatenablog.com

最終的なGithub Actionsの定義はこちら

name: lighthouse

on: [pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:password@127.0.0.1:5432
    services:
      db:
        image: postgres:10.13-alpine
        ports:
          - 5432:5432
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: password
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-ruby@v1
      with:
        ruby-version: '2.7.2'
    - uses: actions/setup-node@v1
      with:
        node-version: '12'
    - name: cache ruby deps
      uses: actions/cache@v1
      with:
        path: vendor/bundle
        key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-gem-
    - name: cache node deps
      uses: actions/cache@v1
      with:
        path: node_modules
        key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-node_modules-
    - name: install bundler
      run: gem install bundler -v 2.1.4
    - name: install ruby deps
      run: bundle install --jobs 4 --retry 3 --path vendor/bundle
    - name: install node deps
      run: yarn install
    - name: db migration
      run: |
        bundle exec rake db:create
        bundle exec rake db:schema:load
    - name: build webpack
      run: NODE_ENV=production bundle exec rails webpacker:compile
    - name: run lighthouse
      run: yarn run lighthouse --chrome-flags="--headless"
    - uses: actions/upload-artifact@v2
      with:
        name: lighthouseci_result
        path: .lighthouseci_result

移行する際のポイント

移行する際のポイントはGithubの公式ドキュメントがあるので、一読しておくと参考になるかと思います🐙

docs.github.com

以下には実際に移行していて感じたポイントを上記のドキュメントとかぶるところもありますが、

デフォルトでinstallされているもの

Github Actionsの実行環境にはデフォルトでRubyChromeDriver等の言語やツールがinstallされています、詳しくは以下のドキュメントで自身が利用する環境をご確認してみると良さそうです✨

docs.github.com

使用するImageをcircleci/fooから移行

Github Actionに移行する際にCircleCIで利用していたcircleci/fooといったimageをそのまま使えると楽だなと思ったのですが、公式では非推奨なようです。(実際に今回移行した際にも権限周りでうまくいきませんでした😢)

GitHub Actionsへの移行に際しては、CircleCIの構築済みイメージから離脱することをおすすめします。 多くの場合、必要な追加の依存関係のインストールにアクションを使うことができます。 https://docs.github.com/ja/free-pro-team@latest/actions/learn-github-actions/migrating-from-circleci-to-github-actions#docker%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%81%AE%E5%88%A9%E7%94%A8

今回はもともとcircleci/ruby:2.7.2-node-browsersを使っていたのですが、rubyとnodeの環境をactions/setup-rubyactions/setup-nodeで実行環境であるubuntu-latest上に構築して、circleci/postgres:10.13ではなく、postgres:10.13-alpineを使用するように修正しました。

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:password@127.0.0.1:5432
    services:
      db:
        image: postgres:10.13-alpine
        ports:
          - 5432:5432
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: password
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-ruby@v1
      with:
        ruby-version: '2.7.2'
    - uses: actions/setup-node@v1
      with:
        node-version: '12'

cacheの利用

もともとorbsの機能を利用してライブラリのcacheを取得していたのですが、

orbs:
  ruby: circleci/ruby@1.1.1
  node: circleci/node@4.0.0

Github Actionsには、それに相当するようなactionsが見つけレレらなかったので以下のような形でchaceを取得するactionとinstallするstepを用意しました。

    - name: cache ruby deps
      uses: actions/cache@v1
      with:
        path: vendor/bundle
        key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-gem-
    - name: cache node deps
      uses: actions/cache@v1
      with:
        path: node_modules
        key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-node_modules-
    - name: install bundler
      run: gem install bundler -v 2.1.4
    - name: install ruby deps
      run: bundle install --jobs 4 --retry 3 --path vendor/bundle
    - name: install node deps
      run: yarn install

(こういったコミュニティ系の機能は、まだCircleCIのほうが豊富で便利な気もしました。)

artifactのアップロードまわり

CircleCIだとstore_artifactsアーティファクトとしてファイルをアップロードしてブラウザ上で参照することができます。

  store_lighthorse_atifacts:
    steps:
      - store_artifacts:
          path: .lighthouseci_result

Github Actionにも同様な機能が提供されていて以下のような形で利用できます。

    - uses: actions/upload-artifact@v2
      with:
        name: lighthouseci_result
        path: .lighthouseci_result

しかしCircleCIのようにブラウザ上で閲覧することはできず、zipファイルとしてダウンロードする必要があるようです・・・!

上記のようなことのやり方としては、htmlの場合はgithub pagesにアップロードしたり、リポジトリとにpushするような利用を想定しているようです。

github.community

おわりに

今まで割とCircleCIを使うことが多かったのですが、Github Actionでも出来ることも多いですね✨

いい感じに分散させてCIの効率を上げていけると待ち時間も削減できてよさそうです👷‍♀️👷‍♂️

参考

qiita.com

RubyonRails: WebpackerなRailsアプリケーションでSourceMapをRollbarにアップロードするMEMO

WebpackerでRailsアプリケーションでエラー管理にRollbarを使用しているときに、デフォルトだとエラーが発生してもminify等がされたjsにリンクされてしまい何が起きているのか、よくわかりません。。。

そんなときにSourceMapをRollbarにアップロードするとエラー発生箇所が特定しやすくなるので、そのへんのやり方をメモしておきます📝

ちなみに今回の環境は以下です。

  • rails: 6.0.3.4
  • webpacker: 5.2.1

また前提として以下のドキュメントの通りBrowserJsのRollbarの設定は完了しているものとします。

docs.rollbar.com

SourceMapとは

ソースマップ は変換後のソースと元のソースを関連付けるファイルであり、ブラウザーが元のソースを再構成して、そのソースをデバッガーに提供できます。

ソースマップを使用する - 開発ツール | MDN

Webpackを利用したminified等、jsのソースコードは実際に実行されるときには元のソースコードから変換されていることが多いので、元のソースコードを生成するための情報をブラウザに提供するものといった感じと理解しました👀

SourceMapをアップロードする

SourceMapをアップロードするにはRollbarの特定のEndpointにminifiedしているjsのファイルごとにリクエストを投げる必要があります。

docs.rollbar.com

単一のjsにまとめているような運用なら以下のようなCI系の機能を使えば簡単に出来そうなのですが、Webpackerのデフォルトのようにエンドポイントごとにentryを分けるような設定の場合には、entryごとにリクエストを投げる必要があるので、結構たいへんです😓

github.com

circleci.com

そんなときに役に立つのがrollbar-sourcemap-webpack-pluginです📦

github.com

this.getAssets(compilation)でソースファイルとSourceMapを集めて、それぞれRollbarにUploadしてくれます📮

// [https://github.com/thredup/rollbar-sourcemap-webpack-plugin/blob/master/src/RollbarSourceMapPlugin.js#L131-L141]

  uploadSourceMaps(compilation) {
    const assets = this.getAssets(compilation);

    /* istanbul ignore next */
    if (assets.length > 0) {
      process.stdout.write('\n');
    }
    return Promise.all(
      assets.map(asset => this.uploadSourceMap(compilation, asset))
    );
  }

rollbar-sourcemap-webpack-pluginは以下のような形でinstallします。

$ yarn add rollbar-sourcemap-webpack-plugin

私は、以下ような感じにconfig/webpack/production.jsを修正しました。 各設定値の詳細はREADMEを参照してください📝

process.env.NODE_ENV = process.env.NODE_ENV || "production";

const RollbarSourceMapPlugin = require("rollbar-sourcemap-webpack-plugin");

// NOTE: `post_server_item`以上のscopeを持つtokenでないと権限エラーになる。
const token = process.env.ROLLBAR_POST_SERVER_ITEM_ACCESS_TOKEN;
// NOTE: 最新のcommit hashを設定
const codeVersion = process.env.SOURCE_VERSION;
// NOTE: minifiedされたソースコードの配置先
const publicPath = process.env.PUBLIC_PATH + "/packs";
const RollbarSourceMapPluginConfig = require("./plugins/rollbarSourceMapConfig");
const environment = require("./environment");

RollbarSourceMapPluginConfig = new RollbarSourceMapPlugin({
  accessToken: token,
  version: codeVersion,
  publicPath: publicPath,
  ignoreErrors: true, // NOTE: uploadに失敗してもdeployが失敗しないようにエラーを無視
});

// NOTE: SourceMapが無効になっているとupload時に以下のエラーが発生するっぽいので`hidden-source-map`を設定
// Rollbar: Error: Source map missing property 'names'
environment.config.merge({
  devtool: "hidden-source-map",
});

environment.plugins.prepend("RollbarSourceMapPlugin", RollbarSourceMapPluginConfig);

process.env.SOURCE_VERSIONにはdeploy時の最新のcommit hashを SourceMapをuploadするwebpackのbuild前に設定しておく必要があります。

CI系のサービスを利用してdeployしている場合には、すでに環境変数に入っているものもあるようです👀

CircleCI

CIRCLE_SHA1 The SHA1 hash of the last commit of the current build. https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables

GitHub

GITHUB_SHA The commit SHA that triggered the workflow. For example, ffac537e6cbbf934b08745a378932722df287a53. https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables

Heroku

SOURCE_VERSION For git-push builds, this is the git commit SHA-1 of the source being built. https://devcenter.heroku.com/changelog-items/630

これで本番のWebpackのビルド時にSourceMapが送信されるようになりました🎉

RollbarにアップロードしたSourMapは、Project > Settings > Source Mapsで見ることが出来ます。

UploadしたSourMapを利用するように既存のRollbarの設定を修正

SourceMapがアップロードされるようになったら、次はError通知時にSourceMapが利用されるように設定を変更していきます⚙

変更した設定が以下の通りです、client部分が追加した設定になります。 詳細は以下のURLから公式ドキュメントを確認してください📝

https://docs.rollbar.com/docs/source-maps#2-configure-the-rollbarjs-sdk-to-support-source-maps

Rollbarはpayload.client.code_versionの値とアップロードされたSourceMapのversionで紐付けているようです🤝

const token = process.env.ROLLBAR_POST_CLIENT_ITEM_ACCESS_TOKEN;
const railsEnv = process.env.RAILS_ENV;
const codeVersion = process.env.SOURCE_VERSION;
const rollbarParamsValidation = () => !(railsEnv === "test") && token;

if (rollbarParamsValidation()) {
  let _rollbarConfig = {
    accessToken: token,
    captureUncaught: true,
    captureUnhandledRejections: true,
    payload: {
      environment: railsEnv,
      client: {
        javascript: {
          source_map_enabled: true,
          code_version: codeVersion,
          guess_uncaught_frames: true,
        },
      },
    },
  };

// Rollbar Snippet
// ...
// End Rollbar Snippet

これで以下のような感じでSourceMapを活用して、minified前の実際にエラーの発生したfileの行数で見ることが出来るようになりました🙌

f:id:madogiwa0124:20201129180228p:plain

参考

engineer.crowdworks.jp

qiita.com

Ruby on Rails: Rails 6.1.rc1が出たので個人Webアプリケーションを6.0.3.4から上げてみた🚃

2020/11/3にrails 6.1.rc1がリリースされました🎉

github.com

ほぼピュアなRailsアプリケーションなので、あんまり実務的にはそこまで参考にならないですが個人で運用しているアプリケーションを6.1.rc1にアップグレードしたので手順とかをMEMO📝

ちなみに6.0から6.1の破壊的変更まわりはedgeguides.rubyonrails.orgrails 6.0から6.1のupgrade guideが公開されているので気になる人は見てみてください。

edgeguides.rubyonrails.org

またリリースノートはこちら📒

edgeguides.rubyonrails.org

↓実際にupgradeしたPR

github.com

rails 6.1にbundle update

まずはGemfileのrailsのバージョンを上げて

gem 'rails', '~> 6.1.rc1'

bundle updateします。

$ bundle update rails

実務ではconservativeオプションを付けて必要最低限のgemだけアップグレードするようにすると影響範囲が限定的になって良いと思います。

--conservative Use bundle install conservative update behavior and do not allow shared dependencies to be updated.

https://bundler.io/man/bundle-update.1.html#OPTIONS

設定ファイルを更新

rails app:updateを使ってもいいのですが、railsdiffで設定ファイルの差分を見て、手動で取り込む方法にしました。

http://railsdiff.org/6.0.3.4/6.1.0.rc1

気になったところ

ほぼピュアなRailsアプリケーションなので殆ど修正する箇所は無く、あまり参考にならないのですが、一応テストが落ちるようになったような修正としては以下ぐらいでした。

環境別の設定ファイルで"active_support/core_ext/integer/time"を明示的にrequireする

タイトルの対応が以下のコミットで入っています👀

github.com

設定ファイル内の影響がありそう部分(config.public_file_server.headers'Cache-Control' => "public, max-age=#{1.hour.to_i}")が入ったのがどうやら2年前みたいなのですが、rails 6.1でロードの順番とかが変わったのかと思ったのですが🤔

github.com

config.active_support.bare を無効な場合でも起動時にエラーとならないようにした意図のようですね🙌

config.active_support.bare: Rails起動時にactive_support/allの読み込みを行なうかどうかを指定します。デフォルトはnilであり、この場合active_support/allは読み込まれます。

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

おわりに

Rails 6.1ではstrict_loadingDelegated types、複数DB周りの改善、routesの分割等、気になる機能も多いですね・・・!✨

参考

inside.pixiv.blog

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

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 on Rails: Asset Pipelineを使わないwebpackなRailsアプリケーションをHerokuでデプロイする

個人アプリの開発ではHerokuを使うことが多いのですが、最近webpack + Railsの環境を使っていてデプロイまわりで少しハマったので、デプロイ方法をメモしておきます📝

Herokuのデプロイついて

使用する Heroku buildpack によって、アプリケーションの開発プロセスや、コードの実行時に用意すべきアセットやランタイムが決まります。複雑なアプリケーションで複数の言語を実行する場合は、1 つのアプリケーションで複数の buildpack を使用することもできます。

Herokuのデプロイというのはbuildpackという仕組みを使って行われているようです。

devcenter.heroku.com

buildpackは様々な言語をサポートしていて、これらによってpushするだけでデプロイされるようなheroku のシンプルなしくみが実現出来ているんですね。

Herokuによる標準のRailsデプロイ

Railsアプリケーションにおいて、デフォルトでは以下のようなbuildpackな設定になっています。

$ heroku buildpacks
=== app-name Buildpack URLs
1. heroku/ruby

RubyのbuildpackではRailsのサポートが入っているので、assets:precompileがデプロイ時に実行されsproketswebpackerで管理しているassets等のファイルのbuildが走ります。

devcenter.heroku.com

しかし、sproketswebpackerも使わずwebpackのみで管理しているいる場合、assets:precompileは使わないのでフロントエンドまわりのファイルのbuildが走りません。。。

WeboackなRailsをHerokuにデプロイする

ではどうするかというと、nodebuildpackを追加して、yarn install及びyarn buildが実行されるようにします。

$ heroku buildpacks:add --index 1 heroku/nodejs
$ heroku buildpacks
=== app-name Buildpack URLs
1. heroku/nodejs
2. heroku/ruby

devcenter.heroku.com

nodebuildpackでは、buildコマンドが実行されます。そのためpackage.jsonscripts内のbuildにデプロイ時に実行するコマンドを定義しておきます。

{
  "scripts": {
    "build": "webpack --mode=production",

これにより以下のようにwebpackbuild後にRailsdeployが走るようになっています✨

-----> Node.js app detected
       
-----> Creating runtime environment
       
       NPM_CONFIG_LOGLEVEL=error
       USE_YARN_CACHE=true
       NODE_ENV=production
       NODE_MODULES_CACHE=true
       NODE_VERBOSE=false
       
-----> Installing binaries
       engines.node (package.json):  unspecified
       engines.npm (package.json):   unspecified (use default)
       engines.yarn (package.json):  unspecified (use default)
       
       Resolving node version 12.x...
       Downloading and installing node 12.19.0...
       Using default npm version: 6.14.8
       Resolving yarn version 1.22.x...
       Downloading and installing yarn (1.22.10)
       Installed yarn 1.22.10
       
-----> Restoring cache
       - yarn cache
       
-----> Installing dependencies
       Installing node modules (yarn.lock)
       yarn install v1.22.10
       Done in 12.10s.
       
-----> Build
       Running build (yarn)
       yarn run v1.22.10
       $ webpack --mode=production
       
       Done in 19.50s.
       
-----> Pruning devDependencies
       yarn install v1.22.10
       warning Ignored scripts due to flag.
       Done in 5.04s.
       
-----> Caching build
       - yarn cache
       
-----> Build succeeded!

-----> Ruby app detected
-----> Installing bundler 2.1.4
-----> Removing BUNDLED WITH version in the Gemfile.lock
-----> Compiling Ruby/Rails
-----> Using Ruby version: ruby-2.7.1
-----> Installing dependencies using bundler 2.1.4
       Bundle complete! 20 Gemfile dependencies, 55 gems now installed.
-----> Detecting rake tasks
-----> Detecting rails configuration
       Released v15

参考

www.bokukoko.info