Socket 编程学习篇
一、概念
TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。在简化的计算机网络OSI模型中(四层模型从下到上分别是:物理层/数据链路层、网络层/IP层、传输层TCP/UDP、应用层HTTP/FTP等),它完成第四层传输层所指定的功能,用户数据报协议(UDP)是同一层内 另一个重要的传输协议。在因特网协议族(Internet protocol suite)中,TCP层是位于IP层之上,应用层之下的中间层。不同主机的应用层之间经常需要可靠的、像管道一样的连接,但是IP层不提供这样的流机制,而是提供不可靠的包交换。
应用层向TCP层发送用于网间传输的、用8位字节表示的数据流,然后TCP把数据流分区成适当长度的报文段(通常受该计算机连接的网络的数据链路层的最大传输单元( MTU)的限制)。之后TCP把结果包传给IP层,由它来通过网络将包传送给接收端实体的TCP层。TCP为了保证不发生丢包,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的包发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据包就被假设为已丢失将会被进行重传。TCP用一个校验和函数来检验数据是否有错误;在发送和接收时都要计算校验和。
JAVA Socket
所谓socket 通常也称作”套接字“,用于描述IP地址和端口,是一个通信链的句柄。应用程序通常通过”套接字”向网络发出请求或者应答网络请求。
Socket和ServerSocket类库位于java.net包中。ServerSocket用于服务器端,Socket是建立网络连接时使用的。在连接成功时,应用程序两端都会产生一个Socket实例,操作这个实例,完成所需的会话。对于一个网络连接来说,套接字是平等的,并没有差别,不因为在服务器端或在客户端而产生不同级别。不管是Socket还是ServerSocket它们的工作都是通过SocketImpl类及其子类完成的。
重要的Socket API
java.net.Socket继承于java.lang.Object,有八个构造器,其方法并不多,下面介绍使用最频繁的三个方法,其它方法大家可以见JDK-1.3文档。
Accept方法用于产生”阻塞”,直到接受到一个连接,并且返回一个客户端的Socket对象实例。”阻塞”是一个术语,它使程序运行暂时”停留”在这个地方,直到一个会话产生,然后程序继续;通常”阻塞”是由循环产生的。
getInputStream方法获得网络连接输入,同时返回一个InputStream对象实例。
getOutputStream方法连接的另一端将得到输入,同时返回一个OutputStream对象实例。
注意:其中getInputStream和getOutputStream方法均会产生一个IOException,它必须被捕获,因为它们返回的流对象,通常都会被另一个流对象使用。
TCP编程
socket通信时,都是必须先启动服务端,再启动客户端才能建立连接实现通信的
客户端实现
package socket; |
服务器端实现
package socket; |
说明
如何告知服务端已经发送完信息
其实这个问题还是比较重要的,正常来说,客户端打开一个输出流,如果不做约定,也不关闭它,那么服务端永远不知道客户端是否发送完消息,那么服务端会一直等待下去,直到读取超时。所以怎么告知服务端已经发送完消息就显得特别重要。
1、通过Socket关闭
可以在客户端发送完消息后,采取手动关闭Socket的方式,服务端就会收到相应的关闭信号,那么服务端也就知道网络字节流
已经关闭了,这个时候读取操作完成,就可以继续后续的工作了。
但是这种方式有一些缺点:客户端Socket关闭后,将不能再接收服务端发送的消息,也不能再向服务端发送消息了。如果客户端想再次发送消息,需要重新创建Socket连接
2、通过Socket关闭输出流的方式
这种方式就是上面客户端代码中采用的方式,在发送完消息后,直接调用 socket.shutdownOutput()
方法,底层会告知服务端我这边已经写完了,那么服务端收到消息后,就能知道已经取完消息了,如果服务端有要返回给客户的消息,那么就可以通过服务端的输出流发送给客户端,如果没有,直接关闭Socket对象就可以了。
这种方式通过关闭客户端的输出流,告知服务端已经写完了,虽然可以读到服务端发送的消息,但是还是有一点点缺点:不能再次发送消息给服务端,如果再次发送,需要重新建立Socket连接。这个缺点,在访问频率比较高的情况下将是一个需要优化的地方。
3、通过约定符号
这种方式的用法,就是双方约定一个字符或者一个短语,来当做消息发送完成的标识,通常这么做就需要改造读取方法。假如约定单端的一行为end,代表发送完成,例如下面的消息,end则代表消息发送完成:
hello abc
end
那么服务端响应的读取操作需要进行如下改造:
Socket socket = server.accept(); |
可以看见,服务端不仅判断是否读到了流的末尾,还判断了是否读到了约定的末尾。
这么做的优缺点如下:
优点:不需要关闭流,当发送完一条命令(消息)后可以再次发送新的命令(消息)
缺点:需要额外的约定结束标志,太简单的容易出现在要发送的消息中,误被结束,太复杂的不好处理,还占带宽。
经过了这么多的优化还是有缺点,难道就没有完美的解决方案吗,答案是有的,看接下来的内容。
4、通过指定长度
如果你了解一点class文件的结构(后续会写,敬请期待),那么你就会佩服这么设计方式,也就是说我们可以在此找灵感,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。
如果你了解一点class文件的结构(后续会写,敬请期待),那么你就会佩服这么设计方式,也就是说我们可以在此找灵感,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。
现在首要的问题就是用几个字节指定长度呢,我们可以算一算:
1个字节:最大256,表示256B
2个字节:最大65536,表示64K
3个字节:最大16777216,表示16M
4个字节:最大4294967296,表示4G
依次类推,这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,其实如果你稍微了解一点UTF-8的编码方式(字符编码后续会写,敬请期待),那么你就应该能想到为什么一定要固定表示长度字节的长度呢,我们可以使用变长方式来表示长度的表示,比如:第一个字节首位为0:即0XXXXXXX,表示长度就一个字节,最大128,表示128B
第一个字节首位为110,那么附带后面一个字节表示长度:即110XXXXX 10XXXXXX,最大2048,表示2K
第一个字节首位为1110,那么附带后面二个字节表示长度:即110XXXXX 10XXXXXX 10XXXXXX,最大131072,表示128K。
依次类推,上面提到的这种用法适合高富帅的程序员使用,一般呢,如果用作命名发送,两个字节就够了,如果还不放心4个字节基本就能满足你的所有要求,下面的例子我们将采用2个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾。
服务端
package socket.type04; |
客户端
package socket.type04; |
客户端要多做的是,在发送消息之前先把消息的长度发送过去。这种事先约定好长度的做法解决了之前提到的种种问题,
Redis
的Java客户端Jedis
就是用这种方式实现的。当然如果是需要服务器返回结果,那么也依然使用这种方式。服务端也是先发送结果的长度,然后客户端进行读取。当然现在流行的是,长度+类型+数据模式的传输方式。