このエントリーをはてなブックマークに追加

はじめに

前回の記事では、Symfony 3.2で継承関係のあるエンティティをDoctrineのSingle Table Inheritanceを使って実装し、テスト時にYAMLのフィクスチャとしてエンティティのテストデータを用意するサンプルを紹介しました。

前回の記事の時点でのエンティティクラスは、下図のようになっています。

前回時点のエンティティクラス図

今回は、配送時期種別を種別(即納 Instant と見積 Estimation)それぞれ別のクラスにします。アプリケーションコード側ではこれらのクラスのインスタンスで扱い、DBにはこれまで通り文字列で保存を行います。この変換にDoctrineのEntityListenerを使います。クラスは以下のように変わります。

今回の実装後のエンティティクラス図

実装を行ったバージョンのソースコードは、以下から参照してください。

利用バージョン

記事執筆時点では、以下のバージョンになっています。

  • Symfony 3.2.0
  • Doctrine ORM 2.5.5

Embeddableではダメなのか?

DoctrineにはValueObjectを実現するためのEmbeddableの機能があり、前回のサンプル実装でも ItemPrice にこの機能を利用していました。商品の価格を Item エンティティから切り出して、価格の値を中心に、それに強く関係のあるメソッドを1箇所にまとめるには、Embeddableを利用したValueObjectが上手く当てはまります。

今回実装しようとしている例では、配送時期種別の値(instantestimation)は単なるラベルであり、この値自身が重要な役割を持っているわけではありません。より重きがあるのは、種別によって異なる計算ロジックの方です。このような種別のバリエーションを実装する場合は、クラス図に示したように1つのインターフェイスを共有する複数のクラスによって表現することがあります(※1)。

今回示した例のように種別が2つくらいだと、ItemPrice と同じように1つのValueObjectクラスにしておいて、その中で分岐させる方が労力が少なそうに思えるかもしれません。しかし、種別の数が多い場合は手に負えないコードになってしまいますね。また、そのような煩雑さと別としても、種別を個々にクラスにしておくことで、今回取り扱っている配送時期というポイントとは異なる振舞のインターフェイスをミックスするといった表現への道も開けます(※2)。

Docrineではインターフェイスを介した複数のクラスをEmbeddableとして扱うことができません。幸いDoctrineにはEntityListenerがあり、これを使うことで、今回のような高度なニーズにも対応できます。

(※1) PHPには表現方法の選択肢が多くないためこうなりますが、別の言語では事情は異なります。

(※2) 書籍『ジェネレーティブプログラミング』ではジェネレーティブドメインモデルとして、ベースとなるフィーチャの組み合わせを定義してドメインモデルを生成するアプローチが紹介されています。

実装例

EntityListenerを使うと決まれば、実装はさほど難しくはありません。方針としては次のようになります。

  • DBには(前回の記事での実装と同様に)文字列で配送時期種別を保存する
  • プログラムコード内でエンティティを取り出したら、DeliveryTimeTypeInterfaceを実装したクラスのインスタンスに自動的に変換されているようにする

Item エンティティのEntityListenerから呼ばれて変換を行う ItemDeliveryTimeTypeTransformer を最初に実装しておきます。このクラス内で種別ごとのValueObjectのインスタンスを作ってしまっている点はやや手抜きになっています。

<?php
namespace AppBundle\Transformer;

use AppBundle\DeliveryTime\DeliveryTimeTypeInterface;
use AppBundle\DeliveryTime\Type\Estimation;
use AppBundle\DeliveryTime\Type\Instant;
use AppBundle\DeliveryTime\Type\Invalid;

class ItemDeliveryTimeTypeTransformer
{
    /**
     * @var array
     */
    private $map;

    public function __construct()
    {
        $this->map = [
            'instant' =>    new Instant(),
            'estimation' => new Estimation(),
        ];
    }

