ずっとRDBとの組み合わせでSymfonyを使ってきた私ですが、思うところあってMongoDB + Symfonyに入門したので記録をシェアしたいと思います。 公式のチュートリアル https://www.doctrine-project.org/projects/doctrine-mongodb-bundle/en/4.3/index.html を斜め読みしながらコードを書いてみただけ程度ですが、 https://github.com/77web/symfony-doctrine-odm-playground に公開していますので、見てみたい方はどうぞ。
準備
ローカルにMongoDBをインストールします。
brew install mongo-community
PHPからMongoDBを利用するため、ext-mongodb拡張を入れます。 phpenvで入れたPHPを利用しているので、ext-mongodbのソースを落としてきて、phpizeしてmake && make installします。
wget {https://pecl.php.net/package/mongodbの最新版のtgz}
tar -zxvf mongodb-x.x.x.tgz
cd mongodb-x.x.x
phpize
./configure
make && make install
# この後php.iniにextension=mongodb.soを追加する
新規Symfonyプロジェクトを作り、mongodb-odm-bundleを追加する
symfony newで新規プロジェクトを作り、maker, profilerを入れておいて、そこから doctrine/mongodb-odm-bundle
を追加します。
今日時点でPHP8.0.2以上の環境でおもむろにsymfony newするとSymfony6が入りますが、 doctrine/mongodb-odm-bundle
はSymfony6にもちゃんと入りました
symfony new
composer require --dev maker profiler
composer require doctrine/mongodb-odm-bundle
flexによってレシピの適用をするかどうか聞かれるのでyを押してレシピを適用します。
.envを確認すると、デフォルトでlocalhostのMongoDBへの接続設定が書かれています。
※ 実際にローカルのMongoDBにcliでログインしてポートを確認したところ 27017
だったので何も編集しなくてOKでした。
###> doctrine/mongodb-odm-bundle ###
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=symfony
###< doctrine/mongodb-odm-bundle ###
レシピで作られたconfig/doctrine_mongodb.yamlも確認します。
このyamlを見た時点でORMでいうエンティティ的なものはsrc/App/Documentにおけば良さそうかな?という予測が立ちますね
doctrine_mongodb:
auto_generate_proxy_classes: true
auto_generate_hydrator_classes: true
connections:
default:
server: '%env(resolve:MONGODB_URL)%'
options: {}
default_database: '%env(resolve:MONGODB_DB)%'
document_managers:
default:
auto_mapping: true
mappings:
App:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Document'
prefix: 'App\Document'
alias: App
ODMに保存すべきドキュメントを定義してみる
bin/console make:document…はなかったので、手動でマッピングを書くことにします。 まず今回保存したいデータのクラスを、私のよく知っているデータ構造から選んで仮に2つ定義しました。
2つのドキュメントクラス
見て分かる通り、この2つのデータは微妙に構造が違いますね。今回はこの構造が違う広告文データをODMを通してMongoDBに放り込んでみようと思います!
FacebookFeedAd
Facebookフィード広告の広告文を表します。
- 見出し
string
- メインテキスト
string
- リンク先URL
string
- 画像URL
string
<?php
declare(strict_types=1);
namespace App\Document;
class FacebookFeedAd
{
private ?string $id = null;
private ?string $headline = null;
private ?string $mainText = null;
private ?string $linkUrl = null;
private ?string $imageUrl = null;
GoogleResponsiveSearchAd
Googleレスポンシブ検索広告の広告文を表します。
- 見出し
string[]
- 説明文
string[]
- リンク先URL
string
- パス1
string|null
- パス2
string|null
<?php
declare(strict_types=1);
namespace App\Document;
class GoogleResponsiveSearchAd
{
private ?string $id = null;
private array $headlines = [];
private array $descriptions = [];
private ?string $finalUrl = null;
private ?string $path1 = null;
private ?string $path2 = null;
}
マッピング定義
ORMのMapping定義と同様にアノテーションまたはアトリビュートで定義できます。
公式のリファレンス https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/2.2/reference/basic-mapping.html ではまだアノテーションの記載しかありませんが、実際のマッピングアノテーションのソースコードを見たら #[\Attribute]
がついていたので、attributeで定義してみようと思います。
# config/packages/doctrine_mongodb.yaml
document_managers:
default:
auto_mapping: true
mappings:
App:
is_bundle: false
- type: annotation
+ type: attribute
まずFacebookFeedAdクラスにマッピング定義を足したところです。
<?php
declare(strict_types=1);
namespace App\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
#[MongoDB\Document]
class FacebookFeedAd
{
#[MongoDB\Id(strategy: "AUTO")]
private ?string $id = null;
#[MongoDB\Field(type: "string")]
private ?string $headline = null;
#[MongoDB\Field(type: "string")]
private ?string $mainText = null;
#[MongoDB\Field(type: "string")]
private ?string $linkUrl = null;
#[MongoDB\Field(type: "string")]
private ?string $imageUrl = null;
このODMのドキュメント App\Document\FacebookFeedAd
はマッピング定義がついているだけでごく普通のクラスなので、もちろんSymfonyのFormのdata_classとして利用することができます。
しかし、make:formすると Somehow the doctrine service is missing. Is DoctrineBundle installed?
というエラーでFormが作られなかったため、手動で作る必要がありました。
<?php
declare(strict_types=1);
namespace App\Form;
use App\Document\FacebookFeedAd;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FacebookFeedAdType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('headline')
->add('mainText')
->add('linkUrl')
->add('imageUrl')
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefaults([
'data_class' => FacebookFeedAd::class,
]);
}
}
<?php
namespace App\Controller;
use App\Document\FacebookFeedAd;
use App\Form\FacebookFeedAdType;
use Doctrine\ODM\MongoDB\DocumentManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\RouterInterface;
class FacebookFeedAdController extends AbstractController
{
#[Route('/facebook/feed/ad', name: 'facebook_feed_ad', methods: ['POST', 'GET'])]
public function create(Request $request, FormFactoryInterface $formFactory, DocumentManager $dm, RouterInterface $router): Response
{
$form = $formFactory->createNamed('', FacebookFeedAdType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$dm->persist($data);
$dm->flush();
return new RedirectResponse($router->generate('facebook_feed_ad_show', ['id' => $data->getId()]));
}
return $this->render('facebook_feed_ad/create.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/facebook/feed/ad/{id}', name: 'facebook_feed_ad_show', methods: ['GET'])]
public function show(FacebookFeedAd $data): Response
{
return $this->render('facebook_feed_ad/show.html.twig', [
'ad' => $data,
]);
}
}
symfony server:startでフォームを表示して項目を入力してみます。ボタンを押すと無事に詳細にリダイレクトしました。
ところが表示された詳細を見ると…空っぽでした。
MongoDBコマンドラインで確認するとデータは登録できています。
> show collections
FacebookFeedAd
> db.FacebookFeedAd.find('61f15925c8337ffaff22c943')
{ "_id" : ObjectId("61f15925c8337ffaff22c943"), "headline" : "リスティング広告もう限界", "mainText" : "カルテットにおまかせ", "linkUrl" : "https://quartet-communications.com", "imageUrl" : "http://placehold.jp/150x150.png" }
どうやらParamConverterが効かないようなので DocumentManager::find()
に書き直したところ、正常にデータが表示されるようになりました。
※ Symfonyの世界ではORM用のParamConverterも単なるタグ付きサービス定義されたクラスなので、自前で書けば同じことはできそうです
同様にGoogleResponsiveSearchAdについてもFormとControllerとtwigを書くことでデータは登録できそうですが、FacebookFeedAdがそれ専用のコレクションに保存されたことから、普通にFormとControllerとtwigを作っただけではGoogleResponsiveSearchAdとFacebookFeedAdは別のコレクションに保存されることが予想できます。
そこでFacebookFeedAdとGoogleResponsiveSearchAdを両方の親となるabstractな AdCreative
を作って、SingleCollectionInheritanceとしてマッピング定義を書いてみることにしました。DoctrineORMでSingleTableInheritanceを使ったことがあればやり方はほぼ同じです。
<?php
declare(strict_types=1);
namespace App\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
#[MongoDB\Document]
#[MongoDB\InheritanceType('SINGLE_COLLECTION')]
#[MongoDB\DiscriminatorField('type')]
#[MongoDB\DiscriminatorMap([
'facebook_feed' => FacebookFeedAd::class,
'google_responsive_search' => GoogleResponsiveSearchAd::class,
])]
abstract class AdCreative
{
#[MongoDB\Id(strategy: "AUTO")]
private ?string $id = null;
#[MongoDB\Field(type: 'date_immutable')]
private ?\DateTimeImmutable $registeredAt;
/**
* @return string|null
*/
public function getId(): ?string
{
return $this->id;
}
/**
* @return \DateTimeImmutable|null
*/
public function getRegisteredAt(): ?\DateTimeImmutable
{
return $this->registeredAt;
}
/**
* @param \DateTimeImmutable|null $registeredAt
* @return AdCreative
*/
public function setRegisteredAt(?\DateTimeImmutable $registeredAt): self
{
$this->registeredAt = $registeredAt;
return $this;
}
abstract public function getType(): string;
}
<?php
declare(strict_types=1);
namespace App\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
#[MongoDB\Document]
- class FacebookFeedAd
+ class FacebookFeedAd extends AdCreative
{
<?php
declare(strict_types=1);
namespace App\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
#[MongoDB\Document]
class GoogleResponsiveSearchAd extends AdCreative
{
#[MongoDB\Field(type: 'collection')]
private array $headlines = [];
#[MongoDB\Field(type: 'collection')]
private array $descriptions = [];
#[MongoDB\Field(type: "string")]
private ?string $finalUrl;
#[MongoDB\Field(type: "string", nullable: true)]
private ?string $path1;
#[MongoDB\Field(type: "string", nullable: true)]
private ?string $path2;
// ...
public function getType(): string
{
return 'google_responsive_search';
}
}
GoogleResponsiveSearchAdについてもFormとControllerとtwigを作成し、データを登録してみました。
MongoDBのコマンドラインで確認すると、2つの広告タイプが同じAdCreativeコレクションにそれぞれのスキーマで保存されていることが確認できました
> db.AdCreative.find()
{ "_id" : ObjectId("61f6983bdb3a08004d2fbf74"), "registeredAt" : ISODate("2022-01-30T13:52:59.280Z"), "headline" : "リスティング広告もう限界", "mainText" : "カルテットにおまかせ", "linkUrl" : "https://quartet-communications.com", "imageUrl" : "http://placehold.jp/150x150.png", "type" : "facebook_feed" }
{ "_id" : ObjectId("61f69bcfdb3a08004d2fbf75"), "registeredAt" : ISODate("2022-01-30T14:08:15.572Z"), "headlines" : [ "リスティング広告もう限界", "SNS広告ももう限界" ], "descriptions" : [ "専門の代行業者にまかせてみよう", "カルテットにおまかせ" ], "finalUrl" : "https://quartet-communications.com", "path1" : "dummy", "path2" : null, "type" : "google_responsive_search" }
まとめ
今回は基本的なマッピングとデータの読み書きだけ試してみましたが、EmbeddedDocument(ORMでいうEmbeddedEntity)等、ドキュメントの目次を見るとORMと同様の便利な使い方も用意されています。 リスティング広告・SNS広告関係のデータ構造は、下手をすると3ヶ月〜半年で変化することがあるので、ORM+RDBだけじゃないストレージもうまく活用できると開発が捗りそうな予感がします!