我的服装DRP之即时通讯——为WCF增加UDP绑定(应用篇)

发个牢骚,博客园发博文竟然不能写副标题。这篇既为我的服装DRP系列第二篇,也给为WCF增加UDP绑定系列收个尾。原本我打算记录开发过程中遇到的一些问题和个人见解,不过写到一半发现要写的东西实在太多,有些问题甚至不好描述,又担心误导读者,就作罢了。

说到即时通讯大伙都会第一时间想到QQ等聊天软件,似乎跟服装DRP八竿子打不着。即时通信翻译自Instant Messaging,如果我把它解释为即时消息推送,再将其放之于企业应用中就好理解了。举例:上级给下级发货,下级能第一时间知道货已发出,就用不着打电话询问或满心期待地频繁刷新列表;下级店铺卖出一单,正在为销售淡季发愁的老板看到蹦出的提示消息,瞬间有了信心……

这个功能对不明真相的客户并没有多少吸引力,因为大部分CS软件似乎都能做到这一点,只不过——或多或少延迟个几秒或几分钟,当然客户对延迟一无所知。但是做技术的知道这个延迟代表什么:频繁地访问数据源,频繁地将“最新”数据与本地数据作比较[or直接使用获取到的数据]刷新UI。假设对数据实时精度控制为1分钟,有1000个客户端运行,平均每个客户端对10种数据类型感兴趣(比如数据类型(即时通信中可称为消息类型)包括入库、发货、零售和调拨,或者基础资料的修改等等),那么每分钟就会产生额外的10000次的数据库的访问量,注意大部分访问都没有任何作用(除了副作用),而且假如没有合理地设置筛选条件[及其它改善手段],那么访问产生的数据量,大部分也可能是无用的。另外,合理的数据结构和逻辑设计以满足对各类数据类型的提示也是个不小的难点,毕竟数据类型多种多样,就单个数据类来说,也有多个属性,假如用户对其中的某些属性感兴趣,如何设计一种方式使得数据库中某条记录的某些字段变动时能检索到,啧啧,水很深哟。

注意:即时通讯和BS几乎没关系,BS应用先天不足,只能采用定时读取数据库的方式来模拟即时通信,同上述的大部分CS软件。也许用插件能行,但是插件本质上也是CS中的C。上回说到BS的缺点,这里又能加上一条,呵呵,开个玩笑。

