65.9K
CodeProject 正在变化。 阅读更多。
Home

一个完整的 TCP 服务器/客户端通信和 RMI 框架 - 用法

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (102投票s)

2011年2月4日

CPOL

22分钟阅读

viewsIcon

363111

downloadIcon

26192

一个名为 Simple Client Server Library (SCS) 的开源轻量级框架,旨在通过简单的远程方法调用机制创建服务器/客户端应用程序。

文章大纲

什么是 SCS?

在本文中,我将介绍一个**开源**、轻量级、**快速**且**可伸缩**的框架(名为 **S**imple **C**lient **S**erver Library ( **SCS** )),它旨在通过简单的**远程方法调用**机制创建**服务器/客户端**应用程序。它完全由 **C#** 和 **.NET Framework 4.0** 开发。它使用 **TCP** 作为传输层协议,但核心库是**协议无关的**,可以扩展以支持任何其他协议。您可以查看第二篇文章以了解框架的**性能**。

SCS 允许客户端像在同一应用程序中进行常规方法调用一样(如 WCF 中的服务契约),通过接口调用服务器应用程序的方法。服务器应用程序也可以通过接口以相同的方式轻松调用客户端方法。SCS 框架是一个双向、面向连接和异步通信库。因此,客户端连接到服务器后,它们可以在两个方向(**服务器到客户端**或**客户端到服务器**)异步通信,直到客户端或服务器关闭连接。它不仅仅是像 Web 服务那样的请求/响应通信。

我开发了 SCS 框架并一直在实际使用它。以下是 SCS 的一些功能列表:

  • 它是一个**开源**的**服务器/客户端**框架。
  • 允许轻松实现客户端到服务器以及服务器到客户端的**远程方法调用**。可以在应用程序之间抛出**异常**。
  • 允许异步或同步的低级**消息传递**而不是远程方法调用。
  • 它**可伸缩**(服务器只有 50-60 个线程时,可同时连接和通信 15000+ 个客户端)且**快速**(在普通 PC 上每秒执行 5,500 次远程方法调用,应用程序之间传输 62,500 条消息)。
  • 允许客户端自动**重新连接**到服务器。
  • 当服务器一段时间内没有通信时,允许客户端自动**发送心跳**以保持连接可用。
  • 允许服务器**注册事件**,用于新客户端连接、客户端断开连接等。
  • 允许客户端注册连接和断开连接事件。
  • 它适用于客户端和服务器之间的**长会话**连接。

在本文中,我们将使用 SCS 框架开发两个示例应用程序,以检查其用法和优点。

第一个示例是一个简单的电话簿应用程序。在此示例中,我们将了解如何构建请求/响应式电话簿服务器/客户端架构。电话簿将存储在服务器中,客户端可以连接到服务器,为某人添加/删除或查询电话号码。为了简单起见,它们将是基于控制台的应用程序。

在第二个示例中,我们将开发一个完整的聊天系统,该系统具有公共和私人消息功能。用户界面将使用 WPF 开发。用户将选择一个昵称并连接到聊天服务器。连接的用户可以看到聊天室中的所有其他用户,可以向聊天室发送公共消息(所有用户都会看到消息),并且可以与任何其他用户私聊。

使用 SCS 的简单服务器/客户端电话簿应用程序

如上所述,客户端和服务器通过服务/客户端契约接口进行远程方法调用通信。这些接口通常定义在一个单独的程序集 (DLL) 中,供客户端和服务器应用程序使用。因此,让我们在 `OnlinePhoneBook` 解决方案中创建一个名为 `PhoneBookCommonLib` 的新类库项目,如下图所示。

TCP-Server-Client/01_AddCommonLib.jpg

图 1:创建一个类库项目来定义 PhoneBook 服务器的服务契约

首先,我们必须添加对 SCS 框架的引用

TCP-Server-Client/01_AddReferenceToScs.gif

现在,我们将定义一个名为 `IPhoneBookService` 的服务契约接口,用于定义将由客户端远程调用的方法。

using Hik.Communication.ScsServices.Service;

namespace PhoneBookCommonLib
{
    /// <summary>
    /// This interface defines methods of Phone Book Service
    /// that can be called remotely by client applications.
    /// </summary>
    [ScsService(Version = "1.0.0.0")]
    public interface IPhoneBookService
    {
        /// <summary>
        /// Adds a new person to phone book.
        /// </summary>
        /// <param name="recordToAdd">Person informations to add</param>
        void AddPerson(PhoneBookRecord recordToAdd);
 
        /// <summary>
        /// Deletes a person from phone book.
        /// </summary>
        /// <param name="name">Name of the person to delete</param>
        /// <returns>True, if a person is deleted,
        ///          false if person is not found</returns>
        bool DeletePerson(string name);
 
        /// <summary>
        /// Searches a person in phone book by name of person.
        /// </summary>
        /// <param name="name">Name of person to search.
        /// Name might not fully match, it can be a part of person's name</param>
        /// <returns>Person informations if found, else null</returns>
        PhoneBookRecord FindPerson(string name);
    }
}

服务契约必须由 `ScsService` 属性标记。您可以定义服务的**版本**。在任何 RMI 异常中,客户端都会在异常消息中获取此版本信息,以便他们知道服务是否已更改。`PhoneBookRecord` 类如下所示:

using System;

namespace PhoneBookCommonLib
{
    /// <summary>
    /// Represents a record in phone book.
    /// </summary>
    [Serializable]
    public class PhoneBookRecord
    {
        /// <summary>
        /// Name of the person.
        /// </summary>
        public string Name { get; set; }
 
        /// <summary>
        /// Phone number of the person.
        /// </summary>
        public string Phone { get; set; }
 
        /// <summary>
        /// Creation date of this record.
        /// </summary>
        public DateTime CreationDate { get; set; }
 
        /// <summary>
        /// Creates a new PhoneBookRecord object.
        /// </summary>
        public PhoneBookRecord()
        {
            CreationDate = DateTime.Now;
        }
 
        /// <summary>
        /// Generates a string representation of this object.
        /// </summary>
        /// <returns>String representation of this object</returns>
        public override string ToString()
        {
            return string.Format("Name = {0}, Phone = {1}", Name, Phone);
        }
    }
}

如果服务/客户端契约在方法调用中定义了一个自定义类,则该类必须标记为 `Serializable`。因此,这就像普通的接口和类定义一样。让我们通过在我们的解决方案中创建一个名为 `PhoneBookServer` 的新控制台应用程序项目来开始开发服务器端。

TCP-Server-Client/02_AddServerProject.jpg

图 2:创建一个控制台应用程序来构建 PhoneBook 服务器

首先,我们必须添加对 SCS 框架和 `PhoneBookCommonLib` 项目的引用。然后我们必须实现 `IPhoneBookService` 接口。我们创建一个名为 `PhoneBookService` 的类,如下所示:

using System;
using System.Collections.Generic;
using Hik.Communication.ScsServices.Service;
using PhoneBookCommonLib;
 
namespace PhoneBookServer
{
    /// <summary>
    /// This class implements Phone Book Service contract.
    /// </summary>
    class PhoneBookService : ScsService, IPhoneBookService
    {
        /// <summary>
        /// Current records that are added to phone book service.
        /// Key: Name of the person.
        /// Value: PhoneBookRecord object.
        /// </summary>
        private readonly SortedList<string, PhoneBookRecord> _records;
 
        /// <summary>
        /// Creates a new PhoneBookService object.
        /// </summary>
        public PhoneBookService()
        {
            _records = new SortedList<string, PhoneBookRecord>();
        }
 
        /// <summary>
        /// Adds a new person to phone book.
        /// </summary>
        /// <param name="recordToAdd">Person informations to add</param>
        public void AddPerson(PhoneBookRecord recordToAdd)
        {
            if (recordToAdd == null)
            {
                throw new ArgumentNullException("recordToAdd");
            }
 
            _records[recordToAdd.Name] = recordToAdd;
        }
 
        /// <summary>
        /// Deletes a person from phone book.
        /// </summary>
        /// <param name="name">Name of the person to delete</param>
        /// <returns>True, if a person is deleted,
        ///     false if person is not found</returns>
        public bool DeletePerson(string name)
        {
            if (!_records.ContainsKey(name))
            {
                return false;
            }
 
            _records.Remove(name);
            return true;
        }
 
        /// <summary>
        /// Searches a person in phone book by name of person.
        /// </summary>
        /// <param name="name">Name of person to search.
        /// Name might not fully match, it can be a part of person's name</param>
        /// <returns>Person informations if found, else null</returns>
        public PhoneBookRecord FindPerson(string name)
        {
            //Get recods by name if there is a record exactly match to given name
            if (_records.ContainsKey(name))
            {
                return _records[name];
            }
 
            //Search all records to check if there
            //is a name string that contains given name
            foreach (var record in _records)
            {
                if (record.Key.ToLower().Contains(name.ToLower()))
                {
                    return record.Value;
                }
            }
 
            //Not found
            return null;
        }
    }
}

所有服务都必须派生自 `ScsService` 类并实现服务契约。我们的电话簿服务几乎完成了。现在我们更改应用程序的 `Main` 方法以创建服务器应用程序来完成电话簿服务。

using System;
using Hik.Communication.Scs.Communication.EndPoints.Tcp;
using Hik.Communication.ScsServices.Service;
using PhoneBookCommonLib;
 
namespace PhoneBookServer
{
    class Program
    {
        static void Main(string[] args)
        {
            //Create a Scs Service application that runs on 10048 TCP port.
            var server = ScsServiceBuilder.CreateService(new ScsTcpEndPoint(10048));
            
            //Add Phone Book Service to service application
            server.AddService<IPhoneBookService, 
                       PhoneBookService>(new PhoneBookService());
            
            //Start server
            server.Start();
 
            //Wait user to stop server by pressing Enter
            Console.WriteLine(
                "Phone Book Server started successfully. Press enter to stop...");
            Console.ReadLine();
            
            //Stop server
            server.Stop();
        }
    }
}

我们首先创建一个服务应用程序,它监听 10048 TCP 端口以接收传入的客户端连接。然后我们将我们的电话簿服务添加到这个服务应用程序中。添加服务时,我们必须指定服务契约(`IPhoneBookService`)和实现该契约的服务类(`PhoneBookService`)。我们可以向服务应用程序添加多个服务,以便从同一个端点(同一个 TCP 端口)运行多个服务。这就是所有;直到用户停止服务,客户端都可以连接到服务并调用服务方法。

现在,我们可以创建一个使用此服务的客户端应用程序。在 `OnlinePhoneBook` 解决方案中创建一个名为 `PhoneBookClient` 的新控制台应用程序项目。

TCP-Server-Client/02_AddServerProject.jpg

图 3:创建一个控制台应用程序来构建 PhoneBook 客户端

首先添加对 SCS 框架和 `PhoneBookCommonLib` 项目的引用。然后我们可以编写一个使用电话簿服务的示例客户端代码

Using System;
using Hik.Communication.Scs.Communication.EndPoints.Tcp;
using Hik.Communication.ScsServices.Client;
using PhoneBookCommonLib;
 
