【Symfony】サービスコンテナを実装する

#サービスとは？

サービスコンテナは、オブジェクト間の依存関係についてDependency Injection(依存性注入) するための仕組みです。

サービスにはロジックを実装します。
サービスを必要とするオブジェクトに対して、外から渡すことにとって、疎統合な設計を実現します。

...と、サービスの概念だけを語っても抽象的でなんだかよくわからないと思うので、実際にサービスコンテナを実装する流れを追っていきます。

#サービスコンテナを実装する

はじめに、あるECサイトを仮定しましょう。

このサイトでは、毎年2月はバレンタインセールを行うので、現在がその期間内か判定するロジックを実装することになりました。

以下のコードはそのままコントローラに実装した例です。

```php
namespace App\Controller;

use App\Entity\Product;
use Symfony\Component\Routing\Annotation\Route;

class HelloController extends AbstractController
{
    /**
     * @Route("/", name="hello_index")
     */
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $products = $em->getRepository(Product::class)->findAll();

        // ここが期間内が確認するロジック
        $startDate = new \Datetime('02-01');
        $endDate = new \Datetime('02-28');
        $now = new \Datetime();

        $isSaleTerm = ($now >= $startDate $$ $now <= $endData);

        return $this->render('hello/index.html.twig', [
            'products' => $products,
            'isSaleTerm' => $isSaleTerm
        ];      
    }
```

最も単純に実装するとなると、このような形になると思います。

##コントローラにドメインロジックを実装する問題点

今がセール期間中が否かというロジックは、ドメイン知識に相当します。
コントローラの責務は、URLをマッピングし、リクエストおよびレスポンスをハンドリングすることであり、ドメイン知識には関心がありません。

どのような状況がセール期間にあたるのか小難しいことは考える必要はなく、とにかくいまがセール期間であるという情報さえ得られればよいのです。
つまるところ、ロジックを抽象化するということです。

では実際に、コントローラがドメインの知識を所有しているとどのような問題点が存在するのかみていきます。

###変更が難しくなる

セール期間かどうか判定するロジックは、indexページ限らず、おそらくサイト上のあらゆる箇所で登場すると考えられます。
また、コントローラーに限らずFormTypeでも使用したくなるかもしれません。

そのためのもっとも簡単な方法は、ロジックをコピペして使い回すことでしょう。

```php
// コピペで解決された実装

    newAction() 
    {
        $startDate = new \Datetime('02-01');
        $endDate = new \Datetime('02-28');
        $now = new \Datetime();

        $isSaleTerm = ($now >= $startDate $$ $now <= $endData);
    }
  
    editAction() 
    {
        $startDate = new \Datetime('02-01');
        $endDate = new \Datetime('02-28');
        $now = new \Datetime();

        $isSaleTerm = ($now >= $startDate $$ $now <= $endData);
    }

    deletAction() 
    {
        $startDate = new \Datetime('02-01');
        $endDate = new \Datetime('02-28');
        $now = new \Datetime();

        $isSaleTerm = ($now >= $startDate $$ $now <= $endData);
    }

```

もうすでにお気づきの方もいらっしゃるかもしれませんが、いまコピペで実装したことで大きな問題が生まれました。

例えば、セール期間が2月から3月に変更になることは容易に想像できます。
その場合には、ロジックが記述された箇所を**漏れなく**変更する必要があります。

実際にすべての修正が完了したかどうかの判断は難しく、バグ発生の温床となります。

日付の変更程度の修正ならまだ定数を使うなどで対応できるかもしれませんが、例えばセールの対象はプレミアム会員限定にする、なんて変更が生じたらその手は利用できません。

```.php
// セールの対象はプレミアム会員のみ
$startDate = new \Datetime('02-01');
$endDate = new \Datetime('02-28');
$now = new \Datetime();

$isSaleTerm = ($now >= $startDate $$ $now <= $endData);

$isSale = ($isSaleTerm && $this->getUser()->isPremium());
```

###テストが書きにくい

コントローラにロジックが入り込むと、テストが書きにくい実装となってしまいます。

現在の日付をコントローラ内でnewして保持しているため、本当に2月にtrueを返すかどうかのテストを書くためには、2月になるまで待たなければいけません。

そうでなくてもコントローラの中のテストを単体テストするのはどうしても難しく、機能テストになってしまいます。

#サービスコンテナの実装

それでは実際に、コントローラからロジックを抜き出して、サービスコンテナを実装します。

##サービスクラスの作成
`src/service/`フォルダを作成して、その配下に`SalesService.php`ファイルを作成します。

```php
// src/service/SalesService.php
namespace App\Service;

class SalesSerivce
{
}
```

メソッドを実装しましょう。先程のロジックを使用しますが、**現在日付は外部から受け取るように**します。

```src/service/SalesService.php

namespace App\Service;

class SalesSerivce
{
    public function isSaleTerm(\Datetime $now): bool
    {
        $startDate = new \Datetime('02-01');
        $endDate = new \Datetime('02-28');

        return ($new >= $startDate && $new <= $endDate);
    }
```

##設定ファイルの作成
今作成したサービスを利用するためには、コンテナに登録する必要があります。

これを`Resouces/config/service.yml`に記述します。

```Resouces/config/service.yml
services:
    app.sales:
        class: src\Services\SalesService
        arguments: [ ]
```

##コントローラからサービスを呼び出す

それでは、作成したサービスをコントローラから呼び出してみましょう。

コントローラではサービスコンテナを呼び出すことができます。

```diff

namespace App\Controller;

use App\Entity\Product;
use Symfony\Component\Routing\Annotation\Route;

class HelloController extends AbstractController
{
    /**
     * @Route("/", name="hello_index")
     */
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $products = $em->getRepository(Product::class)->findAll();

         // ここが期間内が確認するロジック
-        $startDate = new \Datetime('02-01');
-        $endDate = new \Datetime('02-28');
-        $now = new \Datetime();
-
-        $isSaleTerm = ($now >= $startDate $$ $now <= $endData);


+        $SalesService = $this->container->get('app.sales');
+        $isSaleTerm = $SalesService->isSaleTerm(new \Datetime());

        return $this->render('hello/index.html.twig', [
            'products' => $products,
            'isSaleTerm' => $isSaleTerm
        ];      
    }
```


#サービスコンテナを利用するメリット

##疎統合になる

ロジックをサービスとして抜き出したことによって、疎統合になりました。
もし今後セール期間が変更になった場合には、`SalesService`だけ変更すれば修正が完了するという確証が得られます。

##テストがしやすくなる

疎統合になったことで、テストが書きやすくなりました。
実際にサービスに対するテストを記述してみましょう。

```Tests/Service/SalesServiceTest.php
<?php

namespace app\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class SaleseServiceTest extends WebTestCase
{
    private $saleService;

    // ここでサービスコンテナからサービスを取得
    public function setUp():void
    {
        $kernel = self::createKernel();
        $kernel->boot();
        $container = $kernel->getContainer();
        $this->salseService = $container->get('app.sales');
    }

    public function test期間内ならtrueを返す()
    {
        $date = new \DateTime('2020-02-01');
        $this->assertTrue($this->SalesService->isSaleTerm($date));
    }

    public function test期間外ならfalseを返す()
    {
        $date = new \Datetime('2020-03-01');
        $this->assertFalse($this->SalesService->isSaleTerm($date));
    }
}
```

このように、現在日付を外部から渡すことによって、任意の日付のテストを記述することができました。

また、テストの観点は一つのロジックに着目したもとのして記述できていることがわかります。

#おわりに
ざっくりとSymfonyのサービスコンテナについて書いてみました。
Symfonyを使ってみて、サービスコンテナ以外にも部品の再利用性が高く、疎統合な設計にしやすい点がメリットに感じられますね。