Symfony
Symfony4
SymfonyDay 1

[Symfony4] Symfony Best Practice 定点観測

Symfony Advent Calendar 2017 1日目の記事です。

Symfony4リリースおめでとうございます!:tada:

昨年のアドベントカレンダーでSymfony Best Practiceの翻訳をしました。
今年はSymfony4のリリースもあり、Best Practiceにも変更があるということで、昨年との差分を確認しながら、Symfony4の変更点の確認をしていきます。


方法

昨年の翻訳時点のコミットと、masterのdiffを取り、変更点のみつまみ食いしていきます。
例)
git diff 42408178984a882db8cda37d20fa598d7aaaaa3b 5d4b3351adc444848e9944d05f861b644966559e business-logic.rst >> ~/Desktop/business-logic.txt


参考

SymfonyConでのFabianの資料


introduction

  • 初めての方向けの資料が、Quick TourからGetting Started guides setupに変更
  • サンプルアプリケーション(Symfony Demo)のダウンロード方法変更
    • before: $ symfony demo
    • after: $ composer create-project symfony/symfony-demo

creating-the-project

Installing Symfony

  • Best Practice 新規追加
    • アプリケーションの作成と管理にはComposerとSymfony Flexを使いましょう。

ComposerはモダンなPHPアプリケーションで依存性を管理するのに使われるパッケージマネージャです。
Symfony FlexはSymfonyアプリケーションでもっとも一般的に実行されるタスクを自動化する
Composerのプラグインです。
Flexを利用するのは任意ではありますが、生産性を大きく向上させるので、推奨されています。

  • Best Practiceに変更がありました。

    • before: Symfonyベースのプロジェクトを作成するときは、Symfony Installerを使いましょう
    • after: Symfonyベースのプロジェクトを作成するときは、Symfony Skektonを使いましょう
  • Symfony Skeltonについての説明追加

Symfony Skeletonは最小限の空っぽのSymfonyプロジェクトで、新しいプロジェクトのベースにできます。
過去のSymfonyと異なり、このスケルトンはSymfonyプロジェクトの動作に必要最小限の依存関係を
インストールします。
Symfonyのインストールについては、Installing & Setting up the Symfony Frameworkの記事を参照ください。

Creating the Blog Application

  • プロジェクトの初期化方法が変更になりました。
    • before: $ symfony new blog
    • after: $ composer create-project symfony/skeleton blog
  • インストール時に実行されていた、実行環境のチェックに関する記述が削除されました。
    • 削除:インストーラーはお使いのコンピュータの設定がSymfonyアプリケーションを実行するのに必要な条件を満たしているかもチェックします。 条件を満たしていない場合、修正するべき箇所の一覧が表示されます。
  • 実行環境のチェックはこちらの方法に変更になったようです。

    • $ composer require requirements-checker
  • ディレクトリ構成が変わりました

before:

blog/
├─ app/
│  ├─ config/
│  └─ Resources/
├─ bin
│  └─ console
├─ src/
│  └─ AppBundle/
├─ var/
│  ├─ cache/
│  ├─ logs/
│  └─ sessions/
├─ tests/
│  └─ AppBundle/
├─ vendor/
└─ web/

after:

blog/
├─ bin/
│  └─ console
├─ config/
└─ public/
│  └─ index.php
├─ src/
│  └─ Kernel.php
├─ var/
│  ├─ cache/
│  └─ log/
└─ vendor/
  • ディレクトリ構成に関する説明が変更になりました。
    • before: 各ディレクトリごとに説明があった。
    • after: 操作しやすく、ほとんどは自己説明的なディレクトリ名になっているので、この構成を踏襲することが推奨されますが、いずれの設定も上書きすることができます
    • 上書きの方法:override the location of any Symfony directory
// src/Kernel.php
// ...
class AppKernel extends BaseKernel
{
    // ...
    public function getCacheDir()
    {
        return dirname(__DIR__).'/var/'.$this->environment.'/cache';
    }
}

Application Bundles

  • Best Practice変更
    • before: アプリケーションロジックはAppBundle1箇所にまとめましょう。
    • after: アプリケーションロジックを整理するためにバンドルを作ってはいけません。

Symfonyアプリケーションで機能追加のために3rdパーティのバンドル(vendor/配下にインストールされる)
を使うことはできますが、自分のコードを整理するには、bundleにするのではなく、
PHPのネームスペースを使うべきです。


