C#控制台应用程序定时发送电子邮件

15

我有一个运行在Windows Server 2003上的C#控制台应用程序,其目的是读取名为Notifications的表以及名为“NotifyDateTime”的字段,并在达到该时间时发送电子邮件。我已经通过任务计划程序安排它每小时运行一次,检查NotifyDateTime是否在该小时内,然后发送通知。

由于我在数据库中有通知日期/时间,所以似乎应该有比每小时重新运行这个程序更好的方法。

是否存在一个轻量级的进程/控制台应用程序,可以在服务器上运行并从表格中读取当天的通知,准确地在它们到期时发布通知?

我考虑过服务,但那好像太复杂了。


什么类型的数据库? - RBarryYoung
8个回答

27

我的建议是编写一个简单的应用程序,使用Quartz.NET

创建2个作业:

  • 第一个每天触发一次,从数据库中读取当天计划的所有待通知时间,并基于它们创建一些触发器。
  • 第二个注册了这些触发器(由第一个作业准备),发送你的通知。

此外,

我强烈建议你为此创建Windows服务,而不是让孤独的控制台应用程序不断运行。它可能会被有权访问服务器的某人意外终止。此外,如果服务器重新启动,您必须手动记住再次打开此类应用程序,而服务可以配置为自动启动。

如果您正在使用Web应用程序,则始终可以在IIS应用程序池进程中托管此逻辑,尽管这是一种非常糟糕的想法。这是因为默认情况下,该进程会定期重启,因此您应更改其默认配置以确保它仍然在夜间工作时仍在工作,而应用程序未使用。除非您的计划任务将被终止。

更新(代码示例):

管理器类,用于调度和取消作业的内部逻辑。出于安全原因,实现为单例:

internal class ScheduleManager
{
    private static readonly ScheduleManager _instance = new ScheduleManager();
    private readonly IScheduler _scheduler;

    private ScheduleManager()
    {
        var properties = new NameValueCollection();
        properties["quartz.scheduler.instanceName"] = "notifier";
        properties["quartz.threadPool.type"] = "Quartz.Simpl.SimpleThreadPool, Quartz";
        properties["quartz.threadPool.threadCount"] = "5";
        properties["quartz.threadPool.threadPriority"] = "Normal";

        var sf = new StdSchedulerFactory(properties);
        _scheduler = sf.GetScheduler();
        _scheduler.Start();
    }

    public static ScheduleManager Instance
    {
        get { return _instance; }
    }

    public void Schedule(IJobDetail job, ITrigger trigger)
    {
        _scheduler.ScheduleJob(job, trigger);
    }

    public void Unschedule(TriggerKey key)
    {
        _scheduler.UnscheduleJob(key);
    }
}

首要任务是从数据库中收集所需信息并安排通知(第二个任务):

internal class Setup : IJob
{
    public void Execute(IJobExecutionContext context)
    {
        try
        {                
            foreach (var kvp in DbMock.ScheduleMap)
            {
                var email = kvp.Value;
                var notify = new JobDetailImpl(email, "emailgroup", typeof(Notify))
                    {
                        JobDataMap = new JobDataMap {{"email", email}}
                    };
                var time = new DateTimeOffset(DateTime.Parse(kvp.Key).ToUniversalTime());
                var trigger = new SimpleTriggerImpl(email, "emailtriggergroup", time);
                ScheduleManager.Instance.Schedule(notify, trigger);
            }
            Console.WriteLine("{0}: all jobs scheduled for today", DateTime.Now);
        }
        catch (Exception e) { /* log error */ }           
    }
}

第二份工作,用于发送电子邮件:

internal class Notify: IJob
{
    public void Execute(IJobExecutionContext context)
    {
        try
        {
            var email = context.MergedJobDataMap.GetString("email");
            SendEmail(email);
            ScheduleManager.Instance.Unschedule(new TriggerKey(email));
        }
        catch (Exception e) { /* log error */ }
    }

    private void SendEmail(string email)
    {
        Console.WriteLine("{0}: sending email to {1}...", DateTime.Now, email);
    }
}

仅为这个特定的示例目的而创建的数据库模拟:

internal class DbMock
{
    public static IDictionary<string, string> ScheduleMap = 
        new Dictionary<string, string>
        {
            {"00:01", "foo@gmail.com"},
            {"00:02", "bar@yahoo.com"}
        };
}

应用程序的主要入口:

public class Program
{
    public static void Main()
    {
        FireStarter.Execute();
    }
}

public class FireStarter
{
    public static void Execute()
    {
        var setup = new JobDetailImpl("setup", "setupgroup", typeof(Setup));
        var midnight = new CronTriggerImpl("setuptrigger", "setuptriggergroup", 
                                           "setup", "setupgroup",
                                           DateTime.UtcNow, null, "0 0 0 * * ?");
        ScheduleManager.Instance.Schedule(setup, midnight);
    }
}

