クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

CoffeeScript 辞めました

CoffeeScript 辞めました

はじめに

こんにちは、CrowdWorks のジャンヌチームのエンジニア bugfire です。

CrowdWorks は歴史のあるサービスで CoffeeScript が利用されている箇所が大量にあります。 具体的には411ファイルありました。1

いまとなっては CoffeeScript を利用したコードを新たに書くことも少なくなり、改善するときに TypeScript で書き直されることも多くなってきました。しかし、個別に変換すると時間が掛かってしまうため、まとめて機械的に変換することにしました!

3行まとめ

  • CoffeeScript より TypeScript の方に記述を移していきたい。
  • 利用している Sprockets の CoffeeScript 変換コードを借用して全ファイルを変換しました。
  • 確認は変換前後の出力 JavaScript コードの完全一致で行いました。

手順

Minify 設定

Production では Minify しています。手元の開発環境で実験するときも Minify を行う方が再現度が高いです。しかし、変換作業中に差分が発生した原因を追求するときは Minify がない方が差分が見やすくて良いです。

設定は config.assets.js_compressor で行います。

今回は、変換当初は Minify なしで行い、変換に慣れてきた段階で Minify ありに変更しました。

差分検出のためのスナップショットスクリプト

Sprockets のアセットのビルドで public/assets 以下に出力するようにしています。 このようなスクリプトを用い出力された JavaScript のみのスナップショットを取るようにしました。

#!/bin/bash

DATE=`date "+%Y-%m-%d-%H-%M-%S"`
OUTPUT=DECAFE/$DATE

# public/assets 内にある js のリストを作る
FILES=`find public/assets -name "*.js"`

echo "creating... $OUTPUT"
mkdir -p $OUTPUT

# ファイル一覧を書き込む
echo "$FILES" > $OUTPUT/list.txt

tar cf - $FILES | ( cd $OUTPUT; tar xvf - )

# 差分が見やすいようにfile名の64文字のhashを取り除く
for i in `find $OUTPUT -type f`; do
  NEW_FILENAME=`echo $i | sed -e 's/-[0-9A-z]\{64\}\.js/.js/g'`
  if [ "$i" != "$NEW_FILENAME" ]; then
    mv $i $NEW_FILENAME
  fi
done

diff で差分が取りやすいようにファイル名から hash を取り除いて JavaScript ファイルを全てコピーしています。

比較対象となる作業前のオリジナルのスナップショットのディレクトリ名は、タイムスタンプからわかりやすい名前にリネームしておきます。

大きな変換をおこなったときに差分が出ると修正が難しくなります (require_tree など行っている部分は特に)。アセットのビルドに時間がかかり大変ですが、こまめにアセットのビルドとオリジナルとの差分のチェックを行いました。

変換ファイルを出力するモンキーパッチ

Sprockets で変換する部分をモンキーパッチしてファイルに出力します。幸い、ファイル単位で変換してから後段で一つのファイルにまとめて Minify をおこなっているので、ファイル単位の変換部分をモンキーパッチできます。

