Symfony UX Autocompleteとかいう顧客が本当に必要だったもの
はじめに
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クエリで取得できます。
以下の別記事もあわせてご参照ください。
それでも問題は残る
上記の方法でN+1問題を解決しても、常に全件取得することに変わりはないので、
- メモリがもったいない
- データの規模が大きいとスロークエリになりうる
-
<select>
タグの体験向上のために別途 Select2 や selectize.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
してみてください✨
Discussion