Django - 定時任務模塊設計與實踐

背景

在開發後臺與任務相關的功能中,遇到一個需求:用戶須要可以爲任務配置定時策略,使任務定時執行某個操做。html

需求分析

根據需求,咱們能夠拆解成以下幾個步驟:前端

  1. 「某個操做」的實現
  2. 配置爲定時任務
  3. 定時策略可配置
  4. 用戶體驗好

其中步驟 1 與本文無關不提;對於定時任務的實現,在上節 Celery異步任務隊列 有簡單提到 celery 也支持定時任務。python

Celery 的定時任務策略配置於代碼中,在啓動 celery 時寫入本地 shelve 文件,不利於管理。shell

所以在 celery 的文檔中也提到一個擴展模塊 django-celery-beat,該模塊將定時任務的配置寫入 Django 配置的數據庫中,當程序啓動後能夠經過 admin 後臺進行管理,而且能夠直接經過 ORM 對定時任務配置進行修改,無需修改代碼而後重啓 celery,符合咱們預期。數據庫

固然還有不少其餘庫也能實現,由於咱們已經使用 celery 執行異步任務,因此本文仍是用 django-celery-beat 解決問題。django

Celery 的定時任務使用的是相似 crontab 的語法,所以在用戶體驗上,要考慮普通用戶的學習成本,能夠提供一些經常使用的配置,例如每週的工做日天天 1 點執行任務;也要考慮後期的擴展性,能夠提供輸入框方便配置。json

設計與實現

基本用法

定時策略(CrontabSchedule)

CrontabSchedule 支持類 crontab 語法,一樣是 5 個配置域,分別爲:後端

  • 每週中的天
  • 每個月中的天
  • 每一年中的月

每一個配置域使用空格隔開。框架

對每一個配置域經常使用語法:異步

  • *: 範圍內的全部值
  • M-N: M到N之間的值
  • M-N/X*/X: 每X分鐘、每X天等等
  • A,B,...,Z: 枚舉的值

舉個例子: 每一個工做日1點執行:0 1 1-5 * *

建立定時策略代碼以下:

from django_celery_beat.models import CrontabSchedule, PeriodicTask
>>> schedule, _ = CrontabSchedule.objects.get_or_create(
...     minute='30',
...     hour='*',
...     day_of_week='*',
...     day_of_month='*',
...     month_of_year='*',
... )
複製代碼

定時任務

定時任務能夠依賴不一樣的定時策略,例如 crontab, interval 等,建立時指定 schedule 便可。以 crontab 定時任務爲例:

>>> import json
>>> from datetime import datetime, timedelta

>>> PeriodicTask.objects.create(
...     crontab=schedule,                  # we created this above.
...     name='Importing contacts',          # simply describes this periodic task.
...     task='proj.tasks.import_contacts',  # name of task.
...     args=json.dumps(['arg1', 'arg2']),
...     kwargs=json.dumps({
...        'be_careful': True,
...     }),
...     expires=datetime.utcnow() + timedelta(seconds=30)
... )
複製代碼

其中 name 爲定時任務的名稱,每一個任務名必須惟一;task 爲須要執行的 celery 任務。加上定時策略調度器,這三個是一個定時任務所必須的屬性。

定時任務還有其餘配置,如 args/kwargs 對應一個 celery 任務的入參;expires 設置了該定時任務的過時時間。

Django配置

最基礎的配置只須要在 INSTALLED_APPS 中添加引用,並設置定時任務調度器便可:

settings.py

INSTALLED_APPS = [
    ...
    'django_celery_beat'
]

# 配置 celery 定時任務使用的調度器
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
複製代碼

時區問題

在使用 django-celery-beat 過程當中遇到兩個關於時區的問題:

  1. 建立的定時任務,實際觸發時間與配置的時間存在8小時時間差

解決方案:

8小時明顯是由於時區不一樣致使,而 django-celery-beat 對時區的處理彷佛總有問題(若不對請指教)。

修改 settings.py 中的時區配置:

settings.py

# 設置 Django 大部分應用通用的時區
TIME_ZONE = 'Asia/Shanghai'
# 關閉 UTC
USE_TZ = False
CELERY_ENABLE_UTC = False
# 設置 django-celery-beat 真正使用的時區
CELERY_TIMEZONE = TIME_ZONE
# 使用 timezone naive 模式
DJANGO_CELERY_BEAT_TZ_AWARE = False
複製代碼