namespace PhoneBookClient
{
    class Program
    {
        static void Main(string[] args)
        {
            //Create a client to connecto to phone book service on local server and
            //10048 TCP port.
            Var client = ScsServiceClientBuilder.CreateClient<IPhoneBookService>(
                new ScsTcpEndPoint("127.0.0.1", 10048));
 
            Console.WriteLine("Press enter to connect to phone book service...");
            Console.ReadLine();
 
            //Connect to the server
            client.Connect();
 
            var person1 = new PhoneBookRecord { Name = "Halil Ibrahim", 
                Phone = "5881112233" };
            var person2 = 
              new PhoneBookRecord { Name = "John Nash", Phone = "58833322211" };
 
            //Add some persons
            client.ServiceProxy.AddPerson(person1);
            client.ServiceProxy.AddPerson(person2);
 
            //Search for a person
            var person = client.ServiceProxy.FindPerson("Halil");
            if (person != null)
            {
                Console.WriteLine("Person is found:");
                Console.WriteLine(person);
            }
            else
            {
                Console.WriteLine("Can not find person!");
            }
 
            Console.WriteLine();
            Console.WriteLine("Press enter to disconnect from phone book service...");
            Console.ReadLine();
 
            //Disconnect from server
            client.Disconnect();
        }
    }
}

当您查看上面的代码时,调用服务的方法非常简单直接。您可以使用 `IPhoneBookService` 中定义的所有方法。我们首先创建一个客户端对象,该对象连接到服务器以使用运行在本地机器 (127.0.0.1) 10048 TCP 端口上的 `IPhoneBookService`。然后我们连接到服务器,调用远程方法,最后断开连接。如果您先运行 PhoneBookServer,然后运行 PhoneBookClient,并按 Enter,您将看到两个控制台屏幕,如下所示:

TCP-Server-Client/04_PhoneBookRunning.gif

图 4:运行中的 PhoneBook 客户端和服务器截图

如本示例应用程序所示,您可以使用 SCS 框架轻松在 .NET 中创建基于 TCP 的服务器/客户端应用程序。

客户端的更多功能

SCS 客户端支持 `IDisposable` 接口,因此如果您在 `using` 块中编写代码,则无需断开与服务器的连接,如下所示:

using (var client = ScsServiceClientBuilder.CreateClient<IPhoneBookService>(
                    new ScsTcpEndPoint("127.0.0.1", 10048)))
{
    client.Connect();
    client.ServiceProxy.AddPerson(
              new PhoneBookRecord {Name = "Halil", Phone = "2221144"});
}

如果您想连接到服务器,调用服务方法并断开连接,就像 Web 服务方法调用一样,这很有用。实际上,您无需明确连接/断开连接。此代码也将正常工作:

var client = ScsServiceClientBuilder.CreateClient<IPhoneBookService>(
             SscEndPoint.CreateEndPoint("tcp://127.0.0.1:10048")))
client.ServiceProxy.AddPerson(
          new PhoneBookRecord {Name = "Halil", Phone = "2221144"});

在这种情况下,SCS 会**自动**连接,调用 `AddPerson` 方法并断开连接。如果您想进行单个方法调用,这种方法很有用。但如果您想使用一个客户端对象进行多次调用,则显式连接和断开连接的性能要好得多。请注意,我使用了 `ScsEndPoint.CreateEndPoint(...)` 方法从字符串地址创建 `ScsTcpEndPoint` 对象。

SCS 客户端还为远程方法调用提供了 `Timeout` 属性(以**毫秒**为单位),一个 `CommunicationState` 属性来检查客户端是否连接到服务器,以及 `Connected` 和 `Disconnected` 事件,它们分别在客户端连接或断开服务器时触发。

**如果服务器抛出异常**,客户端可以使用 `try/catch` 语句捕获它。例如,如果 `recordToAdd` 对象为 `null`,`PhoneBookService` 的 `AddPerson` 方法会抛出 `ArgumentNullException`。您可以编写如下代码来捕获它:

//Try to save a person with null object
//to demonstrate an exceptional situation
try
{
    client.ServiceProxy.AddPerson(null);
}
catch (Exception ex)
{
    Console.WriteLine();
    Console.WriteLine(ex.Message);
}

当您运行此代码时,您将看到类似以下的消息:

TCP-Server-Client/05_AddPersonException.gif

图 5:PhoneBook 客户端应用程序在异常情况下的控制台输出

如您所见,服务版本会自动添加到异常消息中。

服务器的更多功能

我们创建了一个服务器端,它只响应远程方法调用,不执行其他任何操作。我们也可以像客户端到服务器方法调用一样调用客户端方法。我将在这里简要介绍一些功能,但在下一个示例(IRC (聊天) 应用程序)中,我们将更好地理解这些功能。

为了在服务器端控制客户端,我们首先需要使用 `ScsService.CurrentClient` 属性获取客户端的 `IScsServiceClient` 接口引用。我们可以在 `PhoneBookService` 的任何方法中使用此属性,例如:

TCP-Server-Client/06_CurrentClientProperty.gif

图 6:ScsService 类的 CurrentClient 属性

此属性仅当此方法在服务契约(`IPhoneBookService`)中定义并由客户端调用时才有效。它是线程安全的,并且获取当前调用此方法的客户端。获取(并存储)对客户端的引用后,我们可以使用 `ClientId` 属性获取其唯一编号,使用 `CommunicationState` 属性获取通信状态(连接/断开),并且可以注册到 `Disconnected` 事件,以便在客户端与服务器断开连接时收到通知。最后但并非最不重要的一点是,我们可以使用 `GetClientProxy` 方法获取 `Proxy` 对象来调用客户端的远程方法。此方法是泛型的;它将客户端契约接口的类型作为泛型参数。在使用此方法之前,我们必须定义客户端实现它的接口(我们将在下一个示例聊天应用程序中看到它)。因此,如果获取并存储代理对象,我们就可以随时调用客户端方法。通过这种方式,我们可以超越请求/响应式服务器/客户端架构。

