jms/serializerはオブジェクトの再帰的参照をシリアライズできない
やりたかったこと
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
にはどちらも親である user1
の User
インスタンスが入っているので、確かにこのままだと無限に再帰してしまうので消える仕様は正しいように思う。
じゃあ、 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;
}
結果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; }
ここで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を分けて、フロントからは User
と Post[]
は別々の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;
})
))
);
}
Discussion