    /**
     * @param DeliveryTimeTypeInterface $deliveryTimeType
     * @return string
     */
    public function toDB(DeliveryTimeTypeInterface $deliveryTimeType)
    {
        return $deliveryTimeType->getType();
    }

    /**
     * @param string $type
     * @return DeliveryTimeTypeInterface
     */
    public function toModel($type)
    {
        if (!array_key_exists($type, $this->map)) {
            return new Invalid();
        }

        return $this->map[$type];
    }
}

変換処理では、正常な種別とそうではない場合(Invalid)とを区別して扱っています。正常ではない場合にすぐさま例外をスローする方針もありえます。しかし、この変換処理をSymfonyのFormなどと連携させる場合には、変換処理として一旦正しく完了した後に中身を判定してエラーにするという流れが必要になります。ここで安易に例外をスローすると、フレームワークの処理と馴染まなくなってしまいます。

このTransformerを利用する ItemListener は次のように、ほぼ定型のコードのみとなります。

<?php
namespace AppBundle\Entity\Listener;

use AppBundle\Transformer\ItemDeliveryTimeTypeTransformer;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
use Doctrine\ORM\Mapping as ORM;
use AppBundle\Entity\Item;

class ItemListener
{
    /**
     * @var ItemDeliveryTimeTypeTransformer
     */
    private $transformer;

    public function __construct(ItemDeliveryTimeTypeTransformer $transformer)
    {
        $this->transformer = $transformer;
    }

    /**
     * @ORM\PostLoad()
     * @param Item $item
     * @param LifecycleEventArgs $event
     */
    public function postLoad(Item $item, LifecycleEventArgs $event)
    {
        $deliveryTimeType = $this->transformer->toModel($item->getDeliveryTimeType());
        $item->setDeliveryTimeType($deliveryTimeType);
    }

    /**
     * @ORM\PrePersist()
     * @param Item $item
     * @param LifecycleEventArgs $event
     */
    public function prePersistHandler(Item $item, LifecycleEventArgs $event)
    {
        $deliveryTimeType = $this->transformer->toDB($item->getDeliveryTimeType());
        $item->setDeliveryTimeType($deliveryTimeType);
    }

    /**
     * @ORM\PreUpdate()
     * @param Item $item
     * @param LifecycleEventArgs $event
     */
    public function preUpdateHandler(Item $item, LifecycleEventArgs $event)
    {
        $deliveryTimeType = $this->transformer->toDB($item->getDeliveryTimeType());
        $item->setDeliveryTimeType($deliveryTimeType);
    }
}

これらをサービスとして定義して、利用できるようにします(バンドル配下でYAMLのサービス定義を読み込むように、お決まりのエクステンションクラスなども追加します)。

services:
  app.entity.item_listener:
    class: AppBundle\Entity\Listener\ItemListener
    arguments:
      - '@app.transformer.item_delivery_time_type_transfomer'
    tags:
      - { name: doctrine.orm.entity_listener }

  app.transformer.item_delivery_time_type_transfomer:
    class: AppBundle\Transformer\ItemDeliveryTimeTypeTransformer

