この記事は?
@cosmeを運用するistyleでは、業務でNode.jsを積極的に活用しています。私、村田がいるメディア開発グループにおいても積極的に使っており、BFFのような中間層の実現、バッチの処理、API実装、フロントエンドの実行環境などなど使われ方は多岐に渡ります。この記事では、Node.jsの導入を考えている組織や、Node.jsの基本を振り返りたいエンジニアに向け言語環境の特性と活用を考えてみます。
Node.jsの特性
Node.jsはサーバー上で動き、ブラウザでJavaScriptが動く機構とは異なります。近年、サーバー開発はもちろん、Next.js, RemixなどのNode.jsを利用する各種フロントエンドフレームワークが、サーバーに処理を寄せる流れになってきていることから、フロント開発者にとってもNode.jsの理解は重要でしょう。高性能なv8エンジンを搭載していること、そして何より、Node.jsの最も大きな特徴として、シングルスレッドおよびノンブロッキングI/Oであること、イベント駆動型アーキテクチャを採用していることはNode.jsをNode.jsたらしめる特徴であると言えます。
1. v8エンジンの使用
v8はまずJavaScriptのコードを、AST(抽象構文木)に変換します。ASTは、コードの構造と構文的関係を表すデータ構造です。次に、lgnitionと呼ばれるv8のインタープリタがより低レベルなByte Codeに変換を行います。実行に際してはByteコードは、JITコンパイラによって機械が理解できるマシンコードに変換され、さらに、Turbofanがより高度な変換の最適化を行うことによってJavaScriptの高速な実行を実現することができるのです。
一方、プログラマーとして関心が強いのは変数のメモリーライフサイクルの方ではないでしょうか?v8エンジンは、世代別ガーベッジコレクションという機構を採用し、これをライフサイクルを実現しています。すなわちガーベッジコレクション(GC)を行う中で、長く残っている変数群を老年代に、すぐなくなるような変数群は新生代に分類することで、それぞれ違う方法でGCを行います。すなわち、新生代のGCはスキャビンジングという単純で高速なGCを高頻度で繰り返し、老年代のGCでは低頻度にマーク・アンド・スイープを使用することで、より時間はかかるが、効率の高いメモリの再利用を可能にする方法をとります。実装に際しては、以下のサンプルに示すようなメモリリークに気をつけつつ実装する必要があります。
・メモリリークを起こすコード(NG)
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(c => c + 1);
}, 1000);
}, []);
・メモリリークを起こさないコード(OK)
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
2. シングルスレッド×ノンブロッキングI/O
Node.jsはシングルスレッドで動き、ノンブロッキングI/Oで処理を行います。つまり、シングルスレッドであることでマルチスレッドを採用するJavaなどと比較して、スレッド切り替えのオーバーヘッドを最大限抑えつつ、Non-blocking I/Oであるため、一つのプロセスがI/O待ちの時でも複数プロセスを起動せず別リクエストを処理することが可能です。このI/Oの処理はDBやAPI通信など、一般的なアプリケーションでパフォーマンスのボトルネックになりやすい部分を含みますが、そういった処理をメインスレッドでなく、バックグラウンドスレッドと呼ばれる別領域で行うため、どれだけI/Oが発生してもメインスレッドはブロックされないこともNode.jsの強みです。よって、I/Oバウンドであるアプリケーションなどに対しては、特にNode.jsの使用は有力な選択肢であると言えるでしょう。
(参考: 他言語との比較
・シングルスレッド × ブロッキングI/O: PHP etc.
・マルチスレッド × ブロッキングI/O: Java etc.
・マルチスレッド × ノンブロッキングI/O: Go etc.
3. イベント駆動型アーキテクチャの採用
Node.jsはイベント駆動型アーキテクチャを採用しており、非同期の処理を得意とする言語環境です。非同期処理によって同時に複数のリクエストを捌ける機構は、イベントループという仕組みによって実現されています。
イベントループでは、まず同期、非同期のタスクはコールスタックと呼ばれるスタックに格納されますが、とりわけ非同期のI/O処理は入れられた瞬間に、前述の通り、メインスレッドとは異なるバックグラウンド領域に移されて、通信を開始し、結果が返ってくると、次はタスクキューと呼ばれる別領域に格納され、次々と結果が返ってきたI/O処理を実行していきます。
こうしたイベント駆動型アーキテクチャで設計されたNode.jsは高いI/Oの処理性能を保つため、とりわけI/Oバウンドな処理に高いパフォーマンスを発揮するとともに、シングルスレッドを駆使して効率的な非同期処理を行なっているため、負荷が増えても比較的容易にスケールアウトを行なってインフラリソースの利用効率としても良く、大負荷に対しても備えることができるため、全体としてスケーラビリティが高めなアーキテクチャであると言えます。
TypeScript/JavaScirptの特性
TypeScriptはJavaScriptにトランスパイルされ、Node.jsで処理が実行されます。Node.jsを採用する場合、TypeScript/JavaScriptを必然的に書いていくことになるため、続いてそれらの言語の特徴を見てみましょう。
1. プロトタイプベース
JavaScriptはプロトタイプベースのオブジェクト指向言語であり、クラスベースのオブジェクト指向言語(Rubyなど)とは異なるアプローチを取ります。 つまり、JavaScriptを書く上では、全てのオブジェクトがプロトタイプという隠されたプロパティを持っており、ほかのオブジェクトへの参照を持つため、それらのメソッドやプロパティを参照することができる、ということを理解しつつ、実装を進めていく必要性があります。
(※ 以下、prototypeを意識するためのコードサンプル。)
function Person(name) {
this.name = name;
}
// Personはprototypeという隠しプロパティを持つ
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
// Personインスタンスから生成される
// personオブジェクトは、prototype経由でsayhelloにアクセスできる。
const person = new Person("person");
person.sayHello(); // "Hello, my name is person"
2. 関数型/ Class構文
JavaScriptでは関数型プログラミングをサポートしており、かつ、Class構文があるため、関数型、Class型どちらの記法であっても実装を行うことが可能です。どちらを採用するかはケースバイケースですが、私が現在開発を担当している、@cosmeメディアにおけるバッチ処理の実装では、関数型ではなくClassの記法を採用して開発することにしました。
3. TS-JSトランスパイル
TypeScriptが独特なのは、バイトコードへと直接コンパイルされる代わりに、JavaScriptにコンパイルされる点です。この動きは、TypeScriptコンパイラー(TSC)が担っており、TypeScriptはTypeScript AST(抽象構文木)へ変換された後、ASTは型チェッカーによってチェックされ、問題なければJavaScriptに変換されるとともに、最終的にはv8エンジンによってコードが実行されます。TSCによる働きは、型安全性を担保するものです。
Node.jsの現場での活用と注意点
Node.jsはサーバーで動作するランタイム環境であり、ブラウザで動くJavaScriptとは処理のされ方が異なります。実装に際しては、それぞれの環境の特性を理解した上で、実装を行なっていくことが重要です。とりわけサーバー側のNode.jsの実装において注意しないといけないことの一つは、よく理解せず実装を行うと、Node.jsがシングルスレッドで動作しメモリ内のオブジェクトをリクエストで共有することがあるという特性です。Node.jsだけでなく、現代のWebアプリケーション開発におけるAPI設計においては当たり前のことではありますが、Node.jsでのAPIの設計においてもRESTfulな設計を行うように心がけましょう。
・NG: 各リクエストに共有された時刻を返すステートフルな処理。
const currentTime = new Date().toISOString();
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/time') {
// currentTimeは各リクエストで共有されたインスタンスである
res.end(`Current time: ${currentTime}`);
} else {
res.end('Invalid endpoint');
}
});
・OK: 各リクエストにそれぞれの時刻を返すステートレスな処理。
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/time') {
// currentTimeはリクエストごとに異なるインスタンスを生成して返す
const currentTime = new Date().toISOString();
res.end(`Current time: ${currentTime}`);
} else {
res.end('Invalid endpoint');
}
});
終わりに
今回も記事を読んでいただきありがとうございます。アイスタイルでは、Rebornという大規模なリプレースを全社で行なっています。個人的に、その手段としてNode.jsは一つの強力なツールであり、@cosmeを次の時代へと前進させてくれるような存在だと感じています。読者の皆様にはNode.jsを使った素敵な快適な開発ライフと、素敵な年の瀬と新年を祈っております。
カジュアル面談
アイスタイルでは@cosmeを始めとした各種サービスを展開しています。Node.jsを使った開発、アイスタイルでの開発に興味を持たれた方は、ご気軽にカジュアル面談をお申し込みください。