clancats/container

ClanCats IoC 容器。

v1.3.6 2024-03-07 13:59 UTC

README

ClanCats Container

一个具有简单元语言、快速可编译的依赖注入的 PHP 服务容器。

PHPUnit PHPStan Packagist Packagist GitHub release

需要 PHP >= 7.4

优点

  • 开销最小化,因此非常快。
  • 没有额外的依赖。
  • 在生产环境中每天处理数百万个请求。
  • 单例和工厂服务解析器。
  • 元数据系统允许非常直观的服务查找。
  • 容器构建器允许编译/序列化您的服务定义。
  • 容器文件,一种简单的语言来定义您的服务和管理您的应用程序配置。
  • Composer 集成,允许您从不同的包导入服务定义。
  • 大型和动态类图的懒服务提供者。

可能不喜欢的功能

  • 容器仅允许 命名服务
  • 目前 不支持 自动连接。
  • 目前 不支持容器文件 的 IDE 支持。
  • 元语言可能不符合每个人的口味。

目录

性能

这个包对于服务容器来说可能看起来非常庞大,但在短暂的预热后,编译后的容器运行得飞快,几乎没有开销(3 个类/文件)。动态绑定和解析服务可能较慢,但仍然不会影响真实应用中的性能。

安装

容器遵循 PSR-4 自动加载,并可以使用 composer 安装

$ composer require clancats/container

语法高亮

我在这里创建了一个基本的 tmLanguage 定义: https://github.com/ClanCats/container-tmLanguage

文档 💡

完整文档可以在 clancats.io 上找到

快速开始 ⚡️

以下只是一个粗略示例,更详细和解释的指南可以在这里找到: 入门

设置

我们的目标目录结构将如下所示

app.php
app.ctn
composer.json
cache/ # make sure this is writable
src/
  Human.php
  SpaceShip.php

服务

为了演示如何使用此服务容器,我们需要创建两个类,一个 SpaceShip 和一个 Human

创建一个新的 php 文件 src/Human.php

class Human
{
    public $name;

    public function setName(string $name) {
        $this->name = $name;
    }
}

创建另一个 php 文件 src/SpaceShip.php

class SpaceShip
{
    protected $captain; // every ship needs a captain!

    public function __construct(Human $captain) {
        $this->captain = $captain;
    }

    public function ayeAye() {
        return 'aye aye captain ' . $this->captain->name;
    }
}

容器文件

容器文件允许您使用简单的元语言绑定您的服务和参数。

注意:如果您更喜欢在 PHP 中本身绑定服务,则此功能完全是可选的:请参阅 服务绑定

在您的应用程序根目录中创建一个名为 app.ctn 的新文件。

@malcolm: Human
  - setName('Reynolds')

@firefly: SpaceShip(@malcolm)

容器工厂

现在我们需要解析容器文件并将其编译成一个新的类。为此,我们创建了一个app.php文件。在那里,你需要引入composer自动加载器并引入你的源文件,或者配置composer来自动加载src/目录中的类。

require "vendor/autoload.php";

// for the consistency of the example I leave this here 
// but I strongly recommend to autolaod your classes with composer.
require "src/SpaceShip.php";
require "src/Human.php";

$factory = new \ClanCats\Container\ContainerFactory(__DIR__ . '/cache');

$container = $factory->create('AppContainer', function($builder)
{
    // create a new container file namespace and parse our `app.ctn` file.
    $namespace = new \ClanCats\Container\ContainerNamespace();
    $namespace->parse(__DIR__ . '/app.ctn');

    // import the namespace data into the builder
    $builder->importNamespace($namespace);
});

注意:确保../cache目录可写。

变量$container现在包含一个名为AppContainer的类实例。

echo $container->get('firefly')->ayeAye(); // "aye aye captain Reynolds"

使用示例

带有环境的 App 配置

容器参数无非是全局可用的值。我们用它们来存储大多数静态配置值,并处理不同的环境。

为此,我们通常创建两个文件。在这个例子中

  • config.ctn 主配置文件。
  • config.ctn.env 环境特定的覆盖。

config.ctn:

// default environment
:env: 'stage'

// debug mode
:debug: false

// Firewall whitelist
:firewall.whitelisted_ips: {
  '127.0.0.1': 'Local',
  '1.2.3.4': 'Some Office',
  '4.3.2.1': 'Another Office',
}

// application name
:app.name: 'My Awesome application'

import config.env

config.ctn.env:

override :env: 'dev'
override :debug: true

