形態素解析を行うだけのバッチをつくる

研究開発部の原島です。今日は表題の渋いバッチをつくった話をします。

あっちでも形態素解析、こっちでも形態素解析

みなさん、形態素解析してますか?してますよね?クックパッドでもさまざまなプロジェクトで形態素解析をしています。

いや、むしろ、しすぎです。プロジェクト A でレシピを解析し、プロジェクト B でもレシピを解析し、プロジェクト C でもレシピを解析し、... といった具合です。ちなみに、形態素解析(の結果)が必要なプロジェクトとしてはレシピの分類やレコメンド、各種分散表現(e.g., word2vec)や BERT の学習などがあります。

もちろん、最終的に得たい解析結果が違うのであれば問題ありません。しかし、私が見たかぎり、ほとんどの場合は同じ(もしくは、同じにできそう)でした。であれば、

  1. 解析器をインストール(→ Dockerfile を試行錯誤)
  2. 解析対象を取得(→ SQL を試行錯誤)
  3. 解析器を実行(→ クックパッドの場合は ECS や IAM の設定を試行錯誤)
  4. 解析結果を保存(→ 同様に S3 や RDS の設定を試行錯誤)

という一連の処理を各開発者が個別に行うのは非効率です。同じ解析器を使い、同じ解析対象(基本的には解析時の全レシピ)を集め、定期的に解析を行い、解析結果を簡単に使い回せるようにしたい。形態素解析が必要なプロジェクトが増えるにつれ、そういう想いが募っていました。

共通化は面倒だけど、難しい話ではない

こうした背景で、重すぎる腰を上げて、共通化を行うことにしました。こういう作業ってどうしても後回しになりがちですよね。各開発者は各プロジェクトを進めたいのであって、他のプロジェクトのことまでケアして共通化を行うのはなかなか面倒です。

一方、この話は技術的には大したものではありません。単に、「形態素解析を行うだけのバッチをつくる」というだけの話です。上の処理 1 から 4 を丁寧に行ない、各プロジェクトが使いやすい形で最終的な解析結果を残せば任務完了です。難しい話ではありません。

形態素解析を行うだけのバッチ

というわけで、そういったバッチをつくってみました。バッチの概観は下図のとおりです。以下では、バッチの各処理について、その詳細をお話しします。

f:id:jharashima:20210307203402j:plain

1. 解析器をインストール

していません。同僚の @himkt がつくった konoha を使いました。konoha はさまざまな形態素解析器(e.g., MeCab、Sudachi、KyTea)のラッパーです。konoha で解析を行なう社内サーバがあったので、解析器のインストールや設定はこのサーバに委ねることにしました(なので、上図にも処理 1 は含まれていません)。なお、クックパッドで使っている解析器は MeCab です。

2. 解析対象を取得

解析対象はレシピ(のタイトルや紹介文、手順など)です。これは Redshift から取得しました。クックパッドではほとんどのデータが Redshift に集約されています。また、Queuery(きゅーり)という社内向けのシステムがあり、UNLOAD を使うことで Redshift に負荷をかけずに SELECT が実行できるようになっています。

今回は、Queuery をさらにラップした corter(かーたー)という社内向けの Python パッケージをつくりました。corter は COllect Recipe-related TExts from Redshift の略で、その名のとおり、レシピに関するテキストを Redshift から収集するためのものです。

以下は、corter でクックパッドの全レシピのタイトルを取得するコードです(レシピ ID はダミーです)。

from corter.agent import RecipeTitleAgent
agent = RecipeTitleAgent()
recipe_ids, titles = agent.collect()
print(recipe_ids[0], titles[0]) # => 12345, 'ナスの肉味噌炒め'

corter の内部には、クックパッドの機械学習エンジニアであれば誰もが試行錯誤したであろう SQL が押し込められています。公開済みのレシピを絞り込む WHERE の条件とか毎回忘れちゃうんですよね。

