第十章. common-core 工具类(八):AddressUtils 与 RegionUtils 详解

第十章. common-core 工具类(八):AddressUtilsRegionUtils 详解

摘要:本章我们将深入 RVP 的 IP 地址定位功能。我们将分析 AddressUtils(门面)和 RegionUtils(实现)的职责分离设计。重点是 RegionUtils 如何通过 静态代码块 加载 ip2region.xdb 离线库到内存,以及 RVP 5.x 版本(对比 4.x)在 资源加载方式上的演进

在上一章中,我们深入剖析了 SqlUtil,并通过追踪 RVP 真实应用场景 PageQuery 的源码,理解了它在 MyBatis-Plus (MP) 体系下,作为“原生 ORDER BY 字符串校验器”的真实用途和存在价值。

现在,我们来看 utils 包下一个非常有用的功能模块——IP 地址与地理位置定位。在 RVP 中,这个功能被拆分为了两个类,位于 common-coreutils.ip 包下:

  1. AddressUtils:一个“门面”工具类,负责 业务逻辑判断(如判断内网 IP、IPv4/IPv6)。
  2. RegionUtils:一个“实现”工具类,负责 真正的数据查询,它封装了 ip2region.xdb 离线库。

本章,我们将重点分析 RVP(V5.x 版本)是如何 重构 IP 地址库的加载机制的,以及“二开”时我们该如何正确使用它们。

本章学习路径

AddressUtils 与 RegionUtils 详解


10.1. RVP 的 IP 定位架构:AddressUtils (门面) 与 RegionUtils (实现)

10.1.1. 【二开场景】为何需要 IP 定位?

在任何一个严谨的后台管理系统中,“审计”都是必不可少的功能。我们需要记录“谁,在什么时间,从哪里,做了什么”。

  • 登录日志:记录用户登录的 IP 地址及其地理位置。
  • 操作日志:记录用户执行关键操作(如删除、修改)时的 IP 地址。

AddressUtilsRegionUtils 的核心使命,就是完成“从哪里”这个环节,即:根据 IP 地址,解析出真实的地理位置

10.1.2. AddressUtils.getRealAddressByIP():职责分离的“门面”

在 RVP 框架中,我们“二开”时,唯一应该调用的就是 AddressUtils.getRealAddressByIP(ip)

AddressUtils 扮演了一个“门面”(Facade)的角色。它负责处理所有前置的业务逻辑判断,而不关心数据到底是怎么查出来的。

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class AddressUtils {

// ...常量定义...

public static String getRealAddressByIP(String ip) {
// 1. 预处理:清空、过滤 HTML 标签
ip = HtmlUtil.cleanHtmlTag(StringUtils.blankToDefault(ip,""));

// 2. 判断 IPv4
if (NetUtils.isIPv4(ip)) {
return resolverIPv4Region(ip);
}

// 3. 判断 IPv6
if (NetUtils.isIPv6(ip)) {
return resolverIPv6Region(ip);
}

// 4. 都不是,返回未知
return UNKNOWN_IP;
}

// ...
}

10.1.3. 源码追踪:getRealAddressByIP() 的“三段论”

getRealAddressByIP 的逻辑非常清晰,是一个“三段论”:

  1. 预处理:使用 HtmlUtil.cleanHtmlTag 防止 XSS 攻击,使用 blankToDefault 处理 null
  2. IP 类型判断:使用 Hutool 的 NetUtils.isIPv4NetUtils.isIPv6 进行分发。
  3. 委托处理:IPv4 交给 resolverIPv4Region,IPv6 交给 resolverIPv6Region

10.1.4. 源码追踪:resolverIPv4Region() (内网判断 NetUtils.isInnerIP)

resolverIPv6Region 目前只是简单返回“未知”,因为 ip2region 库不支持 IPv6。所以,核心逻辑全在 resolverIPv4Region 中。

