🎻

Symfony UX Autocompleteとかいう顧客が本当に必要だったもの

2023/06/27に公開

はじめに

PHPerのための「Symfonyを語り合う」PHP TechCafe という勉強会で以下のスライドにてLTをしました。

ほぼ同じ内容にはなりますが、記事の形でも残しておきます🙏

サンプルコード

完全なサンプルコードを以下のリポジトリにて公開していますので、あわせてご参照ください。

https://github.com/ttskch/symfony-ux-autocomplete-example

symfony/form

Symfony Formコンポーネント は、フォームの定義をバックエンドとフロントエンドで一元化できる強力なツールです。

入力項目を抽象化した FormType というクラスを組み合わせて使います。

TextType TextareaType ChoiceType EntityType など、入力形式ごとに雛形としての派生クラスがはじめから用意されています。

EntityType

EntityType を使うと、DB上のデータ(エンティティ)をリストから選択させるようなフォーム項目をシュッと作れます。

$builder
    ->add('user', EntityType::class, [
        'class' => User::class,
        'placeholder' => '選択してください',
    ])
;

これが

こうなります。

このフォームを送信すると、バックエンド側では特に何もしなくてもエンティティのインスタンスとして受け取ることができます。

N+1問題

そんな便利な EntityType ですが、1つ厄介なあるあるがあります。

それは、エンティティが __toString() 内で外部エンティティを参照している場合に N+1問題 が起きるということです。

class User
{
    // ...

    public function __toString(): string
    {
        return sprintf('%s %s', $this->team, $this->name);
    }
}

このフォームをレンダリングすると、

このように User エンティティのレコード数だけクエリが余分に走ってしまいます。

原因

この現象は、Doctrineが自動で外部エンティティ(上記の例では $this->team)を 別クエリで取得 するために起こります。

フォームをレンダリングするときになって初めて外部エンティティが必要であることが分かるので、原理的に仕方のない問題です。(言い換えると、Doctrineには事前にJOINすべきとは分からないわけです)

上記の例ではたかだか100クエリですが、エンティティのレコード数や関連する外部エンティティの数が増えるとパフォーマンスが大きく悪化する要因になります。

典型的な対策

一応この問題には典型的な対策があります。

それは、EntityType のオプションを使って明示的にJOINさせることです。

  $builder
      ->add('user', EntityType::class, [
          'class' => User::class,
          'placeholder' => '選択してください',
+         'query_builder' => function (UserRepository $repository) {
+             return $repository->createQueryBuilder('u')
+                 ->leftJoin('u.team', 't')
+                 ->addSelect('t')
+             ;
          },
      ])
  ;

こうすれば、

このように外部エンティティもまとめて1クエリで取得できます。

以下の別記事もあわせてご参照ください。

[Symfony][Doctrine] EntityTypeでよく起こるN+1問題の原因と対処方法

それでも問題は残る

上記の方法でN+1問題を解決しても、常に全件取得することに変わりはないので、

  • メモリがもったいない
  • データの規模が大きいとスロークエリになりうる
  • <select> タグの体験向上のために別途 Select2selectize.js などのJavaScriptライブラリを入れている場合、<option> タグの数が多いとライブラリの初期化処理に数秒オーダーの時間がかかってしまい、かえって体験が悪くなる

などの問題が残ります。

そこで、Symfony UX Autocomplete

そこで、Symfony UX Autocompleteの出番です。

Symfony UX は、PHPしか書かずにJavaScriptのライブラリを導入・設定できる便利なPHPライブラリ群です。

Symfonyアプリに簡単にリッチなUIを追加でき、目的別に 色々なコンポーネント がリリースされています。

その中の1つ、Symfony UX Autocomplete は、EntityType ChoiceType あるいは生の <select> タグを

  • 選択肢をインクリメンタル検索できるように
  • 選択肢を 非同期で必要な分だけ取得できるように

してくれる超絶便利なやつです。

特に後者によって、エンティティのレコード数が大量でも気にしなくてよくなる というのが今回のポイントです。

使い方の例

詳しくは 公式ドキュメント拙作のサンプルコード を参照いただければと思いますが、ざっくり言うと、

#[AsEntityAutocompleteField]
class UserAutocompleteField extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'class' => User::class,
            'placeholder' => '検索してください',
        ]);
    }

    public function getParent(): string
    {
        return ParentEntityAutocompleteType::class;
    }
}

こんな感じの FormType を作って、あとは

$builder
    ->add('user', UserAutocompleteField::class)
;

こんなふうにいつも通りに使うだけです。

これだけで、こうなります。

何が起こっている?

入力された検索クエリに該当する User エンティティのリストを数件ずつページングして返してくれるAPIが、Symfony UX Autocompleteによって自動で生えています。

そして、検索クエリを打ち込んだり、末尾までスクロールしたりする度に、そのAPIからリストを取得してくれるというわけです。

細かいカスタマイズももちろん可能

細かいカスタマイズももちろん可能です。

JavaScript側の実装は Tom Select というライブラリで、Tom Selectの主要な設定項目は FormTypeオプションとして指定できる ようになっていますし、さらに細かいカスタマイズがしたい場合は、JavaScriptを書けば Tom Selectのすべての設定項目を自由にいじることも可能 です。

実は上記のデモ動画もスピナーの見た目などちょっといじっています🙏

まとめ

というわけで、Symfony UX Autocompleteは、SymfonyでUIを作るなら入れておかない理由がないやつだと思います。

これ一つでアプリ内の <select> タグの体験が全部最高になります。

また、実は EntityType のN+1問題を解決しようとするとインスタンス化すべき物量が多すぎてメモリが足りなくなることが稀によくあるのですが、Symfony UX Autocompleteを入れておけば、N+1をあえて許容するという戦略がとれたりもします。

ぜひ、お手元のSymfonyプロジェクトで composer require symfony/ux-autocomplete してみてください✨

GitHubで編集を提案

Discussion