现在你已经对.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; } }
至此,整个程序的客户端部分已经完成。下一篇我们将介绍如何创建服务端部分…