KLab若手エンジニアの これなぁに?

KLab株式会社の若手エンジニアによる技術ブログです。phpやRubyなどの汎用LLやJavaScript/actionScript等のクライアントサイドの内容、MySQL等のデータベース、その他フレームワークまで幅広く面白い情報を発信します。

2008年10月

Symfonyのトランザクション処理時の動作についての調査結果

先輩のamo-kさんから、「Symfonyのトランザクション処理時の動作について調べてみて」と言われたので、Symfonyの勉強がてら調べてみました。 Synfony のログからわかること まず Symfony のログを見てみると、begin -> begin -> category テーブルの update -> commit -> begin -> begin -> bookmark テーブルの update -> commit -> commit という処理の流れになっています。 アクションで明示的に記述したトランザクションとは別に、$category->save では1重、$bookmark->save では2重のトランザクションが走っているように見えます。 モデルクラスの中身を見てみる $bookmark と $category はそれぞれ Symfony のスクリプトによって自動生成されたモデルクラスである BookMark と Category のインスタンスです。 Bookmark と Category はそれぞれ BaseBookmark と BaseCategory を継承しており、実際の更新処理はこれらのベースクラスに記述されています。 save関数の中でもトランザクションが使われています。 BaseBookmark と BaseCategory の save 関数内に以下のような記述があります。
// BaseBookmark::save 関数の一部(BaseBookmark.php)、BaseCategory::save (BaseCategory.php)も同様
$con->begin();
$affectedRows = $this->doSave($con);
$con->commit();
return $affectedRows;
これを見てわかるとおり、 save 関数内でも $con->begin() ~ $con->commit() によって、トランザクション処理を行っています。 doSave 関数の中では、このインスタンスが新規のレコードなのか、レコードの更新なのかを判定し、新規であれば Peer クラスの doInsert を、更新であれば doUpdate を呼び出すようになっています。 以下、該当部分のソースコード。
// BaseBookmark::doSave 関数の一部(BaseBookmark.php)
  if ($this->isNew()) {
    $pk = BookmarkPeer::doInsert($this, $con);
    $affectedRows += 1;
    $this->setId($pk);
    $this->setNew(false);
  } else {
    $affectedRows += CategoryPeer::doUpdate($this, $con);
  }
doInsert 関数と doUpdate 関数は BookmarkPeer の親クラスである BaseBookmarkPeer クラスで定義されていています。 doInsert関数の中では、以下のように $con->begin() でトランザクションを開始したあと、BasePeer クラスの doInsert 関数を呼び出しています。これが、 $bookmark->save() を実行したときの、最も深い begin ~ commit の正体です。
// BaseBookmarkPeer::doInsert 関数の一部(BaseBookmark.php)
  try {
    $con->begin();
    $pk = BasePeer::doInsert($criteria, $con);
    $con->commit();
  } catch(PropelException $e) {
    $con->rollback();
    throw $e;
  }
なお、doUpdate 関数内ではトランザクションをかけていません。アクション内で記述していた $category->save() はすでにあるレコードの更新操作なので doUpdate が実行され、該当部分のトランザクションは2重までになります。 コネクションオブジェクトに対する関数呼び出しのログ出力処理 アプリケーションをデバッグモードで実行しているときは $con は sfDebugConnection というクラスのインスタンスになっていて、各モデルクラスはこのインスタンスを通して、DB操作を行うようになっています。 このクラスはsymfonyで提供されているもので、コネクションオブジェクトに対して実行された処理のログをとっています。例えば、commit 関数なら以下のようになっています。
 // sfDebugConnection.php から抜粋
  public function commit()
  {
    $this->log("{sfCreole} committing transaction.");
    return $this->childConnection->commit();
  }
