init
This commit is contained in:
		
							
								
								
									
										1013
									
								
								01-讲义/md/day01-项目概述.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1013
									
								
								01-讲义/md/day01-项目概述.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1687
									
								
								01-讲义/md/day02-网关与支付.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1687
									
								
								01-讲义/md/day02-网关与支付.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										2503
									
								
								01-讲义/md/day03-支付微服务.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2503
									
								
								01-讲义/md/day03-支付微服务.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										951
									
								
								01-讲义/md/day04-运费微服务.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										951
									
								
								01-讲义/md/day04-运费微服务.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,951 @@
 | 
			
		||||
# 课程安排
 | 
			
		||||
- 了解运费的业务需求
 | 
			
		||||
- 了解运费模板表的设计
 | 
			
		||||
- 实现运费计算的业务逻辑
 | 
			
		||||
- 完成部署服务以及功能测试
 | 
			
		||||
# 1、背景说明
 | 
			
		||||
 | 
			
		||||
现在出现了新的情况,开发二组一名负责运费微服务的同事小张离职了,开发二组目前人手不足,需要借调去接手这个任务,你需要知道的是,运费计算微服务是核心的微服务,不能出现计算错误,毕竟是钱挂钩的。
 | 
			
		||||
对了,小张离职前已经将该微服务的基本框架搭建完成了,你只需要实现核心的业务逻辑就可以了,这对你来说可能是个好消息……
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
# 2、需求分析
 | 
			
		||||
 | 
			
		||||
接到开发任务后,首先需要了解需求,再动手开发。
 | 
			
		||||
 | 
			
		||||
运费的计算是分不同地区的,比如:同城、省内、跨省,计算规则是不一样的,所以针对不同的类型需要设置不同的运费规则,这其实就是所谓的模板。
 | 
			
		||||
## 2.1、模板列表
 | 
			
		||||
产品需求中的运费模板列表:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
:::info
 | 
			
		||||
**轻抛系数名称解释:**
 | 
			
		||||
 在计算运费时,包裹有两个维度,体积和重量,二者谁大取谁进行计算,但是体积和重量不是一个单位怎么比较呢?一般的做法就是将体积转化成重量,公式:体积 / 轻抛系数 = 重量,这样就可以比较了。
 | 
			
		||||
 也就是说,相同的体积,轻抛系数越大计算出的重量就越小,反之就越大。
 | 
			
		||||
:::
 | 
			
		||||
 | 
			
		||||
## 2.2、计费规则
 | 
			
		||||
 | 
			
		||||
**重量计算方法:**
 | 
			
		||||
取重量和体积两者间较大的数值,体积计算方法:长(cm)×_宽(cm)_×高(cm) / 轻抛系数
 | 
			
		||||
 | 
			
		||||
**普快:**
 | 
			
		||||
同城互寄:12000
 | 
			
		||||
省内寄件:8000
 | 
			
		||||
跨省寄件:12000
 | 
			
		||||
经济区互寄(京津翼、江浙沪):6000
 | 
			
		||||
经济区互寄(黑吉辽):12000
 | 
			
		||||
经济区互寄(川渝):8000
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**计费重量小数点规则:**
 | 
			
		||||
 | 
			
		||||
不满1kg,按1kg计费;
 | 
			
		||||
10KG以下:以0.1kg为计重单位,四舍五入保留 1 位小数;
 | 
			
		||||
10-100KG:续重以0.5kg为计重单位,不足0.5kg按0.5kg算,四舍五入保留 1 位小数;
 | 
			
		||||
100KG及以上:四舍五入取整;
 | 
			
		||||
 | 
			
		||||
> **举例:**
 | 
			
		||||
> 8.4kg按照8.4kg收费
 | 
			
		||||
8.5kg按照8.5kg收费
 | 
			
		||||
8.8kg按照8.8kg收费
 | 
			
		||||
18.1kg按照18.5kg收费
 | 
			
		||||
18.5kg按照18.5kg收费
 | 
			
		||||
18.7kg按照19kg收费
 | 
			
		||||
108.4kg按照108kg收费
 | 
			
		||||
108.5kg按照109kg收费
 | 
			
		||||
108.6kg按照109kg收费
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
总运费小数点规则:**按四舍五入计算,精确到小数点后一位**
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
模板不可重复设置,需确保唯一值。
 | 
			
		||||
如已设置同城寄、跨省寄、省内寄,则只可修改,不可再新增
 | 
			
		||||
如已设置经济区互寄某个城市,下次添加不可再关联此经济区城市
 | 
			
		||||
## 2.3、新增模板
 | 
			
		||||
 | 
			
		||||
运费模板有4种类型,分别为:
 | 
			
		||||
同城寄:同城寄件运费计算模板,全国统一定价
 | 
			
		||||
省内寄:省内寄件运费计算模板,全国统一定价
 | 
			
		||||
跨省寄:不同省份间的运费计算模板,全国统一定价
 | 
			
		||||
经济区互寄:4个经济区(京津翼、江沪浙皖、川渝、黑吉辽),经济区间寄件可设置优惠价格
 | 
			
		||||
### 2.3.1、全国范围
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
此模板为‘同城寄/省内寄/跨省’三个类型的运费模板
 | 
			
		||||
模板类型:可选择同城寄/省内寄/跨省/经济区互寄
 | 
			
		||||
运送类型:可选择运送类型,目前业务只支持普快
 | 
			
		||||
关联城市:
 | 
			
		||||
同城寄/省内寄/跨省:全国统一定价(如上图)
 | 
			
		||||
首重价格:保留小数点后一位,可输入1-999间任意数值
 | 
			
		||||
续重价格:保留小数点后一位,可输入1-999间任意数值
 | 
			
		||||
轻抛系数:整数,可输入1-99999间,任意数值
 | 
			
		||||
 | 
			
		||||
### 2.3.2、经济区互寄
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
此模板为‘经济区互寄’类型的运费模板
 | 
			
		||||
模板类型:可选择同城寄/省内寄/跨省/经济区互寄
 | 
			
		||||
运送类型:可选择运送类型,目前业务只支持普快
 | 
			
		||||
关联城市:
 | 
			
		||||
经济区互寄:可设置单个或多个经济区价格(如上图)
 | 
			
		||||
首重价格:保留小数点后一位,可输入1-999间任意数值
 | 
			
		||||
续重价格:保留小数点后一位,可输入1-999间任意数值
 | 
			
		||||
轻抛系数:整数,可输入1-99999间,任意数值
 | 
			
		||||
# 3、运费模板表
 | 
			
		||||
 | 
			
		||||
运费模板是需要存储到表中的,所以首先需要设计表结构,具体表结构语句如下:
 | 
			
		||||
 | 
			
		||||
```sql
 | 
			
		||||
CREATE TABLE `sl_carriage` (
 | 
			
		||||
  `id` bigint NOT NULL COMMENT '运费模板id',
 | 
			
		||||
  `template_type` tinyint NOT NULL COMMENT '模板类型,1-同城寄 2-省内寄 3-经济区互寄 4-跨省',
 | 
			
		||||
  `transport_type` tinyint NOT NULL COMMENT '运送类型,1-普快 2-特快',
 | 
			
		||||
  `associated_city` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '关联城市,1-全国 2-京津冀 3-江浙沪 4-川渝 5-黑吉辽',
 | 
			
		||||
  `first_weight` double NOT NULL COMMENT '首重价格',
 | 
			
		||||
  `continuous_weight` double NOT NULL DEFAULT '1' COMMENT '续重价格',
 | 
			
		||||
  `light_throwing_coefficient` int NOT NULL COMMENT '轻抛系数',
 | 
			
		||||
  `created` datetime DEFAULT NULL COMMENT '创建时间',
 | 
			
		||||
  `updated` datetime DEFAULT NULL COMMENT '更新时间',
 | 
			
		||||
  PRIMARY KEY (`id`) USING BTREE
 | 
			
		||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='运费模板表';
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
:::danger
 | 
			
		||||
说明:由于该表数据比较少,所以就不需要添加索引字段了。
 | 
			
		||||
:::
 | 
			
		||||
在数据库中已经预存了7条数据:
 | 
			
		||||

 | 
			
		||||
> 🚨 特别需要注意associated_city字段,如果多个经济区互寄城市共用一套模板,其数据是通过逗号分割存储的,如下:
 | 
			
		||||
> 
 | 
			
		||||
 | 
			
		||||
# 4、拉取代码
 | 
			
		||||
需要拉取的工程有3个:
 | 
			
		||||
 | 
			
		||||
| 工程名 | git地址 |
 | 
			
		||||
| --- | --- |
 | 
			
		||||
| sl-express-ms-carriage-domain | [http://git.sl-express.com/sl/sl-express-ms-carriage-domain.git](http://git.sl-express.com/sl/sl-express-ms-carriage-domain.git) |
 | 
			
		||||
| sl-express-ms-carriage-api | [http://git.sl-express.com/sl/sl-express-ms-carriage-api.git](http://git.sl-express.com/sl/sl-express-ms-carriage-api.git) |
 | 
			
		||||
| sl-express-ms-carriage-service | [http://git.sl-express.com/sl/sl-express-ms-carriage-service.git](http://git.sl-express.com/sl/sl-express-ms-carriage-service.git) |
 | 
			
		||||
 | 
			
		||||
# 5、实现业务
 | 
			
		||||
接下来我们要编写代码实现具体的业务了,同事小张已经完成基本的代码框架,包括Controller、Service接口等,我们只需要实现CarriageService即可。需要实现4个方法,如下:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.service;
 | 
			
		||||
 | 
			
		||||
import com.baomidou.mybatisplus.extension.service.IService;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.CarriageDTO;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 运费管理表 服务类
 | 
			
		||||
 */
 | 
			
		||||
public interface CarriageService extends IService<CarriageEntity> {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 新增/修改运费模板
 | 
			
		||||
     *
 | 
			
		||||
     * @param carriageDto 新增/修改运费对象
 | 
			
		||||
     *                    必填字段:templateType、transportType
 | 
			
		||||
     *                    更新时传入id字段
 | 
			
		||||
     */
 | 
			
		||||
    CarriageDTO saveOrUpdate(CarriageDTO carriageDto);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 获取全部运费模板
 | 
			
		||||
     *
 | 
			
		||||
     * @return 运费模板对象列表
 | 
			
		||||
     */
 | 
			
		||||
    List<CarriageDTO> findAll();
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 运费计算
 | 
			
		||||
     *
 | 
			
		||||
     * @param waybillDTO 运费计算对象
 | 
			
		||||
     * @return 运费模板对象,不仅包含模板数据还包含:computeWeight、expense 字段
 | 
			
		||||
     */
 | 
			
		||||
    CarriageDTO compute(WaybillDTO waybillDTO);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 根据模板类型查询模板,经济区互寄不通过该方法查询模板
 | 
			
		||||
     *
 | 
			
		||||
     * @param templateType 模板类型:1-同城寄,2-省内寄,4-跨省
 | 
			
		||||
     * @return 运费模板
 | 
			
		||||
     */
 | 
			
		||||
    CarriageEntity findByTemplateType(Integer templateType);
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
## 5.1、查询模板列表
 | 
			
		||||
编写`com.sl.ms.carriage.service.impl.CarriageServiceImpl`实现类:
 | 
			
		||||
 | 
			
		||||
```java
 | 
			
		||||
@Service
 | 
			
		||||