Configuration

大きな変更点

  • app/config/parameters.yml -> .env
  • app/config/config.yml -> config/services.yaml
  • ディレクトリ構造の変更に伴うnamespace、パスの変更

Infrastructure-Related Configuration

  • Best Practiceの変更
    • before: インフラに関連する環境設定は、app/config/parameters.ymlに定義しましょう。
    • after: インフラに関連する環境設定は、環境変数にしましょう。開発環境ではrootディレクトリの.envファイルに設定しましょう。デフォルトでは、新たな依存性を追加するたびにSymfonyは以下の設定を.envに追加します。
# .env
###> doctrine/doctrine-bundle ###
DATABASE_URL=sqlite:///%kernel.project_dir%/var/data/blog.sqlite
###< doctrine/doctrine-bundle ###
###> symfony/swiftmailer-bundle ###
MAILER_URL=smtp://localhost?encryption=ssl&auth_mode=login&username=&password=
###< symfony/swiftmailer-bundle ###
# ...

これらの設定は、config/services.yamlでは定義されません。

Canonical Parameters

  • Best Practiceの変更
    • before: アプリケーションに関するパラメーターは全てapp/config/parameters.yml.distに定義しましょう
    • after: アプリケーションに関わる環境変数はすべて.env.distに定義しましょう

Application-Related Configuration

  • Best Practiceの変更
    • before: アプリケーションの振る舞い関する設定はapp/config/config.ymlに定義しましょう。
    • after: アプリケーションの振る舞い関する設定はconfig/services.yamlに定義しましょう。

Parameter Naming

  • 削除:Semantic Configuration: Don't Do It
  • Best Practiceの追加
    • 設定するパラメータの命名はできる限り短く、アプリケーション全体で共通のプレフィックスをつけましょう

app.というプレフィックスをつけることでSymfonyと3rdパーティのバンドルやライブラリのパラメーターとの命名の衝突を防ぐ。
パラメーターの目的がわかるように1、2語で表現する。

# config/services.yaml
parameters:
    # よくない例: 'dir' は一般的すぎ、 何の意図も伝わらない
    app.dir: '...'
    # 良い例: 短く、意味がわかる
    app.contents_dir: '...'
    # ドット, アンダースコア, ダッシュ,あるいは区切り文字に何も使わないのもOK
    # ただし、常に同じフォーマットで、表記揺れがないようにパラメータを命名すること
    app.dir.contents: '...'
    app.contents-dir: '...'

Organizing Your Business Logic

  • ディレクトリ構造変更に伴う記述変更
    • src/ディレクトリ以下にコードをまとめる。
symfony-project/
├─ config/
├─ public/
├─ src/
│  └─ Utils/
│     └─ MyClass.php
├─ tests/
├─ var/
└─ vendor/
  • 削除:Storing Classes Outside of the Bundle?

