Node.jsの基本
2019/03/08更新
目次
概要
Node.jsを学び始めた初期の頃、調べたり人に聞いたりしてもなかなかはっきりと理解できなかった根本的なことや概念的なことを中心にまとめておく。
インストール
Node.jsをインストールすると、nodeコマンドが使えるようになり、nodeコマンドに*.jsファイルを渡して実行すると、Node.jsプログラムが実行できる。
ここまでは、perlやrubyのプログラムをコマンドラインで実行するのとほぼ同じイメージである。Node.jsは「サーバーサイドJavaScript」などと言われることもあるが、それ自体はサーバーではなく、Apacheなどをすぐに置き換えるものではない。むしろ、サーバー用途よりもタスクランナーなどのローカル環境での用途の方が多いように思われる。
Cygwinには対応していないが、Windowsにインストールしたnode.exeはCygwin上から問題なく叩ける。ただ、まれにパス区切り文字の違いなどで、Cygwinからは正常に動作しないパッケージなどもある。
npmとpackage.json
npmは、Node.jsのパッケージマネージャで、Node.jsインストール時にnodeコマンドと一緒についてくる。package.jsonは、アプリの情報を記述するJSONファイルである。
npm initを実行すると、対話形式で簡単なpackage.jsonを生成できる。package.jsonの内容は、アプリの規模に応じて数行だったり数百行だったり様々である。
アプリでモジュールを使うときは、npmコマンドによってインストールする。
npm install --save ~とすることで、package.jsonに依存情報として自動的に追記してくれる(最近のバージョンでは--saveは不要)。アプリが依存するモジュールは、node_modulesというディレクトリ以下に自動的にインストールされる。
このディレクトリは、package.jsonさえあれば、npmによって再現することができるので、Gitなどに保存する場合は、依存情報を書いたpackage.jsonだけを保存し、デプロイ時にその場で依存モジュールをインストールすることになる。また、自動的に生成されるpackage-lock.jsonは、さらに詳細な依存情報を記述したファイルで、こちらも一緒に保存しておくとよい。
Node.jsでは、コードの再利用が徹底される傾向にあり、非常に小さな関数でもモジュール化されていることが多く、ほんのわずかな依存関係を持つだけで、node_modulesディレクトリの中身は膨大なファイル数となる。
npm-scripts
package.json内のscriptsというキーに、シェルコマンドのように命令を書いておくと、npm run ~という簡潔なコマンドでそれを実行できるようになる。テスト、ビルド、デプロイ、実行、停止、などに相当するコマンドを事前に登録しておくと便利に使うことができる。
キー名が「start」「stop」「test」などいくつかの予約語の場合は、
npm runのrunを省略できる。サブタスクとして他のnpm-scriptを起動することもできる。サブタスクは「タスク名:サブタスク名」のようなキー名にすることが多い。
"scripts": {
"start": "NODE_ENV=production node app.js",
"test": "npm run test:eslint && npm run test:mocha",
"test:eslint": "eslint app.js",
"test:mocha": "mocha test.js"
},
上記例では、npm startでapp.jsの起動、npm testで2つのテストサブタスクの実行ができる。
非同期コード
Node.jsは、シングルスレッドで動いているため、同期的な処理(他の処理をブロッキングする処理)を徹底的に嫌う。そのため、ほぼ全ての関数がAjaxのような非同期関数となっており、従来はcallback関数を引数に渡して、継続する処理を記述していた。
しかし、callback方式では、ネストが深くなるいわゆる“callback地獄”に陥りやすく、コードの可読性低下が問題となっていた。そこで、現在ではcallback方式に代わり、Promiseオブジェクトが標準化され、さらに、Promiseの簡潔な記述法として、async/awaitが採用されている(ECMAScript 2015の新機能(その1)を参照)。
Promiseを返す非同期関数はawaitをつけて実行すると、コード上は通常の同期的関数のように記述することができる。Promiseが成功したときその値が返り、失敗したときは例外が投げられる。await実行を含む関数は、必ずasyncをつけて宣言しなければならない。これにより、戻り値が自動的にPromiseでラップされ、その関数自体も非同期関数となる。
// Promiseを返す非同期関数
const myPromiseFunc = () => { … };
// 従来の書き方
const myFunc1 = () => {
myPromiseFunc().then((res) => {
console.log(res); // resolve時の処理
}, (err) => {
console.log(err); // reject時の処理
});
};
// async/awaitによる書き方
const myFunc2 = async () => {
try {
const res = await myPromiseFunc();
console.log(res); // resolve時の処理
} catch (err) {
console.log(err); // reject時の処理
}
};
モジュール
Node.jsでは、require()を使って他の*.jsファイルをモジュールとして実行して読み込むことができる。これにより、コードを簡単に分割することができる。
require()の引数には、そのファイルから対象のファイルへの相対パスを指定する。戻り値には対象のファイルがmodule.exportsに代入したオブジェクトが返る。
// my-module.js
const myModule = {};
myModule.myFuncA = () => { … };
:
module.exports = myModule;
// index.js
const myModule = require('./my-module'); // .jsは省略可能。./は省略不可。
myModule.myFuncA();
require時に引数を渡したり、初期化処理を行ないたい場合は、module.exports自体を関数にすればよい。
// my-module.js
module.exports = (arg1, arg2) => {
:
// 初期化処理等
:
const myModule = {};
myModule.myFuncA = () => { … };
:
return myModule;
};
// index.js
const myModule = require('./my-module')(x, y);
myModule.myFuncA();
モジュールは、require時に関数でラップされるので、最上位の関数外の場所にもreturn文を書くことができる。
環境変数
Node.jsでは、プログラム実行時の環境変数keyの値をprocess.env.keyで参照できる。Bash等のシェル上であれば、nodeコマンドの前にkey=valueを付けて実行することで、環境変数keyにvalueを割り当ててプログラムに渡すことができる。
特に、NODE_ENVという環境変数は、プログラムの実行環境を表すものとして「production」「staging」「development」「test」「local」などの値がよく指定される。
// 「NODE_ENV=production PORT=8080 node app.js」を実行した場合: console.log(process.env.NODE_ENV); // "production" console.log(process.env.PORT); // "8080" ※全てStringとして渡されるので注意。
相対パスの注意
相対的な位置にある他のファイル等を参照する場合は注意が必要。例えば、以下のようなディレクトリ構造となっていた場合を考える。
/app.js /dir/hoge.js /dir/fuga.js /dir/data/data001
require()に指定する相対パスは、必ずそのファイルからの相対パスとなる。
// app.js内
const fuga = require('./dir/fuga');
// hoge.js内
const fuga = require('./fuga');
それ以外の相対パスは、nodeコマンドを実行したディレクトリからの相対パスとなる。
// hoge.js内
fs.readFile('data/data001', (err, data) => { ... });
// 「/dir/data/data001」を指定したつもりでも、
// 「/」にて「node app.js」と実行すると、「/data/data001」を探しにいってしまう。
__dirnameはそのファイルが置かれたディレクトリを表す。path.join()でつなげることで、正しい相対パスが得られる。
// hoge.js内
const path = require('path');
fs.readFile(path.join(__dirname, 'data/data001'), (err, data) => { … });
// 「/dir/data/data001」が正しく読み込まれる。
Node.jsによるウェブサーバー
以下のようなHTTPサーバーを作ってリクエスト待ちを開始するコードを書いて実行すると、ウェブサーバーとなる。ただし、このようなHelloWorldコードでは、どんなリクエストに対しても同じ応答を返すだけで、Apacheサーバーのようにパスに応じてファイルを返したりCGIを実行したりするような複雑なことは全くできない。
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('Hello, world\n');
}).listen(8080);
リクエスト待ちをしている間は、nodeコマンドを実行したコンソールは返ってこない。本番環境で運用する際は、基本的にデプロイ時にnodeコマンドを実行し、あとは運用中はずっと実行したままとなる。
基本的に一つのアプリでnodeコマンド1プロセスを使う。同じNode.jsサーバーに複数のアプリを乗せたい場合は、アプリごとにnodeコマンドを実行し、それぞれ待ち受けるポート番号を変えることで実現できる。ただ、URLにポート番号が含まれるのは不格好なので、あくまでNode.jsはアプリケーションサーバーとし、通常のApacheなどのウェブサーバーからリバースプロキシする形にするとよい。
このように、Node.js自体はサーバーではないため、ウェブアプリを作る場合はサーバー機能も含めて実装する必要がある。Express.jsは、Node.jsの代表的なウェブアプリ構築用フレームワークの一つ(デファクトスタンダード)で、パスによるルーティングなどのサーバー機能を含めたウェブアプリを簡単に実現することができる。
ウェブアプリにおける排他制御
Node.jsでは同期的な処理を徹底的に嫌うと書いたが、同期的な処理が必要となる場面もある。
例えば、古典的なアクセスカウンタのように、ファイルから数字を読み取り、1を加算して再び同じファイルに書き込むという処理を行なうサーバープログラムを作るとする。この処理はデータベースで言うところのトランザクションであり、原子性(Atomicity)が確保されなければならない。
このようなケースにおけるファイル入出力を非同期処理で書いた場合、アクセスが集中すると、複数のリクエストが同じ数字を読み込んでしまったり、読み込みや書き込みに失敗してしまう事態が簡単に発生する。ファイル入出力の非同期処理中に、他のリクエストを次々と受け取ってしまうためである。
このような場合は、非同期ではなく同期的に処理を行なわなければならない。例えば、ファイル読み込みであれば、通常はfsモジュールのreadFile()を使うが、同期的にファイルを読み込めるreadFileSync()という関数も存在するので、これを使う。これで、上記のような問題は回避できる。ただし、Node.jsの特長を十分生かせず、パフォーマンスが犠牲になってしまう可能性はある。
const app = require('express')();
// 非同期的(非排他的)なファイル読み書き
app.get('/', (req, res) => {
// 非同期処理を待っている間に、他のGETリクエストの処理が始まってしまうため、複数のリクエストが同じ値を読み込んでしまう。
fs.readFile('data.txt', { encoding: 'utf-8' }, (err, data) => {
data = String(parseInt(data, 10) + 1);
fs.writeFile('data.txt', data, { encoding: 'utf-8' }, (err) => {
res.json({ success: true });
});
});
});
// 例えば、100のリクエストが同時に集中した場合、data.txtの数値は10~20くらいしか増えていない。
// もしくは読み込み失敗が発生してNaNになってしまう。
// 同期的(排他的)なファイル読み書き
app.get('/', (req, res) => {
// ファイルI/Oの最中もブロックされるため、他のリクエストの処理が始まることなく、処理の順序が保証される。
let data = fs.readFileSync('data.txt', { encoding: 'utf-8' });
data = String(parseInt(data, 10) + 1);
fs.writeFileSync('data.txt', data, { encoding: 'utf-8' });
res.json({ success: true });
});
// 100のリクエストが同時に集中した場合でも、data.txtの数値は正確に100増えている。