当前位置: 首页 > news >正文

用户模块 - 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. 怎么测?

我们写一个简单的测试方法,做两件事:

  1. 循环调用解析方法,比如连续解析 10 次;

  2. 记录开始时间和结束时间,算出每次大概需要多久。

示意代码如下:

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. 优雅停机:关闭线程池

测试完毕后,别忘了关闭线程池,不然程序可能无法正常退出。

步骤如下:

  1. 调用 shutdown(),不再接受新任务;

  2. 设置最大等待时间,比如 10 秒;

  3. 如果还没完成,强制关闭。

示意代码:

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 方法中,自动触发。

相关文章:

  • AI Agent开发第50课-机器学习的基础-线性回归如何应用在商业场景中
  • PyTorch_自动微分模块
  • linux tar命令详解。压缩格式对比
  • C++访问MySQL
  • 联邦学习的深度解析,有望打破数据孤岛
  • 3.5/Q1,GBD数据库最新一区文章解读
  • rollout 是什么:机器学习(强化学习)领域
  • 【C/C++】各种概念联系及辨析
  • Socket 编程 TCP
  • 2025年PMP 学习五
  • Qt天气预报系统更新UI界面
  • 电路研究9.3.3——合宙Air780EP中的AT开发指南:HTTP(S)-HTTP GET 示例
  • 逆向常见题目—迷宫类题目
  • 【AI大模型学习路线】第一阶段之大模型开发基础——第四章(提示工程技术-1)In-context learning。
  • android-ndk开发(5): 编译运行 hello-world
  • 机器人强化学习入门学习笔记
  • EPSG:3857 和 EPSG:4326 的区别
  • 雷电模拟器-超好用的Windows安卓模拟器
  • 百度golang开发一面
  • Red Hat6.4环境下搭建DHCP服务器
  • 躺着玩手机真有意思,我“瞎”之前最喜欢了
  • 有乘客被高铁车门夹住?铁路回应:系突感不适下车,未受伤,列车正点发车
  • 新华每日电讯“关爱青年成长”三连评:青春应有多样的精彩
  • 新加坡执政党人民行动党在2025年大选中获胜
  • “特朗普效应”下澳大利亚执政工党赢得大选,年轻选民担忧房价
  • 德雷克海峡发生6.4级地震,震源深度10千米