PHPでオブジェクトをJSONに変換するって思った以上に大変だった。

PHPでオブジェクトをJSONに変換するって思った以上に大変だった。

単純にオブジェクトをJSONに変換するならjson_encode()関数があります。
ただこの関数、privateprotectedなプロパティは無視されます。
セッター/ゲッターなどprivateなプロパティもあり困ります。
さらにORMなどエンティティクラスなど継承する場合もprivateは大変でした。

オブジェクトのプロパティ一覧を取得する簡単な方法の一つはget_object_vars()関数を使うのですがちょっと曲者でした。

class ParentClass
{
    public string $publicParent = '';
    protected string $protectedParent = '';
    private string $privateParent = '';

    public function __construct()
    {
        // publicChild, protectedChild, publicParent, protectedParent, privateParent
        // privateChildにアクセス出来ない
        $vars = get_object_vars($this);
        var_dump($vars);
    }
}

class ChildClass extends ParentClass
{
    public string $publicChild = '';
    protected string $protectedChild = '';
    private string $privateChild = '';

    public function __construct()
    {
        parent::__construct();

        // publicChild, protectedChild, privateChild, publicParent, protectedParent
        // privateParentにアクセス出来ない
        $vars = get_object_vars($this);
        var_dump($vars);
    }
}

$child = new ChildClass();

// publicChild, ublicParent
$vars = get_object_vars($child);
var_dump($vars);

見ての通りget_object_vars()に渡している値はすべて同じChildClassのインスタンスですが、呼び出す場所で結果が異なります。
この関数は現在のコンテキストからアクセスできるプロパティのみ返すようです。

例えばクラス外からはpublicなプロパティにしかアクセスできません。
privateなプロパティはChildClassからは$privateChildにアクセス出来、ParentClassからは$privateParentにアクセス出来ます。

リフレクション

今度はリフレクションを使います。
渡されたオブジェクトからプロパティ一覧を取得するgetProperties()関数を定義します。

function getProperties($obj)
{
    $reflection = new \ReflectionClass($obj);
    $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PRIVATE);
    $std = new stdClass;

    foreach($properties as $property)
    {
        $property->setAccessible(true);
        $name = $property->getName();
        $value = $property->getValue($obj);
        $std->{$name} = $value;
    }

    return $std;
}

class ParentClass
{
    public string $publicParent = '';
    protected string $protectedParent = '';
    private string $privateParent = '';

    public function __construct()
    {
        $props = getProperties($this);

        // publicChild, protectedChild, publicParent, protectedParent, privateParent
        // privateChildにアクセス出来ない
        $vars = get_object_vars($this);
        var_dump($vars);
    }
}

class ChildClass extends ParentClass
{
    public string $publicChild = '';
    protected string $protectedChild = '';
    private string $privateChild = '';

    public function __construct()
    {
        parent::__construct();

        $props = getProperties($this);

        // publicChild, protectedChild, privateChild, publicParent, protectedParent
        // privateParentにアクセス出来ない
        $vars = get_object_vars($this);
        var_dump($vars);
    }
}

$child = new ChildClass();

// publicChild, protectedChild, privateChild, publicParent, protectedParent
// どのコンテキストから呼び出しても変わらない。
$props = getProperties($child);

// publicChild, ublicParent にしかアクセス出来ない。
$vars = get_object_vars($child);
var_dump($vars);

$props = getProperties($child);

インスタンスが同じである以上どこから呼び出しても結果は同じです。
この例のようにChildClassから見たプロパティ一覧と一致します。

publicChild, protectedChild, privateChild, publicParent, protectedParent

このようにprivateParentにのみアクセスできません。

ちなみにデバッグからインスタンスの構成を見るとこのようになってます。

publicChild: ""
protectedChild: ""
privateChild: ""
publicParent: ""
protectedParent: ""
*ParentClass*privateParent: ""

ParentClassサイドのprivateメンバはクラス名が明記されてます。
このプロパティはリフレクションから取得できませんでした。

ではParentClassとChildClassに同じ名前(値は違う)を定義するとどうなるでしょう!?

