15.4 数据报

大家迄今看到的例子使用的都是“传输控制协议”(TCP),亦称作“基于数据流的套接字”。根据该协议的设计宗旨,它具有高度的可靠性,而且能保证数据顺利抵达目的地。换言之,它允许重传那些由于各种原因半路“走失”的数据。而且收到字节的顺序与它们发出来时是一样的。当然,这种控制与可靠性需要我们付出一些代价:TCP具有非常高的开销。 还有另一种协议,名为“用户数据报协议”(UDP),它并不刻意追求数据包会完全发送出去,也不能担保它们抵达的顺序与它们发出时一样。我们认为这是一种“不可靠协议”(TCP当然是“可靠协议”)。听起来似乎很糟,但由于它的速度快得多,所以经常还是有用武之地的。对某些应用来说,比如声音信号的传输,如果少量数据包在半路上丢失了,那么用不着太在意,因为传输的速度显得更重要一些。大多数互联网游戏,如Diablo,采用的也是UDP协议通信,因为网络通信的快慢是游戏是否流畅的决定性因素。也可以想想一台报时服务器,如果某条消息丢失了,那么也真的不必过份紧张。另外,有些应用也许能向服务器传回一条UDP消息,以便以后能够恢复。如果在适当的时间里没有响应,消息就会丢失。 Java对数据报的支持与它对TCP套接字的支持大致相同,但也存在一个明显的区别。对数据报来说,我们在客户和服务器程序都可以放置一个DatagramSocket(数据报套接字),但与ServerSocket不同,前者不会干巴巴地等待建立一个连接的请求。这是由于不再存在“连接”,取而代之的是一个数据报陈列出来。另一项本质的区别的是对TCP套接字来说,一旦我们建好了连接,便不再需要关心谁向谁“说话”——只需通过会话流来回传送数据即可。但对数据报来说,它的数据包必须知道自己来自何处,以及打算去哪里。这意味着我们必须知道每个数据报包的这些信息,否则信息就不能正常地传递。 DatagramSocket用于收发数据包,而DatagramPacket包含了具体的信息。准备接收一个数据报时,只需提供一个缓冲区,以便安置接收到的数据。数据包抵达时,通过DatagramSocket,作为信息起源地的因特网地址以及端口编号会自动得到初化。所以一个用于接收数据报的DatagramPacket构建器是: DatagramPacket(buf, buf.length) 其中,buf是一个字节数组。既然buf是个数组,大家可能会奇怪为什么构建器自己不能调查出数组的长度呢?实际上我也有同感,唯一能猜到的原因就是C风格的编程使然,那里的数组不能自己告诉我们它有多大。 可以重复使用数据报的接收代码,不必每次都建一个新的。每次用它的时候(再生),缓冲区内的数据都会被覆盖。 缓冲区的最大容量仅受限于允许的数据报包大小,这个限制位于比64KB稍小的地方。但在许多应用程序中,我们都宁愿它变得还要小一些,特别是在发送数据的时候。具体选择的数据包大小取决于应用程序的特定要求。 发出一个数据报时,DatagramPacket不仅需要包含正式的数据,也要包含因特网地址以及端口号,以决定它的目的地。所以用于输出DatagramPacket的构建器是: DatagramPacket(buf, length, inetAddress, port) 这一次,buf(一个字节数组)已经包含了我们想发出的数据。length可以是buf的长度,但也可以更短一些,意味着我们只想发出那么多的字节。另两个参数分别代表数据包要到达的因特网地址以及目标机器的一个目标端口(注释②)。

②:我们认为TCP和UDP端口是相互独立的。也就是说,可以在端口8080同时运行一个TCP和UDP服务程序,两者之间不会产生冲突。

大家也许认为两个构建器创建了两个不同的对象:一个用于接收数据报,另一个用于发送它们。如果是好的面向对象的设计方案,会建议把它们创建成两个不同的类,而不是具有不同的行为的一个类(具体行为取决于我们如何构建对象)。这也许会成为一个严重的问题,但幸运的是,DatagramPacket的使用相当简单,我们不需要在这个问题上纠缠不清。这一点在下例里将有很明确的说明。该例类似于前面针对TCP套接字的MultiJabberServer和MultiJabberClient例子。多个客户都会将数据报发给服务器,后者会将其反馈回最初发出消息的同样的客户。 为简化从一个String里创建DatagramPacket的工作(或者从DatagramPacket里创建String),这个例子首先用到了一个工具类,名为Dgram:

//: Dgram.java // A utility class to convert back and forth // Between Strings and DataGramPackets. import java.net.*;
public class Dgram { 
  public static DatagramPacket toDatagram( String s, InetAddress destIA, int destPort) { 
    // Deprecated in Java 1.1, but it works: 
    byte[] buf = new byte[s.length() + 1]; 
    s.getBytes(0, s.length(), buf, 0); 
    // The correct Java 1.1 approach, but it's 
    // Broken (it truncates the String): 
    // byte[] buf = s.getBytes(); 
    return new DatagramPacket(buf, buf.length, destIA, destPort); 
  } 
  public static String toString(DatagramPacket p){ 
    // The Java 1.0 approach: 
    // return new String(p.getData(), 
    // 0, 0, p.getLength()); 
    // The Java 1.1 approach: 
    return new String(p.getData(), 0, p.getLength()); 
  }
} ///:~

Dgram的第一个方法采用一个String、一个InetAddress以及一个端口号作为自己的参数,将String的内容复制到一个字节缓冲区,再将缓冲区传递进入DatagramPacket构建器,从而构建一个DatagramPacket。注意缓冲区分配时的"+1"——这对防止截尾现象是非常重要的。String的getByte()方法属于一种特殊操作,能将一个字串包含的char复制进入一个字节缓冲。该方法现在已被“反对”使用;Java 1.1有一个“更好”的办法来做这个工作,但在这里却被当作注释屏蔽掉了,因为它会截掉String的部分内容。所以尽管我们在Java 1.1下编译该程序时会得到一条“反对”消息,但它的行为仍然是正确无误的(这个错误应该在你读到这里的时候修正了)。 Dgram.toString()方法同时展示了Java 1.0的方法和Java 1.1的方法(两者是不同的,因为有一种新类型的String构建器)。 下面是用于数据报演示的服务器代码:

//: ChatterServer.java // A server that echoes datagrams import java.net.*; import java.io.* ; import java.util.*;
public class ChatterServer { 
  static final int INPORT = 1711; 
  private byte[] buf = new byte[1000]; 
  private DatagramPacket dp = new DatagramPacket(buf, buf.length); 
  // Can listen & send on the same socket: private DatagramSocket socket;
  public ChatterServer() { 
    try { 
      socket = new DatagramSocket(INPORT); 
      System.out.println("Server started"); 
      while(true) { 
        // Block until a datagram appears: 
        socket.receive(dp); 
        String rcvd = Dgram.toString(dp) + ", from address: " + dp.getAddress() + ", port: " + dp.getPort(); 
        System.out.println(rcvd); 
        String echoString = "Echoed: " + rcvd; 
        // Extract the address and port from the 
        // received datagram to find out where to 
        // send it back: 
        DatagramPacket echo = Dgram.toDatagram(echoString, dp.getAddress(), dp.getPort()); 
        socket.send(echo); 
      }
    } catch(SocketException e) { 
      System.err.println("Can't open socket"); 
      System.exit(1); 
    } catch(IOException e) { 
      System.err.println("Communication error"); 
      e.printStackTrace(); 
    }
  } 
  public static void main(String[] args) { 
    new ChatterServer(); 
  }
} ///:~

ChatterServer创建了一个用来接收消息的DatagramSocket(数据报套接字),而不是在我们每次准备接收一条新消息时都新建一个。这个单一的DatagramSocket可以重复使用。它有一个端口号,因为这属于服务器,客户必须确切知道自己把数据报发到哪个地址。尽管有一个端口号,但没有为它分配因特网地址,因为它就驻留在“这”台机器内,所以知道自己的因特网地址是什么(目前是默认的localhost)。在无限while循环中,套接字被告知接收数据(receive())。然后暂时挂起,直到一个数据报出现,再把它反馈回我们希望的接收人——DatagramPacket dp——里面。数据包(Packet)会被转换成一个字串,同时插入的还有数据包的起源因特网地址及套接字。这些信息会显示出来,然后添加一个额外的字串,指出自己已从服务器反馈回来了。 大家可能会觉得有点儿迷惑。正如大家会看到的那样,许多不同的因特网地址和端口号都可能是消息的起源地——换言之,客户程序可能驻留在任何一台机器里(就这一次演示来说,它们都驻留在localhost里,但每个客户使用的端口编号是不同的)。为了将一条消息送回它真正的始发客户,需要知道那个客户的因特网地址以及端口号。幸运的是,所有这些资料均已非常周到地封装到发出消息的DatagramPacket内部,所以我们要做的全部事情就是用getAddress()和getPort()把它们取出来。利用这些资料,可以构建DatagramPacket echo——它通过与接收用的相同的套接字发送回来。除此以外,一旦套接字发出数据报,就会添加“这”台机器的因特网地址及端口信息,所以当客户接收消息时,它可以利用getAddress()和getPort()了解数据报来自何处。事实上,getAddress()和getPort()唯一不能告诉我们数据报来自何处的前提是:我们创建一个待发送的数据报,并在正式发出之前调用了getAddress()和getPort()。到数据报正式发送的时候,这台机器的地址以及端口才会写入数据报。所以我们得到了运用数据报时一项重要的原则:不必跟踪一条消息的来源地!因为它肯定保存在数据报里。事实上,对程序来说,最可靠的做法是我们不要试图跟踪,而是无论如何都从目标数据报里提取出地址以及端口信息(就象这里做的那样)。 为测试服务器的运转是否正常,下面这程序将创建大量客户(线程),它们都会将数据报包发给服务器,并等候服务器把它们原样反馈回来。