ちなみに、このような Python パッケージをつくったのにはもう一つ理由があります。生文(形態素解析されていない文)を使うプロジェクトでもこのパッケージを使いたかったからです。このようなプロジェクトとしては、たとえば、SentencePiece の学習などがあります。

3. 解析器を実行

これは簡単です。2 で集めたレシピを 1 で触れた解析サーバに投げているだけです。ただし、現在、クックパッドには全部で約 350 万品のレシピがあります。いくら MeCab が高速でも、全レシピを 1 並列で解析するのは時間がかかります。

そこで、ジョブ管理システム kuroko2 の paralle_fork を使い、10 並列で解析を行うようにしています(正確には、処理 2 の段階で解析対象を 10 分割で取得しています)。これで、350 万品を解析対象としても、タイトルのような短いテキストであれば 5 分程度で、手順のような長いテキストでも 1 時間程度で解析が行えるようになりました。

4. 解析結果の保存

解析結果は Redshift に保存するようにしました。S3 や RDS などの選択肢もありましたが、2 でも触れたように、クックパッドではほとんどのデータが Redshift に集約されています。解析結果も Redshift に保存することで、他のデータと一緒に使いやすいようにしました。

解析結果を Redshift に保存するには、まず、3 の結果を S3 にアップロードします(上図 4-1)。次に、アップロードされた 10 個の解析結果をマージして、1 個の TSV をつくります(4-2)。そして、SQL バッチフレームワーク bricolage を使い、TSV の中身を Redshift に COPY します(4-3)。これで、数億行のロードでも 10 分弱で終わります。

最終的に、下表のようなテーブルに解析結果が保存されます。これはタイトルの解析結果を保存したテーブル(上図では recipe_title_words)です。一行が一単語です。主なカラムは position(単語の出現位置)と surface(表層形)、pos(品詞)、base(原形)です。analyzed_at は、その名のとおり、解析時刻です。

recipe_id position surface pos base analyzed_at
12345 0 ナス 名詞 ナス 2021-03-08 00:00:00
12345 1 助詞 2021-03-08 00:00:00
12345 2 名詞 2021-03-08 00:00:00
12345 3 味噌 名詞 味噌 2021-03-08 00:00:00
12345 4 炒め 動詞 炒める 2021-03-08 00:00:00

2 から 4 を日次で実行

以上の処理 2 から 4(今回、1 はなにもしていません)を、デプロイツール hako を使い、日次で実行しています。具体的には、ECS でコンテナを起動し、そこで処理 2 から 4 を実行しています。これにより、毎日、その日の時点で公開済みのすべてのレシピの解析結果が Redshift に保存されている状態にしています。

余談として、差分更新も考えました。つまり、前日に投稿(正確には公開)されたレシピの解析結果は追加し、修正されたレシピの解析結果は修正し、削除(正確には非公開)されたレシピの解析結果は削除することもできます。しかし、すべてのレシピを解析したところで大して時間がかからず、むしろ差分更新によって複雑性が増すデメリットの方が大きかったので、差分更新はやめました。

こうやって文書にすると、各処理は大変に見えるかもしれません。しかし、実際のところは既存の便利ツール(konoha、Queuery、kuroko2、bricolage、hako)を組み合わせただけで、そんなに大変ではありません。私が頑張ったことと言えば、corter をつくったことくらいでしょうか。これも Queuery のおかげでだいぶ楽をさせてもらいました。

解析結果を各プロジェクトで使う

さて、解析結果はさまざまなプロジェクトで使えなければ意味がありません。そこで、生文だけでなく解析結果も corter で取得できるようにしました。生文を取得するときと同様、裏では Queuery を使っており、UNLOAD でラップした SELECT 文を Redshift で実行しています。

以下は、クックパッドの全レシピのタイトルの解析結果を取得するコードです。解析結果はスペース区切りで返ってきます。オプションを変えることで、品詞で絞り込んだり、ストップワードを弾くこともできます。

