できない.dev

2つのJSONをマージするには

既定値の JSON に設定ファイルの JSON を上書きする形のマージを各言語で示す。
浅いマージで入れ子が丸ごと消える問題と、再帰的に統合する書き方の違いまでを扱う。

公開:

各言語見出しの横のバッジは検証状態を表す。実行確認済みはコードを実際に実行して確認したもの、静的確認は構文と公式 API ドキュメントで確認したものである。

Python 実行確認済み

import json
 
defaults = json.loads('{"server": {"host": "localhost", "port": 8080}, "debug": false}')
override = json.loads('{"server": {"port": 3000}}')
 
# | は浅いマージ。server ごと置き換わって host が消える
print(defaults | override)  # {'server': {'port': 3000}, 'debug': False}
 
 
def deep_merge(base: dict, patch: dict) -> dict:
    merged = dict(base)
    for key, value in patch.items():
        if isinstance(merged.get(key), dict) and isinstance(value, dict):
            merged[key] = deep_merge(merged[key], value)
        else:
            merged[key] = value
    return merged
 
 
print(deep_merge(defaults, override))
# {'server': {'host': 'localhost', 'port': 3000}, 'debug': False}

辞書の | 演算子は同じキーを丸ごと置き換える浅いマージなので、入れ子の一部だけを上書きすると host が消える。
入れ子まで統合したいときは、両方が辞書のときだけ再帰する関数を自分で用意する。

うまくいかない時: Python の dict アクセスで KeyError が解消できない

JavaScript 実行確認済み

const defaults = JSON.parse('{"server": {"host": "localhost", "port": 8080}, "debug": false}');
const override = JSON.parse('{"server": {"port": 3000}}');
 
// スプレッド構文は浅いマージ。server ごと置き換わって host が消える
console.log({ ...defaults, ...override });
// { server: { port: 3000 }, debug: false }
 
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
 
function deepMerge(base, patch) {
  const merged = { ...base };
  for (const [key, value] of Object.entries(patch)) {
    merged[key] =
      isPlainObject(merged[key]) && isPlainObject(value)
        ? deepMerge(merged[key], value)
        : value;
  }
  return merged;
}
 
console.log(deepMerge(defaults, override));
// { server: { host: 'localhost', port: 3000 }, debug: false }

スプレッド構文は最上位のキーだけを見る浅いマージで、値がオブジェクトでもそのまま差し替わる。
入れ子を残したいときは、両辺がプレーンなオブジェクトのときだけ再帰する関数を書く。
配列を統合対象から外すため isPlainObject で Array を弾いている。

TypeScript 実行確認済み

type Json = { [key: string]: unknown };
 
const defaults = JSON.parse(
  '{"server": {"host": "localhost", "port": 8080}, "debug": false}'
) as Json;
const override = JSON.parse('{"server": {"port": 3000}}') as Json;
 
const isPlainObject = (v: unknown): v is Json =>
  typeof v === "object" && v !== null && !Array.isArray(v);
 
function deepMerge(base: Json, patch: Json): Json {
  const merged: Json = { ...base };
  for (const [key, value] of Object.entries(patch)) {
    const current = merged[key];
    merged[key] =
      isPlainObject(current) && isPlainObject(value)
        ? deepMerge(current, value)
        : value;
  }
  return merged;
}
 
console.log(deepMerge(defaults, override));
// { server: { host: 'localhost', port: 3000 }, debug: false }

JSON.parse の戻り値は any なので、扱う形を Json 型として決めてから受け取る。
値が unknown のままでは再帰に渡せないため、型ガードで Json に絞り込んでから deepMerge を呼ぶ。

うまくいかない時: TypeScript で catch の「Object is of type 'unknown'」が解決できない

Go 静的確認

package main
 
import (
	"encoding/json"
	"fmt"
)
 
func deepMerge(base, patch map[string]any) map[string]any {
	merged := make(map[string]any, len(base))
	for k, v := range base {
		merged[k] = v
	}
	for k, v := range patch {
		if inner, ok := merged[k].(map[string]any); ok {
			if patchInner, ok := v.(map[string]any); ok {
				merged[k] = deepMerge(inner, patchInner)
				continue
			}
		}
		merged[k] = v
	}
	return merged
}
 
func main() {
	var defaults, override map[string]any
	json.Unmarshal([]byte(`{"server": {"host": "localhost", "port": 8080}, "debug": false}`), &defaults)
	json.Unmarshal([]byte(`{"server": {"port": 3000}}`), &override)
 
	out, _ := json.Marshal(deepMerge(defaults, override))
	fmt.Println(string(out))
	// {"debug":false,"server":{"host":"localhost","port":3000}}
}

