JavaScript
Node.js
ライブラリ
Node.jsDay 9

Node.jsの入力パラメーター処理方法・決定版

本日はNode.js Advent Calendar 2018 9日目かつ誕生日です。
プレゼントは常時受け付けています。

この記事の対象者

  • Node.jsで
  • Webアプリケーションを作っている人が
  • 対象です。

TL;DR

Node.jsのWebアプリケーションで入力パラメータを処理するときはadjusterを使いましょう。

はじめに

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

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

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

たとえば req.body に↓こんなパラメーターがほしい場合

  • name
    • String型
    • 1文字以上
    • 省略不可
  • age
    • Number型
    • 0以上
    • 整数(小数点以下は切り捨て)
    • 省略不可
  • status
    • String型
    • 取りうる値は "active", "inactive" のみ
    • 省略可(省略時は "active"

コードに落とすとこんな感じでしょうか。

// デフォルト値を補完
const parameters = Object.assign({
    status: "active",
}, req.body);

// nameの処理
let name = parameters.name;
if(name === undefined) {
    // 省略されていたらエラー
}
name = String(name); // 文字列化
if(name.length < 1) {
    // 1文字未満ならエラー
}
parameters.name = name;

// ageの処理
let age = parameters.age;
if(age === undefined) {
    // 省略されていたらエラー
}
age = Number(age);
if(isNaN(age)) {
    // 数値化できなければエラー
}
if(age < 0) {
    // 0未満ならエラー
}
age = Math.floor(age); // 整数化
parameters.age = age;

// stateの処理
let state = parameters.state;
if(!["active", "inactive"].includes(state)) {
    // 定義域外の値ならエラー
}
parameters.state = state;

// parametersに検証済みのパラメーターが入っている

…面倒くせえ!

1箇所だけならまだしも、複数のページで何回も似たような処理を書かないとアカンし、共通化するのも意外と面倒だったりします。

しかもこのコードでもまだ不十分で、

  • name にオブジェクトが渡されると "[object Object]" になるけど大丈夫?
  • agenull や空配列 [] が渡されると 0 になるけど大丈夫?
  • リクエストボディにオブジェクト型が渡されるとは限らない
    • JSONデータとして配列とか数値を渡されると、最初の「デフォルト値を補完」の時点でコケる

値の検証ライブラリとしてはvalidator.jsとかAjvなんかが有名ですが、Webアプリケーションの入力値処理では検証だけでなく、最初に書いたとおり

  • デフォルト値の補完
  • 型チェック
  • 必要に応じて値の調整

といったものが必要になってきます。

制御構文が複雑だなぁ…

可読性が低いなぁ…

簡単に書ける方法ないかなぁ…

偶然にもこんなところに便利なものが!

長い前フリからの拙作ライブラリ紹介です。

adjuster

論より証拠。まずはこのコードを見てくれ。
上で長々と書いたコードをadjusterを使って書き直したものです。

import adjuster from "adjuster";

// パラメーターに求める制約
const constraints = {
    name: adjuster.string().minLength(1),
    age: adjuster.number().integer(true).minValue(0),
    status: adjuster.string().default("active").only("active", "inactive"),
};

// parametersに検証済みのパラメーターが入っている
const parameters = adjuster.adjust(req.body, constraints);

超簡単じゃね?

超わかりやすくね?

制御構文もないし、何よりコンパクトに書けます。

おそらく唯一意味不明なのが integer(true)true ですが、これは「値を調整する(この場合は整数でなければ整数化する)」という意味で、省略時は false (エラーにする)です。

ライブラリの詳細

特徴

  • パラメーター処理に必要な機能を完備
    • 必須パラメーターのチェック
    • 任意パラメーターのデフォルト値補完
    • JSONの基本型(boolean / number / string / 配列 / オブジェクト)の型チェック / 型変換
      • 「オブジェクトの配列」など、複雑な値にも対応
      • 数値文字列やEメールアドレスなど、特殊な文字列にも対応
      • 定義域が null だけのパラメーターには非対応
    • 定義域の検証
    • 定義域に収まるように調整
  • 豊富な検証メソッド(以下はほんの一例)
    • number型
      • null 許可
      • 上下限チェック
      • 整数チェック
      • 特定の値のみ許可
    • string型
      • null 許可
      • 文字数制限
      • 前後のスペースを除去して検証
      • 特定の値のみ許可
      • 正規表現
    • 数値文字列
      • チェックサム(クレジットカード、ISBN-13)
      • 指定した区切り文字を除去して検証
      • 配列を連結して検証
  • RFCに準拠した検証用正規表現が付属
    • adjuster.STRING.PATTERN.URI
    • adjuster.STRING.PATTERN.HTTP
    • adjuster.STRING.PATTERN.IPV4
    • adjuster.STRING.PATTERN.IPV6
  • エラー処理
    • 「最初のエラーだけ補足」「すべてのエラーを補足」と必要に応じて使い分け可能
    • エラー時の値修正に対応
    • 配列やオブジェクトがネストした複雑な値でも、エラーの発生した場所を確実に特定可能
  • 楽なコーディング
    • コードを楽に書けるようにIDEの補完機能を最大限活かせる設計
    • 覚えることは adjuster. のみで、あとはIDEが出してくれる候補一覧から選ぶだけ
    • Visual Studio CodeのTypeScriptプロジェクトで補完を確認済み
    • WebStorm / IntelliJ IDEA UltimateではJavaScriptプロジェクトでも補完を確認済み
  • 各種インポートに対応
    • CJS
    • ES Modules
    • Babel
    • TypeScript
  • 環境に合わせて最適化
    • npm install した環境のNode.jsバージョンに合わせて自動的に最適化
    • 最新バージョンなのに古い文法を使って遅くなるということはない
  • 高品質
    • Windows / macOS / Linuxでテスト済み
    • カバレッジは100%

ぼくのかんがえたさいきょうのパラメーター処理を目指して作ったので、設計から使いやすさまでかなりこだわりました。

1つのソースでCJSとES Modulesの両方に対応する方法については、以前Node学園 31時限目で発表したCJSとESMとnpmパッケージを参照ください。

要件

  • OS: Windows / macOS / Linux
  • 処理系: Node.js v4.0以降
  • 言語: JavaScript / TypeScript

Node.jsの4.0以降で動くことを確認していますが、インストール時に環境に合わせてBabelでトランスパイルしているのでそれ以下でも動くかもしれません。

インストール

普通にnpmで入れます。

npm i -S adjuster

GitHubのリポジトリ名はnode-adjusterですが、npmのパッケージ名はadjusterなので注意。

インポート

CJS

foo.js
var adjuster = require("adjuster");

ES Modules

foo.mjs
import adjuster from "adjuster";

Babel

foo.js
import adjuster from "adjuster";

TypeScript

foo.ts
import * as adjuster from "adjuster";

TypeScriptはちょっと違うので注意。
allowSyntheticDefaultImports / esModuleInteroptrue にすればES Modulesと同じ書き方でインポートできます。

使い方

詳細についてはREADMEテストコードを参照ください。

入力パラメーターがオブジェクトだけでなく、プリミティブ型や配列の場合でも対応できます。

import adjuster from "adjuster";

// 3.14 (数値型)
const pi = adjuster.number().adjust("3.14"); // adjust() メソッドに入力値を渡せばOK

adjuster.adjust() や他の .adjust() メソッドは入力値や制約条件を一切変更しませんので、制約条件をグローバルに定義してすべてのリクエストで使い回せます。

TypeScriptで使う場合

TypeScriptでは adjuster.adjust()any を返しますが、型安全にするために以下のいずれかを使ってください。

関数の戻り値で型を指定

interface Parameters {
    foo: number;
    bar: string;
}

function getParameters(): Parameters {
    return adjuster.adjust(...);
}

代入先の変数に型を指定

interface Parameters {
    foo: number;
    bar: string;
}

const parameters: Parameters = adjuster.adjust(...);

ジェネリクスで型を指定(推奨)

interface Parameters {
    foo: number;
    bar: string;
}

const parameters = adjuster.adjust<Parameters>(...);

エラー処理

検証エラーは2通りの方法で補足できます。

try-catchで補足

最初にエラーが発生したときに例外をスローします。
エラーを1つだけ補足できればいいならこちらが楽です。

try {
    const constraints = {
        foo: adjuster.number(),
        bar: adjuster.string().minLength(4),
    };
    const input = {
        foo: "abcd",
        bar: "abcd",
    };

    const parameters = adjuster.adjust(input, constraints);
} catch(err) {
    assert.strictEqual(err.keyStack, ["foo"]); // エラーの発生箇所; "foo"
    assert.strictEqual(err.cause, adjuster.CAUSE.TYPE); // エラー原因; 型エラー
}

コールバック関数で補足

エラーが発生するたびに指定したコールバック関数を呼び出します。
すべてのエラーを補足したい場合はこちらを使ってください。

コールバック関数を指定した場合は例外がスローされません。

const constraints = {
    foo: adjuster.number(),
    bar: adjuster.string().minLength(4),
};
const input = {
    foo: "abc",
    bar: "abc",
};

const parameters = adjuster.adjust(input, constraints, (err) => {
    if(err === null) {
        // すべてのパラメーターのチェックが終わったらここに入る
        // 例外を投げてもいい
        return;
    }

    switch(err.keyStack.length.shift()) {
        case "foo": // fooでエラーが発生
            // この戻り値が parameters.foo になる
            return 123;

        case "bar": // barでエラーが発生
            return; // 何も返さなければ parameters.bar はなくなる
    }
});

複雑な値のエラー箇所特定

上記のサンプルコードを見て、「なんで err.keyStack は配列型なの?」と思った人がいるかもしれません。

これは、入力値が単純なオブジェクトではなく「オブジェクトの配列のオブジェクト」のように複雑な値の場合に役に立ちます。

例えばこんな場合。

const input = {
    users: [
        { // index=0
            id: "1",
            name: "John Doe",
        },
        { // index=1
            id: "two", // 数値じゃないのでエラー!
            name: "John Doe 2",
        },
        { // index=2
            id: 3,
            name: "John Doe 3",
        },
    ],
};

この入力値制約はこんなかんじになります。

const constraints = {
    users: adjuster.array().each( // 配列
        adjuster.object().constraints({ // 配列の中身はオブジェクト
            // オブジェクトの制約
            id: adjuster.number(),
            name: adjuster.string(),
        })
    ),
};

そして検証コード

try {
    const parameters = adjuster.adjust(input, constraints);
} catch(err) {
    assert.strictEqual(err.keyStack, ["users", 1, "id"]);
}

「キー "users" 」の「配列インデックス 1 」の「キー "id" 」でエラーが起きていることがわかります。

以下の場合には空配列が入ります。

  • プリミティブ型の検証時にエラーが発生した場合
  • オブジェクトが期待されるところでオブジェクト以外の値が入力された場合
  • 配列が期待されるところで配列以外の値が入力された場合

一言でいうと、キーもインデックスも特定しようがない場合です。

try {
    // プリミティブ型の検証時にエラーが発生
    const parameter = adjuster.number().adjust("abc");
} catch(err) {
    assert.strictEqual(err.keyStack, []);
}
try {
    // オブジェクトが期待されるところでオブジェクト以外の値が入力
    const parameter = adjuster.adjust(123, {
        id: adjuster.number(),
    });
} catch(err) {
    assert.strictEqual(err.keyStack, []);
}
try {
    // 配列が期待されるところで配列以外の値が入力
    const parameter = adjuster.array().adjust(123);
} catch(err) {
    assert.strictEqual(err.keyStack, []);
}

おわりに

いかがでしょうか。
adjusterを活用すれば、パラメーター処理に時間と労力を使わず、本来のロジックに集中できるのではないかと思います。

バグ報告や機能提案は大歓迎です。
「こういうことをやりたい時はどうすればいいの?」といった質問も気軽に投げてください。

繰り返しますがプレゼントは常時受け付けています

それではみなさん、よいパラメーターライフを。