最後に Item エンティティクラスで ItemListener を有効にします。

 /**
  * Item
  *
  * @ORM\Table(name="item")
  * @ORM\Entity(repositoryClass="AppBundle\Repository\ItemRepository")
  * @ORM\DiscriminatorColumn(name="item_type", type="string")
  * @ORM\DiscriminatorMap({
  *   "item_abstract": "AppBundle\Entity\Item",
  *   "normal":        "AppBundle\Entity\Item\NormalItem",
  *   "custom_order":  "AppBundle\Entity\Item\CustomOrderItem",
  * })
  * @ORM\InheritanceType(value="SINGLE_TABLE")
+ * @ORM\EntityListeners({"AppBundle\Entity\Listener\ItemListener"})
  */
 abstract class Item
 {

あとは、DeliveryTimeクラス群を用意したら実装は完了です。

テストの修正

テストの側では、まずYAMLフィクスチャを修正する必要があります。配送時期種別のValueObjectをそれぞれ (local) で定義し、Itemのデータ定義ではそれらを参照するように書き換えます。


+AppBundle\DeliveryTime\Type\Estimation(local):
+  estimation1: ~
+AppBundle\DeliveryTime\Type\Instant(local):
+  instant1: ~

 AppBundle\Entity\Item\NormalItem:
   normal_item1:
     name: 商品1
     price: "@price1"
-    deliveryTimeType: instant
+    deliveryTimeType: "@instant1"
   normal_item2:
     name: 商品2
     price: "@price2"
-    deliveryTimeType: instant
+    deliveryTimeType: "@instant1"
   normal_item3:
     name: 商品3
     price: "@price3"
-    deliveryTimeType: estimation
+    deliveryTimeType: "@estimation1"

 AppBundle\Entity\Item\CustomOrderItem:
   custom_order_item1:
     name: カスタム商品1
     price: "@price4"
-    deliveryTimeType: estimation
+    deliveryTimeType: "@estimation1"
   custom_order_item2:
     name: カスタム商品2
     price: "@price5"
-    deliveryTimeType: estimation
+    deliveryTimeType: "@estimation1"

テストコードには、配送時期種別のクラスを表示するコードのみ追加しておきましょう。 (なお、フィクスチャを読み込んだエンティティマネージャを一旦強制的にクリアしているのは、エンティティ読み込み時の変換処理を明示的に動作させるためです)

<?php
namespace AppBundle\Functional\Entity;

use Doctrine\ORM\EntityManagerInterface;
use Liip\FunctionalTestBundle\Test\WebTestCase;
use AppBundle\Entity\Item;

class ItemTest extends WebTestCase
{
    public function test()
    {
        $this->loadFixtureFiles([
            __DIR__.'/../../Resources/fixtures/items.yml',
        ]);

        /** @var EntityManagerInterface $em */
        $em = static::getContainer()->get('doctrine')->getManager();
        $em->clear();

        $items = $em->getRepository(Item::class)->findAll();
        foreach ($items as $item) {
            echo get_class($item) . PHP_EOL;
            echo get_class($item->getDeliveryTimeType()) . PHP_EOL;
            echo sprintf('%s %s %s',
                    $item->getName(),
                    $item->getPrice(),
                    $item->getDeliveryTimeType()) . PHP_EOL;
        }
    }
}

プロジェクト全体のテストを実行すると、以下のようにテキストが出力されます。 テストコードでは、YAMLフィクスチャでDBに投入したデータを読み込んだものを表示しているだけですが、この段階で配送時期種別が文字列データではなく、各配送時期種別クラスのインタスンスになっていることが分かります。

$ vendor/bin/phpunit
PHPUnit 4.8.29 by Sebastian Bergmann and contributors.

.AppBundle\Entity\Item\CustomOrderItem
AppBundle\DeliveryTime\Type\Estimation
カスタム商品1 400 estimation
AppBundle\Entity\Item\CustomOrderItem
AppBundle\DeliveryTime\Type\Estimation
カスタム商品2 500 estimation
AppBundle\Entity\Item\NormalItem
AppBundle\DeliveryTime\Type\Instant
商品1 100 instant
AppBundle\Entity\Item\NormalItem
AppBundle\DeliveryTime\Type\Instant
商品2 200 instant
AppBundle\Entity\Item\NormalItem
AppBundle\DeliveryTime\Type\Estimation
商品3 300 estimation


Time: 446 ms, Memory: 12.00MB

おわりに

この記事では、単一のクラスではなく、複数のクラスを使って表現されたValueObject群をEntityListenerを使ってエンティティのフィールドにマッピングする方法を紹介しました。

次回は今回実装したValueObject群を持つエンティティに対して、WebAPIの入出力を行えるようにするためのSymfonyのFormの作り方を紹介します。


このエントリーをはてなブックマークに追加

はじめに

Symfonyでアプリケーションを開発する際、テストしたい状況ごとにデータベースのデータをYAMLのフィクスチャファイルとして用意しておくと開発がはかどります。Symfonyではバージョン2の頃から、nelmio/aliceおよびaliceを手軽に使うためのバンドルがよく使われています。先日Symfony 3.2がリリースされましたので、この記事では、Symfony 3.2のプロジェクトとしてaliceを使うプロジェクトの雛形を紹介します。

このプロジェクトは、Doctrineの単一テーブル継承(Single Table Inheritance)、および埋め込みエンティティ(ValueObjectまたはEmbedded)を使う例にもなっています。

利用バージョン

記事執筆時点では、以下のバージョンになっています。

インストール

Symfonyプロジェクトは、Symfony Installerでインストールすると楽ですが、記事執筆時点ではSymfony InstallerがSymfony 3.2に対応していなかったため、composerからインストールしました。

$ composer create-project symfony/framework-standard-edition sf32-app-sample201612
$ cd sf32-app-sample201612

これでSymfony Standard Editionのプロジェクトファイル群が作られます。続いて次のコマンドで、ユニットテストおよびYAMLフィクスチャを利用するのに必要なバンドル/ライブラリをインストールします。

$ composer require --dev phpunit/phpunit liip/functional-test-bundle hautelook/alice-bundle

バンドルの有効化

インストールしたバンドルを、app/AppKernel.php にて有効化します。

<?php

use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;

class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = [
            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
            ...
        ];

        if (in_array($this->getEnvironment(), ['dev', 'test'], true)) {
            ...
+           $bundles[] = new Liip\FunctionalTestBundle\LiipFunctionalTestBundle();
+           $bundles[] = new Hautelook\AliceBundle\HautelookAliceBundle();
        }

テスト用コンフィギュレーションの追記

テストでsqliteを使うため app/config/config_test.yml に、以下を追記します。

+doctrine:
+    dbal:
+        default_connection: default
+        connections:
+            default:
+               driver:   pdo_sqlite
+               path:     "%kernel.cache_dir%/test.db"
+
+liip_functional_test:
+    cache_sqlite_db: true

エンティティの実装

以下の簡単な要件のエンティティを実装します。

  • 商品情報エンティティ
  • 商品名と価格、配送種別を持つ
  • 商品には通常の商品と特注品とがあり、それぞれに特殊なロジックが必要なので、別々のエンティティクラスに分けておきたい

また、今のところ大きな意味はありませんが、価格はValueObjectにしておきます。配送種別は、現段階では単に文字列で種別を格納しておきます。

Doctrineの単一テーブル継承を使って、Item および NormalItemCustomOrderItem、ValueObjectの ItemPrice は、それぞれ以下のようになります。

TIPS エンティティを実装する際、最初の段階から各フィールドの制約(Assert)まで書くようにしておくのは、開発時のドキュメンテーションとしての効果もあってオススメです。

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Item
 *
 * @ORM\Table(name="item")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\ItemRepository")
 * @ORM\DiscriminatorColumn(name="item_type", type="string")
 * @ORM\DiscriminatorMap({
 *   "item_abstract": "AppBundle\Entity\Item",
 *   "normal":        "AppBundle\Entity\Item\NormalItem",
 *   "custom_order":  "AppBundle\Entity\Item\CustomOrderItem",
 * })
 * @ORM\InheritanceType(value="SINGLE_TABLE")
 */
abstract class Item
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255)
     * @Assert\NotBlank
     */
    private $name;

    /**
     * @var ItemPrice
     * @ORM\Embedded(class="AppBundle\Entity\ItemPrice", columnPrefix="price_")
     * @Assert\Valid
     */
    private $price;

    /**
     * @var string
     * @ORM\Column(name="delivery_time_type", type="string", length=255)
     * @Assert\NotBlank
     * @Assert\Choice(choices={"instant", "estimation"})
     */
    private $deliveryTimeType;

    /**
     * Get id
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    : 以下getter/setter
}
<?php
namespace AppBundle\Entity\Item;

use Doctrine\ORM\Mapping as ORM;
use AppBundle\Entity\Item;

/**
 * @ORM\Entity()
 */
