プログラミング初心者がアーキテクトっぽく語る

見苦しい記事も多数あるとは思いますが訂正しつつブログと共に成長していければと思います

JavaScript ES6 + JestでTDD(fetch API Mockなし編)

前回、ブラウザ向けのJavaScript ES6コードをJestでTDDする方法を紹介しました。

architecting.hateblo.jp

今回はcar.jsにfetch APIを導入してテストします。


なぜMockしないのか?

単体試験する際は普通はfetch APIをMockすると思います。

fetch APIをMockしない場合、デメリットが色々あります。

  • 試験が遅くなる
  • サーバに負荷がかかる
  • サーバが到達不能な場合、試験ができない
  • 応答が変化するAPIの場合、Failが頻発してしまう

それでも今回はあえてMockなしで単体試験をしてみたいと思います。

まずはfetch API自体の理解を深めるためです。

次にfetch APIのようなAPIをJestで試験する手法の理解を深めるためです。


REQ|RES

REQRESはテスト用APIを公開しているサイトです。

https://reqres.in/

今回はこのサイトのAPIを渋滞交通情報APIに見立てて叩きます。


テスト1:データ取得成功

まずはデータ取得に成功したケースのテストを書いてみましょう。


car.test.js

car.test.jsに記載する単体試験は以下の通りです。

Car#getTrafficDataはresolveされたPromiseを返す前提です。

    const apiServer = 'https://reqres.in';
<async/awaitの場合>
    it('should get traffic data 1', async () => {
        const traffic = await car.getTrafficData(apiServer + '/api/user/2');
        expect(traffic).toBe(2);
    });
<Promise/thenの場合>
    it('should get traffic data 2', () => {
        return car.getTrafficData(apiServer + '/api/user/2').then((traffic) => {
            expect(traffic).toBe(2);
        });
    });

Promise/thenの場合はreturnを忘れてはいけません。忘れるとJestがresolveを待たずに進んでしまい、常にPassしてしまいます。


car.js

car.jsにgetTrafficDataを実装します。

    getTrafficData() {
        const requestOptions = {
            method: 'GET',
            redirect: 'follow'
        };
        return fetch("https://reqres.in/api/user/2", requestOptions)
            .then(response => response.json())
            .then(result => Promise.resolve(result.data.id));
    }

テスト実行

コーディング完了です。

「npm test」でテストを実行します。

するとエラーが発生してしまいました。

ReferenceError: fetch is not defined

「fetchがない」と言っています。

しかしfetch APIはブラウザが標準で実装しているAPIです。なぜエラーが出るのでしょう?


node-fetchを入れる

Jestを実行しているのはnode.jsです。そしてnode.jsにはfetch APIは標準ではないのでテストでエラーが出ています。

テスト用にnode-fetchをインストールします。

npm install --save-dev node-fetch

あくまでテスト用なので--save-devオプションで入れます。


require('node-fetch')を書く

次に「const fetch = require('node-fetch')」を書きます。

でもcar.jsにrequire文は書きたくありません。car.jsはブラウザ環境で使用する前提です。require文を書いたら今度はブラウザ環境でエラーが出てしまいます。それにブラウザには標準でfetch APIが備わっているのでnode-fetchは不要です。

そこでcar.test.jsに以下のように記述してglobalなfetchを上書きします。

global.fetch = require('node-fetch');

これでテスト実行時はcar.jsのfetchはnode-fetchになります。グローバル変数は恐ろしいけど便利です。。。


テスト実行

「npm test」でテストを実行します。

今度は無事、Passしました。


テスト2:エラー応答受信

サーバからエラー応答を受信したケースをテストします。


car.test.js

car.test.jsに記載する単体試験は以下の通りです。Car#getTrafficDataはrejectされたPromiseを返す前提です。

実際のコーディングにおいては空のPromiseを返す方が便利な気もしますがここではrejectされたケースを扱う勉強としてあえてrejectします。

<async/awaitの場合>
    it('should reject when 4XX is returned 1', async () => {
        expect.assertions(1);
        try {
            await car.getTrafficData('/api/user/23');
        } catch (error) {
            expect(error).toBe('Error 404');
        }
    });
<Promise/thenの場合>
    it('should reject when 4XX is returned 2', () => {
        return expect(car.getTrafficData('/api/user/23')).rejects.toBe('Error 404');
    });

ここでもPromise/thenの場合はreturnを忘れてはいけません。よく忘れます。

新たな注意点ですが、async/awaitの場合、expect.assertions(1)でassertionが発生した回数をチェックすることを忘れないようにしましょう。これがないとresolveされたPromiseが帰ってきたときテストがPassしてしまいます。

ただ私の環境ではexpect.assertionsの動きがちょっとおかしいです。他のテストのassertionの回数まで数えてしまい他のテストの内容によってPass/Failが変わってしまいます。例えば他のテストでもrejectが戻ってきた場合、assertionの回数が2だと思ってしまいます。テストごとにassertionの回数をクリアする方法を探したのですがわかりませんでした。


car.js

getTrafficDataを実装を変更します。

fetch APIはサーバがエラーを返信してもresolveしたPromiseを返します。このため変更するのはcatchではありません。thenの中でresponse.okでエラー有無を確認して処理を分ける必要があります。

    getTrafficData(resourcePath) {
        const requestOptions = {
            method: 'GET',
            redirect: 'follow'
        };
        return fetch(`https://reqres.in${resourcePath}`, requestOptions)
            .then(response => {
                if (!response.ok) {
                    return Promise.reject('Error 404');
                } else {
                    return response.json();
                }
            })
            .then(result => Promise.resolve(result.data.id));
    }

テスト3:fetch APIのエラー

fetch API自身の処理でエラーが発生したケースをテストします。


car.test.js

car.test.jsに記載する単体試験は以下の通りです。

Car#getTrafficDataはrejectされたPromiseを返す前提です。

