返回
顶部

前言

symfony是一个php框架,这篇文章我将会介绍symfony的container(容器)

symfony引入容器是为了更好地管理项目中各个类之间的依赖关系

就像java的Spring框架一样,symfony框架也拥有依赖注入功能,而该功能通过container来实现,在symfony中,container是一个包含了许多对象的容器,该容器中的对象会自动处理依赖关系,容器中的对象叫做服务

正文

示例项目源代码:

下载解压之后,进入项目目录,运行composer install,然后再运行composer dump-autoload即可

这是一个权限检查类的程序,包括两个实体类:UserPost,前者代表用户,后者代表票

一个投票人接口以及其实现类:VoterInterfacePostVoter

一个用来管理权限的类:AccessManager,该类有一个decide方法用于决定给定用户是否对指定的post对象拥有特定的权限

Definition

index.php

<?php

require __DIR__.'/../vendor/autoload.php';

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\DependencyInjection\ContainerBuilder;

$containerBuilder = new ContainerBuilder();

$postVoter = new PostVoter();
$manager = new AccessManager([$postVoter]);

$containerBuilder->set('post_voter', $postVoter);
$containerBuilder->set('access_manager', $manager);

dump($containerBuilder->get('access_manager'));
dump($containerBuilder->get('post_voter'));

这里使用了symfony的container,初始化一个ContainerBuilder类,即可创建出一个container,然后我们使用set方法将我们的对象放到该容器中,第一个参数就是我们的对象在容器中的名称

使用get即可取出指定名称的对象

但是上面这种写法在往容器中放对象的时候已经实例化了我们的PostVoter类和AccessManager类,但是我们还没有到使用这两个对象的时候,提前初始化被认为是对时间和内存资源的浪费

因此,symfony引入了Definition这个概念,来告诉容器何时以及如何实例化我们放入其中的类

<?php

require __DIR__.'/../vendor/autoload.php';

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

$containerBuilder = new ContainerBuilder();

$voterDefinition = new Definition(PostVoter::class);
$containerBuilder->setDefinition('post_voter', $voterDefinition);

$managerDefinition = new Definition(AccessManager::class);
$managerDefinition->addArgument([new Reference('post_voter')]);
$containerBuilder->setDefinition('access_manager', $managerDefinition);

$accessManager = $containerBuilder->get('access_manager');
dump($accessManager);

可以看到,之前实例化类的部分变成了Definition,使用类的FQCN作为参数,另外set方法也被替换成了setDefinition方法

对$managerDefinition额外调用了addArgument方法来设置access_manager在初始化时构造函数的参数,这里用了post_voter的Reference

上面的代码在真正执行$accessManager = $containerBuilder->get('access_manager')这行代码之后完全没有创建任何PostVoterAccessManager对象

我们可以使用容器的register方法来省略创建Definition对象的步骤:

<?php

require __DIR__.'/../vendor/autoload.php';

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

$containerBuilder = new ContainerBuilder();

$containerBuilder->register("post_voter", PostVoter::class);
$containerBuilder->register("access_manager", AccessManager::class)
    ->addArgument([new Reference('post_voter')]);

$accessManager = $containerBuilder->get('access_manager');
dump($accessManager);

作用域

在上面的例子中,post_voter对我们来说是没有太多作用的,他只需要由access_manager来进行管理就行了,我们没必要使用get将其从容器中取出来**

ContainerBuilder提供了private和public服务的概念,我们可以在Definition中进行设置,被设置了private的服务是无法通过容器的get方法进行获取的

用法非常简单,只需要调用setPublic方法即可,参数为布尔型,true则为public,false则为private

$containerBuilder->register("post_voter", PostVoter::class)
    ->setPublic(false);
$containerBuilder->compile();

使用上面的代码即可将post_voter设置为private,compile方法用于使该设置生效

使用symfony的Config组件来管理配置

我们这里使用php文件作为配置文件的文件类型

config/config.php

<?php
// config/config.php

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use Symfony\Component\DependencyInjection\Reference;

$container->register('post_voter', PostVoter::class)
    ->setPublic(false);
$container->register('access_manager', AccessManager::class)
    ->setPublic(true)
    ->addArgument([new Reference('post_voter')]);

在配置文件中$container变量和$loader变量是可以直接访问的

前者是容器,后者是PhpFileLoader对象

上面的配置文件注册了两个服务,并给access_manager设置了构造函数的参数

在index.php中只需要将配置文件加载上来就行了

$containerBuilder = new ContainerBuilder();

$loader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__.'/../config'));
// we'll create a config/config.php file to handle our configuration
$loader->load('config.php');

