CakePHPでデバッグレベルが0の時だけ発生するエラー

CakePHP Advent Calendar 2010 に参加しています。
昨日の mon_sat さんからバトンを受け取ってこの記事が11日目。

さてタイトルのとおり
CakePHP で Debug Level を 0 にしてるときだけエラーが発生する
というよくわからないことが起きました。

結局は単なる実装ミスだったんだけど、
忘れないように書いておきます。

何が悩ましいのか

CakePHP では

Configure::write('debug', 0);

としておくと本番モードになって
エラーが出力されなくなりますね。
デフォルトで app/configure/core.php に書いてあるやつ。

だいたい開発中はデバッグレベルを1か2にするわけですけど、
その間は何の問題もなく動いてたのに
0にしたらいきなりエラーが出るという
わけのわからない不具合が発生しました。
逆ならわかるけど。

どんな現象が起きたのか

Twitter API を使うサービスをつついてて
OAuth 認証するために “Sign in with Twitter” ボタンを押す場面で
開発モードなら何事もなく動くのに
本番モードだとエラーが出た。

発生箇所

Twitter 関連の処理をまとめた
TwitterComponent というのを自前で使ってるんだけど
この認証機能まわりで発生してた。

認証ボタンを押すと
UsersController のこういうのが呼び出されて

public function login() {
	$this->Twitter->auth();
	return;
}

TwitterComponent のここへ飛ぶ。

public function auth() {
	header('Location: ' . $this->getAuthUrl());
	return;
}

getAuthUrl() というのは長いので割愛しますけど
Twitter API と通信して
OAuth のための転送先 URL を取ってくるパブリックメソッド。

コントローラーからは指令を出すだけにしたかったので
実際に転送をかけるところはコンポーネントにやらせてます。

何が原因だったのか

TwitterComponent で header を出力したあと
呼び出し元の UsersController::login() に return してるけど
ここは Twitter に転送するためだけにあるものなので
表示するべきものは何もない。
ビューファイルがありません。

autoRender はデフォルトのまま true だったので
ありもしないビューを描画しようとしてエラー発生。

こんな簡単な話なのになぜ気づかなかったかというと、
開発モード中はそうならなかったからです。

本番モードのときだけ発生する理由

エラー発生までの流れを順を追って見てみると、
まず Dispatcher::_invoke() で autoRender がチェックされて

