• 如何根据IP地址定位用户所在的城市?
  • 发布于 2个月前
  • 203 热度
    0 评论
  • Cactus
  • 20 粉丝 36 篇博客
  •   

项目中需要根据用户IP地址获取到实际的省份,城市。虽然网络上有一些在线api接口(比如淘宝ip.taobao.com/),可以获取到实际的地址,且比较准确。但是会有QPS的限制,不能支持大量请求,且走了http请求返回相对会比较慢。


一. 方案

因此决定采用本地化方案,将IP端的解析放到本地中。再将匹配数据放到内存中,进而可以快速查找到IP对应的省份,城市。这个时候就需要用到一个比较牛逼的IP库了,IP2Region。这个库也会定时更新,我们可以自己编写程序定时同步库到本地即可。


二. 接入方式
2.1. 急速查询
完全基于 xdb 文件的查询,单次查询响应时间在十微秒级别。
vIndex 索引缓存 :使用固定的 512KiB 的内存空间缓存 vector index 数据,减少一次 IO 磁盘操作,保持平均查询效率稳定在10-20微秒之间。
xdb 整个文件缓存:将整个 xdb 文件全部加载到内存,内存占用等同于 xdb 文件大小,无磁盘 IO 操作,保持微秒级别的查询效率。
缺点:只能查询具体的城市,如果需要进行城市过滤,就无法做到,有限制。
2.2. 自定义查询
针对上面继续查询的缺点,这个时候我们就需要把整个数据都解析匹配到数据库中。IP2Region 也提供了手动处理的方式。可以基于 ip2region 自带的 ./data/ip.merge.txt 原始 IP 数据用 ip2region 提供的编辑工具来自己修改。
2.2.1. 解析数据入库
原始数据格式为国家|区域|省份|城市|ISP,项目只需要用到国家,省份,城市这三个字段。定义地域表,表结构如下:
-- auto-generated definition
create table t_area
(
    id        int auto_increment
        primary key,
    name      varchar(64) null comment '名称',
    father_id int         null comment '父一级的id',
    level     int         null comment '1-国家 2-省份 3-城市'
)
    comment '区域';
