準確獲取客戶端真實 IP 並解析地理位置是風控、個性化推薦、日誌分析的基礎需求。本文提供生產級 IPLocator 類,支援多層代理穿透、IPv6、新浪 IP 介面、多種輸出格式、快取優化與錯誤處理。
HTTP_X_FORWARDED_FOR、HTTP_X_REAL_IP、HTTP_CLIENT_IP、REMOTE_ADDRhttps://int.dpool.sina.com.cn/iplookup/iplookup.php)<?php
/**
* IP 地址獲取與地理位置解析
* 依賴:curl、json、session (可選)
*/
class IPLocator
{
private array $config = [
'api_url' => 'https://int.dpool.sina.com.cn/iplookup/iplookup.php',
'api_format' => 'json', // json, js
'timeout' => 3.0,
'cache_ttl' => 3600, // 秒,0 為不快取
'cache_prefix' => 'ip_loc_',
'trust_proxies' => true,
'proxy_headers' => [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
],
];
private ?string $clientIp = null;
private array $locationData = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
}
/**
* 獲取客戶端真實 IP
*/
public function getClientIp(): string
{
if ($this->clientIp !== null) return $this->clientIp;
if ($this->config['trust_proxies']) {
foreach ($this->config['proxy_headers'] as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For 可能包含多個 IP,取第一個非私有 IP
$ips = array_map('trim', explode(',', $_SERVER[$header]));
foreach ($ips as $ip) {
if ($this->isValidPublicIp($ip)) {
return $this->clientIp = $ip;
}
}
// 都無效則取第一個
return $this->clientIp = $ips[0];
}
}
}
return $this->clientIp = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
/**
* 判斷是否為合法公網 IP (排除私有/保留段)
*/
private function isValidPublicIp(string $ip): bool
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
// 簡單過濾私有段,完整請用 ip2long/inet_pton 判斷
$privateRanges = [
'10.', '172.16.', '192.168.', '127.', '169.254.', '::1', 'fe80::', 'fc00:', 'fd00:'
];
foreach ($privateRanges as $range) {
if (str_starts_with($ip, $range)) return false;
}
return true;
}
/**
* 獲取地理位置資料
* @param string|null $ip 指定 IP,null 為自動獲取客戶端 IP
* @param string $format 輸出格式: json, array, raw
* @param bool $onlyIp 僅回傳 IP
* @return mixed
*/
public function getLocation(?string $ip = null, string $format = 'json', bool $onlyIp = false): mixed
{
$ip = $ip ?? $this->getClientIp();
if ($onlyIp) {
return $this->formatOutput($ip, $format, true);
}
// 快取檢查
$cacheKey = $this->config['cache_prefix'] . md5($ip);
if ($this->config['cache_ttl'] > 0 && isset($_SESSION[$cacheKey])) {
$cached = $_SESSION[$cacheKey];
if (time() - $cached['time'] < $this->config['cache_ttl']) {
return $this->formatOutput($cached['data'], $format);
}
}
// 呼叫新浪介面
$url = $this->config['api_url'] . '?ip=' . urlencode($ip) . '&format=' . $this->config['api_format'];
$result = $this->httpGet($url);
if ($result === false) {
return $this->formatOutput(['ip' => $ip, 'error' => 'api_request_failed'], $format);
}
// 解析回應
$data = $this->parseResponse($result, $ip);
// 寫入快取
if ($this->config['cache_ttl'] > 0) {
$_SESSION[$cacheKey] = ['time' => time(), 'data' => $data];
}
return $this->formatOutput($data, $format);
}
private function httpGet(string $url): string|false
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => (int)($this->config['timeout'] * 1000),
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_SSL_VERIFYPEER => false, // 新浪介面 HTTP
CURLOPT_USERAGENT => 'IPLocator/1.0',
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
return $errno ? false : $response;
}
private function parseResponse(string $response, string $ip): array
{
// 新浪 JSON 格式: {"ret":1,"start":"...","end":"...","country":"中國","province":"北京","city":"北京","district":"","isp":"","type":"","desc":"..."}
$data = json_decode($response, true);
if (json_last_error() === JSON_ERROR_NONE && isset($data['ret']) && $data['ret'] == 1) {
return [
'ip' => $ip,
'country' => $data['country'] ?? '',
'province' => $data['province'] ?? '',
'city' => $data['city'] ?? '',
'district' => $data['district'] ?? '',
'isp' => $data['isp'] ?? '',
'raw' => $data
];
}
// JS 格式回退解析
// var remote_ip_info = {"ret":1,"country":"中國",...};
if (preg_match('/\{.*\}/', $response, $m)) {
$data = json_decode($m[0], true);
if (json_last_error() === JSON_ERROR_NONE) {
return [
'ip' => $ip,
'country' => $data['country'] ?? '',
'province' => $data['province'] ?? '',
'city' => $data['city'] ?? '',
'district' => $data['district'] ?? '',
'isp' => $data['isp'] ?? '',
'raw' => $data
];
}
}
return ['ip' => $ip, 'error' => 'parse_failed', 'raw_response' => $response];
}
private function formatOutput(mixed $data, string $format, bool $onlyIp = false): mixed
{
if ($onlyIp && is_string($data)) return $data;
return match (strtolower($format)) {
'json' => json_encode($data, JSON_UNESCAPED_UNICODE),
'array' => is_array($data) ? $data : ['ip' => $data],
'raw' => $data,
default => $data
};
}
// 靜態快捷方法
public static function getClientIp(array $config = []): string
{
return (new self($config))->getClientIp();
}
public static function getLocation(?string $ip = null, array $config = [], string $format = 'json'): mixed
{
return (new self($config))->getLocation($ip, $format);
}
}
<?php
session_start(); // 啟用快取需 session
require 'IPLocator.php';
// 僅獲取 IP
$ip = IPLocator::getClientIp();
echo "Client IP: $ip\n";
// 獲取地理位置 (JSON)
$location = IPLocator::getLocation();
echo $location;
// {"ip":"203.208.60.12","country":"中國","province":"北京","city":"北京","district":"","isp":"電信",...}
// 獲取 PHP 陣列
$arr = IPLocator::getLocation(format: 'array');
print_r($arr);
$ips = ['8.8.8.8', '1.1.1.1', '203.208.60.12'];
foreach ($ips as $ip) {
$loc = IPLocator::getLocation($ip, format: 'array');
echo "{$loc['ip']} => {$loc['country']} {$loc['province']} {$loc['city']} {$loc['isp']}\n";
}
<!-- api/ip.php -->
<?php
session_start();
require '../IPLocator.php';
header('Content-Type: application/json; charset=utf-8');
$loc = IPLocator::getLocation(format: 'json');
echo $loc;
?>
// 前端
fetch('/api/ip.php')
.then(r => r.json())
.then(data => {
console.log('用戶位置:', data.country, data.province, data.city);
// 根據地理位置顯示不同內容、幣別、語言
});
$locator = new IPLocator([
'api_url' => 'https://api.ip.sb/geoip/', // 替代介面
'api_format' => 'json',
'timeout' => 5.0,
'cache_ttl' => 0, // 關閉快取
'trust_proxies' => true,
]);
$location = $locator->getLocation('8.8.8.8', 'array');
print_r($location);
| 欄位 | 含義 | 範例 |
|---|---|---|
| ret | 狀態碼 (1=成功) | 1 |
| country | 國家 | 中國 |
| province | 省份/直轄市 | 北京 |
| city | 城市 | 北京 |
| district | 區/縣 | 海淀區 |
| isp | 運營商 | 電信/聯通/移動/鐵通/教育網 |
| type | 線路類型 | 學校/公司/家庭 |
| desc | 描述 | 北京市海淀區 電信 |
| 服務 | 介面 | 特點 | 限制 |
|---|---|---|---|
| ip.sb | https://api.ip.sb/geoip/{ip} | 免費、HTTPS、支援 IPv6、豐富欄位 | 頻率限制 |
| ip-api.com | https://ip-api.com/json/{ip} | 免費、詳盡、支援批次 | 非商業、HTTP、45 req/min |
| ipinfo.io | https://ipinfo.io/{ip}/json | 免費額度大、企業級 | 需 Token、付費進階 |
| GeoIP2 (MaxMind) | 本地資料庫 (MMDB) | 離線、高效能、精準 | 需定期更新資料庫 |
php-maxmind-db 擴展,零網路延遲、高併發、隱私合規SETEX ip_loc:{md5(ip)} 3600 {json}trust_proxies 與代理 Header,防止 IP 偽造isValidPublicIp 正確處理 IPv6 格式這個 IPLocator 類提供了從簡單到生產級的完整演進路徑。起步用新浪介面零成本,量大遷移至本地 GeoIP2,架構層面保持介面不變,業務代碼零修改。