输出:

在此输入图片描述

如果你要使用服务,只需将此主要逻辑放入OnStart方法中(我建议启动实际逻辑时使用单独的线程而不是等待服务启动,这样可以避免可能的超时 - 在此特定示例中显然不是这样,但总体而言需要这样做):

protected override void OnStart(string[] args)
{
    try
    {
        var thread = new Thread(x => WatchThread(new ThreadStart(FireStarter.Execute)));
        thread.Start();
    }
    catch (Exception e) { /* log error */ }            
}
如果是这样,请将逻辑封装在某个包装器中,例如WatchThread,它将捕获来自线程的任何错误:
private void WatchThread(object pointer)
{
    try
    {
        ((Delegate) pointer).DynamicInvoke();
    }
    catch (Exception e) { /* log error and stop service */ }
}

1
@Caveatrob: 我提供了一些补充的代码示例,基本上展示了如何应对你的目的来使用Quartz。 - jwaliszko

4
预定的任务(在未定义的时间)通常很难处理,相比之下,Quartz.NET适合安排任务。此外,还应区分不应中断/更改的任务(例如重试、通知)和需要积极管理的任务(例如活动或通信)。对于fire-and-forget类型的任务,消息队列非常适合。如果目标不可靠,则必须选择重试级别(例如尝试发送(最多两次),5分钟后重试,再尝试发送(最多两次),15分钟后重试),这至少需要使用发送和重试队列指定特定于消息的TTL。这里是一个解释,其中包含设置重试级别队列的代码链接
托管的预定任务需要您使用数据库队列方法(点击此处查看设计预定任务数据库队列的CodeProject文章)。只要跟踪所有权标识符(例如指定用户ID),您就可以更新、删除或重新安排通知,从而删除所有待处理通知,当用户不再接收通知时(如已过世/取消订阅)。
计划的电子邮件任务(包括任何通信任务)需要更细致的控制(到期、重试和超时机制)。在这里采取的最佳方法是构建一个状态机,能够通过其步骤处理电子邮件任务(到期、预验证、预发送步骤,如模板化、内联css、创建绝对链接,添加用于开放跟踪的跟踪对象、缩短点击跟踪的链接、后验证和发送和重试)。
希望您知道,.NET SmtpClient并不完全符合MIME规范,因此您应该使用SAAS电子邮件提供商,如Amazon SES、Mandrill、Mailgun、Customer.io或Sendgrid。我建议您看一下Mandrill或Mailgun。另外,如果您有时间,请查看MimeKit,您可以使用它来构造MIME消息,以便为提供商发送原始电子邮件,并且不一定支持附件/自定义标头/DKIM签名等功能。
希望这能让您走上正确的道路。
编辑
您将需要使用服务以特定的间隔轮询(例如15秒或1分钟)。数据库负载可以通过一次检出一定数量的到期任务并保留内部池来部分抵消待发送的消息(带有超时机制)。当没有返回消息时,只需“休眠”轮询一段时间。我建议不要在数据库中的单个表上构建这样的系统-而是设计一个独立的电子邮件调度系统,您可以将其与其他系统集成。

4
您正在尝试实现轮询方法,其中一个作业正在监视数据库中的记录以进行任何更改。
在这种情况下,我们尝试定期访问数据库,因此,如果一小时延迟在稍后阶段减少到1分钟,则此解决方案将变成性能瓶颈。
方法1:
对于此场景,请使用基于队列的方法以避免任何问题,如果您要发送大量电子邮件,则还可以扩展实例数。
我了解有一个程序会在表中更新NotifyDateTime,同样的程序可以向队列推送消息,通知需要处理通知。
有一个Windows服务监视此队列以获取任何传入的消息,当有消息时,它执行所需的操作(即发送电子邮件)。
方法2:

http://msdn.microsoft.com/en-us/library/vstudio/zxsa8hkf(v=vs.100).aspx

如果您使用MS SQL Server,还可以从SQL Server存储过程调用C#代码。但在这种情况下,您正在利用SQL服务器进程发送邮件,这不是一个好的做法。

但是,您可以调用可以发送电子邮件的Web服务或WCF服务。

方法1是无误差的、可扩展的、可跟踪的、异步的,并且不会干扰您的数据库或应用程序,您有不同的进程来发送电子邮件。

队列

使用Windows Server中的MSMQ。

您也可以尝试https://www.rabbitmq.com/dotnet.html