1
2
3
4
5
6
7
8
9
10
// 位于 AddressUtils.java
private static String resolverIPv4Region(String ip){
// 1. 【关键】判断是否为内网 IP
if (NetUtils.isInnerIP(ip)) {
return LOCAL_ADDRESS; // "内网IP"
}

// 2. 如果是公网 IP,则委托给 RegionUtils 查询
return RegionUtils.getCityInfo(ip);
}

分析
AddressUtils 的职责非常明确。它利用 Hutool 的 NetUtils.isInnerIP (判断 127.0.0.1, 10.x.x.x, 192.168.x.x 等),过滤掉了所有内网 IP

为什么要这样做?
因为 ip2region.xdb 离线库里只包含 公网 IP 的地理信息。查询一个内网 IP 是毫无意义且浪费性能的。

只有当 AddressUtils 确认这是一个公网 IPv4 地址时,它才会把“查询”这个“脏活累活”交给 RegionUtils.getCityInfo(ip) 去做。


10.2. 【核心】RegionUtilsip2region.xdb 离线库加载

RegionUtils 是实际的“实现”类。它封装了 ip2region 这个第三方离线 IP 定位库。

10.2.1. 核心依赖:org.lionsoul.ip2region.xdb.Searcher

ip2region 库的使用非常简单,核心就是 Searcher (查询器) 对象。

10.2.2. RVP 5.x 演进:static 静态代码块加载

在 RVP 5.x 版本中,RegionUtils 的源码发生了重大变化(我们将在 10.3 节对比 4.x)。我们来看 5.x 的源码:

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Slf4j
public class RegionUtils {

// IP地址库文件名称
public static final String IP_XDB_FILENAME = "ip2region.xdb";

// 核心:Searcher 被定义为 static final
private static final Searcher SEARCHER;

// 关键:在静态代码块中初始化 SEARCHER
static {
try {
// 1. 从 ClassPath (resources) 读取 xdb 文件字节
byte[] bytes = ResourceUtil.readBytes(IP_XDB_FILENAME);

// 2. 基于内存缓冲区创建 Searcher
SEARCHER = Searcher.newWithBuffer(bytes);

log.info("RegionUtils初始化成功,加载IP地址库数据成功!");
} catch (NoResourceException e) {
throw new ServiceException("RegionUtils初始化失败,原因:IP地址库数据不存在!");
} catch (Exception e) {
throw new ServiceException("RegionUtils初始化失败,原因:" + e.getMessage());
}
}

// ...
}

10.2.3. 源码解析:Searcher.newWithBuffer(ResourceUtil.readBytes(...))

static { ... } (静态代码块) 会在 RegionUtils第一次被加载到 JVM 时 执行,且 只执行一次

  1. ResourceUtil.readBytes(IP_XDB_FILENAME)
    • ResourceUtil 是 Hutool 的工具类。
    • 它会从 项目的 ClassPath(即 ruoyi-admin/src/main/resources 目录)中查找 ip2region.xdb 文件。
    • readBytes 将这个 11MB 左右的文件 完整地读取到内存中的一个 byte[] 字节数组 里。
  2. Searcher.newWithBuffer(bytes)
    • 这是 ip2region 库提供的“基于内存的查询”模式。
    • 它告诉 Searcher:“不要去读硬盘,以后所有查询都 直接访问内存中的 bytes 数组。”

这种“内存加载”模式有什么好处?

  • 高性能:后续所有查询都是“内存查询”,速度极快,远胜于“文件查询”。
  • 无状态:不需要在服务器硬盘上读写临时文件(我们将在 10.3 对比)。

10.2.4. getCityInfo()search()0| 字符串清理

RegionUtils 剩下的 getCityInfo 方法就非常简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 位于 RegionUtils.java
public static String getCityInfo(String ip) {
try {
// 3、执行查询
// 直接调用内存中的 SEARCHER 对象
String region = SEARCHER.search(StringUtils.trim(ip));

// 4. 清理 ip2region 返回的原始字符串
// 原始返回: "中国|0|广东省|江门市|电信"
// 清理后: "中国|广东省|江门市|电信"
return region.replace("0|", "").replace("|0", "");
} catch (Exception e) {
log.error("IP地址离线获取城市异常 {}", ip);
return "未知";
}
}