使用 SCS 框架的 IRC 聊天系统

您可能知道 IRC 是什么。虽然如今它不像 MSN 那样被广泛使用,但在过去它非常流行。IRC 是 **I**nternet **R**elay **C**hat 的缩写。简单来说,在 IRC 中,用户加入房间。加入房间后,您可以看到房间中当前所有其他用户。您可以向房间发送消息(所有房间中的用户都会看到该消息),或者您可以点击房间中的用户并与该用户私聊(其他用户看不到您的私聊消息,除了您正在发送消息的用户)。我们的简单 IRC 系统只为用户提供一个房间。他们会自动加入默认房间,并在选择昵称后立即开始聊天。

在此示例应用程序中,我将演示系统的用法,然后解释我是如何在 SCS 框架上实现它的。除了与 SCS 相关部分外,我不会深入探讨应用程序的所有部分。

使用聊天系统

将聊天系统解决方案下载到您的计算机后,首先运行服务器应用程序(`ChatServerApp.exe`),输入一个 TCP 端口号(或保留默认值 10048)以监听客户端,然后单击“启动服务器”按钮。现在您可以看到服务器窗体,如下图所示:

TCP-Server-Client/07_Server.jpg

图 7:聊天服务器应用程序

在上面的示例屏幕中,您会看到四个在线用户连接到服务器并正在聊天。现在运行 `ChatClientApp.exe` 启动客户端应用程序。您将看到如下所示的窗口。

TCP-Server-Client/08_chatclient1.jpg

图 8:聊天客户端应用程序的登录屏幕

您可以输入昵称、服务器 IP 和 TCP 端口。如果您在与服务器应用程序相同的计算机上进行测试,请将 IP 和端口保留为默认值;否则,您可以输入服务器应用程序的 IP 和端口。您可以通过右键单击图片来更改您的头像,如下图所示:

TCP-Server-Client/09_client_change_picture.jpg

图 9:在聊天客户端应用程序中更改头像图片

您可以选择默认的男性/女性图片,或者您自己的图片。如果您使用不同的昵称运行几个客户端应用程序副本,您将看到一个类似这样的屏幕:

TCP-Server-Client/10_chatclient2.jpg

图 10:在公共房间聊天时,聊天客户端应用程序的示例截图

您可以以不同的消息样式编写消息,设置您的状态,打开/关闭声音...等等。客户端的主窗口允许您公开聊天。房间中的所有用户都可以看到您的消息。如果您双击用户列表中的用户,您可以与该用户私聊:

TCP-Server-Client/11_chatclient3.jpg

图 11:在私人窗口中聊天

您可以更深入地研究聊天应用程序。也许它不仅仅是一个演示 SCS 框架用法的示例应用程序。

聊天服务器的实现

现在我将研究聊天服务器的实现。我创建了三个项目:`ChatServerApp` (WPF 应用程序)、`ChatClientApp` (WPF 应用程序) 和 `ChatCommonLib` (DLL 项目)。最后一个用于定义服务和客户端契约以及在客户端和服务器之间传输的对象。

服务器契约接口定义了服务器可以由客户端远程调用的方法。它定义在 `ChatCommonLib` 程序集中的 `IChatServer` 接口中。

using Hik.Communication.ScsServices.Service;
using Hik.Samples.Scs.IrcChat.Arguments;
 
namespace Hik.Samples.Scs.IrcChat.Contracts
{
    /// <summary>
    /// This interface defines Chat Service Contract.
    /// It is used by Chat clients to interact with Chat Server.
    /// </summary>
    [ScsService(Version = "1.0.0.0")]
    public interface IChatService
    {
        /// <summary>
        /// Used to login to chat service.
        /// </summary>
        /// <param name="userInfo">User informations</param>
        void Login(UserInfo userInfo);
        
        /// <summary>
        /// Sends a public message to room.
        /// It will be seen by all users in room.
        /// </summary>
        /// <param name="message">Message to be sent</param>
        void SendMessageToRoom(ChatMessage message);
        
        /// <summary>
        /// Sends a private message to a specific user.
        /// Message will be seen only by destination user.
        /// </summary>
        /// <param name="destinationNick">Nick of the destination user
        /// who will receive the message</param>
        /// <param name="message">Message to be sent</param>
        void SendPrivateMessage(string destinationNick, ChatMessage message);
 
        /// <summary>
        /// Changes status of a user and inform all other users.
        /// </summary>
        /// <param name="newStatus">New status of user</param>
        void ChangeStatus(UserStatus newStatus);
        
        /// <summary>
        /// Used to logout from chat service.
        /// Client may not call this method while logging out (in an application 
        /// crash situation),
        /// it will also be logged out automatically when connection fails between
        /// client and server.
        /// </summary>
        void Logout();
    }
}

我认为所有方法都说明了自己。客户端首先调用 `Login()` 方法(当用户在客户端窗口上点击“登录”按钮时)以登录到服务器。然后它可以使用 `SendMessageToRoom(...)` 方法向房间发送消息,使用 `SendPrivateMessage(...)` 方法发送私有消息,等等。最后,当用户关闭主窗口时,客户端调用 `Logout()` 方法。

