2023年2月

做项目的时候,或多或少需要和其他外部系统或者接口进行数据交互,有些是单向的获取,有些可能是修改状态后再写回去,不管如何,这个都可以称之为数据同步操作,如人员信息同步、业务数据同步、第三方接口数据同步等等。

数据同步涉及到一个同步时间的问题,一般不敏感的数据,一天或者一周左右同步一次就可以了,有些可能需要间隔更短一点。

同步的逻辑不同,有些可能写数据库就可以了,有些可能需要访问WebService或者其他接口,然后在进行数据获取,保存等操作,回写的时候,也一般是调用WebService这样的接口修改数据。

每个同步实现我们都需要做大量重复性工作,如Windows服务安装、卸载、或者基础性的工作,有没有一种方式可以隔离业务逻辑和常用的东西呢?

1、通用定时服务管理模块设计

这样的同步操作看似没有很多必然的关联性,但是,这些都是很常见的东西,如果以插件架构方式来组织各个不同的业务封装,通过参数配置实现同步间隔不同,核心的同步模块其实是很多类似的东西,而同步一般通过Windows访问进行,这样通用的我们可以把它封装成一个通用的Windows服务。再辅以一个界面管理模块来管理服务的安装、卸载、启动、停止、测试等操作就可以了,整个Window服务的插件设计框架如下所示。

Windows定时服务-文件视图如下所示:

以上架构,有几个特点,

1、 基于插件结构,扩展容易。

2、 一个定时服务【通用的WIndows服务模块】,可以同时运行很多个不同的定时规则的定时服务应用。

3、 一个通用的服务管理界面【Windows定时服务管理】,来对定时服务的安装、卸载、启动、停止、测试等操作,并且可以对插件进行可视化配置。

4、【通用的WIndows服务模块】提供参数化安装,卸载、测试的功能。

5、【Windows定时服务管理】提供DOS测试和WInform进度测试的集成。

6、 插件参数化配置,提供插件各种参数的配置,统一调度。

6、插件通过反射加载不同的定时服务应用,实现松耦合和强类型接口的转换,确保弹性化和安全性。

7、整个定时服务管理模块,可以在不同的场合下实现重用,每次只是定时服务应用的不同而已。

8、定时时间设置,提供多样化的设置,可以在间隔时间、每天整点运行、每月指定日期时间运行多种方式。

整个定时服务管理通用模块,可以最大化的实现工具重用、逻辑重用,不管业务场景如何变化,基本上不需要调整了,只需要把定时服务应用ABC模块开发好,参数配置好,丢进去就可以了。

2、定时服务控制台【Windows定时服务管理】的界面设计

开发好的【Windows定时服务管理】界面如下所示,运行后,会把XML文档里面的插件加载在下面的列表中,供查看和修改操作。

插件的XML配置信息如下所示。

<?xml version="1.0"?>
<ArrayOfPlugInSetting>
  <PlugInSetting>
    <!--插件程序名称-->
    <Name>测试名称</Name>
    <!--插件描述内容-->
    <Description>测试描述</Description>
    <!--运行同步服务的间隔时间(单位:分钟)-->
    <ServiceCycleMinutes>1</ServiceCycleMinutes>
    <!--Windows服务在固定时刻(0~23时刻)运行-->
    <ServiceRunAtHour>23</ServiceRunAtHour>
    <!--Windows服务在每月指定天运行,小时按ServiceRunAtHour的值-->
    <ServiceRunAtDay>1</ServiceRunAtDay>
    <!--运行模式,0为间隔分钟运行  1为固定时刻运行, 2为按月某天和小时  其他值为禁用-->
    <RunMode>0</RunMode>
    <!--插件的类型名称:插件类名,程序集名称-->
    <PlugInTypeName>WHC.PlugInService.ClassName,WHC.PlugInService</PlugInTypeName>
  </PlugInSetting>
</ArrayOfPlugInSetting>

单击安装服务,控制台程序调用DOS命令+参数来实现通用定时WIndows服务的安装。

安装后,系统的Windows服务列表中就会增加一个【定时服务】的服务模块了,这样就证明我们顺利安装了通用定时服务了。

定时服务控制台的状态也会同时刷新,并且把服务的状态和类型显示在【服务状态】里面,这个时候,可以对服务进行卸载、测试、停止服务、重新启动、刷新状态等相关操作了。

如果对于很多定时服务应用,每种需要进行动态的禁用或者设置定时方式,那么可以在列表上右键进行相关的操作。

3、定时应用测试及界面集成

在开发过程中,发现经常性的需要调试我们自己的定时应用ABC是否正确生成,可以通过【DOS测试】和【进度测试】这两个按钮进行逻辑测试,这个没有触发Windows的情况下进行调用,可以看到具体的效果如下所示。

一般来说,提供以上DOS窗口来进行跟踪调试就可以了,但是有时候,我们想在Winform程序中调用立即同步的操作的时候,可以使用进度测试的逻辑代码进行处理,这样可以在主界面中显示进度。

由于是插件架构,因此在和Winform集成的时候,其实是和这个定时服务控制台一样,我们在我们的Winform程序中,加载应用插件,进行调用就可以了,调用代码如下所示。

        private void btnTestProgress_Click(object sender, EventArgs e)
        {
            try
            {                
                this.lblTips.Visible = true;
                this.progressBar1.Visible = true;

                //Winform进度条指示测试
                foreach (string key in pluginList.Keys)
                {
                    ITimingPlugIn obj = pluginList[key];
                    if (obj != null)
                    {
                        obj.ProgressChanged += new ProgressChangedEventHandler(TimingPlugIn_ProgressChanged);
                        obj.Excute();

                        LogTextHelper.Info(string.Format("插件【{0}】运行一次", key));
                    }
                }

                MessageUtil.ShowTips("操作完成");
            }
            finally
            {
                lblTips.Text = "";
                this.lblTips.Visible = false;
                this.progressBar1.Visible = false;
            }
        }

        void TimingPlugIn_ProgressChanged(object sender, ProgressChangedEventArgs e)
        {
            //ITimingPlugIn obj = sender as ITimingPlugIn;
            progressBar1.Value = e.ProgressPercentage;
            if (e.UserState != null)
            {
                lblTips.Text = e.UserState.ToString();
            }

            Thread.Sleep(100);
            Application.DoEvents();
        }

