摘要: 在使用 Microsoft Visual Studio .NET 2003 设计投票系统时,我们希望投票系统能够提供一些扩展功能,比如除了正确地完成投票的各个事务外,还能够将投票结果自动发送给投票发起人或管理员。本文讨论了投票结果自动发送功能的设计、实现,以及在此过程中需要注意的问题
关键字:Microsoft Visual Studio .NET 2003、Borland C++ Builder、Windows服务、投票系统、自动发送
一、 总体分析
首先来看看投票系统的工作场景,以便对设计与实现中的问题进行解答。假设现有一个使用Microsoft Visual Studio .NET 2003开发的基于Web页面的投票子系统,某公司某部门要对年终评优进行一次投票,每个员工在登录该投票子系统以后就可以进行投票。管理员在创建该投票项目时指定投票的标题和内容、可选项目、截止时间以及管理员的邮箱地址。在服务器时间到达管理员指定的投票项目截止时间后,员工将无法通过Web页面进行投票,同时服务器将投票结果以电子邮件的形式发送给管理员。
基于上述的工作场景,我们可以了解到,使用Web应用程序实现投票系统[1]的基本处理逻辑并不困难,但是要实现结果的自动发送功能,就不能只依靠Web应用程序。从投票系统的工作情况来看,处理新投票项目的添加、删除、修改,以及投票、通过页面查看投票结果等功能都可以直接交给B/S模式的Web应用程序来完成,客户端只要向Web服务器提出请求就可以完成相应的处理,在完成客户端的请求后,服务器会将处理结果反馈给客户端;然而投票结果的自动发送功能则不一样,它需要这样一种工作方式:长时间驻留内存,时刻监视着系统时间是否已经到达管理员所指定的投票项目截止时间,如果符合条件,就会自动地把该投票的结果发送到指定的邮箱地址。
显然,Web应用程序无法胜任这样的工作方式,我们选择Windows服务来实现这样的功能。Windows服务可以在计算机启动的时候自动启动,一直驻留后台执行,管理员还可以在必要的时候暂停或者停止Windows服务。MMC的服务管理单元为管理Windows服务提供了一个中心位置。
二、 详细分析与设计
为了描述的方便,在这里我们把设计分为两个部分:内部设计和外部设计。
如果把实现投票结果自动发送功能的Windows服务称为投票结果处理服务器,把用于实现投票系统基本逻辑的Web应用程序称为投票子系统,那么内部设计就是指投票结果处理服务器内部处理逻辑的设计,而外部设计的主要任务是完成对投票结果处理服务器和投票子系统之间的通讯设计。
1、 内部设计
我们应该很清楚地看到投票结果处理服务器内部的工作方式:在服务器启动的时候,会自动地将投票项目的信息读入内存,然后进入循环,每隔一定的时间(比如5秒)对读入的投票项目进行判断,如果投票项目过期,则统计投票结果,并将结果发送出去。
首先要解决的是数据存储问题,也就是数据结构问题。投票项目是以数据库记录的形式保存的,那么在读入内存以后,应该以一种什么样的数据结构来存储呢?解决这一问题需要从服务器的工作方式入手。显然,数据库中的投票项目不止一条,由于管理员在创建投票项目时对投票结果的处理方式有不同的选择(可以选择自动发送结果功能,也可以不选择此功能),这就决定了在多条投票项目中可能只有一部分需要对结果进行发送。此外,每一个投票项目记录有多个字段,比如投票项目的ID号、投票内容、截止时间等,这些字段的值构成了投票结果的实体,是所需发送信息的主要组成部分。我们可以考虑使用结构体数组来存储这些投票项目记录,比如:
typedef struct t_VoteItemInfo {
int id; // 投票项目的ID域
String Title; // 投票项目的标题域
String Name; // 投票项目的名称域
// . . .
TdateTime VoteDeadline; // 投票项目的截止时间域
} VoteItemInfo;
typedef VoteItemInfo VoteItemInfoArray[MAX_SIZE];
然而,我们不能确定某一时刻数据库中的投票项目记录有多少,也不能确定在所有的投票项目记录中,需要对结果进行发送的记录有多少,这就使得我们很难确定上面代码中的MAX_SIZE的值,如果MAX_SIZE的值定义得太小,可能会造成投票项目无法一次性读入内存,必要的时候还需要有专门的换入-换出算法来替换数组中的数据,这样实现起来非常麻烦;如果MAX_SIZE定义得太大,又可能会造成存储空间的浪费,投票结果处理服务器是一个常驻内存的Windows服务,大量空间的浪费会对服务器的性能造成影响。
从上面的分析可以看出,数组并不是存储投票项目记录的最好方法,在此我们引入队列,使用单向队列(也就是单向链表)来存储投票项目记录。C++的STL库为我们定义这样的数据结构提供了很好的机遇,下面的代码很容易地实现了这样的定义:
#include <queue>
using namespace std;
typedef struct t_vi {
int id; // 投票项目的ID域
String Title; // 投票项目的标题域
String Name; // 投票项目的名称域
// . . .
TdateTime VoteDeadline; // 投票项目的截止时间域
} VoteInfoNode;
typedef queue<VoteInfoNode> VoteInfoQueue;
其次,让我们来具体分析一下服务器内部对投票项目过期的判断方式。由于管理员在创建投票时所提供的信息和所作的选择需要保存,因此投票项目的截止时间(也可以称为过期时间)是以记录字段的形式保存在数据库中的。在服务器读入投票项目队列时,投票项目的截止时间就会成为队列节点的一个域而装入内存,这样,如果某一时刻服务器发现队列中存在一个已过期的时间,那么就可以很容易地确定与该过期时间相关联的其它投票项目信息,组织、生成并发送投票项目的结果也就变得非常容易。
假设某一时刻投票项目队列中有N个节点(N的值足够大),那么要判断具体是哪个节点已经过期就是一个费时的操作,比如我们可以遍历队列中的每个节点来进行判断,但这样做会占用一定的处理时间,因为需要循环(这种情况下算法复杂度为O(N))。另一方面,服务器程序需要时刻观察队列中的数据,以便及时发现过期的节点,这就决定了服务器程序对投票项目队列的遍历周期不能太长。现假设服务器程序每隔0.5秒对队列遍历一次,如果遍历队列的耗时超过0.5秒,那么就有可能无法找到过期的项目,因为在一次遍历还没有完成的情况下,服务器程序已经进入了下一次遍历周期。显然这里是存在矛盾的,一方面服务器需要时刻扫描投票项目队列,这决定了扫描过程不能太费时;另一方面如果节点数N足够大(比如大于1000000),这又决定了扫描过程会非常耗时。由于投票结果处理服务器是一个常驻内存的Windows服务,这就决定了服务器不能够占用太多的处理时间,否则会影响整个服务器甚至整个计算机系统的性能。
解决这样的矛盾需要从队列本身的属性出发。队列是FIFO的数据结构,如果节点D在T时刻过期,那么就可以对D进行信息发送处理,然后让D出队,这样做既可以节省空间,也可以节约处理的时间。更进一步,我们可以在构建投票项目队列之前先对所有的记录进行排序,把投票截止时间较小的项目排在前面,通过这种方式创建出来的队列具有这样的特性:最早过期的项目排在最前面。这一点是非常重要的,因为如果队列中的前导节点没有过期,那么后继节点是肯定没有过期的。
图一 前导节点没有过期,其后继节点肯定没有过期
采用了这样的处理方式以后,服务器程序只要定期地检查队列的首节点是否过期就可以完成处理:如果首节点过期,那么处理首节点,然后首节点出队,其后继节点成为首节点,并进入下一轮的判断(这种情况下算法复杂度为O(1))。
下面的伪代码详细地描述了这一过程:
//本函数每隔一定的时间间隔(timer interval)执行一次
void __fastcall ProcessTimer_OnTimer (TObject *sender)
{
if (队列不为空)
{
tmpNode = 队列.front(); // 获得队列头节点
if (tmpNode.过期时间 < 当前时间) // 该节点已经过期(被处理过)
{
队列.pop(); // 节点出队
return;
}
else if (tmpNode.过期时间 = 当前时间) // 恰好过期
{
将tmpNode的信息以邮件形式发送出去;
队列.pop(); // 节点出队
}
}
}
此外,由于投票项目信息都保存在数据库中,那么在构造投票项目队列之前对数据的排序可以使用SQL查询语句来实现,例如:
SELECT * FROM t_VoteItems ORDER BY VoteDeadline ASC
上面讲述了投票结果处理服务器两个核心部分的设计:数据结构和过期判断处理。现在来讨论一下服务器程序本身的特性与其相关的设计。
a、 日志
服务器程序是后台运行的,它无法将即时的信息显示在屏幕上,因此需要使用日志。日志是这样一个文件,它记录了服务器运行的整个过程和输出信息,以及信息的写入时间与状态,以便在服务器程序工作不正常的时候跟踪错误出现的时间和原因。当多个服务器程序共用同一个日志文件时,日志文件中必须记录某条信息具体是由哪个服务器写入的;如果服务器程序有各自独立的日志文件时,这样的信息可以省略。
a.1 日志文件的写入
通常使用形如“int WriteLog (int level, char *fmt, . . .)”的可变参函数来实现日志文件的写入。可变参函数主要通过va_list变量以及与其相关的va_start、vfprintf和va_end函数实现,具体内容可以参考MSDN相关文档。需要注意的是:① UNIX系统中可变参函数的实现与Windows系统略有不同;② 日志文件需要用“a+”(添加)的方式打开,否则有可能只写入一条最近产生的日志信息。
在编写服务器程序的时候,应该在合理的地方调用WriteLog函数以完成日志的写入,例如可以在try. . .catch块中使用WriteLog函数:
try
{
// . . .
}
catch (Exception &e)
{
WriteLog (LOGLEV_ERR, “错误信息=%s/n”, e.Message.c_str());
}
a.2 日志文件的管理
日志文件记录了服务器运行的整个细节,每运行一次服务器程序,就会有大量的信息写入日志文件,一段时间以后日志文件的大小有可能达到几十兆,甚至占用整个硬盘空间,造成服务器系统的崩溃。因此,在设计服务器程序的时候,应该根据服务器的使命等级来对日志文件进行相应的处理。例如,在日志文件中快速准确地定位错误信息以找到错误源,这对于使命关键的服务器系统是非常重要的,一般需要对日志文件进行备份,而这又可以通过设计额外的日志管理系统来实现;对于一般的服务器程序,在稳定性要求不是很高的情况下,我们可以选择直接重写日志文件的方式。在设计投票结果处理服务器时,我们选择后面这种日志处理方式。
日志文件的备份可以用单独的模块实现,也可以直接在WriteLog函数中实现。在写入日志之前,先判断日志文件的大小,如果大于规定的大小,则复制日志文件,并且采用“w”(覆盖写入)的方式打开日志文件并写入日志;否则直接使用“a+”(添加)的方式打开日志文件。
b、 配置文件
对于服务器程序而言,配置文件是非常重要的。配置文件中保存了很多服务器程序运行的参数,服务器程序在指定的时刻(一般是启动的时候)读取配置文件并应用相应的参数。在服务器系统中采用配置文件将使得服务器程序更具有灵活性,使得服务器程序能够更好地适应不同的需求环境。例如,如果将邮件服务器地址写入配置文件,那么在邮件服务器地址发生变更时,我们只需要修改配置文件并让服务器程序应用最新的设置,而不需要修改服务器程序代码。
Windows服务的配置文件一般使用INI的文件格式,这样使得读写配置文件变得很方便。在投票结果处理服务器的设计中,我们将读取配置文件参数的代码写在了服务启动(ServiceStart)的处理函数中,这样,每次服务器启动的时候都会应用最新的配置参数。同样,如果某时刻对配置文件进行了更改,那么要使服务器应用最新的配置参数,就必须重新启动服务。
c、 通讯
一般来讲,服务器程序需要和外部的机能进行通讯,以向该外部机能提供服务,这种外部机能通常称为客户端。进程间的通讯有多种方式,例如共享内存、管道、信号以及socket等。在投票系统中,我们使用socket通讯,以实现Web应用程序对服务器程序的信息通知。.NET Framework的System.Net.Sockets命名空间提供了用于socket通讯的类,这使得投票子系统和投票结果处理服务器之间的通讯成为可能。下面的C#代码实现了对投票结果处理服务器的信息通知:
using System.Net.Sockets;
using System.Text;
public class VotingNotifier
{
private int VotingNotifierServerPort = 9023;
private string VotingNotifierServerHostName = "127.0.0.1";
private string PLAIN_TEXT = "-=ByTeArTvOtInGnOtIfIeR=-";
public VotingNotifier()
{
//
// TODO: Add construction code here
//
}
public void SendNotifyMessage ()
{
try
{
TcpClient client = new TcpClient(VotingNotifierServerHostName, VotingNotifierServerPort);
NetworkStream ns = client.GetStream();
byte[] bytes = new byte[1024];
bytes = Encoding.ASCII.GetBytes (PLAIN_TEXT);
ns.Write (bytes, 0, bytes.Length);
client.Close();
}
catch (Exception)
{
return;
}
}
}
在投票结果处理服务器的外部设计中,我们将具体讨论为什么服务器需要这样的通讯功能。
2、 外部设计
外部设计主要讨论的是投票子系统与投票结果处理服务器间的通讯设计。我们需要解决的问题是,投票子系统为什么要与投票结果处理服务器通讯。
在服务器程序的内部设计中,我们讨论了数据结构和过期判断的问题,解决问题的方法就是使用有序队列。显然,有序队列中的数据并不是一次加载后就永远不变了,很可能在完成队列的构建后,数据库中的数据又发生了新的改变,因此,如果管理员使用投票子系统更改了投票项目数据(例如增加或删除),服务器程序也就应该相应地重建有序队列。由此可见,服务器程序需要在下列情况下重新构建有序队列:① 服务程序启动时,② 增加投票项目信息时,③ 删除投票项目信息时。对于后两种情况,服务器程序不可能知道数据库中的投票项目信息何时更改,综合服务器性能上的考虑,我们也不会在服务器程序中设计监视投票项目信息更改的专用模块,因此就需要由投票子系统来通知投票结果处理服务器。
图二 投票子系统与投票结果处理服务器的通讯
投票结果处理服务器中通讯功能的设计就非常简单了,Borland C++ Builder为设计socket通讯程序提供了强大的组件,具体的使用方法我们就不多讨论了,下面给出了实现该功能的伪代码:
void __fastcall ServerSocket_ClientRead(TObject *Sender,
TCustomWinSocket *Socket)
{
String txt;
// 获得接收到的文本
txt = Socket->ReceiveText();
// 如果获得的文本为PLAIN_TEXT
if (txt == PLAIN_TEXT)
{
重建有序队列;
}
}
需要说明的是,上面的PLAIN_TEXT是投票子系统与投票结果处理服务器间事先约定的通知信息字符串。
三、 实现
我们使用Borland C++ Builder 5来实现投票结果处理服务器。这部分内容包括了:使用Borland C++ Builder 5开发Windows服务程序和使用Borland C++ Builder 5实现电子邮件的发送。
1、 使用Borland C++ Builder 5开发Windows服务程序
Borland C++ Builder 5为开发Windows服务程序提供了方便。要使用Borland C++ Builder 5开发Windows服务程序,可以按下面的步骤进行:
l 启动Borland C++ Builder 5
l 在File菜单中选择New
l 在New Items对话框中选择New选项卡下的Service Application选项,然后单击确定
图三 Borland C++ Builder 5的New Items对话框
下面对TService类的主要属性和事件进行简要的介绍。
Ø DisplayName属性:用于在【服务】管理单元中显示服务的名称
Ø StartType属性:设置服务的启动方式
Ø Password属性:设置服务启动时的密码
Ø AfterInstall事件:在服务注册后调用该事件处理过程
Ø AfterUninstall事件:在移除服务后调用该事件处理过程
Ø BeforeInstall事件:在服务第一次注册前调用该事件处理过程
Ø BeforeUninstall事件:在移除服务前调用该事件处理过程
Ø OnContinue事件:在【服务】管理单元中暂停服务后,单击继续按钮使服务继续运行时,调用该事件处理过程
Ø OnCreate事件:在服务实例化数据模块时调用该事件处理过程,例如可以在此初始化数据库连接等
Ø OnDestroy事件:在服务释放数据模块时调用该事件处理过程
Ø OnPause事件:在【服务】管理单元中暂停服务时,调用该事件处理过程
Ø OnShutdown事件:在服务关闭前调用该事件处理过程
Ø OnStart事件:在【服务】管理单元中启动服务时,调用该事件处理过程
Ø OnStop事件:在【服务】管理单元中停止服务时,调用该事件处理过程
设计者应该根据不同的需求来设计以上这些事件,在投票结果处理服务器中,主要用到了OnContinue、OnPause、OnShutdown、OnStop等事件。
2、 使用Borland C++ Builder 5实现电子邮件的发送
Borland C++ Builder 5中提供了TNMSMTP组件,能够方便地实现电子邮件的发送功能。下面的代码演示了使用TNMSMTP实现电子邮件发送的方法,代码比较简单,作了详细的注释,因此不多作说明。
void __fastcall TForm1::Button1Click (TObject *sender)
{
TNMSMTP *smtp = new TNMSMTP (this);
smtp->Charset = “gb 2312” ; // 设置字符集
smtp->Host = “192.168. 100.200” ; // 邮件服务器地址
smtp->SubType = mtHtml; // 以HTML形式发送
// 指定发件人地址
smtp->PostMessageA->FromAddress = “[email protected]”;
smtp->PostMessageA->FromName = “acqy”; // 发件人姓名
// 指定收件人地址
smtp->PostMessageA->ToAddress->Add(“[email protected]”);
smtp->PostMessageA->Subject = “subject”; // 指定主题
// 指定邮件主体内容
smtp->PostMessageA->Body->Add(“<h1>HelloWorld!</h1>”);
try
{
smtp->Connect(); // 连接邮件服务器
smtp->SendMail(); // 发送邮件
smtp->Disconnect(); // 断开连接
}
__finally
{
delete smtp;
}
}
四、 总结
本文对投票系统中投票结果处理服务器的设计作了介绍,详细讲述了服务器程序所采用的数据结构、投票项目过期判断方法,以及设计服务器程序时所应考虑的事项。文章还对使用Borland C++ Builder 5实现Windows服务程序、socket通讯以及电子邮件的发送作了简要的介绍。需要提醒读者注意的是,和一般的应用程序不同,由于服务器程序是常驻内存的后台程序,因此在设计时应该对程序的整体性能以及可操作性作较多的考虑,否则很有可能导致服务器无法正常工作,甚至直接影响整个软件系统的运作。
[1]投票系统包括了投票子系统和投票结果处理服务器