分析
ip2region 库返回的原始格式是 国家|区域|省份|城市|ISP,中间可能包含 0(代表“无”)。RVP 通过 replace0||0 去掉,使返回结果更干净。


10.3. 【4.x vs 5.x】从“临时文件”到“内存加载”的演进

我们如果打开旧版本源码(即 4.x 版本)中看到的 RegionUtils 加载逻辑,和 5.x 的源码 完全不同

10.3.1. 回顾 4.x:将 xdb 释放到 temp 目录的复杂逻辑

在 RVP 4.x 中,static 代码块的逻辑是这样的(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// RVP 4.x 的“临时文件”加载逻辑 (Bad Practice)
static {
String tmpDir = System.getProperty("java.io.tmpdir"); // C:\...Temp\
String dbPath = tmpDir + File.separator + "ip2region.xdb";
File file = new File(dbPath);

if (!file.exists()) {
// 1. 如果临时文件不存在,就从 ClassPath (resources) 读取
InputStream in = ResourceUtil.getStream("ip2region.xdb");
// 2. 将文件“释放” (复制) 到操作系统的临时目录
FileUtil.writeFromStream(in, file);
}

// 3. 基于“文件路径”创建 Searcher
SEARCHER = Searcher.newWithFileOnly(dbPath);
}

10.3.2. 剖析 5.x:newWithBuffer 一步到位的优势

对比 4.x 的“临时文件”方式,5.x 的“内存加载”方式(newWithBuffer)优势巨大:

对比项RVP 4.x (newWithFileOnly)RVP 5.x (newWithBuffer)
工作模式文件 IOresources -> 复制到 temp -> Searchertemp 文件内存 IOresources -> 加载到 byte[] -> Searcher 读内存
性能差。依赖磁盘 IO。极高。纯内存操作。
部署依赖 temp 目录的 写权限无文件依赖
容器化极差。在只读文件系统(如 Docker ro 挂载)或无状态容器中会 失败极好byte[] 在 JVM 堆内存中,与宿主机文件系统无关。

10.3.3. 结论:5.x 架构更适合云原生与容器化部署

RVP 5.x 对 RegionUtils 的重构(从 newWithFileOnly 升级到 newWithBuffer)是一个 巨大进步。它摒弃了对操作系统的文件依赖,使应用 无状态化,这在当今的 云原生和容器化(Docker/K8s)部署 环境中是至关重要的。


10.4. 【二开实战】AddressUtils 的正确测试方式

10.4.1. 痛点:为什么在 main 方法中测试会失败?(NoResourceException)

在 5.x 架构下,RegionUtils 依赖 ResourceUtil.readBytes("ip2region.xdb")Classpath 加载资源。

如果我们尝试在 ruoyi-demo 模块中用 main 方法测试:

1
2
3
4
5
6
7
8
9
10
// 位于 ruoyi-demo/utils/test/AddressTest.java
public static void main(String[] args) {
// 启动时,JVM 会加载 RegionUtils
// RegionUtils 的 static 块开始执行
// ResourceUtil.readBytes("ip2region.xdb")
// ... 在 "ruoyi-demo" 模块的 Classpath 下... 找不到 "ip2region.xdb"!

// 抛出 NoResourceException: Resource [ip2region.xdb] not exist!
String addr = AddressUtils.getRealAddressByIP("61.145.18.18");
}

失败原因ip2region.xdb 文件位于 ruoyi-admin/src/main/resources 目录下。当我们 独立运行 AddressTest.main 时,JVM 的 Classpath 只包含 ruoyi-demo,它根本“看”不到 ruoyi-admin 里的资源文件,因此 ResourceUtil 抛出 NoResourceException(找不到资源异常)。

10.4.2. 正确姿势:在 ruoyi-demo 中创建 AddressController

如何让 ResourceUtil 找到 .xdb 文件?
我们必须 启动完整的 RVP 应用DromaraApplication),因为 ruoyi-admin 模块 依赖ruoyi-common-core,并且它 拥有 ip2region.xdb 资源。

