目标
解析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 重新打包发布即可。