$containerBuilder->compile();

dump($containerBuilder);die;

1623164149595

自动配置

通过创建一个Definition的原型,我们可以控制注入进容器中的服务

config.php

<?php
// config/config.php

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use Symfony\Component\DependencyInjection\Reference;

$definition = new Definition();
$definition->setPublic(false);

$loader->registerClasses($definition, 'App\\', '../src/*');

$container->getDefinition(AccessManager::class)
    ->setPublic(true)
    ->addArgument([new Reference(PostVoter::class)]);

可以看到我们设置的Definition原型是所有服务都是隐藏的,无法直接获取,如果想要暴露出来,需要单独使用setPublic设置为公开的

而且,进行完上述配置之后,需要执行compile方法使配置生效,另外,从容器中获取服务的时候要是用FQCN

index.php

$containerBuilder = new ContainerBuilder();

$loader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__.'/../config'));
// we'll create a config/config.php file to handle our configuration
$loader->load('config.php');

$containerBuilder->compile();

dump($containerBuilder->get(AccessManager::class));
die;

1623165165077

上面的配置文件有一点瑕疵,就是registerClasses方法将APP命名空间以及src目录下的所有类都注册到了容器中,但是有一部分类是没必要注册的,比如Entity目录下的类

1623165418292

这时我们只需要给registerClasses传入第四个参数用于排除不想注册的类即可

$loader->registerClasses($definition, 'App\\', '../src/*', "../src/Entity/*");

1623165453707

service tag

顾名思义就是给服务打上标签

config.php

$definition = new Definition();
$definition->setPublic(false);

$container->registerForAutoconfiguration(VoterInterface::class)
    ->addTag('app.voter');

$loader->registerClasses($definition, 'App\\', '../src/*', "../src/Entity/*");

$container->getDefinition(AccessManager::class)
    ->setPublic(true)
    ->addArgument($container->findTaggedServiceIds('app.voter'));

在上面的代码中,我们使用registerForAutoconfiguration方法给所有实现了VoterInterface接口的类打上了app.voter的标签

当我们需要获取拥有该标签的服务时,只需执行如下代码即可

$container->findTaggedServiceIds('app.voter')

但是,光这样还不行,标签只有在容器编译的时候才会真正打上,此时AccessManager服务中的voter是空的

1623166132256

这个时候我们就需要引入CompilerPass概念了

CompilerPass

compiler pass使得我们可以操作已经注册在容器中的服务定义

在本例中,我们创建一个VoterPass类,该类需要实现Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface接口的process方法,该方法会在容器编译的时候执行

<?php

namespace App\DependencyInjection\Compiler;

use App\Authorization\AccessManager;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class VoterPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $accessManagerDefinition = $container->getDefinition(AccessManager::class);
        $accessManagerDefinition->addArgument(new TaggedIteratorArgument('app.voter'));
    }
}

在上面的代码中,我么使用了TaggedIteratorArgument类来完成对所有打了app.voter标签的服务的注入

然后我们将VoterPass注册到容器中即可

index.php

$containerBuilder = new ContainerBuilder();
$containerBuilder->addCompilerPass(new VoterPass());

另外,还需要将Definition设置为Autoconfigured以实现标签服务的注入

同时在注册类的时候将VoterPass类所处的目录排除

config.php

$definition = new Definition();
$definition
    ->setAutoconfigured(true)
    ->setPublic(false);

$container->registerForAutoconfiguration(VoterInterface::class)
    ->addTag('app.voter');

$loader->registerClasses($definition, 'App\\', '../src/*', '../src/{Entity,DependencyInjection}');

$container->getDefinition(AccessManager::class)
    ->setPublic(true);

这样以来,当我们需要给AccessManager新增Voter的时候,只需要实现VoterInterface接口即可

1623167048807

控制器

在上面的例子中,AccessManager是可以直接被我们访问到的,但是在实际的工作中这并不是一个很好的做法

我们更倾向于使用controller(控制器)来进行访问,这里需要用到autowiring,这个和java的框架Spring Framework是一样的,都拥有根据类型自动注入依赖的功能

autowiring可以最大化的简化配置,它可以根据函数的的类型提示自动注入需要用到的服务

首先需要对config.php进行一些修改

<?php

use App\Authorization\AccessManager;
use App\Authorization\Voter\VoterInterface;
use Symfony\Component\DependencyInjection\Definition;

$privateDefinition = new Definition();
$privateDefinition
    ->setAutowired(true)
    ->setAutoconfigured(true)
    ->setPublic(false);