class NormalItem extends Item
{

}
<?php
namespace AppBundle\Entity\Item;

use Doctrine\ORM\Mapping as ORM;
use AppBundle\Entity\Item;

/**
 * @ORM\Entity()
 */
class CustomOrderItem extends Item
{

}
<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Embeddable
 */
class ItemPrice
{
    /**
     * @var int
     *
     * @ORM\Column(name="price", type="integer")
     * @Assert\NotBlank
     * @Assert\GreaterThanOrEqual(0)
     */
    private $price;

    /**
     * @return int
     */
    public function getPrice()
    {
        return $this->price;
    }

    /**
     * @param int $price
     */
    public function setPrice($price)
    {
        $this->price = $price;
    }

    public function __toString()
    {
        return (string)$this->price;
    }
}

DBのテーブルを生成

ここで一旦、エンティティの実装および定義が有効か確認するために、データベースのスキーマを生成しておきましょう。

$ php bin/console doctrine:database:create
$ php bin/console doctrine:schema:create

テストディレクトリの準備

Symfony3では、アプリケーション/バンドルの標準ディレクトリ構成が変更になり、これまでは各バンドルの中に配置されていたテスト関係のファイルを、プロジェクトルートディレクトリの tests 配下へまとめるようになりました。 YAMLのフィクスチャファイルもテスト時に利用するものなので、tests 配下の方へ配置します。このサンプルでは、以下のような配置にしました。

