リアルタイム・チャットシステム 公開ソースコード

開発/実行環境

OS
Windows 7
サーバー言語
PHP
言語バージョン
ローカル 5.38
リモート 5.6
実行環境
Xampp 1.7.7
ローカルサーバー
Apache 2.2.21

ディレクトリ・ファイル構成

リアルタイム・チャットシステム デモページ

  online-chat/ (http://wiz-code.digick.jp/dev/html5/stream/online-chat/room.php)
     │
     │  room.php
     │  send-chat-data.php
     │  chat-data-stream.php
     │  functions.php
     │  Data_Stream.php
     │  tree.txt
     │  
     ├─js
     │     online-chat.js
     │
     └─tmp
     
  library/
     │
     ├─  PEAR Cache_Lite
     │
     ├─  jQuery 2.2.2
     ├─  Underscore.js 1.8.3
     └─  Bootstrap 3.3.1
                

Cache_Liteによるデータ管理

Cache_Lite
PHP PEARライブラリ
ファイル名 グループ名 オプションの詳細 内容
chat_data online_chat // ファイルロックをかける
fileLocking: true
// 読み込み中に競合がないか検出する
readControl: true
// 書き込み中に競合がないか検出する
writeControl: true
// ファイル名を暗号化する
fileNameProtection: true
JSON
id: チャットID
name: チャット送信者
message: メッセージ内容
create_time: 送信時間(ミリ秒)

ソースコード

room.php

<?php

date_default_timezone_set('UTC');
/* スクリプトの実行時間 */
set_time_limit(60);
/* セッション・クッキーはブラウザ終了で破棄。HTTPSであれば第4引数はtrueを指定する */
session_set_cookie_params(0, '/', '', false, true);
/* キャッシュを無効化とブラウザバック対応 */
session_cache_expire(0);
session_cache_limiter('private_no_expire');

require_once('Cache/Lite.php');
require_once('functions.php');

define('APP_NAME', 'online_chat');

header('Content-type: text/html; charset=utf-8');

session_start();

/* ページの初回訪問者への処理 */
if (!isset($_SESSION['client_id'])) {
	$_SESSION['client_id'] = random_str();
}

/* ノンスを作成 */
$nonce = session_id();

/* Cache_Liteの初期化時に指定するオプション。チャットデータの保存期間は24時間とする */
$options = get_cache_lite_options(array('lifeTime' => 86400));

try {
	
	$cache = new Cache_Lite($options);
	$chat_data = $cache->get('chat_data', APP_NAME);
	
	/* チャットデータが存在しなければ新規作成してファイルに保存する */
	if ($chat_data === false || !is_array($chat_data)) {
		$chat_data = array();
		$cache->save($chat_data, 'chat_data', APP_NAME);
	}
	
    /* 読み込みエラー時に備えて直前のデータをキャッシュする */
	$_SESSION['chat_data_cache'] = $chat_data;
	/* クライアントに最後に送ったチャットデータのIDを格納 */
	$_SESSION['last_chat_id'] = null;
	
	session_write_close();
	
} catch (Exception $e) {
	format_error_log($e->getMessage, __FILE__, __LINE__);
}

?>

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>チャットルームのテストページ - Server-Sent EventsとAjax・PHPのリアルタイム・チャットシステム | ウィザード・コード - WIZARD-CODE</title>
    <link href="/lib/bootstrap/3.3.1/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <div class="container">
      <h1 class="h2">チャットルームのテストページ</h1>
      <p><strong>Server-Sent Events</strong>/<strong>Ajax</strong>/<strong>PHP</strong>を組み合わせたリアルタイム・チャットシステムです。SSEが実装されていない<strong>Internet Explorer</strong>では利用できません。リアルタイムとありますが、ユーザー間で同期していないので実際には擬似リアルタイムです。</p>
      <p>セキュリティについて、Ajax通信はトークンで<strong>CSRF</strong>対策を行います。入力文字のバリデーションはクライアント側で行い、制御文字や特殊文字は入力不可として送信しません。サーバー側の対策は出力時のエスケープ処理のみです。</p>
      <p>Google ChromeのデベロッパーツールやFirebugなどでストリーミングの中身が確認できます。「ネットワーク」のタブを開くとchat-data-stream.phpがコネクションを張り続けているのがわかると思います。書き込まれた内容はキャッシュファイルに24時間保存され、その後に自動的に削除されます。</p>
      <p>ソースコードを<a href="http://wiz-code.digick.jp/dev/html5/stream/online-chat/online-chat.html">こちらのページ</a>で公開しています。ライセンスフリーです。</p>
      <form id="chat-form">
        <div id="name-group" class="form-group">
          <label for="chat-name" class="control-label">名前を入れてください</label>
          <input type="text" class="form-control" id="chat-name" name="chat-name" placeholder="3文字以上20文字まで入力できます(空欄でもOK)。">
        </div>
        <div id="message-group" class="form-group">
          <label for="chat-message" class="control-label">メッセージをどうぞ</label>
          <input type="text" class="form-control" id="chat-message" name ="chat-message" placeholder="最大60文字まで入力できます。" autocomplete="off">
        </div>
        <input type="hidden" id="nonce" name="nonce" value="<?=h($nonce)?>">
        <button id="submit-button" type="submit" class="btn btn-info">チャットを送信</button>
      </form>
      <table class="table">
        <thead>
          <tr>
            <th class="col-xs-2 col-sm-2 col-md-2 col-lg-2">名前</th>
            <th class="col-xs-8 col-sm-8 col-md-8 col-lg-8">メッセージ</th>
            <th class="col-xs-2 col-sm-2 col-md-2 col-lg-2 text-center">送信時間</th>
          </tr>
        </thead>
        <tbody id="chat-list"></tbody>
      </table>
    </div>
    <script src="/lib/jquery/jquery-2.2.2.js"></script>
    <script src="/lib/bootstrap/3.3.1/js/bootstrap.min.js"></script>
    <script src="/lib/underscore/1.8.3/underscore.js"></script>
    <script src="/js/online-chat.js"></script>
  </body>
</html>
                

send-chat-data.php


date_default_timezone_set('UTC');
set_time_limit(60);
session_set_cookie_params(0, '/', '', false, true);

require_once('Cache/Lite.php');
require_once('functions.php');

define('APP_NAME', 'online_chat');
define('MAX_CHAT_LIMIT', 100);

/* レスポンスはテキストデータとして返す */
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');

session_start();

/* クライアントに渡す変数(真偽値) */
$response = false;

/* Ajax通信でなければ終了 */
if (@$_SERVER['HTTP_X_REQUESTED_WITH'] !== 'XMLHttpRequest') {
	$message = 'invalid access';
	format_error_log($message, __FILE__, __LINE__);
	echo $response;
	exit;
}

/* アクセスが room.php 経由でない場合終了 */
if (!isset($_SESSION['client_id'])) {
	$message = 'client id not found';
	format_error_log($message, __FILE__, __LINE__);
	echo $response;
	exit;
}

/* POSTデータを読み込む */
$raw_post_data = @file_get_contents('php://input');
parse_str($raw_post_data, $params);

$chat_name = $params['chat-name'];
$chat_message = $params['chat-message'];
$nonce = $params['nonce'];

/* ノンスが異なっていたら終了 */
if (session_id() !== $nonce) {
	$message = 'invalid nonce';
	format_error_log($message, __FILE__, __LINE__);
	echo $response;
	exit;
}

/* チャットデータの保存期間は24時間とする */
$options = get_cache_lite_options(array('lifeTime' => 86400));

try {
	/* Cache_Liteライブラリを初期化 */
	$cache = new Cache_Lite($options);
	
	/* チャットデータを読み込む */
	$chat_data = $cache->get('chat_data', APP_NAME);
	if ($chat_data === false || !is_array($chat_data)) {
		/* データが壊れている可能性があるので直前のデータで上書き */
		$cache->save($_SESSION['chat_data_cache'], 'chat_data', APP_NAME);
		
		$message = 'chat data not found';
		format_error_log($message, __FILE__, __LINE__);
		echo $response;
		exit;
	}
	
	/* ランダムな文字列を生成してチャットIDにする */
	$chat_id = random_str();
	
	$new_chat = array(
		'id' => $chat_id,
		'name' => $chat_name,
		'message' => $chat_message,
		'create_time' => floor(microtime(true) * 1000),
	);
	
	array_unshift($chat_data, $new_chat);
	/* データ数が最大数を超えたら古い順に削除 */
	$chat_data = array_slice($chat_data, 0, MAX_CHAT_LIMIT);
	
	/* セッション変数に直前のチャットデータを格納 */
	$_SESSION['chat_data_cache'] = $chat_data;
	
	/* チャットデータを保存 */
	$result = $cache->save($chat_data, 'chat_data', APP_NAME);
	if ($result === false || is_object($result)) {
		$message = 'failed to save';
		format_error_log($message, __FILE__, __LINE__);
		echo $response;
		exit;
	}
	
	$response = true;
	echo $response;
	
} catch (Exception $e) {
	format_error_log($e->getMessage, __FILE__, __LINE__);
	echo $response;
}

                

chat-data-stream.php


date_default_timezone_set('UTC');
set_time_limit(60);
session_set_cookie_params(0, '/', '', false, true);
mb_http_output('pass');

require_once('Cache/Lite.php');
require_once('Data_Stream.php');
require_once('functions.php');

/* UPDATE_FREQUENCY: 何秒ごとにデータストリームを送るか */
define('UPDATE_FREQUENCY', 1);

/* ストリーミングのタイムアウト時間を指定 */
define('TIMEOUT', 30);
define('APP_NAME', 'online_chat');
/* ページロード時、一括して読み込むチャットデータの数 */
define('INIT_CHAT_OUTPUT', 30);

/* セッションはストリーミング中の排他ロックを防ぐため明示的に終了させる */
session_start();

/* アクセスが room.php 経由でない場合終了 */
if (!isset($_SESSION['client_id'])) {
	$message = 'client id not found';
	abort_stream($message);
	exit;
}

/* Last-Event-IDをチェックする */
$last_event_id = @$_SERVER['HTTP_LAST_EVENT_ID'];
$last_event_id = is_null($last_event_id) ? 0 : intval($last_event_id) + 1;

/* 最後に送ったチャットデータのIDをセッション変数から取り出す */
$last_chat_id = $_SESSION['last_chat_id'];

session_write_close();

/* Cache_Liteの初期化時に指定するオプション。チャットデータの保存期間は24時間とする */
$options = get_cache_lite_options(array('lifeTime' => 86400));

try {
	
	$cache = new Cache_Lite($options);
	
	$timeout = TIMEOUT * 1000;
	$frequency = UPDATE_FREQUENCY * 1000 * 1000;
	
	/* データストリーミング用のクラスを初期化 */
	$ds = new Data_Stream;
	$ds->start();
	
	/* イベントIDは再接続時に前回からの連番にする */
	if ($last_event_id > 0) {
		$ds->setId($last_event_id);
	}
	
	/* タイムアウト時間までループ処理 */
	while ($ds->getElapsedTime() < $timeout) {
		/* チャットデータを読み込む */
		$chat_data = $cache->get('chat_data', APP_NAME);
		if ($chat_data === false || !is_array($chat_data)) {
        	$message = 'chat data not found';
			format_error_log($message, __FILE__, __LINE__);
            
			/* データが壊れている可能性があるので直前のデータで上書きする */
			@session_start();
			$cache->save($_SESSION['chat_data_cache'], 'chat_data', APP_NAME);
			session_write_close();
			
			$ds->flush('', 'chat-data');
			usleep($frequency);
			continue;
		}
		
		/* 初回だけ一括してチャットデータを送る */
		if (is_null($last_chat_id)) {
			$sliced_data = array_slice($chat_data, 0, INIT_CHAT_OUTPUT);
			
			for ($i = 0, $l = count($sliced_data); $i < $l; $i++) {
				$data = $sliced_data[$i];
				if ($i === 0) {
					$last_chat_id = $data['id'];
				}
				
				/* 文字列データはエスケープする */
				$data = escapeStreamData($data);
				$sliced_data[$i] = $data;
			}
			
			/* 初回の送信は配列データを送る */
			if (!empty($sliced_data)) {
				$json = json_safe_encode($sliced_data);
				$ds->storeData($json);
			}
			
		/* 以降は新規のチャットだけを送る */
		} else {
			$chat_ids = array_column($chat_data, 'id');
			$last_chat_index = array_search($last_chat_id, $chat_ids, true);
			
			if ($last_chat_index > 0) {
				$sliced_data = array_slice($chat_data, 0, $last_chat_index);
				
				for ($i = 0, $l = count($sliced_data); $i < $l; $i++) {
					$data = $sliced_data[$i];
					if ($i === 0) {
						$last_chat_id = $data['id'];
					}
					
					/* 文字列データはエスケープする */
					$data = escapeStreamData($data);
					$json = json_safe_encode($data);
					/* Data_Stream::storeData()の第2引数にtrueを渡すと配列に要素をunshiftで入れる。作成日時の古い順にスタックするため */
					$ds->storeData($json, true);
				}
			}
		}
		
		/* チャットデータをフラッシュして一定時間スリープ */
		$ds->output('chat-data');
		usleep($frequency);
	}
	
	$ds->end();
	
	/* セッションを再開して変数の値を更新する */
	@session_start();
    $_SESSION['chat_data_cache'] = $chat_data;
	$_SESSION['last_chat_id'] = $last_chat_id;
	session_write_close();
	
} catch (Exception $e) {
	format_error_log($e->getMessage(), __FILE__, __LINE__);
	abort_stream();
	exit;
}

/* クライアントにストリーミングの中断を指示する */
function abort_stream($error_log = false) {
	if ($error_log !== false) {
		format_error_log($error_log, __FILE__, __LINE__);
	}
	$ds = new Data_Stream;
	$ds->start();
	$ds->flush('failed to stream', 'abort-chat-data-stream');
	$ds->end();
}

                

functions.php


/* PHP 5.5.0未満のバージョンではarray_column()がサポートされておらず、下記のコードが必要 */

/**
 * This file is part of the array_column library
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @copyright Copyright (c) 2013 Ben Ramsey <http://benramsey.com>
 * @license http://opensource.org/licenses/MIT MIT
 */

if (!function_exists('array_column')) {

	/**
	 * Returns the values from a single column of the input array, identified by
	 * the $columnKey.
	 *
	 * Optionally, you may provide an $indexKey to index the values in the returned
	 * array by the values from the $indexKey column in the input array.
	 *
	 * @param array $input A multi-dimensional array (record set) from which to pull
	 *                     a column of values.
	 * @param mixed $columnKey The column of values to return. This value may be the
	 *                         integer key of the column you wish to retrieve, or it
	 *                         may be the string key name for an associative array.
	 * @param mixed $indexKey (Optional.) The column to use as the index/keys for
     *                        the returned array. This value may be the integer key
	 *                        of the column, or it may be the string key name.
	 * @return array
	 */
	function array_column($input = null, $columnKey = null, $indexKey = null)
	{
		// Using func_get_args() in order to check for proper number of
		// parameters and trigger errors exactly as the built-in array_column()
		// does in PHP 5.5.
		$argc = func_num_args();
		$params = func_get_args();

		if ($argc < 2) {
			trigger_error("array_column() expects at least 2 parameters, {$argc} given", E_USER_WARNING);
			return null;
		}

		if (!is_array($params[0])) {
			trigger_error('array_column() expects parameter 1 to be array, ' . gettype($params[0]) . ' given', E_USER_WARNING);
			return null;
		}

		if (!is_int($params[1])
			&& !is_float($params[1])
			&& !is_string($params[1])
			&& $params[1] !== null
			&& !(is_object($params[1]) && method_exists($params[1], '__toString'))
		) {
			trigger_error('array_column(): The column key should be either a string or an integer', E_USER_WARNING);
			return false;
		}

		if (isset($params[2])
			&& !is_int($params[2])
			&& !is_float($params[2])
			&& !is_string($params[2])
			&& !(is_object($params[2]) && method_exists($params[2], '__toString'))
		) {
			trigger_error('array_column(): The index key should be either a string or an integer', E_USER_WARNING);
			return false;
		}

		$paramsInput = $params[0];
		$paramsColumnKey = ($params[1] !== null) ? (string) $params[1] : null;

		$paramsIndexKey = null;
		if (isset($params[2])) {
			if (is_float($params[2]) || is_int($params[2])) {
				$paramsIndexKey = (int) $params[2];
			} else {
				$paramsIndexKey = (string) $params[2];
			}
		}

		$resultArray = array();

		foreach ($paramsInput as $row) {

			$key = $value = null;
			$keySet = $valueSet = false;

			if ($paramsIndexKey !== null && array_key_exists($paramsIndexKey, $row)) {
				$keySet = true;
				$key = (string) $row[$paramsIndexKey];
			}

			if ($paramsColumnKey === null) {
				$valueSet = true;
				$value = $row;
			} elseif (is_array($row) && array_key_exists($paramsColumnKey, $row)) {
				$valueSet = true;
				$value = $row[$paramsColumnKey];
			}

			if ($valueSet) {
				if ($keySet) {
					$resultArray[$key] = $value;
				} else {
					$resultArray[] = $value;
				}
			}

		}

		return $resultArray;
	}

}

function get_cache_lite_options($options = null) {
	$default_options = array(
		'cacheDir' => './tmp/',
		'caching' => true,
		'fileLocking' => true,
		
		'readControl' => true,
		'writeControl' => true,
		'fileNameProtection' => true,
		
		'automaticSerialization' => true,
		'automaticCleaningFactor' => 200,
		'lifeTime' => 3600,
		'hashedDirectoryLevel' => 1,
	);
	
	if (is_null($options)) {
		return $default_options;
	}
	
	foreach ($default_options as $key => $value) {
		if (!array_key_exists($key, $options)) {
			$options[$key] = $value;
		}
	}
	
	return $options;
}

function escapeStreamData($data) {
	foreach ($data as $key => $value) {
		if (is_string($value)) {
			$data[$key] = h($value);
		}
	}
	return $data;
}

function json_safe_encode($val) {
	return json_encode($val, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}

function h($str) {
	return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

function random_str($length = 32) {
	return strtr(substr(base64_encode(openssl_random_pseudo_bytes($length)), 0, $length), '/+', '_-');
}

function format_error_log($message, $file = null, $line = null) {
	$message = is_string($message) ? $message : strval($message);
	$file = !is_null($file) ? basename($file) . ' ' : '';
	$line = !is_null($line) ? "line {$line} " : '';
	$message = $file . $line . $message;
	
	$date = new DateTime();
	$date->setTimezone(new DateTimeZone('Asia/Tokyo'));
	$message = $message . ' [' . $date->format(DATE_ISO8601) . ']' . PHP_EOL;
	/* ログファイルの場所をフルパスで指定 */
	error_log($message, '3', '/home/*****/log/debug.log');
}

                

Data_Stream.php


class Data_Stream
{
	private $id = 1;
	private $options = array(
		'defaultEvent' => 'message',
		'retry' => 0,
	);
	private $started = false;
	private $data;
	private $elapsedTime;
	private $startTime;
	
	public function __construct($options = NULL)
	{
		if (!is_null($options)) {
			$this->setOptions($options);
		}
		
		if (headers_sent() === true) {
			header_remove('Content-type');
			header_remove('Cache-Control');
		}
		
		header('Content-type: text/event-stream; charset=utf-8');
		header('Cache-Control: no-cache');
	}
	
	public function setOption($options)
	{
		foreach ($options as $key => $value) {
			if (isset($value)) {
				$this->options[$key] = $value;
			}
		}
	}
	
	public function getId()
	{
		return $this->id;
	}
	
	public function setId($id)
	{
		$this->id = is_int($id) ? $id : intval($id);
		return $this->id;
	}
	
	public function start()
	{
		ob_end_clean();
		ob_start();
		
		$this->data = array();
		$this->elapsedTime = 0;
		$this->startTime = microtime(true) * 1000;
		$this->started = true;
	}
	
	public function end()
	{
		ob_end_clean();
		$this->started = false;
	}
	
	public function getElapsedTime()
	{
		$result = 0;
		
		if ($this->started === true) {
			$result = $this->elapsedTime = (microtime(true) * 1000) - $this->startTime;
			return $result;
		}
		return $result;
	}
	
	public function storeData($data, $reverse = false)
	{
		if (is_string($data)) {
			if ($reverse === false) {
				array_push($this->data, $data);
			} else {
				array_unshift($this->data, $data);
			}
			return true;
		}
		return false;
	}
	
	public function output($event = null, $id = null)
	{
		$event = !is_null($event) ? $event : $this->options['defaultEvent'];
		$id = !is_null($id) ? $id : $this->id;
		
		$data_list = "";
		if (!empty($this->data)) {
			for ($i = 0, $l = count($this->data); $i < $l; $i++) {
				$value = $this->data[$i];
				$data_list .= "id: {$id}" . PHP_EOL . "event: {$event}" . PHP_EOL . "data: {$value}" . PHP_EOL . PHP_EOL;
				$id = ++$this->id;
			}
		}
		
		if ($this->started === true && is_string($data_list)) {
            /* ヒアドキュメントの終わりにある二つの改行はデータの終了を示す */
			$message = <<< EOM
: keep alive
retry: {$this->options['retry']}
{$data_list}


EOM;
			
			echo $message;		
			@ob_flush();
			@flush();
			
			$this->data = array();
		}
	}
	
	public function flush($data, $event = null, $id = null)
	{
		$event = !is_null($event) ? $event : $this->options['defaultEvent'];
		if (is_null($id)) {
			$id = $this->id;
			$this->id++;
		}
		
		if ($this->started === true && is_string($data)) {
			if (!empty($data)) {
				$data = "data: {$data}";
			} else {
                $data = '';
            }
            /* ヒアドキュメントの終わりにある二つの改行はデータの終了を示す */
			$message = <<< EOM
: keep alive
id: {$id}
event: {$event}
retry: {$this->options['retry']}
{$data}


EOM;
			
			echo $message;		
			@ob_flush();
			@flush();
		}
	}
}

                

online-chat.js

/* このファイルはコメントを削除したり圧縮しないでください */

;(function () {
	var heredocReg, validReg, fragment, MIN_NAME_LIMIT, MAX_NAME_LIMIT, MAX_MESSAGE_LIMIT, DATE_UPDATE_INTERVAL;
	
	heredocReg = /^function\s+\([^)]*\)\s*\{\s*|\s*\/\*\s*|\s*\*\/\s*\}$/g;
	validReg = /^[\x20\x21\x23-\x25\x28-\x3B\x3D\x3F-\x7E\u2010-\u2027\u3000-\u3036\u303F\u3041-\u3094\u3099-\u309E\u30A1-\u30F6\u30FB-\u30FE\u4E00-\u9FA5\uFF01-\uFF5E\uFF61-\uFF9F\uFFE0-\uFFE6]+$/;
	
	fragment = {};
	/* チャット表示要素のフラグメント */
	fragment.chat = (function () {  
/*
<tr>
  <td class="chat-from"><%= name %></td>
  <td class="chat-body"><%= message %></td>
  <td class="chat-time"><%= time %></td>
</tr>
*/
	}).toString().replace(heredocReg, '');
	
	MIN_NAME_LIMIT = 3;
	MAX_NAME_LIMIT = 20;
	MAX_MESSAGE_LIMIT = 60;
	DATE_UPDATE_INTERVAL = 20;
	
	$(function () {
		var chatList, chatForm, chatName, chatMessage, submitButton, chatDataStream, chatData, dateUpdateInterval;
		
		chatForm = $('#chat-form');
		chatName = $('#chat-name');
		chatMessage = $('#chat-message');
		chatList = $('#chat-list');
		submitButton = $('#submit-button');
		
		chatData = [];
		
		chatForm.on('submit', function (e) {
			e.preventDefault();
		});
		
		chatName.on('keypress', function (e) {
			/* テキストフォームのエンターキー押し下げによるイベントを抑止 */
			if (e.key === 'Enter') {
				e.preventDefault();
			}
		});
		
		submitButton.on('click', sendChatMessage);
		
		/* 新規のチャットメッセージを確認するストリーム */
		chatDataStream = new EventSource('/dev/html5/stream/online-chat/chat-data-stream.php');
		
		/* サーバーからのストリームデータを受信 */
		chatDataStream.addEventListener('chat-data', function (e) {
			var data, time, element;
			data = JSON.parse(e.data);
			
			/* ページロード時はチャットデータが配列にまとめられて送られてくる */
			if (_.isArray(data)) {
				_.each(data, function (d) {
					element = insertChatData(d);
					highlightElement(element);
				});
				chatData = chatData.concat(data.reverse());
				
			/* 以降は一件ずつ送られてくる */
			} else {
				element = insertChatData(data, 'prependTo');
				highlightElement(element);
				chatData.push(data);
			}
		});
		
		chatDataStream.addEventListener('error', function (e) {
			console.info('チャットデータのストリーミングを再開します');
		}, false);
		
		chatDataStream.addEventListener('abort-chat-data-stream', function (e) {
			console.warn('チャットデータのストリーミングを中断しました');
			chatDataStream.close();
		}, false);
		
		$(window).on('unload', function (e) {
			chatDataStream.close();
		}, false);
		
		/* チャットの作成時間を一定時間ごとに更新 */
		dateUpdateInterval = DATE_UPDATE_INTERVAL * 1000;
		window.setInterval(dateUpdater, dateUpdateInterval);
		
		function sendChatMessage(e) {
			var name, message, label, messageGroup;
			e.preventDefault();
			
			name = chatName.val();
			message = chatMessage.val();
			messageGroup = chatForm.children('#message-group');
			label = messageGroup.children('label');
			
			/* ユーザー名のバリデーション */
			if (_.isEmpty(name) || name.length < MIN_NAME_LIMIT || name.length > MAX_NAME_LIMIT || !validReg.test(name)) {
				name = 'NO NAME';
			}
			
			/* メッセージ内容のバリデーション */
			if (_.isEmpty(message)) {
				if (!messageGroup.hasClass('has-error')) {
					messageGroup.addClass('has-error');
				}
				label.text('メッセージを入力してください');
				chatMessage.focus();
				return;
			}
			
			if (message.length > MAX_MESSAGE_LIMIT) {
				if (!messageGroup.hasClass('has-error')) {
					messageGroup.addClass('has-error');
				}
				label.text('メッセージの入力文字数は60文字までです');
				chatMessage.focus();
				return;
			}
			
			if (!validReg.test(message)) {
				if (!messageGroup.hasClass('has-error')) {
					messageGroup.addClass('has-error');
				}
				label.text('特殊文字(& < > " \'など)は使用できません');
				chatMessage.focus();
				return;
			}
			
			if (messageGroup.hasClass('has-error')) {
				messageGroup.removeClass('has-error');
				label.text('チャットツール');
			}
			
			/* 名前欄が空欄のとき代替名を入れる */
			chatName.val(name);
			
			$(this).prop('disabled', true).blur();
			
			/* メッセージを送信 */
			$.post(
				'/dev/html5/stream/online-chat/send-chat-data.php',
				chatForm.serialize(),
				function (data) {
					submitButton.prop('disabled', false);
					
					if (_.isEmpty(data)) {
						if (!messageGroup.hasClass('has-error')) {
							messageGroup.addClass('has-error');
						}
						label.text('メッセージの送信に失敗しました');
						chatMessage.focus();
						return;
					}
					
					chatMessage.val('');
				},
				'text'
			);
		}
		
		function dateUpdater() {
			var chatRows;
			chatRows = chatList.children('tr');
			
			_.each(chatData, function (data) {
				var time;
				time = formatUpdateTime(data.create_time);
				chatRows.filter('[id="' + data.id + '"]').
					find('.chat-time').text(time);
			});
		}
		
		function highlightElement(element) {
			var color, duration;
			color = 50;
			duration = 700;
			
			$(element).css({backgroundColor: 'hsl(60,100%,' + color + '%)'}).
				animate({backgroundColor: 'transparent'}, {
					duration: duration,
					progress: function (animation, progress) {
						$(this).css({backgroundColor: 'hsl(60,100%,' + Math.floor(progress * (100 - color) + color) + '%)'});
					}
				}
			);
		}
		
		function insertChatData(data, method) {
			var time, element, temp, rendered;
			method = !_.isUndefined(method) ? method : 'appendTo';
			time = formatUpdateTime(Math.floor(data.create_time));
			
			temp = _.template(fragment.chat);
			rendered = temp({
				name: data.name,
				message: data.message,
				time: time,
			});
			
			element = $(rendered).attr('id', data.id)[method](chatList);
			return element;
		}
		
		function formatUpdateTime(startTime) {
			var elapsedTime, remainder, years, months, days, hours, minutes, seconds;
			elapsedTime = _.now() - startTime;
			elapsedTime = Math.max(elapsedTime, 0);
			if (Math.floor(elapsedTime / 1000) === 0) {
				return 'たった今';
			}
			
			years = Math.floor(elapsedTime / 3.1536e+10);
			if (years > 0) {
				elapsedTime = years + '年前';
			} else {
				remainder = elapsedTime % 3.1536e+10;
				months = Math.floor(remainder / 2.592e+9);
				if (months > 0) {
					elapsedTime = months + 'ヶ月前';
				} else {
					remainder = elapsedTime % 2.592e+9;
					days = Math.floor(remainder / 8.64e+7);
					if (days > 0) {
						elapsedTime = days + '日前';
					} else {
						remainder = elapsedTime % 8.64e+7;
						hours = Math.floor(remainder / 3.6e+6);
						if (hours > 0) {
							elapsedTime = hours + '時間前';
						} else {
							remainder = elapsedTime % 3.6e+6;
							minutes = Math.floor(remainder / 60000);
							if (minutes > 0) {
								elapsedTime = minutes + '分前';
							} else {
								seconds = Math.floor(elapsedTime / 1000);
								elapsedTime = seconds + '秒前';
							}
						}
					}
				}
			}
			return elapsedTime;
		}
	});
}());