解析每一行数据到数据库中,针对其他国家,进行过滤,只匹配到国家一级即可。
**
     * 从文件中解析出本地库
     */
    public void parseIpFromMergeTxt() {

        ClassPathResource pathResource = new ClassPathResource("/ip.merge.txt");

        Map<String, Area> areaMap = new HashMap<>();

        // 处理等级为1的
        Map<String, Area> levelOneMap = list(new LambdaQueryWrapper<Area>()
                .eq(Area::getLevel, 1))
                .stream().collect(Collectors.toMap(Area::getName, a -> a));

        Map<String, Area> levelTwoMap = list(new LambdaQueryWrapper<Area>()
                .eq(Area::getLevel, 2))
                .stream().collect(Collectors.toMap(Area::getName, a -> a));

        Map<String, Area> levelThreeMap = list(new LambdaQueryWrapper<Area>()
                .eq(Area::getLevel, 3))
                .stream().collect(Collectors.toMap(Area::getName, a -> a));

        Path path = Paths.get("ip.new.txt");

        // 按行读取文件
        try {
            BufferedWriter bw = Files.newBufferedWriter(path);

            Files.lines(Paths.get(pathResource.getURI())).forEach(line -> {
                String[] strArr = line.split("\|");
                log.info("国家:{},省份:{},城市:{}",strArr[2],strArr[4],strArr[5]);

                StringJoiner sj = new StringJoiner("|");
                sj.add(String.valueOf(IPUtil.convertIPToLong(strArr[0])));
                sj.add(String.valueOf(IPUtil.convertIPToLong(strArr[1])));

                // 查询是否有该地域,没有则插入
                // 不是中国,只记录国家
                if ("中国".equals(strArr[2])) {
                    sj.add(String.valueOf(levelOneMap.get(strArr[2]).getId()));
                    sj.add(String.valueOf(levelTwoMap.get(strArr[4]).getId()));
                    sj.add(String.valueOf(levelThreeMap.get(strArr[5]).getId()));
                } else {
                    sj.add(String.valueOf(levelOneMap.get(strArr[2]).getId()));
                    sj.add("");
                    sj.add("");
                }
                try {
                    bw.write(sj.toString());
                    bw.newLine();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });

            bw.flush();

        } catch (Exception e) {
            log.error("read ip file error => {}", e);
        }

    }
前提: IP库文件是按IP段从小到大来进行的排列。

存入数据库中的IP不能是IP段,这样后期查询的时候匹配非常慢,这个时候就需要把IP转化为long数值。转化方法:
public static long convertIPToLong(String ip) {
    // 堆代码 duidaima.com
    String subIP = null;
    // 当ip地址有多条时,只取第一条
    if (ip.contains(",")) {
        String[] ips = ip.split(",");
        subIP = ips[0].trim();
    } else if (ip.contains(",")) {
        String[] ips = ip.split(",");
        subIP = ips[0].trim();
    } else {
        subIP = ip;
    }

    InetAddress IPAddr = null;
    try {
        IPAddr = InetAddress.getByName(subIP);
    } catch (UnknownHostException e) {
        logger.error("ip could not parse, please check the ip is spell correct: + [{}]", ip);
    }

    if (IPAddr == null) {
        return 0 L;
    } else {
        byte[] bytes = IPAddr.getAddress();
        if (bytes.length < 4) {
            return 0 L;
        } else {
            long l0 = (long)(bytes[0] & 255);
            long l1 = (long)(bytes[1] & 255);
            long l2 = (long)(bytes[2] & 255);
            long l3 = (long)(bytes[3] & 255);
            return l0 << 24 | l1 << 16 | l2 << 8 | l3;
        }
    }
}
我们处理完成后,就可以得到一份地域的表。

三. 数据使用
基于上面的前提,我们已经把数据入库了,这个时候就需要把数据展示到页面上。根据level进行区分不同地域类型。这个时候我们就需要用到递归算法来将整个地域以tree的形式展示到页面上。前端使用的antd,所以tree的格式采用:
@Data
public class Tree {
    private int key;
    private String title;
    private List<Tree> children;

}
整个树结构获取:
/**
 * 获取中国地域列表
 * @return
 */
public List<Tree> chinaAreaList() {

    // 查询所有城市
    List<Area> areaList = list(new LambdaQueryWrapper<Area>()
            .ne(Area::getName, "0")
            .orderByAsc(Area::getName)
    );

    // 从中国往下递归
    return childrenTree(areaList, 235);
}

private List<Tree> childrenTree(List<Area> areaList, int fatherId) {

    return areaList.stream()
            .filter(a -> a.getFatherId() == fatherId)
            .map(a -> areaToTree(a))
            .peek(t -> t.setChildren(childrenTree(areaList, t.getKey())))
            .collect(Collectors.toList());

}

/**
 * 树对象转换
 * @param a
 * @return
 */
private Tree areaToTree(Area a) {
    Tree tree = new Tree();
    tree.setTitle(a.getName());
    tree.setKey(a.getId());
    return tree;
}
四. 页面显示
这个时候页面上就可以进行地域配置了,对请求进行地域包含或者排除操作。当用户请求过来时,获取用户IP
public static String parseIp(HttpServletRequest request) {

   String ip = request.getHeader("x-forwarded-for");
   if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
      ip = request.getHeader("Proxy-Client-IP");
   }
   if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
      ip = request.getHeader("WL-Proxy-Client-IP");
   }
   if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
      ip = request.getRemoteAddr();
   }
   return ip;
}
获取到IP后,通过上面的方法转换成long的数字。这个时候就需要跟加载到内存中的数据进行匹配。这个时候就需要用到二分查找进行匹配(基于数据都是排序好的)。

就可以非常快速的查找到对应IP是哪个城市哪个省份。也可以用于用户IP解析入库。IP库中还包含运营商,这个也可以做到数据库中,这样后期也可以判断出该用户是通过哪个网络运营商接入的服务。


五. 总结
自此,一个完整的IP解析库已经完成。也可以定期,半年或者一年去github上下载最新的ip库,更新我们自有库,达到更精准的控制。后续准备把这个独立成一个单独的微服务,供所有业务系统调用。
用户评论