SpringBoot快速实现IP地址解析的全攻略
作者:油墨香^_^
在当今的互联网应用中,IP地址解析已成为许多系统不可或缺的功能,这篇文章主要为大家详细介绍了如何使用SpringBoot快速实现IP地址解析,有需要的小伙伴可以了解下
一、引言与概述
1.1 IP地址解析的重要性
在当今的互联网应用中,IP地址解析已成为许多系统不可或缺的功能。通过IP地址解析,我们可以:
- 地理位置服务:根据用户IP确定其所在地区,提供本地化内容
- 安全防护:识别异常登录地点,防范账号盗用
- 业务分析:分析用户地域分布,优化市场策略
- 访问控制:限制特定地区的访问权限
- 个性化体验:根据地区提供定制化服务
1.2 SpringBoot集成IP解析的优势
SpringBoot作为Java生态中最流行的微服务框架,集成IP地址解析具有以下优势:
- 快速集成:通过Starter可以快速引入IP解析功能
- 配置简单:基于约定大于配置的原则
- 生态丰富:可以轻松整合各种IP解析库
- 易于扩展:便于自定义解析逻辑
二、环境准备与基础配置
2.1 创建SpringBoot项目
使用Spring Initializr创建基础项目:
curl https://start.spring.io/starter.zip \ -d type=maven-project \ -d language=java \ -d bootVersion=3.2.0 \ -d baseDir=ip-geolocation \ -d groupId=com.example \ -d artifactId=ip-geolocation \ -d name=ip-geolocation \ -d description=IP地址解析服务 \ -d packageName=com.example.ip \ -d packaging=jar \ -d javaVersion=17 \ -d dependencies=web,validation,aop \ -o ip-geolocation.zip
2.2 基础依赖配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>ip-geolocation</artifactId>
<version>1.0.0</version>
<name>ip-geolocation</name>
<properties>
<java.version>17</java.version>
<geoip2.version>4.0.1</geoip2.version>
<ip2region.version>2.7.0</version>
<maxmind.db.version>3.0.0</version>
<caffeine.version>3.1.8</caffeine.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- IP解析库 -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>${geoip2.version}</version>
</dependency>
<!-- 本地IP库 -->
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>${ip2region.version}</version>
</dependency>
<!-- 缓存 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>2.3 配置文件
# application.yml
spring:
application:
name: ip-geolocation-service
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
# IP解析配置
ip:
geolocation:
# 使用哪种解析方式: offline(离线), online(在线), hybrid(混合)
mode: hybrid
# 离线解析配置
offline:
# 离线数据库类型: maxmind, ip2region
database: ip2region
# 数据库文件路径
maxmind-db-path: classpath:geoip/GeoLite2-City.mmdb
ip2region-db-path: classpath:geoip/ip2region.xdb
# 在线解析配置
online:
# 启用在线解析
enabled: true
# 在线服务提供商: ipapi, ipstack, taobao, baidu
providers:
- name: ipapi
url: http://ip-api.com/json/{ip}?lang=zh-CN
priority: 1
timeout: 3000
- name: taobao
url: http://ip.taobao.com/service/getIpInfo.php?ip={ip}
priority: 2
timeout: 5000
# 缓存配置
cache:
enabled: true
# 本地缓存时间(秒)
local-ttl: 3600
# Redis缓存时间(秒)
redis-ttl: 86400
# 监控配置
monitor:
enabled: true
# 统计窗口大小
window-size: 100
# 自定义配置
custom:
ip:
# 内网IP范围
internal-ranges:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
- "127.0.0.0/8"
- "169.254.0.0/16"
# 敏感操作记录IP
sensitive-operations:
- "/api/admin/**"
- "/api/user/password/**"
- "/api/payment/**"三、IP地址解析基础理论
3.1 IP地址基础知识
IPv4与IPv6
// IP地址工具类
@Component
public class IpAddressUtils {
/**
* 验证IP地址格式
*/
public static boolean isValidIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return false;
}
// IPv4验证
if (ip.contains(".")) {
return isValidIPv4(ip);
}
// IPv6验证
if (ip.contains(":")) {
return isValidIPv6(ip);
}
return false;
}
/**
* 验证IPv4地址
*/
private static boolean isValidIPv4(String ip) {
try {
String[] parts = ip.split("\\.");
if (parts.length != 4) {
return false;
}
for (String part : parts) {
int num = Integer.parseInt(part);
if (num < 0 || num > 255) {
return false;
}
}
return !ip.endsWith(".");
} catch (NumberFormatException e) {
return false;
}
}
/**
* 验证IPv6地址
*/
private static boolean isValidIPv6(String ip) {
try {
// 简化验证,实际项目可使用Inet6Address
if (ip == null || ip.isEmpty()) {
return false;
}
// 处理压缩格式
if (ip.contains("::")) {
if (ip.indexOf("::") != ip.lastIndexOf("::")) {
return false; // 只能有一个::
}
}
// 分割各部分
String[] parts = ip.split(":");
if (parts.length > 8 || parts.length < 3) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
/**
* 将IP地址转换为长整型
*/
public static long ipToLong(String ip) {
if (!isValidIPv4(ip)) {
throw new IllegalArgumentException("Invalid IPv4 address: " + ip);
}
String[] parts = ip.split("\\.");
long result = 0;
for (int i = 0; i < 4; i++) {
result = result << 8;
result += Integer.parseInt(parts[i]);
}
return result;
}
/**
* 将长整型转换为IP地址
*/
public static String longToIp(long ip) {
return ((ip >> 24) & 0xFF) + "." +
((ip >> 16) & 0xFF) + "." +
((ip >> 8) & 0xFF) + "." +
(ip & 0xFF);
}
/**
* 判断是否为内网IP
*/
public static boolean isInternalIp(String ip) {
if (!isValidIPv4(ip)) {
return false;
}
long ipLong = ipToLong(ip);
// 10.0.0.0 - 10.255.255.255
if (ipLong >= 0x0A000000L && ipLong <= 0x0AFFFFFFL) {
return true;
}
// 172.16.0.0 - 172.31.255.255
if (ipLong >= 0xAC100000L && ipLong <= 0xAC1FFFFFL) {
return true;
}
// 192.168.0.0 - 192.168.255.255
if (ipLong >= 0xC0A80000L && ipLong <= 0xC0A8FFFFL) {
return true;
}
// 127.0.0.0 - 127.255.255.255
if (ipLong >= 0x7F000000L && ipLong <= 0x7FFFFFFFL) {
return true;
}
// 169.254.0.0 - 169.254.255.255
if (ipLong >= 0xA9FE0000L && ipLong <= 0xA9FEFFFFL) {
return true;
}
return false;
}
/**
* 获取客户端真实IP(处理代理)
*/
public static String getClientIp(HttpServletRequest request) {
// 常见代理头
String[] headers = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && ip.length() > 0 && !"unknown".equalsIgnoreCase(ip)) {
// 多次代理的情况,取第一个IP
if (ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
if (isValidIpAddress(ip) && !isInternalIp(ip)) {
return ip;
}
}
}
// 如果没有获取到,使用远程地址
return request.getRemoteAddr();
}
}3.2 CIDR表示法与子网划分
@Component
public class CidrUtils {
/**
* CIDR转IP范围
*/
public static long[] cidrToRange(String cidr) {
String[] parts = cidr.split("/");
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid CIDR format: " + cidr);
}
String ip = parts[0];
int prefixLength = Integer.parseInt(parts[1]);
long ipLong = IpAddressUtils.ipToLong(ip);
long mask = 0xFFFFFFFFL << (32 - prefixLength);
long network = ipLong & mask;
long broadcast = network | (~mask & 0xFFFFFFFFL);
return new long[]{network, broadcast};
}
/**
* 判断IP是否在CIDR范围内
*/
public static boolean isIpInCidr(String ip, String cidr) {
long ipLong = IpAddressUtils.ipToLong(ip);
long[] range = cidrToRange(cidr);
return ipLong >= range[0] && ipLong <= range[1];
}
/**
* 获取子网掩码
*/
public static String getSubnetMask(int prefixLength) {
long mask = 0xFFFFFFFFL << (32 - prefixLength);
return IpAddressUtils.longToIp(mask);
}
/**
* 计算可用IP数量
*/
public static long getAvailableIpCount(String cidr) {
long[] range = cidrToRange(cidr);
return range[1] - range[0] + 1;
}
}四、离线IP地址解析方案
4.1 MaxMind GeoIP2集成
4.1.1 数据库准备
@Configuration
@ConfigurationProperties(prefix = "ip.geolocation.offline")
@Data
public class GeoIpConfig {
private String database = "maxmind";
private String maxmindDbPath = "classpath:geoip/GeoLite2-City.mmdb";
private String ip2regionDbPath = "classpath:geoip/ip2region.xdb";
@Bean
@ConditionalOnProperty(name = "ip.geolocation.offline.database",
havingValue = "maxmind")
public DatabaseReader maxmindDatabaseReader() throws IOException {
Resource resource = new ClassPathResource(
maxmindDbPath.replace("classpath:", ""));
File database = resource.getFile();
return new DatabaseReader.Builder(database).build();
}
}4.1.2 MaxMind解析服务实现
@Service
@Slf4j
@ConditionalOnProperty(name = "ip.geolocation.offline.database",
havingValue = "maxmind")
public class MaxmindGeoIpService implements GeoIpService {
private final DatabaseReader databaseReader;
private final Cache<String, GeoLocation> cache;
public MaxmindGeoIpService(DatabaseReader databaseReader) {
this.databaseReader = databaseReader;
// 初始化缓存
this.cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
}
@Override
public GeoLocation query(String ip) {
// 先从缓存获取
return cache.get(ip, this::queryFromDatabase);
}
private GeoLocation queryFromDatabase(String ip) {
try {
InetAddress ipAddress = InetAddress.getByName(ip);
CityResponse response = databaseReader.city(ipAddress);
GeoLocation location = new GeoLocation();
location.setIp(ip);
location.setCountry(response.getCountry().getName());
location.setCountryCode(response.getCountry().getIsoCode());
location.setRegion(response.getMostSpecificSubdivision().getName());
location.setCity(response.getCity().getName());
location.setPostalCode(response.getPostal().getCode());
if (response.getLocation() != null) {
location.setLatitude(response.getLocation().getLatitude());
location.setLongitude(response.getLocation().getLongitude());
location.setTimeZone(response.getLocation().getTimeZone());
}
location.setSource("MaxMind");
location.setTimestamp(System.currentTimeMillis());
return location;
} catch (AddressNotFoundException e) {
log.warn("IP address not found in database: {}", ip);
return createUnknownLocation(ip);
} catch (Exception e) {
log.error("Error querying MaxMind database for IP: {}", ip, e);
throw new GeoIpException("Failed to query IP location", e);
}
}
private GeoLocation createUnknownLocation(String ip) {
GeoLocation location = new GeoLocation();
location.setIp(ip);
location.setCountry("Unknown");
location.setSource("MaxMind");
location.setTimestamp(System.currentTimeMillis());
return location;
}
@Override
public boolean isAvailable() {
return databaseReader != null;
}
@Override
public String getProviderName() {
return "MaxMind";
}
@PreDestroy
public void shutdown() {
try {
if (databaseReader != null) {
databaseReader.close();
}
} catch (IOException e) {
log.error("Error closing MaxMind database reader", e);
}
}
}4.2 ip2region本地库集成
ip2region配置
@Slf4j
@Service
@ConditionalOnProperty(name = "ip.geolocation.offline.database",
havingValue = "ip2region")
public class Ip2RegionService implements GeoIpService {
private Searcher searcher;
private final Cache<String, GeoLocation> cache;
@Value("${ip.geolocation.offline.ip2region-db-path}")
private String dbPath;
public Ip2RegionService() {
// 初始化缓存
this.cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
// 延迟初始化数据库
initializeDatabase();
}
private void initializeDatabase() {
try {
Resource resource = new ClassPathResource(
dbPath.replace("classpath:", ""));
// 加载数据库文件到内存
byte[] dbBinStr = Files.readAllBytes(resource.getFile().toPath());
// 创建完全基于内存的查询对象
this.searcher = Searcher.newWithBuffer(dbBinStr);
log.info("ip2region database loaded successfully");
} catch (Exception e) {
log.error("Failed to initialize ip2region database", e);
throw new GeoIpException("Failed to initialize ip2region", e);
}
}
@Override
public GeoLocation query(String ip) {
return cache.get(ip, this::queryFromDatabase);
}
private GeoLocation queryFromDatabase(String ip) {
try {
String region = searcher.search(ip);
// ip2region格式:国家|区域|省份|城市|ISP
String[] regions = region.split("\\|");
GeoLocation location = new GeoLocation();
location.setIp(ip);
if (regions.length >= 5) {
location.setCountry(parseCountry(regions[0]));
location.setRegion(regions[2]); // 省份
location.setCity(regions[3]); // 城市
location.setIsp(regions[4]); // ISP
}
location.setSource("ip2region");
location.setTimestamp(System.currentTimeMillis());
return location;
} catch (Exception e) {
log.error("Error querying ip2region for IP: {}", ip, e);
return createUnknownLocation(ip);
}
}
private String parseCountry(String countryStr) {
if ("中国".equals(countryStr)) {
return "China";
} else if ("0".equals(countryStr)) {
return "Unknown";
}
return countryStr;
}
private GeoLocation createUnknownLocation(String ip) {
GeoLocation location = new GeoLocation();
location.setIp(ip);
location.setCountry("Unknown");
location.setSource("ip2region");
location.setTimestamp(System.currentTimeMillis());
return location;
}
@Override
public boolean isAvailable() {
return searcher != null;
}
@Override
public String getProviderName() {
return "ip2region";
}
}4.3 实体类定义
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class GeoLocation {
/**
* 查询的IP地址
*/
private String ip;
/**
* 国家名称
*/
private String country;
/**
* 国家代码(ISO 3166-1 alpha-2)
*/
private String countryCode;
/**
* 区域/省份
*/
private String region;
/**
* 城市
*/
private String city;
/**
* 区县
*/
private String district;
/**
* 邮政编码
*/
private String postalCode;
/**
* 纬度
*/
private Double latitude;
/**
* 经度
*/
private Double longitude;
/**
* 时区
*/
private String timeZone;
/**
* 互联网服务提供商
*/
private String isp;
/**
* 组织
*/
private String organization;
/**
* AS号码和名称
*/
private String as;
/**
* AS名称
*/
private String asName;
/**
* 是否移动网络
*/
private Boolean mobile;
/**
* 是否代理
*/
private Boolean proxy;
/**
* 是否托管
*/
private Boolean hosting;
/**
* 数据来源
*/
private String source;
/**
* 查询时间戳
*/
private Long timestamp;
/**
* 缓存过期时间
*/
private Long expiresAt;
/**
* 原始响应数据
*/
private String rawData;
/**
* 是否成功
*/
@Builder.Default
private Boolean success = true;
/**
* 错误信息
*/
private String error;
/**
* 响应时间(毫秒)
*/
private Long responseTime;
/**
* 是否内网IP
*/
private Boolean internal;
/**
* 可信度评分(0-100)
*/
@Builder.Default
private Integer confidence = 100;
}五、在线IP地址解析方案
5.1 多服务提供商集成
@Data
@ConfigurationProperties(prefix = "ip.geolocation.online")
@Configuration
public class OnlineProviderConfig {
@Data
public static class Provider {
private String name;
private String url;
private Integer priority = 1;
private Integer timeout = 3000;
private String apiKey;
private Boolean enabled = true;
private Map<String, String> headers = new HashMap<>();
private Map<String, String> params = new HashMap<>();
}
private List<Provider> providers = new ArrayList<>();
@Bean
public List<GeoIpProvider> geoIpProviders(RestTemplate restTemplate) {
return providers.stream()
.filter(Provider::getEnabled)
.sorted(Comparator.comparing(Provider::getPriority))
.map(config -> createProvider(config, restTemplate))
.collect(Collectors.toList());
}
private GeoIpProvider createProvider(Provider config, RestTemplate restTemplate) {
switch (config.getName().toLowerCase()) {
case "ipapi":
return new IpApiProvider(config, restTemplate);
case "ipstack":
return new IpStackProvider(config, restTemplate);
case "taobao":
return new TaobaoIpProvider(config, restTemplate);
case "baidu":
return new BaiduIpProvider(config, restTemplate);
default:
throw new IllegalArgumentException(
"Unknown provider: " + config.getName());
}
}
}5.2 服务提供商实现
1IP-API.com实现
@Slf4j
@Component
public class IpApiProvider implements GeoIpProvider {
private final RestTemplate restTemplate;
private final OnlineProviderConfig.Provider config;
public IpApiProvider(OnlineProviderConfig.Provider config,
RestTemplate restTemplate) {
this.config = config;
this.restTemplate = restTemplate;
}
@Override
public GeoLocation query(String ip) {
long startTime = System.currentTimeMillis();
try {
String url = buildUrl(ip);
ResponseEntity<Map> response = restTemplate.exchange(
url, HttpMethod.GET, null, Map.class);
Map<String, Object> data = response.getBody();
if (data == null) {
throw new GeoIpException("Empty response from IP-API");
}
String status = (String) data.get("status");
if (!"success".equals(status)) {
String message = (String) data.get("message");
throw new GeoIpException("IP-API error: " + message);
}
GeoLocation location = parseResponse(data);
location.setResponseTime(System.currentTimeMillis() - startTime);
return location;
} catch (Exception e) {
log.warn("Failed to query IP-API for IP: {}", ip, e);
throw new GeoIpException("IP-API query failed", e);
}
}
private String buildUrl(String ip) {
return config.getUrl().replace("{ip}", ip);
}
private GeoLocation parseResponse(Map<String, Object> data) {
return GeoLocation.builder()
.country((String) data.get("country"))
.countryCode((String) data.get("countryCode"))
.region((String) data.get("regionName"))
.city((String) data.get("city"))
.postalCode((String) data.get("zip"))
.latitude(Double.parseDouble(data.get("lat").toString()))
.longitude(Double.parseDouble(data.get("lon").toString()))
.timeZone((String) data.get("timezone"))
.isp((String) data.get("isp"))
.organization((String) data.get("org"))
.as((String) data.get("as"))
.mobile(Boolean.parseBoolean(data.get("mobile").toString()))
.proxy(Boolean.parseBoolean(data.get("proxy").toString()))
.hosting(Boolean.parseBoolean(data.get("hosting").toString()))
.source("IP-API")
.timestamp(System.currentTimeMillis())
.rawData(JsonUtils.toJson(data))
.build();
}
@Override
public String getName() {
return "IP-API";
}
@Override
public int getPriority() {
return config.getPriority();
}
}淘宝IP库实现
@Slf4j
@Component
public class TaobaoIpProvider implements GeoIpProvider {
private final RestTemplate restTemplate;
private final OnlineProviderConfig.Provider config;
public TaobaoIpProvider(OnlineProviderConfig.Provider config,
RestTemplate restTemplate) {
this.config = config;
this.restTemplate = restTemplate;
}
@Override
public GeoLocation query(String ip) {
long startTime = System.currentTimeMillis();
try {
String url = buildUrl(ip);
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.GET, null, String.class);
String responseBody = response.getBody();
if (responseBody == null) {
throw new GeoIpException("Empty response from Taobao IP");
}
// 解析淘宝返回的JSON
Map<String, Object> data = JsonUtils.parse(responseBody, Map.class);
Number code = (Number) data.get("code");
if (code == null || code.intValue() != 0) {
throw new GeoIpException("Taobao IP API error: " + data.get("msg"));
}
Map<String, Object> ipData = (Map<String, Object>) data.get("data");
GeoLocation location = parseResponse(ip, ipData);
location.setResponseTime(System.currentTimeMillis() - startTime);
return location;
} catch (Exception e) {
log.warn("Failed to query Taobao IP for IP: {}", ip, e);
throw new GeoIpException("Taobao IP query failed", e);
}
}
private String buildUrl(String ip) {
return config.getUrl().replace("{ip}", ip);
}
private GeoLocation parseResponse(String ip, Map<String, Object> data) {
return GeoLocation.builder()
.ip(ip)
.country("中国")
.countryCode("CN")
.region((String) data.get("region"))
.city((String) data.get("city"))
.isp((String) data.get("isp"))
.source("Taobao")
.timestamp(System.currentTimeMillis())
.rawData(JsonUtils.toJson(data))
.build();
}
@Override
public String getName() {
return "Taobao IP";
}
@Override
public int getPriority() {
return config.getPriority();
}
}5.3 统一调用管理器
@Service
@Slf4j
public class GeoIpManager implements GeoIpService {
private final List<GeoIpProvider> onlineProviders;
private final List<GeoIpService> offlineServices;
private final Cache<String, GeoLocation> cache;
private final GeoIpProperties properties;
@Autowired
public GeoIpManager(List<GeoIpProvider> onlineProviders,
List<GeoIpService> offlineServices,
GeoIpProperties properties) {
this.onlineProviders = onlineProviders;
this.offlineServices = offlineServices;
this.properties = properties;
// 初始化缓存
this.cache = Caffeine.newBuilder()
.maximumSize(properties.getCache().getMaximumSize())
.expireAfterWrite(properties.getCache().getLocalTtl(),
TimeUnit.SECONDS)
.recordStats()
.build();
}
@Override
public GeoLocation query(String ip) {
// 参数验证
if (!IpAddressUtils.isValidIpAddress(ip)) {
throw new IllegalArgumentException("Invalid IP address: " + ip);
}
// 检查是否为内网IP
if (IpAddressUtils.isInternalIp(ip)) {
return createInternalLocation(ip);
}
// 尝试从缓存获取
GeoLocation cached = cache.getIfPresent(ip);
if (cached != null &&
cached.getExpiresAt() > System.currentTimeMillis()) {
return cached;
}
// 根据配置模式执行查询
GeoLocation location;
switch (properties.getMode()) {
case "offline":
location = queryOffline(ip);
break;
case "online":
location = queryOnline(ip);
break;
case "hybrid":
default:
location = queryHybrid(ip);
break;
}
// 设置缓存过期时间
if (location != null && location.getSuccess()) {
location.setExpiresAt(
System.currentTimeMillis() +
properties.getCache().getLocalTtl() * 1000);
cache.put(ip, location);
}
return location;
}
private GeoLocation queryOffline(String ip) {
for (GeoIpService service : offlineServices) {
try {
if (service.isAvailable()) {
return service.query(ip);
}
} catch (Exception e) {
log.warn("Offline service {} failed for IP: {}",
service.getProviderName(), ip, e);
}
}
throw new GeoIpException("All offline services failed");
}
private GeoLocation queryOnline(String ip) {
for (GeoIpProvider provider : onlineProviders) {
try {
GeoLocation location = provider.query(ip);
if (location != null && location.getSuccess()) {
return location;
}
} catch (Exception e) {
log.warn("Online provider {} failed for IP: {}",
provider.getName(), ip, e);
}
}
throw new GeoIpException("All online providers failed");
}
private GeoLocation queryHybrid(String ip) {
// 首先尝试离线查询
for (GeoIpService service : offlineServices) {
try {
if (service.isAvailable()) {
return service.query(ip);
}
} catch (Exception e) {
log.debug("Offline service failed, trying online providers");
}
}
// 离线失败则尝试在线查询
return queryOnline(ip);
}
private GeoLocation createInternalLocation(String ip) {
return GeoLocation.builder()
.ip(ip)
.country("Internal")
.city("Internal Network")
.internal(true)
.success(true)
.source("System")
.timestamp(System.currentTimeMillis())
.build();
}
@Override
public boolean isAvailable() {
return !offlineServices.isEmpty() || !onlineProviders.isEmpty();
}
@Override
public String getProviderName() {
return "GeoIpManager";
}
/**
* 批量查询
*/
public Map<String, GeoLocation> batchQuery(List<String> ips) {
Map<String, GeoLocation> results = new ConcurrentHashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(ips.size(), 10));
List<Future<?>> futures = new ArrayList<>();
for (String ip : ips) {
futures.add(executor.submit(() -> {
try {
GeoLocation location = query(ip);
results.put(ip, location);
} catch (Exception e) {
log.error("Failed to query IP: {}", ip, e);
results.put(ip, createErrorLocation(ip, e.getMessage()));
}
}));
}
// 等待所有任务完成
for (Future<?> future : futures) {
try {
future.get();
} catch (Exception e) {
log.error("Error waiting for query task", e);
}
}
executor.shutdown();
return results;
}
private GeoLocation createErrorLocation(String ip, String error) {
return GeoLocation.builder()
.ip(ip)
.success(false)
.error(error)
.source("System")
.timestamp(System.currentTimeMillis())
.build();
}
/**
* 获取缓存统计信息
*/
public CacheStats getCacheStats() {
return cache.stats();
}
/**
* 清空缓存
*/
public void clearCache() {
cache.invalidateAll();
}
}六、SpringBoot整合与配置
6.1 自动配置类
@Configuration
@EnableConfigurationProperties(GeoIpProperties.class)
@ConditionalOnClass(GeoIpService.class)
@AutoConfigureAfter(WebMvcAutoConfiguration.class)
public class GeoIpAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RestTemplate geoIpRestTemplate() {
RestTemplate restTemplate = new RestTemplate();
// 设置超时时间
SimpleClientHttpRequestFactory factory =
new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
restTemplate.setRequestFactory(factory);
// 添加拦截器
restTemplate.getInterceptors().add(new GeoIpRequestInterceptor());
return restTemplate;
}
@Bean
@ConditionalOnMissingBean
public ObjectMapper geoIpObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
@Bean
@ConditionalOnMissingBean
public GeoIpManager geoIpManager(List<GeoIpProvider> onlineProviders,
List<GeoIpService> offlineServices,
GeoIpProperties properties) {
return new GeoIpManager(onlineProviders, offlineServices, properties);
}
@Bean
@ConditionalOnMissingBean
public GeoIpAspect geoIpAspect(GeoIpManager geoIpManager) {
return new GeoIpAspect(geoIpManager);
}
@Bean
@ConditionalOnMissingBean
public IpAddressUtils ipAddressUtils() {
return new IpAddressUtils();
}
}
/**
* HTTP请求拦截器
*/
class GeoIpRequestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution)
throws IOException {
// 添加User-Agent
request.getHeaders().add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
// 记录请求开始时间
long startTime = System.currentTimeMillis();
ClientHttpResponse response = execution.execute(request, body);
// 记录请求耗时
long duration = System.currentTimeMillis() - startTime;
log.debug("HTTP request to {} completed in {}ms",
request.getURI(), duration);
return response;
}
}6.2 属性配置类
@Data
@ConfigurationProperties(prefix = "ip.geolocation")
@Validated
public class GeoIpProperties {
@NotNull
@Pattern(regexp = "offline|online|hybrid")
private String mode = "hybrid";
private Offline offline = new Offline();
private Online online = new Online();
private Cache cache = new Cache();
private Monitor monitor = new Monitor();
@Data
public static class Offline {
private String database = "ip2region";
private String maxmindDbPath = "classpath:geoip/GeoLite2-City.mmdb";
private String ip2regionDbPath = "classpath:geoip/ip2region.xdb";
private Boolean enabled = true;
}
@Data
public static class Online {
private Boolean enabled = true;
private Integer timeout = 5000;
private Integer retryCount = 2;
private List<Provider> providers = new ArrayList<>();
}
@Data
public static class Provider {
private String name;
private String url;
private Integer priority = 1;
private Integer timeout = 3000;
private String apiKey;
private Boolean enabled = true;
}
@Data
public static class Cache {
private Boolean enabled = true;
private Long localTtl = 3600L;
private Long redisTtl = 86400L;
private Long maximumSize = 10000L;
private Boolean recordStats = true;
}
@Data
public static class Monitor {
private Boolean enabled = true;
private Integer windowSize = 100;
private Long statsInterval = 60000L;
}
}6.3 AOP切面处理
@Aspect
@Component
@Slf4j
public class GeoIpAspect {
private final GeoIpManager geoIpManager;
private final ThreadLocal<GeoLocation> currentLocation = new ThreadLocal<>();
public GeoIpAspect(GeoIpManager geoIpManager) {
this.geoIpManager = geoIpManager;
}
/**
* 拦截Controller方法,自动注入IP位置信息
*/
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
"@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
public Object injectGeoLocation(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取HttpServletRequest
HttpServletRequest request = getHttpServletRequest(joinPoint);
if (request != null) {
// 获取客户端IP
String clientIp = IpAddressUtils.getClientIp(request);
// 查询位置信息
GeoLocation location = geoIpManager.query(clientIp);
// 存储到ThreadLocal
currentLocation.set(location);
// 设置到请求属性中
request.setAttribute("geoLocation", location);
request.setAttribute("clientIp", clientIp);
log.debug("Injected geo location for IP: {}, Country: {}",
clientIp, location.getCountry());
}
try {
return joinPoint.proceed();
} finally {
// 清理ThreadLocal
currentLocation.remove();
}
}
/**
* 获取当前请求的位置信息
*/
public GeoLocation getCurrentLocation() {
return currentLocation.get();
}
private HttpServletRequest getHttpServletRequest(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg instanceof HttpServletRequest) {
return (HttpServletRequest) arg;
}
}
// 尝试从RequestContextHolder获取
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
return attributes.getRequest();
}
return null;
}
}七、REST API设计
7.1 控制器设计
@RestController
@RequestMapping("/api/v1/ip")
@Validated
@Slf4j
@Tag(name = "IP地址解析", description = "IP地理位置查询API")
public class IpGeoController {
private final GeoIpManager geoIpManager;
private final IpAddressUtils ipAddressUtils;
@Autowired
public IpGeoController(GeoIpManager geoIpManager,
IpAddressUtils ipAddressUtils) {
this.geoIpManager = geoIpManager;
this.ipAddressUtils = ipAddressUtils;
}
/**
* 查询单个IP地址信息
*/
@GetMapping("/query")
@Operation(summary = "查询IP地理位置",
description = "根据IP地址查询地理位置信息")
@ApiResponse(responseCode = "200", description = "查询成功")
@ApiResponse(responseCode = "400", description = "请求参数错误")
public ResponseEntity<ApiResponse<GeoLocation>> queryIp(
@RequestParam @Pattern(regexp =
"^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$",
message = "IP地址格式不正确") String ip) {
GeoLocation location = geoIpManager.query(ip);
return ResponseEntity.ok(ApiResponse.success(location));
}
/**
* 查询当前请求的IP地址信息
*/
@GetMapping("/current")
@Operation(summary = "查询当前请求IP",
description = "获取当前请求客户端的地理位置信息")
public ResponseEntity<ApiResponse<GeoLocation>> queryCurrentIp(
HttpServletRequest request) {
String clientIp = ipAddressUtils.getClientIp(request);
GeoLocation location = geoIpManager.query(clientIp);
return ResponseEntity.ok(ApiResponse.success(location));
}
/**
* 批量查询IP地址信息
*/
@PostMapping("/batch-query")
@Operation(summary = "批量查询IP地理位置",
description = "批量查询多个IP地址的地理位置信息")
public ResponseEntity<ApiResponse<Map<String, GeoLocation>>> batchQueryIp(
@RequestBody @Valid BatchQueryRequest request) {
// 限制批量查询数量
if (request.getIps().size() > 100) {
throw new IllegalArgumentException("批量查询最多支持100个IP地址");
}
// 验证IP地址格式
for (String ip : request.getIps()) {
if (!ipAddressUtils.isValidIpAddress(ip)) {
throw new IllegalArgumentException("无效的IP地址: " + ip);
}
}
Map<String, GeoLocation> results = geoIpManager.batchQuery(request.getIps());
return ResponseEntity.ok(ApiResponse.success(results));
}
/**
* 验证IP地址
*/
@GetMapping("/validate")
@Operation(summary = "验证IP地址",
description = "验证IP地址格式和类型")
public ResponseEntity<ApiResponse<IpValidationResult>> validateIp(
@RequestParam String ip) {
boolean isValid = ipAddressUtils.isValidIpAddress(ip);
boolean isInternal = ipAddressUtils.isInternalIp(ip);
String ipType = ip.contains(":") ? "IPv6" : "IPv4";
IpValidationResult result = IpValidationResult.builder()
.ip(ip)
.valid(isValid)
.internal(isInternal)
.type(ipType)
.build();
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 获取服务状态
*/
@GetMapping("/status")
@Operation(summary = "获取服务状态",
description = "获取IP解析服务的状态信息")
public ResponseEntity<ApiResponse<ServiceStatus>> getServiceStatus() {
CacheStats stats = geoIpManager.getCacheStats();
ServiceStatus status = ServiceStatus.builder()
.cacheHits(stats.hitCount())
.cacheMisses(stats.missCount())
.cacheHitRate(stats.hitRate())
.cacheSize(stats.evictionCount())
.build();
return ResponseEntity.ok(ApiResponse.success(status));
}
/**
* 清空缓存
*/
@PostMapping("/cache/clear")
@Operation(summary = "清空缓存",
description = "清空IP地理位置查询缓存")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<Void>> clearCache() {
geoIpManager.clearCache();
return ResponseEntity.ok(ApiResponse.success());
}
@Data
@Builder
public static class IpValidationResult {
private String ip;
private boolean valid;
private boolean internal;
private String type;
private String message;
}
@Data
@Builder
public static class ServiceStatus {
private long cacheHits;
private long cacheMisses;
private double cacheHitRate;
private long cacheSize;
private Date timestamp;
}
@Data
public static class BatchQueryRequest {
@NotNull
@Size(min = 1, max = 100, message = "IP数量必须在1-100之间")
private List<String> ips;
}
}7.2 响应封装
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ApiResponse<T> {
private boolean success;
private String code;
private String message;
private T data;
private Long timestamp;
private String requestId;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.code("200")
.message("Success")
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}
public static ApiResponse<Void> success() {
return ApiResponse.<Void>builder()
.success(true)
.code("200")
.message("Success")
.timestamp(System.currentTimeMillis())
.build();
}
public static ApiResponse<Void> error(String code, String message) {
return ApiResponse.<Void>builder()
.success(false)
.code(code)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
}7.3 全局异常处理
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(GeoIpException.class)
public ResponseEntity<ApiResponse<Void>> handleGeoIpException(
GeoIpException ex) {
log.error("GeoIP service error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("GEOIP_ERROR", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(
IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("INVALID_PARAM", ex.getMessage()));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolationException(
ConstraintViolationException ex) {
String message = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("VALIDATION_ERROR", message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(
Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("INTERNAL_ERROR", "Internal server error"));
}
}
/**
* 自定义异常类
*/
public class GeoIpException extends RuntimeException {
public GeoIpException(String message) {
super(message);
}
public GeoIpException(String message, Throwable cause) {
super(message, cause);
}
}八、高级功能实现
8.1 IP地址库自动更新
@Component
@Slf4j
public class GeoIpDatabaseUpdater {
private final GeoIpProperties properties;
private final ApplicationEventPublisher eventPublisher;
@Autowired
public GeoIpDatabaseUpdater(GeoIpProperties properties,
ApplicationEventPublisher eventPublisher) {
this.properties = properties;
this.eventPublisher = eventPublisher;
}
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void scheduledUpdate() {
log.info("Starting scheduled GeoIP database update");
try {
if ("maxmind".equals(properties.getOffline().getDatabase())) {
updateMaxmindDatabase();
} else if ("ip2region".equals(properties.getOffline().getDatabase())) {
updateIp2RegionDatabase();
}
log.info("GeoIP database update completed successfully");
// 发布更新完成事件
eventPublisher.publishEvent(new DatabaseUpdateEvent(this, true));
} catch (Exception e) {
log.error("Failed to update GeoIP database", e);
// 发布更新失败事件
eventPublisher.publishEvent(new DatabaseUpdateEvent(this, false));
}
}
private void updateMaxmindDatabase() throws IOException {
String downloadUrl = "https://download.maxmind.com/app/geoip_download" +
"?edition_id=GeoLite2-City&license_key=YOUR_LICENSE_KEY&suffix=tar.gz";
// 下载数据库文件
File tempFile = downloadFile(downloadUrl);
// 解压文件
File extractedDir = extractTarGz(tempFile);
// 查找.mmdb文件
File mmdbFile = findMmdbFile(extractedDir);
if (mmdbFile == null) {
throw new IOException("Could not find .mmdb file in downloaded archive");
}
// 备份原文件
File originalFile = new File(properties.getOffline().getMaxmindDbPath()
.replace("classpath:", ""));
File backupFile = new File(originalFile.getParent(),
originalFile.getName() + ".bak");
Files.copy(originalFile.toPath(), backupFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
// 替换数据库文件
Files.copy(mmdbFile.toPath(), originalFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
// 清理临时文件
cleanTempFiles(tempFile, extractedDir);
log.info("MaxMind database updated successfully");
}
private void updateIp2RegionDatabase() throws IOException {
String downloadUrl = "https://github.com/lionsoul2014/ip2region/raw/master/data/ip2region.xdb";
// 下载数据库文件
File tempFile = downloadFile(downloadUrl);
// 备份原文件
File originalFile = new File(properties.getOffline().getIp2regionDbPath()
.replace("classpath:", ""));
File backupFile = new File(originalFile.getParent(),
originalFile.getName() + ".bak");
Files.copy(originalFile.toPath(), backupFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
// 替换数据库文件
Files.copy(tempFile.toPath(), originalFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
// 清理临时文件
tempFile.delete();
log.info("ip2region database updated successfully");
}
private File downloadFile(String url) throws IOException {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<byte[]> response = restTemplate.exchange(
url, HttpMethod.GET, null, byte[].class);
File tempFile = File.createTempFile("geoip", ".tmp");
Files.write(tempFile.toPath(), response.getBody());
return tempFile;
}
private File extractTarGz(File tarGzFile) throws IOException {
File outputDir = new File(tarGzFile.getParent(), "extracted");
try (TarArchiveInputStream tarInput = new TarArchiveInputStream(
new GZIPInputStream(new FileInputStream(tarGzFile)))) {
TarArchiveEntry entry;
while ((entry = tarInput.getNextEntry()) != null) {
File outputFile = new File(outputDir, entry.getName());
if (entry.isDirectory()) {
outputFile.mkdirs();
} else {
outputFile.getParentFile().mkdirs();
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
IOUtils.copy(tarInput, fos);
}
}
}
}
return outputDir;
}
private File findMmdbFile(File directory) {
File[] files = directory.listFiles((dir, name) ->
name.endsWith(".mmdb"));
if (files != null && files.length > 0) {
return files[0];
}
// 递归查找子目录
File[] subdirs = directory.listFiles(File::isDirectory);
if (subdirs != null) {
for (File subdir : subdirs) {
File mmdbFile = findMmdbFile(subdir);
if (mmdbFile != null) {
return mmdbFile;
}
}
}
return null;
}
private void cleanTempFiles(File tempFile, File extractedDir) {
try {
tempFile.delete();
deleteDirectory(extractedDir);
} catch (Exception e) {
log.warn("Failed to clean temp files", e);
}
}
private void deleteDirectory(File directory) throws IOException {
if (directory.exists()) {
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
deleteDirectory(file);
} else {
file.delete();
}
}
}
directory.delete();
}
}
}
/**
* 数据库更新事件
*/
public class DatabaseUpdateEvent extends ApplicationEvent {
private final boolean success;
private final Date timestamp;
public DatabaseUpdateEvent(Object source, boolean success) {
super(source);
this.success = success;
this.timestamp = new Date();
}
public boolean isSuccess() {
return success;
}
public Date getTimestamp() {
return timestamp;
}
}8.2 IP访问频率限制
@Component
@Slf4j
public class IpRateLimiter {
private final Cache<String, RateLimitInfo> rateLimitCache;
private final List<String> excludedIps;
public IpRateLimiter() {
this.rateLimitCache = Caffeine.newBuilder()
.maximumSize(100000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
// 从配置文件加载排除的IP列表
this.excludedIps = loadExcludedIps();
}
/**
* 检查IP是否超过频率限制
*/
public boolean isRateLimited(String ip, String endpoint) {
// 排除的IP不受限制
if (excludedIps.contains(ip)) {
return false;
}
String key = ip + ":" + endpoint;
RateLimitInfo info = rateLimitCache.getIfPresent(key);
if (info == null) {
info = new RateLimitInfo();
rateLimitCache.put(key, info);
}
return info.isRateLimited();
}
/**
* 记录IP访问
*/
public void recordAccess(String ip, String endpoint) {
String key = ip + ":" + endpoint;
RateLimitInfo info = rateLimitCache.getIfPresent(key);
if (info == null) {
info = new RateLimitInfo();
}
info.recordAccess();
rateLimitCache.put(key, info);
}
/**
* 获取IP的访问统计
*/
public RateLimitInfo getRateLimitInfo(String ip, String endpoint) {
String key = ip + ":" + endpoint;
return rateLimitCache.getIfPresent(key);
}
/**
* 清除IP的限制
*/
public void clearRateLimit(String ip, String endpoint) {
String key = ip + ":" + endpoint;
rateLimitCache.invalidate(key);
}
private List<String> loadExcludedIps() {
// 从配置文件或数据库加载
return Arrays.asList(
"127.0.0.1",
"192.168.1.1",
"10.0.0.1"
);
}
/**
* 速率限制信息
*/
@Data
public static class RateLimitInfo {
private static final int MAX_REQUESTS_PER_MINUTE = 100;
private static final int MAX_REQUESTS_PER_HOUR = 1000;
private List<Long> accessTimes = new ArrayList<>();
public void recordAccess() {
long now = System.currentTimeMillis();
accessTimes.add(now);
// 清理过期的记录
long oneMinuteAgo = now - 60000;
long oneHourAgo = now - 3600000;
accessTimes.removeIf(time -> time < oneHourAgo);
}
public boolean isRateLimited() {
long now = System.currentTimeMillis();
long oneMinuteAgo = now - 60000;
long oneHourAgo = now - 3600000;
long minuteCount = accessTimes.stream()
.filter(time -> time > oneMinuteAgo)
.count();
long hourCount = accessTimes.stream()
.filter(time -> time > oneHourAgo)
.count();
return minuteCount > MAX_REQUESTS_PER_MINUTE ||
hourCount > MAX_REQUESTS_PER_HOUR;
}
public Map<String, Object> getStats() {
long now = System.currentTimeMillis();
long oneMinuteAgo = now - 60000;
long oneHourAgo = now - 3600000;
long minuteCount = accessTimes.stream()
.filter(time -> time > oneMinuteAgo)
.count();
long hourCount = accessTimes.stream()
.filter(time -> time > oneHourAgo)
.count();
Map<String, Object> stats = new HashMap<>();
stats.put("minuteCount", minuteCount);
stats.put("hourCount", hourCount);
stats.put("minuteLimit", MAX_REQUESTS_PER_MINUTE);
stats.put("hourLimit", MAX_REQUESTS_PER_HOUR);
stats.put("isRateLimited", isRateLimited());
return stats;
}
}
}8.3 地理位置可视化
@RestController
@RequestMapping("/api/v1/visualization")
@Tag(name = "IP可视化", description = "IP地理位置可视化API")
public class IpVisualizationController {
private final GeoIpManager geoIpManager;
private final IpAccessRepository ipAccessRepository;
@Autowired
public IpVisualizationController(GeoIpManager geoIpManager,
IpAccessRepository ipAccessRepository) {
this.geoIpManager = geoIpManager;
this.ipAccessRepository = ipAccessRepository;
}
/**
* 生成访问热力图数据
*/
@GetMapping("/heatmap")
@Operation(summary = "访问热力图",
description = "生成IP访问热力图数据")
public ResponseEntity<ApiResponse<HeatMapData>> getHeatMapData(
@RequestParam(required = false) Date startTime,
@RequestParam(required = false) Date endTime) {
if (startTime == null) {
startTime = Date.from(Instant.now().minus(7, ChronoUnit.DAYS));
}
if (endTime == null) {
endTime = new Date();
}
// 查询访问记录
List<IpAccessRecord> records = ipAccessRepository
.findByAccessTimeBetween(startTime, endTime);
// 按地理位置聚合
Map<String, Integer> locationCount = new HashMap<>();
for (IpAccessRecord record : records) {
String locationKey = record.getCountry() + "|" + record.getCity();
locationCount.put(locationKey,
locationCount.getOrDefault(locationKey, 0) + 1);
}
// 生成热力图数据
List<HeatMapPoint> points = locationCount.entrySet().stream()
.map(entry -> {
String[] parts = entry.getKey().split("\\|");
GeoLocation sampleLocation = geoIpManager.query(
records.stream()
.filter(r -> r.getCountry().equals(parts[0]) &&
r.getCity().equals(parts[1]))
.findFirst()
.map(IpAccessRecord::getIp)
.orElse("8.8.8.8")
);
return HeatMapPoint.builder()
.country(parts[0])
.city(parts[1])
.count(entry.getValue())
.latitude(sampleLocation.getLatitude())
.longitude(sampleLocation.getLongitude())
.build();
})
.collect(Collectors.toList());
HeatMapData data = HeatMapData.builder()
.startTime(startTime)
.endTime(endTime)
.totalAccesses(records.size())
.uniqueLocations(locationCount.size())
.points(points)
.build();
return ResponseEntity.ok(ApiResponse.success(data));
}
/**
* 生成访问统计图表数据
*/
@GetMapping("/statistics")
@Operation(summary = "访问统计",
description = "生成IP访问统计图表数据")
public ResponseEntity<ApiResponse<AccessStatistics>> getAccessStatistics(
@RequestParam(required = false) @Pattern(regexp = "day|week|month|year")
String period) {
if (period == null) {
period = "week";
}
Date endTime = new Date();
Date startTime;
switch (period) {
case "day":
startTime = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
break;
case "week":
startTime = Date.from(Instant.now().minus(7, ChronoUnit.DAYS));
break;
case "month":
startTime = Date.from(Instant.now().minus(30, ChronoUnit.DAYS));
break;
case "year":
startTime = Date.from(Instant.now().minus(365, ChronoUnit.DAYS));
break;
default:
startTime = Date.from(Instant.now().minus(7, ChronoUnit.DAYS));
}
List<IpAccessRecord> records = ipAccessRepository
.findByAccessTimeBetween(startTime, endTime);
// 按时间分组统计
Map<String, Long> timeSeries = createTimeSeries(records, period);
// 按国家分组统计
Map<String, Long> countryStats = records.stream()
.collect(Collectors.groupingBy(
IpAccessRecord::getCountry,
Collectors.counting()
));
// 按城市分组统计
Map<String, Long> cityStats = records.stream()
.collect(Collectors.groupingBy(
record -> record.getCountry() + " - " + record.getCity(),
Collectors.counting()
));
AccessStatistics statistics = AccessStatistics.builder()
.period(period)
.startTime(startTime)
.endTime(endTime)
.totalAccesses(records.size())
.uniqueIps(records.stream().map(IpAccessRecord::getIp).distinct().count())
.timeSeries(timeSeries)
.countryStats(countryStats)
.cityStats(cityStats)
.build();
return ResponseEntity.ok(ApiResponse.success(statistics));
}
private Map<String, Long> createTimeSeries(List<IpAccessRecord> records,
String period) {
DateTimeFormatter formatter;
switch (period) {
case "day":
formatter = DateTimeFormatter.ofPattern("HH:00");
break;
case "week":
formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
break;
case "month":
formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
break;
case "year":
formatter = DateTimeFormatter.ofPattern("yyyy-MM");
break;
default:
formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
}
return records.stream()
.collect(Collectors.groupingBy(
record -> record.getAccessTime().toInstant()
.atZone(ZoneId.systemDefault())
.format(formatter),
Collectors.counting()
));
}
@Data
@Builder
public static class HeatMapData {
private Date startTime;
private Date endTime;
private long totalAccesses;
private int uniqueLocations;
private List<HeatMapPoint> points;
}
@Data
@Builder
public static class HeatMapPoint {
private String country;
private String city;
private int count;
private Double latitude;
private Double longitude;
}
@Data
@Builder
public static class AccessStatistics {
private String period;
private Date startTime;
private Date endTime;
private long totalAccesses;
private long uniqueIps;
private Map<String, Long> timeSeries;
private Map<String, Long> countryStats;
private Map<String, Long> cityStats;
}
}九、性能优化与缓存策略
9.1 多级缓存实现
@Component
@Slf4j
public class MultiLevelCache {
private final Cache<String, GeoLocation> localCache;
private final RedisTemplate<String, GeoLocation> redisTemplate;
private final boolean useRedis;
public MultiLevelCache(RedisTemplate<String, GeoLocation> redisTemplate,
GeoIpProperties properties) {
// 一级缓存:本地缓存
this.localCache = Caffeine.newBuilder()
.maximumSize(properties.getCache().getMaximumSize())
.expireAfterWrite(properties.getCache().getLocalTtl(),
TimeUnit.SECONDS)
.recordStats()
.build();
// 二级缓存:Redis
this.redisTemplate = redisTemplate;
this.useRedis = redisTemplate != null;
}
/**
* 从缓存获取数据
*/
public GeoLocation get(String ip) {
// 先查本地缓存
GeoLocation location = localCache.getIfPresent(ip);
if (location != null) {
log.debug("Cache hit from local cache for IP: {}", ip);
return location;
}
// 本地缓存未命中,查询Redis
if (useRedis) {
location = redisTemplate.opsForValue().get(buildRedisKey(ip));
if (location != null) {
log.debug("Cache hit from Redis for IP: {}", ip);
// 回填到本地缓存
localCache.put(ip, location);
return location;
}
}
log.debug("Cache miss for IP: {}", ip);
return null;
}
/**
* 写入缓存
*/
public void put(String ip, GeoLocation location) {
if (location == null) {
return;
}
// 写入本地缓存
localCache.put(ip, location);
// 写入Redis
if (useRedis) {
try {
redisTemplate.opsForValue().set(
buildRedisKey(ip),
location,
1, TimeUnit.HOURS // Redis缓存1小时
);
log.debug("Data cached in Redis for IP: {}", ip);
} catch (Exception e) {
log.warn("Failed to cache data in Redis for IP: {}", ip, e);
}
}
}
/**
* 批量获取
*/
public Map<String, GeoLocation> batchGet(List<String> ips) {
Map<String, GeoLocation> results = new HashMap<>();
List<String> missingKeys = new ArrayList<>();
// 先查本地缓存
for (String ip : ips) {
GeoLocation location = localCache.getIfPresent(ip);
if (location != null) {
results.put(ip, location);
} else {
missingKeys.add(ip);
}
}
// 如果还有未命中的,批量查询Redis
if (useRedis && !missingKeys.isEmpty()) {
List<String> redisKeys = missingKeys.stream()
.map(this::buildRedisKey)
.collect(Collectors.toList());
List<GeoLocation> redisResults = redisTemplate.opsForValue()
.multiGet(redisKeys);
for (int i = 0; i < missingKeys.size(); i++) {
String ip = missingKeys.get(i);
GeoLocation location = redisResults.get(i);
if (location != null) {
results.put(ip, location);
// 回填到本地缓存
localCache.put(ip, location);
}
}
}
return results;
}
/**
* 批量写入
*/
public void batchPut(Map<String, GeoLocation> data) {
if (data == null || data.isEmpty()) {
return;
}
// 写入本地缓存
data.forEach(localCache::put);
// 批量写入Redis
if (useRedis) {
try {
Map<String, GeoLocation> redisData = data.entrySet().stream()
.collect(Collectors.toMap(
entry -> buildRedisKey(entry.getKey()),
Map.Entry::getValue
));
redisTemplate.opsForValue().multiSet(redisData);
// 设置过期时间
for (String key : redisData.keySet()) {
redisTemplate.expire(key, 1, TimeUnit.HOURS);
}
log.debug("Batch cached {} items in Redis", data.size());
} catch (Exception e) {
log.warn("Failed to batch cache data in Redis", e);
}
}
}
/**
* 删除缓存
*/
public void evict(String ip) {
localCache.invalidate(ip);
if (useRedis) {
redisTemplate.delete(buildRedisKey(ip));
}
}
/**
* 清空所有缓存
*/
public void clear() {
localCache.invalidateAll();
if (useRedis) {
// 注意:这会清空所有缓存,生产环境慎用
Set<String> keys = redisTemplate.keys("geoip:*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
/**
* 获取缓存统计信息
*/
public CacheStats getStats() {
return localCache.stats();
}
private String buildRedisKey(String ip) {
return "geoip:" + ip;
}
}9.2 异步处理优化
@Component
@Slf4j
public class AsyncGeoIpService {
private final GeoIpManager geoIpManager;
private final ExecutorService executorService;
private final CompletionService<GeoLocation> completionService;
public AsyncGeoIpService(GeoIpManager geoIpManager) {
this.geoIpManager = geoIpManager;
// 创建线程池
this.executorService = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2,
new ThreadFactoryBuilder()
.setNameFormat("geoip-async-%d")
.setDaemon(true)
.build()
);
this.completionService = new ExecutorCompletionService<>(executorService);
}
/**
* 异步查询单个IP
*/
public CompletableFuture<GeoLocation> queryAsync(String ip) {
return CompletableFuture.supplyAsync(() -> geoIpManager.query(ip),
executorService);
}
/**
* 异步批量查询
*/
public CompletableFuture<Map<String, GeoLocation>> batchQueryAsync(
List<String> ips) {
return CompletableFuture.supplyAsync(() -> {
Map<String, GeoLocation> results = new ConcurrentHashMap<>();
List<Future<GeoLocation>> futures = new ArrayList<>();
// 提交所有查询任务
for (String ip : ips) {
futures.add(completionService.submit(() -> geoIpManager.query(ip)));
}
// 等待所有任务完成
for (int i = 0; i < futures.size(); i++) {
try {
Future<GeoLocation> future = completionService.take();
GeoLocation location = future.get();
// 根据IP地址找到对应的结果
// 这里需要维护IP和任务的映射关系
// 简化处理:在任务提交时记录IP
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Batch query interrupted", e);
} catch (ExecutionException e) {
log.error("Error executing query task", e);
}
}
return results;
}, executorService);
}
/**
* 带超时的查询
*/
public GeoLocation queryWithTimeout(String ip, long timeout, TimeUnit unit) {
CompletableFuture<GeoLocation> future = queryAsync(ip);
try {
return future.get(timeout, unit);
} catch (TimeoutException e) {
future.cancel(true);
throw new GeoIpException("Query timeout for IP: " + ip);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GeoIpException("Query interrupted", e);
} catch (ExecutionException e) {
throw new GeoIpException("Query failed", e);
}
}
/**
* 关闭线程池
*/
@PreDestroy
public void shutdown() {
executorService.shutdown();
try {
if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
}
}十、安全与监控
10.1 IP黑白名单
@Component
@Slf4j
public class IpFilter {
private final Set<String> blacklist = new ConcurrentHashSet<>();
private final Set<String> whitelist = new ConcurrentHashSet<>();
private final List<CIDR> blacklistCidrs = new ArrayList<>();
private final List<CIDR> whitelistCidrs = new ArrayList<>();
@PostConstruct
public void init() {
loadBlacklist();
loadWhitelist();
}
/**
* 检查IP是否被禁止
*/
public boolean isBlocked(String ip) {
// 检查白名单(白名单优先)
if (isWhitelisted(ip)) {
return false;
}
// 检查黑名单
return isBlacklisted(ip);
}
/**
* 检查IP是否在黑名单中
*/
public boolean isBlacklisted(String ip) {
// 检查精确IP
if (blacklist.contains(ip)) {
return true;
}
// 检查CIDR范围
for (CIDR cidr : blacklistCidrs) {
if (cidr.contains(ip)) {
return true;
}
}
return false;
}
/**
* 检查IP是否在白名单中
*/
public boolean isWhitelisted(String ip) {
// 检查精确IP
if (whitelist.contains(ip)) {
return true;
}
// 检查CIDR范围
for (CIDR cidr : whitelistCidrs) {
if (cidr.contains(ip)) {
return true;
}
}
return false;
}
/**
* 添加IP到黑名单
*/
public void addToBlacklist(String ipOrCidr) {
if (ipOrCidr.contains("/")) {
blacklistCidrs.add(new CIDR(ipOrCidr));
} else {
blacklist.add(ipOrCidr);
}
log.info("Added to blacklist: {}", ipOrCidr);
}
/**
* 添加IP到白名单
*/
public void addToWhitelist(String ipOrCidr) {
if (ipOrCidr.contains("/")) {
whitelistCidrs.add(new CIDR(ipOrCidr));
} else {
whitelist.add(ipOrCidr);
}
log.info("Added to whitelist: {}", ipOrCidr);
}
/**
* 从黑名单移除
*/
public void removeFromBlacklist(String ipOrCidr) {
if (ipOrCidr.contains("/")) {
blacklistCidrs.removeIf(cidr -> cidr.toString().equals(ipOrCidr));
} else {
blacklist.remove(ipOrCidr);
}
log.info("Removed from blacklist: {}", ipOrCidr);
}
/**
* 从白名单移除
*/
public void removeFromWhitelist(String ipOrCidr) {
if (ipOrCidr.contains("/")) {
whitelistCidrs.removeIf(cidr -> cidr.toString().equals(ipOrCidr));
} else {
whitelist.remove(ipOrCidr);
}
log.info("Removed from whitelist: {}", ipOrCidr);
}
/**
* 获取黑名单列表
*/
public Set<String> getBlacklist() {
Set<String> all = new HashSet<>(blacklist);
blacklistCidrs.forEach(cidr -> all.add(cidr.toString()));
return all;
}
/**
* 获取白名单列表
*/
public Set<String> getWhitelist() {
Set<String> all = new HashSet<>(whitelist);
whitelistCidrs.forEach(cidr -> all.add(cidr.toString()));
return all;
}
private void loadBlacklist() {
// 从配置文件或数据库加载
// 这里添加示例数据
blacklist.add("192.168.1.100");
blacklist.add("10.0.0.100");
blacklistCidrs.add(new CIDR("192.168.2.0/24"));
}
private void loadWhitelist() {
// 从配置文件或数据库加载
// 这里添加示例数据
whitelist.add("127.0.0.1");
whitelist.add("192.168.1.1");
whitelistCidrs.add(new CIDR("10.1.0.0/16"));
}
/**
* CIDR表示法类
*/
@Data
public static class CIDR {
private final String cidr;
private final long startIp;
private final long endIp;
public CIDR(String cidr) {
this.cidr = cidr;
long[] range = CidrUtils.cidrToRange(cidr);
this.startIp = range[0];
this.endIp = range[1];
}
public boolean contains(String ip) {
long ipLong = IpAddressUtils.ipToLong(ip);
return ipLong >= startIp && ipLong <= endIp;
}
@Override
public String toString() {
return cidr;
}
}
}10.2 监控与告警
@Component
@Slf4j
public class GeoIpMonitor {
private final MeterRegistry meterRegistry;
private final List<GeoIpProvider> providers;
private final Map<String, ProviderStats> providerStats = new ConcurrentHashMap<>();
private final Map<String, SlidingWindow> errorRates = new ConcurrentHashMap<>();
@Autowired
public GeoIpMonitor(MeterRegistry meterRegistry,
List<GeoIpProvider> providers) {
this.meterRegistry = meterRegistry;
this.providers = providers;
initMetrics();
startMonitoring();
}
private void initMetrics() {
// 注册Micrometer指标
meterRegistry.gauge("geoip.cache.size", this,
m -> m.getCacheStats().map(CacheStats::estimatedSize).orElse(0L));
meterRegistry.gauge("geoip.provider.count", providers, List::size);
// 为每个提供商创建指标
providers.forEach(provider -> {
String name = provider.getName();
Counter.builder("geoip.query.requests")
.tag("provider", name)
.register(meterRegistry);
Counter.builder("geoip.query.errors")
.tag("provider", name)
.register(meterRegistry);
Timer.builder("geoip.query.duration")
.tag("provider", name)
.register(meterRegistry);
});
}
private void startMonitoring() {
// 定期收集统计信息
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
collectStats();
checkHealth();
} catch (Exception e) {
log.error("Error in monitoring task", e);
}
}, 1, 1, TimeUnit.MINUTES);
}
private void collectStats() {
providers.forEach(provider -> {
String name = provider.getName();
ProviderStats stats = providerStats.computeIfAbsent(name,
k -> new ProviderStats());
// 这里可以收集实际的使用统计
// 例如:成功次数、失败次数、平均响应时间等
});
}
private void checkHealth() {
providers.forEach(provider -> {
String name = provider.getName();
SlidingWindow window = errorRates.computeIfAbsent(name,
k -> new SlidingWindow(100));
// 检查错误率
double errorRate = window.getErrorRate();
if (errorRate > 0.1) { // 错误率超过10%
log.warn("High error rate detected for provider {}: {}%",
name, errorRate * 100);
// 发送告警
sendAlert(name, "High error rate: " + (errorRate * 100) + "%");
}
});
}
/**
* 记录查询成功
*/
public void recordSuccess(String provider, long duration) {
meterRegistry.counter("geoip.query.requests",
"provider", provider).increment();
meterRegistry.timer("geoip.query.duration",
"provider", provider).record(duration, TimeUnit.MILLISECONDS);
// 更新滑动窗口
SlidingWindow window = errorRates.computeIfAbsent(provider,
k -> new SlidingWindow(100));
window.recordSuccess();
}
/**
* 记录查询失败
*/
public void recordError(String provider) {
meterRegistry.counter("geoip.query.errors",
"provider", provider).increment();
// 更新滑动窗口
SlidingWindow window = errorRates.computeIfAbsent(provider,
k -> new SlidingWindow(100));
window.recordError();
}
/**
* 发送告警
*/
private void sendAlert(String provider, String message) {
// 实现告警逻辑
// 可以发送邮件、短信、钉钉、企业微信等
log.error("ALERT: Provider {} - {}", provider, message);
}
/**
* 获取缓存统计
*/
public Optional<CacheStats> getCacheStats() {
// 从缓存组件获取统计
return Optional.empty();
}
/**
* 获取提供商统计信息
*/
public Map<String, ProviderStats> getProviderStats() {
return new HashMap<>(providerStats);
}
/**
* 提供商统计
*/
@Data
public static class ProviderStats {
private long totalQueries;
private long successfulQueries;
private long failedQueries;
private double averageResponseTime;
private double errorRate;
private Date lastQueryTime;
private Date lastErrorTime;
}
/**
* 滑动窗口统计
*/
public static class SlidingWindow {
private final int size;
private final Deque<Boolean> window;
public SlidingWindow(int size) {
this.size = size;
this.window = new ArrayDeque<>(size);
}
public synchronized void recordSuccess() {
window.addLast(true);
if (window.size() > size) {
window.removeFirst();
}
}
public synchronized void recordError() {
window.addLast(false);
if (window.size() > size) {
window.removeFirst();
}
}
public synchronized double getErrorRate() {
if (window.isEmpty()) {
return 0.0;
}
long errors = window.stream().filter(success -> !success).count();
return (double) errors / window.size();
}
public synchronized int getWindowSize() {
return window.size();
}
}
}十一、测试与验证
11.1 单元测试
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class GeoIpServiceTest {
@Mock
private DatabaseReader databaseReader;
@Mock
private RestTemplate restTemplate;
@InjectMocks
private MaxmindGeoIpService geoIpService;
@Test
void testValidIpQuery() throws Exception {
// 准备测试数据
String testIp = "8.8.8.8";
InetAddress ipAddress = InetAddress.getByName(testIp);
CityResponse mockResponse = Mockito.mock(CityResponse.class);
Country country = new Country(Arrays.asList("United States"), 6252001, "US", null);
Subdivision subdivision = new Subdivision(Arrays.asList("California"), 5332921, "CA", null);
City city = new City(Arrays.asList("Mountain View"), 5375480, null);
Location location = new Location(37.386, -122.0838, 0, null, null, "America/Los_Angeles");
Postal postal = new Postal("94040", 0);
when(databaseReader.city(ipAddress)).thenReturn(mockResponse);
when(mockResponse.getCountry()).thenReturn(country);
when(mockResponse.getMostSpecificSubdivision()).thenReturn(subdivision);
when(mockResponse.getCity()).thenReturn(city);
when(mockResponse.getLocation()).thenReturn(location);
when(mockResponse.getPostal()).thenReturn(postal);
// 执行测试
GeoLocation result = geoIpService.query(testIp);
// 验证结果
assertNotNull(result);
assertEquals("United States", result.getCountry());
assertEquals("US", result.getCountryCode());
assertEquals("California", result.getRegion());
assertEquals("Mountain View", result.getCity());
assertEquals("94040", result.getPostalCode());
assertEquals(37.386, result.getLatitude(), 0.001);
assertEquals(-122.0838, result.getLongitude(), 0.001);
assertEquals("America/Los_Angeles", result.getTimeZone());
assertEquals("MaxMind", result.getSource());
}
@Test
void testInvalidIp() {
String invalidIp = "999.999.999.999";
assertThrows(IllegalArgumentException.class, () -> {
GeoLocation result = geoIpService.query(invalidIp);
});
}
@Test
void testInternalIp() {
String internalIp = "192.168.1.1";
GeoLocation result = geoIpService.query(internalIp);
assertNotNull(result);
assertEquals("Internal", result.getCountry());
assertTrue(result.getInternal());
}
}
@WebMvcTest(IpGeoController.class)
class IpGeoControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private GeoIpManager geoIpManager;
@MockBean
private IpAddressUtils ipAddressUtils;
@Test
void testQueryIp() throws Exception {
String testIp = "8.8.8.8";
GeoLocation mockLocation = GeoLocation.builder()
.ip(testIp)
.country("United States")
.countryCode("US")
.city("Mountain View")
.latitude(37.386)
.longitude(-122.0838)
.source("MaxMind")
.timestamp(System.currentTimeMillis())
.build();
when(geoIpManager.query(testIp)).thenReturn(mockLocation);
mockMvc.perform(get("/api/v1/ip/query")
.param("ip", testIp))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.country").value("United States"))
.andExpect(jsonPath("$.data.city").value("Mountain View"));
}
@Test
void testBatchQuery() throws Exception {
List<String> ips = Arrays.asList("8.8.8.8", "1.1.1.1");
Map<String, GeoLocation> mockResults = new HashMap<>();
mockResults.put("8.8.8.8", GeoLocation.builder()
.ip("8.8.8.8")
.country("United States")
.build());
mockResults.put("1.1.1.1", GeoLocation.builder()
.ip("1.1.1.1")
.country("Australia")
.build());
when(geoIpManager.batchQuery(anyList())).thenReturn(mockResults);
String requestBody = "{\"ips\": [\"8.8.8.8\", \"1.1.1.1\"]}";
mockMvc.perform(post("/api/v1/ip/batch-query")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data['8.8.8.8'].country").value("United States"))
.andExpect(jsonPath("$.data['1.1.1.1'].country").value("Australia"));
}
}11.2 性能测试
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"ip.geolocation.mode=hybrid",
"ip.geolocation.cache.enabled=true"
})
class GeoIpPerformanceTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private GeoIpManager geoIpManager;
@Test
void testQueryPerformance() {
// 生成测试IP列表
List<String> testIps = generateTestIps(1000);
long startTime = System.currentTimeMillis();
// 执行批量查询
Map<String, GeoLocation> results = geoIpManager.batchQuery(testIps);
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
System.out.println("Batch query of " + testIps.size() + " IPs took " + duration + "ms");
System.out.println("Average time per query: " + (double) duration / testIps.size() + "ms");
// 验证性能要求
assertTrue(duration < 5000, "Batch query should complete within 5 seconds");
}
@Test
void testCachePerformance() {
String testIp = "8.8.8.8";
// 第一次查询(缓存未命中)
long startTime1 = System.currentTimeMillis();
GeoLocation result1 = geoIpManager.query(testIp);
long duration1 = System.currentTimeMillis() - startTime1;
// 第二次查询(缓存命中)
long startTime2 = System.currentTimeMillis();
GeoLocation result2 = geoIpManager.query(testIp);
long duration2 = System.currentTimeMillis() - startTime2;
System.out.println("First query (cache miss): " + duration1 + "ms");
System.out.println("Second query (cache hit): " + duration2 + "ms");
// 验证缓存命中率提升
assertTrue(duration2 < duration1, "Cached query should be faster");
assertTrue(duration2 < 10, "Cached query should be very fast (<10ms)");
}
@Test
void testConcurrentPerformance() throws InterruptedException {
int threadCount = 10;
int queriesPerThread = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
List<Future<Long>> futures = new ArrayList<>();
// 提交并发任务
for (int i = 0; i < threadCount; i++) {
futures.add(executor.submit(() -> {
long totalTime = 0;
List<String> ips = generateTestIps(queriesPerThread);
for (String ip : ips) {
long startTime = System.currentTimeMillis();
geoIpManager.query(ip);
totalTime += System.currentTimeMillis() - startTime;
}
return totalTime;
}));
}
// 等待所有任务完成
long totalQueryTime = 0;
for (Future<Long> future : futures) {
try {
totalQueryTime += future.get();
} catch (ExecutionException e) {
fail("Test execution failed: " + e.getMessage());
}
}
executor.shutdown();
long totalQueries = threadCount * queriesPerThread;
double avgTimePerQuery = (double) totalQueryTime / totalQueries;
System.out.println("Concurrent test: " + totalQueries + " queries");
System.out.println("Average time per query: " + avgTimePerQuery + "ms");
// 验证并发性能
assertTrue(avgTimePerQuery < 100, "Average query time should be <100ms under concurrent load");
}
private List<String> generateTestIps(int count) {
List<String> ips = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < count; i++) {
String ip = random.nextInt(256) + "." +
random.nextInt(256) + "." +
random.nextInt(256) + "." +
random.nextInt(256);
ips.add(ip);
}
return ips;
}
}11.3 集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class GeoIpIntegrationTest {
@Container
static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
private TestRestTemplate restTemplate;
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", redis::getFirstMappedPort);
}
@Test
void testCompleteWorkflow() {
// 测试IP查询
ResponseEntity<ApiResponse> response = restTemplate.getForEntity(
"/api/v1/ip/query?ip=8.8.8.8",
ApiResponse.class
);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().isSuccess());
// 测试批量查询
BatchQueryRequest request = new BatchQueryRequest();
request.setIps(Arrays.asList("8.8.8.8", "1.1.1.1", "114.114.114.114"));
ResponseEntity<ApiResponse> batchResponse = restTemplate.postForEntity(
"/api/v1/ip/batch-query",
request,
ApiResponse.class
);
assertEquals(HttpStatus.OK, batchResponse.getStatusCode());
// 测试服务状态
ResponseEntity<ApiResponse> statusResponse = restTemplate.getForEntity(
"/api/v1/ip/status",
ApiResponse.class
);
assertEquals(HttpStatus.OK, statusResponse.getStatusCode());
}
}十二、部署与运维
12.1 Docker容器化部署
# Dockerfile
FROM openjdk:17-jdk-slim as builder
WORKDIR /app
# 复制Maven包装器
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
# 下载依赖
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline -B
# 复制源代码
COPY src src
# 构建应用
RUN ./mvnw clean package -DskipTests
# 运行时镜像
FROM openjdk:17-jre-slim
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 复制构建产物
COPY --from=builder /app/target/*.jar app.jar
# 创建数据目录
RUN mkdir -p /app/data/geoip
# 复制IP数据库
COPY geoip /app/data/geoip
# 创建非root用户
RUN groupadd -r spring && useradd -r -g spring spring
RUN chown -R spring:spring /app
USER spring
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
# 启动应用
ENTRYPOINT ["java", "-jar", "app.jar"]yaml
# docker-compose.yml
version: '3.8'
services:
ip-geolocation:
build: .
container_name: ip-geolocation
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- JAVA_OPTS=-Xmx512m -Xms256m
- IP_GEOLOCATION_MODE=hybrid
- IP_GEOLOCATION_CACHE_ENABLED=true
volumes:
- geoip-data:/app/data/geoip
- logs:/app/logs
networks:
- geoip-network
restart: unless-stopped
depends_on:
- redis
- mysql
redis:
image: redis:7-alpine
container_name: geoip-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
networks:
- geoip-network
restart: unless-stopped
mysql:
image: mysql:8.0
container_name: geoip-mysql
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=rootpassword
- MYSQL_DATABASE=geoip
- MYSQL_USER=geoip
- MYSQL_PASSWORD=geoip123
volumes:
- mysql-data:/var/lib/mysql
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- geoip-network
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: geoip-prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
networks:
- geoip-network
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: geoip-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin123
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards
networks:
- geoip-network
restart: unless-stopped
networks:
geoip-network:
driver: bridge
volumes:
geoip-data:
redis-data:
mysql-data:
prometheus-data:
grafana-data:12.2 Kubernetes部署
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ip-geolocation
namespace: default
labels:
app: ip-geolocation
spec:
replicas: 3
selector:
matchLabels:
app: ip-geolocation
template:
metadata:
labels:
app: ip-geolocation
spec:
containers:
- name: ip-geolocation
image: your-registry/ip-geolocation:latest
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "k8s"
- name: JAVA_OPTS
value: "-Xmx512m -Xms256m"
- name: REDIS_HOST
value: "geoip-redis"
- name: MYSQL_HOST
value: "geoip-mysql"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
volumeMounts:
- name: geoip-data
mountPath: /app/data/geoip
- name: logs
mountPath: /app/logs
volumes:
- name: geoip-data
persistentVolumeClaim:
claimName: geoip-data-pvc
- name: logs
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: ip-geolocation
namespace: default
spec:
selector:
app: ip-geolocation
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ip-geolocation
namespace: default
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: ip-api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ip-geolocation
port:
number: 8012.3 监控配置
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'ip-geolocation'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['ip-geolocation:8080']
labels:
application: 'ip-geolocation'
environment: 'production'
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
target_label: __address__
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: kubernetes_namespace
- source_labels: [__meta_kubernetes_pod_name]
action: replace
target_label: kubernetes_pod_name十三、总结与最佳实践
13.1 项目总结
通过本文的详细介绍,我们构建了一个完整的SpringBoot IP地址解析系统,实现了:
- 多数据源支持:集成MaxMind、ip2region等离线库和多个在线API
- 智能查询策略:支持离线优先、在线优先、混合模式等多种查询策略
- 高性能缓存:实现多级缓存机制,大幅提升查询性能
- 完整API接口:提供RESTful API,支持单IP查询、批量查询等功能
- 监控告警:集成监控系统,实时监控服务状态
- 安全防护:实现IP黑白名单、访问频率限制等安全机制
- 可视化展示:提供地理位置可视化功能
13.2 最佳实践建议
数据库选择建议
- 生产环境:推荐使用MaxMind商业版,数据更准确
- 国内应用:可优先考虑ip2region,对中文支持更好
- 混合模式:结合使用离线库和在线API,平衡成本和准确性
性能优化建议
缓存策略:
- 使用多级缓存(本地+Redis)
- 合理设置缓存过期时间
- 对热点数据使用更长的缓存时间
并发控制:
- 使用线程池控制并发查询
- 实现请求队列,避免服务过载
- 设置合理的超时时间
数据库优化:
- 定期更新IP数据库
- 使用内存数据库加载常用数据
- 对查询结果进行压缩存储
安全建议
访问控制:
- 实现API密钥认证
- 限制API调用频率
- 记录所有访问日志
数据安全:
- 对敏感信息进行脱敏
- 定期审计IP访问记录
- 实现数据加密存储
运维建议
监控告警:
- 监控服务健康状态
- 设置性能阈值告警
- 定期分析访问日志
备份恢复:
- 定期备份IP数据库
- 实现服务快速恢复
- 准备应急预案
13.3 扩展方向
功能扩展
- IP威胁情报:集成威胁情报数据,识别恶意IP
- 用户行为分析:分析IP访问模式,识别异常行为
- 个性化推荐:基于地理位置提供个性化内容
技术扩展
- 机器学习:使用机器学习算法优化IP定位精度
- 区块链:使用区块链技术确保数据不可篡改
- 边缘计算:在边缘节点部署IP解析服务,降低延迟
架构扩展
- 微服务化:将IP解析拆分为独立微服务
- Serverless:使用云函数实现弹性扩展
- 多区域部署:在全球多个区域部署服务,提供就近访问
13.4 注意事项
- 数据准确性:IP地理位置数据存在一定误差,需告知用户
- 隐私保护:遵循相关法律法规,保护用户隐私
- 服务稳定性:准备备用方案,确保服务高可用
- 成本控制:在线API服务可能产生费用,需合理控制使用量
- 合规要求:确保服务符合地区法律法规要求
十四、附录
性能指标参考
| 指标 | 目标值 | 说明 |
|---|---|---|
| 平均响应时间 | < 50ms | 缓存命中时 |
| 最大响应时间 | < 500ms | 在线查询时 |
| 并发能力 | > 1000 QPS | 单节点 |
| 缓存命中率 | > 90% | 正常访问模式 |
| 可用性 | > 99.9% | 生产环境 |
配置文件示例
# application-prod.yml
spring:
datasource:
url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/geoip
username: ${MYSQL_USER:geoip}
password: ${MYSQL_PASSWORD:geoip123}
hikari:
maximum-pool-size: 20
minimum-idle: 5
redis:
host: ${REDIS_HOST:localhost}
port: 6379
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
cache:
type: redis
redis:
time-to-live: 3600s
cache-null-values: false
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
health:
db:
enabled: true
redis:
enabled: true
ip:
geolocation:
mode: hybrid
offline:
database: maxmind
maxmind-db-path: file:/data/geoip/GeoLite2-City.mmdb
online:
enabled: true
providers:
- name: ipstack
url: http://api.ipstack.com/{ip}?access_key=${IPSTACK_KEY}
priority: 1
timeout: 3000
cache:
enabled: true
local-ttl: 300
redis-ttl: 3600
logging:
level:
com.example.ip: INFO
file:
name: /app/logs/geoip.log
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 30以上就是SpringBoot快速实现IP地址解析的全攻略的详细内容,更多关于SpringBoot解析IP地址的资料请关注脚本之家其它相关文章!
