[Symfony][Doctrine] エンティティを自前でJsonSerializableにするときはプロパティの循環参照に要注意
Symfony Advent Calendar 2021 の13日目の記事です!🎄🌙
ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲
昨日は @77web さんの SymfonyUXのchart.jsにchart.jsのプラグインを追加する でした✨
ある日起こったこと
本題です。ある日こんなことがありました。
- 作っているシステムにおいて、以下のような箇所があった
- あるエンティティで
\JsonSerializable::jsonSerialize()
を実装して - twigに
<div id="json" data-json="{{ foo|json_encode }}"></div>
の様にして埋め込むことでフロントエンドに渡し - フロントエンドではそこで渡された内容を使ってとある処理をする
- あるエンティティで
- ある日、お客さんから 「ログインしているユーザーによって↑の箇所のフロントエンドの処理が動作しないことがある」 という不具合報告をいただいた😱
ログインユーザーによって挙動が変わり得るような認識がなかったので原因の調査にやや手間取りました😓
ので、この件について原因と解決方法をまとめておきたいと思います。
原因
直接的な原因は、
- 特定のユーザーでログインしている場合にのみ、エンティティを(twigの
json_encode
フィルタ によって)json_encode()
した結果がfalse
になってしまっていた
ことでした。
音もなく結果が false
になるという json_encode()
の仕様に立腹したのも束の間、ただ僕が知らなかっただけで json_encode()
には JSON_THROW_ON_ERROR
というフラグがちゃんと用意されており(PHP 7.3以降)、これを指定してあげれば失敗時に \JsonException
という例外を投げてくれるということが分かりました。
そこで、以下のようにして JSON_THROW_ON_ERROR
をセットし、
<div id="json" data-json="{{ foo|json_encode(constant('JSON_THROW_ON_ERROR')) }}"></div>
この状態で現象を再現させてみたところ、下図のとおり \JsonException
のエラーメッセージが "Recursion detected"
であることが分かりました。
つまり、原因はシリアライズしようとしたエンティティのプロパティに循環参照があったということのようです。
エンティティのオブジェクトグラフ内にはユーザーの参照を持つプロパティもあったので、確かにログインユーザーによって循環が起こったり起こらなかったりするということはあり得そうです…
ちなみに、twigの
json_encode
フィルタに複数のフラグをセットしたい場合は、以下のようにb-or
オペレータを使えばよい です👌<div id="json" data-json="{{ foo|json_encode(constant('JSON_THROW_ON_ERROR') b-or constant('JSON_UNESCAPED_UNICODE')) }}"></div>
jsonSerialize()
の実装はどうなっていたか
エンティティの エンティティの jsonSerialize()
の実装は以下のような感じになっていました。
public function jsonSerialize(): array
{
$array = get_object_vars($this);
$array['relation1'] = $this->relation1 ? array_merge(get_object_vars($this->relation1), ['id' => $this->relation1->getId()]) : null;
$array['relation2'] = $this->relation2->getId();
$array['createdAt'] = $this->createdAt->format('Y-m-d H:i:s');
$array['updatedAt'] = $this->updatedAt->format('Y-m-d H:i:s');
return $array;
}
- プロパティが増えたり減ったりしてもそのまま動くようにと、プロパティをハードコードするのではなく
get_object_vars
を使って一括で配列化していた -
relation1
プロパティに入っている関連エンティティはその詳細の情報もフロントに渡したかったので内容を二回層目に持たせるようにしていた -
relation2
プロパティに入っている関連エンティティは詳細の情報は不要だったのでIDだけを持たせるようにしていた -
createdAt
updatedAt
は普通に自分で文字列化していた
という具合です。
relation1
の詳細情報を取得するために get_object_vars($this->relation1)
しているところが問題で、 これだと、relation1
のさらに先のリレーションシップに循環参照があると正常にシリアライズできません。
jsonSerialize()
の実装を工夫する)
解決方法その1(自前の というわけで、まずは jsonSerialize()
の実装を工夫して対応してみましょう。
今目の前で起こっている問題を解消するだけなら、以下のように問題となっているリレーションシップを辿らないようにしてあげればよいでしょう。
public function jsonSerialize(): array
{
$array = get_object_vars($this);
- $array['relation1'] = $this->relation1 ? array_merge(get_object_vars($this->relation1), ['id' => $this->relation1->getId()]) : null;
+ $array['relation1'] = $this->relation1 ? array_merge(get_object_vars($this->relation1), ['id' => $this->relation1->getId()], 'subRelation' => $this->relation1->subRelation->getId()) : null;
$array['relation2'] = $this->relation2->getId();
$array['createdAt'] = $this->createdAt->format('Y-m-d H:i:s');
$array['updatedAt'] = $this->updatedAt->format('Y-m-d H:i:s');
return $array;
}
ただ、これだとエンティティの構造が変わったときにこの部分のコードを修正し忘れるとまた問題が再発する可能性があります。
なので、不要なリレーションシップをすべて null
に置き換えてしまう処理を入れておくことにしましょう。
public function jsonSerialize(): array
{
$array = get_object_vars($this);
- $array['relation1'] = $this->relation1 ? array_merge(get_object_vars($this->relation1), ['id' => $this->relation1->getId()], 'subRelation' => $this->relation1->subRelation->getId()) : null;
+ $relation1Array = filter_var(get_object_vars($this->relation1), FILTER_CALLBACK, ['options' => fn($v) => is_object($v) ? null : $v]);
+ $array['relation1'] = $this->relation1 ? array_merge($relation1Array, ['id' => $this->relation1->getId()], 'subRelation' => $this->relation1->subRelation->getId()) : null;
$array['relation2'] = $this->relation2->getId();
$array['createdAt'] = $this->createdAt->format('Y-m-d H:i:s');
$array['updatedAt'] = $this->updatedAt->format('Y-m-d H:i:s');
return $array;
}
パッと見で何をやっているのか分かりにくいですが、
$relation1Array = filter_var(get_object_vars($this->relation1), FILTER_CALLBACK, ['options' => fn($v) => is_object($v) ? null : $v]);
この部分で、filter_var
を array_map_recursive
的に 使って、オブジェクトをすべて null
に置き換えています。
この用法については以下の記事などご参照ください。
これで、循環参照になり得るオブジェクトへの参照を排除しつつ、必要な情報はちゃんとすべて含まれた状態でシリアライズできるようになりました。
JMSSerializerBundle
を導入する)
解決方法その2(素直に …という強引な解決方法をひとまず示しましたが、これぐらい複雑な要件になったら、もはや自前実装でシリアライズするのはやめて素直に JMSSerializerBundle を導入したほうがいいと思います😅
今回の場合であれば、
/**
* @ORM\Entity(repositoryClass=FooRepository::class)
*/
class Foo
{
/**
* @ORM\ManyToOne(targetEntity=Bar::class)
*
* @Serializer\MaxDepth(1)
*/
public $relation1;
/**
* @ORM\ManyToOne(targetEntity=Baz::class)
*
* @Serializer\MaxDepth(1)
*/
public $relation2;
/**
* @ORM\ManyToOne(targetEntity=Qux::class)
*
* @Serializer\Exclude()
*/
public $unnecessaryRelation;
// ...
}
こんな感じで
-
relation1
relation2
などのシリアライズ結果に含めたいリレーションシップにMaxDepth()
を設定 - シリアライズ結果に含める必要のないリレーションシップに
Exclude()
を設定
してあげるだけで、JMSSerializerがいい感じにシリアライズしてくれます。
あとは jms_serialize
というtwigフィルタが用意されているので、
<div id="json" data-json="{{ foo|jms_serialize }}"></div>
こんな感じで完了です。こっちのほうがスマートですね👍
ちなみに MaxDepth()
アノテーションを使う場合は
# config/packages/jms_serializer.yaml
jms_serializer:
default_context:
serialization:
enable_max_depth_checks: true
この設定が必要なので要注意です。
なぜか ドキュメント では言及されていないのですが、設定リファレンス を眺めてみると、
enable_max_depth_checks
がデフォルトでfalse
であることが分かります。
まとめ
というわけで、
- Symfonyのエンティティを自前で
JsonSerializable
にするときはプロパティの循環参照に要注意 - 循環しないように
jsonSerialize()
の実装を工夫することでも解決できるけど - ある程度複雑な要件なら、素直に JMSSerializerBundle を導入したほうがスマートに対応できる
というお話でした。どこかの誰かのお役に立てば幸いです!
Symfony Advent Calendar 2021、明日はまだ空席です…!どなたかぜひ埋めてください!😭 @polidog 先生が埋めてくれました!さすが!!✨
Discussion