N+1問題を回避してRubyでマスキング処理を実装

ある案件でRubyでマスキング処理を実装しました。本記事はその記録となります。

今回実装したのは、usersテーブルのusernameカラムとemailカラムの各フィールドをダミーデータに置き換えるというものです。イメージとしては、下の図のような感じです。

| id | username | email|        
|:-----------|------------:|:------------:|
|1     | fukuzawa yukichi| fukuzawa@gmail.com      |
|2     | kimura takuya| kimura@icloud.com      |
|3       |kitano takeshi       | kitano@gmail.com       |
|4        | suzuki ichiro       | suzuki@live.com          |
|5      | kataoka tsurutaro    | kataoka@yahoo.co.jp      |
　　　　　　　　　　　⬇️

| id | username | email|        
|:-----------|------------:|:------------:|
|1     | dummy_username_1| dummy_email_1@gmail.com      |
|2     | dummy_username_2| dummy_email_2@icloud.com      |
|3       |dummy_username_3      | dummy_email_3@gmail.com       |
|4        | dummy_username_4        | dummy_email_4@live.com          |
|5      | dummy_username_5     | dummy_email_5@yahoo.co.jp      |

第一感では簡単に実装できそうに思いました。しかし、N+1問題という落とし穴があり、それに嵌まらないように今回のプログラムの作成では努める必要がありました。

#そもそもN+1問題とは？

N + 1問題とは、本来なら1回のクエリ実行で済む処理であるにもかかわらず、無駄にN回ものクエリをループ処理の中で実行してしまい、処理速度が低下してしまうことをいいます（少なくとも私はそう理解しています）。

なぜ処理速度が低下するのかと言うと、仮に処理するレコードの数が一定だとすると、SQLの実行回数の多さに比例して処理時間が掛かるからです。そのため、「100レコードを1回で処理する時間」と「1レコードずつ100回処理する時間」は等しくなく、前者の方が短くなります。

具体的に言うと、SQL文のパースや実行計画生成/評価といったオーバーヘッドの時間が余分に掛かるのです。特にSQL文のパースは、SQL文が発行されデータベースがこれを受理するたびに実行され、1回当たり0.1〜1秒も掛かってしまいます。他のオーバーヘッドならミリ秒単位しか掛からないことを踏まえると、処理速度への影響が非常に大きい要素であると言えます。

要するに、簡単に実装できるからと言って、ループの中で何度もSQLを発行するコーディングにするのはご法度なわけです。

#どう対処したか

usersテーブルのusernameとemailにマスキング処理をかける場合、①SQLでテーブルからデータを取ってくる、②usernameとemailをダミーデータに置き換える、③SQLでテーブルを更新する、という手続きをコードに落とし込むことでマスキング処理は実現できます。

今回作成したプログラムでは、①と③の2回にとどめてSQLを実行する設計にしました。また、①と③を実行するためにActiveRecordを導入しています。

②では、①で取得したuserテーブルデータ（カラム名がキーでフィールドの内容がバリューとなったハッシュが配列になっている）に対してmapメソッドを実行し、ダミーデータと置き換わった新しい配列を作成しています。mapで新しい配列を作ったのは、既製の変数の内容を変更するという副作用を避けるためです。

もちろん②については、一意制約を回避するために（usernameとemailの両カラムには一意制約がかかっています）、配列のインデックスをダミーデータの末尾に記述するようにしました。with_indexに引数として1を与えることで、idと同じ数字が記載されるようにしています。

③に関しては、一括での更新（バルクアップサート）を実現するためにactiverecord-importというgemを導入しました。

実装したコードは以下になります。



```masking.rb
require "activerecord-import"

require_relative "./active_record"
require_relative "./user"
require_relative "./mask_processing"

user_list = User.all

masked_users = MaskProcessing.masking_user(user_list)

User.import masked_users, on_duplicate_key_update: %i[username email], validate: true
```
```mask_processing.rb
class MaskProcessing
  def self.dummy_user(user, id)
    user["username"] = "dummy_username_#{id}"

    email_domain = user["email"].split("@")[1]
    user["email"] = "dummy_email_#{id}@#{email_domain}"

    user
  end

  def self.masking_user(records)
    records.map.with_index(1) do |record, index|
      dummy_user(record, index)
    end
  end

  private_class_method :dummy_user
end
```

その他のファイルは以下になります。

余談ですが、ソースコードをgithubに上げるに当たり、環境変数を管理するためのgemであるdotenvを導入しました。これを使えば、環境変数を定義した.envファイルをルート配下に設置するだけで、定義した環境変数が自動で読み込まれるようになります。

なお、環境変数を利用する場合、環境変数を<%= ENV['DB_HOST'] %>のように<%=  %>で囲む必要があるので、RubyファイルでもERB記法を使えるように工夫しております。


```active_record.rb
require "dotenv"
require "mysql2"
require "active_record"
require "yaml"
require "erb"

Dotenv.load

yaml_file = File.read("./config/database.yml")
config = YAML.safe_load(ERB.new(yaml_file).result)

ActiveRecord::Base.establish_connection(config["db"])
```
```user.rb
class User < ActiveRecord::Base
  self.table_name = "users"
  validates_presence_of :username
  validates_presence_of :email
end
```