调用进度测试的界面如下所示,它可以把各种同步详细情况显示在主界面上。

对于整个模块的运行操作,我们通过日志进行记录,这样可以详细看到具体的操作了。

在上篇随笔《
Winform开发框架之通用定时服务管理
》介绍了我的框架体系中,通用定时服务管理模块的设计以及一些相关功能的展示。我们在做项目的时候,或多或少需要和其他外部系统或者接口进行数据交互,有些是单向的获取,有些是双向的操作。这个定时操作(可能是间隔的时间,也可以能是定在某一个时刻,也可以能是让它在某天某时刻运行),那么这就需要定时服务程序来管理了,通常我们把他寄宿在Windows服务里面(这也是一种最佳的方式),这种方式最好的地方,就是它的生命周期可以随着电脑的启动而启动,而且很少需要用户干预。

1、通用定时服务管理模块框架设计

首先我们回顾一下上面文章对通用定时服务管理模块的设计思路。

整个定时服务的插件设计框架如下所示。

Windows定时服务-文件视图如下所示:

2、如何进行定时服务应用的开发

虽然看起来还是有那么几个文件,其实由于是整个框架是基于插件式的架构,因此但我们开发定时服务应用的时候(如最底下的有黑框的部分),只需要引用插件模块WHC.MyTimingPlugIn.dll并实现ITimingPlugIn接口即可,如上面的
WarningService.dll
就是一个典型的例子。

这个
WarningService
项目就是一个很好的测试例子,它只有一个类,类实现接口ITimingPlugIn。

    public classTestService : ITimingPlugIn
{
#region ITimingPlugIn 成员 /// <summary> ///操作进度状态事件/// </summary> public eventProgressChangedEventHandler ProgressChanged;public boolExcute()
{
Thread.Sleep(
1000);if (ProgressChanged != null)
{
ProgressChanged(
this, new ProgressChangedEventArgs(20, "操作进行中...20%"));
}
//实际工作处理1 Thread.Sleep(1000);if (ProgressChanged != null)
{
ProgressChanged(
this, new ProgressChangedEventArgs(50, "操作进行中...50%"));
}
//实际工作处理2 Thread.Sleep(1000);if (ProgressChanged != null)
{
ProgressChanged(
this, new ProgressChangedEventArgs(80, "操作进行中...80%"));
}
//实际工作处理3:输出内容,作为处理的记录 LogTextHelper.WriteLine(string.Format("{0}......{1}", this.Name, DateTime.Now));
Thread.Sleep(
1000);if (ProgressChanged != null)
{
ProgressChanged(
this, new ProgressChangedEventArgs(100, "操作完成"));
}
return true;
}
/// <summary> ///插件程序名称/// </summary> public stringName
{
get;set;
}
/// <summary> ///插件详细配置/// </summary> publicPlugInSetting Setting
{
get;set;
}
#endregion}

开发编译通过后,我们需要为该定时服务应用,在插件XML配置上增加一个这样的说明,让服务程序能够正常加载并识别。

<?xml version="1.0"?>
<ArrayOfPlugInSetting>
  <PlugInSetting>
    <!--插件程序名称-->
    <Name>测试名称</Name>
    <!--插件描述内容-->
    <Description>测试描述</Description>
    <!--运行同步服务的间隔时间(单位:分钟)-->
    <ServiceCycleMinutes>1</ServiceCycleMinutes>
    <!--Windows服务在固定时刻(0~23时刻)运行-->
    <ServiceRunAtHour>23</ServiceRunAtHour>
    <!--Windows服务在每月指定天运行,小时按ServiceRunAtHour的值-->
    <ServiceRunAtDay>1</ServiceRunAtDay>
    <!--运行模式,0为间隔分钟运行  1为固定时刻运行, 2为按月某天和小时  其他值为禁用-->
    <RunMode>0</RunMode>
    <!--插件的类型名称:插件类名,程序集名称-->
    <PlugInTypeName>WarningServcie.TestService,WarningServcie</PlugInTypeName>
  </PlugInSetting>
</ArrayOfPlugInSetting>

上面工作完成后,在正式部署到服务器正式环境前,我们需要检查开发模块是否正常执行,是否正常运行里面的逻辑。那就用到管理程序了。

运行这个程序,除了它具有安装服务、卸载服务、启动、停止、重新启动、刷新服务等这些服务操作外,还提供了DOS测试的功能,让我们在没有部署到Windows服务前,直接进行逻辑测试,很方便的哦。

运行,我们可以看到DOS窗口的输出内容了(我们在实现内部调用的

if (ProgressChanged != null)
{
ProgressChanged(this, new ProgressChangedEventArgs(20, "操作进行中...20%"));
}

就是为了在DOS或者其他通知界面上显示日志信息的。

当然,为了验证处理逻辑,刚才我们也在内部做了记录,我们查看Log/Log.txt日志文件后,我们看到输出的结果如下所示。我们通过日志进行记录,这样可以详细看到具体的操作了。

以上就是一个简单测试应用的实现和测试过程,其他的定时应用服务也是这样实现插件接口就可以部署了。界面管理、服务管理、插件管理这些都是通用模块负责的工作,不需要变动和修改。

3、基于实际WebService同步程序的开发

上面介绍的是一个简单的例子,在我们实际开发过程中,比这个例子肯定负责很多,不过过程是一样的,下面我们来看看我一个实际定时业务应用的具体开发吧。

和上面例子一样,添加一个项目,应用插件模块dll,然后实现接口的内容,不同的是这里引用了一个WebService进行操作,因为需要把Webservice里面的数据保存到本地数据库里面(也就是所谓的同步接口)。

以上的就是一个定时服务应用的类,它实现ITimingPlugIn接口,关键的部分就是如何实现Execute接口了。

由于Webservice接口返回的数据字段比较多,有些可能不一定是我们需要存储的,在实际中发现,有些如PK字段,还有一些以Specified结尾的字段,好像是自动增加上去的。这些我们可能都不需要写到数据库里面去,而且以后WebService可能会增加一些字段,所以得到下面的结论。

1)不能以反射WebService实体类作为基准字段存储。

2)考虑尽可能通用的保存数据,最好字段不用每次都硬编码到代码,因为很多内容的。

