一直认为,实践出真知,理论是基石。没有理论的实践是空虚和盲目的。
【1】Socket是什么
① socket是什么?
百度百科:
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个socket。 建立网络通信连接需要一对socket,两个socket之间形成一个管道(通道),进行信息流的传输(联想IO流中文件和程序之间读写)。
Java网络编程第四版如是说:
Socket又称套接字,应用程序通常通过套接字向网络发出请求或者应答网络请求。 Socket是建立网络连接时使用的,在连接成功时,应用程序两端都会产生一个socket实例。操作这个实例,完成所需的会话。 Socket 允许程序员将网络连接看作是另外一个可以读写字节的流(既然是流,就肯定有两端)。
即,Socket是网络编程接口,是用来进行网络通信的。客户端与服务器进行一次网络通信,肯定要建立连接,那么在连接建立时,在客户端与服务器端都会产生一个socket实例。客户端和服务器可以通过该实例进行信息的发送与接收。
② socket可以完成7个基本操作:
1.连接远程机器; 2.发送数据; 3.接受数据; 4.关闭连接; 5.绑定端口; 6.监听入站数据; 7.在绑定端口上接受来自远程机器的连接。
Java的socket类(客户端和服务器都可以使用)提供了对应前4个操作的方法。后面三个仅服务器需要,即等待客户端的连接。这些操作由ServerSocket类实现。
一旦建立连接,本地和远程主机就从这个socket得到输入流和输出流,使用这两个流相互发送数据。连接是全双工的,两台主机都可以同时发送和接收数据。
总结:socket是计算机网络编程的基础,TCP/UDP收发消息都靠它。web服务器底层依赖它,MySQL关系数据库、Redis内存数据库底层依赖它。我们用微信聊天、玩网络游戏时也离不开它。
③ 通信简单过程
当客户端和服务器使用TCP协议进行通信时,客户端封装一个请求对象req,将请求对象req序列化成字节数组,然后通过套接字socket将字节数组发送到服务器,服务器通过套接字socket读取到字节数组,再反序列化成请求对象req,进行处理,处理完毕后,生成一个响应对应res,将响应对象res序列化成字节数组,然后通过套接字将自己数组发送给客户端,客户端通过套接字socket读取到自己数组,再反序列化成响应对象。
通信框架往往可以将序列化的过程隐藏起来,我们所看到的现象就是上图所示,请求对象req和响应对象res在客户端和服务器之间跑来跑去。
④ 细节过程
我们平时用到的套接字其实只是一个引用(一个对象ID),这个套接字对象实际上是放在操作系统内核中。这个套接字对象内部有两个重要的缓冲结构,一个是读缓冲(read buffer),一个是写缓冲(write buffer),它们都是有限大小的数组结构。
当我们对客户端的socket写入字节数组时(序列化后的请求消息对象req),是将字节数组拷贝到内核区套接字对象的write buffer中,内核网络模块会有单独的线程负责不停地将write buffer的数据拷贝到网卡硬件,网卡硬件再将数据送到网线,经过一些列路由器交换机,最终送达服务器的网卡硬件中。
同样,服务器内核的网络模块也会有单独的线程不停地将收到的数据拷贝到套接字的read buffer中等待用户层来读取。最终服务器的用户进程通过socket引用的read方法将read buffer中的数据拷贝到用户程序内存中进行反序列化成请求对象进行处理。然后服务器将处理后的响应对象走一个相反的流程发送给客户端。
⑤ 几个概念
5.1阻塞
我们注意到write buffer空间都是有限的,所以如果应用程序往套接字里写的太快,这个空间是会满的。一旦满了,写操作就会阻塞,直到这个空间有足够的位置腾出来。不过有了NIO(非阻塞IO),写操作也可以不阻塞,能写多少是多少,通过返回值来确定到底写进去多少,那些没有写进去的内容用户程序会缓存起来,后续会继续重试写入。
同样我们也注意到read buffer的内容可能会是空的。这样套接字的读操作(一般是读一个定长的字节数组)也会阻塞,直到read buffer中有了足够的内容(填充满字节数组)才会返回。有了NIO,就可以有多少读多少,无须阻塞了。读不够的,后续会继续尝试读取。
5.2 ack
那上面这张图就展现了套接字的全部过程么?显然不是,数据的确认过程(ack)就完全没有展现。比如当写缓冲的内容拷贝到网卡后,是不会立即从写缓冲中将这些拷贝的内容移除的,而要等待对方的ack过来之后才会移除。如果网络状况不好,ack迟迟不过来,写缓冲很快就会满的。
5.3 包头
可能你注意到图中的消息req被拷贝到网卡的时候变成了大写的REQ,这是为什么呢?因为这两个东西已经不是完全一样的了。内核的网络模块会将缓冲区的消息进行分块传输,如果缓冲区的内容太大,是会被拆分成多个独立的小消息包的。并且还要在每个消息包上附加上一些额外的头信息,比如源网卡地址和目标网卡地址、消息的序号等信息,到了接收端需要对这些消息包进行重新排序组装去头后才会扔进读缓冲中。这些复杂的细节过程就非常难以在动画上予以呈现了。
5.4 速率
还有个问题那就是如果读缓冲满了怎么办,网卡收到了对方的消息要怎么处理?一般的做法就是丢弃掉不给对方ack,对方如果发现ack迟迟没有来,就会重发消息。那缓冲为什么会满?是因为消息接收方处理的慢而发送方生产的消息太快了,这时候tcp协议就会有个动态窗口调整算法来限制发送方的发送速率,使得收发效率趋于匹配。如果是udp协议的话,消息一丢那就彻底丢了。
【2】Socket的构造
① 你可以直接使用带参构造方法进行实例化:
Socket socket = new Socket("127.0.0.1",8989);
需要注意的时,这不只是实例化对象,还会在网络上建立连接。如果连接建立成功,将会在客户端和服务端产生socket实例。如果失败,将会抛出异常。
可以为连接设置一个超时时间,按毫秒度量:
socket.setSoTimeOut(15000);
② 也可以使用无参构造方法并进行地址连接
Socket socket = new Socket(); SocketAddress address = new InetSocketAddress("127.0.0.1",8989); socket.connet(address);
【3】服务器Socket–ServerSocket
上面说客户端就是向监听连接的服务器打开一个socket的程序。
对于接受连接的服务器,Java提供了一个ServerSocket类表示服务器socket。
从基本上来讲,服务器Socket在服务器上运行,监听入站TCP连接。每个服务器Socket监听服务器机器上的一个特定端口(客户端侧的socket不用指定端口,系统会自动分配)。
当远程主机上的一个客户端尝试连接这个端口时,服务器就被唤醒。协商建立客户端和服务器之间的连接,并返回一个常规的Socket对象。表示两台主机之间的Socket。
换句话说,服务器socket等待连接,而客户端socke发起连接。一旦ServerSocket建立了连接,服务器会使用一个常规的Socket对象向客户端发送数据。
数据总是通过常规socket传输。
在Java中,服务器程序的基本生命周期如下:
1.使用一个ServerSocket()构造函数在一个特定端口创建一个新的ServerSocket。
2.ServerSocket使用其accept()方法监听这个端口的入站连接。accept()会一直阻塞,直到一个客户端尝试建立连接,此时accept()将返回一个连接客户端和服务器的socket对象。
也就是,客户端和服务器之间连接位于服务器端的socket。
3.根据服务器的类型,会调用socket的getInputStream()方法或getOutputStream()方法,或者这两个方法都调用,以获得与客户端通信的输入和输出流。
4.服务器和客户端根据已协商的协议交互,直到要关闭连接。
5.服务器或客户端(或二者)关闭连接。
6.服务器返回到步骤2,等待下一次连接。
【4】Socket通信模型图
【5】本地和远程
本地,就是客户端发起的主机。远程,就是服务器端的主机。
这里以客户端进行测试,代码如下:
//获取远程请求地址和端口 InetAddress inetAddress = client.getInetAddress(); int port = client.getPort(); String hostAddress = inetAddress.getHostAddress(); String hostName = inetAddress.getHostName(); InetAddress localHost = inetAddress.getLocalHost(); System.out.println("远程主机 :"+inetAddress+",远程端口:"+port+",远程hostAddress:"+hostAddress+",远程hostName:"+hostName); //获取发起的地址和端口 InetAddress localAddress = client.getLocalAddress(); int localPort = client.getLocalPort(); String hostAddress2 = localAddress.getHostAddress(); String hostName2 = localAddress.getHostName(); System.out.println("本地主机 :"+localAddress+",本地端口 :"+localPort+",本地hostAddress:"+hostAddress2+",本地hostName:"+hostName2);
这里连续发起了两次客户端请求,每次都实例化一个新的socket。需要注意的时远程端口和本地端口。
远程端口(对于客户端socket而言)通常是一个标准委员会预先分配的已知端口,而本地端口与远程端口不同,通常是由系统在运行时从未使用的空闲端口中选择。