<async/awaitの場合>
    it('should reject when error happend 1', async () => {
        expect.assertions(1);
        try {
            await car.getTrafficData('http://dont.exist.api/api/bogus');
        } catch (error) {
            expect(error).toMatch('message');
        }
    });
<Promise/thenの場合>
    it('should reject when error happend 2', () => {
        return expect(car.getTrafficData('http://dont.exist.api/api/bogus')).rejects.toMatch('message');
    });

car.js

getTrafficDataを実装を変更します。

今までの実装でもgetTrafficDataはrejectされたPromiseを返します。しかしfetch APIでエラーが発生したときPromiseの中身はオブジェクト形式です。サーバがエラー応答したときは文字列型、fetch APIエラーのときはオブジェクト型を返すという仕様はgetTrafficDataを呼び出す側にとって面倒なのでcatchの中で文字列に統一しました。

(もっといい方法があると思います)

    getTrafficData(url) {
        const requestOptions = {
            method: 'GET',
            redirect: 'follow'
        };
        return fetch(url, requestOptions)
            .then(response => {
                if (!response.ok) {
                    return Promise.reject('Error 404');
                } else {
                    return response.json();
                }
            })
            .then(result => Promise.resolve(result.data.id))
            .catch((error) => {
                if(typeof error === 'object') {
                    return Promise.reject(JSON.stringify(error));
                } else {
                    return Promise.reject(error);
                }
            });
    }

Mock

次は今回のテストをMockを使って行います。

architecting.hateblo.jp

JavaScript ES6 + JestでTDD(環境構築+基本編)

ブラウザ向けのコードをJavaScript ES6で書きましょう。

コードを書くならユニットテストをしながら書きたいですね。

今回はJavaScriptでTDD環境を整えます。ES6のimportが原因でエラーが出やすいので環境構築部分を詳細に書きました。(長文です)

node.jsのコードをTDDするケースは別記事にしたいと思います。


Jest

JestはJavaScriptユニットテストフレームワークです。

他にもMochaなど有名なフレームワークがありますが今回はJestを使います。


Babel

ブラウザ上で動かすコードをES6で書いてJestを実行するとエラーが出ます。原因はES6とCommonJSの違いです。

Jestはnode.jsで動きます。node.jsではモジュールの読み込みは「require」で行います。

一方、ES6ではモジュールの読み込みは「import」で行います。この「import」をJestが理解できなくてエラーが出ます。

解決策としてES6 コードをnode.jsが理解できるES5 コードに変換してJestを実行します。しかもJestで試験するときだけ変換を実行します。この変換作業を行うのがBabelです。


node.js

以降の手順は既にnode.jsがインストールされていることが前提です。

まだの人は下記のリンクを参考にしてインストールしてください

MacにNode.jsをインストールする - プログラミング初心者がアーキテクトっぽく語る


環境作成手順

プロジェクトフォルダを作成

  • 適当な名前のプロジェクトフォルダを作成します。
mkdir jest-sample
  • 作成したらVSCodeでOpen folder...からプロジェクトフォルダを開きます。

package.jsonの雛形作成

  • VSCodeのViewメニューからTerminalを選択します。
  • Terminalが開くので「npm init」と打ちます。
  • 質問には全てEnterでOKです。
  • package.jsonが作成されます。

モジュールをインストール

  • Babelをインストールします。
npm install --save-dev @babel/core @babel/preset-env
  • Jestをインストールします。
npm install --save-dev jest babel-jest @types/jest
  • --save-devを忘れないでください。

  • @types/jestも入れておくとVSCodeで補完ができて便利です。


jest.config.jsを作成

  • プロジェクトフォルダのトップ階層にjest.config.jsを作成して以下の内容を記述します。
module.exports = {
    verbose: true ,
    testMatch: [
        "**/test/**/*.test.js"
    ],
};

babel.config.jsの作成

  • プロジェクトフォルダのトップ階層にbabel.config.jsを作成して以下の内容を記述します。
module.exports = {
    presets: [
        [
            '@babel/preset-env',
            {
                'modules': 'false',
                'useBuiltIns': 'usage',
                'targets': '> 0.25%, not dead',
            }
        ]
    ],
    env: {
        test: {
            presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
        },
    },
};

package.jsonを編集

  • package.jsonのscripts > testsを下記の通り「jest」に変更します。
<変更前>
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
<変更後>
  "scripts": {
    "test": "jest"
  }

動作確認

  • プロジェクトフォルダにtestフォルダを作成します。
  • test/car.test.jsを作成し、以下の内容を記載します。
  • 「i」と入力すると「it」が補完候補として表示されることも確認して下さい。
  • 補完機能が動かない場合は@types/jestがインストールされていない可能性があります。
describe('nothing', () => {
    it('should be nothing', () => {

    });
});
  • Terminalで「npm test」を実行します。
$ npm test

