Java TCP/IP Socket 编程
第二章:基本套接字
-
InetAddress
类和SocketAddress
类代表网络主机。 -
Socket
类和ServerSocket
类代表TCP协议的客户端和服务器端。 -
DatagramSocket
类使用 UDP 协议。
套接字地址
一个客户端要发起一次通信,首先必须知道运行服务器端程序的主机的 IP 地址。然后由网络的基础结构利用目标地址(destination address)。地址可以是:
- IPv4地址
- IPv6地址
-
主机名(如
server.example.com
),主机名必须被解析(resolved)成数字型地址才能用来进行通信。
InetAddress
类代表了一个网络目标地址,该类有两个子类Inet4Address
和Inet6Address
,分别对应了目前 IP 地址的两个版本。InetAddress
实例是不可变的,一旦创建,每个实例就始终指向同一个地址。
我们将通过一个示例程序来示范 InetAddress 类的用法。在这个例子中,首先打印出与本地主机关联的所有 IP 地址,包括 IPv4 和 IPv6,然后对于每个在命令行中指定的主机,打印出其相关的主机名和地址。为了获得本地主机地址,示例程序利用了 NetworkInterface 类的功能。前面已经讲过,IP 地址实际上是分配给了主机与网络之间的连接,而不是主机本身。NetworkInterface 类提供了访问主机所有接口的信息的功能。这个功能非常有用,比如当一个程序需要通知其他程序其 IP 地址时就会用到。
public static void main(String [] args) { // 首先打印出与本地主机关联的所有IP地址,包括IPv4和IPv6 // 然后对于每个在命令行中指定的主机,打印出其相关的主机名和地址 try { // 返回一个列表,该主机每一个接口所对应的`NetworkInterface`类实例 Enumeration<NetworkInterface> interfaceList = NetworkInterface.getNetworkInterfaces(); if (null == interfaceList) { // 无论如何回环地址总是在的,除非操作系统的网络模块坏掉了 System.out.println("--No interface found--"); } else { // 获取并打印出列表中每个接口的地址 while (interfaceList.hasMoreElements()) { NetworkInterface iface = interfaceList.nextElement(); // `getName()`方法打印接口名为接口返回一个本地名称。 如`lo0`或`eth0` System.out.println("Interface " + iface.getName() + ":"); // `getInetAddresses()`即该接口所关联的每一个地址 // 根据主机配置不同可能只包含IPv4或IPv6地址 Enumeration<InetAddress> addrList = iface.getInetAddresses(); if (!addrList.hasMoreElements()) { System.out.println("\t(No addresses forthis interface)"); } while (addrList.hasMoreElements()) { InetAddress address = addrList.nextElement(); // 不同类型的地址格式 String addressType = "(?)"; if (address instanceof Inet4Address) { addressType = "(v4)"; } if (address instanceof Inet6Address) { addressType = "(v6)"; } System.out.println("\tAddress " + addressType // + ": " + address.getHostAddress()); } } } } catch (SocketException e) { System.out.println("Error getting network interface: " + e.getMessage()); } // 处理命令行参数,命令行给的每个参数都作为域名,尝试访问 for (String host : args) { System.out.println(host + ": "); try { // 获取给定主机/地址的相关地址列表 InetAddress[] addressList = InetAddress.getAllByName(host); // 全都打印出来 for (InetAddress address : addressList) { System.out.println("\t" + address.getHostName() + "/" + address.getHostAddress()); } } catch (UnknownHostException e) { System.out.println("\tUnable to find address for : " + host); } } }
为了使用这个应用程序来获取本地主机信息、出版社网站(www.mkp.com
)服务器信息、
一个虚假地址信息(blah.blah
)、以及一个IP地址的信息,需要在命令行中运行
如下代码:
$ java InetAddressExample www.mkp.com blah.blah 129.35.69.7 Interface lo: Address (v4): 127.0.0.1 Address (v6): 0:0:0:0:0:0:0:1 Address (v6): fe80:0:0:0:0:0:0:1%1 Interface eth0: Address (v4): 192.168.159.1 Address (v6): fe80:0:0:0:250:56ff:fec0:8%4 www.mkp.com: www.mkp.com/129.35.69.7 blah.blah: Unable to find address for blah.blah 129.35.69.7: 129.35.69.7/129.35.69.7
返回的信息中:
-
一些 IPv6 地址带有
\%d
型式的后缀(其中 d 是一个数字)。 这样的地址在一个有限的范围内(通常它们是本地链接), 其后缀表明了该地址所关联的特定范围。这就保证了列出的每个地址字符串都是唯一的。 IPv6 的本地链接地址由fe8
开头。 -
当程序解析
blah.blah
这个虚假地址时,会有一定的延迟。因为会尝试多个不同的 域名服务器将耗费大量的时间。
InetAddress
类代表地址
InetAddress
类中创建与访问实例方法:
// 按域名指定主机 static InetAddress [] getAllByName(String host); static InetAddress getByName(String host); // 指定本机 static InetAddress getLocalHost(); // 二进制格式的地址,IPv4大小4字节,IPv6大小16字节 byte[] getAddress();
InetAddress
类中字符串显示方法:
// 返回形式如: // `hostname.example.com/ 192.0.2.127` // 或 // `never.example.net/ 2000::620:1a30:95b2` String toString(); // 数字格式返回IPv4或IPv6: // 对于有范围限制的 IPv6地址, // 如本地链接地址,还会在后面附有一个范围标识符(scope identifier) String getHostAddress(); // 返回主机名 // 如果是用主机名创建的`InetAddress`类,就不用解析域名。 String getHostName(); // 总是从域名解析得到主机名 String getCanonicalHostName();
InetAddress
类还支持地址属性的检查,InetAddress类中检查属性的方法:
// 是否属于"任意"本地地址 boolean isAnyLocalAddress(); // 是否本地链接地址 boolean isLinkLocalAddress(); // 是否回环地址 boolean isLoopBackAddress(); //是否为一个多播地址 boolean isMulticastAddress(); // 检测多播地址的各种范围(scopes)。 // 范围粗略地定义了到达该目的地址的数据报文从它的起始地址开始 // 所能传递的最远距离 boolean isMCGlobal(); boolean isMCLinkLocal(); boolean isMCNodeLocal(); boolean isMCOrgLocal(); boolean isMCSiteLocal(); // 指定超时时间内是否能连通 boolean isReachable(int timeout); // 指定的网络接口(NetworkInterface), // 并检查其是否能在指定的生命周期(time-to-live,TTL)内联系上目的地址。 // 超时时间 boolean isReachable(NetworkInterface netif, int ttl, int timeout);
NetworkInterface
类代表网络接口
NetworkInterface
创建与获取的方法:
// 易获取到运行程序的主机的 IP 地址 // 注意:这个列表包含了主机的所有接口, // 包括不能够向网络中的其他主机发送或接收消息的虚拟回环接口。 static Enumeration<NetworkInterface> getNetworkInterfaces(); // static NetworkInterface getByInetAddress(InetAddress addr); static NetworkInterface getByName(InetAddress addr); // 获取每个接口的所有地址 static Enumeration<InetAddress> getInetAddresses(); // 返回一个接口(interface)的名字(不是主机名)。 // 如`eth0`。在很多系统中,回环地址的名字都是`lo0` String getName(); String getDisplayName();
TCP套接字
Java为TCP协议提供了两个类:Socket
类和ServerSocket
类。
-
服务器端要同时处理
ServerSocket
实例和Socket
实例 -
客户端只需要使用
Socket
实例。
一个Socket实例代表了TCP连接的一端。一个TCP连接(TCP connection)是一条抽象的
双向信道,两端分别由IP地址和端口号确定。在开始通信之前,要建立一个TCP连接,
这需要先由客户端TCP向服务器端TCP发送连接请求。ServerSocket
实例则监听
TCP连接请求,并为每个请求创建新的Socket
实例。
TCP客户端
客户端向服务器发起连接请求后,就被动地等待服务器的响应。TCP客户端要经过下面三步:
-
创建一个
Socket
实例:构造器向指定的远程主机和端口建立一个TCP连接。 -
通过套接字的输入输出流(I/O streams)进行通信:一个
Socket
连接实例包括一个InputStream
和一个OutputStream
。 -
使用
Socket
类的close()
方法关闭连接。
示例程序叫TCPEchoClient.java
,它向服务器发信息。服务器会把发的信息原样再发回来:
public static void main(String[] args) throws IOException { // 检验参数 if ((args.length < 2) || (args.length > 3)) { throw new IllegalArgumentException( "Parameter(s): <Server> <Word> [<Port>]"); } // 参数:服务器,消息的文本,端口 String server = args[0]; byte[] data = args[1].getBytes(); int servPort = (args.length == 3) ? Integer.parseInt(args[2]) : 7; // Create socket that is connected to server on specified port Socket socket = new Socket(server, servPort); System.out.println("Connected to server...sending echo string"); InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); out.write(data); // Send the encoded string to the server // Receive the same string back from the server int totalBytesRcvd = 0; // Total bytes received so far int bytesRcvd; // Bytes received in last read while (totalBytesRcvd < data.length) { // `read()`方法需要 3 个参数: // 接收数据的字节数组, // 接收的第一个字节应该放入数组的位置,即字节偏移量, // 放入数组的最大字节数。 if ((bytesRcvd = in.read(data, totalBytesRcvd, data.length - totalBytesRcvd)) == -1) { // 如果TCP连接被另一端关闭,`read()`方法返回`-1` throw new SocketException("Connection closed prematurely"); } totalBytesRcvd += bytesRcvd; } // data array is full System.out.println("Received: " + new String(data)); socket.close(); // Close the socket and its streams }
上面程序中的read()
方法在没有可读数据时会阻塞等待,直到有新的数据可读,然后
读取指定的最大字节数,并返回实际放入数组的字节数(可能少于指定的最大字节数)。
循环只是简单地将数据填入data
字节数组,直到接收的字节数与发送的字节数一样。
最常见的错误就是认为由一个write()
方法发送的数据总是会由一个read()
方法来接收。
TCP协议并不能确定在read()
和write()
方法中所发送信息的界限,也就是说,虽然
我们只用了一个write()
方法来发送回馈字符串,回馈服务器也可能从多个块(chunks)
中接受该信息。即使回馈字符串在服务器上存于一个块中,在返回的时候也可能被TCP协议
分割成多个部分。
可以使用以下两种方法来与一个名叫server.example.com
,IP地址为192.0.2.1
的
服务器进行通信:
$ java TCPEchoClient server.example.com "Echo this!" Received: Echo this! $ java TCPEchoClient 192.0.2.1 "Echo this!" Received: Echo this!
也可以给程序加个图形界面TCPEchoClientGUI.java
:
public static void main(String[] args) { if ((args.length < 1) || (args.length > 2)) { throw new IllegalArgumentException("Parameter(s): <Server> [<Port>]"); } String server = args[0]; // Server name or IP address int servPort = (args.length == 2) ? Integer.parseInt(args[1]) : 7; JFrame frame = new TCPEchoClientGUI(server, servPort); frame.setVisible(true); } public TCPEchoClientGUI(String server, int servPort) { super("TCP Echo Client"); // Set the window title setSize(300, 300); // Set the window size setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // Set echo send text field final JTextField echoSend = new JTextField(); getContentPane().add(echoSend, "South"); // Set echo replay text area final JTextArea echoReply = new JTextArea(8, 20); echoReply.setEditable(false); JScrollPane scrollPane = new JScrollPane(echoReply); getContentPane().add(scrollPane, "Center"); final Socket socket; // Client socket final DataInputStream in; // Socket input stream final OutputStream out; // Socket output stream try { // Create socket and fetch I/O streams socket = new Socket(server, servPort); in = new DataInputStream(socket.getInputStream()); out = socket.getOutputStream(); echoSend.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent event) { if (event.getSource() == echoSend) { byte[] byteBuffer = echoSend.getText().getBytes(); try { out.write(byteBuffer); in.readFully(byteBuffer); echoReply.append(new String(byteBuffer) + "\n"); echoSend.setText(""); } catch (IOException e) { echoReply.append("ERROR\n"); } } } }); addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent e) { try { socket.close(); } catch (Exception exception) { } System.exit(0); } }); } catch (IOException exception) { echoReply.append(exception.toString() + "\n"); } }
Socket
创建
// 创建后自动连接 Socket(InetAddress remoteAddr, int remotePort); Socket(String remoteHost, int remotePort); // 多了本地接口与端口的参数,因为有的电脑有多个网卡 Socket(InetAddress remoteAddr, int remotePort, InetAddress localAddr, int localPort); socket(String remoteHost, int remotePort, Inetaddress localAddr, int localPort); // 创建后不自动连接,要调用`connect()`方法连接 socket();
Socket
操作
void connect(SocketAddress distination); void connect(SocketAddress destination, int timeout); InputStream getInputStream(); OutputStream getOutputStream(); void close(); // 关闭输入输出流 void shutdownInput(); // 关闭输入流 void shutdownOutput(); // 关闭输出流
注意:默认情况下,Socket是在TCP连接的基础上实现的,但是在Java中,可以改变Socket 的底层连接。由于本书是关于TCP/IP的,因此为了简便我们假设所有这些网络类的 底层实现都与默认情况一致。
Socket
获取与检测属性
InetAddress getInetAddress(); int getPort(); InetAddress getLocalAddress(); int getLocalPort(); SocketAddress getRemoteSocketAddress(); SocketAddress getLocalSocketAddress();
这些方法返回套接字的相应属性。实际上本书中所有返回SocketAddress
的方法返回的
都是InetSocketAddress
实例,而InetSocketAddress
中封装了一个InetAddress
和
一个端口号。
Socket 类实际上还有大量的其他相关属性,称为套接字选项(socket options)。 这些以后再说。
InetSocketAddress
类为主机地址和端口号提供了一个不可变的组合。
InetSocketAddress
创建与访问
InetSocketAddress(InetAddress addr, int port); // 只接收端口号作为参数的构造函数将使用特殊的「任何」地址来创建实例 // 这对于服务器端非常有用。 InetSocketAddress(int port); // 主机名会尝试将其解析成IP地址 InetScoketAddress(String hostname, int port); // 不对主机名进行解析情况下创建 static InetSocketAddress createUnresolved(String host, int port); // 如果创建时没有解析主机名或解析失败将返回 true boolean isUnresolved(); InetAddress getAddres(); int getPort(); String getHostName(); String toString();
TCP服务端
服务器端建立一个通信终端被动地等待客户端的连接。典型的TCP服务器有如下两步工作:
-
创建一个
ServerSocket
实例并指定本地端口。此套接字的功能是侦听该指定端口收到的 连接。重复执行:-
调用
ServerSocket
的accept()
方法以获取下一个客户端连接。 -
基于新建立的客户端连接,创建一个
Socket
实例并由accept()
方法返回。
-
调用
-
使用所返回的
Socket
实例的InputStream
和OutputStream
与客户端进行通信。 -
通信完成后,使用
Socket
类的close()
方法关闭该客户端套接字连接。
例子TCPEchoServer.java
一直运行,反复接受连接请求,接收并返回字节信息。
直到客户端关闭了连接,它才关闭客户端套接字。
private static final int BUFSIZE = 32; // Size of receive buffer public static void main(String[] args) throws IOException { if (args.length != 1) // Test for correct # of args throw new IllegalArgumentException("Parameter(s): <Port>"); int servPort = Integer.parseInt(args[0]); // Create a server socket to accept client connection requests ServerSocket servSock = new ServerSocket(servPort); int recvMsgSize; // Size of received message byte[] receiveBuf = new byte[BUFSIZE]; // Receive buffer while (true) { // Run forever, accepting and servicing connections Socket clntSock = servSock.accept(); // Get client connection // 返回包含了客户端地址和端口号的`InetSocketAddress` SocketAddress clientAddress = clntSock.getRemoteSocketAddress(); System.out.println("Handling client at " + clientAddress); InputStream in = clntSock.getInputStream(); OutputStream out = clntSock.getOutputStream(); // Receive until client closes connection, indicated by -1 return while ((recvMsgSize = in.read(receiveBuf)) != -1) { // 第二个参数指明了要发送的第一个字节在字节数组中的偏移量。 // 在本例中,0表示从data的最前端传送数据。 out.write(receiveBuf, 0, recvMsgSize); } clntSock.close(); // Close the socket. We are done with this client! } /* NOT REACHED */ }
-
ServerSocket
实例的唯一目的是为新的TCP连接请求提供一个已连接的Socket实例。 -
当服务器端已经准备好处理客户端请求时,就调用
accept()
方法。该方法将阻塞等待, 直到有向ServerSocket实例指定端口的新的连接请求到来。 -
如果新的连接请求到来时,在服务器端套接字刚创建,而尚未调用
accept()
, 那么新的连接将排在一个队列中,在这种情况下调用accept()
方法将立即得到响应。 -
ServerSocket
类的accept()
方法将返回一个Socket
实例,该实例已经连接到了 远程客户端的套接字,并已准备好读写数据。
read()
方法并不一定要在整个字节数组填满后才返回。实际上它只接收了一个字节时就可以返回。
OutputStream
类的write()
方法将receiveBuf
中的recvMsgSize个字节写入套接字。该方法的第二个参数指明了要发送的第一个字节在字节数组中的偏移量。在本例中,0表示从data的最前端传送数据。如果我们使用只以缓存数组为参数的write()方法,那么缓存数组中的所有字节都将被传送,甚至可能包括那些不是从客户端接收来的数据。
ServerSocket
创建:
)连接队列的大小以及本地地址也可以选择设置。需要注意的是,最大队列长度不是一个严格的限制,也不能用来控制客户端的总数。。
// 如果端口号被设为0,将选择任意没有使用的端口号 ServerSocket(int loaclPort); // 最大队列长度不是一个严格的限制,也不能用来控制客户端的总数 ServerSocket(int localPort, int queueLimit); // 如果指定了本地地址该地址就必须是主机的网络接口之一 // 如果没有指定,套接字将接受指向主机任何IP地址的连接 // 这将对有多个接口而服务器端只接受其中一个接口连接的主机非常有用 ServerSocket(int localPort, int uqeueLimit, InetAddress localAddr); // 创建没有绑定关联任何端口的实例。在使用该实例前,必须使用`bing()`方法绑定 ServerSocket();
ServerSocket
操作:
// 套接字关联一个本地端口,每个实例只能绑定一个端口。 // 如果已经绑定了一个端口或所指定的端口已经被占用, // 则将抛出`IOException`异常。 void bind(int prot); void bind(int port, int queuelimit); // 等待传入连接并将已成功建立的连接创建Socket实例返回。 // 如果没有连接请求等待`accept()`方法将阻塞等待直到有新的连接请求到来或超时 Socket accept(); // void close();
ServerSocket
获取属性:
InetAddress getInetAddress(); SocketAddress getLocalSocketAddress(); int getLocalPort();
与Socket类不同的是,ServerSocket没有相关联的I/O流。然而,它有另外一些称为选项 (options)的属性,并能通过多种方法对选项进行控制。这些内容将在第4.4节介绍。
输入输出流
Java中TCP套接字的基本输入输出形式是流(stream)抽象。Java的输入流
(input streams)支持读取字节,而输出流(output streams)则支持写出字节。每个
Socket实例都维护了一个InputStream
实例和一个OutputStream
实例。
OutputStream
类是Java中所有输出流的抽象父类。通过OutputStream
我们可以向输出流
写字节、刷新缓存区和关闭输出流。
OutputStream
操作:
abstract void write(int data); void write(byte[] data); void write(byte[] data, int offset, int length); // 将缓存中的所有数据推送到输出流 void flush(); // 用来关闭流,流关闭之后,再调用`write()`方法时将抛出异常 void close();
-
输出一个字节的
write()
方法只将其整型参数的低8位输出。 -
如果在一个TCP套接字关联的输出流上进行这些操作,当大量的数据已发送,而连接的
另一端所关联的输入流最近没有调用
read()
方法时,这些方法可能会阻塞。如果不作 特殊处理,这可能会产生一些不想得到的后果。(见6.2节)
InputStream
类是所有输入流的抽象父类。可以使用InputStream
从输入流中读取字节
或关闭输入流。
InputStream
操作:
// 所有的`read()`方法都阻塞等待直到至少有一个字节可读。 // 在没有数据可读,同时又检测到流结束标记时,所有`read()`方法都将返回-1。 // 读取的一个字节放入一个整型变量的低8位中,并将该变量返回 abstract int read(); int read(byte[] data); int read(byte[] data, int offset, int length); // 返回当前可读字节的总数。 int available(); void close();
UDP套接字
UDP协议提供了一种不同于TCP协议的端到端服务。实际上UDP协议只实现两个功能:
- 在IP协议的基础上添加了另一层地址(端口)
- 对数据传输过程中可能产生的数据错误进行了检测,并抛弃已经损坏的数据。
TCP协议与电话通信相似,而UDP协议则与邮件通信相似:
你寄包裹或信件时不需要进行「连接」,但是你得为每个包裹和信件指定目的地址。 类似的,每条信息(即数据报文,datagram)负载了自己的地址信息,并与其他信息相互 独立。在接收信息时,UDP套接字扮演的角色就像是一个信箱,从不同地址发送来的信件和 包裹都可以放到里面。一旦被创建,UDP套接字就可以用来连续地向不同的地址发送信息, 或从任何地址接收信息。
UDP协议的缺点:
- UDP不建立可靠的连接:由于没有连接,每个数据报文都可能发送自不同的客户端。 而数据报文自身就包含了其发送者的(客户端的)源地址和端口号。
- UDP套接字保留边界信息:明确消息有多长在哪里结束,这点比TCP套接字更简单
- UDP协议不保证传递成功:所提供的端到端传输服务是尽力而为(best-effort)的
- UDP信息就像通过邮政部门寄信一样,到达的顺序不一定一致。因此,使用了UDP套接字 的程序必须准备好处理信息的丢失和重排。
- UDP效率更高:如果应用程序只交换非常少量的数据,TCP连接的建立阶段就至少要传输 其两倍的信息量(还有两倍的往返延迟时间)。
- 灵活:UDP协议则提供了一个最小开销的平台来满足任何需求的实现。
Java程序通过DatagramPacket
类和DatagramSocket
类来使用UDP套接字。
-
客户端和服务器端都使用
DatagramSockets
来发送数据 -
使用
DatagramPackets
来接收数据。 -
与TCP服务器不同UDP服务器为所有的通信使用同一个套接字,TCP服务器为每个成功返回
的
accept()
方法创建一个新的套接字。
DatagramPacket类
与TCP协议发送和接收字节流不同,UDP终端交换的是一种称为数据报文的自包含
(self-contained)信息。这种信息在Java中表示为DatagramPacket
类的实例。
-
发送信息时,Java程序创建一个包含了待发送信息的
DatagramPacket
实例, 并将其作为参数传递给DatagramSocket
类的send()
方法。 -
接收信息时,Java程序首先创建一个
DatagramPacket
实例。该实例中预先分配了一些 空间(一个字节数组byte[]
),并将接收到的信息存放在该空间中。 -
然后把该实例作为参数传递给
DatagramSocket
类的receive()
方法。
除传输的信息本身外每个DatagramPacket
实例中还附加了地址和端口信息,其具体含义
取决于该数据报文是被发送还是被接收。
-
若是要发送的数据报文,
DatagramPacket
实例中的地址则指明了目的地址和端口号 -
若是接收到的数据报文,
DatagramPacket
实例中的地址则指明了所收信息的源地址。
因此,服务器端可以修改接收到的DatagramPacket
实例的缓存区内容,再将这个实例连同
修改后的信息一起,发回给它的源地址。在DatagramPacket
的内部也有length
和
offset
字段,分别定义了数据信息在缓存区的起始位置和字节数。
请参考下面的介绍和第2.3.4节的内容,以避免在使用DatagramPackets
时易犯的一些错误。
DatagramPacket
创建:
// 没有指定其目的地址 // 以后可以可以通过`setAddress()`和`setPort()`或`setSocketAddress()`来指定 DatagramPacket(byte[ ] data, int length) DatagramPacket(byte[ ] data, int offset, int length) // 指定地址,创建发送端的DatagramPackets实例 // 目的地址和端口号可以分别设置,或通过`SocketAddress`同时设置。 DatagramPacket(byte[ ] data, int length, InetAddress remoteAddr, int remotePort) DatagramPacket(byte[ ] data, int offset, int length, InetAddress remoteAddr, int remotePort) DatagramPacket(byte[ ] data, int length, SocketAddress sockAddr) DatagramPacket(byte[ ] data, int offset, int length, SocketAddress sockAddr)
-
如果指定了
offset
,数据报文的数据部分将从字节数组的指定位置发送或接收数据。 -
length
参数指定了字节数组中在发送时要传输的字节数,或在接收数据时所能接收的 最多字节数。length
参数可能比data.length
小,但不能比它大。
DatagramPacket
地址处理:
-
需要注意,
DatagramSocket
的receive()
方法是将其地址和端口设置为数据报发送者的地址和端口。
// 访问和修改DatagramPacket实例的地址信息 InetAddress getAddress() void setAddress(InetAddress address) int getPort() void setPort(int port) SocketAddress getSocketAddress() void setSocketAddress(SocketAddress sockAddr)
DatagramPacket
处理数据:
int getLength() // 设置报文中数据部分的长度。若试图将其设置得比相关联的缓存区长度更大, // 程序将抛出一个`IllegalArgumentException`异常 void setLength(int length) // 数据存放在缓存区时的偏移量。 // 不存在`setOffset()`方法,要使用`setData()`方法来设置偏移量。 int getOffset() // 实际返回的是对与`DatagramPacket`最近关联的字节数组的一个引用, // 而关联则是通过构造函数或`setData()`方法形成。 // 返回的缓存数组的长度可能比数据报文内部长度更长, // 因此,必须使用内部长度和偏移量来指定实际接收到的信息。 byte[ ] getData() // 指定一个字节数组作为该数据报文的数据部分,将整个字节数组作为缓冲区 void setData(byte[ ] data) // 指定一个字节数组作为该数据报文的数据部分 // 把字节数组中,从`offset`到`offset+length-1`的部分作为缓存区。 // 每次调用都将更新数据的内部偏移量和长度 void setData(byte[ ] buffer, int offset, int length)
UDP客户端
UDP客户端首先向被动等待联系的服务器端发送一个数据报文。一个典型的UDP客户端主要 执行以下三步:
-
创建一个
DatagramSocket
实例,可以选择对本地地址和端口号进行设置。 -
使用
DatagramSocket
类的send()
和receive()
方法来发送和接收DatagramPacket
实例,进行通信。 -
通信完成后,使用
DatagramSocket
类的close()
方法来销毁该套接字。
与Socket类不同,DatagramSocket
实例在创建时并不需要指定目的地址。这也是TCP协议
和UDP协议的最大不同点之一。在进行数据交换前,TCP套接字必须跟特定主机和另一个
端口号上的TCP套接字建立连接,之后,在连接关闭前,该套接字就只能与相连接的那个
套接字通信。而UDP套接字在进行通信前则不需要建立连接,每个数据报文都可以发送到或
接收于不同的目的地址。(DatagramSocket
类的connect()
方法确实允许指定远程地址
和端口,但该功能是可选的。)
示例程序UDPEchoClientTimeout.java
发送一个带有回馈字符串的数据报文,并打印出从
服务器收到的所有信息。
使用UDP协议的一个后果是数据报文可能丢失。前面的TCP客户端发送后将在read()
方法上
阻塞等待响应。UDP程序中如果数据报文丢失后远阻塞在receive()
方法上。
DatagramSocket
类的setSoTimeout()
方法指定receive()
方法最长阻塞时间。
示例程序的工作步骤:
- 向服务器端发送回馈字符串。
-
在
receive()
方法上最多阻塞等待3秒钟,在超时则重发请求(最多重发5次)。 - 终止客户端。
// Resend timeout (milliseconds) private static final int TIMEOUT = 3000; // Maximum retransmissions private static final int MAXTRIES = 5; public static void main(String[] args) throws IOException { // Test for correct # of args if ((args.length < 2) || (args.length > 3)) { throw new IllegalArgumentException( "Parameter(s): <Server> <Word> [<Port>]"); } // Server address InetAddress serverAddress = InetAddress.getByName(args[0]); // Convert the argument String to bytes using the default encoding byte[] bytesToSend = args[1].getBytes(); int servPort = (args.length == 3) ? Integer.parseInt(args[2]) : 7; // 创建UDP套接字 DatagramSocket socket = new DatagramSocket(); // 设置超时时间 Maximum receive blocking time (milliseconds) // 超时时间是不精确的,`receive()`方法的会阻塞比这更长的时间但不会少 socket.setSoTimeout(TIMEOUT); // 创建一个要发送的数据报文,我们需要指定三件事: // 数据,目的地址,以及目的端口。 // 若使用的是主机名将在构造函数中转换成实际的IP地址。 DatagramPacket sendPacket = new DatagramPacket(// bytesToSend, // Sending packet bytesToSend.length, serverAddress, servPort); // 创建一个要接收的数据报文,只需要定义一个用来存放报文数据的字节数组。 // 而数据报文的源地址和端口号将从`receive()`方法获得 DatagramPacket receivePacket = // Receiving packet new DatagramPacket(new byte[bytesToSend.length], bytesToSend.length); // 发送数据报文,由于数据报文可能丢失,我们必须准备好重新传输数据。 // 本例中,我们最多循环5次,来发送数据报文并尝试接收响应信息。 // Packets may be lost, so we have to keep trying int tries = 0; boolean receivedResponse = false; do { // 将数据报文传输到其指定的地址和端口号去 // Send the echo string socket.send(sendPacket); try { // 接收阻塞等待,直到收到数据报文或超时 // 超时信息由`InterruptedIOException`异常指示 // Attempt echo reply reception socket.receive(receivePacket); // Check source if (!receivePacket.getAddress().equals(serverAddress)) { throw new IOException("Received packet from an unknown source"); } // 成功接收将循环标记`receivedResponse`设为`true`以退出循环 receivedResponse = true; } catch (InterruptedIOException e) { // We did not get anything tries += 1; System.out.println("Timed out, " + (MAXTRIES-tries) + " more tries..."); } } while ((!receivedResponse) && (tries < MAXTRIES)); if (receivedResponse) { System.out.println("Received: " + new String(receivePacket.getData())); } else { System.out.println("No response -- giving up."); } socket.close(); }
DatagramSocket
创建:
DatagramSocket() DatagramSocket(int localPort) DatagramSocket(int localPort, InetAddress localAddr)
- 如果没有指定本地端口,或将其设置为0,该套接字将与任何可用的本地端口绑定。
- 如果没有指定本地地址, 数据包(packet)可以接收发送向任何本地地址的数据报文。
DatagramSocket
连接与关闭:
// 远程地址和端口。一旦连接成功,该套接字就只能与指定的地址和端口进行通信 // 任何向其他地址和端口发送数据报文的尝试都将抛出一个异常。 // // 套接字也将只接收从指定地址和端口发送来的数据报文, // 从其他地址或端口发送来的数据报文将被忽略。 void connect(InetAddress remoteAddr, int remotePort) void connect(SocketAddress remoteSockAddr) void disconnect() void close()
-
connect()
方法连接到多播地址或广播地址的套接字只能发送数据报文, 因为数据报文的源地址总是一个单播地址(见第4.3节)。 - 注意,连接仅仅是本地操作,因为与TCP协议不同,UDP中没有端对端的数据包交换。
DatagramSocket
地址处理:
InetAddress getInetAddress() // 返回远程套接字地址 int getPort() // 返回远程套接字端口 SocketAddress getRemoteSocketAddress() // 返回远程套接字 // 返回本地的信息 // 没有绑定本地地址将返回通配符地址("任何本地地址") InetAddress getLocalAddress() int getLocalPort() SocketAddress getLocalSocketAddress()
DatagramSocket
发送和接收:
void send(DatagramPacket packet) void receive(DatagramPacket packet)
send()
方法用来发送DatagramPacket
实例。一旦建立连接数据包将发送到该套接字所
连接的地址,除非DatagramPacket
实例中已经指定了不同目的地址,这将抛出一个异常。
如果没有创建连接数据包将发送到DatagramPacket
实例中指定的目的地址,该方法不阻塞等待。
receive()
方法将阻塞等待直到接收到数据报文,并将报文中的数据复制到指定的
DatagramPacket
实例中。如果套接字已经创建了连接,该方法也阻塞等待直到接收到从
所连接的远程套接字发来的数据报文。
DatagramSocket
选项:
// 超时时间以毫秒为单位 int getSoTimeout() void setSoTimeout(int timeoutMillis)
UDP服务器端
与TCP服务器一样,UDP服务器的工作是建立一个通信终端,并被动等待客户端发起连接。 但由于UDP是无连接的,UDP通信通过客户端的数据报文初始化,并没有TCP中建立连接 那一步。典型的UDP服务器要执行以下三步:
-
创建一个
DatagramSocket
实例,指定本地端口号并可以选择指定本地地址。此时, 服务器已经准备好从任何客户端接收数据报文。 -
使用
DatagramSocket
类的receive()
方法来接收一个DatagramPacket
实例。 当receive()
方法返回时,数据报文就包含了客户端的地址,这样我们就知道了回复 信息应该发送到什么地方。 -
使用
DatagramSocket
类的send()
和receive()
方法来发送和接收DatagramPackets
实例进行通信。
示例程序UDPEchoServer.java
只接收和发送数据报文中的前255(ECHOMAX
)个字符,
超出的部分将在套接字的具体实现中无提示地丢弃。
// Maximum size of echo datagram private static final int ECHOMAX = 255; public static void main(String[] args) throws IOException { if (args.length != 1) { // Test for correct argument list throw new IllegalArgumentException("Parameter(s): <Port>"); } int servPort = Integer.parseInt(args[0]); // UDP服务器必须显式地设置它的本地端口号 // 服务器从客户端接收到了回馈数据报文后,能从中获取客户端的地址和端口号。 DatagramSocket socket = new DatagramSocket(servPort); // 创建数据报文`DatagramPacket`实例缓存区最多(ECHOMAX)可容纳255个字节 // 这个数据报文将同时用来接收回馈请求和发送回馈信息。 DatagramPacket packet = new DatagramPacket(new byte[ECHOMAX], ECHOMAX); // Run forever, receiving and echoing datagrams while (true) { // Receive packet from client // 与TCP服务器不同UDP服务器为所有的通信使用同一个套接字 // TCP服务器为每个成功返回的`accept()`方法创建一个新的套接字。 // // `receive()`方法将阻塞等待。 socket.receive(packet); // 由于没有连接,每个数据报文都可能发送自不同的客户端。 // 而数据报文自身就包含了其发送者的(客户端的)源地址和端口号。 System.out.println("Handling client at " + packet.getAddress().getHostAddress() + " on port " + packet.getPort()); // 发送回馈信息。数据包(packet)中已经包含了回馈字符串和回馈目的地址及端口 // Send the same packet back to client socket.send(packet); // Reset length to avoid shrinking buffer // 处理了接收到的消息后,数据包的内部长度将设置为刚处理过的消息的长度, // 而这可能比缓冲区的原始长度短。如果接收新消息前不对内部长度进行重置, // 后续的消息一旦长于之前消息,就会被截断。 packet.setLength(ECHOMAX); } }
使用UDP套接字发送和接收信息
本节我们将比较使用UDP套接字和TCP套接字进行通信的一些不同点。一个微小但重要的差别
是UDP协议保留了消息的边界信息。DatagramSocket
的每一次receive()
调用最多只能
接收调用一次send()
方法所发送的数据。而且不同的receive()
方法调用绝不会返回
同一个send()
方法调用所发送的数据。
当在TCP套接字的输出流上调用的write()
方法返回后,所有的调用者都知道数据已经被
复制到一个传输缓存区中,实际上此时数据可能已经被传送,也可能还没有被传送(第6章
中将对此进行详细介绍)。而UDP协议没有提供从网络错误中恢复的机制,因此,并不对
可能需要重传的数据进行缓存。这就意味着,当send()
方法调用返回时,消息已经被
发送到了底层的传输信道中,并正处在(或即将处在)发送途中。
消息从网络到达后,其所包含数据被read()
方法或receive()
方法返回前,数据存储在
一个先进先出(first-in, first-out,FIFO)的接收数据队列中。对于已连接的
TCP套接字来说,所有已接收但还未传送的字节都看作是一个连续的字节序列(见第6章)。
然而,对于UDP套接字来说,接收到的数据可能来自于不同的发送者。一个UDP套接字所
接收的数据存放在一个消息队列中,每个消息都关联了其源地址信息。每次receive()
调用只返回一条消息。然而,如果receive()
方法在一个缓存区大小为n
的
DatagramPacket
实例中调用,而接收队列中的第一条消息长度大于n
,则receive()
方法只返回这条消息的前n
个字节。超出部分的其他字节都将自动被丢弃,而且对
接收程序也没有任何消息丢失的提示!
出于这个原因,接收者应该提供一个有足够大的缓存空间的DatagramPacket
实例,
以完整地存放调用receive()
方法时应用程序协议所允许的最大长度的消息。这个技术
能够保证数据不会丢失。一个DatagramPacket
实例中所运行传输的最大数据量为65507
字节,即UDP数据报文所能负载的最多数据。因此,使用一个有65600字节左右缓存数组的
数据包总是安全的。
同时,还需要记住的重要一点是,每一个DatagramPacket
实例都包含一个内部消息长度值
,而该实例一接收到新消息,这个长度值都可能改变(以反映实际接收的消息的字节数)。
如果一个应用程序使用同一个DatagramPacket
实例多次调用receive()
方法,每次
调用前就必须显式地将消息的内部长度重置为缓存区的实际长度。
对于新手的另一个潜在的问题根源是DatagramPacket
类的getData()
方法,该方法总是
返回缓冲区的原始大小,忽略了实际数据的内部偏移量和长度信息。消息接收到
DatagramPacket
的缓存区时,只是修改了存放消息数据的地址。例如,假设buf
是一个
长度为20的字节数组,其在初始化时已使每个字节中存放了该字节在数组中的索引:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
同时假设dg
是一个DatagramPacket
实例,我们将dg
的缓存区设置为buf
数组的中间
10个字节:
dg.setData(buf,5,10);
现在假设dgsocket
是一个DatagramSocket
实例,某人向dgsocket
发送了一个包含以下
内容的8字节的消息,
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
该消息接收到了dg中:
dgsocket.receive(dg);
此时,调用dg.getData()
方法将返回buf
字节数组的原始引用,其内容变为:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
可以看到buf数组中只有索引为5-12的字节被修改,一般而言,应用程序需要使用
getOffset()
和getData()
方法来访问刚接收到的数据。一种可能的方式是将接收到的
数据复制到一个单独的字节数组中,如下所示:
byte[] destBuf = new byte[dg.getLength()]; System.arraycopy(dg.getData(), dg.getOffset(), destBuf, 0, destBuf.length);
在Java1.6中我们可以使用Arrays.copyOfRange()
方法,只需要一步就能方便地实现以上
功能:
byte[] destBuf = Arrays.copyOfRange( dg.getData(),dg.getOffset(), dg.getOffset() + dg.getLength());
我们不需要在UDPEchoServer.java中执行复制操作,因为这个服务器根本不从DatagramPacket中读取数据。