利用している Sprockets のバージョンに依存しているので、変換が行われているコード (https://github.com/rails/sprockets/blob/main/lib/sprockets/coffee_script_processor.rb) を見ながら作業するのが良いでしょう。

# CoffeeScript のコンパイル成果物をファイルに出力する
#    安全に変換できそうなものは __decafe__ という suffix
#    何らかの目視確認が必要なものは __decafe_check_me__ という suffix をつけます
# これは、単純な変換で安全に変換できるかどうかを区別しています
module SpyCoffeeBuild
  def call(input)
    output = super
    coffee_filename = input[:filename]
    # .js.coffee と .coffee があるので、一旦 .js があれば外してから付加します
    js_filename = File.dirname(coffeeFilename) + '/' + File.basename(File.basename(coffee_filename, '.coffee'), '.js') + '.js'
    input_text = File.read(coffee_filename)
    # require文がある。変換後は削除されるので目視確認
    has_require = input_text.include?('#= require') || input_text.include?('#=require')
    # コメント終端文字列がある。ファイル末尾にコメントで追加するので異常なコードになる。目視確認
    has_comment = input_text.include?('*/')
    # 変換後のファイル名と同じファイルがある。目視確認
    has_conflict_file = File.exist?(js_filename)
    suffix = (has_require || has_comment || has_conflict_file) ? ".__decafe_check_me__" : ".__decafe__"
    File.write("#{coffee_filename}#{suffix}", output + "\n/*\n#{input_text}*/\n")
    output
  end
end

Sprockets::CoffeeScriptProcessor.singleton_class.prepend(SpyCoffeeBuild)

変換後は、空行やコメントは削除されるので、いくつかの工夫をしています。

空行やコメントの削除対策で、変換時は JavaScript ファイル末尾に /* ... */ コメントで元の CoffeeScript ソースを添付します。

以下は目視確認が必要なケースで、ファイル末尾に __decafe_check_me__ の suffix をつけます。 これらのケースに当てはまらないときは、安全に変換できたとして __decafe__ の suffix をつけます。

  1. 変換時に削除される require コメントが存在する。
  2. CoffeeScript ソースに */ が含まれている。出力は異常なファイルとなる。2
  3. 出力先のファイルと同じ名前のファイルがある。3

変換されたコードから git の commit を作成

目視確認が不要な自動変換されたファイルを commit するためのスクリプトです。

ファイルのリネームと内容の更新を同時に行なった場合、ファイルの同一性の維持に失敗することがあります。そのためリネームと更新の2回にわけて commit しました。

git mv スクリプト

git mv するだけです。 動作させた後は git commit します。

#!/bin/bash

FILES=`find . -name "*.coffee.__decafe__"`

for i in $FILES; do
  COFFEE_FILE=${i%.__decafe__}
  JS_FILE=${COFFEE_FILE%.coffee}
  # .js拡張子がある場合とない場合がある
  JS_FILE=${JS_FILE%.js}.js

  git mv $COFFEE_FILE $JS_FILE
done

git add スクリプト

変換されたものをコピーして git add するだけです。 動作させた後は git commit します。

#!/bin/bash

FILES=`find . -name "*.coffee.__decafe__"`

for i in $FILES; do
  JS_FILE=${i%.coffee.__decafe__}
  # .js拡張子がある場合とない場合がある
  JS_FILE=${JS_FILE%.js}.js

  cp $i $JS_FILE
  git add $JS_FILE
  rm $i
done

手動変換

自動変換された CoffeeScript ファイルをベースに、

  • 元ファイルの #= require//= require 形式に書き直して追加する
  • ファイルが小さければ、手間にならないので読みやすく整形。その結果、元ソースが不要であれば削除。
  • コメント部分は JavaScript として破綻しないように修正

などを行い、アセットのリビルドをこまめにおこない、差分を見ながら変換しました。

トラップ

コメントしか存在しないようなファイルでも、CoffeeScript として変換された場合に、

(function() {

}).call(this);

のようなファイルが出力され、出力されたファイル内に入り込みます。 もしコメント以外に一行も記述がなかったとしても、この記述がない場合は出力 JavaScript に差分がでますので、例外は作らずに変換されたコードを利用します。

最終確認

手元の開発環境では差分がないことを確認しながら作業していますが、最後は念の為ステージング環境でも、変換実施前・実施後のファイルを比較して変更がないか確認しました。

具体的には、Sprockets の出力した Manifest ファイル public/assets/.sprockets-manifest-*.json を比較しました。比較時に見やすいように jq などで JSON を整形しておき、diff で比較を取得しました。

Hash 上の差分はなく mtime (最終更新時刻) だけに差分があることをチームで確認しました

今回やらなかったこと

読みやすいコードへの変換

  • 出力が変わらない部分
    • 削除されたコメント・空行の回復。
  • 出力が変更される部分
    • 無意味な return 文、let を使わないための関数スコープや、空の関数定義の削除。

前者は変換の実装速度のため、後者は同一性を保証するため、わりきりました。 コードの分量が多いため、滅多に変更されないとはいえコンフリクトを避けたかったからです。

出力結果が異なるような変換や、美しく整えることは別の機会に行いましょう。

最後に

クラウドワークスではフロントエンドが好きなエンジニアも、バックエンドが好きなエンジニアも募集しています!

crowdworks.co.jp


  1. JavaScriptは247ファイル、TypeScriptは331ファイル、Storybookは395ファイル、Vue.jsは440ファイルでした

  2. CoffeeScript に変換前の JavaScript (!) が含まれていたり(歴史ですね)、正規表現の一部で出現していました。

  3. xxxx.js と xxxx.js.coffee が両方存在していました。歴史ですね。

© 2016 CrowdWorks, Inc., All rights reserved.