public class CarriageServiceImpl extends ServiceImpl<CarriageMapper, CarriageEntity>
 | 
			
		||||
        implements CarriageService {
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageDTO saveOrUpdate(CarriageDTO carriageDto) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<CarriageDTO> findAll() {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageDTO compute(WaybillDTO waybillDTO) {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
            
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageEntity findByTemplateType(Integer templateType) {
 | 
			
		||||
        if (ObjectUtil.equals(templateType, CarriageConstant.ECONOMIC_ZONE)) {
 | 
			
		||||
            throw new SLException(CarriageExceptionEnum.METHOD_CALL_ERROR);
 | 
			
		||||
        }
 | 
			
		||||
        LambdaQueryWrapper<CarriageEntity> queryWrapper = Wrappers.lambdaQuery(CarriageEntity.class)
 | 
			
		||||
                .eq(CarriageEntity::getTemplateType, templateType)
 | 
			
		||||
                .eq(CarriageEntity::getTransportType, CarriageConstant.REGULAR_FAST);
 | 
			
		||||
        return super.getOne(queryWrapper);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
查询列表,需要按照创建时间倒序排序:
 | 
			
		||||
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<CarriageDTO> findAll() {
 | 
			
		||||
        // 构造查询条件,按创建时间倒序
 | 
			
		||||
        LambdaQueryWrapper<CarriageEntity> queryWrapper = Wrappers.<CarriageEntity>lambdaQuery()
 | 
			
		||||
                .orderByDesc(CarriageEntity::getCreated);
 | 
			
		||||
 | 
			
		||||
        // 查询数据库
 | 
			
		||||
        List<CarriageEntity> list = super.list(queryWrapper);
 | 
			
		||||
 | 
			
		||||
        // 将结果转换为DTO类型
 | 
			
		||||
        return list.stream().map(CarriageUtils::toDTO).collect(Collectors.toList());
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
编写测试用例:
 | 
			
		||||
 | 
			
		||||
:::danger
 | 
			
		||||
如果工程中不存在test目录,需要先创建test目录再编写测试用例:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
:::
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.service;
 | 
			
		||||
 | 
			
		||||
import com.sl.ms.carriage.domain.constant.CarriageConstant;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.CarriageDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import org.junit.jupiter.api.Test;
 | 
			
		||||
import org.springframework.boot.test.context.SpringBootTest;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.*;
 | 
			
		||||
 | 
			
		||||
@SpringBootTest
 | 
			
		||||
class CarriageServiceTest {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageService carriageService;
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void saveOrUpdate() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void findAll() {
 | 
			
		||||
        List<CarriageDTO> list = this.carriageService.findAll();
 | 
			
		||||
        for (CarriageDTO carriageDTO : list) {
 | 
			
		||||
            System.out.println(carriageDTO);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void compute() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void findByTemplateType() {
 | 
			
		||||
        CarriageEntity carriageEntity = this.carriageService.findByTemplateType(CarriageConstant.SAME_CITY);
 | 
			
		||||
        System.out.println(carriageEntity);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
测试结果:
 | 
			
		||||

 | 
			
		||||
也可以基于swagger测试:
 | 
			
		||||
 | 
			
		||||
url地址:[http://127.0.0.1:18094/doc.html](http://127.0.0.1:18094/doc.html)
 | 
			
		||||
 | 
			
		||||
可以看到,已经查询到了数据:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
整合到后台管理系统中:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
## 5.2、新增或更新
 | 
			
		||||
### 5.2.1、整体流程
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
流程说明:
 | 
			
		||||
 | 
			
		||||
- 根据传入的CarriageDTO对象参数进行查询模板,并且判断是否为经济区
 | 
			
		||||
- 如果是非经济区互寄,需要进一步判断模板是否存在,如果存在需要判断是否为新增操作,如果是新增操作就抛出异常,其他情况都可以进行落库
 | 
			
		||||
- 如果是经济区互寄,需要判断关联城市是否重复,如果重复抛出异常,否则进行落库操作
 | 
			
		||||
:::danger
 | 
			
		||||
❓模板为什么不能重复?
 | 
			
		||||
因为运费的计算是通过模板进行的,如果存在多个模板,该基于哪个模板计算呢?所以模板是不能重复的。
 | 
			
		||||
:::
 | 
			
		||||
### 5.2.2、代码实现
 | 
			
		||||
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageDTO saveOrUpdate(CarriageDTO carriageDto) {
 | 
			
		||||
        log.info("新增运费模板 --> {}", carriageDto);
 | 
			
		||||
        //思路:首先根据条件查询运费模板,判断模板是否存在,如果不存在直接新增
 | 
			
		||||
        //如果存在,需要判断是否为经济区互寄,如果不是,抛出异常,如果是,需要进一步判断所关联的城市是否重复
 | 
			
		||||
        //如果重复,抛出异常,如果不重复进行新增或更新
 | 
			
		||||
        LambdaQueryWrapper<CarriageEntity> queryWrapper = Wrappers.<CarriageEntity>lambdaQuery()
 | 
			
		||||
                .eq(CarriageEntity::getTemplateType, carriageDto.getTemplateType())
 | 
			
		||||
                .eq(CarriageEntity::getTransportType, CarriageConstant.REGULAR_FAST);
 | 
			
		||||
 | 
			
		||||
        //查询到模板列表
 | 
			
		||||
        List<CarriageEntity> carriageList = super.list(queryWrapper);
 | 
			
		||||
 | 
			
		||||
        if (ObjectUtil.notEqual(carriageDto.getTemplateType(), CarriageConstant.ECONOMIC_ZONE)) {
 | 
			
		||||
            // 非经济区互寄的情况下,需要判断查询的模板是否为空
 | 
			
		||||
            // 如果不为空并且入参的参数id为空,说明是新增操作,非经济区只能有一个模板,需要抛出异常
 | 
			
		||||
            if (ObjectUtil.isNotEmpty(carriageList) && ObjectUtil.isEmpty(carriageDto.getId())) {
 | 
			
		||||
                // 新增操作,模板重复,抛出异常
 | 
			
		||||
                throw new SLException(CarriageExceptionEnum.NOT_ECONOMIC_ZONE_REPEAT);
 | 
			
		||||
            }
 | 
			
		||||
            //新增或更新非经济区模板
 | 
			
		||||
            return this.saveOrUpdateCarriage(carriageDto);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //判断模板所关联的城市是否有重复
 | 
			
		||||
        //查询其他模板中所有的经济区列表
 | 
			
		||||
        List<String> associatedCityList = StreamUtil.of(carriageList)
 | 
			
		||||
                //排除掉自己,检查与其他模板是否存在冲突
 | 
			
		||||
                .filter(carriageEntity -> ObjectUtil.notEqual(carriageEntity.getId(), carriageDto.getId()))
 | 
			
		||||
                //获取关联城市
 | 
			
		||||
                .map(CarriageEntity::getAssociatedCity)
 | 
			
		||||
                //将关联城市按照逗号分割
 | 
			
		||||
                .map(associatedCity -> StrUtil.split(associatedCity, ','))
 | 
			
		||||
                //将上面得到的集合展开,得到字符串
 | 
			
		||||
                .flatMap(StreamUtil::of)
 | 
			
		||||
                //收集到集合中
 | 
			
		||||
                .collect(Collectors.toList());
 | 
			
		||||
 | 
			
		||||
        //查看当前新增经济区是否存在重复,取交集来判断是否重复
 | 
			
		||||
        Collection<String> intersection = CollUtil.intersection(associatedCityList, carriageDto.getAssociatedCityList());
 | 
			
		||||
        if (CollUtil.isNotEmpty(intersection)) {
 | 
			
		||||
            //有重复
 | 
			
		||||
            throw new SLException(CarriageExceptionEnum.ECONOMIC_ZONE_CITY_REPEAT);
 | 
			
		||||
        }
 | 
			
		||||
        //新增或更新经济区模板
 | 
			
		||||
        return this.saveOrUpdateCarriage(carriageDto);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 5.2.3、测试
 | 
			
		||||
编写测试用例:
 | 
			
		||||
```java
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageService carriageService;
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void saveOrUpdate() {
 | 
			
		||||
        CarriageDTO carriageDTO = new CarriageDTO();
 | 
			
		||||
        carriageDTO.setTemplateType(3);
 | 
			
		||||
        carriageDTO.setTransportType(1);
 | 
			
		||||
        carriageDTO.setAssociatedCityList(Arrays.asList("5"));
 | 
			
		||||
        carriageDTO.setFirstWeight(12d);
 | 
			
		||||
        carriageDTO.setContinuousWeight(1d);
 | 
			
		||||
        carriageDTO.setLightThrowingCoefficient(6000);
 | 
			
		||||
 | 
			
		||||
        CarriageDTO dto = this.carriageService.saveOrUpdate(carriageDTO);
 | 
			
		||||
        System.out.println(dto);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 5.3、运费计算
 | 
			
		||||
### 5.3.1、整体流程
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
:::info
 | 
			
		||||
说明:
 | 
			
		||||
 | 
			
		||||
- 运费模板优先级:同城>省内>经济区互寄>跨省
 | 
			
		||||
- 将体积转化成重量,与重量比较,取大值
 | 
			
		||||
:::
 | 
			
		||||
 | 
			
		||||
### 5.3.2、查找模板
 | 
			
		||||
在上述的流程图中可以看出,计算运费的第一步逻辑就是需要查找到对应的运费模板,否则不能进行计算,如何实现比较好呢,我们这里采用【责任链】模式进行代码编写。
 | 
			
		||||
之所以采用【责任链】模式,是因为在查找模板时,不同的模板处理逻辑不同,并且这些逻辑组成了一条处理链,有开头有结尾,只要能找到符合条件的模板即结束。
 | 
			
		||||
 | 
			
		||||
首先,定义运费模板处理链,这是一个抽象类:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 运费模板处理链的抽象定义
 | 
			
		||||
 */
 | 
			
		||||
public abstract class AbstractCarriageChainHandler {
 | 
			
		||||
 | 
			
		||||
    private AbstractCarriageChainHandler nextHandler;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 执行过滤方法,通过输入参数查找运费模板
 | 
			
		||||
     *
 | 
			
		||||
     * @param waybillDTO 输入参数
 | 
			
		||||
     * @return 运费模板
 | 
			
		||||
     */
 | 
			
		||||
    public abstract CarriageEntity doHandler(WaybillDTO waybillDTO);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 执行下一个处理器
 | 
			
		||||
     *
 | 
			
		||||
     * @param waybillDTO     输入参数
 | 
			
		||||
     * @param carriageEntity 上个handler处理得到的对象
 | 
			
		||||
     * @return
 | 
			
		||||
     */
 | 
			
		||||
    protected CarriageEntity doNextHandler(WaybillDTO waybillDTO, CarriageEntity carriageEntity) {
 | 
			
		||||
        if (nextHandler == null || carriageEntity != null) {
 | 
			
		||||
            //如果下游Handler为空 或 上个Handler已经找到运费模板就返回
 | 
			
		||||
            return carriageEntity;
 | 
			
		||||
        }
 | 
			
		||||
        return nextHandler.doHandler(waybillDTO);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 设置下游Handler
 | 
			
		||||
     *
 | 
			
		||||
     * @param nextHandler 下游Handler
 | 
			
		||||
     */
 | 
			
		||||
    public void setNextHandler(AbstractCarriageChainHandler nextHandler) {
 | 
			
		||||
        this.nextHandler = nextHandler;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
下面针对不同的模板策略编写具体的实现类,同城寄:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import com.sl.ms.carriage.domain.constant.CarriageConstant;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import com.sl.ms.carriage.service.CarriageService;
 | 
			
		||||
import com.sl.transport.common.util.ObjectUtil;
 | 
			
		||||
import org.springframework.core.annotation.Order;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 同城寄
 | 
			
		||||
 */
 | 
			
		||||
@Order(100) //定义顺序
 | 
			
		||||
@Component
 | 
			
		||||
public class SameCityChainHandler extends AbstractCarriageChainHandler {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageService carriageService;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageEntity doHandler(WaybillDTO waybillDTO) {
 | 
			
		||||
        CarriageEntity carriageEntity = null;
 | 
			
		||||
        if (ObjectUtil.equals(waybillDTO.getReceiverCityId(), waybillDTO.getSenderCityId())) {
 | 
			
		||||
            //同城
 | 
			
		||||
            carriageEntity = this.carriageService.findByTemplateType(CarriageConstant.SAME_CITY);
 | 
			
		||||
        }
 | 
			
		||||
        return doNextHandler(waybillDTO, carriageEntity);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
省内寄:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import com.sl.ms.base.api.common.AreaFeign;
 | 
			
		||||
import com.sl.ms.carriage.domain.constant.CarriageConstant;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import com.sl.ms.carriage.service.CarriageService;
 | 
			
		||||
import com.sl.transport.common.util.ObjectUtil;
 | 
			
		||||
import org.springframework.core.annotation.Order;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 省内寄
 | 
			
		||||
 */
 | 
			
		||||
@Order(200) //定义顺序
 | 
			
		||||
@Component
 | 
			
		||||
public class SameProvinceChainHandler extends AbstractCarriageChainHandler {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageService carriageService;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private AreaFeign areaFeign;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageEntity doHandler(WaybillDTO waybillDTO) {
 | 
			
		||||
        CarriageEntity carriageEntity = null;
 | 
			
		||||
        // 获取收寄件地址省份id
 | 
			
		||||
        Long receiverProvinceId = this.areaFeign.get(waybillDTO.getReceiverCityId()).getParentId();
 | 
			
		||||
        Long senderProvinceId = this.areaFeign.get(waybillDTO.getSenderCityId()).getParentId();
 | 
			
		||||
        if (ObjectUtil.equal(receiverProvinceId, senderProvinceId)) {
 | 
			
		||||
            //省内
 | 
			
		||||
            carriageEntity = this.carriageService.findByTemplateType(CarriageConstant.SAME_PROVINCE);
 | 
			
		||||
        }
 | 
			
		||||
        return doNextHandler(waybillDTO, carriageEntity);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
经济区互寄:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.util.ArrayUtil;
 | 
			
		||||
import cn.hutool.core.util.EnumUtil;
 | 
			
		||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 | 
			
		||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 | 
			
		||||
import com.sl.ms.base.api.common.AreaFeign;
 | 
			
		||||
import com.sl.ms.carriage.domain.constant.CarriageConstant;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.domain.enums.EconomicRegionEnum;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import com.sl.ms.carriage.service.CarriageService;
 | 
			
		||||
import com.sl.transport.common.util.ObjectUtil;
 | 
			
		||||
import org.springframework.core.annotation.Order;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
import java.util.LinkedHashMap;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 经济区互寄
 | 
			
		||||
 */
 | 
			
		||||
@Order(300) //定义顺序
 | 
			
		||||
@Component
 | 
			
		||||
public class EconomicZoneChainHandler extends AbstractCarriageChainHandler {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageService carriageService;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private AreaFeign areaFeign;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageEntity doHandler(WaybillDTO waybillDTO) {
 | 
			
		||||
        CarriageEntity carriageEntity = null;
 | 
			
		||||
 | 
			
		||||
        // 获取收寄件地址省份id
 | 
			
		||||
        Long receiverProvinceId = this.areaFeign.get(waybillDTO.getReceiverCityId()).getParentId();
 | 
			
		||||
        Long senderProvinceId = this.areaFeign.get(waybillDTO.getSenderCityId()).getParentId();
 | 
			
		||||
 | 
			
		||||
        //获取经济区城市配置枚举
 | 
			
		||||
        LinkedHashMap<String, EconomicRegionEnum> EconomicRegionMap = EnumUtil.getEnumMap(EconomicRegionEnum.class);
 | 
			
		||||
        EconomicRegionEnum economicRegionEnum = null;
 | 
			
		||||
        for (EconomicRegionEnum regionEnum : EconomicRegionMap.values()) {
 | 
			
		||||
            //该经济区是否全部包含收发件省id
 | 
			
		||||
            boolean result = ArrayUtil.containsAll(regionEnum.getValue(), receiverProvinceId, senderProvinceId);
 | 
			
		||||
            if (result) {
 | 
			
		||||
                economicRegionEnum = regionEnum;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (ObjectUtil.isNotEmpty(economicRegionEnum)) {
 | 
			
		||||
            //根据类型编码查询
 | 
			
		||||
            LambdaQueryWrapper<CarriageEntity> queryWrapper = Wrappers.lambdaQuery(CarriageEntity.class)
 | 
			
		||||
                    .eq(CarriageEntity::getTemplateType, CarriageConstant.ECONOMIC_ZONE)
 | 
			
		||||
                    .eq(CarriageEntity::getTransportType, CarriageConstant.REGULAR_FAST)
 | 
			
		||||
                    .like(CarriageEntity::getAssociatedCity, economicRegionEnum.getCode());
 | 
			
		||||
            carriageEntity = this.carriageService.getOne(queryWrapper);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return doNextHandler(waybillDTO, carriageEntity);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
跨省寄:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import com.sl.ms.carriage.domain.constant.CarriageConstant;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import com.sl.ms.carriage.service.CarriageService;
 | 
			
		||||
import org.springframework.core.annotation.Order;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 跨省
 | 
			
		||||
 */
 | 
			
		||||
@Order(400) //定义顺序
 | 
			
		||||
@Component
 | 
			
		||||
public class TransProvinceChainHandler extends AbstractCarriageChainHandler {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageService carriageService;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageEntity doHandler(WaybillDTO waybillDTO) {
 | 
			
		||||
        CarriageEntity carriageEntity = this.carriageService.findByTemplateType(CarriageConstant.TRANS_PROVINCE);
 | 
			
		||||
        return doNextHandler(waybillDTO, carriageEntity);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
组装处理链,按照`@Order`注解中的值,由小到大排序。
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.collection.CollUtil;
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import com.sl.transport.common.exception.SLException;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.PostConstruct;
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 查找运费模板处理链 @Order注解
 | 
			
		||||
 */
 | 
			
		||||
@Component
 | 
			
		||||
public class CarriageChainHandler {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 利用Spring注入特性,按照 @Order 从小到达排序注入到集合中
 | 
			
		||||
     */
 | 
			
		||||
    @Resource
 | 
			
		||||
    private List<AbstractCarriageChainHandler> chainHandlers;
 | 
			
		||||
 | 
			
		||||
    private AbstractCarriageChainHandler firstHandler;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 组装处理链
 | 
			
		||||
     */
 | 
			
		||||
    @PostConstruct
 | 
			
		||||
    private void constructChain() {
 | 
			
		||||
        if (CollUtil.isEmpty(chainHandlers)) {
 | 
			
		||||
            throw new SLException("not found carriage chain handler!");
 | 
			
		||||
        }
 | 
			
		||||
        //处理链中第一个节点
 | 
			
		||||
        firstHandler = chainHandlers.get(0);
 | 
			
		||||
        for (int i = 0; i < chainHandlers.size(); i++) {
 | 
			
		||||
            if (i == chainHandlers.size() - 1) {
 | 
			
		||||
                //最后一个处理链节点
 | 
			
		||||
                chainHandlers.get(i).setNextHandler(null);
 | 
			
		||||
            } else {
 | 
			
		||||
                //设置下游节点
 | 
			
		||||
                chainHandlers.get(i).setNextHandler(chainHandlers.get(i + 1));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public CarriageEntity findCarriage(WaybillDTO waybillDTO) {
 | 
			
		||||
        //从第一个节点开始处理
 | 
			
		||||
        return firstHandler.doHandler(waybillDTO);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
测试:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.carriage.handler;
 | 
			
		||||
 | 
			
		||||
import com.sl.ms.carriage.domain.dto.WaybillDTO;
 | 
			
		||||
import com.sl.ms.carriage.entity.CarriageEntity;
 | 
			
		||||
import org.junit.jupiter.api.Test;
 | 
			
		||||
import org.springframework.boot.test.context.SpringBootTest;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
@SpringBootTest
 | 
			
		||||
class CarriageChainHandlerTest {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private CarriageChainHandler carriageChainHandler;
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void findCarriage() {
 | 
			
		||||
        WaybillDTO waybillDTO = WaybillDTO.builder()
 | 
			
		||||
                .senderCityId(2L) //北京
 | 
			
		||||
                .receiverCityId(161793L) //上海
 | 
			
		||||
                .volume(1)
 | 
			
		||||
                .weight(1d)
 | 
			
		||||
                .build();
 | 
			
		||||
 | 
			
		||||
        CarriageEntity carriage = this.carriageChainHandler.findCarriage(waybillDTO);
 | 
			
		||||
        System.out.println(carriage);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
注入的处理链集合对象:
 | 
			
		||||

 | 
			
		||||
测试结果,查询到跨省的模板,结果符合预期:
 | 
			
		||||

 | 
			
		||||
### 5.3.3、计算运费
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public CarriageDTO compute(WaybillDTO waybillDTO) {
 | 
			
		||||
        //根据参数查找运费模板
 | 
			
		||||
        CarriageEntity carriage = this.carriageChainHandler.findCarriage(waybillDTO);
 | 
			
		||||
 | 
			
		||||
        //计算重量,确保最小重量为1kg
 | 
			
		||||
        double computeWeight = this.getComputeWeight(waybillDTO, carriage);
 | 
			
		||||
 | 
			
		||||
        //计算运费,首重 + 续重
 | 
			
		||||
        double expense = carriage.getFirstWeight() + ((computeWeight - 1) * carriage.getContinuousWeight());
 | 
			
		||||
 | 
			
		||||
        //保留一位小数
 | 
			
		||||
        expense = NumberUtil.round(expense, 1).doubleValue();
 | 
			
		||||
 | 
			
		||||
        //封装运费和计算重量到DTO,并返回
 | 
			
		||||
        CarriageDTO carriageDTO = CarriageUtils.toDTO(carriage);
 | 
			
		||||
        carriageDTO.setExpense(expense);
 | 
			
		||||
        carriageDTO.setComputeWeight(computeWeight);
 | 
			
		||||
        return carriageDTO;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 根据体积参数与实际重量计算计费重量
 | 
			
		||||
     *
 | 
			
		||||
     * @param waybillDTO 运费计算对象
 | 
			
		||||
     * @param carriage   运费模板
 | 
			
		||||
     * @return 计费重量
 | 
			
		||||
     */
 | 
			
		||||
    private double getComputeWeight(WaybillDTO waybillDTO, CarriageEntity carriage) {
 | 
			
		||||
        //计算体积,如果传入体积不需要计算
 | 
			
		||||
        Integer volume = waybillDTO.getVolume();
 | 
			
		||||
        if (ObjectUtil.isEmpty(volume)) {
 | 
			
		||||
            try {
 | 
			
		||||
                //长*宽*高计算体积
 | 
			
		||||
                volume = waybillDTO.getMeasureLong() * waybillDTO.getMeasureWidth() * waybillDTO.getMeasureHigh();
 | 
			
		||||
            } catch (Exception e) {
 | 
			
		||||
                //计算出错设置体积为0
 | 
			
		||||
                volume = 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 计算体积重量,体积 / 轻抛系数
 | 
			
		||||
        BigDecimal volumeWeight = NumberUtil.div(volume, carriage.getLightThrowingCoefficient(), 1);
 | 
			
		||||
 | 
			
		||||
        //取大值
 | 
			
		||||
        double computeWeight = NumberUtil.max(volumeWeight.doubleValue(), NumberUtil.round(waybillDTO.getWeight(), 1).doubleValue());
 | 
			
		||||
 | 
			
		||||
        //计算续重,规则:不满1kg,按1kg计费;10kg以下续重以0.1kg计量保留1位小数;10-100kg续重以0.5kg计量保留1位小数;100kg以上四舍五入取整
 | 
			
		||||
        if (computeWeight <= 1) {
 | 
			
		||||
            return 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (computeWeight <= 10) {
 | 
			
		||||
            return computeWeight;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 举例:
 | 
			
		||||
        // 108.4kg按照108kg收费
 | 
			
		||||
        // 108.5kg按照109kg收费
 | 
			
		||||
        // 108.6kg按照109kg收费
 | 
			
		||||
        if (computeWeight >= 100) {
 | 
			
		||||
            return NumberUtil.round(computeWeight, 0).doubleValue();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //0.5为一个计算单位,举例:
 | 
			
		||||
        // 18.8kg按照19收费,
 | 
			
		||||
        // 18.4kg按照18.5kg收费
 | 
			
		||||
        // 18.1kg按照18.5kg收费
 | 
			
		||||
        // 18.6kg按照19收费
 | 
			
		||||
        int integer = NumberUtil.round(computeWeight, 0, RoundingMode.DOWN).intValue();
 | 
			
		||||
        if (NumberUtil.sub(computeWeight, integer) == 0) {
 | 
			
		||||
            return integer;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (NumberUtil.sub(computeWeight, integer) <= 0.5) {
 | 
			
		||||
            return NumberUtil.add(integer, 0.5);
 | 
			
		||||
        }
 | 
			
		||||
        return NumberUtil.add(integer, 1);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 5.3.4、测试
 | 
			
		||||
可以通过单元测试或者swagger测试:
 | 
			
		||||
```java
 | 
			
		||||
    @Test
 | 
			
		||||
    void compute() {
 | 
			
		||||
        WaybillDTO waybillDTO = new WaybillDTO();
 | 
			
		||||
        waybillDTO.setReceiverCityId(7363L); //天津
 | 
			
		||||
        waybillDTO.setSenderCityId(2L); //北京
 | 
			
		||||
        waybillDTO.setWeight(3.8); //重量
 | 
			
		||||
        waybillDTO.setVolume(125000); //体积
 | 
			
		||||
 | 
			
		||||
        CarriageDTO compute = this.carriageService.compute(waybillDTO);
 | 
			
		||||
        System.out.println(compute);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
可以看到已经得到计算结果。
 | 
			
		||||
### 5.3.5、异常
 | 
			
		||||
可能会出现如下异常:
 | 
			
		||||
```shell
 | 
			
		||||
2022-08-04 19:27:01.712 - [http-nio-18094-exec-6] - ERROR - c.s.t.common.handler.GlobalExceptionHandler - 其他未知异常 -> 
 | 
			
		||||
feign.RetryableException: Connection refused: connect executing GET http://sl-express-ms-base/area/72975
 | 
			
		||||
	at feign.FeignException.errorExecuting(FeignException.java:268)
 | 
			
		||||
	at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:129)
 | 
			
		||||
	at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:89)
 | 
			
		||||
	at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:100)
 | 
			
		||||
	at com.sun.proxy.$Proxy120.get(Unknown Source)
 | 
			
		||||
	at com.sl.ms.carriage.service.impl.CarriageServiceImpl.findEconomicCarriage(CarriageServiceImpl.java:234)
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
由于在计算服务中使用base微服务,但是101机器的base服务没有启动,会导致如上异常,将base启动即可。
 | 
			
		||||
```shell
 | 
			
		||||
#启动命令
 | 
			
		||||
docker start sl-express-ms-base-service
 | 
			
		||||
 | 
			
		||||
#查看日志
 | 
			
		||||
docker logs -f sl-express-ms-base-service
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 5.3.6、测试举例
 | 
			
		||||
 | 
			
		||||
**举例1:**跨省寄(不足1kg)
 | 
			
		||||
从北京寄到上海一件物品,物品重量0.8千克,1立方厘米(长*宽*高:1cm*1cm*1cm):
 | 
			
		||||
计算:
 | 
			
		||||
重量:不足1千克按照一千克计算
 | 
			
		||||
体积:跨省轻抛系数为12000,1立方厘米计算则为:1/12000
 | 
			
		||||
对比重量和体积,取大值
 | 
			
		||||
按照1公斤来计算,则运费为跨省寄运费,18元
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**举例2:**跨省寄(超过 1kg)
 | 
			
		||||
从北京寄到上海一件物品,物品重量1.8千克,1立方厘米(长*宽*高:1cm*1cm*1cm):
 | 
			
		||||
计算:
 | 
			
		||||
重量:1.8千克
 | 
			
		||||
体积:跨省轻抛系数为12000,1立方厘米计算则为:1/12000
 | 
			
		||||
对比重量和体积,取大值
 | 
			
		||||
按照1.8公斤来计算运费,则运费为跨省寄运费,
 | 
			
		||||
1公斤(首重)+0.8公斤(续重)
 | 
			
		||||
根据运费计算规则,10公斤以下以0.1为续重单位,则
 | 
			
		||||
首重+续重=1*18(首重价格)+0.8*5=18+4=22元
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**举例3:**跨省寄(按体积计费)
 | 
			
		||||
从北京寄到上海一件物品,物品重量3.8千克,125000立方厘米(长*宽*高:50cm*50cm*50cm):
 | 
			
		||||
计算:
 | 
			
		||||
重量:3.8千克=3(首重)+0.8(续重)
 | 
			
		||||
体积:跨省轻抛系数为12000,125000立方厘米:125000/12000=10.41
 | 
			
		||||
对比重量和体积,取大值10.41
 | 
			
		||||
根据运费计算规则,10-100公斤以0.5为计重单位,则10.41为10.5
 | 
			
		||||
首重+续重=1*18(首重价格)+9.5*5=18+47.5=65.5元
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**举例4:**同城寄(按体积计费)
 | 
			
		||||
从北京东城寄到北京西城一件物品,物品重量3.8kg,125000立方厘米(长*宽*高:50cm*50cm*50cm):
 | 
			
		||||
计算:
 | 
			
		||||
重量:3.8kg=1kg(首重)+2.8kg(续重)
 | 
			
		||||
体积:同城轻抛系数为12000,换算成重量125000立方厘米:125000/12000=10.41kg
 | 
			
		||||
对比重量(3.8kg)和体积(10.41kg),取大值10.41kg
 | 
			
		||||
根据运费计算规则,10-100kg以0.5kg为计重单位,则10.41kg为10.5kg
 | 
			
		||||
首重+续重=1kg*12元(首重价格)+9.5kg*2元(续重价格)=12+19=31元
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**举例5:**经济区互寄(按体积计费)
 | 
			
		||||
从北京寄到天津一件物品,物品重量3.8kg,125000立方厘米(长*宽*高:50cm*50cm*50cm):
 | 
			
		||||
计算:
 | 
			
		||||
重量:3.8kg=1kg(首重)+2.8kg(续重)
 | 
			
		||||
体积:经济区互寄(京津翼)轻抛系数为6000,换算成体积125000立方厘米:125000/6000=20.83kg
 | 
			
		||||
对比重量(3.8kg)和体积(20.83kg),取大值20.83kg
 | 
			
		||||
根据运费计算规则,10-100kg以0.5kg为计重单位,则20.83为21kg
 | 
			
		||||
首重+续重=1*12元(首重价格)+20*5元(续重价格)=12+100=112元
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**举例6:**省内寄(按重量计费)
 | 
			
		||||
从石家庄寄到秦皇岛一件物品,物品重量3.8kg,5000立方厘米(长*宽*高:50cm*10cm*10cm):
 | 
			
		||||
计算:
 | 
			
		||||
重量:3.8kg=1kg(首重)+2.8kg(续重)
 | 
			
		||||
体积:省内寄轻抛系数为8000,换算成体积5000立方厘米:5000/6000=0.8kg
 | 
			
		||||
对比重量(3.8kg)和体积(0.8kg),取大值3.8kg
 | 
			
		||||
根据运费计算规则,10kg以下以0.1kg为计重单位,则3.8kg为3.8kg
 | 
			
		||||
首重+续重=1*12元(首重价格)+2.8*3元(续重价格)=12+8.4=20.4元
 | 
			
		||||
## 5.4、部署
 | 
			
		||||
 | 
			
		||||
已经将该服务的部署脚本写到Jenkins中,直接使用即可。
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
部署成功后进行测试,结果与本地一样。
 | 
			
		||||
## 5.5、用户端测试
 | 
			
		||||
 | 
			
		||||
用户下单时,需要根据收发地址计算运费,所以需要将用户端运行起来进行功能测试。
 | 
			
		||||
用户端的部署参考[《前端部署文档》](https://www.yuque.com/docs/share/90dee639-d6a5-48c7-a644-4829db1e47ae)。
 | 
			
		||||
 | 
			
		||||
需要启动如下所需要的服务,进行测试:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
如果出现如下错误,是因为 `sl-express-ms-oms-service` 服务没有启动。
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
测试结果如下:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
# 6、练习
 | 
			
		||||
 | 
			
		||||
需求:计算运费的第一步就是根据参数查询运费模板,而这个动作会访问数据库,并且是比较频繁的,然而运费模板的变更并不频繁,需要可以将运费模板缓存起来,以提高效率。
 | 
			
		||||
 | 
			
		||||
提示:
 | 
			
		||||
 | 
			
		||||
- 需要引入redis相关的依赖
 | 
			
		||||
- 增加redis相关的配置
 | 
			
		||||
- 编码实现缓存相关逻辑
 | 
			
		||||
# 7、面试连环问
 | 
			
		||||
:::info
 | 
			
		||||
面试官问:
 | 
			
		||||
 | 
			
		||||
- 你们的运费是怎么计算的?体积和重量怎么计算,到底以哪个为准?
 | 
			
		||||
- 详细聊聊你们的运费模板是做什么的?
 | 
			
		||||
- 有没有针对运费计算做什么优化?
 | 
			
		||||
:::
 | 
			
		||||
							
								
								
									
										1116
									
								
								01-讲义/md/day05-路线规划之Neo4j入门.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1116
									
								
								01-讲义/md/day05-路线规划之Neo4j入门.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1931
									
								
								01-讲义/md/day06-路线规划之微服务.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1931
									
								
								01-讲义/md/day06-路线规划之微服务.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										956
									
								
								01-讲义/md/day07-智能调度之调度任务.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										956
									
								
								01-讲义/md/day07-智能调度之调度任务.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,956 @@
 | 
			
		||||
# 课程安排
 | 
			
		||||
- 什么是智能调度
 | 
			
		||||
- 实现订单转运单
 | 
			
		||||
- 美团Leaf使用入门
 | 
			
		||||
- 完善运单服务
 | 
			
		||||
- 合并运单
 | 
			
		||||
# 1、背景说明
 | 
			
		||||
通过前面的学习,已经掌握了路线规划的核心实现,有了路线规划之后就可以对运单进行调度了,这将是物流项目最为核心的内容,一个好的调度系统可以高效的管理着运单、运输任务、司机作业单、快递员取派件任务等,接下来你将参与智能调度的开发中,其中一部分业务功能已经实现,但核心的业务逻辑是需要你来实现的。
 | 
			
		||||
这部分内容的难度是比较大的,加油~
 | 
			
		||||

 | 
			
		||||
# 2、智能调度
 | 
			
		||||
在神领物流项目中,采用智能调度的方式对车辆任务、快递员的取派件任务进行调度管理,这样更加有效的进行管理,降低企业运营成本。
 | 
			
		||||
## 2.1、为什么需要调度?
 | 
			
		||||
可能你会这样的疑问,用户下单了,快递员上门取件,取件后送回网点,网点有车辆运走,再经过车辆的一系列的运输,最后进行派件,对方就能收到快件,不就是这么简单的流程吗?为什么需要调度?
 | 
			
		||||
没错,看起来是简单的流程,实际上通过仔细的分析就会发现这个过程并不简单,甚至会非常的复杂,比如:
 | 
			
		||||
 | 
			
		||||
- 用户下单后,应该哪个网点的快递员上门呢?
 | 
			
		||||
   - 这样就需要通过用户的发件人地址信息定位到所属服务范围内的网点进行服务
 | 
			
		||||
   - 紧接着又会有一个问题,确定了网点后,这个网点有多个快递员,这个取件任务应该是指派谁呢?
 | 
			
		||||
   - 这里就需要对快递员的工作情况以及排班情况进行分析,才能确定哪个快递员进行服务。
 | 
			
		||||
- 快递员把快件拿回到网点后,假设这个快件是从上海寄往北京的,是网点直接开车送到北京吗?
 | 
			
		||||
   - 显然不是的,直接送的话成本太高了,怎么样成本最低呢?显然是车辆尽可能的满载,集中化运输(这个车上装的都是从A点→B点的快件,这里的A和B可能代表的网点或转运中心,而非全路线)
 | 
			
		||||

 | 
			
		||||
   - 一般物流公司会有很多的车辆、路线、司机,而每个路线都会设置不同的车次,如何能够将快件合理的分配到车辆上,分配时需要参考车辆的载重、司机的排班,车辆的状态以及车次等信息
 | 
			
		||||
- 快件到收件人地址所在服务范围内的网点了,系统该如何分配快递员?
 | 
			
		||||
- 还有一些其他的情况,比如:快件拒收应该怎么处理?车辆故障不能使用怎么处理?一车多个司机,运输任务是如何划分?等等
 | 
			
		||||
- 基于以上的问题分析,这就需要系统进行计算处理,这就是我们所说的【智能调度系统】,就是让整个物流流程中的参与者都通过系统的计算,可以井然有序的工作。
 | 
			
		||||
## 2.2、整体核心业务流程
 | 
			
		||||

 | 
			
		||||
:::danger
 | 
			
		||||
关键流程说明:
 | 
			
		||||
 | 
			
		||||
- 用户下单后,会产生取件任务,该任务也是由调度中心进行调度的
 | 
			
		||||
- 订单转运单后,会发送消息到调度中心,在调度中心中对相同节点的运单进行合并(这里是指最小转运单元)
 | 
			
		||||
- 调度中心同样也会对派件任务进行调度,用于生成快递员的派件任务
 | 
			
		||||
- 司机的出库和入库操作也是流程中的核心动作,尤其是入库操作,是推动运单流转的关键
 | 
			
		||||
:::
 | 
			
		||||
# 3、订单转运单
 | 
			
		||||
快递员上门取件成功后,会将订单转成运单,后续将进行一系列的转运流程。
 | 
			
		||||
## 3.1、业务流程
 | 
			
		||||

 | 
			
		||||
## 3.2、运单表结构
 | 
			
		||||
运单表是在sl_work数据库中,表名为:sl_transport_order,结构如下:
 | 
			
		||||
```sql
 | 
			
		||||
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='运单表';
 | 
			
		||||
```
 | 
			
		||||
## 3.3、揽收成功的消息
 | 
			
		||||
订单转运单的业务的触发点在于快递员的揽收成功,这个通过是通过消息传递的,之所以通过消息是为了减少系统间的耦合度。
 | 
			
		||||
### 3.3.1、发送消息
 | 
			
		||||
快递员揽件成功后,发出消息,这个逻辑是在`sl-express-ms-web-courier`工程的`com.sl.ms.web.courier.service.impl.TaskServiceImpl#pickup()`方法中实现的。如下:
 | 
			
		||||

 | 
			
		||||
:::info
 | 
			
		||||
消息的交换机名称、路由key均是在sl-express-common工程中的Constants.MQ常量类中定义的。
 | 
			
		||||
:::
 | 
			
		||||
### 3.3.2、消费消息
 | 
			
		||||
消息的消费是在`sl-express-ms-work-service`工程中的`com.sl.ms.work.mq.CourierMQListener#listenCourierPickupMsg()`方法中完成。具体实现如下:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 快递员取件成功
 | 
			
		||||
     *
 | 
			
		||||
     * @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);
 | 
			
		||||
 | 
			
		||||
        //订单转运单
 | 
			
		||||
        this.transportOrderService.orderToTransportOrder(courierMsg.getOrderId());
 | 
			
		||||
 | 
			
		||||
        //TODO 发送运单跟踪消息
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
:::danger
 | 
			
		||||
该消息监听中的交换机、路由key必须与上述消息发送的一致,否则接收不到消息。队列名唯一,不能与其他业务共用。
 | 
			
		||||
_发送运单跟踪消息的业务逻辑我们将在后面做实现,现在暂时不做实现。_
 | 
			
		||||
:::
 | 
			
		||||
## 3.4、生成运单号
 | 
			
		||||
对于运单号的生成有特殊的要求,格式:SL+13位数字,例如:SL1000000000760,对于这个需求,如果采用MP提供的雪花id生成是19位,是不能满足需求的,所以我们需要自己生成id,并且要确保唯一不能重复。
 | 
			
		||||
在这里我们采用美团的Leaf作为id生成服务,其源码托管于GitHub:
 | 
			
		||||
这里有个美团的技术播客,专门介绍了Leaf:
 | 
			
		||||
> 目前Leaf覆盖了美团点评公司内部金融、餐饮、外卖、酒店旅游、猫眼电影等众多业务线。在4C8G VM基础上,通过公司RPC方式调用,QPS压测结果近5w/s,TP999 1ms。
 | 
			
		||||
> Leaf 提供两种生成的ID的方式(segment模式和snowflake模式),我们采用segment模式(号段)来生成运单号。
 | 
			
		||||
 | 
			
		||||
```shell
 | 
			
		||||
# get请求,获取到id
 | 
			
		||||
http://192.168.150.101:28838/api/segment/get/transport_order
 | 
			
		||||
```
 | 
			
		||||
### 3.4.1、号段模式
 | 
			
		||||
号段模式采用的是基于MySQL数据生成id的,它并不是基于MySQL表中的自增长实现的,因为基于MySQL的自增长方案对于数据库的依赖太大了,性能不好,Leaf的号段模式是基于一张表来实现,每次获取一个号段,生成id时从内存中自增长,当号段用完后再去更新数据库表,如下:
 | 
			
		||||

 | 
			
		||||
字段说明:
 | 
			
		||||
 | 
			
		||||
- biz_tag:业务标签,用来区分业务
 | 
			
		||||
- max_id:表示该biz_tag目前所被分配的ID号段的最大值
 | 
			
		||||
- step:表示每次分配的号段长度。如果把step设置得足够大,比如1000,那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。
 | 
			
		||||
- description:描述
 | 
			
		||||
- update_time:更新时间
 | 
			
		||||
 | 
			
		||||
Leaf架构图:
 | 
			
		||||

 | 
			
		||||
说明:test_tag在第一台Leaf机器上是1~1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:
 | 
			
		||||

 | 
			
		||||
Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。
 | 
			
		||||
Leaf为此做了优化,增加了双buffer优化。
 | 
			
		||||
:::info
 | 
			
		||||
当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。
 | 
			
		||||
:::
 | 
			
		||||

 | 
			
		||||
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。
 | 
			
		||||
 | 
			
		||||
- 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS(秒处理事务数)的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
 | 
			
		||||
- 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。
 | 
			
		||||
### 3.4.2、部署服务
 | 
			
		||||
我们只用到了号段的方式,并没有使用雪花方式,所以只需要创建数据库表即可,无需安装ZooKeeper。
 | 
			
		||||
:::danger
 | 
			
		||||
Leaf官方是没有docker镜像的,我们将其进行了镜像制作,并且上传到阿里云仓库,可以直接下载使用。目前已经在101机器部署完成。
 | 
			
		||||
:::
 | 
			
		||||
```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
 | 
			
		||||
```
 | 
			
		||||
```properties
 | 
			
		||||
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数据库脚本:
 | 
			
		||||
```sql
 | 
			
		||||
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');
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
测试:
 | 
			
		||||
```shell
 | 
			
		||||
# 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`工程中,代码如下:
 | 
			
		||||
```java
 | 
			
		||||
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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
```java
 | 
			
		||||
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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
使用步骤:
 | 
			
		||||
 | 
			
		||||
- 在配置文件中进行配置`sl.id.leaf`为: http://192.168.150.101:28838
 | 
			
		||||
- 在Service中注入IdService,调用getId()方法即可,例如:`idService.getId(IdEnum.TRANSPORT_ORDER)`
 | 
			
		||||
## 3.5、编码实现
 | 
			
		||||
订单转运单的实现是在`sl-express-ms-work-service`微服务中完成的,git地址:[http://git.sl-express.com/sl/sl-express-ms-work-service.git](http://git.sl-express.com/sl/sl-express-ms-work-service.git)
 | 
			
		||||
具体编码实现:
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    @Transactional
 | 
			
		||||
    public TransportOrderEntity orderToTransportOrder(Long orderId) {
 | 
			
		||||
        //幂等性校验
 | 
			
		||||
        TransportOrderEntity transportOrderEntity = this.findByOrderId(orderId);
 | 
			
		||||
        if (ObjectUtil.isNotEmpty(transportOrderEntity)) {
 | 
			
		||||
            return transportOrderEntity;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //查询订单
 | 
			
		||||
        OrderDetailDTO detailByOrder = this.orderFeign.findDetailByOrderId(orderId);
 | 
			
		||||
        if (ObjectUtil.isEmpty(detailByOrder)) {
 | 
			
		||||
            throw new SLException(WorkExceptionEnum.ORDER_NOT_FOUND);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //校验货物的重量和体积数据
 | 
			
		||||
        OrderCargoDTO cargoDto = detailByOrder.getOrderDTO().getOrderCargoDto();
 | 
			
		||||
        if (ObjectUtil.isEmpty(cargoDto)) {
 | 
			
		||||
            throw new SLException(WorkExceptionEnum.ORDER_CARGO_NOT_FOUND);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //校验位置信息
 | 
			
		||||
        OrderLocationDTO orderLocationDTO = detailByOrder.getOrderLocationDTO();
 | 
			
		||||
        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);
 | 
			
		||||
            } else {
 | 
			
		||||
                // 不需要调度 发送消息更新订单状态
 | 
			
		||||
                this.sendUpdateStatusMsg(ListUtil.toList(transportOrder.getId()), TransportOrderStatus.ARRIVED_END);
 | 
			
		||||
                //不需要调度,发送消息生成派件任务
 | 
			
		||||
                this.sendDispatchTaskMsgToDispatch(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);
 | 
			
		||||
 | 
			
		||||
            return transportOrder;
 | 
			
		||||
        }
 | 
			
		||||
        //保存失败
 | 
			
		||||
        throw new SLException(WorkExceptionEnum.TRANSPORT_ORDER_SAVE_ERROR);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
发送消息的代码实现:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 发送运单消息到调度中,参与调度
 | 
			
		||||
     */
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 3.6、测试
 | 
			
		||||
测试订单转运单功能,需要启动所必须的一些服务,base、oms、transport服务,启动命令如下:
 | 
			
		||||
```shell
 | 
			
		||||
#在101机器执行如下命令
 | 
			
		||||
 | 
			
		||||
docker start sl-express-ms-base-service
 | 
			
		||||
docker start sl-express-ms-oms-service
 | 
			
		||||
docker start sl-express-ms-transport-service
 | 
			
		||||
```
 | 
			
		||||
编写测试用例:
 | 
			
		||||
```java
 | 
			
		||||
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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
:::danger
 | 
			
		||||
测试时,需要确保在sl_oms数据库中的sl_order、sl_order_cargo、sl_order_location表中有对应的订单数据。
 | 
			
		||||
如果没有数据,可以通过以下SQL插入测试数据或者通过用户端进行下单。
 | 
			
		||||
另外,还没有PickupDispatchTaskService的实现类,直接测试会报错,所以需要把对应controller上面的注解注掉`@RestController`
 | 
			
		||||
:::
 | 
			
		||||
```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');
 | 
			
		||||
```
 | 
			
		||||
测试结果,运单已经写入到了sl_transport_order表中:
 | 
			
		||||

 | 
			
		||||
# 4、完善运单服务
 | 
			
		||||
前面已经完成了订单转运单的功能,接下来我们将完善运单中的其他基本的实现,这部分代码以阅读、理解为主。
 | 
			
		||||
:::info
 | 
			
		||||
其中pageQueryByTaskId()、updateByTaskId()方法在学习运输任务相关业务时进行实现。
 | 
			
		||||
:::
 | 
			
		||||
## 4.1、获取运单分页数据
 | 
			
		||||
接口定义:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 获取运单分页数据
 | 
			
		||||
     *
 | 
			
		||||
     * @return 运单分页数据
 | 
			
		||||
     */
 | 
			
		||||
    Page<TransportOrderEntity> findByPage(TransportOrderQueryDTO transportOrderQueryDTO);
 | 
			
		||||
```
 | 
			
		||||
service实现:
 | 
			
		||||
```java
 | 
			
		||||
    @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);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 4.2、订单id获取运单信息
 | 
			
		||||
接口定义:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 通过订单id获取运单信息
 | 
			
		||||
     *
 | 
			
		||||
     * @param orderId 订单id
 | 
			
		||||
     * @return 运单信息
 | 
			
		||||
     */
 | 
			
		||||
    TransportOrderEntity findByOrderId(Long orderId);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 通过订单id列表获取运单列表
 | 
			
		||||
     *
 | 
			
		||||
     * @param orderIds 订单id列表
 | 
			
		||||
     * @return 运单列表
 | 
			
		||||
     */
 | 
			
		||||
    List<TransportOrderEntity> findByOrderIds(Long[] orderIds);
 | 
			
		||||
```
 | 
			
		||||
service实现:
 | 
			
		||||
```java
 | 
			
		||||
    @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获取运单列表
 | 
			
		||||
接口定义:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 通过运单id列表获取运单列表
 | 
			
		||||
     *
 | 
			
		||||
     * @param ids 订单id列表
 | 
			
		||||
     * @return 运单列表
 | 
			
		||||
     */
 | 
			
		||||
    List<TransportOrderEntity> findByIds(String[] ids);
 | 
			
		||||
```
 | 
			
		||||
service实现:
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransportOrderEntity> findByIds(String[] ids) {
 | 
			
		||||
        LambdaQueryWrapper<TransportOrderEntity> queryWrapper = new LambdaQueryWrapper<>();
 | 
			
		||||
        queryWrapper.in(TransportOrderEntity::getId, ids);
 | 
			
		||||
        return super.list(queryWrapper);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 4.4、根据运单号搜索运单
 | 
			
		||||
接口定义:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 根据运单号搜索运单
 | 
			
		||||
     *
 | 
			
		||||
     * @param id 运单号
 | 
			
		||||
     * @return 运单列表
 | 
			
		||||
     */
 | 
			
		||||
    List<TransportOrderEntity> searchById(String id);
 | 
			
		||||
```
 | 
			
		||||
service实现:
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransportOrderEntity> searchById(String id) {
 | 
			
		||||
        LambdaQueryWrapper<TransportOrderEntity> queryWrapper = new LambdaQueryWrapper<>();
 | 
			
		||||
        queryWrapper.like(TransportOrderEntity::getId, id);
 | 
			
		||||
        return super.list(queryWrapper);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 4.5、统计状态的数量
 | 
			
		||||
接口定义:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 统计各个状态的数量
 | 
			
		||||
     *
 | 
			
		||||
     * @return 状态数量数据
 | 
			
		||||
     */
 | 
			
		||||
    List<TransportOrderStatusCountDTO> findStatusCount();
 | 
			
		||||
```
 | 
			
		||||
service实现:
 | 
			
		||||
```java
 | 
			
		||||
    @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,后面实现)
 | 
			
		||||
- 更新状态后需要通知其他系统(消息通知)
 | 
			
		||||
 | 
			
		||||
接口定义:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 修改运单状态
 | 
			
		||||
     *
 | 
			
		||||
     * @param ids                  运单id列表
 | 
			
		||||
     * @param transportOrderStatus 修改的状态
 | 
			
		||||
     * @return 是否成功
 | 
			
		||||
     */
 | 
			
		||||
    boolean updateStatus(List<String> ids, TransportOrderStatus transportOrderStatus);
 | 
			
		||||
```
 | 
			
		||||
service实现:
 | 
			
		||||
```java
 | 
			
		||||
    @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、实现分析
 | 
			
		||||
运单在运输过程中,虽然快件的起点与终点都不一定相同,但是在中间转运环节有一些运输节点是相同的,如下:
 | 
			
		||||

 | 
			
		||||
可以看出,A→E与A→G的运单,在A→B和B→C的转运是相同的,所以在做任务调度时,首先要做的事情就是将相同转运的运单进行合并,以供后续调度中心进行调度。
 | 
			
		||||
合并之后的结果存储在哪里呢?我们可以想一下,后续处理的需求:
 | 
			
		||||
 | 
			
		||||
- 先进行合并处理的运单按照顺序进行调度
 | 
			
		||||
- 此次运单调度处理完成后就应该将其删除掉,表示已经处理完成
 | 
			
		||||
 | 
			
		||||
根据以上两点的需求,很容易想到队列的存储方式,先进先出,实现队列的技术方案有很多,在这里我们采用Redis的List作为队列,将相同节点转运的订单放到同一个队列中,可以使用其`LPUSH`放进去,`RPOP`弹出数据,这样就可以确保先进先出,并且弹出后数据将删除,因此符合我们的需求。
 | 
			
		||||

 | 
			
		||||
## 5.2、代码实现
 | 
			
		||||
### 5.2.1、准备环境
 | 
			
		||||
合并运单与调度的业务逻辑都放到`sl-express-ms-dispatch-service`工程中,git地址:[http://git.sl-express.com/sl/sl-express-ms-dispatch-service.git](http://git.sl-express.com/sl/sl-express-ms-dispatch-service.git),检出代码如下:
 | 
			
		||||

 | 
			
		||||
### 5.2.2、编码实现
 | 
			
		||||
> 实现中,需要考虑消息的幂等性,防止重复数据的产生。
 | 
			
		||||
 | 
			
		||||
代码实现:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.ms.dispatch.mq;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.map.MapUtil;
 | 
			
		||||
import cn.hutool.core.util.ObjectUtil;
 | 
			
		||||
import cn.hutool.core.util.StrUtil;
 | 
			
		||||
import cn.hutool.json.JSONUtil;
 | 
			
		||||
import com.sl.ms.dispatch.dto.DispatchMsgDTO;
 | 
			
		||||
import com.sl.transport.common.constant.Constants;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.springframework.amqp.core.ExchangeTypes;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.Exchange;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.Queue;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.QueueBinding;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
 | 
			
		||||
import org.springframework.data.redis.core.StringRedisTemplate;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 对于待调度运单消息的处理
 | 
			
		||||
 */
 | 
			
		||||
@Slf4j
 | 
			
		||||
@Component
 | 
			
		||||
public class TransportOrderDispatchMQListener {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private StringRedisTemplate stringRedisTemplate;
 | 
			
		||||
 | 
			
		||||
    @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);
 | 
			
		||||
        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 setRedisKey = this.getSetRedisKey(startId, endId);
 | 
			
		||||
        if (this.stringRedisTemplate.opsForSet().isMember(setRedisKey, transportOrderId)) {
 | 
			
		||||
            //重复消息
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //存储数据到redis,采用list结构,从左边写入数据,读取数据时从右边读取
 | 
			
		||||
        //key =>  DISPATCH_LIST_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(setRedisKey, 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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
## 5.3、测试
 | 
			
		||||
将`DispatchApplication`启动后,观察RabbitMQ服务,发现`sl.queue.dispatch.mergeTransportOrder`队列已经绑定到`sl.exchange.topic.transportOrder.delayed`交换机。
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
测试方法:
 | 
			
		||||
在work微服务中的测试用例进行订单转运单的操作,让其发出消息,在dispatch微服务中进行断点跟踪,最终数据存储到了redis:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
# 6、练习
 | 
			
		||||
## 6.1、练习一:编写代码
 | 
			
		||||
难度系数:★★★★☆
 | 
			
		||||
完成本节课中所编写的业务代码。
 | 
			
		||||
## 6.2、练习二:阅读代码
 | 
			
		||||
难度系数:★★★☆☆
 | 
			
		||||
需求:阅读快递员服务中的【取件】业务功能,主要阅读代码逻辑如下:
 | 
			
		||||
  1)理解取件业务的逻辑
 | 
			
		||||
  2)理解实名认证的方法
 | 
			
		||||
  3)理解更新订单状态的业务逻辑
 | 
			
		||||
# 7、面试连环问
 | 
			
		||||
:::info
 | 
			
		||||
面试官问:
 | 
			
		||||
 | 
			
		||||
- 物流项目中你参与了核心的功能开发吗?能说一下核心的业务逻辑吗?
 | 
			
		||||
- 你们的运单号是怎么生成的?如何确保性能?
 | 
			
		||||
- 能说一下订单转运单的业务逻辑吗?生成运单后如何与调度中心整合在一起的?
 | 
			
		||||
- 合并运单为什么使用Redis的List作为队列?如何确保消息的幂等性的?
 | 
			
		||||
:::
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										670
									
								
								01-讲义/md/day08-智能调度之运输任务.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										670
									
								
								01-讲义/md/day08-智能调度之运输任务.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,670 @@
 | 
			
		||||
# 课程安排
 | 
			
		||||
- 智能调度生成运输任务
 | 
			
		||||
- 实现运输任务相关业务
 | 
			
		||||
- 实现司机入库业务
 | 
			
		||||
# 1、背景说明
 | 
			
		||||
通过前面的学习,已经可以将相同转运节点的运单合并,合并之后就需要进行调度计算,按照车辆的运力生成运输任务,以及司机的作业单,这样的话就可以进入到司机与车辆的运输环节了。
 | 
			
		||||

 | 
			
		||||
# 2、任务调度
 | 
			
		||||
## 2.1、分析
 | 
			
		||||
通过前面的实现,已经将相同转运节点的写入到了Redis的队列中,谁来处理呢?这就需要调度任务进行处理了,基本的思路是:
 | 
			
		||||
> 查询待分配任务的车辆 -> 计算运力 -> 分配运单 -> 生成运输任务 -> 生成司机作业单
 | 
			
		||||
> **也就是说,调度是站在车辆角度推进的。**
 | 
			
		||||
 | 
			
		||||
处理具体的处理业务流程如下:
 | 
			
		||||

 | 
			
		||||
:::info
 | 
			
		||||
线路、车辆、车次操作关系查看文档:
 | 
			
		||||
:::
 | 
			
		||||
## 2.2、实现
 | 
			
		||||
这里采用的是xxl-job的分片式任务调度,主要目的是为了并行多处理车辆,提升调度处理效率。
 | 
			
		||||
### 2.2.1、调度入口
 | 
			
		||||
```java
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportOrderDispatchMQListener transportOrderDispatchMQListener;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private StringRedisTemplate stringRedisTemplate;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private RedissonClient redissonClient;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TruckPlanFeign truckPlanFeign;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private MQFeign mqFeign;
 | 
			
		||||
 | 
			
		||||
    @Value("${sl.volume.ratio:0.95}")
 | 
			
		||||
    private Double volumeRatio;
 | 
			
		||||
    @Value("${sl.weight.ratio:0.95}")
 | 
			
		||||
    private Double weightRatio;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 分片广播方式处理运单,生成运输任务
 | 
			
		||||
     */
 | 
			
		||||
    @XxlJob("transportTask")
 | 
			
		||||
    public void transportTask() {
 | 
			
		||||
        // 分片参数
 | 
			
		||||
        int shardIndex = XxlJobHelper.getShardIndex();
 | 
			
		||||
        int shardTotal = XxlJobHelper.getShardTotal();
 | 
			
		||||
 | 
			
		||||
        //根据分片参数 2小时内并且可用状态车辆
 | 
			
		||||
        // List<TruckPlanDto> truckDtoList = this.queryTruckPlanDtoList(shardIndex, shardTotal);
 | 
			
		||||
        List<TruckPlanDto> truckDtoList = this.truckPlanFeign.pullUnassignedPlan(shardTotal, shardIndex);
 | 
			
		||||
        if (CollUtil.isEmpty(truckDtoList)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 对每一个车辆都进行处理
 | 
			
		||||
        // 为了相同目的地的运单尽可能的分配在一个运输任务中,所以需要在读取数据时进行锁定,一个车辆处理完成后再开始下一个车辆处理
 | 
			
		||||
        // 在这里,使用redis的分布式锁实现
 | 
			
		||||
        for (TruckPlanDto truckPlanDto : truckDtoList) {
 | 
			
		||||
            //校验车辆计划对象
 | 
			
		||||
            if (ObjectUtil.hasEmpty(truckPlanDto.getStartOrganId(), truckPlanDto.getEndOrganId(),
 | 
			
		||||
                    truckPlanDto.getTransportTripsId(), truckPlanDto.getId())) {
 | 
			
		||||
                log.error("车辆计划对象数据不符合要求, truckPlanDto -> {}", truckPlanDto);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            //根据该车辆的开始、结束机构id,来确定要处理的运单数据集合
 | 
			
		||||
            Long startOrganId = truckPlanDto.getStartOrganId();
 | 
			
		||||
            Long endOrganId = truckPlanDto.getEndOrganId();
 | 
			
		||||
            String redisKey = this.transportOrderDispatchMQListener.getListRedisKey(startOrganId, endOrganId);
 | 
			
		||||
            String lockRedisKey = Constants.LOCKS.DISPATCH_LOCK_PREFIX + redisKey;
 | 
			
		||||
            //获取锁
 | 
			
		||||
            RLock lock = this.redissonClient.getFairLock(lockRedisKey);
 | 
			
		||||
            List<DispatchMsgDTO> dispatchMsgDTOList = new ArrayList<>();
 | 
			
		||||
            try {
 | 
			
		||||
                //锁定,一直等待锁,一定要获取到锁,因为查询到车辆的调度状态设置为:已分配
 | 
			
		||||
                lock.lock();
 | 
			
		||||
                //计算车辆运力、合并运单
 | 
			
		||||
                this.executeTransportTask(redisKey, truckPlanDto.getTruckDto(), dispatchMsgDTOList);
 | 
			
		||||
            } finally {
 | 
			
		||||
                lock.unlock();
 | 
			
		||||
            }
 | 
			
		||||
            //生成运输任务
 | 
			
		||||
            this.createTransportTask(truckPlanDto, startOrganId, endOrganId, dispatchMsgDTOList);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //发送消息通过车辆已经完成调度
 | 
			
		||||
        this.completeTruckPlan(truckDtoList);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 2.2.2、运单处理
 | 
			
		||||
递归处理运单,需要考虑到车辆的运力:
 | 
			
		||||
```java
 | 
			
		||||
    private void executeTransportTask(String redisKey, TruckDto truckDto, List<DispatchMsgDTO> dispatchMsgDTOList) {
 | 
			
		||||
        String redisData = this.stringRedisTemplate.opsForList().rightPop(redisKey);
 | 
			
		||||
        if (StrUtil.isEmpty(redisData)) {
 | 
			
		||||
            //该车辆没有运单需要运输
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        DispatchMsgDTO dispatchMsgDTO = JSONUtil.toBean(redisData, DispatchMsgDTO.class);
 | 
			
		||||
 | 
			
		||||
        //计算该车辆已经分配的运单,是否超出其运力,载重 或 体积超出,需要将新拿到的运单加进去后进行比较
 | 
			
		||||
        BigDecimal totalWeight = NumberUtil.add(NumberUtil.toBigDecimal(dispatchMsgDTOList.stream()
 | 
			
		||||
                .mapToDouble(DispatchMsgDTO::getTotalWeight)
 | 
			
		||||
                .sum()), dispatchMsgDTO.getTotalWeight());
 | 
			
		||||
 | 
			
		||||
        BigDecimal totalVolume = NumberUtil.add(NumberUtil.toBigDecimal(dispatchMsgDTOList.stream()
 | 
			
		||||
                .mapToDouble(DispatchMsgDTO::getTotalVolume)
 | 
			
		||||
                .sum()), dispatchMsgDTO.getTotalVolume());
 | 
			
		||||
 | 
			
		||||
        //车辆最大的容积和载重要留有余量,否则可能会超重 或 装不下
 | 
			
		||||
        BigDecimal maxAllowableLoad = NumberUtil.mul(truckDto.getAllowableLoad(), weightRatio);
 | 
			
		||||
        BigDecimal maxAllowableVolume = NumberUtil.mul(truckDto.getAllowableVolume(), volumeRatio);
 | 
			
		||||
 | 
			
		||||
        if (NumberUtil.isGreaterOrEqual(totalWeight, maxAllowableLoad)
 | 
			
		||||
                || NumberUtil.isGreaterOrEqual(totalVolume, maxAllowableVolume)) {
 | 
			
		||||
            //超出车辆运力,需要取货的运单再放回去,放到最右边,以便保证运单处理的顺序
 | 
			
		||||
            this.stringRedisTemplate.opsForList().rightPush(redisKey, redisData);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //没有超出运力,将该运单加入到集合中
 | 
			
		||||
        dispatchMsgDTOList.add(dispatchMsgDTO);
 | 
			
		||||
        //递归处理运单
 | 
			
		||||
        executeTransportTask(redisKey, truckDto, dispatchMsgDTOList);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 2.2.3、消息通知生成运输任务
 | 
			
		||||
```java
 | 
			
		||||
    private void createTransportTask(TruckPlanDto truckPlanDto, Long startOrganId, Long endOrganId, List<DispatchMsgDTO> dispatchMsgDTOList) {
 | 
			
		||||
        //将运单合并的结果以消息的方式发送出去
 | 
			
		||||
        //key-> 车辆id,value ->  运单id列表
 | 
			
		||||
        //{"driverId":123, "truckPlanId":456, "truckId":1210114964812075008,"totalVolume":4.2,"endOrganId":90001,"totalWeight":7,"transportOrderIdList":[320733749248,420733749248],"startOrganId":100280}
 | 
			
		||||
        List<String> transportOrderIdList = CollUtil.getFieldValues(dispatchMsgDTOList, "transportOrderId", String.class);
 | 
			
		||||
        //司机列表确保不为null
 | 
			
		||||
        List<Long> driverIds = CollUtil.isNotEmpty(truckPlanDto.getDriverIds()) ? truckPlanDto.getDriverIds() : ListUtil.empty();
 | 
			
		||||
        Map<String, Object> msgResult = MapUtil.<String, Object>builder()
 | 
			
		||||
                .put("truckId", truckPlanDto.getTruckId()) //车辆id
 | 
			
		||||
                .put("driverIds", driverIds) //司机id
 | 
			
		||||
                .put("truckPlanId", truckPlanDto.getId()) //车辆计划id
 | 
			
		||||
                .put("transportTripsId", truckPlanDto.getTransportTripsId()) //车次id
 | 
			
		||||
                .put("startOrganId", startOrganId) //开始机构id
 | 
			
		||||
                .put("endOrganId", endOrganId) //结束机构id
 | 
			
		||||
                //运单id列表
 | 
			
		||||
                .put("transportOrderIdList", transportOrderIdList)
 | 
			
		||||
                //总重量
 | 
			
		||||
                .put("totalWeight", dispatchMsgDTOList.stream()
 | 
			
		||||
                        .mapToDouble(DispatchMsgDTO::getTotalWeight)
 | 
			
		||||
                        .sum())
 | 
			
		||||
                //总体积
 | 
			
		||||
                .put("totalVolume", dispatchMsgDTOList.stream()
 | 
			
		||||
                        .mapToDouble(DispatchMsgDTO::getTotalVolume)
 | 
			
		||||
                        .sum())
 | 
			
		||||
                .build();
 | 
			
		||||
 | 
			
		||||
        //发送消息
 | 
			
		||||
        String jsonMsg = JSONUtil.toJsonStr(msgResult);
 | 
			
		||||
        this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_TASK,
 | 
			
		||||
                Constants.MQ.RoutingKeys.TRANSPORT_TASK_CREATE, jsonMsg);
 | 
			
		||||
 | 
			
		||||
        if (CollUtil.isNotEmpty(transportOrderIdList)) {
 | 
			
		||||
            //删除redis中set存储的运单数据
 | 
			
		||||
            String setRedisKey = this.transportOrderDispatchMQListener.getSetRedisKey(startOrganId, endOrganId);
 | 
			
		||||
            this.stringRedisTemplate.opsForSet().remove(setRedisKey, transportOrderIdList.toArray());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 2.2.4、消息通知完成车辆计划
 | 
			
		||||
通过消息的方式通知base微服务,完成车辆计划。
 | 
			
		||||
```java
 | 
			
		||||
    private void completeTruckPlan(List<TruckPlanDto> truckDtoList) {
 | 
			
		||||
        //{"ids":[1,2,3], "created":123456}
 | 
			
		||||
        Map<String, Object> msg = MapUtil.<String, Object>builder()
 | 
			
		||||
                .put("ids", CollUtil.getFieldValues(truckDtoList, "id", Long.class))
 | 
			
		||||
                .put("created", System.currentTimeMillis()).build();
 | 
			
		||||
        String jsonMsg = JSONUtil.toJsonStr(msg);
 | 
			
		||||
        //发送消息
 | 
			
		||||
        this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRUCK_PLAN,
 | 
			
		||||
                Constants.MQ.RoutingKeys.TRUCK_PLAN_COMPLETE, jsonMsg);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 2.3、xxl-job任务
 | 
			
		||||
编写完任务调度代码之后,需要在xxl-job中创建定时任务。地址:[http://xxl-job.sl-express.com/xxl-job-admin/](http://xxl-job.sl-express.com/xxl-job-admin/)
 | 
			
		||||
第一步,设置执行器,AppName为:`sl-express-ms-dispatch`
 | 
			
		||||

 | 
			
		||||
第二步,创建任务,任务的分发方式为分片式调度(每5分钟执行一次):
 | 
			
		||||

 | 
			
		||||
创建完成:
 | 
			
		||||

 | 
			
		||||
## 2.4、测试
 | 
			
		||||
### 2.4.1、测试xxl-job
 | 
			
		||||
将`sl-express-ms-dispatch`服务启动,发现在xxl-job中已经注册了服务:
 | 
			
		||||

 | 
			
		||||
:::danger
 | 
			
		||||
在这里一定要注意,注册的这个ip地址与101机器是否能通信,就上面的情况,显然是不能通信的,所以需要需要在配置文件中指定注册的ip地址。
 | 
			
		||||
如果可以通信就不需要指定。
 | 
			
		||||
:::
 | 
			
		||||
指定xxl-job中注册任务的ip地址:
 | 
			
		||||
```properties
 | 
			
		||||
xxl:
 | 
			
		||||
  job:
 | 
			
		||||
    executor:
 | 
			
		||||
      ip: 192.168.150.1
 | 
			
		||||
```
 | 
			
		||||
重新启动,这样就可以通信了,如下:
 | 
			
		||||

 | 
			
		||||
点击执行一次,进行测试看是否会触发代码的执行:
 | 
			
		||||

 | 
			
		||||
可以看到已经执行了:
 | 
			
		||||

 | 
			
		||||
### 2.4.2、添加车辆车次
 | 
			
		||||
在前面的测试中,查询可用车辆是空的,所以我们需要添加车辆、车次。
 | 
			
		||||
:::info
 | 
			
		||||
需要将必要的微服务启动,如果已经启动请忽略。如果没有服务,请先在Jenkins中进行部署。
 | 
			
		||||
docker start sl-express-gateway
 | 
			
		||||
docker start sl-express-ms-base-service
 | 
			
		||||
docker start sl-express-ms-transport-service
 | 
			
		||||
docker start sl-express-ms-web-manager
 | 
			
		||||
docker start sl-express-ms-driver-service
 | 
			
		||||
:::
 | 
			
		||||
首先需要在权限系统中添加司机,系统中要求一个车辆至少配置2个司机,所以至少要创建2个司机,为司机完善好排班之后,还需要完善司机的驾驶证信息:
 | 
			
		||||
> 完善司机驾驶证信息需要上传图片,上传图片需要先nacos中的`sl-express-ms-web-manager.properties`配置oss相关的配置项,否则不能正常上传图片。
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
为司机设置排班,如果司机休息也是不可以进行调度的:
 | 
			
		||||

 | 
			
		||||
排班:
 | 
			
		||||

 | 
			
		||||
创建车型:
 | 
			
		||||

 | 
			
		||||
新增车辆:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
:::danger
 | 
			
		||||
**请注意:**
 | 
			
		||||
添加车辆时,需要为车辆设置行驶证信息,否则是无法配置的,配置行驶证信息需要上传图片,上传图片需要先nacos中的`sl-express-ms-web-manager.properties`配置oss相关的配置项,否则不能正常上传图片。
 | 
			
		||||
:::
 | 
			
		||||
为司机配置车辆:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
为线路添加车次与车辆(确保添加车次是测试数据中转运的路线,否则没有数据可以处理):
 | 
			
		||||

 | 
			
		||||
另外,一定要注意,设置车次的时间要比当前测试时间+2小时要小才能查询到数据,否则查询不到。
 | 
			
		||||
### 2.4.3、完整测试
 | 
			
		||||
现在就可以查询到车次数据,通过xxl-job的调度进行测试。
 | 
			
		||||
车辆计划数据的状态要为1,调度状态为0,才能获取到车次数据。
 | 
			
		||||

 | 
			
		||||
Redis中存在队列数据:
 | 
			
		||||

 | 
			
		||||
获取到数据:
 | 
			
		||||

 | 
			
		||||
调度执行后,redis数据被处理掉了,车辆的计划调度状态也改为了【已调度】状态:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
另外,生成运输任务的消息已经发出了,只是我们还没有监听处理消息,在后面我们将实现对该消息的处理,就可以生成运输任务了。
 | 
			
		||||
# 3、运输任务
 | 
			
		||||
运输任务是针对于车辆的一次运输生成的,每一个运输任务都有对应的司机作业单。
 | 
			
		||||
例如:张三发了一个从北京金燕龙营业部发往上海浦东航头营业部的快递,它的转运路线是:`金燕龙营业部 → 昌平区分拣中心 → 北京转运中心 → 上海转运中心 → 浦东区分拣中心 → 航头营业部`,在此次的转运中一共会产生5个运输任务和至少10个司机作业单(一个车辆至少配备2个司机)。
 | 
			
		||||
需要注意的是,一个运输任务中包含了多个运单,就是一辆车拉了一车的快件,是一对多的关系。
 | 
			
		||||
## 3.1、表结构
 | 
			
		||||
运输任务在work微服务中,主要涉及到2张表,分别是:`sl_transport_task(运输任务表)`、`sl_transport_order_task(运输任务与运单关系表)`。司机作业单是存储在司机微服务中的`sl_driver_job(司机作业单)`表中。
 | 
			
		||||
```sql
 | 
			
		||||
CREATE TABLE `sl_transport_task` (
 | 
			
		||||
  `id` bigint NOT NULL COMMENT 'id',
 | 
			
		||||
  `truck_plan_id` bigint DEFAULT NULL COMMENT '车辆计划id',
 | 
			
		||||
  `transport_trips_id` bigint DEFAULT NULL COMMENT '车次id',
 | 
			
		||||
  `start_agency_id` bigint NOT NULL COMMENT '起始机构id',
 | 
			
		||||
  `end_agency_id` bigint NOT NULL COMMENT '目的机构id',
 | 
			
		||||
  `status` int NOT NULL COMMENT '任务状态,1为待执行(对应 未发车)、2为进行中(对应在途)、3为待确认(保留状态)、4为已完成(对应 已交付)、5为已取消',
 | 
			
		||||
  `assigned_status` tinyint NOT NULL COMMENT '任务分配状态(1未分配2已分配3待人工分配)',
 | 
			
		||||
  `loading_status` int NOT NULL COMMENT '满载状态(1.半载2.满载3.空载)',
 | 
			
		||||
  `truck_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆id',
 | 
			
		||||
  `cargo_pick_up_picture` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提货凭证',
 | 
			
		||||
  `cargo_picture` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '货物照片',
 | 
			
		||||
  `transport_certificate` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '运回单凭证',
 | 
			
		||||
  `deliver_picture` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '交付货物照片',
 | 
			
		||||
  `delivery_latitude` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提货纬度值',
 | 
			
		||||
  `delivery_longitude` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提货经度值',
 | 
			
		||||
  `deliver_latitude` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '交付纬度值',
 | 
			
		||||
  `deliver_longitude` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '交付经度值',
 | 
			
		||||
  `plan_departure_time` datetime DEFAULT NULL COMMENT '计划发车时间',
 | 
			
		||||
  `actual_departure_time` datetime DEFAULT NULL COMMENT '实际发车时间',
 | 
			
		||||
  `plan_arrival_time` datetime DEFAULT NULL COMMENT '计划到达时间',
 | 
			
		||||
  `actual_arrival_time` datetime DEFAULT NULL COMMENT '实际到达时间',
 | 
			
		||||
  `mark` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '备注',
 | 
			
		||||
  `distance` double DEFAULT NULL COMMENT '距离,单位:米',
 | 
			
		||||
  `created` datetime DEFAULT NULL COMMENT '创建时间',
 | 
			
		||||
  `updated` datetime DEFAULT NULL COMMENT '更新时间',
 | 
			
		||||
  PRIMARY KEY (`id`) USING BTREE,
 | 
			
		||||
  KEY `transport_trips_id` (`truck_plan_id`) USING BTREE,
 | 
			
		||||
  KEY `status` (`status`) USING BTREE,
 | 
			
		||||
  KEY `created` (`created`) USING BTREE
 | 
			
		||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='运输任务表';
 | 
			
		||||
```
 | 
			
		||||
```sql
 | 
			
		||||
CREATE TABLE `sl_transport_order_task` (
 | 
			
		||||
  `id` bigint NOT NULL COMMENT 'id',
 | 
			
		||||
  `transport_order_id` varchar(18) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '运单id',
 | 
			
		||||
  `transport_task_id` bigint NOT NULL COMMENT '运输任务id',
 | 
			
		||||
  `created` datetime DEFAULT NULL COMMENT '创建时间',
 | 
			
		||||
  `updated` datetime DEFAULT NULL COMMENT '更新时间',
 | 
			
		||||
  PRIMARY KEY (`id`) USING BTREE,
 | 
			
		||||
  KEY `transport_order_id` (`transport_order_id`) USING BTREE,
 | 
			
		||||
  KEY `transport_task_id` (`transport_task_id`) USING BTREE
 | 
			
		||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='运单与运输任务关联表';
 | 
			
		||||
```
 | 
			
		||||
```sql
 | 
			
		||||
CREATE TABLE `sl_driver_job` (
 | 
			
		||||
  `id` bigint NOT NULL COMMENT 'id',
 | 
			
		||||
  `start_agency_id` bigint DEFAULT NULL COMMENT '起始机构id',
 | 
			
		||||
  `end_agency_id` bigint DEFAULT NULL COMMENT '目的机构id',
 | 
			
		||||
  `status` int DEFAULT NULL COMMENT '作业状态,1为待执行(对应 待提货)、2为进行中(对应在途)、3为改派(对应 已交付)、4为已完成(对应 已交付)、5为已作废',
 | 
			
		||||
  `driver_id` bigint DEFAULT NULL COMMENT '司机id',
 | 
			
		||||
  `transport_task_id` bigint DEFAULT NULL COMMENT '运输任务id',
 | 
			
		||||
  `start_handover` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提货对接人',
 | 
			
		||||
  `finish_handover` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '交付对接人',
 | 
			
		||||
  `plan_departure_time` datetime DEFAULT NULL COMMENT '计划发车时间',
 | 
			
		||||
  `actual_departure_time` datetime DEFAULT NULL COMMENT '实际发车时间',
 | 
			
		||||
  `plan_arrival_time` datetime DEFAULT NULL COMMENT '计划到达时间',
 | 
			
		||||
  `actual_arrival_time` datetime DEFAULT NULL COMMENT '实际到达时间',
 | 
			
		||||
  `created` datetime DEFAULT NULL COMMENT '创建时间',
 | 
			
		||||
  `updated` datetime DEFAULT NULL COMMENT '更新时间',
 | 
			
		||||
  PRIMARY KEY (`id`) USING BTREE,
 | 
			
		||||
  KEY `task_transport_id` (`transport_task_id`) USING BTREE,
 | 
			
		||||
  KEY `created` (`created`) USING BTREE
 | 
			
		||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='司机作业单';
 | 
			
		||||
```
 | 
			
		||||
## 3.2、编码实现
 | 
			
		||||
### 3.2.1、监听消息
 | 
			
		||||
```java
 | 
			
		||||
    @Resource
 | 
			
		||||
    private DriverJobFeign driverJobFeign;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TruckPlanFeign truckPlanFeign;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportLineFeign transportLineFeign;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportTaskService transportTaskService;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportOrderTaskService transportOrderTaskService;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportOrderService transportOrderService;
 | 
			
		||||
 | 
			
		||||
	@RabbitListener(bindings = @QueueBinding(
 | 
			
		||||
            value = @Queue(name = Constants.MQ.Queues.WORK_TRANSPORT_TASK_CREATE),
 | 
			
		||||
            exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_TASK, type = ExchangeTypes.TOPIC),
 | 
			
		||||
            key = Constants.MQ.RoutingKeys.TRANSPORT_TASK_CREATE
 | 
			
		||||
    ))
 | 
			
		||||
    public void listenTransportTaskMsg(String msg) {
 | 
			
		||||
        //解析消息 {"driverIds":[123,345], "truckPlanId":456, "truckId":1210114964812075008,"totalVolume":4.2,"endOrganId":90001,"totalWeight":7,"transportOrderIdList":[320733749248,420733749248],"startOrganId":100280}
 | 
			
		||||
        JSONObject jsonObject = JSONUtil.parseObj(msg);
 | 
			
		||||
        //获取到司机id列表
 | 
			
		||||
        JSONArray driverIds = jsonObject.getJSONArray("driverIds");
 | 
			
		||||
        // 分配状态
 | 
			
		||||
        TransportTaskAssignedStatus assignedStatus = CollUtil.isEmpty(driverIds) ? TransportTaskAssignedStatus.MANUAL_DISTRIBUTED : TransportTaskAssignedStatus.DISTRIBUTED;
 | 
			
		||||
        //创建运输任务
 | 
			
		||||
        Long transportTaskId = this.createTransportTask(jsonObject, assignedStatus);
 | 
			
		||||
 | 
			
		||||
        if (CollUtil.isEmpty(driverIds)) {
 | 
			
		||||
            log.info("生成司机作业单,司机列表为空,需要手动设置司机作业单 -> msg = {}", msg);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        for (Object driverId : driverIds) {
 | 
			
		||||
            //生成司机作业单
 | 
			
		||||
            this.driverJobFeign.createDriverJob(transportTaskId, Convert.toLong(driverId));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 3.2.2、创建运输任务
 | 
			
		||||
```java
 | 
			
		||||
    @Transactional
 | 
			
		||||
    public Long createTransportTask(JSONObject jsonObject, TransportTaskAssignedStatus assignedStatus) {
 | 
			
		||||
        //根据车辆计划id查询预计发车时间和预计到达时间
 | 
			
		||||
        Long truckPlanId = jsonObject.getLong("truckPlanId");
 | 
			
		||||
        TruckPlanDto truckPlanDto = truckPlanFeign.findById(truckPlanId);
 | 
			
		||||
 | 
			
		||||
        //创建运输任务
 | 
			
		||||
        TransportTaskEntity transportTaskEntity = new TransportTaskEntity();
 | 
			
		||||
        transportTaskEntity.setTruckPlanId(jsonObject.getLong("truckPlanId"));
 | 
			
		||||
        transportTaskEntity.setTruckId(jsonObject.getLong("truckId"));
 | 
			
		||||
        transportTaskEntity.setStartAgencyId(jsonObject.getLong("startOrganId"));
 | 
			
		||||
        transportTaskEntity.setEndAgencyId(jsonObject.getLong("endOrganId"));
 | 
			
		||||
        transportTaskEntity.setTransportTripsId(jsonObject.getLong("transportTripsId"));
 | 
			
		||||
        transportTaskEntity.setAssignedStatus(assignedStatus); //任务分配状态
 | 
			
		||||
        transportTaskEntity.setPlanDepartureTime(truckPlanDto.getPlanDepartureTime()); //计划发车时间
 | 
			
		||||
        transportTaskEntity.setPlanArrivalTime(truckPlanDto.getPlanArrivalTime()); //计划到达时间
 | 
			
		||||
        transportTaskEntity.setStatus(TransportTaskStatus.PENDING); //设置运输任务状态
 | 
			
		||||
 | 
			
		||||
        // TODO 完善满载状态
 | 
			
		||||
        if (CollUtil.isEmpty(jsonObject.getJSONArray("transportOrderIdList"))) {
 | 
			
		||||
            transportTaskEntity.setLoadingStatus(TransportTaskLoadingStatus.EMPTY);
 | 
			
		||||
        } else {
 | 
			
		||||
            transportTaskEntity.setLoadingStatus(TransportTaskLoadingStatus.FULL);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //查询路线距离
 | 
			
		||||
        TransportLineSearchDTO transportLineSearchDTO = new TransportLineSearchDTO();
 | 
			
		||||
        transportLineSearchDTO.setPage(1);
 | 
			
		||||
        transportLineSearchDTO.setPageSize(1);
 | 
			
		||||
        transportLineSearchDTO.setStartOrganId(transportTaskEntity.getStartAgencyId());
 | 
			
		||||
        transportLineSearchDTO.setEndOrganId(transportTaskEntity.getEndAgencyId());
 | 
			
		||||
        PageResponse<TransportLineDTO> transportLineResponse = this.transportLineFeign.queryPageList(transportLineSearchDTO);
 | 
			
		||||
        TransportLineDTO transportLineDTO = CollUtil.getFirst(transportLineResponse.getItems());
 | 
			
		||||
        if (ObjectUtil.isNotEmpty(transportLineDTO)) {
 | 
			
		||||
            //设置距离
 | 
			
		||||
            transportTaskEntity.setDistance(transportLineDTO.getDistance());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //保存数据
 | 
			
		||||
        this.transportTaskService.save(transportTaskEntity);
 | 
			
		||||
 | 
			
		||||
        //创建运输任务与运单之间的关系
 | 
			
		||||
        this.createTransportOrderTask(transportTaskEntity.getId(), jsonObject);
 | 
			
		||||
        return transportTaskEntity.getId();
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 3.2.3、创建运单关系
 | 
			
		||||
```java
 | 
			
		||||
    private void createTransportOrderTask(final Long transportTaskId, final JSONObject jsonObject) {
 | 
			
		||||
        //创建运输任务与运单之间的关系
 | 
			
		||||
        JSONArray transportOrderIdList = jsonObject.getJSONArray("transportOrderIdList");
 | 
			
		||||
        if (CollUtil.isEmpty(transportOrderIdList)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //将运单id列表转成运单实体列表
 | 
			
		||||
        List<TransportOrderTaskEntity> resultList = transportOrderIdList.stream()
 | 
			
		||||
                .map(o -> {
 | 
			
		||||
                    TransportOrderTaskEntity transportOrderTaskEntity = new TransportOrderTaskEntity();
 | 
			
		||||
                    transportOrderTaskEntity.setTransportTaskId(transportTaskId);
 | 
			
		||||
                    transportOrderTaskEntity.setTransportOrderId(Convert.toStr(o));
 | 
			
		||||
                    return transportOrderTaskEntity;
 | 
			
		||||
                }).collect(Collectors.toList());
 | 
			
		||||
 | 
			
		||||
        //批量保存运输任务与运单的关联表
 | 
			
		||||
        this.transportOrderTaskService.batchSaveTransportOrder(resultList);
 | 
			
		||||
 | 
			
		||||
        //批量标记运单为已调度状态
 | 
			
		||||
        List<TransportOrderEntity> list = transportOrderIdList.stream()
 | 
			
		||||
                .map(o -> {
 | 
			
		||||
                    TransportOrderEntity transportOrderEntity = new TransportOrderEntity();
 | 
			
		||||
                    transportOrderEntity.setId(Convert.toStr(o));
 | 
			
		||||
                    //状态设置为已调度
 | 
			
		||||
                    transportOrderEntity.setSchedulingStatus(TransportOrderSchedulingStatus.SCHEDULED);
 | 
			
		||||
                    return transportOrderEntity;
 | 
			
		||||
                }).collect(Collectors.toList());
 | 
			
		||||
        this.transportOrderService.updateBatchById(list);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 3.3、测试
 | 
			
		||||
基于调度中心进行测试,需要`sl-express-ms-dispatch-service`、`sl-express-ms-work-service`、sl-`express-ms-oms-service`服务跑起来进行测试。
 | 
			
		||||
可以看到队列已经绑定到交换机:
 | 
			
		||||

 | 
			
		||||
经过测试发现已经生成了运输任务:
 | 
			
		||||

 | 
			
		||||
运输任务与运单的关系数据:
 | 
			
		||||

 | 
			
		||||
生成的司机作业单:
 | 
			
		||||

 | 
			
		||||
司机作业单对应的是两条数据,每个司机会有对应的一条作业单。
 | 
			
		||||
## 3.4、根据运输任务查询运单
 | 
			
		||||
在TransportOrderService中,需要根据运输任务id查询运单列表,下面我们来完善pageQueryByTaskId()方法:
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 根据运输任务id分页查询运单信息
 | 
			
		||||
     *
 | 
			
		||||
     * @param page             页码
 | 
			
		||||
     * @param pageSize         页面大小
 | 
			
		||||
     * @param taskId           运输任务id
 | 
			
		||||
     * @param transportOrderId 运单id
 | 
			
		||||
     * @return 运单对象分页数据
 | 
			
		||||
     */
 | 
			
		||||
    @Override
 | 
			
		||||
    public PageResponse<TransportOrderDTO> pageQueryByTaskId(Integer page, Integer pageSize, String taskId, String transportOrderId) {
 | 
			
		||||
        //构建分页查询条件
 | 
			
		||||
        Page<TransportOrderTaskEntity> transportOrderTaskPage = new Page<>(page, pageSize);
 | 
			
		||||
        LambdaQueryWrapper<TransportOrderTaskEntity> queryWrapper = new LambdaQueryWrapper<>();
 | 
			
		||||
        queryWrapper.eq(ObjectUtil.isNotEmpty(taskId), TransportOrderTaskEntity::getTransportTaskId, taskId)
 | 
			
		||||
                .like(ObjectUtil.isNotEmpty(transportOrderId), TransportOrderTaskEntity::getTransportOrderId, transportOrderId)
 | 
			
		||||
                .orderByDesc(TransportOrderTaskEntity::getCreated);
 | 
			
		||||
 | 
			
		||||
        //根据运输任务id、运单id查询运输任务与运单关联关系表
 | 
			
		||||
        Page<TransportOrderTaskEntity> pageResult = transportOrderTaskMapper.selectPage(transportOrderTaskPage, queryWrapper);
 | 
			
		||||
        if (ObjectUtil.isEmpty(pageResult.getRecords())) {
 | 
			
		||||
            return new PageResponse<>(pageResult);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //根据运单id查询运单,并转化为dto
 | 
			
		||||
        List<String> transportOrderIds = pageResult.getRecords().stream().map(TransportOrderTaskEntity::getTransportOrderId).collect(Collectors.toList());
 | 
			
		||||
        List<TransportOrderEntity> entities = baseMapper.selectBatchIds(transportOrderIds);
 | 
			
		||||
 | 
			
		||||
        //构建分页结果
 | 
			
		||||
        return PageResponse.of(BeanUtil.copyToList(entities, TransportOrderDTO.class), page, pageSize, pageResult.getPages(), pageResult.getTotal());
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
# 4、司机入库
 | 
			
		||||
司机入库业务是非常核心的业务,司机入库就意味着车辆入库,也就是此次运输结束,需要开始下一个运输、结束此次运输任务、完成司机作业单等操作。
 | 
			
		||||
司机入库的流程是在`sl-express-ms-driver-service`微服务中完成的,基本的逻辑已经实现,现在需要我们实现运单向下一个节点的转运,即:开始新的转运工作。
 | 
			
		||||
## 4.1、业务实现
 | 
			
		||||
业务代码是在`sl-express-ms-driver-service`微服务中。
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 司机入库,修改运单的当前节点和下个节点 以及 修改运单为待调度状态,结束运输任务
 | 
			
		||||
     *
 | 
			
		||||
     * @param driverDeliverDTO 司机作业单id
 | 
			
		||||
     */
 | 
			
		||||
    @Override
 | 
			
		||||
    @GlobalTransactional
 | 
			
		||||
    public void intoStorage(DriverDeliverDTO driverDeliverDTO) {
 | 
			
		||||
        //1.司机作业单,获取运输任务id
 | 
			
		||||
        DriverJobEntity driverJob = super.getById(driverDeliverDTO.getId());
 | 
			
		||||
        if (ObjectUtil.isEmpty(driverJob)) {
 | 
			
		||||
            throw new SLException(DriverExceptionEnum.DRIVER_JOB_NOT_FOUND);
 | 
			
		||||
        }
 | 
			
		||||
        if (ObjectUtil.notEqual(driverJob.getStatus(), DriverJobStatus.PROCESSING)) {
 | 
			
		||||
            throw new SLException(DriverExceptionEnum.DRIVER_JOB_STATUS_UNKNOWN);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //运输任务id
 | 
			
		||||
        Long transportTaskId = driverJob.getTransportTaskId();
 | 
			
		||||
 | 
			
		||||
        //2.更新运输任务状态为完成
 | 
			
		||||
        //加锁,只能有一个司机操作,任务已经完成的话,就不需要进行流程流转,只要完成司机自己的作业单即可
 | 
			
		||||
        String lockRedisKey = Constants.LOCKS.DRIVER_JOB_LOCK_PREFIX + transportTaskId;
 | 
			
		||||
        //2.1获取锁
 | 
			
		||||
        RLock lock = this.redissonClient.getFairLock(lockRedisKey);
 | 
			
		||||
        if (lock.tryLock()) {
 | 
			
		||||
            //2.2获取到锁
 | 
			
		||||
            try {
 | 
			
		||||
                //2.3查询运输任务
 | 
			
		||||
                TransportTaskDTO transportTask = this.transportTaskFeign.findById(transportTaskId);
 | 
			
		||||
                //2.4判断任务是否已结束,不能再修改流转
 | 
			
		||||
                if (!ObjectUtil.equalsAny(transportTask.getStatus(), TransportTaskStatus.CANCELLED, TransportTaskStatus.COMPLETED)) {
 | 
			
		||||
                    //2.5修改运单流转节点,修改当前节点和下一个节点
 | 
			
		||||
                    this.transportOrderFeign.updateByTaskId(String.valueOf(transportTaskId));
 | 
			
		||||
 | 
			
		||||
                    //2.6结束运输任务
 | 
			
		||||
                    TransportTaskCompleteDTO transportTaskCompleteDTO = BeanUtil.toBean(driverDeliverDTO, TransportTaskCompleteDTO.class);
 | 
			
		||||
                    transportTaskCompleteDTO.setTransportTaskId(String.valueOf(transportTaskId));
 | 
			
		||||
                    this.transportTaskFeign.completeTransportTask(transportTaskCompleteDTO);
 | 
			
		||||
                }
 | 
			
		||||
            } finally {
 | 
			
		||||
                lock.unlock();
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            throw new SLException(DriverExceptionEnum.DRIVER_JOB_INTO_STORAGE_ERROR);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //3.修改所有与运输任务id相关联的司机作业单状态和实际到达时间
 | 
			
		||||
        LambdaUpdateWrapper<DriverJobEntity> updateWrapper = new LambdaUpdateWrapper<>();
 | 
			
		||||
        updateWrapper.eq(ObjectUtil.isNotEmpty(transportTaskId), DriverJobEntity::getTransportTaskId, transportTaskId)
 | 
			
		||||
                .set(DriverJobEntity::getStatus, DriverJobStatus.DELIVERED)
 | 
			
		||||
                .set(DriverJobEntity::getActualArrivalTime, LocalDateTime.now());
 | 
			
		||||
        this.update(updateWrapper);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
可以看到,大部分的业务逻辑已经时间,我们只需要实现`transportOrderFeign`中的`updateByTaskId()`方法,也就是实现work微服务中`com.sl.ms.work.service.impl.TransportOrderServiceImpl#updateByTaskId()`的方法即可。
 | 
			
		||||
## 4.2、运单流转
 | 
			
		||||
实现的关键点:
 | 
			
		||||
 | 
			
		||||
- 设置当前所在网点id为下一个网点id(司机入库,说明已经到达目的地)
 | 
			
		||||
- 解析完整运输链路,找出下一个转运节点,需要考虑到拒收、最后一个节点等情况
 | 
			
		||||
- 发送消息通知,参与新的调度或生成快递员的取派件任务
 | 
			
		||||
- 发送物流信息的消息(先TODO)
 | 
			
		||||
 | 
			
		||||
代码实现:
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean updateByTaskId(Long taskId) {
 | 
			
		||||
        //通过运输任务查询运单id列表
 | 
			
		||||
        List<String> transportOrderIdList = this.transportTaskService.queryTransportOrderIdListById(taskId);
 | 
			
		||||
        if (CollUtil.isEmpty(transportOrderIdList)) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        //查询运单列表
 | 
			
		||||
        List<TransportOrderEntity> transportOrderList = super.listByIds(transportOrderIdList);
 | 
			
		||||
        for (TransportOrderEntity transportOrder : transportOrderList) {
 | 
			
		||||
            //获取将发往的目的地机构
 | 
			
		||||
            OrganDTO organDTO = organFeign.queryById(transportOrder.getNextAgencyId());
 | 
			
		||||
 | 
			
		||||
            //构建消息实体类
 | 
			
		||||
            String info = CharSequenceUtil.format("快件到达【{}】", organDTO.getName());
 | 
			
		||||
            String transportInfoMsg = TransportInfoMsg.builder()
 | 
			
		||||
                    .transportOrderId(transportOrder.getId())//运单id
 | 
			
		||||
                    .status("运送中")//消息状态
 | 
			
		||||
                    .info(info)//消息详情
 | 
			
		||||
                    .created(DateUtil.current())//创建时间
 | 
			
		||||
                    .build().toJson();
 | 
			
		||||
            //发送运单跟踪消息
 | 
			
		||||
            this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRANSPORT_INFO, Constants.MQ.RoutingKeys.TRANSPORT_INFO_APPEND, transportInfoMsg);
 | 
			
		||||
 | 
			
		||||
            //设置当前所在机构id为下一个机构id
 | 
			
		||||
            transportOrder.setCurrentAgencyId(transportOrder.getNextAgencyId());
 | 
			
		||||
            //解析完整的运输链路,找到下一个机构id
 | 
			
		||||
            String transportLine = transportOrder.getTransportLine();
 | 
			
		||||
            JSONObject jsonObject = JSONUtil.parseObj(transportLine);
 | 
			
		||||
            Long nextAgencyId = 0L;
 | 
			
		||||
            JSONArray nodeList = jsonObject.getJSONArray("nodeList");
 | 
			
		||||
            //这里反向循环主要是考虑到拒收的情况,路线中会存在相同的节点,始终可以查找到后面的节点
 | 
			
		||||
            //正常:A B C D E ,拒收:A B C D E D C B A
 | 
			
		||||
            for (int i = nodeList.size() - 1; i >= 0; i--) {
 | 
			
		||||
                JSONObject node = (JSONObject) nodeList.get(i);
 | 
			
		||||
                Long agencyId = node.getLong("bid");
 | 
			
		||||
                if (ObjectUtil.equal(agencyId, transportOrder.getCurrentAgencyId())) {
 | 
			
		||||
                    if (i == nodeList.size() - 1) {
 | 
			
		||||
                        //已经是最后一个节点了,也就是到最后一个机构了
 | 
			
		||||
                        nextAgencyId = agencyId;
 | 
			
		||||
                        transportOrder.setStatus(TransportOrderStatus.ARRIVED_END);
 | 
			
		||||
                        //发送消息更新状态
 | 
			
		||||
                        this.sendUpdateStatusMsg(ListUtil.toList(transportOrder.getId()), TransportOrderStatus.ARRIVED_END);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        //后面还有节点
 | 
			
		||||
                        nextAgencyId = ((JSONObject) nodeList.get(i + 1)).getLong("bid");
 | 
			
		||||
                        //设置运单状态为待调度
 | 
			
		||||
                        transportOrder.setSchedulingStatus(TransportOrderSchedulingStatus.TO_BE_SCHEDULED);
 | 
			
		||||
                    }
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            //设置下一个节点id
 | 
			
		||||
            transportOrder.setNextAgencyId(nextAgencyId);
 | 
			
		||||
 | 
			
		||||
            //如果运单没有到达终点,需要发送消息到运单调度的交换机中
 | 
			
		||||
            //如果已经到达最终网点,需要发送消息,进行分配快递员作业
 | 
			
		||||
            if (ObjectUtil.notEqual(transportOrder.getStatus(), TransportOrderStatus.ARRIVED_END)) {
 | 
			
		||||
                this.sendTransportOrderMsgToDispatch(transportOrder);
 | 
			
		||||
            } else {
 | 
			
		||||
                //发送消息生成派件任务
 | 
			
		||||
                this.sendDispatchTaskMsgToDispatch(transportOrder);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        //批量更新运单
 | 
			
		||||
        return super.updateBatchById(transportOrderList);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
## 4.3、测试
 | 
			
		||||
编写测试用例:
 | 
			
		||||
```java
 | 
			
		||||
@SpringBootTest
 | 
			
		||||
class TransportOrderServiceTest {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportOrderService transportOrderService;
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void updateByTaskId() {
 | 
			
		||||
        //设置运输任务id
 | 
			
		||||
        this.transportOrderService.updateByTaskId(1568165717632933889L);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
测试之前,观察当前机构、下个机构:
 | 
			
		||||

 | 
			
		||||
测试之后,发现调度状态、当前机构、下个机构都已经更新,并且会发送消息再次进行调度:
 | 
			
		||||

 | 
			
		||||
消息内容(`sl.queue.dispatch.mergeTransportOrder`):
 | 
			
		||||

 | 
			
		||||
# 5、练习
 | 
			
		||||
难度系数:★★★☆☆
 | 
			
		||||
需求:阅读司机微服务中的【司机出库】业务功能,主要阅读代码逻辑如下:
 | 
			
		||||
 | 
			
		||||
- 理解多个司机只能有一个更新运单状态,其他只是可以修改自己的作业单状态
 | 
			
		||||
- 使用分布式事务确保业务的一致性
 | 
			
		||||
# 6、面试连环问
 | 
			
		||||
:::info
 | 
			
		||||
面试官问:
 | 
			
		||||
 | 
			
		||||
- 能说一下xxl-job的分片式调度是在什么场景下使用的吗?这样做的好处是什么?
 | 
			
		||||
- 不同的运单流转节点是不一样的,你们如何将运单合并?如何确保redis的高可用?
 | 
			
		||||
- 你们系统中,车辆、车次和路线之间是什么关系?车辆有司机数量限制吗?
 | 
			
		||||
:::
 | 
			
		||||
							
								
								
									
										1237
									
								
								01-讲义/md/day09-智能调度之作业范围.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1237
									
								
								01-讲义/md/day09-智能调度之作业范围.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1015
									
								
								01-讲义/md/day10-智能调度之取派件调度.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1015
									
								
								01-讲义/md/day10-智能调度之取派件调度.md
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										982
									
								
								01-讲义/md/day11-物流信息微服务.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										982
									
								
								01-讲义/md/day11-物流信息微服务.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,982 @@
 | 
			
		||||
# 课程安排
 | 
			
		||||
- 物流信息的需求分析
 | 
			
		||||
- 技术实现分析
 | 
			
		||||
- 基于MongoDB的功能实现
 | 
			
		||||
- 多级缓存的解决方案
 | 
			
		||||
- Redis缓存存在的问题分析并解决
 | 
			
		||||
# 1、背景说明
 | 
			
		||||
快递员将包裹取走之后,直至收件人签收,期间发件人和收件人最为关心的就是“快递到哪了”,如何让收发件人清晰的了解到包裹的“实时”状态,就需要将物流信息给用户展现出来,也就是今天要学习的主要内容【物流信息】。
 | 
			
		||||
然而,此功能的并发量是有一定要求的,特别是在电商大促期间,快件数量非常庞大,也就意味着查询人的量也是很大的,所以,此处必须是缓存应用的集中地,我们也将在该业务中讲解Redis缓存应用的常见问题,并且去实施解决,从而形成通用的解决方案。
 | 
			
		||||
如果这块搞不好,程序员又要背锅了……
 | 
			
		||||

 | 
			
		||||
# 2、需求分析
 | 
			
		||||
用户寄件后,是需要查看运单的运输详情,也就是需要查看整个转运节点,类似这样:
 | 
			
		||||

 | 
			
		||||
产品的需求描述如下(在快递员端的产品文档中):
 | 
			
		||||

 | 
			
		||||
可以看出,物流信息中有状态、时间、具体信息、快递员姓名、快递员联系方式等信息。
 | 
			
		||||
# 3、实现分析
 | 
			
		||||
基于上面的需求分析,我们该如何实现呢?首先要分析一下物流信息功能的特点:
 | 
			
		||||
 | 
			
		||||
- 数据量大
 | 
			
		||||
- 查询频率高(签收后查询频率低)
 | 
			
		||||
 | 
			
		||||
针对于以上的特点,我们可以进行逐一的分析,首选是数据量大,这个挑战是在存储方面,如果我们做技术选型的话,无非就是两种选择,一种是关系型数据库,另一种是非关系型数据库,显然,在存储大数据方面非关系型数据库更合适一些,以我们目前掌握的技术而言,选择MongoDB存储要比MySQL更合适一些。
 | 
			
		||||
运单在签收之前,查询的频率是非常高的,用户可能会不断的刷物流信息,一般解决查询并发高的解决方案是通过缓存解决,我们也将对查询数据进行缓存。
 | 
			
		||||
## 3.1、MySQL实现
 | 
			
		||||
如果采用MySQL的存储,一般是这样存储的,首选设计表结构:
 | 
			
		||||
```sql
 | 
			
		||||
CREATE TABLE `sl_transport_info` (
 | 
			
		||||
  `id` bigint NOT NULL AUTO_INCREMENT,
 | 
			
		||||
  `transport_order_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '运单号',
 | 
			
		||||
  `status` varchar(10) DEFAULT NULL COMMENT '状态,例如:运输中',
 | 
			
		||||
  `info` varchar(500) DEFAULT NULL COMMENT '详细信息,例如:您的快件已到达【北京通州分拣中心】',
 | 
			
		||||
  `created` datetime DEFAULT NULL COMMENT '创建时间',
 | 
			
		||||
  `updated` datetime DEFAULT NULL COMMENT '更新时间',
 | 
			
		||||
  PRIMARY KEY (`id`)
 | 
			
		||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
 | 
			
		||||
```
 | 
			
		||||
插入测试数据:
 | 
			
		||||
```sql
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (1, 'SL920733749248', '已取件', '神领快递员已取件, 取件人【快递员,电话 18810966207}】', '2022-09-25 10:48:30', '2022-09-25 10:48:33');
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (2, 'SL920733749262', '已取件', '神领快递员已取件, 取件人【快递员,电话 18810966207}】', '2022-09-25 10:51:11', '2022-09-25 10:51:14');
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (3, 'SL920733749248', '运输中', '您的快件已到达【昌平区转运中心】', '2022-09-25 11:14:33', '2022-09-25 11:14:36');
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (4, 'SL920733749248', '运输中', '您的快件已到达【北京市转运中心】', '2022-09-25 11:14:54', '2022-09-25 11:14:57');
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (5, 'SL920733749262', '运输中', '您的快件已到达【昌平区转运中心】', '2022-09-25 11:15:17', '2022-09-25 11:15:19');
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (6, 'SL920733749262', '运输中', '您的快件已到达【江苏省南京市玄武区长江路】', '2022-09-25 11:15:44', '2022-09-25 11:15:47');
 | 
			
		||||
INSERT INTO `sl_transport_info`(`id`, `transport_order_id`, `status`, `info`, `created`, `updated`) VALUES (7, 'SL920733749248', '已签收', '您的快递已签收,如有疑问请联系快递员【快递员},电话18810966207】,感谢您使用神领快递,期待再次为您服务', '2022-09-25 11:16:16', '2022-09-25 11:16:19');
 | 
			
		||||
```
 | 
			
		||||

 | 
			
		||||
查询运单号【SL920733749248】的物流信息:
 | 
			
		||||
```sql
 | 
			
		||||
SELECT
 | 
			
		||||
	* 
 | 
			
		||||
FROM
 | 
			
		||||
	sl_transport_info 
 | 
			
		||||
WHERE
 | 
			
		||||
	transport_order_id = 'SL920733749248' 
 | 
			
		||||
ORDER BY
 | 
			
		||||
	created ASC
 | 
			
		||||
```
 | 
			
		||||
结果:
 | 
			
		||||

 | 
			
		||||
## 3.2、MongoDB实现
 | 
			
		||||
基于MongoDB的实现,可以充分利用MongoDB数据结构的特点,可以这样存储:
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
    "_id": ObjectId("62c6c679a1222549d64ba01e"),
 | 
			
		||||
    "transportOrderId": "SL1000000000585",
 | 
			
		||||
    "infoList": [
 | 
			
		||||
        {
 | 
			
		||||
            "created": NumberLong("1657192271195"),
 | 
			
		||||
            "info": "神领快递员已取件, 取件人【快递员,电话 18810966207}】",
 | 
			
		||||
            "status": "已取件"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "created": NumberLong("1657192328518"),
 | 
			
		||||
            "info": "神领快递员已取件, 取件人【快递员,电话 18810966207}】",
 | 
			
		||||
            "status": "已取件"
 | 
			
		||||
        }
 | 
			
		||||
    ],
 | 
			
		||||
    "created": NumberLong("1657194104987"),
 | 
			
		||||
    "updated": NumberLong("1657194105064"),
 | 
			
		||||
    "_class": "com.sl.transport.info.entity.TransportInfoEntity"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
如果有新的信息加入的话,只需要向【infoList】中插入元素即可,查询的话按照【transportOrderId】条件查询。
 | 
			
		||||
`db.sl_transport_info.find({"transportOrderId":"SL1000000000585"})`
 | 
			
		||||

 | 
			
		||||
## 3.3、分析
 | 
			
		||||
从上面的实现分析来看,MySQL存储在一张表中,每条物流信息就是一条行数据,数据条数将是运单数量的数倍,查询时需要通过运单id作为条件,按照时间正序排序得到所有的结果,而MongoDB存储基于其自身特点可以将物流信息列表存储到属性中,数据量等于运单量,查询时只需要按照运单id查询即可。
 | 
			
		||||
所以,使用MongoDB存储更适合物流信息这样的场景,我们将基于MongoDB进行实现。
 | 
			
		||||
# 4、功能实现
 | 
			
		||||
## 4.1、Service实现
 | 
			
		||||
在TransportInfoService中定义了3个方法:
 | 
			
		||||
 | 
			
		||||
- `saveOrUpdate()` 新增或更新数据
 | 
			
		||||
- `queryByTransportOrderId()` 根据运单号查询物流信息
 | 
			
		||||
### 4.2.1、saveOrUpdate
 | 
			
		||||
```java
 | 
			
		||||
    @Resource
 | 
			
		||||
    private MongoTemplate mongoTemplate;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public TransportInfoEntity saveOrUpdate(String transportOrderId, TransportInfoDetail infoDetail) {
 | 
			
		||||
        //根据运单id查询
 | 
			
		||||
        Query query = Query.query(Criteria.where("transportOrderId").is(transportOrderId)); //构造查询条件
 | 
			
		||||
        TransportInfoEntity transportInfoEntity = this.mongoTemplate.findOne(query, TransportInfoEntity.class);
 | 
			
		||||
        if (ObjectUtil.isEmpty(transportInfoEntity)) {
 | 
			
		||||
            //运单信息不存在,新增数据
 | 
			
		||||
            transportInfoEntity = new TransportInfoEntity();
 | 
			
		||||
            transportInfoEntity.setTransportOrderId(transportOrderId);
 | 
			
		||||
            transportInfoEntity.setInfoList(ListUtil.toList(infoDetail));
 | 
			
		||||
            transportInfoEntity.setCreated(System.currentTimeMillis());
 | 
			
		||||
        } else {
 | 
			
		||||
            //运单信息存在,只需要追加物流详情数据
 | 
			
		||||
            transportInfoEntity.getInfoList().add(infoDetail);
 | 
			
		||||
        }
 | 
			
		||||
        //无论新增还是更新都要设置更新时间
 | 
			
		||||
        transportInfoEntity.setUpdated(System.currentTimeMillis());
 | 
			
		||||
 | 
			
		||||
        //保存/更新到MongoDB
 | 
			
		||||
        return this.mongoTemplate.save(transportInfoEntity);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 4.2.2、查询
 | 
			
		||||
根据运单号查询物流信息。
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    public TransportInfoEntity queryByTransportOrderId(String transportOrderId) {
 | 
			
		||||
        //定义查询条件
 | 
			
		||||
        Query query = Query.query(Criteria.where("transportOrderId").is(transportOrderId));
 | 
			
		||||
        //查询数据
 | 
			
		||||
        return this.mongoTemplate.findOne(query, TransportInfoEntity.class);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 4.2.3、测试
 | 
			
		||||
通过测试用例进行测试:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.service;
 | 
			
		||||
 | 
			
		||||
import com.sl.transport.info.entity.TransportInfoDetail;
 | 
			
		||||
import com.sl.transport.info.entity.TransportInfoEntity;
 | 
			
		||||
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 TransportInfoServiceTest {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportInfoService transportInfoService;
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void saveOrUpdate() {
 | 
			
		||||
        String transportOrderId = "SL1000000001561";
 | 
			
		||||
        TransportInfoDetail transportInfoDetail = TransportInfoDetail.builder()
 | 
			
		||||
                .status("已取件")
 | 
			
		||||
                .info("神领快递员已取件,取件人【张三】,电话:13888888888")
 | 
			
		||||
                .created(System.currentTimeMillis())
 | 
			
		||||
                .build();
 | 
			
		||||
        TransportInfoEntity transportInfoEntity = this.transportInfoService.saveOrUpdate(transportOrderId, transportInfoDetail);
 | 
			
		||||
        System.out.println(transportInfoEntity);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void queryByTransportOrderId() {
 | 
			
		||||
        String transportOrderId = "SL1000000001561";
 | 
			
		||||
        this.transportInfoService.queryByTransportOrderId(transportOrderId);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
测试结果:
 | 
			
		||||

 | 
			
		||||
## 4.2、记录物流信息
 | 
			
		||||
### 4.2.1、分析
 | 
			
		||||
通过前面的需求分析,可以发现新增物流信息的节点比较多,在取件、派件、物流转运环节都有记录物流信息,站在整体架构的方面的考虑,该如何在众多的业务点钟记录物流信息呢?
 | 
			
		||||
一般而言,会有两种方式,一种是微服务直接调用,另一种是通过消息的方式调用,也就是同步和异步的方式。选择哪种方式比较好呢?
 | 
			
		||||
在这里,我们选择通过消息的方式,主要原因有两个:
 | 
			
		||||
 | 
			
		||||
- 物流信息数据的更新的实时性并不高,例如,运单到达某个转运中心,晚几分种记录信息也是可以的。
 | 
			
		||||
- 更新数据时,并发量比较大,例如,一辆车装了几千或几万个包裹,到达某个转运中心后,司机入库时,需要一下记录几千或几万个运单的物流数据,在这一时刻并发量是比较大的,通过消息的方式,可以进行对流量削峰,从而保障系统的稳定性。
 | 
			
		||||
### 4.2.2、消息结构
 | 
			
		||||
消息的结构如下:
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
    "info": "您的快件已到达【$organId】",
 | 
			
		||||
    "status": "运输中",
 | 
			
		||||
    "organId": 1012479939628238305,
 | 
			
		||||
    "transportOrderId": "SL920733749248",
 | 
			
		||||
    "created": 1653133234913
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
可以看出,在消息中,有具体信息、状态、机构id、运单号、时间,其中在info字段中,约定通过`$organId`占位符表示机构,也就是,需要通过传入的`organId`查询机构名称替换到`info`中,当然了,如果没有机构,无需替换。
 | 
			
		||||
### 4.2.3、功能实现
 | 
			
		||||
在TransportInfoMQListener中对消息进行处理。
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.mq;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.convert.Convert;
 | 
			
		||||
import cn.hutool.core.util.StrUtil;
 | 
			
		||||
import cn.hutool.json.JSONUtil;
 | 
			
		||||
import com.sl.ms.transport.api.OrganFeign;
 | 
			
		||||
import com.sl.transport.common.constant.Constants;
 | 
			
		||||
import com.sl.transport.common.vo.TransportInfoMsg;
 | 
			
		||||
import com.sl.transport.domain.OrganDTO;
 | 
			
		||||
import com.sl.transport.info.entity.TransportInfoDetail;
 | 
			
		||||
import com.sl.transport.info.service.TransportInfoService;
 | 
			
		||||
import org.springframework.amqp.core.ExchangeTypes;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.Exchange;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.Queue;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.QueueBinding;
 | 
			
		||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 物流信息消息
 | 
			
		||||
 */
 | 
			
		||||
@Component
 | 
			
		||||
public class TransportInfoMQListener {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private OrganFeign organFeign;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private TransportInfoService transportInfoService;
 | 
			
		||||
 | 
			
		||||
    @RabbitListener(bindings = @QueueBinding(
 | 
			
		||||
            value = @Queue(name = Constants.MQ.Queues.TRANSPORT_INFO_APPEND),
 | 
			
		||||
            exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_INFO, type = ExchangeTypes.TOPIC),
 | 
			
		||||
            key = Constants.MQ.RoutingKeys.TRANSPORT_INFO_APPEND
 | 
			
		||||
    ))
 | 
			
		||||
    public void listenTransportInfoMsg(String msg) {
 | 
			
		||||
        //{"info":"您的快件已到达【$organId】", "status":"运输中", "organId":90001, "transportOrderId":920733749248 , "created":1653133234913}
 | 
			
		||||
        TransportInfoMsg transportInfoMsg = JSONUtil.toBean(msg, TransportInfoMsg.class);
 | 
			
		||||
        Long organId = transportInfoMsg.getOrganId();
 | 
			
		||||
        String transportOrderId = Convert.toStr(transportInfoMsg.getTransportOrderId());
 | 
			
		||||
        String info = transportInfoMsg.getInfo();
 | 
			
		||||
 | 
			
		||||
        //查询机构信息
 | 
			
		||||
        if (StrUtil.contains(info, "$organId")) {
 | 
			
		||||
            OrganDTO organDTO = this.organFeign.queryById(organId);
 | 
			
		||||
            if (organDTO == null) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            info = StrUtil.replace(info, "$organId", organDTO.getName());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //封装Detail对象
 | 
			
		||||
        TransportInfoDetail infoDetail = TransportInfoDetail.builder()
 | 
			
		||||
                .info(info)
 | 
			
		||||
                .status(transportInfoMsg.getStatus())
 | 
			
		||||
                .created(transportInfoMsg.getCreated()).build();
 | 
			
		||||
 | 
			
		||||
        //存储到MongoDB
 | 
			
		||||
        this.transportInfoService.saveOrUpdate(transportOrderId, infoDetail);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
### 4.2.4、测试
 | 
			
		||||
此次测试通过发消息的方式进行,可以在RabbitMQ的管理界面中今天发送消息:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
结果:
 | 
			
		||||

 | 
			
		||||
# 5、多级缓存解决方案
 | 
			
		||||
目前我们已经实现了物流信息的保存、更新操作,基本功能已经了ok了,但是有个问题我们还没解决,就是前面提到的并发大的问题,一般而言,解决查询并发大的问题,常见的手段是为查询接口增加缓存,从而可以减轻持久层的压力。
 | 
			
		||||
按照我们以往的经验,在查询接口中增加Redis缓存即可,将查询的结果数据存储到Redis中,执行查询时首先从Redis中命中,如果命中直接返回即可,没有命中查询MongoDB,将解决写入到Redis中。
 | 
			
		||||
这样就解决问题了吗?其实并不是,试想一下,如果Redis宕机了或者是Redis中的数据大范围的失效,这样大量的并发压力就会进入持久层,会对持久层有较大的影响,甚至可能直接崩溃。
 | 
			
		||||
如何解决该问题呢,可以通过多级缓存的解决方案来进行解决。
 | 
			
		||||
## 5.1、什么是多级缓存
 | 
			
		||||

 | 
			
		||||
由上图可以看出,在用户的一次请求中,可以设置多个缓存以提升查询的性能,能够快速响应。
 | 
			
		||||
 | 
			
		||||
- 浏览器的本地缓存
 | 
			
		||||
- 使用Nginx作为反向代理的架构时,可以启用Nginx的本地缓存,对于代理数据进行缓存
 | 
			
		||||
- 如果Nginx的本地缓存未命中,可以在Nginx中编写Lua脚本从Redis中命中数据
 | 
			
		||||
- 如果Redis依然没有命中的话,请求就会进入到Tomcat,也就是执行我们写的程序,在程序中可以设置进程级的缓存,如果命中直接返回即可。
 | 
			
		||||
- 如果进程级的缓存依然没有命中的话,请求才会进入到持久层查询数据。
 | 
			
		||||
 | 
			
		||||
以上就是多级缓存的基本的设计思路,其核心思想就是让每一个请求节点尽可能的进行缓存操作。
 | 
			
		||||
:::danger
 | 
			
		||||
🚨说明,由于我们没有学习过Lua脚本,所以我们将Redis的查询逻辑放到程序中进行,也就是我们将要在程序中实现二级缓存,分别是:JVM进程缓存和Redis缓存。
 | 
			
		||||
:::
 | 
			
		||||
## 5.2、Caffeine快速入门
 | 
			
		||||
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库,也就是可以通过Caffeine实现进程级的缓存。Spring内部的缓存使用的就是Caffeine。
 | 
			
		||||
Caffeine的性能非常强悍,下图是官方给出的性能对比:
 | 
			
		||||

 | 
			
		||||
### 5.2.1、使用
 | 
			
		||||
导入依赖:
 | 
			
		||||
```xml
 | 
			
		||||
<!--jvm进程缓存-->
 | 
			
		||||
<dependency>
 | 
			
		||||
		<groupId>com.github.ben-manes.caffeine</groupId>
 | 
			
		||||
		<artifactId>caffeine</artifactId>
 | 
			
		||||
</dependency>
 | 
			
		||||
```
 | 
			
		||||
基本使用:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.service;
 | 
			
		||||
 | 
			
		||||
import com.github.benmanes.caffeine.cache.Cache;
 | 
			
		||||
import com.github.benmanes.caffeine.cache.Caffeine;
 | 
			
		||||
import org.junit.jupiter.api.Test;
 | 
			
		||||
 | 
			
		||||
public class CaffeineTest {
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    public void testCaffeine() {
 | 
			
		||||
        // 创建缓存对象
 | 
			
		||||
        Cache<String, Object> cache = Caffeine.newBuilder()
 | 
			
		||||
                .initialCapacity(10) //缓存初始容量
 | 
			
		||||
                .maximumSize(100) //缓存最大容量
 | 
			
		||||
                .build();
 | 
			
		||||
 | 
			
		||||
        //将数据存储缓存中
 | 
			
		||||
        cache.put("key1", 123);
 | 
			
		||||
 | 
			
		||||
        // 从缓存中命中数据
 | 
			
		||||
        // 参数一:缓存的key
 | 
			
		||||
        // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是在未命中时执行
 | 
			
		||||
        // 优先根据key查询进程缓存,如果未命中,则执行参数二的Lambda表达式,执行完成后会将结果写入到缓存中
 | 
			
		||||
        Object value1 = cache.get("key1", key -> 456);
 | 
			
		||||
        System.out.println(value1); //123
 | 
			
		||||
 | 
			
		||||
        Object value2 = cache.get("key2", key -> 456);
 | 
			
		||||
        System.out.println(value2); //456
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
### 5.2.2、驱逐策略
 | 
			
		||||
Caffeine既然是缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。
 | 
			
		||||
Caffeine提供了三种缓存驱逐策略:
 | 
			
		||||
 | 
			
		||||
-  **基于容量**:设置缓存的数量上限 
 | 
			
		||||
```java
 | 
			
		||||
// 创建缓存对象
 | 
			
		||||
Cache<String, String> cache = Caffeine.newBuilder()
 | 
			
		||||
    .maximumSize(1) // 设置缓存大小上限为 1,当缓存超出这个容量的时候,会使用Window TinyLfu策略来删除缓存。
 | 
			
		||||
    .build();
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
-  **基于时间**:设置缓存的有效时间 
 | 
			
		||||
```java
 | 
			
		||||
// 创建缓存对象
 | 
			
		||||
Cache<String, String> cache = Caffeine.newBuilder()
 | 
			
		||||
    // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
 | 
			
		||||
    .expireAfterWrite(Duration.ofSeconds(10)) 
 | 
			
		||||
    .build();
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
-  **基于引用**:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。 
 | 
			
		||||
:::danger
 | 
			
		||||
**🚨注意:**在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。
 | 
			
		||||
:::
 | 
			
		||||
## 5.3、一级缓存
 | 
			
		||||
下面我们通过增加Caffeine实现一级缓存,主要是在 `com.sl.transport.info.controller.TransportInfoController` 中实现缓存逻辑。
 | 
			
		||||
### 5.3.1、Caffeine配置
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.config;
 | 
			
		||||
 | 
			
		||||
import com.github.benmanes.caffeine.cache.Cache;
 | 
			
		||||
import com.github.benmanes.caffeine.cache.Caffeine;
 | 
			
		||||
import com.sl.transport.info.domain.TransportInfoDTO;
 | 
			
		||||
import org.springframework.beans.factory.annotation.Value;
 | 
			
		||||
import org.springframework.context.annotation.Bean;
 | 
			
		||||
import org.springframework.context.annotation.Configuration;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Caffeine缓存配置
 | 
			
		||||
 */
 | 
			
		||||
@Configuration
 | 
			
		||||
public class CaffeineConfig {
 | 
			
		||||
 | 
			
		||||
    @Value("${caffeine.init}")
 | 
			
		||||
    private Integer init;
 | 
			
		||||
    @Value("${caffeine.max}")
 | 
			
		||||
    private Integer max;
 | 
			
		||||
 | 
			
		||||
    @Bean
 | 
			
		||||
    public Cache<String, TransportInfoDTO> transportInfoCache() {
 | 
			
		||||
        return Caffeine.newBuilder()
 | 
			
		||||
                .initialCapacity(init)
 | 
			
		||||
                .maximumSize(max).build();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
具体的配置项在Nacos中的配置中心的`sl-express-ms-transport-info.properties`中:
 | 
			
		||||

 | 
			
		||||
### 5.3.2、实现缓存逻辑
 | 
			
		||||
在`com.sl.transport.info.controller.TransportInfoController`中进行数据的命中,如果命中直接返回,没有命中查询MongoDB。
 | 
			
		||||
```java
 | 
			
		||||
    /**
 | 
			
		||||
     * 根据运单id查询运单信息
 | 
			
		||||
     *
 | 
			
		||||
     * @param transportOrderId 运单号
 | 
			
		||||
     * @return 运单信息
 | 
			
		||||
     */
 | 
			
		||||
    @ApiImplicitParams({
 | 
			
		||||
            @ApiImplicitParam(name = "transportOrderId", value = "运单id")
 | 
			
		||||
    })
 | 
			
		||||
    @ApiOperation(value = "查询", notes = "根据运单id查询物流信息")
 | 
			
		||||
    @GetMapping("{transportOrderId}")
 | 
			
		||||
    public TransportInfoDTO queryByTransportOrderId(@PathVariable("transportOrderId") String transportOrderId) {
 | 
			
		||||
        TransportInfoDTO transportInfoDTO = this.transportInfoCache.get(transportOrderId, s -> {
 | 
			
		||||
            TransportInfoEntity transportInfoEntity = this.transportInfoService.queryByTransportOrderId(transportOrderId);
 | 
			
		||||
            return BeanUtil.toBean(transportInfoEntity, TransportInfoDTO.class);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (ObjectUtil.isNotEmpty(transportInfoDTO)) {
 | 
			
		||||
            return transportInfoDTO;
 | 
			
		||||
        }
 | 
			
		||||
        throw new SLException(ExceptionEnum.NOT_FOUND);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 5.3.3、测试
 | 
			
		||||
未命中场景:
 | 
			
		||||

 | 
			
		||||
已命中:
 | 
			
		||||

 | 
			
		||||
响应结果:
 | 
			
		||||

 | 
			
		||||
## 5.4、二级缓存
 | 
			
		||||
二级缓存通过Redis的存储实现,这里我们使用Spring Cache进行缓存数据的存储和读取。
 | 
			
		||||
### 5.4.1、Redis配置
 | 
			
		||||
Spring Cache默认是采用jdk的对象序列化方式,这种方式比较占用空间而且性能差,所以往往会将值以json的方式存储,此时就需要对RedisCacheManager进行自定义的配置。
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.config;
 | 
			
		||||
 | 
			
		||||
import org.springframework.beans.factory.annotation.Value;
 | 
			
		||||
import org.springframework.context.annotation.Bean;
 | 
			
		||||
import org.springframework.context.annotation.Configuration;
 | 
			
		||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
 | 
			
		||||
import org.springframework.data.redis.cache.RedisCacheManager;
 | 
			
		||||
import org.springframework.data.redis.core.RedisTemplate;
 | 
			
		||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
 | 
			
		||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
 | 
			
		||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
 | 
			
		||||
 | 
			
		||||
import java.time.Duration;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Redis相关的配置
 | 
			
		||||
 */
 | 
			
		||||
@Configuration
 | 
			
		||||
public class RedisConfig {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 存储的默认有效期时间,单位:小时
 | 
			
		||||
     */
 | 
			
		||||
    @Value("${redis.ttl:1}")
 | 
			
		||||
    private Integer redisTtl;
 | 
			
		||||
 | 
			
		||||
    @Bean
 | 
			
		||||
    public RedisCacheManager redisCacheManager(RedisTemplate redisTemplate) {
 | 
			
		||||
        // 默认配置
 | 
			
		||||
        RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
 | 
			
		||||
                // 设置key的序列化方式为字符串
 | 
			
		||||
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
 | 
			
		||||
                // 设置value的序列化方式为json格式
 | 
			
		||||
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
 | 
			
		||||
                .disableCachingNullValues() // 不缓存null
 | 
			
		||||
                .entryTtl(Duration.ofHours(redisTtl));  // 默认缓存数据保存1小时
 | 
			
		||||
 | 
			
		||||
        // 构redis缓存管理器
 | 
			
		||||
        RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
 | 
			
		||||
                .fromConnectionFactory(redisTemplate.getConnectionFactory())
 | 
			
		||||
                .cacheDefaults(defaultCacheConfiguration)
 | 
			
		||||
                .transactionAware() // 只在事务成功提交后才会进行缓存的put/evict操作
 | 
			
		||||
                .build();
 | 
			
		||||
        return redisCacheManager;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 5.4.2、缓存注解
 | 
			
		||||
接下来需要在Service中增加SpringCache的注解,确保数据可以保存、更新数据到Redis。
 | 
			
		||||
```java
 | 
			
		||||
    @Override
 | 
			
		||||
    @CachePut(value = "transport-info", key = "#p0") //更新缓存数据
 | 
			
		||||
    public TransportInfoEntity saveOrUpdate(String transportOrderId, TransportInfoDetail infoDetail) {
 | 
			
		||||
        //省略代码
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    @Cacheable(value = "transport-info", key = "#p0") //新增缓存数据
 | 
			
		||||
    public TransportInfoEntity queryByTransportOrderId(String transportOrderId) {
 | 
			
		||||
       //省略代码
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 5.4.3、测试
 | 
			
		||||
重启服务,进行功能测试,发现数据可以正常写入到Redis中,并且查询时二级缓存已经生效。
 | 
			
		||||

 | 
			
		||||
到这里,已经完成了一级和二级缓存的逻辑。
 | 
			
		||||
## 5.5、一级缓存更新的问题
 | 
			
		||||
更新物流信息时,只是更新了Redis中的数据,并没有更新Caffeine中的数据,需要在更新数据时将Caffeine中相应的数据删除。
 | 
			
		||||
具体实现如下:
 | 
			
		||||
```java
 | 
			
		||||
    @Resource
 | 
			
		||||
    private Cache<String, TransportInfoDTO> transportInfoCache;
 | 
			
		||||
 | 
			
		||||
	@Override
 | 
			
		||||
    @CachePut(value = "transport-info", key = "#p0") //更新缓存数据
 | 
			
		||||
    public TransportInfoEntity saveOrUpdate(String transportOrderId, TransportInfoDetail infoDetail) {
 | 
			
		||||
        //省略代码
 | 
			
		||||
 | 
			
		||||
        //清除缓存中的数据
 | 
			
		||||
        this.transportInfoCache.invalidate(transportOrderId);
 | 
			
		||||
 | 
			
		||||
        //保存/更新到MongoDB
 | 
			
		||||
        return this.mongoTemplate.save(transportInfoEntity);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
这样的话就可以删除Caffeine中的数据,也就意味着下次查询时会从二级缓存中查询到数据,再存储到Caffeine中。
 | 
			
		||||
## 5.6、分布式场景下的问题
 | 
			
		||||
### 5.6.1、问题分析
 | 
			
		||||
通过前面的解决,视乎可以完成一级、二级缓存中数据的同步,如果在单节点项目中是没有问题的,但是,在分布式场景下是有问题的,看下图:
 | 
			
		||||

 | 
			
		||||
说明:
 | 
			
		||||
 | 
			
		||||
- 部署了2个transport-info微服务节点,每个微服务都有自己进程级的一级缓存,都共享同一个Redis作为二级缓存
 | 
			
		||||
- 假设,所有节点的一级和二级缓存都是空的,此时,用户通过节点1查询运单物流信息,在完成后,节点1的caffeine和Redis中都会有数据
 | 
			
		||||
- 接着,系统通过节点2更新了物流数据,此时节点2中的caffeine和Redis都是更新后的数据
 | 
			
		||||
- 用户还是进行查询动作,依然是通过节点1查询,此时查询到的将是旧的数据,也就是出现了一级缓存与二级缓存之间的数据不一致的问题
 | 
			
		||||
### 5.6.2、问题解决
 | 
			
		||||
如何解决该问题呢?可以通过消息的方式解决,就是任意一个节点数据更新了数据,发个消息出来,通知其他节点,其他节点接收到消息后,将自己caffeine中相应的数据删除即可。
 | 
			
		||||
关于消息的实现,可以采用RabbitMQ,也可以采用Redis的消息订阅发布来实现,在这里为了应用技术的多样化,所以采用Redis的订阅发布来实现。
 | 
			
		||||

 | 
			
		||||
:::info
 | 
			
		||||
Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。
 | 
			
		||||

 | 
			
		||||
当有新消息通过 publish 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端。
 | 
			
		||||
Redis的订阅发布功能与传统的消息中间件(如:RabbitMQ)相比,相对轻量一些,针对数据准确和安全性要求没有那么高的场景可以直接使用。
 | 
			
		||||
:::
 | 
			
		||||
在`com.sl.transport.info.config.RedisConfig`增加订阅的配置:
 | 
			
		||||
```java
 | 
			
		||||
    public static final String CHANNEL_TOPIC = "sl-express-ms-transport-info-caffeine";
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 配置订阅,用于解决Caffeine一致性的问题
 | 
			
		||||
     *
 | 
			
		||||
     * @param connectionFactory 链接工厂
 | 
			
		||||
     * @param listenerAdapter 消息监听器
 | 
			
		||||
     * @return 消息监听容器
 | 
			
		||||
     */
 | 
			
		||||
    @Bean
 | 
			
		||||
    public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
 | 
			
		||||
                                                   MessageListenerAdapter listenerAdapter) {
 | 
			
		||||
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
 | 
			
		||||
        container.setConnectionFactory(connectionFactory);
 | 
			
		||||
        container.addMessageListener(listenerAdapter, new ChannelTopic(CHANNEL_TOPIC));
 | 
			
		||||
        return container;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
编写`RedisMessageListener`用于监听消息,删除caffeine中的数据。
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.mq;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.convert.Convert;
 | 
			
		||||
import com.github.benmanes.caffeine.cache.Cache;
 | 
			
		||||
import com.sl.transport.info.domain.TransportInfoDTO;
 | 
			
		||||
import org.springframework.data.redis.connection.Message;
 | 
			
		||||
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
 | 
			
		||||
import org.springframework.stereotype.Component;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * redis消息监听,解决Caffeine一致性的问题
 | 
			
		||||
 */
 | 
			
		||||
@Component
 | 
			
		||||
public class RedisMessageListener extends MessageListenerAdapter {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private Cache<String, TransportInfoDTO> transportInfoCache;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onMessage(Message message, byte[] pattern) {
 | 
			
		||||
        //获取到消息中的运单id
 | 
			
		||||
        String transportOrderId = Convert.toStr(message);
 | 
			
		||||
        //将本jvm中的缓存删除掉
 | 
			
		||||
        this.transportInfoCache.invalidate(transportOrderId);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
更新数据后发送消息:
 | 
			
		||||
```java
 | 
			
		||||
    @Resource
 | 
			
		||||
    private StringRedisTemplate stringRedisTemplate;
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    @CachePut(value = "transport-info", key = "#p0")
 | 
			
		||||
    public TransportInfoEntity saveOrUpdate(String transportOrderId, TransportInfoDetail infoDetail) {
 | 
			
		||||
		//省略代码
 | 
			
		||||
 | 
			
		||||
        //清除缓存中的数据
 | 
			
		||||
        // this.transportInfoCache.invalidate(transportOrderId);
 | 
			
		||||
        //发布订阅消息到redis
 | 
			
		||||
        this.stringRedisTemplate.convertAndSend(RedisConfig.CHANNEL_TOPIC, transportOrderId);
 | 
			
		||||
 | 
			
		||||
        //保存/更新到MongoDB
 | 
			
		||||
        return this.mongoTemplate.save(transportInfoEntity);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 5.6.3、测试
 | 
			
		||||
测试时,需要启动2个相同的微服务,但是端口不能重复,需要设置不同的端口:
 | 
			
		||||

 | 
			
		||||
通过测试,发现可以接收到Redis订阅的消息:
 | 
			
		||||

 | 
			
		||||
最终可以解决多级缓存间的一致性的问题。
 | 
			
		||||
# 6、Redis的缓存问题
 | 
			
		||||
在使用Redis时,在高并发场景下会出现一些问题,常见的问题有:缓存击穿、缓存雪崩、缓存穿透,这三个问题也是面试时的高频问题。
 | 
			
		||||
## 6.1、缓存击穿
 | 
			
		||||
### 6.1.1、说明
 | 
			
		||||
缓存击穿是指,某一热点数据存储到redis中,该数据处于高并发场景下,如果此时该key过期失效,这样就会有大量的并发请求进入到数据库,对数据库产生大的压力,甚至会压垮数据库。
 | 
			
		||||
### 6.1.2、解决方案
 | 
			
		||||
针对于缓存击穿这种情况,常见的解决方案有两种:
 | 
			
		||||
 | 
			
		||||
- 热数据不设置过期时间
 | 
			
		||||
- 使用互斥锁,可以使用redisson的分布式锁实现,就是从redis中查询不到数据时,不要立刻去查数据库,而是先获取锁,获取到锁后再去查询数据库,而其他未获取到锁的请求进行重试,这样就可以确保只有一个查询数据库并且更新缓存的请求。
 | 
			
		||||

 | 
			
		||||
### 6.1.3、实现
 | 
			
		||||
在物流信息场景中,不涉及到此类问题,一般用户只会关注自己的运单信息,而不是并发的查询一个运单信息,所以该问题我们就暂不做实现,但是此类问题的解决方案的思想要学会。
 | 
			
		||||
当然了,防止有人恶意根据运单号查询,可以通过设置验证码的方式进行,如下(韵达快递官网):
 | 
			
		||||

 | 
			
		||||
## 6.2、缓存雪崩
 | 
			
		||||
### 6.2.1、说明
 | 
			
		||||
缓存雪崩的情况往往是由两种情况产生:
 | 
			
		||||
 | 
			
		||||
- 情况1:由于大量 key 设置了相同的过期时间(数据在缓存和数据库都存在),一旦到达过期时间点,这些 key 集体失效,造成访问这些 key 的请求全部进入数据库。
 | 
			
		||||
- 情况2:Redis 实例宕机,大量请求进入数据库
 | 
			
		||||
### 6.2.2、解决方案
 | 
			
		||||
针对于雪崩问题,可以分情况进行解决:
 | 
			
		||||
 | 
			
		||||
- 情况1的解决方案
 | 
			
		||||
   - 错开过期时间:在过期时间上加上随机值(比如 1~5 分钟)
 | 
			
		||||
   - 服务降级:暂停非核心数据查询缓存,返回预定义信息(错误页面,空值等)
 | 
			
		||||
- 情况2的解决方案
 | 
			
		||||
   - 事前预防:搭建高可用集群
 | 
			
		||||
   - 构建多级缓存,实现成本稍高
 | 
			
		||||
   - 熔断:通过监控一旦雪崩出现,暂停缓存访问待实例恢复,返回预定义信息(有损方案)
 | 
			
		||||
   - 限流:通过监控一旦发现数据库访问量超过阈值,限制访问数据库的请求数(有损方案)
 | 
			
		||||
### 6.2.3、实现
 | 
			
		||||
我们将针对【情况1】的解决方案进行实现,主要是在默认的时间基础上随机增加1-10分钟有效期时间。
 | 
			
		||||
需要注意的是,使用SpringCache的`@Cacheable`注解是无法指定有效时间的,所以需要自定义`RedisCacheManager`对有效期时间进行随机设置。
 | 
			
		||||
自定义`RedisCacheManager`:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.config;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.util.ObjectUtil;
 | 
			
		||||
import cn.hutool.core.util.RandomUtil;
 | 
			
		||||
import org.springframework.data.redis.cache.RedisCache;
 | 
			
		||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
 | 
			
		||||
import org.springframework.data.redis.cache.RedisCacheManager;
 | 
			
		||||
import org.springframework.data.redis.cache.RedisCacheWriter;
 | 
			
		||||
 | 
			
		||||
import java.time.Duration;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 自定义CacheManager,用于设置不同的过期时间,防止雪崩问题的发生
 | 
			
		||||
 */
 | 
			
		||||
public class MyRedisCacheManager extends RedisCacheManager {
 | 
			
		||||
 | 
			
		||||
    public MyRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
 | 
			
		||||
        super(cacheWriter, defaultCacheConfiguration);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
 | 
			
		||||
        //获取到原有过期时间
 | 
			
		||||
        Duration duration = cacheConfig.getTtl();
 | 
			
		||||
        if (ObjectUtil.isNotEmpty(duration)) {
 | 
			
		||||
            //在原有时间上随机增加1~10分钟
 | 
			
		||||
            Duration newDuration = duration.plusMinutes(RandomUtil.randomInt(1, 11));
 | 
			
		||||
            cacheConfig = cacheConfig.entryTtl(newDuration);
 | 
			
		||||
        }
 | 
			
		||||
        return super.createRedisCache(name, cacheConfig);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
使用`MyRedisCacheManager`:
 | 
			
		||||
```java
 | 
			
		||||
    @Bean
 | 
			
		||||
    public RedisCacheManager redisCacheManager(RedisTemplate redisTemplate) {
 | 
			
		||||
        // 默认配置
 | 
			
		||||
        RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
 | 
			
		||||
                // 设置key的序列化方式为字符串
 | 
			
		||||
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
 | 
			
		||||
                // 设置value的序列化方式为json格式
 | 
			
		||||
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
 | 
			
		||||
                .disableCachingNullValues() // 不缓存null
 | 
			
		||||
                .entryTtl(Duration.ofHours(redisTtl));  // 默认缓存数据保存1小时
 | 
			
		||||
 | 
			
		||||
        // 构redis缓存管理器
 | 
			
		||||
        // RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
 | 
			
		||||
        //         .fromConnectionFactory(redisTemplate.getConnectionFactory())
 | 
			
		||||
        //         .cacheDefaults(defaultCacheConfiguration)
 | 
			
		||||
        //         .transactionAware()
 | 
			
		||||
        //         .build();
 | 
			
		||||
 | 
			
		||||
        //使用自定义缓存管理器
 | 
			
		||||
        RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisTemplate.getConnectionFactory());
 | 
			
		||||
        MyRedisCacheManager myRedisCacheManager = new MyRedisCacheManager(redisCacheWriter, defaultCacheConfiguration);
 | 
			
		||||
        myRedisCacheManager.setTransactionAware(true); // 只在事务成功提交后才会进行缓存的put/evict操作
 | 
			
		||||
        return myRedisCacheManager;
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
### 6.2.4、测试
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
## 6.3、缓存穿透
 | 
			
		||||
### 6.3.1、说明
 | 
			
		||||
缓存穿透是指,如果一个 key 在缓存和数据库都不存在,那么访问这个 key 每次都会进入数据库
 | 
			
		||||
 | 
			
		||||
- 很可能被恶意请求利用
 | 
			
		||||
- 缓存雪崩与缓存击穿都是数据库中有,但缓存暂时缺失
 | 
			
		||||
- 缓存雪崩与缓存击穿都能自然恢复,但缓存穿透则不能
 | 
			
		||||
### 6.3.2、解决方案
 | 
			
		||||
针对缓存穿透,一般有两种解决方案,分别是:
 | 
			
		||||
 | 
			
		||||
- 如果数据库没有,也将此不存在的 key 关联 null 值放入缓存,缺点是这样的 key 没有任何业务作用,白占空间
 | 
			
		||||
- 采用BloomFilter(布隆过滤器)解决,基本思路就是将存在数据的哈希值存储到一个足够大的Bitmap(Bit为单位存储数据,可以大大节省存储空间)中,在查询redis时,先查询布隆过滤器,如果数据不存在直接返回即可,如果存在的话,再执行缓存中命中、数据库查询等操作。
 | 
			
		||||
### 6.3.3、布隆过滤器
 | 
			
		||||
布隆过滤器(Bloom Filter)是1970年由布隆提出的,它实际上是一个很长的二进制向量和一系列随机映射函数,既然是二进制,那存储的数据不是0就是1,默认是0。
 | 
			
		||||
可以把它看作是这样的:
 | 
			
		||||

 | 
			
		||||
需要将数据存入隆过滤器中,才能判断是否存在,存入时要通过哈希算法计算数据的哈希值,通过哈希值确定存储都哪个位置。如下:
 | 
			
		||||

 | 
			
		||||
:::danger
 | 
			
		||||
说明:数据hello通过哈希算法计算哈希值,假设得到的值为8,这个值就是存储到布隆过滤器下标值。
 | 
			
		||||
:::
 | 
			
		||||
如何判断数据存在或者不存在呢?和存储道理一样,假设判断【java】数据是否存在,首先通过哈希算法计算哈希值,通过下标判断值是0还是1,如果是0就不存在,1就存在。
 | 
			
		||||
:::info
 | 
			
		||||
看到这里,你一定会有这样的疑问,不同的数据经过哈希算法计算,可能会得到相同的值,也就是,【张三】和【王五】可能会得到相同的hash值,会在同一个位置标记为1,这样的话,1个位置可能会代表多个数据,也就是会出现误判,没错,这个就是布隆过滤器最大的一个缺点,也是不可避免的特性。正因为这个特性,所以布隆过滤器基本是不能做删除动作的。
 | 
			
		||||
:::
 | 
			
		||||
这里可以得出一个结论,使用布隆过滤器能够判断一定不存在,而不能用来判断一定存在。
 | 
			
		||||
布隆过滤器虽然不能完全避免误判,但是可以降低误判率,如何降低误判率呢?就是增加多个哈希算法,计算多个hash值,因为不同的值,经过多个哈希算法计算得到相同值的概率要低一些。
 | 
			
		||||

 | 
			
		||||
:::danger
 | 
			
		||||
说明:可以看到,【hello】值经过3个哈希算法(实际不止3个)会计算出3个值,分别以这些值为坐标,标记数据为1,当判断值存在时,同样要经过这3个哈希算法计算3个哈希值,对应的都为1说明数据可能存在,如果其中有一个为0,就说明数据一定不存在。
 | 
			
		||||
在这里也能看出布隆过滤器的另外一个特性,哈希算法越多,误判率越低,但是所占用的空间越多,查询效率将越低。
 | 
			
		||||
:::
 | 
			
		||||
总结下布隆过滤器的优缺点:
 | 
			
		||||
 | 
			
		||||
- 优点
 | 
			
		||||
   - 存储的二进制数据,1或0,不存储真实数据,空间占用比较小且安全。
 | 
			
		||||
   - 插入和查询速度非常快,因为是基于数组下标的,类似HashMap,其时间复杂度是O(K),其中k是指哈希算法个数。
 | 
			
		||||
- 缺点
 | 
			
		||||
   - 存在误判,可以通过增加哈希算法个数降低误判率,不能完全避免误判。
 | 
			
		||||
   - 删除困难,因为一个位置可能会代表多个值,不能做删除。
 | 
			
		||||
 | 
			
		||||
牢记结论:布隆过滤器能够判断一定不存在,而不能用来判断一定存在。
 | 
			
		||||
### 6.3.4、实现
 | 
			
		||||
关于布隆过滤器的使用,建议使用Google的Guava 或 Redission基于Redis实现,前者是在单体架构下比较适合,后者更适合在分布式场景下,便于多个服务节点之间共享。
 | 
			
		||||
Redission基于Redis,使用string类型数据,生成二进制数组进行存储,最大可用长度为:4294967294。
 | 
			
		||||
引入Redission依赖:
 | 
			
		||||
```xml
 | 
			
		||||
        <dependency>
 | 
			
		||||
            <groupId>org.redisson</groupId>
 | 
			
		||||
            <artifactId>redisson</artifactId>
 | 
			
		||||
        </dependency>
 | 
			
		||||
```
 | 
			
		||||
导入Redission的配置:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.config;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.convert.Convert;
 | 
			
		||||
import cn.hutool.core.util.StrUtil;
 | 
			
		||||
import org.redisson.Redisson;
 | 
			
		||||
import org.redisson.api.RedissonClient;
 | 
			
		||||
import org.redisson.config.Config;
 | 
			
		||||
import org.redisson.config.SingleServerConfig;
 | 
			
		||||
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
 | 
			
		||||
import org.springframework.context.annotation.Bean;
 | 
			
		||||
import org.springframework.context.annotation.Configuration;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
@Configuration
 | 
			
		||||
public class RedissonConfiguration {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private RedisProperties redisProperties;
 | 
			
		||||
 | 
			
		||||
    @Bean
 | 
			
		||||
    public RedissonClient redissonSingle() {
 | 
			
		||||
        Config config = new Config();
 | 
			
		||||
        SingleServerConfig serverConfig = config.useSingleServer()
 | 
			
		||||
                .setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
 | 
			
		||||
        if (null != (redisProperties.getTimeout())) {
 | 
			
		||||
            serverConfig.setTimeout(1000 * Convert.toInt(redisProperties.getTimeout().getSeconds()));
 | 
			
		||||
        }
 | 
			
		||||
        if (StrUtil.isNotEmpty(redisProperties.getPassword())) {
 | 
			
		||||
            serverConfig.setPassword(redisProperties.getPassword());
 | 
			
		||||
        }
 | 
			
		||||
        return Redisson.create(config);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
自定义布隆过滤器配置:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.config;
 | 
			
		||||
 | 
			
		||||
import lombok.Getter;
 | 
			
		||||
import org.springframework.beans.factory.annotation.Value;
 | 
			
		||||
import org.springframework.context.annotation.Configuration;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 布隆过滤器相关配置
 | 
			
		||||
 */
 | 
			
		||||
@Getter
 | 
			
		||||
@Configuration
 | 
			
		||||
public class BloomFilterConfig {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 名称,默认:sl-bloom-filter
 | 
			
		||||
     */
 | 
			
		||||
    @Value("${bloom.name:sl-bloom-filter}")
 | 
			
		||||
    private String name;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 布隆过滤器长度,最大支持Integer.MAX_VALUE*2,即:4294967294,默认:1千万
 | 
			
		||||
     */
 | 
			
		||||
    @Value("${bloom.expectedInsertions:10000000}")
 | 
			
		||||
    private long expectedInsertions;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 误判率,默认:0.05
 | 
			
		||||
     */
 | 
			
		||||
    @Value("${bloom.falseProbability:0.05d}")
 | 
			
		||||
    private double falseProbability;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
定义`BloomFilterService`接口:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.service;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 布隆过滤器服务
 | 
			
		||||
 */
 | 
			
		||||
public interface BloomFilterService {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 初始化布隆过滤器
 | 
			
		||||
     */
 | 
			
		||||
    void init();
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 向布隆过滤器中添加数据
 | 
			
		||||
     *
 | 
			
		||||
     * @param obj 待添加的数据
 | 
			
		||||
     * @return 是否成功
 | 
			
		||||
     */
 | 
			
		||||
    boolean add(Object obj);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 判断数据是否存在
 | 
			
		||||
     *
 | 
			
		||||
     * @param obj 数据
 | 
			
		||||
     * @return 是否存在
 | 
			
		||||
     */
 | 
			
		||||
    boolean contains(Object obj);
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
编写实现类:
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.info.service.impl;
 | 
			
		||||
 | 
			
		||||
import com.sl.transport.info.config.BloomFilterConfig;
 | 
			
		||||
import com.sl.transport.info.service.BloomFilterService;
 | 
			
		||||
import org.redisson.api.RBloomFilter;
 | 
			
		||||
import org.redisson.api.RedissonClient;
 | 
			
		||||
import org.springframework.stereotype.Service;
 | 
			
		||||
 | 
			
		||||
import javax.annotation.PostConstruct;
 | 
			
		||||
import javax.annotation.Resource;
 | 
			
		||||
 | 
			
		||||
@Service
 | 
			
		||||
public class BloomFilterServiceImpl implements BloomFilterService {
 | 
			
		||||
 | 
			
		||||
    @Resource
 | 
			
		||||
    private RedissonClient redissonClient;
 | 
			
		||||
    @Resource
 | 
			
		||||
    private BloomFilterConfig bloomFilterConfig;
 | 
			
		||||
 | 
			
		||||
    private RBloomFilter<Object> getBloomFilter() {
 | 
			
		||||
        return this.redissonClient.getBloomFilter(this.bloomFilterConfig.getName());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    @PostConstruct // spring启动后进行初始化
 | 
			
		||||
    public void init() {
 | 
			
		||||
        RBloomFilter<Object> bloomFilter = this.getBloomFilter();
 | 
			
		||||
        bloomFilter.tryInit(this.bloomFilterConfig.getExpectedInsertions(), this.bloomFilterConfig.getFalseProbability());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean add(Object obj) {
 | 
			
		||||
        return this.getBloomFilter().add(obj);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean contains(Object obj) {
 | 
			
		||||
        return this.getBloomFilter().contains(obj);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
改造TransportInfoController的查询逻辑,如果布隆过滤器中不存在直接返回即可,无需进行缓存命中。
 | 
			
		||||
```java
 | 
			
		||||
    @ApiImplicitParams({
 | 
			
		||||
            @ApiImplicitParam(name = "transportOrderId", value = "运单id")
 | 
			
		||||
    })
 | 
			
		||||
    @ApiOperation(value = "查询", notes = "根据运单id查询物流信息")
 | 
			
		||||
    @GetMapping("{transportOrderId}")
 | 
			
		||||
    public TransportInfoDTO queryByTransportOrderId(@PathVariable("transportOrderId") String transportOrderId) {
 | 
			
		||||
        //如果布隆过滤器中不存在,无需缓存命中,直接返回即可
 | 
			
		||||
        boolean contains = this.bloomFilterService.contains(transportOrderId);
 | 
			
		||||
        if (!contains) {
 | 
			
		||||
            throw new SLException(ExceptionEnum.NOT_FOUND);
 | 
			
		||||
        }
 | 
			
		||||
        TransportInfoDTO transportInfoDTO = transportInfoCache.get(transportOrderId, id -> {
 | 
			
		||||
            //未命中,查询MongoDB
 | 
			
		||||
            TransportInfoEntity transportInfoEntity = this.transportInfoService.queryByTransportOrderId(id);
 | 
			
		||||
            //转化成DTO
 | 
			
		||||
            return BeanUtil.toBean(transportInfoEntity, TransportInfoDTO.class);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (ObjectUtil.isNotEmpty(transportInfoDTO)) {
 | 
			
		||||
            return transportInfoDTO;
 | 
			
		||||
        }
 | 
			
		||||
        throw new SLException(ExceptionEnum.NOT_FOUND);
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
改造`com.sl.transport.info.service.impl.TransportInfoServiceImpl#saveOrUpdate()`方法,将新增的运单数据写入到布隆过滤器中:
 | 
			
		||||

 | 
			
		||||
:::danger
 | 
			
		||||
可见,通过布隆过滤器可以解决缓存穿透的问题,还有一点需要注意,如果有存在的数据没有写入都布隆过滤器中就会导致查询不到真实存在的数据。
 | 
			
		||||
:::
 | 
			
		||||
# 7、练习
 | 
			
		||||
## 7.1、练习1
 | 
			
		||||
难度系数:★★★★☆
 | 
			
		||||
描述:在work微服务中完成发送【物流信息】的消息的逻辑,这样的话,work微服务就和transport-info微服务联系起来了。
 | 
			
		||||
提示,一共有4处代码需要完善:
 | 
			
		||||
 | 
			
		||||
- com.sl.ms.work.mq.CourierMQListener#listenCourierPickupMsg()
 | 
			
		||||
- com.sl.ms.work.service.impl.PickupDispatchTaskServiceImpl#saveTaskPickupDispatch()
 | 
			
		||||
   - 此处实现难度较大,会涉及到基础服务系统消息模块,需要阅读相应的代码进行理解。
 | 
			
		||||
- com.sl.ms.work.service.impl.TransportOrderServiceImpl#updateStatus()
 | 
			
		||||
- com.sl.ms.work.service.impl.TransportOrderServiceImpl#updateByTaskId()
 | 
			
		||||
:::danger
 | 
			
		||||
另外,包裹的签收与拒收的消息已经在【快递员微服务】中实现,学生可自行阅读源码:
 | 
			
		||||
 | 
			
		||||
- com.sl.ms.web.courier.service.impl.TaskServiceImpl#sign()
 | 
			
		||||
- com.sl.ms.web.courier.service.impl.TaskServiceImpl#reject()
 | 
			
		||||
:::
 | 
			
		||||
# 8、面试连环问
 | 
			
		||||
:::info
 | 
			
		||||
面试官问:
 | 
			
		||||
 | 
			
		||||
- 你们项目中的物流信息那块存储是怎么做的?为什么要选择MongoDB?
 | 
			
		||||
- 针对于查询并发高的问题你们是怎么解决的?有用多级缓存吗?具体是怎么用的?
 | 
			
		||||
- 多级缓存间的数据不一致是如何解决的?
 | 
			
		||||
- 来,说说在使用Redis场景中的缓存击穿、缓存雪崩、缓存穿透都是啥意思?对应的解决方案是啥?实际你解决过哪个问题?
 | 
			
		||||
- 说说布隆过滤器的优缺点是什么?什么样的场景适合使用布隆过滤器?
 | 
			
		||||
:::
 | 
			
		||||
							
								
								
									
										464
									
								
								01-讲义/md/day12-分布式日志与链路追踪.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										464
									
								
								01-讲义/md/day12-分布式日志与链路追踪.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,464 @@
 | 
			
		||||
# 1、课程安排
 | 
			
		||||
- 了解什么是分布式日志
 | 
			
		||||
- Graylog的部署安装
 | 
			
		||||
- 使用Graylog进行日志收集
 | 
			
		||||
- Graylog的搜索语法
 | 
			
		||||
- 了解什么是链路追踪
 | 
			
		||||
- Skywalking的基本使用
 | 
			
		||||
- 整合微服务使用Skywalking
 | 
			
		||||
- 将Skywalking整合到Docker中
 | 
			
		||||
# 2、背景说明
 | 
			
		||||
在微服务架构体系中,微服务上线后,有两个不容忽略的问题,一是日志该怎么存储、查看,二是如何在复杂的调用链中排查问题。
 | 
			
		||||

 | 
			
		||||
## 2.1、日志问题
 | 
			
		||||
在微服务架构下,微服务被拆分成多个微小的服务,每个微小的服务都部署在不同的服务器实例上,当我们定位问题,检索日志的时候需要依次登录每台服务器进行检索。
 | 
			
		||||
这样是不是感觉很繁琐和效率低下?所以我们还需要一个工具来帮助集中收集、存储和搜索这些跟踪信息。
 | 
			
		||||
集中化管理日志后,日志的统计和检索又成为一件比较麻烦的事情。以前,我们通过使用grep、awk和wc等Linux命令能实现检索和统计,但是对于要求更高的查询、排序和统计等要求和庞大的机器数量依然使用这样的方法难免有点力不从心。
 | 
			
		||||
所以,需要通过**分布式日志服务**来帮我们解决上述问题的。
 | 
			
		||||
## 2.2、调用链问题
 | 
			
		||||
在微服务架构下,如何排查异常的微服务,比如:发布新版本后发现系统处理用户请求变慢了,要想解决这个问首先是要找出“慢”的环节,此时就需要对整个微服务的调用链有清晰的监控,否则是不容易找出问题的。下面所展现的就是通过skywalking可以查看微服务的调用链,就会比较容易的找出问题:
 | 
			
		||||

 | 
			
		||||
# 3、分布式日志
 | 
			
		||||
## 3.1、实现思路
 | 
			
		||||
分布式日志框架服务的实现思路基本是一致的,如下:
 | 
			
		||||
 | 
			
		||||
- **日志收集器:**微服务中引入日志客户端,将记录的日志发送到日志服务端的收集器,然后以某种方式存储
 | 
			
		||||
- **数据存储:**一般使用ElasticSearch分布式存储,把收集器收集到的日志格式化,然后存储到分布式存储中
 | 
			
		||||
- **web服务:**利用ElasticSearch的统计搜索功能,实现日志查询和报表输出
 | 
			
		||||
 | 
			
		||||
比较知名的分布式日志服务包括:
 | 
			
		||||
 | 
			
		||||
- ELK:elasticsearch、Logstash、Kibana
 | 
			
		||||
- GrayLog
 | 
			
		||||
 | 
			
		||||
本课程主要是基于GrayLog讲解。
 | 
			
		||||
## 3.2、为什么选择GrayLog?
 | 
			
		||||
业界比较知名的分布式日志服务解决方案是ELK,而我们今天要学习的是GrayLog。为什么呢?
 | 
			
		||||
ELK解决方案的问题:
 | 
			
		||||
 | 
			
		||||
1. 不能处理多行日志,比如Mysql慢查询,Tomcat/Jetty应用的Java异常打印
 | 
			
		||||
2. 不能保留原始日志,只能把原始日志分字段保存,这样搜索日志结果是一堆Json格式文本,无法阅读。
 | 
			
		||||
3. 不符合正则表达式匹配的日志行,被全部丢弃。
 | 
			
		||||
 | 
			
		||||
GrayLog方案的优势:
 | 
			
		||||
 | 
			
		||||
1. 一体化方案,安装方便,不像ELK有3个独立系统间的集成问题。
 | 
			
		||||
2. 采集原始日志,并可以事后再添加字段,比如http_status_code,response_time等等。
 | 
			
		||||
3. 自己开发采集日志的脚本,并用curl/nc发送到Graylog Server,发送格式是自定义的GELF,Flunted和Logstash都有相应的输出GELF消息的插件。自己开发带来很大的自由度。实际上只需要用inotifywait监控日志的modify事件,并把日志的新增行用curl/netcat发送到Graylog Server就可。
 | 
			
		||||
4. 搜索结果高亮显示,就像google一样。
 | 
			
		||||
5. 搜索语法简单,比如: `source:mongo AND reponse_time_ms:>5000`,避免直接输入elasticsearch搜索json语法
 | 
			
		||||
6. 搜索条件可以导出为elasticsearch的搜索json文本,方便直接开发调用elasticsearch rest api的搜索脚本。
 | 
			
		||||
## 3.3、GrayLog简介
 | 
			
		||||
GrayLog是一个轻量型的分布式日志管理平台,一个开源的日志聚合、分析、审计、展示和预警工具。在功能上来说,和 ELK类似,但又比 ELK要简单轻量许多。依靠着更加简洁,高效,部署使用简单的优势很快受到许多公司的青睐。
 | 
			
		||||
官网:[https://www.graylog.org/](https://www.graylog.org/)
 | 
			
		||||
其基本框架如图:
 | 
			
		||||

 | 
			
		||||
流程如下:
 | 
			
		||||
 | 
			
		||||
- 微服务中的GrayLog客户端发送日志到GrayLog服务端
 | 
			
		||||
- GrayLog把日志信息格式化,存储到Elasticsearch
 | 
			
		||||
- 客户端通过浏览器访问GrayLog,GrayLog访问Elasticsearch
 | 
			
		||||
 | 
			
		||||
这里MongoDB是用来存储GrayLog的配置信息的,这样搭建集群时,GrayLog的各节点可以共享配置。
 | 
			
		||||
## 3.4、部署安装
 | 
			
		||||
我们在虚拟机中选择使用Docker来安装。需要安装的包括:
 | 
			
		||||
 | 
			
		||||
- MongoDB:用来存储GrayLog的配置信息
 | 
			
		||||
- Elasticsearch:用来存储日志信息
 | 
			
		||||
- GrayLog:GrayLog服务端
 | 
			
		||||
 | 
			
		||||
下面将通过docker的方式部署,镜像已经下载到101虚拟机中,部署脚本如下:
 | 
			
		||||
```shell
 | 
			
		||||
#部署Elasticsearch
 | 
			
		||||
docker run -d \
 | 
			
		||||
    --name elasticsearch \
 | 
			
		||||
    -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
 | 
			
		||||
    -e "discovery.type=single-node" \
 | 
			
		||||
    -v es-data:/usr/share/elasticsearch/data \
 | 
			
		||||
    -v es-plugins:/usr/share/elasticsearch/plugins \
 | 
			
		||||
    --privileged \
 | 
			
		||||
    -p 9200:9200 \
 | 
			
		||||
    -p 9300:9300 \
 | 
			
		||||
elasticsearch:7.17.5
 | 
			
		||||
 | 
			
		||||
#部署MongoDB(使用之前部署的服务即可)
 | 
			
		||||
docker run -d \
 | 
			
		||||
--name mongodb \
 | 
			
		||||
-p 27017:27017 \
 | 
			
		||||
--restart=always \
 | 
			
		||||
-v mongodb:/data/db \
 | 
			
		||||
-e MONGO_INITDB_ROOT_USERNAME=sl \
 | 
			
		||||
-e MONGO_INITDB_ROOT_PASSWORD=123321 \
 | 
			
		||||
mongo:4.4
 | 
			
		||||
 | 
			
		||||
#部署
 | 
			
		||||
docker run \
 | 
			
		||||
--name graylog \
 | 
			
		||||
-p 9000:9000 \
 | 
			
		||||
-p 12201:12201/udp \
 | 
			
		||||
-e GRAYLOG_HTTP_EXTERNAL_URI=http://192.168.150.101:9000/ \
 | 
			
		||||
-e GRAYLOG_ELASTICSEARCH_HOSTS=http://192.168.150.101:9200/ \
 | 
			
		||||
-e GRAYLOG_ROOT_TIMEZONE="Asia/Shanghai"  \
 | 
			
		||||
-e GRAYLOG_WEB_ENDPOINT_URI="http://192.168.150.101:9000/:9000/api" \
 | 
			
		||||
-e GRAYLOG_PASSWORD_SECRET="somepasswordpepper" \
 | 
			
		||||
-e GRAYLOG_ROOT_PASSWORD_SHA2=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 \
 | 
			
		||||
-e GRAYLOG_MONGODB_URI=mongodb://sl:123321@192.168.150.101:27017/admin \
 | 
			
		||||
-d \
 | 
			
		||||
graylog/graylog:4.3
 | 
			
		||||
```
 | 
			
		||||
命令解读:
 | 
			
		||||
 | 
			
		||||
- 端口信息: 
 | 
			
		||||
   - `-p 9000:9000`:GrayLog的http服务端口,9000
 | 
			
		||||
   - `-p 12201:12201/udp`:GrayLog的GELF UDP协议端口,用于接收从微服务发来的日志信息
 | 
			
		||||
- 环境变量 
 | 
			
		||||
   - `-e GRAYLOG_HTTP_EXTERNAL_URI`:对外开放的ip和端口信息,这里用9000端口
 | 
			
		||||
   - `-e GRAYLOG_ELASTICSEARCH_HOSTS`:GrayLog依赖于ES,这里指定ES的地址
 | 
			
		||||
   - `-e GRAYLOG_WEB_ENDPOINT_URI`:对外开放的API地址
 | 
			
		||||
   - `-e GRAYLOG_PASSWORD_SECRET`:密码加密的秘钥
 | 
			
		||||
   - `-e GRAYLOG_ROOT_PASSWORD_SHA2`:密码加密后的密文。明文是`admin`,账户也是`admin`
 | 
			
		||||
   - `-e GRAYLOG_ROOT_TIMEZONE="Asia/Shanghai"`:GrayLog容器内时区
 | 
			
		||||
   - `-e GRAYLOG_MONGODB_URI`:指定MongoDB的链接信息
 | 
			
		||||
- `graylog/graylog:4.3`:使用的镜像名称,版本为4.3
 | 
			
		||||
 | 
			
		||||
访问地址 [http://192.168.150.101:9000/](http://192.168.150.101:9000/) , 如果可以看到如下界面说明启动成功。
 | 
			
		||||

 | 
			
		||||
通过 `admin/admin`登录,即可看到欢迎页面,目前还没有数据:
 | 
			
		||||

 | 
			
		||||
## 3.5、收集日志
 | 
			
		||||
### 3.5.1、配置Inputs
 | 
			
		||||
部署完成GrayLog后,需要配置Inputs才能接收微服务发来的日志数据。
 | 
			
		||||
第一步,在`System`菜单中选择`Inputs`:
 | 
			
		||||

 | 
			
		||||
第二步,在页面的下拉选框中,选择`GELF UDP`:
 | 
			
		||||

 | 
			
		||||
然后点击`Launch new input`按钮:
 | 
			
		||||

 | 
			
		||||
点击`save`保存:
 | 
			
		||||

 | 
			
		||||
可以看到,GELF UDP Inputs 保存成功。
 | 
			
		||||
### 3.5.2、集成微服务
 | 
			
		||||
现在,GrayLog的服务端日志收集器已经准备好,我们还需要在项目中添加GrayLog的客户端,将项目日志发送到GrayLog服务中,保存到ElasticSearch。
 | 
			
		||||
基本步骤如下:
 | 
			
		||||
 | 
			
		||||
- 引入GrayLog客户端依赖
 | 
			
		||||
- 配置Logback,集成GrayLog的Appender
 | 
			
		||||
- 启动并测试
 | 
			
		||||
 | 
			
		||||
这里,我们以work微服务为例,其他的类似。
 | 
			
		||||
导入依赖:
 | 
			
		||||
```xml
 | 
			
		||||
<dependency>
 | 
			
		||||
    <groupId>biz.paluch.logging</groupId>
 | 
			
		||||
    <artifactId>logstash-gelf</artifactId>
 | 
			
		||||
    <version>1.15.0</version>
 | 
			
		||||
</dependency>
 | 
			
		||||
```
 | 
			
		||||
配置Logback,在配置文件中增加 GELF的appender:
 | 
			
		||||
```xml
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<!--scan: 当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true。-->
 | 
			
		||||
<!--scanPeriod: 设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为1分钟。-->
 | 
			
		||||
<!--debug: 当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。-->
 | 
			
		||||
<configuration debug="false" scan="false" scanPeriod="60 seconds">
 | 
			
		||||
    <springProperty scope="context" name="appName" source="spring.application.name"/>
 | 
			
		||||
    <!--文件名-->
 | 
			
		||||
    <property name="logback.appname" value="${appName}"/>
 | 
			
		||||
    <!--文件位置-->
 | 
			
		||||
    <property name="logback.logdir" value="/data/logs"/>
 | 
			
		||||
 | 
			
		||||
    <!-- 定义控制台输出 -->
 | 
			
		||||
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
 | 
			
		||||
        <layout class="ch.qos.logback.classic.PatternLayout">
 | 
			
		||||
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} - [%thread] - %-5level - %logger{50} - %msg%n</pattern>
 | 
			
		||||
        </layout>
 | 
			
		||||
    </appender>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
 | 
			
		||||
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
 | 
			
		||||
            <level>DEBUG</level>
 | 
			
		||||
        </filter>
 | 
			
		||||
        <File>${logback.logdir}/${logback.appname}/${logback.appname}.log</File>
 | 
			
		||||
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
 | 
			
		||||
            <FileNamePattern>${logback.logdir}/${logback.appname}/${logback.appname}.%d{yyyy-MM-dd}.log.zip</FileNamePattern>
 | 
			
		||||
            <maxHistory>90</maxHistory>
 | 
			
		||||
        </rollingPolicy>
 | 
			
		||||
        <encoder>
 | 
			
		||||
            <charset>UTF-8</charset>
 | 
			
		||||
            <pattern>%d [%thread] %-5level %logger{36} %line - %msg%n</pattern>
 | 
			
		||||
        </encoder>
 | 
			
		||||
    </appender>
 | 
			
		||||
 | 
			
		||||
    <appender name="GELF" class="biz.paluch.logging.gelf.logback.GelfLogbackAppender">
 | 
			
		||||
        <!--GrayLog服务地址-->
 | 
			
		||||
        <host>udp:192.168.150.101</host>
 | 
			
		||||
        <!--GrayLog服务端口-->
 | 
			
		||||
        <port>12201</port>
 | 
			
		||||
        <version>1.1</version>
 | 
			
		||||
        <!--当前服务名称-->
 | 
			
		||||
        <facility>${appName}</facility>
 | 
			
		||||
        <extractStackTrace>true</extractStackTrace>
 | 
			
		||||
        <filterStackTrace>true</filterStackTrace>
 | 
			
		||||
        <mdcProfiling>true</mdcProfiling>
 | 
			
		||||
        <timestampPattern>yyyy-MM-dd HH:mm:ss,SSS</timestampPattern>
 | 
			
		||||
        <maximumMessageSize>8192</maximumMessageSize>
 | 
			
		||||
    </appender>
 | 
			
		||||
 | 
			
		||||
    <!--evel:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,-->
 | 
			
		||||
    <!--不能设置为INHERITED或者同义词NULL。默认是DEBUG。-->
 | 
			
		||||
    <root level="INFO">
 | 
			
		||||
        <appender-ref ref="stdout"/>
 | 
			
		||||
        <appender-ref ref="GELF"/>
 | 
			
		||||
    </root>
 | 
			
		||||
</configuration>
 | 
			
		||||
```
 | 
			
		||||
修改代码,`com.sl.ms.work.controller.TransportOrderController#findStatusCount()`进入打印日志便于查看数据,启动服务,点击search按钮即可看到日志数据:
 | 
			
		||||

 | 
			
		||||
## 3.6、日志回收策略
 | 
			
		||||
到此graylog的基础配置就算完成了,已经可以收到日志数据。
 | 
			
		||||
但是在实际工作中,服务日志会非常多,这么多的日志,如果不进行存储限制,那么不久就会占满磁盘,查询变慢等等,而且过久的历史日志对于实际工作中的有效性也会很低。
 | 
			
		||||
Graylog则自身集成了日志数据限制的配置,可以通过如下进行设置:
 | 
			
		||||

 | 
			
		||||
选择`Default index set`的`Edit`按钮:
 | 
			
		||||

 | 
			
		||||
GrayLog有3种日志回收限制,触发以后就会开始回收空间,删除索引:
 | 
			
		||||

 | 
			
		||||
分别是:
 | 
			
		||||
 | 
			
		||||
- `Index Message Count`:按照日志数量统计,默认超过`20000000`条日志开始清理
 | 
			
		||||
   - 我们测试时,设置`100000`即可
 | 
			
		||||
- `Index Size`:按照日志大小统计,默认超过`1GB`开始清理
 | 
			
		||||
- `Index Message Count`:按照日志日期清理,默认日志存储1天
 | 
			
		||||
## 3.7、搜索语法
 | 
			
		||||
在search页面,可以完成基本的日志搜索功能:
 | 
			
		||||

 | 
			
		||||
### 3.7.1、搜索语法
 | 
			
		||||
搜索语法非常简单,输入关键字或指定字段进行搜索:
 | 
			
		||||
```shell
 | 
			
		||||
#不指定字段,默认从message字段查询
 | 
			
		||||
输入:undo
 | 
			
		||||
 | 
			
		||||
#输入两个关键字,关系为or
 | 
			
		||||
undo 统计
 | 
			
		||||
 | 
			
		||||
#加引号是需要完整匹配
 | 
			
		||||
"undo 统计"
 | 
			
		||||
 | 
			
		||||
#指定字段查询,level表示日志级别,ERROR(3)、WARNING(4)、NOTICE(5)、INFO(6)、DEBUG(7)
 | 
			
		||||
level: 6
 | 
			
		||||
 | 
			
		||||
#或条件
 | 
			
		||||
level:(6 OR 7)
 | 
			
		||||
```
 | 
			
		||||
更多查询官网文档:[https://docs.graylog.org/docs/query-language](https://docs.graylog.org/docs/query-language)
 | 
			
		||||
### 3.7.2、自定义展示字段
 | 
			
		||||

 | 
			
		||||
效果如下:
 | 
			
		||||

 | 
			
		||||
## 3.8、日志统计仪表盘
 | 
			
		||||
GrayLog支持把日志按照自己需要的方式形成统计报表,并把许多报表组合一起,形成DashBoard(仪表盘),方便对日志统计分析。
 | 
			
		||||
### 3.8.1、创建仪表盘
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
可以设置各种指标:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
最终效果:
 | 
			
		||||

 | 
			
		||||
官方给出的效果:
 | 
			
		||||

 | 
			
		||||
# 4、链路追踪
 | 
			
		||||
## 4.1、APM
 | 
			
		||||
### 4.1.1、什么是APM
 | 
			
		||||
随着微服务架构的流行,一次请求往往需要涉及到多个服务,因此服务性能监控和排查就变得更复杂
 | 
			
		||||
 | 
			
		||||
- 不同的服务可能由不同的团队开发、甚至可能使用不同的编程语言来实现
 | 
			
		||||
- 服务有可能布在了几千台服务器,横跨多个不同的数据中心
 | 
			
		||||
 | 
			
		||||
因此,就需要一些可以帮助理解系统行为、用于分析性能问题的工具,以便发生故障的时候,能够快速定位和解决问题,这就是APM系统,全称是(**A**pplication **P**erformance **M**onitor,当然也有叫 **A**pplication **P**erformance **M**anagement tools)
 | 
			
		||||
APM最早是谷歌公开的论文提到的 [Google Dapper](http://bigbully.github.io/Dapper-translation)。Dapper是Google生产环境下的分布式跟踪系统,自从Dapper发展成为一流的监控系统之后,给google的开发者和运维团队帮了大忙,所以谷歌公开论文分享了Dapper。
 | 
			
		||||
### 4.1.2、原理
 | 
			
		||||
先来看一次请求调用示例:
 | 
			
		||||
 | 
			
		||||
1. 包括:前端(A),两个中间层(B和C),以及两个后端(D和E)
 | 
			
		||||
2. 当用户发起一个请求时,首先到达前端A服务,然后分别对B服务和C服务进行RPC调用;
 | 
			
		||||
3. B服务处理完给A做出响应,但是C服务还需要和后端的D服务和E服务交互之后再返还给A服务,最后由A服务来响应用户的请求;
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
如何才能实现跟踪呢?需要明白下面几个概念:
 | 
			
		||||
 | 
			
		||||
- 探针:负责在客户端程序运行时收集服务调用链路信息,发送给收集器
 | 
			
		||||
- 收集器:负责将数据格式化,保存到存储器
 | 
			
		||||
- 存储器:保存数据
 | 
			
		||||
- UI界面:统计并展示
 | 
			
		||||
 | 
			
		||||
探针会在链路追踪时记录每次调用的信息,Span是**基本单元**,一次链路调用(可以是RPC,DB等没有特定的限制)创建一个span,通过一个64位ID标识它;同时附加(Annotation)作为payload负载信息,用于记录性能等数据。
 | 
			
		||||
一个Span的基本数据结构:
 | 
			
		||||
```c
 | 
			
		||||
type Span struct {
 | 
			
		||||
    TraceID    int64 // 用于标示一次完整的请求id
 | 
			
		||||
    Name       string //名称
 | 
			
		||||
    ID         int64 // 当前这次调用span_id
 | 
			
		||||
    ParentID   int64 // 上层服务的调用span_id  最上层服务parent_id为null,代表根服务root
 | 
			
		||||
    Annotation []Annotation // 记录性能等数据
 | 
			
		||||
    Debug      bool
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
一次请求的每个链路,通过spanId、parentId就能串联起来:
 | 
			
		||||

 | 
			
		||||
当然,从请求到服务器开始,服务器返回response结束,每个span存在相同的唯一标识trace_id。
 | 
			
		||||
### 4.1.3、技术选型
 | 
			
		||||
市面上的全链路监控理论模型大多都是借鉴Google Dapper论文,重点关注以下三种APM组件:
 | 
			
		||||
 | 
			
		||||
- [**Zipkin**](https://link.juejin.im/?target=http%3A%2F%2Fzipkin.io%2F):由Twitter公司开源,开放源代码分布式的跟踪系统,用于收集服务的定时数据,以解决微服务架构中的延迟问题,包括:数据的收集、存储、查找和展现。
 | 
			
		||||
- [**Pinpoint**](https://pinpoint.com/):一款对Java编写的大规模分布式系统的APM工具,由韩国人开源的分布式跟踪组件。
 | 
			
		||||
- [**Skywalking**](https://skywalking.apache.org/zh/):国产的优秀APM组件,是一个对JAVA分布式应用程序集群的业务运行情况进行追踪、告警和分析的系统。现在是Apache的顶级项目之一。
 | 
			
		||||
 | 
			
		||||
选项就是对比各个系统的使用差异,主要对比项:
 | 
			
		||||
 | 
			
		||||
1.  **探针的性能**
 | 
			
		||||
主要是agent对服务的吞吐量、CPU和内存的影响。微服务的规模和动态性使得数据收集的成本大幅度提高。 
 | 
			
		||||
2.  **collector的可扩展性**
 | 
			
		||||
能够水平扩展以便支持大规模服务器集群。 
 | 
			
		||||
3.  **全面的调用链路数据分析**
 | 
			
		||||
提供代码级别的可见性以便轻松定位失败点和瓶颈。 
 | 
			
		||||
4.  **对于开发透明,容易开关**
 | 
			
		||||
添加新功能而无需修改代码,容易启用或者禁用。 
 | 
			
		||||
5.  **完整的调用链应用拓扑**
 | 
			
		||||
自动检测应用拓扑,帮助你搞清楚应用的架构 
 | 
			
		||||
 | 
			
		||||
三者对比如下:
 | 
			
		||||
 | 
			
		||||
| 对比项 | zipkin | pinpoint | skywalking |
 | 
			
		||||
| --- | --- | --- | --- |
 | 
			
		||||
| 探针性能 | 中 | 低 | **高** |
 | 
			
		||||
| collector扩展性 | **高** | 中 | **高** |
 | 
			
		||||
| 调用链路数据分析 | 低 | **高** | 中 |
 | 
			
		||||
| 对开发透明性 | 中 | **高** | **高** |
 | 
			
		||||
| 调用链应用拓扑 | 中 | **高** | 中 |
 | 
			
		||||
| 社区支持 | **高** | 中 | **高** |
 | 
			
		||||
 | 
			
		||||
综上所述,使用skywalking是最佳的选择。
 | 
			
		||||
## 4.2、Skywalking简介
 | 
			
		||||
SkyWalking创建与2015年,提供分布式追踪功能,是一个功能完备的APM系统。
 | 
			
		||||
官网地址:[http://skywalking.apache.org/](http://skywalking.apache.org/)
 | 
			
		||||

 | 
			
		||||
主要的特征:
 | 
			
		||||
 | 
			
		||||
-  多语言探针或类库 
 | 
			
		||||
   - Java自动探针,追踪和监控程序时,不需要修改源码。
 | 
			
		||||
   - 社区提供的其他多语言探针 
 | 
			
		||||
      - [.NET Core](https://github.com/OpenSkywalking/skywalking-netcore)
 | 
			
		||||
      - [Node.js](https://github.com/OpenSkywalking/skywalking-nodejs)
 | 
			
		||||
-  多种后端存储: ElasticSearch, H2 
 | 
			
		||||
-  支持OpenTracing 
 | 
			
		||||
   - Java自动探针支持和OpenTracing API协同工作
 | 
			
		||||
-  轻量级、完善功能的后端聚合和分析 
 | 
			
		||||
-  现代化Web UI 
 | 
			
		||||
-  日志集成 
 | 
			
		||||
-  应用、实例和服务的告警 
 | 
			
		||||
 | 
			
		||||
官方架构图:
 | 
			
		||||

 | 
			
		||||
大致分四个部分:
 | 
			
		||||
 | 
			
		||||
- skywalking-oap-server:就是**O**bservability **A**nalysis **P**latform的服务,用来收集和处理探针发来的数据
 | 
			
		||||
- skywalking-UI:就是skywalking提供的Web UI 服务,图形化方式展示服务链路、拓扑图、trace、性能监控等
 | 
			
		||||
- agent:探针,获取服务调用的链路信息、性能信息,发送到skywalking的OAP服务
 | 
			
		||||
- Storage:存储,一般选择elasticsearch
 | 
			
		||||
 | 
			
		||||
因此我们安装部署也从这四个方面入手,目前elasticsearch已经安装完成,只需要部署其他3个即可。
 | 
			
		||||
## 4.3、部署安装
 | 
			
		||||
通过docker部署,需要部署两部分,分别是`skywalking-oap-server`和`skywalking-UI`。
 | 
			
		||||
```shell
 | 
			
		||||
#oap服务,需要指定Elasticsearch以及链接信息
 | 
			
		||||
docker run -d \
 | 
			
		||||
-e TZ=Asia/Shanghai \
 | 
			
		||||
--name oap \
 | 
			
		||||
-p 12800:12800 \
 | 
			
		||||
-p 11800:11800 \
 | 
			
		||||
-e SW_STORAGE=elasticsearch \
 | 
			
		||||
-e SW_STORAGE_ES_CLUSTER_NODES=192.168.150.101:9200 \
 | 
			
		||||
apache/skywalking-oap-server:9.1.0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#部署ui,需要指定oap服务
 | 
			
		||||
docker run -d \
 | 
			
		||||
--name oap-ui \
 | 
			
		||||
-p 48080:8080 \
 | 
			
		||||
-e TZ=Asia/Shanghai \
 | 
			
		||||
-e SW_OAP_ADDRESS=http://192.168.150.101:12800 \
 | 
			
		||||
apache/skywalking-ui:9.1.0
 | 
			
		||||
```
 | 
			
		||||
启动成功后,访问地址[http://192.168.150.101:48080/](http://192.168.150.101:48080/),即可查看skywalking的ui界面。
 | 
			
		||||

 | 
			
		||||
## 4.4、微服务探针
 | 
			
		||||
现在,Skywalking的服务端已经启动完成,我们还需要在微服务中加入服务探针,来收集数据。
 | 
			
		||||

 | 
			
		||||
将`skywalking-agent`解压到非中文目录。
 | 
			
		||||
在微服务中设置启动参数,以work微服务为例:
 | 
			
		||||

 | 
			
		||||
输入如下内容:
 | 
			
		||||
```shell
 | 
			
		||||
-javaagent:F:\code\sl-express\docs\resources\skywalking-agent\skywalking-agent.jar
 | 
			
		||||
-Dskywalking.agent.service_name=ms::sl-express-ms-work
 | 
			
		||||
-Dskywalking.collector.backend_service=192.168.150.101:11800
 | 
			
		||||
```
 | 
			
		||||
参数说明:
 | 
			
		||||
 | 
			
		||||
- javaagent: 将skywalking-agent以代理的方式整合到微服务中
 | 
			
		||||
- skywalking.agent.service_name:指定服务名称,格式:[${group name}::]${logic name}
 | 
			
		||||
- skywalking.collector.backend_service:指定oap服务,注意端口要走11800
 | 
			
		||||
 | 
			
		||||
设置完成后,重新启动work微服务,多请求几次接口,即可自oap-ui中看到数据。
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
查看链路:
 | 
			
		||||

 | 
			
		||||
服务关系拓扑图:
 | 
			
		||||

 | 
			
		||||
## 4.5、整合到docker服务
 | 
			
		||||
前面的测试是在本地测试,如何将SkyWalking整合到docker服务中呢?
 | 
			
		||||
这里以`sl-express-ms-web-courier`为例,其他的服务类似。
 | 
			
		||||
第一步,修改Dockerfile文件
 | 
			
		||||
```shell
 | 
			
		||||
#FROM openjdk:11-jdk
 | 
			
		||||
#修改为基于整合了skywalking的镜像,其他的不需要动
 | 
			
		||||
FROM apache/skywalking-java-agent:8.11.0-java11
 | 
			
		||||
LABEL maintainer="研究院研发组 <research@itcast.cn>"
 | 
			
		||||
 
 | 
			
		||||
# 时区修改为东八区
 | 
			
		||||
ENV TZ=Asia/Shanghai
 | 
			
		||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 | 
			
		||||
 
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
ARG JAR_FILE=target/*.jar
 | 
			
		||||
ADD ${JAR_FILE} app.jar
 | 
			
		||||
 | 
			
		||||
EXPOSE 8080
 | 
			
		||||
ENTRYPOINT ["sh","-c","java -Djava.security.egd=file:/dev/./urandom -jar $JAVA_OPTS app.jar"]
 | 
			
		||||
```
 | 
			
		||||
第二步,在Jenkins中编辑修改配置:
 | 
			
		||||
`名称:skywalkingServiceName   值:ms::sl-express-ms-web-courier`
 | 
			
		||||

 | 
			
		||||
`名称:skywalkingBackendService   值:192.168.150.101:11800`
 | 
			
		||||

 | 
			
		||||
修改运行脚本,增加系统环境变量:
 | 
			
		||||
`-e SW_AGENT_NAME=${skywalkingServiceName} -e SW_AGENT_COLLECTOR_BACKEND_SERVICES=${skywalkingBackendService}`
 | 
			
		||||

 | 
			
		||||
第三步,重新部署服务:
 | 
			
		||||

 | 
			
		||||
第四步,测试接口,查看数据。
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
# 5、练习
 | 
			
		||||
## 5.1、练习1
 | 
			
		||||
难度系数:★★☆☆☆
 | 
			
		||||
描述:修改所有微服务中的`logback-spring.xml`完成Graylog的整合。
 | 
			
		||||
## 5.1、练习2
 | 
			
		||||
难度系数:★★★★☆
 | 
			
		||||
描述:将所有的微服务与skywalking整合。
 | 
			
		||||
							
								
								
									
										231
									
								
								01-讲义/md/day13_14 项目分组实战.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								01-讲义/md/day13_14 项目分组实战.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,231 @@
 | 
			
		||||
# 1、背景说明
 | 
			
		||||
通过前面课程的学习,我们已经掌握了神领物流项目相关业务的开发,也参与了核心调度中心的开发,对于项目有了更深的理解,在此阶段中,我们将基于神领物流项目本身的基础之上扩展新的功能,这些新的功能将由你来完成。加油~
 | 
			
		||||

 | 
			
		||||
# 2、功能需求
 | 
			
		||||
项目分组实战,目标是完成三个需求,实现三个微服务,分别是
 | 
			
		||||
 | 
			
		||||
- 取派件任务搜索微服务	(★★★★☆)
 | 
			
		||||
- 车辆轨迹微服务		(★★★☆☆)
 | 
			
		||||
- 短信微服务			(★★☆☆☆)
 | 
			
		||||
   - 快递员派件时,下发短信通知收件人
 | 
			
		||||
   - 快递员取件后,用户超时1小时未付款,下发短信通知付款
 | 
			
		||||
   - 收件人签收后,下发短信邀请为快递员评价
 | 
			
		||||
   - ……
 | 
			
		||||
## 2.1、取派件任务搜索
 | 
			
		||||
取派件任务搜索是在快递员端的操作,主要包含两个功能,分别是【任务搜索】和【最近搜索】,其中【最近搜索】已经在`sl-express-ms-web-courier`中实现,所以在实战中,只需要实现【任务搜索】即可。
 | 
			
		||||
任务搜索的需求如下:(仔细阅读需求)
 | 
			
		||||

 | 
			
		||||
功能界面:
 | 
			
		||||

 | 
			
		||||
## 2.2、车辆轨迹
 | 
			
		||||
车辆轨迹,首先是在创建运单后会对整个运输路线进行规划(借助高德地图服务),规划完成后轨迹点数据存于MongoDB,用于展现轨迹。(具体需求可参考需求文档)
 | 
			
		||||
车辆在运输中、快递员在派件中会上报位置自己的位置,具体由各自的APP进行上报,用于展现当前车辆所在的位置。
 | 
			
		||||
功能效果图如下:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
# 3、实现分析
 | 
			
		||||
## 3.1、取派件任务搜索
 | 
			
		||||
关于搜索的实现的,可以参考文档:[https://sl-express.itheima.net/#/zh-cn/modules/sl-express-ms-search](https://sl-express.itheima.net/#/zh-cn/modules/sl-express-ms-search)
 | 
			
		||||
### 3.1.1、业务流程
 | 
			
		||||
由上面的业务流程可以看出:
 | 
			
		||||
 | 
			
		||||
- 快递员的取派件任务都是通过work微服务发出消息与Elasticsearch中的数据同步的
 | 
			
		||||
- 快递员端微服务通过搜索微服务的查询,可以查询到符合条件的数据
 | 
			
		||||
### 3.1.2、基础代码
 | 
			
		||||
在git中提供搜索微服务的基础代码,仅供实战参考。(学生可自行设计代码,不要求必须一样,能够实现业务功能即可)
 | 
			
		||||
 | 
			
		||||
| 工程名 | git地址 |
 | 
			
		||||
| --- | --- |
 | 
			
		||||
| sl-express-ms-search-api | [http://git.sl-express.com/sl/sl-express-ms-search-api.git](http://git.sl-express.com/sl/sl-express-ms-search-api.git) |
 | 
			
		||||
| sl-express-ms-search-domain | [http://git.sl-express.com/sl/sl-express-ms-search-domain.git](http://git.sl-express.com/sl/sl-express-ms-search-domain.git) |
 | 
			
		||||
| sl-express-ms-search-service | [http://git.sl-express.com/sl/sl-express-ms-search-service.git](http://git.sl-express.com/sl/sl-express-ms-search-service.git) |
 | 
			
		||||
 | 
			
		||||
### 3.1.3、实现提示
 | 
			
		||||
#### 3.1.3.1、新版Java API学习
 | 
			
		||||
ElasticSearch自7.15版本以后,废弃了 RestHighLevelClient ,官方推荐使用 ElasticsearchClient ,该客户端的使用示例均已做了测试用例。
 | 
			
		||||
如下图中,IndexTest 是索引相关测试用例,DocumentTest 是文档相关测试用例,ComplexSearchTest 是复杂搜索相关测试用例。
 | 
			
		||||
另外,同学们也可参考官方文档进行自主学习:[https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/searching.html](https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/searching.html)
 | 
			
		||||

 | 
			
		||||
#### 3.1.3.2、分词器学习
 | 
			
		||||
搜索微服务中,我们对于运单号、手机号、姓名字段进行搜索。运单号和手机号字段使用Ngram分词器,姓名字段先使用Ngram分词器,再使用拼音分词器。
 | 
			
		||||
以上两种分词器可参考官方文档进行自主学习:
 | 
			
		||||
 | 
			
		||||
- Ngram分词器官方文档:[https://www.elastic.co/guide/en/elasticsearch/reference/6.8/analysis-ngram-tokenizer.html](https://www.elastic.co/guide/en/elasticsearch/reference/6.8/analysis-ngram-tokenizer.html)
 | 
			
		||||
- 拼音分词器官方文档:[https://github.com/medcl/elasticsearch-analysis-pinyin](https://github.com/medcl/elasticsearch-analysis-pinyin)
 | 
			
		||||
#### 3.1.3.3、索引库结构
 | 
			
		||||
在kibana中,创建索引库和映射(仅供参考,可自行调整):
 | 
			
		||||
```shell
 | 
			
		||||
PUT courier_task
 | 
			
		||||
{
 | 
			
		||||
  "settings": {
 | 
			
		||||
    "index.max_ngram_diff":12,// ngram分词器设置最大最小步长间隔
 | 
			
		||||
    "analysis": {
 | 
			
		||||
      "analyzer": {// 自定义分词器
 | 
			
		||||
        "code_analyzer": {// 编码类分词器,适用于手机号和运单号
 | 
			
		||||
          "tokenizer": "code_tokenizer"
 | 
			
		||||
        },
 | 
			
		||||
        "name_analyzer":{// 姓名分词器
 | 
			
		||||
          "tokenizer": "name_tokenizer",
 | 
			
		||||
          "filter": "py" // 分词后再用拼音分词器过滤
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "tokenizer": {
 | 
			
		||||
        "code_tokenizer": {
 | 
			
		||||
          "type": "ngram",
 | 
			
		||||
          "min_gram": 4,
 | 
			
		||||
          "max_gram": 15,
 | 
			
		||||
          "token_chars": [
 | 
			
		||||
            "letter",
 | 
			
		||||
            "digit"
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        "name_tokenizer": {
 | 
			
		||||
          "type": "ngram",
 | 
			
		||||
          "min_gram": 2,
 | 
			
		||||
          "max_gram": 10,
 | 
			
		||||
          "token_chars": [
 | 
			
		||||
            "letter",
 | 
			
		||||
            "digit"
 | 
			
		||||
          ]
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "filter": {// 自定义tokenizer filter
 | 
			
		||||
        "py":{// 过滤器名称
 | 
			
		||||
          "type": "pinyin",// 过滤器类型,这个自定义的过滤器使用的是pinyin分词器
 | 
			
		||||
          "keep_full_pinyin": false,//不要把单个字ch
 | 
			
		||||
          "keep_joined_full_pinyin": true,//把词语转成全拼
 | 
			
		||||
          "keep_original": true,//转完之后的中文保留
 | 
			
		||||
          "limit_first_letter_length": 16,//转成的拼音首字母不能超过16个
 | 
			
		||||
          "remove_duplicated_term": true,//转成的拼音不能有重复的,重复的删掉
 | 
			
		||||
          "none_chinese_pinyin_tokenize": false
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "mappings": {
 | 
			
		||||
    "properties": {
 | 
			
		||||
      "actualEndTime" : {
 | 
			
		||||
        "type" : "date",
 | 
			
		||||
        "format" : "yyyy-MM-dd HH:mm:ss"
 | 
			
		||||
      },
 | 
			
		||||
      "actualStartTime" : {
 | 
			
		||||
        "type" : "date",
 | 
			
		||||
        "format" : "yyyy-MM-dd HH:mm:ss"
 | 
			
		||||
      },
 | 
			
		||||
      "address" : {
 | 
			
		||||
        "type" : "text"
 | 
			
		||||
      },
 | 
			
		||||
      "agencyId" : {
 | 
			
		||||
        "type" : "long"
 | 
			
		||||
      },
 | 
			
		||||
      "courierId" : {
 | 
			
		||||
        "type" : "long"
 | 
			
		||||
      },
 | 
			
		||||
      "created" : {
 | 
			
		||||
        "type" : "date",
 | 
			
		||||
        "format" : "yyyy-MM-dd HH:mm:ss"
 | 
			
		||||
      },
 | 
			
		||||
      "estimatedEndTime" : {
 | 
			
		||||
        "type" : "date",
 | 
			
		||||
        "format" : "yyyy-MM-dd HH:mm:ss"
 | 
			
		||||
      },
 | 
			
		||||
      "estimatedStartTime" : {
 | 
			
		||||
        "type" : "date",
 | 
			
		||||
        "format" : "yyyy-MM-dd HH:mm:ss"
 | 
			
		||||
      },
 | 
			
		||||
      "id" : {
 | 
			
		||||
        "type" : "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "isDeleted" : {
 | 
			
		||||
        "type" : "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "name" : {
 | 
			
		||||
        "type" : "text",
 | 
			
		||||
        "analyzer": "name_analyzer",
 | 
			
		||||
        "search_analyzer": "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "orderId" : {
 | 
			
		||||
        "type" : "long"
 | 
			
		||||
      },
 | 
			
		||||
      "phone" : {
 | 
			
		||||
        "type" : "text",
 | 
			
		||||
        "analyzer": "code_analyzer",
 | 
			
		||||
        "search_analyzer": "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "status" : {
 | 
			
		||||
        "type" : "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "taskType" : {
 | 
			
		||||
        "type" : "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "transportOrderId" : {
 | 
			
		||||
        "type" : "text",
 | 
			
		||||
        "analyzer": "code_analyzer",
 | 
			
		||||
        "search_analyzer": "keyword"
 | 
			
		||||
      },
 | 
			
		||||
      "updated" : {
 | 
			
		||||
        "type" : "date",
 | 
			
		||||
        "format" : "yyyy-MM-dd HH:mm:ss"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
## 3.2、车辆轨迹
 | 
			
		||||
### 3.2.1、业务流程
 | 
			
		||||
### 3.2.2、基础代码
 | 
			
		||||
在git中提供车辆轨迹微服务的基础代码,仅供实战参考。(学生可自行设计代码,不要求必须一样,能够实现业务功能即可)
 | 
			
		||||
 | 
			
		||||
| 工程名 | git地址 |
 | 
			
		||||
| --- | --- |
 | 
			
		||||
| sl-express-ms-track-api | [http://git.sl-express.com/sl/sl-express-ms-track-api.git](http://git.sl-express.com/sl/sl-express-ms-track-api.git) |
 | 
			
		||||
| sl-express-ms-track-domain | [http://git.sl-express.com/sl/sl-express-ms-track-domain.git](http://git.sl-express.com/sl/sl-express-ms-track-domain.git) |
 | 
			
		||||
| sl-express-ms-track-service | [http://git.sl-express.com/sl/sl-express-ms-track-service.git](http://git.sl-express.com/sl/sl-express-ms-track-service.git) |
 | 
			
		||||
 | 
			
		||||
### 3.2.3、实现提示
 | 
			
		||||
实现思路:
 | 
			
		||||
 | 
			
		||||
- 根据收发件人的地址通过高德地图查询路线数据,需要将转运节点作为途经点
 | 
			
		||||
- 解析高德返回的数据存储到MongoDB中,解析有一定难度
 | 
			
		||||
# 3.3、短信微服务
 | 
			
		||||
项目中需要搭建短信微服务,需要发送短信进行通知,在这里主要实现的业务是,运单开始派送时发送短信通知收件人。
 | 
			
		||||
基础代码,仅供实战参考。(学生可自行设计代码,不要求必须一样,能够实现业务功能即可)
 | 
			
		||||
 | 
			
		||||
| 工程名 | git地址 |
 | 
			
		||||
| --- | --- |
 | 
			
		||||
| sl-express-ms-sms-domain | [http://git.sl-express.com/sl/sl-express-ms-sms-domain.git](http://git.sl-express.com/sl/sl-express-ms-sms-domain.git) |
 | 
			
		||||
| sl-express-ms-sms-api | [http://git.sl-express.com/sl/sl-express-ms-sms-api.git](http://git.sl-express.com/sl/sl-express-ms-sms-api.git) |
 | 
			
		||||
| sl-express-ms-sms-service | [http://git.sl-express.com/sl/sl-express-ms-sms-service.git](http://git.sl-express.com/sl/sl-express-ms-sms-service.git) |
 | 
			
		||||
 | 
			
		||||
> 关于短信发送渠道,自行选择,不做强制要求。建议选择阿里云平台。
 | 
			
		||||
 | 
			
		||||
# 4、项目分组
 | 
			
		||||
## 4.1、时间安排
 | 
			
		||||
此次项目实战安排2天课时(可以加一天自习调整为3天),具体的时间安排如下:
 | 
			
		||||
 | 
			
		||||
- 第一天上午,由讲师带领学生了解项目实战中的内容
 | 
			
		||||
- 第一天的下午,第二天,第三天上午,这些时间段是学生实战开发的时间
 | 
			
		||||
- 第三天下午,成果演示
 | 
			
		||||
## 4.2、分组安排
 | 
			
		||||
 | 
			
		||||
- 将一个班的学生分成若干小组,每个小组成员5~6人,最多不超过8人。
 | 
			
		||||
- 每个小组选取一名组长,组长负责组员的任务分工。
 | 
			
		||||
- 每个组员都要参与开发,不得以任何接口拒绝组长安排的任务。
 | 
			
		||||
- 每个小组都需要完成实战中的三个功能(搜索、车辆轨迹、短信)的开发。
 | 
			
		||||
## 4.3、代码管理
 | 
			
		||||
在项目开发的过程中,我们都是基于虚拟机中的git提交代码的,在分组实战中,需要将代码共享,同组人员公共修改代码,此时虚拟机中的git就无法满足需求了,在这里,可以借助[码云](https://gitee.com/)来完成共享。基本的架构如下:
 | 
			
		||||

 | 
			
		||||
说明:
 | 
			
		||||
 | 
			
		||||
- 组长在码云中创建相应的项目,并且邀请组员成为开发者
 | 
			
		||||
- 组长将本地虚拟机环境中的git代码提交到码云(只需要提交实战中涉及到工程即可)
 | 
			
		||||
- 组员代码拉取到本地,即可进行项目开发,在开发完成后将代码提交到码云
 | 
			
		||||
- 待所有的功能开发完成后,组长将代码同步到本地虚拟机中的git中,最终基于组长的环境进行功能演示
 | 
			
		||||
- 同样,组员也将最终的代码同步到自己的本地环境中,提交到虚拟机中的git服务中
 | 
			
		||||
- 最终,所有人的本地虚拟机中的代码都应该是一致的
 | 
			
		||||
# 5、成果演示
 | 
			
		||||
 | 
			
		||||
- 每个组都需要准备一个演示ppt,在成果演示阶段使用,ppt风格不限
 | 
			
		||||
- 演示时,不仅是通过接口演示功能,还要通过四端进行演示,能够将完整的物流流程走通
 | 
			
		||||
- 如果有额外实现一些相关的功能,会有加分
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day01-项目概述.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day01-项目概述.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day02-网关与支付.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day02-网关与支付.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day03-支付微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day03-支付微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day04-运费微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day04-运费微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day05-路线规划之Neo4j入门.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day05-路线规划之Neo4j入门.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day06-路线规划之微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day06-路线规划之微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day07-智能调度之调度任务.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day07-智能调度之调度任务.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day08-智能调度之运输任务.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day08-智能调度之运输任务.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day09-智能调度之作业范围.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day09-智能调度之作业范围.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day10-智能调度之取派件调度.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day10-智能调度之取派件调度.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day11-物流信息微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day11-物流信息微服务.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day12-分布式日志与链路追踪.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day12-分布式日志与链路追踪.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								01-讲义/pdf/day13_14 项目分组实战.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								01-讲义/pdf/day13_14 项目分组实战.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										178
									
								
								01-讲义/其他文档/Jenkins使用手册.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								01-讲义/其他文档/Jenkins使用手册.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
			
		||||
# 1、Jenkins介绍
 | 
			
		||||

 | 
			
		||||
Jenkins 是一款流行的开源持续集成(Continuous Integration)工具,广泛用于项目开发,具有自动化构建、测试和部署等功能。官网: [http://jenkins-ci.org/](http://jenkins-ci.org/)。
 | 
			
		||||
Jenkins的特征:
 | 
			
		||||
 | 
			
		||||
- 开源的 Java语言开发持续集成工具,支持持续集成,持续部署。
 | 
			
		||||
- 易于安装部署配置:可通过 yum安装,或下载war包以及通过docker容器等快速实现安装部署,可方便web界面配置管理。
 | 
			
		||||
- 消息通知及测试报告:集成 RSS/E-mail通过RSS发布构建结果或当构建完成时通过e-mail通知,生成JUnit/TestNG测试报告。
 | 
			
		||||
- 分布式构建:支持 Jenkins能够让多台计算机一起构建/测试。
 | 
			
		||||
- 文件识别: Jenkins能够跟踪哪次构建生成哪些jar,哪次构建使用哪个版本的jar等。
 | 
			
		||||
- 丰富的插件支持:支持扩展插件,你可以开发适合自己团队使用的工具,如 git,svn,maven,docker等。
 | 
			
		||||
 | 
			
		||||
Jenkins安装和持续集成环境配置
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
- 首先,开发人员每天进行代码提交,提交到Git仓库
 | 
			
		||||
- 然后,Jenkins作为持续集成工具,使用Git工具到Git仓库拉取代码到集成服务器,再配合JDK,Maven等软件完成代码编译,代码测试与审查,测试,打包等工作,在这个过程中每一步出错,都重新再执行一次整个流程。
 | 
			
		||||
- 最后,Jenkins把生成的jar或war包分发到测试服务器或者生产服务器,测试人员或用户就可以访问应用。
 | 
			
		||||
# 2、部署安装
 | 
			
		||||
在神领物流项目中采用Docker方式部署Jenkins,部署脚本如下:
 | 
			
		||||
```shell
 | 
			
		||||
docker run -d \
 | 
			
		||||
-p 8090:8080 \
 | 
			
		||||
-p 50000:50000 \
 | 
			
		||||
-v /usr/local/src/jenkins:/var/jenkins_home \
 | 
			
		||||
-v  /maven:/maven \
 | 
			
		||||
-v /etc/localtime:/etc/localtime \
 | 
			
		||||
-v /usr/bin/docker:/usr/bin/docker \
 | 
			
		||||
-v /var/run/docker.sock:/var/run/docker.sock \
 | 
			
		||||
--privileged \
 | 
			
		||||
--name jenkins \
 | 
			
		||||
-e TZ=Asia/Shanghai \
 | 
			
		||||
--restart=always \
 | 
			
		||||
--add-host=git.sl-express.com:192.168.150.101 \
 | 
			
		||||
--add-host=maven.sl-express.com:192.168.150.101 \
 | 
			
		||||
jenkins/jenkins:lts-jdk11
 | 
			
		||||
```
 | 
			
		||||
在部署脚本中指定了时区、hosts并且将宿主机的docker服务映射到容器内部。
 | 
			
		||||
访问地址:[http://jenkins.sl-express.com/](http://jenkins.sl-express.com/)  用户名密码为:root/123
 | 
			
		||||
# 3、系统配置
 | 
			
		||||
Jenkins安装完成后,需要进行一些配置才能正常使用。
 | 
			
		||||
## 3.1、配置Maven
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
在【系统管理】中的【全局工具配置】中进行配置。
 | 
			
		||||
指定Maven配置文件:
 | 
			
		||||

 | 
			
		||||
配置文件内容如下:
 | 
			
		||||
```xml
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<settings
 | 
			
		||||
    xmlns="http://maven.apache.org/SETTINGS/1.0.0"
 | 
			
		||||
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 | 
			
		||||
          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
 | 
			
		||||
    
 | 
			
		||||
   <localRepository>/maven/repository</localRepository>
 | 
			
		||||
 | 
			
		||||
    <pluginGroups></pluginGroups>
 | 
			
		||||
    <proxies></proxies>
 | 
			
		||||
 | 
			
		||||
    <servers>
 | 
			
		||||
        <server>
 | 
			
		||||
            <id>sl-releases</id>
 | 
			
		||||
            <username>deployment</username>
 | 
			
		||||
            <password>deployment123</password>
 | 
			
		||||
        </server>
 | 
			
		||||
        <server>
 | 
			
		||||
            <id>sl-snapshots</id>
 | 
			
		||||
            <username>deployment</username>
 | 
			
		||||
            <password>deployment123</password>
 | 
			
		||||
        </server>
 | 
			
		||||
    </servers>
 | 
			
		||||
    
 | 
			
		||||
	<mirrors>
 | 
			
		||||
        <mirror>
 | 
			
		||||
            <id>mirror</id>
 | 
			
		||||
            <mirrorOf>central,jcenter,!sl-releases,!sl-snapshots</mirrorOf>
 | 
			
		||||
            <name>mirror</name>
 | 
			
		||||
            <url>https://maven.aliyun.com/nexus/content/groups/public</url>
 | 
			
		||||
        </mirror>
 | 
			
		||||
    </mirrors>
 | 
			
		||||
    
 | 
			
		||||
	<profiles>
 | 
			
		||||
        <profile>
 | 
			
		||||
            <id>sl</id>
 | 
			
		||||
            <properties>
 | 
			
		||||
                <altReleaseDeploymentRepository>
 | 
			
		||||
					sl-releases::default::http://maven.sl-express.com/nexus/content/repositories/releases/
 | 
			
		||||
				</altReleaseDeploymentRepository>
 | 
			
		||||
                <altSnapshotDeploymentRepository>
 | 
			
		||||
					sl-snapshots::default::http://maven.sl-express.com/nexus/content/repositories/snapshots/
 | 
			
		||||
				</altSnapshotDeploymentRepository>
 | 
			
		||||
            </properties>
 | 
			
		||||
        </profile>
 | 
			
		||||
    </profiles>
 | 
			
		||||
    
 | 
			
		||||
	<activeProfiles>
 | 
			
		||||
        <activeProfile>sl</activeProfile>
 | 
			
		||||
    </activeProfiles>
 | 
			
		||||
 | 
			
		||||
</settings>
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
## 3.2、配置Git
 | 
			
		||||

 | 
			
		||||
## 3.3、安装Gogs插件
 | 
			
		||||
我们使用的Git管理工具是Gogs,需要在用户提交代码之后触发自动构建,需要安装Gogs插件。
 | 
			
		||||

 | 
			
		||||
搜索Gogs安装即可。
 | 
			
		||||

 | 
			
		||||
在Gogs中的仓库设置钩子,例如:
 | 
			
		||||

 | 
			
		||||
格式:`http://jenkins.sl-express.com/gogs-webhook/?job=xxxx`
 | 
			
		||||
# 4、构建任务
 | 
			
		||||
在提供的虚拟机环境中虽然已经创建好了构建任务,如果不满足需求,可以执行创建任务,可以通过复制的方式完成。
 | 
			
		||||
首先点击【新建任务】:
 | 
			
		||||

 | 
			
		||||
输入任务的名称,建议名称就是微服务的名字。
 | 
			
		||||

 | 
			
		||||
选择已有的构建任务:
 | 
			
		||||

 | 
			
		||||
输入任务的名称:
 | 
			
		||||

 | 
			
		||||
设置Gogs钩子:
 | 
			
		||||

 | 
			
		||||
设置构建参数,主要用于构建时的脚本使用:
 | 
			
		||||

 | 
			
		||||
设置git地址,每个项目都不一样,一定要修改!
 | 
			
		||||

 | 
			
		||||
设置构建开始前将workspace删除,确保没有之前编译产物的干扰:
 | 
			
		||||

 | 
			
		||||
设置ssh执行命令,主要是宿主机安装的docker服务具备权限,可以在容器内执行:
 | 
			
		||||

 | 
			
		||||
设置maven打包命令:
 | 
			
		||||

 | 
			
		||||
设置部署的ssh脚本,主要是完成微服务打包成docker镜像进行部署:
 | 
			
		||||

 | 
			
		||||
执行命令如下:
 | 
			
		||||
```shell
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
# 微服务名称
 | 
			
		||||
SERVER_NAME=${serverName}
 | 
			
		||||
 | 
			
		||||
# 服务版本
 | 
			
		||||
SERVER_VERSION=${version}
 | 
			
		||||
 | 
			
		||||
# 服务版本
 | 
			
		||||
SERVER_PORT=${port}
 | 
			
		||||
 | 
			
		||||
# 源jar名称,mvn打包之后,target目录下的jar包名称
 | 
			
		||||
JAR_NAME=$SERVER_NAME-$SERVER_VERSION
 | 
			
		||||
 | 
			
		||||
# jenkins下的目录
 | 
			
		||||
JENKINS_HOME=/var/jenkins_home/workspace/$SERVER_NAME
 | 
			
		||||
 | 
			
		||||
cd $JENKINS_HOME
 | 
			
		||||
 | 
			
		||||
# 修改文件权限
 | 
			
		||||
chmod 755 target/$JAR_NAME.jar
 | 
			
		||||
 | 
			
		||||
docker -v
 | 
			
		||||
 | 
			
		||||
echo "---------停止容器($SERVER_NAME)---------"
 | 
			
		||||
docker stop $SERVER_NAME
 | 
			
		||||
 | 
			
		||||
echo "---------删除容器($SERVER_NAME)---------"
 | 
			
		||||
docker rm $SERVER_NAME
 | 
			
		||||
 | 
			
		||||
echo "---------删除镜像($SERVER_NAME:$SERVER_VERSION)---------"
 | 
			
		||||
docker rmi $SERVER_NAME:$SERVER_VERSION
 | 
			
		||||
 | 
			
		||||
echo "---------打包镜像($SERVER_NAME:$SERVER_VERSION)---------"
 | 
			
		||||
docker build -t $SERVER_NAME:$SERVER_VERSION .
 | 
			
		||||
 | 
			
		||||
echo "---------运行服务---------"
 | 
			
		||||
docker run -d -p $SERVER_PORT:8080 --name $SERVER_NAME -e SERVER_PORT=8080 -e SPRING_CLOUD_NACOS_DISCOVERY_IP=${SPRING_CLOUD_NACOS_DISCOVERY_IP} -e  SPRING_CLOUD_NACOS_DISCOVERY_PORT=${port} -e SPRING_PROFILES_ACTIVE=stu $SERVER_NAME:$SERVER_VERSION
 | 
			
		||||
```
 | 
			
		||||
最后,保存即可。
 | 
			
		||||
							
								
								
									
										389
									
								
								01-讲义/其他文档/hutool使用手册.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										389
									
								
								01-讲义/其他文档/hutool使用手册.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,389 @@
 | 
			
		||||
测试代码在`sl-express-ms-base-service`的测试用例中,[点击查看](http://git.sl-express.com/sl/sl-express-ms-base-service/src/master/src/test/java/com/sl/transport/common/util)。
 | 
			
		||||
# 1、[树结构工具-TreeUtil](https://hutool.cn/docs/#/core/%E8%AF%AD%E8%A8%80%E7%89%B9%E6%80%A7/%E6%A0%91%E7%BB%93%E6%9E%84/%E6%A0%91%E7%BB%93%E6%9E%84%E5%B7%A5%E5%85%B7-TreeUtil?id=%e6%a0%91%e7%bb%93%e6%9e%84%e5%b7%a5%e5%85%b7-treeutil)
 | 
			
		||||
### 构建Tree示例
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.common.util;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.core.bean.BeanUtil;
 | 
			
		||||
import cn.hutool.core.collection.CollUtil;
 | 
			
		||||
import cn.hutool.core.lang.tree.Tree;
 | 
			
		||||
import cn.hutool.core.lang.tree.TreeNode;
 | 
			
		||||
import cn.hutool.core.lang.tree.TreeNodeConfig;
 | 
			
		||||
import cn.hutool.core.lang.tree.TreeUtil;
 | 
			
		||||
import cn.hutool.json.JSONUtil;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.junit.jupiter.api.Test;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
@Slf4j
 | 
			
		||||
class TreeUtilTest {
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    void treeTest() {
 | 
			
		||||
        // 构建node列表
 | 
			
		||||
        List<TreeNode<String>> nodeList = CollUtil.newArrayList();
 | 
			
		||||
 | 
			
		||||
        nodeList.add(new TreeNode<>("1", "0", "系统管理", 5));
 | 
			
		||||
        nodeList.add(new TreeNode<>("11", "1", "用户管理", 222222));
 | 
			
		||||
        nodeList.add(new TreeNode<>("111", "11", "用户添加", 0));
 | 
			
		||||
        nodeList.add(new TreeNode<>("2", "0", "店铺管理", 1));
 | 
			
		||||
        nodeList.add(new TreeNode<>("21", "2", "商品管理", 44));
 | 
			
		||||
        nodeList.add(new TreeNode<>("221", "2", "添加商品", 2));
 | 
			
		||||
 | 
			
		||||
        //配置
 | 
			
		||||
        TreeNodeConfig treeNodeConfig = new TreeNodeConfig();
 | 
			
		||||
        // 自定义属性名 都要默认值的
 | 
			
		||||
        treeNodeConfig.setWeightKey("weight");
 | 
			
		||||
        treeNodeConfig.setIdKey("id");
 | 
			
		||||
        // 最大递归深度
 | 
			
		||||
        treeNodeConfig.setDeep(3);
 | 
			
		||||
        //构造树结构
 | 
			
		||||
        List<Tree<String>> treeNodes = TreeUtil.build(nodeList, "0",
 | 
			
		||||
                (treeNode, tree) -> {
 | 
			
		||||
                    tree.setId(treeNode.getId());
 | 
			
		||||
                    tree.setParentId(treeNode.getParentId());
 | 
			
		||||
                    tree.putAll(BeanUtil.beanToMap(treeNode));
 | 
			
		||||
                    tree.remove("bid");
 | 
			
		||||
                });
 | 
			
		||||
        
 | 
			
		||||
        log.info("treeNodes {}", JSONUtil.toJsonStr(treeNodes));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```json
 | 
			
		||||
[{
 | 
			
		||||
	"id": "2",
 | 
			
		||||
	"parentId": "0",
 | 
			
		||||
	"name": "店铺管理",
 | 
			
		||||
	"weight": 1,
 | 
			
		||||
	"children": [{
 | 
			
		||||
		"id": "221",
 | 
			
		||||
		"parentId": "2",
 | 
			
		||||
		"name": "添加商品",
 | 
			
		||||
		"weight": 2
 | 
			
		||||
	}, {
 | 
			
		||||
		"id": "21",
 | 
			
		||||
		"parentId": "2",
 | 
			
		||||
		"name": "商品管理",
 | 
			
		||||
		"weight": 44
 | 
			
		||||
	}]
 | 
			
		||||
}, {
 | 
			
		||||
	"id": "1",
 | 
			
		||||
	"parentId": "0",
 | 
			
		||||
	"name": "系统管理",
 | 
			
		||||
	"weight": 5,
 | 
			
		||||
	"children": [{
 | 
			
		||||
		"id": "11",
 | 
			
		||||
		"parentId": "1",
 | 
			
		||||
		"name": "用户管理",
 | 
			
		||||
		"weight": 222222,
 | 
			
		||||
		"children": [{
 | 
			
		||||
			"id": "111",
 | 
			
		||||
			"parentId": "11",
 | 
			
		||||
			"name": "用户添加",
 | 
			
		||||
			"weight": 0
 | 
			
		||||
		}]
 | 
			
		||||
	}]
 | 
			
		||||
}]
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
# 2、[Bean工具-BeanUtil](https://hutool.cn/docs/#/core/JavaBean/Bean%E5%B7%A5%E5%85%B7-BeanUtil?id=bean%e5%b7%a5%e5%85%b7-beanutil)
 | 
			
		||||
### 对象转Bean
 | 
			
		||||
```java
 | 
			
		||||
@Slf4j
 | 
			
		||||
public class BeanUtilTest {
 | 
			
		||||
    
 | 
			
		||||
    @Getter
 | 
			
		||||
    @Setter
 | 
			
		||||
    public static class SubPerson extends Person {
 | 
			
		||||
    
 | 
			
		||||
        public static final String SUBNAME = "TEST";
 | 
			
		||||
        
 | 
			
		||||
        private UUID id;
 | 
			
		||||
        private String subName;
 | 
			
		||||
        private Boolean slow;
 | 
			
		||||
        private LocalDateTime date;
 | 
			
		||||
        private LocalDate date2;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    @Getter
 | 
			
		||||
    @Setter
 | 
			
		||||
    public static class Person {
 | 
			
		||||
        private String name;
 | 
			
		||||
        private int age;
 | 
			
		||||
        private String openid;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    /**
 | 
			
		||||
    * 对象转Bean
 | 
			
		||||
    */
 | 
			
		||||
    @Test
 | 
			
		||||
    public void toBeanTest() {
 | 
			
		||||
        SubPerson person = new SubPerson();
 | 
			
		||||
        person.setAge(14);
 | 
			
		||||
        person.setOpenid("11213232");
 | 
			
		||||
        person.setName("测试A11");
 | 
			
		||||
        person.setSubName("sub名字");
 | 
			
		||||
        
 | 
			
		||||
        Map<?, ?> map = BeanUtil.toBean(person, Map.class);
 | 
			
		||||
        Assert.assertEquals("测试A11", map.get("name"));
 | 
			
		||||
        Assert.assertEquals(14, map.get("age"));
 | 
			
		||||
        Assert.assertEquals("11213232", map.get("openid"));
 | 
			
		||||
        // static属性应被忽略
 | 
			
		||||
        log.info("map是否包含名为SUBNAME的key {}", map.containsKey("SUBNAME"));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
map是否包含名为SUBNAME的key false
 | 
			
		||||
```
 | 
			
		||||
# 3、[验证码工具-CaptchaUtil](https://hutool.cn/docs/#/captcha/%E6%A6%82%E8%BF%B0)
 | 
			
		||||
### 生成验证码
 | 
			
		||||
```java
 | 
			
		||||
package com.sl.transport.common.util;
 | 
			
		||||
 | 
			
		||||
import cn.hutool.captcha.CaptchaUtil;
 | 
			
		||||
import cn.hutool.captcha.LineCaptcha;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.junit.Assert;
 | 
			
		||||
import org.junit.Test;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
* 直线干扰验证码单元测试
 | 
			
		||||
*
 | 
			
		||||
* @author looly
 | 
			
		||||
*/
 | 
			
		||||
@Slf4j
 | 
			
		||||
public class CaptchaTest {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
    * 生成验证码
 | 
			
		||||
    */
 | 
			
		||||
    @Test
 | 
			
		||||
    public void lineCaptchaTest1() {
 | 
			
		||||
        // 定义图形验证码的长和宽
 | 
			
		||||
        LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(200, 100);
 | 
			
		||||
        Assert.assertNotNull(lineCaptcha.getCode());
 | 
			
		||||
        log.info("直线干扰验证码: {}", lineCaptcha.getCode());
 | 
			
		||||
        log.info("直线干扰验证码验证结果: {}",lineCaptcha.verify(lineCaptcha.getCode()));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
直线干扰验证码: 5ku0o
 | 
			
		||||
直线干扰验证码验证结果: true
 | 
			
		||||
```
 | 
			
		||||
# 4、[类型转换工具类-Convert](https://hutool.cn/docs/#/core/%E7%B1%BB%E5%9E%8B%E8%BD%AC%E6%8D%A2/%E7%B1%BB%E5%9E%8B%E8%BD%AC%E6%8D%A2%E5%B7%A5%E5%85%B7%E7%B1%BB-Convert?id=%e7%b1%bb%e5%9e%8b%e8%bd%ac%e6%8d%a2%e5%b7%a5%e5%85%b7%e7%b1%bb-convert)
 | 
			
		||||
### 转换值为指定类型
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* 转换值为指定类型
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void toObjectTest() {
 | 
			
		||||
    final Object result = Convert.convert(Object.class, "aaaa");
 | 
			
		||||
    log.info(result + "");
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
aaaa
 | 
			
		||||
```
 | 
			
		||||
# 5、[对象工具-ObjectUtil](https://hutool.cn/docs/#/core/%E5%B7%A5%E5%85%B7%E7%B1%BB/%E5%AF%B9%E8%B1%A1%E5%B7%A5%E5%85%B7-ObjectUtil?id=%e5%af%b9%e8%b1%a1%e5%b7%a5%e5%85%b7-objectutil)
 | 
			
		||||
### 对象相等
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* 比较两个对象是否相等
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void equalsTest() {
 | 
			
		||||
    Object a = null;
 | 
			
		||||
    Object b = null;
 | 
			
		||||
    log.info("是否相等:{}" , ObjectUtil.equals(a, b));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
是否相等:true
 | 
			
		||||
```
 | 
			
		||||
# 6、[数字工具-NumberUtil](https://hutool.cn/docs/#/core/%E5%B7%A5%E5%85%B7%E7%B1%BB/%E6%95%B0%E5%AD%97%E5%B7%A5%E5%85%B7-NumberUtil?id=%e6%95%b0%e5%ad%97%e5%b7%a5%e5%85%b7-numberutil)
 | 
			
		||||
### 加法运算
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* 提供精确的加法运算
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void addTest() {
 | 
			
		||||
    final Float a = 3.15f;
 | 
			
		||||
    final Double b = 4.22;
 | 
			
		||||
    final double result = NumberUtil.add(a, b).doubleValue();
 | 
			
		||||
    log.info(result + "");
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
7.37
 | 
			
		||||
```
 | 
			
		||||
# 7、[日期时间工具-DateUtil](https://hutool.cn/docs/#/core/%E6%97%A5%E6%9C%9F%E6%97%B6%E9%97%B4/%E6%97%A5%E6%9C%9F%E6%97%B6%E9%97%B4%E5%B7%A5%E5%85%B7-DateUtil?id=%e6%97%a5%e6%9c%9f%e6%97%b6%e9%97%b4%e5%b7%a5%e5%85%b7-dateutil)
 | 
			
		||||
### 当前时间
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* 当前时间
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void nowTest() {
 | 
			
		||||
    // 当前时间
 | 
			
		||||
    final Date date = DateUtil.date();
 | 
			
		||||
    Assert.assertNotNull(date);
 | 
			
		||||
    // 当前时间
 | 
			
		||||
    final Date date2 = DateUtil.date(Calendar.getInstance());
 | 
			
		||||
    Assert.assertNotNull(date2);
 | 
			
		||||
    // 当前时间
 | 
			
		||||
    final Date date3 = DateUtil.date(System.currentTimeMillis());
 | 
			
		||||
    Assert.assertNotNull(date3);
 | 
			
		||||
 | 
			
		||||
    // 当前日期字符串,格式:yyyy-MM-dd HH:mm:ss
 | 
			
		||||
    final String now = DateUtil.now();
 | 
			
		||||
    Assert.assertNotNull(now);
 | 
			
		||||
    // 当前日期字符串,格式:yyyy-MM-dd
 | 
			
		||||
    final String today = DateUtil.today();
 | 
			
		||||
    log.info(today);
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
2023-03-10
 | 
			
		||||
```
 | 
			
		||||
# 8、[集合工具-CollUtil](https://hutool.cn/docs/#/core/%E9%9B%86%E5%90%88%E7%B1%BB/%E9%9B%86%E5%90%88%E5%B7%A5%E5%85%B7-CollUtil?id=%e9%9b%86%e5%90%88%e5%b7%a5%e5%85%b7-collutil)
 | 
			
		||||
### 自定义函数判断集合是否包含某类值
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* 自定义函数判断集合是否包含某类值
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void testPredicateContains() {
 | 
			
		||||
    final ArrayList<String> list = CollUtil.newArrayList("bbbbb", "aaaaa", "ccccc");
 | 
			
		||||
    log.info( "" + CollUtil.contains(list, s -> s.startsWith("a")));
 | 
			
		||||
    log.info( "" + CollUtil.contains(list, s -> s.startsWith("d")));
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
true
 | 
			
		||||
false
 | 
			
		||||
```
 | 
			
		||||
# 9、[集合串行流工具-CollStreamUtil](https://hutool.cn/docs/#/core/%E9%9B%86%E5%90%88%E7%B1%BB/%E9%9B%86%E5%90%88%E4%B8%B2%E8%A1%8C%E6%B5%81%E5%B7%A5%E5%85%B7-CollStreamUtil?id=%e9%9b%86%e5%90%88%e4%b8%b2%e8%a1%8c%e6%b5%81%e5%b7%a5%e5%85%b7-collstreamutil)
 | 
			
		||||
### 将Collection转化为map
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* 将Collection转化为map(value类型与collection的泛型不同)
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void testToMap() {
 | 
			
		||||
    Map<Long, String> map = CollStreamUtil.toMap(null, Student::getStudentId, Student::getName);
 | 
			
		||||
    Assert.assertEquals(map, Collections.EMPTY_MAP);
 | 
			
		||||
    List<Student> list = new ArrayList<>();
 | 
			
		||||
    map = CollStreamUtil.toMap(list, Student::getStudentId, Student::getName);
 | 
			
		||||
    Assert.assertEquals(map, Collections.EMPTY_MAP);
 | 
			
		||||
    list.add(new Student(1, 1, 1, "张三"));
 | 
			
		||||
    list.add(new Student(1, 1, 2, "李四"));
 | 
			
		||||
    list.add(new Student(1, 1, 3, "王五"));
 | 
			
		||||
    map = CollStreamUtil.toMap(list, Student::getStudentId, Student::getName);
 | 
			
		||||
    Assert.assertEquals(map.get(1L), "张三");
 | 
			
		||||
    Assert.assertEquals(map.get(2L), "李四");
 | 
			
		||||
    Assert.assertEquals(map.get(3L), "王五");
 | 
			
		||||
    Assert.assertNull(map.get(4L));
 | 
			
		||||
 | 
			
		||||
    // 测试value为空时
 | 
			
		||||
    list.add(new Student(1, 1, 4, null));
 | 
			
		||||
    map = CollStreamUtil.toMap(list, Student::getStudentId, Student::getName);
 | 
			
		||||
    log.info(map.get(4L));
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
null
 | 
			
		||||
```
 | 
			
		||||
# 10、[JSON工具-JSONUtil](https://hutool.cn/docs/#/json/JSONUtil?id=json%e5%b7%a5%e5%85%b7-jsonutil)
 | 
			
		||||
###  JSON字符串转JSONObject对象
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
* JSON字符串转JSONObject对象
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void toJsonStrTest2() {
 | 
			
		||||
    final Map<String, Object> model = new HashMap<>();
 | 
			
		||||
    model.put("mobile", "17610836523");
 | 
			
		||||
    model.put("type", 1);
 | 
			
		||||
 | 
			
		||||
    final Map<String, Object> data = new HashMap<>();
 | 
			
		||||
    data.put("model", model);
 | 
			
		||||
    data.put("model2", model);
 | 
			
		||||
 | 
			
		||||
    final JSONObject jsonObject = JSONUtil.parseObj(data);
 | 
			
		||||
 | 
			
		||||
    log.info("是否相等{}", ObjectUtil.equals( "17610836523", jsonObject.getJSONObject("model").getStr("mobile")));
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
是否相等true
 | 
			
		||||
```
 | 
			
		||||
# 11、[唯一ID工具-IdUtil](https://hutool.cn/docs/#/core/%E5%B7%A5%E5%85%B7%E7%B1%BB/%E5%94%AF%E4%B8%80ID%E5%B7%A5%E5%85%B7-IdUtil?id=%e5%94%af%e4%b8%80id%e5%b7%a5%e5%85%b7-idutil)
 | 
			
		||||
### 获取随机UUID
 | 
			
		||||
```java
 | 
			
		||||
/**
 | 
			
		||||
*获取随机UUID
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void randomUUIDTest() {
 | 
			
		||||
    String randomUUID = IdUtil.randomUUID();
 | 
			
		||||
    log.info(randomUUID);
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
e514518b-21d7-4918-9b34-792b21b0b64f
 | 
			
		||||
```
 | 
			
		||||
# 12、[枚举工具-EnumUtil](https://hutool.cn/docs/#/core/%E5%B7%A5%E5%85%B7%E7%B1%BB/%E6%9E%9A%E4%B8%BE%E5%B7%A5%E5%85%B7-EnumUtil?id=%e6%9e%9a%e4%b8%be%e5%b7%a5%e5%85%b7-enumutil)
 | 
			
		||||
### 枚举类中所有枚举对象的name列表
 | 
			
		||||
```java
 | 
			
		||||
public enum TestEnum{
 | 
			
		||||
    TEST1("type1"), TEST2("type2"), TEST3("type3");
 | 
			
		||||
 | 
			
		||||
    TestEnum(String type) {
 | 
			
		||||
        this.type = type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private final String type;
 | 
			
		||||
    @SuppressWarnings("unused")
 | 
			
		||||
    private String name;
 | 
			
		||||
 | 
			
		||||
    public String getType() {
 | 
			
		||||
        return this.type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return this.name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
* 枚举类中所有枚举对象的name列表
 | 
			
		||||
*/
 | 
			
		||||
@Test
 | 
			
		||||
public void getNamesTest() {
 | 
			
		||||
    List<String> names = EnumUtil.getNames(TestEnum.class);
 | 
			
		||||
    boolean equalList = CollUtil.isEqualList(CollUtil.newArrayList("TEST1", "TEST2", "TEST3"), names);
 | 
			
		||||
    log.info(equalList + "");
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
### 输出结果
 | 
			
		||||
```
 | 
			
		||||
true
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										108
									
								
								01-讲义/其他文档/前端部署文档.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								01-讲义/其他文档/前端部署文档.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
# 1、说明
 | 
			
		||||
前端包括4个端,分别是:
 | 
			
		||||
 | 
			
		||||
- 用户端(微信小程序)
 | 
			
		||||
- 快递员端(安卓app)
 | 
			
		||||
- 司机端(安卓app)
 | 
			
		||||
- 后台管理端(pc web)
 | 
			
		||||
# 2、用户端
 | 
			
		||||
## 2.1、开发者工具
 | 
			
		||||
用户端是基于微信小程序开发的,首先需要下载并安装微信开发者工具:
 | 
			
		||||

 | 
			
		||||
可以使用课程资料中提供的安装包或在线下载,[点击下载](https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html)
 | 
			
		||||
## 2.2、申请测试账号
 | 
			
		||||
接下来,申请微信小程序的测试账号,[点击申请](https://mp.weixin.qq.com/wxamp/sandbox),通过手机微信扫码进行操作。
 | 
			
		||||

 | 
			
		||||
申请成功后,进行登录,[点击登录](https://mp.weixin.qq.com/),如下:
 | 
			
		||||

 | 
			
		||||
通过手机微信进行扫码登录:
 | 
			
		||||

 | 
			
		||||
即可看到测试账号信息:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
需要将AppID和AppSecret保存到nacos配置中心的 `sl-express-ms-web-customer.properties`中:
 | 
			
		||||

 | 
			
		||||
## 2.3、导入代码
 | 
			
		||||
从git拉取代码,地址:[http://git.sl-express.com/sl/project-wl-yonghuduan-uniapp-vue3](http://git.sl-express.com/sl/project-wl-yonghuduan-uniapp-vue3)
 | 
			
		||||

 | 
			
		||||
打开微信开发者工具(需要通过手机上的微信客户端进行扫码登录,不要使用游客身份登录),导入代码,注意导入的目录为:`project-wl-yonghuduan-uniapp-vue3\unpackage\dist\dev\mp-weixin`,使用测试账号:
 | 
			
		||||

 | 
			
		||||
导入完成后,需要修改`env.js`配置文件,将`baseUrl`变量设置为:`http://api.sl-express.com/customer`,此链接为与后端服务交互的地址,入口为网关地址:(修改完成后需要点击【编译】按钮进行重新编译)
 | 
			
		||||

 | 
			
		||||
如果需要完成登录,需要确保如下服务保持启动状态:
 | 
			
		||||

 | 
			
		||||
测试登录:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
登录成功:
 | 
			
		||||

 | 
			
		||||
# 3、司机、快递员端
 | 
			
		||||
司机和快递员端都是安卓app的,可以安装在手机或通过模拟器进行使用,这里介绍模拟器的方式进行使用。(如果使用手机的话,需要通过内网穿透的方式访问网关)
 | 
			
		||||
## 3.1、模拟器
 | 
			
		||||
### 3.1.1、联想模拟器
 | 
			
		||||
在Windows平台推荐使用【联想模拟器】,安装包在`资料\软件包\模拟器`中找到。(如果联想模拟器不能正常使用也可以使用其他的模拟器)
 | 
			
		||||
安装完成后,设置分辨率为【手机 720 * 1280】:
 | 
			
		||||

 | 
			
		||||
效果如下:(安装apk直接拖入即可)
 | 
			
		||||

 | 
			
		||||
模拟器中的共享目录:
 | 
			
		||||

 | 
			
		||||
在模拟器的定位功能中可以设定位置信息,主要用于app中获取定位,在项目用于车辆位置上报等场景:
 | 
			
		||||

 | 
			
		||||
### 3.1.2、官方模拟器
 | 
			
		||||
如果使用的是苹果Mac电脑并且是M1、M2芯片的同学,可以安装官方的模拟器进行使用。
 | 
			
		||||
在资料文件夹中找到`android-emulator-m1-preview-v3.dmg`安装包,进行安装。
 | 
			
		||||
安装完成后,还不能安装apk,需要安装android-sdk,这里通过brew命令安装,首先安装brew,在命令控制台输入命令:
 | 
			
		||||
`/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)" `
 | 
			
		||||
推荐使用2号安装:
 | 
			
		||||

 | 
			
		||||
> 如果没有安装git,在提示框中选择安装即可。
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
执行命令立即生效:`source /Users/tianze/.zprofile`
 | 
			
		||||
接下来安装android-sdk:
 | 
			
		||||
```shell
 | 
			
		||||
#先安装android-sdk,再安装android-platform-tools
 | 
			
		||||
brew install --cask android-sdk
 | 
			
		||||
 | 
			
		||||
brew install android-platform-tools
 | 
			
		||||
 | 
			
		||||
#查看
 | 
			
		||||
brew list android-sdk
 | 
			
		||||
brew list android-platform-tools
 | 
			
		||||
```
 | 
			
		||||
可以看到在`/opt/homebrew/Caskroom`目录下有`android-platform-tools`和`android-sdk`两个文件夹:
 | 
			
		||||

 | 
			
		||||
在模拟器中设置adb路径:`/opt/homebrew/Caskroom/android-platform-tools/34.0.1/platform-tools/adb`
 | 
			
		||||

 | 
			
		||||
设置完成后,即可拖入apk进行安装:
 | 
			
		||||

 | 
			
		||||
## 3.2、启动服务
 | 
			
		||||
测试登录的话,需要确保如下的服务处于启动状态:
 | 
			
		||||

 | 
			
		||||
## 3.3、快递员端
 | 
			
		||||
在app中设置接口地址:`http://192.168.150.101:9527/courier`
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
使用正确的用户密码即可登录:
 | 
			
		||||

 | 
			
		||||
## 3.4、司机端
 | 
			
		||||
司机端与快递员端类似,需要配置url为:`http://192.168.150.101:9527/driver`
 | 
			
		||||
输入正确的用户名密码即可登录成功:
 | 
			
		||||

 | 
			
		||||
# 4、pc管理端
 | 
			
		||||
pc管理端是需要将前端开发的vue进行编译,发布成html,然后通过nginx进行访问,这个过程已经在Jenkins中配置,执行点击发布即可。
 | 
			
		||||
地址:[http://jenkins.sl-express.com/view/%E5%89%8D%E7%AB%AF/job/project-slwl-admin-vue/](http://jenkins.sl-express.com/view/%E5%89%8D%E7%AB%AF/job/project-slwl-admin-vue/)
 | 
			
		||||

 | 
			
		||||
vue打包命令:
 | 
			
		||||

 | 
			
		||||
将打包后的html等静态文件拷贝到指定目录下:
 | 
			
		||||

 | 
			
		||||
nginx中的配置:
 | 
			
		||||

 | 
			
		||||
nginx所在目录:`/usr/local/src/nginx/conf`
 | 
			
		||||
输入地址进行测试:[http://admin.sl-express.com/#/login](http://admin.sl-express.com/#/login)
 | 
			
		||||

 | 
			
		||||
确保如下服务是启动状态:
 | 
			
		||||

 | 
			
		||||
							
								
								
									
										84
									
								
								01-讲义/其他文档/学习目标.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								01-讲义/其他文档/学习目标.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
# day01
 | 
			
		||||
- 了解神领物流项目
 | 
			
		||||
- 了解物流行业
 | 
			
		||||
- 了解项目的技术架构
 | 
			
		||||
- 了解项目的业务功能
 | 
			
		||||
- 能够搭建出开发环境
 | 
			
		||||
- 能够完成鉴权的任务开发
 | 
			
		||||
# day02
 | 
			
		||||
 | 
			
		||||
- 理解单token存在的问题
 | 
			
		||||
- 理解双token三验证解决方案的设计思想
 | 
			
		||||
- 能够实现用户端token校验与鉴权
 | 
			
		||||
- 掌握支付宝平台的对接方法
 | 
			
		||||
- 掌握微信支付平台的对接方法
 | 
			
		||||
- 理解分布式锁的原理以及应用
 | 
			
		||||
# day03
 | 
			
		||||
 | 
			
		||||
- 了解支付微服务的需求
 | 
			
		||||
- 能够读懂渠道管理相关的代码
 | 
			
		||||
- 能够理解分布式锁的应用
 | 
			
		||||
- 能够读懂支付宝扫描支付的代码
 | 
			
		||||
- 掌握xxl-job的使用
 | 
			
		||||
- 能够读懂同步支付状态的两种方式
 | 
			
		||||
# day04
 | 
			
		||||
 | 
			
		||||
- 了解计算运费的业务需求
 | 
			
		||||
- 了解运费模板表的设计
 | 
			
		||||
- 了解项目中的代码规范
 | 
			
		||||
- 能够实现运费计算的业务逻辑
 | 
			
		||||
- 能够完成部署服务以及功能测试
 | 
			
		||||
# day05
 | 
			
		||||
 | 
			
		||||
- 了解路线规划需求
 | 
			
		||||
- 了解路线规划实现方案
 | 
			
		||||
- 掌握Neo4j的基本使用
 | 
			
		||||
- 掌握Cypher的编写
 | 
			
		||||
- 掌握Spring Data Neo4j使用
 | 
			
		||||
# day06
 | 
			
		||||
 | 
			
		||||
- 了解路线规划功能
 | 
			
		||||
- 理解实现机构数据同步
 | 
			
		||||
- 能够实现路线管理
 | 
			
		||||
- 能够完成综合功能测试
 | 
			
		||||
# day07
 | 
			
		||||
 | 
			
		||||
- 理解什么是智能调度
 | 
			
		||||
- 能够实现订单转运单
 | 
			
		||||
- 掌握美团Leaf的使用
 | 
			
		||||
- 能够完善运单服务
 | 
			
		||||
- 能够完成合并运单
 | 
			
		||||
# day08
 | 
			
		||||
 | 
			
		||||
- 理解智能调度生成运输任务
 | 
			
		||||
- 掌握运输任务相关业务的实现
 | 
			
		||||
- 掌握司机入库业务的实现
 | 
			
		||||
# day09
 | 
			
		||||
 | 
			
		||||
- 掌握MongoDB的基本使用
 | 
			
		||||
- 掌握Spring Data MongoDB的使用
 | 
			
		||||
- 理解作业范围功能需求
 | 
			
		||||
- 能够实现机构与快递员的作业范围
 | 
			
		||||
# day10
 | 
			
		||||
 | 
			
		||||
- 了解快递员取派件任务需求
 | 
			
		||||
- 理解递员取派件任务相关功能开发
 | 
			
		||||
- 能够实现调度中心的任务调度
 | 
			
		||||
- 能够完成整体业务功能的测试
 | 
			
		||||
# day11
 | 
			
		||||
 | 
			
		||||
- 了解物流信息的需求分析
 | 
			
		||||
- 理解物流信息的技术实现
 | 
			
		||||
- 掌握基于MongoDB的功能实现
 | 
			
		||||
- 掌握多级缓存的解决方案
 | 
			
		||||
- 掌握Redis缓存存在的问题分析并解决
 | 
			
		||||
# day12
 | 
			
		||||
 | 
			
		||||
- 了解什么是分布式日志
 | 
			
		||||
- 掌握Graylog的部署安装
 | 
			
		||||
- 掌握Graylog进行日志收集
 | 
			
		||||
- 掌握Graylog的搜索语法
 | 
			
		||||
- 了解什么是链路追踪
 | 
			
		||||
- 掌握Skywalking的基本使用
 | 
			
		||||
- 掌握整合微服务使用Skywalking
 | 
			
		||||
- 掌握将Skywalking整合到Docker中
 | 
			
		||||
							
								
								
									
										108
									
								
								01-讲义/其他文档/常见问题手册.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								01-讲义/其他文档/常见问题手册.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
# 一、前端问题
 | 
			
		||||
## 1、用户端登录失败
 | 
			
		||||
#### 1.1 可能是网关配置不对
 | 
			
		||||
解决:查看utils/evn.js 第2行,另外更改baseurl后需要重新编译
 | 
			
		||||
另外,需要检查本地hosts文件中的api.sl-express.com域名配置是否指向到了192.168.150.101。
 | 
			
		||||

 | 
			
		||||
#### 1.2 小程序获取不到手机号
 | 
			
		||||
如果出现获取手机号失败,需要进行真机调试,并在手机上打开开发者模式,然后获取手机验证码进行验证,之后既可以正常在电脑上使用微信小程序
 | 
			
		||||
**真机调试:**
 | 
			
		||||

 | 
			
		||||
**打开开发者模式:**
 | 
			
		||||

 | 
			
		||||
#### 1.3 修改前端代码后没有重新编译
 | 
			
		||||
#### 
 | 
			
		||||
## 2、前端代码Jenkins部署不成功
 | 
			
		||||
可能是以下情况,请详细检查
 | 
			
		||||
 | 
			
		||||
1. host配置是否正确 
 | 
			
		||||
2. 虚拟机路径下(/itcast/admin-web)是否有静态文件(index.html)
 | 
			
		||||
3. 可能为浏览器缓存
 | 
			
		||||
## 3、用户端网络异常且不提示登录
 | 
			
		||||
解决:需要清除当前小程序重新扫码使用
 | 
			
		||||
## 4、微信开发者工具 启动闪烁
 | 
			
		||||
解决:关闭开发者工具,再次导入项目。
 | 
			
		||||
# 二、虚拟机环境问题
 | 
			
		||||
## 1、虚拟机启动失败
 | 
			
		||||
虚拟机启动失败很可能是因为下载文件存在缺失。将原始文件做成种子,然后下发给学员下载,某些文件可能会丢失。
 | 
			
		||||
**解决方案:**
 | 
			
		||||
出现问题的话,可通过硬盘或者U盘进行下载
 | 
			
		||||
## 2、虚拟机防火墙关闭命令
 | 
			
		||||
 **systemctl-stop-firewalld**
 | 
			
		||||
# 三、idea环境问题
 | 
			
		||||
## 1、微服务pom文件中依赖报红
 | 
			
		||||
通过git拉下来代码之后,发现微服务里面的pom文件报红
 | 
			
		||||

 | 
			
		||||
**解决方案:**
 | 
			
		||||
#### **1.1 能够成功编译**
 | 
			
		||||
通过maven进行编译项目,如果能够成功编译,说明是IDEA存在缓存,未识别到已下载的依赖,无需处理,正常学习即可
 | 
			
		||||

 | 
			
		||||
#### **1.2 不能成功编译**
 | 
			
		||||
##### 1.2.1 setting文件不正常
 | 
			
		||||
maven的配置文件settings.xml是否和讲义中一致,并且本地仓库地址修改为自己的
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
##### 1.2.2 使用的maven不正确
 | 
			
		||||
检查IDEA中使用的maven是否正确,一定要是自己安装的,不要使用默认的
 | 
			
		||||

 | 
			
		||||
##### 1.2.3 使用的JDK不正确
 | 
			
		||||
检查maven编译使用的jdk是否为11
 | 
			
		||||

 | 
			
		||||
## 2、使用IDEA输入错误git密码
 | 
			
		||||
部分学员输入使用IDEA开发项目进行提交时,意外输错git密码,由于IDEA本地记录有密码数据,仅仅清除git配置是无法解决的,最根本的是需要删除掉IDEA本地保存的git密码
 | 
			
		||||
**解决方案:**
 | 
			
		||||
 | 
			
		||||
1. 确定自己的IDEA版本号:Help-->About
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
2. 进入到文件夹C:\Users\Atom\AppData\Roaming\JetBrains(注:中间的Atom为自定义的用户名,如自己命名为zhangsan,此处即为zhangsan,实际路径自己修改)找到对应版本的IDEA文件夹
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
3. 进入对应版本文件夹,删除掉c.kdbx文件
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
4. 修改IDEA配置,如下图,勾选Protect master password
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
5. 以上步骤全部完成重新提交代码,IDEA即会提示重新输入密码
 | 
			
		||||
## 3、maven下载不到
 | 
			
		||||
####  3.1可能是有idea 缓存
 | 
			
		||||

 | 
			
		||||
#### 3.2 setting文件不正确
 | 
			
		||||
备份自己电脑原有的setting文件,复制课程中的setting文件,修改复制出的setting文件中本地仓库位置为自己电脑的位置。
 | 
			
		||||
#### 3.3 本地仓库依赖包不完整
 | 
			
		||||
需要手动删除该文件夹下全部文件,执行maven编译命令,再次下载。下图为正常情况。
 | 
			
		||||

 | 
			
		||||
## 4、Command line is too long 
 | 
			
		||||
需要修改启动配置如下
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
## 5、No appropriate protocol 
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
# 四、业务流程问题
 | 
			
		||||
## 1、如何查询Neo4j中线路ID
 | 
			
		||||
解决:登录neo4j后台,选择某条线路,右侧属性区域中的ID即为线路ID
 | 
			
		||||

 | 
			
		||||
## 2、如何临时发起一次车次计划调度
 | 
			
		||||
truck_plan车辆计划表中 status改为1,schedule_status改为0,确认plan_departure_time小于当前时间+ 2小时 即可重新调度一次。
 | 
			
		||||

 | 
			
		||||
## 3、nacos配置没生效
 | 
			
		||||
改完nacos配置需要重启生效,改nacos配置有空格也会问题。
 | 
			
		||||
## 4、后台验证码不显示
 | 
			
		||||
idea服务运行了,但前端登陆验证码一直出不来,可能是本机防火墙没有关,导致虚拟机无法向本机ip发送请求,有类似情况的同学可以留意下,把本机防火墙给关掉。
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
## 5、Mysql无法连接
 | 
			
		||||
 版本太高 用idea插件可以,课程提供的客户端也可以
 | 
			
		||||
## 6、扫码支付提示买家不匹配
 | 
			
		||||

 | 
			
		||||
解决:最常见的原因:第一买家扫码的时候是a用户,然后不付钱,b用户又扫码一次,就会报这个错。支付宝二维码只能由第一次扫码的买家进行支付。
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										225
									
								
								01-讲义/其他文档/权限管家使用说明.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								01-讲义/其他文档/权限管家使用说明.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,225 @@
 | 
			
		||||
# 1、了解权限管家
 | 
			
		||||
## 1.1、介绍
 | 
			
		||||
 | 
			
		||||
公司有多个业务系统,这些业务系统的公共部分包括组织、用户、权限等系统管理:
 | 
			
		||||

 | 
			
		||||
这个公共的系统管理,可以剥离出一套基础组件服务,即权限管家。
 | 
			
		||||
 | 
			
		||||
传智权限管家是一个通用的权限管理中台服务,在神领物流项目中,我们使用权限系统管理企业内部员工,比如:快递员、司机、管理员等。
 | 
			
		||||
 | 
			
		||||
在权限管家中可以管理用户,管理后台系统的菜单,以及角色的管理。
 | 
			
		||||
## 1.2、逻辑模型
 | 
			
		||||
 | 
			
		||||
权限管家的接口根据管理范围分为公司级、应用级和系统接口,如下图:
 | 
			
		||||

 | 
			
		||||
# 2、部署安装
 | 
			
		||||
 | 
			
		||||
参考:[https://sl-express.itheima.net/#/zh-cn/base-service?id=权限管家](https://sl-express.itheima.net/#/zh-cn/base-service?id=%e6%9d%83%e9%99%90%e7%ae%a1%e5%ae%b6)
 | 
			
		||||
# 3、登录
 | 
			
		||||
 | 
			
		||||
登录地址:[http://auth.sl-express.com/api/authority/static/index.html#/login](http://auth.sl-express.com/api/authority/static/index.html#/login)
 | 
			
		||||
 | 
			
		||||
用户名密码:admin/123456
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
# 4、应用管理
 | 
			
		||||
 | 
			
		||||
权限管家是一个多应用的管理系统,所以要接入权限管家首先需要创建应用。
 | 
			
		||||
 | 
			
		||||
【应用管理】 => 【添加应用】
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
创建成功:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
查看应用所对应的ID和CODE:
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
# 5、组织管理
 | 
			
		||||
 | 
			
		||||
一般公司会设置不同的组织结构用来管理人员,比如:总部、分公司、人事部、行政部、财务部、物流部、物流转运等。
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
添加组织,组织是一颗树,所以需要选择上级组织:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
添加完成:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
# 6、进入应用
 | 
			
		||||
 | 
			
		||||
对于应用的操作需要进入到应用中才能操作:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
进入应用后,可以看到左侧菜单有3项管理:【用户管理】、【菜单管理】、【角色管理】
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
对应的后台系统的菜单列表:**(不要修改)**
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
应用中的角色:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
# 7、新增用户
 | 
			
		||||
 | 
			
		||||
新增用户需要【返回权限管家】进行操作,在【用户管理】中【新增用户】:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
新增用户:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
添加成功:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
# 8、分配角色
 | 
			
		||||
 | 
			
		||||
现在为【test01】分配快递员角色,怎么操作呢?
 | 
			
		||||
 | 
			
		||||
首先需要进入到【神领物流】应用,在【用户管理】中分配角色:
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
这样,test01就拥有了【快递员】角色了。
 | 
			
		||||
 | 
			
		||||
同理,可以添加其他的用户了。
 | 
			
		||||
# 9、SDK使用说明
 | 
			
		||||
## 9.1、关于authority-sdk
 | 
			
		||||
 | 
			
		||||
authority-sdk是基于authority-sdk的restful接口实现的Java SDK的封装,实现了token、组织、菜单、角色等功能。
 | 
			
		||||
## 9.2、快速集成使用
 | 
			
		||||
 | 
			
		||||
authority-sdk提供了两种方式与业务系统对接,分别是:
 | 
			
		||||
 | 
			
		||||
- java sdk方式
 | 
			
		||||
- Spring Boot集成方式
 | 
			
		||||
## 9.3、使用方法
 | 
			
		||||
### 9.3.1、java sdk方式
 | 
			
		||||
> **第一步,导入maven依赖**
 | 
			
		||||
 | 
			
		||||
```xml
 | 
			
		||||
<dependency>
 | 
			
		||||
    <groupId>com.itheima.em.auth</groupId>
 | 
			
		||||
    <artifactId>authority-sdk</artifactId>
 | 
			
		||||
    <version>{version}</version>
 | 
			
		||||
</dependency>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
> **第二步,实例化AuthTemplate对象**
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
```java
 | 
			
		||||
String host = "127.0.0.1"; //Authority服务地址
 | 
			
		||||
int port = 8764; //Authority服务端口
 | 
			
		||||
int timeout = 1000; //http请求的超时时间,不传值默认为10S
 | 
			
		||||
        
 | 
			
		||||
//token,非登录请求都需要带上,一般情况下登录成功后将该数据放入缓存中        
 | 
			
		||||
String token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIiwiYWNjb3VudCI6ImFkbWluIiwibmFtZSI6IuW5s-WPsOeuoeeQhuWRmCIsIm9yZ2lkIjo4NzQyMjc2MTUxNzM0NDk4MjUsInN0YXRpb25pZCI6ODU3OTQwMTU3NDYwOTU3NTM3LCJhZG1pbmlzdHJhdG9yIjp0cnVlLCJleHAiOjE2NTEyNTkyODB9.anqT0MD9zAr13KG9OE4mqHHK2UMCOXUjeMrEsN1wy9_a14zFbjPkiDZ8dM7JirsAgj61yvshoP6pqBImdmyilQN-iFSa_ci15Ii4HhfFE1mcaRon3ojX_T9ncjHKuF9Y9ZPKT68NOzOfCwvhDrG_sDiaI1C-TwEJmhLM78FhwAI";
 | 
			
		||||
//应用ID,非登录请求都需要带上,该参数表示你需要查询哪个应用相关数据,一般情况下置于配置文件中
 | 
			
		||||
Long applicationId = 1L;
 | 
			
		||||
//登录获取token
 | 
			
		||||
AuthTemplate authTemplate = new AuthTemplate(host,port,TIME_OUT);
 | 
			
		||||
Result<LoginDTO> result = authTemplate.opsForLogin().token("admin","123456");
 | 
			
		||||
 | 
			
		||||
//后续基于AuthTemplate可以调用各种服务
 | 
			
		||||
AuthTemplate authTemplate = new AuthTemplate(host,port,TIME_OUT,token,applicationId);
 | 
			
		||||
Result<List<MenuDTO>> result = authTemplate.opsForPermission().getMenu();
 | 
			
		||||
log.info("菜单:{}", JSONObject.toJSONString(result));
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 9.3.2、Spring Boot集成方式
 | 
			
		||||
> **第一步,导入maven依赖**
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
```xml
 | 
			
		||||
<dependency>
 | 
			
		||||
    <groupId>com.itheima.em.auth</groupId>
 | 
			
		||||
    <artifactId>itcast-auth-spring-boot-starter</artifactId>
 | 
			
		||||
    <version>1.0</version>
 | 
			
		||||
</dependency>
 | 
			
		||||
 | 
			
		||||
<!-- 如果是SNAPSHOT版本,如要在项目的pom.xml文件中引入快照版源 -->
 | 
			
		||||
<repositories>
 | 
			
		||||
    <repository>
 | 
			
		||||
        <id>sonatypeSnapshots</id>
 | 
			
		||||
        <name>Sonatype Snapshots</name>
 | 
			
		||||
        <releases>
 | 
			
		||||
            <enabled>false</enabled>
 | 
			
		||||
        </releases>
 | 
			
		||||
        <snapshots>
 | 
			
		||||
            <enabled>true</enabled>
 | 
			
		||||
        </snapshots>
 | 
			
		||||
        <url>https://s01.oss.sonatype.org/content/repositories/snapshots/</url>
 | 
			
		||||
    </repository>
 | 
			
		||||
</repositories>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
> **第二步,配置application.yml文件**
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
itcast:
 | 
			
		||||
  authority:
 | 
			
		||||
    host: 127.0.0.1 #authority服务地址,根据实际情况更改
 | 
			
		||||
    port: 8764 #authority服务端口
 | 
			
		||||
    timeout: 10000 #http请求的超时时间
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**第三步,使用用例**
 | 
			
		||||
 | 
			
		||||
```java
 | 
			
		||||
package com.example.demo.service;
 | 
			
		||||
 | 
			
		||||
import com.alibaba.fastjson.JSONObject;
 | 
			
		||||
import com.itheima.auth.sdk.AuthTemplate;
 | 
			
		||||
import com.itheima.auth.sdk.common.Result;
 | 
			
		||||
import com.itheima.auth.sdk.dto.LoginDTO;
 | 
			
		||||
import com.itheima.auth.sdk.dto.MenuDTO;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.springframework.beans.factory.annotation.Value;
 | 
			
		||||
import org.springframework.stereotype.Service;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
@Slf4j
 | 
			
		||||
@Service
 | 
			
		||||
public class AuthService {
 | 
			
		||||
 | 
			
		||||
    @Value("${itcast.authority.host}")
 | 
			
		||||
    private String host;
 | 
			
		||||
 | 
			
		||||
    @Value("${itcast.authority.port}")
 | 
			
		||||
    private int port;
 | 
			
		||||
 | 
			
		||||
    private final static int TIME_OUT = 10000;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 登录获取token
 | 
			
		||||
     * @param account
 | 
			
		||||
     * @param password
 | 
			
		||||
     */
 | 
			
		||||
    public void login(String account, String password) {
 | 
			
		||||
        AuthTemplate authTemplate = new AuthTemplate(host,port);
 | 
			
		||||
        Result<LoginDTO> loginDTO = authTemplate.opsForLogin().token(account, password);
 | 
			
		||||
        log.info("登录结果:{}", JSONObject.toJSONString(loginDTO));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										104
									
								
								01-讲义/其他文档/虚拟机导入手册.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								01-讲义/其他文档/虚拟机导入手册.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,104 @@
 | 
			
		||||
# 1、方案说明
 | 
			
		||||
神领物流项目需要使用配套的虚拟机环境才能学习,在虚拟机中提供了各种开发需要的环境,比如:git、maven私服、jenkins、mysql等(具体[点击查看](https://sl-express.itheima.net/#/zh-cn/base-service)),由于学生的电脑品牌、配置存在较大的差异,所以在这里提供了三种使用虚拟机方案,如下:
 | 
			
		||||
 | 
			
		||||
**方案一:**windows电脑环境安装虚拟机,此方案适用于大部分学生的环境,在自己的win电脑安装安装Vmware软件导入虚拟机即可。
 | 
			
		||||
**方案二:**苹果电脑(M1、M2芯片)安装虚拟机,此方案适用于部分学生使用新款苹果电脑的方案,在电脑中安装Vmware Fusion导入虚拟机即可。
 | 
			
		||||
**方案三:**电脑配置低(内存低于16GB)或使用旧款苹果电脑(配置低的Inter芯片),此方案需要再借(或买或租)一台电脑(建议win系统)配合完成,基本思路是在这台电脑中导入虚拟机,通过网络连接到这台机器,进行开发学习。
 | 
			
		||||
# 2、方案一:windows环境
 | 
			
		||||
VMware安装过程省略,建议版本使用15.5以上版本。
 | 
			
		||||
默认虚拟机设置的内存大小内8G,虚拟内存为16GB,建议保持此配置,不建议进行调整。
 | 
			
		||||
## 2.1.配置VMware网络
 | 
			
		||||
因为虚拟机配置了静态IP地址为192.168.150.101,因此需要VMware软件的虚拟网卡采用与虚拟机相同的网段。
 | 
			
		||||
### 2.1.1.配置VMware
 | 
			
		||||
首先,在VMware中选择编辑,虚拟网络编辑器:
 | 
			
		||||

 | 
			
		||||
这里需要管理员权限,因此要点击更改设置:
 | 
			
		||||

 | 
			
		||||
接下来,就可以修改虚拟网卡的IP地址了,流程如图:
 | 
			
		||||

 | 
			
		||||
注意:一定要严格按照标号顺序修改,并且IP地址也要保持一致!
 | 
			
		||||
### 2.1.2.验证
 | 
			
		||||
点击确定后,等待一段时间,VMware会重置你的虚拟网卡。完成后,可以在windows的网络控制面板看到:
 | 
			
		||||

 | 
			
		||||
选中该网卡,右键点击,在菜单中选择状态,并在弹出的状态窗口中选择详细信息:
 | 
			
		||||

 | 
			
		||||
在详细信息中,查看IPv4地址是否是 `192.168.150.1`:
 | 
			
		||||

 | 
			
		||||
如果与我一致,则证明配置成功!
 | 
			
		||||
## 2.2.导入虚拟机
 | 
			
		||||
### 2.2.1.导入
 | 
			
		||||
资料中提供了一个虚拟机文件:
 | 
			
		||||

 | 
			
		||||
打开VMware,选择文件,然后打开,找到之前提供的虚拟机文件夹,进入文件夹后,选中CentOS7.vmx文件,然后点击打开:
 | 
			
		||||

 | 
			
		||||
导入成功:
 | 
			
		||||

 | 
			
		||||
启动虚拟机,选择【我已复制该虚拟机】:
 | 
			
		||||

 | 
			
		||||
### 2.2.2.登入
 | 
			
		||||
虚拟机登入信息如下:
 | 
			
		||||
```shell
 | 
			
		||||
# 用户名
 | 
			
		||||
root
 | 
			
		||||
# 密码
 | 
			
		||||
123321
 | 
			
		||||
```
 | 
			
		||||
## 2.3.测试网络
 | 
			
		||||
最后,通过命令测试网络是否畅通:
 | 
			
		||||
```
 | 
			
		||||
ping baidu.com
 | 
			
		||||
```
 | 
			
		||||
# 3、方案二:MacBook M1 M2
 | 
			
		||||
此方案适用于新款苹果MacBook的M系列芯片电脑,需要在电脑中安装VMware Fusion,建议版本为13.x以上。
 | 
			
		||||
默认虚拟机设置的内存大小内8G,虚拟内存为16GB,建议保持此配置,不建议进行调整。
 | 
			
		||||
## 3.1、配置网络
 | 
			
		||||
在Mac系统中进行网络设置:
 | 
			
		||||

 | 
			
		||||
修改网络配置文件,命令:`sudo vi /Library/Preferences/VMware\ Fusion/networking`
 | 
			
		||||

 | 
			
		||||
主要是修改以上两处内容,需要注意两点:
 | 
			
		||||
 | 
			
		||||
- 对应你的网络名称进行修改,我对应的是VNET_2
 | 
			
		||||
- ip地址**必须**为:`192.168.150.0`,虚拟机的ip地址固定为:`192.168.150.101`
 | 
			
		||||
 | 
			
		||||
修改完成后,退出VMware Fusion,然后重新打开VMware Fusion软件。
 | 
			
		||||
 | 
			
		||||
以上操作完成后,检查网关是否正确,如果显示`192.168.150.2`表示设置成功,查看命令(修改成自己的网络名称):
 | 
			
		||||
`sudo vi /Library/Preferences/VMware\ Fusion/vmnet2/nat.conf`
 | 
			
		||||

 | 
			
		||||
## 3.2、导入虚拟机
 | 
			
		||||
在VMware Fusion中,选择文件 -> 打开,找到资料中提供的【Centos7-sl-x】文件,点击打开即可。
 | 
			
		||||

 | 
			
		||||
设置虚拟机网络为自定义网络:
 | 
			
		||||

 | 
			
		||||
启动虚拟机,通过 `root/123321 `登陆到虚拟机:
 | 
			
		||||

 | 
			
		||||
测试网络是否正常:
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
查看正在运行的服务:
 | 
			
		||||

 | 
			
		||||
如果你的测试结果与上述一致,说明虚拟机导入成功。
 | 
			
		||||
# 4、方案三:双电脑方案
 | 
			
		||||
此方案需要借助于另外一台电脑安装虚拟机环境,自己的电脑做开发学习使用,其原理如图所示:
 | 
			
		||||

 | 
			
		||||
## 4.1、MacBook
 | 
			
		||||
按照官方文档进行设置网络共享:[https://support.apple.com/zh-cn/guide/mac-help/mchlp1540/mac](https://support.apple.com/zh-cn/guide/mac-help/mchlp1540/mac)
 | 
			
		||||
 | 
			
		||||
下面,修改配置文件,目的是修改为192.168.150.x网段:
 | 
			
		||||
在`/Library/Preferences/SystemConfiguration/com.apple.nat.plist`文件增加如下内容:
 | 
			
		||||
```xml
 | 
			
		||||
<key>SharingNetworkMask</key>
 | 
			
		||||
<string>255.255.255.0</string>
 | 
			
		||||
<key>SharingNetworkNumberEnd</key>
 | 
			
		||||
<string>192.168.150.254</string>
 | 
			
		||||
<key>SharingNetworkNumberStart</key>
 | 
			
		||||
<string>192.168.150.2</string>
 | 
			
		||||
```
 | 
			
		||||
修改示例:
 | 
			
		||||

 | 
			
		||||
本地的ip地址为192.168.150.2,所以在本地跑微服务时注册的ip地址为:192.168.150.2。
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								01-讲义/在线版本.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								01-讲义/在线版本.txt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
https://www.yuque.com/zhangzhijun-91vgw/xze2gr?# 《神领物流-讲义-v1.5》 密码:kzcd
 | 
			
		||||
		Reference in New Issue
	
	Block a user