Spring Boot 集成 ip2region 离线 IP 库实战指南

目标

解析ip所在城市

步骤

依赖引入与数据文件准备

Maven 依赖

在基础通用模块(如 common-core)的 pom.xml 中引入官方最新 Java SDK:

xml 复制代码
<dependency>
    <groupId>org.lionsoul</groupId>
    <artifactId>ip2region</artifactId>
    <version>2.7.0</version>
</dependency>

离线数据库文件准备

从官方开源仓库下载最新的 ip2region.xdb 文件(约 11MB):

  • 存放至项目工程路径:src/main/resources/ip/ip2region.xdb;
  • 亦支持在生产环境中挂载宿主机绝对路径。

核心代码落地

统一结果模型 IpLocationResult.java

java 复制代码
package com.example.common.core.ip;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;

/**
 * IP 地理位置解析结果标准对象
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IpLocationResult implements Serializable {
    private static final long serialVersionUID = 1L;

    /** 目标 IP 地址 */
    private String ip;

    /** 国家(如:中国) */
    private String country;

    /** 省份 / 区域(如:广东省) */
    private String province;

    /** 城市(如:深圳市) */
    private String city;

    /** 网络运营商(如:电信 / 联通 / 移动) */
    private String isp;

    /** 是否为内网私有 IP */
    private boolean isInternal;

    /**
     * 获取标准展示地址
     * 例如:"广东省 深圳市" 或 "内网IP"
     */
    public String getDisplayAddress() {
        if (isInternal) {
            return "内网IP";
        }
        StringBuilder sb = new StringBuilder();
        if (province != null && !province.isEmpty() && !"0".equals(province)) {
            sb.append(province);
        }
        if (city != null && !city.isEmpty() && !"0".equals(city)) {
            if (sb.length() > 0) {
                sb.append(" ");
            }
            sb.append(city);
        }
        return sb.length() > 0 ? sb.toString() : (country != null ? country : "未知");
    }
}

多层代理客户端真实 IP 自适应提取器 ClientIpResolver.java

在现代云原生与多层反向代理架构下,提取客户端真实 IP 必须兼顾不同环境与网络头:

java 复制代码
package com.example.common.core.ip;

import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.util.regex.Pattern;

/**
 * 客户端真实 IP 自适应提取器
 */
public class ClientIpResolver {

    private static final Pattern IPV4_PATTERN = Pattern.compile(
            "^((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]?)$");

    private ClientIpResolver() {}

    /**
     * 自适应从 HTTP 请求中提取真实客户端 IP
     */
    public static String getClientIp(HttpServletRequest request) {
        if (request == null) {
            return "127.0.0.1";
        }

        // 1. 首选:Nginx real_ip 模块递归剔除代理后写入的 X-Real-IP
        String xRealIp = request.getHeader("X-Real-IP");
        if (StringUtils.hasText(xRealIp) && !"unknown".equalsIgnoreCase(xRealIp.trim())) {
            String ip = cleanIp(xRealIp);
            if (isValidIp(ip)) return ip;
        }

        // 2. 次选:X-Forwarded-For 逐跳链条(从左至右取首个非内网的合法公网 IP)
        String xff = request.getHeader("X-Forwarded-For");
        if (StringUtils.hasText(xff) && !"unknown".equalsIgnoreCase(xff.trim())) {
            String[] parts = xff.split(",");
            for (String part : parts) {
                String candidate = cleanIp(part);
                if (isValidIp(candidate) && !isInternalIp(candidate)) {
                    return candidate;
                }
            }
        }

        // 3. 第三选:CloudFront 注入的客户端地址
        String cfViewer = request.getHeader("CloudFront-Viewer-Address");
        if (StringUtils.hasText(cfViewer)) {
            String ip = cleanIp(cfViewer);
            if (isValidIp(ip)) return ip;
        }

        // 4. 兜底:本地 Socket 地址
        String remote = cleanIp(request.getRemoteAddr());
        return StringUtils.hasText(remote) ? remote : "127.0.0.1";
    }

    public static boolean isInternalIp(String ip) {
        if (!StringUtils.hasText(ip)) return true;
        if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) return true;
        if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true;
        if (ip.startsWith("172.")) {
            String[] parts = ip.split("\\.");
            if (parts.length >= 2) {
                try {
                    int sec = Integer.parseInt(parts[1]);
                    if (sec >= 16 && sec <= 31) return true;
                } catch (NumberFormatException ignored) {}
            }
        }
        return false;
    }

    private static String cleanIp(String raw) {
        if (!StringUtils.hasText(raw)) return "";
        String ip = raw.trim();
        if (ip.contains(",")) ip = ip.split(",")[0].trim();
        if (ip.contains(":")) ip = ip.split(":")[0].trim(); // 剥离端口
        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    }

    private static boolean isValidIp(String ip) {
        return StringUtils.hasText(ip) && IPV4_PATTERN.matcher(ip).matches();
    }
}

