前言:所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。
一、基础概念
网络通信归根结底是不同主机上的应用程序进程之间的交互,我们可以通过应用层协议HTTP,解决如何包装数据,但有时我们也想直接使用传输层协议,而Socket就相当于是传输层的编程接口。应用通过传输层进行数据通信时,TCP和UDP会遇到同时为多个应用程序进程提供并发服务的问题。为了区别不同的应用程序进程和连接,计算机操作系统就可以为应用程序与TCP/IP协议交互提供套接字(Socket)接口。应用层可以和传输层通过Socket接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。
Socket并不是一种协议,可以将Socket理解为方便直接使用更底层协议(传输层TCP或UDP)而存在的一个抽象层。Socket跟TCP/IP协议没有必然的联系。Socket编程接口在设计的时候,就希望也能适应其他的网络协议,Socket只是使得用TCP/IP协议栈更方便而已。所以Socket是对TCP/IP协议的封装,它是一组接口。这组接口当然可以由不同的语言去实现。它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。用套接字中的相关函数来完成通信过程。
Socket是面向客户/服务器模型而设计的,针对客户和服务器程序提供不同的Socket系统调用。通过Socket建立通信连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket,另一个运行于服务器端,称为ServerSocket。Socket原意是 “插座”,两个Socket端点的连接,就像连接一个插座获取服务一样。套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。
服务器监听:是服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。
客户端请求:是指由客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。
连接确认:是指当服务器端套接字监听到或者说接收到客户端套接字的连接请求,它就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。
在一台计算机本机上可以通过PID唯一标识一个进程,而区分不同应用程序进程间的网络通信和连接,主要有3个参数:通信的目的IP地址、使用的传输层协议(TCP或UDP)和使用的端口号。通过将这3个参数结合起来,与一个Socket绑定,应用层就可以和传输层通过套接字接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。
Socket通过这些信息成为网络通信中的一个端点,是连接应用程序和网络驱动程序的桥梁,Socket在应用程序中创建,通过绑定与网络驱动建立关系。此后,应用程序送给Socket的数据,由Socket交给网络驱动程序向网络上发送出去。计算机从网络上收到与该Socket绑定IP地址和端口号相关的数据后,由网络驱动程序交给Socket,应用程序便可从该Socket中提取接收到的数据,网络应用程序就是这样通过Socket进行数据的发送与接收的。
我自己学C++,填了一个坑又一个坑,深知新手学习C/C++的重要性和疑难问题,因此特地给C/C++开发的同学精心准备了一份优惠优质学习卡——零声白金卡(https://xxetb.xet.tech/s/3wrN44购买地址),6个项目分别是:基础架构-KV存储项目、spdk文件系统实现项目、Linux内核内存管理实战案例分析、golang云原生、FFmpeg+SDL播放器开发实站QtMP3音乐播放器搜索引擎实战,提供项目源码下载,同时这份资料也包括 C/C++学习路线、简历指导和求职技巧等。
1.1Socket定义
套接字的特性有三个属性确定,它们是:域(domain),类型(type),和协议(protocol)。套接字还用地址作为它的名字。地址的格式随域(又被称为协议族,protocol family)的不同而不同。每个协议族又可以使用一个或多个地址族定义地址格式。
套接字的域:域指定套接字通信中使用的网络介质。最常见的套接字域是AF_INET,它是指Internet网络,许多Linux局域网使用的都是该网络,当然,因特网自身用的也是它。其底层的协议——网际协议(IP)只有一个地址族,它使用一种特定的方式来指定网络中的计算机,即IP地址。在计算机系统内部,端口通过分配一个唯一的16位的整数来表示,在系统外部,则需要通过IP地址和端口号的组合来确定。
套接字类型:流套接字(在某些方面类似域标准的输入/输出流)提供的是一个有序,可靠,双向字节流的连接。
流套接字由类型SOCK_STREAM指定,它们是在AF_INET域中通过TCP/IP连接实现的。他们也是AF_UNIX域中常见的套接字类型。
数据包套接字:与流套接字相反,由类型SOCK_DGRAM指定的数据包套接字不建立和维持一个连接。它对可以发送的数据包的长度有限制。数据报作为一个单独的网络消息被传输,它可能会丢失,复制或乱序到达。
数据报套接字实在AF_INET域中通过UDP/IP连接实现,它提供的是一种无需的不可靠服务。
套接字协议:只要底层的传输机制允许不止一个协议来提供要求的套接字类型,我们就可以为套接字选择一个特定的协议。
1.2Socket使用
Socket起源于UNIX,在Unix一切皆文件哲学的思想下,Socket是一种"打开—读/写—关闭"模式的实现,服务器和客户端各自维护一个"文件",在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
二、套接字工作流程
套接字的工作过程(服务器端)
首先,服务器应用程序通过socket系统调用创建一个套接字,它是系统分配给该服务器进程的类似文件描述符的资源,不能与其他进程共享。
其次,服务器进程使用bind系统调用个套接字命名。
接下来,服务器进程开始等待客户连接到这个命名套接字,调用listen创建一个等待队列,以使存放来自客户的进入连接。
最后,服务器通过accept系统调用来接受客户的连接。此时,会产生一个与原有的命名套接字不同的新套接字,它仅用于与这个特定的客户端,而命名套接字则被保留下来继续处理来自其他客户的连接。
套接字的工作工程(客户端)
调用socket创建一个未命名套接字,将服务器的命名套接字作为一个地址来调用connect与服务器建立连接。一旦建立了连接,就可以像使用底层文件描述符那样来用套接字进行双向的数据通信。
套接字的属性
套接字的特性由三个属性决定:
域(domain):指定套接字通信中使用的网络介质,包括地址格式。AF_INET,即互联网络,基于IP协议,并且每个对应一个端口号,套接字地址由IP地址+端口号决定
类型(type)
流套接字:由类型SOCK_STREAM指定,基于TCP/IP实现,提供一个有序、可靠、双向字节流的连接,发送的数据不会丢失、乱序、重复。大的消息会被分块、传输、重组,很像一个文件流。
数据报套接字:由SOCK_DGRAM指定,基于UDP/IP协议,不建立和维持可靠连接,开销小,服务器崩溃不需要客户端重启,因为基于数据报的服务器不保留连接信息
协议(protocol)
套接字举例
客户端程序:创建一个未命名的套接字,然后把它连接到服务器套接字server_socket上,向服务器写一个字符,再读回经服务器处理后的一个字符。
服务器端程序:首先创建一个服务器套接字,绑定一个名字,然后创建一个监听队列,接收来自客户程序的连接。
client1.c
#include <sys/types.h> #include <sys/socket.h> #include <stdio.h> #include <stdlib.h> #include <sys/un.h> #include <unistd.h> int main() { int sockfd; int len; struct sockaddr_un address; int result; char ch = 'A'; sockfd = socket(AF_UNIX, SOCK_STREAM, 0); //根据服务器的情况设定连续地址 address.sun_family = AF_UNIX; strcpy(address.sun_path, "server_socket"); len = sizeof(address); result = connect(sockfd, (struct sockaddr *)&address, len); if (result == -1) { perror("oops:client1"); exit(1); } write(sockfd, &ch, 1); read(sockfd, &ch, 1); printf("char from server = %c\n", ch); close(sockfd); exit(0); }
server1.c
#include <sys/types.h> #include <sys/socket.h> #include <stdio.h> #include <sys/un.h> #include <unistd.h> int main() { int server_sockfd, client_sockfd;//定义套接字标识符 int server_len, client_len;//地址长度 struct sockaddr_un server_address; struct sockaddr_un client_address; unlink("server_socket");//如果当前目录有叫做server_socket的文件,则删掉 server_sockfd = socket(AF_UNIX, SOCK_STREAM, 0);//采用字节流方式,默认协议 server_address.sun_family = AF_UNIX; strcpy(server_address.sun_path, "server_socket"); server_len = sizeof(server_address); bind(server_sockfd, (struct sockaddr *)&server_address, server_len); listen(server_sockfd, 5);//创建长度为5的监听队列 while(1)//等待客户端连接的到来 { char ch; printf("server waiting\n"); client_len = sizeof(client_address);//获取客户端的地址长度 //服务器进程阻塞自身,知道有客户端请求建立连接,此时生成一个新的套接字,并返回新套接子的描述符,用此新套接字与客户进行通信 client_sockfd = accept(server_sockfd, (struct sockaddr *)&client_address, &client_len); read(client_sockfd, &ch, 1); ch++; write(client_sockfd, &ch, 1); close(client_sockfd); } }
三、数据传输
网络应用程序一个很重要的工作是传输数据。传输数据的过程不一样取决是使用哪种交通工具,但是传输的方式是一样的:都是以字节码传输。Java开发网络程序传输数据的过程和方式是被抽象了的,我们不需要关注底层接口,只需要使用Java API或其他网络框架如Netty就能达到传输数据的目的。发送数据和接收数据都是字节码。Nothing more,nothing less。
如果你曾经使用Java提供的网络接口工作过,你可能已经遇到过想从阻塞传输切换到非阻塞传输的情况,这种切换是比较困难的,因为阻塞IO和非阻塞IO使用的API有很大的差异;Netty提供了上层的传输实现接口使得这种情况变得简单。我们可以让所写的代码尽可能通用,而不会依赖一些实现相关的APIs。当我们想切换传输方式的时候不需要花很大的精力和时间来重构代码。
统一的API以及如何使用它们,会拿Netty的API和Java的API做比较来告诉你为什么Netty可以更容易的使用。也提供了一些优质的用例代码,以便最佳使用Netty。使用Netty不需要其他的网络框架或网络编程经验,若有则只是对理解netty有帮助,但不是必要的。下面让我们来看看真是世界里的传输工作。
3.1案例研究:切换传输方式
为了让你想象如何运输,我会从一个简单的应用程序开始,这个应用程序什么都不做,只是接受客户端连接并发送“Hi!”字符串消息到客户端,发送完了就断开连接。我不会详细讲解这个过程的实现,它只是一个例子。
1)使用Java的I/O和NIO
我们将不用Netty实现这个例子,下面代码是使用阻塞IO实现的例子:
[java] view plaincopy 1. package netty.in.action; 2. 3. import java.io.IOException; 4. import java.io.OutputStream; 5. import java.net.ServerSocket; 6. import java.net.Socket; 7. import java.nio.charset.Charset; 8. 9. /** 10. * Blocking networking without Netty 11. * @author c.k 12. * 13. */ 14. public class PlainOioServer { 15. 16. public void server(int port) throws Exception { 17. //bind server to port 18. final ServerSocket socket = new ServerSocket(port); 19. try { 20. while(true){ 21. //accept connection 22. final Socket clientSocket = socket.accept(); 23. System.out.println("Accepted connection from " + clientSocket); 24. //create new thread to handle connection 25. new Thread(new Runnable() { 26. @Override 27. public void run() { 28. OutputStream out; 29. try{ 30. out = clientSocket.getOutputStream(); 31. //write message to connected client 32. out.write("Hi!\r\n".getBytes(Charset.forName("UTF-8"))); 33. out.flush(); 34. //close connection once message written and flushed 35. clientSocket.close(); 36. }catch(IOException e){ 37. try { 38. clientSocket.close(); 39. } catch (IOException e1) { 40. e1.printStackTrace(); 41. } 42. } 43. } 44. }).start();//start thread to begin handling 45. } 46. }catch(Exception e){ 47. e.printStackTrace(); 48. socket.close(); 49. } 50. } 51. }
上面的方式很简洁,但是这种阻塞模式在大连接数的情况就会有很严重的问题,如客户端连接超时,服务器响应严重延迟。为了解决这种情况,我们可以使用异步网络处理所有的并发连接,但问题在于NIO和OIO的API是完全不同的,所以一个用OIO开发的网络应用程序想要使用NIO重构代码几乎是重新开发。
下面代码是使用Java NIO实现的例子:
[java] view plaincopy 1. package netty.in.action; 2. 3. import java.net.InetSocketAddress; 4. import java.net.ServerSocket; 5. import java.nio.ByteBuffer; 6. import java.nio.channels.SelectionKey; 7. import java.nio.channels.Selector; 8. import java.nio.channels.ServerSocketChannel; 9. import java.nio.channels.SocketChannel; 10. import java.util.Iterator; 11. /** 12. * Asynchronous networking without Netty 13. * @author c.k 14. * 15. */ 16. public class PlainNioServer { 17. 18. public void server(int port) throws Exception { 19. System.out.println("Listening for connections on port " + port); 20. //open Selector that handles channels 21. Selector selector = Selector.open(); 22. //open ServerSocketChannel 23. ServerSocketChannel serverChannel = ServerSocketChannel.open(); 24. //get ServerSocket 25. ServerSocket serverSocket = serverChannel.socket(); 26. //bind server to port 27. serverSocket.bind(new InetSocketAddress(port)); 28. //set to non-blocking 29. serverChannel.configureBlocking(false); 30. //register ServerSocket to selector and specify that it is interested in new accepted clients 31. serverChannel.register(selector, SelectionKey.OP_ACCEPT); 32. final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes()); 33. while (true) { 34. //Wait for new events that are ready for process. This will block until something happens 35. int n = selector.select(); 36. if (n > 0) { 37. //Obtain all SelectionKey instances that received events 38. Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); 39. while (iter.hasNext()) { 40. SelectionKey key = iter.next(); 41. iter.remove(); 42. try { 43. //Check if event was because new client ready to get accepted 44. if (key.isAcceptable()) { 45. ServerSocketChannel server = (ServerSocketChannel) key.channel(); 46. SocketChannel client = server.accept(); 47. System.out.println("Accepted connection from " + client); 48. client.configureBlocking(false); 49. //Accept client and register it to selector 50. client.register(selector, SelectionKey.OP_WRITE, msg.duplicate()); 51. } 52. //Check if event was because socket is ready to write data 53. if (key.isWritable()) { 54. SocketChannel client = (SocketChannel) key.channel(); 55. ByteBuffer buff = (ByteBuffer) key.attachment(); 56. //write data to connected client 57. while (buff.hasRemaining()) { 58. if (client.write(buff) == 0) { 59. break; 60. } 61. } 62. client.close();//close client 63. } 64. } catch (Exception e) { 65. key.cancel(); 66. key.channel().close(); 67. } 68. } 69. } 70. } 71. } 72. 73. }
如你所见,即使它们实现的功能是一样,但是代码完全不同。下面我们将用Netty来实现相同的功能。
2)Netty中使用I/O和NIO
下面代码是使用Netty作为网络框架编写的一个阻塞IO例子:
[java] view plaincopy 1. package netty.in.action; 2. 3. import java.net.InetSocketAddress; 4. 5. import io.netty.bootstrap.ServerBootstrap; 6. import io.netty.buffer.ByteBuf; 7. import io.netty.buffer.Unpooled; 8. import io.netty.channel.Channel; 9. import io.netty.channel.ChannelFuture; 10. import io.netty.channel.ChannelFutureListener; 11. import io.netty.channel.ChannelHandlerContext; 12. import io.netty.channel.ChannelInboundHandlerAdapter; 13. import io.netty.channel.ChannelInitializer; 14. import io.netty.channel.EventLoopGroup; 15. import io.netty.channel.nio.NioEventLoopGroup; 16. import io.netty.channel.socket.oio.OioServerSocketChannel; 17. import io.netty.util.CharsetUtil; 18. 19. public class NettyOioServer { 20. 21. public void server(int port) throws Exception { 22. final ByteBuf buf = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("Hi!\r\n", CharsetUtil.UTF_8)); 23. //事件循环组 24. EventLoopGroup group = new NioEventLoopGroup(); 25. try { 26. //用来引导服务器配置 27. ServerBootstrap b = new ServerBootstrap(); 28. //使用OIO阻塞模式 29. b.group(group).channel(OioServerSocketChannel.class).localAddress(new InetSocketAddress(port)) 30. //指定ChannelInitializer初始化handlers 31. .childHandler(new ChannelInitializer<Channel>() { 32. @Override 33. protected void initChannel(Channel ch) throws Exception { 34. //添加一个“入站”handler到ChannelPipeline 35. ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { 36. @Override 37. public void channelActive(ChannelHandlerContext ctx) throws Exception { 38. //连接后,写消息到客户端,写完后便关闭连接 39. ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE); 40. } 41. }); 42. } 43. }); 44. //绑定服务器接受连接 45. ChannelFuture f = b.bind().sync(); 46. f.channel().closeFuture().sync(); 47. } catch (Exception e) { 48. //释放所有资源 49. group.shutdownGracefully(); 50. } 51. } 52. 53. }
上面代码实现功能一样,但结构清晰明了,这只是Netty的优势之一。
3)Netty中实现异步支持
下面代码是使用Netty实现异步,可以看出使用Netty由OIO切换到NIO是非常的方便。
[java] view plaincopy 1. package netty.in.action; 2. 3. import io.netty.bootstrap.ServerBootstrap; 4. import io.netty.buffer.ByteBuf; 5. import io.netty.buffer.Unpooled; 6. import io.netty.channel.ChannelFuture; 7. import io.netty.channel.ChannelFutureListener; 8. import io.netty.channel.ChannelHandlerContext; 9. import io.netty.channel.ChannelInboundHandlerAdapter; 10. import io.netty.channel.ChannelInitializer; 11. import io.netty.channel.EventLoopGroup; 12. import io.netty.channel.nio.NioEventLoopGroup; 13. import io.netty.channel.socket.SocketChannel; 14. import io.netty.channel.socket.nio.NioServerSocketChannel; 15. import io.netty.util.CharsetUtil; 16. 17. import java.net.InetSocketAddress; 18. 19. public class NettyNioServer { 20. 21. public void server(int port) throws Exception { 22. final ByteBuf buf = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("Hi!\r\n", CharsetUtil.UTF_8)); 23. // 事件循环组 24. EventLoopGroup group = new NioEventLoopGroup(); 25. try { 26. // 用来引导服务器配置 27. ServerBootstrap b = new ServerBootstrap(); 28. // 使用NIO异步模式 29. b.group(group).channel(NioServerSocketChannel.class).localAddress(new InetSocketAddress(port)) 30. // 指定ChannelInitializer初始化handlers 31. .childHandler(new ChannelInitializer<SocketChannel>() { 32. @Override 33. protected void initChannel(SocketChannel ch) throws Exception { 34. // 添加一个“入站”handler到ChannelPipeline 35. ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { 36. @Override 37. public void channelActive(ChannelHandlerContext ctx) throws Exception { 38. // 连接后,写消息到客户端,写完后便关闭连接 39. ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE); 40. } 41. }); 42. } 43. }); 44. // 绑定服务器接受连接 45. ChannelFuture f = b.bind().sync(); 46. f.channel().closeFuture().sync(); 47. } catch (Exception e) { 48. // 释放所有资源 49. group.shutdownGracefully(); 50. } 51. } 52. }
因为Netty使用相同的API来实现每个传输,它并不关心你使用什么来实现。Netty通过操作Channel接口和ChannelPipeline、ChannelHandler来实现传输。
2)Transport API
传输API的核心是Channel接口,它用于所有出站的操作。Channel接口的类层次结构。
每个Channel都会分配一个ChannelPipeline和ChannelConfig。ChannelConfig负责设置并存储配置,并允许在运行期间更新它们。传输一般有特定的配置设置,只作用于传输,没有其他的实现。ChannelPipeline容纳了使用的ChannelHandler实例,这些ChannelHandler将处理通道传递的“入站”和“出站”数据。ChannelHandler的实现允许你改变数据状态和传输数据,本书有章节详细讲解ChannelHandler,ChannelHandler是Netty的重点概念。
现在我们可以使用ChannelHandler做下面一些事情:
- 传输数据时,将数据从一种格式转换到另一种格式
- 异常通知
- Channel变为有效或无效时获得通知
- Channel被注册或从EventLoop中注销时获得通知