自從最初的 Java 2 platform, Version 1.2 發布以後,Java Collections Framework 一直在不斷發展。在 Java SE 5 中,泛型的引入增強了框架, java.util.concurrent 的引入添加了對並發的直接支持(請參閱 參考資料)。 在 Java SE 6 中,框架中添加了更好的雙向集合訪問特性。本文將向您介紹集 合庫的所有這些方面,並幫助您利用與並發相關的流行功能。
本文的高級任務是創建一個 Web crawler:給定一個網站的基 URL,從該網 站收集可以用作某種用途的元素。您將從單個網頁收集一系列鏈接,然後蔓延到 整個網站。把高級任務分解為子任務,這些子任務可以轉化為自己的作業。您將 了解並使用泛型和線程池。為了使任務更加簡單,我們將任務作為獨立的客戶端 應用程序實現。(解釋如何部署 Web 應用程序並不是本文的中心目的。但是可 以隨意創建一個 Web 應用程序,將任務作為附加的練習在此應用程序中啟動。 )
您應該熟悉 Java 平台上的程序開發。本文假設您熟悉連網和 I/O 庫,這兩 方面知識將分別用於 socket 連接和讀取流。您需要安裝一個開發人員版本的 Java SE 6 平台。它至少應該是來自 Sun Microsystems 的 Update 5 of JDK 6 或來自 IBM 的 最新的 SDK for Java, Version 6。
了解泛型
從 Java SE 5 版本開始,泛型的概念就成為了 Java 平台的一部分(請參閱 參考資料)。簡單來說,泛型為集合提供了編譯時類型安全。在早期的 Java 平 台版本中,您創建一個集合,並向其中添加項,如清單 1 所示:
清單 1. 向集合添加項 — 舊方法
List buttonList = new LinkedList();
buttonList.add(new JButton("One"));
buttonList.add(new JButton("Two"));
buttonList.add(new JButton("Three"));
buttonList.add(new JButton("Four"));
要從集合中提取元素,您必須知道集合中對象的類型,以將其強制轉換為合 適的局部變量:
JButton first = (JButton)buttonList.get(0);
您並不需要 將其強制轉換為正確的類型,但是如果您想要對某個特定類類型 進行操作,則需要這麼做。這種方法運行得很好,除非您不小心向集合中添加了 錯誤的類型對象:
buttonList.add(new JLabel("Five"));
現在,如果您嘗試將最後一個元素作為 JButton 來提取,則在運行時會出現 一個類轉換異常:
Line 13: JButton last = (JButton)buttonList.get(4);
>java GetIt
Exception in thread "main" java.lang.ClassCastException:
javax.swing.JLabel cannot be cast to javax.swing.JButton
at GetIt.main(GetIt.java:13)
在本質上,將 JLabel 放入集合並沒有任何問題,但是如果提取代碼希望集 合中的所有元素都是同一類型(這裡為 JButton),那麼從集合中提取一個 JLabel 就會生成 ClassCastException。這個異常只會在運行時出現;如果沒有 進行足夠的測試,那麼也許直到部署之後才會出現該異常。
泛型集合的使用
現在進入泛型的世界。泛型可以幫助您在開發周期的早期解決編碼問題。不 只是擁有一個集合並向其中添加 JButton 對象,您可以擁有一個 JButton 對象 的集合。然後,如果想要將 JLabel 添加到集合,則編譯器會在編譯時發現差異 和並拋出異常。
在嘗試向泛型集合(本例中為 List<JButton>)添加錯誤類型的元素 時,清單 2 中的程序會生成編譯時錯誤消息:
清單 2. 使用泛型的示例代碼(沒有編譯)
import java.util.*;
import javax.swing.*;
public class GetIt {
public static void main(String args[]) {
List<JButton> buttonList = new LinkedList<JButton> ();
buttonList.add(new JButton("One"));
buttonList.add(new JButton("Two"));
buttonList.add(new JButton("Three"));
buttonList.add(new JButton("Four"));
JButton first = buttonList.get(0);
buttonList.add(new JLabel("Five"));
JButton last = buttonList.get(4);
}
}
當您保存並編譯該應用程序時,您將注意到對 add() 的最後調用失敗了:
>javac GetIt.java
GetIt.java:12: cannot find symbol
symbol : method add(javax.swing.JLabel)
location: interface java.util.List<javax.swing.JButton>
buttonList.add(new JLabel("Five"));
^
1 error
錯誤消息的第二行表明,您嘗試將一個 JLabel 添加到第三個錯誤行報告的 JButton 對象的 List。然後您必須決定該集合是否必須為 Component 對象(或 者 JComponent,如果您想要使用 Swing 平台組件)的集合,或者您是否不應該 嘗試在第一個位置添加 JLabel。
注意,在 清單 2 中,從集合中提取項並不需要將其強制轉換為正確的類型 。因為您已經說明了集合為某種類型,從集合中提取項的所有調用都返回給定的 類型。
泛型的使用使您的代碼庫更容易維護,尤其當代碼庫不斷增長,以及將代碼 元素轉換為可重復使用的庫時。庫的用戶不用擔心對集合中對象的類型有任何限 制。正確定義的方法應該在其定義中包含這些類型。並且如果您的類型不符合該 類型,編譯器會給出警告。
泛型編譯器問題
當一個類使用的集合的定義中缺少泛型類型時,在編譯這個類時,編譯器就 會報錯,比如編譯 清單 1 中的代碼就會出現這種情形。例如,假設您想要編譯 一個包含以下行的類:
List buttonList = new LinkedList();
編譯器會發出一個警告:
>javac GetIt.java
Note: GetIt.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
您可以忽略該警告,不予理會。假設您並沒有意外添加錯誤的數據類型到集 合中,那麼一切都會運行良好。
查看詳細的警告
要了解編譯器警告您的特定問題的詳細信息,可以向編譯器發出 - Xlint:unchecked 命令。您將看到如清單 3 所示的輸出:
清單 3. 使用 Xlint 進行編譯的詳細信息
>javac -Xlint:unchecked GetIt.java
GetIt.java:7: warning: [unchecked] unchecked call to add(E) as a member of
the raw type java.util.List
buttonList.add(new JButton("One"));
^
GetIt.java:8: warning: [unchecked] unchecked call to add(E) as a member of
the raw type java.util.List
buttonList.add(new JButton("Two"));
^
GetIt.java:9: warning: [unchecked] unchecked call to add(E) as a member of
the raw type java.util.List
buttonList.add(new JButton("Three"));
^
GetIt.java:10: warning: [unchecked] unchecked call to add(E) as a member of
the raw type java.util.List
buttonList.add(new JButton("Four"));
^
4 warnings
在清單 3 中可以看到,編譯器並不特別關心 List 未定義數據類型。它顯示 的是,每個對 add() 的調用都存在問題,因為該 List 未定義數據類型。
另外,這些都是警告,所以您可以忽略它們。但是,修復該集合以顯式地指 定類型,將會避免在編譯時遇到這些警告引起的一個真正的錯誤。
禁止編譯器警告
如果您使用的是一個不能或不想更改的庫,那麼您可以禁止編譯器警告。 @SuppressWarnings 注釋會告訴編譯器您知道代碼生成了警告,但是您不想看到 它們。如果您將下面這行代碼添加到想要忽略其警告的方法前面,則編譯器不再 顯示該方法的警告:
@SuppressWarnings("unchecked")
現在,當您編譯該類時,將不會看到警告消息或錯誤消息。如果處理未預料 到的數據類型,您仍然有可能獲得 ClassCastException。選擇權在您手中。
讀取網頁
現在您應該深刻了解了泛型的用途,以及它們如何使您的程序更容易維護。 下一步是創建一個程序來收集某個特定網頁上的所有鏈接。盡管您可以自己寫一 個程序來讀取網頁並解析其內容,但是不必這麼做。Swing 組件庫提供了這項功 能。您需要做的就是查找與頁面上的錨(<a>)標記相關聯的 href 屬性 。
獲取文檔
javax.swing.text.html 包包含一個 HTMLEditorKit。如果您向它提供一個 流,它會解析相關的網頁。根據這個解析的流,您可以告訴工具箱遍歷所有可用 的標記,並獲得錨標記的 href 屬性。程序的功能還可以更加豐富,可以收集圖 像標記或 Flash 影片,但是它只會收集 <a href="...">xxx</a> 形式的內容。
您需要做的只是創建一個新的 HTMLEditorKit 實例,並將一個 Reader 傳入 到內容中。因為想法是從遠程網站獲得內容,所以您必須使用在命令行輸入的 http:// 字符串來獲得 Reader,該字符串隨後被傳遞到 URL 構造器,您可以從 中獲得一個 URLConnection。這個過程聽起來很復雜,其實並不是這樣。清單 4 顯示了它的工作原理:
清單 4. 讀取網頁
HttpURLConnection.setFollowRedirects(false);
EditorKit kit = new HTMLEditorKit();
Document doc = kit.createDefaultDocument();
doc.putProperty("IgnoreCharsetDirective", Boolean.TRUE);
String uri = args[0];
Reader reader = null;
if (uri != null && uri.startsWith("http")) {
URLConnection conn = new URL(uri).openConnection();
reader = new InputStreamReader(conn.getInputStream());
} else {
System.err.println(
"Usage: java ListUrls http://example.com/startingpage");
System.exit(-1);
}
kit.read(reader, doc, 0);
與連接相關聯的輸入流被提供給 EditorKit 的 read() 方法。read() 的其 他參數包括一個 Document 和一個開始讀取的位置,前者是您通過調用工具箱的 createDefaultDocument() 方法創建的,後者通常為 0,表示流的起點。
清單 4 添加了兩個有幫助的附加任務。調用 HttpURLConnection 類的 setFollowRedirects() 方法可禁止後面的重定向請求。而設置 Document 的 IgnoreCharsetDirective 屬性是因為,當頁面的 <meta> 標記中包含一 個 charset 屬性時,HTMLEditorKit 中明顯存在一個 bug。
遍歷元素
您將使用的下一個 Swing 類是 ElementIterator,可以在 javax.swing.text 包中找到。使用 Document(與剛創建的 Document 類似), 您可以遍歷其中所有的元素:
ElementIterator it = new ElementIterator(doc);
javax.swing.text.Element elem;
while ((elem = it.next()) != null) {
// ...
}
通過搜索 <a> 標記,您可以獲得相關的 href 屬性並添加到發現的鏈 接集合中。這裡使用的集合是一個 Set,因為沒有必要收集重復的內容:
Set<String> uriList = new TreeSet<String>();
// Below is inside of while loop
AttributeSet s = (AttributeSet)
elem.getAttributes().getAttribute(HTML.Tag.A);
if (s != null) {
String href = (String)
s.getAttribute(HTML.Attribute.HREF);
uriList.add(href);
}
盡管到目前為止執行的步驟已經足夠收集所有鏈接,您也可以處理一些特殊 的情況。比如,在發現的 href 為空的地方不需要添加鏈接 — 格式良好的文檔 不應該出現這種情況,但是有時候確實會出現。另外,內部鏈接沒有前導的 http://。最好將這些內部鏈接附加到文檔的基 URL 之後,這樣如果您需要再次 遍歷該列表(比如在下一任務中),您可以擁有完整的 URL。而且,最好不要使 用 javascript: 標記。還可以進行其他更多增強。清單 5 顯示了完整的程序:
清單 5. 列出單個頁面的 URL 的代碼
import java.io.*;
import java.net.*;
import java.util.*;
import javax.swing.text.*;
import javax.swing.text.html.*;
public class ListUrls {
public static void main(String args[]) throws Exception {
Set<String> uriList = new TreeSet<String>();
HttpURLConnection.setFollowRedirects(false);
EditorKit kit = new HTMLEditorKit();
Document doc = kit.createDefaultDocument();
doc.putProperty("IgnoreCharsetDirective", Boolean.TRUE);
String uri = args[0];
Reader reader = null;
if (uri != null && uri.startsWith("http")) {
URLConnection conn = new URL(uri).openConnection();
reader = new InputStreamReader(conn.getInputStream());
} else {
System.err.println(
"Usage: java ListUrls http://example.com/startingpage");
System.exit(-1);
}
kit.read(reader, doc, 0);
ElementIterator it = new ElementIterator(doc);
javax.swing.text.Element elem;
while ((elem = it.next()) != null) {
AttributeSet s = (AttributeSet)
elem.getAttributes().getAttribute(HTML.Tag.A);
if (s != null) {
String href = (String)s.getAttribute (HTML.Attribute.HREF);
if (href == null) {
continue;
} else if (href.startsWith("javascript:")) {
continue; // skip it
} else if (href.startsWith("https:")) {
// add as is
} else if (!href.startsWith("http:")) {
href = uri + href;
}
uriList.add(href);
}
}
for (String element: uriList) {
System.out.printf(">>%s<<%n", element);
}
}
}
該程序打印出了收集的 URL 集合。下載並編譯 ListUrls 程序,通過在命令 行傳入一個 URL 來運行該程序(要獲取本文的完整源代碼,請參閱 下載 部分 的鏈接)。確切的結果取決於您收集的頁面。
.
線程池
清單 5 中的 ListUrls 程序收集某個特定頁面上的所有外出鏈接。要改進此 程序,使其作用到整個網站,最好將其分解為小一些的任務。盡管可以在一個線 程中完成所有工作,但是應用程序肯定會受到 I/O 延遲的阻礙,因為它必須首 先讀取完整的網頁,然後才對其進行處理。網絡延遲是將工作分解為多個線程另 一個原因。在單獨的線程內處理 Set 的每個元素應該可以顯著提高整個工作的 處理速度。當然,您需要限制線程的數量,否則將會執行太多的任務,這些任務 之間的交換將會花費更多的時間。
Executor
Java SE 5 引入了 java.util.concurrent 庫和泛型(請參閱 參考資料)。 Executor 接口接受 Runnable 對象並執行。這類似於將一個 Runnable 對象傳 遞到 Thread 構造器中,但是借助 Executor,當一個 Thread 處理完一個 Runnable 後,可以重新使用它獲得新的 Runnable。因此,該程序避免了不斷丟 棄並重新創建線程的過程。Executor 接口有一個 execute() 方法,它接受 Runnable 參數。具體結果取決於 Executor 接口的特定實現。
Executor 的一個實現就是 ThreadPoolExecutor。使用 Executors 工具類來 創建線程池,而不是直接調用 ThreadPoolExecutor 構造器來創建。對於固定大 小的線程池,使用 newFixedThreadPool(int maxThreads);或者使用 newFixedThreadPool(int maxThreads, ThreadFactor factory),它允許您提供 一個用於創建底層線程的工廠。
創建線程池之後,使用 service(Runnable) 方法添加要運行的任務。對於您 創建的 Web crawler,可以調用 awaitTermination() 方法來確定所有任務何時 完成,或者至少確定出線程池何時終止,如清單 6 所示:
清單 6. 使用線程池
String uri =...
ExecutorService service = Executors.newFixedThreadPool(5);
service.execute(new Crawler(service, uri, uri));
service.awaitTermination(300, TimeUnit.SECONDS);
for (String element: allUriList) {
System.out.printf(">>%s<<%n", element);
}
awaitTermination() 方法接受一個超時。清單 6 中的程序被設置為五分鐘 後超時。根據想要讓程序運行多久、網絡連接速度和想對網站進行處理的深度, 您可以使用更長或更短的超時。
另外請注意,只有基 URI 字符串被添加到了 crawler。讀取每個頁面時,會 將新的 URI 添加到作業隊列。
Runnable
該服務執行的 Runnable 任務是 清單 5 中的大量代碼。我添加了一些額外 的檢查,以改進構建下一個頁面的 URL 的過程。execute() 方法末尾的檢查確 定服務是否應該終止。通常情況下,線程池會運行到程序結束,但是在線程池完 成時這個程序才會結束,所以這個檢查非常必要。
下載 CollectUrls 程序,並在一個相對較小的網站上運行它,最好是您自己 的網站,以從該網站獲得所有的鏈接。您也可以修改該程序以保持一個多重映射 :如果您知道每個鏈接的源,您可以自動生成網站層次和互連的映射。
其他線程池
CollectUrls Web crawler 程序利用一個固定大小的線程池。但它不是惟一 的選擇。可以使用 Executors 工具類創建其他三種線程池:
newCachedThreadPool() 可創建極大的線程池,但是當線程空閒太久時,它 會終止線程。如果您有很多短期的異步任務,可以考慮使用它。如果線程池中有 可用的線程,它就會被使用。如果沒有可用的線程,則會創建一個新線程,然後 如果線程池中的線程空閒了 60 秒,該線程就會消失。當沒有進行任何任務時, 不會使用任何資源。相反,當沒有任務要完成時,固定大小的線程池會讓所有的 線程等待。
newSingleThreadExecutor() 創建的線程池對需要按順序執行的作業非常有 用。如果底層的線程終止了,它會被重新創建。這類似於創建一個固定大小的線 程池,但是固定大小的線程池無法更改大小。
newScheduledThreadPool() 可以創建像 Timer 對象一樣工作的線程池,但 是能夠更好地處理未捕獲的異常和線程饑餓。借助 Timer 類,您可以擁有一個 長時間運行的 TimerTask,阻止其他任務運行。一個線程池中包含多個線程可以 防止其他任務被阻止,並仍然保持線程的計劃。
也可以使用其他集合類型。作為計劃的線程池的備選方法,可以考慮使用 DelayQueue。它允許您向集合中添加那些在延遲時間失效之後才能提取的項目。 它是一種特定類型的 BlockingQueue:如果一個項不可用,則從隊列獲取該項會 受到阻止,直到延遲失效。
結束語
本文向您介紹了創建 Web crawler 的過程:
收集泛型 Set 中的 URI 字符串的集合
生成 Runnable 任務,在網站的頁面上找到更多的 URI。
使用線程池來完成 Runnable 操作
要擴展該 Web crawler,可以考慮收集圖像引用或搜索特定的文本字符串。 您可以改進該程序,增強其功能,並學習更多使用並發集合技術的知識。
本文配套源碼