🎻

jms/serializerはオブジェクトの再帰的参照をシリアライズできない

2018/06/26に公開

やりたかったこと

OneToMany/ManyToOneなリレーションを持つ2つのエンティティを jms/serializer でシリアライズしたかった。

サンプル

// User.php

class User
{
    public $name;

    /**
     * @var Post[]
     */
    public $posts;
}
// Post.php

class Post
{
    public $title;

    /**
     * @var User
     */
    public $user;
}
// index.php

function getUser() {
    $user = new User();
    $user->name = 'user1';

    for ($i = 0; $i < 2; $i++) {
        $post = new Post();
        $post->title = 'post' . ($i + 1);
        $post->user = $user;
        $user->posts[] = $post;
    }

    return $user;
}

$serializer = SerializerBuilder::create()->build();
$json = $serializer->serialize(getUser(), 'json');

echo $json;

結果1

{
	"name": "user1",
	"posts": [
		{
			"title": "post1"
		},
		{
			"title": "post2"
		}
	]
}

posts.user が消え去ってる。

posts.user にはどちらも親である user1User インスタンスが入っているので、確かにこのままだと無限に再帰してしまうので消える仕様は正しいように思う。

じゃあ、 MaxDepth メタデータ を使って再帰の深さを設定すれば安全にシリアライズできるのでは?と考えた。

やってみた

// index.php

+ \Doctrine\Common\Annotations\AnnotationRegistry\AnnotationRegistry::registerLoader('class_exists');

function getUser() {
    $user = new User();
    :
    :

- $serializer = SerializerBuilder::create()->build();
+ $serializer = SerializerBuilder::create()
+     ->setSerializationContextFactory(function () {
+         return SerializationContext::create()
+             ->enableMaxDepthChecks()  // MaxDepthメタデータの使用を有効化
+         ;
+     })
+     ->build()
+ ;
// Post.php

+ use JMS\Serializer\Annotation\MaxDepth;

class Post
{
    public $title;

    /**
     * @var User
+    *
+    * @MaxDepth(1)
     */
    public $user;
}

メタデータはアノテーションの他にXMLやYAMLでも設定可能(参考1参考2

結果2

{
	"name": "user1",
	"posts": [
		{
			"title": "post1"
		},
		{
			"title": "post2"
		}
	]
}

変わらず。

nullをシリアライズするようにしてみる

(printデバッグで)調べたところ、

// JsonSerializationVisitor.php

if ((null === $v && $context->shouldSerializeNull() !== true)
    || (true === $metadata->skipWhenEmpty && ($v instanceof \ArrayObject || \is_array($v)) && 0 === count($v))
) {
    return;
}

https://github.com/schmittjoh/serializer/blob/1.12.1/src/JMS/Serializer/JsonSerializationVisitor.php#L146-L150

ここでreturnしてるようだったので、分かりやすさのために $context->shouldSerializeNull() がtrueになるようコンテキストを設定してみた。

// index.php

$serializer = SerializerBuilder::create()
    ->setSerializationContextFactory(function () {
        return SerializationContext::create()
            ->enableMaxDepthChecks()
+           ->setSerializeNull(true)
        ;
    })
    ->build()
;

結果3

{
	"name": "user1",
	"posts": [
		{
			"title": "post1",
			"user": null
		},
		{
			"title": "post2",
			"user": null
		}
	]
}

うむ。確かに posts.user はnullにシリアライズされている。

nullにシリアライズしている箇所を特定

(printデバッグで)調べたところ、

// GraphNavigator.php

if ($context->isVisiting($data)) {
    return null;
}

https://github.com/schmittjoh/serializer/blob/1.12.1/src/JMS/Serializer/GraphNavigator.php#L151-L153

ここで $data (中身は User インスタンス)が isVisiting だと問答無用でnullがreturnされることが分かった。

プロパティにトップダウンでvisitしつつ、visit中のオブジェクトをスタッキングしておいて、2回目以降にvisitした場合は isVisiting と見なしてnullを返すという実装みたい。

https://github.com/schmittjoh/serializer/blob/1.12.1/src/JMS/Serializer/SerializationContext.php#L65

実験:同一オブジェクトじゃなければ行けるのか

visitした User インスタンスがスタッキング済みのオブジェクトと一致した場合に上記の挙動になるので、 clone して別のオブジェクトにすれば isVisiting はfalseになるはず。

// index.php

function getUser() {
    $user = new User();
    $user->name = 'user1';

    for ($i = 0; $i < 2; $i++) {
        $post = new Post();
        $post->title = 'post' . ($i + 1);
-       $post->user = $user;
+       $post->user = clone $user;
        $user->posts[] = $post;
    }

    return $user;
}

結果4

{
	"name": "user1",
	"posts": [
		{
			"title": "post1",
			"user": {
				"name": "user1",
				"posts": null
			}
		},
		{
			"title": "post2",
			"user": {
				"name": "user1",
				"posts": [
					{
						"title": "post1",
						"user": {
							"name": "user1",
							"posts": null
						}
					}
				]
			}
		}
	]
}

なるほど行けた。

結論

同一オブジェクトが再帰的に参照されるエンティティはシリアライズできない。MaxDepth のチェック以前に弾かれている(たぶん)。

動作確認環境、置いときます。
https://github.com/ttskch/jms-serializer-recursion-sample

感想

こういう場合、APIを分けて、フロントからは UserPost[] は別々のAPIで取得するのが一般的なんだと思われます。

Angular(rxjs@6)の場合は以下のようなイメージ。

// user-repository.ts

get(id: string): Observable<User> {
  return this.http.get(`/users/${id}`).pipe(
    map(response => getUserFromResponse(response)),
    switchMap((user: User) => this.postRepository.cgetByUser(user).pipe(
      map((posts: Post[]) => {
        user.posts = posts;
        return user;
      })
    ))
  );
}
GitHubで編集を提案

Discussion