//: ChatterServer.java // A server that echoes datagrams import java.net.*; import java.io.* ; import java.util.*;
public class ChatterServer { 
  static final int INPORT = 1711; 
  private byte[] buf = new byte[1000]; 
  private DatagramPacket dp = new DatagramPacket(buf, buf.length); 
  // Can listen & send on the same socket: private DatagramSocket socket;
  public ChatterServer() { 
    try { 
      socket = new DatagramSocket(INPORT); 
      System.out.println("Server started"); 
      while(true) { 
        // Block until a datagram appears: 
        socket.receive(dp); 
        String rcvd = Dgram.toString(dp) + ", from address: " + dp.getAddress() + ", port: " + dp.getPort(); 
        System.out.println(rcvd); 
        String echoString = "Echoed: " + rcvd; 
        // Extract the address and port from the 
        // received datagram to find out where to 
        // send it back: 
        DatagramPacket echo = Dgram.toDatagram(echoString, dp.getAddress(), dp.getPort()); socket.send(echo); 
      } 
    } catch(SocketException e) { 
      System.err.println("Can't open socket"); 
      System.exit(1); 
    } catch(IOException e) { 
      System.err.println("Communication error"); 
      e.printStackTrace(); 
    } 
  } 
  public static void main(String[] args) { 
    new ChatterServer(); 
  }
} ///:~

ChatterClient被创建成一个线程(Thread),所以可以用多个客户来“骚扰”服务器。从中可以看到,用于接收的DatagramPacket和用于ChatterServer的那个是相似的。在构建器中,创建DatagramPacket时没有附带任何参数(自变量),因为它不需要明确指出自己位于哪个特定编号的端口里。用于这个套接字的因特网地址将成为“这台机器”(比如localhost),而且会自动分配端口编号,这从输出结果即可看出。同用于服务器的那个一样,这个DatagramPacket将同时用于发送和接收。 hostAddress是我们想与之通信的那台机器的因特网地址。在程序中,如果需要创建一个准备传出去的DatagramPacket,那么必须知道一个准确的因特网地址和端口号。可以肯定的是,主机必须位于一个已知的地址和端口号上,使客户能启动与主机的“会话”。 每个线程都有自己独一无二的标识号(尽管自动分配给线程的端口号是也会提供一个唯一的标识符)。在run()中,我们创建了一个String消息,其中包含了线程的标识编号以及该线程准备发送的消息编号。我们用这个字串创建一个数据报,发到主机上的指定地址;端口编号则直接从ChatterServer内的一个常数取得。一旦消息发出,receive()就会暂时被“堵塞”起来,直到服务器回复了这条消息。与消息附在一起的所有信息使我们知道回到这个特定线程的东西正是从始发消息中投递出去的。在这个例子中,尽管是一种“不可靠”协议,但仍然能够检查数据报是否到去过了它们该去的地方(这在localhost和LAN环境中是成立的,但在非本地连接中却可能出现一些错误)。 运行该程序时,大家会发现每个线程都会结束。这意味着发送到服务器的每个数据报包都会回转,并反馈回正确的接收者。如果不是这样,一个或更多的线程就会挂起并进入“堵塞”状态,直到它们的输入被显露出来。 大家或许认为将文件从一台机器传到另一台的唯一正确方式是通过TCP套接字,因为它们是“可靠”的。然而,由于数据报的速度非常快,所以它才是一种更好的选择。我们只需将文件分割成多个数据报,并为每个包编号。接收机器会取得这些数据包,并重新“组装”它们;一个“标题包”会告诉机器应该接收多少个包,以及组装所需的另一些重要信息。如果一个包在半路“走丢”了,接收机器会返回一个数据报,告诉发送者重传。

下一节:现在让我们想想如何创建一个应用,令其在真实的Web环境中运行,它将把Java的优势表现得淋漓尽致。这个应用的一部分是在Web服务器上运行的一个Java程序,另一部分则是一个“程序片”或“小应用程序”(Applet),从服务器下载至浏览器(即“客户”)。这个程序片从用户那里收集信息,并将其传回Web服务器上运行的应用程序。程序的任务非常简单:程序片会询问用户的E-mail地址,并在验证这个地址合格后(没有包含空格,而且有一个@符号),将该E-mail发送给Web服务器。服务器上运行的程序则会捕获传回的数据,检查一个包含了所有E-mail地址的数据文件。如果那个地址已包含在文件里,则向浏览器反馈一条消息,说明这一情况。该消息由程序片负责显示。若是一个新地址,则将其置入列表,并通知程序片已成功添加了电子函件地址。