前言
在日常的Java开发中,IP地址相关的操作是非常常见的需求。无论是用户行为分析、地域化服务,还是安全防护,都需要精准地获取和解析IP信息。本文将详细介绍一个功能完善的Java IP工具类,涵盖IP地址获取、MAC地址获取以及IP属地解析三大核心功能,帮助你在项目中快速落地这些能力。
一、工具类概述
1.1 核心功能
该IP工具类提供了三个核心功能模块:
- IP地址获取:支持多种代理场景下的客户端IP提取,能够穿透多层代理链获取真实客户端IP
- MAC地址获取:通过Java原生API获取本地网络接口的MAC地址
- IP属地解析:基于ip2region.xdb数据库,实现毫秒级的IP地理位置查询
1.2 应用场景
这些功能在实际项目中有着广泛的应用场景:
- 用户行为分析:通过IP属地信息,统计用户的地域分布,为产品决策提供数据支持
- 地域化服务:根据用户所在省份/城市,提供个性化的内容推荐或服务
- 安全防护:识别异常IP访问,进行地域限制或风险预警
- 日志审计:在关键操作日志中记录IP和属地信息,便于问题追溯
1.3 技术亮点
该工具类的技术亮点主要体现在:
- 多场景IP获取策略:内置HTTP头参数优先级逻辑,能够处理各种代理和负载均衡场景
- 高效内存缓存 :ip2region.xdb数据库采用内存缓存模式(
Searcher.newWithBuffer),查询性能达到微秒级 - 完善的异常处理:针对不同场景设计了合理的异常捕获和处理机制
二、核心功能详解
2.1 IP地址获取(getIpAddr)
在真实的生产环境中,获取客户端IP并非想象中那么简单。用户请求可能经过多层代理、CDN、负载均衡器,因此需要从多个HTTP头中提取真实IP。
HTTP头参数优先级顺序
java
public static String getIpAddr(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
优先级说明:
- X-Forwarded-For:Squid等代理服务器的标准,最为常用
- Proxy-Client-IP:Apache+Mod_Proxy场景
- X-Real-IP:Nginx反向代理常用
- WL-Proxy-Client-IP:WebLogic代理场景
- HTTP_CLIENT_IP:部分代理服务器使用
- HTTP_X_FORWARDED_FOR:部分特殊代理场景
- getRemoteAddr():直接获取,作为兜底方案
代理场景下的IP提取逻辑
在实际场景中,X-Forwarded-For的值可能是多个IP的列表,格式为:client, proxy1, proxy2。其中第一个IP才是真实客户端IP。
增强版的IP提取方法:
java
public static String getRealIp(HttpServletRequest request) {
String ip = getIpAddr(request);
// 处理多个IP的情况(逗号分隔)
if (ip != null && ip.contains(",")) {
String[] ips = ip.split(",");
ip = ips[0].trim(); // 取第一个IP(真实客户端IP)
}
// 移除IPv6的端口信息
if (ip != null && ip.contains(":") && ip.indexOf(":") < ip.lastIndexOf(":")) {
ip = ip.substring(0, ip.lastIndexOf(":"));
}
return ip;
}
关键点解析:
- 逗号分隔处理 :
X-Forwarded-For可能包含代理链中的所有IP,必须提取第一个 - IPv6地址兼容 :将IPv6的本地回环地址
0:0:0:0:0:0:0:1转换为127.0.0.1,便于统一处理 - 端口剥离:某些代理可能返回带端口的IP格式,需要进行清理
2.2 MAC地址获取(getMacAddress)
MAC地址是网络接口的唯一标识,在某些场景下(如设备绑定、局域网通信)需要获取。
NetworkInterface和InetAddress的协作原理
java
public static String getMacAddress() throws SocketException {
// 获取本机的所有网络接口
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = networkInterfaces.nextElement();
// 跳过回环接口和未启用的接口
if (networkInterface.isLoopback() || !networkInterface.isUp()) {
continue;
}
// 获取硬件地址(MAC地址)
byte[] macBytes = networkInterface.getHardwareAddress();
if (macBytes != null) {
return formatMacAddress(macBytes);
}
}
return null;
}
/**
* 将MAC地址字节数组格式化为标准字符串
*/
private static String formatMacAddress(byte[] macBytes) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < macBytes.length; i++) {
sb.append(String.format("%02X%s", macBytes[i], (i < macBytes.length - 1) ? "-" : ""));
}
return sb.toString();
}
技术原理解析:
- NetworkInterface:Java提供的网络接口抽象,可以枚举本机所有网卡
- InetAddress :虽然方法名中包含InetAddress,但MAC地址获取主要依赖NetworkInterface的
getHardwareAddress()方法 - 字节数组转字符串 :MAC地址本质上是6字节的十六进制数,需要格式化为
XX:XX:XX:XX:XX:XX或XX-XX-XX-XX-XX-XX格式
异常处理建议
java
public static String getMacAddressSafe() {
try {
return getMacAddress();
} catch (SocketException e) {
log.error("获取MAC地址失败", e);
return null;
}
}
可能的异常场景:
- SecurityException:在受限环境中(如某些云平台、Docker容器),可能没有访问网络接口的权限
- SocketException:网络接口配置异常或不可用
- 返回null:没有可用的物理网卡(纯虚拟环境或容器)
2.3 IP属地解析(getCityInfo)
ip2region是一个高精度的IP地理位置库,支持多种查询模式。本文介绍基于xdb数据库的内存缓存模式。
ip2region.xdb数据库的加载与使用
java
/**
* ip2region查询器(使用内存缓存模式,性能最优)
*/
private static Searcher searcher = null;
static {
try {
// 加载xdb数据库文件
String dbPath = "classpath:ip2region.xdb";
InputStream inputStream = new ClassPathResource(dbPath).getInputStream();
byte[] cBuff = IoUtil.readBytes(inputStream);
// 创建内存缓存模式的查询器
searcher = Searcher.newWithBuffer(cBuff);
} catch (Exception e) {
log.error("ip2region数据库加载失败", e);
}
}
/**
* 获取IP属地信息
*/
public static String getCityInfo(String ip) {
if (searcher == null) {
log.warn("ip2region查询器未初始化");
return null;
}
try {
// 执行查询
return searcher.search(ip);
} catch (Exception e) {
log.error("IP属地查询失败, ip: {}", ip, e);
return null;
}
}
核心流程解析:
- 数据库加载:从classpath加载ip2region.xdb文件(约11MB)
- 内存缓存 :使用
Searcher.newWithBuffer()将整个数据库加载到内存 - 查询执行 :通过
searcher.search(ip)方法执行查询
内存缓存模式的优势
ip2region支持三种查询模式:
| 模式 | 内存占用 | 查询性能 | 适用场景 |
|---|---|---|---|
| 内存模式(newWithBuffer) | 约11MB | 微秒级 | 高并发、性能敏感 |
| 文件模式(newWithFileOnly) | 极低 | 毫秒级 | 内存受限、低频查询 |
| 缓存模式(newWithVectorIndex) | 约200KB | 微秒级 | 内存和性能的平衡选择 |
推荐使用内存模式:在大多数应用场景中,11MB的内存占用是可以接受的,换来的是极致的查询性能。
返回结果格式与处理
getCityInfo返回的原始格式:中国|0|上海|上海市|电信
工具方法封装:
java
/**
* 提取IP所属省份
*/
public static String getIpPossession(String ip) {
String cityInfo = getCityInfo(ip);
if (cityInfo == null) {
return null;
}
String[] parts = cityInfo.split("\\|");
if (parts.length >= 3) {
return parts[2]; // 省份信息
}
return null;
}
/**
* 提取IP所属城市
*/
public static String getIpCity(String ip) {
String cityInfo = getCityInfo(ip);
if (cityInfo == null) {
return null;
}
String[] parts = cityInfo.split("\\|");
if (parts.length >= 4) {
return parts[3]; // 城市信息
}
return null;
}
结果字段说明:
parts[0]:国家(如:中国、美国)parts[1]:区域编码(0表示国内)parts[2]:省份(如:上海、广东)parts[3]:城市(如:上海市、深圳市)parts[4]:运营商(如:电信、联通)
三、实战应用示例
3.1 控制器层集成
在Spring Boot项目中,可以方便地集成IP工具类:
java
@RestController
@RequestMapping("/api")
public class UserController {
/**
* 用户登录接口 - 记录IP和属地信息
*/
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
// 获取客户端IP
String ip = IpUtil.getRealIp(httpRequest);
// 解析属地信息
String province = IpUtil.getIpPossession(ip);
String city = IpUtil.getIpCity(ip);
log.info("用户登录 - IP: {}, 省份: {}, 城市: {}", ip, province, city);
// 业务逻辑处理
// ...
// 将属地信息返回给前端
LoginResponse response = new LoginResponse();
response.setIp(ip);
response.setProvince(province);
response.setCity(city);
return Result.success(response);
}
/**
* 获取当前用户的属地信息
*/
@GetMapping("/location")
public Result<LocationInfo> getLocation(HttpServletRequest httpRequest) {
String ip = IpUtil.getRealIp(httpRequest);
LocationInfo location = new LocationInfo();
location.setIp(ip);
location.setProvince(IpUtil.getIpPossession(ip));
location.setCity(IpUtil.getIpCity(ip));
return Result.success(location);
}
}
3.2 结果处理与业务逻辑应用
java
@Service
public class UserService {
/**
* 根据用户属地推荐内容
*/
public List<Content> recommendContent(String ip) {
String province = IpUtil.getIpPossession(ip);
String city = IpUtil.getIpCity(ip);
// 业务逻辑:根据属地推荐不同内容
if ("上海".equals(province)) {
return contentRepository.findByRegion("华东");
} else if ("广东".equals(province)) {
return contentRepository.findByRegion("华南");
} else {
return contentRepository.findDefault();
}
}
/**
* 风险检测:识别异地登录
*/
public boolean detectAbnormalLogin(Long userId, String currentIp) {
// 获取用户常用登录属地
UserLocationHistory history = locationHistoryRepository.findLatestByUserId(userId);
String currentProvince = IpUtil.getIpPossession(currentIp);
// 如果当前省份与常用省份不同,标记为异常
if (history != null && !history.getProvince().equals(currentProvince)) {
log.warn("检测到异地登录 - 用户: {}, 常用省份: {}, 当前省份: {}",
userId, history.getProvince(), currentProvince);
return true;
}
return false;
}
}
3.3 完成的IpUtil工具类
java
package com.example.util;
import cn.hutool.core.io.IoUtil;
import lombok.extern.slf4j.Slf4j;
import org.lionsoul.ip2region.xdb.Searcher;
import org.springframework.core.io.ClassPathResource;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Enumeration;
/**
* IP工具类
* 提供IP地址获取、MAC地址获取、IP属地解析等核心功能
*
* @author Your Name
* @since 2026-01-13
*/
@Slf4j
public class IpUtil {
/**
* ip2region查询器(使用内存缓存模式,性能最优)
* 在类加载时初始化,支持高并发查询
*/
private static Searcher searcher = null;
/**
* 静态初始化块:加载ip2region.xdb数据库
*/
static {
try {
// 加载xdb数据库文件
String dbPath = "classpath:ip2region.xdb";
InputStream inputStream = new ClassPathResource(dbPath).getInputStream();
byte[] cBuff = IoUtil.readBytes(inputStream);
// 创建内存缓存模式的查询器
searcher = Searcher.newWithBuffer(cBuff);
log.info("ip2region数据库加载成功,内存占用: {}MB", cBuff.length / 1024.0 / 1024.0);
} catch (Exception e) {
log.error("ip2region数据库加载失败,属地查询功能将不可用", e);
}
}
/**
* 获取客户端IP地址
* 支持多层代理场景,按优先级顺序从HTTP头中提取真实IP
*
* @param request HttpServletRequest对象
* @return 客户端IP地址,无法获取时返回"unknown"
*/
public static String getIpAddr(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// IPv6本地回环地址转换为IPv4
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
/**
* 获取真实客户端IP地址(增强版)
* 处理代理链中的多个IP,提取第一个作为真实客户端IP
*
* @param request HttpServletRequest对象
* @return 真实客户端IP地址
*/
public static String getRealIp(HttpServletRequest request) {
String ip = getIpAddr(request);
if (ip == null || "unknown".equalsIgnoreCase(ip)) {
return "127.0.0.1";
}
// 处理多个IP的情况(逗号分隔),取第一个IP(真实客户端IP)
if (ip.contains(",")) {
String[] ips = ip.split(",");
ip = ips[0].trim();
}
// 移除IPv6的端口信息(格式如:2001:db8::1:8080)
if (ip.contains(":") && ip.indexOf(":") < ip.lastIndexOf(":")) {
ip = ip.substring(0, ip.lastIndexOf(":"));
}
return ip;
}
/**
* 获取本地MAC地址
* 通过遍历网络接口获取第一个非回环且已启用的网卡的MAC地址
*
* @return MAC地址字符串(格式:XX-XX-XX-XX-XX-XX),获取失败返回null
* @throws SocketException 当访问网络接口失败时抛出
*/
public static String getMacAddress() throws SocketException {
// 获取本机的所有网络接口
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = networkInterfaces.nextElement();
// 跳过回环接口和未启用的接口
if (networkInterface.isLoopback() || !networkInterface.isUp()) {
continue;
}
// 跳过虚拟接口(常见于Docker、VPN等)
if (networkInterface.isVirtual()) {
continue;
}
// 获取硬件地址(MAC地址)
byte[] macBytes = networkInterface.getHardwareAddress();
if (macBytes != null && macBytes.length == 6) {
return formatMacAddress(macBytes);
}
}
return null;
}
/**
* 安全获取本地MAC地址(带异常处理)
*
* @return MAC地址字符串,获取失败返回null
*/
public static String getMacAddressSafe() {
try {
return getMacAddress();
} catch (SocketException e) {
log.error("获取MAC地址失败", e);
return null;
}
}
/**
* 将MAC地址字节数组格式化为标准字符串
* 格式:XX-XX-XX-XX-XX-XX(大写十六进制)
*
* @param macBytes MAC地址字节数组(6字节)
* @return 格式化后的MAC地址字符串
*/
private static String formatMacAddress(byte[] macBytes) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < macBytes.length; i++) {
sb.append(String.format("%02X%s", macBytes[i], (i < macBytes.length - 1) ? "-" : ""));
}
return sb.toString();
}
/**
* 获取IP属地信息(原始格式)
* 返回格式:中国|0|上海|上海市|电信
*
* @param ip IP地址
* @return 属地信息字符串,查询失败返回null
*/
public static String getCityInfo(String ip) {
if (searcher == null) {
log.warn("ip2region查询器未初始化");
return null;
}
if (ip == null || ip.isEmpty()) {
return null;
}
try {
return searcher.search(ip);
} catch (Exception e) {
log.error("IP属地查询失败, ip: {}", ip, e);
return null;
}
}
/**
* 安全获取IP属地信息(带异常处理和参数校验)
*
* @param ip IP地址
* @return 属地信息字符串,查询失败返回null
*/
public static String getCityInfoSafe(String ip) {
if (ip == null || ip.isEmpty()) {
return null;
}
return getCityInfo(ip);
}
/**
* 提取IP所属省份
*
* @param ip IP地址
* @return 省份名称,查询失败返回null
*/
public static String getIpPossession(String ip) {
String cityInfo = getCityInfo(ip);
if (cityInfo == null) {
return null;
}
String[] parts = cityInfo.split("\\|");
if (parts.length >= 3) {
String province = parts[2];
return "0".equals(province) ? null : province;
}
return null;
}
/**
* 安全提取IP所属省份(带异常处理)
*
* @param ip IP地址
* @return 省份名称,查询失败返回null
*/
public static String getIpPossessionSafe(String ip) {
try {
return getIpPossession(ip);
} catch (Exception e) {
log.warn("提取省份信息失败, ip: {}", ip, e);
return null;
}
}
/**
* 提取IP所属城市
*
* @param ip IP地址
* @return 城市名称,查询失败返回null
*/
public static String getIpCity(String ip) {
String cityInfo = getCityInfo(ip);
if (cityInfo == null) {
return null;
}
String[] parts = cityInfo.split("\\|");
if (parts.length >= 4) {
String city = parts[3];
return "0".equals(city) ? null : city;
}
return null;
}
/**
* 安全提取IP所属城市(带异常处理)
*
* @param ip IP地址
* @return 城市名称,查询失败返回null
*/
public static String getIpCitySafe(String ip) {
try {
return getIpCity(ip);
} catch (Exception e) {
log.warn("提取城市信息失败, ip: {}", ip, e);
return null;
}
}
/**
* 提取IP所属运营商
*
* @param ip IP地址
* @return 运营商名称,查询失败返回null
*/
public static String getIpIsp(String ip) {
String cityInfo = getCityInfo(ip);
if (cityInfo == null) {
return null;
}
String[] parts = cityInfo.split("\\|");
if (parts.length >= 5) {
String isp = parts[4];
return "0".equals(isp) ? null : isp;
}
return null;
}
/**
* 提取IP所属国家
*
* @param ip IP地址
* @return 国家名称,查询失败返回null
*/
public static String getIpCountry(String ip) {
String cityInfo = getCityInfo(ip);
if (cityInfo == null) {
return null;
}
String[] parts = cityInfo.split("\\|");
if (parts.length >= 1) {
String country = parts[0];
return "0".equals(country) ? null : country;
}
return null;
}
/**
* 判断IP是否为内网IP
*
* @param ip IP地址
* @return true-内网IP,false-外网IP
*/
public static boolean isInternalIp(String ip) {
if (ip == null || ip.isEmpty()) {
return false;
}
// 127.0.0.1 本地回环
if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) {
return true;
}
// 10.0.0.0 - 10.255.255.255
if (ip.startsWith("10.")) {
return true;
}
// 172.16.0.0 - 172.31.255.255
if (ip.startsWith("172.")) {
String[] parts = ip.split("\\.");
if (parts.length >= 2) {
int second = Integer.parseInt(parts[1]);
if (second >= 16 && second <= 31) {
return true;
}
}
}
// 192.168.0.0 - 192.168.255.255
if (ip.startsWith("192.168.")) {
return true;
}
return false;
}
/**
* 验证IP地址格式是否正确
*
* @param ip IP地址字符串
* @return true-格式正确,false-格式错误
*/
public static boolean isValidIp(String ip) {
if (ip == null || ip.isEmpty()) {
return false;
}
String ipv4Pattern = "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$";
return ip.matches(ipv4Pattern);
}
/**
* 获取本地主机名
*
* @return 主机名,获取失败返回"unknown"
*/
public static String getHostName() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
log.error("获取主机名失败", e);
return "unknown";
}
}
/**
* 获取本地IP地址
*
* @return 本地IP地址,获取失败返回null
*/
public static String getLocalIp() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.error("获取本地IP失败", e);
return null;
}
}
}
3.4 注意事项
数据库文件放置位置
ip2region.xdb数据库文件需要正确放置,以便程序加载:
src/main/resources/
└── ip2region.xdb
如果使用外部文件路径,修改加载逻辑:
java
String dbPath = "/path/to/ip2region.xdb"; // 绝对路径
File file = new File(dbPath);
byte[] cBuff = Files.readAllBytes(file.toPath());
searcher = Searcher.newWithBuffer(cBuff);
异常捕获建议
java
// 1. 初始化阶段:数据库加载失败不应影响应用启动
static {
try {
initIp2region();
} catch (Exception e) {
log.error("ip2region初始化失败,属地查询功能将不可用", e);
}
}
// 2. 查询阶段:单个查询失败不应影响主流程
public static String getCityInfoSafe(String ip) {
try {
return getCityInfo(ip);
} catch (Exception e) {
log.warn("IP属地查询异常: {}", ip);
return null;
}
}
// 3. 业务层:优雅降级
public void recordUserLogin(Long userId, HttpServletRequest request) {
String ip = IpUtil.getRealIp(request);
String province = IpUtil.getIpPossessionSafe(ip); // 使用安全版本
if (province != null) {
// 正常处理
loginRecordRepository.save(userId, ip, province);
} else {
// 降级处理:仅记录IP
loginRecordRepository.save(userId, ip, "未知");
}
}
性能优化点
- 静态初始化:查询器在类加载时初始化,避免每次查询都重新加载
- 并发安全:ip2region的Searcher是线程安全的,无需额外同步
- 缓存热点IP:对于高频查询的IP,可以增加一层本地缓存
- 异步日志:属地查询失败时,使用异步日志避免阻塞主流程
java
// 热点IP缓存示例
private static final Cache<String, String> ipCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
public static String getCityInfoWithCache(String ip) {
return ipCache.get(ip, key -> getCityInfo(key));
}
总结
本文详细介绍了一个功能完善的Java IP工具类,涵盖了IP地址获取、MAC地址获取和IP属地解析三大核心功能。通过合理的代码设计和异常处理,能够在各种复杂场景下稳定运行。
关键要点回顾:
- IP获取需要考虑多层代理场景,正确处理HTTP头优先级
- MAC地址获取受环境影响较大,需要做好异常处理
- ip2region的内存缓存模式性能优异,推荐在高并发场景使用
- 业务集成时要注意异常捕获和优雅降级
希望这篇文章能够帮助你在项目中更好地应用IP工具类,为业务提供更精准的地理位置服务。
参考资源
- ip2region官方仓库:https://github.com/lionsoul2014/ip2region
- X-Forwarded-For标准文档:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For