我们必须首先实现服务器契约(`IChatServer`),然后将其注册到 SCS 服务器应用程序对象(就像我们在 PhoneBook 应用程序中做的那样)。我使用 `ChatServer` 类实现了 **IChatServer** 应用程序。我将在此处详细研究各种方法。第一个是 `Login` 方法。它定义如下:

/// <summary>
/// Used to login to chat service.
/// </summary>
/// <param name="userInfo">User informations</param>
public void Login(UserInfo userInfo)
{
    //Check nick if it is being used by another user
    if (FindClientByNick(userInfo.Nick) != null)
    {
        throw new NickInUseException("The nick '" + userInfo.Nick + 
            "' is being used by another user. Please select another one.");
    }
 
    //Get a reference to the current client that is calling this method
    var client = CurrentClient;
 
    //Get a proxy object to call methods of client when needed
    var clientProxy = client.GetClientProxy<IChatClient>();
 
    //Create a ChatClient and store it in a collection
    var chatClient = new ChatClient(client, clientProxy, userInfo);
    _clients[client.ClientId] = chatClient;
 
    //Register to Disconnected event to know when user connection is closed
    client.Disconnected += Client_Disconnected;
 
    //Start a new task to send user list to new user and to inform
    //all users that a new user joined to room
    Task.Factory.StartNew(
        () =>
        {
            OnUserListChanged();
            SendUserListToClient(chatClient);
            SendUserLoginInfoToAllClients(userInfo);
        });
}

首先我们检查是否存在具有相同昵称的用户。如果存在,我们抛出 `NickInUseException`。我们可以在服务方法中安全地抛出异常。它由 SCS 框架处理并在客户端抛出(异常必须是可序列化的;请参阅 `NickInUseException` 以获取示例;据我所知,.NET Framework 中所有预定义的异常类都是可序列化的,您可以抛出它们或根据需要定义自己的异常)。然后我们使用 `CurrentClient` 属性获取对当前客户端对象(实现 `IScsServiceClient`)的引用。如果我们的系统使用客户端契约接口(由服务器调用),这是关键对象。此属性是线程安全的(即使有多个客户端线程调用同一方法,您也可以使用此属性获取正确的客户端对象)。现在,我们可以通过调用 `GetClientProxy()` 方法获取客户端契约接口的引用。这是一个泛型方法。您可以使用任何接口调用它。我们使用 `IChatClient` 调用它,因为我们的客户端方法是在 `IChatClient` 接口中定义的(我们很快将看到此接口的定义和实现;我们将使用此动态透明代理引用调用所有客户端方法)。然后我们创建一个 `ChatClient` 对象来存储客户端信息。它定义为 `ChatService` 类的一个子类,如下所示:

/// <summary>
/// This class is used to store informations for a connected client.
/// </summary>
private sealed class ChatClient
{
    /// <summary>
    /// Scs client reference.
    /// </summary>
    public IScsServiceClient Client { get; private set; }
 
    /// <summary>
    /// Proxy object to call remote methods of chat client.
    /// </summary>
    public IChatClient ClientProxy { get; private set; }
 
    /// <summary>
    /// User informations of client.
    /// </summary>
    public UserInfo User { get; private set; }
 
    ...
}

创建 `ChatClient` 对象后,我们将其添加到名为 `_clients` 的 `SortedList` 集合中。我们使用当前客户端的 `ClientId` 作为该客户端的键。`ClientId` 是一个由 SCS 框架自动生成并分配给客户端的唯一(长)数字。我们可以安全地使用此值来区分客户端。`_clients` 集合定义如下:

/// <summary>
/// List of all connected clients.
/// </summary>
private readonly ThreadSafeSortedList<long, ChatClient> _clients;

`ThreadSafeSortedList` 是我编写的一种包装器,用于在 `SortedList` 集合上执行线程安全操作。因为我们以多线程方式为所有客户端提供服务,所以所有方法都可能同时被调用。因此,我们必须防止集合被两个或更多线程同时访问。`ThreadSafeSortedList` 在内部实现了这一点。

最后但并非最不重要的一点是,我们注册了客户端的 `Disconnect` 事件。这是为了在用户断开服务器连接时收到通知(尤其是如果它在调用 `Logout` 之前断开连接)。`Login` 方法的其余部分是实现细节。我们通知 GUI 用户列表更改,向新连接的客户端发送所有在线用户的列表(在 `SendUserListToClient(...)` 方法中),并向所有其他用户发送通知,告知有新用户连接到服务器(这三个操作在单独的线程中执行,以免长时间阻塞用户,因为 SCS 上的远程方法调用是阻塞操作)。

现在,我们来看看 `ChatServer` 类中 `Logout()` 方法的实现。

/// <summary>
/// Used to logout from chat service.
/// Client may not call this method while logging
/// out (in an application crash situation),
/// it will also be logged out automatically
/// when connection fails between client and server.
/// </summary>
public void Logout()
{
    ClientLogout(CurrentClient.ClientId);
}
 
/// <summary>
/// Handles Disconnected event of all clients.
/// </summary>
/// <param name="sender">Client object that is disconnected</param>
/// <param name="e">Event arguments (not used in this event)</param>
private void Client_Disconnected(object sender, EventArgs e)
{
    //Get client object
    var client = (IScsServiceClient)sender;
 
    //Perform logout (so, if client did not call Logout method before close,
    //we do logout automatically.
    ClientLogout(client.ClientId);
}
/// <summary>
/// This method is called when a client calls
/// the Logout method of service or a client
/// connection fails.
/// </summary>
/// <param name="clientId">Unique Id of client that is logged out</param>
private void ClientLogout(long clientId)
{
    //Get client from client list, if not in list do not continue
    var client = _clients[clientId];
    if (client == null)
    {
        return;
    }
 
    //Remove client from online clients list
    _clients.Remove(client.Client.ClientId);
 
    //Unregister to Disconnected event (not needed really)
    client.Client.Disconnected -= Client_Disconnected;
 
    //Start a new task to inform all other users
    Task.Factory.StartNew(
        () =>
        {
            OnUserListChanged();
            SendUserLogoutInfoToAllClients(client.User.Nick);
        });
}

