QQWry.dat (纯真 IP 資料庫) 是國內常用的離線 IP 地址庫,包含 IP 範圍對應的國家、地區、城市、ISP 等資訊。格式為二進位,需特定解析演算法。
<?php
/**
* QQWry.dat 解析器
* 支援 IP 查詢地理位置 (國家/地區)
*/
class IpLocation
{
private $fp;
private $firstIpOffset;
private $lastIpOffset;
private $totalRecords;
public function __construct(string $datFile = 'qqwry.dat')
{
if (!file_exists($datFile)) {
throw new InvalidArgumentException("IP 資料庫檔案不存在: $datFile");
}
$this->fp = fopen($datFile, 'rb');
if (!$this->fp) {
throw new RuntimeException("無法開啟 IP 資料庫: $datFile");
}
// 檔案頭:前 4 bytes 為第一條記錄偏移,後 4 bytes 為最後一條記錄偏移
$this->firstIpOffset = $this->readLong();
$this->lastIpOffset = $this->readLong();
$this->totalRecords = ($this->lastIpOffset - $this->firstIpOffset) / 7; // 每條索引 7 bytes
}
// 讀取 4 bytes 無號長整數 (Little Endian)
private function readLong(): int
{
$bytes = fread($this->fp, 4);
return unpack('V', $bytes)[1];
}
// 讀取 3 bytes 偏移量 (Little Endian + 補 0)
private function readLong3(): int
{
$bytes = fread($this->fp, 3) . "\x00";
return unpack('V', $bytes)[1];
}
// 讀取以 \0 結尾的字串 (GB2312 → UTF-8)
private function readString(string $prefix = ''): string
{
$str = $prefix;
while (($char = fread($this->fp, 1)) !== false) {
if ($char === "\x00") break;
$str .= $char;
}
return mb_convert_encoding($str, 'UTF-8', 'GB2312');
}
// 讀取區域資訊 (處理重定向)
private function readArea(): string
{
$flag = fread($this->fp, 1);
if ($flag === false) return '';
switch (ord($flag)) {
case 0: // 無區域資訊
return '';
case 1: // 區域重定向
case 2: // 國家+區域重定向
fseek($this->fp, $this->readLong3());
return $this->readString();
default: // 直接儲存區域字串
return $this->readString($flag);
}
}
// IP 字串轉 4 bytes Big Endian (用於比較)
private function packIp(string $ip): string
{
return pack('N', ip2long($ip));
}
// 核心查詢:二分搜尋
public function getLocation(string $ip): ?array
{
if (!$this->fp) return null;
// 域名解析
$ipAddr = gethostbyname($ip);
if ($ipAddr === $ip && !filter_var($ip, FILTER_VALIDATE_IP)) {
return null; // 無效 IP/域名
}
$packedIp = $this->packIp($ipAddr);
// 二分搜尋索引區
$low = 0;
$high = $this->totalRecords;
$foundOffset = $this->lastIpOffset; // 預設最後一條 (版本資訊)
while ($low <= $high) {
$mid = (int)(($low + $high) / 2);
fseek($this->fp, $this->firstIpOffset + $mid * 7);
$midStartIp = strrev(fread($this->fp, 4)); // Little Endian → Big Endian
if ($packedIp < $midStartIp) {
$high = $mid - 1;
} else {
// 讀取結束 IP
$midEndIpOffset = $this->readLong3();
fseek($this->fp, $midEndIpOffset);
$midEndIp = strrev(fread($this->fp, 4));
if ($packedIp > $midEndIp) {
$low = $mid + 1;
} else {
// 命中範圍
$foundOffset = $this->firstIpOffset + $mid * 7;
break;
}
}
}
// 解析命中記錄
fseek($this->fp, $foundOffset);
$location = [
'ip' => $ipAddr,
'beginIp' => long2ip($this->readLong()),
];
$dataOffset = $this->readLong3();
fseek($this->fp, $dataOffset);
$location['endIp'] = long2ip($this->readLong());
// 解析國家/區域 (複雜的重定向邏輯)
$flag = fread($this->fp, 1);
switch (ord($flag)) {
case 1: // 國家+區域皆重定向
$countryOffset = $this->readLong3();
fseek($this->fp, $countryOffset);
$flag2 = fread($this->fp, 1);
if (ord($flag2) === 2) { // 國家再重定向
fseek($this->fp, $this->readLong3());
$location['country'] = $this->readString();
fseek($this->fp, $countryOffset + 4);
} else {
$location['country'] = $this->readString($flag2);
}
$location['area'] = $this->readArea();
break;
case 2: // 國家重定向
fseek($this->fp, $this->readLong3());
$location['country'] = $this->readString();
fseek($this->fp, $dataOffset + 8);
$location['area'] = $this->readArea();
break;
default: // 直接儲存
$location['country'] = $this->readString($flag);
$location['area'] = $this->readArea();
break;
}
// 清理純真特有標記
if (strpos($location['country'], 'CZ88.NET') !== false) $location['country'] = '未知';
if (strpos($location['area'], 'CZ88.NET') !== false) $location['area'] = '';
return $location;
}
public function __destruct()
{
if ($this->fp) fclose($this->fp);
}
}
<?php
require_once 'IpLocation.php';
$iploc = new IpLocation(__DIR__ . '/qqwry.dat');
// 查詢單個 IP
$result = $iploc->getLocation('114.114.114.114');
print_r($result);
/*
Array
(
[ip] => 114.114.114.114
[beginIp] => 114.114.114.0
[endIp] => 114.114.114.255
[country] => 中国
[area] => 南京市 电信DNS服务器
)
*/
// 批次查詢
$ips = ['8.8.8.8', '1.1.1.1', '223.5.5.5', 'invalid'];
foreach ($ips as $ip) {
$loc = $iploc->getLocation($ip);
echo $ip . ' => ' . ($loc ? $loc['country'] . ' ' . $loc['area'] : '查詢失敗') . "\n";
}
| 策略 | 說明 |
|---|---|
| 單例模式 | 解析器實例化一次,複用檔案指標,避免重複開檔 |
| 記憶體映射 | 大檔案可用 mmap 或將索引區載入記憶體陣列 |
| 快取熱點 IP | Redis 快取近期查詢結果,TTL 1 天 |
| 改用 ip2region | B+ 樹索引、二分搜尋更快、支援 xdb 格式、多語言 |