CoffeeScriptをやめてES6を使うためにやったことまとめ

この記事は Speee Advent Calendar 2017 11日目の記事です。

10日目は @iida-hayato による 5000兆円トークンに見るスマートコントラクトの設計と実装。 でした。

記事について

Rails製の自プロダクトからCoffeeScriptを廃止してES6を使えるようにした話です。

CoffeeScriptやめたいけど、色々やること多そう。。」みたいなところで悩まれている方々の参考になれば幸いです。

作業ログをベースに書いてるので、自プロダクト環境固有の話を含むことをご了承ください。

背景

  • オワコン化が叫ばれるCoffeeScript
    • ES6のリリースにより、クラス構文などが標準JSで利用できるようになった
    • 今となっては学習コストが高いわりに強い恩恵がない
      • 普段Rubyを書かないフロントエンジニアにとっては特に
    • TypeScriptの登場
  • フロントエンジニアの採用リスク
    • 上記要因などあって、CoffeeScriptを書ける/書きたがるフロントエンジニアが市場から減っている
  • TypeScript採用したい
    • そのためにもまずはCoffeeScriptを標準JSに置き換える

移行前のフロントエンド状況

ビルド方法

  • coffeeify + browserify + gulpで frontend/配下の .coffee をトランスパイル+バンドル
  • バンドルした .jsapp/assets/javascripts 配下に配置
const gulp = require('gulp');
const browserify = require('browserify');
const coffeeify = require('coffeeify');

const dir = {
    srcJS: "./frontend/js/",
    distJS: "./app/assets/javascripts/"
};

const coffeeFiles = [
    'hoge.coffee',
    'fuga.cofffe',
    ...
]

