Socket的由来
Unix的输入输出(IO)系统遵循Open-Read-Write-Close这样的操作范本。当一个用户进程进行IO操作之前,它需要调用Open来指定并获取待操作文件或设备读取或写入的权限。一旦IO操作对象被打开,那么这个用户进程可以对这个对象进行一次或多次的读取或写入操作。Read操作用来从IO操作对象读取数据,并将数据传递给用户进程。Write操作用来将用户进程中的数据传递(写入)到IO操作对象。 当所有的Read和Write操作结束之后,用户进程需要调用Close来通知系统其完成对IO对象的使用。
在Unix开始支持进程间通信(InterProcess Communication,简称IPC)时,IPC的接口就设计得类似文件IO操作接口
。在Unix中,一个进程会有一套可以进行读取写入的IO描述符。IO描述符可以是文件,设备或者是通信通道(socket套接字)。一个文件描述符由三部分组成:创建(打开socket),读取写入数据(接受和发送到socket)还有销毁(关闭socket)。
Socket可以认为是IPC(进程间通信)接口定义的进程的IO描述符。通俗的说,Socket是用于进程间通信的IO。
在Unix系统中,类BSD版本的IPC接口是作为TCP和UDP协议之上的一层进行实现的
。消息的目的地使用socket地址来表示。一个socket地址是由网络地址和端口号组成的通信标识符。
进程间通信操作需要一对儿socket。进程间通信通过在一个进程中的一个socket与另一个进程中得另一个socket进行数据传输来完成。当一个消息执行发出后,这个消息在发送端的socket中处于排队状态,直到下层的网络协议将这些消息发送出去。当消息到达接收端的socket后,其也会处于排队状态,直到接收端的进程对这条消息进行了接收处理。
TCP和UDP通信
关于socket编程我们有两种通信协议可以进行选择。一种是数据报通信,另一种就是流通信。
数据报通信
数据报通信协议,就是我们常说的UDP(User Data Protocol 用户数据报协议)。UDP是一种无连接的协议,这就意味着我们每次发送数据报时,需要同时发送本机的socket描述符和接收端的socket描述符
。因此,我们在每次通信时都需要发送额外的数据。
流通信
流通信协议,也叫做TCP(Transfer Control Protocol,传输控制协议)。和UDP不同,TCP是一种基于连接的协议。在使用流通信之前,我们必须在通信的一对儿socket之间建立连接。其中一个socket作为服务器进行监听连接请求。另一个则作为客户端进行连接请求。一旦两个socket建立好了连接,他们可以单向或双向进行数据传输。
读到这里,我们多少有这样的疑问,我们进行socket编程使用UDP还是TCP呢。选择基于何种协议的socket编程取决于你的具体的客户端-服务器端程序的应用场景。下面我们简单分析一下TCP和UDP协议的区别,或许可以帮助你更好地选择使用哪种。
1、在UDP中,每次发送数据报时,需要附带上本机的socket描述符和接收端的socket描述符。而由于TCP是基于连接的协议,在通信的socket对之间需要在通信之前建立连接,因此会有建立连接这一耗时存在于TCP协议的socket编程。
2、在UDP中,数据报数据在大小上有64KB的限制。而TCP中也不存在这样的限制
。一旦TCP通信的socket对建立了连接,他们之间的通信就类似IO流,所有的数据会按照接受时的顺序读取。
3、UDP是一种不可靠的协议,发送的数据报不一定会按照其发送顺序被接收端的socket接受
。然后TCP是一种可靠的协议。接收端收到的包的顺序和包在发送端的顺序是一致的。
简而言之,TCP适合于诸如远程登录(rlogin,telnet)和文件传输(FTP)这类的网络服务。因为这些需要传输的数据的大小不确定。而UDP相比TCP更加简单轻量一些。UDP用来实现实时性较高或者丢包不重要的一些服务。在局域网中UDP的丢包率都相对比较低。
TCP套接字的生命周期
新的Socket实例创建后,就立即能用于发送和接收数据。也就是说,当Socket实例返回时,它已经连接到了一个远程终端,并通过协议的底层实现完成了TCP消息或握手信息的交换
客户端连接的建立
Socket构造函数的调用与客户端连接建立时所关联的协议事件之间的关系下图所示
当客户端以服务器端的互联网地址W.X.Y.Z和端口号Q作为参数,调用Socket的构造函数时,底层实现将创建一个套接字实例,该实例的初始状态是关闭的。
TCP开放握手也称为3次握手,这通常包括3条消息:
一条从客户端到服务端的连接请求,一条从服务端到客户端的确认消息,以及另一条从客户端到服务端的确认消息。
对客户端而言,一旦它收到了服务端发来的确认消息,就立即认为连接已经建立
。通常这个过程发生的很快,但连接请求消息或服务端的回复消息都有可能在传输过程中丢失,因此TCP协议实现将以递增的时间间隔重复发送几次握手消息。如果TCP客户端在一段时间后还没有收到服务端的回复消息,则发生超时并放弃连接。如果服务端并没有接收连接,则服务端的TCP将发送一条拒绝消息而不是确认消息。
服务端连接的建立
与客户端的事件序列则有所不同。服务端首先创建一个ServerSocket实例,并将其与已知端口相关联(在此为Q),套接字实现为新的ServerSocket实例创建一个底层数据结构,并就Q赋给本地端口,并将特定的通配符(*)赋给本地IP地址(服务器可能有多个IP地址,不过通常不会指定该参数)
,如下图所示:
现在服务端可以调用ServerSocket的accept()方法,来将阻塞等待客户端连接请求的到来。当客户端的连接请求到来时,将为连接创建一个新的套接字数据结构。该套接字的地址根据到来的分组报文设置:分组报文的目标互联网地址和端口号成为该套接字的本地互联网地址和端口号;而分组报文的源地址和端口号则成为改套接字的远程互联网地址和端口号。注意,新套接字的本地端口号总是与ServerSocket的端口号一致。除了要创建一个新的底层套接字数据结构外,服务端的TCP实现还要向客户端发送一个TCP握手确认消息。如下图所示:
但是,对于服务端来说,在接收到客户端发来的第3条消息之前,服务端TCP并不会认为握手消息已经完成
。一旦收到客户端发来的第3条消息,则表示连接已建立,此时一个新的数据结构将从服务端所关联的列表中移除,并为创建一个Socket实例,作为accept()方法的返回值。如下图所示:
这里有非常重要的一点需要注意,在ServerSocket关联的列表中的每个数据结构,都代表了一个与另一端的客户端已经完成建立的TCP连接。
实际上,客户只要收到了开放握手的第2条消息,就可以立即发送数据——这可能比服务端调用accept()方法为其获取一个Socket实例要早很长时间
。
关闭TCP连接
TCP协议有一个优雅的关闭机制,以保证应用程序在关闭时不必担心正在传输的数据会丢失,这个机制还可以设计为允许两个方向的数据传输相互独立地终止
。
关闭机制的工作流程是:
应用程序通过调用连接套接字的close()方法或shutdownOutput()方法表明数据已经发送完毕。底层TCP实现首先将留在SendQ队列中的数据传输出去(这还要依赖于另一端的RecvQ队列的剩余空间),然后向另一端发送一个关闭TCP连接的握手消息。该关闭握手消息可以看做流结束的标志:它告诉接收端TCP不会再有新的数据传入RecvQ队列了。注意:关闭握手消息本身并没有传递给接收端应用程序,而是通过read()方法返回-1来指示其在字节流中的位置
。而正在关闭的TCP将等待其关闭握手消息的确认消息,该确认消息表明在连接上传输的所有数据已经安全地传输到了RecvQ中。只要收到了确认消息,该连接变成了“半关闭”
状态。直到连接的另一个方向上收到了对称的握手消息后,连接才完全关闭——也就是说,连接的两端都表明它们没有数据发送了
。
TCP连接的关闭事件序列可能以两种方式发生:
一种方式是先由一个应用程序调用close()方法或shutdownOutput方法,并在另一端调用close()方法之前完成其关闭握手消息;
另一种方式是两端同时调用close()方法,他们的关闭握手消息在网络上交叉传输。
下图展示了以第一种方式关闭连接时,发起关闭的一端底层实现中的事件序列:
注意,如果连接处于半关闭状态时,远程终端已经离开,那么本地底层数据结构则无限期地保持在该状态。当另一端的关闭握手消息到达后,则发回一条确认消息并将状态改为“Time—Wait”。
虽然应用程序中相应的Socket实例可能早已消失,与之关联的底层数据结构还将在底层实现中继续存留几分钟
对于没有首先发起关闭的一端,关闭握手消息达到后,它立即发回一个确认消息,并将连接状态改为“Close—Wait”。此时,只需要等待应用程序调用Socket的close()方法。调用该方法后,将发起最终的关闭消息 ,并释放底层套接字数据结构
。 下图展示了没有首先发起关闭的一端底层实现中的事件序列:
注意这样一个事实:close()方法和shutdownOutput()方法都没有等待关闭握手的完成,而是调用后立即返回,这样,
当应用程序调用close()方法或shutdownOutput()方法并成功关闭连接时,有可能还有数据留在SendQ队列中。如果连接的任何一端在数据传输到RecvQ队列之前崩溃,数据将丢失,而发送端应用程序却不会知道
。
最好的解决方案是设计一种应用程序协议,以使首先调用close()方法的一方在接收到了应用程序的数据已接收保证后,才真正执行关闭操作。例如,客户端程序确认其接收到的字节数与其发送的字节数相等后,它就能够知道此时在连接的两个方向上都没有数据在传输,因此可以安全地关闭连接
。
关闭TCP连接的最后微妙之处在于对Time—Wait状态的需要。TCP规范要求在终止连接时,两端的关闭握手都完成后,至少要有一个套接字在Time—Wait状态保持一段时间。这个要求的提出是由于消息在网络中传输时可能延迟。如果在连接两端都完成了关闭握手后,它们都移除了其底层数据结构,而此时在同样一对套接字地址之间又建立了新的连接,那么前一个连接在网络上传输时延迟的消息就可能在新建立的连接后到达。由于包含了相同的源地址和目的地址,旧消息就会被错误地认为是属于新连接的
,其包含的数据就可能被错误地分配到应用程序中。虽然这种情况很少发生,TCP还是使用了包括Time—Write状态在内的多种机制对其进行防范。
Time—Wait状态最重要的作用是:
只要底层套接字数据结构还存在,就不允许在相同的本地端口上关联其他套接字,尤其试图使用该端口创建新的Socket实例时,将抛出IOException异常。
Socket数据传输的底层实现
由于TCP提供了一种可信赖的字节流服务,任何写入Socket和OutputStream的数据副本都必须保留,直到连接的另一端将这些数据成功接收
。向输出流写数据并不意味着数据实际上已经被发送——它们只是被复制到了本地缓冲区,就算在Socket的OutputStream上进行flush()操作,也不能保证数据能够立即发送到信道
在使用TCP套接字时,需要记住的最重要的一点是:不能假设在连接的一端将数据写入输出流和在另一端从输入流读出数据之间有任何的一致性
。尤其是在发送端由单个输出流的write()方法传输的数据,可能会通过另一端的多个输入流的read()方法获取,而一个read()方法可能会返回多个write()方法传输的数据。
一般来讲,我们可以认为TCP连接上发送的所有字节序列在某一瞬间被分成了3个FIFO队列:
1、SendQ:在发送端底层实现中缓存的字节,这些字节已经写入输出流,但还没在接收端成功接收。它占用大约37KB内存。
2、RecvQ:在接收端底层实现中缓存的字节,这些字节等待分配到接收程序——即从输入流中读取。它占用大约25KB内存。
3、Delivered:接收者从输入流已经读取到的字节。
当我们调用OutputStream的write()方法时,将向SendQ追加字节。
TCP协议负责将字节按顺序从SendQ移动到RecvQ。这里有重要的一点需要明确:这个转移过程无法由用户程序控制或直接观察到,并且在块中发生,这些块的大小在一定程度上独立于传递给write()方法的缓冲区大小
。
接收程序从Socket的InputStream读取数据时,字节就从RecvQ移动到Delivered中,而转移的块的大小依赖于RecvQ中的数据量和传递给read()方法的缓冲区的大小。
TCP通信中由于底层队列填满而造成的死锁问题
造成死锁产生的原因是因为客户端在发送数据的同时,没有及时读取反馈回来的数据,从而使数据都阻塞在了底层的传输队列中
客户端:
1 | import java.io.FileInputStream; |
对该示例而言,当需要传递的文件容量不是很大时,程序运行正常,也能得到预期的结果,但如果尝试运行该客户端并传递给它一个大文件,改文件压缩后仍然很大(在此,大的精确定义取决于程序运行的系统,不过压缩后依然超过2MB的文件应该就可以使改程序产生死锁问题),那么客户端将打印出一堆W后停止,而且不会打印出任何R,程序也不会终止。
为什么会产生这种情况呢?我们来看程序,客户端很明显是一边读取本地文件中的数据,一边调用输出流的write()方法,将数据送入客户端主机的SendQ队列,直到文件中的数据被读取完,客户端才调用输入流的read()方法,读取服务端发送回来的数据。
考虑这种情况:客户端和服务端的SendQ队列和RecvQ队列中都有500字节的数据空间,而客户端发送了一个10000字节的文件,同时假设对于这个文件,服务端读取1000字节并返回500字节,即压缩比为2:1,当客户端发送了2000字节后,服务端将最终全部读取这些字节,并发回1000字节,由于客户端此时并没有调用输入流的read()方法从客户端主机的RecvQ队列中移出数据到Delivered,因此,此时客户端的RecvQ队列和服务端的SendQ队列都被填满了,此时客户端还在继续发送数据,又发送了1000字节的数据,并且被服务端全部读取,但此时服务端的write操作尝试都已被阻塞,不能继续发送数据给客户端,当客户端再发送了另外的1000字节数据后,客户端的SendQ队列和服务端的RecvQ队列都将被填满,后续的客户端write操作也将阻塞,从而形成死锁。
解决方案:
方案一是在编写客户端程序时,使客户端一边循环调用输出流的read()方法向服务端发送数据,一边循环调用输入流的read()方法读取从服务端反馈回来的数据,但这也不能完全保证不会产生死锁。
更好的解决方案是在不同的线程中执行客户端的write循环和read循环。一个线程从文件中反复读取未压缩的字节并将其发送给服务器,直到文件的结尾,然后调用该套接字的shutdownOutput()方法。另一个线程从服务端的输入流中不断读取压缩后的字节,并将其写入输出文件,直到到达了输入流的结尾(服务器关闭了套接字)。这样,便可以实现一边发送,一边读取,而且如果一个线程阻塞了,另一个线程仍然可以独立执行。这样我们可以对客户端代码进行简单的修改,将SendByes()方法调用放到一个线程中
当然,解决这个问题也可以不使用多线程,而是使用NIO机制(Channel和Selector)。
Socket编程
端口选择
在选择端口时,需要注意一点,就是0~1023这些端口都已经被系统预留了。这些端口为一些常用的服务所使用,比如邮件,FTP和HTTP。当你在编写服务器端的代码,选择端口时,请选择一个大于1023的端口
。
TCP
客户端向服务器端发送连接请求后,就被动地等待服务器的响应。典型的TCP客户端要经过下面三步操作:
1、创建一个Socket实例:构造函数向指定的远程主机和端口建立一个TCP连接;
2、通过套接字的I/O流与服务端通信;
3、使用Socket类的close方法关闭连接。
服务端的工作是建立一个通信终端,并被动地等待客户端的连接。典型的TCP服务端执行如下两步操作:
1、创建一个ServerSocket实例并指定本地端口,用来监听客户端在该端口发送的TCP连接请求;
2、重复执行:
1)调用ServerSocket的accept()方法以获取客户端连接,并通过其返回值创建一个Socket实例;
2)为返回的Socket实例开启新的线程,并使用返回的Socket实例的I/O流与客户端通信;
3)通信完成后,使用Socket类的close()方法关闭该客户端的套接字连接。
1 | String host = "127.0.0.1"; |
1 | try { |
UDP
Java通过DatagramPacket
类和DatagramSocket
类来使用UDP套接字,客户端和服务器端都通过DatagramSocket的send()方法和receive()方法来发送和接收数据,用DatagramPacket来包装需要发送或者接收到的数据。
发送信息时,Java创建一个包含待发送信息的DatagramPacket实例,并将其作为参数传递给DatagramSocket实例的send()方法;接收信息时,Java程序首先创建一个DatagramPacket实例,该实例预先分配了一些空间,并将接收到的信息存放在该空间中,然后把该实例作为参数传递给DatagramSocket实例的receive()方法。
在创建DatagramPacket实例时,要注意:如果该实例用来包装待接收的数据,则不指定数据来源的远程主机和端口,只需指定一个缓存数据的byte数组即可(在调用receive()方法接收到数据后,源地址和端口等信息会自动包含在DatagramPacket实例中),而如果该实例用来包装待发送的数据,则要指定要发送到的目的主机和端口。
通信步骤:
UDP客户端首先向被动等待联系的服务器发送一个数据报文。一个典型的UDP客户端要经过下面三步操作:
1、创建一个DatagramSocket实例,可以有选择地对本地地址和端口号进行设置,如果设置了端口号,则客户端会在该端口号上监听从服务器端发送来的数据;
2、使用DatagramSocket实例的send()和receive()方法来发送和接收DatagramPacket实例,进行通信;
3、通信完成后,调用DatagramSocket实例的close()方法来关闭该套接字。
由于UDP是无连接的,因此UDP服务端不需要等待客户端的请求以建立连接。另外,UDP服务器为所有通信使用同一套接字,这点与TCP服务器不同,TCP服务器则为每个成功返回的accept()方法创建一个新的套接字。一个典型的UDP服务端要经过下面三步操作:
1、创建一个DatagramSocket实例,指定本地端口号,并可以有选择地指定本地地址,此时,服务器已经准备好从任何客户端接收数据报文;
2、使用DatagramSocket实例的receive()方法接收一个DatagramPacket实例,当receive()方法返回时,数据报文就包含了客户端的地址,这样就知道了回复信息应该发送到什么地方;
3、使用DatagramSocket实例的send()方法向服务器端返回DatagramPacket实例。
UDP程序在receive()方法处阻塞,直到收到一个数据报文或等待超时。由于UDP协议是不可靠协议,如果数据报在传输过程中发生丢失,那么程序将会一直阻塞在receive()方法处,这样客户端将永远都接收不到服务器端发送回来的数据,但是又没有任何提示。为了避免这个问题,我们在客户端使用DatagramSocket类的setSoTimeout()方法来制定receive()方法的最长阻塞时间,并指定重发数据报的次数,如果每次阻塞都超时,并且重发次数达到了设置的上限,则关闭客户端。
客户端代码:
1 | private static final int TIMEOUT = 5000; //设置接收数据的超时时间 |
服务器代码:
1 | String str_send = "Hello UDPclient"; |
TCP与UDP编程的区别
1、UDP套接字和TCP套接字的一个微小但重要的差别:UDP协议保留了消息的边界信息。
DatagramSocket的每一次receive()调用最多只能接收调用一次send()方法所发送的数据,而且,不同的receive()方法调用绝对不会返回同一个send()方法所发送的数据
。
当在TCP套接字的输出流上调用write()方法返回后,所有调用者都知道数据已经被复制到一个传输缓存区中,实际上此时数据可能已经被发送,也有可能还没有被传送,而UDP协议没有提供从网络错误中恢复的机制,因此,并不对可能需要重传的数据进行缓存。这就意味着,当send()方法调用返回时,消息已经被发送到了底层的传输信道中
。
2、UDP数据报文所能负载的最多数据,亦及一次传送的最大数据为65507个字节(64K)
当消息从网络中到达后,其所包含的数据被TCP的read()方法或UDP的receive()方法返回前,数据存储在一个先进先出的接收数据队列中。对于已经建立连接的TCP套接字来说,所有已接受但还未传送的字节都看作是一个连续的字节序列。然而,对于UDP套接字来说,接收到的数据可能来自不同的发送者,一个UDP套接字所接受的数据存放在一个消息队列中,每个消息都关联了其源地址信息,每次receive()调用只返回一条消息。如果receive()方法在一个缓存区大小为n的DatagramPacket实例中调用,而接受队里中的第一条消息的长度大于n,则receive()方法只返回这条消息的钱n个字节,超出部分会被自动放弃,而且对接收程序没有任何消息丢失的提示
!
3、DatagramPacket的内部消息长度值在接收数据后会发生改变,变为实际接收到的数据的长度值。
每一个DatagramPacket实例都包含一个内部消息长度值,其初始值为byte缓存数组的长度值,而该实例一旦接受到消息,这个长度值便会变为接收到的消息的实际长度值,这一点可以用DatagramPacket类的getLength()方法来测试。如果一个应用程序使用同一个DatagramPacket实例多次调用receive()方法,每次调用前就必须显式地将其内部消息长度重置为缓存区的实际长度,以免接受的数据发生丢失(见上面客户端代码第53行,服务端代码第29行)。
4、DatagramPacket的getData()方法总是返回缓冲区的原始大小,忽略了实际数据的内部偏移量和长度信息。
由于DatagramPacket的getData()方法总是返回缓冲数组的原始大小,即刚开始创建缓冲数组时指定的大小,在上面程序中,该长度为1024,因此如果我们要获取接收到的数据,就必须截取getData()方法返回的数组中只含接收到的数据的那一部分。
在Java1.6之后,我们可以使用Arrays.copyOfRange()方法来实现
TCP连接中,服务器Socket会为每个客户端建立一个Socket连接,通过每个Socket连接的输入流可以从FIFO接收队列里源源不断取得数据。
然而,UDP连接中,因为没有建立连接的过程,所以服务器端是通过收到的数据报的协议字段来获取客户端IP和端口号的,所以它不能“源源不断”地接受客户端数据,它只会将完整的数据报一次性读取干净。所以说,TCP是通过流通信的,而UDP是通过数据报通信的
!
Socket传输JAVA对象
因为Socket本质上是依赖IO流来发送和接受数据,所以当需要传对象时,可以使用对象序列化。
具体步骤:
1、需要传输的对象类必须实现Serilizable标记接口
2、用ObjectOutputStream包装Socket的OutputStream,再writeObject
3、服务器端接收到客户端Socket后,用ObjectInputStream保证Socket的InputStream,再readObject
自定义Socket传输协议
在传输消息时,用Java内置的方法和工具确实很用,如:对象序列化,RMI远程调用等。
但有时候,针对要传输的特定类型的数据,实现自己的方法可能更简单、容易或有效。
显而易见地好处就是灵活性(决定传输的数据而非整个对象)
,根据协议最短排除收到的垃圾数据
自定义Socket传输协议的步骤:
1、编写满足业务需求的类
2、根据协议格式,实现将业务对象转化为byte[]的方法
3、通过OutputStream发送数据
4、服务器端获取输入流,读取输入流,实现根据byte[]还原(创建)业务对象的方法
该例子是一个简单的投票协议。这里,一个客户端向服务器发送一个请求消息,消息中包含了一个候选人的ID,范围在0~1000。程序支持两种请求:一种是查询请求,即向服务器询问候选人当前获得的投票总数,服务器发回一个响应消息,包含了原来的候选人ID和该候选人当前获得的选票总数;另一种是投票请求,即向指定候选人投一票,服务器对这种请求也发回响应消息,包含了候选人ID和获得的选票数(包含了刚刚投的一票)。
在实现一个协议时,一般会定义一个专门的类来存放消息中所包含的的信息
。在我们的例子中,客户端和服务端发送的消息都很简单,唯一的区别是服务端发送的消息还包含了选票总数和一个表示相应消息的标志。因此,可以用一个类来表示客户端和服务端的两种消息。下面的VoteMsg.java类展示了每条消息中的基本信息
1 | public class VoteMsg { |
接下来,我们要根据一定的协议来对其进行编解码,我们定义一个VoteMsgCoder接口,它提供了对投票消息进行序列化和反序列化的方法。toWrie()方法用于根据一个特定的协议,将投票消息转换成一个字节序列,fromWire()方法则根据相同的协议,对给定的字节序列进行解析,并根据信息的内容返回一个该消息类的实例。
1 | import java.io.IOException; |
下面给出实现了VoteMsgCoder接口的类,基于文本的编码方式
首先是用文本方式对消息进行编码的程序。该协议指定使用ASCII字符集对文本进行编码。消息的开头是一个所谓的”魔术字符串“,即一个字符序列,用于快速将投票协议的消息和网络中随机到来的垃圾消息区分开
,投票/查询布尔值被编码为字符形似,‘v’代表投票消息,‘i’代表查询消息。是否为服务器发送的响应消息,由字符‘R’指示,状态标记后面是候选人ID,其后跟的是选票总数,它们都编码成十进制字符串。
1 | import java.io.ByteArrayInputStream; |
toWire()方法简单地创建一个字符串,该字符串中包含了消息的所有字段,并由空白符隔开。fromWire()方法首先检查”魔术字符串“,如果在消息最前面没有魔术字符串,则抛出一个异常。在理说明了在实现协议时非常重要的一点:永远不要对从网络中来的任何输入进行任何假设。你的程序必须时刻为任何可能的输入做好准备,并能很好的对其进行处理
客户端:
1 | Socket client = new Socket("127.0.0.1", 12325); |
服务端:
1 | ServerSocket server = new ServerSocket(12325); |
应用程序协议中消息的成帧与解析
由于协议通常处理的是由一组字段组成的离散的信息,因此应用程序协议必须指定消息的接收者如何确定何时消息已被完整接收。成帧技术就是解决接收端如何定位消息首尾位置问题的
, 由于协议通常处理的是由一组字段组成的离散的信息,因此应用程序协议必须指定消息的接收者如何确定何时消息已被完整。主要有两种技术使接收者能够准确地找到消息的结束位置:
1、基于定界符
:消息的结束由一个唯一的标记指出,即发送者在传输完数据后显式添加的一个特定字节序列,这个特殊标记不能在传输的数据中出现(这也不是绝对的,应用填充技术能够对消息中出现的定界符进行修改,从而使接收者不将其识别为定界符)。该方法通常用在以文本方式编码的消息中。
2、显式长度
:在变长字段或消息前附加一个固定大小的字段,用来指示该字段或消息中包含了多少字节。该方法主要用在以二进制字节方式编码的消息中。
由于UDP套接字保留了消息的边界信息,因此不需要进行成帧处理(实际上,主要是DatagramPacket负载的数据有一个确定的长度,接收者能够准确地知道消息的结束位置),而TCP协议中没有消息边界的概念
因此,在使用TCP套接字时,成帧就是一个非常重要的考虑因素(在TCP连接中,接收者读取完最后一条消息的最后一个字节后,将受到一个流结束标记,即read()返回-1,该标记指示出已经读取到了消息的末尾,非严格意义上来讲,这也算是基于定界符方法的一种特殊情况)。
下面给出一个自定义实现上面两种成帧技术的Demo(书上的例子),先定义一个Framer接口,它由两个方法:frameMag()方法用来添加成帧信息并将指定消息输出到指定流,nextMsg()方法则扫描指定的流,从中抽取出下一条消息。
1 | import java.io.IOException; |
下面的代码实现了基于定界符的成帧方法,定界符为换行符“\n”,frameMsg()方法并没有实现填充,当成帧的字节序列中包含有定界符时,它只是简单地抛出异常;nextMsg()方法扫描刘,直到读取到了定界符,并返回定界符前面所有的字符,如果流为空则返回null,如果直到流结束也没找到定界符,程序将抛出一个异常来指示成帧错误。
1 | import java.io.ByteArrayOutputStream; |
下面的代码实现了基于长度的成帧方法,适用于长度小于65535个字节的消息。发送者首先给出指定消息的长度,并将长度信息以big-endian顺序(从左边开始,由高位到低位发送)存入2个字节的整数中,再将这两个字节存放在完整的消息内容前,连同消息一起写入输出流;在接收端,使用DataInputStream读取整型的长度信息,readFully()方法将阻塞等待,直到给定的数组完全填满。使用这种成帧方法,发送者不需要检查要成帧的消息内容,而只需要检查消息的长度是否超出了限制。
1 | import java.io.DataInputStream; |
基于线程池的TCP服务器
我们让服务器在启动时创建一个由固定线程数量组成的线程池,当一个新的客户端连接请求传入服务器,它将交给线程池中的一个线程处理,该线程处理完这个客户端之后,又返回线程池,继续等待下一次请求。如果连接请求到达服务器时,线程池中所有的线程都已经被占用,它们则在一个队列中等待,直到有空闲的线程可用。
实现步骤
1、与一客户一线程服务器一样,线程池服务器首先创建一个ServerSocket实例。
2、然后创建N个线程,每个线程反复循环,从(共享的)ServerSocket实例接收客户端连接。当多个线程同时调用一个ServerSocket实例的accept()方法时,它们都将阻塞等待,直到一个新的连接成功建立,然后系统选择一个线程,为建立起的连接提供服务,其他线程则继续阻塞等待。
3、线程在完成对一个客户端的服务后,继续等待其他的连接请求,而不终止。如果在一个客户端连接被创建时,没有线程在accept()方法上阻塞(即所有的线程都在为其他连接服务),系统则将新的连接排列在一个队列中,直到下一次调用accept()方法。
不用线程池的实现
1 | package zyb.org.server; |
1 | package zyb.org.server; |
使用线程池的实现
Executor接口代表了一个根据某种策略来执行Runnable实例的对象,其中可能包括了排队和调度等细节,或如何选择要执行的任务。
ava提供了大量的内置Executor接口实现,它们都可以简单方便地使用,ExecutorService接口继承于Executor接口,它提供了一个更高级的工具来关闭服务器,包括正常的关闭和突然的关闭。我们可以通过调用Executors类的各种静态工厂方法来获取ExecutorService实例,而后通过调用execute()方法来为需要处理的任务分配线程
它首先会尝试使用已有的线程,但如果有必要,它会创建一个新的线程来处理任务,另外,如果一个线程空闲了60秒以上,则将其移出线程池,而且任务是在Executor的内部排队,而不像之前的服务器那样是在网络系统中排队。
1 | package zyb.org.server; |
基于NIO的TCP通信
在标准IO的Socket编程中,套接字的某些操作可能会造成阻塞:accept()方法的调用可能会因为等待一个客户端连接而阻塞,read()方法也可能会因为没有数据可读而阻塞,write()方法在数据没有完全写入时也可能会发生阻塞,阻塞发生时,该线程被挂起,什么也干不了。
Java NIO引入了选择器的概念,选择器可以监听多个通道的事件(比如:连接打开,数据到达)。因此,单个的线程可以监听多个数据通道,这也是非阻塞IO的核心。而在标准IO的Socket编程中,单个线程则只能在一个端口监听。
NIO主要原理及使用
NIO采取通道(Channel)和缓冲区(Buffer)来传输和保存数据,它是非阻塞式的I/O
,即在等待连接、读写数据(这些都是在一线程以客户端的程序中会阻塞线程的操作)的时候,程序也可以做其他事情,以实现线程的异步操作。
考虑一个即时消息服务器,可能有上千个客户端同时连接到服务器,但是在任何时刻只有非常少量的消息需要读取和分发(如果采用线程池或者一线程一客户端方式,则会非常浪费资源),这就需要一种方法能阻塞等待,直到有一个信道可以进行I/O操作。NIO的Selector选择器就实现了这样的功能
一个Selector实例可以同时检查一组信道的I/O状态,它就类似一个观察者,只要我们把需要探知的SocketChannel告诉Selector,我们接着做别的事情,当有事件(比如,连接打开、数据到达等)发生时,它会通知我们,传回一组SelectionKey,我们读取这些Key,就会获得我们刚刚注册过的SocketChannel,然后,我们从这个Channel中读取数据,接着我们可以处理这些数据。
Selector内部原理实际是在做一个对所注册的Channel的轮询
访问,不断的轮询(目前就这一个算法),一旦轮询到一个Channel有所注册的事情发生,比如数据来了,它就会读取Channel中的数据,并对其进行处理。
要使用选择器,需要创建一个Selector实例,并将其注册到想要监控的信道上(通过Channel的方法实现)。最后调用选择器的select()方法,该方法会阻塞等待,直到有一个或多个信道准备好了I/O操作或等待超时,或另一个线程调用了该选择器的wakeup()方法。现在,在一个单独的线程中,通过调用select()方法,就能检查多个信道是否准备好进行I/O操作,由于非阻塞I/O的异步特性,在检查的同时,我们也可以执行其他任务。
基于NIO的TCP连接的建立步骤
服务端
1、传建一个Selector实例;
2、将其注册到各种信道,并指定每个信道上感兴趣的I/O操作;
3、重复执行:
1)调用一种select()方法;
2)获取选取的键列表;
3)对于已选键集中的每个键:
a、获取信道,并从键中获取附件(如果为信道及其相关的key添加了附件的话);
b、确定准备就绪的操纵并执行,如果是accept操作,将接收的信道设置为非阻塞模式,并注册到选择器;
c、如果需要,修改键的兴趣操作集;
d、从已选键集中移除键
客户端
与基于多线程的TCP客户端大致相同,只是这里是通过信道建立的连接,但在等待连接建立及读写时,我们可以异步地执行其他任务。
基于NIO的TCP通信Demo
客户端:
1 | import java.net.InetSocketAddress; |
服务端
1 | import java.io.IOException; |
1 | import java.nio.channels.SelectionKey; |
1 | import java.nio.channels.SelectionKey; |
说明:
以上的服务端程序,select()方法第一次能选择出来的准备好的信道都是服务端信道,其关联键值的属性都为OP_ACCEPT,亦及有效操作都为accept,在执行handleAccept方法时,为取得连接的客户端信道也进行了注册,属性为OP_READ,这样下次轮询调用select()方法时,便会检查到对read操作感兴趣的客户端信道(当然也有可能有关联accept操作兴趣集的信道),从而调用handleRead方法,在该方法中又注册了OP_WRITE属性,那么第三次调用select()方法时,便会检测到对write操作感兴趣的客户端信道(当然也有可能有关联read操作兴趣集的信道),从而调用handleWrite方法。
结果:
从结果中很明显地可以看出,服务器端在等待信道准备好的时候,线程没有阻塞,而是可以执行其他任务,这里只是简单的打印”.”,客户端在等待连接和等待数据读写完成的时候,线程没有阻塞,也可以执行其他任务,这里也正是简单的打印”.”。
注意:
1、对于非阻塞SocketChannel来说,一旦已经调用connect()方法发起连接,底层套接字可能既不是已经连接,也不是没有连接,而是正在连接。由于底层协议的工作机制,套接字可能会在这个状态一直保持下去,这时候就需要循环地调用finishConnect()方法来检查是否完成连接,在等待连接的同时,线程也可以做其他事情,这便实现了线程的异步操作。
2、write()方法的非阻塞调用只会写出其能够发送的数据,而不会阻塞等待所有数据,而后一起发送,因此在调用write()方法将数据写入信道时,一般要用到while循环,如:
1 | while(buf.hasRemaining()) |
3、任何对key(信道)所关联的兴趣操作集的改变,都只在下次调用了select()方法后才会生效。
4、selectedKeys()方法返回的键集是可修改
的,实际上在两次调用select()方法之间,都必须手动将其清空,否则,它就会在下次调用select()方法时仍然保留在集合中,而且可能会有无用的操作来调用它
,换句话说,select()方法只会在已有的所选键集上添加键,它们不会创建新的建集。
5、对于ServerSocketChannel来说,accept是唯一的有效操作
,而对于SocketChannel来说,有效操作包括读、写和连接,另外,对于DatagramChannle,只有读写操作是有效的
。
总结
基本套接字
1、编写TCP客户端程序,在实例化Socket类时,要注意,底层的TCP协议只能处理IP协议,如果传递的第一个参数是主机名字而不是你IP地址,Socket类具体实现的时候会将其解析成相应的地址,若因为某些原因连接失败,构造函数会抛出一个IOException异常。
2、TCP协议读写数据时,read()方法在没有可读数据时会阻塞等待,直到有新的数据可读
。另外,TCP协议并不能确定在read()和write()方法中所发送信息的界限,接收或发送的数据可能被TCP协议分割成了多个部分。
3、编写TCP服务器端的程序将在accept()方法处阻塞,以等待客户端的连接请求
,一旦取得连接,便要为每个客户端的连接建立一个Socket实例来进行数据通信。
4、在UDP程序中,创建DatagramPacket实例时,如果没有指定远程主机地址和端口,则该实例用来接收数据(尽管可以调用setXXX()等方法指定),如果指定了远程主机地址和端口,则该实例用来发送数据。
5、UDP程序在receive()方法处阻塞,直到收到一个数据报文或等待超时。由于UDP协议是不可靠协议,如果数据报在传输过程中发生丢失,那么程序将会一直阻塞在receive()方法处,这对客户端来说是肯定不行的,为了避免这个问题,我们在客户端使用DatagramSocket类的setSoTimeout()方法来制定receive()方法的最长阻塞时间,并指定重发数据报的次数
,如果每次阻塞都超时,并且重发次数达到了设置的上限,则关闭客户端。
6、UDP服务器为所有通信使用同一套接字,这点与TCP服务器不同,TCP服务器则为每个成功返回的accept()方法创建一个新的套接字。
7、在UDP程序中,DatagramSocket的每一次receive()调用最多只能接收调用一次send()方法所发送的数据,而且,不同的receive()方法调用绝对不会返回同一个send()方法所发送的额数据。
8、在UDP套接字编程中,如果receive()方法在一个缓冲区大小为n的DatagramPscket实例中调用,而接受队列中的第一个消息长度大于n,则receive()方法只返回这条消息的前n个字节,超出的其他字节部分将自动被丢弃,而且也没有任何消息丢失的提示。因此,接受者应该提供一个足够大的缓存空间的DatagramPacket实例,以完整地存放调用receive()方法时应用程序协议所允许的最大长度的消息。一个DatagramPacket实例中所运行传输的最大数据量为65507个字节,即UDP数据报文所能负载的最多数据,因此,使用一个有65600字节左右缓存数组的数据总是安全的。
9、在UDP套接字编程中,每一个DatagramPacket实例都包含一个内部消息长度值,而该实例一接收到新消息,这个长度值便可能改变(以反映实际接收的消息的字节数)。如果一个应用程序使用同一个DatagramPacket实例多次调用receive()方法,每次调用前就必须显式地将消息的内部长度重置为缓冲区的实际长度。
10、另一个潜在问题的根源是DatagramPacket类的getData()方法,该方法总是返回缓冲区的原始大小,忽略了实际数据的内部偏移量和长度信息。
发送和接收数据
1、程序间达成的一种包含了信息交换的形式和意义的共识称为协议,用来实现特定应用程序的协议叫做应用程序协议。
2、TCP/IP协议的唯一约束是:信息必须在块中发送和接收,而块的长度必须是8的倍数,因此,我们可以认为TCP/IP协议中传输的信息是字节序列。
3、关于字符,对于每个整数值都比255小的一组字符,因为其每个字符都能够作为一个单独的字节进行编码,因此不需要其他信息,而对于可能使用超过一个字节的大整数的编码方式,就有多种方式对其进行编码,这就是编码方案。编码字符集和字符的编码方案结合起来称为字符集。在网络编程中,发送者和接收者必须在文本字符串的表示方式上达成共识,最简单的方法就是使用同一个标准字符集。
4、成帧技术解决了消息接收端如何定位消息的首尾位置的问题。与UDP协议不同,TCP协议中没有消息边界的概念,因此在使用TCP套接字时,成帧就是一个非常重要的考虑因素。
5、主要有两种技术能够使消息接收者准确地找到消息的结束位置:基于定界符和显式长度,前者对消息的结束由一个唯一的标记指出,即发送者在传输完数据后显式添加一个特殊字符序列,这个特殊标记不能在传输的数据中出现,当然,填充技术能够对消息中出现的界定符进行修改,从而使接受者不将其识别为界定符;后者在变长字段或消息前附加一个固定大小的字段值,用来指示该字段或消息中包含多少个字节。
进阶
1、主线程结束后,其他线程也可以继续执行,Java虚拟机只有在所有非守护线程都执行完毕的情况下才终止。
2、服务器一般每分钟都要执行上千次客户端的请求,因此,为了更好的分析异常,大部分的服务器都会将它们的活动记录写入日志,Java中可以用java.util.logging.Logger类来实现相关功能,在Java中,每个日志记录器由一个全局唯一的名字识别,可以通过静态方法getLogger(string name)来获取者由名字name标示的唯一记录器。默认情况下,每个logger有一个ConsoleHandler用来将消息打印到System.err中。Logger的一个重要特征是它是线程安全的,即可以在并行运行的不同线程中调用它的方法,而不需要在调用者中添加额外的同步措施,如果没有这个特征,由不同线程记录的不同消息将错乱无章地写入到日志中。
3、用线程池实现TCP服务器端时,首先创建一个ServerSocket实例,然后创建N个线程,每个线程反复循环,从(共享的)ServerSocket实例接收客户端连接。当多个线程同时调用一个ServerSocket实例的accept()方法时,它们都将阻塞等待,直到一个新的连接成功建立,然后系统选择一个线程,用于刚刚建立起的新的连接,其他线程则继续阻塞等待。如果在一个客户端连接被创建时,没有线程在accept()方法上阻塞(即所有的线程都在为其他连接服务),系统则将新的连接排列在一个队列中,直到下一次调用accept()方法。
4、利用线程池实现服务器端程序时,线程池的大小需要根据负载情况进行调整,以使客户端连接时间最短,理想的情况是有一个调度工具,可以在系统负载增加时扩展线程池的大小(低于上限值),负载减轻时缩减线程池的大小。Java中提供了Executor接口来管理调度线程,它就代表了一个根据某种策略来执行Runnable实例的对象其中可能包含了排队和调度等细节,或如何选择要执行的任务。在使用Executor时,任务是在Executor内部排队,而不是在网络系统中排队
。
5、ExecutorService接口继承于Executor接口,当ExecutorService接口的实例调用execute()方法时,需要传入一个实现了Runnable接口的实例,如果必要,它将创建一个新的线程来处理任务,但它首先会尝试使用已有的线程,如果一个线程空闲了60秒以上,则将被移除线程池。值得注意的是,当达到稳定状态时,缓存线程池服务最终将保持合适的线程数,以使每个线程都保持忙碌,同时又很少创建或销毁线程。
6、阻塞式Socket编程中,Socket的I/O会因为多种原因而阻塞。数据输入方法read()和receive()在没有数据可读时会阻塞,TCP套接字的write()方法在没有足够的空间缓存传输的数据时可能阻塞,ServerSocket的accept()方法和Socket的构造函数都会阻塞等待。当调用一个已经阻塞的方法将使用应用程序停止,并使运行它的线程无效。
7、Write()方法调用会阻塞等待,直到最后一个字节成功写入到TCP实现的本地缓存中,如果可用的缓存空间比要写入的数据小,在write()方法调用返回前,必须把一些数据成功传输到连接的另一端。Java现在还没有提供任何使write()超时或有其他线程将其打断的方法,所以,如果一个可以在Socket实例上发送大量数据的协议可能会无限期地阻塞下去。
8、有两种类型的一对多服务:广播和多播。对于广播,(本地)网络中的所有主机都会接收到一份数据副本,对于多播,消息只是发送给一个多播地址,网络只是将数据分发给那些表示想要接收发送到该多播地址数据的主机。总的来说,只有UDP套接字允许广播和多播
。IPv4的多播地址范围是224.0.0.0到239.255.255.255,IPv6中的多播地址是任何由FF开头的地址。除了少数系统暴露的多播地址外,发送者可以向异常范围内的任何地址发送数据。Java中多播应用程序主要通过MulticastSocket实例进行通信。
9、在TCP Socket通信中,其中一端的read()方法返回-1表明通信的另一端关闭了套接字,更确切地说,是关闭了套接字所关联的输出流。
NIO
1、NIO主要包括两个部分:java.nio.channles包介绍Selector和Channel抽象,java.nio包介绍Buffer抽象。Selector和Channel抽象的关键点是:一次轮询一组客户端,查找哪个客户端需要服务;Buffer则提供了比Stream抽象更高效和可预测的I/O。Channel使用的不是流,正是Buffer缓冲区来发送或读写数据。
2、Buffer抽象代表了一个有限容量的数据容器,其本质是一个数组,由指针指示了在哪存放数据和从哪读取数据。使用Buffer有两个主要的好处:第一,与读写缓冲区数据相关联的系统开销暴露给了程序员,可以由程序员直接控制操作;第二,一些对Java对象的特殊Buffer映射操作能够直接操作底层平台的资源。这些操作节省了在不同地址空间中复制数据的开销——这在现代计算机体系结构中是开销很大的操作。
3、NIO的强大功能部分来自于channel的非阻塞特性。NIO的Channel抽象的一个重要特征就是可以通过配置它的阻塞行为,以实现非阻塞式的信道。在非阻塞式信道上调用一个方法总是会返回。例如,在一个非阻塞式ServerSocketChannel上调用accept()方法,如果有连接请求在等待,则返回客户端SocketChannel,否则,返回null;read()方法在没有数据可读时,不会阻塞等待,而是返回0。 在等待连接、读取数据等的时候,线程也可以做其他事情,这便实现了线程的异步操作
4、Selector类的select()方法会阻塞等待,直到有信道准备好了IO操作,或等待超时,或另一个线程唤醒了它(调用了该选择器的wakeup()方法)。select()方法返回的是自上次调用它之后,有多少通道变为就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。
5、我们在用Iterator迭代SelectionKey集合时,每次迭代末尾注意调用remove()方法。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除,以备下次该通道变成就绪时,Selector可以再次将其放入已选择键集中。如果不移除每个处理过的键,它就会在下次调用select()方法时仍然保留在集合中,而且可能会有无用的操作来调用它。Selector选择器实现了在单个线程中监听多个信道的功能。
6、缓冲区是定长的,不可以扩展容量,ByteBuffer是最常用的缓冲区。缓冲区中各索引值的大小关系:0=<mark=<position=<limit=<capacity。
7、allocateDirect()方法尝试分配直接缓存区,使用直接缓冲区,Java将从平台能够直接进行I/O操作的存储空间中为缓冲区分配后援存储空间,但不保证一定能成功,因此在尝试分配直接缓冲区后必须调用isDirect()方法进行检查,分配和销毁直接缓冲区通常要比分配和销毁非直接缓冲区消耗更多的系统资源。
8、Buffer的clear()方法并不改变缓冲区中的数据,它只是将position设置为0,并将limit设置为等于capacity,从而使缓冲区准备好从缓冲区的put操作或信道的读操作接收新的数据。flip()方法用来将缓冲区准备为数据传出状态,这通过将limit设置为position的当前值,再将position的值设为0来实现。Rewind()方法将position设置为0,并使mark值无效,limit值不变,这样便可以重复传送缓冲区中的数据。compact()方法将position与limit之间的元素复制到缓冲区的开始位置,从而为后续的read()/put()操作让出空间,但数据复制是一个非常耗费系统资源的操作,因此要保守地使用compact()方法。如果调用slice()方法创建了一个共享了原始缓冲区子序列的新缓冲区,则在先缓冲区上调用array()方法还是返回整个缓冲数组。
9、对于非阻塞SocketChannel来说,一旦已经调用connect()方法发起连接,底层套接字可能既不是已经连接,也不是没有连接,而是正在连接。由于底层协议的工作机制,套接字可能会在这个状态一直保持下去,这时候就需要循环地调用finishConnect()方法来检查是否完成连接,在等待连接的同时,线程也可以做其他事情,这便实现了线程的异步操作。
10、每个选择器都有一组与之关联的信道,一个信道也可以注册多个Selector实例,因此可以有多个关联的SelectionKey实例。任何对key所关联的兴趣操作集的改变,都只在下次调用select()方法后才会生效。对于serverSocketChannel来说,accept是唯一的有效操作,而对于socketChannel来说,有效操作包括读、写和连接,对于DatagramChannle,只有读写操作是有效的。一个信道可能只与一个选择器注册一次,因此后续对register()方法的调用只是简单地更新该key所关联的兴趣操作集。
11、 selectedKeys()方法返回的键集是可修改的,实际上在两次调用select()方法之间,都必须手动将其清空,换句话说,select()方法只会在已有的所选键集上添加键,它们不会创建新的建集。