ずっと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にもちゃんと入りました :tada:

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におけば良さそうかな?という予測が立ちますね :v:

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に放り込んでみようと思います! :smile:

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でフォームを表示して項目を入力してみます。ボタンを押すと無事に詳細にリダイレクトしました。

FacebookFeedAdフォームのスクショ

ところが表示された詳細を見ると…空っぽでした。

FacebookFeedAd空っぽの表示画面

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も単なるタグ付きサービス定義されたクラスなので、自前で書けば同じことはできそうです

FacebookFeedAdの詳細が表示された

同様に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を作成し、データを登録してみました。

GoogleResponsiveSearchAdのフォーム GoogleResponsiveSearchAdを登録したところ

MongoDBのコマンドラインで確認すると、2つの広告タイプが同じAdCreativeコレクションにそれぞれのスキーマで保存されていることが確認できました :smile:

> 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だけじゃないストレージもうまく活用できると開発が捗りそうな予感がします!