使用定时Webhook计划架构

时间:2019-04-17 10:05:37

标签: asp.net rest api asp.net-core webhooks

背景

我有一个用C#+ ASP.NET Core(v2.2)编写的网站,并公开了此API:

POST /api/account/signup
POST /api/account/send-greeting

我的业务策略是在注册后的15分钟之内向用户发送问候语(POST /api/account/send-greeting

问题

因此,我需要某种方式来注册此新事件。我虽然有两个选择:

  1. 每1分钟运行一次后台任务,以查询数据库中的新用户 谁可能收到此电子邮件。
  2. 使用分布式队列。像Azure存储队列一样。使用此队列,您可以使可见性超时的消息入队。因此,您可以定义您现在将消息发送到队列,但是仅在15分钟后才会出现在队列中。然后,您将必须部署一个后台服务,该服务将等待队列中的新活动消息并执行它们。

这两种解决方案都有明显的缺点:

  1. 解决方案#1是幼稚的解决方案。因为它应该每1分钟运行一次并查询表上的所有注册用户,所以它消耗大量的数据库资源。这效率不高,因为在一天中的大部分时间内我没有新的注册用户。
  2. 解决方案2太麻烦了。您需要使用队列并部署后台服务才能完成此任务。对我来说听起来像是太多的工作。

对我来说,这项任务听起来很明显。我不确定是否存在更好的解决方案,可以是: 您向他发送消息的外部服务,例如

POST /api/register-to-timed-callback?when=15m&target-url=http://example.com/api/account/send-greeting

问题

我错过了什么?如何以最简单,最有效的方式解决此问题?

1 个答案:

答案 0 :(得分:1)

您可以基于IHostedService创建排队的后台服务。然后,当用户通过后台服务注册并处理该队列时,可以将项目添加到队列中。将项目从队列中拉出时,您会根据时间检查是否已准备好发送。如果是这样,您将命中send-greeting端点,否则,您将重新排队该项目。 docs提供了此类服务的示例。

队列:

public interface IBackgroundTaskQueue
{
    void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);

    Task<Func<CancellationToken, Task>> DequeueAsync(
        CancellationToken cancellationToken);
}

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private ConcurrentQueue<Func<CancellationToken, Task>> _workItems = 
        new ConcurrentQueue<Func<CancellationToken, Task>>();
    private SemaphoreSlim _signal = new SemaphoreSlim(0);

    public void QueueBackgroundWorkItem(
        Func<CancellationToken, Task> workItem)
    {
        if (workItem == null)
        {
            throw new ArgumentNullException(nameof(workItem));
        }

        _workItems.Enqueue(workItem);
        _signal.Release();
    }

    public async Task<Func<CancellationToken, Task>> DequeueAsync(
        CancellationToken cancellationToken)
    {
        await _signal.WaitAsync(cancellationToken);
        _workItems.TryDequeue(out var workItem);

        return workItem;
    }
}

托管服务:

public class QueuedHostedService : BackgroundService
{
    private readonly ILogger _logger;

    public QueuedHostedService(IBackgroundTaskQueue taskQueue, 
        ILoggerFactory loggerFactory)
    {
        TaskQueue = taskQueue;
        _logger = loggerFactory.CreateLogger<QueuedHostedService>();
    }

    public IBackgroundTaskQueue TaskQueue { get; }

    protected async override Task ExecuteAsync(
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Queued Hosted Service is starting.");

        while (!cancellationToken.IsCancellationRequested)
        {
            var workItem = await TaskQueue.DequeueAsync(cancellationToken);

            try
            {
                await workItem(cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, 
                   $"Error occurred executing {nameof(workItem)}.");
            }
        }

        _logger.LogInformation("Queued Hosted Service is stopping.");
    }
}

该代码直接来自文档。它主要支持您的用例,但需要进行一些调整才能使您始终如一。首先,由于有一个时间成分(即,您只想处理队列中已存在15分钟的项目),因此需要将ConcurrentQueue<T>的类型参数设为可以对日期时间和放进去可以是ValueTuple或专门为此目的创建的实际对象:由您决定。例如:

ConcurrentQueue<(DateTimeOffset added, Func<CancellationToken, Task> task)>

然后,如果没有经过足够的时间,则需要修改出队逻辑以重新出队:

public async Task<Func<CancellationToken, Task>> DequeueAsync(
    CancellationToken cancellationToken)
{
    await _signal.WaitAsync(cancellationToken);
    _workItems.TryDequeue(out var workItem);
    if (DateTimeOffset.UtcNow.AddMinutes(-15) < workItem.added)
    {
        _workItems.Enqueue(workItem);
        return ct => ct.IsCancellationRequested ? Task.FromCanceled(ct) : Task.CompletedTask;;
    }
    return workItem;
}

还没来的时候,返回的值基本上只是一个伪lambda来满足约束。您可能会返回类似null的内容,但是在处理该函数之前,还需要修改后台服务的ExecuteAsync方法以添加null检查。

还值得注意的是,示例代码被设计为通用的,并允许您将任何内容排队。因此,由于需要特定于时间的更改,因此应使用更特定的命名:例如ITimedBackgroundTaskQueueTimedBackgroundTaskQueueTimedQueuedHostedService。鉴于文档中的示例接口/类实际上将集成到ASP.NET Core 3.0中,这一点尤其正确。