// Firewall whitelist
override :firewall.whitelisted_ips: {
  '127.0.0.1': 'Local',
  '192.168.33.1': 'MyComputer',
}

在PHP中,这些值作为参数可以访问。为了使这工作,你需要配置正确的导入路径在你的容器命名空间中。你可以在示例应用程序中找到一个例子。

echo $container->getParameter('app.name'); // 'My Awesome application'
echo $container->getParameter('env'); // 'dev'
echo $container->getParameter('debug'); // true

别名/服务定义/参数示例

# Parameters can be defined erverywhere
:pipeline.prefix: 'myapp.'

// you can define aliases to services
@pipeline.queue: @queue.redis
@pipeline.storage: @db.repo.pipeline.mysql

// add function calls that will be run directly after construction of the service
@pipeline: Pipeline\PipelineManager(@pipeline.queue, @pipeline.storage, @pipeline.executor)
  - setPrefix(:pipeline.prefix)
  - bind(@pipeline_handler.image.downloader)
  - bind(@pipeline_handler.image.process)

@pipeline_handler.image.downloader: PipelineHandler\Images\DownloadHandler(@client.curl)
@pipeline_handler.image.process: PipelineHandler\Images\ProcessHandler(@image.processor, { 
  'temp_dir': '/tmp/', 
  'backend': 'imagick'
})

使用元数据进行 HTTP 路由

你可以使用容器元数据来直接通过服务定义定义路由

@controller.dashboard.home: App\Controller\Dashboard\HomepageAction
  = route: {'GET'}, '/dashboard/home'

@controller.dashboard.sign_in: App\Controller\Dashboard\SignInAction
  = route: {'GET', 'POST'}, '/dashboard/signin'

@controller.dashboard.sign_out: App\Controller\Dashboard\SignOutAction
  = route: {'GET'}, '/logout'

@controller.dashboard.client: App\Controller\Dashboard\ClientDetailAction
  = route: {'GET'}, '/dashboard/clients/me'
  = route: {'GET'}, '/dashboard/clients/{clientId}'

显然,这取决于你的路由实现。你可以使用如下路由定义获取所有服务

使用FastRoute的示例

$dispatcher = \FastRoute\cachedDispatcher(function(RouteCollector $r) use($container)
{
    foreach($container->serviceNamesWithMetaData('route') as $serviceName => $routeMetaData)
    {
        // an action can have multiple routes handle all of them
        foreach($routeMetaData as $routeData)
        {
            $r->addRoute($routeData[0], $routeData[1], $serviceName);
        }
    }
}, [
    'cacheFile' => PATH_CACHE . '/RouterCache.php',
    'cacheDisabled' => $container->getParameter('env') === 'dev',
]);

使用元数据进行事件监听

就像路由一样,你可以使用元数据系统来定义事件监听器

@signal.exception.http404: App\ExceptionHandler\NotFoundExceptionHandler
  = on: 'http.exception', call: 'onHTTPException'

@signal.exception.http400: App\ExceptionHandler\BadRequestExceptionHandler
  = on: 'http.exception', call: 'onHTTPException'

@signal.exception.http401: App\ExceptionHandler\UnauthorizedAccessExceptionHandler
  = on: 'http.exception', call: 'onHTTPException'

@signal.bootstrap_handler: App\Bootstrap
  = on: 'bootstrap.pre', call: 'onBootstrapPre'
  = on: 'bootstrap.post', call: 'onBootstrapPost'

然后在你的事件调度器中注册所有具有匹配元数据的服务的服务。

以下示例展示了可能的实现方式。粘贴这段代码不会直接工作。

foreach($container->serviceNamesWithMetaData('on') as $serviceName => $signalHandlerMetaData)
{
    // a action can have multiple routes handle all of them
    foreach($signalHandlerMetaData as $singalHandler)
    {
        if (!is_string($singalHandler[0] ?? false)) {
            throw new RegisterHandlerException('The signal handler event key must be a string.');
        }

        if (!isset($singalHandler['call']) || !is_string($singalHandler['call'])) {
            throw new RegisterHandlerException('You must define the name of the function you would like to call.');
        }

        $priority = $singalHandler['priority'] ?? 0;

        // register the signal handler
        $eventdispatcher->register($singalHandler[0], function(Signal $signal) use($container, $singalHandler, $serviceName)
        {
            $container->get($serviceName)->{$singalHandler['call']}($signal);
        }, $priority);
    }
}

日志处理程序发现

