James McCaffrey

下载代码示例

在 Microsoft 技术环境中,使用 Windows Communication Foundation (WCF) 是创建客户端-服务器系统的一种常用方法。 当然有很多替代 WCF 的方法,包括 HTTP Web 服务、Web API、DCOM、AJAX Web 技术、命名管道编程以及原始 TCP 套接字编程,每种方法都各有其优缺点。 但是如果您考虑到开发工作量、可管理性、可扩展性、性能和安全性等因素,那么在很多情况下,使用 WCF 都是最有效的方法。

但是,WCF 也可能极为复杂,对一些编程情况而言可能显得有些大材小用。 在我看来,在 Microsoft .NET Framework 4.5 发布之前,大多数情况下异步套接字编程难以使用,因此不是正确的选择。 但是新的 C# await 和 async 语言功能非常易用,这使情况发生了改变,因此,相比以前,现在对异步客户端-服务器系统使用套接字编程是更具有吸引力的选择。 本文说明如何使用 .NET Framework 4.5 的这些新异步功能来创建低层级、高性能的异步客户端-服务器软件系统。

要了解我将讲述的内容,最好是看一下 图 1 所示的演示客户端-服务器系统。 在图片顶部,一个命令 Shell 正在运行基于异步 TCP 套接字的服务,该服务接受了计算一组数字值的平均值或最小值的请求。 图片中部是一个 Windows Forms (WinForm) 应用程序,它发送了一个计算 (3, 1, 8) 的平均值的请求。 请注意,该客户端是异步的——在请求发出之后,等待服务响应时,客户能够单击标记为“Say Hello”的按钮三次,应用程序可做出响应。

图 1 基于 TCP 的演示服务,带两个客户端

图 1 的底部显示了一个正在运行的 Web 应用程序。 该客户端发送了一个找出 (5, 2, 7, 4) 中的最小值的异步请求。 虽然屏幕截图的显示不太明显,但实际上当 Web 应用程序在等待服务响应时,该应用程序能提供对用户输入的响应。

在接下来的几节中,我将演示如何编写该服务、WinForm 客户端和 Web 应用程序客户端的代码。 在这个过程中,我会讨论使用套接字的优缺点。 本文假定您至少具有 C# 的中级编程技能,但您不需要具备对异步编程的深入了解或丰富经验。 本文附带的代码下载包含 图 1 中显示的三个程序的完整源代码。 我去掉了其中大多数普通错误检查,以尽可能突出核心内容。

图 2 显示了演示服务的整体结构(为节省空间进行了了一些较小的修改)。 为创建该服务,我启动了 Visual Studio 2012(具备所需的 .NET Framework 4.5),并创建了一个名为 DemoService 的全新 C# 控制台应用程序。 因为基于套接字的服务往往功能具体且有限,在实际的使用场合中,使用更具描述性的名称是更好的做法。

图 2 演示服务程序结构

using System;
using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Threading.Tasks;
namespace DemoService
  class ServiceProgram
    static void Main(string[] args)
        int port = 50000;
        AsyncService service = new AsyncService(port);
        service.Run();
        Console.ReadLine();
      catch (Exception ex)
        Console.WriteLine(ex.Message);
        Console.ReadLine();
  public class AsyncService
    private IPAddress ipAddress;
    private int port;
    public AsyncService(int port) { . . }
    public async void Run() { . . }
    private async Task Process(TcpClient tcpClient) { . . }
    private static string Response(string request)
    private static double Average(double[] vals) { . . }
    private static double Minimum(double[] vals) { . . }

在模板代码载入编辑器中之后,我修改了源代码顶部的 using 语句,将 System.Net 和 System.Net.Sockets 包含在内。 在“解决方案资源管理器”窗口中,我将文件 Program.cs 重命名为 ServiceProgram.cs,Visual Studio 自动为我重命名类 Program。 启动服务十分简单:

int port = 50000;
AsyncService service = new AsyncService(port);
service.Run();

服务器上每一个基于套接字的自定义服务都必须使用唯一的端口。 49152 和 65535 之间的端口号通常用于自定义服务。 避免端口号重复可能有点复杂。 可以使用系统注册表的 ReservedPorts 条目保留服务器上的端口号。 该服务使用面向对象的编程 (OOP) 设计,并通过接受该端口号的构造函数来实例化。 因为服务端口号是固定的,该端口号可以不以参数形式传递,直接可以硬编码。 Run 方法中包含了一个 while 循环,该循环将接受并处理客户端请求,直到控制台 shell 接收到 <enter> 键被按下的信息时退出。

AsyncService 类有两个私有成员,即 ipAddress 和 port。 这两个值实际上定义了一个套接字。 该构造函数接受一个端口号,并以编程方式确定服务器的 IP 地址。 公共方法 Run 完成接受请求、计算、发送响应所有这些工作。 Run 方法调用帮助程序方法 Process,后者又调用帮助程序 Response。 Response 方法调用帮助程序 Average 和 Minimum。

可以使用许多方法来组织基于套接字的服务器。 演示中使用的结构兼顾模块化与简单性,在我实际使用时表现得不错。

服务构造函数和 Run 方法

图 3 中显示了基于套接字的这一演示服务的两个公共方法。 在存储端口名称之后,该构造函数使用 GetHostName 方法来确定服务器的名称,然后获取一个包含该服务器的信息的结构。 AddressList 集合保存了不同的机器地址,包括 IPv4 和 IPv6 地址。 InterNetwork 枚举值就是一个 IPv4 地址。

图 3 服务构造函数和 Run 方法