$publicDefinition = new Definition();
$publicDefinition
    ->setAutowired(true)
    ->setAutoconfigured(true)
    ->setPublic(true);

$container->registerForAutoconfiguration(VoterInterface::class)
    ->addTag('app.voter');

$loader->registerClasses($privateDefinition, 'App\\', '../src/*', '../src/{Entity,DependencyInjection}');

$loader->registerClasses($publicDefinition, 'App\\Controller\\', '../src/Controller/*');

我们只将controller服务暴露了出来,其他被注册的服务全部都是私有的,无法被访问到

src\controller\PostController.php

<?php

namespace App\Controller;

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use App\Entity\Post;
use App\Entity\User;

class PostController
{
    /** @var AccessManager */
    private $accessManager;

    public function __construct(AccessManager $accessManager)
    {
        $this->accessManager = $accessManager;
    }

    public function index()
    {
        $user = new User('Alex');
        $admin = new User('Admin');
        $admin->addRole(User::ROLE_ADMIN);

        $post = new Post();

        dump($this->accessManager->decide(PostVoter::READ, $post, $user));      // true
        dump($this->accessManager->decide(PostVoter::READ, $post, $admin));     // true

        dump($this->accessManager->decide(PostVoter::WRITE, $post, $user));     // false
        dump($this->accessManager->decide(PostVoter::WRITE, $post, $admin));    // true
    }
}

AccessManager服务会自动注入以进行PostController服务的初始化

然后我们就可以在index.php中调用PostController的index方法了

$containerBuilder->get(PostController::class)->index()

1623729218286

通过服务的setter方法进行服务注入

我们给AccessManager类增加一个set方法

public function setVoters(iterable $voters)
{
    $this->voters = $voters;
}

VoterPass类也要随之改变

public function process(ContainerBuilder $container)
{
    $accessManagerDefinition = $container->getDefinition(AccessManager::class);
    $accessManagerDefinition->addMethodCall('setVoters', [new TaggedIteratorArgument('app.voter')]);
}

可以看到,之前是给AccessManager的构造函数传参,现在直接改成了调用AccessManager的setVoters方法并传递参数,另外还需要删除AccessManager的构造函数,如果不进行更改,是会报错的,因为symfony会尝试使用autowiring对AccessManager进行自动的依赖注入,但是构造函数的参数类型是iterable,进而会导致报错

更好的做法是使用addVoter方法而不是setVoters方法,这样可以在逐一增加Voter的过程中进行一些验证

AccessManager.php

public function addVoter(VoterInterface $voter)
{
    $this->voters[] = $voter;
}

注意,php正加数组元素的方式就是直接赋值$this->voters[] = $voter,xdm可以运行下面这段代码来加深理解

$array = array('test1', 'test2');
$array[] = 'test3';
$array['name'] = 'jack';
var_dump($array);

VoterPass.php

public function process(ContainerBuilder $container)
{
    $accessManagerDefinition = $container->getDefinition(AccessManager::class);
    $voters = $container->findTaggedServiceIds('app.voter');
    foreach ($voters as $serviceId => $tagAttributes) {
        $accessManagerDefinition->addMethodCall('addVoter', [new Reference($serviceId)]);
    }
}

这样写的好处在于,如果你给没有实现VoterInterface的服务也打上了app.voter的标签,那么在调用addVoter方法的时候会抛出异常

1623799422614

高级用法

使用monolog日志服务

新建配置文件config/monolog.php

<?php
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Reference;

$container->register(StreamHandler::class, StreamHandler::class)
    ->addArgument(__DIR__.'/../var/app.log');
$container->register(LoggerInterface::class, Logger::class)
    ->addArgument('voter')
    ->addMethodCall('pushHandler', [new Reference(StreamHandler::class)]);

现在日志服务注册完了,我们需要将其注入到PostVoter服务中,我们首先想到的注入方式就是通过PostVoter的构造函数参数提示来自动进行注入

其实有一种更好的方法,我们可以通过Psr\Log\LoggerAwareInterface接口来进行日志服务的注入,所有实现了Psr\Log\LoggerAwareInterface接口的服务都会实现一个setLogger方法,借助该方法我们可以实现日志服务的注入

修改后的config/monolog.php如下:

<?php
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Reference;

$container->register(StreamHandler::class, StreamHandler::class)
    ->addArgument(__DIR__.'/../var/app.log');
$container->register(LoggerInterface::class, Logger::class)
    ->addArgument('voter')
    ->addMethodCall('pushHandler', [new Reference(StreamHandler::class)]);
$container->registerForAutoconfiguration(LoggerAwareInterface::class)
    ->addMethodCall('setLogger', [new Reference(LoggerInterface::class)]);

