esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Input object を使ってリクエストパラメータを検証する

はじめに

Bonjour! 近ごろ Duolingo のフランス語コースにハマっている @ima1zumi です。

さて、最近リクエストパラメータにバリデーションをかけたいことがありました。そのために、ユーザの入力を検証する専用のクラスを作ると便利だったので紹介します。

動作確認環境

  • Ruby 3.0.1
  • Ruby on Rails 6.1.4

実現したいこと

ここでは例として User の一覧を表示する UsersController#index に、表示順を制御する order_keyorder_direction というリクエストパラメータを追加します。

order_keyUser モデルのカラム名を、 order_directionascdesc のみ受けつけます。これらのパラメータにバリデーションをかけて、規定の値のみ受け付けるようにします。

class UserController < ApplicationController
  def index
    # order に渡すクエリパラメータのバリデーションを行いたい
    @users = User.all.order(user_params[:order_key] => user_params[:order_direction])
  end

  private

  def user_params
    params.permit(:order_key, :order_direction)
  end
end

Input object の責務

ここではバリデーションをかけるクラスを Input object と呼ぶことにします。

これは Shopify の Upgrow (現在は非公開) の Input object の考え方を参考にしています。

このオブジェクトの責務は ユーザーの入力を検証しオブジェクト化する ことです。

参考:Upgrow: Railsアプリの保守性を高めるためのShopifyのアプローチ / Upgrow - Speaker Deck

Input object の実装

まず、 ActiveModel::ModelActiveModel::Attributesinclude したクラスを作成します。

app/inputs/user_order_input.rb

class UserOrderInput
  include ActiveModel::Model
  include ActiveModel::Attributes
end

ActiveModel::Attributes

ActiveModel::Attributesinclude すると attribute クラスメソッドを使えるようになります。 attribute で属性を宣言するとアクセサが定義されたり、 initialize でその値を渡すことができるようになります。attribute に属性名と型を渡すと型をキャストします。

class UserOrderInput
  include ActiveModel::Model
  include ActiveModel::Attributes
  # 属性の設定を宣言的に記述する
  attribute :order_key, :string
  attribute :order_direction, :string, default: 'desc'
end

ActiveModel::Attributes のドキュメントはありませんので rails のコードを直接ご覧ください。

github.com

キャストできる型はこちらに書いてあります。

github.com

サンプルコード

attribute で宣言した属性にアクセスすることができます。

user_order_input = UserOrderInput.new(order_key: 'name', order_direction: 'asc')
p user_order_input.order_key
# => name

いろいろな型にキャストしてみました。Boolean などキャストできて便利です。

class SampleInput
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :number, :integer
  attribute :flag, :boolean
  attribute :birthday, :date
end

sample = SampleInput.new(number: '1', flag: 'true', birthday: '1993-02-24')

p sample.number
# => 1
p sample.number.class
# => Integer

p sample.flag
# => true
p sample.flag.class
# => TrueClass

p sample.birthday
# => Wed, 24 Feb 1993
p sample.birthday.class
# => Date

ちなみに、ActiveModel::Attributes -> ActiveModel::Model の順番で include すると initialize メソッドの評価順がおかしくなり動かなくなるので注意してください。

class SampleInput
  include ActiveModel::Attributes
  include ActiveModel::Model

  attribute :name, :string
end
# => #<Concurrent::Map:0x00007f8ab03225e8 entries=0 default_proc=nil>
SampleInput.new(name: 'hoge')
# => /Users/mi/.asdf/installs/ruby/3.0.1/lib/ruby/gems/3.0.0/gems/activemodel-6.1.4/lib/active_model/attributes.rb:124:in `_write_attribute': undefined method `write_from_user' for nil:NilClass (NoMethodError)

ActiveModel::Model

ActiveModel::Modelinclude すると validates クラスメソッドなどのバリデーションメソッドを使えるようになります。

railsguides.jp

class UserOrderInput
  include ActiveModel::Model
  include ActiveModel::Attributes
  attribute :order_key, :string
  attribute :order_direction, :string, default: 'desc'
  # 各属性のバリデーションを記述する
  validates :order_key, inclusion: { in: User.column_names }, presence: true
  validates :order_direction, inclusion: { in: %w(asc desc) }
