69
54

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.jsAdvent Calendar 2020

Day 9

入力値のバリデーションはこれを使え(2年間でこう進化した)

Last updated at Posted at 2020-12-08

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アプリケーションを開発していて地味に面倒なのが入力パラメーターの処理です。異論は認めない。

だいたいこんな感じのことをほぼすべてのパスで行う必要があるんじゃないでしょうか。

  1. 存在チェック
    • 必須パラメーターが存在するか?
      • 例: name が存在するか?
    • 省略可能パラメーターが省略されていたらデフォルト値を設定
      • 例: status が省略されたら "active" を設定
  2. 型チェック
    • 期待通りの型か?
      • 例: ageNumber 型か?
    • 必要なら型変換
      • 例: "20"20
      • POSTやPUTではJSONデータのみを受け付けることで型変換を不要にできるが、GETでクエリストリングを処理する場合は型変換が必要
  3. 定義域チェック
    • 定義域に収まっているか?
      • 例: age0 以上の整数か?
    • 必要なら値の調整
      • 例1: -10
      • 例2: 20.320

あらためて面倒ですよね、はい。
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);

ね、簡単でしょう?

記事中のコードなので長く見えるかもしれませんが、同等の機能を自前で書こうと思ったら間違いなくコード量は数倍〜十倍以上にまで膨れ上がります。

そして、何よりこのコードにはロジックがありませんifforなどの制御構文も何もありません。
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でも使えます」というだけで型情報を使用者側であらためて定義しなくてはならず、二度手間だったり使い方の間違いをチェックできないといった問題がありました。

たとえばこんな感じです。

adjuster(いままで)
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の型推論を最大限に活用することで、明示的に型を指定することなくプロパティーの型を自動認識できるようになりました

value-schema(これから)
import vs from "value-schema";

const input: unknown = {};

// メソッド名が変わっている&引数の順序が変わっているので注意
const parameters = vs.applySchemaObject({
    foo: vs.number(),
    bar: vs.string(),
}, input);

Visual Studio CodeやIntelliJ IDEAなら、以下のようにコード補完もバッチリ使えます。
image.png
ちゃんと数値型として認識されているので、メソッドも補完できます。
image.png
簡単ですね!

可読性の高いインターフェース

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通りの指定方法があります

もちろんパラメーターも補完対象なので、他にどんな指定ができるかもわかります。
image.png

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型になります。
image.png
barはnullにはならないので、型推論の結果もnumber型です。
image.png
便利ですね!
これを実現するために型パズルに悩まされたけどね!

また、「省略時は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はエラーが発生したキーの配列です。入力スキーマが「文字列の配列のオブジェクト」という場合にネストしている場合に複数の値が入ります。

エラー処理2
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されません

エラー処理その1
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にはエラー情報の配列が入っている
}

ちゃんとテストしてる?

これが目に入らぬか
image.png

まとめ

  • 入力値のバリデーションはvalue-schemaが便利だよ
    • adjuster時代から大幅に進化したよ
  • Deno版もあるよ
    • 名前は"value_schema"(アンダースコア)だから注意してね
  • TypeScript補完もバッチリだよ
  • めっちゃテストしてるから安心して使っていいよ
  1. 宣言的=型チェックや定義域チェックなどのロジックを実装せず、「ageは数値型で0以上の整数」「数値文字列の場合は数値型に変換する」「負の値が入力されたら0にする」「小数部分は切り捨てる」のようにあるべき姿を記述する方式

69
54
1

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
69
54

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?