3)可以通过获取数据库表的字段作为基准,如果webService实体类也存在该字段,作为写入依据。

从上面的几个经验总结来看,我们需要写一个以数据库表为基准,编写一个通用的服务保存机制函数,来看看如何实现的。

首先通过WebService定义,创建好需要写入的数据库字段(字段必须和WebService同名哦)

然后引入一个辅助类,来获取数据库表字段的信息列表。

    /// <summary>
    ///通过获取表的元数据方式获取字段信息的辅助类/// </summary>
    public classTableSchemaHelper
{
/// <summary> ///获取指定表的元数据,包括字段名称、类型等等/// </summary> /// <param name="tableName">数据库表名</param> /// <returns></returns> private static DataTable GetReaderSchema(stringtableName)
{
DataTable schemaTable
= null;string sql = string.Format("Select * FROM {0}", tableName);
Database db
=DatabaseFactory.CreateDatabase();
DbCommand command
=db.GetSqlStringCommand(sql);try{using (IDataReader reader =db.ExecuteReader(command))
{
schemaTable
=reader.GetSchemaTable();
}
}
catch(Exception ex)
{
LogTextHelper.Error(ex);
}
returnschemaTable;
}
/// <summary> ///获取指定数据表字段/// </summary> /// <param name="tableName">数据库表名</param> /// <returns></returns> public static List<string> GetColumns(stringtableName)
{
DataTable schemaTable
=GetReaderSchema(tableName);

List
<string> list = new List<string>();foreach (DataRow dr inschemaTable.Rows)
{
string columnName = dr["ColumnName"].ToString();
list.Add(columnName);
}
returnlist;
}
/// <summary> ///获取指定数据表字段,并转化为小写列表/// </summary> /// <param name="tableName">数据库表名</param> /// <returns></returns> public static List<string> GetColumnsLower(stringtableName)
{
DataTable schemaTable
=GetReaderSchema(tableName);

List
<string> list = new List<string>();foreach (DataRow dr inschemaTable.Rows)
{
string columnName = dr["ColumnName"].ToString();
list.Add(columnName.ToLower());
}
returnlist;
}

具体的接口实现函数如下所示。

public boolExcute()
{
ShareAccService service
= newShareAccService();var list = service.getAccList4Deal(unitLogo, undertaker_c, 1, 10);int count =list.Length;int i = 1;foreach (shareEmitfileSimpleVO obj inlist)
{
int step = Convert.ToInt32(100 * Convert.ToDouble(i) /list.Length);//保存列表信息 SaveToDatabase(obj, "MAYOR_EMIT_LIST", "ACCID", obj.accid, step);//获取并保存明细信息 shareAccVO accVo =service.getDetailAcc4Deal(unitLogo, undertaker_c, obj.accid, obj.emitid);
SaveToDatabase(accVo,
"MAYOR_EMIT_DETAIL", "ACCID", accVo.accid, step);//保存明细信息里面的复函信息 if (accVo.shareEmitfileVOs != null)
{
foreach (shareEmitfileVO fileVo inaccVo.shareEmitfileVOs)
{
SaveToDatabase(accVo,
"MAYOR_EMIT_ANSWER", "id", fileVo.id, step);
}
}
//保存附件列表信息 List<string> attachList = new List<string>();if (accVo.shareAttachmentVOs != null)
{
foreach (shareAttachmentVO attach inaccVo.shareAttachmentVOs)
{
attachList.Add(attach.id);
SaveToDatabase(attach,
"MAYOR_EMIT_FILE", "id", attach.id, step);
}
}
i
++;

}
return true;
}

我们注意到,关键的逻辑其实就是看SaveToDatabase如何实现通用的数据库保存

首先,我们需要通过反射方法看看具体有哪些实体类字段属性。

PropertyInfo[] proList = ReflectionUtil.GetProperties(obj);

然后看看数据库字段列表

List<string> columnList = TableSchemaHelper.GetColumnsLower(targetTable);

获得这两个信息,我们就可以对他们进行比较,然后确定哪些数据写入数据库里面(安全的写入数据库)。

                PropertyInfo[] proList =ReflectionUtil.GetProperties(obj);
Dictionary
<string, object> recordField = new Dictionary<string, object>();try{//为了避免接口变化以及WebService对象的一些额外字段干扰,以数据库字段为准 #region 组装字段语句List<string> columnList =TableSchemaHelper.GetColumnsLower(targetTable);foreach (PropertyInfo info inproList)
{
if (!recordField.ContainsKey(info.Name) &&columnList.Contains(info.Name.ToLower()))
{
recordField.Add(info.Name, ReflectionUtil.GetProperty(obj, info.Name));
}
}

其中recordField就是记录了,我们需要写入数据库的字段名称和值,这样我们就方便构建相应的操作语句和参数值了。

参数化语句构建如下所示:

string fields = ""; //字段名
                    string vals = ""; //字段值
                    foreach (string field inrecordField.Keys)
{
fields
+= string.Format("{0},", field);
vals
+= string.Format(":{0},", field);
}
fields
= fields.Trim(',');//除去前后的逗号 vals = vals.Trim(',');//除去前后的逗号 string sql = string.Format("INSERT INTO {0} ({1}) VALUES ({2})", targetTable, fields, vals);#endregion

当然,我们写入前,为了避免重复同步,因此需要对以存在的记录进行判断,重复的跳过。

string existSql = string.Format("Select Count(*) from {0} WHERE {1}='{2}'", targetTable, primaryKey, keyValue);
Database db
=DatabaseFactory.CreateDatabase();
DbCommand command
=db.GetSqlStringCommand(existSql);bool hasExist = Convert.ToInt32(db.ExecuteScalar(command)) > 0;if (!hasExist)
{

在数据库不存在的记录要添加到数据库里面,如下代码所示。

                        //不存在则要插入
                        command =db.GetSqlStringCommand(sql);foreach (string field inrecordField.Keys)
{
object val =recordField[field];
val
= val ??DBNull.Value;if (val isDateTime)
{
if (Convert.ToDateTime(val) <= Convert.ToDateTime("1753-1-1"))
{
val
=DBNull.Value;
}
}

db.AddInParameter(command, field, TypeToDbType(val.GetType()), val);
}
bool result = db.ExecuteNonQuery(command) > 0;if(result)
{
string tips = string.Format("操作表【{0}】的记录成功,{1}={2}", targetTable, primaryKey, keyValue);if (ProgressChanged != null)
{
ProgressChanged(
this, newProgressChangedEventArgs(step, tips));
}
}

完整的SaveToDatabase函数如下所示(为了他人方便,也方便自己日后使用。呵呵...):

        /// <summary>
        ///保存指定的记录对象到数据库/// </summary>
        private void SaveToDatabase(object obj, string targetTable, string primaryKey, string keyValue, intstep)
{
if (string.IsNullOrEmpty(targetTable) || string.IsNullOrEmpty(primaryKey) || string.IsNullOrEmpty(keyValue))return;if (ProgressChanged != null)
{
ProgressChanged(
this, new ProgressChangedEventArgs(step, string.Format("正在处理表【{0}】{1} = {2} 的记录。", targetTable, primaryKey, keyValue)));
}
if (obj != null)
{
PropertyInfo[] proList
=ReflectionUtil.GetProperties(obj);
Dictionary
<string, object> recordField = new Dictionary<string, object>();try{//为了避免接口变化以及WebService对象的一些额外字段干扰,以数据库字段为准 #region 组装字段语句List<string> columnList =TableSchemaHelper.GetColumnsLower(targetTable);foreach (PropertyInfo info inproList)
{
if (!recordField.ContainsKey(info.Name) &&columnList.Contains(info.Name.ToLower()))
{
recordField.Add(info.Name, ReflectionUtil.GetProperty(obj, info.Name));
}
}
string fields = ""; //字段名 string vals = ""; //字段值 foreach (string field inrecordField.Keys)
{
fields
+= string.Format("{0},", field);
vals
+= string.Format(":{0},", field);
}
fields
= fields.Trim(',');//除去前后的逗号 vals = vals.Trim(',');//除去前后的逗号 string sql = string.Format("INSERT INTO {0} ({1}) VALUES ({2})", targetTable, fields, vals);#endregion #region 判断指定键值的记录是否存在并写入新的 string existSql = string.Format("Select Count(*) from {0} WHERE {1}='{2}'", targetTable, primaryKey, keyValue);
Database db
=DatabaseFactory.CreateDatabase();
DbCommand command
=db.GetSqlStringCommand(existSql);bool hasExist = Convert.ToInt32(db.ExecuteScalar(command)) > 0;if (!hasExist)
{
//不存在则要插入 command =db.GetSqlStringCommand(sql);foreach (string field inrecordField.Keys)
{
object val =recordField[field];
val
= val ??DBNull.Value;if (val isDateTime)
{
if (Convert.ToDateTime(val) <= Convert.ToDateTime("1753-1-1"))
{
val
=DBNull.Value;
}
}

db.AddInParameter(command, field, TypeToDbType(val.GetType()), val);
}
bool result = db.ExecuteNonQuery(command) > 0;if(result)
{
string tips = string.Format("操作表【{0}】的记录成功,{1}={2}", targetTable, primaryKey, keyValue);if (ProgressChanged != null)
{
ProgressChanged(
this, newProgressChangedEventArgs(step, tips));
}
}
}
#endregion}catch(Exception ex)
{
string tips = string.Format("操作表【{0}】的记录出错,{1}={2}", targetTable, primaryKey, keyValue);if (ProgressChanged != null)
{
ProgressChanged(
this, newProgressChangedEventArgs(step, tips));
}
LogTextHelper.Error(tips);
string description = "";foreach (string field inrecordField.Keys)
{
description
+= string.Format("{0}={1},", field, recordField[field]);
}
LogTextHelper.Info(description);

LogTextHelper.Error(ex);
}
}
}

开发完成后,我们在添加XML插件配置信息,如下所示

<?xml version="1.0"?>
<ArrayOfPlugInSetting>
  <PlugInSetting>
    <!--插件程序名称-->
    <Name>测试名称</Name>
    <!--插件描述内容-->
    <Description>测试描述</Description>
    <!--运行同步服务的间隔时间(单位:分钟)-->
    <ServiceCycleMinutes>1</ServiceCycleMinutes>
    <!--Windows服务在固定时刻(0~23时刻)运行-->
    <ServiceRunAtHour>23</ServiceRunAtHour>
    <!--Windows服务在每月指定天运行,小时按ServiceRunAtHour的值-->
    <ServiceRunAtDay>1</ServiceRunAtDay>
    <!--运行模式,0为间隔分钟运行  1为固定时刻运行, 2为按月某天和小时  其他值为禁用-->
    <RunMode>0</RunMode>
    <!--插件的类型名称:插件类名,程序集名称-->
    <PlugInTypeName>WarningServcie.TestService,WarningServcie</PlugInTypeName>
  </PlugInSetting>
  
<PlugInSetting>
<!--插件程序名称-->
<Name>热线数据同步</Name>
<!--插件描述内容-->
<Description>测试描述</Description>
<!--运行同步服务的间隔时间(单位:分钟)-->
<ServiceCycleMinutes>1</ServiceCycleMinutes>
<!--Windows服务在固定时刻(0~23时刻)运行-->
<ServiceRunAtHour>23</ServiceRunAtHour>
<!--Windows服务在每月指定天运行,小时按ServiceRunAtHour的值-->
<ServiceRunAtDay>1</ServiceRunAtDay>
<!--运行模式,0为间隔分钟运行 1为固定时刻运行, 2为按月某天和小时 其他值为禁用-->
<RunMode>1</RunMode>
<!--插件的类型名称:插件类名,程序集名称-->
<PlugInTypeName>MayorHotlineService.DownDataService,MayorHotlineService</PlugInTypeName>
</PlugInSetting>
</ArrayOfPlugInSetting>

这样开发完成后,我们需要对服务进行一次测试,确认逻辑正常。

最后利用管理界面的
安装服务
,把它部署上去就可以了,这样它就以Windows服务的形式进行运行,不用再进行干预了。

由于一个朋友的要求,需要在晚会上做一个抽奖的软件,来随即抽取录入的号码进行抽奖,于是参考了一下别人做的抽奖程序,然后抽时间做了一个这样的软件,应该总体还是符合实际要求了,这样的程序麻雀虽小,五脏俱全的,还确实有不少细节的地方。

一般为了迎合喜庆的年会气氛,界面一般是大红大紫,这个难度不大,如下所示。

由于是抽奖活动,一般就要求尽可能操作简单了,通过回车键来启动或者停止随机抽奖的过程,另外,为了方便切换各个奖项(如特等奖、一等奖、二等奖等等),就设置了数字键进行切换,0为特等奖,1为一等奖,2为二等奖,如此类推,可以设置到9为九等奖,一般很少有更多的了,呵呵。

为了使得抽奖的记录得以记录下来,这里软件采用了Sqlite数据库进行数据记录,因此利用我的Winfrom开发框架的模式,对数据库的记录进行代码生成,然后集成到整套的东西,就形成了该软件了。

为了抽奖,我们需要先建立一个抽奖列表,本功能也是通过数据保存到记录里面,然后获取数据库记录里面的记录进行抽奖,先看设置抽奖号码的功能界面如下所示。

保存后记录就是抽奖号码池了,号码随机从中抽取,非常公平了,这社会公平第一。生成的号码,还可以把号码关联到员工姓名上去,如下所示。

奖项抽奖记录如下所示。

全部记录则通过树形列表进行分类,如下所示。

为了有效管理奖品的发放,还可以对抽奖记录进行奖品发放和姓名登记管理,如下所示(期望今年能够抽奖大中。。。^_^)

软件没有用到很复杂的技术,不过细节的地方确实需要花费不少时间来进行雕琢,希望对大家有用,如果需要晚会上使用,一定记得联系我哦。

喜欢体验,玩一下的,从网址下载软件进行测试。
http://www.iqidi.com/download/LuckyDrawSetup.rar

希望给你带来好运,谢谢支持。

在做Winform项目的时候,一直有一个梦想,就是希望把所有的组件模块组合即可组装成一个完整的项目系统(或者至少可以大部分完成)。在之前介绍的《
Winform开发框架之通用附件管理模块
》里面介绍了我的Winform开发框架的版图,里面包含了我对Winform模块化的一系列规划的组件,组件尽可能是适用于大多数的业务环境组合,以达到最大程度的重用和高效开发。

Winform开发框架是我集多年开发经验以及积累而成,很多细节之处润物细无声,但却是精粹心得所至,很多地方都希望是精益求精,力求把框架中的模块当成一把把神兵利器,用到的时候,马上就可以派生用场解决问题,这样可以避免给客户开发业务的时候,延误战机,或者因为事无巨细,都要从头来过,效率大打折扣,而且时间和金钱的大投入也未见得取得好的效果。

上两篇介绍了WInform框架中的通用定时服务管理模块,《
Winform开发框架之通用定时服务管理
》、《
Winform开发框架之通用定时服务管理2---如何开发定时服务应用
》,主要介绍主要在常规数据同步的时候,如何快速利用定时服务管理的通用模块,加上插件化的一些同步业务实现模块,实现强大、高效、松耦合的定时服务管理。本篇继续介绍Winform开发框架中的版图部分,
通用短信邮件通知模块

开发这个模块的初衷是我在开发过的很多项目中,发现或多或少,都有短信通知、邮件通知、站内通知信息的需求,短信和邮件用的很多,也是大多数业务系统重复建设的模块之一,如单位办理业务要通知申请人,公司和客户沟通交流,公司内部员工沟通,这些可能是通过短信、邮件或者站内信息(或叫系统内部的通知信息)。如果这些模块能够快速搭建的话,对项目组来说,节省的时间,提高的是效率。对单位企业来说,这是高质量代码的保证,重复利用更可也减少重复的人力投资和成本支出。

1、模块设计

首先我们知道,短信、邮件、站内信息这几个业务的发送信息的时候,我们都希望记录相关的发送例子,其中短信实现发送的机制比较多,可能通过自己企业内部的短信MAS代理机来发送,也可能是通过购买的外部的WebService服务发送,因此通过插件模块的机制,实现短信发送逻辑的动态加载;邮件发送则利用.NET的邮件发送API,加上企业或者个人邮件发送参数即可实现,也是通过插件方式实现发送逻辑的动态加载;站内信息主要就是数据库记录,然后定时检测是否有记录即可,相对比较简单。

短信、邮件、站内信息的数据库设计,如下图所示,他们三者之间共用一个模板表,通过不同的TemplateType来识别不同的信息模板,这样信息模板表可以为短信、邮件、站内信息做更好的引用。

这里我们可以看到,我主要就是设计几个表来存储他们的发送记录,对于具体的短信发送,邮件发送,我们采用插件方式实现具体发送逻辑的封装,如下所示。

其中邮件发送接口和短信发送接口定义如下,方便实现基于插件方式的发送逻辑模块。

    public interfaceIMailSend
{
/// <summary> ///发送外部邮件(自定义邮件配置,如个人邮件)/// </summary> /// <param name="mailInfo">发送邮件信息</param> /// <param name="settingInfo">SMTP协议设置信息</param> /// <returns></returns> CommonResult Send(MailInfo mailInfo, SmtpSettingInfo settingInfo);/// <summary> ///发送外部邮件(系统配置,系统邮件)/// </summary> /// <param name="mailInfo">发送邮件信息</param> /// <returns></returns> CommonResult Send(MailInfo mailInfo);
}
public interfaceISmsSend
{
/// <summary> ///发送短信/// </summary> /// <param name="content">短信内容</param> /// <param name="mobiles">手机号码(多个号码用”,”分隔)</param> /// <param name="sendTime">预约发送时间</param> /// <returns></returns> CommonResult Send(string content, string mobiles, DateTime?sendTime);
}

2、软件使用界面

1)短信界面功能

具体的模块界面如下,输入信息发送后成功的界面如下所示。

发送信息后,手机收到的结果如下所示。

短信还可以使用带有通讯录的发送界面,以及模板功能等方便性功能操作,如下所示。

通讯录可以通过调用窗体函数进行数据绑定,这样在调用的时候,只需要按照要求填写相应的数据就可以显示个人专属的通讯录了,方便勾选手机进行发送短信,操作绑定代码如下所示。

        private void btnSendWithList_Click(objectsender, EventArgs e)
{
FrmSendSMSWithList dlg
= newFrmSendSMSWithList();
Dictionary
<string, List<ContactInfo>> dict = new Dictionary<string,List<ContactInfo>>();
List
<ContactInfo> list = new List<ContactInfo>();string category = "个人通讯录";
list.Add(
new ContactInfo(category, "001", "伍华聪", "18620292076", "wuhuacong@163.com"));
list.Add(
new ContactInfo(category, "002", "张三", "18620292077"));
list.Add(
new ContactInfo(category, "003", "李四", "18620292078"));
dict.Add(category, list);

dlg.BindTreeData(dict);
dlg.ShowDialog();
}

2)邮件发送界面

可以把邮件保存为模板,方便第二次引用,也可以从模板中选取已有的邮件内容进行发送,邮件模板界面如下所示。

邮件发送成功后,会在邮件发送历史中留下发送的内容记录,如下界面所示。

邮件发送成功的同时,我们可以看到腾讯的QQ邮件提示信息,如下所示。

进一步查看邮件的信息,可以看到邮件里面的内容如下所示,由于采用了RichEditControl控件编辑数据,因此嵌入到编辑器里面的图片(本地插入或者动态截图),都可以进行正常发送,利用这个特点,可以编辑图文并茂的邮件信息,而且RichEditControl控件支持直接从Word文档中直接加载等功能,该控件的使用,我会另外开一篇随笔进行介绍。

另外,和短信发送一样,也可以通过动态绑定用户的通讯录,然后发送的时候,选定即可,如下所示。

3)站内信息使用界面

站内信息,可以用于系统内部用户的信息交流,业务信息通知等用途,用户登录系统后,就可以看到系统内部用户发给自己的通知信息。

这个功能没有调用其他外部的插件模块进行处理,直接就是把数据存储到数据库,然后对方定时获取到通知后进行提示或者查看,界面如下所示。

3、通用短信邮件通知模块控件的调用

通用短信邮件通知模块已经对这几类业务进行较好的封装了,包括Winform界面部分的处理,也已经进行了较好的设计和封装,因此和我的权限管理模块、字典管理模块一样,非常方便使用的。

下面为了介绍具体三个模块的使用,我使用了一个测试界面作为演示,如下所示。

各个按钮的实现代码如下所示,从中我们可以看到具体的模块调用了。

        /// <summary>
        ///短信发送模块调用/// </summary>
        private void btnSendSMS_Click(objectsender, EventArgs e)
{
FrmSendSMS dlg
= newFrmSendSMS();
dlg.ShowDialog();
}
/// <summary> ///带通讯录的短信发送模块调用/// </summary> private void btnSendWithList_Click(objectsender, EventArgs e)
{
FrmSendSMSWithList dlg
= newFrmSendSMSWithList();
Dictionary
<string, List<ContactInfo>> dict = new Dictionary<string,List<ContactInfo>>();
List
<ContactInfo> list = new List<ContactInfo>();string category = "个人通讯录";
list.Add(
new ContactInfo(category, "001", "伍华聪", "18620292076", "wuhuacong@163.com"));
list.Add(
new ContactInfo(category, "002", "张三", "18620292077"));
list.Add(
new ContactInfo(category, "003", "李四", "18620292078"));
dict.Add(category, list);

dlg.BindTreeData(dict);
dlg.ShowDialog();
}
/// <summary> ///短信发送历史/// </summary> private void btnSMSHistory_Click(objectsender, EventArgs e)
{
FrmSMSHistory dlg
= newFrmSMSHistory();
dlg.ShowDialog();
}
/// <summary> ///邮件发送模块调用/// </summary> private void btnSendMail_Click(objectsender, EventArgs e)
{
FrmSendMail dlg
= newFrmSendMail();
dlg.Owner
= this;//记录用来隐藏 dlg.Show();
}
/// <summary> ///带通讯录的邮件发送模块调用/// </summary> private void btnSendEmailWithList_Click(objectsender, EventArgs e)
{
FrmSendMailWithList dlg
= newFrmSendMailWithList();
Dictionary
<string, List<ContactInfo>> dict = new Dictionary<string, List<ContactInfo>>();
List
<ContactInfo> list = new List<ContactInfo>();string category = "个人通讯录";
list.Add(
new ContactInfo(category, "001", "伍华聪", "18620292076", "wuhuacong@163.com"));
list.Add(
new ContactInfo(category, "002", "张三", "18620292077", "wuhuacong@hotmail.com"));
list.Add(
new ContactInfo(category, "003", "李四", "18620292078", "6966254@qq.com"));
dict.Add(category, list);

dlg.BindTreeData(dict);
dlg.ShowDialog();
}
/// <summary> ///邮件发送历史/// </summary> private void btnMailHistory_Click(objectsender, EventArgs e)
{
FrmMailHistory dlg
= newFrmMailHistory();
dlg.ShowDialog();
}
/// <summary> ///站内信息发送模块调用/// </summary> private void btnSendBroad_Click(objectsender, EventArgs e)
{
FrmSendBroad dlg
= newFrmSendBroad();
dlg.ShowDialog();
}
/// <summary> ///站内信息发送历史/// </summary> private void btnBroadHistory_Click(objectsender, EventArgs e)
{
FrmBroadHistory dlg
= newFrmBroadHistory();
dlg.ShowDialog();
}

4、短信邮件插件化开发及设置

我们从上图可以看到,里面对于邮件和短信,都定义了接口,用于具体模块的实现,自己可以实现这些接口,然后配置好exe.config里面的参数,就可以被调用了。

下面是我为短信和邮件接口开发了几个实现类,其中MyMailSend是发送邮件的实现类,其他几个是基于各种MAS短信接口和WebService接口的短信发送实现。

开发好后,在配置项里面设置一下插件的相关参数即可,如下所示的内容。

做Winform的,我们一般都知道,传统.NET界面有一个RichTextBox控件,这个是一个富文本控件,可以存储图片文字等内容,它有自己的文件格式RTF,在DevExpress控件组里面也有一个同等的控件,他的名字是RichEditControl,这个控件功能很强大,在我上一篇随笔《
Winform开发框架之通用短信邮件通知模块
》中,有介绍过利用它来做邮件编辑器,实现图文并茂的邮件的功能,如下所示。本文主要介绍如何一步步使用这个控件实现自己需要的功能和界面。

但是默认它没有任何工具栏,全部是需要自己添加上去。

1、如何创建带工具栏的RichEditControl控件

为了使得控件更加通用,我做了一个自定义控件,用来实现通用文本编辑器的功能,首先我们创建一个自定义控件,如下所示。

这样我们会看到一个空白的自定义控件界面,然后再往里面添加一个RichEditControl进去,设置Dock=Fill,让
RichEditControl
控件铺满整个自定义控件界面,如下所示。

设置器ActiveViewType=Simple,让控件显示的更紧凑一些。如下所示。

从上面我们看到,它默认是没有任何工具栏的,比较简单,那么我们要添加向上面邮件界面的功能,如何实现呢?很简单,选中RichEditControl,然后再右上角的三角符号上,单击可以看到有一些功能菜单,如下所示。

单击Create BarManager然后可以进一步看到更多的工具栏菜单了,如下所示。你可以悬着Create All Bar来创建所有工具栏,然后删除多余的就可以了。

这样就可以把所有的工具栏全部列出来了,很多很多。

但是一般我们不需要那么多,精简一些重要的功能即可,这些多余的最好删除,否则很凌乱。

这些功能按钮默认都已经带有事件的处理,就是不需要额外的代码就可以实现各种标准的功能了,这些很方便,类似DevExpress这方面做得很好,如打印预览控件也是一样,基本上不需要编写代码了,选择需要的功能,多余的删除即可,这样就可以精简到我本文开头的那个邮件编辑器界面了。

2、如何实现自定义的按钮功能

刚才说到,里面的按钮可以随意删除,也可以随意组合到一个工具栏里面,当然,这个控件的工具栏除了内置的按钮外,还可以增加自己的按钮和事件相应,这样就更加完美和强大了。

如上面的

按钮,就是我用来截图的一个功能,自定义的,内置的没有这样的功能,这样添加按钮及图片后,实现按钮的事件就可以了,和自己创建的按钮一样。

这个截图的功能,利用了我的共用类库里面的一个截图类,实现代码如下所示。

        ScreenCaptureWindow captureWindow = null;private void barCapture_ItemClick(objectsender, DevExpress.XtraBars.ItemClickEventArgs e)
{
if (this.ParentForm.Owner != null)
{
this.ParentForm.Owner.Hide();
}
this.ParentForm.Hide();if (captureWindow != null)
{
captureWindow.BitmapCropped
-= newEventHandler(captureWindow_BitmapCropped);
captureWindow.Dispose();
captureWindow
= null;
}
if (captureWindow == null)
{
captureWindow
= newScreenCaptureWindow();
captureWindow.BitmapCropped
+= newEventHandler(captureWindow_BitmapCropped);
}

captureWindow.Show();
captureWindow.TopMost
= true;
captureWindow.TopMost
= false;
}
void captureWindow_BitmapCropped(objectsender, EventArgs e)
{
try{if (captureWindow.DragStop !=captureWindow.DragStart)
{
RichEditControl control= this.richEditControl1;
control.Document.InsertImage(control.Document.CaretPosition, DocumentImageSource.FromImage(captureWindow.BitmapCache));
}
}
finally{if (this.ParentForm.Owner != null)
{
this.ParentForm.Owner.Show();
}
this.ParentForm.Show();
}
}

这个截图,直接就是把Image插入到RichEditControl里面,不需要另外存储图片到文件里的,这就是RichEditControl控件方便之处,如果我们要实现插入图片和加载文档的方法,除了使用内置按钮(推荐)外,其实自己也可以写事件来实现的,如下代码就是实现这两个简单的功能(一般不需要)。

       private void barInsertImg_ItemClick(objectsender, DevExpress.XtraBars.ItemClickEventArgs e)
{
string selectImage = FileDialogHelper.OpenImage(true, "");if (!string.IsNullOrEmpty(selectImage))
{
foreach (string file in selectImage.Split(new char[] { ',', ';', '', ''}))
{
if(File.Exists(file))
{
try{
RichEditControl control
= this.richEditControl1;
control.Document.InsertImage(control.Document.CaretPosition, DocumentImageSource.FromFile(file));
}
catch{
}
}
}
}
}
private void barLoadFile_ItemClick(objectsender, DevExpress.XtraBars.ItemClickEventArgs e)
{
string filter = "Word2003(*.doc)|*.doc|Word2007(*.docx)|*.docx|RTF(*.rtf)|*.rtf|HTM(*.htm)|*.htm|HTML(*.html)|*.html|All File(*.*)|*.*";string file = FileDialogHelper.Open("打开文件", filter);if (!string.IsNullOrEmpty(file))
{
//string htmlContent = File.ReadAllText(file, Encoding.Default);//this.richEditControl1.HtmlText = htmlContent; string path =Path.GetFullPath(file);string extension =Path.GetExtension(file);switch(extension.ToLower())
{
case ".htm":case ".html":this.richEditControl1.Document.LoadDocument(file, DocumentFormat.Html, path);break;case ".doc":this.richEditControl1.Document.LoadDocument(file, DocumentFormat.Doc, path);break;case ".docx":this.richEditControl1.Document.LoadDocument(file, DocumentFormat.OpenXml, path);break;case ".rtf":this.richEditControl1.Document.LoadDocument(file, DocumentFormat.Rtf, path);break;default:this.richEditControl1.Document.LoadDocument(file, DocumentFormat.PlainText, path);break;
}
//DocumentRange range = richEditControl1.Document.Range;//CharacterProperties cp = this.richEditControl1.Document.BeginUpdateCharacters(range);//cp.FontName = "新宋体";//cp.FontSize = 12;//this.richEditControl1.Document.EndUpdateCharacters(cp); }
}

3、RichEditControl的特殊操作

1)文档字体修正

RichEditControl控件功能是强大,不过一般也需要处理一些特殊的情况,由于该控件加载的时候,默认好像字体都是方正姚体的字体,因此感觉很不好看,那么我们就要在文档加载的时候,把它的字体修改下,操作如下所示,修改为新宋体的字体比方正姚体的好看很多。

        publicMyRichEdit()
{
InitializeComponent();
this.richEditControl1.DocumentLoaded += newEventHandler(richEditControl1_DocumentLoaded);
}
void richEditControl1_DocumentLoaded(objectsender, EventArgs e)
{
DocumentRange range
=richEditControl1.Document.Range;
CharacterProperties cp
= this.richEditControl1.Document.BeginUpdateCharacters(range);
cp.FontName
= "新宋体";//cp.FontSize = 12; this.richEditControl1.Document.EndUpdateCharacters(cp);
}

2)RichEditControl内置图片资源的解析

