SpringBoot快速實現(xiàn)IP地址解析的全攻略
一、引言與概述
1.1 IP地址解析的重要性
在當(dāng)今的互聯(lián)網(wǎng)應(yīng)用中,IP地址解析已成為許多系統(tǒng)不可或缺的功能。通過IP地址解析,我們可以:
- 地理位置服務(wù):根據(jù)用戶IP確定其所在地區(qū),提供本地化內(nèi)容
- 安全防護:識別異常登錄地點,防范賬號盜用
- 業(yè)務(wù)分析:分析用戶地域分布,優(yōu)化市場策略
- 訪問控制:限制特定地區(qū)的訪問權(quán)限
- 個性化體驗:根據(jù)地區(qū)提供定制化服務(wù)
1.2 SpringBoot集成IP解析的優(yōu)勢
SpringBoot作為Java生態(tài)中最流行的微服務(wù)框架,集成IP地址解析具有以下優(yōu)勢:
- 快速集成:通過Starter可以快速引入IP解析功能
- 配置簡單:基于約定大于配置的原則
- 生態(tài)豐富:可以輕松整合各種IP解析庫
- 易于擴展:便于自定義解析邏輯
二、環(huán)境準(zhǔn)備與基礎(chǔ)配置
2.1 創(chuàng)建SpringBoot項目
使用Spring Initializr創(chuàng)建基礎(chǔ)項目:
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地址解析服務(wù) \ -d packageName=com.example.ip \ -d packaging=jar \ -d javaVersion=17 \ -d dependencies=web,validation,aop \ -o ip-geolocation.zip
2.2 基礎(chǔ)依賴配置
<?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:
# 離線數(shù)據(jù)庫類型: maxmind, ip2region
database: ip2region
# 數(shù)據(jù)庫文件路徑
maxmind-db-path: classpath:geoip/GeoLite2-City.mmdb
ip2region-db-path: classpath:geoip/ip2region.xdb
# 在線解析配置
online:
# 啟用在線解析
enabled: true
# 在線服務(wù)提供商: 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
# 監(jiān)控配置
monitor:
enabled: true
# 統(tǒng)計窗口大小
window-size: 100
# 自定義配置
custom:
ip:
# 內(nèi)網(wǎng)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地址解析基礎(chǔ)理論
3.1 IP地址基礎(chǔ)知識
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地址轉(zhuǎn)換為長整型
*/
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;
}
/**
* 將長整型轉(zhuǎn)換為IP地址
*/
public static String longToIp(long ip) {
return ((ip >> 24) & 0xFF) + "." +
((ip >> 16) & 0xFF) + "." +
((ip >> 8) & 0xFF) + "." +
(ip & 0xFF);
}
/**
* 判斷是否為內(nèi)網(wǎng)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;
}
}
}
// 如果沒有獲取到,使用遠(yuǎn)程地址
return request.getRemoteAddr();
}
}3.2 CIDR表示法與子網(wǎng)劃分
@Component
public class CidrUtils {
/**
* CIDR轉(zhuǎn)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范圍內(nèi)
*/
public static boolean isIpInCidr(String ip, String cidr) {
long ipLong = IpAddressUtils.ipToLong(ip);
long[] range = cidrToRange(cidr);
return ipLong >= range[0] && ipLong <= range[1];
}
/**
* 獲取子網(wǎng)掩碼
*/
public static String getSubnetMask(int prefixLength) {
long mask = 0xFFFFFFFFL << (32 - prefixLength);
return IpAddressUtils.longToIp(mask);
}
/**
* 計算可用IP數(shù)量
*/
public static long getAvailableIpCount(String cidr) {
long[] range = cidrToRange(cidr);
return range[1] - range[0] + 1;
}
}四、離線IP地址解析方案
4.1 MaxMind GeoIP2集成
4.1.1 數(shù)據(jù)庫準(zhǔn)備
@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解析服務(wù)實現(xiàn)
@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();
// 延遲初始化數(shù)據(jù)庫
initializeDatabase();
}
private void initializeDatabase() {
try {
Resource resource = new ClassPathResource(
dbPath.replace("classpath:", ""));
// 加載數(shù)據(jù)庫文件到內(nèi)存
byte[] dbBinStr = Files.readAllBytes(resource.getFile().toPath());
// 創(chuàng)建完全基于內(nèi)存的查詢對象
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格式:國家|區(qū)域|省份|城市|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;
/**
* 區(qū)域/省份
*/
private String region;
/**
* 城市
*/
private String city;
/**
* 區(qū)縣
*/
private String district;
/**
* 郵政編碼
*/
private String postalCode;
/**
* 緯度
*/
private Double latitude;
/**
* 經(jīng)度
*/
private Double longitude;
/**
* 時區(qū)
*/
private String timeZone;
/**
* 互聯(lián)網(wǎng)服務(wù)提供商
*/
private String isp;
/**
* 組織
*/
private String organization;
/**
* AS號碼和名稱
*/
private String as;
/**
* AS名稱
*/
private String asName;
/**
* 是否移動網(wǎng)絡(luò)
*/
private Boolean mobile;
/**
* 是否代理
*/
private Boolean proxy;
/**
* 是否托管
*/
private Boolean hosting;
/**
* 數(shù)據(jù)來源
*/
private String source;
/**
* 查詢時間戳
*/
private Long timestamp;
/**
* 緩存過期時間
*/
private Long expiresAt;
/**
* 原始響應(yīng)數(shù)據(jù)
*/
private String rawData;
/**
* 是否成功
*/
@Builder.Default
private Boolean success = true;
/**
* 錯誤信息
*/
private String error;
/**
* 響應(yīng)時間(毫秒)
*/
private Long responseTime;
/**
* 是否內(nèi)網(wǎng)IP
*/
private Boolean internal;
/**
* 可信度評分(0-100)
*/
@Builder.Default
private Integer confidence = 100;
}五、在線IP地址解析方案
5.1 多服務(wù)提供商集成
@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 服務(wù)提供商實現(xiàn)
1IP-API.com實現(xiàn)
@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庫實現(xiàn)
@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 統(tǒng)一調(diào)用管理器
@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) {
// 參數(shù)驗證
if (!IpAddressUtils.isValidIpAddress(ip)) {
throw new IllegalArgumentException("Invalid IP address: " + ip);
}
// 檢查是否為內(nèi)網(wǎng)IP
if (IpAddressUtils.isInternalIp(ip)) {
return createInternalLocation(ip);
}
// 嘗試從緩存獲取
GeoLocation cached = cache.getIfPresent(ip);
if (cached != null &&
cached.getExpiresAt() > System.currentTimeMillis()) {
return cached;
}
// 根據(jù)配置模式執(zhí)行查詢
GeoLocation location;
switch (properties.getMode()) {
case "offline":
location = queryOffline(ip);
break;
case "online":
location = queryOnline(ip);
break;
case "hybrid":
default:
location = queryHybrid(ip);
break;
}
// 設(shè)置緩存過期時間
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()));
}
}));
}
// 等待所有任務(wù)完成
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();
}
/**
* 獲取緩存統(tǒng)計信息
*/
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();
// 設(shè)置超時時間
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);
// 設(shè)置到請求屬性中
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();
}
}
/**
* 獲取當(dāng)前請求的位置信息
*/
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設(shè)計
7.1 控制器設(shè)計
@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 = "根據(jù)IP地址查詢地理位置信息")
@ApiResponse(responseCode = "200", description = "查詢成功")
@ApiResponse(responseCode = "400", description = "請求參數(shù)錯誤")
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));
}
/**
* 查詢當(dāng)前請求的IP地址信息
*/
@GetMapping("/current")
@Operation(summary = "查詢當(dāng)前請求IP",
description = "獲取當(dāng)前請求客戶端的地理位置信息")
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) {
// 限制批量查詢數(shù)量
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));
}
/**
* 獲取服務(wù)狀態(tài)
*/
@GetMapping("/status")
@Operation(summary = "獲取服務(wù)狀態(tài)",
description = "獲取IP解析服務(wù)的狀態(tài)信息")
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數(shù)量必須在1-100之間")
private List<String> ips;
}
}7.2 響應(yīng)封裝
@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);
}
}八、高級功能實現(xiàn)
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點執(zhí)行
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");
// 發(fā)布更新完成事件
eventPublisher.publishEvent(new DatabaseUpdateEvent(this, true));
} catch (Exception e) {
log.error("Failed to update GeoIP database", e);
// 發(fā)布更新失敗事件
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";
// 下載數(shù)據(jù)庫文件
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);
// 替換數(shù)據(jù)庫文件
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";
// 下載數(shù)據(jù)庫文件
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);
// 替換數(shù)據(jù)庫文件
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();
}
}
}
/**
* 數(shù)據(jù)庫更新事件
*/
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的訪問統(tǒng)計
*/
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() {
// 從配置文件或數(shù)據(jù)庫加載
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;
}
/**
* 生成訪問熱力圖數(shù)據(jù)
*/
@GetMapping("/heatmap")
@Operation(summary = "訪問熱力圖",
description = "生成IP訪問熱力圖數(shù)據(jù)")
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);
}
// 生成熱力圖數(shù)據(jù)
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));
}
/**
* 生成訪問統(tǒng)計圖表數(shù)據(jù)
*/
@GetMapping("/statistics")
@Operation(summary = "訪問統(tǒng)計",
description = "生成IP訪問統(tǒng)計圖表數(shù)據(jù)")
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);
// 按時間分組統(tǒng)計
Map<String, Long> timeSeries = createTimeSeries(records, period);
// 按國家分組統(tǒng)計
Map<String, Long> countryStats = records.stream()
.collect(Collectors.groupingBy(
IpAccessRecord::getCountry,
Collectors.counting()
));
// 按城市分組統(tǒng)計
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;
}
}九、性能優(yōu)化與緩存策略
9.1 多級緩存實現(xiàn)
@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;
}
/**
* 從緩存獲取數(shù)據(jù)
*/
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);
// 設(shè)置過期時間
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) {
// 注意:這會清空所有緩存,生產(chǎn)環(huán)境慎用
Set<String> keys = redisTemplate.keys("geoip:*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
/**
* 獲取緩存統(tǒng)計信息
*/
public CacheStats getStats() {
return localCache.stats();
}
private String buildRedisKey(String ip) {
return "geoip:" + ip;
}
}9.2 異步處理優(yōu)化
@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;
// 創(chuàng)建線程池
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<>();
// 提交所有查詢?nèi)蝿?wù)
for (String ip : ips) {
futures.add(completionService.submit(() -> geoIpManager.query(ip)));
}
// 等待所有任務(wù)完成
for (int i = 0; i < futures.size(); i++) {
try {
Future<GeoLocation> future = completionService.take();
GeoLocation location = future.get();
// 根據(jù)IP地址找到對應(yīng)的結(jié)果
// 這里需要維護IP和任務(wù)的映射關(guān)系
// 簡化處理:在任務(wù)提交時記錄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);
}
}
/**
* 關(guān)閉線程池
*/
@PreDestroy
public void shutdown() {
executorService.shutdown();
try {
if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
}
}十、安全與監(jiān)控
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) {
// 檢查白名單(白名單優(yōu)先)
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() {
// 從配置文件或數(shù)據(jù)庫加載
// 這里添加示例數(shù)據(jù)
blacklist.add("192.168.1.100");
blacklist.add("10.0.0.100");
blacklistCidrs.add(new CIDR("192.168.2.0/24"));
}
private void loadWhitelist() {
// 從配置文件或數(shù)據(jù)庫加載
// 這里添加示例數(shù)據(jù)
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 監(jiān)控與告警
@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指標(biāo)
meterRegistry.gauge("geoip.cache.size", this,
m -> m.getCacheStats().map(CacheStats::estimatedSize).orElse(0L));
meterRegistry.gauge("geoip.provider.count", providers, List::size);
// 為每個提供商創(chuàng)建指標(biāo)
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() {
// 定期收集統(tǒng)計信息
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());
// 這里可以收集實際的使用統(tǒng)計
// 例如:成功次數(shù)、失敗次數(shù)、平均響應(yīng)時間等
});
}
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);
// 發(fā)送告警
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();
}
/**
* 發(fā)送告警
*/
private void sendAlert(String provider, String message) {
// 實現(xiàn)告警邏輯
// 可以發(fā)送郵件、短信、釘釘、企業(yè)微信等
log.error("ALERT: Provider {} - {}", provider, message);
}
/**
* 獲取緩存統(tǒng)計
*/
public Optional<CacheStats> getCacheStats() {
// 從緩存組件獲取統(tǒng)計
return Optional.empty();
}
/**
* 獲取提供商統(tǒng)計信息
*/
public Map<String, ProviderStats> getProviderStats() {
return new HashMap<>(providerStats);
}
/**
* 提供商統(tǒng)計
*/
@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;
}
/**
* 滑動窗口統(tǒng)計
*/
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 {
// 準(zhǔn)備測試數(shù)據(jù)
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);
// 執(zhí)行測試
GeoLocation result = geoIpService.query(testIp);
// 驗證結(jié)果
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();
// 執(zhí)行批量查詢
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<>();
// 提交并發(fā)任務(wù)
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;
}));
}
// 等待所有任務(wù)完成
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");
// 驗證并發(fā)性能
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());
// 測試服務(wù)狀態(tài)
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
# 復(fù)制Maven包裝器
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
# 下載依賴
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline -B
# 復(fù)制源代碼
COPY src src
# 構(gòu)建應(yīng)用
RUN ./mvnw clean package -DskipTests
# 運行時鏡像
FROM openjdk:17-jre-slim
# 設(shè)置時區(qū)
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 復(fù)制構(gòu)建產(chǎn)物
COPY --from=builder /app/target/*.jar app.jar
# 創(chuàng)建數(shù)據(jù)目錄
RUN mkdir -p /app/data/geoip
# 復(fù)制IP數(shù)據(jù)庫
COPY geoip /app/data/geoip
# 創(chuàng)建非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
# 啟動應(yīng)用
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 監(jiān)控配置
# 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十三、總結(jié)與最佳實踐
13.1 項目總結(jié)
通過本文的詳細(xì)介紹,我們構(gòu)建了一個完整的SpringBoot IP地址解析系統(tǒng),實現(xiàn)了:
- 多數(shù)據(jù)源支持:集成MaxMind、ip2region等離線庫和多個在線API
- 智能查詢策略:支持離線優(yōu)先、在線優(yōu)先、混合模式等多種查詢策略
- 高性能緩存:實現(xiàn)多級緩存機制,大幅提升查詢性能
- 完整API接口:提供RESTful API,支持單IP查詢、批量查詢等功能
- 監(jiān)控告警:集成監(jiān)控系統(tǒng),實時監(jiān)控服務(wù)狀態(tài)
- 安全防護:實現(xiàn)IP黑白名單、訪問頻率限制等安全機制
- 可視化展示:提供地理位置可視化功能
13.2 最佳實踐建議
數(shù)據(jù)庫選擇建議
- 生產(chǎn)環(huán)境:推薦使用MaxMind商業(yè)版,數(shù)據(jù)更準(zhǔn)確
- 國內(nèi)應(yīng)用:可優(yōu)先考慮ip2region,對中文支持更好
- 混合模式:結(jié)合使用離線庫和在線API,平衡成本和準(zhǔn)確性
性能優(yōu)化建議
緩存策略:
- 使用多級緩存(本地+Redis)
- 合理設(shè)置緩存過期時間
- 對熱點數(shù)據(jù)使用更長的緩存時間
并發(fā)控制:
- 使用線程池控制并發(fā)查詢
- 實現(xiàn)請求隊列,避免服務(wù)過載
- 設(shè)置合理的超時時間
數(shù)據(jù)庫優(yōu)化:
- 定期更新IP數(shù)據(jù)庫
- 使用內(nèi)存數(shù)據(jù)庫加載常用數(shù)據(jù)
- 對查詢結(jié)果進(jìn)行壓縮存儲
安全建議
訪問控制:
- 實現(xiàn)API密鑰認(rèn)證
- 限制API調(diào)用頻率
- 記錄所有訪問日志
數(shù)據(jù)安全:
- 對敏感信息進(jìn)行脫敏
- 定期審計IP訪問記錄
- 實現(xiàn)數(shù)據(jù)加密存儲
運維建議
監(jiān)控告警:
- 監(jiān)控服務(wù)健康狀態(tài)
- 設(shè)置性能閾值告警
- 定期分析訪問日志
備份恢復(fù):
- 定期備份IP數(shù)據(jù)庫
- 實現(xiàn)服務(wù)快速恢復(fù)
- 準(zhǔn)備應(yīng)急預(yù)案
13.3 擴展方向
功能擴展
- IP威脅情報:集成威脅情報數(shù)據(jù),識別惡意IP
- 用戶行為分析:分析IP訪問模式,識別異常行為
- 個性化推薦:基于地理位置提供個性化內(nèi)容
技術(shù)擴展
- 機器學(xué)習(xí):使用機器學(xué)習(xí)算法優(yōu)化IP定位精度
- 區(qū)塊鏈:使用區(qū)塊鏈技術(shù)確保數(shù)據(jù)不可篡改
- 邊緣計算:在邊緣節(jié)點部署IP解析服務(wù),降低延遲
架構(gòu)擴展
- 微服務(wù)化:將IP解析拆分為獨立微服務(wù)
- Serverless:使用云函數(shù)實現(xiàn)彈性擴展
- 多區(qū)域部署:在全球多個區(qū)域部署服務(wù),提供就近訪問
13.4 注意事項
- 數(shù)據(jù)準(zhǔn)確性:IP地理位置數(shù)據(jù)存在一定誤差,需告知用戶
- 隱私保護:遵循相關(guān)法律法規(guī),保護用戶隱私
- 服務(wù)穩(wěn)定性:準(zhǔn)備備用方案,確保服務(wù)高可用
- 成本控制:在線API服務(wù)可能產(chǎn)生費用,需合理控制使用量
- 合規(guī)要求:確保服務(wù)符合地區(qū)法律法規(guī)要求
十四、附錄
性能指標(biāo)參考
| 指標(biāo) | 目標(biāo)值 | 說明 |
|---|---|---|
| 平均響應(yīng)時間 | < 50ms | 緩存命中時 |
| 最大響應(yīng)時間 | < 500ms | 在線查詢時 |
| 并發(fā)能力 | > 1000 QPS | 單節(jié)點 |
| 緩存命中率 | > 90% | 正常訪問模式 |
| 可用性 | > 99.9% | 生產(chǎn)環(huán)境 |
配置文件示例
# 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快速實現(xiàn)IP地址解析的全攻略的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot解析IP地址的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
MapStruct處理Java中實體與模型間不匹配屬性轉(zhuǎn)換的方法
今天小編就為大家分享一篇關(guān)于MapStruct處理Java中實體與模型間不匹配屬性轉(zhuǎn)換的方法,小編覺得內(nèi)容挺不錯的,現(xiàn)在分享給大家,具有很好的參考價值,需要的朋友一起跟隨小編來看看吧2019-03-03
Mybatis-plus中IService接口的基本使用步驟
Mybatis-plus是一個Mybatis的增強工具,它提供了很多便捷的方法來簡化開發(fā),IService是Mybatis-plus提供的通用service接口,封裝了常用的數(shù)據(jù)庫操作方法,包括增刪改查等,下面這篇文章主要給大家介紹了關(guān)于Mybatis-plus中IService接口的基本使用步驟,需要的朋友可以參考下2023-06-06
idea創(chuàng)建springboot項目(版本只能選擇17和21)的解決方法
idea2023創(chuàng)建spring boot項目時,java版本無法選擇11,本文主要介紹了idea創(chuàng)建springboot項目(版本只能選擇17和21),下面就來介紹一下解決方法,感興趣的可以了解一下2024-01-01
java Spring Boot 配置redis pom文件操作
這篇文章主要介紹了java Spring Boot 配置redis pom文件操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-07-07
idea啟動springboot報錯: 找不到或無法加載主類問題
這篇文章主要介紹了idea啟動springboot報錯: 找不到或無法加載主類問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-12-12
springboot的異步任務(wù):無返回值和有返回值問題
這篇文章主要介紹了springboot的異步任務(wù):無返回值和有返回值問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-07-07

