2011年11月28日月曜日

IEでscrollTopプロパティが効かない場合がある

今日はJavaScriptのお話。

うちの会社で作ってる業務システムで奇妙な動きをしてる所があって、そこの部分の解決策の調査を依頼されて、最終的に解決したので忘れないうちに記録に残しとく。


1. 問題部分

次のようなコードがあったとします。

<div id="Scroller" style="overflow:auto;width:100px;height:100px;">
ああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ
</div>

この時、高さ100幅100のブロック要素に対して縦のスクロールバーが表示されます。今回の場合、もともとやりたかったことは、スクロールバーが表示されているブロック要素に対して、表示時にスクロールバーを一番下まで持っていきたいということでした。

で、もともとのソースで、どうやってたかというと

$("#Scroller").get(0).scrollTop = 100000000;

ってやってた(※jQueryを使用しています)。なるほど、スクロールバーの位置にありえんくらい大きい値をセットして一番したまで移動させているわけです。通常であればこれで問題なく動作していました。でも、ここでIEの拡大レベル(画面右下にちょこっと出てるでしょ?)を変えるとなぜか動かなくなった。


しかも、ある倍率では動くけど、ある倍率では動かない、という奇妙な現象だった。



2. 解決

たとえば125%の時などは、うまく動いてなかった。IEのデバッガ(開発者ツール。これ使いにくいよね・・・)で該当部分をステップ実行すると、どうもscrollTopに0が入っているようだ。拡大レベルが100%の時はブロック要素内のスクロールバー位置の最大値がきちんと入ってたのに、拡大レベルが125%の時は0が入ってる。

ここでちょっと閃いたヽ(゚∀゚)ノ パッ☆

代入してる数字が大きすぎるっちゃない?!


と思って、試しに2桁減らしてみたらなんと動いた

$("#Scroller").get(0).scrollTop = 1000000;

ああ、なんて簡単な・・・。実際に、そのブロック要素が最大でどれくらいの高さになるのかをきちんと見積もってみたら10000(いちまん)もあれば十分だった。かなり余裕をもってその10倍にしたとしても100000(じゅうまん)だ。もともと指定されてた数値は100000000(いちおく)。なんという欲張り。

ということで、代入する数値を小さくしたら解決しました。実際にどれくらい大きな値だとダメなのか、とかの検証はしてないので正確な境界値はわかりません。

2011年11月16日水曜日

IEにおけるXMLHttpRequestの不具合

ExtJSを使っているとAjax通信を多用しますよね。

さて今日はIEでのAjax通信のお話。

とあるシステムでExtJS(バージョン3.3)のExt.Ajax.requestを使ってPOSTしている部分があるけど、なぜかたまーにコールバックに返ってこない場合がありました。しかもIEでばっかり起こる。パケットキャプチャしてみると、サーバー側にPOSTデータが送られてない。5分後にサーバー側でタイムアウトして、処理を中断してました。

  • IEはデータを送ったつもり
  • でもサーバーには届いていない
  • サーバーは5分間じっと待つけど、もう無理!って諦める
  • IEはサーバーからの返答をじっと待つ

サーバーとIEがすれ違いなんですね。気持ちが届かないって、はがゆいよね。

で、この現象、結論から言うと、IEのバグじゃないの?ってことです。英語ですが、こんな投稿をみつけました。

XMLHttpRequest POST sometimes fails when server is using keep-aliv


ざっくり翻訳しました。ところどころ意味が通りやすいように意訳してる箇所もありますが、意味不明なところがあれば原文を参照してください。。。


サーバーがkeep-aliveを設定している時、XMLHttpRequestのPOSTが時々失敗する


