現在你已經對.NET 中的網絡編程有了一個初步的了解,現在我們來實際討論下本章將要實現的示例程序。這個例子的目的是通過創建一個網絡應用程序來讓你熟悉線程的使用。這個程序實際上由兩個小的Windows 窗體程序組成,一個作為服務端而另外一個作為客戶端。我們將使用Visual Studio.NET 來設計實現這些程序。
設計目標
我們想創建兩個交互程序。第一個是用來從一個數據庫表中尋找股票交易數據然后將數據異步地返回給客戶端的多線程/多用戶股票交易服務程序。第二個是一個通過股票交易號來從服務端查詢股票信息的客戶端。所有這些都異步執行,客戶端用戶接口在服務端對請求作出響應時不會卡住。
在.NET Framework 中有很多方法可以為我們處理異步操作;通過這些方法我們從手動創建並管理線程的工作中解放出來。
下面第一個列表列舉出我們創建應用程序所需要的基本信息:
1. 將會有兩個獨立存在的程序可以在網絡上互相通信
2. 當向服務端查詢股票交易時,客戶端的用戶接口不會由於網絡連接問題而導致卡住或延遲
3. 服務端應該有能力同時處理多個客戶端連接和請求,能夠以異步方式和客戶端通信
4. 網絡設置必須與應用層隔離開來並且是易於修改的
為了幫助我們了解程序里典型的用戶交互邏輯,我們來看一下UML 圖形。
圖 1
到目前為止,我們從一個很高的起點討論了一下程序的基本設計指導。如果你和大多數程序員一樣,你可能已經等不及要看看代碼了。事不宜遲,我們現在就來實際創建這兩個程序並同時檢查一下我們學過的概念。
創建程序
正如之前提到的,本章的示例程序包含兩個獨立部分:一個客戶端和一個服務端。這兩個程序將通過一個特殊的TCP/IP 端口進行通信,可以通過應用程序配置文件修改端口。好了,我們現在就來開始實現程序部分。
創建客戶端程序
下面的類圖包含了客戶端程序的所有代碼:
StockClient 應用程序包含所有客戶端程序的代碼,比如私有成員變量和方法。我們首先創建一個StockClient Windows 窗體程序。在默認窗體上再創建三個控件;一個名為txtStock 的文本框,一個名為btnGetQuote 的按鈕和一個名為lstQuotes 的列表視圖控件。然后為這個程序添加一個菜單頁,添加包括文件,連接和退出菜單項。最后,要保證除了菜單項意外的所有控件的屬性都設置為False; 直到用戶連接到服務端才啟用這些控件。
代碼如下:
首先是在StockClient 程序中要用到的一些私有成員變量:
private int mPort; private string mHostName; private const int mPacketSize = 1024; private byte[] mReceivedData = new byte[mPacketSize]; private TcpClient mMyClient; private StringBuilder mStrBuilder = new StringBuilder();
稍后我們再看這些變量,現在我們來修改一下ListView 控件以便於可以保留我們輸入的所有股票信息。我們需要它包含六列:Symbol, Price, Change, Bid, Ask 和 Volume. 現在創建一個InitializeStockWindow() 來把這些列添加到ListView 控件中:
private void InitializeStockWindow() { lstQuotes.View = View.Details; lstQuotes.Columns.Add("Symbol", 60, HorizontalAlignment.Left); lstQuotes.Columns.Add("Price", 50, HorizontalAlignment.Left); lstQuotes.Columns.Add("Change", 60, HorizontalAlignment.Left); lstQuotes.Columns.Add("Bid", 50, HorizontalAlignment.Left); lstQuotes.Columns.Add("Ask", 50, HorizontalAlignment.Left); lstQuotes.Columns.Add("Volume", 170, HorizontalAlignment.Left); }
下面代碼用來實現關閉事件。它簡單地啟用文件菜單的連接選項並通過一個消息提示框提示消息:
public delegate void DisconnectedHandler(object sender); public event DisconnectedHandler Disconnected; private void OnDisconnected(object sender) { mnuConnect.Enabled = true; MessageBox.Show("The connection was lost!", "Disconnected", MessageBoxButtons.OK, MessageBoxIcon.Error); EnableComponents(false); }
提到關閉事件,就不得不說一下連接事件,下面看一下連接方法:
private void mnuConnect_Click(object sender, EventArgs e) { IDictionary hostSettings; try { hostSettings = (IDictionary)ConfigurationManager.GetSection("HostInfo"); mHostName = (string)hostSettings["hostname"]; mPort = (int)hostSettings["port"]; mMyClient = new TcpClient(mHostName, mPort); mMyClient.GetStream().BeginRead( mReceivedData, 0, mPacketSize, new AsyncCallback(ReceiveStream), null); EnableComponents(true); InitializeStockWindow(); mnuConnect.Enabled = false; Disconnected += new DisconnectedHandler(OnDisconnected); } catch (System.Exception ex) { MessageBox.Show("Error: Unable to establish a connection!", "Disconnected", MessageBoxButtons.OK, MessageBoxIcon.Error); mMyClient.Close(); } }
如果你正確地實現了上面所有步驟而且有一個服務器在指定主機名和端口處監聽,那么就會創建一個新連接。為了保持住連接,我們必須生成一個后台線程來異步地從服務端獲取數據並將數據顯示給用戶。這部分開始變得有趣了。
如之前提到的,我們需要我們程序的接收方法是異步的。這是客戶端能夠工作且不會延遲任何用戶請求的唯一方式。讓客戶端程序在等待數據從服務端返回過程中卡死是無法讓人接受的。由於有了.NET Framework, 解決方案相對來說簡單並易於實現了。我們首先定義一個TcpClient 的NetworkStream 對象。我們可以調用TcpClinet.GetStream() 方法來返回NetworkStream 對象,並通過它來發送和接收數據。NetworkStream 繼承自Stream 類,提供了一系列方法用來進行網絡通信。一旦我們有了一個底層數據流,我們可以用它來在網絡上發送和接收數據。與其兄弟類FileStream 和 TextStream 類似,NetworkStream 類暴露讀寫方法用來以同步方式發送和接收數據。BeginRead() 和 BeginWrite() 是這些方法的異步版本。實際上,.NET Framework 中的大部分以Begin開始的方法,比如BeginRead() 和 BeginGetResponse(),當作為委托時不需要程序員提供任何額外代碼就能實現異步調用。因此,沒有必要生成新線程,由於有一個后台線程來處理數據讀取,程序的主線程仍然可以相應UI 請求。讓我們來看一下BeginRead() 方法簽名:
public override IAsyncResult BeginRead( byte[] buffer, int offset, int size, AsyncCallback callback, object state);
下表解釋了這個方法的每個參數。
在我們繼續之前,讓我們來了解下異步調用,因為這是一個非常重要的概念。正如之前提到的那樣,同步操作的問題在於直到調用結束之后工作線程才能繼續工作。異步調用可以運行在一個后台線程中並允許調用線程繼續正常執行。.NET 允許使用委托對任何類/方法進行異步調用。然而,特定的類,比如NetworkStream中的BeginRead() 方法已經包含內建的異步能力。委托作為需要進行異步調用的占位符。委托事實上是一個類型安全的函數指針。
正如我們看到的,BeginRead() 方法需要一個字節數組而不是字符串或者文本流,因此處理起來會稍微有點復雜。我們已經定義了一個名為ReceiveData的變量和另外一個整型變量PacketSize. 現在我們需要傳遞實際接收數據的方法名-當數據到達以后這個方法將要被委托調用。記住這個方法將要運行在一個后台線程中,所以如果我們希望和UI交互的話那就得小心了。我們通過一行代碼生成一個后台線程來接收通過網絡從服務端發過來的數據:
mMyClient.GetStream().BeginRead( mReceivedData, 0, mPacketSize, new AsyncCallback(ReceiveStream), null);
我們創建了一個ReceivedStream() 方法來處理接收到的數據:
private void ReceiveStream(IAsyncResult ar) { int bytesCount; try { bytesCount = mMyClient.GetStream().EndRead(ar); if (bytesCount < 1) { Disconnected(this); return; } MesssageAssembler(mReceivedData, 0, bytesCount); mMyClient.GetStream().BeginRead(mReceivedData, 0, mPacketSize, new AsyncCallback(ReceiveStream), null); } catch (System.Exception ex) { //Display error message object[] paramObjs = {("An error has occurred " + ex.ToString()).ToString()}; Invoke(new InvokeDisplay(DisplayData), paramObjs); } }
首先,我們需要檢查下緩存中是否有數據。通常情況下應該一直有數據。你可以把網絡連接想象成一段連續的脈沖;只要客戶端連着服務端,在到來的數據包中就會有數據,不管多小。我們使用Stream 對象的EndRead() 方法來檢查當前字節數組的大小。我們給EndRead() 方法傳遞一個IAsyncResult 的實例。GetStream().BeginRead() 方法初始化一個異步調用來調用ReceiveStream()方法,為了保證異步調用完成編譯器還會在后台做一些額外工作。ReceiveStream() 方法在一個線程池線程上執行。如果委托方法ReceiveStream() 拋出一個異常,那么新創建的異步線程就會被終止,並在調用線程中再產生一個異常。下表深入描述了這種情況:
如果EndRead() 方法返回的數字小於1,我們就會知道連接已經丟失,接下來可以引發Disconnected 事件來進行適當的工作處理這種情況。然而,如果接收到字節數目大於0,我們可以開始接受數據。在這個時候,我們需要一個幫助類類幫助我們把從服務端接收到的數據構造成一個字符串。
事實上,.NET 中你可以按照與BeginRead()方法同樣行為來異步調用幾乎任何方法。你僅需要定義一個委托並使用BeginInvoke()和EndInvoke() 來調用委托。后者在定義委托時會自動添加。異步架構復雜的細節已經抽象出來,你不需要擔心后台線程和同步問題。需要注意的是在VS.NET IDE 的智能感知中不能找到這兩個方法。它們僅在運行時才會被添加。
好的,我們再來看看MessageAssembler() 方法。由於BeginRead() 方法的異步特性,我們實際上並不知道何時以及何種數據將會從服務端到達。數據可能一次全部接收到,或者可能以幾百個小數據塊形式到達,每塊數據僅是一到兩個字母大小。在這種情況下,我們將在消息的后面加上一個字符”#”,這將告訴MessageAssembler() 方法何時到達消息末尾,並停止接受數據以便進一步處理數據。我們將使用StringBuilder - 這個類用來高性能字符串連接操作。我們來看一下MessageAssembler() 方法:
private void MesssageAssembler(byte[] bytes, int offset, int count) { for (int bytesCount = 0; bytesCount < count - 1;bytesCount++ ) { if (bytes[bytesCount] == 35) //Check for "#" to signal the end { object[] paramObjs = new object[]{mStrBuilder.ToString()}; Invoke(new InvokeDisplay(DisplayData), paramObjs); mStrBuilder = new StringBuilder(); } else { mStrBuilder.Append((char)bytes[bytesCount]); } } }
我們可以看到MessageAssembler() 方法循環遍歷字節數組並把數據作為一個字符串附加到StringBuilder 實例上知道遇到”#“字符。一旦遇到這個字符,意味着數據流到達末尾。我們不需要擔心字節到字符串的轉換因為StringBuilder 類替我們做了這些。接下來它會調用DisplayData() 方法來處理數據:
private void DisplayData(string stockInfo) { if (stockInfo == "-1") { MessageBox.Show("Symbol not found!", "Invalid Symbol", MessageBoxButtons.OK, MessageBoxIcon.Error); } else { AddStock(stockInfo); } }
這是我們第二次看到類似的代碼,你可能想知道它是用來干嘛的。這個方法運行在后台工作線程里且與UI表單是同一個線程。盡管我們可以在程序中任何地方調用這個方法,但是這樣做並不是一個好主意,因為它是非線程安全的。Windows 窗體程序基於Win32 單線程單元並且是非線程安全的,這意味着一個窗體在初始化以后不能安全地與操作線程(包括異步操作創建的后台線程)之間來回切換。你必須在窗體程序內部調用方法。為了解決這個問題,CLR 支持Invoke() 方法,它負責包裝不同線程間的調用。
如果你懷疑上面說的,你可以自己調試一下代碼並通過線程窗口看代碼執行的當前線程ID。通過窗體的Invoke()方法調用創建的委托,實際上是運行在窗體線程中的,因此可以與窗體控件進行交互。如果不使用包裝,通常情況下代碼執行也沒有問題,但是以后可能出問題並導致程序執行不穩定。程序生成的線程越多情況就會變得越差。因此,如果沒有包裝線程的話不要調用GUI。額外的,委托簽名必須與Invoke()方法匹配,我們需要創建一個對象數組來保存字符串;這是使用Invoke()的唯一方式。我們可以調用DisplayData()方法來顯示數據:
object[] paramObjs = new object[]{mStrBuilder.ToString()}; Invoke(new InvokeDisplay(DisplayData), paramObjs);
當要發送的數據傳遞給Send() 方法,Send() 方法創建一個SteamWriter 類的實例並傳遞TcpClient 流作為其參數然后調用Write()方法,這回將數據以數據流形式在網絡上發送。我們也調用Flush()方法來保證所有數據立即發送出去而不會被放到緩存中:
private void Send(string sendData) { StreamWriter writer = new StreamWriter(mMyClient.GetStream()); writer.Write(sendData); writer.Flush(); }
我們將要完成了,現在做一些清理工作。大多數據情況,Windows窗體類會調用它自己的Dispose()方法來清理資源,但是由於.NET 有不確定的垃圾回收期,所以我們最好自己手動關閉TcpClient 連接。我們寫一個小函數來實現這個:
private void CloseConnection() { if (mMyClient != null) { mMyClient.Close(); mMyClient = null; } }
至此,整個程序的客戶端部分已經完成。下一篇我們將介紹如何創建服務端部分…