IP 归属地与二分法

写在前面

我们可以通过 DNS 来完成负载均衡,也就是在 DNS 解析的过程中,预先完成了线路分配
线路分配 并不只局限于负载均衡,如果在分配的过程中,增加更多的逻辑判断,那分配的规则也就更细化了。比如一些商业 DNS 服务商,会提供不同的线路 (电信、移动、联通) 可以有不同的解析结果,不同的国家、不同的省有不同的解析结果,这样可以尽可能地让你的访客处于最佳连接速度。

那么,如何判定一个 IP 是来自不同的线路?来自不同的省市呢?
本篇的内容,我们探讨下实现方法,但主要考虑的是判断 一个 IP 是否来自于中国,因为它本质上跟判别来自哪个省市的逻辑是相似的。判别是否来自于某个国家,看似精度很低;但另外一方面,如果精度足够高,就已经是商业价值很高的商业模式了。高精度的判别我们做不了,一来没有能力,二来也不合适。
当然,关键不在于 精度,而是这个过程中,我们将接触真正实用意义的 算法: 二分法

China IP

Github 上有个 repo https://github.com/17mon/china_ip_list,名为 《IPList for China by IPIP.NET》,IPIP.NET 是一个 IP 库 的数据服务商,可以通过 IP 获得比较精准的地理位置信息。
简而言之,这个 repo 提供了中国大陆地区的 IP 列表,如此一来,我们只要把某个 IP 跟这个列表进行比对,自然就能知道它是否来自中国大陆了。

等下,好像不对劲。怎么 IP 列表里的是 103.203.168.0/22, 这是 IP 吗?怎么多了一个 /22?! 而且总记录数才 5 千多条,中国这么大,怎么可能才这么几个 IP?!
然后,我们使用关键词 IP 后面斜杠,你会发现新的线索,子网、subnet、subnetwork、CIDR 类似的关键字会自动跳出来。
关于这些技术关键字更多细节,你可以进一步了解,也可以像我一样,选择 不求甚解。知道它的存在,就可以寻找到解决方案了,并不一定要完全掌握所有的细节。
简而言之,现在我们知道 IP/数字 是一种子网,可以代表不止一个 IP。所以,5 千多个 IP 列表,可以涵盖中国大陆的所有 IP,从逻辑上就讲得通了。

IP 是否在子网中

在『 China IP』 中,产生了一个新的问题,就是要判断一个 IP 是否在某个子网中。
有一种朴素的做法,把一个子网包含的 所有IP 都遍历出来,最终获得庞大的列表,这样具体的某个 IP, 代码中判断是否 in 就可以了,方便快捷。但是,这要注意了,不能使用 list 这个数据类型,而是要 set 这个类型,因为数据量可能会大到 list 的性能要吃不住了。
这种朴素的做法, 你可以尝试一下。毕竟入门阶段,用代码去碰撞下某个 idea 挺好的,即使 idea 本身比较朴素,毕竟也容易触及到一些边界问题,这可以让我们在未来时候,提早避免一些错误。

一时想不到更好的解决方案,就使用搜索引擎吧。对了,此时你应该是在使用 Google 搜索技术性的关键词,Google 会给你更想要的结果,当然,这些结果大部分也是英文的。一般来说,技术性的英文内容,其基本的阅读能力的要求,对多数人而言,应该是没有问题的。
我们使用 subnet python 这个关键词在 Google 上搜索,假设它最终并不能给你准确的结果,你可以再去 Github 上使用 subnet 类似的关键词寻找。
然后,你应该会找到 《Checking if an IP belongs to a subnetwork in Python》https://diego.assencio.com/?index=85e407d6c771ba2bc5f02b17714241e2 类似的内容,或者一个 StackOverflow 上的问题
此时,问题就已经初步解决了。上面引用的文章地址内就包括了具体的代码,即使不用现成的代码,StackOverflow 上的内容中也提及了 netaddr、ipaddress 这些 module,去看看它们的源码,再多花些时间,自己也能写出需要的代码来。
另外一方面,我们也要注意在寻找解决方案过程中,一些代码片段的版权问题。如果不能直接用的,那就不要直接复制、粘贴,但是不论如何,思路是可以参考的, 思路不是抄袭,特别一些常见的、基础的、接近标准范式的,自己再重新实现一遍就好。

我们来看看代码的基本逻辑,首先是将一个 IP (本文处理的都是 IPV4,未对应 IPV6) 转为一个整数:

def ip_to_integer(ip_address):
    version = socket.AF_INET
    try:
        ip_hex = socket.inet_pton(version, ip_address)
        ip_integer = int(binascii.hexlify(ip_hex), 16)
        return ip_integer
    except:
        pass
    raise ValueError("invalid IP address")

然后,是一个子网转为一个整数的范围区间:

def subnetwork_to_ip_range(subnetwork):
    try:
        fragments = subnetwork.split('/')
        network_prefix = fragments[0]
        netmask_len = int(fragments[1])
        ip_len = 32
        try:
            suffix_mask = (1 << (ip_len - netmask_len)) - 1
            netmask = ((1 << ip_len) - 1) - suffix_mask
            ip_hex = socket.inet_pton(socket.AF_INET, network_prefix)
            ip_lower = int(binascii.hexlify(ip_hex), 16) & netmask
            ip_upper = ip_lower + suffix_mask
            return ip_lower, ip_upper
        except:
            pass
    except:
        pass
    raise ValueError("invalid subnetwork")

那么最后判断一个 IP 是否在一个子网中,就很简单了: 1. IP 先转为整数;2. 子网转为一个区间,包含了最大值、最小值 (都是整数);3. IP 对应的整数是否在子网的区间内。

载入子网列表

def load_ip_subsets(filepath):
    with open(filepath) as f:
        raw_content = f.read()
    lines = raw_content.split('\n')
    subsets = []
    for line in lines:
        line = line.strip()
        try: subsets.append(subnetwork_to_ip_range(line))
        except: pass
    return subsets

我们需要把 China IP 中的文本中,每一行的子网 IP 地址,转为 Python 的 list 数据,方便后续调用。
务必要注意一点,像 load_ip_subsets 这样的函数,应该要尽可能地减少调用次数,因为,它最终获得的变量 (比如叫 subnets) 其实都是固定的,读取一次就好了,反复读,只会造成性能的无端浪费。

效率与性能

『 IP 是否在子网中』中提到了一个朴素的想法,尝试了吗?尝试时要避免使用 list,它会有性能问题。
我们继续产生另外一个朴素的想法,现在已经能判断某个 IP 是否在某个子网中了,那么 5 千多个中国大陆的 IP 地址列表,也就是 5 千多个 子网 的地址列表。我们只要做一个 for subnet in subnets 这样的循环,命中了就 return True,如此一来,本文的问题就有了完整的解决方案!
是的。写代码,终归是为了一个解决方案,能解决比什么都重要。

但是,这样的代码只能自己(临时)用用,是不能放在外面 (某个正式产品) 被调用的。
为什么呢? 性能太低了! 一个循环下去,如果 IP 不是中国大陆的,那么就铁板钉钉要运行 5 千多次的逻辑判断,才能出结果。这个性能问题,比 list 产生的问题还严重。
我们要一直抱着一个自我怀疑的态度,会不会有性能问题,这非常非常重要。
对于初入门者而言,容易把 人脑 认为很难搞定的事下意识也会认为计算机同样很难搞定,比如 21431241 * 3123123 算起来很复杂吧,如果运行一百万次呢,会不会有性能问题? 实际上没有啦…… (此行内容,请进一步参考 LearnPython.app 的内容)
初入门者另外则是经验不够,性能问题的判断容易犯错。但这很正常,我自己就犯过不少错误,甚至现在也仍然在犯错误,而且有些决策定了,也会犹疑不决,担心产生潜在的(致命)问题。

我们始终要对 性能 的问题,多一份注意。
回归到问题本身,我们能否避免那 5 千多次很可能白白浪费的查询呢?这里要引入 算法 了,叫二分法

二分法

有一万个人在排队,而且已经从矮到高有序地排队了,那么你也过来排队,怎么找到正确的位置?如果计算机程序这种傻呵呵的系统,就会遍历队伍里的每个人,看你是否合适排在他的前面,最快的可能查 1人就好 (你最矮),倒霉的要查 1万 人 (你最高)。 (注: 这例子也不够好,但相对比较好理解;不够好的原因是难以用这个例子去覆盖『如果有不是 China IP 的 IP 去匹配那必然会全部遍历』的情况)
二分法 的逻辑很简单,就是让你先到队伍的中间:如果你比前面都高,那么就可以淘汰掉前面的一半,到后面 (中间) 继续排队;如果你比前面都矮,那么淘汰掉 后面的一半,到前面 (中间) 继续排队。第二次排队的时候,依旧找到队伍中间,如此循环往复。

我们观察子网的 IP 列表,每个子网 IP 都转化为 [整数1, 整数2] 的格式,这些子网之间是不重复的,而且是有大小次序的,完美符合二分法的基本条件。当然,这里有个很重要的前提,就是修改『载入子网列表』中 load_ip_subsets 函数,末尾一行增加 subsets.sort() 进行排序。
我们把这个二分法的算法,转为 Python 的代码,主要目的就是找到一个 IP 它对应的子网,如果能找到,就是中国大陆的 IP,反之,就不是了。

参考代码:

def find_subnet_for_ip(ip, subsets):
    try:
        ip = ip_to_integer(ip)
    except:
        return
    low_index = 0
    high_index = len(subsets)-1
    tried_times = 0
    while low_index < high_index:
        tried_times += 1
        middle_index = int((low_index+high_index)/2)
        middle_obj = subsets[middle_index]
        start_value, end_value = middle_obj
        if end_value >= ip >= start_value:
            print('done, tried_times times %s'%tried_times)
            return middle_obj
        elif ip > end_value:
            low_index = middle_index + 1
        else: # ip < start_value
            high_index = middle_index - 1
    print('failed, tried_times times %s' % tried_times)
    return None

