asterisc

技術ネタ中心にその他雑多なこと

フロントエンド向けvalidator: favalidの紹介

はじめに

少し前にフロントエンドのjs向けvalidatorフレームワークfavalid というものを作ったので、その紹介記事を書きます。

favalidの特徴

フロントエンドというかjs向けvalidatorといえば joiyupv8nといったようなライブラリがありますが、それらに比べると機能を大幅に削り、とにかく軽量なのと関数型ライクなAPIがfavalidの特徴です。デフォルトのバリデーションメッセージすら入ってないので自分で定義しないとダメです。 ユースケースやコンフィグによりますが、最小構成でwebpackでbundleすると、1kbくらい。機能を色々盛り込んでも~10kbくらいになるかと思います。gzipを効かせれば容量は更に削減されるかと思います。

使い方

ここからは favalid の使い方を説明します。

インストール

普通に npm ないしは yarn で入ります。

$ npm install favalid

基本機能の紹介

READMEにのせている使い方をベースに基本機能を説明します。 *以下に載せている正規表現等はあくまでサンプルです。実際に使用する場合は注意してください。

tester

favalid のコアは tester という関数です。 tester は以下の関数を引数にとる高階関数です。

  • 第1引数にvalidation対象の値を受け取り、validation errorが発生していないかどうかのbool値を返す関数
  • 第2引数にvalidation対象の値を受け取り、validation error messageの文字列を返す関数 (= この関数を messager と呼んでいます)

tester の返り値はvalidation対象の値を受け取り、 {error: boolean; message: string} のオブジェクトを返す関数です(= この関数をfavalidの文脈で validator と呼びます)。 第1引数で渡された関数を元に値を検証し、 error=true であれば第2引数の関数を実行してerror messageを取得します。

以下にシンプルな例を載せます。ES2015以降の文法で記載します。

import { tester } from 'favalid';

const validator = tester((num) => {
    return num < 10
}, (num) => {
    return `${num}は10より小さい数字である必要があります。`
});

console.log(validator(10)); // <=  {error: true, message: '10は10より小さい数字である必要があります。'}
console.log(validator(9)); // <= {error: false, message: ''}

combine

tester だけであればただの使いづらいAPIですが、 favalidcombine という関数を提供しています。 combine は複数の tester の返り値のvalidatorをまとめてvalidatorを返す関数です。

以下に例を示します。

import { tester, combine } from 'favalid';

const validator1 = tester((num) => {
    return num < 10
}, (num) => {
    return `${num}は10より小さい数字である必要があります。`
});

const validator2 = tester((num) => {
    return num > 2
}, (num) => {
    return `${num}は2より大きい数字である必要があります。`
});

const combined = combine(validator1, validator2);

console.log(combined(10)) // <= {error: true, message: '10は10より小さい数字である必要があります。'}
console.log(combined(1)) // <= {error: true, message: '1は2より大きい数字である必要があります。'}

combine された validatorvalidator なので、さらに combine することができます。

// ... 前の続き 

const validator3 = tester((num) => {
    return num % 2 === 0;
}, (num) => {
    return `${num}は2で割り切れる必要があります。`
});

const combined2 = combine(combined, validator3);

console.log(combined2(10)) // <= {error: true, message: '10は10より小さい数字である必要があります。'}
console.log(combined2(1)) // <= {error: true, message: '1は2より大きい数字である必要があります。'}
console.log(combined2(5)) // <= {error: true, message: '5は2で割り切れる必要があります。'}

このように、 favalid では tester を用いて細かい粒度でvalidatorを実装し、 combine を用いてユースケースに応じたvalidatorを構成していくことが基本となります。

pre-defined validators

すべて自分で tester を使って実装するのは大変なので、よく使うルールについてはフレームワーク側で提供しています。これらを pre-defined validators と呼びます。 これらの validator は自分で作った validator と組み合わせることも可能です。 これらを使って、 10文字以上100文字以内のemailアドレス のルールを持ったvalidatorを作ります。

import {
  combine,
  maxLength,
  minLength,
  regexp
} from "favalid";

const minLengthMessage = () => 'メールアドレスは10文字以上です。';
const maxLengthMessage = () => 'メールアドレスは100文字以内です。';
const regexpMessage = () => 'メールアドレスのフォーマットが不正です。';
const mailRegexp = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;

const emailValidator = combine(minLength(10, minLengthMessage), maxLength(100, maxLengthMessage), regexp(mailRegexp, regexpMessage, {}));

このようにいくつか実装がeasyになるようなAPIを提供しています。 冒頭にも解説しましたが、validation messageは favalid では提供していません。なので、全て自分で定義する必要があります。

regexp のpre-defined validatorの実装を見てみましょう。実装ファイルは これ です。

export default (
  regex: RegExp,
  messager: Messager,
  { exclude = false }: IRegExpOption
): Validator => {
  if (exclude) {
    return tester((target: string) => !regex.test(target), messager);
  } else {
    return tester((target: string) => regex.test(target), messager);
  }
};

