Node.js や TypeScript で使える O/R マッパーライブラリを探してみたが、イマイチなので自前でやってみたり

最近、

といったことをやっている。

Java でいう POJO なクラスを用意しておいて、MyBatis (古くは iBatis) にクラスへのマッピングを任せたり、もしくは BeanUtils#copyProperties() を使うようなヤツを、最近は JavaScript・TypeScript でもやるようになってきた。というか、JavaScript には本質的に型がないので、「こういうプロパティがあるはずだよな」と決め打ちで書いてしまえば動きはするのだが、段々とその辺の管理が大変になってきたので、クラス・型で管理したいな、と思うようになってきた。

ということで、npm パッケージとして良い感じの O/R マッパーが提供されていないかなーと思ったのだが、なんかあんまりない感じ。

ネットを調べてみて皆がどうしているのか探ってみたので、その結果をまとめる。

目次

サンプルで扱うデータとクラス

今回のサンプルで扱うデータは以下のようなイメージ。

// マッピングしたいデータ:受信した JSON データだったり、SQLResultSet だったりのテイ
const src = {
  id: 1,
  name: 'Name',
  age: 25,
  address: 'Tokyo',
  dummyParams: 'Dummy'  // DTO クラスにないプロパティ
};

Ajax 通信で受信した JSON データだったり、DB から SELECT した ResultSet の一つだったりするテイ。

コレに対し、以下のような構造の DTO クラスにデータをうまいことマッピングしたい。

// マッピングに使う DTO クラス
class User {
  constructor(id, name, age, address, zipCode) {
    this.id      = id;
    this.name    = name;
    this.age     = age;
    this.address = address;
    this.zipCode = zipCode;
  }
}

以降、これらのコードをベースに、変数 src を上手いことマッピングした User インスタンスを生成しようとしてみる。

方法1:クラスのコンストラクタで連想配列をクラスにマッピングする

まずは、クラスのコンストラクタでマッピングを試みる。

// マッピングに使う DTO クラス
class User {
  constructor(data, name, age, address, zipCode) {
    // プロパティ定義 : ES2015 ではクラス直下でプロパティ定義できないため
    // また、後述のマッピング時に undefined だと for...in ループが使えないため null を代入しておく
    this.id      = null;
    this.name    = null;
    this.age     = null;
    this.address = null;
    this.zipCode = null;
    
    // 引数が1つで、第1引数が連想配列の場合は連想配列のデータをバインディングする
    if(arguments.length === 1 && Object.prototype.toString.call(arguments[0]) === '[object Object]') {
      // Object.assign() を使うと、dummyParams のように User クラスにないプロパティも取り込まれてしまう
      // Object.assign(this, data);
      
      // クラスに定義していないプロパティを取り除くには以下のようにチェックしながら代入する
      for(const key in data) {
        if(this.hasOwnProperty(key) && data[key] !== null && data[key] !== undefined) {
          this[key] = data[key];
        }
      }
    }
    else {
      // 通常どおり引数が設定されている場合はそのまま設定する
      this.id      = data;  // 第1引数のみ直接的な引数名にできないがご容赦…
      this.name    = name;
      this.age     = age;
      this.address = address;
      this.zipCode = zipCode;
    }
  }
}

// どちらでもインスタンス化できる
console.log( new User(src) );
console.log( new User(2, 'My Name', 30, 'Yokohama', '100-0001') );

ES2015 のクラス構文の場合、オーバーロード (引数違いで同名のメソッドを複数作ること) ができないので、自前で条件分岐を入れて、第1引数が JSON データ (連想配列) かどうかを見ている。

連想配列だった場合は、Object.assign(this, 【第1引数のオブジェクト】) と書くと、サクッとマッピングできる。ただしこのやり方をすると、「DTO クラスにないプロパティを第1引数が持っていた場合」に、そのプロパティが DTO クラスに付与されてしまう。前述の変数 src の場合、dummyParams というプロパティがあり、コレは User クラスにはないはずだが、コレもマッピングされてしまう。

それを避けるには、for ... in ループと hasOwnProperty() を利用して、そのクラスが対象のプロパティを持っている場合のみ値を設定する、というやり方になる。しかしこの場合も注意点があって、User クラスにプロパティが定義されている (hasOwnProperty()true になる) 状態にするには、this.id; のように宣言だけするのではダメで、this.id = null; というように何らかの値を代入して undefined 以外の状態にしておかないといけないのだ。そのため、前述のサンプルコードは先に全プロパティに null を代入している。

方法2:JSON からインスタンスを生成する static メソッドを用意する

色々と面倒な感じになってきたので、少し違うアプローチを。

コンストラクタは通常どおり用意しておいて、連想配列からインスタンスを生成する場合は、それ用の static メソッドを使う、という方法。

// JSON (連想配列) からインスタンスを生成する static メソッドを用意する
class User {
  constructor(id, name, age, address, zipCode) {
    this.id      = id;
    this.name    = name;
    this.age     = age;
    this.address = address;
    this.zipCode = zipCode;
  }
  
