ビヘイビアとは - フィールドを GZIP 圧縮して永続化する方法

こんにちは。実務で Phalcon を使っています。
今回は Model にペタッとアタッチして使うビヘイビアについて書いてみます。

## ビヘイビアとはなにか

Phalcon\Mvc\Model は onConstruct や beforeSave, afterSave といったイベントをハンドルすることができます。永続化が実行される直前、直後でなにかを実行したい場合に便利です。

これらのイベントハンドラを使うことで、たいていのことはできます。
例えばレコードの作成日時と更新日時を記録するには以下のように書きます。

```php
class BlogEntry extends \Phalcon\Mvc\Model {
    
    public $created_at;
    public $updated_at;

    public function beforeCreate()
    {
        $this->created_at = date('r');
        $this->updated_at = date('r');
    }

    public function beforeUpdate()
    {
        $this->updated_at = date('r');
    }
    
}
```

さらに、あるフィールドを GZIP 圧縮するようにしてみましょう。
圧縮されたフィールドは MySQL であれば BLOB 型カラムに格納できます。

```php
class BlogEntry extends \Phalcon\Mvc\Model {
    
    public $created_at;
    public $updated_at;
    public $body;
    public $metadata;

    public function beforeCreate()
    {
        $this->created_at = date('r');
    }

    public function beforeUpdate()
    {
        $this->updated_at = date('r');
    }
    
    public function beforeSave() {
    	$this->body = gzcompress($this->body);
    	$this->metadata = gzcompress($this->metadata);
    }
    
    public function afterFetch() {
    	$this->body = gzuncompress($this->body);
    	$this->metadata = gzuncompress($this->metadata);
    }
    
}
```

あ、これだと保存した直後は圧縮されたままになってしまいますね。

```php
$entry = new BlogEntry();
$entry->body = "あばばばばばぼぼぼぼぼぼぼぼ";
$entry->save();
echo $entry->body; // ->文字化け
```

afterSave を追加すれば解決します。

```php
class BlogEntry extends \Phalcon\Mvc\Model {
    
    public $created_at;
    public $updated_at;
    public $body;
    public $metadata;

    public function beforeCreate()
    {
        $this->created_at = date('r');
    }

    public function beforeUpdate()
    {
        $this->updated_at = date('r');
    }
    
    public function beforeSave() {
    	$this->body = gzcompress($this->body);
    	$this->metadata = gzcompress($this->metadata);
    }
    
    public function afterSave() { // 追加！(だけど内容は afterFetch と同じ...
    	$this->body = gzuncompress($this->body);
    	$this->metadata = gzuncompress($this->metadata);
    }
    
    public function afterFetch() {
    	$this->body = gzuncompress($this->body);
    	$this->metadata = gzuncompress($this->metadata);
    }
    
}
```

### お前は何が言いたいんだ早く言え！

イベントシステムの問題は、メソッドがその処理の目的を表していないためいちいち「このモデルは～のタイミングで何をしでかすんだ？」とコードを読む必要があります。それに、読むべきコードは各イベントメソッドに散在してしまうため見通しがすこぶる悪くなります。

こういった「状態に影響する」ものは副作用です、副作用をパッと見て限定できなくなればバグの名産地となることは間違いありません。

### ビヘイビア is 何

