できない.dev

JSONをファイルに保存するには

データを JSON ファイルとして保存する基本形を各言語で示す。
一時ファイルに書き終えてから本来の名前に置き換えて、失敗しても元のファイルを壊さないこと、UTF-8 で書いて BOM を付けないこと、末尾の改行やエスケープの既定が言語ごとに違うことまでを扱う。

公開:

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

Python 実行確認済み

import json
import os
 
config = {"name": "予約API", "endpoint": "https://example.com/api?v=2&lang=ja", "ports": [80, 443]}
path = "config.json"
tmp = path + ".tmp"
 
# 一時ファイルに書き終えてから置き換えるので、途中で失敗しても元のファイルは壊れない
with open(tmp, "w", encoding="utf-8") as f:
    json.dump(config, f, ensure_ascii=False, indent=2)
    f.write("\n")  # json.dump は末尾に改行を付けない
os.replace(tmp, path)
 
# config.json の中身:
# {
#   "name": "予約API",
#   "endpoint": "https://example.com/api?v=2&lang=ja",
#   "ports": [
#     80,
#     443
#   ]
# }

json.dump で一時ファイルに書き、書き終えてから os.replace で本来の名前に置き換える。
json.dump は変換しながら少しずつ書き込むので、途中で変換できない値に当たると書きかけのファイルが残り、本来の名前へ直接書いていると前回の正常な内容まで失うからである。
encoding を省略すると日本語版 Windows では cp932 で書かれ、ほかの環境から UTF-8 として読めなくなるため明示する。

うまくいかない時: Python で「json.decoder.JSONDecodeError: Expecting value」が解消できないPython で「UnicodeDecodeError」を解消できずファイルが読めない

JavaScript 実行確認済み

import { rename, writeFile } from "node:fs/promises";
 
const config = { name: "予約API", endpoint: "https://example.com/api?v=2&lang=ja", ports: [80, 443] };
const path = "config.json";
const tmp = `${path}.tmp`;
 
// JSON.stringify は末尾に改行を付けないので自分で足す
await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
// 書き終えてから置き換えるので、途中で失敗しても元のファイルは壊れない
await rename(tmp, path);
 
// config.json の中身:
// {
//   "name": "予約API",
//   "endpoint": "https://example.com/api?v=2&lang=ja",
//   "ports": [
//     80,
//     443
//   ]
// }

JSON.stringify で文字列を作ってから writeFile で一時ファイルに書き、rename で置き換える。
JSON.stringify は変換を終えてから文字列を返すので、BigInt のように変換できない値があれば書き込む前に TypeError で止まり、ファイルには触れない。
Map と Set は例外にならず {} として保存されて中身が消えるため、保存する前に Object.fromEntries や配列へ変換しておく。

TypeScript 実行確認済み

import { rename, writeFile } from "node:fs/promises";
 
type Config = { name: string; endpoint: string; ports: number[] };
 
async function saveJson(path: string, data: Config): Promise<void> {
  const tmp = `${path}.tmp`;
  await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
  await rename(tmp, path); // 書き終えてから置き換える
}
 
await saveJson("config.json", {
  name: "予約API",
  endpoint: "https://example.com/api?v=2&lang=ja",
  ports: [80, 443],
});
 
// config.json の中身:
// {
//   "name": "予約API",
//   "endpoint": "https://example.com/api?v=2&lang=ja",
//   "ports": [
//     80,
//     443
//   ]
// }

処理は JavaScript と同じで、保存するデータの形を型で決めておける点だけが異なる。
ただし型が保証するのは形だけで、JSON にできる値かどうかは検査しない。
Map<string, number> のフィールドを持つ型でもコンパイルは通り、保存すると {} になるので、JSON に載せる型は文字列・数値・真偽値・配列・プレーンなオブジェクトで組み立てる。

Go 実行確認済み

package main
 
import (
	"encoding/json"
	"log"
	"os"
)
 
type Config struct {
	Name     string `json:"name"`
	Endpoint string `json:"endpoint"`
	Ports    []int  `json:"ports"`
}
 
