jsonをデコードしてクラスに割り当てたい【JavaScript編】

## 概要

* やったこと
    * [Pythonで書いたこの記事](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)のnode.js版です。
    * jsonをparseした結果を基に、任意のクラスのインスタンスを生成します。
    * Javascriptもjsonを扱う機会は沢山あるよなと思ったので書いてみました。
        * そもそもJavaScript Object Notationなんだし…

* 得られたこと
    * [Python版](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)とほぼ同じ動作をするNode.js版を作成した。
    * 試してないけど、ブラウザ上でも動くと思う（Babel等で変換したらほぼ問題なさそう）。
        * でも、ブラウザ側でどんなクラスを扱うのかは考えどころ。

## 得られる成果の違い
概要で触れたとおり、今回は[Pythonでやったこと](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)をnode.jsでやっただけ、もっと直接的に言えば`JSON.parse`の結果を任意のクラスのインスタンスとして再構築するという記事です。
JavaScriptの場合、Pythonとは違って、`JSON.parse`して得られた結果のオブジェクトにアクセスするために、最初から`obj.key.value`のような構文が使えます。
よって、**値へのアクセス手段を簡略化する効果はない**のですが、振舞いを持つクラスにjsonの中身を割り当てるための仕組みとしては、今回の試みはPython版と同じく有効です。

## 設計
では、[前回](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)と同じように、**json中の任意のキーをクラスにマッピングする**仕組みを作ります。以下はテストに使うクラスと、jsonとクラスのマッピング定義です。
jsonのルート要素には名前がないので、仮に`ROOT_CLASS`というプロパティを充てます。ここは[Python版](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)とやや違います。[^using-map]

```javascript:テスト用クラスとマッピング
// テスト用クラスその1
class TestObject{
    test_method(){
        return 'return:' + this.value1
    }
}
// テスト用クラスその2
class NestedObject{
    test_method(){
        return 'NestedObject.test_method'
    }
}
// マッピング定義
let object_mapping = {
    ROOT_CLASS : TestObject,
    nested : NestedObject
}
```

これをこんな風に使って、jsonからクラスのインスタンスを生成するコードを書きます。

```javascript:usage
// ソースjson
let source_json = JSON.parse(`{
    "value1" : "string value 1",
    "nested" : {
        "value" : "nested value 1"
    }
}`)

let builder = new ObjectBuilder(object_mapping)
let result = builder.build(source_json)
console.log(result.nestedd.test_method()) // 'NestedObject.test_method'
```
## 実装
こんなコードになりました。

```javascript:object_builder.js
class ObjectBuilder {

    constructor(mapping) {
        this.mapping = mapping
    }

    build(src) {
        let rootInstance = null

        if (src instanceof Array){
            rootInstance = this.buildSequence(src, this.mapping.ROOT_CLASS)
        }
        else {
            rootInstance = this.buildObject(new this.mapping.ROOT_CLASS(), src)
        }
        return rootInstance
    }

    buildObject(current, src) {
        for (let key in src){
            if (key in this.mapping){
                current[key] = this.buildValue(src[key], this.mapping[key])
            }
            else {
                current[key] = src[key]
            }
        }
        return current
    }

    buildSequence(src, func) {
        let list = []
        for (let value of src){
            list.push(this.buildValue(value, func))
        }
        return list
    }
    
    buildValue(value, func) {
        if (value instanceof Array) {
            return this.buildSequence(value, func)
        }
        else if (value instanceof Object){
            return this.buildObject(new func(), value)
        }
        else {
            // 値のみ引数として渡す
            return func(value)
        }
    }
}

module.exports = ObjectBuilder
```
ほとんど[Python版](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)と変わらないのですが、JavaScriptでクラスのインスタンスを生成するにはnew演算子が必要です。これにより、[Python版](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)で実装していた。「数値と文字列を変換する」部分などはnew演算子を使わない関数呼び出しをしています。しかし、この機能はこの記事の目的には蛇足な気もしてきました。[^call-constructor]
## 使い方の紹介を兼ねたユニットテスト
<details><summary>長いので折りたたみます。</summary><div>
テストにはmochaを使っています。