在 `Logout` 方法中,我们获取调用 `Logout` 方法的客户端的唯一 `ClientId` 值,并调用 `ClientLogout(...)` 方法。我们还在 `Client_Disconnected(...)` 方法中处理客户端的 `Disconnect` 事件。在此事件处理程序方法中获取客户端对象的引用是不同的。它是通过将 `sender` 对象转换为 `IScsServiceClient` 接口获得的(`CurrentClient` 属性仅在服务契约方法中有效)。`ClientLogout` 方法简单地从 `_clients` 集合中获取并删除客户端对象,注销 `Disconnected` 事件(即使这不是强制性的),然后通知所有其他用户有用户从服务器注销。

现在,我将研究 `SendPrivateMessage(...)` 方法的实现。此方法用于向用户发送私有消息,定义如下。

/// <summary>
/// Sends a private message to a specific user.
/// Message will be seen only by destination user.
/// </summary>
/// <param name="destinationNick">Nick of the destination
///          user who will receive message</param>
/// <param name="message">Message to be sent</param>
public void SendPrivateMessage(string destinationNick, ChatMessage message)
{
    //Get ChatClient object for sender user
    var senderClient = _clients[CurrentClient.ClientId];
    if (senderClient == null)
    {
        throw new ApplicationException("Can not send message before login.");
    }
 
    //Get ChatClient object for destination user
    var receiverClient = FindClientByNick(destinationNick);
    if (receiverClient == null)
    {
        throw new ApplicationException("There is no online " + 
                  "user with nick " + destinationNick);
    }
 
    //Send message to destination user
    receiverClient.ClientProxy.OnPrivateMessage(senderClient.User.Nick, message);
}

首先,我们从 `_clients` 列表中获取 `client` 对象(这是一个 `ChatClient` 对象),并检查当前用户是否已登录(请记住,我们在 `Login()` 方法中使用其唯一的 `ClientId` 将客户端存储在 `_clients` 列表中)。然后我们尝试使用 `FindClientByNick(...)` 方法查找目标用户。它是一个简单的方法,定义如下:

/// <summary>
/// Finds ChatClient ojbect by given nick.
/// </summary>
/// <param name="nick">Nick to search</param>
/// <returns>Found ChatClient for that nick, or null if not found</returns>
private ChatClient FindClientByNick(string nick)
{
    return (from client in _clients.GetAllItems()
            where client.User.Nick == nick
            select client).FirstOrDefault();
}

它使用 LINQ 表达式在 `_clients` 集合中通过用户昵称查找 `ChatClient` 对象。如果没有具有该昵称的用户,则返回 `null`。如果不存在使用目标昵称的客户端,`SendPrivateMessage(...)` 方法会抛出异常(如果目标用户在此方法被调用之前离开了聊天室,可能会发生这种情况;这种情况发生的概率很低,但我们无论如何都必须处理它(墨菲定律说:“如果坏事可能发生,它就会发生。”))。在 `SendPrivateMessage(...)` 方法的末尾,我们远程调用目标客户端的 `OnPrivateMessage(...)` 方法(此方法定义在 `IChatClient` 接口中,并将很快进行检查),以便它可以获取传入的消息。

此处不再赘述,但 `ChatServer` 类还定义了一个名为 `UserListChanged` 的事件,用于在用户添加到或从 `_clients` 集合中移除时通知服务器 GUI (WPF 窗口)。`ChatServer` 类由聊天服务器的 `MainWindow` 创建和使用。在 `btnStart` 按钮的 `Click` 事件的处理程序方法中,我们创建并启动服务,如下所示:

_serviceApplication = ScsServiceBuilder.CreateService(new ScsTcpEndPoint(port));
_chatService = new ChatService();
_serviceApplication.AddService<IChatService, ChatService>(_chatService);
_chatService.UserListChanged += chatService_UserListChanged;
_serviceApplication.Start();

它与 `PhoneBookService` 应用程序非常相似。我们创建一个在 TCP 端口(用户在 `txtPort` 文本框中输入)上运行的 SCS 服务应用程序。然后我们创建 `ChatService` 类的一个实例,使用 `AddService` 方法将其注册到服务应用程序,注册到 `UserListChanged` 事件(以将用户列表更改反映到 GUI),并启动服务应用程序。要停止服务器,我们调用 `IScsServiceApplication` 的 `Stop` 方法。

_serviceApplication.Stop();

我们已经学会了如何编写一个多线程服务器,它可以被客户端调用,也可以调用客户端方法。顺便说一下,您可以在同一个端点(TCP 端口)上运行的服务应用程序中添加多个服务。SCS 框架处理这种情况,并根据使用服务的客户端调用正确服务的正确方法(请记住,您在客户端创建客户端对象时提供了服务契约接口)。如果您不想为每个服务使用单独的端口并在同一个进程中运行多个服务,这种方法非常有用。

聊天客户端的实现

尽管我们的示例聊天应用程序有点复杂(因为 WPF 和其他附加功能),但与 SCS 相关的部分很容易理解。通过调用服务契约的远程方法来使用服务与 `PhoneBook` 客户端相同。此外,聊天客户端可以由服务器调用(这是 SCS 框架的双向通信),因此它必须定义一个客户端契约。它在 `IChatClient` 接口中定义如下:

using Hik.Communication.ScsServices.Service;
using Hik.Samples.Scs.IrcChat.Arguments;
 
namespace Hik.Samples.Scs.IrcChat.Contracts
{
    /// <summary>
    /// This interface defines methods of chat client.
    /// Defined methods are called by chat server.
    /// </summary>
    public interface IChatClient
    {
        /// <summary>
        /// This method is used to get user list from chat server.
        /// It is called by server once after user logged in to server.
        /// </summary>
        /// <param name="users">All online user informations</param>
        void GetUserList(UserInfo[] users);
 
        /// <summary>
        /// This method is called from chat server to inform that a message
        /// is sent to chat room publicly.
        /// </summary>
        /// <param name="nick">Nick of sender</param>
        /// <param name="message">Message</param>
        void OnMessageToRoom(string nick, ChatMessage message);
 
        /// <summary>
        /// This method is called from chat server to inform that a message
        /// is sent to the current user privately.
        /// </summary>
        /// <param name="nick">Nick of sender</param>
        /// <param name="message">Message</param>
        void OnPrivateMessage(string nick, ChatMessage message);
 
        /// <summary>
        /// This method is called from chat server to inform that a new user
        /// joined to chat room.
        /// </summary>
        /// <param name="userInfo">Informations of new user</param>
        void OnUserLogin(UserInfo userInfo);
 
        /// <summary>
        /// This method is called from chat server to inform that an existing user
        /// has left the chat room.
        /// </summary>
        /// <param name="nick">Informations of new user</param>
        void OnUserLogout(string nick);
        /// <summary>
        /// This method is called from chat server to inform that a user
        /// changed his/her status.
        /// </summary>
        /// <param name="nick">Nick of the user</param>
        /// <param name="newStatus">New status of the user</param>
        void OnUserStatusChange(string nick, UserStatus newStatus);
    }
}

客户端契约方法在代码注释中说明了自己。此接口由 `ChatClientApp` 的 `ChatClient` 类在客户端应用程序中实现。例如,当另一个用户向此用户发送私有消息时,服务器会调用 `OnPrivateMessage(...)` 方法。因此,我们必须打开一个私人聊天窗口(如果尚未打开)与消息发送者,并将消息写入消息区域。在本文中,我将解释 `OnPrivateMessage(..)` 方法的实现;对于其他方法,请参阅 `ChatClient` 类。此方法的实现如下所示:

/// <summary>
/// This method is called from chat server to inform that a message
/// is sent to the current used privately.
/// </summary>
/// <param name="nick">Nick of sender</param>
/// <param name="message">Message</param>
public void OnPrivateMessage(string nick, ChatMessage message)
{
    _chatRoom.OnPrivateMessageReceived(nick, message);
}

它除了调用 `IChatRoomView` 接口的 `OnPrivateMessageReceived(...)` 方法外,什么也没做。此接口与 GUI 无关。`IChatRoomView` 接口由 `MainWindow` 类实现,如下所示:

public partial class MainWindow : Window, IChatRoomView, 
               ILoginFormView, IMessagingAreaContainer
{
    . . .
}

如您所见,`MainWindow` 还实现了 `ILoginFormView`(它具有 GUI 控件,允许用户登录服务器;参见图 8)和 `IMessagingAreaContainer`(它具有消息区域,用于向公共房间显示传入消息;参见图 10)。顺便说一下,`MainWindow` 实现了 `IChatRoomView.OnPrivateMessageReceived` 方法,如下所示:

/// <summary>
/// This method is called from chat server to inform that a message
/// is sent to the current used privately.
/// </summary>
/// <param name="nick">Nick of sender</param>
/// <param name="message">Message</param>
public void OnPrivateMessage(string nick, ChatMessage message)
{
    _chatRoom.OnPrivateMessageReceived(nick, message);
}

如果您以前使用过 WPF,您就知道只能在创建 WPF 对象的线程上使用它。在其他线程上使用它会导致运行时异常。因此,例如,您不能在 UI 线程之外的另一个线程中更改 `TextBox` 的 `Text` 值。SCS 框架是**多线程**的,因此 `OnPrivateMessageReceived` 由 UI 线程之外的另一个线程调用。因此,我们使用 `Dispatcher` 对象从 UI 线程调用方法。此方法是 `OnPrivateMessageReceivedInternal`,定义如下:

/// <summary>
/// This method is used to send private message to proper private messaging window.
/// </summary>
/// <param name="nick">Nick of sender</param>
/// <param name="message">Message</param>
private void OnPrivateMessageReceivedInternal(string nick, ChatMessage message)
{
    var userCard = FindUserInList(nick);
    if (userCard == null)
    {
        return;
    }
 
    if (!_privateChatWindows.ContainsKey(nick))
    {
        //Create new private chat window
        _privateChatWindows[nick] = CreatePrivateChatWindow(userCard);
 
        //Set initial state as minimized
        _privateChatWindows[nick].WindowState = WindowState.Minimized;
                
        //Flash the window button on taskbar to inform user
        WindowsHelper.FlashWindow(new WindowInteropHelper(
            _privateChatWindows[nick]).Handle,
            WindowsHelper.FlashWindowFlags.FLASHW_ALL, 2, 1000);
    }
 
    _privateChatWindows[nick].MessageReceived(message);
}

我们首先检查用户是否在用户列表中。然后我们检查 `_privateChatWindows`(打开的私人聊天窗口列表)是否包含该用户的窗口。如果不是,我们创建一个新窗口,将其状态设置为最小化,并像 MSN 一样在任务栏中应用闪烁效果。最后,我们调用私人消息窗口(`PrivateChatWindow` 类)的 `MessageReceived` 事件。

/// <summary>
/// This method is used to add a new message to message history of that window.
/// </summary>
/// <param name="message">Message</param>
public void MessageReceived(ChatMessage message)
{
    MessageHistory.MessageReceived(_remoteUserNick, message);
    if (!IsActive)
    {
        //Flash taskbar button if this window is not active
        WindowsHelper.FlashWindow(_windowInteropHelper.Handle,
            WindowsHelper.FlashWindowFlags.FLASHW_TRAY, 1, 1000);
        ClientHelper.PlayIncomingMessageSound();
    }
}

此方法调用消息区域 (`MessagingAreaControl` 用户控件) 的 `MessageReceived(...)` 方法,然后闪烁窗口的任务栏按钮以在窗口不活动时通知用户。最后,`MessagingAreaControl.MessageReceived` 方法将消息添加到 `RichTextBox`。就这样;现在我们知道如何从服务器发送、从客户端接收并向用户显示私有消息。

现在,让我们看看客户端如何连接到 SCS 聊天服务器。这在 `ChatController` 类的 `Connect()` 方法中完成。

/// <summary>
/// Connects to the server.
/// It automatically Logins to server if connection success.
/// </summary>
public void Connect()
{
    //Disconnect if currently connected
    Disconnect();
 
    //Create a ChatClient to handle remote method invocations by server
    _chatClient = new ChatClient(ChatRoom);
 
    //Create a SCS client to connect to SCS server
    _scsClient = ScsServiceClientBuilder.CreateClient<IChatService>(
         new ScsTcpEndPoint(LoginForm.ServerIpAddress, 
                            LoginForm.ServerTcpPort), _chatClient);
 
    //Register events of SCS client
    _scsClient.Connected += ScsClient_Connected;
    _scsClient.Disconnected += ScsClient_Disconnected;
 
    //Connect to the server
    _scsClient.Connect();
}

与 `PhoneBook` 客户端创建客户端对象的唯一区别是,我们传递了一个实现 `IChatClient` 接口的对象(`_chatClient`),以处理来自服务器的传入远程方法调用。我们上面已经检查了 `ChatClient` 类的 `OnPrivateMessage(...)` 方法。

正如您从 PhoneBook 客户端应用程序中了解到的,我们使用 `ServiceProxy` 调用服务器的方法,如下所示。

/// <summary>
/// Sends a private message to a user.
/// </summary>
/// <param name="nick">Destination nick</param>
/// <param name="message">Message</param>
public void SendPrivateMessage(string nick, ChatMessage message)
{
    _scsClient.ServiceProxy.SendPrivateMessage(nick, message);
}

通过 ChatController 的 `SendPrivateMessage(...)` 方法,我们已经完成了对客户端的检查。您可以查看代码以更深入地了解我是如何实现客户端的。但我们已经看到了本文所有重要的 SCS 相关部分。

SCS 客户端的其他一些优点

自动重新连接

如果您正在构建必须始终保持与服务器连接的客户端应用程序,那么在与服务器断开连接时,您会遇到重新连接的问题。当然,您可以通过注册客户端对象的 `Disconnect` 事件并尝试重新连接到服务器,直到成功建立连接来解决此问题。幸运的是,SCS 框架有一个名为 `ClientReconnecter` 的类,它会代表您重新连接到服务器。首先,您必须使用客户端对象创建 `ClientReconnecter` 对象,如下所示。

//Create a reconnecter to reconnect automatically on disconnect.
var reconnecter = new ClientReConnecter(_scsClient) {ReConnectCheckPeriod = 30000};

就是这样(参见 `ChatController.Connect()` 方法以了解 `_scsClient` 是什么)。如果您的连接失败,重新连接器将每隔 30 秒尝试重新连接,直到成功连接。`ReConnectCheckPeriod` 的默认值为 20 秒。要停止/处置重新连接器,您可以使用 `Dispose()` 方法。

自动发送心跳以保持连接

在 TCP 中,如果服务器和客户端之间不发送消息(这意味着通信线路空闲),连接可能会丢失(由于操作系统、防火墙、路由器等)。SCS 框架通过在通信线路空闲时定期发送一种特殊的心跳消息来解决此问题。心跳消息的周期为 30 秒。如果客户端和服务器已经在通信,则不发送心跳消息。

本文的最后总结

在本文中,我介绍了一个新框架,可用于在 .NET 中开发基于 TCP 的客户端/服务器系统。它与 WCF 有相似之处,但更简单。我认为,如果您在 .NET 中同时开发服务器和客户端,SCS 可能就足够了。SCS 服务构建在可以通过消息传递进行通信的层之上。此层也对用户可用。它没有 RMI 支持,但具有其他优点,例如允许您更改消息传递的线路协议,以便您的应用程序可以与非 .NET 构建的其他应用程序进行通信。我没有在本文中解释这一层,但会在第二篇文章中进行解释。

关于第二篇文章 

我在第二篇文章中解释了 SCS 框架的实现。它演示了如何通过原始 TCP 套接字流实现一个完整的 RMI 框架。这是第二篇文章的链接:一个完整的 C# .NET TCP 服务器/客户端通信和 RMI 框架 - 实现

历史

  • 2011年6月13日
    •  SCS 框架更新至 v1.1.0.1。 
  • 2011年5月29日
    • SCS 框架更新至 v1.1.0.0。(点击此处查看所有更改/新增功能)
    • 文章根据更改进行了更新。
  • 2011年4月12日
    • 更新了 SCS 源代码。
© . All rights reserved.