请时刻注意本文所说的IM并非单纯的聊天软件,而是为企业应用系统服务的辅助类工具。它应该具有相对独立性、良好的扩展性和简便的应用性(应用是对用户和开发人员两者来说的,用户能方便的使用它,开发人员能方便地将它接入系统)。按照本系列惯例,列客户关注的几个功能需求:

  1. 在线用户管理(这在大中型服装企业比较有用,能有效跟进各个分支机构的分销系统使用情况);
  2. 系统消息广播;
  3. 单点登录(当已有相同账号在线时,两种处理方式,一是登录失败,一是仿QQ,将原在线用户踢下线;用数据库方式能实现前一种。); 
  4. 业务事件成功后可自动[对N个目标客户端]发送消息;
  5. 用户接收消息权限管理(是否能接收某个类型的消息);
  6. 消息提示;
  7. 消息查询(目前并未提供往期历史消息查询
  8. 企业通讯工具(重点是美工,推后)
  9. ……

需求看似挺多,其实技术实现起来难点就一个:UDP打洞。单纯打洞而言,直接用Socket编码相当简单。不过为了提升自己对WCF的理解,我决定使用WCF来完成,后来发现这真是自讨苦吃(一些知识要点记录在为WCF增加UDP绑定(储备篇))中。依托WCF框架进行UDP通信与直接使用Socket相比,也有很多好处,比如消息的传递被封装为方法的调用,更符合咱“高层开发者”的口味。WCF原生支持的绑定类型并没有给实现打洞提供太多可用信息(TCP等若干绑定能获取发送端IPEndPoint信息),因此我使用微软后来提供的UDP绑定封装示例,并增加了设置通信端口和获取发送端IPEndPoint的功能,这两者是实现打洞的前提,此处不予赘述。下面关注业务代码。

 1 /// <summary>
 2 /// 用户终端
 3 /// </summary>
 4 [DataContract]
 5 public class UserPoint
 6 {
 7     /// <summary>
 8     /// 用户标识
 9     /// </summary>
10     [DataMember]
11     public string UserGuid { get; set; }
12 
13     [DataMember]
14     public int UserID { get; set; }
15     [DataMember]
16     public string UserName { get; set; }
17     [DataMember]
18     public int OrganizationID { get; set; }
19     [DataMember]
20     public string OrganizationName { get; set; }
21 
22     /// <summary>
23     /// 用户主机用于侦听和发送消息的网络地址(和端口)
24     /// </summary>
25     [DataMember]
26     public string NetPointAddress { get; set; }
27 
28     public string UDPIMIPPort
29     {
30         get
31         {
32             if (string.IsNullOrEmpty(NetPointAddress))
33                 return "";
34             else
35                 return "soap.udp://" + NetPointAddress;
36         }
37     }
38 
39     //给子类使用
40     //WCF不支持继承,可以使用KnowType,子类并非定义在当前程序集,此处用显式转换
41     public UserPoint ConvertToBase()
42     {
43         return new UserPoint
44         {
45             OrganizationID = this.OrganizationID,
46             OrganizationName = this.OrganizationName,
47             UserID = this.UserID,
48             UserName = this.UserName,
49             NetPointAddress = this.NetPointAddress,
50             UserGuid = this.UserGuid
51         };
52     }
53 }

接着定义服务契约,由于客户端会相互通信,在打洞时服务端也会调用客户端方法,因此所有客户端在运行时也要寄宿服务。

服务端: 

 1 [ServiceContract(Namespace = "http://www.tuoxie.com/erp/")]
 2 public interface IServerService
 3 {
 4     /// <summary>
 5     /// 用户登入[到服务器端用户列表]
 6     /// </summary>
 7     [OperationContract(IsOneWay = true)]
 8     void UserLogin(UserPoint user);
 9 
10     /// <summary>
11     /// 用户登出[移出服务器端用户列表]
12     /// </summary>
13     [OperationContract(IsOneWay = true)]
14     void UserLogout(UserPoint user);
15 
16     /// <summary>
17     /// 叫用户A给用户B方向发一条消息(打洞)
18     /// </summary>
19     /// <param name="callingUser">打洞方</param>
20     /// <param name="waitingUserID">等待方标识</param>
21     [OperationContract(IsOneWay = true)]
22     void CallUserToPunchHole(UserPoint callingUser, string waitingUserGuid);
23 
24     /// <summary>
25     /// 维持映射端口
26     /// </summary>
27     [OperationContract(IsOneWay = true)]
28     void HoldMyPort();
29 }

注意已映射端口在一段时间不使用后会自动失效。我在本地测试时,100秒端口还能用,能相互通信,120秒后失效,服务器再通过原先端口给客户端发送讯息,客户端不再接收到。为了维持有效性,需要客户端定时给服务器发送消息,即心跳检测(反之应该也可以?)。HoldMyPort就是这个作用,一般实现为空方法。

客户端[服务]: 

 1 /// <summary>
 2 /// 客户端服务,主要用来接收各种消息
 3 /// </summary>
 4 [ServiceContract(Namespace = "http://www.tuoxie.com/erp/")]
 5 public interface IClientService
 6 {
 7     /// <summary>
 8     /// 用户上线通知
 9     /// </summary>
10     [OperationContract(IsOneWay = true)]
11     void NotifyWhenUserLogin(UserPoint user);
12 
13     /// <summary>
14     /// 用户下线通知
15     /// </summary>
16     [OperationContract(IsOneWay = true)]
17     void NotifyWhenUserLogout(UserPoint user);
18 
19     /// <summary>
20     /// 消息通知
21     /// </summary>
22     [OperationContract(IsOneWay = true)]
23     void NotifyMessage(IMessage message);
24 
25     /// <summary>
26     /// 打洞
27     /// </summary>
28     [OperationContract(IsOneWay = true)]
29     void NotifyPunchHole(UserPoint waitingUser);
30 
31     /// <summary>
32     /// sbody say "hi" to me
33     /// <remarks>属于打洞过程</remarks>
34     /// </summary>
35     [OperationContract(IsOneWay = true)]
36     void SayHi(UserPoint callingUser);
37 
38     /// <summary>
39     /// 踢我下线
40     /// </summary>
41     [OperationContract(IsOneWay = true)]
42     void KickOff(UserPoint user);
43 }

当用户登录系统时,发送讯息给服务器,服务端将执行下述方法:

 1 public void UserLogin(UserPoint user)
 2 {
 3     var users = MainWindowVM.OnlineUsers.Where(o => o.UserID == user.UserID).ToArray();
 4     lock (((ICollection)MainWindowVM.OnlineUsers).SyncRoot)
 5     {                
 6         if (users.Count() > 0)
 7         {
 8             Parallel.ForEach(users, u =>
 9             {
10                 MainWindowVM.OnlineUsers.Remove(u);
11                 ServerService.InvokeClientService(u, service => service.KickOff(u.ConvertToBase()));
12             });
13         }
14     }
15     OperationContext context = OperationContext.Current;
16     //获取传进的消息属性
17     MessageProperties properties = context.IncomingMessageProperties;
18     //获取消息发送的远程终结点IP和端口
19     IPEndPoint endpoint = properties[RemoteEndpointMessageProperty.Name] as IPEndPoint;
20     user.NetPointAddress = endpoint.ToString();
21     lock (((ICollection)MainWindowVM.OnlineUsers).SyncRoot)
22     {
23         MainWindowVM.OnlineUsers.Add(new ServerUserPoint(user) { LoginTime = DateTime.Now });
24     }
25     NotifyWhenUserLogin(user);
26 }
27 
28 /// <summary>
29 /// 通知所有在线用户有新用户上线了
30 /// </summary>
31 /// <param name="user">上线用户</param>
32 private void NotifyWhenUserLogin(UserPoint user)
33 {
34     lock (((ICollection)MainWindowVM.OnlineUsers).SyncRoot)//避免在循环过程中集合被修改
35     {
36         for (int i = 0; i < MainWindowVM.OnlineUsers.Count; i++)
37         {
38             var u = MainWindowVM.OnlineUsers.ElementAtOrDefault(i);
39             if (u != null && u.UserID != user.UserID)
40                 InvokeClientService(u, service => service.NotifyWhenUserLogin(user));
41         }
42     }
43 }

测试该方法需要三台最好处于不同局域网内的机子,其中一台通过NAT映射为公网服务器。
单点登录:当有相同账号用户在线或系统管理员在服务端使用了踢TA下线的功能后,该账号已在线用户将被强制退出系统。原本面对这样的需求,我们常常在用户数据表中增加一个标识用户是否在线的字段,当用户登录成功置为1,退出则置为0。但这只能实现后续用户登录失败,而不会给已在线用户带来任何影响,另外会带来一个发生率较高的问题:系统异常退出,极端的情况诸如断电,那么用户以后就再也登录不了了,除非增加一个重置状态的功能,假如用户数多的话,那系统管理员就有的忙了。无论如何,这不是一个好的方法。假如有一天,客户希望取消同时在线数的限制,或者,取消部分用户的同时在线数限制,那么开发人员就有的忙了。有了IM,一切都变得相当轻松。我们只要在用户登入IM时进行相应的处理即可,我们甚至可以决定哪些用户不能重复登入,哪些可以重复登入。由于IM相对独立,改动起来比较方便,而且IM服务端只运行在服务器上,也不存在部署问题。强制用户退出只需要请求相应客户端的KickOff操作,此时客户端扮演服务端的角色。

接下来到了重点:打洞。少年们两眼绽放出异样的光芒,却不知道当事者的辛苦。其实关键代码相当简单。

 1 public static void SendMessageTo(ClientUserPoint user, IMessage message)
 2 {
 3     Action invokeAction = () =>
 4     {
 5         InvokeClientService(user, service => service.NotifyMessage(message));
 6     };
 7     if (user.IsTrustMe)//信任用户(已经建立信任连接)不需要打洞
 8     {
 9         invokeAction();
10     }
11     else
12     {
13         Action action = () =>
14         {
15             int maxTryCount = 3;//最大尝试次数
16             for (int i = 0; i < maxTryCount && !user.IsTrustMe; i++)
17             {
18                 InvokeClientService(user, service => service.SayHi(CurrentUser));//我先打招呼
19                 InvokeServerService(service => service.CallUserToPunchHole(user.ConvertToBase(), CurrentUser.UserGuid));//服务器叫对方给我打招呼
20                Thread.Sleep(500);
21             }
22             if (user.IsTrustMe)
23             {
24                 invokeAction();
25             }
26         };
27         action.BeginInvoke(null, null);
28     }
29 }

这里有个问题,当通信双方处于相同局域网,应该期望它们直接通信,省略打洞步骤。方法是在用户登录时将本机IP和端口号(未映射)同时发送到服务端,当客户端A和客户端B的映射IP相同则说明他们处于同一内网,然后根据本机地址直连通信。不过这应该有两个问题需要解决:当局域网内存在多级子网NAT,A、B分属不同层,那么它们还要进行内部局域网打洞;本机IP有时候并不能准确获取,特别有些软件能生成虚拟IP。

在打洞成功后我们将对方的IsTrustMe设置成true。

1 public void SayHi(UserPoint callingUser)
2 {
3     if(VMGlobal.CurrentUser != null)
4     {
5         var user = IMHelper.OnlineUsers.Find(o => o.UserGuid == callingUser.UserGuid);
6         if (user != null)
7             user.IsTrustMe = true;
8     }
9 }

现在就可以直连通信咯。

经测试,打洞过程一般尝试1次就能连接成功,此处每次等待500毫秒。

关于组播。原本打算采用组播的方式群发消息(包括所有终端用户其它用户上下线的提示消息),不成想,路由器默认情况下是不会转发组播包的,必须在路由器上进行配置才行,解决该问题需要网管进行配合,不是编程就能解决的。而且一般的路由器都不支持组播,也就是说,目前很多路由器不支持组播协议,所以,局域网的路由器不会将这个组播信息传输出去,so,外面的电脑以及路由根本就不知道你这个组播的信息。有专门支持组播的路由,不过貌似价格不菲。如果路由器不支持组播的话,那么你的交换机就把你的组播数据当成广播数据了,广播只能在局域网里面。(该段话来自网络)。按照这个说法,外部组播数据想要进入内网也困难重重(对or错?)。因此我改用循环发送方式。

最后截个消息查询和消息接收权限的图,消息接收权限设置我目前将之放入角色管理中。

至此,IM核心功能基本实现完毕,能满足目前系统的需求(还有大数据传输等问题暂时未涉及到就不考虑了)。所谓企业通讯工具不过是在此基础上功能的累加,以后再加入吧。:)

后记:窃以为消息提示只是IM基本辅助功能,IM还能帮助系统即时刷新。举例:当权限管理员为我新增了几个模块权限,按照平常的做法,需要我注销后重新登录才能看到,现在只要将新增的模块信息发送给我,我这边系统自动将它们构造进左侧菜单树中即可;我正在下拉框中选择下级机构准备为他发货,下拉框中的数据项突然增加了一个,原因是机构管理员录入了一个新机构;……

转载本文请注明出处:http://www.cnblogs.com/newton/archive/2013/01/26/2877500.html

posted @ 2013-01-26 06:30  莱布尼茨  阅读(3584)  评论(18编辑  收藏  举报