🎻

[Symfony][Doctrine] エンティティを自前でJsonSerializableにするときはプロパティの循環参照に要注意

2021/12/12に公開約7,500字

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 のさらに先のリレーションシップに循環参照があると正常にシリアライズできません。

解決方法その1(自前の 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['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_vararray_map_recursive 的に 使って、オブジェクトをすべて null に置き換えています。

この用法については以下の記事などご参照ください。

array_map_recursiveはPHPに標準実装されていた! - Qiita

これで、循環参照になり得るオブジェクトへの参照を排除しつつ、必要な情報はちゃんとすべて含まれた状態でシリアライズできるようになりました。

解決方法その2(素直に JMSSerializerBundle を導入する)

…という強引な解決方法をひとまず示しましたが、これぐらい複雑な要件になったら、もはや自前実装でシリアライズするのはやめて素直に 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 先生が埋めてくれました!さすが!!✨

GitHubで編集を提案

Discussion

ログインするとコメントできます