好的,我们现在来创建服务端程序。由于目的系统的多客户端特性,我们在创建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 中的特性给了程序员前所未有的力量-线程是其中之一。