ヤフー株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。LINEヤフー Tech Blog

テクノロジー

ES6時代のNode.js

こんにちは。情報システム本部の伊藤(@koh110)です。
社内システムの開発、運用を担当しています。

今回、担当しているシステムをNode.js LTS(v4.x)へバージョンアップしました。
それに伴い実施したES6対応の中から3つの事例を紹介したいと思います。

  1. varを撲滅しよう
  2. arrow functionを使おう
  3. callbackを撲滅しよう

varを撲滅しよう

varをlet, constに置き換えます。基本はconstに置き換えます。
メリットは以下の点で、コードの品質向上につながると思います。

  • プログラム中で変更不可である事を明示的に示せる。
  • 誤った使い方をした時にバグとして検出される。

varを利用するとブロックの外までスコープが残ってしまいます。
let, constを利用するとスコープがブロック内に収められ、誤った使い方をした時に検出されるようになります。
ES6対応の中ではすごく簡単なので、まずはここから手をつけるといいのではと思います。
なにより対応した気になれてモチベーションを保ったまま作業できるのが個人的に一番のメリットです。

var

for (var i = 0; i < 100; i++) {
  ...
}
console.log(i); // 100

let

for (let i = 0; i < 100; i++) {
  ...
}
console.log(i); // ReferenceError: i is not defined

Node.jsのconstはただの再代入禁止なので、オブジェクトの操作は可能です。
なので、基本的に変数はconstを使うと思っておけば大丈夫でしょう。
requireなんかはconstの格好の餌食です。どんどんconstにしましょう。

const hoge = {
  fuga: 'fuga'
};

// OK
hoge.fuga = 'fugafuga';

// NG
hoge = 'hoge'; // Assignment to constant variable.

letはどうしても再代入が必要な部分でしか使いません。今の所for文のiくらいに収めきれています。

eslintで以下の2つのルールをあてています。
http://eslint.org/docs/rules/no-var.html
http://eslint.org/docs/rules/prefer-const.html

arrow functionを使おう

functionはほぼ全てarrow functionに置き換えます。

他の言語をすでに習得している人がJavaScriptを学習して、一番つまずきやすいのはthisだと思います。
functionが入れ子になっていたりする中でthisが使われるともう初学者にはお手上げな状態です。僕がそうでした。

今までだと、thisを一度別の変数に置き換えたり、bindしたりしていました。

var _this = this;
var hoge = function(data) {
  _this.data = data;
};

var fuga = function(data) {
  this.data = data;
}.bind(this);

これがarrow functionで以下のようにかけます。

const hoge = (data) => {
  this.data = data;
};

文法的には()や{}を省略できたりするのですが、最低でも上のように()と{}はつけたほうが読みやすかったです。

arrow functionによって、thisの問題が解決する上に記述量が減るメリットがあります。

eslintで以下ののルールをあてています。
http://eslint.org/docs/rules/prefer-arrow-callback.html
http://eslint.org/docs/rules/arrow-parens.html

callbackを撲滅しよう

個人的にES6対応で一番恩恵が大きかったのはPromiseとGeneratorです。

Node.jsではブラウザに比べてcallback地獄になりがちです。
今までのNode.jsではおとなしくcallbackを書いたり、asyncといったモジュールが利用されてきました。
これがPromiseとGeneratorで同期処理のようにかけるようになりました。

また、Node v4がリリースされて以降、多くのモジュールがPromise対応しています。
node-mongodb-nativeやioredisなど、すでにPromiseに対応しているモジュールも数多くあります。
http://mongodb.github.io/node-mongodb-native/2.0/api/index.html
https://www.npmjs.com/package/ioredis

今のうちにPromiseやGeneratorを利用する事は、Promise化されゆくモジュールの対応にも役立つ事になるのでメリットは十分にあると思います。

callback地獄については以下の記事がわかりやすいです。
こちらの記事でも述べられていますが、実際にGeneratorによってNode.jsの書き方は大きく変わったと感じました。
https://techblog.yahoo.co.jp/programming/js_callback/

簡単に例をあげてみます。

ex)処理したら1秒待つ。を5回繰り返す

今までのNode.js

var i = 0;

var timer = function() {
  console.log(i);
  i++;

  if (i < 5) {
    setTimeout(timer, 1000);
  }
};

timer();

PromiseとGeneratorを使ったNode.js

'use strict';

const co = require('co');

const sleep = (ms) => {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
};

co(function*() {
  for (let i = 0; i < 5; i++) {
    console.log(i);
    yield sleep(1000);
  }
});

for文が使えるようになった分、同期的にみえませんか?
Generatorではyieldという文字列を使うと、そこでPromiseの実行を待機してくれます。
上の例で言うと、sleepという関数がsetTimeoutで何ms後かに解決するPromiseを返します。
for文の中のyieldでPromiseが解決されるまで処理が待機され、Promiseが解決されると次の処理へ進みます。

上の例ではPromiseを扱いやすくするためにcoというモジュールを利用しています。
Promiseを扱うモジュールはbluebirdとcoが有名ですので、今はどちらかを利用するのがいいと思います。

PromiseとGeneratorへ移行してみよう

ファイル読み込み、書き込みを例にとってPromiseとGeneratorへの移行方法を見てみましょう。

