DrawMe - 使用WPF/WCF创建的网络绘图板聊天程序 2

DrawMe序列图

 为了解释DrawMe是如何工作的,我们画了几个UML序列图来表示程序在不同场景下的状态。

 登录

在登录时,主要有四个事件:

  • 开启服务器 -  如果用户开启一个新的DrawMe服务器,程序会生成一个线程来运行DrawMeService协调客户端之间的通讯。我们使用TCP,不过WCF可以很容易的更改协议。
  • 开启客户端 - 构造一个ClientCallBack实例(实现IDrawMeServiceCallback接口),以便让服务器回调客户端上的功能。同时构造一个DrawMeServiceClient来处理同DrawMe服务器的通讯,并用它连接服务器。
  • 更新用户列表 - 服务器使用客户端的回调来更新已登录的用户列表。
  • 结束登录 - 关闭登录控件,进入聊天模式。

下面的代码说明了我们如何实现登录过程。注意为了简化程序,我们关闭了安全检查(见App.config),我们也把端口硬编码为了8000。同样,这只是为了让演示程序简单些,在实际的程序中我们可不能这么做。

App.config

< bindings >
      
< netTcpBinding >
        
< binding  name ="DrawMeNetTcpBinding" >
          
< security  mode ="None" >
            
< transport  clientCredentialType ="None"   />
            
< message  clientCredentialType ="None"   />
          
</ security >
        
</ binding >
      
</ netTcpBinding >
    
</ bindings >
LoginControl.xaml.cs
private   void  btnLogin_Click( object  sender, RoutedEventArgs e)
{
    EndpointAddress serverAddress;
    
if (this.chatTypeServer.IsChecked == true)
    
{
        DrawMe.App.s_IsServer 
= true;
        serverAddress 
= new EndpointAddress("net.tcp://localhost:8000/DrawMeService/service");
    }

    
else
    
{
        DrawMe.App.StopServer();
        DrawMe.App.s_IsServer 
= false;
        
if (txtServer.Text.Length == 0)
        
{
            MessageBox.Show(
"Please enter server name");
            
return;
        }

        serverAddress 
= new EndpointAddress(string.Format("net.tcp://{0}:8000/DrawMeService/service", txtServer.Text));
    }


    
if (txtUserName.Text.Length == 0)
    
{
        MessageBox.Show(
"Please enter username");
        
return;
    }


    
if (DrawMeServiceClient.Instance == null)
    
{
        
if (App.s_IsServer)
        
{
            DrawMe.App.StartServer();
        }


        
try
        
{
            ClientCallBack.Instance 
= new ClientCallBack(SynchronizationContext.Current, m_mainWindow);
            DrawMeServiceClient.Instance 
= new DrawMeServiceClient
                                            (
                                                
new DrawMeObjects.ChatUser
                                                (
                                                    txtUserName.Text,
                                                    System.Environment.UserName,
                                                    System.Environment.MachineName,
                                                    System.Diagnostics.Process.GetCurrentProcess().Id,
                                                    App.s_IsServer
                                                ),
                                                
new InstanceContext(ClientCallBack.Instance),
                                                
"DrawMeClientTcpBinding",
                                                serverAddress
                                            );
            DrawMeServiceClient.Instance.Open();
        }

        
catch (System.Exception ex)
        
{
            DrawMe.App.StopServer();
            DrawMeServiceClient.Instance 
= null;
            MessageBox.Show(
string.Format("Failed to connect to chat server, {0}", ex.Message),this.m_mainWindow.Title);
            
return;
        }

    }


    
if (DrawMeServiceClient.Instance.IsUserNameTaken(DrawMeServiceClient.Instance.ChatUser.NickName))
    
{
        DrawMeServiceClient.Instance 
= null;
        MessageBox.Show(
"Username is already in use");
        
return;
    }


    
if (DrawMeServiceClient.Instance.Join() == false)
    
{
        MessageBox.Show(
"Failed to join chat room");
        DrawMeServiceClient.Instance 
= null;
        DrawMe.App.StopServer();
        
return;
    }


    
this.m_mainWindow.ChatMode();
}
处理墨水笔迹

一旦用户连接服务器后,程序就可以发送和接受笔迹了。在这一步骤有两个主要事件:

  • SendInkStrokes - 用户在画布上绘画,笔迹会发送给服务器以便转发给所有客户端。
  • OnInkStrokesUpdate - 其他用户绘画后,服务器使用回调来更新每个用户的画布。

在DrawMe所有的笔迹都以MemeoryStream对象传送(或者以底层的字节数组形式)。注意我们没有用巧妙的方法来传送笔迹,我们传送了画布上的所有内容而不是最后更新的那部分。这作为演示可以很简单的来处理擦除模式(和绘画模式是一样的)。我们本来要优化笔迹传送,但是可惜最后没有时间实现了。下面的代码表明了我们如何实现客户端里的功能。

private   void  SaveGesture()
  
{
      
try
      
{
          MemoryStream memoryStream 
= new MemoryStream();
  
          
this.inkCanv.Strokes.Save(memoryStream);
             
          memoryStream.Flush();
  
          DrawMeServiceClient.Instance.SendInkStrokes(memoryStream);
      }

      
catch (Exception exc)
      
{
          MessageBox.Show(exc.Message, Title);
      }

  }
一旦笔迹发送到服务器,下面的代码会被执行来更新所有用户。在发送笔迹更新客户端时,注意我们如何调用传送内存流的 GetBuffer()方法。最初我们传递 MemoryStream对象,但是我们马上遇到了问题:在我们使用前,对象就被GC回收了。这是因为每个客户端需要保证在GUI线程上进行全部更新,所以我们使用了一个匿名代理来向GUI线程发送一个异步调用。当GUI线程在处理更新时, MemoryStream对象就有可能被GC回收了。现在看来这个问题很明显,但当时的确困扰了我们不少时间。
public   class  DrawMeService : IDrawMeService
  
{
      
public void SendInkStrokes(MemoryStream memoryStream)
      
{
          IDrawMeServiceCallback client 
= OperationContext.Current.GetCallbackChannel();
          
          
foreach (IDrawMeServiceCallback callbackClient in s_dictCallbackToUser.Keys)
          
{
              
if (callbackClient != OperationContext.Current.GetCallbackChannel())
              
{
                  callbackClient.OnInkStrokesUpdate(s_dictCallbackToUser[client], memoryStream.GetBuffer());
              }

          }

      }

      ...
  }

登出

当一个用户登出时,程序会通知服务器将它从用户列表中移出。如果这个用户就是服务器,那所有用户都会断开连接并返回到登录窗口。

这是当用户登出时执行的代码:

public   void  Leave(ChatUser chatUser)
  
{
      IDrawMeServiceCallback client 
= OperationContext.Current.GetCallbackChannel();
      
if (s_dictCallbackToUser.ContainsKey(client))
      
{
          s_dictCallbackToUser.Remove(client);
      }

  
      
foreach (IDrawMeServiceCallback callbackClient in s_dictCallbackToUser.Keys)
      
{
          
if (chatUser.IsServer)
          
{
              
if (callbackClient != client)
              
{
                  
//server user logout, disconnect clients
                  callbackClient.ServerDisconnected();
              }

          }

          
else
          
{
              
//normal user logout
              callbackClient.UpdateUsersList(s_dictCallbackToUser.Values.ToList());
          }

      }

  
      
if (chatUser.IsServer)
      
{
          s_dictCallbackToUser.Clear();
      }

  }

使用WCF通讯

到目前为止我们还没有讲到如何用WCF来实现程序间的通讯。在这一节中,我们给出WCF关键特性的概述。在通讯上我们需要解决三个主要问题:

  • 序列化自定义对象 - 提供一个方法来在网络上传送我们的实例对象
  • 定义服务合同 - 指定一个服务器要实现的接口
  • 提供客户端回调函数 - 指定一个回调,服务器可以用来调用每个客户端的方法

WCF提供了每个问题的解决

序列化自定义对象

很多.NET内置类型默认都是可序列化的,这意味着他们在网络通讯中可以用标准方式表示。但是,当你定义一个新的类时,却不是默认可序列化的。我们创建了ChatUser类来储存每个用户的信息,为了在网络上传送CharUser对象,我们需要指定它为可序列化的。

我们给ChatUser类加上WCF的System.Runtime.Serialization - [DataContract]特性,应用这个特性表示我们打算序列化这个类。要序列化类的特定成员,我们要给它加上[DataMember]特性,这是因为DataContract被设计为了“Opt-in”模式(同意才加入),也就是说,任何没有指定DataMemeber特性的成员是不会被序列化的。下面的代码片断说明了我们如何给ChatUser类应用这些特性的。在ChatUser.cs中可以看到全部信息。

[DataContract]
    
public   class  ChatUser
    
{
        ...
        
        [DataMember]
        
public string NickName
        
{
            
get return m_strNickName; }
            
set { m_strNickName = value; }
        }

        
        ...        
    }

服务合同

为了使每个客户端都能与服务器通讯,需要建立一个服务合同。合同的目的是公开服务的接口,这样客户端就知道了服务端可以使用的方法。在WCF中,合同可以通过在接口指定ServiceContract特性建立。当应用了这个特性后,可能还需要制定一个CallbackContract回调合同,来表明客户端实现的回调接口。你可以在下面代码中看到我们如何使用这些特性的。

[
       ServiceContract
       (
           Name 
=   " DrawMeService " ,
           Namespace 
=   " http://DrawMe/DrawMeService/ " ,
           SessionMode 
=  SessionMode.Required,
           CallbackContract 
=   typeof (IDrawMeServiceCallback)
       )
]
    
public   interface  IDrawMeService
    
{
        [OperationContract()]
        
bool Join(ChatUser chatUser);

        [OperationContract()]
        
void Leave(ChatUser chatUser);

        [OperationContract()]
        
bool IsUserNameTaken(string strUserName);

        [OperationContract()]
        
void SendInkStrokes(MemoryStream memoryStream);
    }
每一个客户端都需要知道 IDrawMeService接口,服务器需要包含它的实现,并在实现上指定 ServiceBehavior特性。DrawMe服务使用如下的服务行为。
  • ConcurrencyMode - Single. 服务一次只会处理一个响应
  • InstanceContextMode - Single. 只使用一个DrawMeService对象来处理所有响应并且不会回收它。如果DrawMe服务对象不存在,则创建一个。这一点很像单例模式。

下面是我们如何在DrawMeService实现上添加ServiceBehavior特性的。

[
        ServiceBehavior
        (
            ConcurrencyMode 
=  ConcurrencyMode.Single, 
            InstanceContextMode 
=  InstanceContextMode.Single
        )
]
    
public   class  DrawMeService : IDrawMeService
    
{
        ...
    }

客户回调

DrawMe有一个IDrawMeServiceCallback接口,允许服务器来给客户端发送消息。例如,新用户加入聊天室,服务器使用回调机制来通知每个用户。回调接口在共享的DrawMeInterfaces.dll中定义,并在客户端上实现,见ClientCallBack.cs

DrawMe客户端实现了三个回调函数:

  • UpdateUsersList - 当新用户加入时,服务器通知每一个用户
  • OnInkStrokesUpdate - DrawMe服务器发送最新的墨水笔迹给每一个用户
  • ServerDisconnected - 当服务器断开时,通知所有客户端

应该给每个回调方法上制定一个OperationContract特性。在DrawMe中,我们选择用IsOneWay=true来实现回调。也就是说,操作不会返回任何信息给服务器,无论它是否成功执行。

public   interface  IDrawMeServiceCallback
    
{
        [OperationContract(IsOneWay 
= true)]
        
void UpdateUsersList(List listChatUsers);

        [OperationContract(IsOneWay 
= true)]
        
void OnInkStrokesUpdate(ChatUser chatUser, byte[] bytesStroke);

        [OperationContract(IsOneWay 
= true)]
        
void ServerDisconnected();
    }

总结

希望这篇文章能够给你一些关于WCF的有用信息。在实现我们系统绘画程序中,我们演示了用WCF的一些很棒的功能可以相对轻松的实现。实际上,我们认为我们应该花更多的时间在这篇文章上,而不是在代码上,那样会给你更多的WCF框架强大的功能展示(假如我们不是很滥的作者)。

附 - 通过CodePlex协同

在做这个小项目时,我们希望使用一个协同工作机制,而不是互相跑到对方家里去。使用一个基于Web的免费源代码管理系统是一个很不错的选择。我们决定尝试CodePlex(http://www.codeplex.com/),它是微软的开源项目网站。我们发现CodePlex是一个很好用的工具,可以协同工作和掌握事件。而且,CodePlex有一个直观的界面,我们可以很轻松的使用它。

CodePlex的后端使用了Team Foundation Server(TFS)系统来储存社区项目。使用Team Explore 2008可以使VS2008与TFS更紧密地集成。Team Explore 2008是微软的一个免费的简单的TFS客户端,可以直接集成到VS2008开发环境中。不过Team Explore 2008不能用于VS2008 Beta2(下载了387MB后才痛苦发现)。但最后也没什么大碍,因为我们使用了TortoiseSVN(一个Windows的Subversion客户端)来访问TFS。更多信息可见于CodePlex FAQ列表。

一旦我们获得了源代码控制访问,就可以很方便的来协同工作。CodePlex让我们很喜欢的一点是它集成的Issue Tracker事件追踪器,可以很方便的提交一个事件,给每个人指定任务。总之,如果你打算建立一个多人开发的开源项目,使用CodePlex是一个很好的选择。

如果你有兴趣“签出”CodePlex上的DrawMe项目,请访问http://www.codeplex.com/drawme看一看。Issue Tracker事件追踪和Source Code源代码估计是两个最常用的标签,如果你要看看我们是如何工作的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值