> jest-sample@1.0.0 test /Users/john/Code/JavaScript/jest-sample
> jest

 PASS  test/car.test.js
  nothing
    ✓ should be nothing (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.471 s
Ran all test suites.
$ 
  • これで環境は完成です。

コーディング

  • 次はTest Firstでコーディングしていきます。内容はIntellijかPyCharmのTutorialに登場したcarクラスをいじってます。

インスタンスを作成するテスト

  • 最初のテストを削除してCarクラスをインスタンス化するテストをcar.test.jsに書きます。
  • この時点ではなにもAssertしていません。
  • 試験は「it」か「test」から始めます。
describe('Car', () => {
    it('should be created', () => {
        new Car();
    });
});
  • テストがFailすることを確認します。
$ npm test

> jest-sample@1.0.0 test /Users/john/Code/JavaScript/jest-sample
> jest

 FAIL  test/car.test.js
  Car
    ✕ should be created (1 ms)

  ● Car › should be created

    ReferenceError: Car is not defined

      1 | describe('Car', () => {
      2 |     it('should be created', () => {
    > 3 |         new Car();
        |         ^
      4 |     });
      5 | });

      at Object.<anonymous> (test/car.test.js:3:9)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.414 s
Ran all test suites.
npm ERR! Test failed.  See above for more details.
  • Carクラスがないので怒っています。

  • car.jsを作り空のCarクラスを記述します。

class Car {

}

export default Car;
  • car.test.jsでcar.jsをインポートします。
import Car from '../car'
  • テストがパスすることを確認します。
$ npm test

> jest-sample@1.0.0 test /Users/john/Code/JavaScript/jest-sample
> jest

 PASS  test/car.test.js
  Car
    ✓ should be created (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.174 s
Ran all test suites.
$
  • TDDではこんな感じのTest > Pass > Refactorを繰り返します。

  • このテストでimport関連のエラーが出る場合はBabel設定が間違っている可能性あります。Babel関係の設定を見直してください。


期待結果と比較する

  • 期待結果との比較はexpect().toXXXで行います。
describe('Car', () => {
    it('should be empty at first', () => {
        const car = new Car();
        expect(car.speed).toBe(0);
    });
});
  • どんなtoXXXが利用可能かはVSCodeで補完機能を使って確かめてください。

Fixtureを使う

  • 試験の最初にいつも実施する作業はbeforeEachにまとめます。

  • 下の例では各テストの中で実施していたインスタンス作成作業をbeforeEachに移しました。

describe('Car', () => {
    let car;
    beforeEach(() => {
        car = new Car();    <<< 移動
    });

    it('should be empty at first', () => {
        expect(car.speed).toBe(0);
    });

    it('should have speed of 5 after one acceleration', () => {
        car.accelerate();
        expect(car.speed).toBe(5);
    });
});

Exceptionを試験する

  • ExceptionはtoThrowで確認できます。便利。。。
    it('should throw exception if driver is distracted', () => {
        const action = () => {
            car.driveByGirls();
        };
        expect(action).toThrow('Keep your eyes on the road!');
    });

非同期の試験(Promise/then)

  • JavaScriptで多用する非同期処理の試験方法です。

  • Promiseを返す関数なら単純です。JestはPromiseがresolveされるまで待ってくれます。thenにexpectを書いておけばOKです。

  • car.jsに以下の関数を追加します。関数はPromiseを返します。

    coolDownForGivenTime(time) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(15);
            }, time * 1000);
        })
        .then((currentTemp) => currentTemp );
    }
  • car.test.jsに以下のテストを追加します。

  • Promiseが resolveされるのを待って、戻ってきたcurrentTempの値をexpect.toBeでチェックしてます。

    it('should cool down the room asynchronousy', () => {
        return car.coolDownForGivenTime(5).then((temp) => {
            expect(temp).toBe(15);
        })
    });

非同期の試験(async/await)

  • Promiseを返す関数ならasync/awaitを使って試験することもできます。
    it('should cool down the room asynchronousy with async/await', async() => {
        const temp = await car.coolDownForGivenTime(5);
        expect(temp).toBe(15);
    });

外部ライブラリをMockする

  • 外部ライブラリをモックする方法を紹介します。ここではradio.jsのRadio.playMusicメソッドをStub化します。

  • radio.jsの空ファイルを作成します。中身はなくても大丈夫です。

  • car.jsでradio.jsをimportしてRadio.playMusicを使用します。

import Radio from './radio';
<略>
    playMusic() {
        return Radio.playMusic();
    }
  • car.test.jsでradio.jsをimportしてRadio.playMusicを「La Bamba」を返すStubにしています。
import Radio from '../radio';
<略>
    it(`should play a music and return the title`, () => {
        Radio.playMusic = jest.fn().mockReturnValue('La Bamba');
        const title = car.playMusic();
        expect(title).toBe('La Bamba');
    });

fetch APIをテスト

長くなったので別記事にしました。

architecting.hateblo.jp

architecting.hateblo.jp


参考

https://jestjs.io/docs/en/getting-started

https://qiita.com/riversun/items/6c30a0d0897194677a37

VSCodeでJavaScript/Node.jsのコードをデバッグする

ここまでMacにNode.jsをインストールして、VSCodeでコード補完ができるようにしました。

次はデバッグします。


Node.jsアプリケーションを作成

デバッグ対象となる適当なアプリケーションを作成します。

VSCodeを立ち上げてserve.jsという名前でファイルを作成し、下記の内容を記述します。

const http = require('http');
const port = 3000;
const server = http.createServer( (req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
    console.log('/にアクセスがありました')
});
server.listen(port);

console.log(`http://localhost:${port}/でListenしています`);

動作確認

ターミナルを起動してserve.jsを実行します。

$ node serve.js 
http://localhost:3000/でListenしています

ブラウザでlocalhost:3000にアクセスします。

ブラウザに「Hello World」と表示されます。またターミナルに以下の出力があります。

$ node serve.js 
http://localhost:3000/でListenしています
/にアクセスがありました   <<<

ちゃんと動作していることが確認できました。次はこのアプリケーションをデバッグします。

Ctrl + Cでserve.jsを終了します。


デバッグ設定

VSCode画面左の虫ボタンをクリックします。Run and Debugの下のcreate a launch.json fileをクリックします。

f:id:hogehoge666:20210201224243p:plain

「Select Environment」と要求されるので選択肢の中からNode.js(legacy)を選択します。

f:id:hogehoge666:20210201224433p:plain

Node.js(legacy)が従来のデバッグ機能でNode.js(preview)が実験中の新しいデバッグ機能です。新しもの好きの人はNode.js(preview)を選んでも結構です。

launch.jsonファイルが自動生成されます。launch.jsonデバッグ実行に関連する各種の構成情報を記述するファイルです。programがserve.jsになっていることを確認します。

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/serve.js"   <<<
        }
    ]
}

デバッグ開始

画面左上、「RUN」右横の三角ボタンを押してDebugを開始します。