end

サンプルコード

インスタンスに対して valid? / invalid? でバリデーション結果を確認することが出来ます。

user_order_input = UserOrderInput.new(order_direction: 'asc')
p user_order_input.valid?
# => true
user_order_input = UserOrderInput.new(order_direction: 'hoge')
p user_order_input.valid?
# => false

コントローラから呼び出す

ここでは UsersControllerindex アクションでバリデーションをかけます。

class UserController < ApplicationController
  before_action :valid_params, only: [:index]

  def index
    @users = User.all.order(user_order_input.order_key => user_order_input.order_direction)
  end

  private

  def valid_params
    head :bad_request if user_order_input.invalid?
  end

  def user_order_input
    @user_order_input ||= UserOrderInput.new(user_params)
  end

  def user_params
    params.permit(:order_key, :order_direction)
  end
end

このようにコントローラとバリデーションの責務を分けることができました。

Input object と Form object の違い

Form object はいろいろな定義がありますが、ここでは willnet さんの定義を引用します。

form_withのmodelオプション*1にActive Record以外のオブジェクトを渡すデザインパターンです。form_withのmodelオプションに渡すオブジェクト自体もform objectと呼びます。

(参考:form objectを使ってみよう - メドピア開発者ブログ

Form object と Input object の一番の違いは、 Input object は入力値の検証とインスタンスの作成のみ行うということです。

Form object は名前や性質がフォームと強く結びついています。また Form object を使って save することをスコープに入れる場合があります。

今回紹介した方法では、フォームでなくてもよいです。また、 save は行いません。

ここでは、リクエストパラメータのバリデーションに使える Input object の実装例を紹介しました。

参考

プルリクエストのレビュー時に気をつけていること

こんにちは、 @yuki0920 です。

エンジニア歴が1年を超えたあたりから、プルリクエスト(以下、PR)をレビューしてもらうだけでなく、レビューをする機会が多くなりました。

本記事では、PRのレビュワーとして、業務で気をつけている点について記します。

前提

私がエンジニア1年目の時はPRを出すと、100や200以上の指摘をもらうことが多々ありました。 自分のレビューを受ける側としての経験から、「対応しやすかった」というレビューのポイントを抽出し、自身がレビュワーをする際に活かすことで、効率的なPRのレビューを行えるのではないかと考え実践しています。

ポイント1. 現時点の問題点の理由を伝える

「~の実装のほうが良いのではないでしょうか?」という指摘は、対応しづらいことが多いです。 なぜ現状のコードが良くないのか?がわからないためです。

そのため、現状のコードが抱える課題とその理由をできるだけ具体的に記すようにしています。

ポイント2. 提案内容をコードで示す

提案内容をコードで示すのは、レビュワーからすると自分の頭の中で描いていることをコードに落とし込むだけなので、そんなに時間をかけずにできる場合が多いはずです。 一方で、レビューを受ける側からすると、具体的なコードがないと対応方針がわからない場合でも、レビュワーから具体的なコードの提案があれば、対応方針を立てやすくなります。

私は GitHubのSuggestionを積極的に活用し、自分がより良いと思うコードを書いて、議論の足がかりとなるようにしています。

ポイント3. 断定するのではなく提案する

いくら合理的な指摘をしたとしても、断定口調で言葉使いが良くないと、指摘を受けた側は積極的に対応しづらい気持ちになります。

あくまでも、PRの提出者を尊重して、「~と対応するのはいかがでしょうか?」など、提案する風に、かつ柔らかく伝えられるように気をつけています。

まとめ

私がPRのレビュワーとして普段から気をつけている点を紹介しました。 みなさんの気をつけている点もぜひ教えていただけると幸いです。

Rails / OSS パッチ会オンライン 2021年6月のお知らせ

2021年6月の Rails / OSS パッチ会を 6月24日(木)にオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS への upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。Zoom あたりのテレビ会議システムを使います。

当日の招待 URL は Idobata の esminc/rails ルームで共有する予定です。

idobata.io

特に募集ページなど設けませんが、上記理由から Idobata のアカウントが必要になると思います。

RubyKaigi Takeout 2021 や Kaigi on Rails 2021 の CFP や、最近の Ruby / Rails まわりの動向に関する話題などあるかもしれません。

その他の開催方針については以下の Gist に記していますので、ご参照ください。

Reboot Rails/OSS meetup online · GitHub

Amazon ECS タスクメタデータを利用して、Sentry のタグを設定する

はじめに

こんにちは。yoshino です。

私が開発に参加しているチームではECSタスクで処理を実行して、エラー監視にはSentryを利用しています。

その際、Sentry のCustomize Tagsは、Sentry からの通知をタグで絞り込むことができるので便利です。

この記事では、Amazon ECS タスクメタデータエンドポイントを利用して、「タスク定義で指定されたコンテナの名前」と「コンテナのイメージ名」を Sentry のタグに設定する方法を紹介します。

Amazon ECS タスクメタデータ

タスクメタデータエンドポイントバージョン 3 のECS_CONTAINER_METADATA_URIを利用する例です*1

エージェントはタスクの各コンテナに、環境変数 ECS_CONTAINER_METADATA_URI を挿入するため、環境変数経由でエンドポイントへ GET リクエストをするだけでタスクのメタデータを取得することができます。

require 'net/http'

response = Net::HTTP.get(URI.parse(ENV['ECS_CONTAINER_METADATA_URI']))
meta_data = JSON.parse(response.body, symbolize_names: true)
container = meta_data[:Name]   # => 'nginx-curl'
image = meta_data[:Image]      # => 'nrdlngr/nginx-curl'

Sentry のタグの設定

Railsであればconfig/initializers/sentry.rbに取得したcontainerimageを設定します。

getsentry/sentry-rubyを利用することを想定しています。

Sentry.configure_scope do |scope|
  scope.set_tags(container: container, image: image)
end

以上の設定を追加しておくことで、新しい ECS タスクを作成する時に、タグの設定を追加で書く必要がなくなります。

おわりに

タスクメタデータエンドポイントでは様々な情報を取得できるので、Sentry と組み合わせて利用することで、エラー監視のためのより良い環境を構築できるのではないかと思います。

*1: タスクメタデータエンドポイントバージョン 4が最新のバージョンになります。

Rubyで任意の文字列を削除するメソッドの速度を比較してみた

はじめに

こんにちは、wai-doiです。

Railsアプリケーションを開発していて、文字列の削除をするコードを書くことがあると思います。 例えば以下のようなコードです。

zip_code = '123-4567'
zip_code.gsub(/-/, '')
#=> 1234567

この例では String#gsub を使いましたが、他のメソッドでも同様のことを実現することができます。 私はそのたびに、どの書き方が良いのか迷っていました。そこで今回は速度の観点でどのメソッドを使うのがよいのかを測定してみました。

実行環境

速度の測定には、簡単に処理速度を計測することができる benchmark-ips を用いました。

github.com

今回の実行環境です。

  • Ruby (3.0.0)
  • Active Support (6.1.3.1)
  • benchmark-ips (2.8.4)

測定その1

郵便番号からハイフン - 1 文字を削除するときの速度を測定します。以下の観点を比較してみることにしました。

  • 比較するメソッドはString#tr, String#delete, String#sub, String#gsub, String#remove の 5 種類。
  • 非破壊的と破壊的な変更をするメソッドはどちらが速いのか。
  • 置換対象の引数に文字列でも正規表現でもとることができるメソッドはどちらが速いのか。

郵便番号を表す文字列から - 1文字を削除する Ruby のコードを実行します。String#remove を使うため Active Support を require しています。

require 'active_support/core_ext/string'
require 'benchmark/ips'

Benchmark.ips do |x|
  x.report('String#tr') { '123-4567'.tr('-', '') }
  x.report('String#tr!') { '123-4567'.tr!('-', '') }

  x.report('String#delete') { '123-4567'.delete('-') }
  x.report('String#delete!') { '123-4567'.delete!('-') }

  x.report('String#sub (string)') { '123-4567'.sub('-', '') }
  x.report('String#sub (regexp)') { '123-4567'.sub(/-/, '') }

  x.report('String#sub! (string)') { '123-4567'.sub!('-', '') }
  x.report('String#sub! (regexp)') { '123-4567'.sub!(/-/, '') }

  x.report('String#gsub (string)') { '123-4567'.gsub('-', '') }
  x.report('String#gsub (regexp)') { '123-4567'.gsub(/-/, '') }

  x.report('String#gsub! (string)') { '123-4567'.gsub!('-', '') }
  x.report('String#gsub! (regexp)') { '123-4567'.gsub!(/-/, '') }

  x.report('String#remove (string)') { '123-4567'.remove('-') }
  x.report('String#remove (regexp)') { '123-4567'.remove(/-/) }

  x.report('String#remove! (string)') { '123-4567'.remove!('-') }
  x.report('String#remove! (regexp)') { '123-4567'.remove!(/-/) }

  x.compare!
end

benchmark-ips の出力は以下になりました。

Warming up --------------------------------------
           String#tr   402.521k i/100ms
          String#tr!   434.153k i/100ms
       String#delete   453.843k i/100ms
      String#delete!   519.036k i/100ms
 String#sub (string)   245.331k i/100ms
 String#sub (regexp)   240.213k i/100ms
String#sub! (string)   262.920k i/100ms
String#sub! (regexp)   255.543k i/100ms
String#gsub (string)   120.926k i/100ms
String#gsub (regexp)    63.566k i/100ms
String#gsub! (string)
                       117.623k i/100ms
String#gsub! (regexp)
                        63.364k i/100ms
String#remove (string)
                        56.069k i/100ms
String#remove (regexp)
                        37.356k i/100ms
String#remove! (string)
                        67.638k i/100ms
String#remove! (regexp)
                        42.655k i/100ms
Calculating -------------------------------------
           String#tr      3.933M (± 2.4%) i/s -     19.724M in   5.018301s
          String#tr!      4.370M (± 1.6%) i/s -     22.142M in   5.067534s
       String#delete      4.514M (± 1.9%) i/s -     22.692M in   5.028565s
      String#delete!      5.081M (± 2.0%) i/s -     25.433M in   5.007540s
 String#sub (string)      2.419M (± 1.6%) i/s -     12.267M in   5.071963s
 String#sub (regexp)      2.385M (± 3.1%) i/s -     12.011M in   5.041188s
String#sub! (string)      2.600M (± 3.5%) i/s -     13.146M in   5.062776s
String#sub! (regexp)      2.539M (± 1.9%) i/s -     12.777M in   5.035136s
String#gsub (string)      1.186M (± 2.6%) i/s -      5.925M in   5.001154s
String#gsub (regexp)    630.950k (± 3.6%) i/s -      3.178M in   5.044408s
String#gsub! (string)
                          1.074M (± 4.8%) i/s -      5.411M in   5.050392s
String#gsub! (regexp)
                        528.834k (± 1.7%) i/s -      2.661M in   5.033851s
String#remove (string)
                        487.230k (± 2.8%) i/s -      2.467M in   5.067225s
String#remove (regexp)
                        349.516k (± 4.1%) i/s -      1.756M in   5.032162s
String#remove! (string)
                        677.874k (± 1.7%) i/s -      3.450M in   5.090182s
String#remove! (regexp)
                        427.461k (± 1.9%) i/s -      2.175M in   5.091096s

Comparison:
      String#delete!:  5080887.5 i/s
       String#delete:  4514317.0 i/s - 1.13x  (± 0.00) slower
          String#tr!:  4370478.5 i/s - 1.16x  (± 0.00) slower
           String#tr:  3932740.8 i/s - 1.29x  (± 0.00) slower
String#sub! (string):  2600367.7 i/s - 1.95x  (± 0.00) slower
String#sub! (regexp):  2538594.2 i/s - 2.00x  (± 0.00) slower
 String#sub (string):  2419134.9 i/s - 2.10x  (± 0.00) slower
 String#sub (regexp):  2385105.6 i/s - 2.13x  (± 0.00) slower
String#gsub (string):  1185649.3 i/s - 4.29x  (± 0.00) slower
String#gsub! (string):  1073853.4 i/s - 4.73x  (± 0.00) slower
String#remove! (string):   677873.7 i/s - 7.50x  (± 0.00) slower
String#gsub (regexp):   630949.8 i/s - 8.05x  (± 0.00) slower
String#gsub! (regexp):   528833.6 i/s - 9.61x  (± 0.00) slower
String#remove (string):   487230.2 i/s - 10.43x  (± 0.00) slower
String#remove! (regexp):   427461.0 i/s - 11.89x  (± 0.00) slower
String#remove (regexp):   349516.3 i/s - 14.54x  (± 0.00) slower

測定その 1 の考察

メソッドを速い順に並べると String#delete, String#tr, String#sub, String#gsub, String#remove の順番でした。用途がより限定的なメソッドは速く、より汎用的なメソッドは遅いという結果となりました。便利な String#gsub をいつも使うのではなく、用途に合わせて適切なメソッドを使うことが良いということですね。

非破壊的と破壊的メソッドについては、常にどちらの方が速いかというのは今回の測定ではわかりませんでした。破壊的メソッドの方がオブジェクトを新たに作らないため速いのではないかと予想していましたが、速度のために破壊的メソッドを使うメリットはそれほど無いのかもしれません。

引数に文字列を与えるか正規表現を与えるかですが、常に文字列を与えたときの方が少しだけ速いことがわかりました。それは正規表現のマッチに少し時間がかかるためと考えられます。置換対象の引数が文字列で十分なときは文字列を使うのが良いですね。

また、とても学びになったのは String#remove がとても遅かったということです。Rails で開発しているなら String#remove がせっかく使えるから使おうと私はいままで考えていましたが、考え直す必要があるなと思いました。実装を見たところ、中で String#gsub! を呼び出しているだけなので gsub より遅いのは納得ですね。

測定その2

次に、レシーバーが長い文字列の場合の速度を測定してみました。

先ほどの郵便番号に対して1 万字の長さの文字列をくっつけた文字列をレシーバーにして、削除のメソッドを実行してみました 。比較する観点は、測定その 1 と同じにしています。

Benchmark.ips do |x|
  x.report('String#tr') { ('a' * 10000 + '123-4567').tr('-', '') }
  x.report('String#tr!') { ('a' * 10000 + '123-4567').tr!('-', '') }

  x.report('String#delete') { ('a' * 10000 + '123-4567').delete('-') }
  x.report('String#delete!') { ('a' * 10000 + '123-4567').delete!('-') }

  x.report('String#sub (string)') { ('a' * 10000 + '123-4567').sub('-', '') }
  x.report('String#sub (regexp)') { ('a' * 10000 + '123-4567').sub(/-/, '') }

  x.report('String#sub! (string)') { ('a' * 10000 + '123-4567').sub!('-', '') }
  x.report('String#sub! (regexp)') { ('a' * 10000 + '123-4567').sub!(/-/, '') }

  x.report('String#gsub (string)') { ('a' * 10000 + '123-4567').gsub('-', '') }
  x.report('String#gsub (regexp)') { ('a' * 10000 + '123-4567').gsub(/-/, '') }

  x.report('String#gsub! (string)') { ('a' * 10000 + '123-4567').gsub!('-', '') }
  x.report('String#gsub! (regexp)') { ('a' * 10000 + '123-4567').gsub!(/-/, '') }

  x.report('String#remove (string)') { ('a' * 10000 + '123-4567').remove('-') }
  x.report('String#remove (regexp)') { ('a' * 10000 + '123-4567').remove(/-/) }

  x.report('String#remove! (string)') { ('a' * 10000 + '123-4567').remove!('-') }
  x.report('String#remove! (regexp)') { ('a' * 10000 + '123-4567').remove!(/-/) }

  x.compare!
end
Warming up --------------------------------------
           String#tr     4.700k i/100ms
          String#tr!     5.329k i/100ms
       String#delete     4.399k i/100ms
      String#delete!     5.182k i/100ms
 String#sub (string)    15.998k i/100ms
 String#sub (regexp)     8.603k i/100ms
String#sub! (string)    14.971k i/100ms
String#sub! (regexp)     8.500k i/100ms
String#gsub (string)    11.075k i/100ms
String#gsub (regexp)     5.322k i/100ms
String#gsub! (string)
                        13.001k i/100ms
String#gsub! (regexp)
                         5.132k i/100ms
String#remove (string)
                        10.418k i/100ms
String#remove (regexp)
                         4.718k i/100ms
String#remove! (string)
                        12.948k i/100ms
String#remove! (regexp)
                         5.079k i/100ms
Calculating -------------------------------------
           String#tr     47.354k (± 3.1%) i/s -    239.700k in   5.066950s
          String#tr!     50.061k (±10.0%) i/s -    250.463k in   5.078335s
       String#delete     45.918k (± 6.5%) i/s -    228.748k in   5.005509s
      String#delete!     51.088k (± 5.2%) i/s -    259.100k in   5.088284s
 String#sub (string)    160.722k (±14.8%) i/s -    799.900k in   5.071508s
 String#sub (regexp)     83.337k (± 7.9%) i/s -    421.547k in   5.090275s
String#sub! (string)    149.517k (±11.8%) i/s -    748.550k in   5.070937s
String#sub! (regexp)     83.269k (± 5.1%) i/s -    416.500k in   5.015623s
String#gsub (string)    140.134k (±10.0%) i/s -    697.725k in   5.024230s
String#gsub (regexp)     55.118k (± 3.4%) i/s -    276.744k in   5.026910s
String#gsub! (string)
                        137.273k (± 8.2%) i/s -    689.053k in   5.051651s
String#gsub! (regexp)
                         54.317k (± 6.9%) i/s -    271.996k in   5.036091s
String#remove (string)
                         99.842k (± 6.8%) i/s -    500.064k in   5.031706s
String#remove (regexp)
                         45.780k (± 9.3%) i/s -    231.182k in   5.114578s
String#remove! (string)
                        128.251k (± 8.8%) i/s -    647.400k in   5.085621s
String#remove! (regexp)
                         53.039k (± 3.1%) i/s -    269.187k in   5.080040s

Comparison:
 String#sub (string):   160722.0 i/s
String#sub! (string):   149517.0 i/s - same-ish: difference falls within error
String#gsub (string):   140134.1 i/s - same-ish: difference falls within error
String#gsub! (string):   137273.0 i/s - same-ish: difference falls within error
String#remove! (string):   128250.9 i/s - same-ish: difference falls within error
String#remove (string):    99842.5 i/s - 1.61x  (± 0.00) slower
 String#sub (regexp):    83337.3 i/s - 1.93x  (± 0.00) slower
String#sub! (regexp):    83269.4 i/s - 1.93x  (± 0.00) slower
String#gsub (regexp):    55118.3 i/s - 2.92x  (± 0.00) slower
String#gsub! (regexp):    54317.2 i/s - 2.96x  (± 0.00) slower
String#remove! (regexp):    53039.1 i/s - 3.03x  (± 0.00) slower
      String#delete!:    51087.8 i/s - 3.15x  (± 0.00) slower
          String#tr!:    50060.5 i/s - 3.21x  (± 0.00) slower
           String#tr:    47353.9 i/s - 3.39x  (± 0.00) slower
       String#delete:    45917.8 i/s - 3.50x  (± 0.00) slower
String#remove (regexp):    45779.8 i/s - 3.51x  (± 0.00) slower

測定その 2 の考察

結果は測定その 1 から大きく変わり、長い文字列がレシーバーのときは String#deleteString#tr はとても遅くなってしまうことが分かりました。逆に String#sub, String#gsub, String#remove の方が速くなっていました。

また、String#sub, String#gsub, String#remove において引数が正規表現の場合がとても遅くなってしまうことがわかりました。それは、文字列が長いと正規表現のマッチ処理のステップ数が多くなるため速度に影響してしまったと思われます。

まとめ

今回は任意の文字列を削除するメソッドの速度を測定してみました。今回の知見から速度の面と用途の面で適切なメソッドを選んでいきましょう。

RDBMS付属のツールで大規模データをインポートする

こんにちわ、swamp09です。

先日Railsプロジェクトで遭遇した大規模データのインポートについてお話しします。

ある外部システムから大規模なデータの連携をファイルで行うことになり、ファイルからの大規模データのインポートについていくつか方法を試しました。 大規模データは、月次で連携され毎回データをインポートします。 例として、その大規模データはユーザーデータで CSV 形式で受け取るとして、users.csv を毎月ファイルで受け取りインポートすると思ってください。

最初にやったのは、取り込む CSV ファイルを分割し並列で読み込む方法です。CSVファイルのサイズが1GB以上あり、データ数が100万件を超えていたので、ファイルを一度に読み込みデータをインポートするのはメモリへの負荷が大きすぎました。 ファイルの分割はsplitコマンドを使用し、10万件ずつのファイルに分割しました。 gem の Parallel を使用し、だいたい下記のような形になりました。 Parallel.map を使用するとデフォルトでCPUコア数から並列数を決定してくれます。https://github.com/grosser/parallel/blob/v1.20.1/lib/parallel/processor_count.rb#L8

def import(file_path)
  file_split(file_path)
  split_files = Dir.glob(SPLITTED_FILES_PATH + '*')
  
  Parallel.map(split_files) do |csv_file|
    csv = CSV.read(csv_file)
    User.insert_all(csv)
    File.delete(csv_file)
  end
end

def file_split(file_path)
 system("split -l 100000 -d --additional-suffix=.csv #{file_path} #{SPLITTED_FILES_PATH}splitted_", exception: true)
end

手元の開発環境で試して、約30分でインポートが終わりました。 こちらを試そうとしたのですが、実行する予定のサーバーのマシンパワーに余力がない懸念があり、もう少し良い方法がないか検討することにしました。

そんな時、チームメンバーから MySQL だとファイルの一括インポートできるツールがあるけど、それと似たようなツールあるだろうか、といった話があがりました。プロジェクトで使っていた RDBMS が Oracle だったのですが、調べてみると SQL*Loader というファイルからの一括インポートのツールがあったので試してみることにしました。 SQL*Loader では、制御ファイルを作ってインポートを行います。

users.csv のデータの例としてこのようなデータが入っているとします。

user_id name prefecture birth_day last_sign_in_at
1 田中 太郎 東京都 2000/06/01 2020/01/30
2 山田 次郎 神奈川県 2000/10/01 2020/03/30

上記のデータから、必要なものが user_idnameaddress だけであるとして、3つだけ抽出します。 制御ファイルを下記のように作成します。

LOAD DATA CHARACTERSET JA16SJIS
INFILE 'users.csv'
INTO TABLE users
APPEND
FIELDS TERMINATED BY ','
(
  user_id INTEGER EXTERNAL,
  name CHAR,
  prefecture CHAR,
  birth_day FILLER,
  last_sign_in_at FILLER
)

SQL*Loader で処理すると、ファイルの分割処理も必要なくなり、処理時間も格段に短くなりました。 スクリプトを自作していた時は約30分ぐらいかかっていた処理時間が SQL*Loader では約3分で終わったので、圧倒的スピードで終わるようになりました。

まとめ

Active Record を使って良い感じに速くデータをインポートする方法を考えなければ、と思っていたところRDBMSの付属のツールを使えば一気に解決しました。 他の RDBMS では、MySQL は mysqlimport があり、PostgreSQL では COPY があるようです。

大量のデータをファイルからインポートするケースはあまり多くないかもしれませんが、なにかの参考になれば幸いです。

Rails / OSS パッチ会オンライン 2021年5月のお知らせ

2021年5月の Rails / OSS パッチ会を 5月27日(木)にオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS への upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。Zoom あたりのテレビ会議システムを使います。

当日の招待 URL は Idobata の esminc/rails ルームで共有する予定です。

idobata.io

特に募集ページなど設けませんが、上記理由から Idobata のアカウントが必要になると思います。

以下、前回の活動が関わる成果です。

koic: rubygems.org

github.com

osyo-manga: doctree

github.com

yahonda: ruby-plsql

github.com github.com github.com


開催日程の決まった RubyKaigi Takeout 2021 や、最近の Ruby / Rails まわりの動向に関する話題などあるかもしれません。

その他の開催方針については以下の Gist に記していますので、ご参照ください。

Reboot Rails/OSS meetup online · GitHub