func saveJSON(path string, v any) error {
	tmp := path + ".tmp"
	f, err := os.Create(tmp)
	if err != nil {
		return err
	}
	enc := json.NewEncoder(f)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false) // 既定では & が \u0026 になる
	if err := enc.Encode(v); err != nil { // Encode は末尾に改行を付ける
		f.Close()
		os.Remove(tmp)
		return err
	}
	if err := f.Close(); err != nil {
		os.Remove(tmp)
		return err
	}
	// 書き終えてから置き換えるので、途中で失敗しても元のファイルは壊れない
	return os.Rename(tmp, path)
}
 
func main() {
	c := Config{Name: "予約API", Endpoint: "https://example.com/api?v=2&lang=ja", Ports: []int{80, 443}}
	if err := saveJSON("config.json", c); err != nil {
		log.Fatal(err)
	}
}
 
// config.json の中身:
// {
//   "name": "予約API",
//   "endpoint": "https://example.com/api?v=2&lang=ja",
//   "ports": [
//     80,
//     443
//   ]
// }

json.NewEncoder をファイルに向けて書き、Close のエラーまで確かめてから os.Rename で置き換える。
Go の JSON 出力は既定で & < > を \u0026 などにエスケープするので、URL を含む設定ファイルを人が読むなら SetEscapeHTML(false) を指定する。
Encode は値の末尾に改行を付けるが、json.MarshalIndent の結果には付かない。

Rust 実行確認済み

use serde::Serialize;
use std::fs::{self, File};
use std::io::{BufWriter, Write};
 
#[derive(Serialize)]
struct Config {
    name: String,
    endpoint: String,
    ports: Vec<u16>,
}
 
fn save_json(path: &str, value: &impl Serialize) -> Result<(), Box<dyn std::error::Error>> {
    let tmp = format!("{path}.tmp");
    let mut w = BufWriter::new(File::create(&tmp)?);
    serde_json::to_writer_pretty(&mut w, value)?;
    w.write_all(b"\n")?; // to_writer_pretty は末尾に改行を付けない
    // drop に任せると書き込みエラーが捨てられるので、flush で結果を受け取る
    w.flush()?;
    drop(w);
    fs::rename(&tmp, path)?; // 書き終えてから置き換える
    Ok(())
}
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config {
        name: "予約API".to_string(),
        endpoint: "https://example.com/api?v=2&lang=ja".to_string(),
        ports: vec![80, 443],
    };
    save_json("config.json", &config)
}
 
// config.json の中身:
// {
//   "name": "予約API",
//   "endpoint": "https://example.com/api?v=2&lang=ja",
//   "ports": [
//     80,
//     443
//   ]
// }

serde_json::to_writer_pretty で BufWriter 越しに一時ファイルへ書き、flush してから fs::rename で置き換える。
BufWriter は drop のときに残りを書き出すが、そこで起きたエラーは捨てられるので、flush を明示して Result を受け取る。
serde と serde_json は標準ライブラリに無いため、cargo add serde --features derive と cargo add serde_json で依存に加える。

うまくいかない時: Rust で「failed to resolve: use of undeclared crate or module」(E0433) が解決できない

Java 実行確認済み

import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
 
record Config(String name, String endpoint, List<Integer> ports) {}
 
public class Main {
    public static void main(String[] args) throws Exception {
        var config = new Config("予約API", "https://example.com/api?v=2&lang=ja", List.of(80, 443));
        // 先に文字列を作るので、変換に失敗してもファイルには触れない
        String json = new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(config);
 
        Path path = Path.of("config.json");
        Path tmp = Path.of("config.json.tmp");
        Files.writeString(tmp, json + "\n"); // UTF-8(BOM なし)で書く
        // 書き終えてから置き換えるので、途中で失敗しても元のファイルは壊れない
        Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
    }
}
 
// config.json の中身:
// {
//   "name" : "予約API",
//   "endpoint" : "https://example.com/api?v=2&lang=ja",
//   "ports" : [ 80, 443 ]
// }

Jackson の writeValueAsString で先に文字列を作り、Files.writeString で一時ファイルに書いてから Files.move で置き換える。
Files.writeString は既定で UTF-8 を使い、BOM も付けない。
writerWithDefaultPrettyPrinter の整形はキーの後ろが " : " になり、数値の配列を 1 行にまとめるので、ほかの言語で整形したファイルとは見た目が揃わない。
JDK に JSON ライブラリは含まれないため、Jackson(例は 2.21 で確認)を依存に加える。