tests
└── AppBundle
    ├── Functional
    │   └── Entity
    │       └── ItemTest.php
    └── Resources
        └── fixtures
            └── items.yml

YAMLフィクスチャファイル

商品情報を登録するYAMLフィクスチャファイルは、以下のようになります。ValueObjectを使っているItemPriceの部分がやや手間ですが、スッキリと定義できますね。aliceの細かな機能については、aliceのドキュメントを参照してください。

AppBundle\Entity\ItemPrice(local):
  price1:
    price: 100
  price2:
    price: 200
  price3:
    price: 300
  price4:
    price: 400
  price5:
    price: 500

AppBundle\Entity\Item\NormalItem:
  normal_item1:
    name: 商品1
    price: "@price1"
    deliveryTimeType: instant
  normal_item2:
    name: 商品2
    price: "@price2"
    deliveryTimeType: instant
  normal_item3:
    name: 商品3
    price: "@price3"
    deliveryTimeType: estimation

AppBundle\Entity\Item\CustomOrderItem:
  custom_order_item1:
    name: カスタム商品1
    price: "@price4"
    deliveryTimeType: estimation
  custom_order_item2:
    name: カスタム商品2
    price: "@price5"
    deliveryTimeType: estimation

フィクスチャを読み込むテスト

次は、YAMLのフィクスチャをテストコードから読み込んでみましょう。ItemTest.phpを以下のように作成します。

<?php
namespace AppBundle\Functional\Entity;

use Liip\FunctionalTestBundle\Test\WebTestCase;
use AppBundle\Entity\Item;

class ItemTest extends WebTestCase
{
    public function test()
    {
        $this->loadFixtureFiles([
            __DIR__.'/../../Resources/fixtures/items.yml',
        ]);

        $em = static::getContainer()->get('doctrine')->getManager();

        $items = $em->getRepository(Item::class)->findAll();
        foreach ($items as $item) {
            echo get_class($item) . PHP_EOL;
            echo sprintf('%s %s %s',
                $item->getName(),
                $item->getPrice(),
                $item->getDeliveryTimeType()) . PHP_EOL;
        }
    }
}