```javascript:test_object_builder.js
const ObjectBuilder = require('../object_builder')
const assert = require('assert')

// テスト用クラスその1
class TestObject{
    test_method(){
        return 'return:' + this.value1
    }
}

// テスト用クラスその2
class NestedObject{
    test_method(){
        return 'NestedObject.test_method'
    }
}

describe('jsonから任意のクラスのインスタンスに変換', function(){

    it('ルートになるクラスのプロパティを設定する', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
        }
        // ソースjson
        let source_json = JSON.parse(`{
            "value1" : "string value 1"
        }`)
        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert.equal(result.value1, 'string value 1')

        // 生成したクラスのメソッドを呼んでみる
        assert.equal(result.test_method(), 'return:string value 1')
    })
    it('ネストしたクラスを生成する', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
            nested : NestedObject
        }
        // ソースjson
        let source_json = JSON.parse(`{
            "value1" : "string value 1",
            "nested" : {
                "value" : "nested value 1"
            }
        }`)

        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert.equal(result.value1, 'string value 1')
        assert(result.nested instanceof NestedObject)
        assert.equal(result.nested.value, 'nested value 1')
    })
    it('マッピングを指定しない場合はただのobject', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
        }
        // ソースjson
        let source_json = JSON.parse(`{
            "value1" : "string value 1",
            "nested" : {
                "value" : "nested value 1"
            }
        }`)
        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert.equal(result.value1, 'string value 1')
        assert(!(result.nested instanceof NestedObject)) // 当たり前だけど
        assert.equal(result.nested.value, 'nested value 1')
    })

    it('リストのプロパティを持つ場合', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
            nestedObjects : NestedObject,
        }

        // ソースjson
        let source_json = JSON.parse(`{
            "value1" : "string value 1",
            "nestedObjects" : [
                {
                    "value" : "0"
                },
                {
                    "value" : "1"
                },
                {
                    "value" : "2"
                }
            ]
        }`)

        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert.equal(result.value1, 'string value 1')
        assert(result.nestedObjects instanceof Array)
        assert.equal(result.nestedObjects.length, 3)
        
        for (let i = 0; i<result.nestedObjects.length; i++) {
            assert(result.nestedObjects[i] instanceof NestedObject)
            assert.equal(result.nestedObjects[i].value, i)
        }
    })
    
    it('ルート要素自体がリストの場合', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
        }

        // ソースjson
        let source_json = JSON.parse(`[
            {
                "value" : "0"
            },
            {
                "value" : "1"
            },
            {
                "value" : "2"
            }
        ]`)

        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert(result instanceof Array)
        assert.equal(result.length, 3)

        for (let i = 0; i<result.length; i++) {
            assert(result[i] instanceof TestObject)
            assert.equal(result[i].value, i)
        }
    })
    
    it('jsonとクラスを相互に変換する', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
            nested : NestedObject
        }
        // ソースjson - 比較の都合のため一行で
        let source_json = JSON.parse(`{"value1": "string value 1", "nested": {"value": "nested value 1"}}`)

        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert.equal(result.value1, 'string value 1')
        assert(result.nested instanceof NestedObject)
        assert.equal(result.nested.value, 'nested value 1')

        // 再変換しても結果が同じこと
        result = builder.build(JSON.parse(JSON.stringify(result)))
        assert.equal(result.value1, 'string value 1')
        assert(result.nested instanceof NestedObject)
        assert.equal(result.nested.value, 'nested value 1')
    })
    it('型変換に使ってみる', function(){
        // マッピング定義
        let object_mapping = {
            ROOT_CLASS : TestObject,
            value1 : String
        }
        // ソースjson - データは数値
        let source_json = JSON.parse(`{
            "value1" : 256
        }`)
        let builder = new ObjectBuilder(object_mapping)
        let result = builder.build(source_json)
        assert.strictEqual(result.value1, '256')

        // 生成したクラスのメソッドを呼んでみる
        assert.equal(result.test_method(), 'return:256')
    })
})
```
</div></details>

## 相互変換について
jsonをクラスの振舞いで処理した後、インスタンスを再びjsonに変換したいことも多々あると思いますが、JavaScriptでは**クラスのインスタンスもそのまま`JSON.stringify`で変換することができます。**

## ブラウザでの利用を考える
今回はNode.jsで動かしてみましたが、JavaScriptの主要な用途としては他にブラウザ上での利用が考えられます。たぶん、このコードはES6が動く環境なら動くと思います（module周りだけ自信なし）。ES6が動かないなら、Babel等で変換してしまえば問題ないと思います。[^ES5-ES6]
しかし、**フロントエンドでクラスが振舞いを持つことには良し悪しがある**と思います。例えばフロントエンドがビジネスロジックを持ったりしてしまうと、フロントとバックの両方にビジネスロジックが散ったりして、コードが混乱する恐れがあります。[^f-end-b-end]
ユーザ定義のフロントエンドにクラスを持たせるなら、外観に特化した振舞いだけを持つくらいに抑えておくのが無難かと思います。[^js-framework]

[^using-map]: 本当はなにか定数をキーに充てて、ObjectBuilderのモジュールから取得させた方がバグは少なくなりそうです。`Map`の使用も考えましたが、記事の目的からは外れるのでやめました。
[^call-constructor]: 高機能に作るなら、呼び出される関数がコンストラクタであるか否か判別することもできそうですが、記事で書くには複雑です。他に、全ての個所でnew演算子の使用をやめて、ファクトリメソッド的な関数を渡してもいい。しかし、本題とは逸れちゃいますね。
[^ES5-ES6]: そもそもES5だとclassがないからそのままでは意味がなさそうです。
[^js-framework]: しかし、Vue.jsとかフロントエンドのフレームワークが、ユーザ定義のクラスと噛み合って動作するか、ちょっと調べてみないと解らないですね。
[^f-end-b-end]: フロント側でも書けるロジックというのはあっても、たとえばDB周りとか、バック側じゃないと書けないロジックというのも通常は多々あるはずです。フロント側も概観のためにかなりの記述量が必要なので、ビジネスロジックはバック側にまとめた方が無難かと思います。