optionとしてexclude オプションを取れるようになっており、正規表現にマッチしたのか、してないのかをルールで出し分けることができます。 内部実装としては tester を用いて実装していますが、これは pre-defined validator 全てに共通する特徴です。 tester を用いて実装しているので、共通のインターフェイスを保つことができ、全て combine によりvalidator同士を結合することができます。 pre-defined validatorここ に定義されています。必要なものを探していただければと思います。

応用的な使い方

上記の基本機能を踏まえた上で、以下に応用的な使い方を載せていきます。

Object Schema Validation

favalid の基本機能では値のバリデーションしかサポートしていませんが、objectのvalidationを可能にする shape という関数を提供しています。 objectを引数にとり、 key に結びついたvalidatorを用いて値のチェックを行えます。

import {
  combine,
  maxLength,
  minLength,
  regexp,
  shape // <= これ
} from "favalid";

/** ここからは上のemail validatorと同じ */
const emailMinLengthMessage = () => 'メールアドレスは10文字以上です。';
const emailMaxLengthMessage = () => 'メールアドレスは100文字以内です。';
const emailRegexpMessage = () => 'メールアドレスのフォーマットが不正です。';
const mailRegexp = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;

const emailValidator = combine(
  minLength(10, emailMinLengthMessage),
  maxLength(100, emailMaxLengthMessage),
  regexp(mailRegexp, emailRegexpMessage, {})
);

/** ここまで上と同じ **/

