Skip to content

Java 怎么获取用户 IP 归属地?在线接口版工具类(Spring Boot 可用)

先给结论:Java 拿 IP 归属地,最省事的是调在线 HTTP 接口:一个 GET + Jackson 解析,几十行封装成工具类,不依赖任何本地库文件。 淘宝老接口早废了,ip2region 离线库要自己下 xdb 文件还得惦记更新,中小项目为查个省市维护一套离线库,不划算。下面是能直接抄的实现。

一、老办法为什么越来越不好用

翻以前的博客,教 Java 查归属地的方案就三类,各有各的坑:

  • 淘宝 IP 接口ip.taobao.com):多年前已停止服务,老教程还在抄,照着写必挂;
  • 纯真/ip2region 离线库:要下数据文件、引 SDK,还得自己盯着更新——IP 段一变结果就旧;
  • 自建 IP 库:从 APNIC 拉分配数据自己解析,只能到机构级,省市都费劲。

在线接口没这些包袱:更新是服务商的事,你只管调。

二、方案设计

三步走:

  1. 拿真实 IP:后端在 Nginx 后面时,request.getRemoteAddr() 拿到的是 Nginx 的地址,得从 X-Forwarded-For 里取客户端 IP(前提:Nginx 配了 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for);
  2. 查归属地GET https://ip9.com.cn/get?ip=<ip>,返回 JSON:country/prov/city/isp/lng/lat 等字段,免费版支持 IPv4/IPv6、无需注册;
  3. 缓存 + 降级:同一 IP 一天内反复查没意义,本地缓存 24 小时;接口超时返回 null,调用方记“未知”继续走流程,别让归属地查询拖垮主流程。

三、Java 实现

依赖只需要 Jackson(解析 JSON)。JDK 11+ 自带的 java.net.http.HttpClient 发请求,不用引 HTTP 库:

java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;

public class IpLocation {

    private static final String API = "https://ip9.com.cn/get?ip=";
    private static final long TTL_MS = 24 * 3600 * 1000L;   // 缓存24小时

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2)).build();
    private final ObjectMapper mapper = new ObjectMapper();
    private final ConcurrentHashMap<String, CacheItem> cache = new ConcurrentHashMap<>();

    public record Location(String ip, String country, String prov,
                           String city, String isp, String lng, String lat) {}

    private record CacheItem(Location loc, long expireAt) {}

    /** 查归属地:先走缓存,miss 再调接口 */
    public Location lookup(String ip) {
        CacheItem hit = cache.get(ip);
        if (hit != null && hit.expireAt() > System.currentTimeMillis()) {
            return hit.loc();
        }
        Location loc = remote(ip);
        if (loc != null) {
            cache.put(ip, new CacheItem(loc, System.currentTimeMillis() + TTL_MS));
        }
        return loc;
    }

    private Location remote(String ip) {
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create(API + ip))
                    .timeout(Duration.ofSeconds(3))
                    .header("User-Agent", "ip-location/1.0")
                    .GET().build();
            HttpResponse<String> resp = http.send(req,
                    HttpResponse.BodyHandlers.ofString());
            JsonNode root = mapper.readTree(resp.body());
            if (root.path("ret").asInt() != 200) {
                return null;   // 非法 IP 返回 ret=400,按查不到处理
            }
            JsonNode d = root.path("data");
            return new Location(
                    d.path("ip").asText(), d.path("country").asText(),
                    d.path("prov").asText(), d.path("city").asText(),
                    d.path("isp").asText(), d.path("lng").asText(),
                    d.path("lat").asText());
        } catch (Exception e) {
            return null;   // 超时/网络异常:降级,不让归属地查询拖垮主流程
        }
    }

    /** 从 X-Forwarded-For 取第一个公网 IP(仅信任自家 Nginx 设置的头) */
    public static String resolveClientIp(jakarta.servlet.http.HttpServletRequest req) {
        String xff = req.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isBlank()) {
            for (String part : xff.split(",")) {
                String ip = part.trim();
                if (!ip.isEmpty() && !isPrivate(ip)) {
                    return ip;
                }
            }
        }
        return req.getRemoteAddr();
    }

    private static boolean isPrivate(String ip) {
        if (ip.startsWith("10.") || ip.startsWith("192.168.")
                || ip.startsWith("127.")) {
            return true;
        }
        if (ip.startsWith("172.")) {          // 172.16.0.0 - 172.31.255.255
            try {
                int second = Integer.parseInt(ip.split("\\.")[1]);
                return second >= 16 && second <= 31;
            } catch (Exception e) {
                return false;
            }
        }
        return false;
    }
}

Spring Boot 里这么用——登录成功把来源地写进审计日志:

java
@PostMapping("/login")
public Result login(HttpServletRequest request, @RequestBody LoginForm form) {
    String ip = IpLocation.resolveClientIp(request);
    IpLocation.Location loc = ipLocation.lookup(ip);
    auditLogService.save(form.getUserId(), ip,
            loc == null ? "未知" : loc.prov() + " " + loc.city(),
            loc == null ? "" : loc.isp());
    // 继续正常登录流程...
    return Result.ok();
}

四、落地注意事项

  • XFF 不能盲信X-Forwarded-For 客户端可自己伪造,只在请求确认经过自家 Nginx/网关且网关覆盖了该头时才可信;直接暴露公网的接口别用上面的解析方法。
  • 海外 IP 字段会空:查 8.8.8.8 只返回国家和运营商,prov/city 为空,展示时做空值处理(上面 record 已用 asText() 兜底)。
  • 缓存设过期:运营商调整 IP 段后归属地会变,缓存 24 小时是折中;要实时就缩短 TTL,调用量也会上去。
  • 版本差异:代码里的 jakarta.servlet.* 是 Spring Boot 3;还在用 Boot 2 的换成 javax.servlet.*
  • 识别机房要 VIP:免费接口的 isp 只能看个大概;ip_type(ISP/BUS/IDC)是 VIP 字段,有需求再上。

总结

Java 项目接 IP 归属地,在线接口性价比最高:无鉴权、免维护、字段够用。把 IpLocation 类扔进项目,注册成 Spring Bean 就能用——写审计日志、做地域统计、风控前置都够了。接口文档和字段说明见官网 https://www.ip9.com.cn,浏览器直接打开 https://ip9.com.cn/get?ip=114.114.114.114 就能看返回。先跑通再优化,别一上来就上离线库。