分布式、消息队列,中间件的大趋势需要我们对网络编程的理解更加的深厚。那么我们知道如果需要实现在网络上的通讯那么肯定需要连接然后发送数据。那么我们在需要访问服务器的时候是通过ip地址加端口号来进行访问的,如果使用的是域名来进行访问的话是通过DNS来解析域名实现连接。而在程序中socket使用的协议分为TCP和UDP协议。
TCP协议:面向连接的协议,可靠的协议,需要三次握手才可以通讯(在HTTP底层使用的协议)
UDP协议:不面向连接的协议,不可靠协议,容易丢包,但是效率高。(当在需要使用到聊天的时候可以使用,提高效率)
在网络工程中分为应用层使用http协议,在传输层使用TCP协议,在网路层使用IP协议,数据链路层使用的是以太网协议。
对TCP的深度理解:
三次握手的理解:当客户端给服务器发送请求时,算第一次握手这时客户端会个服务器发送一个报文SYN给服务器;当服务器收到该报文时会给客户端也发送发送一个报文ACK给客户端这时算第二次握手;当客户端收到该报文时,就会在给服务器发送一个报文ACK,当服务器接受到该报文时,客户端开始传输数据。
四次挥手(也叫四次分手):当需要停止连接时,因为TCP是全双工的需要每个方向都进行关闭。第一次挥手是在当用户确认想服务端断开连接,给服务器发送一个报文FIN;第二次挥手是在服务端收到该停止报文FIN后发回给客户端一个确认报文ACK;第三次挥手是客户端停止对客户端的连接,然后发送报文FIN给客户端;第四次挥手是客户端为了确认是否关闭给服务端发送ACK报文。(第四次的原因是为了防止当服务端发送报文客户端没有收到的情况)
在编程的角度看TCP和UDP协议:(以TCP编程为例)
服务端:
class TcpServer {
public static void main(String[] args) throws IOException {
System.out.println("socket tcp服务器端启动....");
ServerSocket serverSocket = new ServerSocket(8080);
// 等待客户端请求
Socket accept = serverSocket.accept();
InputStream inputStream = accept.getInputStream();
// 转换成string类型
byte[] buf = new byte[1024];
int len = inputStream.read(buf);
String str = new String(buf, 0, len);
System.out.println("服务器接受客户端内容:" + str);
serverSocket.close();
}
}
客户端:
public class TcpClient {
public static void main(String[] args) throws UnknownHostException, IOException {
System.out.println("socket tcp 客户端启动....");
Socket socket = new Socket("127.0.0.1", 8080);
OutputStream outputStream = socket.getOutputStream();
outputStream.write("helloworld".getBytes());
socket.close();
}
}
在编码的时候,其实可以试一试不写客户端,用浏览器去访问127.0.0.1:8080看看结果是什么会发现是可以访问的并且发送了一堆请求头参数,这是可以说明底层HTTP协议是使用了TCP协议的(当你重复的去开启一个服务端的时候会发生报错,由于涉及到长连接和短连接的问题,建议关闭后重新开启)
传统处理IO的方式使使用了IO流的方式,术语就是使用了BIO同步阻塞的IO流,对于在网路传输的过程中,也就是说如果需要去操作文件就需要在程序中去创建一个输入流去读取文件,需要写数据到文件中是就需要使用到输出流去写到指定文件。明显在传统的IO流的操作是对流进行操作,并且是同步的操作因为流的操作都是单向的操作,而且在进行操作流的时候是只允许单操作(即单方向只读,只写),当没有可以读的数据时就开始等待一直到有数据可读为主,这时就体现了阻塞。
为了使效率的提高,在1.4的时候就提出了NIO同步非阻塞的IO流 ,NIO在操作时是采用了通道和缓冲区来实现对IO的操作,通道Channel用于传输,缓冲区用于存储数据,相当于在操作缓冲区。而在该体系中缓冲区是双向的,从程序中出去后可以回到程序中来可以实现重用。为什么说是非阻塞的呢,因为当线程在写入数据到缓冲区的时候,其他的线程是可以做其他的操作的,在NIO中还有一个选择器,可以对多个通道进行监听。
对NIO操作的缓冲区的理解:缓冲区的类型除了布尔型外都有,但是一般使用byteBuffer,缓冲区的相关参数有:容器capacity,限制limit,位置position,标记mark,重置reset。
public class Test004 {
public static void main(String[] args) {
// 1.指定缓冲区大小1024
ByteBuffer buf = ByteBuffer.allocate(1024);
System.out.println("--------------------");
System.out.println(buf.position());
System.out.println(buf.limit());
System.out.println(buf.capacity());
// 2.向缓冲区存放5个数据
buf.put("abcd1".getBytes());
System.out.println("--------------------");
System.out.println(buf.position());
System.out.println(buf.limit());
System.out.println(buf.capacity());
// 3.开启读模式
buf.flip();
System.out.println("----------开启读模式...----------");
System.out.println("----------重复读模式...----------");
// 4.开启重复读模式
buf.rewind();
System.out.println(new String(bytes2, 0, bytes2.length));
// 5.clean 清空缓冲区 数据依然存在,只不过数据被遗忘
System.out.println("----------清空缓冲区...----------");
buf.clear();
}
}
注意:容器capacity相当于是数组最大上限,limit如果没有其他的特殊限制的话一般也指向容器最大,但是当开启读模式limit就指向存入缓冲区数据的长度。而mark和reset一个是用与标记,一个是用于重置,也就是将指针重置到标记位置
上面提及到了NIO在使用缓冲区的时候是直接使用了直接缓冲区,那么来了解下直接缓冲区和非直接缓冲区的关系和区别
非直接缓冲区是建立在java虚拟机上的,通过 allocate() 方法分配缓冲区。由图中可以看出程序在使用非直接缓冲区的时候是间接的去操作物理内存空间,当需要读一个数据的时候是通过虚拟机去向内存地址空间去复制一份到用户地址空间然后在进行操作,明显这样的缓冲效率是不高的但是可以保证一定的安全性。
直接缓冲区是通过 allocateDirect() 方法分配直接缓冲区,他将缓冲区建立在物理内存中,就跨越了JVM直接操作物理内存从而提高了效率,但是也加大了对安全的不确定性。所以在使用的时候并不是什么类型都可以使用直接缓冲区,建议是将直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区。
传统NIO编程由于在1.7以前未进行升级,如果需要使用异步非阻塞就可以使用多线程(建议使用线程池,可以复用)来实现NIO的伪异步处理,在1.7以后将NIO进行了加强那就是出现了AIO异步非阻塞IO流。
传统的NIO编程案例(由于代码是死的,可以深入理解含义)
class Server {
public static void main(String[] args) throws IOException {
System.out.println("服务器端已经启动....");
// 1.创建通道
ServerSocketChannel sChannel = ServerSocketChannel.open();
// 2.切换读取模式
sChannel.configureBlocking(false);
// 3.绑定连接
sChannel.bind(new InetSocketAddress(8080));
// 4.获取选择器
Selector selector = Selector.open();
// 5.将通道注册到选择器 "并且指定监听接受事件"
sChannel.register(selector, SelectionKey.OP_ACCEPT);
// 6. 轮训式 获取选择 "已经准备就绪"的事件
while (selector.select() > 0) {
// 7.获取当前选择器所有注册的"选择键(已经就绪的监听事件)"
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
// 8.获取准备就绪的事件
SelectionKey sk = it.next();
// 9.判断具体是什么事件准备就绪
if (sk.isAcceptable()) {
// 10.若"接受就绪",获取客户端连接
SocketChannel socketChannel = sChannel.accept();
// 11.设置阻塞模式
socketChannel.configureBlocking(false);
// 12.将该通道注册到服务器上
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (sk.isReadable()) {
// 13.获取当前选择器"就绪" 状态的通道
SocketChannel socketChannel = (SocketChannel) sk.channel();
// 14.读取数据
ByteBuffer buf = ByteBuffer.allocate(1024);
int len = 0;
while ((len = socketChannel.read(buf)) > 0) {
buf.flip();
System.out.println(new String(buf.array(), 0, len));
buf.clear();
}
}
it.remove();
}
}
}
}
其中出现了许多静态的final变量KEY:OP_CONNECT、OP_ACCEPT、OP_READ、OP_WRITE分别表示可连接,可接受连接,可读,可写。
Netty框架特点是:异步非阻塞、基于事件驱动、高性能、高可靠性和高可定制性,其是基于JAVA NIO的异步通信框架。
使用框架的原因肯定不用说,为了节省开发时间和提高开发的效率。除此之外NIO在编程的时候涉及到的知识点多开发起来困难大,并且NIO有一个相当大的BUG,它会导致选择器轮询的时候轮空而导致占用大量CPU,其可靠性也不大易出现各种断连,失败缓存,异常码流等,所以建议开发者使用框架开发。
简单看一个使用框架搭建的服务端程序(客户端类似,只需要把服务端的端口代码换成连接服务器,并传输数据)
class ServerHandler extends SimpleChannelHandler {
/**
* 通道关闭的时候触发
*/
@Override
public void channelClosed(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception {
System.out.println("channelClosed");
}
/**
* 必须是连接已经建立,关闭通道的时候才会触发.
*/
@Override
public void channelDisconnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception {
super.channelDisconnected(ctx, e);
System.out.println("channelDisconnected");
}
/**
* 捕获异常
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception {
super.exceptionCaught(ctx, e);
System.out.println("exceptionCaught");
}
/**
* 接受消息
*/
public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
super.messageReceived(ctx, e);
// System.out.println("messageReceived");
System.out.println("服务器端收到客户端消息:"+e.getMessage());
//回复内容
ctx.getChannel().write("好的");
}
}
// netty 服务器端
public class NettyServer {
public static void main(String[] args) {
// 创建服务类对象
ServerBootstrap serverBootstrap = new ServerBootstrap();
// 创建两个线程池 分别为监听监听端口 ,nio监听
ExecutorService boos = Executors.newCachedThreadPool();
ExecutorService worker = Executors.newCachedThreadPool();
// 设置工程 并把两个线程池加入中
serverBootstrap.setFactory(new NioServerSocketChannelFactory(boos, worker));
// 设置管道工厂
serverBootstrap.setPipelineFactory(new ChannelPipelineFactory() {
public ChannelPipeline getPipeline() throws Exception {
ChannelPipeline pipeline = Channels.pipeline();
//将数据转换为string类型.
pipeline.addLast("decoder", new StringDecoder());
pipeline.addLast("encoder", new StringEncoder());
pipeline.addLast("serverHandler", new ServerHandler());
return pipeline;
}
});
// 绑定端口号
serverBootstrap.bind(new InetSocketAddress(9090));
System.out.println("netty server启动....");
}
}
关于在使用TCP进行数据传输时出现的粘包和拆包问题:
当进行多次的写提交刷新时,在测试打印时会发现并没有出现5条信息,而是将信息都重合再一起这叫做粘包,但是如果在其中插入一条sleep语句就发现出现2条信息,这就叫拆包。但是如果在程序中使用多个循环去遍历多次写刷新,会发现打印的不规则。这就是在TCP会出现的问题导致拿到的数据不是同一个包下面的。
cf.channel().writeAndFlush(Unpooled.wrappedBuffer("helloworld".getBytes()));
cf.channel().writeAndFlush(Unpooled.wrappedBuffer("helloworld".getBytes()));
Thread.sleep(500);
cf.channel().writeAndFlush(Unpooled.wrappedBuffer("helloworld".getBytes()));
cf.channel().writeAndFlush(Unpooled.wrappedBuffer("helloworld".getBytes()));
cf.channel().writeAndFlush(Unpooled.wrappedBuffer("helloworld".getBytes()));
问题的解决:
1.使用在给个将要进行写刷新的操作后面加上sleep方法,但是不现实。
2.使用分隔符,例如每条报文结束都添加回车换行符(例如FTP协议)或者指定特殊字符作为报文分隔符,接收方通过特殊分隔符切分报文区分,将消息分为消息头和消息体,消息头中包含表示信息的总长度(或者消息体长度)的字段。
3.将消息定长,报文大小固定长度,不够空格补全,发送和接收方遵循相同的约定,这样即使粘包了通过接收方编程实现获取定长报文也能区分。
sc.pipeline().addLast(new FixedLengthFrameDecoder(10));
关于序列化协议,其实序列化协议和数据交换格式类似就是一种规范;常用的序列化协议为XML和JSon。