背景
工作中經常會有定時任務的需求,常見的做法可以使用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. 撤銷任務
任務狀態被置為已撤銷:
任務沒再繼續往下執行:
訊息佇列中的臨時佇列被刪除,訊息也被消費完