f:id:hogehoge666:20210201225021p:plain

エディタ画面左端で適当な行をクリックしてブレークポイントを設定します。下の例では5行目にブレークポイントを設定しました。

f:id:hogehoge666:20210201225246p:plain

ブラウザからlocalhost:3000にアクセスするとブレークポイントで処理が停止します。画面左側に変数の情報などが表示されています。

f:id:hogehoge666:20210201225502p:plain

画面上部のアイコンでコンティニュー、ステップオーバー、停止などができます。

f:id:hogehoge666:20210201225612p:plain


VSCodeでJavaScript/Node.jsのコードを補完する

VSCodeJavaScriptを書く時、ちゃんと候補表示&補完してくれるようにした話です。

やり方については情報が錯綜しています。私が調べた感じだと「tsdを使う」→「tsdが非推奨になりtypingsが推奨」→「typingsより便利なnpm install @types/が使えるようになった」という流れっぽいです。

ここではnpm install @types/とtypingsを使う方法を紹介します。まずはnpm install @types/を試して、だめだったらtypingsを試して下さい。


JavaScript標準のCoreライブラリの場合

候補表示&補完できる

JavaScript標準のCoreライブラリはなにもしなくても候補表示&補完ができます。

下の例ではmessageがString型なことを理解してStringクラスのメソッドを候補として表示しています。

f:id:hogehoge666:20210201010956p:plain


Node.jsの標準ライブラリの場合

候補表示&補完されない

Node.jsの標準ライブラリのAPIはデフォルトでは補完されません。

下の例ではhttpライブラリを読み込もうとしていますが、まずrequireが補完されません。

f:id:hogehoge666:20210201011010p:plain

下の例ではhttpライブラリを読み込みましたがhttpライブラリのメソッドが候補として表示されていません。

f:id:hogehoge666:20210201011021p:plain

この状態でコーディングするのは初学者には厳しいです。補完できるようにしましょう。

型定義ファイルの取得

npm install @typesを使う方式を紹介します。もしうまくいかない場合は後述するtypingsの方法を試して下さい。

npm install @types/<ライブラリ名>でそのライブラリの型定義ファイルを取得できます。VSCodeはその型定義ファイルを参照してコードを補完してくれます。

早速、Node.jsの型定義ファイルを取得しましょう。

npm install --save-dev @types/node

型定義ファイルは最終成果物の動作には不要なものなので--save-devオプションでローカルに開発用モジュールとしていれています。

これでnode_moduleディレクトリに型定義ファイルが入りました。これだけでOKです。

動作確認

requireが候補として表示されるようになりました。

f:id:hogehoge666:20210201011034p:plain

httpライブラリのメソッドが表示されるようになりました。

f:id:hogehoge666:20210201011043p:plain


Node.jsの外部ライブラリ(Express.js)の場合

候補表示&補完されない

Express.jsを例に話を進めます。まずはExpress.jsをインストールします。

npm install express

Express.jsを読み込んで使おうとしましたがExpress.jsのメソッドが候補に表示されません。ここではgetが表示されることを期待していました。

f:id:hogehoge666:20210201011109p:plain

型定義ファイルの取得

Node.jsのときと同じ方法でExpress.jsの型定義ファイルを取得します。

npm install --save-dev @types/express

動作確認

候補が表示されるようになりました。

f:id:hogehoge666:20210201011123p:plain

簡単ですね。


typingsを使った例

typingsを使って型定義ファイルを取得することもできます。もしnpm install @typesでだめだった場合はこちらを試してみてください。

Node.jsの標準ライブラリを例にとって話を進めます。

typingsをインストール

typingsがまだインストールされていない場合はインストールします。

npm install -g typings

globalでよいと思います。

型定義ファイルの取得

以下のコマンドでNode.jsの型定義ファイルを取得します。

typings install dt~node --global --save

取得した型定義ファイルはtypingsというディレクトリに配置されます。

VSCodeを設定

この方法ではVSCodeを設定する必要があります。

プロジェクトフォルダの最上位階層にjsconfig.jsonというファイルを作成し、以下の内容をコピペします。

{
    "compilerOptions": {
        "target": "es6",
        "module": "commonjs"
    }
}

動作確認

補完できるようになりました。

f:id:hogehoge666:20210201013258p:plain


感想

補完できるようになりました。

JavaScriptを敬遠していた理由の一つが補完が弱いことでしたがこれで学習のハードルが一つ下がりました。

MacにNode.jsをインストールする

Node.jsはスケーラブルなアプリケーションを構築するための非同期型処理を前提とした環境です。ブラウザを必要とせず、PCのTerminalやIDE上でJavaScriptを実行することができます。

JavaScriptをブラウザで実行できるのは手軽な反面、細かい挙動を見ようとすると不便だと感じるようになりました。そこで手元のMac OS MojaveにNode.jsをインストールしてみることにしました。


Node.jsのインストール方法の選択肢

Node.jsをMac上で利用するにはいくつか選択肢があります。

選択肢1. Docker

Node.jsのコンテナが多数、Dockerhubで公開されています。もしDockerがインストール済みなら簡単にNode.js環境を構築、破棄できます。

コンテナ化する前にまずは王道手順でインストールして使い勝手を把握したいので、今回はDockerはパスします。

選択肢2. 公式サイトからインストール

公式サイトでMac用のインストーラが提供されており簡単にインストールできます。

nodejs.org

インストール方法としては手軽ですがバージョンを変更するには毎回、公式サイトからダウンロードして上書きする必要があります。一方、後述するnodebrewはNode.jsのバージョン切り替えが容易です。

いくつかの文献によるとNode.jsはバージョンごとにライブラリとの相性に差が出やすく、Node.jsのバージョンを切り替える頻度は少なくないそうです。よってこの選択肢もパスします。

選択肢3. nodebrewからインストール