liip/functional-test-bundleでは、WebTestCaseクラスに loadFixtureFiles() メソッドがあり、バージョン1.5.0以降ではYAMLフィクスチャファイルのパスを引数にして loadFixtureFiles() メソッドを呼び出すだけで、フィクスチャのロードが完了します。

拙著『基本からしっかり学ぶ Symfony2入門』では、利用しているliip/functional-test-bundleのバージョンが古く、この時はわざわざDoctrineFixturesBundle形式のローダークラスを用意しないといけませんでした(そのためにDoctrineFixturesBundleのインストールも必要でした)。これらは今は不要です。便利になりましたね。

プロジェクト全体のテストを実行すると、以下のようにテキストが出力されます。

$ vendor/bin/phpunit
PHPUnit 4.8.29 by Sebastian Bergmann and contributors.

.AppBundle\Entity\Item\CustomOrderItem
カスタム商品1 400 estimation
AppBundle\Entity\Item\CustomOrderItem
カスタム商品2 500 estimation
AppBundle\Entity\Item\NormalItem
商品1 100 instant
AppBundle\Entity\Item\NormalItem
商品2 200 instant
AppBundle\Entity\Item\NormalItem
商品3 300 estimation


Time: 416 ms, Memory: 12.00MB

おわりに

この記事ではSymfony 3.2でYAMLフィクスチャを使う方法と、Doctrineの単一テーブル継承、ValueObjectを使う例をざっと紹介しました。この記事の応用編として、次回はInterfaceとそれを実装する複数の具象クラスから成るValueObjectを利用する方法を紹介する予定です。


このエントリーをはてなブックマークに追加

はじめに

カルテットで開発しているサービスLisketは、2014年始めにSymfony2ベースに置き換えて以来、3年間Symfonyを使い続けています。この記事では、カルテットで3年間育てたSymfonyプロジェクトの現状の数値や、現場で感じている課題などを整理してみました。Symfonyに限らず、同じようなプロジェクトの悩みを抱えている方々と、苦労しているポイントを分かち合えれば幸いです。

プロジェクトの規模

LisketのメインのSymfonyアプリケーション、バックエンドのアプリケーション、および自社開発の独立コンポーネントを合わせたプロジェクトの規模は、2016年12月の時点で以下のとおりです。

  • ソースコード行数
    • PHP:38万行 (*1)
    • JavaScript:6万行+α
  • Symfonyバンドルの数:37(メインアプリ)、11(バックエンドアプリ)、+α
  • 独立コンポーネントの数:21(PHP)
  • MySQLテーブルの数:158(メインアプリ)、85(バックエンドアプリ)
  • テストの数:3658(メインアプリ)、 1280(バックエンドアプリ)、+α(独立コンポーネント)
  • CircleCIでのPRのビルド時間:33分前後(2並列、メインアプリ)、12分前後(1並列、バックエンドアプリ)
  • CircleCIでのmasterビルド(デプロイ込み)実行時間:1時間前後(2並列、メインアプリ)、15分前後(1並列、バックエンドアプリ)

その他の参考数値

  • PHPのバージョンは7(メインアプリ)
  • 2016年12月での開発チームメンバー数は7名(1チーム構成)
  • CircleCIは5インスタンスのみ契約

業務アプリケーションで膨大なユースケースがあるプロジェクトと比べたらまだまだですが、全貌を把握するにはそれなりに腰を据える必要があるくらいの規模にはなっています。

(*1) 外部APIをラップするクラス群を作っていたりと、やたらとコード行数が膨らんでいるものも含まれています。

達成できていること

いくつか抱えている困難は後述しますが、現時点でクリアできていることもたくさんあります。

  • ユニットテスト/機能テストを書くこと
  • こまめなリリース(数日〜1週間間隔)
  • 機能の適切な設計(コンポーネント分割、クラス設計)

これらの達成には、開発メンバーの技術的努力の成果も大いにあると思いますが、それとは別に、Symfonyの持つテストの書きやすさ、クラス設計の自由さなどが後押ししてくれたとも思います。

