JsonSerializableでオブジェクトのJSON表現を自分で決める

JsonSerializable は、json_encode() したときの自分の表現をクラス側で決めるためのインターフェイスです。PHP 5.4から使え、jsonSerialize(): mixed を1つ実装するだけで済みます。

APIのレスポンスを組み立てるとき、コントローラで毎回オブジェクトを配列に詰め替えている、という現場をよく見ます。詰め替え処理があちこちに散らばって、片方だけ直し忘れる。あれは大体、JSON表現の置き場所を決めていないのが原因だと思っています。

何もしないと公開プロパティが素通しで出る

json_encode() にオブジェクトをそのまま渡すと、public なプロパティだけが出力されます。private や protected は無視されます。つまり、プロパティを private にした瞬間にJSONが空になる。

class User
{
    public function __construct(
        private int $id,
        private string $name,
        private string $passwordHash,
    ) {}
}

echo json_encode(new User(1, '田中', 'xxxx')); // {}

逆にすべて public にすると、今度は passwordHash まで出ていきます。カプセル化とJSON出力が正面からぶつかる場所で、ここを埋めるのが JsonSerializable です。

class User implements JsonSerializable
{
    public function __construct(
        private int $id,
        private string $name,
        private string $passwordHash,
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'id'   => $this->id,
            'name' => $this->name,
        ];
    }
}

echo json_encode(new User(1, '田中', 'xxxx'), JSON_UNESCAPED_UNICODE);
// {"id":1,"name":"田中"}
// フラグを付けないと name は "\u7530\u4e2d" と出る

インターフェイス側の宣言は mixed ですが、実装側で array のように狭めるのは問題ありません。戻り値の型は共変なので、より具体的な型を書けます。読む側にとっても、そのほうが親切だと思います。

返した値はどう扱われる?

jsonSerialize() の戻り値は、そのオブジェクトの代わりに json_encode() がもう一度エンコードします。配列でも文字列でも数値でも、別のオブジェクトでも構いません。

final class Money implements JsonSerializable
{
    public function __construct(
        private int $amount,
        private string $currency,
    ) {}

    // スカラーを返してもよい
    public function jsonSerialize(): string
    {
        return $this->amount . ' ' . $this->currency;
    }
}

class Order implements JsonSerializable
{
    public function __construct(
        private int $id,
        private Money $total,
    ) {}

    public function jsonSerialize(): array
    {
        return ['id' => $this->id, 'total' => $this->total];
    }
}

echo json_encode(new Order(7, new Money(1200, 'JPY')));
// {"id":7,"total":"1200 JPY"}

入れ子の Money も、その場で jsonSerialize() が呼ばれます。中で自分から json_encode() を呼ぶ必要はありません。呼んでしまうと二重エンコードになって、JSONの中に文字列としてのJSONが埋まる、あの見慣れた事故になります。

配列のキーが飛ぶとオブジェクトになる

実務で一番踏むのはここでした。PHPの配列がJSONの配列([])になるのは、キーが 0 から連番で並んでいるときだけです。1つでも飛ぶと、キー付きのオブジェクト({})に化けます。

class UserList implements JsonSerializable
{
    public function __construct(private array $users) {}

    public function jsonSerialize(): array
    {
        // 退会ユーザーを除外
        return array_filter($this->users, fn($u) => $u->isActive());
    }
}

// 2番目が除外されると
// {"0":{...},"2":{...}}  ← 配列のつもりがオブジェクト

array_filter() はキーを保持するので、こうなります。フロント側は配列を期待して map() を呼んでいて、そこで落ちる。しかも全員アクティブなときは再現しないので、原因にたどり着くまでが長いです。

public function jsonSerialize(): array
{
    return array_values(
        array_filter($this->users, fn($u) => $u->isActive())
    );
}

array_values() を挟むだけです。コレクション的なクラスで jsonSerialize() を書くときは、返す直前に必ず通す、くらいの決めごとにしてしまってよいと思います。

空のときに [] にするか {} にするか

関連して、空配列は必ず [] になります。連想配列を意図していた場所が空だと、型が揺れます。

echo json_encode(['meta' => []]);           // {"meta":[]}
echo json_encode(['meta' => (object) []]);  // {"meta":{}}

JSON_FORCE_OBJECT という定数もありますが、これは json_encode() 呼び出し全体に効いてしまい、配列にしたい部分まで巻き込みます。個別に (object) でキャストするか、素直に new stdClass() を返すほうが事故が少ないと感じています。

エラーはどこから飛んでくるのか

json_encode() は失敗時に false を返します。JSON_THROW_ON_ERROR(PHP 7.3以降)を付けると、代わりに JsonException が投げられます。不正なUTF-8や、深すぎる入れ子($depth の既定値は512)で起きます。

try {
    $json = json_encode($order, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
} catch (JsonException $e) {
    // json_last_error_msg() 相当のメッセージが入る
    error_log('encode failed: ' . $e->getMessage());
}

注意したいのは、jsonSerialize() の中で投げた例外はこのフラグとは無関係に、そのまま json_encode() を突き抜けて出てくることです。中でリポジトリを叩いて遅延ロードするような実装にしていると、DB例外がレスポンス生成の一番外側で飛んできて面食らいます。jsonSerialize() は手元のデータを整形するだけ、と決めておくのが無難です。

enumの場合はどうなる?

バックド enum、つまり値を持つenumは、何もしなくてもその値としてエンコードされます。値を持たない純粋なenumはJSON表現が決められないため、エンコードしようとするとエラーになります。

enum Status: string
{
    case Active = 'active';
    case Banned = 'banned';
}

echo json_encode(['status' => Status::Active]); // {"status":"active"}

enum Color  // 値を持たない
{
    case Red;
}
// json_encode(Color::Red) はエラーになる

純粋なenumをどうしてもJSONに出したいなら、enum自身に JsonSerializable を実装できます。ただ、そこまでするなら最初からバックド enum にしたほうが素直かもしれません。バックド enum の表現を変えたいとき(ラベルも一緒に返したい、など)も同じく実装で上書きできます。

まとめ

JSON表現をクラスの中に置けるようになると、コントローラから詰め替えコードが消えます。private を守ったまま出力を制御できるのが本来の狙いで、実務で刺さるのは array_filter 後のキー飛びと、空配列が [] になる話のほうだったりします。この2つを知っているだけでも、フロントとの往復がだいぶ減るはずです。

逆に、表示都合のロジックが jsonSerialize() にどんどん溜まってきたら、それはモデルではなく別のプレゼンテーション層に置くべき仕事なのだと思います。線引きの目安として、私は「保存しているデータを並べ替えるだけか」を見ています。

よくある質問

Q. jsonSerialize() は serialize() にも影響しますか?
A. しません。JsonSerializablejson_encode() 専用です。PHPの serialize() の挙動を変えたい場合は __serialize() / __unserialize() を実装します。

Q. 戻り値の型に array と書いても大丈夫ですか?
A. 大丈夫です。インターフェイスの宣言は mixed ですが、戻り値の型は共変なので、実装側でより狭い型に絞れます。

Q. json_decode() で元のオブジェクトに戻せますか?
A. 戻りません。json_decode() は連想配列か stdClass を返すだけです。復元したいなら、名前付きコンストラクタ(fromArray() のような静的メソッド)を自分で用意することになります。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です