C# 中的 IP 地理位置和 CIDR 范围解析






4.43/5 (4投票s)
使用第三方地理位置 .csv 文件和一些地址范围逻辑来提供简单的 IP 地址地理位置。
引言
.NET 提供了 IPAddress 类来处理 IP 地址。但是,截至 .NET 4.6,它没有提供任何内置功能来执行 IP 地理位置,即确定 IP 地址的地理位置(例如,大陆、国家、城市或纬度/经度)。IP 地理位置需要一个定期更新的数据库,因为 IP 地址分配会随时间而变化。有几个定期维护的商业和免费 IP 地理位置数据库(例如,GeoLite2、DB-IP),并且它们相对容易从 .NET 使用。
GeoLite2 国家数据
在本文中,我们将介绍一种快速加载免费 GeoLite2 Country 地理位置数据的方法。GeoLite2 有自己的 API 用于读取数据,但其 CSV 数据易于解析,无需其他程序集。IPv4 块位于 GeoLite2-Country-Blocks-IPv4.csv 中,IPv6 块位于 GeoLite2-Country-Blocks-IPv6.csv 中。这两个文件都具有如下结构:
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,... 1.0.0.0/24,2077456,2077456,,0,0 1.0.1.0/24,1814991,1814991,,0,0 1.0.2.0/23,1814991,1814991,,0,0 1.0.4.0/22,2077456,2077456,,0,0
…
76:96:42:219::/64,6252001,,,0,0 600:8801:9400:580::/128,6252001,,,0,0 2001:200::/49,1861060,,,0,0 2001:200:120::/49,1861060,,,0,0
network
字段指定了一个 CIDR 表示法(无类别域间路由表示法)中的 IP 地址块,它给出了一个 IP 地址和用于子网掩码的有效位数。这用于计算网络或子网的起始和结束地址。.NET 不包含任何解析 CIDR 表示法的功能,因此我们需要自己处理。geoname_id
值是整数,它们引用 GeoLite2-Country-Locations-en.csv 中的国家信息,该文件具有如下结构:
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name 49518,en,AF,Africa,RW,Rwanda 51537,en,AF,Africa,SO,Somalia 69543,en,AS,Asia,YE,Yemen 99237,en,AS,Asia,IQ,Iraq
这两个 GeoLite2-Country-Blocks-IPv?.csv 文件不包含任何需要引用的字段,因此可以使用 StreamReader.ReadLine 并对每一行调用 string.Split 来解析。但是,GeoLite2-Country-Locations-en.csv 文件确实包含带有嵌入逗号的字段值(例如,“Bonaire, Sint Eustatius, and Saba”),因此必须使用能够理解 CSV 格式的内容来解析。最容易使用的就是 .NET 的 TextFieldParser。虽然它声明在 Microsoft.VisualBasic.dll 中,但如果您添加对该程序集的引用,就可以在 C# 中很好地使用它。
private static Dictionary<int, string[]> LoadLocations(string fileName)
{
Dictionary<int, string[]> result = new Dictionary<int, string[]>();
using (var reader = new TextFieldParser(Path.GetFullPath(fileName)))
{
reader.TextFieldType = FieldType.Delimited;
reader.Delimiters = new[] { "," };
while (!reader.EndOfData)
{
string[] fields = reader.ReadFields();
int geoNameId;
if (int.TryParse(fields[0], out geoNameId))
{
result[geoNameId] = fields;
}
}
}
return result;
}
解析 CIDR 块
在加载块文件后,我们需要解析 CIDR 表示法并计算起始和结束地址。.NET 的 IPAddress
类可以解析地址,但它不会解析有效位数或计算起始和结束地址。解析 CIDR 表示法相对简单,因为我们只需要在 '/' 字符处拆分。计算起始和结束地址则更复杂。对于转换为 address uint
的 32 位 IPv4 地址(使用 IPAddress.GetAddressBytes),其逻辑如下:
// This needs to work with routingBitCount between 0 and 32
// where 0's mask is 0, and 32's mask is 0xFFFFFFFF.
const byte BitSize = 32;
uint mask = routingBitCount == 0 ? 0 :
unchecked(~(((uint)1 << (BitSize - routingBitCount)) - 1));
uint start = address & mask;
uint end = start | ~mask;
类似的逻辑也适用于 128 位 IPv6 地址。但是,.NET 不包含简单的 128 位无符号整数类型。它包含用于任意精度整数的 BigInteger,但与固定大小的整数相比,BigInteger 的速度相对较慢。我们可以通过将逻辑拆分为使用两个 64 位无符号整数来做得更好。其逻辑如下:
// This needs to work with routingBitCount between 0 and 128
// where 0's mask is 0, and 128's mask is 0xFFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF.
ulong upperMask, lowerMask;
const byte BitSize = 128;
const byte HalfBitSize = BitSize / 2;
if (routingBitCount == 0)
{
upperMask = 0ul;
lowerMask = 0ul;
}
else if (routingBitCount <= HalfBitSize)
{
upperMask = unchecked(~(((ulong)1 << (HalfBitSize - routingBitCount)) - 1));
lowerMask = ulong.MinValue;
}
else
{
upperMask = ulong.MaxValue;
lowerMask = unchecked(~(((ulong)1 << (BitSize - routingBitCount)) - 1));
}
ulong startUpper = upper & upperMask;
ulong startLower = lower & lowerMask;
ulong endUpper = startUpper | ~upperMask;
ulong endLower = startLower | ~lowerMask;
我们可以将这种拆分 CIDR 表示法和计算地址范围的逻辑封装在一个 CidrBlock
类中,其 API 如下:
public Address NetworkAddress { get; }
public byte RoutingBitCount { get; }
public Address StartAddress { get; }
public Address EndAddress { get; }
public static bool TryParse(string ipAddressAndBits, out CidrBlock value)
我们可以创建一个可比较、可公平的地址类型层次结构,如下所示:
abstract class Address : IEquatable<Address>, IComparable<Address>
sealed class V4Address : Address, IEquatable<V4Address>, IComparable<V4Address>
sealed class V6Address : Address, IEquatable<V6Address>, IComparable<V6Address>
因为它们实现了 IComparable<T>(与 .NET 的 IPAddress
类不同),我们可以将 Address
实例与有序集合一起使用(例如,排序的 List<T> 的 BinarySearch)。示例 AddressRangeMap<TAddress, TValue>
类就是这样做的,以便搜索包含给定 Address
的第一个范围。AddressRangeMap
的 API 是:
public AddressRangeMap()
public AddressRangeMap(int capacity)
public void Add(TAddress start, TAddress end, TValue value)
public void SetReadOnly()
public bool TryGetValue(TAddress address, out TValue value)
要使用 AddressRangeMap
,您需要添加地址范围及其关联的值(例如,地理位置信息),然后调用 SetReadOnly
,以便它可以对数据进行排序。然后,您可以使用 TryGetValue
来查找与给定 Address
所属的地址范围关联的值。因为 AddressRangeMap
可以处理任何 Address
派生类型,所以您可以创建仅用于 IPv4 地址、仅用于 IPv6 地址或两者混合的映射,具体取决于您的缓存需求。随附的示例代码展示了所有三种变体。AddressRangeMap
实例在调用 SetReadOnly
后也是线程安全的,因此多个线程可以使用共享的缓存地理位置数据实例。
关注点
Address
(及其后代)、CidrBlock
和 AddressRangeMap
是可重用的,并且与 GeoLite2 数据无关。GeoLite2 数据仅用作本文的示例,并且仅由 Program
示例类引用。出于法律和大小原因,本文的示例代码不包含完整的 GeoLite2 国家数据库。它仅包含每个文件的少数样本记录,用于说明目的。要运行所有包含的示例代码(例如,测试缓存块映射中的地址查找),您应该下载 GeoLite2 国家数据库并替换样本 .csv 文件。
如果需要,您还可以使用包含的类来解析 DB-IP 地理位置数据。例如,其 dbip-country.csv 文件的结构如下,因此您可以使用 Address.Parse
和 AddressRangeMap
轻松缓存该地理位置信息。
"0.0.0.0","0.255.255.255","US" "1.0.0.0","1.0.0.255","AU" "1.0.1.0","1.0.3.255","CN" "1.0.4.0","1.0.7.255","AU" "1.0.8.0","1.0.15.255","CN"
示例代码还包含第三方程序集 System.Net.IPNetwork.dll,其中包含 IPNetwork 类。仅当您将示例 Program
类的 Validate bool
成员设置为 true 时,才使用它进行验证。IPNetwork 类在内部使用 BigInteger 来进行 128 位数字计算。通过切换 Validate
的开启和关闭,您可以查看我们使用两个 64 位无符号整数所获得的性能提升。