【開發實戰】基於ActiveMQ和Celery搭建跨平臺異步任務調度系統

目錄:

1. 系統架構:API接口服務,業務處理服務

2. 開發環境:Redis,ActiveMQ

3. API服務:Java + SpringBoot + ActiveMQ

4. 業務處理服務:Python + Django + ActiveMQ

5. 任務處理請求信息發送和接收

6. 業務處理服務集成Celery任務調度

7. ActiveMQ常見問題和解決方法

8. Celery常見問題和解決方法

附錄1,JMS規範定義的2類消息發送接收模型

附錄2,JMS規範定義的5類消息


一,系統架構

SpringBoot是Java開發時常用框架,有非常豐富的組件和易用的功能。


Python在AI領域是主流開發語言,Django是應用廣泛的開源框架,在實際系統開發中,我們經常面臨的是跨平臺的業務處理需求。


ActiveMQ是一個非常流行的消息隊列服務中間件,基於JMS(Java Message Service)規範。


Celery是一個靈活可靠的分佈式系統,用於異步任務調度,系統通常將一些耗時的操作任務提交給Celery去異步執行,典型系統架構示意圖如下:

image.png

本文基於Java + SpringBoot,Python + Django,集成ActiveMQ和Celery,搭建起一個跨平臺異步任務調度系統。

API接口服務:https://github.com/jextop/StarterApi

├── controller

│   └── CheckController.java

├── mq

│   └── MqService.java

│   └── MqConsumer.java


業務處理服務:https://github.com/jextop/starter_service

├── celery.py

├── tasks.py

├── mq

│   └── mq_service.py

│   └── mq_listener.py


二,開發環境

系統依賴ActiveMQ和Redis運行,手動安裝配置稍顯繁瑣,可以使用Docker一鍵部署,下載資源編排代碼後運行腳本:docker-compose up -d

開發環境部署:https://github.com/rickding/HelloDocker/tree/master/data

├── docker-compose.yml

├── up.sh

image.png

三,API服務:Java + SpringBoot + ActiveMQ

SpringBoot集成ActiveMQ只需簡單配置,下載項目代碼,開發步驟如下:


代碼文件

功能要點

SpringBoot集成ActiveMQ

pom.xml

引入ActiveMQ依賴spring-boot-starter-activemq

application.yml

配置ActiveMQ服務器broker-url, user, passworkd

MqConfig.java

配置Bean: ActiveMQQueue, ActiveMQTopic, 還有JmsListenerContainerFactory

封裝服務

MqService.java

調用ActiveMQ發送消息:JmsMessagingTemplate.convertAndSend()發送Queue和Topic

接收處理消息

MqConsumer.java

接收ActiveMQ消息,@JmsListener()聲明處理函數

單元測試

MqServiceTest.java

測試封裝的ActiveMQ發送接收功能

功能調用

CheckController.java

增加REST接口/chk/mq,調用ActiveMQ發送消息

1,application.yml中配置ActiveMQ服務器信息:

spring:
  activemq:
    broker-url: tcp://127.0.0.1:61616
    user: admin
    password: admin
    in-memory: false
    packages:
      trust-all: true
    pool:
      enabled: false

2,MqService封裝了消息發送功能,詳見代碼MqService.java,注意Java環境下使用文本消息TextMessage,發送時將Map轉換爲JSON字符串,Python環境下STOMP簡單文本協議對應。

3,MqConsumer.java接收任務處理狀態消息,使用的是發佈訂閱消息Topic,附錄1中解釋Queue和Topic兩類消息的區別:

@Component
public class MqConsumer {
    @JmsListener(destination = "starter.status", containerFactory = "jmsTopicListenerContainerFactory")
    public void listenTopic(Message msg) {
        Map<String, ?> msgMap = MqUtil.parseMsg(msg);
        LogUtil.info("Receive status msg", msgMap);
    }
}