服务接口定义 IpLocationService.java

java 复制代码
package com.example.common.core.ip;

import javax.servlet.http.HttpServletRequest;

/**
 * IP 地理位置解析统一服务接口
 */
public interface IpLocationService {

    /**
     * 根据 IP 字符串解析
     */
    IpLocationResult getLocation(String ip);

    /**
     * 直接从 HttpServletRequest 自适应提取真实 IP 并解析
     */
    default IpLocationResult getLocation(HttpServletRequest request) {
        String clientIp = ClientIpResolver.getClientIp(request);
        return getLocation(clientIp);
    }

    /**
     * 获取城市名称(如 "深圳市")
     */
    String getCity(String ip);

    default String getCity(HttpServletRequest request) {
        return getCity(ClientIpResolver.getClientIp(request));
    }

    /**
     * 获取格式化展示地址(如 "广东省 深圳市")
     */
    String getDisplayAddress(String ip);

    default String getDisplayAddress(HttpServletRequest request) {
        return getDisplayAddress(ClientIpResolver.getClientIp(request));
    }
}

纯内存预加载实现类 Ip2regionLocationServiceImpl.java

java 复制代码
package com.example.common.core.ip.impl;

import com.example.common.core.ip.ClientIpResolver;
import com.example.common.core.ip.IpLocationResult;
import com.example.common.core.ip.IpLocationService;
import lombok.extern.slf4j.Slf4j;
import org.lionsoul.ip2region.xdb.Searcher;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.StringUtils;

import java.io.InputStream;

/**
 * 基于 ip2region (xdb) 纯内存预加载的高性能实现
 */
@Slf4j
@Service
public class Ip2regionLocationServiceImpl implements IpLocationService, InitializingBean, DisposableBean {

    private Searcher searcher;

    @Override
    public void afterPropertiesSet() throws Exception {
        log.info("[Ip2region] 准备预加载 ip2region 离线数据库...");
        Resource resource = new ClassPathResource("ip/ip2region.xdb");
        try (InputStream in = resource.getInputStream()) {
            byte[] cBuff = FileCopyUtils.copyToByteArray(in);
            // 核心:全内存预加载,避免任何运行期文件 IO
            this.searcher = Searcher.newWithBuffer(cBuff);
            log.info("[Ip2region] 离线库预加载至内存成功, 大小: {} 字节", cBuff.length);
        } catch (Exception e) {
            log.error("[Ip2region] 初始化加载失败: {}", e.getMessage(), e);
            throw e;
        }
    }

    @Override
    public IpLocationResult getLocation(String ip) {
        if (!StringUtils.hasText(ip)) {
            return createUnknownResult(ip);
        }

        String targetIp = ip.trim();
        // 内网与回环 IP 短路保护
        if (ClientIpResolver.isInternalIp(targetIp)) {
            return IpLocationResult.builder()
                    .ip(targetIp)
                    .country("中国")
                    .province("内网IP")
                    .city("内网IP")
                    .isp("内网")
                    .isInternal(true)
                    .build();
        }

        try {
            // 纯内存寻址查询
            String region = searcher.search(targetIp);
            return parseRegion(targetIp, region);
        } catch (Exception e) {
            log.warn("[Ip2region] 解析 IP 地理位置异常: ip={}, error={}", targetIp, e.getMessage());
            return createUnknownResult(targetIp);
        }
    }

    @Override
    public String getCity(String ip) {
        IpLocationResult result = getLocation(ip);
        return result != null ? result.getCity() : "";
    }

    @Override
    public String getDisplayAddress(String ip) {
        IpLocationResult result = getLocation(ip);
        return result != null ? result.getDisplayAddress() : "未知";
    }

    @Override
    public void destroy() {
        if (searcher != null) {
            try {
                searcher.close();
                log.info("[Ip2region] searcher 资源已安全释放");
            } catch (Exception ignored) {}
        }
    }

    /**
     * 解析 v4 格式:国家|省份|城市|ISP|ISO (如: 中国|广东省|深圳市|电信|CN)
     */
    private IpLocationResult parseRegion(String ip, String region) {
        if (!StringUtils.hasText(region)) return createUnknownResult(ip);

        String[] parts = region.split("\\|");
        String country = getPart(parts, 0);
        String province = getPart(parts, 1);
        String city = getPart(parts, 2);
        String isp = getPart(parts, 3);

        return IpLocationResult.builder()
                .ip(ip)
                .country(StringUtils.hasText(country) ? country : "未知")
                .province(province)
                .city(city)
                .isp(isp)
                .isInternal(false)
                .build();
    }

