バグから見る Phalcon の DI と Dispatcher の動き

空いてたので書きました．

# 内容

## ログインしてなかったら ログインページにフォワードする

ログインしてなかったら ログインページにフォワードするよくある機能を考えます．
適当に下記のようなコードを実装したとしましょう．

```php:AdminController.php
<?php

use \Phalcon\Mvc\Controller;

class AdminController extends Controller
{
    public function beforeExecuteRoute()
    {
        if ($this->dispatcher->getActionName() !== 'login' && !$this->session->get('is_auth')) {
            $this->dispatcher->forward([
                'controller' => 'admin',
                'action' => 'login',
            ]);
            return false;
        }
    }

    public function initialize()
    {
        $this->view->role = $this->session->get('is_auth') ? 'admin' : 'guest';
    }

    public function homeAction()
    {
    }

    public function loginAction()
    {
    }
} 
```

```twig:login.volt
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    {{ getTitle() }}
</head>
<body>
   Your are {{ role }} !!! 
</body>
</html>

```

かいつまんで説明すると 「ログインしてなければログインページにフォワードする」というようなコードです．
ログインページでは 「role」が表示されるのでログインしていれば「admin」していなければ「guest」と表示されるはずです．

## initialize() が呼ばれない？

さてここで http://localhost/admin/home にアクセスしてみます．
なにもログインとかせずにアクセスするので admin ではないでしょうし 「You you guest !!!」と表示されると思うでしょう．

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Admin Page</title>
</head>
<body>
   Your are  !!!
</body>
</html>
```

guest どころか 何も表示されない！！
どうしてこうなった！！

### ログを吐かせてホントに initialize() が呼ばれてないか確認

元のコードであれば initialize() が実行されていれば role がセットされているはずです．
ということは initialize() がなぜか実行されないような気もします？
ログを挟んでみましょう．

```php:AdminController.php
<?php

use \Phalcon\Mvc\Controller;

class AdminController extends Controller
{
    public function beforeExecuteRoute()
    {
        $this->logger->info('admin:beforeExecuteRoute');
        if ($this->dispatcher->getActionName() !== 'login' && !$this->session->get('is_auth')) {
            $this->logger->info('forward!!!');
            $this->dispatcher->forward([
                'controller' => 'admin',
                'action' => 'login',
            ]);
        }
    }

    public function initialize()
    {
        $this->view->role = $this->session->get('is_auth') ? 'admin' : 'guest';

        $this->logger->info('admin:initialize');
    }

    public function homeAction()
    {
        $this->logger->info('admin:home');
    }

