项目背景
最近在一个项目中需要获取访客的 IP 归属地信息。尝试了几个公开的 API,要么无法访问,要么限制频次,要么开始收费。索性自己动手,基于 IP2Region 数据库实现了一个轻量级的本地查询接口。
注意:本方案仅适用于 IPv4 环境。IP2Region 对国内 IPv4 地址的覆盖率和准确度都比较理想,且数据持续更新。
为什么不用 Composer?
IP2Region 官方提供了 PHP 扩展和 Composer 包,但很多虚拟主机环境并不支持 Composer。因此本文采用纯 PHP + 本地 xdb 数据文件的方式,无需任何依赖,上传即可使用。
实现步骤
1. 下载数据库文件
在 /ip 目录下创建 download_db.php,用于从 GitHub 拉取最新的 ip2region.xdb 数据文件(约 10MB)。数据库更新时,重新访问该文件即可完成更新。
<?php
// 文件: /ip/download_db.php
header('Content-Type: text/plain; charset=utf-8');
$dbUrl = 'https://raw.githubusercontent.com/adysec/IP_database/main/ip2region/ip2region.xdb';
$dbFile = __DIR__ . '/ip2region.xdb';
echo "开始下载数据库文件...\n";
echo "源地址: $dbUrl\n";
echo "保存到: $dbFile\n\n";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $dbUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$data = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 && $data && strlen($data) > 1024 * 1024) {
file_put_contents($dbFile, $data);
echo "✓ 下载成功!\n";
echo "文件大小: " . number_format(filesize($dbFile)) . " 字节\n";
echo "文件位置: $dbFile\n";
} else {
echo "✗ 下载失败!HTTP状态码: $httpCode\n";
echo "请手动下载: $dbUrl\n";
}访问 download_db.php 即可自动下载并保存数据库文件到当前目录。
2. 编写查询接口
在 /ip 目录下创建 index.php,用于接收请求、解析客户端 IP、查询归属地并返回 JSON 数据。
<?php
/**
* IP 归属地查询接口
* 基于 IP2Region xdb 数据库(官方推荐算法)
* 返回 JSON 格式:IP、国家、省份、城市、ISP 等
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
/**
* XdbSearcher 类 - 官方 xdb 搜索实现
*/
class XdbSearcher
{
const HeaderInfoLength = 256;
const VectorIndexRows = 256;
const VectorIndexCols = 256;
const VectorIndexSize = 8;
const SegmentIndexSize = 14;
private $dbPath = null;
private $handle = null;
private $header = null;
private $ioCount = 0;
private $vectorIndex = null;
private $cachePolicy = 1; // 1: vector index cache, 2: all cache
public function __construct($dbPath)
{
$this->dbPath = $dbPath;
if (!file_exists($dbPath)) {
throw new Exception("数据库文件不存在: {$dbPath}");
}
$this->handle = fopen($dbPath, 'rb');
if (!$this->handle) {
throw new Exception("无法打开数据库文件: {$dbPath}");
}
$this->initHeader();
$this->initVectorIndex();
}
private function initHeader()
{
fseek($this->handle, 0);
$this->header = fread($this->handle, self::HeaderInfoLength);
if (strlen($this->header) != self::HeaderInfoLength) {
throw new Exception("读取头部信息失败");
}
}
private function initVectorIndex()
{
$vectorIndexLength = self::VectorIndexRows * self::VectorIndexCols * self::VectorIndexSize;
fseek($this->handle, self::HeaderInfoLength);
$vIndex = fread($this->handle, $vectorIndexLength);
if (strlen($vIndex) != $vectorIndexLength) {
throw new Exception("读取向量索引失败");
}
$this->vectorIndex = $vIndex;
}
private function ip2uint($ip)
{
$ipArr = explode('.', $ip);
if (count($ipArr) != 4) {
return 0;
}
return (($ipArr[0] << 24) | ($ipArr[1] << 16) | ($ipArr[2] << 8) | $ipArr[3]) & 0xFFFFFFFF;
}
private function getLong($b, $offset)
{
return unpack('V', substr($b, $offset, 4))[1];
}
private function getShort($b, $offset)
{
return unpack('v', substr($b, $offset, 2))[1];
}
public function search($ip)
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return "0|0|0|0|0";
}
$ipInt = $this->ip2uint($ip);
$il0 = ($ipInt >> 24) & 0xFF;
$il1 = ($ipInt >> 16) & 0xFF;
$vIndex = $il0 * self::VectorIndexCols * self::VectorIndexSize + $il1 * self::VectorIndexSize;
$sPtr = $this->getLong($this->vectorIndex, $vIndex);
$ePtr = $this->getLong($this->vectorIndex, $vIndex + 4);
$dataLen = -1;
$dataPos = -1;
$l = 0;
$h = ($ePtr - $sPtr) / self::SegmentIndexSize;
while ($l <= $h) {
$m = ($l + $h) >> 1;
$p = $sPtr + $m * self::SegmentIndexSize;
fseek($this->handle, $p);
$buffer = fread($this->handle, self::SegmentIndexSize);
if (strlen($buffer) < self::SegmentIndexSize) {
break;
}
$sip = $this->getLong($buffer, 0);
if ($ipInt < $sip) {
$h = $m - 1;
} else {
$eip = $this->getLong($buffer, 4);
if ($ipInt > $eip) {
$l = $m + 1;
} else {
$dataLen = $this->getShort($buffer, 8);
$dataPos = $this->getLong($buffer, 10);
break;
}
}
}
if ($dataLen == -1 || $dataPos == -1) {
return "0|0|0|0|0";
}
fseek($this->handle, $dataPos);
$regionBuff = fread($this->handle, $dataLen);
if (strlen($regionBuff) != $dataLen) {
return "0|0|0|0|0";
}
return $regionBuff;
}
public function close()
{
if ($this->handle) {
fclose($this->handle);
}
}
public function __destruct()
{
$this->close();
}
}
/**
* 获取客户端真实 IP(支持代理、CDN)
*/
function getClientIp()
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$headers = [
'HTTP_CF_CONNECTING_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED'
];
foreach ($headers as $header) {
if (isset($_SERVER[$header]) && $_SERVER[$header]) {
$ipList = explode(',', $_SERVER[$header]);
$firstIp = trim($ipList[0]);
if (filter_var($firstIp, FILTER_VALIDATE_IP)) {
$ip = $firstIp;
break;
}
}
}
// IPv6 映射的 IPv4 处理
if (strpos($ip, '::ffff:') === 0) {
$ip = substr($ip, 7);
}
// 私有地址过滤(必要时从 X-Real-IP 重新获取)
$privateRanges = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8'
];
foreach ($privateRanges as $range) {
if (ip_in_range($ip, $range)) {
if (isset($_SERVER['HTTP_X_REAL_IP'])) {
$realIp = $_SERVER['HTTP_X_REAL_IP'];
if (filter_var($realIp, FILTER_VALIDATE_IP)) {
return $realIp;
}
}
break;
}
}
return $ip;
}
/**
* 检查 IP 是否在 CIDR 范围内
*/
function ip_in_range($ip, $range)
{
list($subnet, $mask) = explode('/', $range);
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
$maskLong = ~((1 << (32 - $mask)) - 1);
return ($ipLong & $maskLong) == ($subnetLong & $maskLong);
}
/**
* 解析归属地原始字符串
*/
function parseLocation($region)
{
$parts = explode('|', $region);
while (count($parts) < 5) {
$parts[] = '';
}
$result = [];
foreach ($parts as $part) {
$part = trim($part);
$result[] = ($part === '0' || $part === '') ? '' : $part;
}
list($country, $regionArea, $province, $city, $isp) = $result;
$addressParts = [];
if ($country && $country !== '0') $addressParts[] = $country;
if ($province && $province !== '0' && $province !== $country) $addressParts[] = $province;
if ($city && $city !== '0' && $city !== $province) $addressParts[] = $city;
if ($isp && $isp !== '0') $addressParts[] = $isp;
$full = implode(' ', $addressParts);
return [
'country' => $country ?: '未知',
'region' => $regionArea ?: '',
'province' => $province ?: '',
'city' => $city ?: '',
'isp' => $isp ?: '',
'full' => $full ?: '未知'
];
}
// ----- 主流程 -----
try {
$dbPath = __DIR__ . '/ip2region.xdb';
if (!file_exists($dbPath)) {
throw new Exception("数据库文件不存在: {$dbPath}");
}
$fileSize = filesize($dbPath);
if ($fileSize < 1024 * 1024) {
throw new Exception("数据库文件大小异常: {$fileSize} 字节");
}
$searcher = new XdbSearcher($dbPath);
$clientIp = getClientIp();
$region = $searcher->search($clientIp);
$searcher->close();
$location = parseLocation($region);
echo json_encode([
'success' => true,
'ip' => $clientIp,
'location' => $location,
'raw' => $region,
'timestamp'=> time()
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} catch (Exception $e) {
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
'timestamp'=> time()
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}使用说明
- 将以上两个文件上传至服务器
/ip目录。 - 浏览器访问
http://你的域名/ip/download_db.php下载数据库文件(只需执行一次,后续数据更新可再次访问)。 - 访问
http://你的域名/ip/index.php即可看到当前访问 IP 的归属地 JSON 结果。 - 其他应用可通过 AJAX 或后端请求该接口获取归属地信息。
返回示例
{
"success": true,
"ip": "113.109.216.75",
"location": {
"country": "中国",
"region": "",
"province": "广东",
"city": "深圳",
"isp": "电信",
"full": "中国 广东 深圳 电信"
},
"raw": "中国|0|广东|深圳|电信",
"timestamp": 1746000000
}注意事项
- 本方案仅支持 IPv4,IPv6 地址会返回
0|0|0|0|0或提示无效。 - 数据库文件约 10MB,请确保服务器磁盘空间充足。
- 如果数据库更新,重新访问
download_db.php即可覆盖旧文件。 - 若 GitHub 原始地址失效,可自行替换为其他可访问的
ip2region.xdb下载链接。
评论 (0)