在Linux环境下,从网站进行爬虫,并通过Netty将爬取的信息发送至另一方;将接收的html文件信息存储至Apache Kafka队列,同时保留html的url信息;再从Apache Kafka队列中读取文本信息以及url信息;将读取到的信息再保存至Redis数据库。
目录
- 1.环境搭建
- 1.1安装JDK
- 1.2安装eclipse
- 1.3安装、打开Kafka服务
- 1.4安装、打开Redis服务
- 1.5导入jar包
- 2.程序使用说明
- 3.总体设计
- 4.详细设计
- 4.1 PNettyServer.java
- 4.2 PNettyClient.java
- 4.3 P02Crawler.java
- 4.4 P03KafkaProducer.java
- 4.5 P04KafkaConsumer.java
- 4.6 P05SaveToRedis.java
- 4.7 P01Main.java
- 5.存在问题
- 6.源码
- 6.1 PNettyServer.java
- 6.2 PNettyClient.java
- 6.3 P02Crawler.java
- 6.4 P03KafkaProducer.java
- 6.5 P04KafkaConsumer.java
- 6.6 P05SaveToRedis.java
- 6.7 P01Main.java
1.环境搭建
1.1安装JDK
- 从官网下载合适的安装包,这里使用的是安装包是jdk-15.0.1_linux-x64_bin.tar.gz
- 解压tar -zxvf jdk-15.0.1_linux-x64_bin.tar.gz
- 设置环境变量:打开文件vim /etc/profile并在最前面添加,其中第一个路径为jdk文件解压后的路径
export JAVA_HOME=/usr/lib/jvm/jdk
export JRE_HOME=${JAVA_HOME}/jre
export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib
export PATH=${JAVA_HOME}/bin:$PATH
- 使用source /etc/profile使修改后的文件生效
1.2安装eclipse
从官网上下载,解压后直接打开里面的执行文件就可以了,这里使用的安装包是eclipse-java-2020-12-R-linux-gtk-x86_64.tar.gz
1.3安装、打开Kafka服务
- 从官网下载安装包,这里使用的安装包是kafka_2.13-2.6.0.tgz
- 解压下载的安装包tar -zxf kafka_2.13-2.6.0.tgz
- 切换到解压后的文件的目录,cd kafka_2.13-2.6.0
- 启动安装包里面自带的zookeeper(如果没有的话)bin/zookeeper-server-start.sh config/zookeeper.properties
- 最后再通过命令bin/kafka-server-start.sh config/server.properties启动Kafka服务
1.4安装、打开Redis服务
- 官网下载安装包,这里使用的是redis-6.0.9.tar.gz并解压
- 切换到解压后的目录
- make
- 完成后使用./src/redis-server ./redis.conf启动Redis服务即可开始使用
1.5导入jar包
- 在maven项目的pom.xml文件中添加以下代码导入kafka及其相关jar包
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<version>1.7.25</version>
</dependency>
- 在项目中新建lib文件,并把jedis-3.3.0.jar和netty-all-4.1.54.Final.jar(官网下载)放入并建立路径来使用redis和netty服务
2.程序使用说明
在程序运行之前需要通过命令行启动zookeeper/kafka/redis服务,也就是实验环境搭建部分的服务启动,然后直接在eclipse中运行即可。而爬虫获得的数据在kafka中保存在名为my_topic的主题,数据在redis数据库中保存的键值为my_key。
3.总体设计
P01Main类为程序入口,负责程序其它类的调用。由于需要通过netty来将P02Crawler爬虫程序爬取的信息传到P03KafkaProducer,且netty client在前者,后netty server在后者,server需要在client之前开启。所以将P02Crawler爬虫程序放在另一个线程,先休眠3s,等待P03里面的server启动完成。P03接收到爬虫类传过来的信息后传到Kafka队列,然后通过P04KafkaConsumer消费Kafka队列里面的信息并返回作为P05SaveToRedis的方法Start的形参并通过该方法保存到redis中。
4.详细设计
4.1 PNettyServer.java
该类为Netty服务端。首先通过构造函数传入目的port端口号,在入口方法Start实例化一个I/O线程组,用于处理业务逻辑;同时还有accepet线程组,用来接受连接。通过服务端启动引导的group方法绑定这两个线程组、channel指定通道类型、option设置TCP连接的缓冲区、handle设置日志级别、childHandle里面的匿名类方法initChannel,通过覆盖此方法获取处理器链、添加新的件处理器。然后利用服务端启动引导bind方法绑定端口并启动服务,返回ChannelFuture对象并阻塞主线程直到服务被关闭,同时还定义一个获取data接收的信息方法,里面直接返回data。
对于添加的服务端内部事件类,继承自ChannelInboundHandlerAdapter类。每当有数据从客户端发送过来时,覆盖的方法channelRead方法会被调用,将接收的信息类型Object转化为ByteBuf类型,再利用toString函数转化为String类型。这里由于接收的数据过长,所以它会自动将数据分为几部分发送,所以这里需要将每次接收的信息连接起来。读完数据后,覆盖的方法channelReadComplete方法会被调用,里面会清除缓冲区。由于这里只需要接收一次信息,所以在接收完成后,关闭I/O线程组、accept线程组,服务结束。
4.2 PNettyClient.java
该类为Netty服务端。通过构造函数传入ip, port, data,分别对应服务端IP地址、端口号、以及要发送给服务端的数据。首先还是通过客户端启动引导绑定I/O线程组,用于处理业务逻辑,通过channel方法指定通道类型、option设置TCP连接的缓冲区、handle设置事件处理类,里面的匿名类含有覆盖的方法initChannel用于获取处理器链并添加新的件处理器。然后利用服务端启动引导bind方法绑定端口、IP并启动服务,返回ChannelFuture对象并阻塞主线程直到服务被关闭。
对于添加的客户端内部处理器类,继承自ChannelInboundHandlerAdapter类。里面含有channelActive覆盖的方法,启动就执行。里面先将String类型的data通过getBytes方法转化为字节类型,再利用Unpooled的wrappedBuffer方法打包,最后再利用ChannelHandlerContext的方法writeAndFlush方法将数据写入通道并刷新。
4.3 P02Crawler.java
该类爬取指定的网站信息并通过Netty传到P03KfkaProducer类。构造函数传入ip, port, url(爬取网站网址),由于爬取的网站信息读取是逐行读取的,对字符串的连接较多,所以这里使用字符串生成器StringBuilder更为节省时间,然后将爬取的网站网址添加在首行。在入口方法Start处,通过网址建立URL对象,利用其方法openConnection打开连接并返回URLConnection对象,再通过该对象的getInputStream方法连接取得网页返回的数据,这里返回InputStream对象。将该对象转化为BufferedReader对象,并指定UTF-8编码,利用while进行按行读取并利用字符串生成器append方法逐行添加,再利用PnettyClient传送数据后依次关闭BufferedStream, InputStream。以上代码放在try-catch语句块中用于处理异常信息。
4.4 P03KafkaProducer.java
该类通过Netty获取P02Crawler发送过来的信息并将其保存到Kafka队列。构造函数形参包括Kafka主题名、端口号,里面通过调用PNettyServer来获取发送过来的数据。在入口方法Start处,新建一个Properties配置文件对象设置bootstrap.servers为localhost:9092用于建立初始连接到kafka集群的"主机/端口对"配置列表(必须);acks 为all,表示Producer在确认一个请求发送完成之前需要收到的反馈信息的数量,0表示不会收到,认为已经完成发送;retries为0,若设置大于0的值,则客户端会将发送失败的记录重新发送(可选);batch.size为16384,控制发送数据批次的大小(可选);linger.ms为1,发送时间间隔,producer会将两个请求发送时间间隔内到达的记录合并到一个单独的批处理请求中(可选);buffer.memory为33554432,Producer用来缓冲等待被发送到服务器的记录的总字节数(可选);key.serializer为org.apache.kafka.common.serialization.StringSerializer,关键字的序列化类(必须);value.serializer为org.apache.kafka.common.serialization.StringSerializer,值的序列化类(必须)。
然后利用以上的配置新建一个Kafka生产者,并利用send方法将数据往Kafka队列指定主题的发送,最后close关闭即可。
4.5 P04KafkaConsumer.java
该类从Kafka队列中读取信息并将该信息返回。通过入口方法Start传入主题名参数,同样的新建一个配置文件对象,bootstrap.servers字段设置为localhost:9092,用于建立初始连接到kafka集群的"主机/端口对"配置列表(必须);group.id为my_group,消费者组id(必须);auto.offset.reset为earliest,自动将偏移量重置为最早的偏移量(可选);enable.auto.commit为true,消费者的偏移量将在后台定期提交(可选);auto.commit.interval.ms为1000,如果将enable.auto.commit设置为true,则消费者偏移量自动提交给Kafka的频率(以毫秒为单位)(可选);key.deserializer为org.apache.kafka.common.serialization.StringDeserializer,关键字的序列化类(必须);value.deserializer 为org.apache.kafka.common.serialization.StringDese
rializer,值的序列化类(必须)。
利用上述配置新建一个Kafka消费者,利用方法subscribe订阅指定名称的主题。再利用poll方法获取队列中的数据集,再利用for结构输出即可,同时close消费者并返回获得的数据。
4.6 P05SaveToRedis.java
该类将传过来的信息保存到Redis。该类的入口方法包含存放数据key值、要保存的数据。创建Jedis对象,连接本地的Redis服务,利用该对象的set方法存放数据,然后利用close关闭Redis服务即可。
4.7 P01Main.java
该方法为该程序的入口,负责各个类方法的调用。程序前面定义一些常量,包括爬取的网址站址、IP地址、端口号、存放数据的Kafka主题名、存放数据的Redis键值。在主方法里面创建该类对象,执行线程start方法,run方法里面休眠3s后再爬虫爬取信息,保证P03KafkaProducer类里面的Netty服务端启动完成,再通过P04KafkaConsumer的Start方法获取发送过来的信息,并将其作为P05SveToRedis类Start方法的参数之一保存到Redis。
5.存在问题
每次重新启动各项相关服务后运行的第1、2或者第1次运行都会出现从Kafka队列中读取的数据是空的,之后的运行就完全没问题了。虽然已经知道是消费者的问题,但目前仍然没有找到解决办法。
6.源码
6.1 PNettyServer.java
/**
* Netty服务端类
*
* @author zhz
*/
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import java.nio.charset.Charset;
public class PNettyServer {
ServerBootstrap serverBootstrap;// 服务端启动引导
int port;// 端口号
String data = "";// 存放接收自客户端的数据
EventLoopGroup bossGroup;// accept线程组,用来接受连接
EventLoopGroup workerGroup;// I/O线程组, 用于处理业务逻辑
public PNettyServer(int port) {
this.port = port;
}
// 入口方法
public void Start() {
bossGroup = new NioEventLoopGroup(1);// accept线程组,用来接受连接
workerGroup = new NioEventLoopGroup(1);// I/O线程组, 用于处理业务逻辑
try {
serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup)// 绑定两个线程组
.channel(NioServerSocketChannel.class)// 指定通道类型
.option(ChannelOption.SO_BACKLOG, 100)// 设置TCP连接的缓冲区
.handler(new LoggingHandler(LogLevel.INFO))// 设置日志级别
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline channelpipeline = socketChannel.pipeline();// 获取处理器链
channelpipeline.addLast(new NettyServerHandler());// 添加新的件处理器
}
});
ChannelFuture channelFuture = serverBootstrap.bind(port).sync();// 通过bind启动服务
channelFuture.channel().closeFuture().sync();// 阻塞主线程,直到网络服务被关闭
} catch (Exception e) {
e.printStackTrace();
} finally {
workerGroup.shutdownGracefully();// 关闭I/O线程组
bossGroup.shutdownGracefully();// 关闭accept线程祖,服务端关闭
}
}
// 获取dataSB
public String GetData() {
return data;
}
// 内部事件处理类
class NettyServerHandler extends ChannelInboundHandlerAdapter {
// 每当从客户端收到新的数据时,这个方法会在收到消息时被调用
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
data = data + ((ByteBuf) msg).toString(Charset.defaultCharset());
}
// 数据读取完后被调用
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.flush();// 清理缓冲区
workerGroup.shutdownGracefully();// 关闭I/O线程组
bossGroup.shutdownGracefully();// 关闭accept线程祖,服务端关闭
}
}
}
6.2 PNettyClient.java
/**
* Netty客户端类
*
* @author zhz
*/
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
public class PNettyClient {
EventLoopGroup eventLoopGroup;// I/O线程组, 用于处理业务逻辑
String data;// 要发送给服务端的消息
String ip;// 服务端ip地址
int port;// 服务端端口号
public PNettyClient(String ip, int port, String data) {
this.ip = ip;
this.port = port;
this.data = data;
}
// 入口方法
public void Start() {
eventLoopGroup = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();// 客户端启动引导
bootstrap.group(eventLoopGroup)// 绑定线程组
.channel(NioSocketChannel.class)// 指定通道类型
.option(ChannelOption.TCP_NODELAY, true)// 设置TCP连接的缓冲区
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline channelpipeline = socketChannel.pipeline();// 获取处理器链
channelpipeline.addLast(new NettyClientHandler());// 添加新的件处理器
}
});
ChannelFuture channelFuture = bootstrap.connect(ip, port).sync();// 通过bind启动客户端
channelFuture.channel().closeFuture().sync();// 阻塞在主线程,直到发送信息结束
} catch (Exception e) {
e.printStackTrace();
} finally {
eventLoopGroup.shutdownGracefully();// 关闭I/O线程组
}
}
// 内部事件处理类
class NettyClientHandler extends ChannelInboundHandlerAdapter {
// 启动就发送数据到服务端
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.wrappedBuffer(data.getBytes()));
}
}
}
6.3 P02Crawler.java
/**
* 爬取网站信息并通过Netty传到P03KafkaProducer
*
* @author zhz
*/
import java.io.*;
import java.net.*;
public class P02Crawler {
String url;// 爬取的网站网址
String dataLine;// 爬取的每一行的数据
StringBuilder dataSB;// 网址+返回的数据(使用字符串生成器,由于需要频繁附加字符串)
PNettyClient pNettyClient;// 定义Netty客户端对象
String ip;// ip地址
int port;// 端口号
public P02Crawler(String ip, int port, String url) {
this.ip = ip;
this.port = port;
this.url = url;
dataSB = new StringBuilder(url + '\n');
}
// 入口方法
public void Start() {
try {
URL urlObject = new URL(url);// 建立URL对象
URLConnection conn = urlObject.openConnection();// 通过URL对象打开连接
InputStream is = conn.getInputStream();// 通过连接取得网页返回的数据
BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));// 一般按行读取网页数据,因此用BufferedReader和InputStreamReader把字节流转化为字符流的缓冲流
while ((dataLine = br.readLine()) != null) {// 按行读取
dataSB.append(dataLine + '\n');
}
pNettyClient = new PNettyClient(ip, port, dataSB.toString());
pNettyClient.Start();// 通过Netty客户端将数据发送到P03KafkaProducer
br.close();// 关闭BufferedReader
is.close();// 关闭InputStream
} catch (Exception e) {
e.printStackTrace();
}
}
}
6.4 P03KafkaProducer.java
/**
* 通过Netty获取Crawler发送过来的信息并将其保存到Kafka队列
*
* @author zhz
*/
import java.util.Properties;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;
public class P03KafkaProducer {
PNettyServer pNettyServer;// 服务端Netty对象
String topic;// 主题名
String data;// 存放到Kafka的数据
int port;// 端口号
public P03KafkaProducer(String topic, int port) {
this.topic = topic;
this.port = port;
pNettyServer = new PNettyServer(port);
pNettyServer.Start();
data = pNettyServer.GetData();
}
// 入口方法
public void Start() {
try {
Properties props = new Properties();// 新建一个配置文件对象
props.put("bootstrap.servers", "localhost:9092");// 用于建立初始连接到kafka集群的"主机/端口对"配置列表(必须)
props.put("acks", "all");// Producer在确认一个请求发送完成之前需要收到的反馈信息的数量,0表示不会收到,认为已经完成发送(可选)
props.put("retries", 0);// 若设置大于0的值,则客户端会将发送失败的记录重新发送(可选)
props.put("batch.size", 16384);// 控制发送数据批次的大小(可选)
props.put("linger.ms", 1);// 发送时间间隔,producer会将两个请求发送时间间隔内到达的记录合并到一个单独的批处理请求中(可选)
props.put("buffer.memory", 33554432);// Producer用来缓冲等待被发送到服务器的记录的总字节数(可选)
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");// 关键字的序列化类(必须)
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");// 值的序列化类(必须)
Producer<String, String> producer = new KafkaProducer<>(props);// 新建一个以上述定义的配置的生产者
producer.send(new ProducerRecord<String, String>(topic, data));// 将数据传到Kafka队列的url主题
producer.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
6.5 P04KafkaConsumer.java
/**
* 从Kafka队列中读取信息并将该信息返回
*
* @author zhz
*/
import java.util.Collections;
import java.util.Properties;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
public class P04KafkaConsumer {
private String data;// 从kafka队列获得的数据
// 入口方法
public String Start(String topic) {
Properties props = new Properties();// 新建一个配置文件对象
props.put("bootstrap.servers", "localhost:9092");// 用于建立初始连接到kafka集群的"主机/端口对"配置列表(必须)
props.put("group.id", "my_group");// 消费者组id(必须)
props.put("auto.offset.reset", "earliest");// 自动将偏移量重置为最早的偏移量(可选)
props.put("enable.auto.commit", "true");// 消费者的偏移量将在后台定期提交(可选)
props.put("auto.commit.interval.ms", "1000");// 如果将enable.auto.commit设置为true,则消费者偏移量自动提交给Kafka的频率(以毫秒为单位)(可选)
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");// 关键字的序列化类(必须)
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");// 值的序列化类(必须)
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);// 新建一个以上述定义的配置的消费者
consumer.subscribe(Collections.singletonList(topic));// 订阅消息主题url
ConsumerRecords<String, String> records = consumer.poll(100);
consumer.close();
for (ConsumerRecord<String, String> record : records) {
data = record.value();
}
return data;
}
}
6.6 P05SaveToRedis.java
/**
* 将传过来的信息保存到Redis
*
* @author zhz
*/
import redis.clients.jedis.Jedis;
public class P05SaveToRedis {
public void Start(String key, String data) {
try {
Jedis jedis = new Jedis("localhost");// 创建Jedis对象:连接本地的Redis服务
jedis.set(key, data);// 存放数据 key value
jedis.close();// 关闭Redis服务
} catch (Exception e) {
e.printStackTrace();
}
}
}
6.7 P01Main.java
/**
* Netty库的使用: 从不同的网站进行爬虫(可以是预先设定的也可以是动态爬虫); 简单起见可以只爬取英文网站; 将html文件信息存储至Apache
* Kafka队列,同时保留html的url信息; 从Apache Kafka队列中读取文本信息以及url信息;
* 将读取到的信息再保存至Redis(内存数据库,注意字段划分); 该文件为程序入口。
*
* @author zhz
*/
public class P01Main implements Runnable {
final static String URL = "https://docs.oracle.com/javase/7/docs/api/overview-summary.html";// 爬取的网站网址(Java第7版API文档)
final static String IP = "127.0.0.1";// ip地址
final static int PORT = 8080;// 端口号
final static String KAFKA_TOPIC = "my_topic";// 存放数据的kafka主题名
final static String REDIS_KEY = "my_key";// 存放数据的redis键值
@Override
public void run() {
try {
Thread.sleep(3000);
new P02Crawler(IP, PORT, URL).Start();// 爬取网站信息并通过Netty传到P03KafkaProducer
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
// 需要提前通过终端输入以下命令打开Zookeeper、Kafka、Redis服务端
// /home/mpimpi/计算机网络管理/kafka_2.13-2.6.0/bin/zookeeper-server-start.sh /home/mpimpi/计算机网络管理/kafka_2.13-2.6.0/config/zookeeper.properties
// /home/mpimpi/计算机网络管理/kafka_2.13-2.6.0/bin/kafka-server-start.sh /home/mpimpi/计算机网络管理/kafka_2.13-2.6.0/config/server.properties
// /home/mpimpi/计算机网络管理/redis-6.0.9/src/redis-server /home/mpimpi/计算机网络管理/redis-6.0.9/redis.conf
// 删除、读取kafka中名为my_topic的主题内容
// /home/mpimpi/计算机网络管理/kafka_2.13-2.6.0/bin/kafka-topics.sh --delete --zookeeper localhost:2181 --topic my_topic
// /home/mpimpi/计算机网络管理/kafka_2.13-2.6.0/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic my_topic --from-beginning
// 启动redis-cli、获取/删除键值为my_key的内容
// /home/mpimpi/计算机网络管理/redis-6.0.9/src/redis-cli
// get my_key
// del my_key
P01Main p01Main = new P01Main();
new Thread(p01Main).start();// 该线程休眠3s保证接收信息的服务端已启动并已做好接收来自客户端的信息的准备
new P03KafkaProducer(KAFKA_TOPIC, PORT).Start();// 通过Netty获取Crawler发送过来的信息并将其保存到Kafka
new P05SaveToRedis().Start(REDIS_KEY, new P04KafkaConsumer().Start(KAFKA_TOPIC));// 从Kafka队列中读取信息并将该信息返回,同时将返回的信息传到Redis
}
}