180
255

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[メモ] Node.jsで開発するにあたっての基礎知識まとめ

Last updated at Posted at 2021-10-17

はじめに

この記事は自分がNode.js未経験の状態からNode.jsのプロジェクトに携わって学んだことを忘れない様にメモしておくための記事です。
今はプロジェクトを離れてNode.jsに関わっていないため、思い出すためのメモとしてまとめました。
各ツールの役割を解説する事で、開発時に知識として必要なキーワードを効率よく知る事を目的としています。
私自身常に最新の情報を追えていた訳ではないので、情報としては古い可能性があるかもしれませんのでご注意を。

また、ツールの導入の仕方はバージョンアップによって変わる事があり、都度公式の最新の情報をチェックすべきであり、かつ今回の趣旨ではないため省略します。
実際こちらの記事を記載中にバージョンアップによって色々と変わったツールが散見されました。

より詳しい情報が欲しい方には

私はある程度自力で試行錯誤しながら学んだ後でこの本に出会ったので、本の内容の半分くらいは既に知っていましたが、知識を固めるのに非常に助かりました。
また、最初にこの本を読んでいたらもっと効率よく必要な知識を学べたと感じました。
ガチガチな技術書より実践的な内容を丁度よく学べるよい本だと思います。
なので、これからNode.jsを始める方が周辺知識を学ぶには最適かと思います。
以下はテクブのリンクとBoothのリンクです。

本書をベースに加筆を行なった、商業誌版もございます。

こちらは持っていませんが、上記の本がベースであれば内容は恐らく問題ないかとは思います。
2020/06に加筆されて出版されているため、より新しい情報が記載されていると思います。

目次

  • Node.jsの特徴
    • インストール
    • 開発サイクルとサポート期間
  • npmとyarnって何?
    • yarn 1とyarn 2について
    • npm 7
  • package.jsonとpackage-lock.json(yarn.lock)って何?
    • dependenciesやdevDependenciesの違い
    • バージョンの記法について
  • コードの品質を保つために
    • リンター(ESLint)を使う
    • フォーマッター(Prettier)を使う
    • husky + lint-stagedを使ってcommit時にチェックする
  • babelの役割って何?
    • 手軽にソースを実行したい時は babel-register を使う
  • なぜタスクランナーやバンドラーを使うのか
  • Gruntやgulpなどのタスクランナーの役割
  • webpackとは?
    • デバッグしたい時は source map を生成する
  • AltJS(TypeScriptなど)を使うには?
  • プロセスマネージャーとは?
  • Callback地獄と改善の歴史
    • Promiseとは
    • Async/Awaitとは
  • Streamって何?どういう時に使うの?
    • オリジナルのStream処理を実装してみる
  • 終わりに

Node.jsの特徴

Node.jsの特徴として以下が挙げられます。
参考: Node.js® とは

  • GoogleのV8エンジンで作られたJavaScriptの実行環境
  • シングルスレッド
  • 非同期のイベント駆動

Node.jsはシングルスレッドでありながらイベント駆動で動作するため、大量の接続が来ても接続毎にスレッドを生成して同時実行するモデルに比べて効率的に処理を捌くことができます。
Node.jsではイベントループを回して従来ブロッキングな処理(I/Oなど)を非ブロッキングな処理にする事で、I/Oを処理している間もJavaScriptの処理を止めずに実行することが可能になっています。
詳しくはこちら(ブロッキングとノンブロッキングの概要)を読んでください。
これらはlibuvによって実現されていますが、libuv自体は他の様々なプロジェクトでも使用されている様です。

インストール

HomebrewなどのパッケージマネージャーでNode.jsを直接インストールする事も可能ですが、複数のプロジェクトを対応する際にNode.jsのバージョンを切り替える可能性があるため、nvmnnodebrew などのNode.jsの複数のバージョンを管理してくれるツールを使用した方がよいでしょう。
残念ながらあまりこれらのツールの良し悪しについては詳しくないため、別の記事を参考にするなどしてください。
ちなみにGitHubのスターの数だけ見ると、 nvm > n > nodebrew でした。
余談ですが、 nodebrew は日本のエンジニアの方が作成していたんですね。知らなかったです。

開発サイクルとサポート期間

Node.jsのメジャーバージョンは6ヶ月間現行リリースの状態になり、その後奇数のバージョンはサポートされなくなり、偶数のバージョンはアクティブLTSになります。
LTSは長期サポートであり、重要なバグは30ヶ月の間修正されることが保証されています。
プロダクションアプリケーションでは、アクティブLTSかメンテナンスLTSのバージョンを使用してください。
詳しくはこちらのページを参照してください。

npmとyarnって何?