const passMinLengthMessage = () => 'パスワードは8文字以上です。';
const passMaxLengthMessage = () => 'パスワードは16文字以内です。';
const passRegexpMessage = () => 'パスワードは大文字小文字数字記号が含まれていなければいけません。';
const passRegexp = /^(?=.*?[a-z])(?=.*?\d)(?=.*?[!-\/:-@[-`{-~])[!-~]/i;

const passwordValidator = combine(
  minLength(8, passMinLengthMessage),
  maxLength(16, passMaxLengthMessage),
  regexp(passRegexp, passRegexpMessage, {})
);

const userValidator = shape({
  email: emailValidator,
  password: passwordValidator,
});

/** これが出力される
{ email: { error: false, message: '' },
  password: { error: true, message: 'パスワードは8文字以上です。' } }
**/
console.log(userValidator({
  email: 'hoge@hogehoge.com',
  password: '1234',
}));


/** これが出力される
{ email: { error: true, message: 'メールアドレスのフォーマットが不正です。' },
  password: { error: true, message: 'パスワードは大文字小文字数字記号が含まれていなければいけません。' } }
**/
console.log(userValidator({
  email: 'hogehogehoge.com',
  password: 'Hoge12345',
})); 

ErrorReducerを使う

favalid のdefaultの挙動は、 combine された validatorcombine された順に実行していき、error messageは最初にvalidation errorが発生したときのものを採用しています。

例えば、上記のemailValidatorの場合、 aaa という文字列を入れたとき、 minLength のルールと regexp のルール両方に違反することになるのですが、error messageの値として得られるのは最初に実行される minLength のルールのものになります。

console.log(emailValidator('aaa'));  // { error: true, message: 'メールアドレスは10文字以上です。' } が出力される

この挙動をユーザが任意に変更できます。combineする際に、errorをどう扱うかを定義できる reducer を渡すことができる combineWithReducer というAPIがあります。 例えば、messageをすべて配列に格納し、違反したメッセージをすべて取得するような reducer を定義します。

const reducer = (prevResult, currentResult) => {
  if (currentResult.error) {
     prevResult.error = true;
    prevResult.message.push(currentResult.message);
  }
  return prevResult;
};

const emailValidatorWithReducer = combineWithReducer(
  [
    minLength(10, emailMinLengthMessage),
    maxLength(100, emailMaxLengthMessage),
    regexp(mailRegexp, emailRegexpMessage, {}),
  ],
  reducer,
  {error: false, message: []}
);

/* 以下のように表示される
{ error: true,
  message:
   [ 'メールアドレスは10文字以上です。',
     'メールアドレスのフォーマットが不正です。'  ] 
 }
*/
console.log(emailValidatorWithReducer('aaa'));

combineWithReducer の第1引数にvalidatorの配列、第2引数に reducer 、第3引数にerrorの初期値を渡します。 reducer は第1引数に前回までのvalidationの結果が格納され、第2引数に現在のvalidationの結果が入ってきます。validationの結果を任意に加工して、returnすることができます。

値を複数とるvalidator

パスワードの確認フォームで、前に入力されたパスワードの値と同一かどうかをチェックするvalidatorなど、値を複数とるvalidatorが存在します。 favalid では testerの第1引数の関数および第2引数の関数は可変長の値を取ることができます。 tester および messager が引数を一つしかとらないように定義されている場合、第2引数以降のinputは無視されます。

以前作った passwordValidator を再利用します。

const confirmValidator = tester((passwordConfirm, password) => {
  return passwordConfirm === password
}, (passwordConfirm, password) => {
   // あくまでサンプルです、ユーザが入力したパスワードをそのままメッセージに使うのは絶対にやめましょう!
  return `${passwordConfirm}は${password}と一致しません。`
});

const passwordConfirmValidator = combine(passwordValidator, confirmValidator);

console.log(passwordConfirmValidator('123', 'Passw0rd!123')) // { error: true, message: 'パスワードは8文字以上です。' }
console.log(passwordConfirmValidator('password!', 'Passw0rd!123')) // { error: true, message: 'パスワードは大文字小文字数字記号が含まれていなければいけません。' }
console.log(passwordConfirmValidator('passw0rD!123', 'Passw0rd!123')) // { error: true, message: 'passw0rD!123はPassw0rd!123と一致しません。' }

非同期のvalidation

emailの重複チェックなど、一度サーバサイドに問い合わせてvalidationしたい場合があります。 favalid のデフォルトは同期の挙動ですが、 async 版の asyncTesterasyncCombine と同期のvalidatorを非同期に変換する toAsync というAPIを提供しています。

先程作ったemailのvalidatorを再利用して、サーバサイドにメールの重複を問い合わせる版のemailのvalidatorを作ってみます。

asyncTester の第一引数はPromiseを返す関数で、 resolve した際に true/falseの値を返すことを期待します。 asyncTester の返り値は非同期のvalidatorとなります。

import { asyncCombine, asyncTester, toAsync } from 'favalid';

const USERDB = {
  "example@hello.com": true
};

// API requestをstubする。
const apiRequest = async (email) => {
  if (USERDB[email]) {
    return false
  }
  return true
};

const asyncEmailValidator = asyncCombine(
  toAsync(emailValidator), // 以前作ったemailValidatorを非同期化
  asyncTester(apiRequest, (email) => `${email} はすでに存在しています`)
);

async function testing() {
  console.log(await asyncEmailValidator('hoge.com')); // { error: true, message: 'メールアドレスは10文字以上です。' }
  console.log(await asyncEmailValidator('email@hoge.com')); // { error: false, message: '' }
  console.log(await asyncEmailValidator('example@hello.com')); // { error: true, message: 'example@hello.com はすでに存在しています' }
}

testing();

今後の機能改善の予定

  • 型を付ける
    • せっかくtypescriptで書いているのに、genericsなどの機能が使いこなせておらず、残念な実装になっています。そこをもうちょいなんとかする予定です。
  • ramda.jsのリプレイス
    • 依存でramda.jsが入っていますが、ごくごく一部の機能しか使っていないので、除去する予定です。
  • ドキュメント
    • がんばります...
  • i18n
    • どういうAPIにするか考えている最中ですが、i18nにも対応したいと思います。

それ以外にも機能のリクエストなどありましたら教えていただけると幸いです。

まとめ

frontend javascript向けvalidator favalid の紹介記事を書きました。 githubのREADMEがあまりにも適当なので、使い方を補完させていただきました。応用的な使い方では様々な機能を紹介しましたが、不要なものは import しなければ tree-shaking が効くはずなので、自分が必要なものだけを import するように使っていただければと思います。 以上となります、読んでいただきありがとうございました!

CIとTest Sizesの話

はじめに

前回 akito0107.hatenablog.com

どちらかというとこっちが本編。

前回の記事ではTest Sizesについて紹介したが、今回の記事はその分類が実際の開発にどう役に立っているのかをまとめたいと思う。もちろん用語の統一も大きな意味を持つが、それ以外のことを書いていきたい。 具体的には、CIでテストのパイプラインを組む時にこの分類どおりに組んでいくと綺麗に整理でき、CI全体のスループット向上にも効果がでているという話だ。今回の話は僕たちのチームに特化した内容になるが、1) Test SizesごとにTestの起動コマンドを分ける、 2) Smallから順に実行していき、落ちるべきテストはできるだけ早期に落とす、というポイントはどこにでも使えるものだと思う。

コンテナ技術とテスト

僕たちはローカルの開発環境だけではなく、本番環境やCI環境でコンテナ技術(主にDocker)を積極的に導入している。

コンテナ技術のメリットの一つに環境の再現性がある。(理論的には)ローカル環境・CI環境・本番環境かかわらずどこでも同じ環境を簡単に再現することができる。CIで自動テストを回している人なら一度は遭遇したことがあると思うが、世の中にはローカルで回したら問題ないのに、CI環境に上げたら落ちるようなテストがある。そのような環境依存で落ちるテストを調査するのは非常に辛く面白くない作業だが、コンテナの上でテストが回っていれば、CI環境で使っているコンテナImageをそのままローカルにPullしてきて、落ちているときの環境を再現しながら調査することができる。コンテナ以前に比べると圧倒的にこの手の環境依存の調査のコストが大幅に削減されていると感じている。

また、CIで回すテストの難しさに、DBなどのTestで使うMiddlewareをどう用意するかといった問題がある。 そういったときは docker-compose を使えばテストに必要なmiddlewareを個別に立ち上げられることができ、アプリケーションのコードに特に変更を加えなくて良いのも大きなメリットである。

CircleCI2.0やWerckerなど、多くのモダンなCIはデフォルトでコンテナベースになっており、CLIと組み合わせて、手元でも容易に環境が再現できるようになっている。

CIのテストパイプライン

実装

上記のようなメリットを享受するため、僕たちはCI環境のテストのパイプラインを以下のように設計していた。ちなみにCIはコンテナベースでのパイプラインを容易に組める ConcourseCI を使っている。 f:id:akito0107:20180825172431p:plain

GithubからのPull Request(PR)が来たらCIがキックされ、sourceをpullしてきて、imageをbuildし、コンテナレジストリであるECRにpush。そしてそのimageとテストに必要なmiddlewareを docker-compose で立ち上げてテストを実行するという流れである。テストに使うimageを一度ECRにpushするので、もしテストが落ちた時には手元にテストに使った一式のイメージをpullしてきて、環境を再現することができる。

スループットに関する問題

基本的にはこの構成で問題はなかったが、PRのたびにDockerの build やimageの push が走り、IO負荷などが原因で、テストを一通り実行するまでに30分以上かかることがあった。PRのたびに回るテストは10分以内に回りきるのが望ましいとされており、この状態が開発チームのストレスになってしまっていた。もちろんDockerfileの書き方の問題や、テストの並列実行数、CIのインスタンスのパワーの問題などもあるが、ECRのレスポンスが極端に悪化するケースがあるなど、既存の構成のままでの対策に限界があった。そこでボトルネックとなっているimageの build / push を極力削ることでスループットの改善を試みた。

Test Sizesに従ったテストパイプライン

CIで回すテストが失敗する要因は様々だが、環境要因で落ちることは実は稀で、大半は lint や、簡単なテストケースの修正漏れであり、この中にはいちいち docker-compose でmiddlewareを立ち上げなくても検知できるものが多い。そのため、 docker-compose するまでもなく落ちることがわかっているようなimageの余分なbuildなどを省ければ、全体のCIのスループットが上げられると考えた。 具体的には、 lintSmall のテストは外界に依存が存在しなく、 docker-compose で依存middlewareを立ち上げなくても実行可能なので、docker buildする際に実行してしまい、そのbuildが通ったもののみ docker-compose を立ち上げて Medium 以降のテストを実行するというパイプラインにすれば良さそうである。イメージとしては以下の通りである。

f:id:akito0107:20180825182409p:plain

実装としては

  1. Testを Small / Medium / Large 別々に起動できるようにする
  2. Dockerifleのbuildのstep中に Small のテストを起動できるようにする

というシンプルなステップで可能になる。Small , Medium のテストを別々に起動するためには、testing frameworkにより様々な実現方法はあるだろうが、例えばgoだったらテストを

// Medium
func TestXXX_Medium(t *testing.T) {
.....
}

// Small
func TestXXX(t *testing.T) {
.....
}

のように宣言して、名前で実行しわける方法がある。jsで jest などを使っていれば、directoryを分ける方法もあると思う。 このように、落ちることがわかっているテストのパイプラインを早期に落としてしまうことで、ボトルネックを解消でき、CIのスループットを改善することができた。

コンテナとTest Sizesのまとめ

遠回りになってしまったが、ここで伝えたいのは、Test Sizesごとに別々にテストを起動できるようにしておくと、CIのテストのパイプラインを設計する際に便利だし、CIのスループットを上げる際においても有利に働くということである。 Small のテストはdocker buildと同じタイミングで、 Medium のテストは docker-compose でというようにきれいに整理できるため、今後はこの形でCIを整備していきたい。 Large のテストはテストの記述自体がそもそも難しいが、同様にコマンドを分けて実行できるようにしておくことで容易にパイプラインが組めるようになると思う。 ちなみに、その思想で作られた@orisanoの gobase というGoのプロジェクトテンプレートもある。 DockerfileMakefile の書き方の参考になると思う。

今回のポイントとしては、

  1. Test Sizesごとにテストの起動コマンドをわける
  2. テストを実行する際に、準備と実行のコストが低いSmallから順に実行していき、失敗したら即座にパイプラインを中止する

という2点にある。

まとめ

単体テスト結合テストという呼び方はやめて、 Test Sizesによる分類を進め、その分類どおりにCIのテストパイプラインを組んだ。 Small / Medium / Largeのそれぞれテストを別々のコマンドで分けて起動できるようにしておき、 Smalldocker build の際に、 Mediumdocker-compose で、 Large 以降はテスト環境に実際にデプロイしてから、とテストの実行のフェイズを分けておくと、余分なImageのbuildを削減できるようになった。

結合テストと呼ぶのをやめた話

はじめに

最近、意図的に「単体テスト」「結合テスト」という呼び方を避け、Google Testing Blogで紹介されてるTest Sizesによる分類(small / medium / large)に従った呼び方でテストを呼んでいる。 この分類方が自分の身の回りに徐々に浸透してきて、実際のチーム内のテスト戦略も一歩進んだ議論ができるようになってきたので、改めてまとめる。 ちなみにこの記事の話は手動で行われるテストではなく、自動テストを対象としているが本質はあまり変わらないと思う。

続き書きました。 akito0107.hatenablog.com

単体テスト」「結合テスト」という呼び方について

ソフトウェア開発に従事していれば必ず聞く言葉だと思う。改めて他のサイトから引用する形で定義をまとめておく。

単体テストとは *1

単体テストとは、プログラムを検証する作業の中でも、プログラムを手続きや関数といった個々の機能ごとに分割し、そのそれぞれについて動作検証を行う手法のことである。単体テストでは、個々の機能を果たすためのプログラム部品(プログラムモジュール)がそれぞれしっかりと動作しているかを検証する。モジュールのインターフェースや処理手順が正しく動作するか、仕様書の通りに動作しているか、が単体テストによって確認される。単体テストで不備が発見された際には、コーディングの段階に戻ってプログラムの書き直しが行われる。そして再度単体テストが行われ、問題ないことが確認されたら、それらのモジュールどうしを組み合わせた場合にもうまく機能するかどうかを検証する「結合テスト」の段階に引き渡される。

結合テストとは *2

結合テストとは、システム開発におけるプログラムの検証作業の中でも、手続きや関数といった個々の機能を結合させて、うまく連携・動作しているかを確認するテストのことである。 結合テストでは、個々の機能を果たすためのプログラム部品(プログラムモジュール)を組み合わせて、データの受け渡しがうまく行われているか、コードの記述様式は揃っているか、データを授受するタイミングはずれていないか、といった点が確認される。結合テストで不備が発見された際には、再度コーディングが行われる。

なぜこの呼び方が問題なのか

出典により様々な言い方の違いはあるが、非常に雑に理解すると、「単体テスト」とは個々のモジュールに対してテストを行い、「結合テスト」とは個々のモジュールを組み合わせてテストを行うことである。 問題は、「個々のモジュール」や「モジュールを組み合わせて」といった解釈が人・チーム・組織によりバラバラで、テストのことを議論する際、いまいち何を指しているのかわからないということである。 例えば、自分の身の回りではJUnitで回すテストのことを単体テストと呼ぶ人もいたし、スマートフォンアプリ開発ではアプリの1画面が仕様どおりに正しく動作するかどうかを検証する作業のことを単体テストと呼ぶ人もいた。結合テストに関してはさらに解釈の幅が様々で、外部システムとの連携の動作チェックを結合テストと呼ぶ人もいれば、APIのレスポンスが正しく仕様どおりに返ってきているかをテストするJUnitのコードなどを指す人もいる。これらの解釈が間違っているということではないが、 明らかに定義がぶれているし矛盾しているものも多くある。

おそらくプロジェクト管理としてのテスト工程の話が中途半端に混ざった結果、よくわからないことになっているような気がするが、いずれにせよ、上記の定義は主観的だし、最終的には宗教に近いものになってしまっているのが現状だと思う。 ちなみに、「単体テスト」、「結合テスト」だけに言及しているが、定義が定まりきらない全てのソフトウェアテストの用語に当てはまり、例えば「E2Eテスト」という呼び方にも同様の問題があるとおもう。

Test Sizesによる分類

Google Testing Blog *3の記事にソフトウェアテストの分類について紹介されていた。 Googleにおいてどのようにtestが分類されているかの話で(2010年の記事なのでもしかしたら現在は異なっているかもしれないが)、主観的な観点を廃し、計測可能な指標で分類している。

Test Sizes

分類の指標は以下の通り。

Feature Small Medium Large
Network access No localhost only Yes
Database No Yes Yes
File system access No Yes Yes
Use external systems No Discouraged Yes
Multiple threads No Yes Yes
Sleep statements No Yes Yes
System properties No Yes Yes
Time limit (seconds) 60 300 900+

表を見れば一目瞭然だが、 Small はI/Oなどは全てモックやStub化し、 Mediumlocalhostへのアクセス、 Large は本番に近い環境を用意して行うものというイメージである。 例えばRailsだと、DBにアクセスするテストが基本は多くなると思う。そういうテストは Medium に分類される。逆に、DBアクセスはmock/stub化しているようなテストは Small に分類される。また、 puppeteer などで実際に画面操作を行うようなテストは Large に分類される。Testとは呼べないかもしれないが、 Lint もこの分類に合わせると Small に分類されると思う。

twadaさんの有名なプレゼン 組織にテストを書く文化を根付かせる戦略と戦術 *4 にもこのことは紹介されている(57p-58p)。この中では「自分たちでSMLを定義し、調整する」と述べられているが、僕たちのチームではもとのブログそのままの定義で使うようにしている。次回の記事でこの実例については紹介したい。

まとめ

  • 単体テスト」「結合テスト」という呼び名をやめよう。
  • Test Sizesによる分類を導入した。
  • 今度こそこまめに記事書いていくぞ。

続きはこちら akito0107.hatenablog.com

引用元

Node.jsでもassertしたい

まえがき

この記事はリクルートエンジニアアドベントカレンダー12日目の記事です。

Node.jsでのAssertion

Assertionとは?

多くのプログラミング言語assert というメカニズムが実装されています。 assert はプログラムを記述する人が、絶対にコードのこの場所ではこの値が入ってくる、という意志を表明するための仕組みです。 例えば、以下の add(a, b) のコードを見てみると、

var assert = require('assert');

function add(a, b) {
  assert(!isNaN(a));
  assert(!isNan(b));
  return a + b;
}

この add(a, b) では、2つの引数、 a b に数値以外の値が入ってきた場合、特に何か特別な処置をしない限り Assertion Error を発生させ、プログラムを終了させます。試しにやってみると

add('string', 2);
assert.js:85
  throw new assert.AssertionError({
  ^
AssertionError: false == true
    at add (/Users/akito/workspace/tmp/assert-test/index.js:5:3)
    at Object.<anonymous> (/Users/akito/workspace/tmp/assert-test/index.js:10:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9) 

このように add に非数の値を与えると、想定通りエラーが発生することがわかります。 この仕組みにより、想定以外の値が入ってきたときに適切にプログラムを終了させることができ、主に開発フェイズでバグの早期発見に役立てることができます。 詳しくは 契約による設計 などで検索してみてください。

Javascript / Node.jsのAssertion

この assert というメカニズムは多くの言語に実装されていますが、実はJavascriptには等価の機能はありません。console.assert() という関数はありますが、これは第1引数の値が false だった場合にconsoleにエラーメッセージを出力するもので、assert 本来の目的を達成するには少し弱いかなと感じています。

一方、Node.jsではcoreのモジュールとして assert が提供されています(上のサンプルコードではそれを利用しています)。この assert は上で見たとおり、表明した値以外の値が入ってきた場合にエラーを発生させ、プログラムを終了させることができます。

じゃあこの assert をプロダクションにガンガン記述し、他言語同様に堅牢なコーディングができるか、というと実はまだ少し足りない点があります。 assert が実装されている多くの言語では、プロダクションビルドもしくは実行時には assert はOFFにするのが一般的です。例えば、以下のJavaの例を見てみると、

public class Main {
    public static void main(String args[]) {
        String name = null;
        assert name != null; // ここでAssertionErrorが起きてほしい
        System.out.println(name);
    }
}
$ javac Main.java
$ java Main

> null

name の中身が null なので、本来は AssertionError が発生してほしいのですが、実際に実行してみるとなにも起きません。実はJavaは実行時にはデフォルトでassertはOFFになるように設計されています。Javaの場合、assertをONにするためには、実行時に -ea オプションを設定します。

$ java -ea Main

Exception in thread "main" java.lang.AssertionError
    at Main.main(Main.java:5)

今度は想定通り AssertionError が発生しました。

いくら堅牢なコードを書くための仕組みといっても、本番で動いているシステムがいきなり AssertionError で死んだら困りますよね。このように、Javaを始め多くの言語ではbuild、もしくは実行時にassertのON / OFFを切り替える機能がついていて、開発時、本番時で挙動を切り替えられるようになっています。

On The FlyでUnassertする

では一方Node.jsではどうかというと、悲しいことにこの機能が実装されていません。ですので、 assert に引っかかったら本番だろうがなんだろうが死んでしまいます。 ようやくこの記事の本題になりますが、今回は assert をNode.jsのプロダクションでも活用するために、動的に assert の ON / OFFを切り替える仕組みを実装していきたいと思います。

unassert

0から作るのはなかなか大変な作業ですが、幸い、id:t-wada さんが作成した、jsのコードベースから assert を除去するnpmモジュール unassert がありますので、こちらをNode.js向けに活用してなんとかしようと思います。

unassertBabelwebpack などのpluginとして動作します。最近のフロントエンド界隈ですとプレーンなjsを記述することは少なくなってきており、babelwebpackを用いてES2016 - 2017もしくはjsxなどをブラウザで動作するjsへとトランスパイルすることが多くなっています。unassert はこのトランスパイル時にコードから assert を取り除くのが主な使い方となってきます。

一方で、サーバサイドJavascriptのNode.jsでは、babelなどを使うケースは、フロントエンドほどは多くはないのではと思っています。1つに、Node.js(とv8)は言語系のキャッチアップの速度が非常に早く、標準になったシンタックスや機能はいち早く取り込んでいます。このサイトを見ても分かる通り、仕様が固まったものに関してはほぼほぼ実装されており、特別なケースが無い限り、 babel を使うメリットはそこまでないのかなと思います。そのため、 Node.jsには unassert がassertを取り除いているトランスパイルのフェイズが存在しないことになります。

requireをhookして実装する

じゃあどうするかというと、Node.jsがモジュールをロードする仕組みであるrequire をhookして、そのタイミングで unassert してしまおうと思います。 Node.jsでは実行時に--requireオプションを指定することができ、そこでモジュールを unassert した上でロードするようにrequireの挙動を書き換えてあげればうまくいきそうです。 コードはこんな感じになります。

var extensions = require.extensions;
var fs = require('fs');
var unassert = require('unassert');
var esprima = require('esprima');
var escodegen = require('escodegen');

function unassertLoader() {
  extensions['.js']= function(localModule, filePath) {
    var originalSource = fs.readFileSync(filePath, 'utf-8')
    var ast = esprima.parse(originalSource);
    var modifiedAst = unassert(ast);
    localModule._compile(escodegen.generate(modifiedAst), filePath);
  };
}

unassertLoader()

実際の複雑なところはすべて unassert がやってくれているのですごくシンプルですね。 流れとしては、requireモジュールの中のjsの拡張子がついてきているファイル用のloaderを書き換え、一旦esprimaでASTになおしてから、unassertし、再度escodegenでASTからコードに戻した上で、module loaderに渡してあげているという形です。

このコードを unassertLoader.js という名前で保存しておきます。

挙動チェックのためにこんなコードを用意しました。

// test.js
var assert = require('assert');
assert(false); // ここで必ず落ちる。
console.log('Hello');

動かしてみます。

$ node test.js
assert.js:85
  throw new assert.AssertionError({
  ^
AssertionError: false == true
    at Object.<anonymous> (/Users/akito/workspace/unassert-loader/benchmark/test.js:3:1)
....

当然ですが落ちますね。 では --require オプションを付けて先程書いた unassertLoader.jsをpreloadしてみたいと思います。

$ node --require ./unassertLoader.js test.js
Hello

今度はassertが削除されて、無事にconsole.log('Hello') が動作していることがわかります。 これで目的どおり、実行時の指定でNode.jsでも動的に、つまり、On The FlyでassertをON/OFFできるようになりました!

簡易ベンチマーク

ちょっと心配なのが、requireの度にASTにしてunassertして更にコードに直して、という処理を入れるのはパフォーマンス的に大丈夫なの?という点です。 実はNode.jsのrequireは一度読み込むとあとはすべてキャッシュから読み込むため、毎度AST変換などの処理は入らないので大きな影響はないというように予想できますが、しかし、推測するな計測せよの精神でとりあえず簡易的にですがベンチマークを取ってみたいと思います。 Benchamrk.jsを使ってこんなテストを書きました。

// bench.js
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;
var decache = require('decache'); // requireのキャッシュをclearするためのモジュール

let time = 0;
suite.add('Require testing', function() { // 1つめのテストケース
  require('./math')
})
.add('Require uncache testing', function() { // 2つめのテストケース
  decache('./math')
  require('./math')
})
.on('cycle', function(event) {
    console.log(String(event.target));
})
.run({ 'async': true });
// math.js
function add (a, b) {
  assert(!isNaN(a));
  assert(!isNaN(b));
  return a + b;
}

module.exports = add;

math.js という冒頭に出てきたモジュールをrequireさせ、ops (operation per sec)を計測します。 1つめのテストケースではrequireのベーシックなパフォーマンスを計測します。Benchmark.jsでは1つのTest Suitを複数回繰り返し、opsを計測するので、想定通りならば2回目以降のrequireはすべてキャッシュから返されるため、通常のケースもunassertLoaderをpreloadした場合もopsに影響を及ばさないと考えられます。 2つめのテストケースではキャッシュから呼び出されるのではなく、1回のrequireの処理のopsを計測します。1度requireしたモジュールを都度decacheして無効化しているので、このケースではunassertLoaderの1回目の処理が通常のケースに比べてどの程度遅延するのかを計測します。想定通りであるならば、通常のケースとunassertLoaderを用いたケースではopsに大きな差があると予測できます。

それでは実際に実行してみます。

$ node ./unaasetLoader.js bench.js
$ node --require ./unassertLoader.js bench.js

結果は以下のようになりました。

Case1 Case2
通常の起動方式 110,789 ops/sec ±1.37% 7,919 ops/sec ±2.34%
unassertLoaderをpreload 108,085 ops/sec ±1.33% 2,421 ops/sec ±2.13%

Case1では想定通り、通常の起動方式もunassertLoaderをpreloadした場合もopsに大きな差は見られませんでした。このことから、requireのキャッシュが十分に働き、パフォーマンスに大きな影響はないことがわかります。 Case2においても、想定通り、opsに3倍以上の差があることがわかります。実際のところ、decacheにかかるオーバーヘッドが計測できていないので正しい数値は出せませんが、やはりAST変換 ~ unassert ~ コード生成の一連の流れは大きな負荷になっていることがわかりました。

ベンチマークの結果から、通常用途であればunassertLoaderは問題なく使用できるが、もし何らかのケースでキャッシュを使えない場合は注意が必要、ということがわかりました(そんなケースあるのか?)。

あとがき

この記事ではNode.jsでも他の言語と同じようにassertを活用した堅牢なプログラミングを行うためにはどのようにすればよいか、ということを考察してきました。 この話を考え始めたきっかけは今年のNode学園祭でt-wadaさんのLTを聞いたことがきっかけでした。たしかにunassert使えばフロントではassert使えるけどサーバだとどうなるんだろう?みたいな感じです。

今回あまり触れることのないNode.jsのrequire関連の挙動について知見を深めることができました。これからは安心して、プロダクションにガンガンassertをいれられそうです。

今回作成したunassertLoaderは近々OSSとして公開する予定です、 公開したらまた報告したいと思います!

東京Node学園祭2016に参加しました

2016年11月12, 13日と開催された東京Node学園祭に参加してきたので、その報告をします。

nodefest.jp

 

 

登壇したはなし

僕は初日にLTしました。発表資料はこれ。

speakerdeck.com

 

色々書いてありますが、

  1. Node.jsを始めとするJavascript界隈は特に言語の変化が激しい。
  2. 今書いているES2015やES2016だっていつかLegacyになるときがくる。そのときに、どうやって安全に移行しますか。
  3. 色々方法はあるけど、TDDで継続的リファクタの環境や土壌、文化を今から作っておくのは1個の正解じゃない?

っていうことが言いたかったことです。そう考えるとタイトルミスった感が。まあしょうがないね。

 

この話をしようと思った背景

2つあります。

1つめは、ここ半年くらい月1でとある著名な方とTDDのペアプロをさせていただく機会があり、色々と学んだこと、感じたことを一度言語化してまとめたかったからです。

2つめは、まさに今業務であまりメンテされてこなかったNode.jsのシステムをちくちくリファクタしつつ新しいNodeのバージョンに移行する作業をやっていて、そのときTDDの考え方に大分助けられている実感があり、それを共有したかったからです。

 

1つめに関しては、やりたいことは概ねやれたと思います。やっぱり言語化すると色々と知識が整理されて良いですね。正しく発表するために色々と調べるし。

2つめに関しては正直この問題に直面している人がどれくらいいるのかわからないので、なんとも感触がありません。。。

今回、業務で古いシステムをリファクタする際、真っ先に手をつけたことはテストが不安定もしくは壊れているコードの修正でした。とにかく正しいテストが正しく回る状況を作り出し、あとは単体テストに守られながらまずい切り方をしているAPIを直したり、サポートされなくなったnpmモジュールの置換などを行ったりしました。その方針はかなりうまくいっているかなと思っていて、現在のところスケジュールもまきで行けているし、大きな手戻りも発生していません。ココらへんの話はプロジェクトが終わったらまた別でできると良いですね。。

 

印象に残ったセッションのはなし

ひとつめ: Douglas CrockfordのKeynote

Seif Projectの話でした。

The Seif project - O'Reilly Media

構想自体は壮大すぎてもはやピンと来なかったレベルなんですが、

 

「WWWはドキュメントをdeliverするための仕組みであり、Applicationをdeliverする仕組みとしては必ずしも最適ではない」

という話はすごい共感できました。

特に、2日目午後にやったVue.jsとReact-Reduxの話を聞いてもった感想がこちら

 

ふたつめ: Boarding the tiny framework train by @yoshuawuyts

http://tacit-fly.surge.sh/

Yoshuaさんの話です。chooの開発裏話から、Yoshuaさんのvim力が卍解してたライブデモまであってとにかく楽しめた発表でした。

choo、チュートリアルだけやったことあるのですが、react-reduxをベースとしていながらもシンプルな作りで変なところで悩まないすごく好感が持てる作りでした。フロントエンドのFWが色々出てきている中で、流行るかどうかはわかんないですけど、積極的に使っていきたいなあと思います。なにせ書いてて楽しいし。

 

chooのリポジトリはこちら

github.com

 

みっつめ: How Do We Get Along With Static Types by @__gfx__

How Do We Get Along With Static Types // Speaker Deck

jsと静的型の話です。

jsと型の話は少し考えるところがあって、懇親会でも「jsは型は必要だと思うか?」みたいな質問をしたんですが。。。

懇親会で色々な人に話を聞いた結果、型になにを求めるかは人によって様々で、IDEでのリファクタやAutocompleteをより効率的にやるために型が欲しい人から、型によってよりプログラミングの表現力を向上させたい人まで、色々な幅がありました。まだjsに型をつける系のやつ全然触っていないので、Flowtypeあたりから試して見ようかなあ。。。

 

これ以外にもたくさんのセッションがあり、それぞれ実践的かつ濃い内容ばかりで、色々刺激を受けました!特に、Debugging Node.js Performance Issues in Productionで話していた内容はすぐに業務に活かせそう。

 

さいごに

国内外スピーカーとの調整や、当日の同時通訳など、限られた時間と予算の中で実行するのは大変に困難だったかと思います。運営の皆様の配慮とホスピタリティのお陰で、非常に楽しめた2日間になりました。

この場を借りてNode学園祭の運営の皆様にお礼を言いたいと思います、
本当にありがとうございました!

 

まとめ

Node学園祭でTDDのはなししてきた。リファクタがキマると気持ちいいです。

 

 

あ、あとCode and Learnでおくったパッチマージされるといいなあ。