#IT明星不是梦#【开发实战】搭建跨平台异步任务调度系统

目录:

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

猜你喜欢

转载自blog.51cto.com/13851865/2471543