from corter.agent import SegmentedRecipeTitleAgent
agent = SegmentedRecipeTitleAgent()
recipe_ids, segmented_titles = agent.collect()
print(recipe_ids[0], segmented_titles[0]) # => 12345, 'ナス の 肉 味噌 炒め'

解析結果が必要なプロジェクトは基本的に Python がベースです。上のコードのあとで schikt-learn なり gensim なり transformers なりを使えば、レシピの分類やレコメンド、各種分散表現や BERT の学習などがすぐに始められます。

こうして、当初の目的どおり、さまざまなプロジェクトで各開発者が個別に形態素解析を行うという事態が避けられるようになりました。

次は?

本エントリの最後に、次に取り組みたいことを三つほど挙げておきます。

一つ目は、今回 Redshift に保存した解析結果をまだ使えていないプロジェクトが残っているので、これらをなくすことです。基本的には、既存のコードを corter に置き換えていくだけです。一方、それだけでは済まないプロジェクトがあります。レシピ検索のインデキシングです。まさに形態素解析が重要なプロジェクトですが、レシピ検索周りはレガシーの巣窟なので、これを置き換えるには相当の時間と覚悟が必要です。

二つ目は、再学習した解析器を使うことです。クックパッドでは、昨年、500 品からなるレシピの解析済みコーパスをつくりました。このコーパスには形態素解析(と固有表現認識、構文解析)の正解データが含まれています。このコーパスで解析器を改善し、解析誤りを減らすことで、形態素解析が必要なすべてのプロジェクトを底上げしたいと考えています。解析済みコーパスと再学習については来週の言語処理学会の @himkt の発表もご覧ください。

三つ目は、Redshift ML を使うことです。Redshift ML、突然現れましたね。上でも述べたように、解析結果は Redshift に保存してあります。これらを特徴量としてモデルを学習し、Redshift 内で推論するというフローをつくれば、レシピの分類などのプロジェクトは大部分を Redshift に任せられるかも?と考えています。Redshift ML についてはまだ勉強不足なので、まずは勉強します。

さて、これらに取り組むだけでも大変ですが、クックパッドには他にも取り組みたいことがたくさんあります。「おもしろそう!」とか「やってみたい!」と思ってくださった方は、ぜひ、採用ページをご覧ください。ご応募をお待ちしております。

info.cookpad.com

Rails に Babel と Rollup を組み込んで CoffeeScript を JavaScript に段階的に移行した話

こんにちは。技術部クックパッドサービス基盤グループの青沼です。当グループではクックパッドのレシピサービスを支える web アプリケーションの改善を進めています。今回はフロントエンドの改善の一環として、 Babel と Rollup を Rails のアセットパイプラインに組み込み、レガシーな CoffeeScript ファイルを ES2015+ の JavaScript に移行した話をします。

レシピサービスと CoffeeScript の歴史

クックパッドは10年以上の歴史を持つサービスです。中でもレシピサービスの web アプリケーションは初期に作られた Rails 2 アプリケーションがアップグレードを重ねながら今も動いています。2018年には Rails 3 から4へ、つい最近では4から5へのアップグレードを完了しました。 Ruby のコードはそれに伴って新しい書き方へと徐々に移行されてきましたが、「壊れていないものは直すな」という言葉もあるように昔から姿を変えていないコードもあります。その一角がビューで使われる CoffeeScript のコードです。

CoffeeScript はいわゆる AltJS (JavaScript に変換される言語)のはしりで、2012年ごろに流行しました。当時の JavaScript は今のように毎年仕様改訂されることがなく、進化が止まったように見えていました。その停滞した空気に新鮮な風を吹き込んだのが CoffeeScript です。Ruby や Python や Haskell から影響を受けた簡潔な記法を取り入れ、リスト内包表記などの新しい言語機能を実装しました。今では JavaScript に取り入れられているスプレッド構文 [1, 2, ...rest] やアロー関数 () => {} は CoffeeScript が実装した機能から影響を受けています。Rails はバージョン3.1から CoffeeScript をフレームワークに統合し、新しい JavaScript コードは CoffeScript で書くように推奨していました。レシピサービスでも CoffeeScript のコードが多く書かれました。