使用registerForAutoconfiguration方法来进行日志服务的注入

然后我们的PostVoter服务只需要实现LoggerAwareInterface接口即可

class PostVoter implements VoterInterface, LoggerAwareInterface
{
    use LoggerAwareTrait;
    ...
}

我们甚至都不用自己去实现LoggerAwareInterface接口,只需要一句use LoggerAwareTrait即可

我们在PostVoter类的vote方法中加入如下语句来测试我们的日志服务是否被正常注入

$this->logger->debug(self::class . ' executed.');

可以正常生成日志文件,没有问题

1623811814372

处理参数

在上面的日志服务的配置中,我们需要传递几个参数

symfony的依赖注入允许我们设置参数并将其注入或者应用到我们的app中

创建config/parameters.php文件

<?php
$container->setParameter('root_dir', __DIR__.'/..');
$container->setParameter('app.logger.voter_channel', 'voter');
$container->setParameter('app.logger.file_path', '%root_dir%/var/app.log');

该文件应该在所有配置文件加载之前被加载

public/index.php

...
$loader->load('parameters.php');
$loader->load('config.php');
$loader->load('monolog.php');
...

parameters.php配置文件中我们设置了三个参数

  • 应用的根目录
  • 日志的描述性名称
  • 日志文件路径

下面是容器编译前后的参数变化,可以看到编译之后的%root_dir%被替换成了绝对路径

1623813096744

1623813108229

更新我们的config/monolog.php文件

<?php
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Reference;

$container->register(StreamHandler::class, StreamHandler::class)
    ->addArgument('%app.logger.file_path%');
$container->register(LoggerInterface::class, Logger::class)
    ->addArgument('%app.logger.voter_channel%')
    ->addMethodCall('pushHandler', [new Reference(StreamHandler::class)]);
$container->registerForAutoconfiguration(LoggerAwareInterface::class)
    ->addMethodCall('setLogger', [new Reference(LoggerInterface::class)]);

将以前的硬编码替换城变量

现在我们的应用程序已经像模像样了:

  • 应用参数保存在parameters.php文件中
  • 应用程序的配置保存在config.php中
  • 第三方库的配置在以他们自己命名的配置文件中:monolog.php

装饰服务

上面我们使用了monolog来进行日志的生成,现在我们可以对其进行装饰以生成更加漂亮的日志

新建src/Logger/FancyLoggerDecorator.php装饰器

<?php

namespace App\Logger;

use Psr\Log\LoggerInterface;

class FancyLoggerDecorator implements LoggerInterface
{
    /** @var LoggerInterface */
    private $decoratedLogger;

    public function __construct(LoggerInterface $decoratedLogger)
    {
        $this->decoratedLogger = $decoratedLogger;
    }

    public function emergency($message, array $context = array())
    {
        $this->decoratedLogger->emergency('🆘 '.$message, $context);
    }

    public function alert($message, array $context = array())
    {
        $this->decoratedLogger->alert('🚨 '.$message, $context);
    }

    public function critical($message, array $context = array())
    {
        $this->decoratedLogger->critical('🛑 '.$message, $context);
    }

    public function error($message, array $context = array())
    {
        $this->decoratedLogger->error('❌ '.$message, $context);
    }

    public function warning($message, array $context = array())
    {
        $this->decoratedLogger->warning('⚠️ '.$message, $context);
    }

    public function notice($message, array $context = array())
    {
        $this->decoratedLogger->notice('📝 ' . $message, $context);
    }

    public function info($message, array $context = array())
    {
        $this->decoratedLogger->info('ℹ️ '.$message, $context);
    }

    public function debug($message, array $context = array())
    {
        $this->decoratedLogger->debug('🤖 '.$message, $context);
    }

    public function log($level, $message, array $context = array())
    {
        $this->decoratedLogger->log($level, $message, $context);
    }
}

更改config/monolog.php注入装饰服务并将原始日志服务注入到装饰服务的构造函数的参数中:

...
$container->register(FancyLoggerDecorator::class)
    ->setDecoratedService(LoggerInterface::class)
    ->addArgument(new Reference(FancyLoggerDecorator::class.'.inner'));

被装饰的LoggerInterface的services id会自动转换为装饰服务的名称加上.inner

image-20210616091716908

优化性能

缓存已经编译的配置

在现阶段的代码中,每次我们发起一个请求,容器都会重新创建一次,由于我们的应用程序比较小,看不出来对性能的影响,我们通过下面的代码来演示缓存对性能的提升效果