4,配置完成後,啓動API服務,運行單元測試驗證消息發送接收功能。

@SpringBootTest
public class MqServiceTest {
    @Autowired
    MqService mqService;

    @Test
    public void testSendQueue() {
        mqService.sendQueue(new HashMap<String, Object>() {{
            put("msg", "test active queue from java");
            put("date", new Date().toString());
        }});
    }

    @Test
    public void testSendTopic() {
        mqService.sendTopic(new HashMap<String, Object>() {{
            put("msg", "test active topic from java");
            put("date", new Date().toString());
        }});
    }
}

5,API服務接收到的狀態信息:

image.png

四,業務處理服務:Python + Django + ActiveMQ

Python集成ActiveMQ使用stomp.py,基於STOMP協議(端口爲61613),簡單(流)文本消息,開發步驟如下:


代碼文件

功能要點

Python集成ActiveMQ

requirements.txt

安裝stomp.py:

stomp.py >= 5.0.1

封裝服務

mq_serivce.py

封裝ActiveMQ的消息發送和處理功能。在Django框架下,將地址等配置在settings.py中集中管理,注意端口爲61613

接收處理消息

mq_listener.py

增加消息接收處理類,繼承stomp.ConnectionListener

啓動消息監聽服務

mq.py

在Django框架下,將啓動服務代碼封裝成command,方便調用和維護。

單元測試

test_mq_serivce.py

測試封裝的功能函數

功能調用

views.py

增加REST接口/chk/mq,調用mq_service發送消息

1,ActiveMQ服務器地址等信息配置在settings.py中,方便維護管理。

MQ_URL = '127.0.0.1'
MQ_PORT = 61613
MQ_USER = 'admin'
MQ_PASSWORD = 'admin'
MQ_QUEUE = '/queue/starter.process'
MQ_TOPIC = '/topic/starter.status'

2,爲了增加代碼的兼容和容錯能力,封裝輔助函數send_msg(), consume_msg(), get_conn(), close_conn(),詳見代碼文件mq_service.py

3,增加mq_listener.py,聲明消息處理類,繼承stomp.ConnectionListener,on_message()函數中將消息字符串解析爲JSON,注意STOMP協議只支持簡單文本協議,所以此步轉換是必須的。

4,根據接收到的消息內容創建一個異步任務。

from __future__ import absolute_import, unicode_literals
import json
import logging
import stomp
from ..tasks import do_task

log = logging.getLogger(__name__)

class MqListener(stomp.ConnectionListener):
    def on_message(self, headers, msg_str):
        log.info('Receive msg: %s, %s, %s' % (type(msg_str), msg_str, headers))

        msg_dict = None
        try:
            msg_dict = json.loads(msg_str)
        except Exception as e:
            log.warning('Exception when parse msg: %s' % str(e))

        log.info('Parsed msg: {}, {}'.format(type(msg_dict), msg_dict))
        do_task(msg_dict)

    def on_error(self, headers, msg_str):
        log.info('Error msg: %s, %s, %s' % (type(msg_str), msg_str, headers))

5,封裝一個Django Command,調用comsume_msg啓動消息監聽服務,代碼在目錄management/commands下的mq.py

import logging
from django.core.management.base import BaseCommand
from starter_service.mq import mq_service as mq
from starter_service.mq.mq_listener import MqListener

log = logging.getLogger(__name__)

class Command(BaseCommand):
    help = 'mq starts listener'

    def handle(self, *args, **options):
        log.info("mq starts")
        return mq.consume_msg(MqListener())

6,運行命令python manage.py mq,看到消息提示,啓動監聽服務成功。

image.png

7,增加測試test_mq_service.py,發送消息:

import logging
from django.test import TestCase
from ..mq import mq_service as mq

log = logging.getLogger(__name__)

class MQServiceTest(TestCase):
    def test_send_msg(self):
        msg_dict = {'content': 'test msg dict', 'msg': 'queue from python'}
        mq.send_msg_to_queue(msg_dict)
        mq.send_msg_to_topic({'msg': "test topic from python"})

