mecha/php-modules

一套从一系列模块中组装PHP应用程序的系统。

dev-main 2023-10-08 14:16 UTC

This package is auto-updated.

Last update: 2024-09-08 16:09:25 UTC


README

一套从一系列可复用模块中组装PHP应用程序的系统。

此包仍在开发中

目录

动机

将应用程序拆分为“切片”或模块,以方便应用程序组件的连接,允许模块化组装应用程序功能,并提供更好的应用程序表面分离。

此模块系统主要是对Dhii模块规范(我共同编写)的修订,它提供了一种构建模块化应用程序的一站式解决方案,而不是模块互操作性的规范。虽然此包的目标是提供您可能需要的所有内容,但大多数功能都是可选的。该系统构建为与最基本的内容一起工作:PSR-11服务定义的数组。

快速开始

使用Composer安装

composer require mecha/modules

创建一个应用程序并向它添加模块

use Mecha\Modules\App;

$app = new App([
    $module1,
    $module2,
]);

$app->addModules([
    $module3,
    $module4,
]);

运行您的应用程序

$app->run();

默认情况下,App将使用捆绑的DI容器实现。如果您需要使用自己的DI容器,请参阅高级设置部分。

模块

模块是任何提供PSR-11服务定义的可迭代值(数组、迭代器和生成器),这些服务定义是接受DI容器以创建并返回一些值的函数。

数组模块示例

$module = [
    'greeter' => function(ContainerInterface $c) {
        return new Greeter($c->get('message'));
    },
    'message' => fn() => 'Hello world',
];

生成器模块示例

function module() {
    yield 'greeter' => function(ContainerInterface $c) {
        return new Greeter($c->get('message'));
    };

    yield 'message' => fn() => 'Hello world';
];

注意:当模块添加到应用程序中时,其服务定义将注册到DI容器中。模块的顺序可能会影响解决相同键冲突的方式。如果您想避免所有ID冲突,请考虑为您的模块设置作用域

服务

模块应提供PSR-11服务定义,但这并不意味着我们应该手动编写它们。

此包提供了一些辅助函数,这些函数使服务定义更容易编写、更易读,并提供对模块系统大多数功能的访问。

绑定

考虑以下服务定义

function (ContainerInterface $c) {
    if ($c->get('debug_enabled')) {
        return new DebugThing($c->get('some_value'));
    } else {
        return new NormalThing($c->get('other_value'));
    }
}

此服务依赖于DI容器中存在3个其他服务:debug_enabledsome_valueother_value。这些依赖关系仅由定义函数内部的代码所知,因为对容器get()方法的调用。

如果我们可以将我们的服务定义编写为“普通”函数,不是更容易吗?

function (bool $debugEnabled, $someValue, $otherValue) {
    if ($debugEnabled) {
        return new DebugThing($someValue);
    } else {
        return new NormalThing($otherValue);
    }
}

虽然上述版本更加简洁且易于阅读,但它不是一个有效的PSR-11服务定义。

这就是bind()函数的用武之地。

此函数接受我们的“普通”函数和一组服务ID,并返回一个有效的PSR-11服务定义

bind(
    function (bool $debugEnabled, $someValue, $otherValue) { ... },
    ['debug_enabled', 'some_value', 'other_value']
);

更具体地说,bind()函数删除第一个参数(容器),保留传递给服务的任何其他参数,并在参数列表的末尾添加给定依赖的解析值。依赖关系始终在调用参数之后传递。这将在以后变得很重要。

由于 bind() 的结果是标准的 PSR-11 服务定义,因此可以使用模块系统提供的许多其他服务功能。

包装

模块系统的一些功能,如 扩展操作连接,需要服务定义来提供额外信息。这是通过 可调用对象 来完成的,这些对象的行为类似于函数,但也可以有属性和方法。

这种包装是通过 service() 函数完成的,该函数简单地接受一个 PSR-11 服务定义

service(function(ContainerInterface $c) { ... });

它也可以接受 bind() 的结果

service(bind(fn(int $timeout) => $timeout + 1, ['timeout']);

通常您不需要直接调用此函数。相反,您很可能正在使用其他辅助函数,这些函数在底层使用 service()

factory()

这是最简单的包装器。它为任何提供的函数创建一个包装、绑定定义

use function Mecha\Modules\factory;

$module = [
    'message' => fn() => 'Hello world',
    'greeter' => factory(fn(string $msg) => new Greeter($msg), ['message']),
];

instance()

这是 factory() 的更专业形式,它通过构造函数参数来帮助构造实例。

use function Mecha\Modules\instance;

$module = [
    'message' => fn() => 'Hello world',
    'greeter' => instance(Greeter::class, ['message']),
];

callback()

factory() 的专业形式,相当于给 factory() 一个返回另一个函数的函数。

结果是返回函数的包装、绑定服务定义。

use function Mecha\Modules\factory;
use function Mecha\Modules\callback;

factory(
    function($dep1, $dep2) {
        return fn($arg1, $arg2) => /*...*/;
    },
    ['dep1', 'dep2']
);

// Equivalent to:

callback(
    fn($arg1, $arg2, $dep1, $dep2) => /*...*/,
    ['dep1', 'dep2']
);

示例

use function Mecha\Modules\callback;

$module = [
    'format' => fn() => 'Hello %s',
    'greeter' => callback(
        fn($name, $format) => sprintf($format, $arg),
        ['format']
    ),
];

传递给 callback() 的函数将首先接收调用参数,然后是已解析的依赖项(如果服务有依赖项)。

例如,在上面的示例中,greeter 接收 2 个参数,但其中一个绑定到 format 依赖项。这意味着生成的函数只接受 1 个参数

$greeter = $container->get('greeter');
greeter('John'); // Output: "Hello John"

template()

factory() 的专业形式,使用 printf 风格模板来创建包含其依赖项的字符串值。

use function Mecha\Modules\template;

$module = [
    'name' => fn() => 'Neo',
    'ammo' => fn() => 999,
    'message' => template('%s has %d rounds left.', ['name', 'ammo']),
];

value()

此函数创建一个包装的服务定义,该定义解析为固定值。

use function Mecha\Modules\value;

$module = [
    'name' => value('Pumba'),
];

注意:与 service(fn() => 'Hello %s') 相似,但细微差别在于值不是在函数内部创建的。这使得 value() 比使用函数更“急切”。如果您需要值在运行时懒创建,请使用箭头函数或 factory()

alias()

创建一个包装、绑定服务定义,它仅解析为其单个依赖项;非常适合创建其他服务的别名。

use function Mecha\Modules\alias;

$module = [
    'person' => fn() => 'Thomas Anderson',
    'chosen_one' => alias('person'),
];

collect()

创建一个包装、绑定服务定义,它简单地返回其解析的依赖项数组。

use function Mecha\Modules\collect;
use function Mecha\Modules\value;

$module = [
    'admin' => value('GLaDOS'),
    'tester' => value('Chell'),
    'assistant' => value('Wheatley'),

    'everyone' => collect(['admin', 'tester', 'assistant']),
];

env()

创建一个包装服务定义,它解析为环境变量的值。

use function Mecha\Modules\env;

$module = [
    'editor' => env('EDITOR'),
];

constValue()

创建一个包装服务定义,它解析为定义的常量的值。

use function Mecha\Modules\constValue;

define('DEV_MODE', false);

$module = [
    'dev_mode' => constValue('DEV_MODE'),
];

请记住,使用 const 关键字定义的常量是隐式命名空间的!

use function Mecha\Modules\constValue;

namespace Foo {
    const BAR = 123;
}

$module = [
    'foo_bar' => constValue('Foo\\BAR'),
];

globalVar()

创建一个包装服务定义,它解析为全局变量的值。

use function Mecha\Modules\globalVar;

global $user;

$module = [
    'user' => globalVar('user'),
];

注意:创建的服务定义将在调用时捕获全局变量的值。如果您是一个缓存 DI 容器,如提供的实现,那么全局变量的重新赋值将不会反映在服务中。但是,直接修改应该仍然反映。

global $foo;
$foo = 1;

$app = new App([
    $module = [
        'foo' => globalVar('foo'),
    ],
]);

$app->run();
$app->get('foo'); // => 1
$foo = 2;
$app->get('foo'); // => Still 1

load()

创建一个包装服务定义,该定义加载由 PHP 文件返回的服务定义。此函数有两种使用方式

  1. 如果没有提供依赖项,则指定文件中的函数将接收 DI 容器
// module.php
$module = [
    'foo' => load('my-service.php'),
];

// my-service.php
return function(ContainerInterface $c) {
    /*...*/
};
  1. 如果提供了依赖项,则期望指定文件中的函数是绑定形式的
// module.php
$module = [
    'foo' => load('my-service.php', ['dep1', 'dep2']),
];

// my-service.php
return function ($dep1, $dep2) {
    /*...*/
};

invoke()

创建一个绑定但不包裹的服务定义,通过ID获取另一个服务,使用依赖项作为参数调用其解析值,并解析其返回值。

$module = [
    'name' => value('Luke'),
    'msg_fn' => callback(fn($name) => "Hello, I am %s."),
    'the_msg' => invoke('msg_fn', ['name']),
];

以上等同于

$module = [
    'name' => fn() => 'Luke',
    'msg_fn' => fn() => fn($name) => "Hello, I am %s.",
    'the_msg' => function (ContainerInterface $c) {
        $fn = $c->get('msg_fn');
        $arg = $c->get('name');
        return $fn($arg);
    },
];

扩展

扩展是修改另一个服务解析值的工具。这也可以跨模块工作,这使得扩展成为集成模块的绝佳方式。

扩展定义以DI容器作为参数——就像服务定义一样——但也接受一个名为$previous的第二个参数,它包含该服务的先前值。扩展定义的返回值将成为该服务的新值。

function (ContainerInterface $c, $prev) {
    // ...
    return $new;
}

绑定函数也可以用于扩展

bind(
    function($prev, $dep) {
        /* ... */
        return $new;
    },
    ['dep']
);

一个服务可以有多个扩展,这些扩展按顺序调用。除第一个扩展之外的所有扩展都将接收前一个扩展的返回值作为第二个参数。

创建扩展有两种方式

1. 使用extend()函数

$module = [
    'footer' => value('My ugly blog'),

    extend('footer', function(ContainerInterface $c, $footer) {
        return "$footer | Copyright 2023";
    }),

    extend('footer', function(ContainerInterface $c, $footer) {
        return "$footer | Site is under maintenance.";
    }),
];

$app = new App([$module]);
$app->run();
$app->get('footer'); // => "My ugly blog | Copyright 2023 | Site is under maintenance."

以这种方式声明的扩展通常是匿名的。但如果你愿意,也可以包含一个键!

2. 使用包裹服务的extends()方法

$module = [
    'list' => instance(AnimalList::class),
    'item' => value('Pumba')->extends('list', function ($c, $list) {
        $list->add($c->get('item'));
        return $list;
    }),
];

如果你使用bind()进行扩展,模块系统将自动将包裹服务的值作为依赖项添加到绑定扩展中,这将在参数列表的末尾添加服务的值

$module = [
    'list' => instance(AnimalList::class),
    'item' => value('Pumba')->extends('list', bind(function ($list, $self) {
        // $self is the value of 'item'
        $list->add($self);
        return $list;
    }),
];

操作

操作是一种特殊的扩展,它不会执行任何实际扩展。

考虑以下场景

我们有一个提供用户列表的模块,并通过扩展该列表,当它被创建时,列表中的每个用户都会加载他们的首选项。注意扩展实际上并没有扩展列表。

$module = [
    'users' => instance(List::class),

    extend('users', bind(function(List $users) {
        foreach ($users as $user) {
            $user->loadPreferences();
        }

        return $users;
    }),

这是一个常见的模式。虽然我们可以使用运行回调来加载用户首选项,但这种方法确保只有在users服务被另一个服务使用时,才会加载用户首选项,而不是每次应用程序运行时。

现在让我们添加第二个扩展来向列表中添加一些用户

$module = [
    /* ... */
    extend('users', bind(function(List $users) {
        $users->add(new User('Abigail'));
        $users->add(new User('Britney'));
        $users->add(new User('Chrissy'));
        return $users;
    }),
];

遗憾的是,这不会产生预期的结果。扩展按提供的顺序调用,这意味着第一个扩展将接收一个空列表。然后第二个列表将添加用户,但那时已经太晚了。

为了解决这个问题,我们需要重新排序此模块中的扩展。但是,这并不总是可能的,例如,当扩展位于不同的模块时。我们可以在将模块添加到应用程序之前尝试仔细排序我们的模块,但这假设存在某种模块顺序,可以满足所有扩展。

这就是操作的作用。

操作是简单的扩展,其返回值被忽略,并在常规扩展之后运行。这保证了操作总是接收扩展服务的最终值。

它们被称为“操作”,因为它们最常用于在从DI容器中获取另一个服务时运行代码片段。

就像扩展一样,有两种方式可以创建操作

1. 使用action()函数

use function Mecha\Modules\action;
use function Mecha\Modules\bind;
use function Mecha\Modules\extend;
use function Mecha\Modules\instance;

$module = [
    'users' => instance(List::class),

    action('users', function($c, $users) {
        foreach ($users as $user) {
            $user->loadPreferences();
        }
    }),

    extend('list', bind(function(List $list) {
        $list->add(new User('Abigail'));
        $list->add(new User('Britney'));
        $list->add(new User('Chrissy'));
        return $list;
    }),
];

现在这将产生预期的结果,因为操作保证在所有其他扩展之后运行。

2. 使用包裹服务的on()方法

就像扩展一样,绑定函数将接收操作附加到的服务的值,作为最后一个参数

use function Mecha\Modules\bind;
use function Mecha\Modules\extend;
use function Mecha\Modules\instance;

$module = [
    'db' => instance(MyDb::class),

    'migrations' =>
        load('migrations.php')
        ->on('db', bind(function(MyDb $db, Migrator $self) {
            $self->runIfNeeded($db);
        })),
];

线缆

线(Wires)是在操作之上构建的一个便利工具,有助于提高模块之间的代码协同定位和关注点分离。

考虑以下模块。第一个模块提供了一个用户列表

$module1 = [
    'users' => instance(List::class),
];

第二个模块使用扩展向列表中添加一个用户

$module2 = [
    extend('list', bind(function(List $list) {
        $list->add(new User('Charlie'));
    }),
];

这给第二个模块带来了负担,需要知道如何添加新用户。具体来说,它必须知道存在一个list服务,它是一个List对象,以及新用户需要通过add()方法添加。

如果我们将第一个模块中的users服务从List改为array,我们还需要更新第二个模块中的扩展以与数组一起工作。如果我们有更多模块中的扩展,它们也需要更新。

理想情况下,这种更改只在第一个模块中需要,即提供users服务的模块。Wires(线)就是实现这一点的工具!

Wires(线)是一种运行“目标”服务上函数的方式,与其它“连接”的服务一起。它们通过使用wire()函数创建。

例如,下面的代码创建了一个针对users服务的线

$module1 = [
    'users' => instance(List::class),
    'add_user' => wire('users', function(List $users, User $user) {
        $users->add($user);
    },
];

wire()函数接收目标服务的值(例如用户列表)和一个连接服务(例如单个用户)。在底层,这将为目标服务创建一个动作,为每个连接服务运行线的函数。

通过在包装服务上调用wire()方法,可以将服务连接到线上。

$module2 = [
    'albert' => value(new User('Albert'))->wire('add_user'),
    'bobby' => value(new User('Bobby'))->wire('add_user'),
];

现在,当DI容器创建users服务时,线动作将运行其函数两次;一次为albert,一次为bobby

这允许第二个模块在不需要知道如何扩展的情况下扩展第一个模块中的users服务。它只需要知道线的ID;所有其他细节都包含在第一个模块中。

使用bind()与线一起使用:

虽然wire()函数的第二个参数不接受服务定义,但您仍然可以提供绑定函数。如果您的线函数需要一些依赖项,这非常有用。

$module1 = [
    wire('users', bind(function (List $users, User $user, $dep) {
        // ...
    }, ['dep'])),
];

运行回调

运行回调是模块提供的需要在应用“运行”时调用的函数。您可能还记得,当您创建应用时,您可以调用$app->run()。这是模块运行回调被调用的时刻。

模块可以提供任意数量的运行回调。模块系统将以模块提供的相同顺序运行它们。有两种提供运行回调的方式

1. 使用run()函数:

$module = [
    run(function(ContainerInterface $c) {
        echo "App is running!\n";
    }),
];

当然,我们也可以在这里使用bind

$module = [
    'version' => value('1.0'),

    run(bind(
        function($version) {
            echo "App v{$version}\n";
        },
        ['version']
    ),
];

就像扩展一样,运行操作通常留为匿名(没有ID)。

2. 在包装服务上使用runs()方法:

$module = [
    'server' =>
        instance(Service::class)
        ->runs(fn($c) => $c->get('server')->start()),
];

就像扩展和操作一样,绑定函数也会收到服务本身的值作为最后一个依赖项。

$module = [
    'server' =>
        instance(Server::class)
        ->runs(bind(fn(Server $server) => $server->start())),
];

或者,您可以使用->then(...)作为->runs(bind(...))的简写。

$module = [
    'server' =>
        instance(Server::class)
        ->then(fn(Server $server) => $server->start()),
];

结合run()invoke():

考虑这个场景:我们有一个提供callback()服务的模块,该服务作为运行回调被检索和调用。

$module = [
    'server' => instance(Server::class),
    'init' => callback(fn(Server $s) => $s->start(), ['server']),

    run(function(ContainerInterface $c) {
        $init = $c->get('init');
        $init();
    }),
];

这可以通过使用invoke()函数来简化

$module = [
    'server' => instance(Server::class),
    'init' => callback(fn(Server $s) => $s->start(), ['server']),

    run(invoke('init'))
];

它甚至适用于服务附加的运行回调

$module = [
    'server' => instance(Server::class)->runs(invoke('init')),
    'init' => callback(fn(Server $s) => $s->start(), ['server']),
];

注意:invoke()不能与$service->then()一起使用。

生成器返回回调

如果您的模块是一个生成器函数,它可以选择返回一个服务定义,该定义将被模块系统视为运行回调。

function my_module() {
    yield 'server' => instance(Server::class);

    return function(ContainerInterface $c) {
        $c->get('server')->start();
    };
}

您也可以在这里使用bind()

function my_module() {
    yield 'server' => instance(Server::class);

    return bind(function(Server $server) {
        $server->start();
    }, ['server']);
}

上面两个示例的效果与有匿名run()服务相同。

作用域

作用域是将模块与其所有服务前缀相连接的行为,以确保没有服务ID与其他模块冲突。

仅仅在每个服务的ID前加上一个字符串是不够的。这样做会破坏依赖于原始未加前缀ID的服务。为了正确地定义模块的作用域,我们必须也为每个服务的依赖项加上前缀。因此,只有对封装服务才能正确地使用作用域。

要定义模块的作用域,请使用scope()函数并给它提供前缀字符串。

$module = scope('greeter.', [
    'name' => value('Michael Scott'),
    'message' => template('Hello %s', ['name']),
]);

上面的代码相当于:

$modules = [
    'greeter.name' => value('Michael Scott'),
    'greeter.message' => template('Hello %s', ['greeter.name'])
];

注意greeter.message的依赖项也被加上了前缀。

排除ID

你可能希望从作用域中排除一些服务ID。这种情况的一个常见例子是当模块依赖于来自另一个模块的服务。

要排除一个ID,请在它前面加上@符号。

$module1 = scope('greeter.', [
    'message' => template('Hello %s', ['@name']),
]);

$module2 = [
    'name' => value('Michael Scott'),
];

当第一个模块被定义作用域时,@name依赖项将简单地改为name,它指向第二个模块中的服务。

作为一个额外的优点,@符号让读者清楚地知道ID指的是另一个模块中的服务。

定义多个模块的作用域

还提供了一个scopeAssoc()函数,以便一次性方便地定义多个模块的作用域。它接受一个模块的关联数组作为参数,并使用数组键作为前缀字符串。

scopeAssoc([
    'greeter/' => $greeterModule,
    'config/' => $configModule,
    'db/' => $dbModule,
]);

高级设置

装饰模块

由于模块只是服务的iterable值,你可以通过生成器函数轻松地装饰它们。

例如,假设你想要为每个带有! ID前缀的服务注入运行回调

function autorun(iterable $module) {
    foreach ($module as $id => $service) {
        if (is_string($id) && $id[0] === '!') {
            $id = substr($id, 1);
            yield run(invoke($id));
        }

        yield $id => $service;
    }
}

自定义容器

默认情况下,模块系统将使用此包提供的DI容器实现。但是,你可以通过向App类提供一个工厂函数将其更改为任何PSR-11容器实现。

$app = new App([], fn($factories, $extensions) => /*...*/);

工厂函数将接收服务工厂和扩展的关联数组,其中数组键分别是服务和扩展的ID。

如果你的容器不支持扩展,你可以使用mergeExtensions()函数将扩展合并到工厂中

use Mecha\Modules\mergeExtensions;

$app = new App([], function ($factories, $extensions) {
    $merged = mergeExtensions($factories, $extensions);
    return new MyContainer($merged);
});

直接使用编译器

使用App类实际上是可选的。这个类只是Compiler的一个方便包装器,负责处理模块和编译PSR-11容器服务定义。

如果你需要对模块的处理有更多控制,你可以直接与编译器接口。

$compiler = new Compiler($modules);
$compiler->addModules($extraModules);
$compiler->addModule($oneMore);

编译器在每次添加模块后会增量更新其编译数据。你可以使用以下方法在任何时候提取编译的工厂、扩展和合并的运行回调

$factories = $compiler->getFactories();
$extensions = $compiler->getExtensions();
$callback = $compiler->getCallback();

工厂和扩展通常提供给DI容器。回调需要在你的应用程序执行过程中适当的时间点由你运行。

为了方便,编译器还提供了一个runCallback()方法,它接受一个容器并运行回调。你可以用它作为从编译器获取回调并立即调用的替代方法。

$container = new MyContainer(
    $compiler->getFactories(),
    $compiler->getExtensions(),
);

$compiler->runCallback($container);

Dhii模块的兼容性

注意我打算包含一个Dhii模块的兼容层,但由于与Dhii模块包及其依赖项相关的Composer依赖问题,这目前是不可能的。转换必须手动完成。你可以在此跟踪此问题的进度这里

Dhii模块系统要求模块必须是Dhii\Module\ModuleInterface的实例。在这个系统中,模块有一个返回ServiceProviderInterfacesetup()方法,来自(现已废弃的)服务提供者规范,以及一个run()方法。

将可迭代模块转换为Dhii模块实际上非常简单。

首先,为单个模块创建一个编译器并获取编译后的数据

$compiler = new Compiler([$module]);
$factories = $compiler->getFactories();
$extensions = $compiler->getExtensions();
$callback = $compiler->getCallback();

然后,创建一个通用的Dhii模块,它接受这些数据并通过其接口方法公开

$dhiiModule = new HybridModuleThing($factories, $extensions, $callback);

class HybridModuleThing implements ModuleInterface, ServiceProviderInterface
{
    public function __construct(
        protected array $factories,
        protected array $extensions,
        protected callable $callback
    ) {}

    public function setup(): ServiceProviderInterface
    {
        return $this;
    }

    public function getFactories(): array
    {
        return $this->factories;
    }

    public function getExtensions(): array
    {
        return $this->extensions;
    }

    public function run(ContainerInterface $c): void
    {
        ($this->callback)($c);
    }
}

提示:您可以让您的Dhii模块实现ServiceProviderInterface,然后在setup()中返回$this

许可

本项目采用MIT许可证授权。

版权所有 © 2023 Miguel Muscat