1. 需求分析
运费计算微服务是核心的微服务,不能出现计算错误,毕竟是钱挂钩的。接到开发任务后,首先需要了解需求,再动手开发。
运费的计算是分不同地区的,比如:同城、省内、跨省,计算规则是不一样的,所以针对不同的类型需要设置不同的运费规则,这其实就是所谓的模板。
1.1 模板列表
产品需求中的运费模板列表:
轻抛系数名称解释:
在计算运费时,包裹有两个维度,体积和重量,二者谁大取谁进行计算,但是体积和重量不是一个单位怎么比较呢?一般的做法就是将体积转化成重量,公式:体积 / 轻抛系数 = 重量,这样就可以比较了。
也就是说,相同的体积,轻抛系数越大计算出的重量就越小,反之就越大。
1.2 计费规则
重量计算方法:
取重量和体积两者间较大的数值,体积计算方法:长(cm) × 宽(cm) × 高(cm) / 轻抛系数
普快:
- 同城互寄:12000
- 城内寄件:12000
- 跨省寄件:12000
- 经济区互寄(京津翼、江浙沪皖、川渝):6000
- 经济区互寄(黑吉辽):9000
计费重量小数点规则:
- 不满 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收费
总运费小数点规则:按四舍五入计算,精确到小数点后一位
价格:(目前只有普快)
同城寄:首重(1.0kg)13.0元,续重2.0元/kg
省内寄:首重(1.0kg)14.0元,续重2.0元/kg
跨省寄:首重(1.0kg)18.0元,续重5.0元/kg
经济区互寄(江浙沪皖):首重(1.0kg)12.0元,续重3.0元/kg
经济区互寄(京津翼):首重(1.0kg)12.0元,续重3.0元/kg
经济区互寄(黑吉辽):首重(1.0kg)16.0元,续重6.0元/kg
经济区互寄(川渝):首重(1.0kg)18.0元,续重8.0元/kg
模板不可重复设置,需确保唯一值。
如已设置同城寄、跨省寄、省内寄,则只可修改,不可再新增。
如已设置经济区互寄某个城市,下次添加不可再关联此经济区城市。
1.3 新增模板
运费模板有 4 种类型,分别为:
- 同城寄:同城寄件运费计算模板,全国统一定价
- 省内寄:省内寄件运费计算模板,全国统一定价
- 跨省寄:不同省份间的运费计算模板,全国统一定价
- 经济区互寄:4个经济区(京津翼、江沪浙皖、川渝、黑吉辽),经济区间寄件可设置优惠价格。
a. 全国范围
此模板为「同城寄/省内寄/跨省」三个类型的运费模板
模板类型:可选择同城寄/省内寄/跨省/经济区互寄
运送类型:可选择运送类型,目前业务只支持普快
关联城市:同城寄/省内寄/跨省:全国统一定价(如上图)
首重价格:保留小数点后一位,可输入1-999 间任意数值
续重价格:保留小数点后一位,可输入1-999 间任意数值
轻抛系数:整数,可输入1-99999 间,任意数值
b. 经济区互寄
此模板「经济区互寄」类型的运费模板
模板类型:经济区互寄
运送类型:可选择运送类型,目前业务只支持普快
关联城市:经济区互寄:可设置单个或多个经济区价格(如上图)
首重价格:保留小数点后一位,可输入 1-999 间任意数值
续重价格:保留小数点后一位,可输入 1-999 间任意数值
轻抛系数:整数,可输入 1-99999 间,任意数值
1.4 运费模板表
运费模板是需要存储到表中的,所以首先需要设计表结构,具体表结构语句如下:
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) 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='运费模板表';
由于该表数据比较少,所以就不需要添加索引字段了。
2. 开发环境
2.1 工程规范
在神领物流项目中,微服务代码是独立的工程(非聚合项目结构),这样更适合多团队间的协作,在部署方面更加的独立方便。
1 个微服务需要创建 3 个工程,分别是:
- sl-express-ms-xxx-api(定义 Feign 接口)
- sl-express-ms-xxx-domain(定义 DTO、枚举对象)
- sl-express-ms-xxx-service(微服务的实现)
它们之间的依赖关系如下:
2.2 拉取代码
需要拉取的工程有 3 个:
工程名 | Git 地址 |
---|---|
sl-express-ms-carriage-domain | http://git.sl-express.com/sl/sl-express-ms-carriage-domain |
sl-express-ms-carriage-api | http://git.sl-express.com/sl/sl-express-ms-carriage-api |
sl-express-ms-carriage-service | http://git.sl-express.com/sl/sl-express-ms-carriage-service |
2.3 代码规范
a. DTO 对象
在神领物流项目中,微服务之间的对象传输都使用 DTO,命名规范:XxxxDTO(DTO 必须大写),并且将 DTO 类放置到 domain 工程中,如下:
DTO 类中统一使用 lombok 的 @Data 注解进行标注。
b. 数据校验
微服务之间的接口调用,对于传输的数据是需要做校验的,一般校验方式有 2 种:
(1)采用 hibernate-validator 注解方式校验
(2)在程序中通过 if 进行判断
我们采用哪一种方式呢?实际上在项目中,我们采用二者结合的方式进行校验。
对于第一种方式的补充说明:
(1)在 Controller 中需要在类声明上增加 @Validated
注解,来开启校验。
(2)对于表单、url 参数校验,在 Controller 方法入参上增加校验规则。
(3)对于 @RequestBody 的校验,校验规则写的 DTO 对象中,统一通过 Spring 的 AOP 进行校验,具体在 common 工程中的 com.sl.transport.common.aspect.ValidatedAspect
实现:
c. 自定义异常
在神领物流项目中,我们统一做了自定义异常的处理。定义了 2 个异常:
- com.sl.transport.common.exception.SLWebException 用于前后端交互时抛出的异常
@Data public class SLWebException extends RuntimeException { /** 异常中的信息 */ private String msg; /** 业务状态码,规则:4位数,从1001开始递增 */ private int code = 1001; /** http状态码,按照http协议规范,如:200,201,400等 */ private int status = 500; ... }
- com.sl.transport.common.exception.SLException 用于微服务之前接口调用抛出的异常
@Data public class SLWebException extends RuntimeException { /** 异常中的信息 */ private String msg; /** 业务状态码,规则:异常弹窗状态码 */ private int code = 1; /** http状态码,按照http协议规范,如:200,201,400等 */ private int status = 200; ... }
这两个异常的区别在于 code、status 的值不同。
疑问:为什么不使用一个,而是要设置两个?
这个主要是前端和后端的设计不同,一般在微服务间接口调用时会采用标准的 RESTful 方式,按照 RESTful 的规范响应的状态码要使用标准的 HTTP 状态码,成功 200、失败 500、没有权限 401 等。
而前后端进行交互时,一般都是响应 200,即使出错也是 200,只是响应结果中通过 msg 和 code 进行表达是否成功。
基于以上的场景,所以设置了两个异常类。
【统一异常处理】具体的业务逻辑在 com.sl.transport.common.handler.GlobalExceptionHandler 中实现。
package com.sl.transport.common.handler;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 参数校验失败异常
*
* @param exception 校验失败异常
* @return 响应数据
*/
@ExceptionHandler(ValidationException.class)
public ResponseEntity<Object> handle(ValidationException exception) {
List<String> errors = null;
if (exception instanceof ConstraintViolationException) {
ConstraintViolationException exs = (ConstraintViolationException) exception;
Set<ConstraintViolation<?>> violations = exs.getConstraintViolations();
errors = violations.stream()
.map(ConstraintViolation::getMessage).collect(Collectors.toList());
}
if (ObjectUtil.isNotEmpty(exception.getCause())) {
log.error("参数校验失败异常 -> ", exception);
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(MapUtil.<String, Object>builder()
.put("code", HttpStatus.BAD_REQUEST.value())
.put("msg", errors)
.build());
}
/**
* 自定义异常处理
*
* @param exception 自定义异常
* @return 响应数据
*/
@ExceptionHandler(SLException.class)
public ResponseEntity<Object> handle(SLException exception) {
if (ObjectUtil.isNotEmpty(exception.getCause())) {
log.error("自定义异常处理 -> ", exception);
}
return ResponseEntity.status(exception.getStatus())
.body(MapUtil.<String, Object>builder()
.put("code", exception.getCode())
.put("msg", exception.getMsg())
.build());
}
/**
* web自定义异常处理
* 用于统一封装VO对象返回前端
* @param exception web自定义异常
* @return 响应数据
*/
@ExceptionHandler(SLWebException.class)
public ResponseEntity<Object> handle(SLWebException exception) {
if (ObjectUtil.isNotEmpty(exception.getCause())) {
log.error("自定义异常处理 -> ", exception);
}
JSONObject jsonObject = JSONUtil.parseObj(exception);
return ResponseEntity.ok(MapUtil.<String, Object>builder()
.put("code", exception.getCode())
.put("msg", jsonObject.getStr("msg"))
.build());
}
/**
* 其他未知异常
*
* @param exception 未知异常
* @return 响应数据
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handle(Exception exception) {
if (ObjectUtil.isNotEmpty(exception.getCause())) {
log.error("其他未知异常 -> ", exception);
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(MapUtil.<String, Object>builder()
.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value())
.put("msg", ExceptionUtil.stacktraceToString(exception))
.build());
}
}
d. @Resource
在项目中,涉及到注入 Spring 容器中 bean 对象时,均使用 @Resource
,目前 IDEA 不推荐使用 @Autowired
,原因是它是 Spring 提供,并非是 Java 标准,而 @Resource
是 Java 标准中定义的,建议使用。
如果想要使用 @Autowired
的话,建议通过构造器注入。
两者区别:
- @Autowired:默认是 ByType,可以使用 @Qualifier 指定 Name,可以对构造器、方法、参数、字段使用。
- @Resource:默认 ByName,如果找不到则 ByType,只能对方法、字段使用,不能用于构造器。
- @Autowired 是 Spring 提供的,@Resource 是 JSR-250 提供的。
- 总结:基本上 @Resource 可以完全替代 @Autowired。
2.4 配置文件
a. SpringBoot 配置
文件 | 说明 |
---|---|
bootstrap.yml | 通用配置项,服务名、日志文件、swagger配置等 |
bootstrap-local.yml | 多环境配置,本地开发环境 |
bootstrap-stu.yml | 多环境配置,学生101环境 |
bootstrap-test.yml | 多环境配置,开发组测试环境 |
对于配置文件的补充说明:
- 关于 swagger 的配置,统一在
com.sl.transport.common.properties.SwaggerConfigProperties
中读取,并且在com.sl.transport.common.config.Knife4jConfiguration
中进行了初始化 Knife4j。 - spring.profiles.active 默认 local,部署发布到 101 机器,在 Jenkins 中发布时设置为 stu。
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
通过环境变量的方式配置了 spring.profiles.active、发布到注册中心的 ip 和端口。
规则:环境变量统一采用大写字母,不允许使用 .- 符号,采用下划线“_”取代点“.”,减号“-”直接删除。
为了与 101 环境中服务互通,所以在 local 环境中固定设置了注册到注册中心的服务地址。
具体的一些项目配置统一使用 Nacos 的配置中心管理,并且在这里使用 Nacos 的共享配置机制,这样可以在多个项目中共享相同的配置。
b. Seata 配置
shared-spring-seata.yml:
seata:
registry:
type: nacos
nacos:
server-addr: 192.168.150.101:8848
namespace: ecae68ba-7b43-4473-a980-4ddeb6157bdc
group: DEFAULT_GROUP
application: seata-server
username: nacos
password: nacos
tx-service-group: sl-seata # 事务组名称
service:
vgroup-mapping: # 事务组与cluster的映射关系
sl-seata: default
seata 服务的配置(seata-server.properties):
# 指定seata存储的数据库
store.mode = db
store.db.datasource = druid
store.db.dbType = mysql
store.db.driverClassName = com.mysql.cj.jdbc.Driver
store.db.url = jdbc:mysql://192.168.150.101:3306/seata?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false
store.db.user = root
store.db.password = 123
store.db.minConn = 5
store.db.maxConn = 100
store.db.globalTable = global_table
store.db.branchTable = branch_table
store.db.lockTable = lock_table
store.db.distributedLockTable = distributed_lock
store.db.queryLimit = 100
store.db.maxWait = 5000
seata 服务地址:http://seata.sl-express.com 账号信息:seata/seata
c. MySQL 配置
spring:
datasource: # 数据库的配置
driver-class-name: ${jdbc.driver:com.mysql.cj.jdbc.Driver}
url: ${jdbc.url}
username: ${jdbc.username}
password: ${jdbc.password}
具体的配置项在每个微服务自己的配置文件中,例如运费服务:
jdbc.url = jdbc:mysql://192.168.150.101:3306/sl_carriage?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false
jdbc.username = root
jdbc.password = 123
需要说明的是,${jdbc.driver:com.mysql.cj.jdbc.Driver}
这种写法冒号后面的是默认值,如果不配置 jdbc.driver
就采用默认值。
d. MP 配置
mybatis-plus:
configuration:
# 在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
#log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
global-config:
db-config:
id-type: ASSIGN_ID
在配置文件中指定的默认的 id 策略为 ASSIGN_ID,只当插入对象 ID 为空时,自动填充雪花 id。
e. Log 配置
项目中统一使用 logback 日志框架,其配置文件如下:
<?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>
<!--evel:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,-->
<!--不能设置为INHERITED或者同义词NULL。默认是DEBUG。-->
<root level="INFO">
<appender-ref ref="stdout"/>
</root>
</configuration>
3. 业务实现
// TODO
标签:02,exception,kg,运费,db,store,sl,服务,模板 From: https://www.cnblogs.com/liujiaqi1101/p/18141728