關於 timezone naive 與 timezone aware 模式的區別能夠參考文章:Django時區詳解

簡單來講就是,naive 模式不存儲時區信息,只存儲通過時區轉換後的時間;反之 aware 模式則存儲了 UTC 時間和 UTC 時區信息。

根據文檔,在修改了時區後,須要將已執行過的定時任務的 last_run_at 重置爲 None

python manage.py shell
>>> from django_celery_beat.models import PeriodicTask
>>> PeriodicTask.objects.all().update(last_run_at=None)
複製代碼

修改完成後,重啓 celery beat

PS: 就算是通過這樣配置,我也仍然遇到了任務不斷執行的問題,而且在我屢次重啓 celery 後再也不復現,所以本配置可能還有問題。

  1. 數據庫中,CrontabScheduletimezone 配置始終是 UTC

解決方案:

查看 CrontabSchedule 模型的源碼,找到數據庫中 timezone 字段的屬性:

class CrontabSchedule(models.Model):
    ...
    timezone = timezone_field.TimeZoneField(
        default='UTC',
        verbose_name=_('Cron Timezone'),
        help_text=_(
            'Timezone to Run the Cron Schedule on. Default is UTC.'),
    )
複製代碼

因爲咱們在建立 CrontabSchedule 實例時並未指定 timezone,所以在建立任務時,添加該字段的配置便可:

from django_celery_beat.models import CrontabSchedule
>>> schedule, _ = CrontabSchedule.objects.get_or_create(
...     minute='30',
...     hour='*',
...     day_of_week='*',
...     day_of_month='*',
...     month_of_year='*',
...     timezone='Asia/Shanghai'
... )
複製代碼

*業務先後端設計

本節內容僅供參考,不必定適用其餘場景。

前端

設計前端定時任務配置項,包含一個開關,一個三選一單選組件,以及一個輸入框:

爲了方便非技術人員設置定時任務,優化用戶體驗,定時任務除了「自定義」的輸入模式,還有一個「天天」與「每週」的選項:

  • 天天:0 1 1-5 * *
  • 每週:0 1 1 * *

單選框與字符串雙向綁定,在後端返回上面兩個字符串之一時選中天天或每週,不然選中自定義選項。

後端

假設對於個人業務來講,前端須要的任務數據字段爲:

{
    "task_id": 1,
    "is_periodic_task": true,
    "periodic_task_id": 1,
    "crontab": "* * * * *"
}
複製代碼

ER 模型如圖:

返回給前端的數據中,若 periodic_task 不爲空,則 is_periodic_taskTrue,並經過 periodic_task.crontab_id 獲取到 CrontabSchedule 實例,轉化爲字符串返回。

要注意,CrontabSchedule__str__ 方法除了返回 crontab 配置,還會返回時區等信息,而這些信息前端展現時並不須要。

所以能夠新建一個方法:

def get_crontab_str(contab) -> str:
    """ 獲取前端配置須要的 5 項值 :param contab: CrontabSchedule對象 :return: """
    return '{0} {1} {2} {3} {4}'.format(
        cronexp(contab.minute), cronexp(contab.hour),
        cronexp(contab.day_of_week), cronexp(contab.day_of_month),
        cronexp(contab.month_of_year)
    )
複製代碼

序列化時調用該方法返回給前端便可。

修改任務

修改任務包括如下三種狀況

  1. 從定時任務改成非定時任務
  2. 從非定時任務改成定時任務
  3. 在定時任務基礎上修改定時策略

對應流程圖以下:

1:

2, 3:

圖中「修改配置中的」指前端傳來的修改請求中的新配置信息

具體代碼就不贅述,只提一下暫停定時任務的方法:

修改 PeriodicTask.objects.enabledFalse/0 便可

>>> periodic_task.enabled = False
>>> periodic_task.save()
複製代碼

總結

Django 實現網頁端配置定時任務的功能實現大抵如此,惋惜 Django 及其插件對時區的配置比較複雜,花了不少時間踩了不少坑都仍是有些問題沒能搞清楚。繼續探索吧!

版本說明

框架/服務/組件 版本 說明
Python 3.6.7
Django 2.2
RabbitMQ 3
Celery 4.3
django-celery-beat 1.5.0

參考

相關文章
相關標籤/搜索