好的,我們現在來創建服務端程序。由於目的系統的多客戶端特性,我們在創建StockServer 程序時要采用一個稍微不同的方案。我們想要跟蹤客戶端行為並知道它們什么時候連接/斷開。由於為每個客戶端生成一個單一實例所以客戶端管理器在這方面很高效。因此我們將要創建一個能夠表示客戶端連接到服務端的這個過程的一個客戶類,如下圖類圖所示:
圖 4
每個連接到服務端的客戶端都會創建一個新的QuoteClient 實例,所以StockServer 類和QuoteClient類有一對多的關系。QuoteClient 類總是在用來處理新創建的且連接到服務端的新生成線程中進行實例化。QuoteClient類用於一個TcpClient 對象中,用於創建新客戶端。我們稍后繼續討論QuoteClient 類。首先,我們來看下用戶接口的樣子。服務端在UI上比客戶端要簡單許多。我們將要加一個ListBox 控件用來顯示一些信息並加一個標准的文件菜單。除了那些控件,拖拽一個新的StatusBar並設置其Anchor 屬性為Bottom, Right, 這樣你就可以將它放置在窗體的右下角。把窗體的名字和文本改為StockServer. 做完這些后你的窗體看起來如下圖所示:
我們也需要一個類文件QuoteClient.cs. 這個程序將要訪問SQL Server 數據庫來獲得股票交易信息,我們在程序的StockServer_Load() 方法中加入一些代碼,另外我們也要介紹服務Listener()方法,這個服務程序的核心部分;但是首先我們要在后台生成一個線程來運行我們的Listener() 方法:
private void StockServer_Load(object sender, EventArgs e) { IDictionary hostSettings; try { hostSettings = (IDictionary)ConfigurationManager.GetSection("HostInfo"); mPort = int.Parse(hostSettings["port"].ToString()); mListenerThread = new Thread(new ThreadStart(Listener)); mListenerThread.Start(); //RefreshClientStatus(); } catch (System.Exception ex) { AddStatus("An error has occurred. The server is not running. " + ex.ToString()); //CleanUp(); } finally { hostSettings = null; } }
我們需要在配置文件中設定端口,宿主不需要設置,因為本機即是宿主。由於程序是兩個獨立運行的部分,所以需要保證客戶端和服務端使用同樣的配置文件,如若不然可能會導致程序不能正常運行。如果發生錯誤,我們通過AddStatus() 方法通知用戶並通過調用CleanUp() 方法來做一些手動清除工作,稍后我們將看到這兩處內容。目前我們看一下Listener() 方法:
private void Listener() { try { mMyListener = new TcpListener(IPAddress.Any, mPort); mMyListener.Start(); object[] message = {"Server started. Awaiting new connections..."}; Invoke(new InvokeStatus(this.AddStatus), message); while(true) { QuoteClient newClient = new QuoteClient(mMyListener.AcceptTcpClient()); newClient.Disconnected += new QuoteClient.DisconnectedHandler(OnDisconnected); newClient.QuoteArrived +=new QuoteClient.QuoteArrivedHandler(CheckQuote); newClient.Receive(); object[] connectMessage = { "A new client just connected at " + DateTime.Now.ToShortTimeString()}; Invoke(new InvokeStatus(this.AddStatus), connectMessage); mTotalClients += 1; //RefreshClientStatus(); } } catch (System.Exception ex) { object[] message = { "The server stopped due to an unexpected error\r\n " + ex.ToString() }; Invoke(new InvokeStatus(this.AddStatus), message); } }
這是服務端程序非常重要的部分,因為它基本上代表了我們服務器的底層引擎。在初始化端口號之前,我們調用TcpListener.AcceptTcpClient() 方法來接收連接的進入請求。換句話說,TcpListener類就是服務器。它基於Socket類來在一個更高的抽象層上提供TCP服務。然而,生成一個新的后台線程來處理Listener()方法的是AcceptClient()方法,這是一個同步方法,它會保持后台線程阻塞同時等待連接請求,因為我們需要讓其在后台運行。由於這個線程在后台執行,我們需要使用窗體程序的Invoke()方法在當前工作線程和UI線程之間封裝調用。我們也啟動異步過程來監聽數據,這里使用的和客戶端使用的類似:
private void StreamReceive(IAsyncResult ar) { int bytesCount; try { lock (mMyClient.GetStream()) { bytesCount = mMyClient.GetStream().EndRead(ar); } if (bytesCount < 1) { Disconnected(this); return; } MesssageAssembler(mReceivedData, 0, bytesCount); lock (mMyClient.GetStream()) { Receive(); } } catch (System.Exception ex) { Disconnected(this); } }
上面的方法和客戶端中使用的方法的主要不同之處在於我們現在使用的是多線程、多用戶環境,這意味着我們不能僅獲得默認流並做任何想做的操作。有很大的機會導致資源沖突,比如當我們從這里讀數據,另外一個服務端線程可能嘗試給同樣的數據流發數據;所以我們需要使用同步技術。對簡單同步來說,我們將會在從請求數據流讀取數據前,使用lock 關鍵字來鎖住請求數據流。lock 是現有的最基本的線程同步工具。當訪問鎖定資源時不要忘了使用合適的判斷條件,如果使用過度的話會降低程序的性能。對更復雜的定制線程同步,你可以使用System.Threading 命名空間的其他類,比如InterLocked, 它允許你增加/減少 interlocks. 除了以上提到的這些,ReceiveStream() 方法基本上和客戶端程序中的一樣。
MessageAssembler() 方法也與客戶端程序中定義的副本非常類似。唯一的不同是它調用CheckQuote() 方法來連接數據庫並通過觸發QuoteArrived事件來收集股票交易信息:
private void MesssageAssembler(byte[] bytes, int offset, int count) { for (int bytesCount = 0; bytesCount < count; bytesCount++) { if (bytes[bytesCount] == 35) //Check for "#" to signal the end { QuoteArrived(this, mStrBuilder.ToString()); mStrBuilder = new StringBuilder(); } else { mStrBuilder.Append((char)bytes[bytesCount]); } } }
在我們繼續討論CheckQuote() 方法之前,我們簡單地討論下服務端收集股票信息的數據源。
我們需要創建一個數據庫StockDB, 並創建一張表tbl_stocks, 表結構如下圖所示:
這是我們需要從數據庫獲取的所有內容。當QuoteArrive()方法被觸發時,CheckQuote()方法被調用。這個方法的作用是創建一個數據庫連接,查詢並收集股票信息然后把數據發回給客戶端。你可以再Visual Studio .NET 中使用SqlConnection 並按照向導創建一個數據庫連接字符串,或者你簡單地實例化SqlConnection類,然后手動設置連接字符串:
private void CheckQuote(QuoteClient sender, string stockSymbol) { //Connection string using SQL Server authentication //SqlConnection sqlConn = new SqlConnection("Data Source=.\\SQLEXPRESS;Initial Catalog=StockDB;User ID=sa;Password=''"); //Alternative Connection string using Windows Integrated security string connectionString = "Data Source=.\\SQLEXPRESS;Initial Catalog=StockDB;Integrated Security=True"; SqlConnection sqlConn = new SqlConnection(connectionString); string sqlStr = "SELECT symbol, price,change,bid,ask,volume FROM tbl_stocks WHERE symbol='" + stockSymbol + "'"; SqlCommand sqlCmd = new SqlCommand(sqlStr, sqlConn); try { sqlConn.Open(); int records = 0; StringBuilder tempString = new StringBuilder(); SqlDataReader sqlDataReader = sqlCmd.ExecuteReader(); while (sqlDataReader.Read()) { for (int fieldCount = 0; fieldCount <= 5; fieldCount++) { tempString.Append(sqlDataReader.GetValue(fieldCount).ToString() + ","); records += 1; } } if (records == 0) { sender.Send("-1#"); } else { tempString.Replace(",", "#", tempString.Length - 1, 1); sender.Send(tempString.ToString()); } } catch (SqlException sqlEx) { object[] message = { sqlEx.ToString() }; Invoke(new InvokeStatus(this.AddStatus), message); } catch (Exception ex) { object[] message = { "Unable to retrieve quote information from the database." }; Invoke(new InvokeStatus(this.AddStatus), message); } finally { //Close the Connection and the Data Reader if (sqlConn.State != ConnectionState.Closed) { sqlConn.Close(); } } }
我們也需要一個SQL 查詢語句來返回客戶端請求的獨立股票信息:
string sqlStr = "SELECT symbol, price,change,bid,ask,volume FROM tbl_stocks WHERE symbol='" + stockSymbol + "'";
現在我們有了必要的SQL 字符串和連接,我們可以實例化SqlCommand和SqlDataReader對象來從數據庫服務器讀取數據。
最后,我們創建一個新的SqlDataReader類實例來執行查詢,並將SqlCommand.ExecuteReader() 的返回值設置給它。然后,我們遍歷返回數據中的每一列並把值附加到一個StringBuilder 對象上,在每個值之間使用逗號分隔。如果ExecuteReader() 沒有返回任何數據,那么我們向用戶發送-1來通知他們請求的數據不存在。否則,我們把字符串中最后一個逗號替換成井號來表示這是字符串的末尾並使用Send()方法發回給客戶端。最后,我們必須確保數據庫連接用完即關閉。
Send()方法與客戶端中的完全不同,主要在於我們使用異步方法把數據發回給客戶端:
public void Send(string sendData) { byte[] buffer = ASCIIEncoding.ASCII.GetBytes(sendData); lock (mMyClient.GetStream()) { mMyClient.GetStream().BeginWrite(buffer, 0, buffer.Length, null, null); } }
BeginWrite() 方法和BeginRead() 方法類似。我們首先需要將字符串轉換成一個字節數組。然后,我們需要鎖住數據流並保證其他線程不會操作同一個數據流。
運行程序
現在我們來運行一下程序。首先需要運行StockServer.exe,現在運行客戶端的一個實例。點擊菜單頁的Connect 來連接服務器,輸入一個合法的股票參數,然后點擊Get Quote 按鈕,服務端成功地返回了一條股票信息,由於修改賬戶是正值,整個行顯示綠色:
我們近一步生成其他2個StockClient 實例來驗證所有功能:
通過實驗得知,StockClient和StockServer 程序運行地非常好。服務跟蹤有多少個客戶端連接/斷開並顯示在列表中。多線程服務器可以同時處理多個請求,也可以異步發送和接收數據。你可以跟蹤客戶端和服務端代碼來更好地了解程序工作情況。
總結
正如我們所描述的那樣,在.NET Framework 中開發多線程網絡應用程序很容易。很多復雜的底層內容都以一個全面的、面向對象的類集合抽象出來。為了更好地控制網絡套接字,System.Net.Socket 類提供大量功能。我們也經歷了使用.NET 對異步操作的固有支持是很簡單的,可以運行在后台而不用太多代碼。
我們希望你已經發現這本書很有用且很有意思。.NET 中的特性給了程序員前所未有的力量-線程是其中之一。