# 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套接字,因為它們是“可靠”的。然而,由于數據報的速度非常快,所以它才是一種更好的選擇。我們只需將文件分割成多個數據報,并為每個包編號。接收機器會取得這些數據包,并重新“組裝”它們;一個“標題包”會告訴機器應該接收多少個包,以及組裝所需的另一些重要信息。如果一個包在半路“走丟”了,接收機器會返回一個數據報,告訴發送者重傳。
- Java 編程思想
- 寫在前面的話
- 引言
- 第1章 對象入門
- 1.1 抽象的進步
- 1.2 對象的接口
- 1.3 實現方案的隱藏
- 1.4 方案的重復使用
- 1.5 繼承:重新使用接口
- 1.6 多態對象的互換使用
- 1.7 對象的創建和存在時間
- 1.8 異常控制:解決錯誤
- 1.9 多線程
- 1.10 永久性
- 1.11 Java和因特網
- 1.12 分析和設計
- 1.13 Java還是C++
- 第2章 一切都是對象
- 2.1 用引用操縱對象
- 2.2 所有對象都必須創建
- 2.3 絕對不要清除對象
- 2.4 新建數據類型:類
- 2.5 方法、參數和返回值
- 2.6 構建Java程序
- 2.7 我們的第一個Java程序
- 2.8 注釋和嵌入文檔
- 2.9 編碼樣式
- 2.10 總結
- 2.11 練習
- 第3章 控制程序流程
- 3.1 使用Java運算符
- 3.2 執行控制
- 3.3 總結
- 3.4 練習
- 第4章 初始化和清除
- 4.1 用構造器自動初始化
- 4.2 方法重載
- 4.3 清除:收尾和垃圾收集
- 4.4 成員初始化
- 4.5 數組初始化
- 4.6 總結
- 4.7 練習
- 第5章 隱藏實現過程
- 5.1 包:庫單元
- 5.2 Java訪問指示符
- 5.3 接口與實現
- 5.4 類訪問
- 5.5 總結
- 5.6 練習
- 第6章 類復用
- 6.1 組合的語法
- 6.2 繼承的語法
- 6.3 組合與繼承的結合
- 6.4 到底選擇組合還是繼承
- 6.5 protected
- 6.6 累積開發
- 6.7 向上轉換
- 6.8 final關鍵字
- 6.9 初始化和類裝載
- 6.10 總結
- 6.11 練習
- 第7章 多態性
- 7.1 向上轉換
- 7.2 深入理解
- 7.3 覆蓋與重載
- 7.4 抽象類和方法
- 7.5 接口
- 7.6 內部類
- 7.7 構造器和多態性
- 7.8 通過繼承進行設計
- 7.9 總結
- 7.10 練習
- 第8章 對象的容納
- 8.1 數組
- 8.2 集合
- 8.3 枚舉器(迭代器)
- 8.4 集合的類型
- 8.5 排序
- 8.6 通用集合庫
- 8.7 新集合
- 8.8 總結
- 8.9 練習
- 第9章 異常差錯控制
- 9.1 基本異常
- 9.2 異常的捕獲
- 9.3 標準Java異常
- 9.4 創建自己的異常
- 9.5 異常的限制
- 9.6 用finally清除
- 9.7 構造器
- 9.8 異常匹配
- 9.9 總結
- 9.10 練習
- 第10章 Java IO系統
- 10.1 輸入和輸出
- 10.2 增添屬性和有用的接口
- 10.3 本身的缺陷:RandomAccessFile
- 10.4 File類
- 10.5 IO流的典型應用
- 10.6 StreamTokenizer
- 10.7 Java 1.1的IO流
- 10.8 壓縮
- 10.9 對象序列化
- 10.10 總結
- 10.11 練習
- 第11章 運行期類型識別
- 11.1 對RTTI的需要
- 11.2 RTTI語法
- 11.3 反射:運行期類信息
- 11.4 總結
- 11.5 練習
- 第12章 傳遞和返回對象
- 12.1 傳遞引用
- 12.2 制作本地副本
- 12.3 克隆的控制
- 12.4 只讀類
- 12.5 總結
- 12.6 練習
- 第13章 創建窗口和程序片
- 13.1 為何要用AWT?
- 13.2 基本程序片
- 13.3 制作按鈕
- 13.4 捕獲事件
- 13.5 文本字段
- 13.6 文本區域
- 13.7 標簽
- 13.8 復選框
- 13.9 單選鈕
- 13.10 下拉列表
- 13.11 列表框
- 13.12 布局的控制
- 13.13 action的替代品
- 13.14 程序片的局限
- 13.15 視窗化應用
- 13.16 新型AWT
- 13.17 Java 1.1用戶接口API
- 13.18 可視編程和Beans
- 13.19 Swing入門
- 13.20 總結
- 13.21 練習
- 第14章 多線程
- 14.1 反應靈敏的用戶界面
- 14.2 共享有限的資源
- 14.3 堵塞
- 14.4 優先級
- 14.5 回顧runnable
- 14.6 總結
- 14.7 練習
- 第15章 網絡編程
- 15.1 機器的標識
- 15.2 套接字
- 15.3 服務多個客戶
- 15.4 數據報
- 15.5 一個Web應用
- 15.6 Java與CGI的溝通
- 15.7 用JDBC連接數據庫
- 15.8 遠程方法
- 15.9 總結
- 15.10 練習
- 第16章 設計模式
- 16.1 模式的概念
- 16.2 觀察器模式
- 16.3 模擬垃圾回收站
- 16.4 改進設計
- 16.5 抽象的應用
- 16.6 多重分發
- 16.7 訪問器模式
- 16.8 RTTI真的有害嗎
- 16.9 總結
- 16.10 練習
- 第17章 項目
- 17.1 文字處理
- 17.2 方法查找工具
- 17.3 復雜性理論
- 17.4 總結
- 17.5 練習
- 附錄A 使用非JAVA代碼
- 附錄B 對比C++和Java
- 附錄C Java編程規則
- 附錄D 性能
- 附錄E 關于垃圾收集的一些話
- 附錄F 推薦讀物