8,運行測試python manage.py test,看到消息發送和接收:

image.png

五,任務處理請求信息發送和接收

現在API服務和業務處理服務都已經能夠發送和接收ActiveMQ消息,將它們連接起來:

1,API服務:REST接口處理客戶端請求時,發送業務處理消息,使用點對點消息Queue, CheckController.java

@GetMapping(path = "/chk/mq")
public Object mq(@RequestAttribute(required = false) String ip) {
    String msg = String.format("check mq from java, %s, 消息隊列", ip);

    // to service
    mqService.sendQueue(new HashMap<String, Object>() {{
        put("msg", msg);
        put("date", DateUtil.format(new Date()));
    }});

    return new HashMap<String, Object>() {{
        put("chk", "mq");
        put("msg", msg);
    }};
}

2,業務處理服務:消息監聽服務接收到請求消息,調用Celery創建一個異步任務,代碼mq_listener.py,調用tasks.py中do_task()函數:

def do_task(param_dict):
    log.info('do_task: %s, %s' % (type(param_dict), param_dict))
    job = dispatch_task(task, param_dict)

    param_dict['status'] = 'waiting'
    param_dict['task'] = job.id
    send_msg_to_topic(param_dict)
    return job

3,將異步任務創建功能封裝爲dispatch_task()函數:

from __future__ import absolute_import, unicode_literals
import json
import logging

log = logging.getLogger(__name__)

def dispatch_task(task_func, param_dict):
    param_json = json.dumps(param_dict)

    try:
        return task_func.apply_async(
            [param_json],
            retry=True,
            retry_policy={
                'max_retries': 1,
                'interval_start': 0,
                'interval_step': 0.2,
                'interval_max': 0.2,
            },
        )
    except Exception as ex:
        log.info(ex)
        raise

4,Celery異步任務處理時,發送狀態信息到API服務,代碼tasks.py,Celery的集成方法見下一部分。

5,API服務接收到信息,更新狀態並通知客戶端,代碼MqConsumer.java

消息格式轉換過程:

image.png

基本時序圖如下:

image.png

六,業務處理服務集成Celery

Django集成Celery配置方法步驟如下:


代碼文件

功能要點

Django集成Celery

requirements.txt

安裝Celery, Redis和工具包:

celery == 4.2.1

flower == 0.9.2

redis == 3.2.0

eventlet == 0.24.1

celery.py

配置Celery,依賴的消息中間件broker和後端backend地址配置在settings.py中集中維護。

__init__.py

配置項目加載celery.app

聲明異步任務

tasks.py

聲明Celery可調度的任務@shared_task

封裝工具task_util

task_util.py

異步任務創建和分發

啓動異步任務處理服務

腳本celery.sh

運行命令:celery -A hello_celery worker -l info -P eventlet

單元測試

test_task_util.py

測試異步任務創建和分發功能

創建異步任務

views.py

增加REST接口/chk/job

1. 增加celery.py,配置信息:

from __future__ import absolute_import, unicode_literals
import os
from celery import Celery, platforms
from django.conf import settings

# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'starter_service.settings')

app = Celery(
    'starter_service',
    include=['starter_service.tasks'],
    broker=settings.CELERY_BROKER,
    backend=settings.CELERY_BACKEND
)

# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
#   should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')

app.conf.update(
    CELERY_ACKS_LATE=True,
    CELERY_ACCEPT_CONTENT=['pickle', 'json'],
    CELERYD_FORCE_EXECV=True,
    CELERYD_MAX_TASKS_PER_CHILD=500,
    BROKER_HEARTBEAT=0,
)

# Optional configuration, see the application user guide.
app.conf.update(
    CELERY_TASK_RESULT_EXPIRES=3600,  # celery任務執行結果的超時時間,即結果在backend裏的保存時間,單位s
)

