前言: 前段时间听过了纯真ip数据库,只知道是一个qqwry.dat文件,里面有一些网友收集的数据,可以用来查询ip的大致位置,即ip定位。正好,我最近在一个项目里面看到了这个数据库,它就是将qqwry.dat集成到springboot项目里面,做成一个查询服务来使用的。所以,我也想来试试,但是我不是直接使用qqwry.dat这个文件。我换一种方式,我才用将qqwry.dat中的ip数据导入mysql数据库中,通过数据库的查询来提供ip位置查询功能。
gitee代码地址: 纯真ip库服务搭建
基本介绍
纯真ip数据库
纯真数据库收集了包括中国电信、中国移动、中国联通、长城宽带、聚友宽带等 ISP 的 IP 地址数据,包括网吧数据。希望能够通过大家的共同努力打造一个没有未知数据,没有错误数据的QQ IP。IP数据库每5天更新快速,请大家定期更新IP数据库!
因为IP地址数据是民间收集的,各运营商也会不时的更改IP段,所以有点遗漏、错误是难免的。随数据库附送IP解压,查询软件。假如发现IP地址有不对的,或想提供新的IP地址,请到纯真IP小秘书提供IP地址数据,或者登陆纯真时空论坛“电脑板块”中的“代理及IP板”块告诉我们。以便及时更新IP数据库。谢谢!
这个数据库目前2020.12月版本的含有52万+条ip数据。包括了0.0.0.0-255.255.255.255的所有ip,但是我们都知道ipv4地址总数是:2^32,大概42亿多条。所以,这里的52多万条只是一部分,它是按照ip地址段来区分的,可以这样来理解:把0.0.0.0-255.255.255.255想象成坐标轴上一个长度为42亿的区间,然后分成52万份(ip段),每一份属于一个地区。 它的作用就算是这个,通过ip知道此ip属于哪个地区(当然了,精度不是太高,不适合于精确定位。)
注意:
1.一个ip段是连续的,包括一个ip段起始地址和一个ip段结束地址。
2.纯真数据库对于国外的ip地址,只能定位到国家,对于国内的ip地址是可以有省市县的,毕竟主要收集的还是国内的地址。
关于ip定位的精度:
因为ip地址(发达国家就占据了大部分了)不够用,所以它实际上是动态分配的,所以一个人的ip一直都会变化的,但是在一段时间内是不变的(政府、学校、公司等机构会具有固定的ip,个人一般没有这个权力),所以定位精度不是很好,但是如果你在国内发现某个人的ip地址在国外,这并不是数据出了问题,而是因为他的ip地址基本就是在国外。但是,ip地址的分配记录是保存下来的,如果有法律权限是可以查看的,因此上网需要规范言行,互联网不是法外之地,举头三尺有神明!
如何使用
我们的ip地址必在0.0.0.0-255.255.255.255之间(这里不考虑ipv6的地址),所以只要判断某个ip是否在ipstart和ipend之间就行了(即一个ip段内),就知道它属于哪里了。
sql查询方式即为:
注:INET_ATON函数 会将一个有效的ip地址转成整数,所以不用自己手动转换ip地址了,直接传递点分式ip地址就行了。
但是不推荐这样使用,前面那种方式是直接将ip转为整数存入数据库,下面这种方式是直接存入ip的点分式,然后查询时转换。这样的话,速度非常慢,因为它相当于要对每一个ip地址做转换才能查询,这是错误的使用方式。
数据准备
首先,需要准备qqwry.dat这个文件,因为数据在它里面。但是,我发现了一个更好用的软件,这里就不直接使用这个文件了。
注意:鉴于网络上面资源下载容易踩坑,这里推荐到这个网站去下载,点击页面中的下载即可。如果有能力的话,也可以选择捐助作者。
下载完成之后,解压安装这个软件,就可以使用ip查询了。但是它是一个桌面软件,我们这里的目的是做成web服务,即像这个网站一样的查询服务。这样的ip查询服务有很多,但是如果使用量大的话,自己搭建一个还是非常好的,成本低、可靠!并且,这个数据库是一直在更新的,而且更新频率也还高,说明它的效果还是很好的。
测试一下,结果同上面的web页面
这里我们不直接使用这个软件,而是使用它的一个功能,在主界面上有一个解压按钮,点击将ip数据导出成文本(我这里命名成qqwry.txt)。
qqwry.txt的内容格式如下
因为这个qqwry.txt的格式,直接在数据库中查询不方便,所以需要处理一下,它目前的格式是:4列(ip段起始地址 ip段结束地址 area地区 location位置)。
如果直接将它们存入数据库中,那么查询就会变得麻烦,而且基本上也无法使用索引了,对于性能影响很大。这里我们采用网上别人的方式,将ip起始地址和ip结束地址转成数字。
所以需要先使用程序对上面的文本数据进行处理一下,我看别人的博客处理数据都是基于php的,但是我并不会世界上最好的语言,所以我就采用java来处理了。
注意:由于java语言没有无符号数的概念,所以ip地址并不能转成java的int型。ipv4地址是32位,int型也是32位,但是java的int中第一位是符号位,所以实际可以表示的大小是其它语言中int的一半。这也导致了,如果将ip地址转换为int的话,会有许多数据溢出,变成负数,所以这里需要将ip地址转成long型。
数据处理
数据处理代码
package re;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.stream.Collectors;
public class IPRecord {
private static final String regex = "\\s+";
private static final int limit = 4; // split方法的这个参数的作用很值得深入学习一下
private static final String ipRegex = "\\.";
private static class Record {
String ipStartStr;
String ipEndStr;
long ipStart;
long ipEnd;
String area;
String location;
public Record(String[] record) {
this.ipStartStr = record[0];
this.ipEndStr = record[1];
this.ipStart = ip2Long(record[0]); // 将ip地址字符串转换成long
this.ipEnd = ip2Long(record[1]); // 将ip地址字符串转换成long
this.area = record[2];
this.location = record[3];
}
@Override
public String toString() {
return ipStart + "," + ipEnd + "," + area + "," + location + "," + ipStartStr + "," + ipEndStr; // 转成csv格式
}
public String toRedis() {
return "ZADD\tqqwry\t" + ipEnd + "\t" + "{\"ipstart\":" + ipStart
+ ",\"ipend\":" + ipEnd
+ ",\"area\":" + "\"" + area + "\""
+ ",\"location\":" + "\"" + location + "\""
+ ",\"ipstartstr\":" + "\"" + ipStartStr + "\""
+ ",\"ipstartend\":" + "\"" + ipEndStr
+ "\"}";
}
}
public static void main(String[] args) throws IOException {
// qqwry.tx 的文件路径
Path path = Paths.get("C:/Users/Alfred/Desktop/data/test/qqwry.txt");
// 使用指定字符集(UTF-8)读取文件
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
// 输出测试,注意不要省略 limit,因为总共有 52万+行数据,直接输出的话,压力比较大。
// lines.stream().map(IPRecord::split).limit(10).map(Record::toJson).forEach(System.out::println);
// 将文件转为csv文件存放
long start = System.currentTimeMillis();
lines = lines.stream()
.map(IPRecord::split)
.map(Record::toString)
.limit(528129) // 有效数据行数,剩下的是一些空行和最后的统计信息
.collect(Collectors.toList());
// String csvHeader = "ipstart,ipend, area, location, ipstartstr, ipendstr\n";
// Files.write(Paths.get("C:/Users/Alfred/Desktop/data/test/qqwry.csv"), csvHeader.getBytes(), StandardOpenOption.CREATE); //写入csv文件的头部
Files.write(Paths.get("C:/Users/Alfred/Desktop/data/test/qqwry_redis.csv"), lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE); //写入csv文件的主体部分
System.out.println("Execute Success! Time: " + (System.currentTimeMillis()-start) + " ms");
}
public static Record split(String record) {
return new Record(record.split(regex, limit));
}
/**
* int类型是32位的,但是无法直接将ip和int类型进行转换,因为可能会溢出。
* int类型是有符号的,除非这里有无符号整型,但是Java是没有这个的,所以
* 这里需要转换成long型。
* */
public static long ip2Long(String ip) {
String[] strs = ip.split(ipRegex);
int[] bs = new int[4];
for (int i = 0; i < 4; i++) {
bs[i] = Integer.parseInt(strs[i]); // Byte.parseByte(strs[i]); 这个方法转成的字节范围是 -128-127,真是太坑了!
}
return ((bs[3] & 0xFFL) ) + // 这个转换方法是从jdk源码里面偷来的,挺好用的!
((bs[2] & 0xFFL) << 8) +
((bs[1] & 0xFFL) << 16) +
((bs[0] & 0xFFL) << 24);
}
}
// 默认的split(regex)方法会以split(regex,0)调用
// 当limit N 大于0时,会匹配N-1次,数组长度最大为N,返回数组的最后一个元素会把数组最后的所有字符包括进来
// 当limit N 小于0时,会匹配尽可能多次,返回最大长度的数组
// 当limit N 等于0时,会匹配尽可能多次,返回最大长度的数组,并且末尾的空串不会包含进来
//推荐使用正则表达式,速度飞起
//String record = "0.0.0.0 0.255.255.255 IANA 保留地址";
//String regex = "\\s+";
//long start = System.currentTimeMillis();
//for (String s : record.split(regex)) {
// System.out.println(s);
//}
//System.out.println(System.currentTimeMillis()-start);
//这种方式太慢了,简直难以忍受!
//List<String> strList = new ArrayList<>();
//boolean flag = true;
//StringBuilder sb = new StringBuilder();
//
//char[] chs = record.toCharArray();
//for (int i = 0; i < chs.length; i++) {
// if (chs[i] != ' ') {
// sb.append(chs[i]);
// if (i == chs.length - 1) {
// strList.add(sb.toString());
// break;
// }
// flag = true;
// } else {
// if (flag) {
// strList.add(sb.toString());
// sb.delete(0, sb.length());
// }
// flag = false;
// }
//}
//strList.forEach(System.out::println);
注意: toString方法是转成符合数据库导入格式的数据,toRedis是转成符号redis导入的格式(我不太确定这种方式的效率,并且我最终没有采用这种方法)。
说明:
1.这里的处理逻辑很简单,就是将文件分行读入内存,然后对每一行切分成4块,然后调用编写的ip2Long方法进行处理即可。
2.如果可以使用正则表达式,尽量用正则表达式,特别是这种数据处理,速度差距非常之大!
3.这个ip2Long方法,是我从jdk的ObjectInpustream里面拿来的,随便修改了一下,原来的代码使用的是byte,但是这里如果传入一个尝试把一个大于127的数转成int会抛出异常,所以使用int数组代替了byte数组,并且由于我这里只有32位,所以我就删除了后32位的处理代码。
执行结果:
这个notepad++打开大文件感觉还行,40多m也能流畅使用。我这里使用excel打开就比较慢了,而且中文乱码了,就不放截图了。如果使用excel打开的话和普通的excel文件显示没有什么区别。我这里转成csv的原因是:
a).csv文件本身就是文本格式,使用程序直接输出即可。
b).我使用的sqlyog社区版无法导入excel数据,只能导入csv格式的数据。
csv格式数据
redis格式的数据,直接就是一条命令,但是它总共是52万+的数据,想必一条一条的执行,时间上也会接受不了,但是这个给我了启发,我后来在redis中使用了这种格式来存储值(json数据)。
redis格式数据(没有使用)
数据入库
因为我使用的是sqlyog,数据导入有一点麻烦,如果使用其它的数据库客户端应该会简单一些。
建库建表
这个建表语句参考了别人的博客,做了一些修改(添加了原始的ip起始地址和结束地址、修改了表的字符编码)
CREATE TABLE `ip_data` (
`ipstart` INT(10) UNSIGNED NOT NULL, # 开始ip地址
`ipend` INT(10) UNSIGNED NOT NULL, # 结束ip地址
`area` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, # 区域
`location` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, # 位置
`ipstartstr` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, # 开始ip地址的点分表示方式
`ipendstr` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, # 结束ip地址的点分表示方式
PRIMARY KEY (`ipstart`),
INDEX `ip` (`ipstart`, `ipend`) USING BTREE
)
ENGINE=INNODB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci;
导入数据
我直接使用sqlyog的导入csv,但是没有成功。它的图形界面导入功能感觉就很鸡肋,我就没成功过,最后没办法,从网上查找到了如下命令,然后导入了csv文件,速度还行!
LOAD DATA LOCAL INFILE "C:/Users/Alfred/Desktop/data/test/qqwry.csv"
INTO TABLE `poem_land`.`qqwry_ip` FIELDS TERMINATED BY ',' LINES TERMINATED BY '\r\n';
数据查询
数据行数量
简单查看几行数据
查询数据
好了,还算不错!
数据存入redis
关系型数据库的查询效果可能不够理想,这里使用redis这个nosql来提高查询性能,首先要做的还是数据导入。我发现这个数据导入真的是麻烦哎,昨天这个东西搞了我好久,当然也因为我对redis并不熟悉。
上面说了,我没有采用那种一条一条执行命令的方式,而是使用了一种更加快速的方式——redis的管道技术。网上看了好多资料才搞成,第一次听说这个东西,效果确实很好!这里我们要做的是使用redis的管道技术将mysql数据导入到redis。
导入数据命令
sudo mysql -uroot -p'password' poem_land --skip-column-names --raw < data.sql | ./redis-4.0.11/src/redis-cli -h host -p port -a password --pipe
参数:
password:mysql数据库密码、redis密码
host:redis所在主机
port:redis所用端口
data.sql:一个sql文件,执行它会将数据库数据转成适合于redis导入的形式。
data.sql 文件
注意: redis_member 就是存入redis的值,这里我采用数据库的CONCAT函数把所有列拼成了一个json字符串的形式,这样的话查询就可以获取到全部数据了,我是第一次这样用,不知道其它更好的方式(不过,导入时间却耗费了好几分钟,估计是拼接字符串影响了性能)。
SELECT CONCAT(
'*4\r\n',
'$',LENGTH(redis_cmd),'\r\n',redis_cmd,'\r\n',
'$',LENGTH(redis_key),'\r\n',redis_key,'\r\n',
'$',LENGTH(redis_score),'\r\n',redis_score,'\r\n',
'$',LENGTH(redis_member),'\r\n',redis_member,'\r'
) FROM (
SELECT 'ZADD' AS redis_cmd,
'qqwry' AS redis_key,
ipend AS redis_score,
CONCAT('{"ipstart":', ipstart, ',"ipend":', ipend,
',"area":"', `area`, '","location":"', location, '"}') AS redis_member
FROM `poem_land`.`qqwry_ip`
) AS NAME
导入结果
查询数据
Springboot结合使用
我最终的目的,还是要提供一个查询的接口来使用。现在就将它作为服务提供了,让我们开始吧!
控制器代码
这里我使用的是mybatis_plus和它的生成器,所以代码是自动生成的,基本上只需要看这个Controller的代码就行了。
package org.dragon.ip_seek.controller;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import org.dragon.ip_seek.entity.QqwryIp;
import org.dragon.ip_seek.service.IQqwryIpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* <p>
* 前端控制器
* </p>
*
* @author Alfred
* @since 2020-12-08
*/
@RestController
@RequestMapping("/seeker/")
public class QqwryIpController {
private static final Logger logger = LoggerFactory.getLogger(QqwryIpController.class);
private static final String ipRegex = "\\."; // 匹配ip的正则表达式
@Autowired
private IQqwryIpService iqqwryIpService; // mysql 服务调用
@Autowired
private RedisTemplate<String, String> redisTemplate; // redis 服务调用
@GetMapping(value = "/poem1", produces = MediaType.APPLICATION_JSON_VALUE)
public String test01(HttpServletRequest request) {
// 本地局域网测试,ip地址是内网地址,无法使用,这里我先传递一个模拟的ip地址。
String host = request.getRemoteHost();
logger.info("当前用户的ip地址为:{}", host);
Map<String, String> ipInfo = iqqwryIpService.getIpInfo("60.174.231.10");
ipInfo.forEach((k, v) -> {
logger.info("K:{} V:{}", k, v);
});
return "{\"poem\": \"一日不见兮,思之如狂。\"}";
}
@GetMapping(value = "/poem_mysql", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> test02(HttpServletRequest request) {
String host = request.getRemoteHost();
// 本地局域网测试,ip地址是内网地址,无法使用,这里我先传递一个模拟的ip地址。
// 如果部署到云服务器上,去掉此行即可。
host = "60.174.231.10";
LambdaQueryWrapper<QqwryIp> wrapper = Wrappers.lambdaQuery();
// 忘记了,eclipse自己的编译器不支持这个 SFunction接口
wrapper.select(QqwryIp::getArea, QqwryIp::getLocation)
.apply("INET_ATON({0}) between ipstart and ipend", host);
Map<String, Object> info = iqqwryIpService.getMap(wrapper);
Map<String, Object> ipInfoMap = new java.util.LinkedHashMap<>(); // 我想要指定map序列化的键值对顺序为:ip area location
ipInfoMap.put("ip", host);
if (Objects.nonNull(info)) {
ipInfoMap.putAll(info);
}
logger.info("通过mysql查询 ip: {}", ipInfoMap.get("ip"));
return ipInfoMap; // "{\"poem\": \"一日不见兮,思之如狂。\"}";
}
/**
* 采用 Redis 缓存的方式进行服务调用
* @param <T>
* */
@GetMapping(value = "/poem_redis", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> test03(HttpServletRequest request) {
String host = request.getRemoteHost();
host = "116.62.188.176"; // 本来想测试一个错误数据的:256.256.266.255,结果long都溢出了,直接变成了0
// 从redis中查询数据
Set<String> resultSet = redisTemplate.opsForZSet()
.rangeByScore("qqwry", ip2Long(host), Double.MAX_VALUE, 0, 1);
logger.info("通过reids查询 ip: {}", host);
return transfer(resultSet, host);
}
/**
* 对redis查询数据做一个转换,使之格式更加友好
* */
@SuppressWarnings("unchecked")
private Map<String, Object> transfer(Set<String> resultSet, String host) {
Map<String, Object> ipInfoMap = new java.util.LinkedHashMap<>(); // 我想要指定map序列化的键值对顺序为:ip area location
ipInfoMap.put("ip", host);
if (resultSet.isEmpty()) {
ipInfoMap.put("area", "NAN");
ipInfoMap.put("location", "NAN");
return ipInfoMap;
}
String result = resultSet.toArray(new String[0])[0];
Map<String, Object> info = null;
ObjectMapper mapper = new ObjectMapper();
try {
info = mapper.readValue(result, Map.class); // 这里泛型会被擦除,直接赋值会报编译器警告,但是我也不知道Class<T>这种参数该如何传递了,就先抑制一下编译器警告吧
if (Objects.nonNull(info)) {
ipInfoMap.putAll(info);
}
} catch (JsonMappingException e) {
e.printStackTrace();
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return ipInfoMap;
}
private long ip2Long(String ip) {
String[] strs = ip.split(ipRegex);
int[] bs = new int[4];
for (int i = 0; i < 4; i++) {
bs[i] = Integer.parseInt(strs[i]); // Byte.parseByte(strs[i]); 这个方法转成的字节范围是 -128-127,真是太坑了!
}
return ((bs[3] & 0xFFL) ) + // 这个转换方法是从jdk源码里面偷来的,挺好用的!
((bs[2] & 0xFFL) << 8) +
((bs[1] & 0xFFL) << 16) +
((bs[0] & 0xFFL) << 24);
}
}
说明:
- test01是传统的mybatis的写法,后来发现mybatis_plus可以使用更加简单的方法,就换成了test02,但是保留了test01。
- test03是从redis中进行数据查询。
效果演示
启动项目
test01——mysql
这里查询的结果,我是使用在日志上了,本来是打算在后台使用的,但是暂时也没啥想法,就算了。
test02——mysql
test03——redis
注意:因为我本地测试,它获取的是本地的ip地址(127.0.0.1),所以我就在后台硬编码了一个公网ip地址来查询。
PS
我最近看到了一个基于java的开源软件——JMeter。它可以用来做压力测试,但是我并不会。但是,这并不妨碍我来玩一玩!
下面的内容仅供参考,我只是玩一玩!
步骤:
启动JMeter,然后创建一个线程组(1000个线程,50s,循环两次),添加一个Http请求,添加一个查看结果树,添加一个聚合报告,启动。
接口:poem_mysql
接口:poem_redis
说明
这里是两千个请求,从聚合报告里面看似乎redis确实性能是远远超过了mysql(我听别人说也是这样的),但是由于这个测试不严谨,我也不会JMeter,所以就不作为一个有效的参考了。
参数资源:
纯真数据库查询
查询网ip查询
通过管道传输快速将MySQL的数据导入Redis
使用redis有序集合搭建自有ip定位解析库
将mysql表数据批量导入redis zset结构中
IPLook解析纯真数据库,我没有使用这个软件,因为我是手动解析数据
mysql快速导入大量数据问题
一个简单的方式,但是有效率问题
ip location介绍
redis的简单配置
jackson json map object 之间互相转换