ファイルを読み込み、内容を加えて別ファイルに書き出し、書き出したファイルを再び読み込むという処理です。

すべてのcallbackでエラー処理をしなければならない典型的なcallback地獄です。

var fs = require('fs');

fs.readFile('input.txt', 'utf8', function(err, text) {
  if (err) {
    console.log(err);
    return;
  }
  var write = text + '\nwrite';
  fs.writeFile('output.txt', write, function(err) {
    if (err) {
      console.log(err);
      return;
    }
    fs.readFile('output.txt', 'utf8', function(err, text) {
      if (err) {
        console.log(err);
        return;
      }
      console.log(text);
    })
  })
});

Promise化

まずはPromise化します。
ついでにconst, let, arrow function化もしてしまいます。

自前でそれぞれの関数をPromiseラップしてもいいのですが、bluebirdなどがpromisifyというPromise化する関数を提供しているので利用すると楽です。
promisifyAllを利用するとモジュールに実装されている関数のPromise版を「関数名Async」という名前で追加してくれます。
あまり外部モジュールに頼りたくないという気持ちはあるのですが、今後はいろいろなモジュールがPromise対応をするはずです。
promisifyを利用してもPromise対応されたら、2行ほど直すだけなので移行コストはかからないと予想して、今の所promisifyを利用する方針です。

'use strict';

const Promise = require('bluebird');
// ここで関数が追加されます
// fs.readFile -> fs.readFileAsync
// fs.writeFileFile -> fs.writeFileAsync
const fs = Promise.promisifyAll(require('fs'));

// 実行
fs.readFileAsync('input.txt', 'utf-8')
.then((text) => {
  const output = text + '\nwrite';
  return fs.writeFileAsync('output.txt', output);
})
.then(() => {
  return fs.readFileAsync('output.txt', 'utf-8');
})
.then((text) => {
  console.log(text);
})
.catch((err) => {
  console.log(err);
});

Promiseを使うとthenメソッドで呼び出す事ができるようになるので、フローがわかりやすくなります。
また、catchを使って1カ所でエラーを受け取る事ができます。
しかし、まだ実行部分のコードが長いですし、同期的とは言いづらいです。

ちなみにpromisifyを使わないで自前でPromise化をすると以下のようになります。

const readFile = (fileName) => {
  return new Promise((resolve, reject) => {
    fs.readFile(fileName, 'utf8', (err, text) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(text);
    });
  });
};

Generator化

実行部分をGenerator化してみましょう。

'use strict';

const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));

const exec = function*() {
  let input = yield fs.readFileAsync('input.txt', 'utf-8');
  const output = input + '\nwrite';
  yield fs.writeFileAsync('output.txt', output);
  input = yield fs.readFileAsync('output.txt', 'utf-8');
  console.log(input);
};

// 実行
Promise.coroutine(exec)().catch((err) => {
  console.log(err);
});

Generatorを利用する事で同期的に返り値を受け取れたり、ネストが一段に統一できました。
callbackを使う時に比べて、かなり見通しがよくなったのではないかと思います。

もちろん採用するデメリットもあり、Generatorはcallbackに比べて遅いと言われています。
読みやすさと処理速度のトレードオフになると思いますが、今の所自分の利用範囲では処理速度がボトルネックになっている事はありません。
また、そのレベルでチューニングが必要になると他に解消するべき部分があったりします。
個人的に読みやすく書けることが一番重要と考えているので、「同期的に書ける」「ネストが深くなりにくい」というメリットをとり、Generatorを採用しています。

おまけ

Generatorで並列処理を書く

Generatorを使うと同期処理が簡単にかけるといいましたが、並列処理もとてもシンプルにかけます。

bluebird

'use strict';

const Promise = require('bluebird');

const timer = (str, ms) => {
  return new Promise((resolve) => {
    console.log(str);
    setTimeout(() => {
      resolve();
    }, ms);
  });
};

Promise.coroutine(function*() {
  yield timer('1', 1000);
  yield timer('2', 1000);
  // 並列処理
  yield Promise.all([timer('3', 1000), timer('4', 1000), timer('5', 1000)]);
  yield timer('6', 1000);
})();

co

'use strict';

const co = require('co');

const timer = (str, ms) => {
  return new Promise((resolve) => {
    console.log(str);
    setTimeout(() => {
      resolve();
    }, ms);
  });
};

co(function*() {
  yield timer('1', 1000);
  yield timer('2', 1000);
  // coはPromiseの配列を渡すだけで並列に回せます
  // https://github.com/tj/co#yieldables
  yield [timer('3', 1000), timer('4', 1000), timer('5', 1000)];
  yield timer('6', 1000);
});

おわりに

ES6対応で行った3事例を紹介しました。

  1. varを撲滅しよう
    メリット: コードの品質向上

  2. arrow functionを使おう
    メリット: thisの扱いを均一化。記述量の削減

  3. callbackを撲滅しよう
    メリット: 可読性の向上。

他にもいろいろと移行できる点はありますが、まずはこの3点を中心に書き換えをしていくとES6のメリットを受けやすいのではないかと思います。

快適なJavaScriptライフを!

こちらの記事のご感想を聞かせください。

  • 学びがある
  • わかりやすい
  • 新しい視点

ご感想ありがとうございました

このページの先頭へ