Node.jsアドベントカレンダーとDenoアドベントカレンダーの9日目の記事です。
同じ記事のURLを複数のアドベントカレンダーに指定できなかったので、1つは短縮URLを使いました。
Node.jsやDenoとはどういうものかついては、以前さくらのナレッジに寄稿したのでよければそちらもごらんください。非同期処理についての記事もどうぞ。
はじめに
value-schemaというライブラリーをご存知でしょうか。いや、誰も知るまい。
2年前のNode.jsアドベントカレンダーで紹介した入力データのバリデーションライブラリー"adjuster"の後継版です。adjusterも誰も知るまい。
正確には、後継というよりバージョン2からリネームした地続きのライブラリーなのですが、まあそんな細かいことはどうでもいいです。
数カ月間、実際のアプリケーションなどで使い勝手を徹底的に試してきました。この記事を書いている段階ではバージョン3のリリース候補版ですが、おそらくみなさんが読んでいる頃には正式版としてリリースされています。
本記事では、
- value-schemaとは何か?
- なぜvalue-schemaが必要なのか?
- 2年前からどう変わったのか?
このあたりについて説明します。
対象者
- Node.jsまたはDenoで
- Webアプリケーションを作っている人が
- 対象です。
value-schemaの存在意義
2年前の記事から引用します。
Webアプリケーションを開発していて地味に面倒なのが入力パラメーターの処理です。異論は認めない。
だいたいこんな感じのことをほぼすべてのパスで行う必要があるんじゃないでしょうか。
- 存在チェック
- 必須パラメーターが存在するか?
- 例:
name
が存在するか?- 省略可能パラメーターが省略されていたらデフォルト値を設定
- 例:
status
が省略されたら"active"
を設定- 型チェック
- 期待通りの型か?
- 例:
age
はNumber
型か?- 必要なら型変換
- 例:
"20"
→20
- POSTやPUTではJSONデータのみを受け付けることで型変換を不要にできるが、GETでクエリストリングを処理する場合は型変換が必要
- 定義域チェック
- 定義域に収まっているか?
- 例:
age
は0
以上の整数か?- 必要なら値の調整
- 例1:
-1
→0
- 例2:
20.3
→20
あらためて面倒ですよね、はい。
adjusterは、そんな面倒なバリデーション処理を簡潔に、宣言的に1記述できるライブラリーです。
value-schemaもこの思想は変わらず、より使いやすく改善しました。
コードで語る
百聞は一見に如かず。実際にコードを見れば便利さはすぐわかります。
例えば、入力値として以下のようなスキーマを想定しているとしましょう。ユーザー管理APIなんかでよくある例だと思います。
-
id
- 数値型
- 1以上
-
name
- 文字列型
- 最大16文字(16文字を超えた分は切り捨てる)
-
age
- 数値型
- 0以上
- 整数(小数点以下は切り捨て)
-
email
- RFCに準拠したEメールアドレス(文字列型)
-
state
- 文字列型
-
"active"
,"inactive"
のどちらか
-
skills
- 文字列型の配列(ただし入力データはカンマ区切りの文字列)
- 解析時のエラーは無視する(正常に解析できた部分だけで配列を構成する)
-
creditCard
- 数字文字列(数字しか含まない文字列)
- ただし、読みやすさのために
-
で区切られている(-
が含まれていてもエラーにはせず、最終的には数字文字列のみがほしい)
- ただし、読みやすさのために
- 全角数字も受け付ける(半角に変換する)
- クレジットカードとして有効な文字列
- 数字文字列(数字しか含まない文字列)
-
remoteAddr
- 文字列
- IPv4として有効な文字列
-
limit
- 数値型
- 整数(小数部分があればエラー)
- 1以上(1未満の場合は1にする)
- 100以下(100を超える場合は100にする)
- 省略可能(省略時は10)
以上の内容を全て間違えずにロジックに落とし込むのはかなり面倒だと思いませんか?
さらに、APIの数だけ入力データもあるので、他のAPIでも同じようなロジックを書かなければいけません。ロジックを完全に使い回せるならいいのですが、微妙に条件が違っていたりして使い回せないことも多いですよね。
value-schemaを使うと、以下のように記述できます。
import assert from "assert";
import vs from "value-schema";
const schemaObject = { // 入力スキーマ
id: vs.number({ // 数値型 / 1以上
minValue: 1,
}),
name: vs.string({ // 文字列型 / 最大16文字(超えた分は切り捨てる)
maxLength: {
length: 16,
trims: true,
},
}),
age: vs.number({ // 数値型 / 0以上 / 整数(小数点以下は切り捨て)
minValue: 0,
integer: vs.NUMBER.INTEGER.FLOOR_RZ,
}),
email: vs.email(), // RFCに準拠したEメールアドレス(文字列型)
state: vs.string({ // 文字列型 / "active", "inactive"のどちらか
only: ["active", "inactive"],
}),
skills: vs.array({ // 配列型 / カンマ区切り文字列を配列化 / 配列の要素は文字列型 / 解析時のエラーは無視する
separatedBy: ",",
each: {
schema: vs.string(),
ignoresErrors: true,
},
}),
creditCard: vs.numericString({ // 数字文字列 / "-"で区切られている / 全角数字も受け付ける(半角に変換する) / クレジットカード用のチェックサムを行う
separatedBy: "-",
fullWidthToHalf: true,
checksum: vs.NUMERIC_STRING.CHECKSUM_ALGORITHM.CREDIT_CARD,
}),
remoteAddr: vs.string({ // 文字列型 / IPv4として有効な形式
pattern: vs.STRING.PATTERN.IPV4,
}),
limit: vs.number({ // 数値型 / 整数(小数部分があればエラー) / 1以上(1未満の場合は1にする) / 100以下(100を超える場合は100にする) / 省略可能(省略時は10)
integer: true,
minValue: {
value: 1,
adjusts: true,
},
maxValue: {
value: 100,
adjusts: true,
},
ifUndefined: 10,
}),
};
const input = { // 入力値
id: "1",
name: "Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Ciprin Cipriano de la Santísima Trinidad Ruiz y Picasso",
age: 20.5,
email: "picasso@example.com",
state: "active",
skills: "c,c++,javascript,python,,swift,kotlin",
creditCard: "4111-1111-1111-1111",
remoteAddr: "127.0.0.1",
};
const expected = { // 最終的にこんな値になってほしい
id: 1,
name: "Pablo Diego José",
age: 20,
email: "picasso@example.com",
state: "active",
skills: ["c", "c++", "javascript", "python", "swift", "kotlin"],
creditCard: "4111111111111111",
remoteAddr: "127.0.0.1",
limit: 10,
};
// 入力スキーマを適用してみる
const actual = vs.applySchemaObject(schemaObject, input);
// 検証
assert.deepStrictEqual(actual, expected);
ね、簡単でしょう?
記事中のコードなので長く見えるかもしれませんが、同等の機能を自前で書こうと思ったら間違いなくコード量は数倍〜十倍以上にまで膨れ上がります。
そして、何よりこのコードにはロジックがありません。if
やfor
などの制御構文も何もありません。
「id
は1以上の数値型」のようなデータのあるべき姿を宣言しているだけなので理解しやすく、「あ、条件に『整数であること』を忘れていた!」という発見もしやすくなります。
adjusterからの変更箇所
3行で。
- Deno対応
- TypeScriptでより使いやすく
- 可読性の高いインターフェース
Deno対応
バージョン3からDenoにも対応しました。
Denoで使う場合は以下のようにインポートしてください。
import vs from "https://deno.land/x/value_schema/mod.ts";
value-schema
(ダッシュ)ではなくvalue_schema
(アンダースコア)であることに注意してください。deno.landへはダッシュがついた名前は登録できなかったのでアンダースコアに変更しました。
Node.jsで使う場合はハイフンです。
// npm i value-schema
import vs from "value-schema";
TypeScriptでより使いやすく
これまでもTypeScriptに対応はしていましたが、単に「TypeScriptでも使えます」というだけで型情報を使用者側であらためて定義しなくてはならず、二度手間だったり使い方の間違いをチェックできないといった問題がありました。
たとえばこんな感じです。
import adjuster from "adjuster";
const input: unknown = {}; // 入力データ
interface Parameters {
foo: number;
bar: string;
}
// どんな型のデータになるかはGenericsで指定する
const parameters = adjuster.adjust<Parameters>(input, {
foo: adjuster.number(),
bar: adjuster.string(),
});
しかし、これでは例えば Parameters.foo
を間違えてstring型にしてしまってもTypeScriptコンパイラーは間違いを検出できず、実行時のどこかのタイミング、たとえば文字列型のメソッドを使った時点で実行時エラーが発生してしまいます。
実行時エラーが発生するならまだいいほうで、場合によってはエラーにならず想定と全く違う動作をして(文字列連結のつもりで数値演算をしてしまうなど)頭を抱えることもあるかもしれません。
value-schema v3ではTypeScriptの型推論を最大限に活用することで、明示的に型を指定することなくプロパティーの型を自動認識できるようになりました。
import vs from "value-schema";
const input: unknown = {};
// メソッド名が変わっている&引数の順序が変わっているので注意
const parameters = vs.applySchemaObject({
foo: vs.number(),
bar: vs.string(),
}, input);
Visual Studio CodeやIntelliJ IDEAなら、以下のようにコード補完もバッチリ使えます。
ちゃんと数値型として認識されているので、メソッドも補完できます。
簡単ですね!
可読性の高いインターフェース
adjuster時代は(value-schema v2時代も)値の細かな挙動はメソッドチェーンで指定していました。例えば以下のような感じです。
import adjuster from "adjuster";
// パラメーターに求める制約
const constraints = {
name: adjuster.string().minLength(1),
age: adjuster.number().integer(true).minValue(0, true),
status: adjuster.string().default("active").only("active", "inactive"),
};
// parametersに検証済みのパラメーターが入っている
const parameters = adjuster.adjust(input, constraints);
このコードは、integer(true)
やminValue(0, true)
のtrue
が何を意味しているのかわからないですよね。
integer(true)
は一見すると「整数型にする場合はtrue
、しない場合はfalse
」かと思いますが、実は「小数部分を切り捨てるならtrue
、切り捨てずにエラーにするならfalse
」という意味で誤解を招く内容でした。
minValue(0, true)
は、「0より小さい値の場合は強制的に0にするならtrue
、エラーにするならfalse
(あるいは省略)」という意味です。初見でこんなことわかりませんよね。
これは他の人からも指摘されていたことで、なんとかして改善できないかと考えた結果チェーンメソッドを廃止して、全てのパラメーターをオブジェクトで渡すことにしました。
先ほどのコードと同様の内容をvalue-schemaで書き直すと以下のようになります。
import vs from "value-schema";
// パラメーターに求める制約
const constraints = {
name: vs.string({
minLength: 1,
}),
age: vs.number({
integer: vs.NUMBER.INTEGER.FLOOR_RZ, // 小数点部分を切り捨て(負の数は0に向けて切り上げ)
minValue: {
value: 0,
adjusts: true,
},
}),
status: vs.string({
ifUndefined: "active",
only: ["active", "inactive"],
}),
};
// parametersに検証済みのパラメーターが入っている
const parameters = vs.applySchemaObject(constraints, input);
これなら誤解はありませんね。
丸めの方法も「切り上げ」「切り捨て」「四捨五入」「五捨五超入」など細かく指定できるようになりました。負の数の場合にどうするかも含めて計10通りの指定方法があります。
もちろんパラメーターも補完対象なので、他にどんな指定ができるかもわかります。
Q&A
動作環境は?
大抵の環境に対応しています。
- OS: Windows / macOS / Linux
- Node.js: v4以降(v4からv12までテスト済み)
- TypeScript: v3.4.1以降
- Deno: v1以降(v1.0からv1.6までテスト済み)
何が言いたいかというと、GitHub Actions最高!
Node.js版とDeno版の違いは?
違いはありません。importのパスにさえ注意すれば、後は全く同じように使えます。
// Node.js版: インストールは "npm i value-schema"
import vs from "value-schema";
// Deno版: インストールは不要 / value-schemaではなくvalue_schemaなので注意
import vs from "https://deno.land/x/value_schema/mod.ts";
nullableにしたいんだけど?
null時の挙動はifNull
で指定できます。
const constraints = {
foo: vs.number({
ifNull: null, // nullが指定されたらnullを返す
}),
bar: vs.number({
ifNull: 10, // nullが指定されたら10を返す
}),
};
型推論の結果は、ちゃんとnumber | null
型になります。
bar
はnullにはならないので、型推論の結果もnumber
型です。
便利ですね!
これを実現するために型パズルに悩まされたけどね!
また、「省略時はnull」「空文字列が指定されたらnull」という挙動も指定できます。
const constraints = {
foo: vs.number({
ifUndefined: null, // 省略時はnull
}),
bar: vs.number({
ifEmptyString: null, // 空文字列が渡されたらnull
}),
};
エラー処理はどうすればいい?
2通りの方法があります。
エラーが1つでも見つかったらすぐにエラー処理したい場合
エラーが発生したら例外がthrowされるので、catchして処理してください。
try {
// parametersに検証済みのパラメーターが入っている
const parameters = vs.applySchemaObject(constraints, input);
}
catch(err) {
const key = err.keyStack.shift();
switch(key) { // エラーが発生したプロパティー
case "foo":
switch(err.cause) { // エラーの原因
case vs.CAUSE.TYPE: // 型エラー
...
}
case "bar":
switch(err.cause) {
case vs.CAUSE.TYPE: // 型エラー
...
case vs.CAUSE.NULL: // nullが渡された
...
}
}
}
err.cause
はエラーが発生した原因(型エラー、nullalbeじゃない場所でnullが渡されたなど)です。
err.keyStack
はエラーが発生したキーの配列です。入力スキーマが「文字列の配列のオブジェクト」という場合にネストしている場合に複数の値が入ります。
import vs from "value-schema";
const input: unknown = {
foo: {
values: [1, 2, "a"],
},
};
// パラメーターに求める制約
const constraints = { // 数値の配列のオブジェクト
foo: vs.object({
schemaObject: {
values: vs.array({
each: vs.number(),
}),
},
}),
};
try {
// parametersに検証済みのパラメーターが入っている
const parameters = vs.applySchemaObject(constraints, input);
}
catch(err) {
// "a"("foo"プロパティーのインデックス2)でエラーが発生したので
// err.keyStackが["foo", 2]となる
});
全てのエラーをチェックしたい場合
実際のアプリケーションでは、入力エラーが複数あった場合に全てのエラーに対して一度に指摘してあげたほうが親切ですよね。
applySchemaObject()
の3番目の引数に関数を指定すると、エラーが見つかるたびに関数が呼ばれます。この場合、err
自体の型チェックやプロパティー補完ができます。
3番目の引数を指定した場合はエラーが見つかっても例外はthrowされません。
const parameters = vs.applySchemaObject(constraints, input, (err) => {
// errの中身は例外バージョンと同じ
const key = err.keyStack.shift();
switch(key) {
case "foo":
return 0; // ここで返した値がfooエラー時の値になる
case "bar":
return 1; // ここで返した値がbarエラー時の値になる
}
});
また、4番目の引数に関数を指定すると、1つでもエラーが見つかった場合にパラメーター検証後に呼ばれます。これを利用すれば、以下のようにエラー情報をまとめてthrowできます。
try {
// エラー情報の配列
const errors: string[] = [];
// parametersに検証済みのパラメーターが入っている
const parameters = vs.applySchemaObject(constraints, input, (err) => {
const key = err.keyStack.shift();
switch(key) { // エラーが発生したプロパティー
case "foo":
errors.push("fooがなんかおかしいよ");
return 0; // とりあえず0を返す
case "bar":
switch(err.cause) {
case vs.CAUSE.NULL: // nullが渡された
errors.push("barにnullはあかんよ");
return 0;
}
errors.push("barがなんかおかしいよ");
return 0;
}
}, () => {
// エラーがあれば、検証処理後に呼ばれる
throw errors;
});
}
catch(errors) {
// errorsにはエラー情報の配列が入っている
}
ちゃんとテストしてる?
まとめ
- 入力値のバリデーションはvalue-schemaが便利だよ
- adjuster時代から大幅に進化したよ
-
Deno版もあるよ
- 名前は"value_schema"(アンダースコア)だから注意してね
- TypeScript補完もバッチリだよ
- めっちゃテストしてるから安心して使っていいよ
-
宣言的=型チェックや定義域チェックなどのロジックを実装せず、「
age
は数値型で0以上の整数」「数値文字列の場合は数値型に変換する」「負の値が入力されたら0にする」「小数部分は切り捨てる」のようにあるべき姿を記述する方式 ↩