いっときブームを起こした CoffeeScript でしたが、 JavaScript 自体がそれなりに書きやすく進化し、 TypeScript など有力な AltJS が台頭してきた現在では、積極的に採用する理由はほとんどなくなってしまいました。むしろ「JavaScript や AltJS の進歩から取り残されている」「既存のコードを変更するために CoffeeScript を覚える・覚え直すのが面倒」「Rails 自体がもはや CoffeeScript に力を入れていない」などのデメリットが目立つようになっています。

さらば CoffeeScript

そこで、2019年12月、レシピサービスに残る CoffeeScript をすべて JavaScript に変換することを決めました。元々 JavaScript に変換することが前提の言語ですから、変換自体は特に難しいものではありません。ただし純正のコンパイラの出力はコメント行が残っていなかったりと読みやすさを優先したものではないので、素直で読みやすい JavaScript を生成する別のコンパイラ、 decaffeinate を使うことにしました。(脱 CoffeeScript を支援するツールにデカフェと名付けるセンスがいいですね。)実際にはファイルひとつひとつを手作業で変換していくのではなく、一括変換を実行してくれるサポートツール bulk-decaffeinate を使います。 bulk-decaffeinate は以下の順番で変換対象に指定したファイル群を処理します。

  1. ファイルをエラーなく変換できるかをまずチェック。エラーがあれば中断
  2. ファイルをコピーしてバックアップを作成
  3. 拡張子を .coffee から .js に変更して Git にコミット
  4. ファイルを decaffeinate で JavaScript に変換し、上書き保存して Git にコミット
  5. 変換後のファイルに ESLint を適用して整形し、上書き保存して Git にコミット

拡張子の変更だけを先に行ってコミットするのは地味ですが重要なポイントです。内容の変更とファイル名の変更を1つのコミットに混ぜてしまうと、 Git は変更前後のファイルの関係を推測できず、変更前のファイルが削除されて新しいファイルが追加されたものとみなします。これではファイルの履歴が途切れたように見えてしまいます。内容の変更とファイル名の変更を別のコミットに分ければ Git はファイルの履歴を正しく推測してくれます。(Git がコミットとして記録するのはファイルツリーのスナップショットだけです。ファイル名が変更されたことを表すデータは存在しません。2つのコミットのスナップショットを比較して、同じかほとんど違いがない別名のファイルがあれば、ファイル名が変更されたとみなして表示します。)

サポートブラウザの問題

2020年1月から、 bulk-decaffeinate を使ってページや機能ごとの単位で徐々に変換を進めていきました。変換後の JavaScript をそのまま使えるなら楽なのですが、残念ながら一筋縄ではいかないことが分かりました。変換作業を行った当時のレシピサービスは InternetExploler 9 をサポートしていたからです。(今では IE 11 までのサポートになりましたが、これでもまだレガシーですね。) decaffeinate が書き出す JavaScriptは IE 9 で動かない ES2015 以降の新しい構文を含みます:

function foo() {
  const x = 1; // const は使えない

  var y;
  for (y of [1, 2, 3]) { ... } // for-of は使えない

  sendAPIRequest('/example', (result) => { // アロー関数は使えない
    if ([4, 5, 6].includes(result.items)) { ... } // Array.includes は使えない
  });
}

これらを手作業で書き換えることもできますが、人間が新しい構文を見分けるのはミスを起こしがちですし、遠からずサポート対象外になる IE 9 のためだけに古い構文に書き直すのはもったいないものです。なんとか自動的に JavaScript を IE 9 に対応させられないだろうかと考えました。さて、 JavaScript を昔のブラウザに対応するよう変換するといえば Babel の出番です。 Babel を使えば新しい構文をターゲットのブラウザに対応した構文に変換することができます。

ところで、 JavaScript は仕様が改訂を重ねると構文の追加だけでなく新しいクラスやメソッドが追加されることもあります。それらはソースコードの変換で昔のブラウザに対応した形に書き換えることはできません。クラスやメソッドは動的に扱われる(たとえば実行時に組み立てた文字列を名前としてメソッドを呼び出すことができる)ので、ソースコードを静的に解析しただけでは網羅できないからです。昔のブラウザで動かそうとすれば、新しいクラスを再現する polyfill と呼ばれる一群のコードを追加で導入する必要があります。しかしながら、 IE 6 までサポートする巨大な polyfill を丸ごと入れてしまうと web ページのサイズとロード時間に影響してきます。かといって不要なパーツを除外していくのも手間がかかります。

幸いなことに、 decaffeinate が生成したコードで polyfill が必要になるのは Array.includes() ほか少しだけで、1万行のうち数十行程度でした。今回はそこだけ polyfill がいらない形に手作業で書き換えることで対処しました。

Babel をアセットパイプラインに組み込む

話を Babel に戻すと、 JavaScript をいつ Babel で変換するかという問題が出てきます。 Rails は Rails で JavaScript のアセットを変換するアセットパイプラインを持っています。 Rails アプリケーションをデプロイするときにアセットをコンパイルするタスクを実行すると、アセットパイプラインによって入力ディレクトリ以下の JavaScript ファイルが読み込まれ (CoffeeScript ファイルなら読み込んだ後に JavaScript に変換するプリプロセスが行われ)、 JavaScript 同士が結合され、最後に圧縮されて出力ディレクトリに書き出されます。また開発モードで Rails サーバを起動しているときは、入力ディレクトリ以下のファイルが監視され、編集されたアセットは自動的に再コンパイルされます。

この一連の処理に Babel を付け加えるなら、入力ディレクトリ内のファイルを先に書き換えるか、出力ディレクトリ内のファイルを後で書き換えるかです。どちらにせよ開発モードで自動再コンパイルのタイミングと干渉しないようにする必要があり、ちょっと面倒な予感がします。

ここでアセットパイプラインの処理をもう一度よく見てみると、「CoffeeScript ファイルなら読み込んだ後に JavaScript に変換するプリプロセス」が目にとまります。アセットパイプラインにはソースコードを別のソースコードへと変換する処理がすでに組み込まれているのです。この仕組みに乗せてしまえば、ビルドツールを用意しなくても CoffeScript が使えていたのと同様に、 Babel が裏で動いていることを意識しなくてよくなるはずです。

アセットパイプラインに新しい変換処理を組み込むには、 call(input) クラスメソッドを持つクラスを書いて Sprockets.register_postprocessor で登録するだけと、拍子抜けするほど簡単です。実際に書いたのがこのコードです:

# babel_processor.rb
module BabelProcessor
  BABEL_PATH = Shellwords.escape(Rails.root.join("node_modules/.bin/babel").to_s)

  class Error < StandardError; end

  def self.call(input)
    # data は JavaScript のソースコード文字列
    data = input[:data]

    if has_babel_pragma?(input)
      # data を babel コマンドの標準入力に流し込んで標準出力から結果を得る
      stdout, stderr, status = Open3.capture3("#{BABEL_PATH} --no-babelrc --no-highlight-code", stdin_data: data)
      raise Error, "in #{input[:filename]}: #{stderr}" unless status == 0
      data = stdout
    end

    { data: data }
  end

  # ファイルの先頭に // @babel か /* @babel */ があるときだけ変換
  def self.has_babel_pragma?(data)
    %r{\A \s* /[/*] \s* @babel\b}x.match?(data)
  end
end

# config/application.rb の中で
Sprockets.register_postprocessor 'application/javascript', ::BabelProcessor

BabelProcessor はひとつの変換処理を担当するアセットプロセッサとして振る舞うクラスです。 call(input) メソッドはファイルから読み込まれた JavaScript のファイル名とソースコード文字列を受け取ります。その文字列を babel コマンドの標準入力に渡し、変換後の文字列を標準出力から得て返すだけです。これをアセットパイプラインのポストプロセッサとして登録します。なお、アセットパイプラインのメイン処理で結合された後の JavaScript を扱うため、プリプロセッサではなくポストプロセッサにしています。また変換されることで壊れるコードがないとも限らないので、選ばれたファイルだけを変換するため、先頭に // @babel と書かれたファイルだけを変換するようにしています。

この仕組みはうまく動き、移行作業はスムーズに進みました。 bulk-decaffeinate によって出力された ES2015+ の JavaScript はほとんど修正の必要がなく、1ヶ月程度で合計1万行程度の CoffeeScript をすべて JavaScript に変換することができたのです。

JavaScript と CoffeeScript の行数の変化
JavaScript と CoffeeScript の行数の変化

CoffeeScript の行数が減るにつれて JavaScript の行数が増えています。(並行して不要な JavaScript を削除する作業も進めていたので2月13日あたりで行数が急激に減っていますが、無関係です。)

Babel から Rollup へ

CoffeeScript と jQuery で書かれていたコードが ES2015 になるともっと欲が出てきます。 Rails 独自の //= require foo.js ディレクティブで文字列的にファイルを結合するのをやめて import 'foo'require('foo') を使いたいし、 npm でインストールしたモジュールも使いたいし React と JSX でビューを書きたいし、そうなったら TypeScript も使いたい。

Rails 6 に統合された webpack を使えば実現できるのですが、あいにく Rails 4 では動きません。それにレシピサービスはページごとに異なる小さな JavaScript ファイルをいくつも読み込んでいて、コードをひとまとめにバンドルするのが基本の webpack とは相性が悪いのです。たとえば、ビューの部分テンプレートの中で特定の条件のときだけ <script> タグを差し込む以下のような処理が存在します:

/ _footer.haml

.footer
  %p ページのフッターです
  - if some_condition?
    %p 条件に一致したときだけ表示される追加のフッターです
    / 追加のフッターにイベントハンドラを追加する JS
    = javascript_include_tag 'optional_footer.js'

この optional_footer.js をバンドルにまとめるとすると、条件に一致するときだけ処理を実行するようにロジックを変える必要があります。ひとつならともかく何十箇所もこんなコードがあるので修正の手間も馬鹿になりません。

なんとかならないかと思っていたら、モジュールバンドラの Rollup がコマンドラインで単一のファイルを変換できることに気づきました。 webpack よりは若干マイナーな存在ですが、つくりがシンプルなぶん設定が簡単で、コマンドとして実行しても動作が速いです。上記の BabelProcessor をベースに、 babel コマンドを呼ぶ箇所を rollup にし、いくつかの処理を加えました。またその処理に対応する Rollup の設定を rollup.config.js に書きました。

# rollup_processor.rb
require "tempfile"

module RollupProcessor
  ROLLUP_PATH = Shellwords.escape(Rails.root.join("node_modules/.bin/rollup").to_s)
  ROLLUP_CONFIG_PATH = Shellwords.escape(Rails.root.join("rollup.config.js").to_s)

  class Error < StandardError; end

  def self.call(input)
    data = input[:data]
    if has_rollup_pragma?(data)
      self.build(input[:data], input[:filename])
    else
      { data: data }
    end
  end

  def self.build(data, filename)
    dirname = File.dirname(filename)

    Tempfile.create("cookpad_all_rollup_processor") do |temp|
        stdout, stderr, status = Open3.capture3(
          { "COLLECT_MODULE_PATHS" => temp.path },
          "#{ROLLUP_PATH} --config #{ROLLUP_CONFIG_PATH} -",
          stdin_data: data,
          chdir: dirname,
        )
      raise Error, "in #{filename}: #{stderr}" unless status == 0


     # Rollup に渡した JavaScript から `require()` や `import` で読み込まれたファイルのパスを集め、
     # それらのファイルが依存関係にあることを Rails に伝える。
      module_paths = JSON.parse(temp.read)
      dependencies = self.dependencies_from_paths(module_paths, dirname)

      { data: stdout, dependencies: dependencies }
    end
  end

  def self.dependencies_from_paths(paths, base_dir)
    node_modules_path = Rails.root.join("node_modules").to_s + "/"
    paths.reject do |path|
      path == "-" || path.start_with?("\0") || path.start_with?(node_modules_path)
    end.map do |path|
      realpath = File.realpath(path, base_dir)
      "file-digest://#{realpath}"
    end
  end

  def self.has_rollup_pragma?(data)
    %r{\A \s* /[/*] \s* @rollup-entry-point\b}x.match?(data)
  end
end

# config/application.rb の中で
Sprockets.register_postprocessor 'application/javascript', ::RollupProcessor
// rollup.config.js
import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';

import fs from 'fs';
import path from 'path';

const collectModulePaths = {
  buildEnd() {
    if (process.env.COLLECT_MODULE_PATHS) {
      const modulePaths = Array.from(this.getModuleIds());
      fs.writeFileSync(
        process.env.COLLECT_MODULE_PATHS,
        JSON.stringify(modulePaths)
      );
    }
  },
};

export default {
  output: { format: 'iife' },
  plugins: [
    commonjs(),
    resolve({
      browser: true,
      extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
    }),
    babel({
      configFile: path.resolve(__dirname, 'babel.config.js'),
      babelHelpers: 'bundled',
      extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
    }),
    collectModulePaths,
  ],
};

追加した処理はアセット間の依存関係に関するものです。 Rails のアセットパイプラインでは、 JavaScript のファイルから別の JavaScript を読み込むディレクティブ //= require を書くことができます。たとえば a.js から b.js を読み込むには

console.log('This is a.js');
//= require ./b.js
console.log('This is b.js');

と書き、生成される a.js の中身は

console.log('This is a.js');
console.log('This is b.js');

になります。このような //= require が書いてある参照元は参照先に(a.js は b.js に)依存することになります。依存関係があることで、開発モードで Rails サーバを起動しているとき、 b.js をエディタで編集すると、 b.js だけでなく参照元の a.js も自動的に再コンパイルされます。

しかし、 Rollup をアセットプロセッサとして使うと依存関係が失われてしまう場合があります。たとえば Rollup で処理される c.js から d.js を読み込むには

console.log('This is c.js');
import './d';
console.log('This is d.js');

と、 JavaScript の import 文を使うこともできます。アセットパイプラインの視点で見ると、 rollup コマンドに c.js の中身を流し込んだら

console.log('This is c.js');
console.log('This is d.js');

が得られたということになります。 Rollup によって d.js がインポートされたことは知る由もないので、 c.js が d.js に依存するという情報は失われます。

そこで依存関係を正しく認識させるために Rollup のプラグインを書きました。 rollup.config.js の collectModulePaths の部分です。 Rollup はプラグインで簡単に拡張でき、ビルドのさまざまなフェーズに独自の差し込むことができます。 collectModulePaths プラグインはビルド後のフェーズでビルド中にインポートされたパスを集めてファイルに書き出します。それをアセットプロセッサ側で読み込み、依存関係データを構築しています。

こうして Rails に Rollup を組み込んだ結果、アセットパイプラインを前提に書かれた既存の JavaScript コードにまったく手を加えることなく Rollup の恩恵を受けられるようになりました。 JavaScript の中で他のスクリプトやモジュールを import することができますし、そうしたければ //= require ディレクティブと混ぜて書くことさえできます。

import React from 'react';
//= require ./e.js
import './f';

何やら禍々しい見た目ですが、段階的にコードを改善していくには便利な仕組みです。

おわりに

CoffeeScript とお別れした話と、アセットパイプラインの一工夫でフロントエンド開発がちょっとモダン化した話をしました。レガシーな JavaScript コードを抱えた Rails アプリケーションを運用している皆さんの参考になれば幸いです。

クックパッドでは仲間を募集しています!

さて、歴史ある web サービスの改善は地道なものですが、ときにはエキサイティングで急激な変化もあります。クックパッドサービス基盤グループでは、まずスマートフォンブラウザ向けのレシピページから、 Next.js と GraphQL バックエンドを使って一から書き直すプロジェクトを進めています。実はすでに一部のスマホ向けレシピページは Next.js で表示されています。今後もさらに多くのページを改善していきますので、最新の web 技術をバリバリ使ったサービス開発に興味がある方も、レガシーコードをバタバタやっつけたい方も、ぜひお気軽にご連絡ください。

info.cookpad.com

Cookpad Online Spring Internship 2021 を開催します!

ユーザー・決済基盤部の三吉(@sankichi92)です。昨年よりエンジニアの立場から新卒採用を担当しています。

この記事では、3月下旬に開催するスプリングインターンシップについて紹介します。 インターンシップの実施要項や応募フォームは下記のページよりご確認ください。

クックパッドでは、スプリングインターンシップを毎年開催しています。 しかし、その形式は年々改善を重ね変わってきています。 昨年は初のオンライン開催でした。 今年もオンラインでの開催ですが、社内ハッカソン "Hackarade" の形式を取り入れた新しいものになっています。

また、昨年のテーマはサーバサイドアプリケーションのパフォーマンスチューニングでしたが、今年はモダン Web フロントエンドがテーマです。

クックパッドの社内ハッカソン "Hackarade" を体験してみよう!

クックパッドでは年に数回、エンジニア全員参加の社内ハッカソン "Hackarade" を開催しています。 Hackarade は単なるハッカソンとちがい、エンジニア全員で新技術に触れることが目的です。 そのため毎回テーマを変えており、社内の第一人者による講義を受けたのち開発に取り組みます。 詳しい様子は以下の開催レポートをご覧ください。

もともとの Hackarade は1日で完結するものですが、昨年のスプリングインターンシップでは1日は短すぎるという声をいただいたので、5日間の日程を2ターム用意しています。

  • 第1ターム: 3/15(月)〜3/19(金)
  • 第2ターム: 3/22(月)〜3/26(金)

5日間という期間を取っていますが、時間を合わせて集まるのは初日と最終日のみです。 初日にオリエンテーションを行い、最終日に成果発表を行います。 2日目から4日目は各自での開発の期間とし、時間の使い方は自由です。 研究室やバイトに行ったり、別のイベントに参加したりしていただいても構いません。 その間、Slack や必要に応じて Zoom で社員 TA が非同期的に質問の回答や開発のサポートを行う予定です。

オフィスに集まる形だと全日程の参加が前提なので、こういった形式が取れるのもオンラインならではかと思います。首都圏以外の地域や海外からの参加など、オフィスに来ることが難しい方でも、より参加しやすくなっています。 他にもオンラインならではの工夫を凝らしたいと考えています。

実践! モダン Web フロントエンド開発

Hackarade では毎回テーマを変えていると書きましたが、今回のテーマは「実践! モダン Web フロントエンド開発」です。 技術部の外村 @hokaccha が講師を務めます。 TypeScript、React、Next.js、GraphQL など、クックパッドでも利用している Web フロントエンドの技術について実践を通して学びます。 参加に際して Web フロントエンドの知識は問いませんが、これらの技術にすでに親しみのある学生も(もちろんそうでない学生も)楽しめる内容になるはずです。

クックパッドのフロントエンド事情については、外村による以下の記事をご覧ください。 この記事をきっかけに今回のテーマが決まりました。


スプリングインターンシップの応募締切は 2/26(金) 3/3(水) 13:00 です。 選考フローは、書類選考とプログラミング課題のみのシンプルなものになっています。

学生の皆さまのご応募をお待ちしています!

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/