用户模块 - IP归属地功能实现与测试
一、获取用户 IP 并注入逻辑
在开发用户系统时,我们有时需要知道用户的真实 IP 地址,比如为了记录用户的登录地点。获取 IP 的最佳时机,就是用户刚发起请求的时候,而不是等到后面业务逻辑再来处理。
1. 在拦截器中获取 IP
我们通常会在拦截器(Interceptor)中处理用户请求,比如过去我们在这里提取 token,现在我们可以在同样的位置提取用户 IP。
不过需要注意的是,用户请求经过了 Nginx 转发,不能直接使用 request.getRemoteAddr()
来获取 IP,这样得到的是网关地址,不是真实 IP。
正确做法是让 Nginx 把用户 IP 放到请求头中,比如:
X-Real-IP: 用户真实 IP
我们只要在拦截器中读取这个请求头即可:
String ip = request.getHeader("X-Real-IP");
2. IP 信息暂存到 channel 附件中
拿到 IP 后,我们可以把它存到一个临时存储位置(通常叫做“附件”)中,后面的登录逻辑会用到它。
这里用的是 Netty 的 Channel
对象,我们可以像存储 token 一样,把 IP 放进去:
channel.attr(AttributeKey.valueOf("ip")).set(ip);
这样就把 IP 临时存好了。
3. 登录逻辑中使用 IP 后移除
这个 IP 只在登录逻辑中用一次,用完后就可以从 channel
附件中移除,避免多余数据残留。
小结
-
IP 读取要从 Nginx 的请求头中拿;
-
拿到后存到 channel 附件中;
-
登录用一次后就删掉;
-
整个流程只做一次,简洁高效。
二、用户上线事件与 IP 信息封装
IP 获取完后,下一步就是记录并封装 IP 信息,以便后续分析用户行为,比如判断是否异地登录、展示登录地点等。
1. 用户上线事件
用户成功登录后,我们会把这个“用户上线”的动作作为一个事件抛出去。为什么要这样做?
简单来说,这样可以解耦,比如登录逻辑专注于验证账号密码,IP 解析等事情可以通过事件异步处理,不影响主流程的速度。
抛出事件的代码很简单(示意):
eventBus.post(new UserOnlineEvent(userId));
2. 创建 IpInfo
实体类
为了更方便管理 IP 数据,我们单独封装了一个 IpInfo
类,用来保存用户的 IP 信息。常见字段有:
-
createIp
:用户注册时的 IP,只设置一次; -
updateIp
:用户每次登录时的 IP,会持续更新; -
其他解析出的归属地信息(如省份、城市、运营商等)。
结构大致如下:
public class IpInfo {private String createIp;private String updateIp;private String province;private String city;private String isp;// ...更多字段
}
3. 设置 IP 的时机
在用户登录成功的方法中,我们就来设置这些 IP 信息。
-
如果是第一次登录(
createIp
为空),就设置createIp
; -
不管是不是第一次,都更新
updateIp
。
示意逻辑如下:
if (ipInfo.getCreateIp() == null) {ipInfo.setCreateIp(currentIp);
}
ipInfo.setUpdateIp(currentIp);
小结
-
登录成功后抛出“上线事件”,用于异步处理;
-
创建
IpInfo
类封装 IP 信息; -
createIp
只记录一次,updateIp
每次都更新。
三、IP 解析框架设计
拿到用户 IP 后,我们希望知道这个 IP 属于哪个省市、运营商。这就需要一个 IP 解析框架 —— 它的任务是根据 IP 地址查出归属地信息,并更新到用户资料中。
这个过程我们设计为异步处理,避免影响登录速度。
1. 在用户上线处理器中触发异步解析
在“用户上线事件”被监听后,我们会触发一个处理器,负责进行 IP 解析。这一步是后台执行的,用户无感知。
为了不阻塞主线程,我们用线程池来异步执行解析任务。
2. 自定义线程池
我们自己定义一个专用线程池,主要考虑:
-
线程命名明确,方便排查问题;
-
核心线程数和最大线程数都设置为 1,表示串行处理;
-
所有任务都用异步方式提交。
示意代码如下:
ExecutorService ipParserPool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<>(),new NamedThreadFactory("ip-parser")
);
提交任务:
ipParserPool.submit(() -> parseIp(userId, currentIp));
3. 是否需要解析的判断逻辑
每次上线都解析 IP 会浪费资源。所以我们先对比:
-
用户资料中
updateIp
是否和当前 IP 相同; -
如果一样,就不解析;
-
如果不一样,说明 IP 变了,就执行解析逻辑。
判断示例:
if (!currentIp.equals(ipInfo.getUpdateIp())) {// IP 变了,去解析
}
4. 解析 IP 的具体逻辑
解析 IP 主要包括这几步:
(1)重试机制
有时解析服务会不稳定,为了提高成功率,我们设置 最多重试 3 次,每次失败 休眠 2 秒,并打印错误日志。
示意代码:
for (int i = 0; i < 3; i++) {try {// 发起解析请求break; // 成功就退出循环} catch (Exception e) {log.warn("解析失败,第 {} 次", i + 1, e);Thread.sleep(2000);}
}
(2)发起请求并反序列化
我们使用 Hutool 工具类来发起 HTTP 请求获取 IP 信息:
String response = HttpUtil.get("http://xxx.com/ip?ip=" + currentIp);
拿到返回的数据后,用 JsonUtil 工具类把它转成 Java 对象:
IpInfo info = JsonUtil.toBean(response, IpInfo.class);
最后,把解析结果更新到用户资料中即可。
小结
-
用户上线后触发异步解析;
-
解析框架用单线程池,串行执行;
-
先判断 IP 是否已变,避免重复解析;
-
最多重试 3 次,每次失败等待 2 秒;
-
使用 Hutool 发请求,JsonUtil 解析数据,更新用户信息。
四、IP 框架吞吐量测试
我们已经完成了 IP 解析框架的基本逻辑。那接下来就要测试一下它的性能 —— 比如:一秒钟能处理多少个 IP?这就是所谓的吞吐量测试。
1. 为什么要测试?
-
看看框架能不能扛住大量用户同时登录;
-
帮助我们找到性能瓶颈,是否需要优化;
-
为未来扩容做准备。
2. 怎么测?
我们写一个简单的测试方法,做两件事:
-
循环调用解析方法,比如连续解析 10 次;
-
记录开始时间和结束时间,算出每次大概需要多久。
示意代码如下:
long start = System.currentTimeMillis();for (int i = 0; i < 10; i++) {try {parseIp(userId, testIp); // 调用 IP 解析方法System.out.println("第 " + (i + 1) + " 次解析成功:" + System.currentTimeMillis());} catch (Exception e) {e.printStackTrace();}
}long end = System.currentTimeMillis();
System.out.println("总耗时:" + (end - start) + "ms");
测试结果显示:差不多每秒解析 1 个 IP,当然这也跟你网络、第三方服务快慢有关。
3. 测试中的异常处理
在测试过程中,如果解析失败,我们不希望影响整个流程。
所以加上简单的异常捕获,一旦失败,就返回 null
,方便继续测试:
try {return parseIp(ip);
} catch (Exception e) {return null;
}
4. 优雅停机:关闭线程池
测试完毕后,别忘了关闭线程池,不然程序可能无法正常退出。
步骤如下:
-
调用
shutdown()
,不再接受新任务; -
设置最大等待时间,比如 10 秒;
-
如果还没完成,强制关闭。
示意代码:
ipParserPool.shutdown();
if (!ipParserPool.awaitTermination(10, TimeUnit.SECONDS)) {ipParserPool.shutdownNow();
}
小结
-
吞吐量测试可以估算框架性能;
-
循环调用解析方法,记录时间;
-
异常要处理,不能中断整个测试;
-
测试后要关闭线程池,程序才能优雅停机。
五、线程池优雅停机
我们在 IP 解析中使用了线程池来异步执行任务。程序运行时一切正常,但如果线程池不关闭,程序可能无法退出或者资源一直占用。
所以我们需要在系统关闭时,把线程池优雅地停掉。
1. 什么是“优雅停机”?
优雅停机的意思是:
-
不再接受新的任务;
-
把正在执行的任务做完;
-
给任务一点时间完成;
-
超时没完成,就强制停止。
这样既不会浪费资源,也不会留下“未完成的事情”。
2. 停机代码怎么写?
假设我们的线程池叫 ipParserPool
,优雅停机步骤如下:
// 1. 通知线程池:别接新任务了
ipParserPool.shutdown();// 2. 等最多 10 秒,看看线程池能不能处理完现有任务
if (!ipParserPool.awaitTermination(10, TimeUnit.SECONDS)) {// 3. 如果超过 10 秒还没停下来,那就强制关闭ipParserPool.shutdownNow();
}
解释一下:
-
shutdown()
:相当于说“停止接活”; -
awaitTermination()
:最多等 10 秒,看有没有彻底停下来; -
shutdownNow()
:如果还不行,直接打断所有线程,强制停机。
3. 这个操作放在哪?
一般放在项目的 @PreDestroy
方法或 Spring 的销毁钩子里。比如:
@PreDestroy
public void destroy() {// 上面的优雅停机代码就写在这里
}
Spring 容器关闭时,就会自动调用这个方法。
小结
-
用了线程池,一定要在项目关闭时优雅停机;
-
做法是先
shutdown()
,再awaitTermination()
,最后shutdownNow()
; -
推荐把停机逻辑写在
@PreDestroy
方法中,自动触发。