nodebrewはNode.jsのインストールや、インストール済みの複数のNode.jsバージョンの切り替えを行えるツールです。Pythonのpyenv的立ち位置です。

私はPythonのバージョンをpyenvで管理しています。言語のバージョンを容易に切り替える利便性は理解しているのでnodebrewを使うことにしました。

Node.jsには同様の機能を提供するツールが他にもあります。どれが推奨かは情報を十分に吟味する時間がなかったためとりあえずnodebrewにしました。他に推奨のツールがあればそれを使って下さい。


homebrewの確認

nodebrewはgitのrepositoryからインストールすることもできますが、homebrewが入っていれば一発でインストール可能です。

手元のMacでhomebrewがインストール済みか確認します。

$ brew -v
Homebrew 2.7.0

いつインストールしたのか覚えてませんが入ってました。使うことにします。


nodebrewのインストール

homebrewがあれば下記コマンド一発でインストールできます。

brew install nodebrew

インストールが完了すると以下のパスをbash profileに追記するようメッセージが表示されるので従います。

export PATH=$HOME/.nodebrew/current/bin:$PATH

パスが通ったことを確認します。

$ nodebrew -v
nodebrew 1.0.1

Node.jsをインストール

インストール可能なNode.jsの版数の選択肢を表示します。

nodebrew ls-all

版数を指定してインストールします。latestで最新版、stableで最新の安定版がインストールできます。保守的にstableにしました。

nodebrew install stable

文献によってはnodebrew installではなくnodebrew install-binaryを推奨しているところもあります。しかしhelpを見るとinstall-binaryはただのinstallのエイリアスで中身は同じだそうです。昔はinstall-binaryの方が早かったそうですが、今はinstallでよいと思います。

インストール済みのNode.js版数を確認します。

$ nodebrew ls
v14.15.4   <<<

current: none

v14.15.4がインストールされました。current: noneとはどのNode.js版数を有効化するか指定されていないことを示しています。


v14.15.4を有効化

v14.15.4を有効化します。

nodebrew use v14.15.4

有効化されたことをnodebrewの視点から確認します。

$ nodebrew ls
v14.15.4

current: v14.15.4   <<<

有効化されたことをNode.jsの視点から確認します。

$ node -v
v14.15.4   <<<

動作確認

「node」と実行するとREPL(対話式実行環境)に入ります。適当にJavaScriptのコードを書いて動作することを確認します。Ctrl + Cでも出れます。

$ node
Welcome to Node.js v14.15.4.
Type ".help" for more information.
> 
> console.log('Hello world');
Hello world
undefined
> 
> .exit
$

これでMacにNode.jsをインストールすることができました。

JavaScriptのレガシーコードを読み解く

JavaScriptのド素人が「JavaScript本格入門」を読んだ後、ES5のレガシーコードを読んだ話です。


JavaScript入門書読破

JavaScript本格入門」を読みました。簡潔でわかりやすいだけでなく構成もすばらしいです。こういう本を書ける人ってすごいと思います。

ネットで「JavaScript」と検索するとES2015以降とES5以前 の話が入り混じっていて「ESってうまいの?」な初心者は混乱します。この本を読んでJavaScriptの概要とES2015とES5の基本的の違いを理解できたことは大きかったです。お薦めです。


コードリーディングを実践

入門書を読んだ勢いで以前から気になっていたけど読み解けなかったJavaScriptのコードを読んでみました。

以前はわかりませんでしたがES5以前のレガシーコードで、入門書には登場しない外部ライブラリを多用してました。苦労しましたが入門書で得た基礎知識をもとに調査してなんとか読み解くことができました。

今後もレガシーコードを目にする機会は少なくない気がするので調査の中で得た知識をまとめておきます。


ES2015とES5

まずES2015とES5とはなんでしょう?

JavaScriptは元はNetscape Communications社が開発したLiveScriptというスクリプト言語でした。その後、Internet Explorerが採用したことで普及が進み、ブラウザ標準のスクリプト言語となりました。

しかし各ブラウザベンダが独自に開発を行ったためブラウザ間でスクリプトの互換性がないという問題が発生しました。そこで国際的な標準化団体であるEcma International(エクマ)が標準化を行うようになりました。1997年に初版をリリースし、今日現在の最新版は第10版です。

ECMAScript 2015とは、2015年にEcma Internationalが採択したJavaScript標準の第6版です。ES2015、ES6と呼ばれます。ES2015の1つ前の第5版はES5と呼ばれます。ES2015では大規模な変更が加えられたため、ES5以前とES2015以降のJavaScriptでは見栄えが大きく異なります。

ES5で書かれたスクリプトを読むにはES5の知識が必要で、ES2015で書かれたスクリプトを読むにはES2015の知識が必要です。


1. ライブラリ関連

ES2015には様々な機能が追加されており、外部ライブラリに頼ることなくできることが増えてます。

一方、ES5は外部ライブラリへの依存度が高いです。外部ライブラリについてある程度、知識がないと読み解けません。

細かいコードの話に入る前に登場したライブラリについて簡単に説明します。

RequireJS

モジュール管理ライブラリ。

どの言語でもある程度の規模のコードを書く場合は名前が衝突しないようモジュールやパッケージなどに分割する。JavaScriptはグローバルスコープが広くなりやすいので同様にモジュール分割は重要だがES5ではJavaScriptにモジュール機能がなかった。そのためRequireJSなどのモジュール管理ライブラリを使用する。

使い方を簡単に説明するとdefineでモジュールを定義してrequireでエントリポイントとなるJavaScriptファイルを呼び出す。

HTML側には<script>タグを1行だけ書く。残りの<script>タグはRequireJSが適切な順序とタイミングでHTMLに埋め込んでくれる。

ES2015ではモジュール機能が標準化されている。jsファイルがモジュールとなり、公開するものにはexportをつける。使う側はimport <識別子> from <モジュール名>で読み込む。

jQuery