gulp.task('build', () => {
    jsFiles.forEach(function (file) {
        var srcfile = coffee2jsExtension(file);
        browserify({
            entries: [dir.srcJS + file],
            extensions: ['.coffee'],
            transform:  [coffeeify]
        })
        .bundle()
        .pipe(source(srcfile))
        .pipe(gulp.dest(dir.distJS));
    }
});

function coffee2jsExtension(file){
    return file.replace(".coffee", ".js");
}

やったこと

  1. decaffeinateで既存の.coffeeを.jsに全て置き換え
  2. トランスパイル周辺の設定
  3. コケるfeature specをなんとかする
  4. JSエラー検知の有効化

今回のスコープではやらないことにしたもの

使用言語やフレームワークのリプレイスは遅れるほど負債が溜まっていく上、ステークホルダーがいつまでも笑顔でいるとは限りません。

とにかくCoffeeScriptをやめることを第一優先に、下記に挙げられるものは別タイミングでやることに決めました。

  • ESLint導入
  • browserify => webpackへの移行
  • ES6風のコードにする

1. decaffeinateで .coffee.js

coffeeファイルをjsに置き換える上で、decaffeinateを利用します。

ちなみにdecaffeinateという言葉には「カフェインを取り除く」という意味があるようです。おしゃれ。

$ npm install -g decaffeinate
$ find ./ | grep .coffee | grep -v node_modules | xargs -I{} decaffeinate {} 
.//app/assets/javascripts/hogehoge.coffee → app/assets/javascripts/hogehoge.js
.//app/views/hoge/histories/edit.coffee → app/views/hoge/histories/edit.js
.//frontend/js/hoge/hige.coffee → frontend/js/hoge/hige.js
...

suggestionsが付与されたコードを確認する

decaffeinateの思想は 生成したコードの動作の正確性 に重きを置いており、そのため生成されたコードが既存コードの意図よりも 過剰に防御的 になってることがあります。

そういった可能性があるjsファイルに対して、decaffeinateはsuggestionsを残します。

decaffeinateの実行結果の例

-  $('.hogeButton').on 'click', (event) ->     
-   button  = $(event.target)      
-   {from, to}= button.data()      
-  
-   $.ajax(        
-     url: "/hoge/hige"      
-     data: { from: from, to: to }     
-     dataType: 'json'     
-     method: 'POST'       
-   )

+ /*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */

+ $('.hogeButton').on('click', function(event) {
+   const button  = $(event.target);
+   const {from, to}= button.data();
+
+   return $.ajax({
+     url: "/hoge/hige",
+     data: { from, to },
+     dataType: 'json',
+     method: 'POST'
+   });
+ })

自プロダクト内で散見されたsuggestionsをいくつか紹介します。

DS102: Remove unnecessary code created because of implicit returns

  • CoffeeScriptは関数の終わりに暗黙的に最後のステートメントをreturnしている
  • JavaScriptにはこの動作はない
  • decafeinateはCoffeeScriptと同一の挙動を模倣するので、必ず最後にreturnが生成される
  • よって実装上不要なreturnは確認した上で消しましょう

DS104: Avoid inline assignments

  • 関数の結果を再利用するためにdecaffeinateが変数を自動生成する
  • 評価順序を模倣するためインラインで代入を行っており、コードが見辛くなることがある

CoffeeScript

accounts[getAccountId()] //= splitFactor

decaffeinate後のJavaScript

let name;
accounts[name = getAccountId()] = Math.floor(accounts[name] / splitFactor);

修正例

const accountId = getAccountId();
accounts[accountId] = Math.floor(accounts[accountId] / splitFactor);

DS207: Consider shorter variations of null checks

  • CoffeeScriptにはnullやundefinedを評価する ? 演算子がある
  • JavaScriptでは != nullに置き換えられるが、単にboolとして扱うほうが望ましいケースもある

CoffeeScript

if currentUser()?
  sendMessage(customMessage() ? 'Hello')

decaffeinate後のJavaScript

if (currentUser() != null) {
  let left;
  sendMessage((left = customMessage()) != null ? left : 'Hello');
}

修正例

if (currentUser()) {
  sendMessage(customMessage() || 'Hello');
}

その他のsuggestionsのパターンと解消例はsuggestions.mdを参照。

2. トランスパイル周辺の設定

  • coffeefy関連の処理を削除
  • babelifyを導入
    • ES6を既存ブラウザで実行できる形式(ES5)にトランスパイルする
    • IEなどES5のサポートが限定的なブラウザに向けてbabel-polyfillも併用

package.json

  "dependencies": {
+     "babel-plugin-espower": "2.1.2",
+     "babel-polyfill": "6.26.0",
+     "babel-preset-es2015": "6.6.0",
+     "babelify": "7.2.0",
    ...
  },
+   "babel": {
+     "presets": [
+       "es2015"
+     ]
+   }

gulpfile.js

const gulp = require('gulp');
const browserify = require('browserify');
- const coffeeify = require('coffeeify');
+ const babelify = require('babelify');
+ const fs = require('fs');

const dir = {
    srcJS: "./frontend/js/",
    distJS: "./app/assets/javascripts/"
};

- const coffeeFiles = [
-     'hoge.coffee',
-     'fuga.cofffe',
-     ...
+ const jsFiles = [
+     'hoge.js',
+     'fuga.js',
]

gulp.task('build', () => {
    jsFiles.forEach(function (file) {
-         var srcfile = coffee2jsExtension(file);
-         browserify({
-             entries: [dir.srcJS + file],
-             extensions: ['.coffee'],
-             transform:  [coffeeify]
-         })
-         .bundle()
-         .pipe(source(srcfile))
-         .pipe(gulp.dest(dir.distJS));
+         browserify(dir.srcJS + file, { debug: true } )
+             .transform(babelify)
+             .bundle()
+             .pipe(fs.createWriteStream(dir.distJS + file));
    }
});

- function coffee2jsExtension(file){
-     return file.replace(".coffee", ".js");
- }

各エントリポイントのJSにbabel-polyfillを適用

+ import 'babel-polyfill'

3. コケるfeature specをなんとかする

  • Poltergist + PhantomJSで書いてたfeature specがコケる
    • buttonのclickイベントが発火しない, etc
  • クロスブラウザチェック等では問題なく動作できている
  • PhantomJSでしか発現しない問題に対処するのは辛い

というような経緯で、これを期にHeadless Chromeに乗り換える決断をしました。Headless Chrome乗り換えに関する記事は後日書きます。

4. JSエラー検知の導入

クロスブラウザチェックとテストコードが通ることを確認したものの、 何か漏れてるリスクも踏まえてSentryによるJSのエラー通知を導入しました。

package.json

  "dependencies": {
+     "raven-js": "3.0.4",
  ...

各エントリポイントに追加

var Raven;

if (typeof history.pushState === "function") {
    Raven = require('raven-js');
    Raven.config(sentry_dsn).install();
}

5.リリース後

リリース後1ヶ月程度が経ちましたが、DS102の対応(不要なreturnの削除)で、消してはいけないreturnを消してた箇所が一つあったという人為的な凡ミス以外に、今のところ問題は起きていません。 工数的に大きかったのはクロスブラウザチェックやfeature spec回りで、decafeそのものは比較的スムーズに行うことができたという所感です。

とはいえフロントエンドも定期的に負債解消しないとツケが大きくなってくるのは当然で、 バックエンドだけでなくフロントエンドも継続的にキャッチアップしていかなければなぁ。。という気持ちになりました。

次回は @yt-tanabeより「SpeeeKaigiのつくりかた」です。

プロダクト開発プロセスを考えるようになってから一年が経った

Speee Advent Calendar 2016」の25日目です。

世間ではクリスマスとかいう流行り病がキテるようですが、この記事でクリスマスの話は一切しません。

クリスマスっぽい記事を読みたかった方は、@pataiji氏が書いた超絶リア充記事を御覧になれば満足頂けるかと思います。

 

自己紹介

株式会社Speeeに2015年新卒エンジニアとして入社しました。

2015年10月末頃まではウェブマーケティング事業に関連する社内ツールの開発を主務とし、

2015年11月頃からはとある新規事業の立ち上げにリードエンジニアポジションとして携わることになり、以来その事業にてプロダクトの機能開発と運用を約1年間続けてきました。

 

初めてのプロダクト開発

とある新規事業にアサインされるまで、プロダクト開発(=一つのプロダクトを長期的に開発/運用していくこと)というものをきちんと経験したことはありませんでした。

そんな中での開発チーム立ち上げとなったのですが、初期リリースの期限が早速迫っているという事情もあり、非常に大まかなフローのみを決めて開発を進めていくこととなります。

f:id:kohtaro24:20161225184644p:plain

一般的な開発プロセスといえばこうだよね、みたいな。

開発プロセスの大きな段階を一つずつ切り出して並べ、あとは見積もった工数に基いてざっくりとしたスケジュールに落とし込む、という感じでした。

 

結果どうなったか

リリース目標日にはなんとか間に合わせることができました。

ただ、計画通りに事が運んだというわけではなく、最後の最後に予実を合わせにいった形です。

f:id:kohtaro24:20161225213838p:plain

 何が起きてたのかというと、こんな感じ。実装中に仕様や優先度の変更が行われたり、設計や見積もりのやり直しが発生しています。

仕様や優先度の前提が覆る

事業の立ち上げ初期の現場において、数日の間に仮説が変化する場面は珍しくなく、「初期リリースの時点でどんな機能がどんな状態で必要なんだっけ?」という前提が実装期間の中で右往左往し、リリースのスコープから外れる機能が出てきたり、逆もまた然りであったり。

工数見積もりも覆る

「この開発はリリース日までに間に合うのか?」を明確にする上で見積もりは重要です。しかし、長期的な機能開発に費やされる工数を正確に見積もるのは大変で、仕様や設計が変化すると見積もりに関してもやり直す必要が出てきます。見積もった結果がリリース日までに間に合わないと判断された場合は、仕様上簡素化できそうな部分を探したり、代替案を考えて調整したりします。そして再度見積り。

 上記のような要因が重なった結果、初期リリースに含まれた機能群は当初見込んでいた数の半分以下になり、テストや修正に費やされる期間が縮まったことで、リリースのタイミングを予定と合わせることができた、という形です。

 

開発プロセス検討

なんとか初期リリースを乗り切った後に、これからの開発プロセスをどうしていこうか、という話し合いをチームで実施しました。

アジャイルスクラムといったキーワードがそこで登場するようになり、「アジャイルかぁ。。聞いたことはあるけどどういうものなんだろう」とか思いながら、アジャイルサムライSCRUM BOOT CAMP のような書籍を読み漁りつつ、自分たちのリソースで運用していくことができそうな開発プロセスを思案しました。

大きく変えていったことを以下に列挙していきます。

開発のリリース頻度について

プロダクトの初期リリースというのは、開発計画における分かりやすい一つのマイルストーンでした。一方で、これから進めていく開発については二つの案が考えられました。

  1. 何月何日にどんな機能をリリースをするかを事前に決めてから開発する
  2. 優先度が高いとされる機能から開発し、開発完了次第即リリースする

自分たちのチームにおいて、1のような開発が求められるケースは例外的であり、基本的には目の前の価値を早く届けることに注力したいという観点から2の案を選択しました。 

開発の周期について

先の項で書いたように、立ち上げフェーズの事業においては著しく状況が変わるため、開発チームが事業課題に追従しやすい動き方を取る必要があります。

優先度や計画を調整したり、振り返りを行い易くするために、開発プロセスに一定の周期を設けることが必要だと考えられました。

f:id:kohtaro24:20161226012607p:plain

ということで、一回あたりの開発周期(スクラムでいうところのスプリント)を1週間と置き、毎週の金曜日を締め日として、振り返り・新規開発要望及び優先度の確認・計画引き直しを毎週実施するようにしました。

工数見積もりについて

この件については、工数見積をどのくらいまで厳密にやるのか、ということが争点でした。「この機能を開発するのに〇〇時間かかります!」ということを伝えたとして、実際には開発する人によって結果が違ったり、コードレビューやリリース作業などが入ったりもするので、〇〇時間という数字を強く信用されると色々困ることもあり、これを伝えるには結構な勇気がいります。それ故に見積もり自体も非常に神経をすり減らす作業になります。

とはいえ、この機能開発は今週の計画内に組み込めそうかであったり、チームがどのくらいの開発を今週捌こうとしてるのかくらいは把握可能にしておきたいという事情はあります。

そこで自分たちのチームではストーリーポイント見積もりを採用しました。

時間ではなく作業の大きさを相対的に表したポイントを使うことで、時間を扱う際には密に考慮する必要のあった要素を、可能な限り見積もり作業から排除できます。

また、週ごとに消化できたポイントの数を計測していくことで、この機能開発は今週の計画内に組み込めそうかなどの判断もできるようになっていきます。

要件定義について

ぽっと出の開発要望の中には、「この要件、本当に適切?」って思うようなものも少なからず含まれます。

何を叶えるべきかが明確であれば、満たさなければならない要件はもっとシンプルにできるんじゃないか?ということで、開発要望において主要な項目をまとめたものをテンプレート化しました。

f:id:kohtaro24:20161226003513p:plain

githubからIssueを作ろうとすると、このようなテンプレートが表示されます。

各項目に関しては以下のような内容を記載します。

  • (Who) として
    • 要望の出処を明らかにする。
    • 要望の持ち主が誰なのかを把握することで、仕様に関するヒアリング等を誰に行うのが妥当なのかを判断できる。
  • (What) がしたい
    • どのような要望なのかを明らかにする。
    • 本質的なゴールは何なのかを把握することで、「どうなるのが理想なのか」を基準に仕様を検討できる。
  • なぜなら (Why) だからだ
    • その要望が発生した背景、実現することの妥当性について明らかにする。
  • 本Issueは (Acceptance Criteria) を以って完了とする
    • その要望を実現する上で、何がなされている必要があるかを明らかにする。

項目を共通化することで内容を把握しやすくするとともに、主要な項目に絞ることで論点が明らかになるようにしています。

そして1年が経った

上記にあげたような開発プロセスをおよそ1年間の間、少しずつチューニングを繰り返しながら継続してきました。

この開発プロセスを導入して以降、前より良くなった面は沢山あると思っているものの、1年も続けていると形骸化してきている部分がチラホラあったり、事業としてのフェーズも大きく変わっていたりするので、

  • 本来やりたかったことが今も守れているか?
  • そもそも今となっては不要(ないしは形を変えるべき)なことはないか?

など、もう一度見つめ直さないといけないなぁという気持ちでこの記事を書きました。

開発プロセスは、エンジニアが価値を提供し続ける上で絶対に欠かすことのできないもので、かつBiz側の方々の協力無しでは成立し得ないものだと思います。

日頃からの協力者である様々な方々に感謝の念を送りつつ、今年のクリスマスを終えます。良いお年を。