C/S架构的ERP、CRM程序有的是以并发点(Concurrency)来销售,并发点是指同时在线人数。并发数量大时,理论上程序的运行速度会慢,软件供应商(vendor)也以控制并发的上限以解决客户对系统性能的抱怨。我接触到的一个ERP系统,它的定价策略如下表所示:
序号 | 并发用户 | 价格 |
1 | 5个以下 | 每用户20000,总价小于10万 |
2 | 5-20 | 每用户15000,总价小于30万 |
3 | 20-50 | 每用户12000,总价小于60万 |
4 | 50个以 | 每用户10000,总价最小50万 |
从软件开发的角度,我来分享一下我对并发功能的设计与实现。
需求与设计
1 正常的顺序是先启动服务器,再启动客户端主程序。如果启动客户端主程序时,连接不上服务器,要报错并终止程序。
2 运行过程中,服务器可能因各种情况停止工作。比如杀度软件扫描,停电等原因,这时我们的客户端主程序要能检测到服务器岩机,挂起当前界面。
为了减少这种事情发生的概率,我建议在服务器中安装程序AlwaysUp。
AlwaysUp能将可执行文件、批处理文件及快捷方式作为windows系统服务,并且进行管理和监视确保100%运行。当程序崩溃、挂起、弹出错误对话框时,AlwaysUp 能自动重启程序,并运行自定义的检查功能确保程序一直可用。AlwaysUp 能发送详细的email使你清楚地了解崩溃、重启等事件。
详细信息参考以下地址 http://www.0daydown.com/07/314246.html
3 我们的C/S程序有两种运行模式。第一种是客户端主程序与服务器不在同一台机器上,两个进程运行在物理隔离的两台电脑中,第二种就是客户端主程序与服务器都运行在服务器中,客户端以远程桌面的方式运行。
前一种模式好理解,两台机器之前以.NET通信机制(.NET Remoting,WCF)交互,后一种模式两个进程实际是运行在同一部电脑中,在并发控制上这两者有区别。
我们来看一下C#中的进程(Process)的定义,地址在
https://msdn.microsoft.com/en-us/library/system.diagnostics.process(v=vs.110).aspx
里面有一个SessionId的属性,它的含义如下
Gets the Terminal Services session identifier for the associated process. 获取进程的终端服务的会话标识。
在程序开发时为了识别是否是相同的并发,前者只需要根据IP地址或MAC地址,后者则需要根据SessionId来识别。
这个知识点的重要性在于,用户A已经登录过,在另一台电脑或会话中用户A再次登录时,系统要可以识别出来,要么阻止重复的登录,要么踢出前一个登录,要么刷新登录会话。
4 我们从数据的操作角度对并发用户作两个分组,一组是可编辑数据的用户,另一组是只读用户(readonly)。公司的主管,经理层或是总经理层,常常是查询报表,他们不需要操作数据。由于查询数据对服务器的压力要少很多(事务),所以一般在销售并发用户的时候,还会赠送相应数量的查看用户数。
5 用户之间关系的处理。管理员可以踢出用户,用户之间可以发送消息通知,管理员可以强制所有用户下线(由于系统需要进行重大更新,系统重要业务处理(月结,年结,期末处理等))。
6 运行过程中,客户端意外终止。比如一个耗费时间的操作(MRP运算,工作单发料,产品完工),用户在等待过程中失去耐心,强制杀死运行中的进程。这时因为没有调用Logoff方法清除服务器中的进程会话。如果再次启动登录时,可能会提示会话已经存在,或是登录用户超过最大许可数。
前面提到由于有心跳机制,服务器进程死去,客户端进程要挂起(阻止用户任何输入,暴力一点的方法是退出)。
这一点提到服务器运行正常,客户端意外终结,完全没有时机去通知服务器我已经下线。我们的处理方法是服务器每5分钟轮循一次客户端,检测到会话所在的客户端进程无法回应,则主动清除会话信息,以便于客户端下一次正常登录。
7 我们对许可机制有严格的要求,安装完成ERP后,会给当前机器环境生成一个签名文件,这个文件附注于许可文件中。运行时我们会检测当前运行的机器是否与许可文件中的机器签名匹配。
获取电脑配置可参考下面的方地:
private static string GetDiskDriveSignature(){return WmiHelper.GetWmiPropertyValue("Win32_DiskDrive", "Signature");}private static string GetDiskDriveSize(){return WmiHelper.GetWmiPropertyValue("Win32_DiskDrive", "Size");}private static string GetDiskDriveTotalTracks(){return WmiHelper.GetWmiPropertyValue("Win32_DiskDrive", "TotalTracks");}
从代码中可以看出,是使用WMI。
这是服务器中的许可验证方法,客户端程序因为有并发数量控制,不验证许可文件和它的签名。
8 阻止服务器程序被第三方恶意API调用
.NET Remoting的服务端代码例子:
static void Main(string[] args){TcpChannel channel = new TcpChannel(8080);ChannelServices.RegisterChannel(channel, false);RemotingConfiguration.RegisterWellKnownServiceType(typeof(RemotingObjects.Person), "RemotingPersonService", WellKnownObjectMode.SingleCall);System.Console.WriteLine("Server:Press Enter key to exit");System.Console.ReadLine();}
.NET Remoting客户端程序例子:
TcpChannel channel = new TcpChannel();ChannelServices.RegisterChannel(channel, false);IPerson obj = (IPerson)Activator.GetObject(typeof(RemotingObjects.IPerson), "tcp://localhost:8080/RemotingPersonService");string userName=obj.GetName();
最后一行我们调用了服务器中的程序。如果服务器程序被恶意人员获取,可以很容易的构造出客户端程序进行调用,服务器完全不知觉。为解决这个问题,我们的设计方案是客户端登录时,将当前环境因素(IP地址,电脑名,MAC地址,程序集版本与哈希值等)组合发送到服务器中,经过一个特定的算法,得出一个哈希值,登录完成后(用户名密码正确,权限允许)返回给客户端,客户端也以之前的环境变量进行算法计算,将这两者的值比较,若相等则允许登录。
这为恶意调用服务器程序增加了难度。
9 服务器会话
说穿不值一文钱,其实就是个DataTable对象,当有用户登录(Login)时,增加会话记录。用户注销(Logout)时,清除会画记录。
也可以学习ASP.NET的Session对象的设计思路,参考这里 Exploring Session in ASP.NET
DataTable因为操作上的不方便,后期维护的时候,我们把它完善成强类型对象。
[Serializable]public class Session{public string SessionId { get;set;}public string UserId { get;set;}public string UserGroup { get;set;}public string MachineName { get;set;}......}public class SessionCollection :List<Session>{}//经过OOP的封装,调用时比DataTable要方便Singleton<SessionCollection> sessions.....;sessions.Add(new Session());
10 日志记录
记录客户端登录日志,数据库表设计
1) 日志主表 UserLog(LogNo,LoginTime,LogoutTime,Profile)
纪录登入和注销时间,如果是客户端进程被强制杀死,LogoutTime常常是没有值。对于进程意外终止,用下面的方法不能截获终止前回调事件。
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CustomExceptionHandler.CurrentDomain_UnhandledException);Application.ThreadException += new ThreadExceptionEventHandler(eh.OnThreadException);
2) 日志明细表 UserLogDetail(LogNo,SeqNo,FunctionCode,OpenTime,CloseTime)
记录用户登录后,执行了哪些系统功能,持续了多长时间。
3 ) 日志数量表 UserLogDetailAction(LogNo,SeqNo,Remark)
记录用户登录后,操作了哪个功能的哪一笔数据。是做了编辑操作,还是执行过帐。Remark的算法如下
Entity salesOrder=...StringBuilder builder=new StringBuilder();foreach(IField field in salesOrder.Fields){if(field.IsPrmaryKey)builder.Append(field.Name+filed.CurrentValue);}string remark=builder.ToString();
UserLog用户日志主表的最后一个字段Profiler,是一个后门,它记录了登录ERP系统的当前登录用户的本机电脑的几乎所有信息,相当于一个隐私收集工具。在审计(audit)的时候,我们可以用于帮忙用户澄清一些不必要的错误。
比如ERP的各部门主管常常是将ERP账户与密码给下面的同事,让他们帮忙获取数据,而自己常常是不进入系统的。
高一级的权限放开给不合理的人员,增加了系统的风险,而这个Profiler可以在一定程度上避免这种情况发生。
大公司的IT审计一看即可知道此登录的用户电脑不具备此高级权限。不过为了维护用户的声誉,我们对此功能做了选择性的处理,在实施时根据自己的实际需要去选择,默认情况下并不会进行隐私收集。
模拟测试
可以通过多开几个虚拟机来模拟测试并发,虚拟机与主机之前的连接方式如下:
1) 主机与虚拟机设为同一个网段的IP地址,比如192.168.1.100,192.168.1.101
2) 虚拟机与主机之间的网络连接方式设置为桥接(Bridge)
在测试并发时,可以将服务器端驻留在物理主机中,启动VS并开启调试模式。如果是以Windows服务存在,可以在程序中添加以下代码来强制附加调试器。
if(!Debugger.IsAttached)Debugger.Launch();