多くの便利な静的メソッドを提供してくれるユーティリティ系ライブラリ。JavaScriptを知らなくても「jQuery」という単語を聞いたことがある人は多いはず。

代表的な機能としてはAjax用の非同期通信機能やDOM操作機能などがある。2000年代に隆盛を極めたが前者の機能はfetchやaxiosに、後者の機能はVue.jsやReactなどの後発ライブラリに押されている。

jQueryはコード内では「jquery」とも「$」とも表記することができる。頻繁に使用するため省略表記である「$」の方が一般的。なおJavaScriptの識別子名に使える記号は「$」と「_」だけ。

lodash

多くの便利な静的メソッドを提供してくれるユーティリティ系ライブラリ。著名なライブラリである Underscoreのクローンだがlodashも同じくらい人気がある。

配列や連想配列(オブジェクト)の操作に関わる機能が有名。ES2015で提供されているmap、reduce、filter、forEachなどがES5ではなかったため、配列やオブジェクトを操作する上で使用頻度の高いライブラリだった。

lodashはコード内では「_」と表記することが一般的。なおunderscoreもコード内では「_」と表記する。

bluebird

非同期処理におけるPromise機能を提供するライブラリ。Promise機能の老舗でかつては高速、高機能が売りだった。速度面の優位性は失われたが今でもファンが多い。

コードの中では「Promise」と表記するのが一般的。 一見するとES2015標準のPromise機能とそっくりだが、記法が少し異なるので注意。

ES2015でPromise機能が標準化されている。


2. 実行パス

RequireJSを利用した場合にHTMLの<script>タグから個々のモジュールへどのように制御が移っていくのかコードで追いかけます。

加えてモジュールをどのように定義するかも確認します。

HTML

HTMLには<script>タグが一行だけある。

<script data-main="index" src="//cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.js"></script>

data-main属性で指定されているindex.jsがまずは読み込まれる。それ以外のスクリプトは適切な順序とタイミングでRequireJSがHTMLに<script>タグで埋め込む。


index.js

index.jsには呼び出すモジュールが定義されている。

requirejs.config({
  paths: {
    jquery: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery',
    lodash: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash',
  }
});

RequireJSのオプションを指定している。pathsオプションでは読み込むモジュールのバージョンまで指定することができる。

require(['main']);

具体的な処理が記述されているmain.jsを呼び出している。


main.js

main.jsには具体的な処理が記載されている。

define(['jquery', 'lodash'], function($, _) {
  'use strict';
   処理
});

処理内容はdefineのコールバックとして記述する。コールバックの中ではjQueryを$、lodashを_で参照できる。

Strictモードをモジュール内で有効にしている。Strictモードはスクリプト全体にかけると非Strictモードのコードが混在するスクリプトと連結した場合にエラーが発生する可能性がある。上記サンプルのように範囲を限定してかけることが望ましい。


クラス

main.jsで利用するクラスを別モジュールで定義する例を示す。

define(['jquery', 'lodash'], function($, _) {
  'use strict';
  var Foo;
  Foo = function(params) {
    ...
  };
  return Foo;
});

defineのコールバックの中でコンストラクタを定義する。最後にコンストラクタのFunctionオブジェクトを返す。

main.js側では以下のようにする。

define(['jquery', 'lodash', 'Foo'], function($, _, Foo) {
  'use strict';
  var params = { ... };
  foo = new Foo(params);
});

defineの第1引数で呼び出すモジュールを指定する。その後は通常通り。


3. クラス関連

ここからはRequireJSから離れて、ES5でどのようにコーディングするか見ていきます。

まずはクラスの定義です。

基本

ES5における基本的なクラスの定義は下記の通り。

var Foo;
Foo = function(bar) {
  ...
};

ES5ではクラスを関数と同じFunctionオブジェクトで表現する。正確には「クラス」ではなく「コンストラクタ」の定義である。

関数とクラスの定義方法が同じなので一見するとどちらかわかりずらい。見分ける一番簡単な方法は変数名の表記を見ることだ。Pascal表記ならクラス、Camel表記なら関数である。

ES2015の下記と同義。

class Foo {
  constructor(bar) {
    ...
  }
  ...
}

応用

少し実践的なクラスの定義方法を紹介する。

Foo = function(params) {
  $.extend(this, {
     ... // クラスのデフォルト定義
  }, params || {} );
  _.bindAll(this, [メソッド名]);
);
$.extend()

$はjQueryを表す。よって$.extendはjQuery.extendと同じ。

$.extendはオブジェクトを連結するjQueryの関数。標準のObject.assignとほぼ同じだがjQuery.extendは再帰的な連結ができる。jQuery.extendの第1引数でtrueを指定すると再帰的連結できるが上記の例では未指定(=false)なのでObject.assignと同じ結果が得られる。

クラスのデフォルト定義に引数paramsで指定された定義があれば連結する。引数次第で同じクラスのインスタンスでも全く異なるプロパティを持つことになる。ちょっと異なるよく似た小粒クラスを継承を使わずに定義する、ES5におけるクラス定義の定形の1つ。

_.bindAll()

_はlodashを表す。_.bindAllはlodash.bindAllと同じ。

_.bindAllは指定されたメソッドをthisオブジェクトに束縛している。つまり指定されたメソッド内のthisがFooオブジェクトを表すことを「どんな状況でも」保証する。

JavaScriptのthisは状況によって異なる箇所を示す可能性があるのでこのように防御策を講じている。(JavaScriptではメソッドが他のオブジェクトに格納されて実行される可能性がある)これもES5におけるクラス定義の定形の1つ。


インスタンスメソッド定義

クラスにインスタンスメソッドを追加する方法を以下の通り。

Foo.prototype = {
    doA: function(params) { ... },
    doB: function(params) { ... }
};

JavaScriptではインスタンスメソッドはクラスのprototypeに登録するのが一般的だ。コンストラクタの中で定義することもできるがprototypeを利用した方がリソースが節約できる。