2004年、IE6において、サーバーがコネクションをリセットした場合、POSTリクエストのbodyが失われてしまうというバグがありました(http://support.microsoft.com/kb/831167/en-us)。同じバグがどうやら(少なくとも)IE6,7,8のXMLHttpRequestにも存在しているようです。

この問題は、keep-aliveタイムアウトに短い時間(たとえば10秒とか)を設定しているサーバーに対してXMLHttpRequestをPOSTすることによって簡単に再現できます。もし、POSTリクエストがkeep-aliveが切れる直前に作られていたとして、そこにわずかなネットワークの遅延があったとすれば、Webサーバーはコネクションをリセットして、IEにリクエストを再送することを強要します。この時、IEはContent-Lengthヘッダーは送り直すのですが、bodyを送信するのを"忘れ"ます。結果として、サーバーではタイムアウトが発生するまで、bodyが到着するのを待つことになります。

"keep-aliveの直前"と"わずかなネットワーク遅延"というと、このバグの再現が難しそうに思えますが、モダンなWebアプリを例えばモバイル回線などを使って利用しているとそう難しくありません。

この問題のデモをhttp://artur.virtuallypreinstalled.com/apache2-default/ie-xhr.phpに置いています(訳注:現在はリンク切れのようです)。keep-aliveタイムアウトを15秒に設定しているので、14~15秒待って、ボタンをクリックしてください。レスポンスがきちんと返ってきたらアラートがポップアップされ、ボタンが再度有効になるはずです。少なくとも私がモバイル回線を使ってやると、IE8で2~3回で問題が再現します。

サーバーのkeep-aliveをオフにするのが唯一の解決策だと思われます(もしくは、keep-aliveタイムアウトを60秒以上に設定することでも解決するかもしれません)。他に誰かこのようなことが起こっていませんか?他の解決策はありませんか?過去にこの問題について報告されたり議論されたりしていませんか?



ということらしいです。この投稿のレスで「私もその現象起こったよ!」って言ってる人もいます。実際、うちのサーバーのkeep-aliveは5秒になってました。ためしにkeep-aliveを切ったら(offにした)、システムのレスポンスが著しく低下してしまって、結局切り戻し。今はkeep-aliveの時間を伸ばして様子見。それでもまだ現象は発生してるけど、回数は少なくなったような・・・。

これが、本当にIEのバグならはやく直って欲しいが・・。

プログラムではどうしようもないような、こういう問題はやっかいだなぁ(・。・;

2011年11月5日土曜日

CakePHPでデータベースセッションを使った時の怪現象

CakePHP1.3.5でのお話。

CakePHPを配置するWebサーバーを複数台でロードバランスしている環境でセッションを使おうと思ったら、ファイル以外にセッションを保存しないといけません。そういった理由で、セッションをデータベースに保存しているシステムがあります。このシステムでデータベースに保存されているセッションが突然全消しされる怪現象が何度か起こってて、なんじゃこりゃ(´・ω・`)ショボーンな感じで、原因究明に3日ぐらいかかった。忘れないうちにその全貌を書き残しておこう。

1. 問題部分

CakePHPでデータベースにセッションを保存している場合、有効期限が切れた時、PHPのsession_destroy関数が呼ばれて、それをトリガーにcake/libs/cake_session.phpの__destroyメソッドが呼ばれます。
function __destroy($id) {
    $model =& ClassRegistry::getObject('Session');
    $return = $model->delete($id);

    return $return;
}

ここで見てわかるようにセッションIDをキーとしてデータベースからセッション情報を削除する処理が流れます。さて、このモデルのdeleteメソッドを詳しく見てみると

X. cake/libs/model/model.php deleteメソッド
function delete($id = null, $cascade = true) {
    if (!empty($id)) {
        $this->id = $id;
    }
    $id = $this->id;

    if ($this->beforeDelete($cascade)) {
        $filters = $this->Behaviors->trigger($this, 'beforeDelete', array($cascade), array(
            'break' => true, 'breakOn' => false
        ));
        if (!$filters || !$this->exists()) {
            return false;
        }
        $db =& ConnectionManager::getDataSource($this->useDbConfig);

        $this->_deleteDependent($id, $cascade);
        $this->_deleteLinks($id);
        $this->id = $id;

        if (!empty($this->belongsTo)) {
            $keys = $this->find('first', array(
                'fields' => $this->__collectForeignKeys(),
                'conditions' => array($this->alias . '.' . $this->primaryKey => $id)
            ));
        }

        if ($db->delete($this)) {
            if (!empty($this->belongsTo)) {
                $this->updateCounterCache($keys[$this->alias]);
            }
            $this->Behaviors->trigger($this, 'afterDelete');
            $this->afterDelete();
            $this->_clearCache();
            $this->id = false;
            return true;
        }
    }
    return false;
}

Y. cake/libs/model/datasources/dbo_source.php deleteメソッド
function delete(&$model, $conditions = null) {
    $alias = $joins = null;
    $table = $this->fullTableName($model);
    $conditions = $this->_matchRecords($model, $conditions);

    if ($conditions === false) {
        return false;
    }

    if ($this->execute($this->renderStatement('delete', compact('alias', 'table', 'joins', 'conditions'))) === false) {
        $model->onError();
        return false;
    }
    return true;
}

処理の流れのポイントとしてX11行目でIDが存在するかどうかをチェックして、存在しなければ処理を終了しています。IDが存在すればX27行目でデータソースのdeleteメソッドが呼ばれます。

Y4行目の_matchRecordsメソッドはWHERE句を文字列で返してくれる関数なのですが、中身を辿っていくともう一度モデルのexistsメソッドが呼ばれています。ここでIDが存在すれば

A. WHERE Session.id = 'xxxxx' AND 1 = 1

という文字列が返されます。もしIDが存在しなければ

B. WHERE 1 = 1

という文字列が返されます。

BのWHERE句はなんとも危険な香り(´∀`;) ま、通常はX11行目でIDの存在をチェックして、存在する場合しかYには入ってこないんだけど・・・さて、ここまで来たらなんとなくわかってきた気がします。


2. 非同期通信の怖さ

まず今回のシステムではUIにExtJSというJavaScriptのフレームワークを使っていました。ExtJSからバックエンドのCakePHPにいろんなデータを投げて処理するという基本的な流れです。さてこのExtJS、これを使って開発していると、どうしてもAjax通信を多用することになります。

今回は画面を開いた時に、連続してAjax通信するようにコーディングされている部分がありました。Ajaxは非同期通信なので、連続でAjax通信されている部分があるとすると、前の通信の完了をまたずに次の通信を開始します。これが諸悪の根源でした。

さて、今ここにセッションの有効期限が切れたユーザーがいるとします。このユーザーが画面を開いたとするとAjax通信が非同期に2つ同時に流れます。その2つの通信それぞれについて、セッションの有効期限が切れているためcake/libs/cake_session.phpの__destroyメソッドからモデルのdeleteメソッドが呼ばれます。

それぞれ通信Aと通信Bとしましょう。AとBはそれぞれ__destroyメソッドからdeleteメソッドを呼ばれ、X11行目のID存在チェックを通りぬけYに入ります。たまたまタイミングの問題でAが先にY4行目にさしかかり、WEHERE句を取得してそのままY10行目のSQL実行まで行ったとします。次にBがY4行目にさしかかり、WHERE句を取得して・・・・と、ちょっとここでストップ。そう、Aが先にデータベースからIDを削除しています。すると、BがY4行目を実行してる時点では既にIDは消えてなくなっていることに!

ここまで来るともうわかります。IDが無いのでWHERE 1 = 1という危険なSQLが返ってきて、それを実行しちゃうのでセッションが全消しされます。


3. コーディングルール

そのシステムではAjax通信をする際は、前の通信が終わってから(コールバックなどを利用して)次の通信を始めるというような基本的なルールがあったのですが、今回問題が起こった部分はそれに沿っていない箇所でした。もし、そのルールにのっとっていれば非同期の同時アクセスもなくこんな問題は起きなかった。開発者がJavaScriptについてあまり知らないらしいので、コールバックを使った同期通信の手法を知らなかったのかもしれません。



と、まぁ、今回はタイミングの問題もあるので、ほとんど起こらないような事象だけど、こういうこともあるんだよ、っていうことで誰かが読んでくれればいいな。

ところで、根本的にこれを解決するにはコアをいじらなきゃいけないよな。たぶん。どこをどう変えたらいいんだろう。


2011-11-07追記
CakePHP1.3.6からはモデルのdeleteメソッドが微修正されているようです。たぶんもう起きません。