public AsyncService(int port)
  this.port = port;
  string hostName = Dns.GetHostName();
  IPHostEntry ipHostInfo = Dns.GetHostEntry(hostName);
  this.ipAddress = null;
  for (int i = 0; i < ipHostInfo.AddressList.Length; ++i) {
    if (ipHostInfo.AddressList[i].AddressFamily ==
      AddressFamily.InterNetwork)
      this.ipAddress = ipHostInfo.AddressList[i];
      break;
  if (this.ipAddress == null)
    throw new Exception("No IPv4 address for server");
public async void Run()
  TcpListener listener = new TcpListener(this.ipAddress, this.port);
  listener.Start();
  Console.Write("Array Min and Avg service is now running"
  Console.WriteLine(" on port " + this.port);
  Console.WriteLine("Hit <enter> to stop service\n");
  while (true) {
    try {
      TcpClient tcpClient = await listener.AcceptTcpClientAsync();
      Task t = Process(tcpClient);
      await t;
    catch (Exception ex) {
      Console.WriteLine(ex.Message);

此方法限制服务器仅使用服务器初次分配的 IPv4 地址来侦听请求。 一个更简单的替代方法是,只需要将该成员字段指定为 this.ipAddress = IPAddress.Any,就可以允许服务器接受发送到它的任何地址的请求。

请注意,该服务的 Run 方法签名使用了 async 修饰符,这表示在方法主体中,将用 await 关键字调用某个异步方法。 该方法返回 void,而不是更常见的 Task,因为 Run 是由 Main 方法调用的,而 Main 是一个特殊的方法,它不允许使用 async 修饰符。 也可以换一种做法,定义方法 Run 返回 Task 类型,然后以 service.Run().Wait 形式调用该方法。

该服务的 Run 方法使用服务器的 IP 地址和端口号来实例化 TcpListener 对象。 该侦听器的 Start 方法开始监控指定端口,等待连接请求。

在主 while 处理循环中,将创建一个 TcpClient 对象(您可以把它视为一个智能的套接字),并通过 AcceptTcpClientAsync 方法等待连接。 在 .NET Framework 4.5 之前,您必须使用 BeginAcceptTcpClient,然后编写自定义异步协调代码,相信我,这个活儿不轻松。 .NET Framework 4.5 增加了很多的新方法,按约定,这些新方法都以“Async”结尾。这些新方法与 async 和 await 关键字结合使用,使得异步编程变得轻松很多很多。

方法 Run 使用两个语句来调用方法 Process。 也可以使用快捷语法,在一条语句中调用方法 Process:await Process(tcpClient)。

总结一下,该服务使用 TcpListener 和 TcpClient 对象来隐藏原始套接字编程的复杂性,并结合新的 async 和 await 关键字使用新的 AcceptTcpClientAsync 方法,来隐藏异步编程的复杂性。 方法 Run 建立并协调连接活动,调用方法 Process 来处理请求,然后用第二条语句来等待返回 Task。

该服务的 Process 和 Response 方法

图 4 中显示该服务对象的 Process 和 Response 方法。 Process 方法的签名使用 async 修饰符并返回 Task 类型。

图 4 该演示服务的 Process 和 Response 方法

private async Task Process(TcpClient tcpClient)
  string clientEndPoint =
    tcpClient.Client.RemoteEndPoint.ToString();
  Console.WriteLine("Received connection request from "
    + clientEndPoint);
  try {
    NetworkStream networkStream = tcpClient.GetStream();
    StreamReader reader = new StreamReader(networkStream);
    StreamWriter writer = new StreamWriter(networkStream);
    writer.AutoFlush = true;
    while (true) {
      string request = await reader.ReadLineAsync();
      if (request != null) {
        Console.WriteLine("Received service request: " + request);
        string response = Response(request);
        Console.WriteLine("Computed response is: " + response + "\n");
        await writer.WriteLineAsync(response);
        break; // Client closed connection
    tcpClient.Close();
  catch (Exception ex) {
    Console.WriteLine(ex.Message);
    if (tcpClient.Connected)
      tcpClient.Close();
private static string Response(string request)
  string[] pairs = request.Split('&');
  string methodName = pairs[0].Split('=')[1];
  string valueString = pairs[1].Split('=')[1];
  string[] values = valueString.Split(' ');
  double[] vals = new double[values.Length];
  for (int i = 0; i < values.Length; ++i)
    vals[i] = double.Parse(values[i]);
  string response = "";
  if (methodName == "average") response += Average(vals);
  else if (methodName == "minimum") response += Minimum(vals);
  else response += "BAD methodName: " + methodName;
  int delay = ((int)vals[0]) * 1000; // Dummy delay
  System.Threading.Thread.Sleep(delay);
  return response;

使用低层级套接字而不是 Windows Communication Foundation (WCF) 的一个优点是您可以在您选择的任何位置插入诊断用的 WriteLine 语句。 在此演示中,为安全起见,我用虚拟的 IP 地址值 123.45.678.999 替代了 clientEndPoint。

方法 Process 中三行重要的代码是:

string request = await reader.ReadLineAsync();
string response = Response(request);
await writer.WriteLineAsync(response);

您可以将第一条语句理解为:“异步读取一行请求,如有必要,允许其他语句执行该请求。”一旦获得请求字符串,它就会传递到 Response 帮助程序。 然后将响应异步发回给发出请求的客户端。

服务器使用“读取请求、写入响应”这样一个周期。 它很简单,但有几个您应注意的问题。 如果服务器只读不写,则无法检测到“半开放”情形。 如果服务器只写不读(例如,以大量的数据做出响应),可能会在客户端造成死锁。 对于简单的内部服务而言,“读-写”设计是可以接受的,但不应该对关键的或面向公众的服务使用这种设计。

Response 方法接受请求字符串、分析该请求并计算响应字符串。 基于套接字的服务有同等的优势和劣势,因此您必须精心地设计出某种自定义协议。 在本例中,我们假定请求类似于这样:

method=average&data=1.1 2.2 3.3&eor

换句话说,该服务期望在“method=”文字之后紧跟字符串“average”或“minimum”,然后是一个“&”符号,再跟“data=”。 实际的输入数据必须是以空格分隔的形式。 该请求以“&”结尾,后跟“eor”,“eor”表示“请求结束”(end-of-request)。 基于套接字的服务相对于 WCF 的一个缺点是,复杂参数类型的序列化有时可能有点麻烦。

在此演示示例中,服务响应很简单,只是一个数字值数组的平均值或最小值的字符串表达式。 在很多自定义的客户端-服务器情形下,您都需要为服务响应设计出某种协议。 例如,您不能只是简单地发送“4.00”作为响应,您可能需要发送“average=4.00”。

如果发生异常,方法 Process 将使用一种相对简陋的方法来关闭连接。 有一种替代方法是通过语句来使用 C#(这样将自动关闭任何连接)并删除对方法 Close 的显式调用。

帮助程序方法 Average 和 Minimum 如下定义:

private static double Average(double[] vals)
  double sum = 0.0;
  for (int i = 0; i < vals.Length; ++i)
    sum += vals[i];
  return sum / vals.Length;
private static double Minimum(double[] vals)
  double min = vals[0]; ;
  for (int i = 0; i < vals.Length; ++i)
    if (vals[i] < min) min = vals[i];
  return min;

在大多数情况下,如果您使用类似于此处的演示服务的程序结构,帮助程序方法将连接到某个数据源并获取某些数据。 低层级服务的一个优势是,您可以对数据访问方法进行更多控制。 例如,如果您要从 SQL 获取数据,则可以使用经典的 ADO.NET、Entity Framework 或任何其他数据访问方法。

低层级方法的一个缺点是,您必须显式确定如何处理系统中的错误。 在本例中,如果演示的服务无法正常分析请求字符串,则服务将返回一条错误消息,而非有效的响应(以字符串形式)。 根据我的经验,有几条值得依赖的通用原则。 每个服务都需要自定义错误处理。

注意,Response 方法有一个虚拟延迟:

int delay = ((int)vals[0]) * 1000;
System.Threading.Thread.Sleep(delay);

插入这个响应延迟(随意地基于请求的第一个数字值)是为了减缓服务,让 WinForm 和 Web 应用程序客户端能够在等待响应时演示 UI 的响应能力。

WinForm 应用程序演示客户端

为了创建图 1 中所示的 WinForm 客户端,我启动了 Visual Studio 2012 并创建了一个名为 DemoFormClient 的新 C# WinForm 应用程序。 请注意,默认情况下 Visual Studio 会将 WinForm 应用程序模块化为几个文件,将 UI 代码与逻辑代码分开。 对于本文附带的代码下载,我将模块化的 Visual Studio 代码重构为单个源代码文件。 您可以启动 Visual Studio 命令 shell(它知道 C# 编译器的位置)并执行以下命令来编译该应用程序:csc.exe /target:winexe DemoFormClient.cs。

我使用 Visual Studio 的设计工具添加了一个 ComboBox 控件、一个 TextBox 控件、两个 Button 控件、一个 ListBox 控件以及四个 Label 控件。 对于 ComboBox 控件,我将字符串“average”和“minimum”添加到该控件的 Items 集合属性中。 我将 button1 和 button2 的 Text 属性分别更改为 Send Async 和 Say Hello。 然后在设计视图中双击 button1 和 button2 控件以注册其事件处理程序。 我如图 5 所示编辑了单击处理程序。

图 5 WinForm 演示客户端按钮单击处理程序

private async void button1_Click(object sender, EventArgs e)
  try {
    string server = "mymachine.network.microsoft.com";
    int port = 50000;
    string method = (string)comboBox1.SelectedItem;
    string data = textBox1.Text;
    Task<string> tsResponse = 
      SendRequest(server, port, method, data);
    listBox1.Items.Add("Sent request, waiting for response");
    await tsResponse;
    double dResponse = double.Parse(tsResponse.Result);
    listBox1.Items.Add("Received response: " +
     dResponse.ToString("F2"));
  catch (Exception ex) {
    listBox1.Items.Add(ex.Message);
private void button2_Click(object sender, EventArgs e)
  listBox1.Items.Add("Hello");

请注意,button1 控件的单击处理程序的签名发生了更改,改为包含 async 修饰符。 该处理程序将一个硬编码的服务器名称设置为一个字符串和端口号。 使用基于套接字的低层级服务时,没有自动发现机制,因此客户端必须能够访问服务器名称或 IP 地址以及端口信息。

下面是关键的代码行:

Task<string> tsResponse = SendRequest(server, port, method, data);
// Perform some actions here if necessary
await tsResponse;
double dResponse = double.Parse(tsResponse.Result);

SendRequest 是一个程序定义的异步方法。 该调用可以大致理解为“发送一个将返回字符串的异步请求,完成后继续从语句‘await tsResponse’处执行。”这就允许应用程序在等待响应时执行其他操作。 因为响应封装在 Task 中,实际的字符串结果必须使用 Result 属性提取。 该字符串结果将转换为 double 类型,以便可以完美地将其格式化为带两个小数位。

也可以采用另一种调用方法:

string sResponse = await SendRequest(server, port, method, data);
double dResponse = double.Parse(sResponse);
listBox1.Items.Add("Received response: " + dResponse.ToString("F2"));

在以上代码中,await 关键字嵌入 SendRequest 的异步调用。 这使得调用代码有所简化,且无需调用 Task.Result 就可获取返回字符串。 使用内嵌 await 调用还是单独语句的 await 调用,对于不同情形有不同的选择,但一般的经验是,最好避免显式使用 Task 对象的 Result 属性。

图 6 所示,大部分异步工作都在 Send­Request 方法中执行。 因为 SendRequest 是异步方法,因此将它命名为 SendRequestAsync 或 MySendRequestAsync 可能更好。

图 6 WinForm 演示客户端 SendRequest 方法

private static async Task<string> SendRequest(string server,
  int port, string method, string data)
  try {
    IPAddress ipAddress = null;
    IPHostEntry ipHostInfo = Dns.GetHostEntry(server);
    for (int i = 0; i < ipHostInfo.AddressList.Length; ++i) {
      if (ipHostInfo.AddressList[i].AddressFamily ==
        AddressFamily.InterNetwork)
        ipAddress = ipHostInfo.AddressList[i];
        break;
    if (ipAddress == null)
      throw new Exception("No IPv4 address for server");
    TcpClient client = new TcpClient();
    await client.ConnectAsync(ipAddress, port); // Connect
    NetworkStream networkStream = client.GetStream();
    StreamWriter writer = new StreamWriter(networkStream);
    StreamReader reader = new StreamReader(networkStream);
    writer.AutoFlush = true;
    string requestData = "method=" + method + "&" + "data=" +
      data + "&eor"; // 'End-of-request'
    await writer.WriteLineAsync(requestData);
    string response = await reader.ReadLineAsync();
    client.Close();
    return response;
  catch (Exception ex) {
    return ex.Message;

SendRequest 接受一个表示服务器名称的字符串,并通过与服务类构造函数中相同的代码逻辑将该名称解析为 IP 地址。 一个较简单的替代方法是直接传递服务器名称:await client.ConnectAsync(server, port)。

确定了服务器的 IP 地址后,将实例化一个 TcpClient 智能套接字对象,然后使用该对象的 Connect­Async 方法向服务器发送连接请求。 设置好向服务器发送字符串的网络 StreamWriter 对象和接收来自服务器的数据的 StreamReader 对象之后,按照服务器要求的格式创建一个请求字符串。 该请求将以异步方式发送和接收,并以字符串形式由方法返回。

Web 应用程序演示客户端

我分两步创建图 1 中所示的演示 Web 应用程序客户端。 首先,我使用了 Visual Studio 来创建托管该应用程序的 Web 站点,然后我使用“记事本”编写了该 Web 应用程序的代码。 我启动了 Visual Studio 2012 并以 http://localhost/ 为网址创建了一个名为 DemoClient 的全新 C# 空网站。 这个步骤完成了托管应用程序的所有必要的 IIS 探测操作,并在 C:\inetpub\wwwroot\DemoClient\ 处创建了与该网站关联的物理位置。 这个过程还创建了一个基本配置文件,即 Web.config。该文件包含允许站点中的应用程序访问 .NET Framework 4.5 中异步功能的信息:

<?xml version="1.0"?>
<configuration>
  <system.web>
    <compilation debug="false" targetFramework="4.5" />
    <httpRuntime targetFramework="4.5" />
  </system.web>
</configuration>

接下来,我以管理权限启动了“记事本”。 创建简单的 ASP.NET 应用程序时,我有时更喜欢使用“记事本”而不用 Visual Studio。因为这样我就可以把所有应用程序代码放在一个 .aspx 文件中,而不是生成多个文件和一些我并不想要的示例代码。 我在 C:\inetpub\wwwroot\DemoClient 将该空文件保存为 DemoWeb­Client.aspx。

该 Web 应用程序的整体结构如图 7 中所示。

图 7 Web 应用程序演示客户端结构

<%@ Page Language="C#" Async="true" AutoEventWireup="true"%>
<%@ Import Namespace="System.Threading.Tasks" %>
<%@ Import Namespace="System.Net" %>
<%@ Import Namespace="System.Net.Sockets" %>
<%@ Import Namespace="System.IO" %>
<script runat="server" language="C#">
  private static async Task<string> SendRequest(string server,
  private async void Button1_Click(object sender, System.EventArgs e) { . . }
</script>
  <title>Demo</title>
</head>
  <form id="form1" runat="server">
  <p>Enter service method:
    <asp:TextBox ID="TextBox1" runat="server"></asp:TextBox></p>
  <p>Enter data:
    <asp:TextBox ID="TextBox2" runat="server"></asp:TextBox></p>
  <p><asp:Button Text="Send Request" id="Button1"
    runat="server" OnClick="Button1_Click"> </asp:Button> </p>
  <p>Response:
    <asp:TextBox ID="TextBox3" runat="server"></asp:TextBox></p>
  <p>Dummy responsive control:
    <asp:TextBox ID="TextBox4" runat="server"></asp:TextBox></p>
  </form>
</body>
</html>

在页面顶部,我添加了几条 Import 语句以将相关的 .NET 名称空间导入进来,还添加了一条包含 Async=true 属性的 Page 指令。

这段 C# 脚本包含两个方法,即 SendRequest 和 Button1_Click。 该应用程序页面主体部分提供了两个 TextBox 控件和一个 Button 控件用于输入,还有用于保存服务响应的一个 TextBox 控件作为输出,还有一个未使用的虚拟 TextBox 控件,用于演示应用程序在等待服务响应请求时的 UI 响应能力。

Web 应用程序的 Send Request 方法的代码与 WinForm 应用程序的 SendRequest 中的代码完全一样。 Web 应用程序的 Button1_Click 处理程序的代码与 WinForm 的 Button1_Click 处理程序的代码只稍有不同,以便适合不同的 UI 使用:

try {
  string server = "mymachine.network.microsoft.com";
  int port = 50000;
  string method = TextBox1.Text;
  string data = TextBox2.Text;
  string sResponse = await SendRequest(server, port, method, data);
  double dResponse = double.Parse(sResponse);
  TextBox3.Text = dResponse.ToString("F2");
catch (Exception ex) {
  TextBox3.Text = ex.Message;

尽管 Web 应用程序的代码本质上与 WinForm 应用程序的代码相同,调用机制却很不一样。 当用户使用 WinForm 发出请求时,WinForm 将直接向服务发出调用,服务直接向 WinForm 发出响应。 当用户从 Web 应用程序发出请求时,Web 应用程序会将请求信息发送给托管该应用程序的 Web 服务器,该 Web 服务器向服务发起调用,服务向 Web 服务器返回响应,Web 服务器构造包含响应的响应页面,响应页面发送回客户端浏览器。

那么,您什么时候该考虑使用异步 TCP 套接字而不是 WCF 呢? 大概 10 年以前,在 WCF 及其前身 ASP.NET Web 服务推出之前,如果您想创建客户端-服务器系统,那么使用套接字通常是最合乎逻辑的选择。 WCF 的推出是一大进步,但是因为 WCF 是为处理很多情况而设计的,对简单的客户端-服务器系统使用它有时可能有些过于复杂了。 虽然 WCF 最新的版本比起以前的版本来更易于配置,但 WCF 的使用仍然可能很复杂。

在有些情况下,客户端和服务器在不同网络上,这使得安全性成为需要考虑的重要因素,这种时候我始终使用 WCF。 但是对于很多客户端-服务器系统来说,客户端和服务器位于单个安全企业网络上,我通常更倾向于使用 TCP 套接字。

有一种较新的客户端-服务器系统实现方法,那就是将基于 HTTP 的服务的 ASP.NET Web API 框架与异步方法的 ASP.NET SignalR 库结合使用。 在很多情况下,这种方法比使用 WCF 实施起来更简单,并能避免与套接字方法有关的很多底层细节。

**Dr.**James McCaffrey 供职于华盛顿地区雷蒙德市沃什湾的 Microsoft Research。他参与过多个 Microsoft 产品的工作,包括 Internet Explorer 和 Bing。可通过 jammc@microsoft.com 与他联系。

衷心感谢以下技术专家对本文的建议和审阅:Piali Choudhury (MS Research)、Stephen Cleary (consultant)、Adam Eversole (MS Research)、 Lynn Powers (MS Research) 和 Stephen Toub (Microsoft)