所以,正确的测试姿势是创建一个 Controller 接口:

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/AddressController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package org.dromara.demo.controller;

import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.common.doc.annotation.SaIgnore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* IP 地址工具类测试
*/
@SaIgnore // 忽略权限认证
@Slf4j
@RestController
@RequestMapping("/addr")
public class AddressController {

@GetMapping("/addr")
public R<Void> testAddress() {

log.info("--- 开始测试 IP 地址 ---");

String ip1 = "127.0.0.1";
log.info("【{}】=> {}", ip1, AddressUtils.getRealAddressByIP(ip1));

String ip2 = "10.0.0.1";
log.info("【{}】=> {}", ip2, AddressUtils.getRealAddressByIP(ip2));

String ip3 = "61.145.18.18"; // 公网 IP
log.info("【{}】=> {}", ip3, AddressUtils.getRealAddressByIP(ip3));

return R.ok("操作成功");
}
}

验证

  1. 启动 DromaraApplication
  2. 在浏览器中访问 http://localhost:8080/demo/addr/addr
  3. 查看后端控制台
    1
    2
    3
    4
    ... INFO ... --- 开始测试 IP 地址 ---
    ... INFO ... 【127.0.0.1】=> 内网IP
    ... INFO ... 【10.0.0.1】=> 内网IP
    ... INFO ... 【61.145.18.18】=> 中国|广东省|江门市|电信
    测试成功!

10.4.3. Debug 追踪:验证 AddressUtils -> RegionUtils 的完整调用链路

我们可以通过 Debug 来验证 RVP 5.x 的加载流程:

  1. RegionUtilsstatic 代码块第一行(try { ... })打上断点。
  2. AddressUtils.resolverIPv4Region 的第一行打上断点。
  3. 【关键】重启 DromaraApplication,并使用 Debug 模式 启动。
  4. 第一次启动:程序会 立即暂停RegionUtilsstatic 代码块断点处。这证明了 SEARCHER 是在 Spring Boot 启动时 随类加载而初始化 的。按 F8 (Resume) 继续。
  5. 启动完成后,访问 http://localhost:8080/demo/addr/addr
  6. 程序会暂停在 AddressUtils.resolverIPv4Region
  7. 【关键】重启 DromaraApplication非 Debug 模式)。
  8. 启动完成后,再次访问 http://localhost:8080/demo/addr/addr
  9. static 断点不会再触发) 程序只会暂停在 AddressUtils.resolverIPv4Region
  10. 结论static 代码块在 JVM 的生命周期中只执行一次,SEARCHER 对象被成功初始化并缓存在内存中,供后续所有请求使用。

10.5. 本章总结

10.5.1. 总结:RVP IP 定位的架构与“二开”调用

本章我们深入了 RVP 的 IP 定位功能,其架构设计高度解耦:

  • AddressUtils (门面):作为“二开”的 唯一入口。它负责业务逻辑(过滤 null、区分 IPv4/IPv6、拦截内网 IP)。
  • RegionUtils (实现):负责数据查询。RVP 5.x 采用 静态代码块,通过 ResourceUtil.readBytes + Searcher.newWithBufferip2region.xdb 一次性加载到内存,实现了高性能、无状态的查询。

10.5.2. ip2region 不支持 IPv6 的现状与 RVP 的处理

我们必须注意到 AddressUtils.resolverIPv6Region() 中的源码:

1
2
3
4
5
6
7
8
9
private static String resolverIPv6Region(String ip){
// 内网不查询
if (NetUtils.isInnerIPv6(ip)) {
return LOCAL_ADDRESS;
}
log.warn("ip2region不支持IPV6地址解析:{}", ip);
// ...直接返回 UNKNOWN_ADDRESS
return UNKNOWN_ADDRESS;
}

ip2region(xdb 格式)目前不支持 IPv6。RVP 在此处做了 降级处理:如果是公网 IPv6,则记录一条警告日志并直接返回“未知”,避免了不必要的查询。