使用Symfony\Component\DependencyInjection\Dumper\PhpDumper类将容器dump到文件中来进行缓存,另外在容器编译之后,我们设置了一个3s的等待以更明显地显示效果

public/index.php

<?php

require __DIR__.'/../vendor/autoload.php';

use App\Authorization\AccessManager;
use App\Authorization\Voter\PostVoter;
use App\Controller\PostController;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;

$cachedContainerFile = __DIR__ .'/../var/cache/CachedContainer.php';

if (file_exists($cachedContainerFile)) {
    require_once $cachedContainerFile;

    $container = new CachedContainer();
} else {
    $container = new ContainerBuilder();

    $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../config'));
    $loader->load('parameters.php');
    $loader->load('config.php');
    $loader->load('monolog.php');

    $container->compile();
    // make the compilation really long to show the difference
    sleep(3);

    $dumper = new PhpDumper($container);
    file_put_contents($cachedContainerFile, $dumper->dump(['class' => 'CachedContainer']));
}

$container->get(PostController::class)->index();

我们将容器内容缓存到了文件中,当下一次请求到达的时候我们会先判断文件是否存在(容器是否被缓存),如果文件存在则直接require_once,否则就重新进行容器的编译

xdm可以自己运行一下代码体验一下,第一次会等待几秒,第二次访问你计划看不到浏览器刷新,因为已经缓存了,所以速度非常快

在缓存的文件var/cache/CachedContainer.php文件中我们可以看到如下代码

...
protected function getPostControllerService()
{
    $a = new \App\Authorization\AccessManager();

    $b = new \App\Authorization\Voter\PostVoter();

    $c = new \Monolog\Logger('voter');
    $c->pushHandler(new \Monolog\Handler\StreamHandler('C:\\Users\\x\\phplearn\\symfony\\container\\config/../var/app.log'));

    $d = new \App\Logger\FancyLoggerDecorator($c);

    $b->setLogger($d);
    $e = new \App\Authorization\Voter\TestVoter();
    $e->setLogger($d);

    $a->addVoter($b);
    $a->addVoter($e);

    return $this->services['App\\Controller\\PostController'] = new \App\Controller\PostController($a);
}
...

上面的代码完全符合我们之前讲的关于容器的知识点

  • AccessManager和PostVoter、TestVoter作为私有服务被初始化(服务的注册)
  • 日志服务被初始化($c),voter作为构造函数参数传入,然后根据monolog.php的配置调用pushHandler方法
  • 装饰服务($d)被初始化,日志服务作为构造函数参数被注入
  • PostVoter和TestVoter通过setter将日志服务注入
  • AccessManager服务($a)将PostVoter和TestVoter服务注入
  • 最后PostController将自己添加到services数组中,service id就是他的FQCN(App\\Controller\\PostController

但是上面的缓存机制还存在问题,一旦我们的配置更新了,缓存也就失效了,但是我们的app还在继续使用之前缓存的过时的容器

因此我们需要判断一下当前运行环境是生产环境还是开发环境

...
$env = "dev";

if ("prod" === $env && file_exists($cachedContainerFile))
...

增加一个$env变量来表示当前的环境,在if语句中新增一个判断环境的条件即可

最佳的实践是创建一个.env文件,然后使用symfony/dotenv来解析该文件中的变量,从而使得这些变量可以通过$_ENV在任何地方被访问到

...
$dotenv = new Dotenv();
$dotenv->load('../.env');

if ("prod" === $_ENV['env'] && file_exists($cachedContainerFile))
...

.env:

env=prod

通过变量名称进行注入

我们前面注入的都是各种服务,如果想要注入变量怎么办

这一点可以借助Definition原型来实现,通过setBindings方法可以将一个变量名称和现有变量进行绑定

config/config.php

...
//先给容器设置一个env参数
$container->setParameter('env', $_ENV['env']);
...
$privateDefinition
    ->setAutowired(true)
    ->setAutoconfigured(true)
    ->setPublic(false)
    //将容器中的env参数和$env绑定,这样当privateDefinition定义的任何服务需要使用$env变量时
    //就会解析成容器的env参数,也就是$_ENV['env']
    ->setBindings(['$env' => $container->getParameter('env'),]);
...

然后我们哪一个服务需要env变量就在哪一个服务中添加一个对应的成员即可

src\Authorization\Voter\PostVoter.php

...
private $env;

public function __construct(string $env)
{
    $this->env = $env;
    dump($this->env);
}

public function vote(string $attribute, $subject, User $user): bool
{
    $this->logger->debug(self::class.' voter executed', ['env' => $this->env]);
    ...
}
...

image-20210616105212436

结语

牛逼

references: