クッキングLIVEアプリcookpadTVのコメント配信技術

こんにちは。メディアプロダクト開発部の長田です。

この記事では、クッキングLIVEアプリcookpadTVのLIVE中のコメント配信について工夫したことを紹介したいと思います。

2018/3/28 (水)に開催されたCookpad Tech Kitchen #15資料も合わせてご覧いただけると、分かりやすい部分もあるかと思います。

cookpadTV

cookpadTVでは、料理家や料理上手な有名人による料理のLIVE配信を視聴することができます。iOS/Androidのアプリがリリースされおり、LIVE配信を通して、分かりづらい工程や代替の材料の質問などをコメント機能を使って質問することができます。また、他のLIVE配信アプリのようにハートを送ることでLIVEを盛り上げることができます。

以下では、LIVE中のコメント配信を実装するにあたって私が課題だと感じたものと、それらをどう解決したのかを紹介します。

コメント配信の課題

コメント配信には次のような課題があると感じています。

1つ目は、パフォーマンスの問題です。LIVEの日時に合わせてユーザーが同時に集まるので、人気な配信ほど多くのユーザーがサーバーにリクエストしてきます。また、コメントだけではなくハートを送信する機能を設けており、これは気軽に連打できるようにしてあるので、リクエスト数も多くなることが予想されました。

2つ目は、双方向通信です。cookpadTVでは「料理家や有名人にその場で質問できる」のを価値にしていて、ユーザーのコメントは演者が読み上げて回答してくれたりします。 演者とユーザーのコミュニケーションと、それを見ている他のユーザーの体験を損なわないようにするために、サーバーとアプリの情報をある程度同期させておく必要がありました。

パフォーマンスを出すために

コメントを受けるAPIは別アプリケーションとして構築しました。コメントを受けるAPIはその他のAPIとは特性が違うので、コメントを受けるAPIだけをチューニングしやすくなるからです。 以下では、このコメントを受けるAPIサーバーのことを メッセージサーバー と呼び、その他のAPIサーバーを 通常のAPIサーバー と呼ぶことにします。*1

f:id:osadake212:20180412145148p:plain

まず、実装言語はgolangを採用しました。採用理由は以下が挙げられます。

  • 並列処理が得意な言語なので、同時接続を受け付けやすい
  • 後述のFirebaseを使うためのAdmin SDKが提供されていた
  • golang書きたかった

クックパッドはRubyの会社というイメージがあると思いますが、特性に応じてRuby以外の言語を選択できるよう、hakoを使ったDockerコンテナのデプロイ環境が全社的に整備されており、他のサービスでもRuby以外の言語で実装されているものがあります。*2 *3

また、hakoによってDockerコンテナがECSにデプロイされるようになっており、必要に応じてECSのAuto Scalingの設定ができるので、このメッセージサーバーも設定しています。これにより、アクセスが増えてきてサーバーリソースが消費され始めたらスケールアウトして、アクセスが減ってサーバーリソースに余裕がでてきたらスケールインするようになります。 また、Auto Scalingが間に合わないことが予想される場合は、予めコンテナ数を増やしておくようにしています。*4

さらに、WebアプリケーションはDBアクセスがボトルネックになりがちだと思うのですが、メッセージサーバーではDBにアクセスをしない、という選択をしました。一方で、DBにアクセスしないので認証と永続化について工夫する必要がありました。

認証については、メッセージサーバー用の寿命の短い認証情報(トークン)を通常のAPIサーバーで発行しておき、それをキャッシュに乗せておきます。各アプリはそのトークンを乗せてリクエストするので、メッセージサーバーはキャッシュを見に行くことで認証を実現しています。

また、永続化については非同期で行うようにしました。 コメント/ハートは後述のFirebase Realtime Databaseを使って各アプリに配信されており、LIVE配信中に永続化できなくてもよかったので非同期で行う選択をしました。

永続化の流れは、fluentdを使ってコメント/ハートのデータをS3に送ったあと、弊社のデータ基盤を使うことで、Redshiftに継続的に取り込まれるようになっています。*5 さらに、Redshiftに入ったデータは、Kuroko2を使ったバッチ処理によりMySQLに取り込む流れになります。*6

f:id:osadake212:20180412145141p:plain

これらの工夫をして、直近の配信ではピーク時 5,100rpm のメッセージを無事捌くことができました。

双方向通信

コメントやハートのやり取りで使用する、iOS/Androidアプリとの双方向通信を行うためにいくつかの手段を検討しました。

などを検討した結果、最終的にFirebase Realtime Databaseを使うことにしました。選択した理由としては、

  • iOS/AndroidのSDKが提供されており、アプリの実装工数が減らせる
  • 社内の他プロジェクトで導入されており、知見があった

というのが挙げられます。

また、Firebase Realtime Databaseに直接アプリが書き込むのではなく、以下の図のように、一度メッセージサーバーがリクエストを受け付けて、その内容をFirebase Realtime Databaseに書き込むようにしました。こうすることで、認証と永続化を実現しています。 つまり、Firebase Realtime Databaseをストレージとしてではなく、イベント通知をするために利用しています。これに関しては、この後のデータ構造の工夫と合わせて詳しく説明します。

f:id:osadake212:20180412145041p:plain

Firebase Realtime Databaseを使うことにしたので、データ構造を工夫する必要がありました。

cookpadTVでは、データ転送量を抑えるために最新のコメントだけを保存するようにしました。 具体的には以下のようなJSON構造にしています。(これはイメージなので実際のものとは異なります。)

{
  "latest_comment": {
    "user_id" : 1,
    "text": "こんにちはー"
  }
}

このような構造にしておいて、 latest_comment を上書き更新することで、各アプリに配布するデータは最新のコメント分だけになるので、転送量を抑えることができます。過去のコメントはアプリ側で保持しておいて、LIVE中に受け取ったデータは遡れるようになっています。

ただしこのデータ構造には、途中からLIVE配信を見始めたユーザーは過去のコメントを見ることが出来ないという課題が残っています。 この課題に関しては、直近のコメントはいくつか保持しておく、というものと、非同期での永続化のラグを短くした上でAPIでコメントを返せるようにする、という2つのアプローチのあわせ技で解決したいと思っています。

まとめ

この記事では、cookpadTVのLIVE中のコメント配信について工夫したことを紹介しました。 最後になりましたが、この記事がLIVE配信サービスの開発について、少しでもお役に立てれば幸いです。

*1:コメントだけではなく、ハート等、他のメッセージも受けるのでメッセージサーバーと呼んでいます。

*2:hakoの近況は本ブログでも紹介されています。http://techlife.cookpad.com/entry/2018/04/02/140846

*3:2018/02/10に開催されたCookpad TechConf 2018では、「Rubyの会社でRustを書くということ」というタイトルで弊社のkobaによる発表が行われました。 https://techconf.cookpad.com/2018/hidekazu_kobayashi.html

*4:LIVEコンテンツの集客予想に応じて、自動でコンテナ数を増やす仕組みを実装しています。

*5:本ブログの過去のエントリで、クックパッドのデータ基盤について紹介しているものがあるので、詳細はこちらを御覧ください。 http://techlife.cookpad.com/entry/2017/10/06/135527

*6:弊社のオープンソースで、WebUIが用意されているジョブスケジューラーです。

ディープラーニングによるホットドッグ検出器のレシピ

研究開発部の画像解析担当のレシェックです。techlife を書くのは初めてです。よろしくお願いいたします。

最先端の機械学習を使うためには、常に自分のスキルアップが必要です。そのために、毎日論文を読んだり、新しいオープンソースのコードを試してみたり、クックパッドのデータで実験しています。これはちょっと料理の練習と似ています。新しいモデルを学習させるのは料理をオーブンに入れるのと同じ気持ちです。オーブンの温度は学習率と同じで、低すぎだとよく焼けず、高すぎだと焦げてしまいます。しかし、ちゃんと他のリサーチャーの論文やブログの中のレシピを見ながら自分のデータでモデルを学習させると、失敗せずに済むかもしれません。

このエントリでは、そういった機械学習のレシピの一例を紹介します。

f:id:lunardog:20180405185342j:plain

このブログで使っているテスト画像はPixabayから取得した、Creative Commonsのライセンスの写真です。

概要

クックパッドは料理/非料理のモデルを開発しています。ここでは、このモデルのミニチュア版のレシピを紹介します。カテゴリは「料理」と「非料理」の代わりに、「ホットドッグ」と「非ホットドッグ」にします。そして、パッチ化した画像に対する認識モデルを使って、画像の中でホットドッグがどこにあるかを検出します。

調理器具

  • python
  • Keras
  • numpy
  • pillow (PIL)
  • jupyter notebook(お好みでお使い下さい。)

