[奇思異想]使用RabbitMQ實現定時任務

程式設計玩家發表於2019-07-16

背景

  工作中經常會有定時任務的需求,常見的做法可以使用Timer、Quartz、Hangfire等元件,這次想嘗試下新的思路,使用RabbitMQ死信佇列的機制來實現定時任務,同時幫助再次瞭解RabbitMQ的死信佇列。

 

互動流程

  

 

  1. 使用者建立定時任務

  2. 往死信佇列插入一條訊息,並設定過期時間為首個任務執行時間

  3. 死信佇列中的訊息過期後,訊息流向工作佇列

  4. 任務執行消費者監聽工作佇列,工作佇列向消費者推送訊息

  5. 消費者查詢資料庫,讀取任務資訊

  6. 消費者確認任務有效(未被撤銷),執行任務

  7. 消費者確認有下個任務,再往死信佇列插入一條訊息,並設定過期時間為任務執行時間

  8. 重複2-7的步驟,直到所有任務執行完成或任務撤銷

 

環境準備

  請自行完成MongoDB和RabbitMQ的安裝,Windows、Linux、Docker皆可,以下提供Windows的安裝方法:

  MongoDB:https://docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/

  RabbitMQ:https://www.rabbitmq.com/install-windows.html

 

核心程式碼

  1. (WebApi)建立任務,並根據設定建立子任務,把任務資料寫入資料庫

    var task = new Task
    {
        Name = form.Name,
        StartTime = form.StartTime,
        EndTime = form.EndTime,
        Interval = form.Interval,
        SubTasks = new List<SubTask>()
    };
    
    var startTime = task.StartTime;
    var endTime = task.EndTime;
    
    while ((endTime - startTime).TotalMinutes >= 0)
    {
        var sendTime = startTime;
        if (sendTime <= endTime && sendTime > DateTime.UtcNow)
        {
            task.SubTasks.Add(new SubTask { Id = ObjectId.GenerateNewId(), SendTime = sendTime });
        }
    
        startTime = startTime.AddMinutes(task.Interval);
    }
    
    await _mongoDbContext.Collection<Task>().InsertOneAsync(task);

 

  2. (WebApi)往死信佇列中寫入訊息

    var timeFlag = task.SubTasks[0].SendTime.ToString("yyyy-MM-dd HH:mm:ssZ");
    var exchange = "Task";
    var queue = "Task";
    
    var index = 0;
    var pendingExchange = "PendingTask";
    var pendingQueue = $"PendingTask|Task:{task.Id}_{index}_{timeFlag}";
    
    using (var channel = _rabbitConnection.CreateModel())
    {
        channel.ExchangeDeclare(exchange, "direct", true);
        channel.QueueDeclare(queue, true, false, false);
        channel.QueueBind(queue, exchange, queue);
    
        var retryDic = new Dictionary<string, object>
        {
            {"x-dead-letter-exchange", exchange},
            {"x-dead-letter-routing-key", queue}
        };
    
        channel.ExchangeDeclare(pendingExchange, "direct", true);
        channel.QueueDeclare(pendingQueue, true, false, false, retryDic);
        channel.QueueBind(pendingQueue, pendingExchange, pendingQueue);
    
        var properties = channel.CreateBasicProperties();
        properties.Headers = new Dictionary<string, object>
        {
            ["index"] = index,
            ["id"] = task.Id.ToString(),
            ["sendtime"] = timeFlag
        };
    
        properties.Expiration = ((int)(task.SubTasks[0].SendTime - DateTime.UtcNow).TotalMilliseconds).ToString(CultureInfo.InvariantCulture);
        channel.BasicPublish(pendingExchange, pendingQueue, properties, Encoding.UTF8.GetBytes(string.Empty));
    }

  其中:

  PendingTask為死信佇列Exchange,死信佇列的佇列名(Queue Name)會包含Task、index、timeFlag的資訊,幫助跟蹤佇列和子任務,同時也起到唯一標識的作用。

  task.id為任務Id

  index為子任務下標

  timeFlag為子任務執行時間

 

  3. (消費者)處理訊息

    var exchange = "Task";
    var queue = "Task";
    
    _channel.ExchangeDeclare(exchange, "direct", true);
    _channel.QueueDeclare(queue, true, false, false);
    _channel.QueueBind(queue, exchange, queue);
    
    var consumer = new EventingBasicConsumer(_channel);
   //監聽處理 consumer.Received
+= (model, ea) => {
     //獲取訊息頭資訊
var index = (int)ea.BasicProperties.Headers["index"]; var id = (ea.BasicProperties.Headers["id"] as byte[]).BytesToString(); var timeFlag = (ea.BasicProperties.Headers["sendtime"] as byte[]).BytesToString();

   //刪除臨時死信佇列 _channel.QueueDelete($
"PendingTask|Task:{id}_{index}_{timeFlag}", false, true); var taskId = new ObjectId(id); var task = _mongoDbContext.Collection<Task>().Find(n => n.Id == taskId).SingleOrDefault();

     //撤銷或已完成的任務不執行
if (task == null || task.Status != TaskStatus.Normal) { _channel.BasicAck(ea.DeliveryTag, false); return; }
     //執行任務 _logger.LogInformation($
"[{DateTime.UtcNow}]執行任務...");
//設定子任務已完成 task.SubTasks[index].IsSent
= true; if (task.SubTasks.Count > index + 1) //還有未完成的子任務,把下個子任務的資訊寫入死信佇列 { PublishPendingMsg(_channel, task, index + 1); } else { task.Status = TaskStatus.Finished; //所有子任務執行完畢,設定任務狀態為完成 } _mongoDbContext.Collection<Task>().ReplaceOne(n => n.Id == taskId, task); //更新任務狀態 _channel.BasicAck(ea.DeliveryTag, false); }; _channel.BasicConsume(queue, false, consumer);

 

  4. (WebApi)撤銷任務,更新任務狀態即可

    var taskId = new ObjectId(id);
    var task = await _mongoDbContext.Collection<Task>().Find(n => n.Id == taskId).SingleOrDefaultAsync();
    if (task == null)
    {
        return NotFound(new { message = "任務不存在!" });
    }
    
    task.Status = TaskStatus.Canceled;
    await _mongoDbContext.Collection<Task>().FindOneAndReplaceAsync(n => n.Id == taskId, task);

 

效果展示

   1. 先使用控制檯把消費者啟動起來。

  

 

  2. 建立任務

  啟動WebApi,建立一個任務,開始時間為2019-07-16T07:55:00.000Z,結束時間為2019-07-16T07:59:00.000Z,執行時間間隔1分鐘:

  

 

  任務與相應的子任務也寫入了MongoDB,這裡假設子任務可能是郵件傳送任務:

  

     

  建立了一個臨時死信佇列,佇列名稱包含任務Id,子任務下標、以及子任務執行時間,並往其寫入一條訊息:

  

 

  3. 執行(子)任務

  從日誌內容可以看出,(子)任務正常執行:

  

  子任務狀態也標註為已傳送

  

 

  同時也往訊息佇列寫入了下一個子任務的訊息:

  

  

  4. 撤銷任務

   

 

  任務狀態被置為已撤銷:

  

 

  任務沒再繼續往下執行:

  

   

  訊息佇列中的臨時佇列被刪除,訊息也被消費完

  

 

原始碼地址

  https://github.com/ErikXu/rabbit-scheduler

相關文章