异地登录判断 —— Ip2region离线库的使用

前言

博主有一天被安排了一个需求:现在需要对我们系统的用户进行异地登陆校验,如果省市信息发生了变化就要发送提醒消息给项目管理员和用户。在实现这个需求的过程中,博主接触到了Ip2region这个功能强大的离线库开源框架!下面,我将讲解Ip2region是什么以及如何在 SpringBoot 中使用Ip2region

 

1. Ip2region 介绍

ip2region v2.0 - 是一个离线 IP 地址定位库和IP定位数据管理框架,10 微秒级别的查询效率,提供了众多主流编程语言的 xdb 数据生成和查询客户端实现和 Binary、B 树、内存三种查询算法。

 

 

官网:

GitHub - lionsoul2014/ip2region: Ip2region (2.0 - xdb) is a offline IP address manager framework and locator, support billions of data segments, ten microsecond searching performance. xdb engine implementation for many programming languages


2. Ip2region 特性

2.1. 标准化的数据格式

每个 IP 数据段的 region 信息都固定了格式:国家|区域|省份|城市|ISP,只有中国的数据绝大部分精确到了城市,其他国家部分数据只能定位到国家,缺省的选项默认是0。

 

2.2. 数据去重和压缩

xdb 格式生成程序会自动去重和压缩部分数据,默认的全部 IP 数据,生成的 ip2region.xdb 数据库是 11MiB,随着数据的详细度增加数据库的大小也慢慢增大。

 

2.3. 急速查询响应

即使是完全基于 xdb 文件的查询,单次查询响应时间在十微秒级别,还可通过如下两种方式开启内存加速查询:

  • vIndex 索引缓存 :使用固定的 512KiB 的内存空间缓存 vector index 数据,减少一次 IO 磁盘操作,保持平均查询效率稳定在10-20微秒之间。
  • xdb 整个文件缓存:将整个 xdb 文件全部加载到内存,内存占用等同于 xdb 文件大小,无磁盘 IO 操作,保持微秒级别的查询效率。

3. SpringBoot 集成

3.1. 下载 xdb 文件

由于Ip2region是基于 xdb 文件来查询的 IP 数据。所以我们需要先去 github/gitee 上下载此 xdb 文件

 

只需要下载/data中的ip2region.xdb文件即可。其他的文件不用下载

 

 

下载下来后,放到本地项目中的 resource 目录下(注:我是放在 resource 目录下,具体放在哪可自己决定)

 


3.2. 依赖添加

maven 仓库中添加依赖

<!-- ip 地址离线库依赖 -->
<dependency>
    <groupId>org.lionsoul</groupId>
    <artifactId>ip2region</artifactId>
    <version>2.7.0</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
  <groupId>cn.hutool</groupId>
  <artifactId>hutool-all</artifactId>
  <version>5.8.26</version>
</dependency>
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <scope>provided</scope>
</dependency>

3.3. Java 代码

博主将常用的功能封装成了一个工具类,直接使用即可

package com.saite.car.base.util;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.lionsoul.ip2region.xdb.Searcher;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.FileCopyUtils;

import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.UnknownHostException;

/**
 * @Description IP 解析工具类
 * @Author gongming.Zhang
 * @Date 2025/1/6 09:23
 * @Version 1.0
 */
@Slf4j
public class IpUtil {
    private final static String LOCAL_IP = "127.0.0.1";

    private static Searcher searcher;

    /**
     *   在服务启动时加载 ip2region.db 到内存中
     *   解决打包 jar 后找不到 ip2region.db 的问题
     */
    static {
        try {
            // 查找 resource 下的文件资源
            Resource resource = new ClassPathResource("/ip2region/ip2region.xdb");
            InputStream ris = resource.getInputStream();
            byte[] dbBinStr = FileCopyUtils.copyToByteArray(ris);
            searcher = Searcher.newWithBuffer(dbBinStr);
            //注意:不能使用文件类型,打成 jar 包后,会找不到文件
            log.debug("缓存成功");
        } catch (IOException e) {
            log.error("解析ip地址失败,无法创建搜索器: {}", e.getMessage());
            throw new RuntimeException(e);
        }
    }

    /**
     * 获取用户真实IP地址,不使用request.getRemoteAddr();的原因是有可能用户使用了代理软件方式避免真实IP地址,
     * <p>
     * 可是,如果通过了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP值,究竟哪个才是真正的用户端的真实IP呢?
     * 答案是取X-Forwarded-For中第一个非unknown的有效IP字符串。
     * <p>
     * 如:X-Forwarded-For:192.168.1.110, 192.168.1.120, 192.168.1.130,
     * 192.168.1.100
     * <p>
     * 用户真实IP为: 192.168.1.110
     *
     * @param request
     * @return
     */
    public static String getIp(HttpServletRequest request) {
        String ipAddress;
        try {
            // 以下两个获取在 k8s 中,将真实的客户端 IP,放到了 x-Original-Forwarded-For。而将WAF的回源地址放到了 x-Forwarded-For 了。
            ipAddress = request.getHeader("X-Original-Forwarded-For");
            if (ipAddress == null || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("X-Forwarded-For");
            }

            //获取nginx等代理的ip
            if (ipAddress == null || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("x-forwarded-for");
            }
            if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("Proxy-Client-IP");
            }
            if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("WL-Proxy-Client-IP");
            }
            if (ipAddress == null || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("HTTP_CLIENT_IP");
            }
            if (ipAddress == null || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR");
            }