ES2015ではclass Fooの中でメソッドを定義できる。

class Foo {
  doA (params) {
    ...
  }
  doB (params) {
    ...
  }
}

またオブジェクトリテラルで定義する場合も下記の表記が許容されるようになっている。

{
    doA(params) { ... },
    doB(params) { ... }
};

インスタンスメソッドの定義、、、ではない

  funcs = {};
  funcs.foo = function() { ... };
  funcs.bar = function() { ... };

一見してクラスのインスタンスメソッド定義と勘違いしそうだが違う。funcsはクラスではなくオブジェクト=連想配列だ。連想配列の要素に無名関数を登録している。紛らわしいが騙されないようにしたい。

下記と同義。

  funcs = {
    foo : function() { ... };
    bar : function() { ... };
  };

4. 関数関連

次は関数の定義について見てみる。

関数定義

ES5における関数の定義方法を以下に示す。

  doSomething = function() {
    ...
  };

関数リテラルで作成した匿名関数を変数doSomethingに代入している。JavaScriptではよくある関数の定義方法だ。

この時点では実行されない。doSomething()と括弧付きで呼び出したタイミングで実行される。

匿名関数は呼び出しの前に宣言する必要がある。一方、function命令で定義した関数は実行前に評価されるため呼び出しの後に宣言しても問題ない。

ES2015では下記のようにアロー関数を使って記述することができる。

  let doSomething = () => {
    ...
  };

変数宣言

  var foo,
        bar,
        obj = {
           x: 1
        };

ES5以前のJavaScriptでは変数を関数の上の方にまとめて宣言する。宣言する場所と利用する場所が離れているので読みにくく感じるが「hoisting」という問題を防ぐための慣習。異なる型などもまとめて宣言できる。

ES2015以降ではブロックスコープが導入された。letとconstを利用して使用する場所の近くで宣言できる。


5. 非同期通信

非同期通信について見ていきます。

単発の非同期通信

jQueryで非同期通信を行う方法を以下に示す。

$.ajax().done( 成功したときの処理 ).fail( 失敗したときの処理).alwasy( いつも実施する処理 );

複数の非同期通信を連続するときのcallback地獄対策

複数の非同期通信を連続させようとすると$.ajax().done( $.ajax.done( $.ajax.done($.ajax.done( ... ))))のような多段構造になり可読性、保守性が低下する。このようなcallbackがマトリョーシカ化した状態をcallback地獄と呼ぶ。

jQuery.Deferredを使ったcallback地獄対策を以下に示す。

asyncFuncA = function() {
  var deferred = $.Deferred();  // DefferedとPromiseをセットで作る
  非同期処理
  deffered.resolve();  // Defferedをpendingからresolve状態へ遷移させる
  return deferred.promise();  // Promiseを返す=asyncFunc().then( 次の処理 )を実行できる。
};

このようにjQuery.Deferredを利用した関数は次のような記述ができる。このようにthenで連結された記述をPromiseチェーンと呼び、callback地獄より好まれる。

asyncFuncA()
  .then(asyncFuncB)
  .then( asyncFuncC)
  .then( asyncFuncD)
  .then( asyncFuncE);

下記のように関数を配列に格納してPromise.eachでIterateすることもできる。

Promise.resolve([
  asyncFuncA,
  asyncFuncB,
  asyncFuncC,
  asyncFuncD,
  asyncFuncE
]).each(function(fn){
   return fn().then(function() {
     log('--------------------------------------------------');
   });
});

ここで使っているPromiseはbluebirdライブラリのもの。Promise.eachは配列内の要素がPromiseなら非同期処理が完了してから次の要素へ進む。

このようなjQuery.Deferredとbluebirdを組み合わせた記述は非同期処理の定形パターンの1つ。

ES2015では標準化されたPromise機能でPromiseチェーンを記述できる。


複数の非同期通信を並列に実行する

先程は非同期処理A、B、C、Dを順番に(可読性の高い記述で)実行する方法を紹介した。

非同期処理A、B、Cを同時に並列実行し、全てが完了したらDを実行するにはどうすればよいだろう。

deferreds = [ Promiseを返す非同期処理の配列 ];
$.when.apply($, deferreds).done( ... ).fail( ... ).always( ... );

$.whenは複数の非同期処理を並列に実行し、全て終わるのを待ってから次の処理へ進む。通常、複数の非同期処理を動的に作成して配列にするのでapplyと組み合わせて上記のように$.when.applyとすることが多い。

ES2015のPromise.allに相当する。


_.defer(this.poll);

コールスタックが空になるまで、関数の呼び出しを遅延させる。setTimeoutを0秒指定した場合と同等。複雑な処理、時間がかかる処理を実行するときに使う。


6. コレクション関連

最後に配列やコレクション関連の処理を見ていく。

削除

_.remove(array, function(value, index, collection) { return value === 'john' } );

配列の中から第2引数の条件にマッチする要素を削除し、削除後の配列を返す。

第2引数の表記法は省略記法も含めて下記の通り色々ある。

_.remove(array, {'name': 'john', 'age': 30});
_.remove(array, [ 'age',  30] );
_.remove(array, 30);

検索