    public function loginAction()
    {
        $this->logger->info('admin:login');
    }
} 
```

ログはどうなるか．

```txt:log
admin:beforeExecuteRoute
forward!!!
admin:beforeExecuteRoute
admin:login
```

admin:initialize がないですね．

##なにが起きてるのか

どういう動作になってるか詳しく見て行きましょう．

### Phalcon の DI は 生成されたり Singleton になったり

その説明の前に一旦 Phalcon の DI の特性について書いておきます．
Phalcon の DI は shared にしてるかどうかで返ってくるインスタンスが新規か使い回しか変わります．

> Dependency Injection/Service Location — Phalcon 1.3.1 ドキュメント
> http://docs.phalconphp.com/ja/latest/reference/di.html#id11
>> Services can be registered as “shared” services this means that they always will act as singletons. Once the service is resolved for the first time the same instance of it is returned every time a consumer retrieve the service from the container:

つまり `$di->get('hoge')` としてる場合は 毎回異なるインスタンスが返ってきて， `$di->getShared('hoge')` とすると毎回同じインスタンスが返ってきます． （`$di->set()` 時に shared としてるかどうかにもよるけど）

そのインスタンスが生成されたか既存のインスタンスを返しているかどうかについては `$di->getShared()` を呼び出した直後に `$di->wasFreshInstance()` を呼び出すことでわかります．（ [ドキュメント](http://docs.phalconphp.com/ja/latest/api/Phalcon_DI.html)


### Dispatcher で何が行われているか

#### Dispatcher の dispatch() の概要

さて話を戻すと initialize() が呼ばれそうなんだけど呼ばれてないという感じです．
Dispatcher の dispatch() では下記のようなイベントやメソッドが呼び出されます．

> Phalcon アクション周りのイベントとかメモ - Qiita
> http://qiita.com/nise_nabe/items/87aac65fffc192c1b0f1

※１ 記事には書いてないけど種類はテキトーに自分が勝手に呼び名つけてるだけです．
※２ 関係ないけど  dispatch:afterInitialize イベント抜けてた．

| 種類   | 名前とか                         | 実行回数とか |
|:-------|:---------------------------------|:---------|
| Event  | dispatch:beforeDispatch          | ディスパッチループが回るごとに一回でアクションがなくても実行  |
| Event  | dispatch:beforeExecuteRoute      | ディスパッチループが回るごとに一回でアクションがあったら実行 |
| Method | Controller#beforeExecuteRoute()  | ディスパッチループが回るごとに一回でコントローラで定義できる前処理 |
| Method | Controller#initialize()          | 最初に対象コントローラがインスタンス化されたときだけ |
| Method | Controller#xxxAction()           | <- メイン |

これを見ると beforeExecuteRoute のあとでアクションが実行されるまえぐらいには initialize() が実行されるように見えます．

#### Dispatcher の dispatch() のコード

もうちょっと詳しく見るために Dispatcher のコードを追ってみます．

https://github.com/phalcon/cphalcon/blob/phalcon-v1.3.4/ext/dispatcher.c#L568

要点を絞るために擬似コードを書くと下記のようになります．

```擬似コード
（Dispatcher 内）
function dispatch()
{
  fireEvent("dispatch:beforeDispatchLoop")
  while(1) {
    $this->_finish = true;
    $handlerName = $this->getHandlerName()
    $actionName = $this->getActionName()

    if (fireEvent("dispatch:beforeDispatch") === false) continue;
    if ($this->_finish === false) continue;

    $handler = $this->getShared($handlerName)
    $wasFresh = $this->wasFreshInstance();

    if (method_exist($handler, $actionName) {
      if (fireEvent("dispatch:beforeNotFoundAction") === false) continue;
      throw new Phalcon\Dispatcher\Exception( "Action '", action_name, "' was not found on handler '", EXCEPTION_ACTION_NOT_FOUND);
    }

  
    if (fireEvent("dispatch:beforeExecuteRoute") === false) continue;
    if ($this->_finish === false) continue;
  
    if ($handler->beforeExecuteRoute() === false) continue;
    if ($this->_finish === false) continue;
  
    if ($wasFresh === true) {
      $handler->initialize();
      if (fireEvent("dispatch:afterInitialize") === false) continue;
      if ($this->_finish === false) continue;
    }
  
    $handler->$actionName();
  }
}

function forward($forward)
{
  $this->setHandlerName($forward['controller']);
  $this->setActionName($forward['action']);
  $this->_finished = false;
}
```

Phalcon の Dispatcher では forward() とはこのディスパッチループをもう一回回すことに相当します．

#### wasFresh でないと initialize() が呼ばれない

dispatch() のコードを見るとコントローラは DI によって Singleton として取得されていることがわかります．
そして forward() がよばれている場合は _finished の値が false となるので while ループが continue になります．
その上で initialize() がどこで呼ばれているか．wasFresh が true の場合ですね．そしてこれは beforeExecuteRoute() のあとです．

では最初のコードはどうなっていたでしょうか

```php:AdminController.php
<?php

use \Phalcon\Mvc\Controller;

class AdminController extends Controller
{
    public function beforeExecuteRoute()
    {
        if ($this->dispatcher->getActionName() !== 'login' && !$this->session->get('is_auth')) {
            $this->dispatcher->forward([
                'controller' => 'admin',
                'action' => 'login',
            ]);
            return false;
        }
    }

    public function initialize()
    {
        $this->view->role = $this->session->get('is_auth') ? 'admin' : 'guest';
    }

    public function homeAction()
    {
    }

    public function loginAction()
    {
    }
} 
```

1. getShared() でコントローラのインスタンスが生成される（ここでは wasFresh は true）
2. beforeExecuteRoute() で forward() しているためディスパッチループの最初に戻る
3. getShared() でコントローラのインスタンスが取得される（ここでは wasFresh は false）
4. wasFresh は false となるため initialize() はスルー
5. アクションメソッドが呼ばれる

となります．

なるほど initialize() が呼ばれないですね．これはバグなのかよくわからないですけど他のコントローラに forward した場合は initialize() 呼ばれるのに同じコントローラ
に fowrward() すると呼ばれないのはちょっと意味がわからない．

###  対処どうすれば？

initialize() はたぶん生成されて一回だけ呼ばれる想定のはずなのでじゃあ 代わりにコンストラクタに書いてしまえばいいのでは？？？

http://docs.phalconphp.com/ja/latest/api/Phalcon_Mvc_Controller.html#methods

> final public __construct ()

final 指定なんですね．オーバーライドできません．ダメでした．

わりとどうしようもないので 自分で `$isFresh` 作って対応するとか複数回呼ばれても大丈夫なようなコードにして beforeExexuteRoute() に入れるとかするしかないですかね．

## 404 ページを実装した時にも起きそう

ここでまだ dispatch() のコードをみると， getShared() で呼び出した後から initialize() が呼ばれるまでの間に continue が行われる場合もあります．
コード部分を確認すると下記部分も該当します．

https://github.com/phalcon/cphalcon/blob/phalcon-v1.3.4/ext/dispatcher.c#L792

```擬似コード
    if (method_exist($handler, $actionName) {
      if (fireEvent("dispatch:beforeNotFoundAction") === false) continue;
      throw new Phalcon\Dispatcher\Exception( "Action '", action_name, "' was not found on handler '", EXCEPTION_ACTION_NOT_FOUND);
    }
```

擬似コードでは throw と表してしてますが本当は Phalcon\Dispatcher#_throwdispatchexception() が呼ばれて返り値が false の場合 continue してます．

あり得るパターンは例えば ErrorController エラーページを集めたコントローラを作った場合に存在しないアクションのページを開こうとして例外を投げるパターンです．
「アクションがなかったらエラーページに飛ばす」という感じですね．

[先日の記事](http://qiita.com/nise_nabe/items/6907792756db2c1b7eb5) にも書いた 404 ページへの遷移 error アクションに飛んでますね．
この EXCEPTION_ACTION_NOT_FOUND が error/hoge のような適当なページにアクセスした時にも投げられます．
すると ErrorController の initialize() は実行されません．

> php - How to setup a 404 page in Phalcon - Stack Overflow
> https://stackoverflow.com/questions/14071261/how-to-setup-a-404-page-in-phalcon

```
       $evManager->attach(
            "dispatch:beforeException",
            function($event, $dispatcher, $exception)
            {
                switch ($exception->getCode()) {
                    case PhDispatcher::EXCEPTION_HANDLER_NOT_FOUND:
                    case PhDispatcher::EXCEPTION_ACTION_NOT_FOUND:
                        $dispatcher->forward(
                            array(
                                'controller' => 'error',
                                'action'     => 'show404',
                            )
                        );
                        return false;
                }
            }
        );
```

うーん．どうするか．

## 既知のバグか

下記二つのチケットがあります．

> Dispatcher forward doesn't work from initialize in Controller (1.2.3) · Issue #1431 · phalcon/cphalcon
> https://github.com/phalcon/cphalcon/issues/1431

２つ目

> [BUG] Controller method beforeExecuteRoute and Initialize problem · Issue #2413 · phalcon/cphalcon
> https://github.com/phalcon/cphalcon/issues/2413

まあ似たような問題が報告されてはいますが解決はしてないようです．
まあ諦めましょう．

# おわりに

ここでは Phalcon の DI と Dipsatcher の動きが組み合わさって発生したバグについて書いてみました．
こういう取っ掛かりを得て中身を読み込むのはいいんじゃないでしょうか！

2.0 で同じバグが有るかどうかは確認してないです．
自分はコントリビュートする気あんまりないですが，だれかいい感じにできるなら直したり報告してみたらいいんじゃないかな！ 
以上です．