            // 2.如果没有转发的ip,则取当前通信的请求端的ip(兼容k8s集群获取ip)
            if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getRemoteAddr();
                // 如果是127.0.0.1,则取本地真实ip
                if (LOCAL_IP.equals(ipAddress)) {
                    // 根据网卡取本机配置的IP
                    InetAddress inet = null;
                    try {
                        inet = InetAddress.getLocalHost();
                        ipAddress = inet.getHostAddress();
                    } catch (UnknownHostException e) {
                        e.printStackTrace();
                    }
                }
            }

            // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
            if (ipAddress != null && ipAddress.length() > 15) {
                if (ipAddress.indexOf(",") > 0) {
                    ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
                }
            }
        } catch (Exception e) {
            log.error("解析请求IP失败", e);
            ipAddress = "";
        }
        return "0:0:0:0:0:0:0:1".equals(ipAddress) ? LOCAL_IP : ipAddress;
    }

    /**
     * 根据ip获取城市信息
     *
     * @param ipAddress
     * @return
     */
    public static String getCityInfo(String ipAddress) {
        try {
            return searcher.search(ipAddress);
        } catch (Exception e) {
            log.error("搜索:{} 失败: {}", ipAddress, e.getMessage());
        }
        return null;
    }

    /**
     * 根据ip2region解析ip地址
     *
     * @param ip ip地址
     * @return 解析后的ip地址信息
     */
    public static String getIp2region(String ip) {

        if (searcher == null) {
            log.error("Error: DbSearcher is null");
            return null;
        }

        try {
            String ipInfo = searcher.search(ip);
            if (!StringUtils.isEmpty(ipInfo)) {
                ipInfo = ipInfo.replace("|0", "");
                ipInfo = ipInfo.replace("0|", "");
            }
            return ipInfo;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 获取IP地址
     *
     * @return 本地IP地址
     */
    public static String getHostIp() {
        try {
            return InetAddress.getLocalHost().getHostAddress();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
        return LOCAL_IP;
    }


    /**
     * 获取主机名
     *
     * @return 本地主机名
     */
    public static String getHostName() {
        try {
            return InetAddress.getLocalHost().getHostName();
        } catch (UnknownHostException e) {
        }
        return "未知";
    }
}

注:

static 静态代码块中的代码是为了在服务启动时加载 ip2region.xdb 数据到内存。

  1. 可以增加查询效率,减少查询耗时
  2. 在打成 jar 包后可以正常使用 ip2region.xdb

 

IpUtil.getCityInfo() 方法返回的数据格式为:

 

 

具体调用处写法:

@Service
@Slf4j
public class UserServiceImpl implements UserService {
    // 注入 HttpServletRequest 的 Bean
    // 也可以直接在 controller 层接收 HttpServletRequest 参数传递到 service
    @Autowired
    private HttpServletRequest request;

    @Override
    public LoginResponse login(LoginRequest request, String loginDevice) {
        // 从 HttpServletRequest 中获取真实 IP;再通过 IP 获取 region 数据
        String cityInfo = IpUtil.getCityInfo(IpUtil.getIp(request));
        if (StringUtils.isNotEmpty(cityInfo)) {
            String city = cityInfo.split("\\|")[3];  // 因为本处只需要城市名,所以截取了
            String cacheCity = redisTemplate.opsForValue().getAndSet(CacheKeyConst.IP + userId, city);
            redisTemplate.expire(CacheKeyConst.IP + userId, 60, TimeUnit.MINUTES);
        
            if (StringUtils.isNotEmpty(cacheCity) && !StringUtils.equals(city, cacheCity)) {
                // 发送邮件
                aliyunMailService.sendMail(user, SUBJECT, REMOTE_LOGIN);
            }
        }
    }
    
}

 

4. 踩坑点

用了 Ip2region 可能会导致用 maven package 或者 install 时报错。是因为 maven 尝试解析 Ip2region 导致的。此时我们要在 maven 的 pom 文件中增加一段:

<!-- 解决 ip2region 无法打包问题 -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-resources-plugin</artifactId>
  <configuration>
    <encoding>UTF-8</encoding>
    <!-- 过滤后缀不需要转码的文件后缀名 xdb -->
    <nonFilteredFileExtensions>
      <nonFilteredFileExtension>xdb</nonFilteredFileExtension>
    </nonFilteredFileExtensions>
  </configuration>
</plugin>

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值