使用行為(behavior)可以在不修改現有類的情況下,對類的功能進行擴充。通過將行為綁定到一個類,可以使得類具有行為本身所具有的屬性和方法,就好像是類本來就具有的這些屬性和功能一樣。 好的代碼設計,必須要同時滿足可復用性、可維護性和可擴展性。設計原則中有一條非常重要的一條:類應該對擴展開放,對修改 ...
使用行為(behavior)可以在不修改現有類的情況下,對類的功能進行擴充。通過將行為綁定到一個類,可以使得類具有行為本身所具有的屬性和方法,就好像是類本來就具有的這些屬性和功能一樣。
好的代碼設計,必須要同時滿足可復用性、可維護性和可擴展性。設計原則中有一條非常重要的一條:類應該對擴展開放,對修改關閉。改變原有代碼往往會帶來潛在風險,因此我們儘量減少修改的行為。我們的目標是允許類容易擴展,在不修改現有代碼的情況下,就可以搭配新的行為。如果能實現這樣的目標,有什麼好處呢?這樣的設計具有彈性,可以應對改變,可以接收新的功能來應對改變的需求。
Yii的行為就是這樣一類對象,當一個對象(繼承了Component的)想要擴展功能,又不想改變原有代碼時,那麼你完全可以用行為去實現這些新功能,然後綁定到該對象上——完全是符合“開閉原則”的。
Yii的行為都需要繼承自yii\base\Behavior
,而能接受行為綁定從而擴充自身功能的只能是yii\base\Component的子類,只繼承BaseObject基類沒有繼承Component的不能享受此“待遇”。因此,行為是組件才有的功能。行為和事件結合起來使用,還可以定義組件在何種事件進行何種反饋。因此行為有如下兩個作用:
- 將屬性和方法註入到一個component裡面,被訪問時和別的屬性或者方法訪問無異(行為的附加)
- 響應component中觸發的事件,以便對某一事件作出反應(行為的綁定和觸發,是對事件的應用)
定義行為
行為必須繼承自yii\base\Behavior,定義一個行為仿照下麵進行:
class MyBehavior extends \yii\base\Behavior
{
public $prop1;
private $_prop2;
private $_prop3;
//綁定事件和處理器,從而擴展類的功能表現,這裡體現了“行為”字面意義
public function events()
{
}
//行為的只讀屬性
public function getProp2()
{
return $this->_prop2;
}
//行為的只寫屬性
public function setProp3($prop3)
{
$this->_prop3 = $prop3;
}
//行為的方法
public function foo()
{
return 'foo';
}
protected function bar()
{
return 'bar';
}
}
接下來,將行為附加到對象上,從而擴充對象的功能:
$user = new User();
//$user對像附加行為,擴充功能
$user->attachBehavior('myBehavior', new MyBehavior());
//獲取prop2屬性
$user->prop2;
//給只讀屬性賦值會報錯
$user->prop2 = 3;
//給只寫屬性prop3賦值
$user->prop3 = 2;
//操作可讀-可寫屬性prop1
$user->prop1 = 1;
$var = $user->prop1;
// 使用方法foo
$user->foo();
// 不可訪問,這裡會拋出'Unknown Method'異常
$user->bar();
當然MyBehavior()完全可以支持依賴註入,從而在運行時決定這些屬性的值。
從上面可以看出,$user對象使用其MyBehavior的屬性和方法來幾乎毫不費勁,就像自己擁有這些屬性和方法一樣。但是,我們並沒有給User類中添加任何一行代碼,因此這個擴展做得真是悄無聲息啊!
行為的附加
行為的附加或者綁定,通常是由Component來發起。有兩種方式可以將一個Behavior綁定到一個 yii\base\Component 。 一種是靜態附加行為,另一種是動態附加行為。靜態附加在實踐中用得比較多一些,因為一般情況下,在你的代碼沒跑起來之前,一個類應當具有何種行為是確定的。 動態附加主要是提供了更靈活的方式,上面即是行為的動態附加,但實際使用中並不多見。
靜態附加
class User extends ActiveRecord
{
const MY_EVENT = 'my_event';
public function behaviors()
{
return [
// 匿名行為,只有行為類名
MyBehavior::className(),
// 命名行為,只有行為類名
'myBehavior2' => MyBehavior::className(),
// 匿名行為,配置數組
[
'class' => MyBehavior::className(),
'prop1' => 'value1',
'prop2' => 'value2',
],
// 命名行為,配置數組
'myBehavior4' => [
'class' => MyBehavior::className(),
'prop1' => 'value1',
'prop2' => 'value2',
]
];
}
}
上面的數組響應的鍵就是行為的名稱,這種行為成為命名行為,沒有指定名稱的就成為匿名行為。
還有一個靜態的綁定辦法,就是通過配置文件來綁定:
[
'class' => User::className(),
'as myBehavior2' => MyBehavior::className(),
'as myBehavior3' => [
'class' => MyBehavior::className(),
'prop1' => 'value1',
'prop3' => 'value3',
],
]
通過這個配置文件獲取的User對象的實例,依然被附加了MyBehavior行為。
動態附加
要動態附加行為,在對應組件里調用 yii\base\Component::attachBehavior()
方法即可,如:
use app\components\MyBehavior;
// 附加行為——對象
$user->attachBehavior('myBehavior1', new MyBehavior);
// 附加行為——類名
$user->attachBehavior('myBehavior2', MyBehavior::className());
// 附加行為——配置數組
$user->attachBehavior('myBehavior3', [
'class' => MyBehavior::className(),
'prop1' => 'value1',
'prop2' => 'value2',
]);
也可以通過yii\base\Component::attachBehaviors()
同時附加多個行為:
$myBehavior = new MyBehavior();
$user->attachBehaviors([
'myBehavior1'=> $myBehavior,
[
'class' => MyBehavior2::className(),
'prop1' => 'value1',
'prop3' => 'value2',
],
new MyBehavior3()
]);
附加多個行為,那麼組件就獲得了所有這些行為的屬性和方法。
不管是靜態附加還是動態附加,命名行為都可以通過yii\base\Component::getBehavior($name)獲取出來,匿名行為不可以單獨獲取出來,但是可以通過Component::getBehaviors()
一次全部獲取出來。
行為附加的原理
在Component內部,事件是通過私有屬性$_event
保存事件及其處理器,和事件類似,行為是通過私有屬性$_behavior
來保存的:
private $_events = [];
private $_behaviors;
$_behaviors的數據結構:
上圖中前面兩個是命名行為,後面兩個是匿名行為。數組的每個元素值都是Behavior的子類實例。
行為附加涉及到四個方法:
Component::behaviors()
Component::ensureBehaviors()
Component::attachBehaviorInternal()
Behavior::attach()
Component::behaviors()用於供子類覆寫,比如:
public function behaviors()
{
return [
'timeStamp' => [
'class' => TimeBehavior::className(),
'create' => 'create_at',
'update' => 'update_at',
],
];
}
yii\base\Component::ensureBehaviors()方法經常出現,它的作用是將各種動態的和靜態的方式附加的行為變成標準格式(參看$_behaviors的數據結構):
public function ensureBehaviors()
{
if ($this->_behaviors === null) {
$this->_behaviors = [];
// behaviors()方法由Component的子類重寫
foreach ($this->behaviors() as $name => $behavior) {
$this->attachBehaviorInternal($name, $behavior);
}
}
}
接下來的第三個出場的attachBehaviorInternal(),我們看看是何方神聖:
private function attachBehaviorInternal($name, $behavior)
{
//如果是配置數組,那就將其創建出來再說
if (!($behavior instanceof Behavior)) {
$behavior = Yii::createObject($behavior);
}
if (is_int($name)) { // 匿名行為
//先是行為本身和component綁定
$behavior->attach($this);
//將行為放進$_behaviors數組,沒有鍵值的是匿名行為
$this->_behaviors[] = $behavior;
} else { //命名行為
if (isset($this->_behaviors[$name])) {
//命名行為需要保證唯一性
$this->_behaviors[$name]->detach();
}
$behavior->attach($this);
//命名行為,鍵值就是行為名稱
$this->_behaviors[$name] = $behavior;
}
return $behavior;
}
Yii中以Internal開頭或者結尾的,一般是私有方法,往往都是命門所在,如果要看源碼,這些都是核心邏輯實現的地方。
最後一個出場的是Behavior::attach()
,Behavior有一個屬性$owner
,指向是擁有它的組件,就是行為的擁有者。組件和行為是一個相互綁定、相互持有的過程。組件在$_behavior
持有行為的同時,行為也在$owner
中持有組件。因此,不管是行為的附加還是解除都是雙方的事情,不是一方能說了算的。
public function attach($owner)
{
//Behavior的$owner指向的是行為的所有者
$this->owner = $owner;
//讓行為的所有者$owner綁定用戶在Behavior::events()中所定義的事件和處理器
foreach ($this->events() as $event => $handler) {
$owner->on($event, is_string($handler) ? [$this, $handler] : $handler);
}
}
行為的解除
有附加當然有解除,命名行為可以被單個解除,使用方法Component::detachBehavior($name)
,匿名行為不可以單獨解除,但是可使用detachBehaviors()
方法解除所有的行為。
//解除命名行為
$user->detachBehavior('myBehavior1');
//解除所有行為
$user->detachBehaviors();
這上面兩種方法,都會調用到 yii\base\Behavior::detach() ,其代碼如下:
public function detachBehavior($name)
{
$this->ensureBehaviors();
if (isset($this->_behaviors[$name])) {
$behavior = $this->_behaviors[$name];
//1.將行為從$owner的$_behaviors中刪除
unset($this->_behaviors[$name]);
//2.解除$owner的所有事件和其處理器
$behavior->detach();
return $behavior;
}
return null;
}
而$behavior->detach()
是這樣的:
public function detach()
{
if ($this->owner) {
//解綁$owner所有事件和其事件處理器
foreach ($this->events() as $event => $handler) {
$this->owner->off($event, is_string($handler) ? [$this, $handler] : $handler);
}
//$owner重新置為null,表示沒有任何擁有者
$this->owner = null;
}
}
行為所要響應的事件
行為與事件結合後,可以在不對類作修改的情況下,補充類在事件觸發後的各種不同反應。因此,只需要重載 yii\base\Behavior::events() 方法,表示這個行為將對類的何種事件進行何種反饋即可:
class MyBehavior extends Behavior
{
public $attr;
public function events() //覆寫events方法
{
return [
ActiveRecord::EVENT_BEFORE_INSERT => 'beforeInsert', //將事件和事件處理器綁定
User::MY_EVENT => [$object, 'methodName'],//自己定義的事件
];
}
//$event可以帶來三個信息,事件名,觸發此事件的對象(類或者實例),附加的數據
public function beforeInsert($event)
{
$model = $this->owner;//訪問已附件的組件
// Use $model->attr
}
public function methodName($event)
{
$owner = $this->owner;//行為的擁有者
$sender = $event->sender//觸發此事件的類或者實例
$data = $event->data;//觸發事件時傳遞的參數
// Use $model->attr
}
}
events()方法返回一個關聯數組,鍵是事件名,值是要響應的事件處理器。事件處理器可以是一下四種形式:
- 此行為中的方法
methodName
,等效為[$this, 'methodName']
- 對象的方法:
[$object, 'methodName']
- 類的靜態方法:
['Page', 'methodName']
- 閉包:
function ($event) { ... }
這些方法中都會傳遞事件$event
過來,通過$event
你可以獲得事件名,觸發此事件的對象(類或者實例),附加的數據信息。詳見《Yii2基本概念之——事件(Event)》。
行為響應事件的實例
Yii費了那麼大勁,主要就是為了將行為中的事件handler綁定到類中去。因為在編程中用的最多的,也就是Component對各種事件的響應。通過行為註入,可以在不修改現有類的代碼的情況下更改、擴展類對於事件的響應和支持。使用這個技巧,可以玩出很酷的花樣出來。
比如,Yii自帶的 yii\behaviors\AttributeBehavior
類,定義了在一個 ActiveRecord 對象的某些事件發生時, 自動對某些欄位進行修改的行為。它有一個很常用的子類 yii\behaviors\TimeStampBehavior 用於將指定的欄位設置為一個當前的時間戳。現在以它為例子說明行為的運用。
在 yii\behaviors\AttributeBehavior::event() 中,代碼如下:
public function events()
{
return array_fill_keys(
array_keys($this->attributes),
'evaluateAttributes'
);
}
代碼很容易看懂,無需詳述。
而在yii\behaviors\TimeStampBehavior::init()中有代碼:
public function init()
{
parent::init();
if (empty($this->attributes)) {
//重點看這裡
$this->attributes = [
BaseActiveRecord::EVENT_BEFORE_INSERT => [$this->createdAtAttribute, $this->updatedAtAttribute],
BaseActiveRecord::EVENT_BEFORE_UPDATE => $this->updatedAtAttribute,
];
}
}
上面的這個方法是初始化$this->attributes
這個數組。結合前面的兩個方法,返回的$event數組應該是這樣的:
return [
BaseActiveRecord::EVENT_BEFORE_INSERT => 'evaluateAttributes',
BaseActiveRecord::EVENT_BEFORE_UPDATE => 'evaluateAttributes',
];
這裡的意思是BaseActiveRecord::EVENT_BEFORE_INSERT
和BaseActiveRecord::EVENT_BEFORE_UPDATE
都響應處理器evaluateAttributes
。看看其關鍵部分:
public function evaluateAttributes($event)
{
...
if (!empty($this->attributes[$event->name])) {
$attributes = (array) $this->attributes[$event->name];
//這裡預設返回預設的時間戳time()
$value = $this->getValue($event);
foreach ($attributes as $attribute) {
if (is_string($attribute)) {
if ($this->preserveNonEmptyValues && !empty($this->owner->$attribute)) {
continue;
}
//將其賦值給$owner的欄位
$this->owner->$attribute = $value;
}
}
}
}
使用時,只需要在ActiveRecord裡面重載behaviors()方法:
public function behaviors()
{
return [
[
'class' => TimestampBehavior::className(),
'attributes' => [
ActiveRecord::EVENT_BEFORE_INSERT => 'created_at',
ActiveRecord::EVENT_BEFORE_UPDATE => 'updated_at',
]
],
];
}
因此,當EVENT_BEFORE_INSERT
事件觸發,
這樣,你在插入記錄時created_at
和updated_at
會自動更新,而在修改時updated_at
會更新。
行為的屬性和方法註入原理
通過以上各個例子,組件附加了行為之後,就獲得了行為的屬性和方法。那麼,這是如何實現的呢?歸根結底主要通過__set(),__get(),__call()
這些魔術方法來實現的。屬性的註入靠的是__set(),__get()
,而方法的註入是靠__call()
。
屬性的註入
Component持有一個數組$_behavior,裡面都是Behavior子類,而Behavior繼承自Yii最基礎的BaseObject。在《Yii2基本概念之——屬性(property)》中我們介紹了屬性的概念,因此Behavior也是可以運用屬性的。
Component的可讀屬性,我們看看Component的getter函數:
public function __get($name)
{
$getter = 'get' . $name;
//這是自己的可寫屬性
if (method_exists($this, $getter)) {
return $this->$getter();
}
/**下麵是比BaseObject多出來的部分**/
$this->ensureBehaviors();
//依次檢查各個行為中的可讀屬性
foreach ($this->_behaviors as $behavior) {
if ($behavior->canGetProperty($name)) {
return $behavior->$name;
}
}
...
}
Component的可寫屬性,我們看看Component的setter函數:
public function __set($name, $value)
{
$setter = 'set' . $name;
//自己的可寫屬性
if (method_exists($this, $setter)) {
$this->$setter($value);
return;
} elseif (strncmp($name, 'on ', 3) === 0) {
$this->on(trim(substr($name, 3)), $value);
return;
} elseif (strncmp($name, 'as ', 3) === 0) {
$name = trim(substr($name, 3));
$this->attachBehavior($name, $value instanceof Behavior ? $value : Yii::createObject($value));
return;
}
$this->ensureBehaviors();
//依次檢查各個行為中是否有可寫屬性$name
foreach ($this->_behaviors as $behavior) {
if ($behavior->canSetProperty($name)) {
$behavior->$name = $value;
return;
}
}
...
}
對於setter函數,略微複雜,檢查順序依次是:
- 自己的setter函數,也即是自己的可寫屬性
- 如果
$name
是'on xyz'形式,則xyz作為事件,$value作為handler,將其綁定 - 如果
$name
是'as xyz'形式,則xyz作為行為名字,$value作為行為,將其附加 - 依次檢查各個行為中是否有可寫屬性$name,返回第一個;如果沒有則拋出異常
因此,Component的可讀屬性就是本身的可讀屬性加上所有行為的可讀屬性;而可寫屬性就是本身的可寫屬性加上所有行為的可寫屬性。
方法的註入
同屬性的註入類似,方法的註入也是自身的方法加上所有行為的方法:
public function __call($name, $params)
{
$this->ensureBehaviors();
//遍歷所有行為的方法
foreach ($this->_behaviors as $object) {
if ($object->hasMethod($name)) {
return call_user_func_array([$object, $name], $params);
}
}
...
這裡以為最終調用的是call_user_func_array()
的函數,所以只有行為的public 方法才能被註入組件中。
除了屬性的讀和寫,還有對屬性的判斷(isset)和註銷(unset),分別通過對魔術方法__isset
和__unset
的重載來實現,這裡就不多贅述了。
結語
屬性,事件和行為是Yii的基礎功能,它們使得Yii成為一個變化無窮、魅力無窮的框架。然而,框架不能做PHP本身都做不到的事情,它酷炫的功能無非是PHP自身的面向對象特性(重載,魔術方法,成員變數/函數可見性)和一些數據結構,外加巧妙的演算法來實現的。因此“解剖”的目的就在於,解開這次神秘面紗,搞清楚內在邏輯,最終使得自己的編程能力得到切實的提高。