map[string]any へ Unmarshal すると入れ子も map[string]any になるので、型アサーションで両方がマップのときだけ再帰する。
マップの代入は参照を渡すため、元の値を壊さないよう新しいマップへ複製してから書き換える。

Rust 静的確認

use serde_json::{json, Value};
 
fn deep_merge(base: &mut Value, patch: &Value) {
    match (base, patch) {
        (Value::Object(base_map), Value::Object(patch_map)) => {
            for (key, value) in patch_map {
                deep_merge(base_map.entry(key.clone()).or_insert(Value::Null), value);
            }
        }
        (base_slot, value) => *base_slot = value.clone(),
    }
}
 
fn main() {
    let mut defaults = json!({"server": {"host": "localhost", "port": 8080}, "debug": false});
    let override_cfg = json!({"server": {"port": 3000}});
 
    deep_merge(&mut defaults, &override_cfg);
    println!("{}", defaults);
    // {"debug":false,"server":{"host":"localhost","port":3000}}
}

serde_json::Value のまま再帰し、両方がオブジェクトのときだけキー単位で降りていく。
entry(...).or_insert(Value::Null) を挟むと、patch にしか無いキーも同じ経路で書き込める。
serde_json クレートを Cargo.toml に足しておく。

Java 静的確認

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Iterator;
 
public class MergeJson {
    static ObjectNode deepMerge(ObjectNode base, ObjectNode patch) {
        ObjectNode merged = base.deepCopy();
        Iterator<String> names = patch.fieldNames();
        while (names.hasNext()) {
            String key = names.next();
            JsonNode current = merged.get(key);
            JsonNode value = patch.get(key);
            if (current instanceof ObjectNode && value instanceof ObjectNode) {
                merged.set(key, deepMerge((ObjectNode) current, (ObjectNode) value));
            } else {
                merged.set(key, value.deepCopy());
            }
        }
        return merged;
    }
 
    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        ObjectNode defaults = (ObjectNode) mapper.readTree(
                "{\"server\": {\"host\": \"localhost\", \"port\": 8080}, \"debug\": false}");
        ObjectNode override = (ObjectNode) mapper.readTree("{\"server\": {\"port\": 3000}}");
 
        System.out.println(deepMerge(defaults, override));
        // {"server":{"host":"localhost","port":3000},"debug":false}
    }
}

Jackson の ObjectNode を使い、両方がオブジェクトのときだけ再帰して統合する。
set はノードの参照をそのまま持たせるため、元の JSON を後から変更しても影響が出ないよう deepCopy を挟んでいる。

C# 実行確認済み

using System.Text.Json.Nodes;
 
var defaults = JsonNode.Parse("""{"server": {"host": "localhost", "port": 8080}, "debug": false}""")!.AsObject();
var patch = JsonNode.Parse("""{"server": {"port": 3000}}""")!.AsObject();
 
static JsonObject DeepMerge(JsonObject baseObj, JsonObject patch)
{
    var merged = (JsonObject)baseObj.DeepClone();
    foreach (var (key, value) in patch)
    {
        if (merged[key] is JsonObject inner && value is JsonObject patchInner)
            merged[key] = DeepMerge(inner, patchInner);
        else
            merged[key] = value?.DeepClone();
    }
    return merged;
}
 
Console.WriteLine(DeepMerge(defaults, patch).ToJsonString());
// {"server":{"host":"localhost","port":3000},"debug":false}

System.Text.Json の JsonNode は追加の依存なしで木構造を組み替えられる。
JsonNode は 1 つの親にしか属せないため、別の木へ移すときは DeepClone を通してから代入する。

つまずき

既定値と設定ファイルを合わせるつもりで浅いマージを使うと、入れ子のオブジェクトが丸ごと置き換わる。
上の例では port だけを上書きしたのに host が消えてしまう。
設定の一部だけを上書きする用途では、ほぼ常に再帰的なマージが要る。

配列はマージしない

ここで示した再帰は、両方がオブジェクトのときだけ降りていき、配列は後勝ちで置き換える。
配列を連結するか置き換えるかは仕様の問題で、正解は用途によって変わる。
連結すると既定値の要素を消せなくなるため、設定の上書きでは置き換えを既定にしておくほうが扱いやすい。

元のデータを壊さない

マージ処理は元のオブジェクトを書き換えてしまいがちで、既定値を使い回す場面で事故になる。
Python の dict(base)、JavaScript のスプレッド、Java の deepCopy、C# の DeepClone はいずれも新しい入れ物を作るためのもので、複製を挟んでから書き込めば呼び出し元の値は変わらない。

この記事は役立ちましたか?