関数が呼び出されると無条件にログ出力が行われます。そのため、今回のようにコネクションオブジェクトに対して最大で3重のトランザクションをかけている場合は、ログにもそのように出力されるわけです。 その次に $this->childConnection の同名の関数を呼び出すという処理になっています。 $this->childConnection は実際に使われているDBに応じた、Connectionインタフェースを実装したドライバになります。今回はMySQLを使用しているので、MySQLConnectionというクラスになっています。 MySQLConnection は ConnectionCommon というクラスを継承しています。DBの実装に関係なくコネクションで共通の処理が ConnectionCommon に記述されています。 begin関数、commit関数はConnectionCommonで以下のように定義されています。
// sfConnectionCommon.php から抜粋
    public function begin()
    {
        if ($this->transactionOpcount === 0 || $this->supportsNestedTrans()) {
            $this->beginTrans();
        }
        $this->transactionOpcount++;
    }

    public function commit()
    {
        if ($this->transactionOpcount > 0) {
            if ($this->transactionOpcount == 1 || $this->supportsNestedTrans()) {
                $this->commitTrans();
            }
            $this->transactionOpcount--;
        }
    }
begin関数で transactionOpcount を +1 し、commit関数で -1 するようになっています。 ここで supportNestedTrans 関数はDBがトランザクションの入れ子をサポートしている場合にtrueを返します。今回使用しているMySQLはトランザクションの入れ子をサポートしないので、この関数は false を返します。 ConnectionCommon のサブクラスで beginTrans および commitTrans 関数がオーバーライドされ、実際のトランザクション開始~コミット処理を行っています。 この部分の処理で、MySQLのようにトランザクションの入れ子をサポートしていないDBを使用している場合は最も外側のトランザクションのみが有効になることがわかります。 以上の処理をシーケンス図にすると、以下のようになります。


トランザクション処理時のシーケンス(拡大画像へリンク) と、ここまでわかったところで、この記事を発見。まさに知りたかったことが書いてあるじゃないか・・・。 まあ、もう少し詳細にコードを追っているので、本記事も参考になるのではないでしょうか。

Symfonyのトランザクション処理時のログ出力内容を調査

Symfony(phpのフレームワーク)にて気になる部分があったので若手ホープのhonda-hに調査してもらった。 ※データベースはMySQL5、InnoDBを利用。 気になった部分 以下のコードサンプルのように 明示的にトランザクション処理を行ったとする。 Symfonyのログを見ると何故か4回づつbeginやcommitが行われたように sfCreoleのログが出力されている。 これでは、以下のコードサンプルの更新2の部分で エラーとなった場合に更新1も含めてロールバックしてほしいのに 更新1はコミットされたように見える。 しかし、SQLログを見るとサンプルコードで期待する結果となっている。 (begin ⇒ 更新1⇒ 更新2 ⇒ commit) コードサンプル:
    // 実際はもう少し処理をしているがコアな部分以外は割愛する
