Symfony 3.3服务容器变化详解 (autowiring, _defaults等) ...翻译中

3.4 版本
维护中的版本

如果你在Symfony 3.3的一个新项目中看一看 services.yml 文件,你会发现有一些重大改变: _defaults, autowiring, autoconfigure 还有其他更多。这些功能被设计为 automate configuration(自动配置)以令开发更快速,并且未牺牲可预测性,此点非常重要!另一个目标,是要让控制器和服务牢不可破。在In Symfony 3.3中,控制器默认 即是 服务。

文档已经更新,并且假设你已开启了这些新功能。如果你是一个Symfony老用户,十分希望了解这些改变背后的“为什么”和“是什么”,那么本文非常适合你!

所有的改变都是可选的 

最重要的一点, 你现在就可以升到Symfony 3.3而毋须对自己的程序做出任何改变。Symfony有很严格的 backwards compatibility promise(向下兼容承诺),可以确保你在微小版本的升级过程中万无一失。

所有新功能都是 optional(可选): 它们默认并未开启,因此你需要在配置文件中做出改变方可使用它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
services:
    # default configuration for services in *this* file
    # 作用于 *本文件* 之内的服务的默认配置
    _defaults:
        # automatically injects dependencies in your services
        # 在你的服务中自动注入依赖
        autowire: true
        # automatically registers your services as commands, event subscribers, etc.
        # 将服务自动注册为命令,事件订阅器,等等
        autoconfigure: true
        # this means you cannot fetch services directly from the container via $container->get()
        # if you need to do this, you can override this setting on individual services
        # 表示不可以通过 $container->get() 直接从容器中取出服务
        # 如果你需要这样配置,你可以在单一服务的配置中覆写此项
        public: false
 
    # makes classes in src/AppBundle available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    # 让 src/AppBundle 中的所有类变成服务
    # 以这种方式创建服务时使用类的FQCN作为服务id
    AppBundle\:
        resource: '../../src/AppBundle/*'
        # you can exclude directories or files
        # but if a service is unused, it's removed anyway
        # 你可以排除目录或文件,但如果某个服务未被使用,它会被移除
        exclude: '../../src/AppBundle/{Entity,Repository}'
 
    # controllers are imported separately to make sure they're public
    # and have a tag that allows actions to type-hint services
    # 逐控制器地分别地导入服务,以确保其是公有的
    # 并且拥有一个标签,以允许actions能够对服务进行type-hint(类型提示)
    AppBundle\Controller\:
        resource: '../../src/AppBundle/Controller'
        public: true
        tags: ['controller.service_arguments']
 
    # add more services, or override services that need manual wiring
    # 添加更多服务,或覆写那些需要手动关联的服务
    # AppBundle\Service\ExampleService:
    #     arguments:
    #         $someArgument: 'some_value'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- app/config/services.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    <services>
        <defaults autowire="true" autoconfigure="true" public="false" />
 
        <prototype namespace="AppBundle\" resource="../../src/AppBundle/*" exclude="../../src/AppBundle/{Entity,Repository}" />
 
        <prototype namespace="AppBundle\Controller" resource="../../src/AppBundle/Controller" public="true">
            <tag name="controller.service_arguments" />
        </prototype>
    </services>
</container>
1
2
3
4
5
6
7
8
9
10
// app/config/services.php
 
// _defaults and loading entire directories is not possible with PHP configuration
// you need to define your services one-by-one
use AppBundle\Controller\DefaultController;
 
$container->autowire(DefaultController::class)
    ->setAutoconfigured(true)
    ->addTag('controller.service_arguments')
    ->setPublic(true);

这一小段配置,包含了“Symfony中的服务是如何配置的”之范本级(写法)改变。

1) 服务是自动加载的 

Seealso

参考 服务的自动加载

增补 译注Symfony文档随着框架版本而不停地更新,我们需要对旧文档进行修复。此篇翻译完成后,会增补此处链接中的段落。

第一个重大改变是,服务不需要再逐个定义了,这得益于以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# app/config/services.yml
services:
    # ...
 
    # makes classes in src/AppBundle available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    # 让 src/AppBundle 下面的类可以作为服务来使用
    # 这把每一个类都创建为服务,该服务的id就是类的FQCN(完整类名)
    AppBundle\:
        resource: '../../src/AppBundle/*'
        # you can exclude directories or files
        # but if a service is unused, it's removed anyway
        # 你可以排除目录或文件,但如果某个服务未被使用,它会被移除
        exclude: '../../src/AppBundle/{Entity,Repository}'
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- app/config/services.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    <services>
        <!-- ... -->
 
        <prototype namespace="AppBundle\" resource="../../src/AppBundle/*" exclude="../../src/AppBundle/{Entity,Repository}" />
    </services>
</container>
1
2
3
4
5
// app/config/services.php
 
// services cannot be automatically loaded with PHP configuration
// you need to define your services one-by-one
// PHP配置方式,服务无法被自动加载,你需要手动逐个定义服务

这意味着,在 src/AppBundle/ 之下的每一个类都 可以 当成一个服务来使用。多亏了配置文件顶部的 _defaults 区段,所有这些服务,都是 autowired(自动关联)以及 private (私有,即 public: false) 的。

它们的服务id,等同于类名 (即 AppBundle\Service\InvoiceGenerator)。在Symfony 3.3中你也许注意到另一项改变: 我们推荐你使用类名作为服务id,除非你有 同一个类作为多个服务 这种情况。

但是这如何能让容器获知我的服务的参数?

由于服务已经 自动关联,容器能够自动确定多数参数。但是你始终可以覆写某个服务,并且能够 手动配置参数,或是(手动配置)服务中的任何特别之处。

但是慢着,如果我在 src/AppBundle/ 目录下有一些model类(非服务),这不就把它们也都给注册成服务了吗?这难道不是问题吗?

这真的 不是 问题。由于所有的新服务都是 private (这得益于 _defaults),如果任何一个服务 没有 被你的代码使用,它们会自动地从已编译的容器中移除。这意味着你的容器中的服务之数量,和你显式配置每一个服务或通过此种方式加载进全部服务是 相同 的。

Ok,但是我是否可以排除一些我知道的不含服务的路径?

是的! exclude 键接收一个glob pattern,可用于那些你 不希望 被当作服务的 blacklist (黑名单)路径。但是,由于未使用的服务会从容器中自动删除, exclude 也就不那么重要了。最大的好处是,那些路径将不会被容器所 跟踪,这样一来可能导致容器在 dev 环境下不是那么频繁地被重新构建。

2) 默认的自动关联:使用Type-Hint(类型提示)而不是服务id 

第二个大改变是,对于你注册的所有服务,自动关联被开启了(通过 _defaults)。 这也意味着现在的服务id 不太 重要了,而 "类型(types)" (即,类或接口的名称)变得 更加 重要。

例如,在Symfony 3.3之前 (现在仍然可以),你可以把一个服务作为参数传到另一个服务中,像下面这样的配置:

1
2
3
4
5
6
7
8
9
# app/config/services.yml
services:
    app.invoice_generator:
        class: AppBundle\Service\InvoiceGenerator

    app.invoice_mailer:
        class: AppBundle\Service\InvoiceMailer
        arguments:
            - '@app.invoice_generator'
1
2
3
4
5
6
7
8
9
<!-- app/config/services.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    TODO
</container>
1
2
3
// app/config/services.php
 
TODO

To pass the InvoiceGenerator as an argument to InvoiceMailer, you needed to specify the service's id as an argument: app.invoice_generator. Service id's were the main way that you configured things.

But in Symfony 3.3, thanks to autowiring, all you need to do is type-hint the argument with InvoiceGenerator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/AppBundle/Service/InvoiceMailer.php
// ...
 
class InvoiceMailer
{
    private $generator;
 
    public function __construct(InvoiceGenerator $generator)
    {
        $this->generator = $generator
    }
 
    // ...
}

That's it! Both services are automatically registered and set to autowire. Without any configuration, the container knows to pass the auto-registered AppBundle\Service\InvoiceGenerator as the first argument. As you can see, the type of the class - AppBundle\Service\InvoiceGenerator - is what's most important, not the id. You request an instance of a specific type and the container automatically passes you the correct service.

Isn't that magic? How does it know which service to pass me exactly? What if I have multiple services of the same instance?

The autowiring system was designed to be super predictable. It first works by looking for a service whose id exactly matches the type-hint. This means you're in full control of what type-hint maps to what service. You can even use service aliases to get more control. If you have multiple services for a specific type, you choose which should be used for autowiring. For full details on the autowiring logic, see Autowiring Logic Explained.

But what if I have a scalar (e.g. string) argument? How does it autowire that?

If you have an argument that is not an object, it can't be autowired. But that's ok! Symfony will give you a clear exception (on the next refresh of any page) telling you which argument of which service could not be autowired. To fix it, you can manually configure *just* that one argument. This is the philosophy of autowiring: only configure the parts that you need to. Most configuration is automated.

Ok, but autowiring makes your applications less stable. If you change one thing or make a mistake, unexpected things might happen. Isn't that a problem?

Symfony has always valued stability, security and predictability first. Autowiring was designed with that in mind. Specifically:

  • If there is a problem wiring any argument to any service, a clear exception is thrown on the next refresh of any page, even if you don't use that service on that page. That's powerful: it is not possible to make an autowiring mistake and not realize it.
  • The container determines which service to pass in an explicit way: it looks for a service whose id matches the type-hint exactly. It does not scan all services looking for objects that have that class/interface (actually, it does do this in Symfony 3.3, but has been deprecated. If you rely on this, you will see a clear deprecation warning).

Autowiring aims to automate configuration without magic.

3) 控制器被注册为服务 

The third big change is that, in a new Symfony 3.3 project, your controllers are services:

1
2
3
4
5
6
7
8
9
10
# app/config/services.yml
services:
    # ...
 
    # controllers are imported separately to make sure they're public
    # and have a tag that allows actions to type-hint services
    AppBundle\Controller\:
        resource: '../../src/AppBundle/Controller'
        public: true
        tags: ['controller.service_arguments']
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- app/config/services.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    <services>
        <!-- ... -->
 
        <prototype namespace="AppBundle\Controller" resource="../../src/AppBundle/Controller" public="true">
            <tag name="controller.service_arguments" />
        </prototype>
    </services>
</container>
1
2
3
4
5
6
7
8
9
10
// app/config/services.php
 
// loading entire directories is not possible with PHP configuration
// you need to define your services one-by-one
use AppBundle\Controller\DefaultController;
 
$container->autowire(DefaultController::class)
    ->setAutoconfigured(true)
    ->addTag('controller.service_arguments')
    ->setPublic(true);

But, you might not even notice this. First, your controllers can still extend the same base Controller class or a new AbstractController. This means you have access to all of the same shortcuts as before. Additionally, the @Route annotation and _controller syntax (e.g.``AppBundle:Default:homepage``) used in routing will automatically use your controller as a service (as long as its service id matches its class name, which it does in this case). See How to Define Controllers as Services for more details. You can even create invokable controllers

In other words, everything works the same. You can even add the above configuration to your existing project without any issues: your controllers will behave the same as before. But now that your controllers are services, you can use dependency injection and autowiring like any other service.

To make life even easier, it's now possible to autowire arguments to your controller action methods, just like you can with the constructor of services. For example:

1
2
3
4
5
6
7
8
9
use Psr\Log\LoggerInterface;
 
class InvoiceController extends Controller
{
    public function listAction(LoggerInterface $logger)
    {
        $logger->info('A new way to access services!');
    }
}

This is only possible in a controller, and your controller service must be tagged with controller.service_arguments to make it happen. This new feature is used throughout the documentation.

In general, the new best practice is to use normal constructor dependency injection (or "action" injection in controllers) instead of fetching public services via $this->get() (though that does still work).

4) 通过autoconfigure选项自动打标签 

The last big change is the autoconfigure key, which is set to true under _defaults. Thanks to this, the container will auto-tag services registered in this file. For example, suppose you want to create an event subscriber. First, you create the class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/AppBundle/EventSubscriber/SetHeaderSusbcriber.php
// ...
 
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
 
class SetHeaderSusbcriber implements EventSubscriberInterface
{
    public function onKernelResponse(FilterResponseEvent $event)
    {
        $event->getResponse()->headers->set('X-SYMFONY-3.3', 'Less config');
    }
 
    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::RESPONSE => 'onKernelResponse'
        ];
    }
}

Great! In Symfony 3.2 or lower, you would now need to register this as a service in services.yml and tag it with kernel.event_subscriber. In Symfony 3.3, you're already done! The service is automatically registered. And thanks to autoconfigure, Symfony automatically tags the service because it implements EventSubscriberInterface.

That sounds like magic - it automatically tags my services?

In this case, you've created a class that implements EventSubscriberInterface and registered it as a service. This is more than enough for the container to know that you want this to be used as an event subscriber: more configuration is not needed. And the tags system is its own, Symfony-specific mechanism. And of course, you can always default autowire to false in services.yml, or disable it for a specific service.

Does this mean tags are dead? Does this work for all tags?

This does not work for all tags. Many tags have required attributes, like event listeners, where you also need to specify the event name and method in your tag. Autoconfigure works only for tags without any required tag attributes, and as you read the docs for a feature, it'll tell you whether or not the tag is needed. You can also look at the extension classes (e.g. FrameworkExtension for 3.3.0) to see what it autoconfigures.

What if I need to add a priority to my tag?

Many autoconfigured tags have an optional priority. If you need to specify a priority (or any other optional tag attribute), no problem! Just manually configure your service and add the tag. Your tag will take precedent over the one added by auto-configuration.

性能如何 

Symfony is unique because it has a compiled container. This means that there is no runtime performance impact for using any of these features. That's also why the autowiring system can give you such clear errors.

However, there is some performance impact in the dev environment. Most importantly, your container will likely be rebuilt more often when you modify your service classes. This is because it needs to rebuild whenever you add a new argument to a service, or add an interface to your class that should be autoconfigured.

In very big projects, this may be a problem. If it is, you can always opt to not use autowiring. If you think the cache rebuilding system could be smarter in some situation, please open an issue!

升级到 Symfony 3.3 时的配置 

Ready to upgrade your existing project? Great! Suppose you have the following configuration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# app/config/services.yml
services:
    app.github_notifier:
        class: AppBundle\Service\GitHubNotifier
        arguments:
            - '@app.api_client_github'

    markdown_transformer:
        class: AppBundle\Service\MarkdownTransformer

    app.api_client_github:
        class: AppBundle\Service\ApiClient
        arguments:
            - 'https://api.github.com'

    app.api_client_sl_connect:
        class: AppBundle\Service\ApiClient
        arguments:
            - 'https://connect.sensiolabs.com/api'

It's optional, but let's upgrade this to the new Symfony 3.3 configuration step-by-step, without breaking our application.

Step 1): 添加 _defaults 选项  

Start by adding a _defaults section with autowire and autoconfigure.

1
2
3
4
5
6
7
# app/config/services.yml
services:
+     _defaults:
+         autowire: true
+         autoconfigure: true
 
    # ...

This step is easy: you're already explicitly configuring all of your services. So, autowire does nothing. You're also already tagging your services, so autoconfigure also doesn't change any existing services.

You have not added public: false yet. That will come in a minute.

Step 2): 使用class服务的id  

Right now, the service ids are machine names - e.g. app.github_notifier. To work well with the new configuration system, your service ids should be class names, except when you have multiple instances of the same service.

Start by updating the service ids to class names:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# app/config/services.yml
services:
    # ...
 
-     app.github_notifier:
-         class: AppBundle\Service\GitHubNotifier
+     AppBundle\Service\GitHubNotifier:
        arguments:
            - '@app.api_client_github'
 
-     markdown_transformer:
-         class: AppBundle\Service\MarkdownTransformer
+     AppBundle\Service\MarkdownTransformer: ~
 
    # keep these ids because there are multiple instances per class
    app.api_client_github:
        # ...
    app.api_client_sl_connect:
        # ...

But, this change will break our app! The old service ids (e.g. app.github_notifier) no longer exist. The simplest way to fix this is to find all your old service ids and update them to the new class id: app.github_notifier to AppBundle\Service\GitHubNotifier.

In large projects, there's a better way: create legacy aliases that map the old id to the new id. Create a new legacy_aliases.yml file:

1
2
3
4
5
6
7
# app/config/legacy_aliases.yml
services:
    # aliases so that the old service ids can still be accessed
    # remove these if/when you are not fetching these directly
    # from the container via $container->get()
    app.github_notifier: '@AppBundle\Service\GitHubNotifier'
    markdown_transformer: '@AppBundle\Service\MarkdownTransformer'

Then import this at the top of services.yml:

1
2
3
4
5
# app/config/services.yml
+ imports:
+     - { resource: legacy_aliases.yml }
 
# ...

That's it! The old service ids still work. Later, (see the cleanup step below), you can remove these from your app.

Step 3): 令服务私有  

Now you're ready to default all services to be private:

1
2
3
4
5
6
7
8
# app/config/services.yml
# ...
 
services:
     _defaults:
         autowire: true
         autoconfigure: true
+          public: false

Thanks to this, any services created in this file cannot be fetched directly from the container. But, since the old service id's are aliases in a separate file (legacy_aliases.yml), these are still public. This makes sure the app keeps working.

If you did not change the id of some of your services (because there are multiple instances of the same class), you may need to make those public:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# app/config/services.yml
# ...
 
services:
    # ...
 
    app.api_client_github:
        # ...
 
+         # remove this if/when you are not fetching this
+         # directly from the container via $container->get()
+         public: true
 
    app.api_client_sl_connect:
        # ...
+         public: true

This is to guarantee that the application doesn't break. If you're not fetching these services directly from the container, this isn't needed. In a minute, you'll clean that up.

Step 4): 自动注册服务  

You're now ready to automatically register all services in src/AppBundle/ (and/or any other directory/bundle you have):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# app/config/services.yml
 
services:
    _defaults:
        # ...
 
+     AppBundle\:
+         resource: '../../src/AppBundle/*'
+         exclude: '../../src/AppBundle/{Entity,Repository}'
+
+     AppBundle\Controller\:
+         resource: '../../src/AppBundle/Controller'
+         public: true
+         tags: ['controller.service_arguments']
 
    # ...

That's it! Actually, you're already overriding and reconfiguring all the services you're using (AppBundle\Service\GitHubNotifier and AppBundle\Service\MarkdownTransformer). But now, you won't need to manually register future services.

Once again, there is one extra complication if you have multiple services of the same class:

1
2
3
4
5
6
7
8
9
10
11
12
13
# app/config/services.yml
 
services:
    # ...
 
+     # alias ApiClient to one of our services below
+     # app.api_client_github will be used to autowire ApiClient type-hints
+     AppBundle\Service\ApiClient: '@app.api_client_github'
 
    app.api_client_github:
        # ...
    app.api_client_sl_connect:
        # ...

This guarantees that if you try to autowire an ApiClient instance, the app.api_client_github will be used. If you don't have this, the auto-registration feature will try to register a third ApiClient service and use that for autowiring (which will fail, because the class has a non-autowireable argument).

Step 5): 扫尾!  

To make sure your application didn't break, you did some extra work. Now it's time to clean things up! First, update your application to not use the old service id's (the ones in legacy_aliases.yml). This means updating any service arguments (e.g. @app.github_notifier to @AppBundle\Service\GitHubNotifier) and updating your code to not fetch this service directly from the container. For example:

1
2
3
4
5
6
7
8
9
-     public function indexAction()
+     public function indexAction(GitHubNotifier $gitHubNotifier, MarkdownTransformer $markdownTransformer)
    {
-         // the old way of fetching services
-         $githubNotifier = $this->container->get('app.github_notifier');
-         $markdownTransformer = $this->container->get('markdown_transformer');
 
        // ...
    }

As soon as you do this, you can delete legacy_aliases.yml and remove its import. You should do the same thing for any services that you made public, like app.api_client_github and app.api_client_sl_connect. Once you're not fetching these directly from the container, you can remove the public: true flag:

1
2
3
4
5
6
7
8
9
10
11
# app/config/services.yml
services:
    # ...
 
    app.api_client_github:
        # ...
-         public: true
 
    app.api_client_sl_connect:
        # ...
-         public: true

Finally, you can optionally remove any services from services.yml whose arguments can be autowired. The final configuration looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    AppBundle\:
        resource: '../../src/AppBundle/*'
        exclude: '../../src/AppBundle/{Entity,Repository}'

    AppBundle\Controller\:
        resource: '../../src/AppBundle/Controller'
        public: true
        tags: ['controller.service_arguments']

    AppBundle\Service\GitHubNotifier:
        # this could be deleted, or I can keep being explicit
        arguments:
            - '@app.api_client_github'
 
    # alias ApiClient to one of our services below
    # app.api_client_github will be used to autowire ApiClient type-hints
    AppBundle\Service\ApiClient: '@app.api_client_github'
 
    # keep these ids because there are multiple instances per class
    app.api_client_github:
        class: AppBundle\Service\ApiClient
        arguments:
            - 'https://api.github.com'

    app.api_client_sl_connect:
        class: AppBundle\Service\ApiClient
        arguments:
            - 'https://connect.sensiolabs.com/api'

You can now take advantage of the new features going forward.

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)