首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

PHP 使用 QQWry.dat 純真 IP 庫離線查詢城市完整實作

     編輯:PHP 綜合知識

專案架構

project/
├── ipLocation/
│   ├── qqwry/
│   │   └── QQWry.dat          # 純真 IP 資料庫檔案
│   └── IpCity.php             # 查詢類別

完整查詢類別 (IpCity.php)

<?php
/**
 * 純真 IP 資料庫 (QQWry.dat) 解析類別
 * 支援 IPv4 查詢國家/省份/城市/ISP
 * 資料庫下載: https://github.com/out0fmemory/qqwry.dat
 */
class IpCity
{
    private $fp;
    private $firstIndexOffset;
    private $lastIndexOffset;
    private $totalIndexCount;

    /**
     * 查詢 IP 所屬地區
     * @param string $ip IPv4 位址 (如 114.114.114.114)
     * @param string $datPath 資料庫路徑 (預設同目錄下 qqwry/QQWry.dat)
     * @return string 地區字串 (如 "北京市 海淀區 聯通") 或錯誤訊息
     */
    public function getCity(string $ip, string $datPath = ''): string
    {
        // 1. 驗證 IP 格式
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            return 'IP Address Invalid';
        }

        // 2. 開啟資料庫
        $datPath = $datPath ?: __DIR__ . '/qqwry/QQWry.dat';
        $this->fp = @fopen($datPath, 'rb');
        if (!$this->fp) {
            return 'IP data file not exists or access denied';
        }

        // 3. 讀取索引區塊範圍 (檔案開頭 8 位元組)
        $this->firstIndexOffset = $this->readLong(fread($this->fp, 4));
        $this->lastIndexOffset  = $this->readLong(fread($this->fp, 4));
        $this->totalIndexCount = (int)(($this->lastIndexOffset - $this->firstIndexOffset) / 7 + 1);

        // 4. IP 轉長整數
        $ipNum = $this->ipToLong($ip);

        // 5. 二分搜尋索引
        $locationOffset = $this->binarySearch($ipNum);
        if ($locationOffset === false) {
            fclose($this->fp);
            return 'No Data';
        }

        // 6. 讀取地區資訊
        $area = $this->readArea($locationOffset);
        fclose($this->fp);