class Dispatcher extends Object {
	//略
	function _invoke(&$controller, $params) {
		//略
		if ($controller->autoRender) {
			$controller->output = $controller->render();
		} elseif (empty($controller->output)) {
			$controller->output = $output;
		}

true なら Controller::render() から出力内容を取ってこようとする。

class Controller extends Object {
	//略
	function render($action = null, $layout = null, $file = null) {
		//略
		$this->autoRender = false;
		$this->output .= $View->render($action, $layout, $file);

		return $this->output;
	}

ここから View::render() が呼ばれる。
このとき $action が明示的に false になっていなければ

class View extends Object {
	//略
	function render($action = null, $layout = null, $file = null) {
		//略
		if ($action !== false && $viewFileName = $this->_getViewFileName($action)) {
			$out = $this->_render($viewFileName, $this->viewVars);
		}

_getViewFileName() からビューのファイル名をもらう。
ここで今回のように適切なファイルが名 return できなかったら
最終行まで行って

	function _getViewFileName($name = null) {
		//略
		return $this->_missingView($defaultPath . $name . $this->ext, 'missingView');
	}

_missingView() へ。エラー出力っぽくなってきた。

_missingView() から cakeError() が呼ばれてる。

	function _missingView($file, $error = 'missingView') {
		if ($error === 'missingView') {
			$this->cakeError('missingView', array(
				'className' => $this->name,
				'action' => $this->action,
				'file' => $file,
				'base' => $this->base
			));
			return false;
		} elseif ($error === 'missingLayout') {
			$this->cakeError('missingLayout', array(
				'layout' => $this->layout,
				'file' => $file,
				'base' => $this->base
			));
			return false;
		}
	}

これはすべての源 Object クラスにあるやつですね。

ここで AppError が定義されていなければ

class Object {
	//略
	function cakeError($method, $messages = array()) {
		if (!class_exists('ErrorHandler')) {
			App::import('Core', 'Error');

			if (file_exists(APP . 'error.php')) {
				include_once (APP . 'error.php');
			} elseif (file_exists(APP . 'app_error.php')) {
				include_once (APP . 'app_error.php');
			}
		}

		if (class_exists('AppError')) {
			$error = new AppError($method, $messages);
		} else {
			$error = new ErrorHandler($method, $messages);
		}
		return $error;
	}

ErrorHandler を生成。

ErrorHandler のコンストラクタで

class ErrorHandler extends Object {
	//略
	function __construct($method, $messages) {
		//略
		if ($method !== 'error') {
			if (Configure::read('debug') == 0) {
				$parentClass = get_parent_class($this);
				if (strtolower($parentClass) != 'errorhandler') {
					$method = 'error404';
				}
				$parentMethods = array_map('strtolower', get_class_methods($parentClass));
				if (in_array(strtolower($method), $parentMethods)) {
					$method = 'error404';
				}
				if (isset($messages[0 gutter="false"]['code' gutter="false"]) && $messages[0 gutter="false"]['code' gutter="false"] == 500) {
					$method = 'error500';
				}
			}
		}
		$this->dispatchMethod($method, $messages);
		$this->_stop();
	}
  • デバッグレベルが 0 なら ‘error404’
  • 0 以外なら _missingView() → cakeError() と引き継がれてきた ‘missingView’

が dispatchMethod() の第1引数 $method に渡される。
dispatchMethod() が定義されてるのも Object ですか。

	function dispatchMethod($method, $params = array()) {
		switch (count($params)) {
			case 0:
				return $this->{$method}();
			case 1:
				return $this->{$method}($params[0 gutter="false"]);
			case 2:
				return $this->{$method}($params[0 gutter="false"], $params[1 gutter="false"]);
			case 3:
				return $this->{$method}($params[0 gutter="false"], $params[1 gutter="false"], $params[2 gutter="false"]);
			case 4:
				return $this->{$method}($params[0 gutter="false"], $params[1 gutter="false"], $params[2 gutter="false"], $params[3 gutter="false"]);
			case 5:
				return $this->{$method}($params[0 gutter="false"], $params[1 gutter="false"], $params[2 gutter="false"], $params[3 gutter="false"], $params[4 gutter="false"]);
			default:
				return call_user_func_array(array(&$this, $method), $params);
			break;
		}
	}

ここはその名のとおり
$method で与えられた名前のメソッドをディスパッチしてるので

  • デバッグレベルが 0 なら ErrorHandler::error404()
  • 0 以外 なら ErrorHandler::missingView()

が呼ばれる。

missingView() の方は

	function missingView($params) {
		extract($params, EXTR_OVERWRITE);

		$this->controller->set(array(
			'controller' => $className,
			'action' => $action,
			'file' => $file,
			'title' => __('Missing View', true)
		));
		$this->_outputMessage('missingView');
	}

コントローラーに各種値をセットして
メッセージ出力を呼んでるだけなのに対して、
error404() では

	function error404($params) {
		extract($params, EXTR_OVERWRITE);

		if (!isset($url)) {
			$url = $this->controller->here;
		}
		$url = Router::normalize($url);
		$this->controller->header("HTTP/1.0 404 Not Found");
		$this->controller->set(array(
			'code' => '404',
			'name' => __('Not Found', true),
			'message' => h($url),
			'base' => $this->controller->base
		));
		$this->_outputMessage('error404');
	}

ヘッダに “HTTP/1.0 404 Not Found” を吐いてる。
まあ404だから当たり前だけど。

さて元の話に戻ると、
そもそも TwitterComponent で

public function auth() {
	header('Location: ' . $this->getAuthUrl());
	return;
}

ということをやろうとしていたわけで、
この後に “HTTP/1.0 404 Not Found” が吐き出されたら
当然そのまま404で終了。

デバッグレベル1や2の開発モードの場合
エラー処理はされるけど header が書き換えられないから
そのまま Twitter に転送されて
先ほどのエラーは外見上なかったことになってた。

解決方法

いくつかあると思います。

  1. autoRender を false にする

    Dispatcher::_invoke() の時点で autoRender が true になってるところからがスタートなので、これを false にすれば問題は起きない。

    これがスマートかもしれませんね。もともと必要ないところなのに自動で render しようとしていために起きてた問題だし。

    最初の UsersContriller::login() の時点で

    public function login() {
    	$this->autoRender = false;
    	$this->Twitter->auth();
    	return;
    }
    

    とするか、そもそも autoRender が必要ないなら AppController で

    var $autoRender = false;
    

    としてしまうか。

  2. render() に false を渡す

    View::render() のところで第1引数に false が渡っていればビューファイルを探そうとしないのでエラーも起きない。

    public function login() {
    	$this->Twitter->auth();
    	$this->render(false);
    	return;
    }
    

    ただこれやるんなら最初から autoRender を false にしといた方がいいような気もします。

  3. Controller::redirect() を使う

    今回の例ではコンポーネント側で header() を使ってるけど、それをコントローラー側でやってもいいんなら

    public function login() {
    	$this->redirect($this->Twitter->getAuthUrl());
    	return;
    }
    

    でもいいですね。redirect() なら

    	function redirect($url, $status = null, $exit = true) {
    		$this->autoRender = false;
    

    となっているのでヘッダ出力後の描画は強制停止。

    コントローラーでは具体的な処理をせず指示を出すだけにしたかったので今回はこれやってないけど、まあ遷移に関することはコンポーネントよりコントローラかなという気もする。

  4. exit() する

    Location ヘッダを出した時点で exit() してしまう。ヘッダ出力までは必要でそれ以降は一切不要なので、効率が一番いいのはこれかも。

    または CakePHP の Object が _stop() を持ってるので

    	function _stop($status = 0) {
    		exit($status);
    	}
    

    これ使っても同じことか。

他の環境で同じ問題が起きないようにするためと
必要ない処理を発生させないために4番にしました。

もっと賢い方法があるとか
何か根本的に間違ってるとかいうことがあれば教えてください。

まあほんとは

デバッグレベルが0のときだけ発生するんじゃなくて
デバッグレベルが0じゃなかったら気づかないだけなので
本当はタイトルの時点で間違ってるんだけど
そう書いた方が面白いから書いた。

明日は

CakePHP Advent Calendar の担当、明日は ogaaaan さんですね。
プロフィールに「オガーンと呼び捨てにしてくれ」って書いてあるので
では明日よろしくお願いしますオガーン。

  • このエントリーをはてなブックマークに追加

One Response to “CakePHPでデバッグレベルが0の時だけ発生するエラー”

  • 893240

    2011/02/01 16:33

    すばらしい解説ありがとうございます。勉強になりました。