一个小变化是,我会在每天开始时知道哪些通知需要发送以及何时发送。这样是否简化了事情? - Caveatrob
有两件事情:1)触发器 - 启动通知过程2)通知器 - 发送通知。对于任何系统,通知可以是多种类型,例如电子邮件、短信、移动推送通知等,这取决于用户的偏好或应用程序的性质。 但现实情况是通知类型随时可能会改变,我们需要快速处理这种变化。因此,如果将通知器与触发器分开,您就可以获得很好的灵活性。 - CreativeManix
当您在一天开始时知道需要发送什么内容时,为什么要每小时轮询?我能看到“触发器”部分不够清晰,请详细解释什么事件需要发送通知?触发器可以从任何地方初始化,例如从定期作业、代码、数据库、用户操作等,但是“通知器”只是查看队列并发送通知,而不必关心是谁触发了它。 - CreativeManix

2
我会将其转化为一个服务。您可以使用System.Threading.Timer事件处理程序来安排每个预定时间。

1
如果您提前知道电子邮件需要何时发送,那么建议使用适当超时的事件处理程序等待。在午夜查看表格,然后等待事件处理程序,并将超时设置为下一个要发送电子邮件的时间。发送电子邮件后,再次等待,超时设置根据应该发送的下一封邮件确定。
此外,根据您的描述,这可能应该作为服务实现,但不是必需的。

1

我大约三年前也遇到过同样的问题。在最终得到良好结果之前,我进行了多次流程更改。下面是我为什么这样做的原因:

  1. 第一次实现是使用Webhosting的特殊守护程序,称为IIS网站。该网站检查调用者IP,然后检查数据库并发送电子邮件。这一直有效,直到有一天,我收到了很多非常恶心的来自用户的电子邮件,他们说我的邮件完全垃圾了他们的邮箱。将电子邮件保存在数据库中并从SMTP电子邮件发送的缺点是没有任何东西可以确保DB到SMTP事务。您永远不确定电子邮件是否已成功发送。发送电子邮件可能会成功,可能会失败,也可能是误报或虚假负面(SMTP客户端告诉您,电子邮件未发送,但实际上已发送)。SMTP服务器存在一些问题,服务器返回false(电子邮件未发送),但电子邮件已发送。在肮脏的邮件出现之前,该守护程序一整天每小时重新发送电子邮件。

  2. 第二次实现:为了防止垃圾邮件,我改变了算法,即使发送失败,也认为已发送(我的电子邮件通知并不太重要)。我的第一个建议是:“不要太频繁地启动守护程序,因为这种虚假负面SMTP错误会让用户感到沮丧。”

  3. 几个月后,服务器发生了一些变化,守护程序运行不好。我从stackoverflow得到的想法是将.NET计时器绑定到Web应用程序域。这不是一个好主意,因为似乎IIS会因为内存泄漏而不时地重新启动应用程序,如果重启比计时器滴答声更频繁,则计时器永远不会触发。

  4. 最后一次实现。Windows调度程序每小时启动Python批处理程序,该程序读取本地网站。这将触发ASP.NET代码。优点是时间窗口调度程序可靠地调用本地批处理和网站。IIS不会挂起,它具有重新启动功能。计时器站点是我的网站的一部分,仍然是一个项目。(您可以使用控制台应用程序代替)简单就是更好。它只是有效!


1
我认为你的第一个选择是正确的。任务计划程序是微软推荐的执行定期任务的方式。此外,它灵活,可以向运维报告失败情况,在系统中进行了优化和分摊等等。
创建任何全天候运行的控制台应用程序都很脆弱。它可能会被任何人关闭,需要打开会话,不会自动重启等等。
另一个选项是创建某种服务。这保证了它一直在运行,所以至少可以工作。但是你的动机是什么?
“因为我在数据库中有通知日期/时间,所以似乎应该有比每小时重新运行这个东西更好的方法。”
哦,优化...所以你想在计算机上添加一个新的永久运行的服务,以便避免每小时进行一次潜在的不必要的SQL查询?在我看来,治疗比疾病更严重。
我还没有提到服务的所有缺点。一方面,当任务不运行时,它不使用任何资源。它非常简单,轻量级,并且查询效率高(前提是你有正确的索引)。
另一方面,如果您的服务崩溃了,那么它可能就永远消失了。它需要一种通知新电子邮件的方式,这些电子邮件可能需要比当前计划的时间更早发送。它会永久地使用计算机资源,如内存。更糟糕的是,它可能包含内存泄漏问题。
我认为,除了微不足道的定期任务之外,任何其他解决方案的成本效益比都非常低。

可以设置服务在崩溃后自动重启。但如果服务挂起,就会有问题,因为没有像asp.net请求那样的超时等待。 - Tomas Kubes

1
计划任务可以安排在特定时间运行一次(而不是每小时、每天等),因此一种选择是在数据库中特定字段更改时创建计划任务。您没有提到使用哪个数据库,但是某些数据库支持触发器的概念,例如 SQL:http://technet.microsoft.com/en-us/library/ms189799.aspx

这基本上是读取日期列表以通知用户;这些日期是单独计算并放入数据库中的。为每个日期安排一个任务似乎过于浩大。 - Caveatrob

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接