  // static メソッド
  static fromHash(hash) {
    return new this(hash.id, hash.name, hash.age, hash.address, hash.zipCode);
  }
}

// static メソッドから生成する場合
console.log( User.fromHash(src) );
// それ以外は通常どおりコンストラクタを利用する
console.log( new User(2, 'My Name', 30, 'Yokohama', '100-0001') );

このように static メソッドを用意しておけば、コンストラクタ内で引数の数や型をチェックしたりする必要はなくなる。JSON データからインスタンスを生成する時は、new User() ではなく、

const myUser = User.fromHash(src);

と書けば良いのだ。

方法3:JSON データをクラスにマッピングする関数を用意する

「方法1」「方法2」ともに、DTO クラスごとに似たような実装を持たなくてはならず、いささか面倒臭い。先に作った User クラスの実装をコピペした時の修正忘れとかでくだらない不具合を生む恐れがありそうだ。

そこで、「方法1」のやり方をもう少し汎用的にして、特定のクラスに依存しないマッピング関数を作ってみる。

// マッピングに使う DTO クラス : 基本的なコンストラクタの定義のみ
class User {
  constructor(id, name, age, address, zipCode) {
    this.id = id;
    this.name = name;
    this.age = age;
    this.address = address;
    this.zipCode = zipCode;
  }
}

/**
 * JSON オブジェクトからクラスのインスタンスを生成する
 * 
 * @param type クラスのオブジェクト・new して使用する
 * @param json マッピングしたい JSON データ
 * @return データをマッピングしたインスタンス
 */
function createInstanceFromJson(type, json) {
  let instance = new type();
  for(const key in json) {
    if(instance.hasOwnProperty(key) && json[key] !== null && json[key] !== undefined) {
      instance[key] = json[key];
    }
  }
  return instance;
}

console.log( createInstanceFromJson(User, src) );

仕組み上は TypeScript でも同様にできるはず。

方法4:object-mapper を使ってみる

O/R マッパーライブラリが存在しないワケではなくて、極端に少なく感じた、というだけなので、今回は object-mapper というパッケージを使ってみる。

// 「npm install -S object-mapper」でインストールしておく
const objectMapper = require('object-mapper');

// マッピングしたいデータ:受信した JSON データだったり、SQLResultSet だったりのテイ
const src = {
  id: 1,
  name: 'nAME',  // あとで変換させる
  age: 25,
  zip_code: '100-0000',  // スネークケースのプロパティ
  zipCode: '100-9999',   // キャメルケースのプロパティ (userMap の設定順序によりコチラの値は無視される)
  dummyParams: 'Dummy'   // DTO クラスにないプロパティ
};

// マッピング用の設定オブジェクト
const userMap = {
  // id プロパティを受け取ったら、そのまま id プロパティに出力する
  'id': 'id',
  // name プロパティを受け取ったら、name プロパティと originalName プロパティに出力する
  'name': [
    // 出力する name プロパティの方は値をパスカルケースに変換する
    {
      key: 'name',
      transform: (value) => {
        return value.charAt(0).toUpperCase() + value.substr(1).toLowerCase();
      }
    },
    // originalName プロパティは、name プロパティの値をそのまま出力する
    {
      key: 'originalName'
    }
  ],
  'age': 'age',
  // address プロパティがなかった場合はデフォルト値 '住所なし' を設定する
  'address': {
    key: 'address',
    default: '住所なし'
  },
  // キャメルケースのプロパティで受け取った場合
  'zipCode': 'zipCode',
  // スネークケースのプロパティで受け取った場合
  'zip_code': 'zipCode'
  // マッピング先のプロパティが同名で、両方のプロパティに値があった場合、設定オブジェクトの後ろの方で宣言した設定の方が優先される
  // つまりこの場合、src.zip_code の値の方が優先して利用される
};

const user = objectMapper(src, userMap);
console.log( user );

このライブラリは User クラスにマッピングするのではなく、User クラスに相当する、マッピング用の設定オブジェクトを用意する形になる。

入力となるオブジェクトのプロパティ名に対し、どのような出力をするか、というマッピングと変換ができる。

上の例では、src.name プロパティの値を利用して user.nameuser.originalName という2つのプロパティを出力したり、src.zipCodesrc.zip_code のいずれかの値を利用して user.zipCode というプロパティに出力したりしている。

割と柔軟に設定できるので、「SQLResultSet はスネークケースのプロパティ名なのでキャメルケースにしたい」とかいう場合にも使いやすい。結局はモデル (DTO) に応じてそれぞれにマッピング処理が必要になってくると思うので、こういう作りでも良いのかもしれない。

以上

なんだかこう、コレという方式が見付けきれなかったが、方法3がお手軽かつクラスに依存しなさそうで、プロパティ名やデータの変換もしたいなら方法4の object-mapper だろうか。