...
    $bookmark = new Bookmark();
    $bookmark->setTitle($this->getRequestParameter('title'));
    $bookmark->setUrl($this->getRequestParameter('url'));

    if ($this->getRequestParameter('category_id'))
    {
        $bookmark->setCategoryId($this->getRequestParameter('category_id'));
        $category = CategoryPeer::retrieveByPk($this->getRequestParameter('category_id'));
        $category->incrementSize();
    }

    $con = Propel::getConnection();  // DBコネクションオブジェクト取得

    try
    {
        $con->begin();  // トランザクション開始
        if ($this->getRequestParameter('category_id'))
        {
            $this->logMessage('{sfAction} save category.', 'info');
            $category->save($con);  // 更新1
        }
    
        $this->logMessage('{sfAction} save bookmark.', 'info');
        $bookmark->save($con);  // 更新2
    
        $this->logMessage('{sfAction} commit transaction.', 'info');
        $con->commit();  // コミット
    }
    catch (Exception $e)
    {
        $con->rollback();  // ロールバック
...
Symfonyログサンプル
Oct 28 11:29:51 symfony [info] {sfCreole} prepareStatement(): SELECT category.ID, category.TITLE, category.SIZE FROM category WHERE category.ID=?
Oct 28 11:29:51 symfony [info] {sfCreole} executeQuery(): [0.47 ms] SELECT category.ID, category.TITLE, category.SIZE FROM category WHERE category.ID=5
Oct 28 11:29:51 symfony [info] {sfCreole} connect(): DSN: array (   'compat_assoc_lower' => NULL,   'compat_rtrim_string' => NULL,   'database' => 'criteria_test',   'encoding' => 'cp932',   'hostspec' => 'localhost',   'password' => 'criteria_test',   'persistent' => NULL,   'phptype' => 'mysql',   'port' => NULL,   'protocol' => NULL,   'socket' => NULL,   'username' => 'criteria_test', ), FLAGS: 0
Oct 28 11:29:51 symfony [info] {sfCreole} beginning transaction.
Oct 28 11:29:51 symfony [info] {sfAction} save category.
Oct 28 11:29:51 symfony [info] {sfCreole} beginning transaction.
Oct 28 11:29:51 symfony [info] {sfCreole} prepareStatement(): UPDATE category SET SIZE = ? WHERE category.ID=?
Oct 28 11:29:51 symfony [info] {sfCreole} executeUpdate(): UPDATE category SET SIZE = 5 WHERE category.ID=5
Oct 28 11:29:51 symfony [info] {sfCreole} committing transaction.
Oct 28 11:29:51 symfony [info] {sfAction} save bookmark.
Oct 28 11:29:51 symfony [info] {sfCreole} beginning transaction.
Oct 28 11:29:51 symfony [info] {sfCreole} beginning transaction.
Oct 28 11:29:51 symfony [info] {sfCreole} prepareStatement(): INSERT INTO bookmark (CATEGORY_ID,TITLE,URL,CREATED_AT,UPDATED_AT) VALUES (?,?,?,?,?)
Oct 28 11:29:51 symfony [info] {sfCreole} executeUpdate(): INSERT INTO bookmark (CATEGORY_ID,TITLE,URL,CREATED_AT,UPDATED_AT) VALUES (5,'koreha hidoi','http://korea.hidoi/','2008-10-28 11:29:51','2008-10-28 11:29:51')
Oct 28 11:29:51 symfony [info] {sfCreole} committing transaction.
Oct 28 11:29:51 symfony [info] {sfCreole} committing transaction.
Oct 28 11:29:51 symfony [info] {sfAction} commit transaction.
Oct 28 11:29:51 symfony [info] {sfCreole} committing transaction.
SQLログサンプル:
# at 3946
#081028 11:29:51 server id 1  end_log_pos 4023 	Query	thread_id=47	exec_time=0	error_code=0
SET TIMESTAMP=1225160991/*!*/;
BEGIN
/*!*/;
# at 4023
#081028 11:29:51 server id 1  end_log_pos 4143 	Query	thread_id=47	exec_time=0	error_code=0
SET TIMESTAMP=1225160991/*!*/;
UPDATE category SET SIZE = 5 WHERE category.ID=5
/*!*/;
# at 4143
#081028 11:29:51 server id 1  end_log_pos 4171 	Intvar
SET INSERT_ID=40/*!*/;
# at 4171
#081028 11:29:51 server id 1  end_log_pos 4401 	Query	thread_id=47	exec_time=0	error_code=0
SET TIMESTAMP=1225160991/*!*/;
INSERT INTO bookmark (CATEGORY_ID,TITLE,URL,CREATED_AT,UPDATED_AT) VALUES (5,'koreha hidoi','http://koreha.hidoi/','2008-10-28 11:29:51','2008-10-28 11:29:51')
/*!*/;
# at 4401
#081028 11:29:51 server id 1  end_log_pos 4428 	Xid = 334
COMMIT/*!*/;
DELIMITER ;
ということで、honda-hの調査結果を次に書きます。 ではhonnda-h、あとはよろしく~ 調査結果

SMTPコマンドを打ってみよう

takada-atです。こんにちは。 amo-k先輩に「自分SMTPコマンドなんて打ったことないッス」と言うと、「おまえも打てよ、な?」と、まるで後輩に煙草を薦める不良の先輩のような調子で、SMTPコマンドを打つようにすごまれました。 というわけで今日は、telnetとSMTPコマンドを使い、メーラーになった気持ちでメールを送信してみます。 SMTPといっても何だかわからないという方もおられるでしょうが、SMTPは「Simple Mail Transfer Protocol(単純なメール転送プロトコロル)」の略であり、メール送信のために定められた手続きのことです。要するに「この決まりを守っていればメールを送受信できるよー」というきまりのことです。 どんなメーラーもメールサーバーも基本的には、SMTPに従った動作を実装しています。 Wikipediaの記事にリンクをはっておきます。 -Simple Mail Transfer Protocol - Wikipedia 最新のSMTPプロトコルは、RFC 5321で標準化されています。 わたしも全部読んだことはないのですが、以下にリンクを載せておきます。RFC5321の日本語訳は見つけられませんでしたが、旧版のRFC 821には複数の日本語訳があるようです。 -RFC 5321 - Simple Mail Transfer Protocol -RFC日本語版リスト SMTPでメールを送信するには、メールサーバーにtelnetでアクセスし、SMTPコマンドを送っていけばよいようです。 実際にやってみましょう。 同期の honda-h に「ランチに行きませんか」というメールを出してみます。 ↓honda-h


以下入力したコマンドを、C:からはじまる行に、サーバーからの返答をH:からはじまる行に書きます。 まずtelnetコマンドを使いmail.example.comの25番ポートにログインします。 (アドレスはすべて架空のものです)。
# telnet mail.example.com 25
> 220 mail.example.com ESMTP
HELOコマンドを入力し、こちらのサーバー名を伝えます。
C:HELO localhost
H:250 mail.example.com
MAILコマンドを利用し、差し出しアドレスを伝えます。
C:MAIL FROM:takada-at@example.com
H:250 ok
RCPTコマンドを利用し、送り先アドレスを伝えます。
C:RCPT TO:honda-h@example.com
H:250 ok
メールの本文はDATAコマンドを使って送ります。「.」だけで終る行がメッセージの終了を意味します。 今回はマルチバイト文字を使わず、ローマ字で送ってみることにします。
C:DATA
H:354 Please start mail input.
C:
Subject: lunch
From: takada-at@example.com

honda-h san. gohan tabe ni ikimasyou.
.

H:250 Mail queued for delivery.
最後にQUITコマンドを利用し、コネクションを切断します。メールサーバーはちゃんと挨拶ができる子のようです。
C:QUIT
H:221 Closing connection. Good bye.
もう少し実験してみましょう。 HELO の際に、nothing.example.com と答え、noone@example.com という存在しないアドレスにメールを送ります。 DATA コマンド中の From: の値も、noone@example.com にしておきます。
220 mail.example.com ESMTP
HELO nothing.example.com
250 mail.example.com
MAIL FROM: takada-at@example.com
250 ok
RCPT TO: noone@example.com
250 ok
DATA:
354 Please start mail input.
Subject: aaa
From: noone@example.com
cccc

.
250 Mail queued for delivery.
QUIT
221 Closing connection. Good bye.
こういう入力の仕方でも、メーラーデーモンからのリプライが takada-at@example.com に届きました。 デーモンからの返信先は必ず MAIL FROMで指定したアドレスとなるようです。 以上です。簡単ながら、自分で打ってみると、ブラックボックスに見えていたメール送信の仕組みが、心で理解できた気がします。やったことのない方はぜひ一度試してみるとよいのではないでしょうか。

SMTPコマンドを使って手動でメールを送ってみよう

amo-kです。 さて、SMTPクライント絡みでSMTPの話題です。 聞くところによるとtakada-at以外はSMTPコマンドを打ったことがあるそうです。 ということで今回はtakada-atにSMTPコマンドを使って手動でメールを送ってもらうことにします。 では、takada-at、よろしくお願いします~

SMTPクライアントを書いてみた

amo-kです。phpでSMTPクライアント書きました~ 書いてみたきっかけ PHPだとmail()mb_send_mail()でメール送信できちゃうけどよくよく見てみると、SMTPコマンドのHELOコマンドやMAIL FROMコマンドに値を指定できないぽい。最後の引数に、MTAに渡すコマンドラインオプションを指定可能だが、これはMTAに依存するということだ。MAIL FROMコマンドの値を指定したくても、この最後の引数に指定するしかないということになる。 では、何処でHELOコマンドやMAIL FROMコマンドの値を設定しているかというと、php.iniのSMTPディレクティヴやsendmail_fromディレクティヴの値となる。(Windows版phpのみ)つまり、アプリケーションレベルでこれ等の値を指定したい場合に、明確に指定するIFが無いということだ。 あれやこれやと考える時間がもったいないので、この前HTTPクライアント書いたし、せっかくなのでSMTPクライアントも書いて見たw コード:
<?php
/**
 * SMTPクライアント
 */
class AN_SMTP extends AN_Base
{
    private $host;
    private $port;
    private $error_no;
    private $error_str;
    private $sock_ttl;
    private $to;
    private $from;
    private $subject;
    private $body;
    private $header;
    private $envelope_from;
    private $socket;

    /**
     * コンストラクタ
     *
     * @param String $host     SMTPサーバホスト名
     * @param String $port     SMTPサーバポート番号
     * @param String $sock_ttl ソケットタイムアウト値
     * @return なし
     */
    public function __construct($host, $port, $sock_ttl)
    {
        $this->host     = $host;
        $this->port     = $port;
        $this->sock_ttl = $sock_ttl;
    }

    /**
     * メール送信
     *
     * @param String $to            RCPT TO コマンド用
     * @param String $from          Fromヘッダ用($envelope_from省略時はMAIL FROMコマンドにも使用)
     * @param String $subject       Subjectヘッダ用
     * @param String $body          Subjectヘッダ用
     * @param String $header        任意のヘッダ
     * @param String $envelope_from MAIL FROMコマンド用
     * @return なし
     */
    public function mail($to, $from, $subject, $body, $header = "", $envelope_from = "")
    {
        if (!$to || !$from || !$subject || !$body)
        {
            throw new AN_Exception("引数が不正です。");
        }
        $this->to      = $to;
        $this->from    = $from;
        $this->subject = $subject;
        $this->body    = $body;
        $this->header  = $header;
        $this->envelope_from = $envelope_from ? $envelope_from : $from;

        $this->socketOpen();
        $this->sendSMTP();
    }

    /**
     * ソケット接続
     *
     * @param  なし
     * @return なし
     */
    private function socketOpen()
    {
        if (($this->socket = fsockopen($this->host, $this->port, $this->error_no, $this->error_str, $this->sock_ttl)) === false)
        {
            throw new AN_Exception("socket open error!");
        }
        $this->getReceiveMsg();
    }

    /**
     * SMTPコマンド送信
     *
     * @param  なし
     * @return なし
     */
    private function sendSMTP()
    {
        $this->sendSocketCommand(sprintf("HELO <%s>\r\n", $this->host));
        $this->sendSocketCommand(sprintf("MAIL FROM: <%s>\r\n", $this->envelope_from));
        $this->sendSocketCommand(sprintf("RCPT TO: <%s>\r\n", $this->to));
        $this->sendSocketCommand("DATA\r\n");
        $this->sendSocketCommand(sprintf("%s\r\n%s\r\n.\r\n", $this->getHeader(), $this->body));
    }

    /**
     * ソケット接続先サーバよりレスポンスメッセージ取得
     *
     * @param  なし
     * @return なし
     */
    private function getReceiveMsg()
    {
        if(($res = fgets($this->socket, 1024)) === false)
        {
            // クライアント応答通信エラー
            throw new AN_Exception("socket receive error!");
        }
        if(!preg_match("/(2|3)/", substr(str_replace("\r\n", "", $res), 0, 1)))
        {
            // サーバ応答通信エラー
            throw new AN_Exception("socket response error!");
        }
    }

    /**
     * ソケット接続先サーバへコマンド送信
     *
     * @param  String $command コマンド
     * @return なし
     */
    private function sendSocketCommand($command)
    {
        if (!$command)
        {
            throw new AN_Exception(sprintf("SMTPコマンドが不正です。: %s", $command));
        }
        fputs($this->socket, $command);
        $this->getReceiveMsg();
    }

    /**
     * ヘッダ文字列取得
     *
     * @param  なし
     * @return なし
     */
    private function getHeader()
    {
        $header  = "MIME-version: 1.0\r\n";
        $header .= sprintf("Date: %s\r\n",    $nowdate);
        $header .= sprintf("From: %s\r\n",    $this->from);
        $header .= sprintf("Subject: %s\r\n", sprintf("=?shift_jis?B?%s?=", base64_encode($this->subject)));
        $header .= $this->header;
        return $header;
    }
}
?>
 KLab若手エンジニアブログのフッター