RichEditControl控件支持把图片作为内嵌资源存储在里面,如果我们要把他作为邮件发送,我们知道,邮件内容虽然是HTML的,但是图片资源需要独立取出来放到LinkedResource对象作为邮件发送才能显示,否则不能显示图片的。而RichEditControl默认转换出来的HTML内容,是把图片作为Base64码写到文档里面,文档比较大的。为了实现把图片独立提取出来,我们需要一个该控件的解析类RichMailExporter,代码如下所示。

    /// <summary>
    ///把RichEditControl里面的内容导出为HTML和嵌入图片资源的辅助函数/// </summary>
    public classRichMailExporter : IUriProvider
{
intimageId;readonlyRichEditControl control;
List
<LinkedAttachementInfo>attachments;publicRichMailExporter(RichEditControl control)
{
Guard.ArgumentNotNull(control,
"control");this.control =control;
}
/// <summary> ///导出内容和嵌入资源/// </summary> /// <param name="htmlBody">HTML内容</param> /// <param name="attach">附件资源</param> public virtual void Export(out string htmlBody, out List<LinkedAttachementInfo>attach)
{
this.attachments = new List<LinkedAttachementInfo>();
control.BeforeExport
+=OnBeforeExport;
htmlBody
= control.Document.GetHtmlText(control.Document.Range, this);
control.BeforeExport
-=OnBeforeExport;

attach
= this.attachments;
}
void OnBeforeExport(objectsender, BeforeExportEventArgs e)
{
HtmlDocumentExporterOptions options
= e.Options asHtmlDocumentExporterOptions;if (options != null)
{
options.Encoding
=Encoding.UTF8;
}
}
#region IUriProvider Members public string CreateCssUri(string rootUri, string styleText, stringrelativeUri)
{
returnString.Empty;
}
public string CreateImageUri(string rootUri, RichEditImage image, stringrelativeUri)
{
string imageName = String.Format("image{0}", imageId);
imageId
++;

RichEditImageFormat imageFormat
=GetActualImageFormat(image.RawFormat);
Stream stream
= newMemoryStream(image.GetImageBytes(imageFormat));string mediaContentType =RichEditImage.GetContentType(imageFormat);
LinkedAttachementInfo info
= newLinkedAttachementInfo(stream, mediaContentType, imageName);
attachments.Add(info);
return "cid:" +imageName;
}
privateRichEditImageFormat GetActualImageFormat(RichEditImageFormat _RichEditImageFormat)
{
if (_RichEditImageFormat == RichEditImageFormat.Exif ||_RichEditImageFormat==RichEditImageFormat.MemoryBmp)returnRichEditImageFormat.Png;else return_RichEditImageFormat;
}
#endregion}
    /// <summary>
    ///用来传递附件信息/// </summary>[Serializable]public classLinkedAttachementInfo
{
privateStream stream;private stringmimeType;private stringcontentId;/// <summary> ///参数构造函数/// </summary> /// <param name="stream">附件流内容</param> /// <param name="mimeType">附件类型</param> /// <param name="contentId">内容ID</param> public LinkedAttachementInfo(Stream stream, string mimeType, stringcontentId)
{
this.stream =stream;this.mimeType =mimeType;this.contentId =contentId;
}
/// <summary> ///附件流内容/// </summary>public Stream Stream { get { returnstream; } }/// <summary> ///附件类型/// </summary>public string MimeType { get { returnmimeType; } }/// <summary> ///内容ID/// </summary>public string ContentId { get { returncontentId; } }
}

这样我们在获取编辑控件里面的HTML的同时,也把里面的附件提取出来了,方便我们发送邮件使用,如下代码就是获取HTML内容和附件列表的,其中LinkedAttachementInfo是以Stream和相关信息存在的一个实体类。

            string html = "";
List
<LinkedAttachementInfo> linkList = new List<LinkedAttachementInfo>();
RichMailExporter exporter
= new RichMailExporter(this.txtBody.richEditControl1);
exporter.Export(
out html, outlinkList);

MailInfo info
= newMailInfo();
info.Subject
= this.txtSubject.Text;
info.Body
=html;
info.IsBodyHtml
= true;
info.EmbedObjects.AddRange(linkList);
//添加嵌入资源 info.ToEmail = this.txtRecipient.Text;//收件人

这样我们发送邮件的时候,写入附件数据就可以了,如下代码所示。

//嵌入资源的发送操作
            AlternateView view =AlternateView.CreateAlternateViewFromString(mailInfo.Body, Encoding.UTF8, MediaTypeNames.Text.Html);foreach (LinkedAttachementInfo link inmailInfo.EmbedObjects)
{
LinkedResource resource
= newLinkedResource(link.Stream, link.MimeType);
resource.ContentId
=link.ContentId;
view.LinkedResources.Add(resource);
}
mail.AlternateViews.Add(view);

发送成功后,我们就会看到图文并茂的邮件了。

3、内容支持搜索的操作

邮件保存的时候,我们可以把RichEditControl的内容作为RTF格式进行保存,这样图片的资源就作为代码进行保存了,这样恢复显示也很方便,唯一不好的就是这些内容不好搜索,建议如果要支持搜索,可以通过增加另外一个文本字段,把内容转换为纯文本进行保存,这样加载就以RTF内容加载,搜索的时候,就搜索纯文本就可以了。

以上就是我在使用DevExpress的RichEditControl过程中碰到和解决问题的过程,希望对大家有帮助,共同提高。

如果需要了解DevExpress控件使用的一些文章,可以看看我的这几篇随笔。


DevExpress控件使用经验总结


DevExpress控件开发常用要点(项目总结版)


Winform传统DataGridView和DevExpress控件的GridControl两者表头全选功能的实现(源码提供)