うまくいかない時: Java の javac で「package does not exist」が解決できない

C# 実行確認済み

using System.Text.Encodings.Web;
using System.Text.Json;
 
var config = new Config("予約API", "https://example.com/api?v=2&lang=ja", new[] { 80, 443 });
var options = new JsonSerializerOptions
{
    WriteIndented = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // 日本語と & をそのまま出す
};
string json = JsonSerializer.Serialize(config, options);
 
var tmp = "config.json.tmp";
// エンコーディングを渡さなければ BOM なしの UTF-8 で書かれる
File.WriteAllText(tmp, json + "\n");
// 書き終えてから置き換えるので、途中で失敗しても元のファイルは壊れない
File.Move(tmp, "config.json", overwrite: true);
 
record Config(string Name, string Endpoint, int[] Ports);
 
// config.json の中身:
// {
//   "name": "予約API",
//   "endpoint": "https://example.com/api?v=2&lang=ja",
//   "ports": [
//     80,
//     443
//   ]
// }

JsonSerializer.Serialize で文字列を作り、File.WriteAllText で一時ファイルに書いてから File.Move(overwrite: true)で置き換える。
既定のエンコーダは日本語を \u4E88 のように、& を \u0026 にエスケープし、プロパティ名も Name のまま出すので、人が読む設定ファイルなら Encoder と PropertyNamingPolicy を指定する。
File.WriteAllText にエンコーディングを渡さなければ BOM なしの UTF-8 で書かれる。

つまずき

保存でいちばん困るのは、書き込みに失敗したときに元のファイルまで失うことである。
Python では open で "w" を指定した時点でファイルは空になり、json.dump は変換しながら書き込むので、途中に datetime のような変換できない値があると、TypeError で止まったときには書きかけの 38 バイトだけが残る。
次に json.load で読むと JSONDecodeError: Expecting value になり、前回の正常な設定は戻らない(本記事の作成時に手元で確認した)。
サンプルがどの言語でも一時ファイルに書いてから本来の名前へ置き換えているのはこのためで、失敗しても前回のファイルがそのまま残る。
プロセスの強制終了だけでなく電源断まで考えるなら、置き換える前に fsync でディスクへの書き込みを確定させる。

BOM を付けない

C# で File.WriteAllText の第 3 引数に Encoding.UTF8 を渡すと、ファイルの先頭に BOM(EF BB BF の 3 バイト)が付く。
エンコーディングを省略すれば付かない。
BOM 付きの JSON は、Python の json.load(encoding="utf-8")では JSONDecodeError: Unexpected UTF-8 BOM、Node.js の JSON.parse では SyntaxError、Go の json.Unmarshal では invalid character 'ï' になり、書いた側では正しく見えても読む側で失敗する(いずれも本記事の作成時に手元で確認した)。
JSON の仕様である RFC 8259 も、ネットワーク越しに送る JSON テキストの先頭に BOM を付けてはならないとしている。
読む側で吸収するしかない場合、Python なら encoding="utf-8-sig" で開けば BOM を読み飛ばせる。

Windows の Python は encoding と改行を明示する

open の encoding を省略すると、日本語版 Windows の Python 3.12 ではロケールの cp932 で書かれる。
{"name": "予約API"} を保存したファイルを encoding="utf-8" で読み直すと UnicodeDecodeError になり、cp932 で表せない絵文字を含むと書き込みの途中で UnicodeEncodeError が出て、ファイルは {"name": までで止まる。
また、テキストモードでは \n が \r\n に変換されて書かれるので、Linux で保存したファイルとは改行が食い違う。
改行を LF にそろえたいときは open に newline="\n" を渡す。

末尾の改行と整形の見た目は言語で違う

JSON.stringify、Python の json.dump、Rust の to_writer_pretty、Jackson、System.Text.Json はいずれも末尾に改行を付けないので、サンプルでは自分で 1 つ足している。
Go の Encoder.Encode だけは改行を付けるが、json.MarshalIndent は付けない。
改行が無いファイルを Git で管理すると、差分に No newline at end of file の表示が出る。
整形の見た目も揃っておらず、Jackson の既定の整形はキーの後ろが " : " になり、数値の配列を 1 行にまとめる。
複数の言語から同じファイルを書き換える構成では、どの言語で保存したかによって中身は同じでも差分が出る。

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