day06-智能调度之调度任务

课程安排

  • 什么是智能调度
  • 实现订单转运单(运单中包含路线的一些信息)
  • 美团Leaf使用入门
  • 完善运单服务
  • 合并运单

1、背景说明

一个好的调度系统可以高效的管理着运单、运输任务、司机作业单、快递员取派件任务等

2、智能调度

2.1、为什么需要调度?

用户下单后,应该哪个网点的哪个快递员上门呢?

1 发件人地址信息定位到所属服务范围内的网点进行服务

2 对快递员的工作情况以及排班情况进行分析,才能确定哪个快递员进行服务。

快递员把快件拿回到网点后,假设这个快件是从上海寄往北京的,是网点直接开车送到北京吗

1 车辆尽可能的满载,集中化运输。

2 一般物流公司会有很多的车辆、路线、司机,而每个路线都会设置不同的车次,如何能够将快件合理的分配到车辆上,分配时需要参考车辆的载重、司机的排班,车辆的状态以及车次等信息

快件到收件人地址所在服务范围内的网点了,系统该如何分配快递员?

还有一些其他的情况,比如:快件拒收应该怎么处理?车辆故障不能使用怎么处理?一车多个司机,运输任务是如何划分?等等

2.2、整体核心业务流程

实线为主流程触发,虚线为mq等机制触发

关键流程说明:

  • 用户下单后,会产生取件任务,该任务也是由调度中心进行调度的
  • 订单转运单后,会发送消息到调度中心,在调度中心中对相同节点的运单进行合并(这里是指最小转运单元)
  • 调度中心同样也会对派件任务进行调度,用于生成快递员的派件任务
  • 司机的出库和入库操作也是流程中的核心动作,尤其是入库操作,是推动运单流转的关键
  • 订单在网点的流转也是由调度中心进行调度的

3、订单转运单

订单和运单的区别:

订单:与电商平台物品有关的购买信息

运单(数据库表为sl_transpport_order):货物运输时的路径,成本等的信息

介绍:快递员已经接收到订单,使用mq往消息队列中发送消息。

现在任务为将订单转运单,补充消息

1查询订单相关的订单是否已经创建(mq多次发送,或网络延迟造成多次发送)

2 下单消息发送过来,在转换为运单的过程中,用户又取消了订单等原因导致订单不存在。故须判断订单是否存在

3 本项目的订单号有规定为sl+13位数字。假若使用数据库进行创建的自增id,性能差,这里使用美团leaf算法进行计算

4 使用mq发消息通知其他系统

3.2、运单表结构

运单表是在sl_work数据库中,表名为:sl_transport_order,结构如下:

其中当运单和某辆车产生关联时,为以装车。当车装满开始运送时为运输中

往mq中发消息就变成了待调度的货物

CREATE TABLE `sl_transport_order` (
  `id` varchar(18) CHARACTER SET utf16 COLLATE utf16_general_ci NOT NULL DEFAULT '' COMMENT 'id',
  `order_id` bigint NOT NULL COMMENT '订单ID',
  `status` int DEFAULT NULL COMMENT '运单状态(1.新建 2.已装车 3.运输中 4.到达终端网点 5.已签收 6.拒收)',
  `scheduling_status` int DEFAULT NULL COMMENT '调度状态(1.待调度2.未匹配线路3.已调度)',
  `start_agency_id` bigint DEFAULT NULL COMMENT '起始网点id',
  `end_agency_id` bigint DEFAULT NULL COMMENT '终点网点id',
  `current_agency_id` bigint DEFAULT NULL COMMENT '当前所在机构id',
  `next_agency_id` bigint DEFAULT NULL COMMENT '下一个机构id',
  `transport_line` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '完整的运输路线',
  `total_volume` decimal(32,4) DEFAULT NULL COMMENT '货品总体积,单位:立方米',
  `total_weight` decimal(32,2) DEFAULT NULL COMMENT '货品总重量,单位:kg',
  `is_rejection` tinyint(1) DEFAULT NULL COMMENT '是否为拒收运单',
  `created` datetime DEFAULT NULL COMMENT '创建时间',
  `updated` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `order_id` (`order_id`) USING BTREE,
  KEY `created` (`created`) USING BTREE,
  KEY `status` (`status`) USING BTREE,
  KEY `scheduling_status` (`scheduling_status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='运单表';

给经常查询的数据构建索引。查询时加快速度

索引构建太多的缺点:1占用空间2增删改时候需重新构建结构,操作复杂

3.3、揽收成功的消息

快递员揽件成功后,发出消息,这个逻辑是在sl-express-ms-web-courier工程的com.sl.ms.web.courier.service.impl.TaskServiceImpl#pickup()方法中实现的。

如下:

消息中包含了订单id,取到消息后根据id查询订单其他消息构建出对应的运单消息

 

/**
     * 快递员取件成功
     *
     * @param msg 消息
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = Constants.MQ.Queues.WORK_COURIER_PICKUP_SUCCESS),
            exchange = @Exchange(name = Constants.MQ.Exchanges.COURIER, type = ExchangeTypes.TOPIC),
            key = Constants.MQ.RoutingKeys.COURIER_PICKUP
    ))
    public void listenCourierPickupMsg(String msg) {
        log.info("接收到快递员取件成功的消息 >>> msg = {}", msg);
        //解析消息
        CourierMsg courierMsg = JSONUtil.toBean(msg, CourierMsg.class);
        //消费消息,调用transportOrderService的订单转运单方法
        this.transportOrderService.orderToTransportOrder(courierMsg.getOrderId());
        //TODO 发送运单跟踪消息

    }

消息的交换机名称、路由key均是在sl-express-common工程中的Constants.MQ常量类中定义的。

3.4、生成运单号

对于运单号的生成有特殊的要求,格式:SL+13位数字,例如:SL1000000000760,对于这个需求,如果采用MP提供的雪花id生成是19位,是不能满足需求的,所以我们需要自己生成id,并且要确保唯一不能重复。

在这里我们采用美团的Leaf作为id生成服务,其源码托管于GitHub:

GitHub - Meituan-Dianping/Leaf: Distributed ID Generate Service

这里有个美团的技术播客,专门介绍了Leaf:

Leaf——美团点评分布式ID生成系统 - 美团技术团队

目前Leaf覆盖了美团点评公司内部金融、餐饮、外卖、酒店旅游、猫眼电影等众多业务线。在4C8G VM基础上,通过公司RPC方式调用,QPS压测结果近5w/s,TP999 1ms。

Leaf 提供两种生成的ID的方式(segment模式,snowflake模式改进了雪花算法强依赖系统时钟的方式),我们采用segment模式(号段自增)来生成运单号。

订单号中还包括手机后四位验证,图片验证码验证。从而较好的保证了订单的安全性

3.4.1、号段模式

服务集群每次取一个号段(step规定号段长度),max_id为所有集群下最大的id。

biz_tag为leaf算法为对应某个表单设置的属性项,如运单表

号段模式采用的是基于MySQL数据生成id的,它并不是基于MySQL表中的自增长实现的,因为基于MySQL的自增长方案对于数据库的依赖太大了,性能不好,Leaf的号段模式是基于一张表来实现,每次获取一个号段,生成id时从内存中自增长,当号段用完后再去更新数据库表,如下:

缺点:

Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。

双buffer优化:

两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。以此解决号段用完时的阻塞。

采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。

  • 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS(秒处理事务数)的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。

3.4.2、部署服务(了解)

我们只用到了号段的方式,并没有使用雪花方式,所以只需要创建数据库表即可,无需安装ZooKeeper。

Leaf官方是没有docker镜像的,我们将其进行了镜像制作,并且上传到阿里云仓库,可以直接下载使用。目前已经在101机器部署完成。

双buffer实现测试:当网页中的号段id发出超过100的10%时,maxid会增加一个号段的值。此时即为另一个buffer中在存储号段

shell命令

docker run \
-d \
-v /itcast/meituan-leaf/leaf.properties:/app/conf/leaf.properties \
--name meituan-leaf \
-p 28838:8080 \
--restart=always \
registry.cn-hangzhou.aliyuncs.com/itheima/meituan-leaf:1.0.1

配置

leaf.name=leaf-server
leaf.segment.enable=true
leaf.jdbc.url=jdbc:mysql://192.168.150.101:3306/sl_leaf?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false
leaf.jdbc.username=root
leaf.jdbc.password=123
leaf.snowflake.enable=false
#leaf.snowflake.zk.address=
#leaf.snowflake.port=

创建sl_leaf数据库脚本:

CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '',
  `max_id` bigint NOT NULL DEFAULT '1',
  `step` int NOT NULL,
  `description` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 插入运单号生成规划数据
INSERT INTO `leaf_alloc` (`biz_tag`, `max_id`, `step`, `description`, `update_time`) VALUES ('transport_order', 1000000000001, 100, 'Test leaf Segment Mode Get Id', '2022-07-07 11:32:16');

测试:

# transport_order 与 biz_tag字段的值相同
http://192.168.150.101:28838/api/segment/get/transport_order
#监控
http://192.168.150.101:28838/cache

3.4.3、封装服务

在项目中,已经将Leaf集成到sl-express-common工程中,代码如下:

package com.sl.transport.common.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.sl.transport.common.enums.IdEnum;
import com.sl.transport.common.exception.SLException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
 * id服务,用于生成自定义的id
 */
@Service
public class IdService {
    @Value("${sl.id.leaf:}")
    private String leafUrl;
    /**
     * 生成自定义id
     *
     * @param idEnum id配置
     * @return id值
     */
    public String getId(IdEnum idEnum) {
        String idStr = this.doGet(idEnum);
        return idEnum.getPrefix() + idStr;
    }
    private String doGet(IdEnum idEnum) {
        if (StrUtil.isEmpty(this.leafUrl)) {
            throw new SLException("生成id,sl.id.leaf配置不能为空.");
        }
        //访问leaf服务获取id
        String url = StrUtil.format("{}/api/{}/get/{}", this.leafUrl, idEnum.getType(), idEnum.getBiz());
        //设置超时时间为10s
        HttpResponse httpResponse = HttpRequest.get(url)
                .setReadTimeout(10000)
                .execute();
        if (httpResponse.isOk()) {
            return httpResponse.body();
        }
        throw new SLException(StrUtil.format("访问leaf服务出错,leafUrl = {}, idEnum = {}", this.leafUrl, idEnum));
    }
}

 本项目只有运单服务使用,故此处枚举只有一个

package com.sl.transport.common.enums;
public enum IdEnum implements BaseEnum {
    TRANSPORT_ORDER(1, "运单号", "transport_order", "segment", "SL");
    private Integer code;
    private String value;
    private String biz; //业务名称
    private String type; //类型:自增长(segment),雪花id(snowflake)
    private String prefix;//id前缀
    IdEnum(Integer code, String value, String biz, String type, String prefix) {
        this.code = code;
        this.value = value;
        this.biz = biz;
        this.type = type;
        this.prefix = prefix;
    }
    @Override
    public Integer getCode() {
        return this.code;
    }
    @Override
    public String getValue() {
        return this.value;
    }
    public String getBiz() {
        return biz;
    }
    public String getType() {
        return type;
    }
    public String getPrefix() {
        return prefix;
    }
    @Override
    public String toString() {
        final StringBuffer sb = new StringBuffer("IdEnum{");
        sb.append("code=").append(code);
        sb.append(", value='").append(value).append('\'');
        sb.append(", biz='").append(biz).append('\'');
        sb.append(", type='").append(type).append('\'');
        sb.append(", prefix='").append(prefix).append('\'');
        sb.append('}');
        return sb.toString();
    }
}

使用步骤:

3.5、编码实现

订单转运单

订单转运单的实现是在sl-express-ms-work-service微服务中完成的,git地址:http://git.sl-express.com/sl/sl-express-ms-work-service.git

上文中提到快递员通过mq接收消息,消费消息,从中获得订单id,传入transportOrderService的orderToTransportOrder方法中进行订单转换运单的操作

transportOrderService.orderToTransportOrder()具体编码实现:

逻辑:

1使用orderId通过TransportOrderService中.findByOrderId()方法获取运单实体

若非空,说明运单已存在,return

2幂等性判断

订单,订单货物的体积和重量,位置信息),查询数据库表中对应的信息为非空,否则return

使用如下方法查询 orderFeign.findById(orderId)  cargoFeign.findByOrderId(orderId)  

orderFeign.findOrderLocationByOrderId(orderId)

分别对应数据库sl_oms的三张表

3 从查询到的位置信息的orderLocationDTO.getReceiveAgentId()中获取起点和终点网点的id

使用Convert.toLong将字符串转换为Long类型

4 定义是否参与调度的bool变量(起始和结束网点相同的不参与调度)boolean isDispatch = true

定义路线信息的变量 TransprotLineNodeDTO

5 判断起始和终点是否为一个网点,是 将idDispatch设为false

否 进行路线策略查询transportLineFeign.queryPathByDispatchMethod(sendAgentId, receiveAgentId)。这里固定写死为距离最短查询,后期将根据redis中的内容选择策略成本或距离

并判断transportLineNodeDTO和CollUtil.isEmpty(transportLineNodeDTO.getNodeList())是否为空

transportLineNodeDTO.getNodeList()为路线中的各个节点

空则报错

6创建新的运单对象

设置属性 id (根据美团leaf获得 idService.getId(IdEnum.TRANSPORT_ORDER)  )

OrderId,StartAgencyId,EndAgencyId,CurrentAgencyId(当前所在机构id刚开始为起始机构id)

判断transportLineNodeDTO是否为空

空(下个网点就是当前网点,不参与调度)set  NextAgencyId,Status运单状态,SchedulingStatus调度状态

非空(进行转运)  set  Status运单状态,SchedulingStatus调度状态

NextAgencyId( transportLineNodeDTO.getNodeList().get(1).getId() )下标为1是路线中的第二个

setTransportLine(JSONUtil.toJsonStr(transportLineNodeDTO))

设置信息 setTotalVolume(cargoDto.getVolume()),setTotalWeight(cargoDto.getWeight()),setIsRejection(false)默认非拒收订单

7保存boolean result = super.save(transportOrder)

8保存成功后发消息,失败时抛异常

isDispatch为true 发消息到调度中心sendTransportOrderMsgToDispatch(transportOrder)

并  //使用mq接口发消息通知其他系统,运单已经生成

为false 更新订单状态sendUpdateStatusMsg(ListUtil.toList(transportOrder.getId()), TransportOrderStatus.ARRIVED_END),

生成派件任务sendDispatchTaskMsgToDispatch(transportOrder)

mq:交换机根据路由键的规则将消息路由到适当的队列

发送消息设置延迟:服务端代码过长,需要时间运行。保证在其他服务查询时服务端订单转换运单,数据更新已经完成。

String msg = TransportOrderMsg.builder()
                    .id(transportOrder.getId())
                    .orderId(transportOrder.getOrderId())
                    .created(DateUtil.current())
                    .build().toJson();
            this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED,
                    Constants.MQ.RoutingKeys.TRANSPORT_ORDER_CREATE, msg, Constants.MQ.NORMAL_DELAY);
            return transportOrder;

代码:

@Resource
    private OrderFeign orderFeign;
    @Resource
    private MQFeign mqFeign;
    @Resource
    private CargoFeign cargoFeign;
    @Resource
    private TransportLineFeign transportLineFeign;
    @Resource
    private OrganFeign organFeign;
    @Resource
    private IdService idService;
    @Transactional(rollbackFor = Exception.class,timeout = 120000)
    @Override
    public TransportOrderEntity orderToTransportOrder(Long orderId) {
        //幂等性校验
        TransportOrderEntity transportOrderEntity = this.findByOrderId(orderId);
        if (ObjectUtil.isNotEmpty(transportOrderEntity)) {
            return transportOrderEntity;
        }
        //查询订单
        OrderDTO orderDTO = this.orderFeign.findById(orderId);
        if (ObjectUtil.isEmpty(orderDTO)) {
            throw new SLException(WorkExceptionEnum.ORDER_NOT_FOUND);
        }
        //查询货物的重量和体积数据
        OrderCargoDTO cargoDto = this.cargoFeign.findByOrderId(orderId);
        if (ObjectUtil.isEmpty(cargoDto)) {
            throw new SLException(WorkExceptionEnum.ORDER_CARGO_NOT_FOUND);
        }
        //查询位置信息
        OrderLocationDTO orderLocationDTO = orderFeign.findOrderLocationByOrderId(orderId);
        if (ObjectUtil.isEmpty(orderLocationDTO)) {
            throw new SLException(WorkExceptionEnum.ORDER_LOCATION_NOT_FOUND);
        }
        Long sendAgentId = Convert.toLong(orderLocationDTO.getSendAgentId());//起始网点id
        Long receiveAgentId = Convert.toLong(orderLocationDTO.getReceiveAgentId());//终点网点id
        //默认参与调度
        boolean isDispatch = true;
        TransportLineNodeDTO transportLineNodeDTO = null;
        if (ObjectUtil.equal(sendAgentId, receiveAgentId)) {
            //起点、终点是同一个网点,不需要规划路线,直接发消息生成派件任务即可
            isDispatch = false;
        } else {
            //根据起始机构规划运输路线
            transportLineNodeDTO = this.transportLineFeign.queryPathByDispatchMethod(sendAgentId, receiveAgentId);
            if (ObjectUtil.isEmpty(transportLineNodeDTO) || CollUtil.isEmpty(transportLineNodeDTO.getNodeList())) {
                throw new SLException(WorkExceptionEnum.TRANSPORT_LINE_NOT_FOUND);
            }
        }
        //创建新的运单对象
        TransportOrderEntity transportOrder = new TransportOrderEntity();
        transportOrder.setId(this.idService.getId(IdEnum.TRANSPORT_ORDER)); //设置id
        transportOrder.setOrderId(orderId);//订单ID
        transportOrder.setStartAgencyId(sendAgentId);//起始网点id
        transportOrder.setEndAgencyId(receiveAgentId);//终点网点id
        transportOrder.setCurrentAgencyId(sendAgentId);//当前所在机构id
        if (ObjectUtil.isNotEmpty(transportLineNodeDTO)) {
            transportOrder.setStatus(TransportOrderStatus.CREATED);//运单状态(1.新建 2.已装车 3.运输中 4.到达终端网点 5.已签收 6.拒收)
            transportOrder.setSchedulingStatus(TransportOrderSchedulingStatus.TO_BE_SCHEDULED);//调度状态(1.待调度2.未匹配线路3.已调度)
            transportOrder.setNextAgencyId(transportLineNodeDTO.getNodeList().get(1).getId());//下一个机构id
            transportOrder.setTransportLine(JSONUtil.toJsonStr(transportLineNodeDTO));//完整的运输路线
        } else {
            //下个网点就是当前网点
            transportOrder.setNextAgencyId(sendAgentId);
            transportOrder.setStatus(TransportOrderStatus.ARRIVED_END);//运单状态(1.新建 2.已装车 3.运输中 4.到达终端网点 5.已签收 6.拒收)
            transportOrder.setSchedulingStatus(TransportOrderSchedulingStatus.SCHEDULED);//调度状态(1.待调度2.未匹配线路3.已调度)
        }
        transportOrder.setTotalVolume(cargoDto.getVolume());//货品总体积,单位m^3
        transportOrder.setTotalWeight(cargoDto.getWeight());//货品总重量,单位kg
        transportOrder.setIsRejection(false); //默认非拒收订单
        boolean result = super.save(transportOrder);
        if (result) {
            if (isDispatch) {
                //发送消息到调度中心,进行调度
                this.sendTransportOrderMsgToDispatch(transportOrder);
                //发消息通知其他系统,运单已经生成
                String msg = TransportOrderMsg.builder()
                        .id(transportOrder.getId())
                        .orderId(transportOrder.getOrderId())
                        .created(DateUtil.current())
                        .build().toJson();
                this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED,
                        Constants.MQ.RoutingKeys.TRANSPORT_ORDER_CREATE, msg, Constants.MQ.NORMAL_DELAY);
            } else {
                // 不需要调度 发送消息更新订单状态
                this.sendUpdateStatusMsg(ListUtil.toList(transportOrder.getId()), TransportOrderStatus.ARRIVED_END);
                //不需要调度,发送消息生成派件任务
                this.sendDispatchTaskMsgToDispatch(transportOrder);
            }


            return transportOrder;
        }
        //保存失败
        throw new SLException(WorkExceptionEnum.TRANSPORT_ORDER_SAVE_ERROR);
    }

发送消息的代码实现(了解):

sendTransportOrderMsgToDispatch:通过map绑定参数,进行mq消息传递

sendDispatchTaskMsgToDispatch:如果是中午12点到的快递,当天22点前,否则,第二天22点前。使用offset 0或1进行添加

sendPickupDispatchTaskMsgToDispatch:

//(1)运单为空:取件任务取消,取消原因为返回网点;重新调度位置取寄件人位置
//(2)运单不为空:生成的是派件任务,需要根据拒收状态判断位置是寄件人还是收件人
// 拒收:寄件人  其他:收件人

sendUpdateStatusMsg:运单只要创建就发送消息,等待其他微服务调用运单消息

/**
     * 发送运单消息到调度中,参与调度
     */
    private void sendTransportOrderMsgToDispatch(TransportOrderEntity transportOrder) {
        Map<Object, Object> msg = MapUtil.builder()
                .put("transportOrderId", transportOrder.getId())
                .put("currentAgencyId", transportOrder.getCurrentAgencyId())
                .put("nextAgencyId", transportOrder.getNextAgencyId())
                .put("totalWeight", transportOrder.getTotalWeight())
                .put("totalVolume", transportOrder.getTotalVolume())
                .put("created", System.currentTimeMillis()).build();
        String jsonMsg = JSONUtil.toJsonStr(msg);
        //发送消息,延迟5秒,确保本地事务已经提交,可以查询到数据
        this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED,
                Constants.MQ.RoutingKeys.JOIN_DISPATCH, jsonMsg, Constants.MQ.LOW_DELAY);
    }
    /**
     * 发送生成取派件任务的消息
     *
     * @param transportOrder 运单对象
     */
    private void sendDispatchTaskMsgToDispatch(TransportOrderEntity transportOrder) {
        //预计完成时间,如果是中午12点到的快递,当天22点前,否则,第二天22点前
        int offset = 0;
        if (LocalDateTime.now().getHour() >= 12) {
            offset = 1;
        }
        LocalDateTime estimatedEndTime = DateUtil.offsetDay(new Date(), offset)
                .setField(DateField.HOUR_OF_DAY, 22)
                .setField(DateField.MINUTE, 0)
                .setField(DateField.SECOND, 0)
                .setField(DateField.MILLISECOND, 0).toLocalDateTime();
        //发送分配快递员派件任务的消息
        OrderMsg orderMsg = OrderMsg.builder()
                .agencyId(transportOrder.getCurrentAgencyId())
                .orderId(transportOrder.getOrderId())
                .created(DateUtil.current())
                .taskType(PickupDispatchTaskType.DISPATCH.getCode()) //派件任务
                .mark("系统提示:派件前请与收件人电话联系.")
                .estimatedEndTime(estimatedEndTime).build();
        //发送消息
        this.sendPickupDispatchTaskMsgToDispatch(transportOrder, orderMsg);
    }
    /**
     * 发送消息到调度中心,用于生成取派件任务
     *
     * @param transportOrder 运单
     * @param orderMsg       消息内容
     */
    @Override
    public void sendPickupDispatchTaskMsgToDispatch(TransportOrderEntity transportOrder, OrderMsg orderMsg) {
        //查询订单对应的位置信息
        OrderLocationDTO orderLocationDTO = this.orderFeign.findOrderLocationByOrderId(orderMsg.getOrderId());
        //(1)运单为空:取件任务取消,取消原因为返回网点;重新调度位置取寄件人位置
        //(2)运单不为空:生成的是派件任务,需要根据拒收状态判断位置是寄件人还是收件人
        // 拒收:寄件人  其他:收件人
        String location;
        if (ObjectUtil.isEmpty(transportOrder)) {
            location = orderLocationDTO.getSendLocation();
        } else {
            location = transportOrder.getIsRejection() ? orderLocationDTO.getSendLocation() : orderLocationDTO.getReceiveLocation();
        }
        Double[] coordinate = Convert.convert(Double[].class, StrUtil.split(location, ","));
        Double longitude = coordinate[0];
        Double latitude = coordinate[1];
        //设置消息中的位置信息
        orderMsg.setLongitude(longitude);
        orderMsg.setLatitude(latitude);
        //发送消息,用于生成取派件任务
        this.mqFeign.sendMsg(Constants.MQ.Exchanges.ORDER_DELAYED, Constants.MQ.RoutingKeys.ORDER_CREATE,
                orderMsg.toJson(), Constants.MQ.NORMAL_DELAY);
    }
    private void sendUpdateStatusMsg(List<String> ids, TransportOrderStatus transportOrderStatus) {
        String msg = TransportOrderStatusMsg.builder()
                .idList(ids)
                .statusName(transportOrderStatus.name())
                .statusCode(transportOrderStatus.getCode())
                .build().toJson();
        //将状态名称写入到路由key中,方便消费方选择性的接收消息
        String routingKey = Constants.MQ.RoutingKeys.TRANSPORT_ORDER_UPDATE_STATUS_PREFIX + transportOrderStatus.name();
        this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED, routingKey, msg, Constants.MQ.LOW_DELAY);
    }
测试:

测试订单转运单功能,需要启动所必须的一些服务,base、oms、transport服务,启动命令如下:

#在101机器执行如下命令
docker start sl-express-ms-base-service
docker start sl-express-ms-oms-service
docker start sl-express-ms-transport-service

编写测试用例:

快递员揽收成功后,发出消息(中包含订单id的信息),再使用订单转运单的方法

package com.sl.ms.work.mq;
import cn.hutool.json.JSONUtil;
import com.sl.transport.common.vo.CourierMsg;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class CourierMQListenerTest {
    @Resource
    private CourierMQListener courierMQListener;
    @Test
    void listenCourierTaskMsg() {
    }
    @Test
    void listenCourierPickupMsg() {
        CourierMsg courierMsg = new CourierMsg();
        //目前只用到订单id
        courierMsg.setOrderId(1564170062718373889L);
        String msg = JSONUtil.toJsonStr(courierMsg);
        this.courierMQListener.listenCourierPickupMsg(msg);
    }
}

测试时,需要确保在sl_oms数据库中的sl_order、sl_order_cargo、sl_order_location表中有对应的订单数据。如果没有数据,可以通过以下SQL插入测试数据或者通过用户端进行下单

use `sl_oms`;
INSERT INTO `sl_order` (`id`, `trading_order_no`, `trading_channel`, `payment_method`, `payment_status`, `amount`, `refund`, `is_refund`, `order_type`, `pickup_type`, `create_time`, `member_id`, `receiver_member_id`, `receiver_province_id`, `receiver_city_id`, `receiver_county_id`, `receiver_address`, `receiver_address_id`, `receiver_name`, `receiver_phone`, `sender_province_id`, `sender_city_id`, `sender_county_id`, `sender_address`, `sender_address_id`, `sender_name`, `sender_phone`, `current_agency_id`, `estimated_arrival_time`, `mark`, `estimated_start_time`, `distance`, `status`, `created`, `updated`) VALUES (1590586236289646594, 0, '0', 1, 1, 12.00, 0.00, NULL, 1, 0, '2022-11-10 14:04:45', 1555110960890843137, NULL, 545532, 545533, 545763, '西华大道16号', 1575682056178180097, '吴思涵', '15645237852', 545532, 545533, 545669, '光华大道一段1666号', 1575763704869625857, '邓诗涵', '15745678521', 1024771753995515873, '2022-11-14 14:04:45', NULL, '2022-11-10 15:04:00', 11265, 23000, '2022-11-10 14:04:45', '2022-11-10 14:04:45');
INSERT INTO `sl_order` (`id`, `trading_order_no`, `trading_channel`, `payment_method`, `payment_status`, `amount`, `refund`, `is_refund`, `order_type`, `pickup_type`, `create_time`, `member_id`, `receiver_member_id`, `receiver_province_id`, `receiver_city_id`, `receiver_county_id`, `receiver_address`, `receiver_address_id`, `receiver_name`, `receiver_phone`, `sender_province_id`, `sender_city_id`, `sender_county_id`, `sender_address`, `sender_address_id`, `sender_name`, `sender_phone`, `current_agency_id`, `estimated_arrival_time`, `mark`, `estimated_start_time`, `distance`, `status`, `created`, `updated`) VALUES (1590586360180998146, 0, '0', 1, 1, 12.00, 0.00, NULL, 1, 0, '2022-11-10 14:05:15', 1555110960890843137, NULL, 545532, 545533, 545669, '光华大道一段1666号', 1575763704869625857, '邓诗涵', '15745678521', 545532, 545533, 545669, '光华大道一段1666号', 1575681460301799425, '李成百', '15812357412', 1024771753995515873, '2022-11-14 14:05:15', NULL, '2022-11-10 15:05:00', 1, 23000, '2022-11-10 14:05:15', '2022-11-10 14:05:15');
INSERT INTO `sl_order` (`id`, `trading_order_no`, `trading_channel`, `payment_method`, `payment_status`, `amount`, `refund`, `is_refund`, `order_type`, `pickup_type`, `create_time`, `member_id`, `receiver_member_id`, `receiver_province_id`, `receiver_city_id`, `receiver_county_id`, `receiver_address`, `receiver_address_id`, `receiver_name`, `receiver_phone`, `sender_province_id`, `sender_city_id`, `sender_county_id`, `sender_address`, `sender_address_id`, `sender_name`, `sender_phone`, `current_agency_id`, `estimated_arrival_time`, `mark`, `estimated_start_time`, `distance`, `status`, `created`, `updated`) VALUES (1590587504731062273, 0, '0', 1, 1, 18.00, 0.00, NULL, 2, 0, '2022-11-10 14:09:47', 1555110960890843137, NULL, 161792, 161793, 165026, '上海迪士尼度假区', 1590587449528274946, '吕奉先', '18512345678', 545532, 545533, 545669, '光华大道一段1666号', 1575763704869625857, '邓诗涵', '15745678521', 1024771753995515873, '2022-11-14 14:09:47', NULL, '2022-11-10 15:09:00', 1990898, 23000, '2022-11-10 14:09:47', '2022-11-10 14:09:47');
INSERT INTO `sl_order_cargo` (`id`, `order_id`, `tran_order_id`, `goods_type_id`, `name`, `unit`, `cargo_value`, `cargo_barcode`, `quantity`, `volume`, `weight`, `remark`, `total_volume`, `total_weight`, `created`, `updated`) VALUES (1590586236767797249, 1590586236289646594, NULL, '1552846618315661320', '单肩包', NULL, NULL, NULL, 1, 1.0000000000, 1.0000000000, NULL, 1.0000000000, 1.0000000000, '2022-11-10 14:04:45', '2022-11-10 14:04:45');
INSERT INTO `sl_order_cargo` (`id`, `order_id`, `tran_order_id`, `goods_type_id`, `name`, `unit`, `cargo_value`, `cargo_barcode`, `quantity`, `volume`, `weight`, `remark`, `total_volume`, `total_weight`, `created`, `updated`) VALUES (1590586360294244354, 1590586360180998146, NULL, '1552846618315661321', '项链', NULL, NULL, NULL, 1, 1.0000000000, 1.0000000000, NULL, 1.0000000000, 1.0000000000, '2022-11-10 14:05:15', '2022-11-10 14:05:15');
INSERT INTO `sl_order_cargo` (`id`, `order_id`, `tran_order_id`, `goods_type_id`, `name`, `unit`, `cargo_value`, `cargo_barcode`, `quantity`, `volume`, `weight`, `remark`, `total_volume`, `total_weight`, `created`, `updated`) VALUES (1590587504747839490, 1590587504731062273, NULL, '1552846618315661323', '跑步鞋', NULL, NULL, NULL, 1, 1.0000000000, 1.0000000000, NULL, 1.0000000000, 1.0000000000, '2022-11-10 14:09:47', '2022-11-10 14:09:47');
INSERT INTO `sl_order_location` (`id`, `order_id`, `send_location`, `receive_location`, `send_agent_id`, `receive_agent_id`, `status`, `created`, `updated`) VALUES (1590586238537793537, 1590586236289646594, '103.960686,30.671626', '104.034504,30.721027', '1024771753995515873', '1024771466287232801', '1', '2022-11-10 14:04:46', '2022-11-10 14:04:46');
INSERT INTO `sl_order_location` (`id`, `order_id`, `send_location`, `receive_location`, `send_agent_id`, `receive_agent_id`, `status`, `created`, `updated`) VALUES (1590586360315215874, 1590586360180998146, '103.960686,30.671626', '103.960686,30.671626', '1024771753995515873', '1024771753995515873', '1', '2022-11-10 14:05:15', '2022-11-10 14:05:15');
INSERT INTO `sl_order_location` (`id`, `order_id`, `send_location`, `receive_location`, `send_agent_id`, `receive_agent_id`, `status`, `created`, `updated`) VALUES (1590587504756228097, 1590587504731062273, '103.960686,30.671626', '121.661735,31.141333', '1024771753995515873', '1024981295454874273', '1', '2022-11-10 14:09:47', '2022-11-10 14:09:47');

结果

第二个订单发送和接收地相同,故不参加调度,没有路线

4、完善运单服务

前面已经完成了订单转运单的功能,接下来我们将完善运单中的其他基本的实现,这部分代码以阅读、理解为主。

其中pageQueryByTaskId()、updateByTaskId()方法在学习运输任务相关业务时进行实现。

4.1、获取运单分页数据

mybatisPlus中可以将bool类型参数放入查询语句中,如下

 实现

@Override
    public Page<TransportOrderEntity> findByPage(TransportOrderQueryDTO transportOrderQueryDTO) {
        Page<TransportOrderEntity> iPage = new Page<>(transportOrderQueryDTO.getPage(), transportOrderQueryDTO.getPageSize());
        //设置查询条件
        LambdaQueryWrapper<TransportOrderEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.like(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getId()), TransportOrderEntity::getId, transportOrderQueryDTO.getId());
        lambdaQueryWrapper.like(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getOrderId()), TransportOrderEntity::getOrderId, transportOrderQueryDTO.getOrderId());
        lambdaQueryWrapper.eq(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getStatus()), TransportOrderEntity::getStatus, transportOrderQueryDTO.getStatus());
        lambdaQueryWrapper.eq(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getSchedulingStatus()), TransportOrderEntity::getSchedulingStatus, transportOrderQueryDTO.getSchedulingStatus());
        lambdaQueryWrapper.eq(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getStartAgencyId()), TransportOrderEntity::getStartAgencyId, transportOrderQueryDTO.getStartAgencyId());
        lambdaQueryWrapper.eq(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getEndAgencyId()), TransportOrderEntity::getEndAgencyId, transportOrderQueryDTO.getEndAgencyId());
        lambdaQueryWrapper.eq(ObjectUtil.isNotEmpty(transportOrderQueryDTO.getCurrentAgencyId()), TransportOrderEntity::getCurrentAgencyId, transportOrderQueryDTO.getCurrentAgencyId());
        lambdaQueryWrapper.orderByDesc(TransportOrderEntity::getCreated);
        return super.page(iPage, lambdaQueryWrapper);
    }

注:如果订单id不为空,根据订单id进行模糊查询。此时是单个订单的精确查询。

不输入id时,根据page和pagesize进行查询多个运单进行显示

4.2、订单id获取运单信息

@Override
    public TransportOrderEntity findByOrderId(Long orderId) {
        LambdaQueryWrapper<TransportOrderEntity> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(TransportOrderEntity::getOrderId, orderId);
        return super.getOne(queryWrapper);
    }
    @Override
    public List<TransportOrderEntity> findByOrderIds(Long[] orderIds) {
        LambdaQueryWrapper<TransportOrderEntity> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.in(TransportOrderEntity::getOrderId, orderIds);
        return super.list(queryWrapper);
    }

4.3、运单ids获取运单列表

@Override
    public List<TransportOrderEntity> findByIds(String[] ids) {
        LambdaQueryWrapper<TransportOrderEntity> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.in(TransportOrderEntity::getId, ids);
        return super.list(queryWrapper);
    }

4.4、根据运单号搜索运单

用户端输入一部分订单号后进行模糊查询

@Override
    public List<TransportOrderEntity> searchById(String id) {
        LambdaQueryWrapper<TransportOrderEntity> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(TransportOrderEntity::getId, id);
        return super.list(queryWrapper);
    }

4.5、统计状态的数量

统计所有枚举类设置初值为0,只更改后续查询到的状态数量。这样保证了没有的状态可以设置为0显示

逻辑

获取枚举类中所有的值抓换为数组

TransportOrderStatus[] values = TransportOrderStatus.values()

将数组使用流进行处理转换为TransportOrderStatusCountDTO对象。使用map的方法将每一个枚举的值转换为DTO对象

Arrays.stream(TransportOrderStatus.values()).map()

map中将每一个枚举的值转换为DTO对象

transportOrderStatus -> TransportOrderStatusCountDTO.builder()
                        .status(transportOrderStatus)
                        .statusCode(transportOrderStatus.getCode())
                        .count(0L)
                        .build()

将他们收集为集合(转换为List形式)

这里的baseMapper为函数头继承的mapper类型

数据库语句查询

查询到的结果使用两层循环,如果有(即.getStatusCode得到的code相同),则将查询到的状态数量更改

实现

@Override
    public List<TransportOrderStatusCountDTO> findStatusCount() {
        //将所有的枚举状态放到集合中
        List<TransportOrderStatusCountDTO> statusCountList = Arrays.stream(TransportOrderStatus.values())
                .map(transportOrderStatus -> TransportOrderStatusCountDTO.builder()
                        .status(transportOrderStatus)
                        .statusCode(transportOrderStatus.getCode())
                        .count(0L)
                        .build())
                .collect(Collectors.toList());
        //将数量值放入到集合中,如果没有的数量为0
        List<TransportOrderStatusCountDTO> statusCount = super.baseMapper.findStatusCount();
        for (TransportOrderStatusCountDTO transportOrderStatusCountDTO : statusCountList) {
            for (TransportOrderStatusCountDTO countDTO : statusCount) {
                if (ObjectUtil.equal(transportOrderStatusCountDTO.getStatusCode(), countDTO.getStatusCode())) {
                    transportOrderStatusCountDTO.setCount(countDTO.getCount());
                    break;
                }
            }
        }
        return statusCountList;
    }

测试 

4.6、更新状态

在更新运单状态时需要考虑三件事:

  • 如果更新运单为拒收状态,需要将快递退回去,也就是原路返回
  • 在更新状态时,需要同步更新物流信息,通过发送消息的方式完成(先TODO,后面实现)
  • 更新状态后需要通知其他系统(消息通知)
  • 一车货有很多订单,需要批量修改List<String> ids

逻辑:

1 List<String> ids非空

2 订单状态不能为新建状态

3 运单为拒收状态时

3.1 查询所有订单super.listByIds(ids) ,for循环订单

3.2 查询起始和终点,将终点网点设置为起点,将起点网点设置为终点,拒收时原路返回

3.3 默认调度isDispatch设置为true。

3.4 若起终点相同,isDispatch设置为false,不参与调度

3.5 参与调度时 this.transportLineFeign.queryPathByDispatchMethod(sendAgentId, receiveAgentId)查询路线。判断是否为空

注:这里查询的transportLineNodeDTO为拒收路线。

之前根据ids查询的transportOrderEntity为发送到这个网点的路线

3.6 删除掉拒收路线的第一个机构,需要和之前的路线拼接为完整线路,故删去一个重复的D。

transportLineNodeDTO.getNodeList().remove(0)

3.7 设置 调度状态:待调度,当前所在机构id,

下一个机构id   transportLineNodeDTO.getNodeList().get(0).getId()。

3.8合并为完整路线

//获取原发送路线
TransportLineNodeDTO transportLineNode = JSONUtil.toBean(transportOrderEntity.getTransportLine(), TransportLineNodeDTO.class);
//将拒收路线追加到节点列表中
transportLineNode.getNodeList().addAll(transportLineNodeDTO.getNodeList());
//合并成本
transportLineNode.setCost(NumberUtil.add(transportLineNode.getCost(), transportLineNodeDTO.getCost()));
//完整的运输路线给transportOrderEntity作为返回对象
transportOrderEntity.setTransportLine(JSONUtil.toJsonStr(transportLineNode));

3.9设置运单状态为拒收。isDispatch判断是否需要发送消息参与调度

//不需要调度,发送消息生成派件任务
                    transportOrderEntity.setStatus(TransportOrderStatus.ARRIVED_END);
                    this.sendDispatchTaskMsgToDispatch(transportOrderEntity);

4 运单非拒收状态时

4.1 使用stream流循环ids(  ids.stream().map(id->{}).collect(Collectors.toList())  ),每一个id构建一个transprotOrderEntity实体(设置id和状态),最后收集为List集合转换为transprotOrderList

4.2 Day10时订单只要发生变化,使用mq发送消息,这样用户端可以一直观看进度

4.3 批量更新运单数据super.updateBatchById(transportOrderList),和发送运单更新消息super.updateBatchById(transportOrderList)

代码:

@Override
    public boolean updateStatus(List<String> ids, TransportOrderStatus transportOrderStatus) {
        if (CollUtil.isEmpty(ids)) {
            return false;
        }
        if (TransportOrderStatus.CREATED == transportOrderStatus) {
            //修改订单状态不能为 新建 状态
            throw new SLException(WorkExceptionEnum.TRANSPORT_ORDER_STATUS_NOT_CREATED);
        }
        List<TransportOrderEntity> transportOrderList;
        //判断是否为拒收状态,如果是拒收需要重新查询路线,将包裹逆向回去
        if (TransportOrderStatus.REJECTED == transportOrderStatus) {
            //查询运单列表
            transportOrderList = super.listByIds(ids);
            for (TransportOrderEntity transportOrderEntity : transportOrderList) {
                //设置为拒收运单
                transportOrderEntity.setIsRejection(true);
                //根据起始机构规划运输路线,这里要将起点和终点互换
                Long sendAgentId = transportOrderEntity.getEndAgencyId();//起始网点id
                Long receiveAgentId = transportOrderEntity.getStartAgencyId();//终点网点id
                //默认参与调度
                boolean isDispatch = true;
                if (ObjectUtil.equal(sendAgentId, receiveAgentId)) {
                    //相同节点,无需调度,直接生成派件任务
                    isDispatch = false;
                } else {
                    TransportLineNodeDTO transportLineNodeDTO = this.transportLineFeign.queryPathByDispatchMethod(sendAgentId, receiveAgentId);
                    if (ObjectUtil.hasEmpty(transportLineNodeDTO, transportLineNodeDTO.getNodeList())) {
                        throw new SLException(WorkExceptionEnum.TRANSPORT_LINE_NOT_FOUND);
                    }
                    //删除掉第一个机构,逆向回去的第一个节点就是当前所在节点
                    transportLineNodeDTO.getNodeList().remove(0);
                    transportOrderEntity.setSchedulingStatus(TransportOrderSchedulingStatus.TO_BE_SCHEDULED);//调度状态:待调度
                    transportOrderEntity.setCurrentAgencyId(sendAgentId);//当前所在机构id
                    transportOrderEntity.setNextAgencyId(transportLineNodeDTO.getNodeList().get(0).getId());//下一个机构id
                    //获取到原有节点信息
                    TransportLineNodeDTO transportLineNode = JSONUtil.toBean(transportOrderEntity.getTransportLine(), TransportLineNodeDTO.class);
                    //将逆向节点追加到节点列表中
                    transportLineNode.getNodeList().addAll(transportLineNodeDTO.getNodeList());
                    //合并成本
                    transportLineNode.setCost(NumberUtil.add(transportLineNode.getCost(), transportLineNodeDTO.getCost()));
                    transportOrderEntity.setTransportLine(JSONUtil.toJsonStr(transportLineNode));//完整的运输路线
                }
                transportOrderEntity.setStatus(TransportOrderStatus.REJECTED);
                if (isDispatch) {
                    //发送消息参与调度
                    this.sendTransportOrderMsgToDispatch(transportOrderEntity);
                } else {
                    //不需要调度,发送消息生成派件任务
                    transportOrderEntity.setStatus(TransportOrderStatus.ARRIVED_END);
                    this.sendDispatchTaskMsgToDispatch(transportOrderEntity);
                }
            }
        } else {
            //根据id列表封装成运单对象列表
            transportOrderList = ids.stream().map(id -> {
                //获取将发往的目的地机构
                Long nextAgencyId = this.getById(id).getNextAgencyId();
                OrganDTO organDTO = organFeign.queryById(nextAgencyId);
                //构建消息实体类
                String info = CharSequenceUtil.format("快件发往【{}】", organDTO.getName());
                String transportInfoMsg = TransportInfoMsg.builder()
                        .transportOrderId(id)//运单id
                        .status("运送中")//消息状态
                        .info(info)//消息详情
                        .created(DateUtil.current())//创建时间
                        .build().toJson();
                //发送运单跟踪消息
                this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_INFO, Constants.MQ.RoutingKeys.TRANSPORT_INFO_APPEND, transportInfoMsg);
                //封装运单对象
                TransportOrderEntity transportOrderEntity = new TransportOrderEntity();
                transportOrderEntity.setId(id);
                transportOrderEntity.setStatus(transportOrderStatus);
                return transportOrderEntity;
            }).collect(Collectors.toList());
        }
        //批量更新数据
        boolean result = super.updateBatchById(transportOrderList);
        //发消息通知其他系统运单状态的变化
        this.sendUpdateStatusMsg(ids, transportOrderStatus);
        return result;
    }

测试:

5、合并运单

5.1、逻辑

使用xxl job定时任务查询redis,进行合并订单的操作

将待调度的运单按照不同的路线放入redis的List中,使用redis set解决幂等性问题。

运单在运输过程中,虽然快件的起点与终点都不一定相同,但是在中间转运环节有一些运输节点是相同的,如下

根据以上两点的需求,很容易想到队列的存储方式。在这里我们采用Redis的List作为队列,将相同节点转运的订单放到同一个队列中,可以使用其LPUSH放进去,RPOP弹出数据,这样就可以确保先进先出,并且弹出后数据将删除,因此符合我们的需求。

redis优点: 1:内存中访问快

2:持久化机制,系统宕机时,数据不会丢失

redis使用的键值构造为A_B,每两点之间都有。

mq发消息可能重复发送,考虑幂等性时,同时使用List集合和Set集合存储货物。

每次List存储货物时,先在Set集合中查看是否已经存在,若存在,则不进行List的第二次存储

5.2、代码实现

5.2.1、准备环境

合并运单与调度的业务逻辑都放到sl-express-ms-dispatch-service工程中,git地址:http://git.sl-express.com/sl/sl-express-ms-dispatch-service.git,检出代码如下:

sl-express-ms-dispatch-service:只处理调度相关的任务,代码结构中无entity,数据库层的代码

5.2.2、逻辑

逻辑:

1从调度中获取消息

@RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = Constants.MQ.Queues.DISPATCH_MERGE_TRANSPORT_ORDER),
            exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED, type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
            key = Constants.MQ.RoutingKeys.JOIN_DISPATCH
    ))

2 判断消息非空

3 获取当前机构id,下一个机构id,运单id

4 .getSetRedisKey(startId, endId)获取redis的setReidsKey

redis获取List,set key的代码

public String getListRedisKey(Long startId, Long endId) {
        return StrUtil.format("DISPATCH_LIST_{}_{}", startId, endId);
    }
    public String getSetRedisKey(Long startId, Long endId) {
        return StrUtil.format("DISPATCH_SET_{}_{}", startId, endId);
    }

5幂等性判断,stringRedisTemplate.opsForSet().isMember(setRedisKey, transportOrderId)

判断该运单是否为set成员,若是则表示已经合并过,return

注:stringRedisTemplate与redisTemplate区别:string为string类型数据,原redisTemplate为jdk序列化数据

6 若未处理过,则添加到List集合中.getListRedisKey(startId, endId)

设置value

String value = JSONUtil.toJsonStr(MapUtil.builder()
                .put("transportOrderId", transportOrderId)
                .put("totalVolume", dispatchMsgDTO.getTotalVolume())
                .put("totalWeight", dispatchMsgDTO.getTotalWeight())
                .put("created", dispatchMsgDTO.getCreated()).build()
        );

7存储到List和set中。存数据用leftPush,取数据用rightPop

stringRedisTemplate.opsForList().leftPush(listRedisKey, value)

stringRedisTemplate.opsForSet().add(setRedisKey, transportOrderId)

5.2.3、代码

@Slf4j
@Component
public class TransportOrderDispatchMQListener {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 处理消息,合并运单到Redis队列
     *
     * @param msg
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = Constants.MQ.Queues.DISPATCH_MERGE_TRANSPORT_ORDER),
            exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED, type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
            key = Constants.MQ.RoutingKeys.JOIN_DISPATCH
    ))
    public void listenDispatchMsg(String msg) {
        // {"transportOrderId":"SL1000000000560","currentAgencyId":100280,"nextAgencyId":90001,"totalWeight":3.5,"totalVolume":2.1,"created":1652337676330}
        log.info("接收到新运单的消息 >>> msg = {}", msg);
        // {"transportOrderId":"SL1000000000560","currentAgencyId":100280,"nextAgencyId":90001,"totalWeight":3.5,"totalVolume":2.1,"created":1652337676330}
        log.info("接收到新运单的消息 >>> msg = {}", msg);
        DispatchMsgDTO dispatchMsgDTO = JSONUtil.toBean(msg, DispatchMsgDTO.class);
        if (ObjectUtil.isEmpty(dispatchMsgDTO)) {
            return;
        }
        Long startId = dispatchMsgDTO.getCurrentAgencyId();
        Long endId = dispatchMsgDTO.getNextAgencyId();
        String transportOrderId = dispatchMsgDTO.getTransportOrderId();
        //消息幂等性处理,将相同起始节点的运单存放到set结构的redis中,在相应的运单处理完成后将其删除掉
        String getSetRedisKey = this.getSetRedisKey(startId, endId);
        if (this.stringRedisTemplate.opsForSet().isMember(getSetRedisKey, transportOrderId)) {
            //重复消息
            return;
        }
        //存储数据到redis,采用list结构,从左边写入数据,读取数据时从右边读取
        //key =>  DISPATCH_CurrentAgencyId_NextAgencyId
        //value =>  {"transportOrderId":111222, "totalVolume":0.8, "totalWeight":2.1, "created":111222223333}
        String listRedisKey = this.getListRedisKey(startId, endId);
        String value = JSONUtil.toJsonStr(MapUtil.builder()
                .put("transportOrderId", transportOrderId)
                .put("totalVolume", dispatchMsgDTO.getTotalVolume())
                .put("totalWeight", dispatchMsgDTO.getTotalWeight())
                .put("created", dispatchMsgDTO.getCreated()).build()
        );
        //存储到redis
        this.stringRedisTemplate.opsForList().leftPush(listRedisKey, value);
        this.stringRedisTemplate.opsForSet().add(getSetRedisKey, transportOrderId);
    }
    public String getListRedisKey(Long startId, Long endId) {
        return StrUtil.format("DISPATCH_LIST_{}_{}", startId, endId);
    }
    public String getSetRedisKey(Long startId, Long endId) {
        return StrUtil.format("DISPATCH_SET_{}_{}", startId, endId);
    }


}

测试:

6、练习

6.2、练习二:阅读代码

难度系数:★★★☆☆

需求:阅读快递员服务中的【取件】业务功能,主要阅读代码逻辑如下:

1)理解取件业务的逻辑

2)理解实名认证的方法

3)理解更新订单状态的业务逻辑

取件业务、实名认证业务逻辑都放到sl-express-ms-web-courier工程中,git地址:http://git.sl-express.com/sl/sl-express-ms-web-courier.git,检出代码如下:

找到其中 TaskController

7、面试连环问

面试官问:

  • 物流项目中你参与了核心的功能开发吗?能说一下核心的业务逻辑吗?
  • 你们的运单号是怎么生成的?如何确保性能?
  • 能说一下订单转运单的业务逻辑吗?生成运单后如何与调度中心整合在一起的?
  • 合并运单为什么使用Redis的List作为队列?如何确保消息的幂等性的?
  • 项目中在哪些地方使用到了redis

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值