_.find(array, {'name: 'john'})

Collectionの中から第2引数の条件にマッチする要素があるか探し、マッチした要素を返す。第2引数は表記法は省略記法も含めて色々ある。

ES2015のArray.findと同じ。


繰り返し

_.each(array, function(value, index, array) { ... }

ES2015のArray.forEachと同じ。

nginxコンテナを自己署名認証局でHTTPS化する

前回の記事では自己署名証明書を使ってリバースプロキシとして動作するnginxコンテナをHTTPS化しました。

architecting.hateblo.jp

しかしブラウザがエラーや警告を色々出すのでHTTPS化した実感がありませんでした。

今回は自己署名認証局が署名した証明書を使ってHTTPS化してエラーや警告をなくしたいと思います。


自己署名証明書との違い

認証局(CA)を自分で立ててその認証局サーバ証明書に署名してもらいます。

オレオレCAですが一応、CAが署名しているのでオレオレ証明書ではなくなります。

これでnginxにアクセスしたときのエラーや警告がなくなるはずです。


CA証明書とサーバ証明書作成

構築していきましょう。

CA証明書とサーバ証明書はopensslコマンド連打で作成することができます。以下のページが参考になります。

https://qiita.com/makoto1899/items/ef15372d4cf4621a674e

もっと簡単かつ環境を汚さない方法はないかな、、、Dockerみたいな、、、と探したらopensslコマンドを連打してCA証明書とサーバ証明書を作成するDockerイメージがありました。

https://hub.docker.com/r/paulczar/omgwtfssl

怪しい名前ですがPull回数は100万回を超えています。GitHubにソースが公開されています。

https://github.com/paulczar/omgwtfssl

今回はこれを使うことにします。

omgwtfsslを使い、Dockerコマンド一発でCA証明書とサーバ証明書作成を作成します。

docker run --rm -e SSL_SUBJECT="localhost" -e SSL_DNS="localhost" -v /Users/john/certs:/certs  paulczar/omgwtfssl

-e SSL_SUBJECTはSubjectのCommon Name(CN)を指定します。

-e SSL_DNSはSubject Alternative Name(SAN)を指定します。Chromeの場合、これがないと以下のようにERR_CERT_COMMON_NAME_INVALIDエラーを出します。

f:id:hogehoge666:20210116025826p:plain

ChromeはSubjectのCNではなくSANをチェックします。CNはあってSANがないのにERR_CERT_COMMON_NAME_INVALIDエラーってエラー名がおかしくないか?と思うのですがSANを忘れるとこのエラーが出ます。fetch APIはCNを見てるっぽいし、ややこしいですね。

-vで指定した/Users/john/certsに色々作成されます。

$ ls /Users/john/certs
ca-key.pem  ca.pem      ca.srl      cert.pem    key.csr     key.pem     openssl.cnf secret.yaml

この先の作業で使うのは以下の3つです。


証明書をチェック

サーバ証明書の内容をチェックします。署名がtest-caになっています。自己署名証明書ではないことがわかります。

60日でExpireします。-e SSL_EXPIREオプションで変更できるそうです。

SANもlocalhostになっています。DNSが2つある理由はわかりません。

# openssl x509 -text -noout -in cert.pem 
        Issuer: CN = test-ca
        Validity
            Not Before: Jan 15 15:47:42 2021 GMT
            Not After : Mar 16 15:47:42 2021 GMT
        Subject: CN = localhost
<略>
            X509v3 Subject Alternative Name: 
                DNS:localhost, DNS:localhost

nginx設定変更

前回、下記Dockerコマンドで作成したnginxコンテナを引き続き使用します。nginxコンテナがまだない場合は下記Dockerコマンドで作成して下さい。

docker run -it --name nginx -p 443:443 nginx /bin/bash

作成されたサーバ証明書とサーバ秘密鍵をnginxコンテナにコピーします。

docker cp /Users/john/certs/cert.pem <コンテナID>:/etc/nginx/conf.d/ssl/
docker cp /Users/john/certs/key.pem <コンテナID>:/etc/nginx/conf.d/ssl/

/etc/nginx/conf.d/default.confを下記の通り変更します。

vimが入っていない場合はapt-getでインストールして下さい。

前回からの変更はサーバ証明書秘密鍵を新しく作成したものに変えただけです。

server {
    listen 443 ssl;
    server_name localhost;

    ssl_certificate /etc/nginx/conf.d/ssl/cert.pem;   <<< 変更1
    ssl_certificate_key /etc/nginx/conf.d/ssl/key.pem;   <<< 変更2

    access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location /api {
        proxy_pass   http://todoapi-app:5000;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

nginxを再起動します。今回はpass phraseは要求されません。


CA証明書のインポート

ブラウザがnginxのサーバ証明書を検証するにはCA証明書(ca.pem)をブラウザにインポートする必要があります。

インポートの方法はOSやブラウザによって異なります。

Firefoxだと「オプション」 / 「プライバシーとセキュリティー」→「証明書」→「証明書を表示」からインポートするそうです。

WindowsChromeは「インターネットオプション」→「コンテンツ」→「証明書」→「信頼されたルート機関タブ」からインポートするそうです。

ここではMacChromeを使います。MacChromeはキーチェーンアクセスからインポートします。

  • Macのキーチェーンアクセスを起動します。
  • 画面左側「キーチェーン」で「ログイン」が選択されていることを確認します。
  • 画面右側の証明書や鍵の一覧が表示されているところに生成したca.pemをドラッグ&ドロップします。
  • 下記のようにtest-caという名前で登録されるので一覧からtest-caを見つけてダブルクリックします。 f:id:hogehoge666:20210116031015p:plain
  • 「信頼」→「この証明書を使用するとき」で「常に信頼」を選びます。 f:id:hogehoge666:20210116031027p:plain
  • 閉じます。

動作確認

ブラウザでhttps://localhostへアクセスします。

エラーはありません。

アドレスバーはこの接続が安全であることを示しています。

f:id:hogehoge666:20210116032928p:plain

安全なHTTPS接続を確立することができました。


結論

ようやく安全なHTTPセッションができました。

しかもomgwtfsslを使ったお陰で証明書はコマンド1発で出来てしまいました。すごい便利です。

あえてpaulczar/omgwtfsslの難点を上げるとすれば、このコンテナが作ったCA証明書をキーチェーンアクセスに登録して本当に安全なのかという点です。

安心が欲しい人は自分でopenssl連打で証明書を作成した方が安心かもしれません。

omgwtfsslを利用する場合でも利用前にGitHubでソースを確認して納得した上で使った方がよいでしょう。