Socket基础
在说到计算机网络模型的时候一定都会提到这两个模型:OSI七层网络模型和TCP/IP四层网络模型,因为OSI七层过于复杂,现在普遍采用的是TCP/IP的四层网络模型。
tcp/udp协议位于TCP/IP协议栈的传输层,tcp是一个面向连接、可靠的协议,而udp协议是一个不可靠的、无连接协议。刚开始我有点疑惑,什么样的场景才需要udp协议呢?因为udp协议并不保证数据一定传输到目的地,有什么场景能容忍可能发生丢包的情况呢?后来在项目的某个解决方案的讨论中,听到了老大的解释。当时我们可以选用udp和tcp方案,问我该选用什么方案。我当时也了解了一些tcp和udp,记得在网上看到过这么一句话,如果你需要用额外的操作保证数据准确的传递到目的地,那不如直接采用tcp协议,所以我说udp并不保证数据一定传达,而tcp是可靠的,所以应该采用tcp协议。然后老大说在局域网(当时要解决的就是局域网中的一个问题)可以认为udp是可靠的,不需要考虑丢包的情况,不过数据处理难一点,后来还是用了TCP……
Socket可以说是对TCP协议的封装,可以理解成TCP的API,在TCP/IP协议栈中应当属于应用层和传输层之间的抽象。那么属于应用层的HTTP协议,和Socket有何异同呢?HTTP协议一般来说是短连接(当然,可以指定长连接),每次请求都会建立和断开TCP连接,而Socket默认就是长连接,两者在传输层都是建立和断开TCP连接。相比于Socket,HTTP协议显得更加的高级。二者都是基于TCP的,Socket可以用来编写一个HTTP框架。
Socket Client
在Java中想要使用Socket只要用Socket这个类就可以了,而且得益于Java方便的IO,入门的成本不会非常高,但是前提是你熟悉Java的IO和基本的网络知识。当然了,如果你的追求更高还可以去用非阻塞的NIO玩玩……我这是不会介绍的……
接下来将会创建一个Socket来获取时间(要看到结果得翻墙……)
首先创建一个Socket
1 | Socket socket = new Socket("time.nist.gov",13); |
第一个参数可以是主机域名也可以是ip,第二个参数是端口,这种方式建立的Socket会直接开始尝试连接远程服务器的端口。服务器监听端口的服务在和客户端建立连接之后,会按照一定的协定发送、接收数据。而这里建立连接之后就会直接把时间返回给客户端,这里用Java中的字符流处理,简单~
1 | InputStream in = socket.getInputStream(); |
完整代码:
1 | public class TimeSocket { |
Socket Client & Socket Server
接下来的例子会建立一个TCP Client 和 TCP Server,通过互相发送字符串来模拟服务器和客户端的通信。在Java中想要使用TCP Server,可以使用ServerSocket。我们首先建立一个TCP Server:
1 | /** |
注释写的很清楚了,这里还要提醒一下,accept()这个方法是一个阻塞方法,也就是说如果没有socket连接,代码会一直阻塞在这里。而接下来则是拿到输入流,读入客户端的输入。拿到客户端的输入之后,再获取输出流返回响应给客户端。
接下来是建立客户端,上面的服务端监听了10086端口,所以下面的服务端也要连10086端口,套路跟之前的Socket差不多:
1 | // 客户端 |
熟悉的配方,熟悉的味道,这里就不再多做介绍了。注释写的很详细,运行的时候注意先运行服务端,再运行客户端。因为服务端的代码会阻塞在accept方法,而客户端在服务端没有启动的情况下,会出现连接失败的情况。
看一下server和client各自的输出:
这里server和client的代码我是写在两个类各自的main方法中的,这代码我也是跟着网上找到的博客一行一行敲的,敲完之后运行,恩,成功了。后来我感觉到有点不对……不对在哪呢?因为我这里是两个main方法运行,可以理解成是两个进程,这两个进程竟然惊人的出现了顺序性!你连接我,我监听到,拿到你发的内容,然后我响应你,你再拿到我的响应。符合逻辑,合乎情理,但是凭什么啊?多线程都要为一些个顺序性挠破头,何况是多进程?后来我仔细看了代码,发现这些表现出来的顺序性是因为:阻塞。
首先是服务端的代码,第一次阻塞发生在accept()方法:
1 | Socket socket = serverSocket.accept(); |
这里会阻塞进程,等待Socket连接。而在客户端连接上之后,会继续执行代码,代码会在哪里阻塞呢?第二次阻塞发生在读取客户端输入的时候:
1 | while ((info = br.readLine()) != null) { |
是的,就是阻塞在这个br.readLine()了,首先我们可以从代码的现象来分析:从代码来说,能正确的打印客户端传来的信息,那么这个while循环一定是能正常的执行的。因为如果不满足条件会跳出,满足则会一直打印,即使没读到信息,那么只能说明一件事,br.readLine()阻塞了程序。这里的io是阻塞式io,不作更多的介绍,有兴趣可以了解Java的IO模型。在读到信息之后将之打印出来,那么后来为什么又不阻塞跳出了循环呢?这里可以看一下这个方法的注释:
1 | /** |
注释中关于返回值写的非常清楚: or null if the end of the stream has been reached,如果到了流的末尾会返回null。怎么判断到了流的末尾呢?这里我认为是这个流结束了,不会再有任何后续输出了,这个在代码中是怎么体现的呢?在Client中的这句代码就是解释的体现:
1 | socket.shutdownOutput(); |
终结了输出流,这样在Server中就跳出了while循环,而不是阻塞在br.readLine。如果注释这句shutdownOutput(),程序则会阻塞住,现象如下:
Server没有跟上次一样最后有显示退出了程序,而是一直在运行,是的阻塞住了。而Client则是没有任何输出,因为Client也有一个读取的操作,也阻塞住了。
简单的流程就是这样,这是一个简单的TCP Server和TCP Client的阻塞模型。接下来介绍几种TCP Server模型。
TCP Server 的几种模型
TCP Server有阻塞式、并发式以及异步服务器。其中阻塞式服务器最好实现,同时也是问题比较多的。因为客户端发送到服务器的请求,服务器会依次处理,只要碰到一个处理时间特别长的请求,其余请求就无法得到及时的处理,同时可能会有部分客户端认为连接已经超时了。并发式服务器在处理请求时会为连接开启一个线程,在线程中完成各自的读取响应操作,这样就不会阻塞服务器对于其他请求的访问了,缺点无疑是服务器资源有限,不可能为每一个连接开辟新的线程,通常会配合线程池一起处理。异步服务器需要Java中的NIO配合,这里不做介绍。
阻塞式的实现就是上面的代码,不过上面的代码通过shutdown来跳出阻塞,通常来说是不会这样的,因为shutdown之后无法再次使用输入输出流,一般来说会自定义数据边界,在拿到一个完整的数据之后将数据传递出去,让上层处理,然后就阻塞在那,等待下一次的数据到来。这种实现起来也不是非常难,跟人配合的时候协商好协议就可以了。下面要实现的是并发式服务器,至于异步服务器,我自己也不是非常熟,各位感兴趣可以自己查阅资料。
客户端还是使用上面的代码,不做改变,而Server则做一些并发的处理:
1 | import java.io.*; |
当然,这里我偷懒也没有设置个数据边界什么的,只是简单的让服务器读取数据然后传递给处理方法。关于并发,坑也是比较多的,只能说各位量力而行吧……下面看一下启动多个Socket连接并发服务器时的输出吧:
这里都是比较简单的例子,希望各位看官能有耐心的看完。接下来要写的是关于Socket失效的问题。
Socket失效
在使用Socket的过程中,有的时候会发现无论怎么发送数据,服务器都收不到,而且Java本地也没有任何异常提示,这个时候就需要一个机制来让开发者判断Socket是否失效。目前我了解的方式有两个:心跳包和超时重发,主要原理都是客户端向服务端发送数据,心跳是如果超过一段时间服务器无响应则判定Socket失效,而超时重发可以设置为累计重发满一定次数无响应判断Socket失效。心跳机制的好处是相对比较稳定,客户端很容易就能知道Socket是否断开,一般来说也不是特别耗费资源,有的心跳可能会设置成十几分钟发送一次数据包。但是对于一些特殊的场景,需要保证Socket一定要稳定的(比如通过中间层硬件和底层交互),那么心跳包可能会十分的频繁,这个时候超时重发更加的适合。不过超时重发的前提是客户端每一个数据发送都需要有服务器的响应,不然客户端是无法判断数据发送是否成功的。