或者你可能有一个带有monolog日志记录器的自定义框架,你想要使每个集成添加自定义日志处理器变得容易

/**
 * Log to Graylog
 */
:gelf.host: 'monitoring.example.com'
:gelf.port: 12201

@gelf.transport: Gelf\Transport\UdpTransport(:gelf.host, :gelf.port)
@gelf.publisher: Gelf\Publisher(@gelf.transport)
@logger.error.gelf_handler: Monolog\Handler\GelfHandler(@gelf.publisher)
  = log_handler

/**
 * Also send a slack notification 
 */
@logger.,error.slack_handler: Example\MyCustom\SlackWebhookHandler('https://hooks.slack.com/services/...', '#logs')
    = log_handler

并且你的框架可以简单地查找暴露log_handler元键的服务

// gather the log handlers
$logHandlerServices = array_keys($container->serviceNamesWithMetaData('log_handler'));

// bind the log hanlers
foreach($logHandlerServices as $serviceName) {
    $logger->pushHandler($container->get($serviceName));
}

容器文件语法

容器文件是用一个非常简单的元语言编写的。

类型

该语言支持以下标量类型

  • 字符串 单引号和双引号。
    'hello'"world"
  • 数字 浮点数/双精度数,整数。
    3.14, 42
  • 布尔值
    truefalse
  • 空值
    null
  • 数组 列表和关联。
    {'A', 'B', 'C'}{'A': 10, 'B': 20}

数字

容器文件不区分不同的数字类型,因为这会是一个不必要的开销,我们直接将这项工作转发给PHP。

42 # Int
42.01 # Float
-42.12345678912345 # Double

这意味着浮点数的精度也由PHP处理。所有值都是解释的,这意味着大双精度数可能会存储成舍入的形式。

字符串

字符串必须始终用单个'或双引号"括起来。这主要是为了在字符串中有许多引号时方便,不必全部转义。

特殊字符的转义方式与常规方式相同。

:say: 'Hello it\'s me!'`

喜爱的或讨厌的表情符号也能正常工作。

:snails: '🐌🐌🐌'

布尔值和 Null

关于它们就没有太多可说的了

:nothing: null
:positive: true
:negative: false

数组

重要的是要注意,所有数组在内部都是关联的。当定义一个简单的列表时,关联键会自动生成,代表项目的索引。

这意味着数组{'A', 'B'}等于{0: 'A', 1: 'B'}

数组可以定义多维。

{
    'title': 'Some catchy title with Star Wars',
    'tags': {'top10', 'movies', 'space'},
    'body': 'Lorem ipsum ...',
    'comments': 
    {
        {
            'text': 'Awesome!',
            'by': 'Some Dude',
        }
    }
}

参数

参数或配置值也可以在容器文件中定义。

参数总是以:字符为前缀。

:database.hostname: "production.db.example.com"
:database.port: 7878
:database.cache: true

服务定义

服务定义总是有名称,并且必须以@字符为前缀。

## <service name>: <class name>
@log.adapter: FileAdapter

类名可以包含完整的命名空间。

@log.adapter: Acme\Log\FileAdapter

构造函数

构造函数参数可以放在类名之后。

@dude: Person("Jeffery Lebowski")
引用参数

参数可以引用一个参数或服务。

:name: 'Jeffery Lebowski'

@dude: Person(:name)
@mysql: MySQLAdapter('localhost', 'root', '')

@repository.posts: Repositories/Post(@mysql)

方法调用

可以将方法调用分配给服务定义。

@jones: Person('Duncan Jones')
@sam: Person('Sam Rockwell')

@movie.moon: Movie('Moon')
  - setDirector(@jones)
  - addCast(@sam)
  - setTags({'Sci-fi', 'Space'})

服务元数据

可以将元数据分配给每个服务定义。

然后可以获取匹配元数据键的服务。

@controller.auth.sign_in: Controller\Auth\SignInController(@auth)
  = route: {'GET', 'POST'}, '/signin'

元数据键始终是一个向量/数组,因此您可以添加多个相同类型的元数据。

@controller.auth.sign_in: Controller\Auth\SignInController(@auth)
  = route: {'GET', 'POST'}, '/signin'
  = tag: 'users'
  = tag: 'auth'

元数据定义内部元素可以有命名键。

@app.bootstrap: Bootstrap()
  = on: 'app.start' call: 'onAppStart'

服务更新

可以更新已定义的服务,添加更多的构造调用和元数据。这对于组织大量具有动态查找的依赖项非常有用。

例如,您可以在一个文件中定义您的日志记录器。

@logger.main: Acme\Logger

然后在需要的地方使用构造调用添加观察者。

@logger.observers.email_devs: Acme\EmailLogObserver('dev@example.com')
@logger.observers.email_support: Acme\EmailLogObserver('support@example.com')

@logger.main
  - addObserver(@logger.observers.email_devs)
  - addObserver(@logger.observers.email_support)

对于元数据也是如此。

@controller.homepage: Controller\Homepage
  = on: '/homepage'


// also show homepage on root
@controller.homepage
  = on: '/'

导入

其他容器文件可以从中导入容器命名空间。

import config
import app/dashboard
import app/user
import app/shop

覆盖

如果已定义,服务参数将被明确覆盖。

:ship: 'Star Destroyer'

override :ship: 'X-Wing'

示例应用

这应该展示了使用CCContiner构建的应用程序的可能结构。这是我们私有服务框架使用的简化版本。

文件夹结构

# The main entry point for our container application
app.ctn

# A per environment defined config. This file
# is being generated by our deployment process 
# individually for each node.
app.ctn.env 

# We like to but all other container files in one directory
app/
  # Most configuration parameters go here
  config.ctn

  # Command line commands are defined here
  commands.ctn

  # Application routes (HTTP), actions and controllers 
  routes.ctn

  # General application services. Depending on the size of 
  # the project we split the services into more files to keep
  # things organized. 
  services.ctn

# PHP Bootstrap
bootstrap.php

# Composer file
composer.json

# A writable directory for storing deployment 
# depndent files.
var/
  cache/

# PHP Source 
src/
  Controller/
    ListBlogPostController.php
    GetBlogPostController.php

  Commands/
    CreateUserCommand.php

  Servies/
    UserService.php
    BlogService.php

引导(容器构建器)

此容器构建器执行以下操作

  • 从使用composer安装的包中导入容器命名空间。
  • 扫描./app目录中的ctn文件。
  • 将环境容器文件添加到命名空间中。
<?php 
if (!defined('DS')) { define('DS', DIRECTORY_SEPARATOR); }

define('PATH_ROOT',         __DIR__);
define('PATH_CACHE',        PATH_ROOT . DS . 'var' . DS . 'cache');
define('PATH_APPCONFIG',    PATH_ROOT . DS . 'app');

$factory = new \ClanCats\Container\ContainerFactory(PATH_CACHE);

$container = $factory->create('AppContainer', function($builder)
{
    $importPaths = [
        'app.env' => PATH_ROOT . '/app.ctn.env',
    ];

    // find available container files
    $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(PATH_APPCONFIG));

    foreach ($rii as $file) 
    {
        // skip directories
        if ($file->isDir()) continue;

        // skip non ctn files
        if (substr($file->getPathname(), -4) !== '.ctn') continue;

        // get the import name
        $importName = 'app' . substr($file->getPathname(), strlen(PATH_APPCONFIG), -4);

        // add the file
        $importPaths[$importName] = $file->getPathname();
    }

    // create a new container file namespace and parse our `app.ctn` file.
    $namespace = new \ClanCats\Container\ContainerNamespace($importPaths);
    $namespace->importFromVendor(PATH_ROOT . '/vendor');

    // start with the app file
    $namespace->parse(__DIR__ . '/app.ctn');

    // import the namespace data into the builder
    $builder->importNamespace($namespace);
});

App 容器文件

第一个文件app.ctn的主要任务很简单,就是包含其他文件,从而定义它们被读取的顺序。

app.ctn:

/**
 * Import the configuration
 */
import app/config

/**
 * Import the services
 */
import app/services

/**
 * Import the actions & routes
 */
import app/routes

/**
 * Import the commands
 */
import app/commands

/**
 * Load the environment config last so it is
 * able to override most configs.
 */
import app.env

待办事项/功能愿望清单

  • 容器文件
    • 元数据支持
    • 数组支持
    • 别名支持
    • 容器文件命名空间支持
    • 通过“使用特质”进行自动装配
    • 通过“实例为”进行自动装配
    • 通过“有方法”进行自动装配
    • 属性注入
    • 参数连接
    • 输入参数(用于环境检测)
    • 后期服务覆盖(允许添加元数据或调用)
  • 容器
    • 元数据支持
    • 属性注入

鸣谢

许可

MIT许可(MIT)。有关更多信息,请参阅许可文件