npmyarn もパッケージマネージャーです。
近年のJavaScriptの開発にはなくてはなりません。
パッケージマネージャーについて詳しくは説明しませんが、どちらもnpmレポジトリからパッケージをインストールしたり、アップデートしたり、削除したりといった管理を行ってくれます。
元々 npm が先に存在していましたが、高速で安定してしていて、かつ安全な依存性管理を目指して yarn が作られました。
なぜこの様になったかと言う背景を簡単に説明すると、 Node.js の哲学のThe Node Wayが多少関係しています。
The Node WayはUnixの重要な原則を取り入れています。

  • モジュールで構成されていて、シンプルで短く保たれており、きれいなインターフェースで繋がれている
  • 1つのプログラムは一つのことを素晴らしく処理すべき

これらの考え方はとても素晴らしいのですが、結果として1つの処理をする細かいパッケージが多く存在し、1つのライブラリがそれらの大量の細かいパッケージに依存している事が多いです。
そのため、 npm install コマンドで依存関係を解決する時に大量のパッケージをダウンロードするため、時間がかかります。
yarn はこの問題を高速化するためにキャッシュの仕組みを活用するなどして改善していきました。
現在は npm も改善しているためそこまで大きな差はない様です。
npmyarn の細かな差などは私も把握できていないため、プロジェクトでどちらを採用するかは別の記事などを調べてください。

なお、 npmyarn 1 は互換性があるため、どちらも package.json を解析して依存関係を解決します。
違いは依存関係を解決した後に作成するlockファイル名です。
npmpackage-lock.jsonyarnyarn.lock と言う別名のファイルを作成します。

yarn 1とyarn 2

先程書いた通り yarn 1npm と互換性がありますが、 yarn 2node_modules 配下に全ての依存関係のパッケージがダウンロードされるという根本的な問題を解決するために互換性を維持しない方針にしました。
ただし、動作はいくつかのモードがあり、node-modules を使用するモードであればかなり互換性は保たれる様です。
既に有名なライブラリなども採用している例があるそうですが、私は商用環境で使用した事がないので、色々調べたり検討した上で採用した方がよさそうです。

npm 7

npmもバージョンアップで様々な変更を行っています。
現時点で最新のnpm 7ではlockファイルのフォーマットのアップデートや yarn.lock ファイルへの対応などの機能が追加されています。
また、Breaking Changeなどもある様なので、詳しくは GitHubのBlog を読むとよいでしょう。
ここで注意したいのは、 チームメンバー間でツールのバージョンを揃える事 です。
せっかくlockファイルのバージョンを上げても、古いツールを使用している人が誤ってバージョンを下げてしまいます。
なので、ツールのバージョンアップは自分一人対応するだけでは不十分です。

package.jsonとpackage-lock.json(yarn.lock)って何?

既にこれらの用語は出てきましたが、どの様な役割のファイルなのか説明します。
package.json は様々な役割がありますが、その中の一つがライブラリの依存関係のバージョン管理です。
Node.jsやnpmのエコシステムを利用したフロントエンドの開発では、既に提供されているライブラリを利用して開発する事が一般的です。
ライブラリを使用する時にそのライブラリが依存しているライブラリなど、数珠繋ぎに依存しているライブラリも必要です。
また、ライブラリはバグフィックスなどでバージョンが上がる事もよくあります。
当然人力ではとても管理できないので、ツールで管理します。
ライブラリは npm で探してみてください。
また、GitHubのawesome-xxxを参考にすると、よく使用されているライブラリが見つかるかも知れません。
awesome-nodejsawesome-javascriptなど。
注意点として、npmにアップロードされているライブラリは安全なものばかりではありません。
有名なライブラリに似た名前や誤字を狙った悪意のあるライブラリや、有名なライブラリでもメンテナーが変わり、悪意のあるコードが埋め込まれるなどといった事が過去に起きています。
ですので、面倒くさがらずにnpmでパッケージのページを確認して、よく使われているツールなのか、今もメンテされているのかを確認しておくのが重要です。
ライブラリをインストールする場合、以下のコマンドでインストールできます。

npmの場合:

npm i [インストールしたいnpmのパッケージ名] # iはinstallのaliasです

yarnの場合:

yarn add [インストールしたいnpmのパッケージ名]

ここで覚えておきたい仕組みがあります。
dependenciesdevDependencies です。
他にも peerDependenciesbundledDependenciesoptionalDependencies もありますが、これらは主にライブラリを作成して公開する時に必要なものなので、今回は説明しません。
実際 devDependencies も公開する予定がなければ極論意識しなくてもよいのかも知れませんが、分けておいた方がプロダクトに影響があるものなのかどうか判断しやすいと思います。

dependencies はプロダクトが動作するために必要なライブラリです。
実際にコードの中で import または require し、実行されるライブラリはこちらに設定します。

devDependencies はプロダクトの動作と直接関係ないツール類を指定します。
例えば、 ESLint などのリンター、 Prettier などのフォーマッター、 MochaJest などのテストツールなどがこちらに当たります。
npmではこういった開発ツールも管理されており、これらのツールも package.json でバージョン管理できます。

ですので、ライブラリをインストールする時は、 dependencies なのか devDependencies なのかは意識してインストールしましょう。

パッケージをそれぞれの依存関係にインストールする時は以下のコマンドでインストール可能です。
npmの場合:

# dependenciesにインストールしたい時
npm i -S または -P [パッケージ名] # 何も指定がない時(-Sや-Pが付いていない時)はdependenciesにインストールされます
# devDependenciesにインストールしたい時
npm i -D [パッケージ名] # devDependencies
# npm installの詳しいヘルプはこちら
npm help install

yarnの場合:

# dependenciesにインストールしたい時
yarn add [パッケージ名] # 何も指定がない時はdependenciesにインストールされます
# devDependenciesにインストールしたい時
yarn add [パッケージ名] -D # yarn add -D [パッケージ名] でも動作するがマニュアルを見る限り -D は後ろが正しい
# yarn addの詳しいヘルプはこちら
yarn help add

バージョンの記法について

npmのモジュールはセマンティックバージョニングを推奨しています。
セマンティックバージョニングは簡単に言うとバージョン番号を major.minor.patch で表しています。
それぞれの番号は以下の様なケースの時に更新します。

  • majorは後方互換性を壊す様な変更をリリースする時に更新
  • minorは後方互換性を保った新機能をリリースする時に更新
  • patchは後方互換性を保ったバグ修正をリリースする時に更新

そして package.json には以下の様なバージョンの記法があります。
npmのセマンティックバージョニングで紹介されている例を参考にしています。

// 1. Patch releasesの更新を受け入れる場合
1.0 or 1.0.x or ~1.0.4
// 2. Minor releasesの更新を受け入れる場合
1 or 1.x or ^1.0.4
// 3. Major releasesの更新を受け入れる場合
* or x
// 4. バージョンを固定する場合
1.0.4

上記の記載をしている場合、npm installnpm update のコマンドを実行した時点で記法に従った最新のバージョンをインストールします。
例えば 1.0.51.1.22.0.0 がリリースされている場合、以下のバージョンがインストールされます。

1. 1.0.5
2. 1.1.2
3. 2.0.0
4. 1.0.4

また、既存のバージョンが 0.2.0 の場合はまた違った法則になるので注意してください。
例えば、 ^0.4.0 の場合は >= 0.4.0 < 0.5.0、つまりpatchバージョンのみの更新となります。

コードの品質を保つために

コードの品質を保つためにコーディングルールなどを決めてチームで取り組むと思いますが、中々全ては覚えられないですし、レビューで指摘し合うのも消耗します。
そこで静的解析ツール(リンター)を使ってルールに従っていないコードをツールで指摘するのが効率的です。
JavaScriptは後方互換性を重視しており、既存の仕様を壊さない様に機能が追加されています。
なので静的解析を使って無駄な記述やエラーになりそうな記述をツールで指摘して、よりコードの品質を保てます。

また、フォーマッターを使うことで一定のルールでコードをフォーマット可能です。
インデントにスペースを使うかタブを使うか、スペースなら4つか2つか、一行に文字数が何文字を超えたら改行するかなどを自動で整形してくれるため、コードの見やすさを保つのに便利です。

リンター(ESLint)を使う

JavaScriptには様々なリンターが存在しますが、その中で人気のあるESLintを紹介します。
ESLintは様々なルールやプラグインがあり、その中から自分で好きなルールを設定可能な自由度の高いリンターです。
また、推奨のルールセット(Google、Airbnb、Standard)が用意されており、1から設定しなくてもルールセットを導入して、そこにプラグインなどを追加する事が可能です。
また、React、Vue、TypeScriptなどを使用する場合はそれらのプラグインも用意されているので、それらのルールもきちんと整備できると統一感があり、バグを生みやすい記載が少ないコードとなります。
私もどの様なルールセットがよいのかはあまり知見がないので、有名なプロジェクトを参考にしながらルールセットを更新していくのがよいと思います。

フォーマッター(Prettier)を使う

ESLintにもフォーマット関連のルールがありますが、より強力にフォーマットを強制したい場合はフォーマッターを導入するとよいです。
ここで紹介するPrettierはESLintと違ってOpinionated(独善的)なフォーマッターと公式で言っており、設定可能な項目が少ないです。
その分あれこれ悩む事がなく、フォーマットはPrettier任せにできます。

ただしそのままではESLintのルールと競合し、ESLintが修正した箇所をPrettierが戻してしまう事があります。
eslint-config-prettier プラグインを導入することでESLintとPrettierで競合するルールを解消できます。

また、フォーマッターを eslint --fix 実行時に連動して実行できると便利です。
以前は eslint-plugin-prettier プラグインをインストールして連動させるのが一般的でしたが、今は多くのIDEがPrettier単体の実行をサポートしており、公式的には eslint-plugin-prettier を使用してESLintと連動させるのは 非推奨 (実行速度が落ちるなどの理由のため)の様です。

PrettierとESLintを連動させたい場合は prettier-eslint というツールもあるそうです。

husky + lint-stagedを使ってcommit時にチェックする

ESLintやPrettierを導入しても、指摘を無視されると全く効果がありません。
これはCIでチェックする方法もありますが、簡単に導入するにはhuskey + lint-stagedを使って各自コミット前にチェックする方法です。

HuskyはGit hooks(Gitの特定のアクション時に別のコマンドを実行するための仕組み)を手軽に導入するパッケージです。
lint-stagedはGitのstaged( git add された状態)のファイルをリンターでチェックするパッケージです。

これらを組み合わせる事でコミット前にコミット対象のファイルにフォーマッターを実行し、リンターでチェックして指摘があったらコミットを中止する事が可能です。
ただし、GitをGUI化するツールによってはGit hooksが実行されない場合がある様で、指摘があるのにコミットされるケースがあったので注意が必要です。

Babelの役割って何?

JavaScript開発で度々登場するBabelですが、とても重要な役割を果たしています。
BabelはJSの新しい仕様で追加された関数や構文を対応していないブラウザでも実行できる様に、下位互換のある書き方に変換してくれます。

新規の関数を対応していないブラウザで使用する時に代わりに使用されるコードをPolyfill(ポリフィル)と呼びます。
例えば Array.prototype.every()のページを見ると下部にポリフィルのコードが書かれていて、一番下にブラウザやNode.js毎のサポートされているバージョンの記載があります。
every の場合は全てのブラウザやNode.js 10以降でサポートされているので特に問題はないですが、例えばarrow functionはIEでサポートされていません。
また、そもそもarrow functionの場合は新しい構文なので、ポリフィルもありません。

この様な場合にBabelを使うと、サポートしていない構文や関数を指定したJSの仕様(例えばIE11をサポートしたい場合ES5)で書き換えて出力してくれます。
新しい構文を使わない選択肢もありますが、使いたいライブラリがサポートしていない構文や関数を使用しているケースもあります。

また、BabelReactで使われるJSXという構文を変換する用途にも使われています。
Babelには様々なプラグインがあり、Node.js用のライブラリをブラウザで使用するために使われるBrowserifyを連携するBabelifyや、JSを最小化するminifyなどを組み合わせて使う事ができます。
ですが、これらの役割は最近はWebpackで実行される事が多いです。

Babelもプラグインを導入する事で様々な事ができる反面、初期に設定してそのまま放置され、ブラックボックス化するなどのマイナス面もあるため、程々に設定するのがよいと思います。

手軽にソースを実行したい時は babel-register を使う

Babelの項で書いた通り、Babelはサポートされていない構文を指定のJSの仕様に変換してくれます。
そのため、コンパイル前のソースでは動作しない場合、実行前に毎回ビルドしなければ動作しません。
これでは修正を確認する度に時間がかかってしまいます。

そこでbabel-registerというプラグインを使用すれば、毎回ビルドしなくても適宜ビルドしながら実行してくれます。
これはローカル環境での確認など、ビルドされていないソースをすぐ実行したい時にとても便利です。
ただし、今は記載されていないですが、以前はbabel-registerは「適宜ビルドする分性能は劣化するので本番環境で使用すべきではない」と記載されていたので、あくまでもテスト環境のみで使用するようにしてください。

なぜタスクランナーやバンドラーを使うのか

こちらの記事に詳細をまとめました。
Why webpack?を読んでタスクランナーやバンドラーの理解が少し深まった

詳しい説明は上記の記事を見てもらうとして、簡単にまとめると下記の理由が挙げられると考えています。

  • スコープを壊す事なく事前に一つまたは複数のファイルにビルドできる
  • CommonJS、ESMの依存関係を解決し、ブラウザ/Node.jsで動作する様にしてくれる
  • それ以外のタスク(BabelやTypeScriptのコンパイルなど)も自動化できる