したがって、やや自慢気に聞こえるかもしれませんが、カルテットの開発部は「テストを書く文化を作る!」段階はとっくの昔にクリアできているんです。今はそこから3周くらい先のステージにいて、さまざまな壁にぶつかっているという状況なのです。

課題

現状感じている課題は、主に以下の点です。

  • CI・テストの実行に時間がかかる
  • コードの行数が多い
  • バージョンアップ

それぞれどのような内容か、および対策として取り組んでいることを説明します。

CI・テストの実行に時間がかかる

数字に顕著にあらわれていますが、CircleCIでのCIのビルド、およびテストの実行にかなりの時間がかかっています。composerでのパッケージインストール、npmでのパッケージインストール、アセットのビルドにも時間がかかっています。

アプリケーションの継続的なリリース、機能の変更、コードのリファクタリングやライブラリのバージョンアップなどを安心して行っていくために自動テストはなくてはならないので、テストを書ける機能にはできるだけテストを書いています。しかし、その反動として、テストの実行時間が長くなってしまっています。

この問題の対策としては、PHP、JavaScriptともに独立して切り出せる部分は単独のリポジトリにして、そちらでできるだけテストを行うようにし、メインのSymfonyアプリケーション側のテストを増やさないといったことをしています。

また、Symfonyアプリケーションではアセットのビルド(assetic:dump)に時間がかかる問題についても、脱Asseticを目指して、一部はJavaScriptのエコシステム(webpack)側で実行する方法などに取り組んでいます(参考1 参考2)。

コードの行数が多い

これはSymfonyを利用していることと直接的な関係はありませんが、PHPのコードの量がかなり多くなっており、簡単には全貌を把握できません。開発メンバー全員がソースコードのすべてをくまなく把握するということは無理な量になっています。

Lisketは1つのサービスに機能追加などを行って成長させていくスタイルのプロダクトなので、コードの量が増えていくのは仕方ないといえます。したがって、これに対抗する手段としては、とにかくメンテナンス性の高いコードを常に書く、書き直していくという活動が重要と考えています。このために、プログラミング言語の進化やモダンなアプローチなどを常に取り入れながら、より洗練されたコードにしていく意識を持っておくことも大事ですね。

バージョンアップ

3年前の開発開始から、何度かSymfonyのバージョンアップを行ってきました。Symfony公式では先日3.2がリリースされましたが、Lisketで使っているのはまだバージョン2系です。SymfonyおよびComposerエコシステムを利用したプロジェクトは、一般的にはバージョンアップを行いやすいと思います。しかし、Lisketでは利用している外部ライブラリがかなりの数になっており、個々のバージョンアップが微妙にさまざまな箇所に影響しあうため、バージョンを上げようにも上げられなかったりと難しい状況になっています。

また、LisketのPHPはバージョン7に切り替え済みですが、利用しているパッケージのいくつかでは、まだPHP 7の機能に対応していないということもあります(特にReturn type declarationに絡むものが多いです)。

この問題の対策はなかなか進んでいません。多少期間をとって、メンバー全員で一気にバージョンアップとテスト、コードの書き直しなどの作業を行う以外に方法は無いのかもしれませんね。

まとめ

カルテットのSymfonyプロジェクトの現状についてまとめてみました。

約3年間開発を続けてきて、いくつかの問題点もありますが、複雑な要件・サービス全体にまたがるような機能要件に対しても比較的エレガントに解決(*2)できています。特に最近では、メンバー全員がSymfonyに慣れてきたことと、サービスの根本に関わるような変更などは一段落したこともあり、ビジネスの問題を解決する本質的な課題に注力できるようになっています。

今後は、今よりもさらに高いレベルで問題解決ができるように、技術力・設計力をチーム全体で磨きつつ、技術の負債を増やさない地道な改善も続けていきます。

この記事を読まれた同じような問題と格闘している方、独自に取り組まれた工夫のアイデアなどありましたら、是非お寄せください。