Services: Naming and Configuration

  • Services: Naming and Formatの書き換えと新規追加
  • アプリケーションサービスの設定を自動化するためにオートワイアリングを使いましょう
    サービスのオートワイアリングは最小限の設定でサービスを管理するために、Symfonyのサービスコンテナが提供する機能です。
    コンストラクタ(あるいは他のメソッド)のタイプヒンティングを読み込み、それぞれのメソッドに適当なサービスを自動的に引き渡します。
    Twig extensionsや, event subscribersなどのように、サービスタグを必要なサービスに追加することでも実現できます。
    (中略)
    デフォルトのservice.yamlによる設定をお使いであれば、このクラス(Slugger)はApp\Utils\Slugger(あるいはインポート済みであれば単にSlugger::class)というIDで自動的にサービスとして登録されます。

  • アプリケーションサービスのIDは、同じクラスに複数のサービスの設定をしていない限り(その場合はスネークケースのIDを使いましょう)、クラス名と一致するべきです。`

こうすることで、他のサービスやコントローラーからカスタマイズしたsluggerを使うことができます

use App\Utils\Slugger;
public function create(Request $request, Slugger $slugger)
{
    // ...
    if ($form->isSubmitted() && $form->isValid()) {
        $slug = $slugger->slugify($post->getTitle());
        $post->setSlug($slug);
        // ...
    }
}

サービスはpublicまたはprivateです。デフォルトのservice.yamlによる設定をお使いであれば、デフォルトはprivateです。

  • できる限りサービスはprivateであるべきです。そうすることで$container->get()によりアクセスされることを防ぐことができます。代わりにdependency injectionを使う必要があります。

  • 削除:Service: No Class Parameter

  • Using a Persistence Layer

    • ディレクトリ構成の変更
  • Doctrine Mapping Information

    • ディレクトリ構成の変更
  • Data Fixtures

    • $ composer require "doctrine/doctrine-fixtures-bundle"の説明変更
    • before: AppKernel.phpで有効になった。
    • after: 自動的に有効になる。

Controllers

  • 削除:5-10-20 ruleに関する記述
  • 追加:コントローラーの役割は以下の通り

コントローラーのメソッドは一切のビジネスロジックを排した上で、必要な時にサービスを呼び出すかイベントを発火させ、レスポンスを返すだけにするべきです。
もし、ビジネスロジックがあるならば、コントローラーからサービスに移すリファクタリングをしましょう。

  • Best Practice変更
    • before: コントローラーはフレームワークバンドルを拡張して作りましょう。可能な限り、ルーティング、キャッシュ、セキュリティの設定はアノテーションで記述しましょう。
    • after: コントローラーはSymfonyが提供するベースコントローラーであるAbstractControllerをextendして作りましょう。ルーティング、キャッシュ、セキュリティなどの設定は可能な限りアノテーションで記述しましょう。

Controller Action Naming

  • 新規追加
  • Best Practice
    • コントローラーのアクションに "Action"というサフィックスをつけをつけてはいけません 最初のSymfonyでは(例えばnewAction()やshowAction()のように)コントローラーのメソッド名はActionで終わっている必要がありました。 このサフィックスは、コントローラーにアノテーションが導入された段階で、任意のものとなりました。 モダンなSymfonyアプリケーションでは、必要でも推奨でも無くなりますので、安心してサフィックスを削除してください。

Routing Configuration

  • サンプルコード/ディレクトリ構成変更
# config/routes.yaml
controllers:
    resource: '../src/Controller/'
    type:     annotation
<your-project>/
├─ ...
└─ src/
   ├─ ...
   └─ Controller/
      ├─ DefaultController.php
      ├─ ...
      ├─ Api/
      │  ├─ ...
      │  └─ ...
      └─ Backend/
         ├─ ...
         └─ ...

What does the Controller look like

  • サンプルコード変更
namespace App\Controller;
use App\Entity\Post;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class DefaultController extends AbstractController
{
    /**
     * @Route("/", name="homepage")
     */
    public function index()
    {
        $posts = $this->getDoctrine()
            ->getRepository(Post::class)
            ->findLatest();
        return $this->render('default/index.html.twig', [
            'posts' => $posts,
        ]);
    }
}

Fetching Services

  • 新規追加

AbstractControllerクラスを継承した場合、$this->container->get()$this->get()によってサービスに直接アクセスすることはできなくなります。その代わりdependency injectionを使う必要があります。最も簡単に実現するには、アクションメソッドのタイプヒンティングを使います

  • $this->get()$this->container->get()を使ってサービスを呼び出してはいけません。dependency injectionを使いましょう。

コンテナから直接サービスを呼び出さないことで、サービスをprivateにできます。そうすることでいくつか利点があります。

コントローラーの章の残りは概ねサンプルコードの変更なので省略


Templates

Template Locations

  • Best Practiceの変更
    • before: app/Resources/view/ディレクトリ配下にテンプレートを格納しましょう。
    • after: templates/ディレクトリ配下にテンプレートを格納しましょう。

テンプレートを1箇所にまとめることで、デザイナーの仕事を軽減できます。加えて、他のテンプレートを参照する時の書き方を単純にできます。(例えば$this->render('@SomeTwigNamespace/Admin/Posts/show.html.twig'))ではなく、$this->render('admin/post/show.html.twig')のように書くことができます。)

  • 削除: バンドル内のテンプレートとapp/以下のテンプレートの表記方法の違い
  • Best Practice新規追加
    • 「ディレクトリとテンプレート名には、小文字のスネークケースを使いましょう」に説明追加
    • このベストプラクティスはTwigの、変数名とテンプレート名は小文字のスネークケースで記述するというベストプラクティスにならったものです。
  • Best Practice新規追加
    • パーシャルテンプレートの名前には、アンダースコアをプレフィックスとしてつけましょう
      •  include関数を使ってコードの重複を避けるために、テンプレートを再利用したいと思うことはよくあるでしょう。ファイルシステム内で、パーシャルを見分けるために、パーシャルやHTMLのbodyやextendsタグ以外のテンプレートにはアンダースコアのプレフィックスをつけましょう。

Twig Extensions

  • Best Practiceの変更
    • before: AppBundle/Twig/ディレクトリ配下に拡張機能を格納して、app/config/services.ymlで設定しましょう。
    • after: Twig extention はsrc/Twig/ディレクトリ配下に定義しましょう。自動的にアプリケーションに設定されます。

このアプリには、マークダウンで書かれた記事をHTMLに変換して投稿するために、md2htmlというTwigのフィルターが必要です。 実現するために、Twig extensionから呼び出すMarkdownクラスを作成します。マークダウンをHTMLに変換するメソッドだけを定義します。

namespace App\Utils;
class Markdown
{
    // ...
    public function toHtml(string $text): string
    {
        return $this->parser->text($text);
    }
}

次に、新しいTwig extensionを作成して、TwigFilterクラスをuseしてmd2htmlというフィルターを定義します。新しく作ったMarkdownクラスをTwig extensionのコンストラクターにインジェクトします。

namespace App\Twig;
use App\Utils\Markdown;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class AppExtension extends AbstractExtension
{
    private $parser;
    public function __construct(Markdown $parser)
    {
        $this->parser = $parser;
    }
    public function getFilters()
    {
        return [
            new TwigFilter('md2html', [$this, 'markdownToHtml'], [
                'is_safe' => ['html'],
                'pre_escape' => 'html',
            ]),
        ];
    }
    public function markdownToHtml($content)
    {
        return $this->parser->toHtml($content);
    }
}

以上です!

デフォルトのservices.yamlによる設定を利用しているのであれば、これで終わりです!
Symfonyは自動的にあなたの新しいサービスと、タグを認識して、Twig extensionとして利用します。


Forms

  • Best Practiceの変更

    • before: data transformerなどカスタムフォームクラスを使っていないのであれば、AppBundle\Formネームスペースにフォームタイプクラスを配置しましょう。
    • after: data transformerなどカスタムフォームクラスを使っていないのであれば、App\Formネームスペースにフォームタイプクラスを配置しましょう。
  • 削除:Registering Forms as Services

Handling Form Submits

フォームのsubmitデータの扱い方は、テンプレートの扱い方とよく似ています。

public function new(Request $request)
{
    // build the form ...
    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        $em = $this->getDoctrine()->getManager();
        $em->persist($post);
        $em->flush();
        return $this->redirectToRoute('admin_post_show', [
            'id' => $post->getId()
        ]);
    }
    // render the template
}

フォームの表示とsubmitを1つのアクションで扱うことをお勧めします。
例えば、new()というアクションに表示だけを、create()というアクションにsubmitデータの処理をさせることもできます。これら2つのアクションはほとんど一致します。そのため、new()で全て処理をするようにすることで、よりシンプルになります。


Internationalization

国際化とローカライズにより、ユーザーの地域性や言語にアプリケーションの内容を合わせることができます。Symfonyでは、この機能はオプト・インの(訳注:選択して取り入れることができる)機能で、利用する前にインストールが必要です。(composer require translation)

Translation Source File Location

  • Best Practiceの変更
    • ルートディレクトリ直下のtranslations/ディレクトリに翻訳ファイルを格納しましょう。

全ての翻訳ファイルがわかりやすい1箇所にあれば、翻訳家の気も楽になります。


Security

Authentication and Firewalls (i.e. Getting the User's Credentials)

  • Best Practice変更
    • before: ユーザーパスワードの生成にはbcryptエンコーダーを使いましょう。
    • after: ユーザーパスワードのハッシュ化にはbcryptエンコーダーを使いましょう。
# config/packages/security.yaml
security:
    encoders:
        App\Entity\User: bcrypt
    providers:
        database_users:
            entity: { class: App\Entity\User, property: username }
    firewalls:
        secured_area:
            pattern: ^/
            anonymous: true
            form_login:
                check_path: login
                login_path: login
            logout:
                path: security_logout
                target: homepage
# ... access_control exists, but is not shown here

Authorization (i.e. Denying Access)

  • Best Practice変更
    • before:
      • 細かな制限のためにセキュリティVoterを定義する。
      • 管理機能を経由したユーザーがあらゆるオブジェクトにアクセスするの制限するために、Symfony ACLを利用する。
    • after: 細かな制限のためにセキュリティVoterを定義する。

Security Voters

複雑なセキュリティロジックを実装する必要があって、isAuthor()のようなメソッドにまとめられない場合は、カスタムVoterを活用しましょう。ACLを使うより簡単で、ほとんどすべての場合に対応できる柔軟性があります。

まずVoterクラスを作りましょう。以下の例は上記で実装してきたgetAuthorEmailと同じ内容を実装しています。

namespace App\Security;
use App\Entity\Post;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class PostVoter extends Voter
{
    const CREATE = 'create';
    const EDIT   = 'edit';
    private $decisionManager;
    public function __construct(AccessDecisionManagerInterface $decisionManager)
    {
        $this->decisionManager = $decisionManager;
    }
    protected function supports($attribute, $subject)
    {
        if (!in_array($attribute, [self::CREATE, self::EDIT])) {
            return false;
        }
        if (!$subject instanceof Post) {
            return false;
        }
        return true;
    }
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();
        /** @var Post */
        $post = $subject; // $subject must be a Post instance, thanks to the supports method
        if (!$user instanceof UserInterface) {
            return false;
        }
        switch ($attribute) {
            // if the user is an admin, allow them to create new posts
            case self::CREATE:
                if ($this->decisionManager->decide($token, ['ROLE_ADMIN'])) {
                    return true;
                }
                break;
            // if the user is the author of the post, allow them to edit the posts
            case self::EDIT:
                if ($user->getEmail() === $post->getAuthorEmail()) {
                    return true;
                }
                break;
        }
        return false;
    }
}

Web Assets

アセットとは、CSS、JavaScript、イメージファイルのように、フロントエンドの見た目と操作をいい感じにするものです。

  • Best Practice追加
    • プロジェクトルートのassets/ ディレクトリにアセットを格納しましょう。

1箇所にまとまっていることで、デザイナーやフロントエンドの開発者の仕事がやりやすくなります。

  • Best Practice追加
    • アセットのコンパイル、結合、圧縮にWebpack Encoreを使いましょう。

Webpackはブラウザで利用するために、アセットのコンパイル、換、圧縮を担うJavaScriptのモジュールのバンドラーとしては、最もよく使われているものです。
Webpack Encoreは機能を隠したり、その使い勝手や哲学を侵したりすることなく、Webpackの複雑さを取り除くJavaScriptライブラリです。

Webpack EncoreはSymfonyベースのアプリケーションとモダンなWebアプリケーションで利用されるJavaScriptベースのツール群とのギャップを埋めるために設計されました。詳しい機能についてはWebpack Encoreのオフィシャルドキュメントを確認してください。


Tests

色々なテストの種類はありますが、このベストプラクティスではユニットテストとファンクショナルテストを中心にします。
ユニットテストは、ある機能の入出力をテストすることができます。
ファンクショナルテストは、自分のサイトを見るようにリンクをクリックしたり、フォームに入力したり、特定の項目をチェックしたり、「ブラウザ」での操作をテストできます。

  • ユニットテストのサンプルコード変更
// tests/ApplicationAvailabilityFunctionalTest.php
namespace App\Tests;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ApplicationAvailabilityFunctionalTest extends WebTestCase
{
    /**
     * @dataProvider urlProvider
     */
    public function testPageIsSuccessful($url)
    {
        $client = self::createClient();
        $client->request('GET', $url);
        $this->assertTrue($client->getResponse()->isSuccessful());
    }
    public function urlProvider()
    {
        yield ['/'];
        yield ['/posts'];
        yield ['/post/fixture-post-1'];
        yield ['/blog/category/fixture-category'];
        yield ['/archives'];
        // ...
    }
}

Hardcode URLs in a Functional Test

  • サンプルコードの変更
// ...
private $router; // consider that this holds the Symfony router service
public function testBlogArchives()
{
    $client = self::createClient();
    $url = $this->router->generate('blog_archives');
    $client->request('GET', $url);
    // ...
}

明日は@polidog兄さんによるsymfony4でTodoアプリを作って気づいた事です!