# Load task modules from all registered Django app configs.
app.autodiscover_tasks()

platforms.C_FORCE_ROOT = True

2. 打開settings.py,配置BROKER和BACKEND地址:

CELERY_BROKER = 'redis://127.0.0.1:6379/2'
CELERY_BACKEND = 'redis://127.0.0.1:6379/3'

3. 打開__init__.py,增加代碼:

from __future__ import absolute_import, unicode_literals
from .celery import app as celery_app

__all__ = ['celery_app']

4. 增加tasks.py,聲明異步任務。任務處理過程中將調用mq_service發送狀態信息到API服務,Topic類型的文本信息。

from __future__ import absolute_import, unicode_literals
import logging
import json
import time
from celery import shared_task
from .util.task_util import dispatch_task
from .mq.mq_service import send_msg_to_topic

log = logging.getLogger(__name__)

@shared_task
def task(param_str):
    log.info('task starts: %s, %s' % (type(param_str), param_str))

    param_dict = None
    try:
        param_dict = json.loads(param_str)
    except Exception as e:
        log.warning('Exception when parse param: %s' % str(e))

    log.info('parsed param: {}, {}'.format(type(param_dict), param_dict))

    param_dict['status'] = 'processing'
    send_msg_to_topic(param_dict)

    # do something
    time.sleep(3)

    param_dict['status'] = 'finished'
    send_msg_to_topic(param_dict)
    return 'finished'

5. 正確配置後,運行命令啓動celery worker異步任務處理服務:celery -A hello_celery worker -l info -P eventlet,注意Win10環境中需要增加eventlet,Celery成功啓動信息:

image.png

6. 增加單元測試test_task_util.py,創建一個任務:

import logging
from django.test import TestCase
from ..tasks import task
from ..util.task_util import dispatch_task, get_task_status

log = logging.getLogger(__name__)

class TasksTest(TestCase):
    def test_get_task_status(self):
        job = dispatch_task(task, {'msg': 'test_task'})
        self.assertIsNotNone(job)

        ret = get_task_status(task, job.id)
        log.info('task status: %s,%s, %s' % (ret, job.id, str(task)))
        self.assertIsNotNone(ret.get('status'))

7. 運行python manage.py test,Celery服務將執行測試函數創建的任務:

image.png

七,ActiveMQ常見問題和解決方法

發送消息時異常:MessageConversionException

Could not convert 'GenericMessage [payload=java.lang.Object@472096, headers={id=bcbe6b1d-b340-bbb5-3121-41417cdc5e35, timestamp=1581671689357}]'; nested exception is org.springframework.jms.support.converter.MessageConversionException: Cannot convert object of type [java.lang.Object] to JMS message. Supported message payloads are: String, byte array, Map<String,?>, Serializable object.

解決:發送的消息時Object對象實例時,需要實現Serializable序列化接口。

原因:ActiveMQ支持的消息內容:String, byte[], Map<>, 可序列化的類。另外注意類所在包路徑發送方和接收方應該一致。


啓動服務錯誤:[transport.py: 787, attempt_connection] Could not connect to host 127.0.0.1, port 61613

解決:檢查ActiveMQ是否正常啓動,特別注意是否開啓STOMP協議端口61613

原因:Python連接ActiveMQ使用STOMP協議,端口默認61613


發送消息時錯誤:TypeError: message should be a string or bytes, found <class 'dict'>

解決:將消息內容序列化爲JSON,發送時調用json.dumps(),接收時調用json.loads()

原因:Python連接ActiveMQ使用的是STOMP協議,消息格式爲簡單文本。


跨系統對接時接收到的消息類型不是TextMessage

Python開發的業務處理服務 -> Java開發的API服務,接收到的消息類型爲BytesMessage,Python發送時設置conn.send('xx', msg_str, content_type="text/plain")仍然接收不到期望的類型TextMessage

解決:stomp建立連接時配置參數conn = stomp.Connection10([("localhost", 61613)], auto_content_length=False)

