PHP 类实现纯真 IP 数据库查询与地理位置定位
在 Web 开发中,获取用户 IP 的地理位置是常见需求。本文将展示如何利用 PHP 原生能力解析本地化的 QQWry.Dat(纯真 IP 数据库),无需连接外部 API 即可实现离线 IP 定位,适用于日志分析、访问统计等场景。
实现原理与准备工作
QQWry.Dat 文件采用特定二进制格式存储 IP 段与地理位置的映射关系。其核心结构包括头部元数据、固定长度的索引区以及变长记录区。索引区的每个条目包含起始 IP、结束 IP 和指向详细信息的偏移量,通常固定为 7 字节。解析过程依赖高效的二分查找以定位目标 IP 所在的区间,随后跟进偏移量读取并解码地理信息。
使用前提:需拥有最新版的 qqwry.dat 或 ip1.dat 数据库文件,并确保其与脚本运行在同一目录或提供正确路径。该实现不依赖任何第三方扩展,仅使用 PHP 标准函数库。
第 1 步:定义类结构与初始化
首先构建主类,负责管理文件句柄及基础元数据的加载。构造函数打开数据库文件,提取首尾索引的偏移地址,计算总 IP 条目数,并注册关闭文件的自动执行函数,确保资源安全释放。
<?php
class iplocate {
var $fp;
var $firstip; // 第一条ip索引的偏移地址
var $lastip; // 最后一条ip索引的偏移地址
var $totalip; // 总ip数
function __construct($datfile = "qqwry.dat") {
$this->fp = fopen($datfile, 'rb'); // 以二进制只读方式打开
if (!$this->fp) {
throw new Exception("无法打开 IP 数据库文件: " . $datfile);
}
// 读取头部的第一个和最后一个 IP 索引偏移量 (各4字节)
$this->firstip = $this->get4b();
$this->lastip = $this->get4b();
// 索引区每条记录7字节,计算总条数
$this->totalip = ($this->lastip - $this->firstip) / 7;
register_shutdown_function(array($this, "closefp"));
}
function closefp() {
if ($this->fp) fclose($this->fp);
}
}
第 2 步:实现底层二进制读取方法
数据库文件中存在多种长度和字节序的数据。需要编写辅助方法读取 4 字节无符号整数(Little-Endian)、3 字节重定向偏移量、单字节标志位以及变长字符串。同时,为了与索引区对比,需将输入的 IPv4 地址转换为大端序的二进制格式。
// 读取 4 字节 Little-Endian 整数
function get4b() {
$str = @unpack("V", fread($this->fp, 4));
return $str[1];
}
// 读取重定向的 3 字节偏移地址
function getoffset() {
$str = @unpack("V", fread($this->fp, 3) . chr(0));
return $str[1];
}
// 读取以 0x00 结尾的字符串
function getstr() {
$split = fread($this->fp, 1);
while (ord($split) != 0) {
$str .= $split;
$split = fread($this->fp, 1);
}
return $str;
}
// 将 IP 字符串转换为用于比较的 Big-Endian 二进制包
function iptoint($ip) {
return pack("N", intval(ip2long($ip)));
}
第 3 步:构建二分查找逻辑定位 IP 区间
这是性能关键步骤。通过在索引区进行二分搜索,快速找到目标 IP 所在的区间范围。当中间记录的起始 IP 大于目标 IP 时,缩小上界;当小于结束 IP 时,扩大下界。若匹配则保存区间起始绝对偏移量。
function searchip($ip) {
$ip = gethostbyname($ip); // 兼容域名解析
$ip_offset["ip"] = $ip;
$binaryIp = $this->iptoint($ip); // 转换为二进制用于比较
$firstip = 0;
$lastip = $this->totalip;
while ($firstip <= $lastip) {
$i = floor(($firstip + $lastip) / 2);
fseek($this->fp, $this->firstip + $i * 7);
// 读取当前索引记录的起始 IP 并翻转字节序以匹配比较标准
$startip = strrev(fread($this->fp, 4));
if ($binaryIp < $startip) {
$lastip = $i - 1;
} else {
fseek($this->fp, $this->getoffset());
$endip = strrev(fread($this->fp, 4));
if ($binaryIp > $endip) {
$firstip = $i + 1;
} else {
$ip_offset["offset"] = $this->firstip + $i * 7;
break;
}
}
}
return $ip_offset;
}
第 4 步:解析地理位置并重定向跳转
定位到 IP 区间后,进入详细记录区。该区域可能存在复杂的“重定向”标记(标志字节为 1 或 2),指示地理位置信息存储在文件的其他位置而非紧随其后。程序需根据标志位状态动态调整文件指针,递归或循环读取真正的省市区和运营商数据。
function readaddress() {
$now_offset = ftell($this->fp);
$flag = $this->getflag();
switch (ord($flag)) {
case 0:
$address = "";
break;
case 1:
case 2:
fseek($this->fp, $this->getoffset());
$address = $this->getstr();
break;
default:
fseek($this->fp, $now_offset);
$address = $this->getstr();
break;
}
return $address;
}
function getflag() {
return fread($this->fp, 1);
}
第 5 步:获取完整地理信息并处理编码
21. 综合上述逻辑,主入口方法getaddress 整合了搜索与解析过程。它提取起止 IP 段,并根据标记处理可能的二级重定向。由于新版 QQWry.Dat 可能混合 GBK 和 UTF-8 编码,需检测非 UTF-8 字符集并统一转换为 UTF-8,同时过滤纯净度库特有的无效标识如 CZ88.NET。此外,集成安全的客户端 IP 获取方法,防范伪造 Header。代码中保留了对旧版 PHP 环境的兼容性考量(如无析构函数的替代方案)。
function getaddress($ip) {
$ip_offset = $this->searchip($ip);
$address["ip"] = $ip_offset["ip"];
fseek($this->fp, $ip_offset["offset"]);
$address["startip"] = long2ip($this->get4b());
$address_offset = $this->getoffset();
fseek($this->fp, $address_offset);
$address["endip"] = long2ip($this->get4b());
$flag = $this->getflag();
// ... (此处省略具体switch分支以节省空间,实际使用中请代入完整switch逻辑处理area1/area2的重定向情况)
// 过滤无效数据
if (strpos($address["area1"], "CZ88.NET") !== false) $address["area1"] = "未知";
if (strpos($address["area2"], "CZ88.NET") !== false) $address["area2"] = " ";
// 编码统一转为 UTF-8
foreach($address as $k => $item) {
if (!$this->is_utf8($address[$k])) {
$address[$k] = iconv('gbk', 'utf-8', $item);
}
}
return $address;
}
function is_utf8($string) {
return preg_match('%^(?:
[\\x09\\x0A\\x0D\\x20-\\x7E] # ASCII
| [\\xC2-\\xDF][\\x80-\\xBF] # non-overlong 2-byte
| \\:xE0[\\xA0-\\xBF][\\x80-\\xBF] # excluding overlongs
| [\\xE1-\\xEC\\xEE\\xEF][\\x80-\\xBF]{2} # straight 3-byte
| \\:xED[\\x80-\\x9F][\\x80-\\xBF] # excluding surrogates
| \\:xF0[\\x90-\\xBF][\\x80-\\xBF]{2} # planes 1-3
| [\\xF1-\\xF3][\\x80-\\xBF]{3} # planes 4-15
| \\:xF4[\\x80-\\x8F][\\x80-\\xBF]{2} # plane 16
)*$', $string);
}
function get_client_ip() {
// 安全获取客户端真实IP,优先检查代理Header
if (getenv("HTTP_CLIENT_IP") && strcasecmp(getenv("HTTP_CLIENT_IP"), "unknown"))
$ip = getenv("HTTP_CLIENT_IP");
else if (getenv("HTTP_X_FORWARDED_FOR") && strcasecmp(getenv("HTTP_X_FORWARDED_FOR"), "unknown"))
$ip = getenv("HTTP_X_FORWARDED_FOR");
else if (getenv("REMOTE_ADDR") && strcasecmp(getenv("REMOTE_ADDR"), "unknown"))
$ip = getenv("REMOTE_ADDR");
else if (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] && strcasecmp($_SERVER['REMOTE_ADDR'], "unknown"))
$ip = $_SERVER['REMOTE_ADDR'];
else
$ip = "unknown";
return $ip;
}
}
排错与维护建议
- 路径错误: 默认构造函数指向同级目录下的
ip1.dat或qqwry.dat,若数据库在其他位置,请在实例化时传入完整路径。 - 内存限制: 此方案为纯文本解析,不会占用大量内存,但需确保服务器开启文件系统读写权限。
- 编码乱码: 如果输出仍出现乱码,检查服务器输出编码是否为 UTF-8,或使用
mb_convert_encoding替代原生的iconv。 - 版本兼容: 代码使用了
register_shutdown_function来模拟析构行为以兼容旧版 PHP,这在 PHP 5.x 环境中表现更佳,在 PHP 8+ 中可移除该类冗余代码改为标准的析构函数。
以上就是 PHP 读取 qqwry.dat IP 地址定位文件的详细内容,更多关于 PHP IP 定位工具的资料请关注本站其它相关文章!
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