Gruntやgulpなどのタスクランナーの役割

最近はあまり使われていませんが、以前はGruntgulpがタスクランナーとして使われていました。
私もあまり詳しくない(関わったプロジェクトでgulpが導入されていましたが修正すること無く終わった)ので解説は省きますが、複数の処理を自動化する為に使われているツールです。

例えばSassを導入していてCSSに変換する必要があったり、ビルドに複数の手順があったり、最後にモジュールをまとめて圧縮する手順があったりなど、毎回手動で実行するのが手間な作業がある場合、これらのツールを導入して自動化して作業の手間を減らせます。

Gruntよりgulpの方が後発のツールで、gulpだとタスクを並列で処理ができるという明確な強みがあるそうですが、バージョンアップで変わっている可能性もあります。
今から新規で導入される事は少ないかもしれませんが、既存のプロジェクトに参画した場合はこれらのツールを触る事があるかもしれません。

webpackとは?

公式では自身をモジュールバンドラーと呼んでいます。
webpackは以下の様な事ができます。

  • 単体でバンドラーとして動作する
  • 自動で依存関係グラフを作れる
  • ツリーシェイクができる
  • 画像などのJSファイル以外のアセットもバンドルできる

webpackは単体でJavaScriptのプロジェクトの内部的な依存関係を読み取り、1つまたは複数のバンドルとして出力します。
しかし、実際にはコードをバンドルする前にBabelでES5、AltJSをJSにビルドしたりする作業が必要です。
なので loader を使って事前にコードを処理してからバンドルします。
babel-loaderts-loader など各種loaderが用意されているので、必要な loader を導入してください。
webpackはモジュールをバンドルする以外にも事前処理などをこなせたり、プラグインを導入する事でタスクランナーの様な役割も担う事ができますが、複雑なconfigファイルになると後々メンテも大変になるので、程々がよいとは思います。
私は出来合いのwebpackを少し弄る程度の事しかしていないのですが、1から調査して設定を書くのはかなり労力がかかる作業だと思います。

デバッグしたい時は source map を生成する

今までBabelやwebpackを紹介してきましたが、これらのツールでビルドすると元のコードとは違うコードが出力されます。
そのままだとデバッグする際にエラーの発生箇所の情報や、任意の場所でブレークしたいなどの作業ができません。
そのため、ビルドしたソースと元のソースを紐付ける source map という仕組みがあります。
Babelやwebpackでもsource mapを出力するプラグインや設定があるので、デバッグ時には出力するようにしておくとデバッグが数倍捗ります。
VSCodeでもきちんとデバッガーの設定をするとブレークが貼れる様になるので大変助かりました。

AltJS(TypeScriptなど)を使うには?

有名所としては CoffeeScriptTypeScriptDart などがあります。
しかし、いくつかあるAltJSの中では現在では TypeScript が圧倒的に人気があり、一番使われています。
それとは別に最近では Elm も一部で使われています。
これらは当然ながら公式のツールでビルドが必要となります。
それぞれの言語が思想を持って開発しているので、コンセプトが目的に合っているかどうかもポイントになると思います。
とはいえ、現状は基本TypeScriptになるかと思います。

TypeScriptはJavaScriptにはない型を導入しているので、自由度は落ちますが動的言語の弱点である大規模な開発になると型由来の問題がかなり解消されるので、導入する事で得られるメリットは大きいです。
開発も活発かつツールも枯れてきているのでTypeScript由来のバグは少なくなっていますが、それでも多少はあるのでその点は注意が必要です。
また、TypeScriptは途中から部分的に導入する事も可能なので、JavaScriptのプロジェクトに導入して徐々にTypeScript化していく事もできます。
JavaScriptは後方互換性を大切にしているため破壊的な変更は入れられないですが、AltJSによって不可能だった事が実現されているという側面もあります。

プロセスマネージャーとは?

Express.jsのサイトにまとまった記事があったので、そちらを参照するとよいです。
Express アプリケーション用のプロセス・マネージャー

Node.jsはシングルスレッドでも性能が出る様に設計されていますが、マルチCPUの場合シングルスレッドだとCPUの性能を活かしきれません。
また、万が一プロセスが落ちた時に再度Node.jsを起動しないといけないなど本番環境で運用するには不安な面があります。
その様な要件を解決するために以下の様なプロセスをマネージするツールがあります。

  • forever
  • pm2
  • StrongLoop
  • Process Manager

これらのツールはそれぞれ出来る事が違うため、要件に合わせて選択してください。
私は主に pm2 が導入されていたためそのまま使っていましたが、複数のプロセスの管理や、コマンドで様々な操作ができたりと、小規模なプロジェクトでは特に問題なく安定して動作していました。