class ParentClass
{
    private string $pv = 'P';
}

class ChildClass extends ParentClass
{
    private string $pv = 'C';
}

どちらにもprivate$pvを定義しました。
デバッグからプロパティ一覧を見ると、

pv: "C"
*ParentClass*pv: "P"

ChildClassから見た$pvCなのでリフレクションから取得出来ます。
親クラスのParentClass側の$pvである*ParentClass*pv: "P"は取得出来ません。

先祖をめぐって

ならば先祖をたどってプロパティを取得すればいい!
getProperties()を変更します。

function getProperties($obj)
{
    $std = new stdClass;
    $currentClass = get_class($obj);

    do
    {
        $reflection = new \ReflectionClass($currentClass);
        $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PRIVATE);

        foreach($properties as $property)
        {
            $property->setAccessible(true);
            $name = $property->getName();
            $value = $property->getValue($obj);

            // 先祖に向かってプロパティを調べる上で既にあるプロパティを更新しないことで
            // オーバーライドを実現。
            if(!property_exists($std, $name))
            {
                $std->{$name} = $value;
            }
        }
    }
    while($currentClass = get_parent_class($currentClass));

    return $std;
}

class ParentClass
{
    private string $pv = 'P';

    public string $publicParent = '';
    protected string $protectedParent = '';
    private string $privateParent = '';
}

class ChildClass extends ParentClass
{
    private string $pv = 'C';

    public string $publicChild = '';
    protected string $protectedChild = '';
    private string $privateChild = '';
}

$child = new ChildClass();

$props = getProperties($child);

結果。

pv: "C"
publicChild: ""
protectedChild: ""
privateChild: ""
publicParent: ""
protectedParent: ""
privateParent: ""

$pvについてはParentClassよりChildClassの方が優先されています。
もし

if(!property_exists($std, $name))
{
    $std->{$name} = $value;
}

ここで上書させてしまったらParentClassの方の値がセットされてしまいます。

JSONシリアライズ

話をJSONに戻します。
json_encode()はシリアライズするうえで対象のインスタンスがJsonSerializableインターフェースを実装していたらプロパティ一覧をそのメソッドから取得でき制御することが出来ます。
これが本来一番やりたかったことです。
まずはインターフェースを実装せずにJSON化するとどうなるか。

class Entity
{
    private string $id;

    public function getId(): string { return $this->sid; }

    public function __construct()
    {
        $this->id = 'A123';
    }

}

class User extends Entity
{
    private string $name;

    public function __construct()
    {
        parent::__construct();
        $this->name = 'Pikachu';
    }
}

class Admin extends User
{
    private int $level;

    public function __construct()
    {
        parent::__construct();
        $this->level = 3;
    }
}

$admin = new Admin();

$json = json_encode($admin, JSON_PRETTY_PRINT);

この結果は

{}

空のオブジェクト。
privateなプロパティは無視されています。
ではシリアライズによく使われているget_object_vars()を使うとどうなるか。

class Entity implements JsonSerializable
{
    private string $id;

    public function getId(): string { return $this->sid; }

    public function __construct()
    {
        $this->id = 'A123';
    }

    public function jsonSerialize(): mixed
    {
        return get_object_vars($this);
    }

}

class User extends Entity
{
    private string $name;

    public function __construct()
    {
        parent::__construct();
        $this->name = 'Pikachu';
    }
}

class Admin extends User
{
    private int $level;

    public function __construct()
    {
        parent::__construct();
        $this->level = 3;
    }
}

$admin = new Admin();

$json = json_encode($admin, JSON_PRETTY_PRINT);

最上位クラスにJsonSerializableを実装した場合。
jsonSerialize()get_object_vars($this)を返すと

{
    "id": "A123"
}"

なんと、$idだけ検出されます。
get_object_vars()Entityのコンテキストから見える$idのみ取得するからです。
逆にノーマルにリフレクションを使えばAdminから見える$levelだけが検出されます。

そこで親をたどるgetProperties()を使えば・・・。

