affinity4 / magic
Magic 特性用于轻松添加事件监听器、错误中的拼写建议以及任何类中的 __set 和 __get 风格的设置器和获取器。神奇!
Requires (Dev)
- phpunit/phpunit: ^8.2
This package is auto-updated.
Last update: 2024-09-18 15:54:52 UTC
README
Magic 特性用于轻松添加事件监听器、错误中的拼写建议以及任何类中的 __set 和 __get 风格的设置器和获取器。神奇!
查看此仓库的Wiki以获取完整文档
安装
composer require affinity4/magic
事件监听器
只需将 Magic 包含在任何类中,即可立即拥有事件监听器!
一旦您将 Magic 作为特性包含,您就可以添加任何以 "on" 开头的公共 "camelCased" 属性。您现在有了事件监听器!就这么简单!
假设我们有一个名为 User 的模型
class User extends Model { public function register(string $username, string $email, string $password) { // ...save data to `users` table echo "New user saved to `users` table\n"; } }
当新用户注册时,我们想通过电子邮件通知他们他们的登录详情。
我们将添加 Magic 特性并创建一个公共 onRegistration
属性。它必须是一个数组。
use Affinity4\Magic\Magic; class User extends Model { use Magic; /** * @var array */ public $onRegistration = []; public function register(string $username, string $email, string $password) { echo "New user saved to `users` table"; $this->onRegistration($username, $email, $password); } }
现在每次调用 User::register()
时,User::onRegistration()
方法也会被调用,用户的详细信息可用于任何附加的事件监听器。
事件监听器
要附加事件监听器,您只需向 onRegistration 数组添加一个回调函数。然后,每次执行 User::registration()
时,它们将按顺序调用。
require_once __DIR__ . '/vendor/autoload.php'; use Affinity4\Magic\Magic; class Model {} class User extends Model { use Magic; /** * @var array */ public $onRegistration = []; public function register(string $username, string $email, string $password) { echo "New user saved to `users` table\n"; $this->onRegistration($username, $email, $password); } } $User = new User; $User->onRegistration[] = function($username, $email, $password) { echo "Send email to $email\n"; echo "Hi $username!"; echo "Thank you for signing up! Your password is '$password'"; }; $User->register('johndoe', 'john.doe@somewhere.com', 'whoami'); // echos: // New user saved to `users` table // Send email to john.doe@somewhere.com // Hi johndoe! // Thank you for signing up!. Your password is 'whoami'
当然,您可能想做一些更聪明(且更注重安全)的事情,但您已经明白了。
“链式”或“嵌套”事件
重要
始终要注意的是,事件监听器不会在类的所有实例之间共享。如果您创建以下内容
require_once __DIR__ . '/vendor/autoload.php'; use Affinity4\Magic\Magic; use Some\Library\Log; class Email { use Magic; public $onEmail; public function send($to, $from, $body) { // Email stuff... $this->onEmail($to, $from, $body); } } $EmailA = new Email; $EmailA->onEmail[] = function($to, $from, $body) { Log::info("Email sent to $to from $from that said $body"); }; $EmailB = new Email; $EmailB->send('someone@work.com', 'my.email@home.com', 'Check this out!');
将不会触发任何日志事件。这是因为将日志电子邮件的事件监听器仅监听 $EmailA
。
当并列在一起时,这可能是相当明显的,但在大型项目中,如果您忘记正在处理哪个实例以及绑定到它的哪些事件,这可能会令人困惑。您可能会混淆日志,或者更糟。所以请小心!
可扩展性的容器
这就是 ServiceManagers 或 IoC 和 DI 容器救命的时候。然而,由于容器默认情况下始终返回从容器中获取的类的同一实例,因此如果您打算在创建类的同时在容器中设置事件,则需要使用工厂。
require_once __DIR__ . '/vendor/autoload.php'; use Affinity4\Magic\Magic; use Pimple\Container; class Email { use Magic; public $onEmail = []; public function send($to) { echo "Emailed $to\n"; $this->onEmail($to); } } class User { use Magic; public $onSave = []; public function save($id) { echo "Saved $id\n"; $this->onSave($id); } } $Container = new Container(); $Container[User::class] = $Container->factory(function($c) { $User = new User; $User->onSave[] = function($id) use ($c) { echo "EVENT: Saved $id\n"; $c[Email::class]->send('email'); }; return $User; }); $Container[Email::class] = $Container->factory(function($c) { $Email = new Email; $Email->onEmail[] = function($to) { echo "EVENT: Emailed $to"; }; return $Email; }); $Container[User::class]->onSave[] = function($id) use ($Container) { echo "EVENT: Saved $id\n"; $Container[Email::class]->send('email'); }; $Container[User::class]->save(1); // Will echo: // Saved 1 // EVENT: Saved 1 // Emailed email // EVENT: Emailed email
然而,有时拥有每个实例独特的事件非常有用。对于具有多个“Player”类实例的游戏,您不希望每个玩家在击杀时都获得分数,对吧?
您将在“Magic 设置器和获取器”部分中看到这个示例。
魔法属性
Magic 特性还为您提供了确保在从定义它的类之外直接设置或获取值时调用设置器或获取器方法的能力,无论您是否使用这些方法。
考虑以下学术示例:在一个类似 StackOverflow 的平台上,用户账户有声誉点。当用户达到下一个“级别”并获得新功能访问权限时,会触发一个事件,并且可以触发其他事件,例如给他们发送电子邮件或通知管理员等。
重要:这里有一个重大错误!错误在于将 $reputation
属性设置为公共的,这可能导致事件被错误地绕过。
让我们看看这个错误的示例。
注意 UserAccount
类缺少 @property
文档注释属性,并且 $reputation
和 $level
都被设置为公共的
require_once __DIR__ . '/vendor/autoload.php'; use Affinity4\Magic\Magic; class User { // User model } class UserAccount { use Magic; private $User; public $reputation = 0; public $level = 0; public $onReputationChange = []; public $onLevelUp = []; public function __construct(\User $User) { $this->User = $User; } public function setReputation(int $reputation) { $current_reputation = $this->reputation; // We want acces to the user model also in our event listeners $this->onReputationChange($current_reputation, $reputation, $this->User); $this->reputation = $reputation; } public function getReputation(): int { return $this->reputation; } public function setLevel(int $level) { $current_level = $this->level; if ($current_level < $level) { $this->onLevelUp($level, $this->User); } $this->level = $level; } public function getLevel(): int { return $this->level; } } $User = new User; $UserAccount = new UserAccount($User); $UserAccount->onReputationChange[] = function(int $current_reputation, int $new_reputation, \User $User) use ($UserAccount) { // Chweck this was a reputation increase and by 10 points or more if ($current_reputation < $new_reputation && $new_reputation >= 10) { echo "Reputation increased to $new_reputation\n"; // Make sure to use the same instance of $UserAccount $UserAccount->setLevel(1); // Level up to Level 1 } }; $UserAccount->onLevelUp[] = function(int $new_level) { echo "You have leveled up! You're now on Level $new_level!\n"; }; $UserAccount->setReputation(10); // echos... // Reputation increased to 10 // You have leveled up! You're now on Level 1!
注意:如果您想验证升级事件不会发生,可以将它设置为 9。
当事物按预期使用时,一切都很顺利,然而,由于声誉属性和等级属性被设置为公共的,以下情况是可以发生的
// .... // $UserAccount->setReputation(10); $UserAccount->reputation = 10;
没有任何事情发生。你甚至可以直接设置等级属性,但没有任何事情会发生。系统不知道这些属性已更改。
只需将属性更改为受保护的或私有的,并添加2个文档块属性,魔法就可以解决这个问题!
/** * @property int $reputation * @property int $level */ class UserAccount { use Magic; private $User; private $reputation = 0; // Change to private private $level = 0; // Change to private // ...the rest is uncahnged!
现在这...
$UserAccount->reputation = 10;
...将正确触发我们的设置事件。
Reputation increased to 10 You have leveled up! You're now on Level 1!
当然,您仍然可以像往常一样使用您的设置器和获取器!但如果你忘记了,魔法将会发生,并保持你的系统按预期工作。
高地人游戏示例
为了说明所有这些如何为您节省大量条件if/else/elseif代码,该代码成为难以维护的噩梦,请查看这个游戏(至少是这个游戏的开头),它基于1986年的电影《高地人》。你知道,“只有一个”和所有这些。
要求
- 必须有一个
Highlander
类,所有玩家都是该类的实例 - 每个玩家开始游戏时有10点“生命力”(与健康状况无关)
- 当一个玩家杀死另一个玩家时,他们将吸收/获得那个对手的生命力,无论它当时是多少
- 只有在我们杀死另一个玩家时,我们才会知道剩下多少高地人
- 如果还有其他玩家要击败,玩家将大喊:“只有一个!”
这就是电影的基本情节:)
因此,首先我们创建一个名为Highlander的类,该类使用Affinity4\Magic\Magic
,并有两个私有属性$number_of_highlanders
和$lifeforce
。这些将具有设置器/获取器方法set/get_number_of_highlanders
和set/getLifeforce
。我们将为$number_of_highlanders
和$lifeforce
添加@property
文档块属性以启用魔法。我们还将有一个简单的shout
方法,该方法只回显一个短语
require_once __DIR__ . '/vendor/autoload.php'; use Affinity4\Magic\Magic; /** * @property int $number_of_highlanders * @property int $lifeforce */ class Highlander { use Magic; /** * @var int */ private $number_of_highlanders= 3; /** * @var int */ private $lifeforce = 10; public function setNumberOfHighlanders(int $number_of_highlanders) { $this->number_of_highlanders= $number_of_highlanders; } public function getNumberOfHighlanders(): int { return $this->number_of_highlanders; } public function setLifeforce(int $lifeforce) { $this->lifeforce = $lifeforce; } public function getLifeforce() { return $this->lifeforce; } public function shout(string $phrase) { echo $phrase; } }
接下来,我们创建kills
方法,该方法接受被你杀死的玩家的实例(因此你可以获得他们的生命力等)。它使用被击败的玩家触发onKill
事件
require_once __DIR__ . '/vendor/autoload.php'; use Affinity4\Magic\Magic; /** * @property int $lifeforce * @property int $number_of_highlanders */ class Highlander { use Magic; private $lifeforce = 10; private $number_of_highlanders= 4; public $onKill = []; public function setLifeforce(int $lifeforce) { $this->lifeforce = $lifeforce; } public function getLifeforce(): int { return $this->lifeforce; } public function setNumberOfHighlanders(int $number_of_highlanders) { $this->number_of_highlanders= $number_of_highlanders; } public function getNumberOfHighlanders(): int { return $this->number_of_highlanders; } public function shout(string $phrase) { echo $phrase; } public function kill(\Highlander $Opponent) { $this->onKill($Opponent); } } $Highlander = new Highlander; $Opponent = new Highlander; // He killed someone along the way here. But so far only // he's aware there are only 3 Highlanders left, our player still thinks there are 4 --$Opponent->number_of_highlanders; $Highlander->onKill[] = function($Opponent) use ($Highlander) { $Highlander->lifeforce += $Opponent->getLifeforce(); if ($Opponent->number_of_highlanders< $Highlander->number_of_highlanders) { $Highlander->number_of_highlanders= ($Opponent->number_of_highlanders- 1); } echo "You lifeforce is {$Highlander->lifeforce}!\n"; echo "There are {$Highlander->number_of_highlanders} highlanders left\n"; if ($Highlander->number_of_highlanders> 1) { $Highlander->shout("There can be only one!!!\n"); } }; $Highlander->kill($Opponent); // echoes... // There are only 2 Highlanders left // You now have 20 lifeforce! // There can be only one!!
这不仅少于75行,而且高地人类中的任何方法都没有超过一行代码!而且它永远不会需要。从现在开始,如果我们决定在有人被杀或做出杀戮时需要发生更多的事情,我们只需添加更多的事件处理器!
如果不是魔法,我不知道那是什么!
可调用的类作为事件处理器
虽然回调作为事件处理器既方便又快速编写,但它们有限制,并且常常会鼓励不良的设计选择。
例如,我们Magic Properties
页面上的高地人游戏示例,它只使用了11行代码作为事件处理器。然而,它已经存在严重的问题,而且随着代码行的增加或回调的增加,这些问题只会变得更加严重。
这是事件处理器
$Highlander = new Highlander; $Opponent = new Highlander; // He killed someone along the way here. But so far only // he's aware there are only 3 Highlanders left, our player still thinks there are 4 --$Opponent->number_of_highlanders; $Highlander->onKill[] = function($Opponent) use ($Highlander) { $Highlander->lifeforce += $Opponent->getLifeforce(); if ($Opponent->number_of_highlanders< $Highlander->number_of_highlanders) { $Highlander->number_of_highlanders= ($Opponent->number_of_highlanders- 1); } echo "You lifeforce is {$Highlander->lifeforce}!\n"; echo "There are {$Highlander->number_of_highlanders} highlanders left\n"; if ($Highlander->number_of_highlanders> 1) { $Highlander->shout("There can be only one!!!\n"); } }; $Highlander->kill($Opponent);
问题1:强制类型
让我们从第一行开始
$Highlander->onKill[] = function($Opponent) use ($Highlander) {
这里的问题是,我们无法强制类型。我们可以在触发onKill
方法的Highlander::kill()
方法中对$Opponent
进行类型提示,但这假设我们通过相同的值传递给kill
方法。我们实际上可能传递的是生成的值,这可以是任何东西。
我们也不能确保$Highlander
确实是\Highlander
的实例。如果我们传递了其他东西,我们可能会遇到错误,或者更糟糕的是,我们可能会传递具有相同属性和方法的其他类,这会导致完全不可预期的行为。这不会引起错误,但可能会产生难以调试的副作用。
问题2:单一职责
仅仅有一个回调来添加我们的代码,我们就失去了面向对象编程的组织优势。在不意识到的情况下很容易打破单一职责原则(SRP),尤其是在有众多开发者的项目中。
虽然我们的代码看起来一开始好像都属于一起,但经过仔细检查,我们可以看到它实际上正在修改我们“英雄”类的两个部分,更新$lifeforce
和更新$number_of_highlanders
。
$Highlander->lifeforce += $Opponent->getLifeforce(); echo "You lifeforce is {$Highlander->lifeforce}!\n"; if ($Opponent->number_of_highlanders< $Highlander->number_of_highlanders) { $Highlander->number_of_highlanders= ($Opponent->number_of_highlanders - 1); } echo "There are {$Highlander->number_of_highlanders} highlanders left\n"; if ($Highlander->number_of_highlanders> 1) { $Highlander->shout("There can be only one!!!\n"); }
前两行仅处理$lifeforce
属性,应该将其移出此函数。然而,将所有内容拆分到各自的回调函数中会很快变得混乱且难以维护。回调和闭包需要阅读代码来确定它们在做什么。如果这些行被重构为类,我们就可以根据名称了解每个类的用途以及每个方法应该做什么(名称应该是清晰且描述性的)。我们还会得到类提供的一切,而回调则没有。
问题3:组织
我们应该如何组织所有这些内容?我们是否应该为应用中的每个事件创建一个单独的文件并将所有内容都放入每个文件中?我们可以这样做,但我可以想象这会很快变得非常糟糕。
相反,如果我们有自动加载和合理的文件夹结构,我们就可以简单地遍历自动加载的类并为事件添加事件监听器。这意味着在正确的文件夹中创建一个新类,就足以绑定处理器到事件。
解决方案
可调用类可以解决所有这些问题,并提供面向对象编程可以提供的额外好处。因此,让我们将现有代码重构为两个单独的事件处理器类LifeforceEventHandler
和NumberOfHighlandersEventHandler
。
TakeOpponentsLifeforceEventHandler
可调用事件处理器类的要求只有一个,即它有一个与回调具有相同参数的__invoke()
方法。然而,我们现在可以利用构造函数进行更多的“设置”。
我们的事件处理器现在可能看起来是这样的
class TakeOpponentsLifeforceEventHandler { private $Highlander; public function __construct(\Highlander $Highlander) { $this->Highlander = $Highlander; } public function __invoke(\Highlander $Opponent) { $this->Highlander->lifeforce += $Opponent->getLifeforce(); echo "You're lifeforce is now {$this->Highlander->lifeforce}!\n"; } }
我们可以现在强制$Highlander
和$Opponent
参数为\Highlander
实例。实际上,我们应该在这里使用接口,但这取决于你。
很明显,这个类的目的是处理与获取对手生命值相关的一切。
我们甚至可以使用魔术特性在这里触发事件,供其他类订阅。比如说,我们需要添加一个SpecialAbility
特性,在玩家达到50生命值点后给予他们一个随机特殊能力。我们可以在__invoke()
方法中添加一个事件onFiftyLifeforce
。现在,我们的特殊能力类可以订阅这个事件来完成它需要做的。
UpdateNumberOfHighlandersEventHandler
实现UpdateNumberOfHighlandersEventHandler
应该很明显,但为了完整性,让我们看看它是什么样的。
class UpdateNumberOfHighlandersEventHandler { private $Highlander; public function __construct(\Highlander $Highlander) { $this->Highlander = $Highlander; } private function decrementNumberOfHighlanders(\Highlander $Opponent) { if ($Opponent->number_Of_highlanders < $this->Highlander->number_Of_highlanders) { $this->Highlander->number_Of_highlanders = (--$Opponent->number_Of_highlanders); } } public function __invoke(\Highlander $Opponent) { $this->decrementNumberOfHighlanders($Opponent); echo "There are {$this->Highlander->number_Of_highlanders} highlanders left\n"; if ($this->Highlander->number_Of_highlanders > 1) { $this->Highlander->shout("There can be only one!!!\n"); } } }
调用事件处理器
为了附加事件处理器,我们只需将回调替换为初始化的事件处理器类,如下所示
$Highlander = new Highlander; $Opponent = new Highlander; // He killed someone along the way here. But so far only // he's aware there are only 3 Highlanders left, our player still thinks there are 4 --$Opponent->number_of_highlanders; $Highlander->onKill[] = new TakeOpponentsLifeforceEventHandler($Highlander); $Highlander->onKill[] = new UpdateNumberOfHighlandersEventHandler($Highlander); $Highlander->kill($Opponent);
内部,将使用调用方法,并将从kill()
方法传递的$Opponent
实例传递给它。
待办事项
- 使用PHP 8属性代替魔法set/get
- 改进示例,以使用PSR兼容的容器示例