ただし、よく理解して設定しないとせっかくの性能が発揮できません。運用していた際に負荷が集中してレスポンスが落ちてしまった事があり、その対策を取っていたところ、クラスターモードで運用されておらず、CPUを一つしか使えていないという事がありました。
幸いクラスターモードには設定を変更してすぐに移行する事ができたのですが、しばらくはプロセスごとにログがバラバラに出力される事になりました。
これはプログラムがログ出力に関して複数プロセスを前提に作られていなかったためです。
クラスターモードでも正常に動作したのはプロセス毎に状態を持っていなかったためだったので、運が良かったとも言えます。
Node.jsに限らずスケール可能なサービスにするには、ステートレスな設計にしておく事が大事です。

Callback地獄と対策

Node.jsはブロッキング(特にファイルアクセス、APIコール、DBアクセスなどのI/O処理)が発生して処理を止めてしまわない様に、非同期処理を極力行わない事に力を入れています。
そのため、ブロッキングが発生する関数(fs.readFileなど)の場合、Callbackに次の処理を書いて渡すのが基本でした。
しかし、それを続けるとネストが深くなり、Callback地獄と呼ばれる処理が読みにくい状態になってしまいます。
以下が例です。

fs.readFile('./config/foo.txt', (err, data) => {
  if (err) {
    // error処理
  }
  fs.readFile('./config/bar.txt', (err, data) => {
    if (err) {
      // error処理
    }
    // 以下繰り返し
  })
})

実際にはこの様に何度もファイルを読み出したりはしないかもしれませんが、APIコールやDBアクセスなどを繰り返すとあっという間にネストが深くなります。
Callback地獄を避ける対策の1つは処理を適切に関数に切り出す事ですが、意識して切り出さないと難しいでしょう。
今ではCallback以外にも非同期で処理を書ける構文が追加されているため、関数に切り出す以外にもネストが深くならずに処理を書けます。
ネストが深くなって見通しが悪い場合、既存のCallbackで書かれた関数をAsync/Awaitに書き換える事も検討しましょう。
JSを書く上でAsync/AwaitとPromiseの仕組みを理解するのは必須だと思います。

Promiseとは

公式のドキュメントがあるのでそちらを読むとよいでしょう。
Promiseは作成した時点では値が分かりません。
予め成功または失敗した時にどの様な処理をするかを記載しておき、処理が成功もしくは失敗して値が確定したら処理を実行します。

例えばNode.js v10で fsPromises.readFile() が追加されています。
こちらの関数を使うと以下の様に書き換えられます。

// promiseを受け取る
const promise = fsPromises.readFile('./config/foo.txt');

promise.then(data => {
    // foo.txtの読み込みに成功した時
    console.log(data);
    // 結果が次のthenに渡される
    return fsPromises.readFile('./config/bar.txt');
}).then(data2 => {
    // bar.txtの読み込みに成功した時
    console.log(data2);
}).catch(err => {
    // 失敗した時
    console.log(err);
});

// 一度変数として受け取らなくてもチェーンを開始する事が可能
fsPromises.readFile('./config/foo.txt')
    .then(...);

上記の例の場合、 thenpromiseresolve した時、 catchreject された場合に呼び出されます。
thenthen(resolve, reject) と2つのハンドラーを登録できますが、 reject はoptionalなので省略可能です。
ただし、 reject を登録していない場合、 reject が発生してしまうとエラーになるため、本当に省略してよいか注意して下さい。
catchreject のみ処理します。
thencatch の詳細な解説は、是非公式ドキュメントを読んで下さい。

自分で Promise を返す関数を作る場合、例えば readFilePromise を使った形式にする時は以下の様に書き換えられます。

const promisedFileRead = path => {
    return new Promise((resolve, reject) =>
        fs.readFile(path, (err, data) => {
            if (err) {
                return reject(err);
            }
            return resolve(data);
        }));
}

正常終了した時は resolve、エラーになった時は reject に値を渡します。
そうすると Promise に設定されたそれぞれのハンドラーが実行されます。
よくある (err, value) タイプのCallbackを設定する関数の場合、util.promisify()を使ってPromise関数化する事も可能です。

Promise を使うことで複数の処理を then でつなげて書く事ができ、Callback地獄を避けてフラットに書けます。
また、 Promise は複数の Promise の処理を制御する便利な関数があり、例えば Promise.all で複数の Promise が全て終わるのを待ったり、 Promise.anyPromise.race でどれか一つの Promise が終わった時点で結果を取得する事もできます。
これは複数のAPIコールを同時に呼び出せる場合に、順番に呼び出して適宜結果を待つのではなく、並列に実行して結果を待つという使い方ができます。

// それぞれAPIを呼び出して結果をPromiseを返す関数とする
const promiseA = callApiA();
const promiseB = callApiB();
const promiseC = callApiC();

Promise
    .all([promiseA, promiseB, promiseC])
    .then(values => {
        // 全てのPromiseがresolveした場合、結果が登録したPromise順の配列としてセットされる
        values.forEach(value => console.log(value));
    })
    .catch(err => console.log(err)); // 最初に返されたrejectの値しか取れない

Promise.anyPromise.race で表現できない事がある場合、 bluebirdなどのライブラリを探してみると目的にあった処理があるかもしれません。

Async/Awaitとは

Promise を使う事でフラットに書く事ができましたが、 Promise に続く処理する時は全て then の中に処理を書かねばならず、エラー時の処理も手続き的に上から下に書くよりも少し直感的では無いという点があります。
そこで Async/Await 構文を使うとより手続き的に Promise を処理する事ができます。
Async/Awaitの公式ドキュメントがあるので詳細はこちらを読んで下さい。
Async/AwaitPromise をよりすっきりした方法で書く事ができます。
Await を使うには関数を宣言する時に Async キーワードを使う必要があります。
例として以下の様に書けます。

const main = async () => {
    // rejectが発生した場合try/catchで処理可能
    try {
        // resolveした場合、dataに値がセットされる
        const data = await fsPromises.readFile('./config/foo.txt');
        console.log(data);
        // catchでエラーを処理することも可能
        const data2 = await fsPromises.readFile('./config/bar.txt')
            .catch(err => {
                console.log(err);
                return "";
            });
        console.log(data2);
    } catch (err) {
        console.log(err);
    }
}

main();

Await を使う事で Promise のデータが resolve または reject されるまで他の処理をブロックする事なく待つ事が可能です。
これで then でチェーンする事なく、非同期処理をあまり意識せずに手続き的に上から下に順番に処理を書く事が可能です。
かなり見やすくなったと思います。
Async キーワードを付けた関数の戻り値は暗黙的に Promise となります。
例えば以下の例の様に書けます。

// 受け取ったcount分待ってresolveにcountをセットする
const waitAndReturn = count => {
    return new Promise(resolve => {
        setTimeout(() => { resolve(count) }, count * 1000);
    });
}

const asyncWaitAndReturn = async count => {
    const result = await waitAndReturn(count);
    // awaitで受け取った結果を加工する
    return result * 10;
}

// 4秒後に40が出力される
asyncWaitAndReturn(4).then(count => console.log(count));

また、 Async/AwaitPromise.all などと組み合わせて使う事もできます。

// 受け取ったcount分待ってresolveにcountをセットする
const waitAndReturn = count => {
    return new Promise(resolve => {
        setTimeout(() => { resolve(count) }, count * 1000);
    });
}

const asyncWaitAndReturn = async count => {
    const result = await waitAndReturn(count);
    // awaitで受け取った結果を加工する
    return result * 10;
}

const main = async () => {
    // 解決順は1、2、3だがちゃんと2、1、3の順に出力される
    const promise1 = asyncWaitAndReturn(2);
    const promise2 = asyncWaitAndReturn(1);
    const promise3 = asyncWaitAndReturn(3);

    const results = await Promise.all([promise1, promise2, promise3]);
    results.forEach(result => console.log('result: ' + result));
}

main();

Streamって何?どういう時に使うの?

Streamを制するものはNode.jsを制すと言われ、特定のケースでStreamは非常に有用です。
Streamは大量データの一部だけ読み取り、加工した後に出力するといった処理だったり、ソケット通信の様な不定期に通信が送られてきて適宜処理をする様なケースに向いています。
Streamには以下の4種類のタイプがあります。
stream_types_of_streams

  • Writable: データの書き込み可能なStream
  • Readable: データの読み込み可能なStream
  • Duplex: ReadableとWritableの両方の性質を併せ持つStream
  • Transform: Duplexと同じ性質を持ち、データを加工するStream

大まかなイメージとしては、ReadableなStreamからデータを読み込み、Transformで必要に応じて加工し、WritableなStreamで書き込みします。
各Streamを繋ぐのは emitter.ondataend が来た時の挙動を設定するのですが、便利ユーティリティの pipe が用意されているのでこちらを使います。
ファイルを読み取ってzipに圧縮して出力するサンプルは以下の様になります。

import fs from "fs";
import zlib from "zlib";

const src = fs.createReadStream('./text.txt')
const gzip = zlib.createGzip()
const dest = fs.createWriteStream('./text.zip')

src.pipe(gzip).pipe(dest)

fs.createReadStream で読み込まれたデータが、 zlib.createGzip でzipに変換され、 fs.createWriteStream に書き込まれます。
基本はこの様な使い方になりますが、データの読み出し元がソケットになったり、途中の変換処理が複数になったり、色々と応用が効きます。

Streamの強みとしては、データを少量ずつ流すため、メモリに優しいという点があります。
例えば、上記の例だともし数百MBあるデータを処理する場合、読み出したファイルをメモリに持ち、更に変換処理毎に変換結果を全てメモリに持たないといけないため、結果として元のファイルの数倍以上のメモリが必要になります。
しかし、Streamだと例えば各ステップごとに1MBずつデータを流すとすれば、1MBのデータを読み、1MBのデータを変換し、1MBのデータを書き込むという処理を全てのデータが流れるまで延々と続けるだけなので、データ量が大きくなったとしても安定して処理ができます。

データのバッファ量は fs.createReadStream であれば highWatermark と言うオプションで指定できます。
Buffering に関しては公式ドキュメントも読んでみてください。

上記ではファイル読み込みをサンプルとしましたが、業務ではDBから抽出してきたデータを加工してCSVとしてダウンロードするようなケースが多いかもしれません。
サービスの可動とともにデータ量が増えていく様なCSVファイルの場合、初めからStreamで実装しておくとデータ量が多くなっても、少なくともメモリオーバーフローなどの問題は起きにくくなります。

オリジナルのStream処理を実装してみる

Streamを使いたくてもニーズに合うものがないかもしれません。
その様な時は自分でStreamを作りましょう。
とは言え中々難しい部分が多いので、stream_api_for_stream_implementersをしっかり読んだ方がよいでしょう。
以下は単純なstreamのサンプルです。

import {Readable, Transform, Writable} from 'stream';

const buffer = [];
// Readable Streamを作成する
const readable = new Readable({
	construct(callback) {
		for (let i = 1; i <= 10000; i++) {
			buffer.push(i);
		}
		callback();
	},
	// readを実装
	read(size) {
		for (let i = 0; i < size; i++) {
			if (buffer.length === 0) {
				this.push(null);
				return
			}
			this.push(buffer.shift().toString());
		}
	},
	encoding: "utf-8", // Stringはutf-8フォーマットに変換された後にBuffer形式で渡される
});

// Transform Streamを作成する
const transform = new Transform({
	transform(chunk, encoding, next) {
		// 受け取ったchunkを処理する - Writableの性質
		const number = parseInt(chunk.toString(), 10);
		const doubled = number * 2;
		// 次のstreamにデータを流す - Readableの性質
		this.push(doubled.toString());
		next();
	},
	defaultEncoding: "utf-8",
	encoding: "utf-8",
})

// Writable Streamを作成する
const writable = new Writable({
	write(chunk, encoding, next) {
		// objectModeがfalseの時は基本encodingはbufferとなる
		// console.log(encoding);
		console.log(chunk.toString());
		// 次のバッファを処理できる段階でコールバックを呼び出す
		next();
	},
	final() {
		// chunkにnullがセットされた時にstreamが終了する
		console.log('end');
	},
	objectMode: false, // default falseだが説明のため明示的に書いている
	defaultEncoding: "utf-8",
});

readable.pipe(transform).pipe(writable);

上記の例ではReadableのconstructでデータを作った後に一気に後続の処理に流し込んでいます。
下記の様にReadableの部分を変更する事で、随時データが流れてきたら処理する様にもできます。

// Readable Streamを作成する
const readable = new Readable({
	// readを実装
	read() {},
	encoding: "utf-8", // Stringはutf-8フォーマットに変換された後にBuffer形式で渡される
});

let counter = 1;
const interval = setInterval(() => {
	readable.push(counter.toString());
	counter++;
	if (counter === 11) {
		readable.push(null);
		clearInterval(interval)
	}
}, 1_000);

// 以下TransformとWritableは上記の例と同じ物を使用

上記はあくまでもサンプルなので実際の業務で使用するのであれば、エラー処理などの実装も必要になると思われます。
公式ドキュメントや既に実装されているStreamのライブラリなどをしっかり読んだ方がよいでしょう。

終わりに

Node.jsからはしばらく離れそうなので忘れてもいい様に自分のメモ用として学んだ知識を雑にアウトプットするつもりの記事でしたが、間違いのない様に公式ドキュメントなどを調べながら記載したら思ったより量も期間もかかってしまいました。
しかし、おかげで曖昧だった箇所もかなり明確になりました。
これで書いた事に関しては一旦忘れても大丈夫そうです。
なお、間違いなどありましたら指摘お願い致します。

180
255
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
180
255

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?