フロントエンド向けvalidator: favalidの紹介
はじめに
少し前にフロントエンドのjs向けvalidatorフレームワークの favalid というものを作ったので、その紹介記事を書きます。
favalidの特徴
フロントエンドというかjs向けvalidatorといえば joi や yup 、v8nといったようなライブラリがありますが、それらに比べると機能を大幅に削り、とにかく軽量なのと関数型ライクな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ですが、 favalid
は combine
という関数を提供しています。
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
された validator
は validator
なので、さらに 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
された validator
を combine
された順に実行していき、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
版の asyncTester
と asyncCombine
と同期の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
それ以外にも機能のリクエストなどありましたら教えていただけると幸いです。
まとめ
frontend javascript向けvalidator favalid
の紹介記事を書きました。
githubのREADMEがあまりにも適当なので、使い方を補完させていただきました。応用的な使い方では様々な機能を紹介しましたが、不要なものは import
しなければ tree-shaking
が効くはずなので、自分が必要なものだけを import
するように使っていただければと思います。
以上となります、読んでいただきありがとうございました!
CIとTest Sizesの話
はじめに
どちらかというとこっちが本編。
前回の記事では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 を使っている。
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のスループットが上げられると考えた。
具体的には、 lint
や Small
のテストは外界に依存が存在しなく、 docker-compose
で依存middlewareを立ち上げなくても実行可能なので、docker buildする際に実行してしまい、そのbuildが通ったもののみ docker-compose
を立ち上げて Medium
以降のテストを実行するというパイプラインにすれば良さそうである。イメージとしては以下の通りである。
実装としては
- Testを
Small
/Medium
/Large
別々に起動できるようにする - 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のプロジェクトテンプレートもある。 Dockerfile
や Makefile
の書き方の参考になると思う。
今回のポイントとしては、
- Test Sizesごとにテストの起動コマンドをわける
- テストを実行する際に、準備と実行のコストが低いSmallから順に実行していき、失敗したら即座にパイプラインを中止する
という2点にある。
まとめ
単体テスト・結合テストという呼び方はやめて、 Test Sizesによる分類を進め、その分類どおりにCIのテストパイプラインを組んだ。
Small / Medium / Largeのそれぞれテストを別々のコマンドで分けて起動できるようにしておき、 Small
は docker build
の際に、 Medium
は docker-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化し、 Medium
はlocalhostへのアクセス、 Large
は本番に近い環境を用意して行うものというイメージである。
例えばRailsだと、DBにアクセスするテストが基本は多くなると思う。そういうテストは Medium
に分類される。逆に、DBアクセスはmock/stub化しているようなテストは Small
に分類される。また、 puppeteer
などで実際に画面操作を行うようなテストは Large
に分類される。Testとは呼べないかもしれないが、 Lint
もこの分類に合わせると Small
に分類されると思う。
twadaさんの有名なプレゼン 組織にテストを書く文化を根付かせる戦略と戦術 *4 にもこのことは紹介されている(57p-58p)。この中では「自分たちでSMLを定義し、調整する」と述べられているが、僕たちのチームではもとのブログそのままの定義で使うようにしている。次回の記事でこの実例については紹介したい。
まとめ
続きはこちら 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向けに活用してなんとかしようと思います。
unassert
は Babel や webpack などのpluginとして動作します。最近のフロントエンド界隈ですとプレーンなjsを記述することは少なくなってきており、babel
やwebpack
を用いて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学園祭に参加してきたので、その報告をします。
登壇したはなし
僕は初日にLTしました。発表資料はこれ。
色々書いてありますが、
- Node.jsを始めとするJavascript界隈は特に言語の変化が激しい。
- 今書いているES2015やES2016だっていつかLegacyになるときがくる。そのときに、どうやって安全に移行しますか。
- 色々方法はあるけど、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の話を聞いてもった感想がこちら
本当にUI / UXを考えてWebアプリケーションを作るとここまで大変なんだぞということがすごい伝わってきた。そうなると、Qtってのもあながち突拍子な話ではないと思えてくる。 #nodefest
— 伊藤 瑛 (@Akito0107) November 13, 2016
ふたつめ: Boarding the tiny framework train by @yoshuawuyts
Yoshuaさんの話です。chooの開発裏話から、Yoshuaさんのvim力が卍解してたライブデモまであってとにかく楽しめた発表でした。
choo、チュートリアルだけやったことあるのですが、react-reduxをベースとしていながらもシンプルな作りで変なところで悩まないすごく好感が持てる作りでした。フロントエンドのFWが色々出てきている中で、流行るかどうかはわかんないですけど、積極的に使っていきたいなあと思います。なにせ書いてて楽しいし。
chooのリポジトリはこちら
みっつめ: 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でおくったパッチマージされるといいなあ。
はじめました。
技術ネタを中心に、勉強したことをアウトプットしていきたいと思います。