KerasはTensorflow、CNTKやTheano上で動く高水準のライブラリーです。Keras は特に画像データに対して、単なる学習以外にも前処理などでも様々な機能があります。

材料

KaggleからHot Dog - Not Hot Dogのデーターセットをダウンロードしてください。なお、ダウンロードするには Kaggle の登録が必要です。

ダウンロードした後、seefood.zipunzipしてください。

アーカイブの中に、2つのディレクトリtraintestがあります。

seefood/train/not_hot_dog
seefood/train/hot_dog
seefood/test/not_hot_dog
seefood/test/hot_dog

hot_dogディレクトリの中にホットドッグの画像が入っており、not_hot_dogの中にそれ以外の画像が入っています。新しい機械学習のレシピを開発する時はテストデータを分けるべきです。しかし、今回は画像が少ないので、テストデータも学習に使いましょう。

mkdir seefood/all
cp -r seefood/test/* seefood/train/* seefood/all

以降では、seefood/allのディレクトリを使います。

データ拡張

Keras のモバイルネットは(224px・224px)のフィックスサイズの画像しか認識できないので、これから学習や認識用にサイズを変換します。

IMG_SIZE=[224, 224]

テストデータを学習に使っても、このデータセットはまだ小さいので、データ拡張を使いましょう。

KerasのImageDataGeneratorは学習時に画像を一つずつ変換します。

import keras.preprocessing.image

image_generator = keras.preprocessing.image.ImageDataGenerator(
        rescale=1./255,
        shear_range=0.0,
        width_shift_range=0.1,
        height_shift_range=0.1,
        rotation_range=10,
        fill_mode="wrap",
        vertical_flip=True,
        horizontal_flip=True
)

上のimage_generator"seefood/all"のディレクトリで動かします。

train_generator = image_generator.flow_from_directory(
    "seefood/all",
    target_size=IMG_SIZE,
    batch_size=32,
    class_mode="categorical",
    classes=["not_hot_dog", "hot_dog"]
)

モデルの作り方

以下のレシピでは、3 個のモデルを 3 層のスポンジケーキのように積み重ねています。

  1. base_modelMobileNetです。転移学習のために使います。
  2. その上のpatch_modelは画像のパッチごとに分類できます。
  3. さらにその上のclassifierは「ホットドッグ」と「非ホットドッグ」の二値分類器です。

まずkerasimportします:

import keras

ベースとして、Googleで開発されたMobileNetというモデルを使います。

weights="imagenet"は、ILSVRCのコンペティションのデータセットで学習されたパラメタを使って、転移学習することを意味しています。

base_model = keras.applications.mobilenet.MobileNet(
    input_shape=IMG_SIZE + [3], 
    weights="imagenet",
    include_top=False
)

ベースモデルの一番上のフィーチャサイズは1024です。パッチレイヤが学習できるようにちょっと下げましょう。

drop1 = keras.layers.SpatialDropout2D(0.3)(base_model.output)
conv_filter = keras.layers.convolutional.Conv2D(
    4, (1,1),
    activation="relu",
    use_bias=True,
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop1)

パッチレイヤもConv2Dのタイプのレイヤです。この場合、softmaxを使えば、パッチごとに分類できるようになります。

drop2 = keras.layers.SpatialDropout2D(0.3)(conv_filter)
patch = keras.layers.convolutional.Conv2D(
    2, (3, 3),
    name="patch",
    activation="softmax",
    use_bias=True,
    padding="same",
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop2)

これでパッチモデルができました。

patch_model = keras.models.Model(
    inputs=base_model.input, 
    outputs=patch
)

パッチモデルをベースにして、最後の出力レイヤを追加して分類モデルを作ります。

pool = keras.layers.GlobalAveragePooling2D()(patch)
logits = keras.layers.Activation("softmax")(pool)


classifier = keras.models.Model(
    inputs=base_model.input, 
    outputs=logits
)

学習

ベースモデルは学習させません。

for layer in base_model.layers:
    layer.trainable = False

そして全体のモデルをcompileします。

classifier.compile(optimizer="rmsprop", loss="categorical_crossentropy", metrics=["accuracy"])

では、学習を始めましょう!

いくつか実験をした結果、以下のようにnot_hot_dogのクラスのclass_weightを高くするほうが良いことが分かりました。

%%time
classifier.fit_generator(
    train_generator, 
    class_weight={0: .75, 1: .25}, 
    epochs=10
)
Epoch 1/10
32/32 [==============================] - 148s 5s/step - loss: 0.3157 - acc: 0.5051
Epoch 2/10
32/32 [==============================] - 121s 4s/step - loss: 0.3017 - acc: 0.5051
Epoch 3/10
32/32 [==============================] - 122s 4s/step - loss: 0.2961 - acc: 0.5010
Epoch 4/10
32/32 [==============================] - 121s 4s/step - loss: 0.2791 - acc: 0.5862
Epoch 5/10
32/32 [==============================] - 122s 4s/step - loss: 0.2681 - acc: 0.6380
Epoch 6/10
32/32 [==============================] - 123s 4s/step - loss: 0.2615 - acc: 0.6876
Epoch 7/10
32/32 [==============================] - 121s 4s/step - loss: 0.2547 - acc: 0.6790
Epoch 8/10
32/32 [==============================] - 122s 4s/step - loss: 0.2522 - acc: 0.7052
Epoch 9/10
32/32 [==============================] - 123s 4s/step - loss: 0.2522 - acc: 0.7045
Epoch 10/10
32/32 [==============================] - 145s 5s/step - loss: 0.2486 - acc: 0.7164
CPU times: user 1h 4min 20s, sys: 2min 35s, total: 1h 6min 56s
Wall time: 21min 8s

このデータセットの場合、10エポックぐらいが良さそうです。パッチベースを使っているので、精度は100%にならないほうがいいです。70%ぐらいがちょうどいいです。

私の MacBook Pro では10エポックで20分ぐらいかかりました。

確認作業

画像とデータの変換のために、PILnumpyを使います。

import numpy as np
from PIL import Image

画像をインファレンスする前に、numpyのデータに変換します。

def patch_infer(img):
    data = np.array(img.resize(IMG_SIZE))/255.0
    patches = patch_model.predict(data[np.newaxis])
    return patches

そして、元の画像とインファレンス結果をビジュアライズします。

def overlay(img, patches, threshold=0.99):
    # transposeはパッチをクラスごとに分けます。
    patches = patches[0].transpose(2, 0, 1)
    # hot_dogパッチ - not_hot_dogパッチ
    patches = patches[1] - patches[0]
    # 微妙なパッチをなくして
    patches = np.clip(patches, threshold, 1.0)
    patches = 255.0 * (patches - threshold) / (1.0 - threshold)
    # 数字を画像にして
    patches = Image.fromarray(patches.astype(np.uint8)).resize(img.size, Image.BICUBIC)
    # もとの画像を白黒に
    grayscale = img.convert("L").convert("RGB").point(lambda p: p * 0.5)
    # パッチをマスクに使って、元の画像と白黒の画像をあわせて
    composite = Image.composite(img, grayscale, patches)
    return composite

まとめて、インファレンスとビジュアライズを一つのファンクションにすると、

def process_image(path, border=8):
    img = Image.open(path)
    patches = patch_infer(img)
    result = overlay(img, patches)
    # 元の画像と変換された画像をカンバスに並べます
    canvas = Image.new(
        mode="RGB", 
        size=(img.width * 2 + border, img.height), 
        color="white")
    canvas.paste(img, (0,0))
    canvas.paste(result, (img.width + border, 0))
    return canvas

では、結果を見てみましょう!

f:id:lunardog:20180405185418j:plain きれいですね!

f:id:lunardog:20180405185437j:plain ホットドッグの色はちょっと隣のコーヒーに移りましたが、ほとんど大丈夫です。

f:id:lunardog:20180405185457j:plain フォーカスが足りないところは認識にならなかったみたいです。なぜでしょう?学習データにフォーカスが当たらないホットドッグがなかったからです。

f:id:lunardog:20180405185342j:plain こちらも、左側のホットドッグはフォーカスが当たっておらず、モデルはホットドッグを認識できませんでした。

ホットドッグではない画像は? f:id:lunardog:20180405185526j:plain

f:id:lunardog:20180405185541j:plain

f:id:lunardog:20180405185558j:plain

f:id:lunardog:20180405185609j:plain

ホットドッグではない画像には、パッチはゼロやゼロに近い値になります。

まとめ

転移学習を使えば、データが少なくても、それなりの識別器が作れますね!

パッチごとの分類を使えば、画像の中の認識したいフィーチャーを可視化できます。

モバイルネット(MobileNet)のおかげで、CPU でもモデルを学習できます。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

Ruby の lazy loading の仕組みを利用して未使用の gem を探す

技術部開発基盤グループのシム(@shia)です。 最近は cookpad のメインレポジトリを開発しやすい環境に改善するために様々な試みをしています。 この記事ではその試みの一つとして不要な gem を検出し、削除した方法を紹介したいと思います。

背景

cookpad は10年以上にわたって運用されている巨大なウェブアプリケーションです。 巨大かつ古いアプリケーションには昔は使っていたが、現在は使われてない依存性などが技術負債として溜まっています。 事業的観点から技術的負債を完全返却するのはコストとのバランスが悪いことも多いです。 これは20万行を超えるプロジェクトを幾つも抱えている cookpad のメインレポジトリも例外ではなく、その規模から使ってないと思われる依存性を探しだすのも大変な状況でした。

どうするか

人が頑張るより機械に頑張らせたほうが楽ができるし、何より確実です。 ですので今回は未使用の gem を探すために Ruby の遅延ロード仕組みに乗りました。 遅延ロードのために用意された仕組みにパッチを当て、使用されている gem のリストを出します。 これを利用して依存してる gem のリストから未使用である gem のリストを逆引きします。

InstructionSequence(iseq) とは

InstructionSequence(iseq) とは Ruby のソースコードをコンパイルして得られる命令の集合を指します。 この命令は Ruby VM が理解できるもので、各 iseq はツリー構造で成り立ちます。例えば

class Cat
  def sleep
  end
end

このコードからはCat クラスを表現する iseq が一つ、 sleep メソッドを表現する iseq が一つ作られます*1が、 構造的には Catの iseq に sleep の iseq が含まれている状態です。 これより詳しい説明を見たい方は RubyVM::InstructionSequence の説明や「Rubyのしくみ」という本がおすすめです。 もしくは弊社で Ruby の内部が分かる Ruby Hack Challenge というイベントが不定期に開催されるので参加してみるのも良いも思います。 参考記事

InstructionSequence lazy loading

Ruby 2.3 では iseq を lazy loading するという仕組みが試験的に導入されました。 この機能は iseq を初めて実行する時まで中身の読み込みを遅延させることで、

  • アプリケーションのローディングが早くなる
  • メモリーの使用量を減らす

ということを狙っています。ですが、今回は「初めて実行する時まで中身の読み込みを遅延させる」ために用意された仕組みに興味があります。 iseq の定義パスや first line number は iseq から簡単に取り出せるので、これらを利用すれば実際に使用された gem のリストを作れます。

どういうパッチを当てるのかを見る前に少しだけ Ruby のコードを見てみましょう。

// https://github.com/ruby/ruby/blob/v2_4_3/vm_core.h#L415-L424
static inline const rb_iseq_t *
rb_iseq_check(const rb_iseq_t *iseq)
{
#if USE_LAZY_LOAD
    if (iseq->body == NULL) {
    rb_iseq_complete((rb_iseq_t *)iseq);
    }
#endif
    return iseq;
}

rb_iseq_check は iseq が実行される前に呼ばれる関数です。 ここで iseq の中身が空なら(まだ実行されたことがない)、中身をロードしてるのがわかります。 先程話したようにこれは実験的な機能であるため USE_LAZY_LOAD がマクロで宣言されてないと使われません。 ですので普段はなにもせず引数として渡された iseq を返すだけの関数です。 ここで iseq の初回実行のみ特定の関数を呼び、そこで必要なロギング作業すれば良さそうです。

パッチ

上記のコードからどういう感じのパッチを書けばよいのか理解できると思うので実際のパッチを見てみましょう。 以下のパッチは 2.4.3 をターゲットとして書かれてるので注意してください。

---
 iseq.c    | 16 ++++++++++++++++
 vm_core.h | 15 +++++++++++++++
 2 files changed, 31 insertions(+)

diff --git a/iseq.c b/iseq.c
index 07d8828e9b..322dfb07dd 100644
--- a/iseq.c
+++ b/iseq.c
@@ -2482,3 +2482,19 @@ Init_ISeq(void)
     rb_undef_method(CLASS_OF(rb_cISeq), "translate");
     rb_undef_method(CLASS_OF(rb_cISeq), "load_iseq");
 }
+
+#if USE_EXECUTED_CHECK
+void
+rb_iseq_executed_check_dump(rb_iseq_t *iseq)
+{
+    iseq->flags |= ISEQ_FL_EXECUTED;
+    char *output_path = getenv("IE_OUTPUT_PATH");
+    if (output_path == NULL) { return; }
+
+    char *iseq_path = RSTRING_PTR(rb_iseq_path(iseq));
+    FILE *fp = fopen(output_path, "a");
+    fprintf(fp, "%s:%d\n", iseq_path, FIX2INT(rb_iseq_first_lineno(iseq)));
+    fclose(fp);
+}
+#endif
diff --git a/vm_core.h b/vm_core.h
index 8e2b93d8e9..96f14445f9 100644
--- a/vm_core.h
+++ b/vm_core.h
@@ -412,6 +412,16 @@ struct rb_iseq_struct {
 const rb_iseq_t *rb_iseq_complete(const rb_iseq_t *iseq);
 #endif

+#ifndef USE_EXECUTED_CHECK
+#define USE_EXECUTED_CHECK 1
+#endif
+
+#define ISEQ_FL_EXECUTED IMEMO_FL_USER0
+
+#if USE_EXECUTED_CHECK
+void rb_iseq_executed_check_dump(rb_iseq_t *iseq);
+#endif
+
 static inline const rb_iseq_t *
 rb_iseq_check(const rb_iseq_t *iseq)
 {
@@ -419,6 +429,11 @@ rb_iseq_check(const rb_iseq_t *iseq)
     if (iseq->body == NULL) {
        rb_iseq_complete((rb_iseq_t *)iseq);
     }
+#endif
+#if USE_EXECUTED_CHECK
+    if ((iseq->flags & ISEQ_FL_EXECUTED) == 0) {
+       rb_iseq_executed_check_dump((rb_iseq_t *)iseq);
+    }
 #endif
     return iseq;
 }
--
  • iseq が持っている未使用のフラグ一つを iseq が実行されたことがあるかを判断するためのフラグ(ISEQ_FL_EXECUTED)として使えるようにする
  • ISEQ_FL_EXECUTED フラグが立ってない場合 rb_iseq_checkrb_iseq_executed_check_dump という関数を呼ふ
  • rb_iseq_executed_check_dump ではその iseq の path, first_lineno を(環境変数 IE_OUTPUT_PATH で指定した)ファイルに書き込む

このように rb_iseq_check にフックポイントを作ることで TracePoint とは比べるまでもないほどの低コストで実行された iseq を探せます。 もちろんロギングのコストは発生するので注意する必要はありますが、仕組み自体のコストは実質ゼロに近いことがわかっています。

このパッチを当てた Ruby を利用することで実行された iseq のリストを得ることができます。 今回は手作業で確認したい対象を減らすためのものなので、パッチを当てた ruby でテストを完走させ、そのログを利用することにしました。以下のような大量のログが吐かれるのでこれらを処理して実際使われてる gem のリストを作成できます。

.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:46
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:58
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:71
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:63
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:67
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:245
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:3

依存している gem のリストは Bundler::LockfileParser を利用すると簡単に得られます。

# プロジェクト root
require "bundler"

lockfile_parser = Bundler::LockfileParser.new(File.read("Gemfile.lock"))
lockfile_parser.specs.map(&:name)

この使用された gem のリストと依存している gem のリストから、後者から前者を引き算することで、 依存しているが使用されてない gem のリストを作れます。

成果

現在、cookpad のメインレポジトリには1つの mountable engine を共有する 5つのプロジェクトがあります。 この5つのプロジェクトを対象に上記のパッチを利用して作り出した未使用 gem のリストを作成し、必要のないものをなくす作業を進めました。

結果としてすべてのプロジェクトから未使用の gem が 41個見つかりました。 これらを削除することで、依存している gem の数を大幅に減らすことができました。 さらに require するファイルの数が大量に減ったため、アプリケーションの読み込み時間が最大1秒程度速くなりました。

まとめ

Ruby の lazy loading という仕組みを利用して未使用の gem を探す方法を紹介しました。 この方法は使用されてないコードを探すのに以下のような利点を持っています。

  • プロジェクト別にコードを書く必要がないのでどのプロジェクトからも簡単に利用することができる
  • 動的に生成されるメソッドもある程度追跡ができる
  • 低コストにコードの使用状況が分かる

特に三番目が重要だと思っていて、本番のサービスから使われてない依存 gem やプロジェクトコードを簡単に追跡できるんじゃないかと期待しているので、次回にご期待ください。

*1:正確には3つが作られますが、ここでは説明のため省略しています