希望你能理解这个算法,至于代码上怎么实现,能记住就记住,记不住也没有关系,有实际需求时再返回这里,跟实际场景结合,哪怕重新实现的时候比较慢也没有关系,终归是能写出来的。
如果仍然感觉犯懵的话,非常理解,因为,即使我也是如此。手写二分法这么简单的算法,都要在纸上画一画。计算机专业出身的话或许经过刻意的训练,可以快速的完成,但对于我这个文科生而言,知其然了,速度却上不来。坦然接受呗,不想做什么刻意的训练,当下足够了。于你而言,应该有自己的判断,浅尝还是深入,都是好的方法,不用受他人的裹挟。
对了,为了方便,参考代码中才直接 print,再强调一遍,在正式的代码中,除非为了 Debug 的逻辑,不然,不建议直接使用 print。

看看效果

我们先把之前的各个函数,串联起来,加上几个 IP 作为验证的样本:

def test_china_ip():
    dir_path = os.path.dirname(os.path.abspath(__file__))
    data_filepath = os.path.join(dir_path, 'china_ip_list.txt')
    subsets = load_ip_subsets(data_filepath)
    ips_to_check = ['8.8.8.8', '39.181.24.242', '139.81.2.124', 
                    '121.9.34.15', '192.188.12.9', '129.56.147.145']
    for ip in ips_to_check:
        is_china_ip = bool(find_subnet_for_ip(ip, subsets))
        status = 'yes' if is_china_ip else 'no'
        print('%s is China ip: %s\n' % (ip, status))

最终的运行结果:

failed, tried_times times 12
8.8.8.8 is China ip: no

done, tried_times times 10
39.181.24.242 is China ip: yes

failed, tried_times times 12
139.81.2.124 is China ip: no

done, tried_times times 11
121.9.34.15 is China ip: yes

failed, tried_times times 12
192.188.12.9 is China ip: no

failed, tried_times times 11
129.56.147.145 is China ip: no

我们可以看到,不是 China IP 时,相当于某种意义的『全遍历』,会有 12 次的逻辑判断,而不是 5 千多次。2 的 12 次方,结果接近 5 千,二分法可以让查找的性能飙升。假设这里 IP 归属子网的逻辑判断进行了 100 次,其实相当于支撑了约 2 的 100 次方 (也就是 1267650600228229401496703205376) 大小的列表。

重要的总结

自学 应该要靠自己的来总结,但本篇内容比较特殊,它相当于一次比较完整的 从无到有 的过程,这个过程有些环节,如果不再强调一次,不从整体的角度来梳理一遍,它的价值或许就打了折扣。

我们理一下各个环节:

  1. 负载均衡 的逻辑中衍生开来,本质上是 DNS 的 预先线路分派
  2. 线路的判断、分派,从 是否为中国大陆的 IP这个小问题出发,一方面它可能有实际的用途,另一方面则是窥一斑而知全貌,这个问题解决了,其它的也就迎刃而解了。
  3. 找相关的资料,在 Github 上能找到 china-ip-list 这个 repo。
  4. 在 china-ip-list 中发现 IP/数字 这种从未见过的 IP 格式,由此通过 搜索引擎 发现,这叫 子网,涵盖了不止一个 IP 地址。
  5. 由此衍生出来,要实现一个函数,可以判断一个 IP 是否属于某个子网,我们通过 Google 或者 Github 或者 StackOverflow,会找到对应的方案。
  6. 在上一步过程中,会发现其实 IP 可以转化成为一个 整数,而子网就是两个一大一小整数决定的区间
  7. 这一步可能有点跳跃了,因为没有任何细节可以指向 二分法,但如果你知道 二分法 (一般基础算法介绍中都会有),即使从未实现过,你会 感觉 这里可以用起来。
  8. 要一直有 性能 的概念,即使上一步中不知道 二分法,但是在找到 china-ip-list 之后,会衍生地看到 IPIP.net 的服务,自然会看到基于他们的数据的一些第三方 package。如果匹配一个 IP 要从头到尾的遍历,这个性能想当然的低,肯定不可能。而通过看这些第三方 package 的源码 (并不复杂),你一定会在源码中发现 二分法 的踪影。

整个过程,开始了第一步,就必然会走到最后一步,非常像蹒跚学步的小孩,看着仿佛要摔倒了,实际上又能往前迈出一步。
也有些像 《贫民窟的百万富翁》
提及这部电影,有另外试图的表达,只是很难通过文字被直接感受到。且就如此,技术上的不少事情,如电影里的情节,有答案就可以了,即使机缘巧合到要被质疑,那也没有关系,知道就可以了。
如果涉及到技术细节,有时间,就去钻研;如果没有时间,大概知其然,且有当下的解决方案,也是很好的。甚至于,后者是更好的;但是,轻视前者的,又必然会因为盲目无知而被人耻笑。
这种矛盾的状态,希望能传达给你了。