レポジトリ種類: SVN
<?php
namespace Site\Lib;
/**
* PHP純正のMySQLドライバ。php-mysql拡張モジュールが不要。
* TODO: execute()で他のMySQLタイプを処理 (MYSQL_TYPE_DATA, MYSQL_TYPE_BLOB)
* TODO: トランザクション管理
* TODO: 各種接続オプション
* TODO: プロトコルの完全なカバレッジ
*/
class Mysql {
private $socket = null;
private bool $connected = false;
private bool $debug = false;
private array $packetLog = [];
private array $serverInfo = [];
public string $username;
private string $password;
public string $dbname;
public string $host;
private int $port;
private array $prepared = [];
/**
* コンストラクタ
*
* MySQL接続に必要な設定をDBINFOから初期化します。
*/
public function __construct() {
if (!MYSQL_ENABLED) return;
$this->host = DBINFO['host'];
$this->username = DBINFO['username'];
$this->password = DBINFO['password'];
$this->dbname = DBINFO['dbname'];
$this->port = DBINFO['port'];
$this->debug = DBINFO['debug'];
}
/**
* デストラクタ
*
* オブジェクト破棄時に接続を閉じます。
*/
public function __destruct() {
$this->close();
}
/**
* デバッギングの有無
*
* @param bool $debug デバッグモードを有効にするかどうか
* @return Mysql 自身を返す(メソッドチェーン用)
*/
public function setDebug(bool $debug): Mysql {
$this->debug = (bool)$debug;
return $this;
}
/**
* パケットログの取得
*
* @return array 送信および受信したパケットのログ
*/
public function getPacketLog(): array {
return $this->packetLog;
}
/**
* MySQLサーバーに接続
*
* ソケットを作成し、サーバーに接続後、認証とデータベース選択を行います。
*
* @return bool 接続成功時はtrue
* @throws \Exception 接続または認証に失敗した場合
*/
public function connect(): bool {
if (!MYSQL_ENABLED) return false;
$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($this->socket === false) {
$msg = 'ソケットの作成に失敗: '.socket_strerror(socket_last_error());
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$res = socket_connect($this->socket, $this->host, $this->port);
if ($res === false) {
$msg = 'ソケットに接続に失敗: '
.socket_strerror(socket_last_error($this->socket));
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$greeting = $this->readPacket();
$this->parseServerGreeting($greeting);
$this->authenticate();
$response = $this->readPacket();
if (ord($response[0]) !== 0x00) {
$code = unpack('v', substr($response, 1, 2))[1];
$mes = substr($response, 3);
$msg = "認証応答に失敗: {$code} - {$mes}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
if (!empty($this->dbname)) {
$this->selectDatabase($this->dbname);
}
$this->connected = true;
return true;
}
/**
* 接続を閉じる
*
* COM_QUITコマンドを送信し、ソケットを閉じます。
*
* @return void
*/
public function close(): void {
if (!MYSQL_ENABLED) return;
if ($this->socket) {
$this->sendCommand(0x01); // COM_QUIT
socket_close($this->socket);
$this->socket = null;
$this->connected = false;
}
}
/**
* 利用するデータベースを選択する
*
* COM_INIT_DBコマンドを使用してデータベースを選択します。
*
* @param string $database データベース名
* @return bool 成功時はtrue
* @throws \Exception データベース選択に失敗した場合
*/
public function selectDatabase(string $database): bool {
if (!MYSQL_ENABLED) return false;
$this->sendCommand(0x02, $database); // COM_INIT_DB
$res = $this->readPacket();
if (ord($res[0]) === 0xFF) {
$code = unpack('v', substr($res, 1, 2))[1];
$mes = substr($res, 3);
$msg = "データベースの選択に失敗: {$code} - {$mes}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$this->dbname = $database;
return true;
}
/**
* プリペアドステートメントの準備
*
* SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。
*
* @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?")
* @return int 成功時はステートメントID
* @throws \Exception 準備に失敗した場合
*/
public function prepare(string $query): int {
if (!$this->connected || !MYSQL_ENABLED) return false;
$this->sendCommand(0x16, $query); // COM_STMT_PREPARE
$res = $this->readPacket();
if (ord($res[0]) === 0xFF) {
$code = unpack('v', substr($res, 1, 2))[1];
$mes = substr($res, 3);
$msg = "準備に失敗: {$code} - {$mes}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$pos = 0;
$statementId = unpack('V', substr($res, $pos + 1, 4))[1]; // ステートメントID
$pos += 5;
$numCols = unpack('v', substr($res, $pos, 2))[1]; // 列数
$pos += 2;
$numParam = unpack('v', substr($res, $pos, 2))[1]; // パラメートル数
$pos += 4;
$this->prepared[$statementId] = [
'num_params' => $numParam,
'num_columns' => $numCols,
'params' => [],
'columns' => [],
];
if ($numParam > 0) {
for ($i = 0; $i < $numParam; $i++) {
$paramPacket = $this->readPacket();
$this->prepared[$statementId]['params'][] =
$this->parseFieldPacket($paramPacket);
}
$this->readPacket();
}
if ($numCols > 0) {
for ($i = 0; $i < $numCols; $i++) {
$columnPacket = $this->readPacket();
$this->prepared[$statementId]['columns'][] =
$this->parseFieldPacket($columnPacket);
}
$this->readPacket();
}
return $statementId;
}
/**
* プリペアドステートメントの実行
*
* 指定されたステートメントIDとパラメータを使用してクエリを実行します。
*
* @param int $statementId プリペアドステートメントID
* @param array $params パラメータ値の配列
* @return array 結果セットまたはOKパケットデータ
* @throws \Exception 実行に失敗した場合
*/
public function execute(int $statementId, array $params = []): array {
if (!MYSQL_ENABLED) return [];
if (!isset($this->prepared[$statementId])) {
$msg = "不正なステートメントID: {$statementId}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$stmtInfo = $this->prepared[$statementId];
if (count($params) != $stmtInfo['num_params']) {
$msg = "パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params);
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$data = chr(0x17); // COM_STMT_EXECUTE
$data .= pack('V', $statementId);
$data .= chr(0); // 0 = カーソルなし
$data .= pack('V', 1); // 繰り返し数(常に1)
if ($stmtInfo['num_params'] > 0) {
// NULLビットマップ
$nullBitmap = str_repeat("\0", ceil($stmtInfo['num_params'] / 8));
foreach ($params as $k => $v) {
if ($v === NULL) {
$nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7)));
}
}
$data .= $nullBitmap;
$data .= chr(1); // 新パラメートルフラグ(1=はい)
$paramTypes = '';
$paramValues = '';
foreach ($params as $param) {
/**
* MYSQL_TYPE_DECIMAL 0x00
* MYSQL_TYPE_TINY 0x01
* MYSQL_TYPE_SHORT 0x02
* MYSQL_TYPE_LONG 0x03
* MYSQL_TYPE_FLOAT 0x04
* MYSQL_TYPE_DOUBLE 0x05
* MYSQL_TYPE_NULL 0x06
* MYSQL_TYPE_TIMESTAMP 0x07
* MYSQL_TYPE_LONGLONG 0x08
* MYSQL_TYPE_INT24 0x09
* MYSQL_TYPE_DATE 0x0A
* MYSQL_TYPE_TIME 0x0B
* MYSQL_TYPE_DATETIME 0x0C
* MYSQL_TYPE_YEAR 0x0D
* MYSQL_TYPE_NEWDATE 0x0E
* MYSQL_TYPE_VARCHAR 0x0F
* MYSQL_TYPE_BIT 0x10
*
* MYSQL_TYPE_NEWDECIMAL 0xF6
* MYSQL_TYPE_ENUM 0xF7
* MYSQL_TYPE_SET 0xF8
* MYSQL_TYPE_TINY_BLOB 0xF9
* MYSQL_TYPE_MEDIUM_BLOB 0xFA
* MYSQL_TYPE_LONG_BLOB 0xFB
* MYSQL_TYPE_BLOB 0xFC
* MYSQL_TYPE_VAR_STRING 0xFD
* MYSQL_TYPE_STRING 0xFE
* MYSQL_TYPE_GEOMETRY 0xFF
*/
if ($param === null) {
$paramType .= pack('v', 0x06); // MYSQL_TYPE_NULL
} else if (is_int($param)) {
$intLen = strlen((string)$param);
if ($intLen == 10) {
$paramTypes .= pack('v', 0x07); // MYSQL_TYPE_TIMESTAMP
} else if ($intLen >= -128 && $intLen < 127) {
$paramTypes .= pack('v', 0x01); // MYSQL_TYPE_TINY
} else if ($intLen >= -32768 && $intLen < 32767) {
$paramTypes .= pack('v', 0x02); // MYSQL_TYPE_SHORT
} else if ($intLen >= -8388608 && $intLen < 8388607) {
$paramTypes .= pack('v', 0x09); // MYSQL_TYPE_INT24
} else if ($intLen >= -2147483648 && $intLen < 2147483647) {
$paramTypes .= pack('v', 0x03); // MYSQL_TYPE_LONG
} else if ($intLen >= -9223372036854775808 && $intLen < 9223372036854775807) {
$paramTypes .= pack('v', 0x08); // MYSQL_TYPE_LONGLONG
}
$paramValues .= pack('V', $param);
} else if (is_float($param)) {
$decLen = strpos(strrev((string)$param), '.');
if ($decLen !== FALSE && $decLen < 25) {
$paramTypes .= pack('v', 0x04); // MYSQL_TYPE_FLOAT
} else if ($decLen !== FALSE && $decLen >= 25 && $decLen < 60) {
$paramTypes .= pack('v', 0x05); // MYSQL_TYPE_DOUBLE
}
$paramValues .= pack('d', $param);
} else {
$paramTypes .= pack('v', 0x0F); // MYSQL_TYPE_STRING
$len = strlen($param);
$paramValues .= $this->encodeLengthEncodedInteger($len).$param;
}
}
$data .= $paramTypes.$paramValues;
}
$this->sendPacket($data);
$res = $this->readPacket();
if (ord($res[0]) === 0xFF) {
$code = unpack('v', substr($res, 1, 2))[1];
$mes = substr($res, 3);
$msg = "実行に失敗: {$code} - {$mes}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
if (ord($res[0]) === 0x00) {
return $this->parseOkPacket($res);
}
return $this->parseResultSet($res);
}
/**
* プリペアドステートメントの解放
*
* 指定されたステートメントIDを解放し、リソースをクリーンアップします。
*
* @param int $statementId プリペアドステートメントID
* @return bool 成功時はtrue
*/
public function demolish(int $statementId): bool {
if (!MYSQL_ENABLED || !isset($this->prepared[$statementId])) return false;
$data = chr(0x19).pack('V', $statementId); // COM_STMT_CLOSE
$this->sendPacket($data);
unset($this->prepared[$statementId]);
return true;
}
/**
* SQLクエリの実行
*
* COM_QUERYを使用してSQLクエリを実行し、結果を返します。
*
* @param string $query 実行するSQLクエリ
* @return array 結果セットまたはOKパケットデータ
* @throws \Exception クエリ実行に失敗した場合
*/
public function query(string $query): array {
if (!MYSQL_ENABLED) return [];
$this->sendCommand(0x03, $query); // COM_QUERY
$res = $this->readPacket();
if (ord($res[0]) === 0xFF) {
$code = unpack('v', substr($res, 1, 2))[1];
$mes = substr($res, 3);
$msg = "クエリに失敗: {$code} - {$mes}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
// レスポンスは0x00で始まったら、OKパケットだ
if (ord($res[0]) === 0x00) {
return $this->parseOkPacket($res);
}
// レスポンスは0xFBで始まったら、、 LOCAL INFILEリクエストだ
// @todo LOCAL INFILEリクエストの処理を実装
if (ord($res[0]) === 0xFB) {
$msg = "LOCAL INFOリクエストは未対応です";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
return $this->parseResultSet($res);
}
/**
* パケットログをファイルに保存する
*
* デバッグ用に収集したパケットログを指定されたファイルに保存します。
*
* @param string $filename 保存先ファイル名
* @return bool|int 成功時は書き込んだバイト数、失敗時はfalse
*/
public function savePacketLogToFile(string $filename): bool|int {
if (!MYSQL_ENABLED) return 0;
$output = '';
foreach ($this->packetLog as $index => $packetInfo) {
$direction = $packetInfo['direction'];
$length = $packetInfo['length'];
$seqNum = $packetInfo['seqNum'];
$data = $packetInfo['data'];
$timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']);
$output .= "=== パケット #{$index} ({$timestamp}) {$direction}
$output .= (長さ: {$length}, シーケンス: {$seqNum}) ===\n";
$output .= "16進数: ".$this->hexDump($data)."\n";
$output .= "ASCII: ".$this->asciiDump($data)."\n";
$output .= "==========================================\n\n";
}
return file_put_contents(ROOT.'/log/'.$filename, $output);
}
// 機能性メソッド
/**
* MySQLサーバーで認証する
*
* クライアント機能フラグと認証情報を送信してサーバー認証を行います。
*
* @return bool 認証成功時はtrue
* @throws \Exception 認証に失敗した場合
*/
private function authenticate(): bool {
/**
* CLIENT_LONG_PASSWORD 0x00000001
* CLIENT_PROTOCOL_41 0x00000200
* CLIENT_SECURE_CONNECTION 0x00008000
* CLIENT_CONNECT_WITH_DB 0x00000800
*
* 0x00020D05 = CLIENT_LONG_PASSWORD | CLIENT_PROTOCOL_41 |
* CLIENT_SECURE_CONNECTION | CONNECT_WITH_DB
*/
$data = '';
$data .= pack('L', 0x00020D05); // クライアント機能フラグ
$data .= pack('L', 16777216); // パケットサイズの大きさ
$data .= chr(33); // チャーセット(33 = utf8_general_ci)
$data .= str_repeat("\0", 23); // 予約バイト
$data .= $this->username."\0"; // ユーザー名
// パスワード
if (empty($this->password)) {
$data .= "\0"; // 空
} else {
$pw = $this->scramblePassword($this->password, $this->serverInfo['scramble']);
$data .= chr(strlen($pw)).$pw;
}
// データベース名
if (!empty($this->dbname)) {
$data .= $this->dbname."\0";
}
// 認証パケットを送信する
$this->sendPacket($data, 1);
// サーバー返事を送る
$res = $this->readPacket();
if (ord($res[0]) === 0xFF) {
$code = unpack('v', substr($res, 1, 2))[1];
$mes = substr($res, 3);
$this->close();
$msg = "認証に失敗: {$code} - {$mes}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
return true;
}
/**
* サーバーから結果セットを解析する
*
* クエリ結果のフィールドと行データを解析して返します。
*
* @param string $firstPacket 最初の結果セットパケット
* @return array フィールドと行データの配列
* @throws \Exception EOFパケットが期待通りに受信できない場合
*/
private function parseResultSet(string $firstPacket): array {
$fieldCnt = ord($firstPacket[0]);
$fields = [];
for ($i = 0; $i < $fieldCnt; $i++) {
$fieldPacket = $this->readPacket();
$fields[] = $this->parseFieldPacket($fieldPacket);
}
$eofPacket = $this->readPacket();
if (ord($eofPacket[0]) !== 0xFE) {
$msg = "フィールド説明の後にEOFパケットが期待されます";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$rows = [];
while (true) {
$rowPacket = $this->readPacket();
// 行データの終了を示すEOFパケットを確認
if (ord($rowPacket[0]) === 0xFE && strlen($rowPacket) < 9) break;
$rows[] = $this->parseRowPacket($rowPacket, $fields);
}
return [
'fields' => $fields,
'rows' => $rows,
];
}
/**
* フィールドパケットを解析する
*
* フィールドのメタデータを解析して返します。
*
* @param string $packet フィールドパケット
* @return array フィールドのメタデータ
*/
private function parseFieldPacket(string $packet): array {
$pos = 0;
$field = [];
// カタログのスキップ(def等)
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['catalog'] = substr($packet, $pos, $len);
$pos += 1 + $len;
// データベース名
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['db'] = substr($packet, $pos, $len);
$pos += $len;
// テーブル名
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['table'] = substr($packet, $pos, $len);
$pos += $len;
// 元のテーブル名
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['org_table'] = substr($packet, $pos, $len);
$pos += 1 + $len;
// フィールド名
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['name'] = substr($packet, $pos, $len);
$pos += $len;
// 元のフィールド名
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['org_name'] = substr($packet, $pos, $len);
$pos += $len;
// フィルターバイトをスキップ(通常は0x0C)
$pos += 1;
// 文字セット
$field['charset'] = unpack('v', substr($packet, $pos, 2))[1];
$pos += 2;
// 列の長さ
$field['length'] = unpack('V', substr($packet, $pos, 4))[1];
$pos += 4;
// フィールド種類
$field['type'] = ord($packet[$pos]);
$pos += 1;
// フラグ
$field['flags'] = unpack('v', substr($packet, $pos, 2))[1];
$pos += 2;
// 小数点以下の桁数
$field['decimals'] = ord($packet[$pos]);
$pos += 1;
// フィルターバイトをスキップ
$pos += 2;
// デフォルト値(存在する場合、長さエンコード文字列)
if ($pos < strlen($packet)) {
$len = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$field['default'] = substr($packet, $pos, $len);
}
return $field;
}
/**
* 行パケットを解析する
*
* 結果セットの行データを解析して返します。
*
* @param string $packet 行パケット
* @param array $fields フィールドメタデータの配列
* @return array 行データの連想配列
*/
private function parseRowPacket(string $packet, array $fields): array {
$pos = 0;
$row = [];
foreach ($fields as $field) {
// 0xFB = NULL
if (ord($packet[$pos]) === 0xFB) {
$row[$field['name']] = null;
$pos++;
continue;
}
// 長さ
$len = ord($packet[$pos]);
$pos++;
$row[$field['name']] = substr($packet, $pos, $len);
$pos += $len;
}
return $row;
}
/**
* OKパケットを解析する
*
* OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。
*
* @param string $packet OKパケット
* @return array OKパケットのデータ
* @throws \Exception パケットが不完全な場合
*/
private function parseOkPacket(string $packet): array {
if (strlen($packet) < 2) {
$msg = "OKパケットが短すぎます: " . strlen($packet) . "バイト";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$pos = 1; // ヘッダーバイト(0x00)をスキップする
$affectedRows = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($affectedRows);
$insertId = $this->getLengthEncodedIntegerValue($packet, $pos);
$pos += $this->getLengthEncodedIntegerSize($insertId);
if (strlen($packet) < $pos + 2) {
$msg = "OKパケットにサーバーステータス用のデータが不足しています";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$serverStatus = unpack('v', substr($packet, $pos, 2))[1];
$pos += 2;
if (strlen($packet) < $pos + 2) {
$msg = "OKパケットに警告カウント用のデータが不足しています";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
$warningCount = unpack('v', substr($packet, $pos, 2))[1];
return [
'affectedRows' => $affectedRows,
'insertId' => $insertId,
'serverStatus' => $serverStatus,
'warningCount' => $warningCount,
];
}
/**
* MySQLサーバーからパケットを読み込む
*
* ソケットからパケットを読み込み、完全なデータを受信するまで待機します。
*
* @return string 受信したパケットデータ
* @throws \Exception 読み込みに失敗した場合
*/
private function readPacket(): string {
$header = ''; // パケットのヘッダー=4バイト
$bytesRead = socket_recv($this->socket, $header, 4, MSG_WAITALL);
if ($bytesRead !== 4) {
$msg = "パケットヘッダーの読み込みに失敗: 期待 4 バイト, 取得 {$bytesRead}";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
// パケットの長さを最初3バイトからパーシングする
$len = ord($header[0]) + (ord($header[1]) << 8) + (ord($header[2]) << 16);
// パケットの順序番号は第4目のバイト
$seqNum = ord($header[3]);
// パケットの内容を読み込む
$data = '';
$remaining = $len;
$timeout = 5;
socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, [
'sec' => $timeout,
'usec' => 0,
]);
while ($remaining > 0) {
$buffer = '';
$bytesRead = socket_recv($this->socket, $buffer, $remaining, 0);
if ($bytesRead === false) {
$msg = "パケット内容の読み込みに失敗: エラー "
.socket_strerror(socket_last_error($this->socket));
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
if ($bytesRead === 0) {
usleep(10000);
continue;
}
$data .= $buffer;
$remaining -= $bytesRead;
}
if (ord($data[0]) === 0x00 && strlen($data) < 7) {
$extra = '';
$extraBytes = socket_recv($this->socket, $extra, 7 - strlen($data), 0);
if ($extraBytes !== false && $extraBytes > 0) {
$data .= $extra;
}
}
// デバッグ
if ($this->debug) {
$packetInfo = [
'direction' => 'RECV',
'length' => $len,
'seqNum' => $seqNum,
'data' => $data,
'timestamp' => microtime(true),
];
$this->logPacket($packetInfo);
}
return $data;
}
/**
* MySQLサーバーにパケットを送信する
*
* 指定されたデータとシーケンス番号でパケットを送信します。
*
* @param string $data 送信するデータ
* @param int $seqNum シーケンス番号(デフォルトは0)
* @return bool 成功時はtrue
* @throws \Exception 送信に失敗した場合
*/
private function sendPacket(string $data, $seqNum = 0): bool {
$len = strlen($data);
// パケットヘッダー:長さ=3バイト、順序番号=1バイト
$header = chr($len & 0xFF)
.chr(($len >> 8) & 0xFF)
.chr(($len >> 16) & 0xFF)
.chr($seqNum);
// デバッグ
if ($this->debug) {
$packetInfo = [
'direction' => 'SEND',
'length' => $len,
'seqNum' => $seqNum,
'data' => $data,
'timestamp' => microtime(true),
];
$this->logPacket($packetInfo);
}
// ヘッダーの送信
$sent = socket_write($this->socket, $header, 4);
if ($sent !== 4) {
$msg = "パケットヘッダーの送信に失敗";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
// データの送信
$sent = socket_write($this->socket, $data, $len);
if ($sent !== $len) {
$msg = "パケットデータの送信に失敗";
logger(\LogType::MySQL, $msg);
throw new \Exception($msg);
}
return true;
}
/**
* MySQLサーバーにコマンドを送信する
*
* 指定されたコマンドとデータを送信します。
*
* @param string $command コマンド(例: 0x03 = COM_QUERY)
* @param string $data 付加データ(デフォルトは空)
* @return bool 成功時はtrue
* @throws \Exception 送信に失敗した場合
*/
private function sendCommand(string $command, string $data = ''): bool {
$packet = chr($command).$data;
return $this->sendPacket($packet);
}
/**
* サーバーの挨拶パケットを解析する
*
* サーバーからの初期挨拶パケットを解析し、サーバー情報を保存します。
*
* @param string $packet 挨拶パケット
* @return void
*/
private function parseServerGreeting(string $packet): void {
$pos = 0;
// プロトコールバージョン(1バイト)
$this->serverInfo['protocol'] = ord($packet[$pos]);
$pos++;
// サーバーバージョン
$end = strpos($packet, "\0", $pos);
$this->serverInfo['version'] = substr($packet, $pos, $end - $pos);
$pos = $end + 1;
// スレッドID(4バイト)
$this->serverInfo['threadId'] = unpack('V', substr($packet, $pos, 4))[1];
$pos += 4;
// スクランブルバッファの最初の部分(8バイト)
$this->serverInfo['scramble'] = substr($packet, $pos, 8);
$pos += 8;
// フィルターバイトをスキップする
$pos++;
// サーバー機能(2バイト)
$this->serverInfo['capabilities'] = unpack('v', substr($packet, $pos, 2))[1];
$pos += 2;
// サーバー言語(1バイト)
$this->serverInfo['language'] = ord($packet[$pos]);
$pos++;
// サーバー状況(2バイト)
$this->serverInfo['status'] = unpack('v', substr($packet, $pos, 2))[1];
$pos += 2;
// 13バイトのスキップ
$pos += 13;
// その他(12バイト)
$this->serverInfo['scramble'] .= substr($packet, $pos, 12);
}
/**
* パスワードをスクランブルする
*
* MySQL認証用のパスワードをスクランブルします。
*
* @param string $password プレーンテキストのパスワード
* @param string $scramble サーバーから提供されたスクランブル文字列
* @return string スクランブルされたパスワード(20バイト)
*/
private function scramblePassword(string $password, string $scramble): string {
$stage1 = sha1($password, true);
$stage2 = sha1($stage1, true);
$stage3 = sha1($scramble.$stage2, true);
// $stage1 XOR $stage3
$res = '';
for ($i = 0; $i < 20; $i++) {
$res .= chr(ord($stage1[$i]) ^ ord($stage3[$i]));
}
return $res;
}
/**
* デバッグのためにパケットをログする
*
* パケット情報をログに追加し、デバッグ出力を表示します。
*
* @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ)
* @return void
*/
private function logPacket(array $packetInfo): void {
$this->packetLog[] = $packetInfo;
$direction = $packetInfo['direction'];
$length = $packetInfo['length'];
$seqNum = $packetInfo['seqNum'];
$data = $packetInfo['data'];
echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n";
echo $this->hexDumpWithAscii($data)."\n";
$this->interpretPacket($data);
echo "==========================================\n\n";
}
/**
* バイナリデータを16進数で出力する
*
* デバッグ用にデータを16進数形式で表示します。
*
* @param string $data バイナリデータ
* @return string 16進数文字列
*/
private function hexDump(string $data): string {
$res = '';
$len = strlen($data);
for ($i = 0; $i < $len; $i++) {
$res .= sprintf('%02X ', ord($data[$i]));
// 読み易さの為、各16バイトで新行列を入る
if (($i + 1) % 16 === 0 && $i !== $len - 1) {
$res .= "\n";
}
}
return $res;
}
/**
* バイナリデータをASCIIで出力する
*
* デバッグ用にデータをASCII形式で表示します。
*
* @param string $data バイナリデータ
* @return string ASCII文字列
*/
private function asciiDump(string $data): string {
$res = '';
$len = strlen($data);
for ($i = 0; $i < $len; $i++) {
$char = ord($data[$i]);
// 表示出来るASCII文字だけを書き出す
if ($char >= 32 && $char <= 126) {
$res .= $data[$i];
} else {
$res .= '.';
}
// 読み易さの為、各16バイトで新行列を入る
if (($i + 1) % 16 === 0 && $i !== $len - 1) {
$res .= "\n";
}
}
return $res;
}
/**
* バイナリデータを2進数で出力する
*
* デバッグ用にデータを2進数形式で表示します。
*
* @param string $data バイナリデータ
* @return string 2進数文字列
*/
private function binaryDump(string $data): string {
$res = '';
$len = strlen($data);
for ($i = 0; $i < $len; $i++) {
$res .= sprintf('%08b ', ord($data[$i]));
// 読み易さの為、各88バイトで新行列を入る
if (($i + 1) % 8 === 0 && $i !== $len - 1) {
$res .= "\n";
}
}
return $res;
}
/**
* パケットを最初のバイトに基づいて解釈する
*
* パケットの種類を特定し、デバッグ情報を出力します。
*
* @param string $data パケットデータ
* @return void
*/
private function interpretPacket(string $data): void {
if (empty($data)) {
echo "解釈: 空パケット\n";
return;
}
$firstByte = ord($data[0]);
switch ($firstByte) {
case 0x00:
echo "解釈: OKパケット\n";
$this->debugOkPacket($data);
break;
case 0x17:
echo "解釈: COM_STMT_EXECUTEパケット\n";
$this->debugStmtExecutePacket($data);
break;
case 0xFF:
echo "解釈: エラーパケット\n";
$this->debugErrorPacket($data);
break;
case 0xFE:
echo "解釈: EOFパケット\n";
break;
case 0xFB:
echo "解釈: LOCAL INFILEリクエスト\n";
break;
default:
if ($firstByte === 3
&& $data[1] === 'd'
&& $data[2] === 'e'
&& $data[3] === 'f') {
// フィールドパケットかどうかの確認
echo "解釈: フィールド説明パケット\n";
$this->debugFieldPacket($data);
} else if ($firstByte > 0 && $firstByte < 251) {
// 結果セットパケットかどうかの確認
echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n";
} else {
// 以上じゃないと、列データパケットでかもしん
echo "解釈: 列データパケット又はその他のパケット種類\n";
$this->debugLengthEncodedStrings($data);
}
break;
}
}
/**
* OKパケット構造をデバッグする
*
* OKパケットの内容を解析し、デバッグ情報を出力します。
*
* @param string $data OKパケットデータ
* @return void
*/
private function debugOkPacket(string $data): void {
if (strlen($data) < 2) {
echo ' Error: OK packet too short ('.strlen($data)." bytes)\n";
return;
}
$pos = 1; // ヘッダーバイトをスキップ
// 影響を受けた行数を取得
$affectedRows = $this->getLengthEncodedIntegerValue($data, $pos);
echo " 影響を受けた行数: {$affectedRows}\n";
$pos += $this->getLengthEncodedIntegerSize($affectedRows);
// 最後の挿入IDを取得
$insertId = $this->getLengthEncodedIntegerValue($data, $pos);
echo " 最後の挿入ID: {$insertId}\n";
$pos += $this->getLengthEncodedIntegerSize($insertId);
// サーバーステータス
if (strlen($data) >= $pos + 2) {
$serverStatus = unpack('v', substr($data, $pos, 2))[1];
echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n";
$pos += 2;
} else {
echo " サーバーステータス: 利用不可\n";
}
// 警告カウント
if (strlen($data) >= $pos + 2) {
$warningCount = unpack('v', substr($data, $pos, 2))[1];
echo " 警告カウント: {$warningCount}\n";
$pos += 2;
} else {
echo " 警告カウント: 利用不可\n";
}
// サーバーメッセージ(存在する場合)
if (strlen($data) > $pos) {
$message = substr($data, $pos);
echo " メッセージ: ".$this->safeString($message)."\n";
}
}
/**
* エラーパケット構造をデバッグする
*
* エラーパケットの内容を解析し、デバッグ情報を出力します。
*
* @param string $data エラーパケットデータ
* @return void
*/
private function debugErrorPacket(string $data): void {
$errorCode = unpack('v', substr($data, 1, 2))[1];
echo " エラーコード: {$errorCode}\n";
// SQLステートマーカーをスキップ(#)
$sqlState = substr($data, 4, 5);
echo " SQLステート: {$sqlState}\n";
$errorMessage = substr($data, 9);
echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n";
}
/**
* フィールドパケット構造をデバッグする
*
* フィールドパケットの内容を解析し、デバッグ情報を出力します。
*
* @param string $data フィールドパケットデータ
* @return void
*/
private function debugFieldPacket(string $data): void {
$pos = 0;
// パケットから長さエンコード文字列を抽出
echo " フィールドパケット構造:\n";
// カタログを抽出
$len = $this->getLengthEncodedIntegerValue($data, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$catalog = substr($data, $pos, $len);
echo " カタログ: " . $this->safeString($catalog) . " (長さ: $len)\n";
$pos += $len;
// データベースを抽出
$len = $this->getLengthEncodedIntegerValue($data, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$database = substr($data, $pos, $len);
echo " データベース: " . $this->safeString($database) . " (長さ: $len)\n";
$pos += $len;
// テーブルを抽出
$len = $this->getLengthEncodedIntegerValue($data, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$table = substr($data, $pos, $len);
echo " テーブル: " . $this->safeString($table) . " (長さ: $len)\n";
$pos += $len;
// 元のテーブルを抽出
$len = $this->getLengthEncodedIntegerValue($data, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$orgTable = substr($data, $pos, $len);
echo " 元のテーブル: " . $this->safeString($orgTable) . " (長さ: $len)\n";
$pos += $len;
// 名前を抽出
$len = $this->getLengthEncodedIntegerValue($data, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$name = substr($data, $pos, $len);
echo " 名前: " . $this->safeString($name) . " (長さ: $len)\n";
$pos += $len;
// 元の名前を抽出
$len = $this->getLengthEncodedIntegerValue($data, $pos);
$pos += $this->getLengthEncodedIntegerSize($len);
$orgName = substr($data, $pos, $len);
echo " 元の名前: " . $this->safeString($orgName) . " (長さ: $len)\n";
$pos += $len;
// 次の長さエンコード整数を抽出(固定フィールドの長さ、通常は0x0C)
$fixedLength = $this->getLengthEncodedIntegerValue($data, $pos);
echo " 固定フィールドの長さ: {$fixedLength}\n";
$pos += $this->getLengthEncodedIntegerSize($fixedLength);
// 文字セット
$charSet = unpack('v', substr($data, $pos, 2))[1];
echo " 文字セット: ".sprintf('0x%04X', $charSet)."\n";
$pos += 2;
// 列の長さ
$columnLength = unpack('V', substr($data, $pos, 4))[1];
echo " 列の長さ: {$columnLength}\n";
$pos += 4;
// 列の種類
$columnType = ord($data[$pos]);
echo " 列の種類: ".sprintf('0x%02X', $columnType)."\n";
$pos++;
// フラグ
$flags = unpack('v', substr($data, $pos, 2))[1];
echo " フラグ: ".sprintf('0x%04X', $flags)."\n";
$pos += 2;
// 小数点以下の桁数
$decimals = ord($data[$pos]);
echo " 小数点以下の桁数: {$decimals}\n";
$pos++;
// フィルター
echo " フィルター: ".sprintf('0x%04X', unpack('v', substr($data, $pos, 2))[1])."\n";
}
/**
* パケット内の長さエンコード文字列をデバッグする
*
* パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。
*
* @param string $data パケットデータ
* @return void
*/
private function debugLengthEncodedStrings(string $data): void {
$pos = 0;
$length = strlen($data);
$stringCount = 0;
echo " 長さエンコード文字列:\n";
while ($pos < $length) {
// 長さエンコード文字列を特定
if ($pos >= $length) break;
$firstByte = ord($data[$pos]);
// MySQLプロトコルに基づく長さエンコーディング
if ($firstByte < 251) {
// 1バイト長
$len = $firstByte;
$pos++;
if ($pos + $len <= $length) {
$value = substr($data, $pos, $len);
echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
." (長さ: {$len})\n";
$pos += $len;
} else {
echo " 位置 {$pos} での無効な長さエンコーディング\n";
break;
}
} else if ($firstByte == 251) {
// NULL値
echo " 文字列 ".(++$stringCount).": NULL\n";
$pos++;
} else if ($firstByte == 252) {
// 2バイト長
if ($pos + 3 <= $length) {
$len = unpack('v', substr($data, $pos + 1, 2))[1];
$pos += 3;
if ($pos + $len <= $length) {
$value = substr($data, $pos, $len);
echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
." (長さ: {$len})\n";
$pos += $len;
} else {
echo " 位置 {$pos} での無効な長さエンコーディング\n";
break;
}
} else {
echo " 位置 {$pos} での不完全な2バイト長\n";
break;
}
} else if ($firstByte == 253) {
// 3バイト長
if ($pos + 4 <= $length) {
$len = unpack('V', substr($data, $pos + 1, 3) . "\0")[1];
$pos += 4;
if ($pos + $len <= $length) {
$value = substr($data, $pos, $len);
echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
." (長さ: {$len})\n";
$pos += $len;
} else {
echo " 位置 {$pos} での無効な長さエンコーディング\n";
break;
}
} else {
echo " 位置 {$pos} での不完全な3バイト長\n";
break;
}
} else if ($firstByte == 254) {
// 8バイト長
if ($pos + 9 <= $length) {
// PHPでは8バイト整数を完全に扱えないため、最初の4バイトのみ読み取る
$len = unpack('V', substr($data, $pos + 1, 4))[1];
$pos += 9;
if ($pos + $len <= $length) {
$value = substr($data, $pos, $len);
echo " 文字列 ".(++$stringCount).": ".$this->safeString($value)
." (長さ: {$len})\n";
$pos += $len;
} else {
echo " 位置 {$pos} での無効な長さエンコーディング\n";
break;
}
} else {
echo " 位置 {$pos} での不完全な8バイト長\n";
break;
}
} else {
// 長さエンコード文字列でない場合、次のバイトへ
$pos++;
}
}
if ($stringCount === 0) {
echo " No length-encoded strings found\n";
}
}
/**
* 文字列を安全に出力用に変換する
*
* 表示可能な文字のみを含み、非表示文字は16進数で表現します。
*
* @param string $str 変換する文字列
* @return string 安全な文字列
*/
private function safeString(string $str): string {
$result = '';
$length = strlen($str);
for ($i = 0; $i < $length; $i++) {
$char = ord($str[$i]);
if ($char >= 32 && $char <= 126) {
$result .= $str[$i];
} else {
$result .= '\\x' . sprintf('%02X', $char);
}
}
return $result;
}
/**
* 指定位置の長さエンコード整数値を取得する
*
* パケット内の長さエンコード整数を読み取ります。
*
* @param string $data パケットデータ
* @param int $pos 開始位置
* @return mixed 整数値または0(不明な場合)
*/
private function getLengthEncodedIntegerValue(string $data, int $pos): mixed {
$firstByte = ord($data[$pos]);
if ($firstByte < 251) {
return $firstByte;
} else if ($firstByte == 252) {
return unpack('v', substr($data, $pos + 1, 2))[1];
} else if ($firstByte == 253) {
return unpack('V', substr($data, $pos + 1, 3) . "\0")[1];
} else if ($firstByte == 254) {
// 簡略化のため、8バイト整数の最初の4バイトのみ読み取る
return unpack('V', substr($data, $pos + 1, 4))[1];
}
return 0;
}
/**
* 長さエンコード整数のサイズを取得する
*
* 値に基づいて長さエンコードに必要なバイト数を返します。
*
* @param int $value 整数値
* @return int バイト数
*/
private function getLengthEncodedIntegerSize(int $value): int {
if ($value < 251) {
return 1;
} else if ($value < 65536) {
return 3; // 0xFCマーカー1バイト + 値2バイト
} else if ($value < 16777216) {
return 4; // 0xFDマーカー1バイト + 値3バイト
} else {
return 9; // 0xFEマーカー1バイト + 値8バイト
}
}
/**
* 整数を長さエンコード形式に変換する
*
* MySQLプロトコルに基づいて整数を長さエンコードします。
*
* @param int $value 変換する整数
* @return string 長さエンコードされた文字列
*/
private function encodeLengthEncodedInteger(int $value): string {
if ($value < 251) {
return chr($value);
} else if ($value < 65536) {
return chr(0xFC).pack('v', $value);
} else if ($value < 16777216) {
return chr(0xFD).pack('V', $value & 0xFFFFFF);
} else {
return chr(0xFE).pack('P', $value);
}
}
/**
* STMTパケットをデバッグする
*
* COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。
*
* @param string $data パケットデータ
* @return void
*/
private function debugStmtExecutePacket(string $data): void {
$pos = 1; // コマンドバイトをスキップ
$statementId = unpack('V', substr($data, $pos, 4))[1];
echo " ステートメントID: {$statementId}\n";
$pos += 5; // ステートメントIDとカーソルフラグをスキップ
$iterationCount = unpack('V', substr($data, $pos, 4))[1];
echo " 繰り返し数: {$iterationCount}\n";
$pos += 4;
$numParams = $this->prepared[$statementId]['num_params'] ?? 0;
if ($numParams > 0) {
$nullBitmapLen = ceil($numParams / 8);
$nullBitmap = substr($data, $pos, $nullBitmapLen);
echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n";
$pos += $nullBitmapLen;
$newParamsFlag = ord($data[$pos]);
echo " 新パラメータフラグ: {$newParamsFlag}\n";
$pos++;
if ($newParamsFlag) {
echo " パラメータ種類:\n";
for ($i = 0; $i < $numParams; $i++) {
$type = unpack('v', substr($data, $pos, 2))[1];
$typeName = $this->getMysqlTypeName($type);
echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n";
$pos += 2;
}
echo " パラメータ値: (生バイナリが続く)\n";
}
}
}
/**
* 16進値をMySQLタイプ名に変換する
*
* MySQLのデータ型コードを対応する型名に変換します。
*
* @param int $type 16進数の型コード
* @return string 型名(不明な場合は'UNKNOWN')
*/
private function getMysqlTypeName(int $type): string {
$types = [
0x00 => 'MYSQL_TYPE_DECIMAL',
0x01 => 'MYSQL_TYPE_TINY',
0x02 => 'MYSQL_TYPE_SHORT',
0x03 => 'MYSQL_TYPE_LONG',
0x04 => 'MYSQL_TYPE_FLOAT',
0x05 => 'MYSQL_TYPE_DOUBLE',
0x06 => 'MYSQL_TYPE_NULL',
0x07 => 'MYSQL_TYPE_TIMESTAMP',
0x08 => 'MYSQL_TYPE_LONGLONG',
0x09 => 'MYSQL_TYPE_INT24',
0x0A => 'MYSQL_TYPE_DATE',
0x0B => 'MYSQL_TYPE_TIME',
0x0C => 'MYSQL_TYPE_DATETIME',
0x0D => 'MYSQL_TYPE_YEAR',
0x0E => 'MYSQL_TYPE_NEWDATE',
0x0F => 'MYSQL_TYPE_VARCHAR',
0x10 => 'MYSQL_TYPE_BIT',
0xF6 => 'MYSQL_TYPE_NEWDECIMAL',
0xF7 => 'MYSQL_TYPE_ENUM',
0xF8 => 'MYSQL_TYPE_SET',
0xF9 => 'MYSQL_TYPE_TINY_BLOB',
0xFA => 'MYSQL_TYPE_MEDIUM_BLOB',
0xFB => 'MYSQL_TYPE_LONG_BLOB',
0xFC => 'MYSQL_TYPE_BLOB',
0xFD => 'MYSQL_TYPE_VAR_STRING',
0xFE => 'MYSQL_TYPE_STRING',
0xFF => 'MYSQL_TYPE_GEOMETRY',
];
return $types[$type] ?? '不明';
}
/**
* 16進数とASCII値を横に並べて出力する
*
* 16進エディタのように、16進数とASCII値を並べて表示します。
*
* @param string $data バイナリデータ
* @return string フォーマットされた文字列
*/
private function hexDumpWithAscii(string $data): string {
$output = '';
$len = strlen($data);
$offset = 0;
while ($offset < $len) {
$hex = '';
$ascii = '';
$bytesInLine = min(16, $len - $offset); // 1行あたり16バイト
// 16進数部分
for ($i = 0; $i < 16; $i++) {
if ($i < $bytesInLine) {
$hex .= sprintf('%02X ', ord($data[$offset + $i]));
} else {
$hex .= ' '; // 揃えのためにスペースを埋める
}
}
// ASCII部分
for ($i = 0; $i < $bytesInLine; $i++) {
$char = ord($data[$offset + $i]);
$ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.';
}
$output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii);
$offset += 16;
}
return $output;
}
}