原因:Python連接ActiveMQ使用STOMP協議,消息格式爲簡單文本,不攜帶類型信息,只通過header中的content-length來判斷TextMessage和BytesMessage,所以發送消息時不在header中添加content-length就可以了。


八,Celery常見問題和解決方法

啓動Celery: celery -A hello_celery worker -l info,運行出錯:

Unrecoverable error: VersionMismatch('Redis transport requires redis-py versions 3.2.0 or later. You have 2.10.6',)

解決:指定Redis使用3.2.0或更高pip install redis>=3.2.0

原因:Redis版本兼容問題。


啓動Celery: celery -A hello_celery worker -l info,運行出錯:

Task handler raised error: ValueError('not enough values to unpack (expected 3, got 0)',)

解決:安裝eventlet:pip install eventlet,然後更新命令行:celery -A hello_celery worker -l info -P eventlet

原因:Celery4.x在win10上需要安裝 eventlet包


啓動Celery Flower任務管理工具: celery flower --broker=redis://127.0.0.1:6379/2,運行出錯

ATTRIBUTEERROR: MODULE ‘TORNADO.WEB HAS NO ATTRIBUTE 'ASYNCHRONOUS’

解決:安裝tornado5.1.1版本:pip uninstall -y tornado; pip install tornado==5.1.1

原因:tornado6.0開始使用coroutine並刪除了tornado.web.asynchronous,5.1版本能正常調用。


附錄1:JMS規範定義的2類消息發送接收模型

JMS規範定義了2類消息發送接收模型:點對點queue,發佈訂閱topic,區別是能夠重複消費和是否保存。在任務調度系統中,請求處理消息使用queue,任務處理狀態消息使用topic。


1,點對點queue:不可重複消費,消息被消費前一直保存。

生產者發送消息到queue,一個消費者取出並消費消息。

消息被消費後,queue中不再保存,所有隻有一個消費者能夠取到消息。

queue支持多個消費者存在,但是一個消息只有一個消費者可以消費。

當前沒有消費者時,消息一直保存,直到被消費者消費。

image.png

2,發佈訂閱topic:可重複消費,發佈給所有訂閱者。

生產者發佈消息到topic中,多個訂閱者收到並消費消息。

queue不同,發佈到topic中的消息會被所有訂閱者消費。

當生產者發佈消息時,不管是否有訂閱者,都不保存消息。

image.png

JMS規範定義的2類消息傳輸模型queue和topic比較:


Queue

Topic

模型

點對點Point-to-Point

發佈訂閱publish/subscribe

有無狀態

queue消息在消費前被一直保存在mq服務器上文件或者配置DB

topic數據默認不保存,是無狀態的。

完整性保障

queue保證每條消息都被消費者接收到

topic不保證生產者發佈的每條消息都被訂閱者接收到

消息是否會丟失

生產者發送消息到queue,消費者接收到消息。如果沒有消費者,將一直保存,不會丟失。

生產者發佈消息到topic時,當前的訂閱者都能夠接收到消息。如果當前沒有訂閱者,該消息就丟失。

消息發佈接收策略

一對一的消息發佈接收策略,一個生產者發送的消息只被一個消費者接收。mq服務器收到回覆後,將這個消息刪除。

一對多的消息發佈接收策略,同一個topic的多個訂閱者都能收到生產者發佈的消息。


附錄2:JMS規範定義的5類消息

字符串TextMessage,

鍵值對MapMessage,

序列化對象ObjectMessage

字節流BytesMessage

數據流StreamMessage

image.png

ActiveMQ支持5類JMS消息,增加了二進制大文件消息BlobMessage:

image.png

--------------------------------

如果您覺得這篇文章對您有幫助,請點個“贊”,博主感激不盡!

Jext技術社區專注領域:軟件工程實踐,JIRA研發管理分佈式系統架構,軟件質量保障

image.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章