    private String getPart(String[] parts, int index) {
        if (parts != null && parts.length > index) {
            String val = parts[index].trim();
            return ("0".equals(val) || "null".equalsIgnoreCase(val)) ? "" : val;
        }
        return "";
    }

    private IpLocationResult createUnknownResult(String ip) {
        return IpLocationResult.builder()
                .ip(ip)
                .country("未知")
                .province("")
                .city("")
                .isp("")
                .isInternal(false)
                .build();
    }
}

接口流量切面与请求上下文接入

自定义注解 @IpAnalysis.java

java 复制代码
package com.example.framework.annotation;

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IpAnalysis {
    /** 是否在日志中打印流量分析结果 */
    boolean printLog() default true;

    /** 业务操作描述 */
    String description() default "";
}

线程请求上下文 IpContextHolder.java

java 复制代码
package com.example.framework.context;

import com.example.common.core.ip.IpLocationResult;

public class IpContextHolder {
    private static final ThreadLocal<IpLocationResult> CONTEXT = new ThreadLocal<>();

    public static void set(IpLocationResult result) {
        CONTEXT.set(result);
    }

    public static IpLocationResult get() {
        return CONTEXT.get();
    }

    public static String getCity() {
        IpLocationResult result = get();
        return result != null ? result.getCity() : "";
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

AOP 流量分析切面 IpAnalysisAspect.java

java 复制代码
package com.example.framework.aspectj;

import com.example.common.core.ip.IpLocationResult;
import com.example.common.core.ip.IpLocationService;
import com.example.framework.annotation.IpAnalysis;
import com.example.framework.context.IpContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

@Slf4j
@Aspect
@Order(1) // 保证优先于普通业务拦截器执行
@Component
public class IpAnalysisAspect {

    @Resource
    private IpLocationService ipLocationService;

    @Pointcut("@annotation(com.example.framework.annotation.IpAnalysis) || @within(com.example.framework.annotation.IpAnalysis)")
    public void ipAnalysisPointcut() {}

    @Around("ipAnalysisPointcut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        try {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes != null) {
                HttpServletRequest request = attributes.getRequest();
                // 一行代码直接从 request 提取真实 IP 并解析
                IpLocationResult location = ipLocationService.getLocation(request);
                IpContextHolder.set(location);

                long cost = System.currentTimeMillis() - startTime;
                log.info("[IpAnalysis] 接口: {} | 客户端IP: {} | 归属地: {} | 耗时: {}ms",
                        request.getRequestURI(), location.getIp(), location.getDisplayAddress(), cost);
            }
        } catch (Exception e) {
            log.error("[IpAnalysis] 切面解析发生异常", e);
        }

        try {
            return joinPoint.proceed();
        } finally {
            // 生命周期闭环:必须在 finally 中清理 ThreadLocal,防止 Tomcat 线程复用污染
            IpContextHolder.clear();
        }
    }
}

业务使用与调用示例

在 Controller 或业务 Service 中,有两种极其便捷的使用方式:

方式 1:一行注解(无感捕获)

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {

    @IpAnalysis(description = "用户登录")
    @PostMapping("/login")
    public Result login(@RequestBody LoginDto dto) {
        // 直接从 ThreadLocal 上下文中获取城市
        String city = IpContextHolder.getCity();
        log.info("用户当前登录城市: {}", city);
        return Result.success();
    }
}

方式 2:代码手动调用

java 复制代码
@Service
public class OrderService {

    @Resource
    private IpLocationService ipLocationService;

    public void processOrder(HttpServletRequest request) {
        // 直接传 request 解析
        IpLocationResult location = ipLocationService.getLocation(request);
        log.info("下单省份: {}, 城市: {}, 运营商: {}", 
                 location.getProvince(), location.getCity(), location.getIsp());
    }
}

总结

ip2region 社区一般每个季度会根据最新的 APNIC/纯真等数据源生成新的 xdb 文件,只需下载替换 resources/ip/ip2region.xdb 重新打包发布即可。

参考

相关推荐
盛世宏博智慧档案1 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10241 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
Thneonl1 小时前
ZK、etcd、Consul 怎么选:差别不在共识,在壳
后端·架构
vipxieliang1 小时前
ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据
java·spring boot·后端
花间相见1 小时前
【计算基础|网络15】【上手】Socket 网络编程:亲手写 TCP/UDP 程序
后端
huaweichenai2 小时前
spring boot实现base64上传
java·spring boot·后端
ThinkerQAQ_2 小时前
并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性
后端
Bazingga2 小时前
让Agent的挣扎可见:执行轨迹面板与生成统计行
后端
萧瑟余晖2 小时前
Spring Web综合实战与性能调优详解
java·spring