传输控制协议是一种基于流的网络通讯方法,它与其它的任何协议都有很大的不同。本文讨论TCP流以及在Java中怎样操作它。
一、概述
TCP提供的网络通讯接口与用户数据报协议(UDP)截然不同。TCP的特性使网络编程很具魅力,而且它删除了UDP的很多干扰部分(例如数据包的排序和丢失),简化了网络通讯。UDP关心的是数据包的传输,而TCP关注的是建立网络连接,并在网络连接中发送和接收字节流。
数据包可以通过网络用多种方法发送,并且它们到达的时间可能不同。这有利于性能的提高和程序的健壮性,因为单个包的丢失不一定干扰其它包的传输。但是,这样的系统使程序员必须作更多的工作,他们必须保证数据的送达(delivery)。TCP通过对发送和次序的保证消除了这些额外的工作,为客户端和支持两路(two-way)通讯的服务器之间提供了可靠的字节通讯流。它在两台计算机之间建立了"虚拟连接",可以通过虚拟连接发送数据流。
图1:TCP建立虚拟连接传输数据
TCP使用更低层的(lower-level)的IP通讯协议在两台计算机之间建立连接。这种连接提供了一个允许字节流发送和接收的接口,并且采用透明的方式把数据转换为IP数据报。数据报(datagram)的问题之一是不能保证数据包到达目的地。TCP解决了这个问题,它提供了有保证的数据字节的送达。当然,网络错误影响了送达也是可能的,但是TCP通过类似重新发送数据包解决了这种实现的问题,并且只在情况很严重(例如没有到网络主机的路由或连接丢失了)的时候才提醒程序员。
两台计算机之间的虚拟连接表现为套接字(socket)。套接字允许数据的发送和接收,但是UDP套接字和TCP套接字之间有本质的区别。首先TCP套接字连接到单个计算机,然而UDP套接字可以向多台计算机传输或接收数据;其次,UDP套接字只能发送和接收数据包,然而TCP允许通过字节流的数据传输(表现为输入流(InputStream)和输出流(OutputStream))。为了在网络上传输,它们被转换为数据包,不需要程序员干涉(如图2所示)。
图2:TCP把数据流处理为协议的命令,但是为在网络上传输把流转换为IP数据报
1、 UDP(用户数据报协议)上的TCP的优点
⑴自动化地错误控制
TCP流上的数据传输比通过UDP的信息包的传输更可靠。在TCP下层,通过虚拟连接发送的数据包括一个检查机制以确保它们没有被破坏(与UDP类似)。但是,TCP保证了数据的送达--在传输过程中丢失的数据包将被重新传输。
你也许想知道这是如何实现的--实际上,IP和UDP不保证送达,当数据包丢失的时候它们也不会发出任何警告。在TCP使用数据包发送了某个数据集合的时候就会启动一个计时器。在UDP中,我们使用 DatagramSocket.setSoTimeout为receive()操作启动一个计时器。在TCP中,如果接收者发送一个肯定的应答就禁止计时器,但是如果在超时前还没有收到肯定的应答,数据包就被重新传输。这意味着写入某个TCP套接字的任何数据将到达另一方而不需要程序员的进一步干涉(除非发生大的事故造成整个网络瘫痪)。错误控制的代码都由TCP处理了。
⑵可靠性
因为在TCP连接中有多方参与的两台计算机之间发送的数据通过IP数据报传输,数据包到达的次序可能经常出现不同。这可能需要使用一个循环从TCP套接字读取信息,因为字节流的次序可能被打乱并且频繁遇到不可靠的问题。幸运的是,次序等问题已经被TCP处理好了--每一个数据包都包含一个用于排序的序列号。后发送、先到达的数据包将保持在一个队列中,直到排好次序的数据可以使用为止。接着数据就可以通过套接字的接口传递到应用程序中。
⑶易于使用
尽管把信息存储为数据包的确没有超越程序员的范围,但这不会是计算机之间通讯的最高效率的途径。还应该有另外一些的复杂性,你可以讨论在某个底线之上设计和建立软件,为程序员提供足够的复杂性。典型情况下开发者欢迎软件开发复杂性的降低,TCP就实现了这种功能。TCP允许程序员用一种完全不同的方式思考问题,而这种方式更加现代化。数据不是被处理为不连续的单元(数据报包),而是被处理为连续的流,就像目前读者所熟悉的I/O流。TCP套接字延续了传统的Unix编程,在Unix编程中通讯与文件输入和输出是一样处理的。无论开发者写入网络套接字、通讯管道、数据结构、用户控制台或文件的时候,这种机制都时相同的。当然它也同样应用与读取信息。这使得通过TCP套接字进行通讯比通过数据报包通讯更加简单。
2、使用端口在应用程序之间通讯
很明显,TCP与UDP之间差别巨大,但是在两种协议之间也有一项重要的相似性。两种都共享了通讯端口的概念,它可以区别各个应用程序。在相同的端口上可以运行多个服务和客户端,而且希望不给它们分配端口号而挑选出某个应用程序是不可能的。当TCP套接字建立到某台计算机的连接的时候,它需要两部分非常重要的信息才能连接到远程客户端--该计算机的IP地址和端口号。此外,本地的IP地址和端口号也将绑定到它上面,因此远程计算机能够识别是哪一个应用程序建立了连接(图3所示)。总之,你不会希望你自己的电子邮件被在相同系统上运行软件的其它用户访问。
图3:本地端口识别了其它程序建立的到某个应用程序的连接,
允许多个TCP应用程序在同一台计算机上运行
TCP中的端口与UDP中的端口相似--它们的数字范围都是1-65535。1024以下的端口是受限制的,只能被知名的服务(例如HTTP、FTP、SMTP、POP3和telnet)使用。表1列举了一些知名的服务以及与它们对应的端口。
表1:协议和与它们相关的端口
知名的服务 服务端口
Telnet 23
SMTP(简单邮件传输协议) 25
HTTP(超文本传输协议) 80
POP 3 110
3、套接字操作
TCP套接字可以执行多种操作,包括:
建立到某个远程主机的连接。
给远程主机发送数据。
从远程主机接收数据。
关闭连接。
此外还有一些特殊类型的套接字,它们提供绑定到特定端口号的服务。这类套接字通常用在服务器中,可以执行下面一些操作:
绑定到某个本地端口
从远程主机接收输入的连接
从本地端口取消绑定。
这两种套接字可以被分为不同的类,要么是客户端使用的,要么是服务器使用的(由于某些客户端可以象服务器一样操作,还有些服务器可以象客户端一样操作)。但是,客户端和服务器的角色还是可以靠经验区分的。
二、TCP和客户端/服务器范型
在网络编程中(同样在其它形式的通讯中,例如数据库编程),使用套接字的应用程序也被分为两类--客户端程序和服务器程序。你可能对"客户端/服务器编程"术语比较熟悉,尽管这个术语的准确意思你不一定清楚。下面的范例就是讨论这个主题。
1、 客户端/服务器范型
客户端/服务器范型把软件分为两类--客户端程序和服务器程序。客户端软件启动一个连接并发送请求,而服务器软件监听连接并处理请求。在UDP编程环境中,没有建立实际的连接,并且UDP应用程序可以在相同的套接字上建立并接收请求。在TCP环境中,两台计算机之间建立了连接,客户端/服务器范型是相对应的。
当软件作为客户端或者服务器的时候,它严格地定义了角色以更容易适应我们所熟悉的思维模型。软件要么启动请求,要么处理请求。在两种角色之间切换使系统更加复杂。即使允许切换,在某个特定的时刻软件程序也只能是客户端,而另一个必须是服务器。如果两个同时是客户端,就没有服务器处理请求了。
客户端/服务器范型是一个重要的理论概念,它广泛用于实际应用程序中。目前也有其它的通讯模型,例如对等(peer to peer)模型,在这种模型中每一方都可以启动通讯。但是客户端/服务器概念是更加流行的选择,因为它很简单并且在多数网络编程中使用。
2、网络客户端
网络客户端启动连接,通常处理网络事务。服务器程序用于实现客户端的请求--客户端不用实现服务器的请求。尽管客户端处于控制地位,但是服务器端仍然有一些功能。客户端可以要求服务器删除本地文件系统的所有文件,但是服务器并不是必须执行这个任务的。
网络客户端使用双方都同意的通讯标准(即网络协议)与服务器对话。例如HTTP客户端使用的命令组就与邮件客户端使用的不同,而且目的也完成不同。把HTTP连接到邮件服务器,或邮件客户端连接到HTTP服务器,要么会出现一个错误消息,要么出现一个客户端不能理解的错误消息。因为这个原因,作为协议规格的一部分,必须使用某个端口号,这样客户端才能定位服务器。Web服务器通常运行在80端口上,而其它一些服务器可能运行在非标准的端口上,URL的习惯是不列出端口的,它假定使用80端口。
3、网络服务器
网络服务器的角色是绑定某个特定的端口(客户端使用它定位服务器),并且监听新的连接。尽管客户端是临时的,并且只有在用户选中的时候才运行,但是服务器程序必须不间断地运行(即使实际上没有已连接的客户端),期望某个客户端在某个时刻需要该服务。服务器程序通常作为数据自适应监视器进程引用,使用Unix用法。它持久的运行,而且一般在该服务器程序的主机启动时启动。因此服务器一直等待,直到某个客户端建立到该服务器端口的连接。有些服务器程序在某个时刻只能处理单个连接,其它一些服务器程序可以通过使用多线程同时处理多个连接。
当开始连接后,服务器就服从客户端。它等待客户端发送请求,并且"忠实地"处理它们(可是服务器可以响应错误信息,特别是当请求违反某些重要地协议规则或有安全风险的时候)。某些协议(例如HTTP/1.0)通常在每个连接中只允许一个请求,而其它一些协议(例如POP3)支持一系列请求。服务器可以通过发送响应或错误消息应答客户端的请求。学习新的网络协议(编写客户端或服务器)与学习一种新的语言相似,只是语法改变了。但是典型情况下,它的命令的数量更小,使事情更简单。服务器的行为一部分由协议决定,一部分由开发者决定(某些命令是可选的,服务器不一定支持)。
三、TCP套接字和Java
Java提供了对TCP套接字的良好的支持,有两种套接字类:java.net.Socket和java.net.ServerSocket。当编写连接到已有服务的客户端软件的时候使用Socket类。当编写绑定到本地端口以提供服务的服务器软件的时候使用ServerSocket类。这是与DatagramSocket的UDP工作方式不同的地方--在TCP中,连接服务器地和从客户端接收数据的函数被分为两个独立的类。
四、Socket类
Socket类表现了客户端套接字,它是属于一台或两台计算机的两个TCP通讯端口之间的通讯通道。端口可以连接到本地系统的另一个端口,这样可以避免使用另一台计算机,但是大多数网络软件将使用两台计算机。但是TCP套接字不能与两台以上的计算机通讯。如果需要这种功能,客户端应用程序必须建立多个套接字连接,每台计算机一个套接字。
构造函数
java.net.Socket类有几个构造函数。其中两个构造函数允许使用布尔型参数指定是否使用UDP或TCP套接字,我们不赞成使用它们。这儿没有使用这两个构造函数,并且没有列举在此处--如果需要UDP功能,请使用DatagramSocket。
try
{
// 连接到指定的主机和端口
Socket mySocket = new Socket ( "www.awl.com", 80);
// ......
}
catch (Exception e)
{
System.err.println ("Err - " + e);
}
但是还有很多构造函数可以用于不同的情形。除非特别指出,所有的构造函数都是公共的。
· protected Socket ()-使用当前套接字产生组件提供的默认实现建立不连接的套接字。开发者一般不应该使用这个方法,因为它不允许指定主机名称和端口。
· Socket (InetAddress address, int port)产生 java.io.IOException异常。
· java.lang.SecurityException-建立连接到指定的IP地址和端口的套接字。如果不能建立连接,或连接到主机违反了安全性约束条件(例如某个小的服务程序试图连接到某台计算机而不是载入它的计算机时),就产生这种异常。
· Socket (InetAddress address, int port, InetAddress localAddress, int localPort)产生java.io.IOException、java.lang.SecurityException异常-建立连接到指定的地址和端口的套接字,并把它绑定到特定的本地地址和本地端口。默认情况下,使用一个自由(空)的端口,但是在多地址主机环境(例如本地主机有两个或多个的计算机)中,该方法也允许你指定一个特定的端口号、地址。
· protected Socket (SocketImpl implementation)--使用特定的套接字的实现(implementation)建立未连接的套接字。通常情况下开发者不应该使用这个方法,因为它允许指定主机名称和端口。
· Socket (String host, int port)产生java.net.UnknownHostException、java.io.IOException、java.lang.SecurityException异常--建立连接到特定主机和端口的套接字。这个方法允许指定一个字符串而不是一个InetAddress。如果指定的主机名称不能够解析,就不能建立连接,如果违反了安全性约束条件就产生异常。
· Socket (String host, int port, InetAddress localAddress, int localPort)产生java.net.UnknownHostException、java.io.IOException、java.lang.SecurityException异常--建立连接到特定主机和端口的套接字,并绑定到特定的本地端口和地址。它允许指定字符串形式的主机名称,而不是指定InetAddress实例,同时它允许指定一个将绑定的本地地址和端口。这些本地参数对于多地址主机(如果可以通过两个或更多IP地址访问的计算机)是有用的。如果主机名称不能解析,就不能建立连接,如果违反了安全性约束条件会产生异常。
1、建立套接字
在正常环境下,建立套接字的时候它就连接了某台计算机和端口。尽管有一个空的构造函数,它不需要主机名称或端口,但是它是受保护的(protected),在正常的应用程序中不能够调用它。此外,不存在用于在以后指定这些细节信息的connect()方法,因此在正常的环境下建立套接字的时候就应该连接了。如果网络是好的,在建立连接的时候,调用套接字构造函数将立即返回,但是如果远程计算机没有响应,构造函数方法可能会阻塞一段时间。这是随着系统的不同而不同的,它依赖于多种因素,例如正在使用的操作系统和默认的网络超时设置(例如本地局域网中的一些计算机一般比Internet上的计算机响应得快)。你甚至不能肯定套接字将阻塞多长的时间,但是这是非正常的行为,并且它不会频繁出现。即使如此,在关键事务系统中把此类调用放在第二个线程中或许更合适,这样可以防止应用程序停止。
注意
在较低的层次,套接字是由套接字产生组件(socket factory)产生的,它是一个负责建立适当的套接字实现的特殊的类。在正常环境下,将会产生标准的java.net.Socket,但是在一些特殊的情形中,例如使用自定义套接字的特殊的网络环境(例如通过使用特殊的代理服务器穿透防火墙),套接字产生组件实际上可能返回一个套接字子类(subclass)。对于错综复杂的Java网络编程比较熟悉,明确为了建立自定义套接字和套接字产生组件的有经验的开发者可以去了解套接字产生组件的细节信息。对于这个主题的更多信息,你可以查看java.net.SocketFactory和java.net.SocketImplFactory类的Java API文档。
2、使用套接字
套接字可以执行大量的事务,例如读取信息、发送数据、关闭连接、设置套接字选项等等。此外,下面提供的方法可以获取套接字的信息(例如地址和端口位置):
方法
· void close()产生java.io.IOException异常--关闭套接字连接。关闭连接可能允许也可能不允许继续发送剩余的数据,这依赖于SO_LINGER套接字选项的设定。我们建议开发者在关闭套接字连接之前清除所有的输出流。
· InetAddress getInetAddress()--返回连接到套接字的远程主机的地址。
· InputStream getInputStream()产生java.io.IOException异常--返回一个输入流,它从该套接字连接到的应用程序读取信息。
· OutputStream getOutputStream()产生java.io.IOException异常--返回一个输出流,它向套接字连接到的应用程序写入信息。
· boolean getKeepAlive()产生java.net.SocketException异常--返回SO_KEEPALIVE套接字选项的状态。
· InetAddress getLocalAddress()--返回与套接字关联的本地地址(在多地址计算机中有用)。
· int getLocalPort()--返回该套接字绑定在本地计算机上的端口号。
· int getPort()--返回套接字连接到的远程服务的端口号。
· int getReceiveBufferSize()产生java.net.SocketException异常--返回套接字使用的接收缓冲区大小,由SO_RCVBUF套接字选项的值决定。
· int getSendBufferSize()产生java.net.SocketException异常--返回套接字使用的发送缓冲区大小,由SO_SNDBUF套接字选项的值决定。
· int getSoLinger()产生java.net.SocketException异常--返回SO_LINGER套接字选项的值,它控制连接终止的时候未发送的数据将排队多长时间。
· int getSoTimeout()产生java.net.SocketException异常--返回SO_TIMEOUT套接字选项的值,它控制读取操作将阻塞多少毫秒。如果返回值为0,计时器就被禁止了,该线程将无限期阻塞(直到数据可以使用或流被终止)。
· boolean getTcpNoDelay()产生java.net.SocketException异常--如果TCP_NODELAY套接字选项的设置打开了返回"true",它控制是否允许使用Nagle算法。
· void setKeepAlive(boolean onFlag)产生java.net.SocketException异常--允许或禁止SO_KEEPALIVE套接字选项。
· void setReceiveBufferSize(int size)产生java.net.SocketException异常--修改SO_RCVBUF套接字选项的值,它为操作系统的网络代码推荐用于接收输入的数据的缓冲区大小。并不是每种系统都支持这种功能或允许绝对控制这个特性。如果你希望缓冲输入的数据,我们建议你改用BufferedInputStream或BufferedReader。
· void setSendBufferSize(int size)产生java.net.SocketException异常--修改SO_SNDBUF套接字选项的值,它为操作系统的网络代码推荐用于发送输入的数据的缓冲区大小。并不是每种系统都支持这种功能或允许绝对控制这个特性。如果你希望缓冲输入的数据,我们建议你改用BufferedOutputStream或Buffered Writer。
· static void setSocketImplFactory (SocketImplFactory factory)产生java.net.SocketException、java.io.IOException、java. lang.SecurityException异常--为JVM指定一个套接字实现的产生组件,它可以已经存在,也可能违反了安全性约束条件,无论是哪种情况都会产生异常。只能指定一个产生组件,当建立套接字的时候都会使用这个产生组件。
· void setSoLinger(boolean onFlag, int duration)产生java.net. SocketException、java.lang.IllegalArgumentException异常--激活或禁止SO_LINGER套接字选项(根据布尔型参数onFlag的值),并指定按秒计算的持续时间。如果指定负值,将产生异常。
· void setSoTimeout(int duration)产生java.net.SocketException异常--修改SO_TIMEOUT套接字选项的值,它控制读取操作将阻塞多长时间(按毫秒计)。0值会禁止超时设置,引起无限期阻塞。如果发生了超时,当套接字的输入流上发生读取操作的时候,会产生java.io.IOInterruptedException异常。这与内部的TCP计时器是截然不同的,它触发未知报文包的重新发送过程。
· void setTcpNoDelay(boolean onFlag)产生java.net.SocketException异常--激活或禁止TCP_NODELAY套接字选项,它决定是否使用Nagle算法。
· void shutdownInput()产生java.io.IOException异常--关闭与套接字关联的输入流,并删除所有发送的更多的信息。对输入流的进一步的读取将会遭遇流的结束标识符。
· void shutdownOutput()产生java.io.IOException异常--关闭与套接字关联的输出流。前面写入的、但没有发送的任何信息将被清除,紧接着是TCP连接终止,它通知应用程序没有更多的数据可以使用了(在Java应用程序中,这样就到达了流的末尾)。向套接字进一步写入信息将引起IOException异常。
3、 向TCP套接字读取和写入信息
在Java中使用TCP建立用于通讯的客户端软件极其简单,无论使用哪种操作系统都一样。Java网络API提供了一致的、平台无关的接口,它允许客户端应用程序连接到远程服务。一旦建立了套接字,它就已经连接了并准备使用输入和输出流读取/写入信息了。这些流都不需要建立,它们是Socket. getInputStream()和Socket.getOutputStream()方法提供的。
为了简化编程,过滤器可以很容易地连接到套接字流。下面的代码片断演示了一个简单的TCP客户端,它把BufferedReader连接到套接字输入流,把PrintStream连接到套接字输出流。
try
{
// 把套接字连接到某台主机和端口
Socket socket = new Socket ( somehost, someport );
// 连接到被缓冲地读取程序
BufferedReader reader = new BufferedReader (
new InputStreamReader ( socket.getInputStream() ) );
// 连接到打印流
PrintStream pstream =
new PrintStream( socket.getOutputStream() );
}
catch (Exception e)
{
System.err.println ("Error - " + e);
}
4、套接字选项
套接字选项是改变套接字工作方式的设置,并且它们能影响(正反两方向)应用程序的性能。对于套接字选项的支持是在Java 1.1中引入的,在后面的一些版本中对其中一些做了改进(例如在Java 2 和Java 3中支持SO_KEEPALIVE选项)。通常情况下,不应该修改套接字选项,除非有很必要的原因,因为这种改变可能反面影响应用程序和网络的性能(例如,激活Nagle算法可能提高telnet类型应用程序的性能,但是会降低可以使用地网络带宽)。唯一的例外是SO_TIMEOUT选项--事实上,如果套接字连接的应用程序传输数据出现失败的时候,它都应该温和地处理超时问题,而不应该因此延迟速度。
⑴SO_KEEPALIVE套接字操作
Keepalive(保持活动)套接字选项是很有争议的,一些开发者认为使用它会很强大。在默认情况下,两个连接的套接字之间没有数据发送,除非应用程序有需要发送的数据。这意味着在长期存活的进程中空闲地的接字可能几分钟、几小时、甚至于几天不会提交数据。但是,假设某个客户端崩溃了,并且连接终结序号没有发送给TCP服务器。贵重的资源(例如CPU时间和内存)将会浪费在哪个永远不会响应的客户端上。如果允许keepalive套接字选项,套接字的另一端可以探测以验证它是否仍然是活动的。但是,应用程序不能控制keepalive探测器的发送频率。为了激活keepalive,需要调用Socket.setSoKeepAlive(boolean)方法,参数的值为"true"("false"值将禁止它)。例如,为了在某个套接字上允许keepalive,可能使用下面的代码:
// 激活SO_KEEPALIVE
someSocket.setSoKeepAlive(true);
尽管keepalive的好处并不多,但是很多开发者提倡在更高层次的应用程序代码中控制超时设置和死的套接字。同时需要记住,keepalive不允许你为探测套接字终点(endpoint)指定一个值。我们建议开发者使用的另一种比keepalive更好的解决方案是修改超时设置套接字选项。
⑵SO_RCVBUF套接字操作
接收缓冲区套接字选项控制用于接收数据的缓冲区。你可以通过调用方法改变它的大小。例如,为了把缓冲区大小改变为4096,可以使用下面的代码:
// 修改缓冲区大小
someSocket.setReceiveBufferSize(4096);
注意:修改接收缓冲区大小的请求不能保证改变成功。例如,有些操作系统可能不允许修改这个套接字选项,并忽略对该值的任何改变。你可以调用Socket. getReceiveBufferSize()方法得到当前缓冲区的大小。使用缓冲的更好的选择是使用BufferedInputStream/BufferedReader。
⑶ SO_SNDBUF套接字操作
发送缓冲区套接字选项控制用于发送数据的缓冲区的大小。通过调用Socket.setSendBufferSize(int)方法,你能够试图改变缓冲区的大小,但是改变缓冲区大小的请求可能被操作系统拒绝。
// 把发送缓冲区的大小改为4096字节
someSocket.setSendBufferSize(4096);
为了得到当前发送缓冲区的大小,你可以调用Socket.getSendBufferSize()方法,它返回一个整型值。
// 得到默认的大小
int size = someSocket.getSendBufferSize();
使用DatagramSocket类时改变缓冲区大小可能更有效。当对写进行缓冲的时候,更好的选择是使用BufferedOutputStream和BufferedWriter。
⑷ SO_LINGER套接字操作
当某个TCP套接字连接被关闭的时候,可能还有一些数据在队列中等待发送但是还没有被发送(特别是在IP数据报在传输过程中丢失了,必须重新发送的情况下)。Linger(拖延)套接字选项控制未发送的数据可能发送的时间总和,过了这个时间以后数据就会被完全删除。通过使用Socket.setSoLinger(boolean onFlag, int duration)方法完全激活/禁止linger选项、或者修改linger的持续时间都是可以的。
// 激活linger,持续50秒
someSocket.setSoLinger( true, 50 );
⑸ TCP_NODELAY套接字操作
这个套接字选项是一个标记,它的状态控制着是否激活Nagle算法(RFC 896)。因为TCP数据是使用IP数据报在网络上发送的,因此每个包都有一定位数的开销(例如IP和TCP头部信息)。如果在某个时刻每个包中只发送了少量的字节,头部信息的大小将远远超过数据的大小。在局域网中,发送的额外的数据可能不会很多,但是在Internet上,成百、成千、甚至于成百万地客户端可能通过某个路由器发送这种数据包,加起来显著地增加了带宽的消耗。
解决的方法是Nagle算法,它规定TCP在一个时刻只能发送一个数据报。当每个IP数据报得到肯定应答的时候,才能发送新的队列中包含数据的数据报。它限制了数据报头部信息消耗的带宽总量,但是有不太重要的代价--网络延迟。因为数据被排队了,它们不是立即发送的,因此需要快速响应时间的系统(例如X-Windows或telnet)的速度被减慢了。禁止Nagle算法可能提高性能,但是如果被太多的客户端使用,网络性能也会降低。
可以通过调用Socket.setTcpNoDelay(boolean state)方法激活或禁止Nagle算法。例如,为了禁止该算法,可能使用下面的代码:
// 为了得到更快的响应时间禁止Nagle算法
someSocket.setTcpNoDelay(false);
为了获取Nagle算法的状态和TCP_NODELAY标识符,可以使用Socket.getTcpNoDelay()方法:
// 得到TCP_NODELAY标识符的状态
boolean state = someSocket.getTcpNoDelay();
⑹ SO_TIMEOUT套接字操作
超时设置选项是最有用的套接字选项。在默认情况下,I/O操作(基于文件的或基于网络的)都是阻塞的操作。试图从InputStream读取数据将无限期等待直到输入到达。如果输入永远没有到达,应用程序将停止并且在大多数情况下变得不可用(除非使用了多线程)。用户不喜欢不能响应的应用程序,他们认为这类应用程序行为很讨厌。更牢固的应用程序应该预料到这类问题并采取正确的操作。
注意
在测试期间的本地内部网环境中网络问题很少,但是在Internet上,应用程序停止是很可能的。服务器应用程序并没有免疫力--服务器也使用Socket类连接客户端,并且很容易停止。因为这个原因,所有的应用程序(无论是客户端或者服务器)都应该温和地处理网络超时的问题。
当激活SO_TIMEOUT选项时,任何向套接字的InputStream的读取请求都会启动一个计时器。当数据没有按时到达并且计时器超期的时候,就产生java.io.InterruptedIOException异常,你可以捕捉该异常。接着就是应用程序开发者的工作了--可以再次尝试、通知用户或取消连接。可以调用Socket. setSoTimeout(int)方法控制计时器的持续时间,它的参数是等待数据的毫秒数。例如,为了设置5秒钟超时,将使用下面的代码:
// 设置5秒钟超时
someSocket.setSoTimeout ( 5 * 1000 );
激活设置后,任何读取数据的企图都可能产生InterruptedIOException异常,该异常扩展自java.io.IOException类。由于读取数据的企图可能已经产生了IOException异常,所以不需要更多的代码来处理该异常了--但是,有些应用程序可能希望逐步捕捉与超时设置相关地异常,在这种情况下可能需要添加另外地异常处理代码:
try
{
Socket s = new Socket (...);
s.setSoTimeout ( 2000 );
// 执行一些读取操作
}
catch (InterruptedIOException iioe)
{
timeoutFlag = true; // 执行一些操作,例如设置标识符
}
catch (IOException ioe)
{
System.err.println ("IO error " + ioe);
System.exit(0);
}
为了得到TCP计时器的长度,可以使用Socket.getSoTimeout()方法,它返回一个整型值。如果返回值为零表明超时设定被禁止了,任何读取操作将无限期阻塞。
// 查看超时设定是否为零
if ( someSocket.getSoTimeout() == 0) someSocket.setSoTimeout (500);
五、建立TCP客户端
讨论了套接字类的功能后,我们将分析一个完整的TCP客户端程序。此处我们将看到的客户端程序是一个daytime客户端,它连接到一个daytime服务器程序以读取当前的日期和时间。建立套接字连接并读取信息是一个相当简单的过程,只需要少量的代码。默认情况下daytime服务运行在13端口上。并非每台计算机都运行了daytime服务器程序,但是Unix服务器是客户端运行的很好的系统。如果你没有访问Unix服务器的权限,在第七部分我们给出了TCP daytime服务器程序代码--有了这段代码客户端就可以运行了。
DaytimeClient的代码
DaytimeClient是如何工作的
Daytime应用程序是很容易理解的,它使用了文章前面谈到的概念。建立套接字、获取输入流,在很少的事件中(在连接时像daytime一样简单的服务器程序失败)激活超时设置。不是连接已筛选过的流,而是把有缓冲的读取程序连接到套接字输入流,并且把结果显示给用户。最后,在关闭套接字连接后客户端终止。这是你可能得到的最简单的套接字应用程序了--复杂性来自实现的网络协议,而不是来自具体网络的编程。
运行DaytimeClient
运行上面的应用程序很简单。简单地把运行daytime服务的计算机的主机名称作为命令行参数指定并运行它就可以了。如果daytime服务器程序使用了非标准的端口号(在后面会讨论),记得需要改变端口号并重新编译。
例如,如果服务器程序在本机上,为了运行客户端将使用下面的命令:
java DaytimeClient localhost
注意
Daytime服务器程序必须正在运行中,否则该客户端程序将不能建立连接。例如如果你正在使用Wintel系统而不是Unix,那么你需要运行DaytimeServer(后面会谈到)。
六、ServerSocket类
服务器套接字是一种特定类型的套接字,它用于提供TCP服务。客户端套接字绑定到本地计算机的任何空的端口,并且连接到特定服务器程序的端口和主机。服务器套接字与它的差别是它们绑定到本地计算机的某个特定的端口,这样远程客户端才能定位某种服务。客户端套接字连接只能连接到一台计算机,然而服务器套接字能够满足多个客户端的请求。
它工作的方法很简单--客户端知道服务运行在某个特定的端口(通常端口号是知名的,并且特定的协议使用特定的端口号,但是服务器程序也可能运行在非标准的端口上)。它们建立连接,在服务器程序内部,连接会被接受。服务器程序可以同时接受多个连接,在某个给定的时刻也可以选择只接受一个连接。某个连接被接受后,它就表现为正常的套接字,形式为Socket对象--一旦你掌握了Socket类,编写服务器程序就和编写客户端程序几乎一样简单了。服务器程序和客户端程序的唯一区别是服务器程序帮定到特定的端口,使用ServerSocket对象。ServerSocket对象就像创建客户端连接的工厂--你不必亲自建立Socket类的实例。这些连接都模拟正常的套接字,因此你能够把输入和输出过滤流关联到这些连接上。
1、建立ServerSocket
你在建立服务器套接字后,就应该把它绑定到某个本地端口并准备接受输入的连接。当客户端试图连接的时候,它们被放入一个队列中。一旦这个队列中的所有空间都被耗尽,其它的连接的就会被拒绝。
构造函数
建立服务器套接字的最简单的途径是绑定到某个本地地址,该地址作为使用构造函数的唯一的参数。例如,为了在端口80(通常用于Web服务器程序)上提供某个服务,将使用下面的代码片断:
这是ServerSocket构造函数的最简单的形式,但是下面有一些其它的允许更多自定义的构造函数。所有这些函数都是公共的。
· ServerSocket(int port)产生java.io.IOException、java.lang.SecurityException异常--把服务器套接字绑定到特定的端口号,这样远程客户端才能定位TCP服务。如果传递进来的值为零(zero),就使用任何空闲的端口--但是客户端可能没办法访问该服务,除非用什么方式通知了客户端端口号是多少。在默认情况下,队列的大小设置为50,但是也提供了备用的构造函数,它允许修改这个设置。如果端口已经被绑定了,或者安全性约束条件(例如安全性规则或知名端口上的操作系统约束条件)阻挡了访问,就会产生异常。
· ServerSocket(int port, int numberOfClients)产生java.io.IOException、java.lang.SecurityException异常--把服务器套接字绑定到特定的端口号并为队列分配足够的空间用于支持特定数量的客户端套接字。它是ServerSocket(int port)构造函数的重载版本,如果端口已经被绑定了或安全性约束条件阻挡了访问,就产生异常。
· ServerSocket(int port, int numberOfClients, InetAddress address)产生java.io.IOException、java.lang.SecurityException异常--把服务器套接字绑定到特定的端口号,为队列分配足够的空间以支持特定数量的客户端套接字。它是ServerSocket(int port, int numberOfClients)构造函数的重载版本,在多地址计算机上,它允许服务器套接字绑定到某个特定的IP地址。例如,某台计算机可能有两块网卡,或者使用虚拟IP地址把它配置成像几台计算机一样工作的时候。如果地址的值为空(null),服务器套接字将在所有的本地地址上接受请求。如果端口已经被绑定了或者安全性约束条件阻挡了访问,就产生异常。
2、使用ServerSocket
虽然Socket类几乎是通用的,并且有很多方法,但是Server Socket类没有太多的方法,除了接受请求并作为模拟客户端和服务器之间连接的Socket对象的产生组件就没有几个了。其中最重要的方法是accept()方法,它接受客户端连接请求,但是还有其它几个开发者可能感到有用的方法。
如果没有注明的话该方法就是公共的。
· Socket accept()产生java.io.IOException、java.lang.Security异常--等待客户端向某个服务器套接字请求连接,并接受连接。它是一种阻塞(blocking)I/O操作,并且不会返回,直到建立一个连接(除非设置了超时套接字选项)。当连接建立时,它将作为Socket对象被返回。当接受连接的时候,每个客户端请求都被默认的安全管理程序验证,这使得接受一定IP地址并阻塞其它IP地址、产生异常成为可能。但是,服务器程序不必依赖安全管理程序阻塞或终止连接--可以通过调用客户端套接字的getInetAddress()方法确定客户端的身份。
· void close()产生java.io.IOException异常--关闭服务器套接字,取消TCP端口的绑定,允许其它的服务使用该端口。
· InetAddress getInetAddress()--返回服务器套接字的地址,在多地址计算机中(例如某个计算机的本地主机可以通过两个或多个IP地址访问)它可能与本地地址不同。
· int getLocalPort()--返回服务器套接字绑定到的端口号。
· int getSoTimeout()产生java.io.IOException异常--返回超时套接字选项的值,该值决定accept()操作可以阻塞多少毫秒。如果返回的值为零,accept()操作无限期阻塞。
· void implAccept(Socket socket)产生java.io.IOException异常--这个方法允许ServerSocket子类传递一个未连接的套接字子类,让这个套接字对象接受输入的请求。使用implAccept方法接受连接时,重载的ServerSocket.accept()方法可以返回已连接的套接字。很少开发者希望对ServerSocket再细分类,在不必要的情况下应该避免使用它。
· static void setSocketFactory ( SocketImplFactory factory )产生java.io.IOException、java.net.SocketException、java.lang.SecurityException异常--为JVM指定服务器套接字产生组件。它是一个静态的方法,在JVM的生存周期中只能调用一次。如果禁止指定新的套接字产生组件,或者已经指定了一个,就会产生异常。
· void setSoTimeout(int timeout)产生java.net.SocketException异常--为accept()操作指定一个超时值(以毫秒计算)。如果指定的值是零,超时设置就被禁止了,该操作将无限制阻塞。但是,如果允许超时设置,在accept()方法被调用的时候就启动一个计时器。当计时器期满时,产生java.io.InterruptedIOException异常,并允许服务器程序执行进一步的操作。
3、从客户端接受和处理请求
服务器套接字的最重要的功能是接受客户端套接字。一旦获取了某个客户端套接字,服务器就可以执行服务器程序的所有"真实的工作",包括从套接字读取信息、向套接字写入信息以实现某种网络协议。发送或接收的准确数据依赖于该协议的详细情况。例如,对存储的消息提供访问的邮件服务器将监听命令并发回消息内容。telnet服务器监听键盘输入并把这些信息传递给一个登陆外壳(shell),并把输出发回网络客户端。具体协议的操作与网络的相关性很小,更多的面向编程。
下面的代码片断演示了如果接受客户端套接字,以及I/O流怎样连接到客户端:
从这个时候开始,服务器程序就可以处理任何需要完成的事务并响应客户端请求了,或者可以选择事务给另一个线程中的代码运行。请记住与Java中的其它形式的I/O操作类似,从客户端读取回应的时候代码会无限制阻塞--因此为了为多个客户端并行服务,必须使用多线程。但是在简单的情形中,多个执行线程可能是不必要的,特别是在对请求响应迅速并且处理时间很短的情况下。
建立完整实现通用Internet协议的客户端/服务器应用程序需要作大量的工作,对于网络编程的新手来说这一点更为明显。它也需要其它一些技巧,例如多线程编程。从现在开始,我们聚焦于一个简单的、作为单线程应用程序执行的TCP服务器程序框架。
七、建立TCP服务器程序
网络编程的最有趣的部分之一是编写网络服务器。客户端发送请求并响应发回来的数据,但是服务器执行大多数真正的工作。下面的例子是一个daytime(日期时间)服务器(你可以使用上面描述的客户端测试它)。
DaytimeServer的代码
DaytimeServer是如何工作的
这是最简单的服务器程序了。这个服务器程序的第一步是建立一个ServerSocket。如果端口已经绑定了,将会产生一个BindException异常,因为两个服务器程序不可能共享相同的端口。否则,就建立了服务器套接字。下一步是等待连接。
因为daytime是个非常简单的协议,并且我们的第一个TCP服务器程序示例必须很简单,所以我们此处使用了单线程服务器程序。在简单的TCP服务器程序中通常使用无限运行的for循环,或者使用表达式的值一直为true的While循环。在这个循环中,第一行是server.accept()方法,它会阻塞代码运行直到某个客户端试图连接为止。这个方法返回一个表示某个客户端的连接的套接字。为了记录数据,该连接的IP地址和端口号被发送到System.out。你将看到每次某个人登陆进来并获取某天的时间。
Daytime是一个仅作应答(response-only)的协议,因此我们不需要担心对任何输入信息的读取过程。我们获得了一个OutputStream(输出流),接着把它包装进PrintStream(打印流),使它工作更简单。我们在使用java.util.Date类决定日期和时间后,基于TCP流把它发送给客户端。最后,我们清除了打印流中的所有数据并通过在套接字上调用close()关闭该连接。
运行DaytimeServer
运行该服务器程序是很简单的。该服务器程序没有命令行参数。如果这个服务器程序示例需要运行在UNIX上,你需要把变量SERVICE_PORT的值该为1024,除非你关闭默认的daytime进程并作为root运行这个示例。在Windows或其它操作系统上,就没有这个问题。如果需要在本机上运行该服务器程序,需要使用下面的命令:
java DaytimeServer
八、异常处理:特定套接字的异常
网络作为通讯的媒介充满了各种问题。随着大量的计算机连接到了全球Internet,遭遇到某个主机名称无法解析、某个主机从网络断开了、或者某个主机在连接的过程中被锁定了的情形在软件应用程序的生存周期中是很可能遇到的。因此,知道引起应用程序中出现的这类问题的条件并很好的处理这些问题是很重要的。当然,并不是每个程序都需要精确的控制,在简单的应用程序中你可能希望使用通用的处理方法处理各种问题。但是对于更高级的应用程序,了解运行时可能出现的特定套接字异常是很重要的。
注意
所有的特定套接字异常都扩展自SocketException,因此通过捕捉该异常,你可以捕捉到所有的特定套接字的异常并编写一个通用的处理程序。此外,SocketException扩展自java.io.IOException,如果你希望提供捕捉所有I/O异常的处理程序可以使用它。
1、 SocketException
java.net.SocketException表现了一种通用的套接字错误,它可以表现一定范围的特定错误条件。对于更细致的控制,应用程序应该捕捉下面讨论的子类。
2、 BindException
java.net.BindException表明没有能力把套接字帮定到某个本地端口。最普通的原因是本地端口已经被使用了。
3、ConnectException
当某个套接字不能连接到特定的远程主机和端口的时候,java.net.ConnectException就会发生。发生这种情况有一个原因,例如远程服务器没有帮定到某个端口的服务,或者它被排队的查询淹没了,不能接收更多的请求。
4、 NoRouteToHostException
当由于出现网络错误,不能找到远程主机的路由的时候产生java.net.NoRouteToHostException异常。它的起因可能是本地的(例如软件应用程序运行的网络正在运行),可能是临时的网关或路由器问题,或者是套接字试图连接的远程网络的故障。另一个普通原因是防火墙和路由器阻止了客户端软件,这通常是个持久的限制。
5、InterruptedIOException
当某个读取操作被阻塞了一段时间引起网络超时的时候产生java.net.InterruptedIOException异常。处理超时问题是使代码更加牢固和可靠的很好的途径。
九、总结
在TCP中使用套接字通讯是你应该掌握的一种重要的技术,因为目前使用的大多数有趣的应用程序协议都是在TCP上出现的。Java套接字API提供了一种清晰的、易于使用的机制,利用这种机制开发者可以作为服务器接受通讯或作为客户端启动通讯。通过使用前面讨论的概念(包括Java下的输入和输出流),过渡到基于套接字的通讯是很直接的。有了建立在java.net程序包中的异常处理水平后,很容易处理运行时发生的网络错误。