私は2012年にStorifyの開発にフルタイムで加わったときからNodeを使っています。それからというもの、それまでの10年間にWeb開発に使っていた言語、Python、Ruby、Java、PHPが恋しくなることは一度もありませんでした。
Storifyの開発はやりがいのある仕事でした。Storifyはほかの会社のプロジェクトとは違って、すべてがJavaScriptで動いていたからです(おそらく現在もそうだと思います)。だってそうでしょう、ほとんどの会社、特に大企業であるPayPal、Walmart、Capital OneなどではNodeは特定のレイヤーにしか使われていません。よくあるのは、APIのゲートウェイやオーケストレーション(複雑なシステムの統合管理)のレイヤーとしてNodeが使用されています。すばらしいことですが、ソフトウェアエンジニアから見れば、完全なNode漬けの環境にはかないません。
本記事では、2017年にNodeの開発者として上のレベルを目指すための10個のヒントを記載します。ヒントには、私が現場の最前線で学んだことに加え、Nodeとnpmの著名なモジュール開発者からの助言も含まれています。
これから解説するヒントはこちらです。
- 複雑にしない:コードを整理して、これ以上小さくならないところまで分割し、そこからさらに小さく分割する
- 非同期通信のコードを使う:同期通信のコードは、伝染病のように避ける
- require文による邪魔を回避:同期通信であるため実行の妨げになるrequire文は、すべてファイルの先頭に書く
- requireはキャッシュされることを知っておく:コードの助けにもなるしバグにもなる
- 常にエラーチェックする:フットボールではないのだから、投げっぱなしにせず、チェックを怠らない
- try...catch文は同期通信のコードにだけ使う:非同期通信のコードではtry...catchは役に立たず、さらにV8ではプレーンなコードと同様try...catchのコードを最適化できない
- コールバックを返すか、if...else文を使う:念のため、処理が実行され続けないようにコールバックを利用する
- errorイベントのリスナーを設ける:ほぼすべてのNodeのクラスとオブジェクトは、EventEmitter(オブザーバーパターン)クラスを継承しており、errorイベントを返す。リスナーを使ってキャッチする
- npmを深く知る:--saveや--save-devよりも、-Sまたは-Dでモジュールをインストールする
- package.jsonで使うバージョンを固定:-Sを使うと、npmはデフォルトで頑なにキャレット(^)を付けるが、手動で消去してバージョンを固定する。アプリのsemverを信用せずに、オープンソースのモジュールではバージョンを固定する
- おまけ:異なる依存オブジェクト(dependencies)を使う。開発段階でのみ必要なものはdevDependenciesに入れ、npm i --productionを使う。不要な依存オブジェクトが多いとそれだけ脆弱になるおそれがある
それでは、それぞれを分けて個別に説明してきましょう。準備はいいですか?
この記事はパート1です。近々公開する日本語訳版パート2ではさらなるヒントを解説します。
複雑にしない
npmの作者、Isaac Z. Schlueterが作ったモジュールを見てください。たとえば、モジュールに使うJavaScriptをStrictモードにするuse-strictは、わずか3行のコードです:
var module = require('module')
module.wrapper[0] += '"use strict";'
Object.freeze(module.wrap)
なぜ複雑にしないのでしょう? 米国海軍のあるレジェンドは「KEEP IT SIMPLE STUPID(シンプルにしろ、馬鹿野郎)」と言っています。これにはもっともな理由があります。人間の脳は一度に5つから7つのことまでしか作業領域にとどめておけないのです。これが事実です。
コードを小さくパートごとに部品化しておけば、自分自身もほかの開発者も内容をより深く理解して納得できます。テストをするにも好都合です。次の例を見てください。
app.use(function(req, res, next) {
if (req.session.admin === true) return next()
else return next(new Error('Not authorized'))
}, function(req, res, next) {
req.db = db
next()
})
これが次のようならどうでしょう。
const auth = require('./middleware/auth.js')
const db = require('./middleware/db.js')(db)
app.use(auth, db)
大半の人は2番目の例のほうが良いと感じるはずです。名称から内容が分かればなおさらそうです。もちろん自分がコードを書くときには、なにがどのように動くか分かっているつもりでしょう。もしかしたら、1行にたくさんのメソッドを繋げて書くことで、自分の頭の良さを見せつけたいのかもしれません。しかし、頭が冴えないときの自分のためにコードを書いてください。6カ月間このコードを見なかったあとの自分、疲れ果てたとき、あるいは酔っぱらったときの自分のために書くのです。もし絶好調の頭に合わせて書いたら、コードはあとで理解しづらいものになるでしょう。複雑なアルゴリズムに慣れていない同僚から見ればなおさらです。シンプルにせよ、という格言は、非同期通信を使うNodeにはとりわけ重要なことです。
たしかに、以前にleft-pad事件というのがありましたが、公開レジストリに依存しているプロジェクトにしか影響せず11分後には修復されました。細かくモジュール化することの恩恵は、その欠点をはるかに上回ります。またnpmは非公開のポリシーを変更したので、どの本格プロジェクトもキャッシュを活用するべきですし、一時しのぎならプライベートレジストリも使えます。
非同期通信のコードを使う
Nodeには同期通信のコードにも(少しだけ)居場所があります。たいていはCLIコマンドや、そのほかのWebアプリに関係しないスクリプトです。Nodeの開発者は主にWebアプリを開発しているので、スレッドがブロックされないように非同期通信のコードを使います。
たとえば、もし同時並行するタスクを扱うシステムではなく、ただのデータベーススクリプトを書くだけなら、これでもかまわないでしょう。
let data = fs.readFileSync('./acconts.json')
db.collection('accounts').insert(data, (results))=>{
fs.writeFileSync('./accountIDs.json', results, ()=>{process.exit(1)})
})
しかし、Webアプリを作るのなら、こちらのほうがもっと良いでしょう。
app.use('/seed/:name', (req, res) => {
let data = fs.readFile(`./${req.params.name}.json`, ()=>{
db.collection(req.params.name).insert(data, (results))=>{
fs.writeFile(`./${req.params.name}IDs.json`, results, ()={res.status(201).send()})
})
})
})
2つの違いは、同時並行のタスクがあるシステム(多くの場合は長期間稼働)か、あるいは同時並行しないシステム(短期間)かによります。ざっくり言ってしまうと、Nodeではいつも非同期のコードを使ってください。
require文による邪魔を回避
Nodeには、CommonJSモジュール形式による、シンプルなモジュールを読み込む仕組みがあります。この標準のrequire関数は、別々のファイルにあるモジュールを読み込むのに便利です。RequireJSのAMDとは違い、NodeのCommonJS方式のモジュール読み込みは同期通信です。require関数の動作は、モジュールやファイルでエクスポートされたものを読み込むことです。
const react = require('react')
大半の開発者が知らないのは、requireはキャッシュされるということです。つまり一度読み込んだモジュールやファイルが変更されない限り(npmの場合それはありません)、モジュールにあるコードは実行され、その過程で一度だけ変数に書き込まれます。これは優れた効率化です。しかし、キャッシュされるとしても、require文は先頭に置いたほうが賢明です。下の、実際使用するルートにaxiosモジュールの読み込みがあるだけのコードについて考えてください。この場合、リクエストされると毎回モジュールを読み込むため、/connectのルートは必要以上に遅くなります。
app.post('/connect', (req, res) => {
const axios = require('axios')
axios.post('/api/authorize', req.body.auth)
.then((response)=>res.send(response))
})
もっと効率のよい読み込み方法は、/connectのルートではなく、サーバーが特定されないうちにモジュールを先に読み込むことです。
const axios = require('axios')
const express = require('express')
app = express()
app.post('/connect', (req, res) => {
axios.post('/api/authorize', req.body.auth)
.then((response)=>res.send(response))
})
requireはキャッシュされることを知っておく
前項で、requireはキャッシュすると述べましたが、おもしろいのは次の例のようにmodule.exports文の「外側」にコードを書けることです。
console.log('I will not be cached and only run once, the first time')
module.exports = () => {
console.log('I will be cached and will run every time this module is invoked')
}
上のように一度しか走らせない部分があることを知っておけば、requireがキャッシュすることを上手に利用できます。
常にエラーチェックする
NodeはJavaとは違います。Javaではたいていの場合、エラーが発生したらアプリの処理を中断して、エラーを表示します。Javaでは、1つのtry...catch文で複数のエラーを、より高い水準で扱えます。
Nodeではそうはいきません。Nodeはイベントループを使用している非同期実行ですので、どのようなエラーでも発生時には、try...catchのようなエラー処理のコンテクストとは切り離されています。以下はNodeでは無意味です。
try {
request.get('/accounts', (error, response)=>{
data = JSON.parse(response)
})
} catch(error) {
// Will NOT be called
console.error(error)
}
もっともtry...catch文は、Nodeの同期通信のコードでは使えます。次のコードは上の例を改良したものです:
request.get('/accounts', (error, response)=>{
try {
data = JSON.parse(response)
} catch(error) {
// Will be called
console.error(error)
}
})
このようにrequestのコールをtry...catch文のブロック内に含めないなら、requestにかかわるエラーは扱えません。Node開発者は解決のために、コールバック引数としてerrorを用意しています。このように、毎回手動で、すべてのコールバックのerrorを処理する必要があります。すなわちエラーチェック(中身がnullでないかも確認してください)をして、ユーザーやクライアントにエラーメッセージを表示して記録したり、あるいはerrorでコールバック関数を呼び出し、コールスタックをたどるわけです(コールスタックにコールバック関数やほかの関数がある場合)。
request.get('/accounts', (error, response)=>{
if (error) return console.error(error)
try {
data = JSON.parse(response)
} catch(error) {
console.error(error)
}
})
ちょっとした使えるワザとしては、okayライブラリーです。以下のようにすると、何重にも入れ子になったコールバック(コールバック地獄へようこそ)による手動エラーチェックを回避できます。
var ok = require('okay')
request.get('/accounts', ok(console.error, (response)=>{
try {
data = JSON.parse(response)
} catch(error) {
console.error(error)
}
}))
コールバックを返すか、if...else文を使う
Nodeはコンカレント(同時並行)処理なので、気をつけないとバグの素になります。安全のために、return文で実行を止めてください。
let error = true
if (error) return callback(error)
console.log('I will never run - good.')
制御フローの誤りによる、意図しない同時処理(およびそのエラー)を避けられます。
let error = true
if (error) callback(error)
console.log('I will run. Not good!')
気をつけることは、実行を継続しないようにreturnでコールバックを返すようにすることです。
errorイベントのリスナーを設ける
ほぼすべてのNodeのクラスとオブジェクトはEventEmitter(オブザーバーパターン)クラスを継承しているため、errorイベントを持っています。大惨事を招く前に煩わしいエラーをキャッチして対処できる機会を開発者に与えくれます。
.on()を使い、常にerrorに対するイベントリスナーを設けるようにしてください。
var req = http.request(options, (res) => {
if (('' + res.statusCode).match(/^2\d\d$/)) {
// Success, process response
} else if (('' + res.statusCode).match(/^5\d\d$/))
// Server error, not the same as req error. Req was ok.
}
})
req.on('error', (error) => {
// Can't even make a request: general error, e.g. ECONNRESET, ECONNREFUSED, HPE_INVALID_VERSION
console.log(error)
})
npmを深く知ろう
多くのNodeやフロントエンド開発者が知っているとおり、--save(npm installコマンド使用時)ではモジュールをインストールするだけでなく、package.jsonの中にモジュールバージョンが書き込まれます。devDependencies(本番稼働では不要な依存オブジェクト)のインストール用に--save-devがあります。しかし、実は--saveや--save-devの代わりに、ただ-Sや-Dと書けば良いことを知っていますか。
またモジュールのインストール時に、-Sおよび-Dにより自動で付加されるキャレット(^)を削除してください。危険です。npm install (または短縮のnpm i)使用時に、npmから最新のマイナー(バージョン番号の2番目の数値)バージョンアップ版を落としてくるからです。たとえばv6.1.0からv6.2.0はマイナーリリースです。
npmチームはsemverを信じているようですが、私たちはやめておきましょう。というのも、彼らが自動でキャレット(^)を付けるのは、オープンソースの開発者がマイナーバージョンアップで互換性を壊すような真似はしないだろう、と考えているためです。まともな神経をしているのなら、これを信じてはいけません。自分が使うバージョンは固定しましょう。さらに良いのは、shrinkwrapを使うことです。npm shrinkwrapは、依存オブジェクトの依存先まで、ズバリそのバージョンで新しいファイルを生成します。
最後に
最初に書いたように本記事は、2つのパートのうち前編です。コールバックの使用、非同期コード、エラーチェック、依存オブジェクトの特定など、すでに広い範囲をカバーしました。なにか新情報や役立つものが見つかったなら幸いです。ぜひ近々公開する日本語訳版パート2もチェックしてください。
※本記事は、ゲストライターのAzat Mardanによるものです。SitePointのゲスト投稿では、Webコミュニティの著名な執筆者や講演者の魅力的なコンテンツの提供を目指しています。
※本記事のもともとのタイトルは『 The Best Node Practices from Gurus of The Platform(プラットホームの達人が教えるNodeのベストプラクティス)』です。この記事が扱うのは、2017年の最新手法ではなく、実践で何度もテストし実証された確実な手法です。Nodeの達人による定番の手法は、2017年でも2018年でも、いや2019年になっても通用するはずですが、記事ではasync、await、promisesといった最新機能には触れません。これらはNodeのコアやnpm、Expressといった人気プラットホームのコードには含まれないからです。近々公開する日本語訳版パート2では、コンテンツの性質も考慮するつもりです。
(原文:10 Tips to Become a Better Node Developer in 2017)
[翻訳:西尾健史/編集:Livit]