function getProperties($obj)
{
    $std = new stdClass;
    $currentClass = get_class($obj);

    do
    {
        $reflection = new \ReflectionClass($currentClass);
        $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PRIVATE);

        foreach($properties as $property)
        {
            $property->setAccessible(true);
            $name = $property->getName();
            $value = $property->getValue($obj);

            // 先祖に向かってプロパティを調べる上で、既にあるプロパティを更新しないことで
            // オーバーライドを実現。
            if(!property_exists($std, $name))
            {
                $std->{$name} = $value;
            }
        }
    }
    while($currentClass = get_parent_class($currentClass));

    return $std;
}

class Entity implements JsonSerializable
{
    private string $id;

    public function getId(): string { return $this->sid; }

    public function __construct()
    {
        $this->id = 'A123';
    }

    public function jsonSerialize(): mixed
    {
        return getProperties($this);
    }

}

class User extends Entity
{
    private string $name;

    public function __construct()
    {
        parent::__construct();
        $this->name = 'Pikachu';
    }
}

class Admin extends User
{
    private int $level;

    public function __construct()
    {
        parent::__construct();
        $this->level = 3;
    }
}

$admin = new Admin();

$json = json_encode($admin, JSON_PRETTY_PRINT);
{
    "level": 3,
    "name": "Pikachu",
    "id": "A123"
}

成功しました。

最後に注意

DB操作のためにDoctrineなどのORMを使う際エンティティクラスを定義します。
通常プロパティはprivateで、そのプロパティへの変更と取得はセッター/ゲッターを定義して行います。
さらにエンティティは複雑に継承関係を築きます。

privateの検出、それも先祖クラスのプロパティの検出は思った以上に大変でした。
今回の例では最上位のEntityクラス派生のインスタンスにしか効果はありません。

もし関連オブジェクトなどでEntityクラスを派生しないものなどがあった場合を想定してません。
そこは適切に対処してください。

当然ながら今回のコードは今のところうまく動いてますが、今後問題が出てくる可能性もあります。
くれぐれも自己責任で使ってください。

function getProperties($obj)
{
    $std = new stdClass;
    $currentClass = get_class($obj);

    do
    {
        $reflection = new \ReflectionClass($currentClass);
        $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PRIVATE);

        foreach($properties as $property)
        {
            $property->setAccessible(true);
            $name = $property->getName();
            $value = $property->getValue($obj);

            // 先祖に向かってプロパティを調べる上で、既にあるプロパティを更新しないことで
            // オーバーライドを実現。
            if(!property_exists($std, $name))
            {
                $std->{$name} = $value;
            }
        }
    }
    while($currentClass = get_parent_class($currentClass));

    return $std;
}

class Entity implements JsonSerializable
{
    private string $id;

    public function getId(): string { return $this->sid; }

    public function __construct()
    {
        $this->id = 'A123';
    }

    public function jsonSerialize(): mixed
    {
        return getProperties($this);
    }

}

class User extends Entity
{
    private string $name;
    private Product $product;

    public function __construct()
    {
        parent::__construct();
        $this->name = 'Pikachu';
        $this->product = new Product();
    }
}

class Admin extends User
{
    private int $level;

    public function __construct()
    {
        parent::__construct();
        $this->level = 3;
    }
}

class Product extends Entity
{
    private int $price;

    public function __construct()
    {
        parent::__construct();
        $this->price = 12345;
    }
}

$admin = new Admin();

$json = json_encode($admin, JSON_PRETTY_PRINT);

例えばUserクラスにProductクラスを追加した場合を考えます。
なんでUserにProductやねんといったツッコミはこの際無視します。

{
    "level": 3,
    "name": "Pikachu",
    "product": {
        "price": 12345,
        "id": "A123"
    },
    "id": "A123"
}

うまくいきました。

もしProductEntityを実装してないとJSON中のproductは空のオブジェクトになってしまいます。
json_encode()の引数でコンバーター的なものがあるといいんですけどねー。
なんかエンティティクラスがちょっとでも汚染されると、イヤ。

BlockEditor certificate css DataGrid Docker Gutenberg Hyper-V iframe MUI openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク