除了在开源中国发布必要的版本更新新闻以外我没有对 ip2region 进行任何推广,目前已经在 github 有 10.9K star / 2K fork,在 Gitee 上也有 6.2K star / 1.6K fork,借此感谢 8 年来那些提交 pr 的贡献者,也随喜这个无心插柳的工作能给如此多的开发同行带来了工作的便利。
ip2region v2.0 的 xdb 数据管理引擎已经发布,并且已经完成了 3 种语言的 maker 以及 8 种语言的 binding 实现,在此简单地描述下 v1.0 的缺陷,算是告别这个工作了近 8 年的管理引擎,详细介绍下 v2.0 xdb 的数据结构以及查询的过程。
一、v1.0 db 格式的缺陷
二、v2.0 xdb 格式的设计
header 段主要是记载一些头信息,这个段固定占据 256 字节的空间,目前实际上只用到 16 个字节,剩下的全部为 0 的预留空间,便于后期扩展需要,目前的具体空间使用情况如下:
如果想要验证 xdb 数据的完整性,可以在 16 到 31 之间的 16 字节中存储 header 段外的数据的 MD5 哈希值,便于确认文件是否在网络传输或者项目打包过程中受损。
第二层 - Vector 索引段:
第三层 - 地域信息段:
0.0.0.0|0.255.255.255|0|0|0|内网IP|内网IP1.0.0.0|1.0.0.255|澳大利亚|0|0|0|01.0.1.0|1.0.3.255|中国|0|福建省|福州市|电信
0|0|0|内网IP|内网IP澳大利亚|0|0|0|0中国|0|福建省|福州市|电信
每个二分索引项占据 14 个字节,详细字段和空间分布如下:
三、查询过程的分析
计算机里面任何数据都可以转换为数字,这是一句废话?整个数字电路就是基于二进制的,每个查询 binding 都有一个类似的 ip2long 的函数,用于把字符串 IP 转换为整数 IP,IP(v4) 本身就是一个 4 字节的整数,IP 的字符串输出形式就是四个字节转换成整数然后用点串接起来:
var ip = "120.24.78.129"// 把字符串 ip 按照 . 拆分,此处略过错误检测var ps = ip.split(".")// 把切分得到四个数字转换成整数,然后进行位运行就好。var ip_int = (toInt(ps[0]) << 24) | (toInt(ps[1]) << 16) | (toInt(ps[2]) << 8) | toInt(ps[3]))
// 常量定义// 每个 vector 索引项的字节数var VectorIndexSize = 8// vector 索引的列数var VectorIndexCols = 256// vector 索引段整个的字节数var VectorIndexLength = 512KiB// 假设 vectorIndex 为加载的向量索引// loadVectorIndex api 返回的数据,本质上是一维的 byte 数组。var vectorIndex = byte[VectorIndexLength]// 第一个字节的整数 (&0xFF,确保取值在 0 ~ 255)var il0 = (ip_int >> 24) & 0xFF// 第二个字节的整数 (&0xFF,确保取值在 0 ~ 255)var il1 = (ip_int >> 16) & 0xFF// 计算得到 vector 索引项的开始地址。// 这里可以对比上述的 vector table 结构进行理解var idx = il0 * VectorIndexCols * VectorIndexSize + il1 * VectorIndexSize// 区域二分索引的开始地址// idx 开始读取 4 个字节,用小端字节序解码得到一个整数var sPtr = getInt(vectorIndex, idx)// 二分区域索引的结束地址// idx + 4 处读取 4 个字节,用小端字节序解码得到一个整数var ePtr = getInt(vectorIndex, idx + 4)
// 常量定义// 二分索引项的字节数var SegmentIndexSize = 14// 二分搜索低位var l = 0// 二分搜索高位// 上第一步得到的结束索引位置减去开始索引位置// 再除以每个索引的字节大小就是这次搜索要扫描的索引的个数了。var h = (ePtr - sPtr) / SegmentIndexSize// 数据长度var dataLen = 0// 数据地址var dataPtr = 0// 开始拆半搜索得到地域数据长度和位置信息var buff[SegmentIndexSize]for l < h {// 得到中间的索引项var m = (l + h) >> 1// 计算中间索引项的指针地址// 在起始地址 sPtr 上加上 m 的相对地址即可var p = sPtr + m * SegmentIndexSize// 从 p 位置开始读取 SegmentIndexSize = 14 个字节到 buff// 得到一个完整的上述描述的二分索引项,不过为了减少不必要的操作// 我们是按需要解码,此处 buff 为 p 开始的 14 个 byte 的数据read(p, buff)// 前面 4 个字节是起始 IPvar sip = getInt(buff, 0)if ip_int < sip {// 目标比 sip 小,也就是在 p 位置的左边h = m - 1} else {// 4 ~ 7 之间的 4 个字节是结束 IP 地址var eip = getInt(buff, 4)if ip_int > eip {// 目标 ip 比 eip 大,也就是在 p 位置的右边l = m + 1} else {// 搜索命中// 目标 ip 正好在 sip 和 eip 之间// 也就是找到目标 ip 了// 8 ~ 10 两个字节是地域数据的长度dataLen = getShort(buff, 8)// 10 ~ 13 的 4 个字节是地域数据的地址dataPtr = getInt(buff, 10)}}}
// 将文件指针移动到 dataPtrhandle.seek(dataPtr)// 当前位置读取 dataLen 个字节var buff[dataLen]handle.read(buff)// 把 buff 解码成 utf-8 字符串返回return string(buff, "utf-8")