こういったイベントによる振る舞いを、目的ごとに定義したものがビヘイビアです。
例えば最初の作成日時を自動的に設定するビヘイビアは[Phalcon が用意してくれています](http://docs.phalconphp.com/ja/latest/reference/models.html#id28)

モデルの initialize メソッドにて addBehavior するだけでOKです。

```php
use Phalcon\Mvc\Model\Behavior\Timestampable;

class BlogEntry extends \Phalcon\Mvc\Model {

	public $created_at;
	public $updated_at;
	public $body;
	public $metadata;

	public function initialize()
	{
		$this->addBehavior(new Timestampable(
			array(
				'beforeCreate' => array(
					'field' => array(
						'created_at',
						'updated_at'
					),
					'format' => 'Y-m-d'
				),
				'beforeUpdate' => array(
					'field' => 'updated_at',
					'format' => 'Y-m-d'
				),
			)
		));
	}

}
```

### GZIP 圧縮するビヘイビアを独自に定義する

フィールドを圧縮するビヘイビアを作ってみました。

```php:GzCompressBehavior
use Phalcon\Mvc\Model\Behavior,
	Phalcon\Mvc\Model\BehaviorInterface;

/**
 * フィールドを gzip 圧縮して永続化するビヘイビア
 */
class GzCompressBehavior extends Behavior implements BehaviorInterface {

	/**
	 * @type array|\string[]
	 */
	private $fields;
	private $level = -1;
	
	/**
	 * @param string[] $fields 圧縮するフィールド名を格納した配列
	 * @param integer $level 圧縮レベル デフォルトで zlib ライブラリのデフォルト値
	 * @param array $options
	 */
	public function __construct(array $fields, $level = -1, $options = null)
	{
		parent::__construct($options);
		$this->fields = $fields;
	}

	/**
	 * @param string $type
	 * @param \Phalcon\Mvc\Model $model
	 */
	public function notify($type, $model)
	{
		switch ($type) {
			case 'beforeSave':
				foreach ($this->fields as $field) {
					$value = $this->getValue($model, $field);
					$this->setValue($model, $field, \gzcompress($value, $this->level));
				}
				break;
			case 'afterSave':
			case 'afterFetch':
				foreach ($this->fields as $field) {
					$value = $this->getValue($model, $field);
					$this->setValue($model, $field, \gzuncompress($value));
				}
				break;
		}
	}

	/**
	 * @param \App\Models\ModelBase $model
	 * @param string $field
	 * @return mixed
	 * @throws \ErrorException
	 */
	private function getValue($model, $field)
	{
		$getter = "get" . $field->toPascalCase();
		if (method_exists($model, $getter)) {
			return $model->$getter();
		} else if (property_exists($model, $field)) {
			return $model->$field;
		} else {
			throw new \ErrorException("field $field not found on class " . get_class($model));
		}
	}

	/**
	 * @param \App\Models\ModelBase $model
	 * @param string $field
	 * @param mixed $value
	 * @throws \ErrorException
	 */
	private function setValue($model, $field, $value)
	{
		$setter = "set" . $field->toPascalCase();
		if (method_exists($model, $setter)) {
			$model->$setter($value);
		} else if (property_exists($model, $field)) {
			$model->$field = $value;
		} else {
			throw new \ErrorException("field $field not found on class " . get_class($model));
		}
	}

	private function toPascalCase($str, $delim = '_')
	{
		$str = strtolower($str);
		$str = str_replace($delim, ' ', $str);
		$str = ucwords($str);
		$str = str_replace(' ', '', $str);
		return $str;
	}

}
```

これを addBehavior して完成です！

```php
use Phalcon\Mvc\Model\Behavior\Timestampable;

class BlogEntry extends \Phalcon\Mvc\Model {

	public $created_at;
	public $updated_at;
	public $body;
	public $metadata;

	public function initialize()
	{
		$this->addBehavior(new Timestampable(
			array(
				'beforeCreate' => array(
					'field' => array(
						'created_at',
						'updated_at'
					),
					'format' => 'Y-m-d'
				),
				'beforeUpdate' => array(
					'field' => 'updated_at',
					'format' => 'Y-m-d'
				),
			)
		));
		// 圧縮したいフィールド名を配列で渡す
		$this->addBehavior(new GzCompressBehavior(array(
			'body', 'metadata'
		)));
	}

}
```

これでもし振る舞いに不具合があった場合、対象のビヘイビアを修正すればそれを利用しているすべての Model に反映されます。それにパッと見ただけで Model の振る舞いと意図が読めるようになりました。やはりコードは目的ごとに書くべきです。