15

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

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

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

我以为服务,但这似乎有点过头了。

4

8 回答 8

27

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

创建 2 个工作:

  • 首先,每天触发一次,从当天计划的数据库中读取所有等待通知时间,并根据它们创建一些触发器。
  • 其次,注册此类触发器(由第一份工作准备),发送您的通知。

更重要的是,

我强烈建议您为此目的创建 Windows 服务,只是不要让孤独的控制台应用程序不断运行。它可能会被同一帐户下有权访问服务器的人意外终止。更重要的是,如果服务器将重新启动,您必须记住手动重新打开此类应用程序,同时可以将服务配置为自动启动。

如果您使用的是 Web 应用程序,您总是可以将这个逻辑托管在例如 IIS 应用程序池进程中,尽管这不是一个好主意。这是因为默认情况下此类进程会定期重新启动,因此您应该更改其默认配置以确保它在半夜不使用应用程序时仍在工作。除非您的计划任务将被终止。

更新(代码示例):

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

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);
    }
}

输出:

在此处输入图像描述

如果您要使用service,只需将此主要逻辑放入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 */ }
}
于 2013-09-22T21:39:08.600 回答
4

与 Quartz.NET 似乎非常适合的计划任务相反,预先计划的任务(在未定义的时间)通常很难处理。

此外,对于不应该被中断/更改的任务(例如重试、通知)和需要主动管理的任务(例如活动或通信),还要区分“即发即弃”。

对于即发即弃类型的任务,消息队列非常适合。如果目的地不可靠,您将不得不选择至少需要指定消息特定 TTL 的重试级别(例如,尝试发送(最多两次)、5 分钟后重试、尝试发送(最多两次)、15 分钟后重试)发送和重试队列。这是一个解释,其中包含设置重试级别队列的代码链接

托管的预先计划任务将要求您使用数据库队列方法(单击此处查看有关为计划任务设计数据库队列的 CodeProject 文章)。这将允许您在跟踪所有权标识符的情况下更新、删除或重新安排通知(例如,指定用户 ID,当用户不应再收到通知(例如已故/取消订阅)时,您可以删除所有待处理的通知)

预定的电子邮件任务(包括任何通信任务)需要更细粒度的控制(过期、重试和超时机制)。最好的方法是构建一个状态机,它能够通过其步骤(过期、预验证、预邮寄步骤,如模板、内联 css、使链接绝对、添加跟踪对象)来处理电子邮件任务用于打开跟踪、缩短点击跟踪链接、验证后以及发送和重试)。

希望您知道 .NET SmtpClient 不完全符合 MIME 规范,并且您应该使用 SAAS 电子邮件提供商,例如 Amazon SES、Mandrill、Mailgun、Customer.io 或 Sendgrid。我建议你看看 Mandrill 或 Mailgun。另外,如果您有时间,请查看MimeKit,您可以使用它来构建 MIME 消息,因为提供商允许发送原始电子邮件,并且不一定支持附件/自定义标头/DKIM 签名等内容。

我希望这能让你走上正确的道路。

编辑

您必须使用服务以特定间隔(例如 15 秒或 1 分钟)进行轮询。通过一次签出一定数量的到期任务并保持内部消息池待发送(使用超时机制),可以在一定程度上抵消数据库负载。当没有消息返回时,只需让轮询“休眠”一会儿。我建议不要针对数据库中的单个表构建这样的系统 - 而是设计一个可以集成的独立电子邮件调度系统。

于 2013-09-26T14:46:36.397 回答
4

您尝试实施轮询方法,其中一项工作正在监视数据库中的记录是否有任何更改。

在这种情况下,我们试图周期性地访问 DB,因此如果延迟一小时减少到后期 1 分钟,那么这个解决方案就会变成性能瓶颈。

方法一

对于这种情况,请使用基于队列的方法来避免任何问题,如果您要发送如此多的电子邮件,您还可以扩大实例数量。

我知道表中有一个程序更新NotifyDateTime,同一个程序可以将消息推送到队列,通知有一个通知要处理。

有一个 Windows 服务在这个队列中寻找任何传入的消息,当有消息时它执行所需的操作(即发送电子邮件)。

方法二

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

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

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

但是方法 1是无错误的、可扩展的、可跟踪的、异步的,并且不会给您的数据库或应用程序带来麻烦,您有不同的过程来发送电子邮件。

队列

使用作为 Windows 服务器一部分的 MSMQ

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

于 2013-09-25T13:02:12.007 回答
2

我会把它变成一项服务。您可以为每个预定时间使用 System.Threading.Timer 事件处理程序。

于 2013-09-26T22:00:29.103 回答
1

在我看来,您的第一选择是正确的选择。任务计划程序是 MS 推荐的执行定期作业的方法。此外,它很灵活,可以向操作报告故障,在系统中的所有任务中进行优化和摊销,...

创建任何一直运行的控制台类应用程序都是脆弱的。它可以被任何人关闭,需要一个开放的视野,不会自动重启,......

另一种选择是创建某种服务。它保证一直在运行,所以至少可以工作。但你的动机是什么?

“这似乎是因为我在数据库中有通知日期/时间,应该有比每小时重新运行这个东西更好的方法。”

哦,是的,优化...所以你想在你的计算机上添加一个新的永久运行的服务,这样你就可以避免每小时一次可能不需要的 SQL 查询?对我来说,治疗看起来比疾病更糟糕。

而且我没有提到该服务的所有缺点。一方面,您的任务在不运行时不使用任何资源。它非常简单、轻量级且查询高效(前提是您有正确的索引)。

另一方面,如果您的服务崩溃,它可能永远消失了。它需要一种方法来通知可能需要比当前计划更早发送的新电子邮件。它永久使用计算机资源,例如内存。更糟糕的是,它可能包含内存泄漏。

我认为除了琐碎的周期性任务之外,任何解决方案的成本/收益比都非常低。

于 2013-09-26T20:24:11.063 回答
1

如果您知道何时需要提前发送电子邮件,那么我建议您在具有适当超时的事件句柄上使用等待。在午夜查看表格,然后等待一个事件句柄,超时设置为在需要发送下一封电子邮件时到期。发送电子邮件后,根据应发送的下一封邮件再次等待超时设置。

此外,根据您的描述,这可能应该作为服务实现,但不是必需的。

于 2013-09-25T20:55:29.257 回答
1

大约三年前,我一直在处理同样的问题。在它足够好之前我已经改变了几次过程,我告诉你为什么:

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

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

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

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

于 2013-09-26T18:45:37.673 回答
1

计划任务可以计划在特定时间仅运行一次(而不是每小时、每天等),因此一种选择是在数据库中的特定字段更改时创建计划任务。

您没有提及您使用哪个数据库,但有些数据库支持触发器的概念,例如在 SQL 中:http ://technet.microsoft.com/en-us/library/ms189799.aspx

于 2013-09-22T04:32:38.980 回答