        // 7. 清理與編碼轉換 (QQWry 內部為 GBK)
        $area = preg_replace('/CZ88\.Net/is', '', $area);
        $area = trim($area);
        if (preg_match('/http/i', $area) || $area === '') {
            return 'No Data';
        }
        if (!$this->isUtf8($area)) {
            $area = iconv('GBK', 'UTF-8//IGNORE', $area);
        }
        return $area;
    }

    // ===== 私有輔助方法 =====

    private function ipToLong(string $ip): int
    {
        $parts = explode('.', $ip);
        return ($parts[0] << 24) | ($parts[1] << 16) | ($parts[2] << 8) | $parts[3];
    }

    private function readLong(string $bytes): int
    {
        $val = unpack('V', $bytes)[1]; // V = little-endian unsigned long
        return $val < 0 ? $val + 4294967296 : $val; // PHP 整數為有號,修正無號
    }

    private function binarySearch(int $ipNum): int|false
    {
        $low = 0;
        $high = $this->totalIndexCount - 1;

        while ($low <= $high) {
            $mid = (int)(($low + $high) / 2);
            fseek($this->fp, $this->firstIndexOffset + $mid * 7);

            $indexData = fread($this->fp, 7);
            if (strlen($indexData) < 7) return false;

            $startIp = $this->readLong(substr($indexData, 0, 4));
            $offset  = $this->readLong(substr($indexData, 4, 3) . "\x00"); // 3 bytes + padding

            if ($ipNum < $startIp) {
                $high = $mid - 1;
            } else {
                // 讀取下一筆索引的起始 IP 確認範圍
                $nextOffset = $this->firstIndexOffset + ($mid + 1) * 7;
                if ($nextOffset > $this->lastIndexOffset) {
                    return $offset; // 最後一筆
                }
                fseek($this->fp, $nextOffset);
                $nextStartIp = $this->readLong(fread($this->fp, 4));
                if ($ipNum < $nextStartIp) {
                    return $offset; // 找到範圍
                }
                $low = $mid + 1;
            }
        }
        return false;
    }

    private function readArea(int $offset): string
    {
        fseek($this->fp, $offset);
        $flag = fread($this->fp, 1);

        $area1 = '';
        $area2 = '';

        if ($flag === "\x01") {
            // 模式 1: 國家/區域 為重導向
            $redirectOffset = $this->readLong(fread($this->fp, 3) . "\x00");
            fseek($this->fp, $redirectOffset);
            $flag = fread($this->fp, 1);
        }

        if ($flag === "\x02") {
            // 模式 2: 區域為重導向
            $redirectOffset = $this->readLong(fread($this->fp, 3) . "\x00");
            $area2 = $this->readString($redirectOffset);
            fseek($this->fp, $offset + 4); // 回到國家/區域位置
            $area1 = $this->readString(ftell($this->fp));
        } else {
            // 直接模式
            fseek($this->fp, -1, SEEK_CUR);
            $area1 = $this->readString(ftell($this->fp));
            $flag = fread($this->fp, 1);
            if ($flag === "\x02") {
                $redirectOffset = $this->readLong(fread($this->fp, 3) . "\x00");
                $area2 = $this->readString($redirectOffset);
            } else {
                fseek($this->fp, -1, SEEK_CUR);
                $area2 = $this->readString(ftell($this->fp));
            }
        }

        return trim("$area1 $area2");
    }

    private function readString(int $offset): string
    {
        fseek($this->fp, $offset);
        $str = '';
        while (($char = fread($this->fp, 1)) !== "\x00") {
            $str .= $char;
        }
        return $str;
    }

    private function isUtf8(string $str): bool
    {
        return (bool)preg_match('/^[\x00-\x7F]+$/u', $str) || // 純 ASCII
               (bool)preg_match('/^[\xC2-\xDF][\x80-\xBF]+$/u', $str) ||
               (bool)preg_match('/^[\xE0-\xEF][\x80-\xBF]{2}+$/u', $str) ||
               (bool)preg_match('/^[\xF0-\xF4][\x80-\xBF]{3}+$/u', $str);
    }
}

// ===== 使用範例 =====
require_once __DIR__ . '/ipLocation/IpCity.php';

$ipCity = new IpCity();
echo $ipCity->getCity('114.114.114.114'); // 北京市 海淀區 電信
echo $ipCity->getCity('8.8.8.8');         // 美國 加利福尼亞州 聖克拉拉郡 谷歌
echo $ipCity->getCity('172.0.0.1');       // 內網地址 / No Data
?>

資料庫檔案說明

  • 來源:純真網絡 (cz88.net) 維護,社群整理版本可在 GitHub 取得
  • 格式:二進位,小端序,索引區塊 7 位元組/筆 (4 字節起始 IP + 3 字節資料偏移)
  • 更新頻率:建議每月更新一次資料庫檔案
  • 授權:免費供個人/非商業使用,商業用途請聯繫純真網絡授權

效能優化建議

  1. 快取檔案指標:單例模式保持 $fp 開啟,避免重複開關
  2. 記憶體映射:大流量場景可 mmap() 整個 .dat 到記憶體 (需擴充功能)
  3. 批次查詢:排序 IP 後依序查詢,減少磁碟搜尋

現代替代方案

方案優點缺點
MaxMind GeoIP2 (MMDB)標準格式、支援 IPv6、精度高、官方庫需授權、檔案較大
IP2Location功能豐富、多語言 SDK商業收費
IP-API / ipinfo.io (API)免維護、即時更新需網路、有頻率限制、隱私考量
QQWry.dat (本文)免費、離線、中文地區細分度高僅 IPv4、需手動更新、解析複雜

Composer 套件推薦

# 現代 PHP 專案建議使用封裝良好的套件
composer require jasongrimes/qqwry-php
# 或
composer require torann/geoip (支援多種驅動)

離線 IP 查詢在無外網環境、高頻率、隱私敏感場景仍具不可替代價值,掌握 QQWry.dat 解析是 PHP 開發者的實用技能。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved