开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

建议收藏!!! 若依框架文档开发手册【持续更新】 首页 前端

发表于 2021-06-09

首页

作者

青衫

2940500@qq.com

点击进入文档实时更新地址-码云

前言:

:boom:接触若依也很长时间了从1.0到现在的4.0 期间一直想写个手册 但一直没有很好地切入点 最近在开发新系统 正好根据开发中遇到或者使用到的内容作为切入点来进行写文档 可能会有些混乱 一开始先写上准备后续再排版精修 推荐Git拉取,方便文档实时更新

CSDN过来的同学注意 文档已经停止在CSDN的维护

目录结构:

大致分为前端、后端,前端根据使用的页面add、edit、list来进行详细划分,后端根据三层加上其他特殊内容点划分

@TOC

前端

add.html

下拉列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
html复制代码// 1 其中t_vip_user_details_vip_type 为字典表的字典类型 可前往-系统管理-->字段管理 --> 添加新的字典
<div class="form-group">
<label class="col-sm-3 control-label">VIP用户类别:</label>
<div class="col-sm-8">
<select id="xxxxx" name="xxxxx" class="form-control m-b"
th:with="type=${@dict.getType('t_vip_user_details_vip_type')}">
<option th:each="dict : ${type}" th:text="${dict.dictLabel}" th:value="${dict.dictValue}"></option>
</select>
</div>
</div>


// 2 取非字典的数据(model)
<div class="form-group">
<label class="col-sm-3 control-label">司机:</label>
<div class="col-sm-8">
<select id="xxxx" name="xxxx" class="form-control m-b">
<option value="">--请选择(非必选)--</option>
<option th:each="xxxx : ${xxxxList}" th:text="${xxxx.name}" th:value="${xxxx.id}"></option>
</select>
</div>
</div>

//3 使下拉列带有搜索功能:引入该JS即可
https://www.cnblogs.com/tianxinyu/p/9988763.html

select的class样式为
<div th:include="include::footer"></div>
// 该JS需要在include下方
<script th:src="@{/ajax/libs/select/select2.js}"></script>

时间框

1
2
3
4
5
6
7
8
9
html复制代码<div class="form-group">
<label class="col-sm-3 control-label">设备到期时间:</label>
<div class="col-sm-8">
<div class="input-group date">
<span class="input-group-addon"><i class="fa fa-calendar"></i></span>
<input name="xxxxx" class="time-input" placeholder="yyyy-MM-dd" type="text">
</div>
</div>
</div>

大文本框

1
html复制代码<textarea name="content" style="width: 762px ;margin: 0px; height: 295px;"></textarea>

Ajax校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
javascript复制代码 $("#form-motorman-add").validate({
rules: {
name: {
required: true,
},
identityCard: {
required: true,
isIdentity: true,
remote: {
url: ctx + "iot/motorman/checkIdentityCard",
type: "post",
dataType: "json",
data: {
name: function () {
return $.common.trim($("#identityCard").val());
},
id: ''
},
dataFilter: function (data, type) {
return $.validate.unique(data);
}
}
},
contactPhone: {
required: true,
isPhone: true
},
},
messages: {
"identityCard": {
remote: "身份证号已存在"
},
}
});
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码		/**
* 校验身份证
*/
@PostMapping("/checkIdentityCard")
@ResponseBody
public Integer checkIdentityCard(String identityCard, Integer id)
{
// 存在
return CommonEnum.EXIST.getCode();
// 不存在
return CommonEnum.EXIST.NOT_EXIST();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码package com.ruoyi.common.constant;
import lombok.Getter;
/**
* @author: [青衫] 'QSSSYH@QQ.com'
* @Date: 2019-08-08 10:24
* @Description: < 通用校验是否存在返回状态码 >
*/
@Getter
public enum CommonEnum
{
/**
* 用户是否存在返回码
*/
EXIST(1, "存在"), NOT_EXIST(0, "不存在");
private Integer code;
private String msg;

CommonEnum(Integer code, String msg)
{
this.code = code;
this.msg = msg;
}
}

自定义校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
javascript复制代码jQuery.validator.addMethod("isAllNumber", function (value, element) {
var loginName = $("#loginName").val();
var patrn = /^[0-9]*$/;
if (patrn.test(loginName)) {
return false;
} else {
return true;
}
}, "用户名不能为纯数字");


$("#form-product-edit").validate({
rules: {
loginName: {
required: true,
// 自定义属性 属性名要和上方的一参一样
isAllNumber: true,
},

}
});

成功截图

回显选中图片

在下方已经写过了

路径:前端 –> 其他 –> 回显选中图片

如果需要放大回显图片可以看

前端 –> 其他 –> 放大图片

JS对添加下拉列元素

1
2
3
> arduino复制代码http://ourjs.com/detail/5be7fa5cac52fe63eba502af 看这种方式 很好用
>
>

edit.html

下拉列

1
2
3
4
5
6
7
8
html复制代码  <div class="form-group">
<label class="col-sm-3 control-label">性别:</label>
<div class="col-sm-8">
<select id="xxx" name="xxx" class="form-control m-b" th:with="type=${@dict.getType('sys_user_sex')}">
<option th:each="dict : ${type}" th:text="${dict.dictLabel}" th:value="${dict.dictValue}" th:field="*{sex}"></option>
</select>
</div>
</div>

回显时间

1
2
3
4
5
6
html复制代码	<div class="form-group">
<label class="col-sm-3 control-label">合同到期日期:</label>
<div class="col-sm-8" >
<input id="xxxxxx" name="xxxxxx" class="time-input" type="text" readonly th:value="${dates.format(xx.xxxxxx,'yyyy-MM-dd HH:mm:ss')}">
</div>
</div>




list.html

搜索栏

下拉列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
html复制代码<li>
用户状态:<select name="status" th:with="type=${@dict.getType('sys_normal_disable')}">
<option value="">所有</option>
<option th:each="dict : ${type}" th:text="${dict.dictLabel}" th:value="${dict.dictValue}"></option>
</select>
</li>


//2: 下拉列带搜索功能 主要是select class加了form-control 属性
// 然后引入
// <script th:src="@{/ajax/libs/select/select2.css}"></script>
// <script th:src="@{/ajax/libs/select/select2.js}"></script>


<li style="width: 280px;">
<p>设备类别: </p>
<select name="equipmentType" id="equipmentType" th:with="type=${@dict.getType('t_equipment_equipment_type')}" class="form-control">
<option value="">所有</option>
<option th:each="dict : ${type}" th:text="${dict.dictLabel}" th:value="${dict.dictValue}"></option>
</select>
</li>

sys_normal_disable值位置

sys_normal_disable 为字典类型值 字典类型值一般为表名字段名来命名防止出现重复

时间框

根据开始时间结束时间搜索

如果使用的是MybatisPlus版本 注意后台接收数据需要创建Vo对象 或者直接使用Map对象来进行接收开始时间 和 结束时间 不然会报错的哈

**Html: **

1
2
3
4
5
6
html复制代码<li class="select-time">
<label>创建时间: </label>
<input type="text" class="time-input" id="startTime" placeholder="开始时间" name="params[beginTime]"/>
<span>-</span>
<input type="text" class="time-input" id="endTime" placeholder="结束时间"name="params[endTime]"/>
</li>

Vo:

注意set get方法和普通实体类有区别

这么写的原因是防止前端没有传入开始时间和结束时间

然后mapper.xml 这样去判断就会报错 因为params是null 可以在这个判断外边再加一层if判断params是否为空即可解决 但还是推荐 下边这种方式写get set方法

1
2
3
4
5
6
java复制代码		/** 请求参数 */
private Map<String, Object> params;
/** get()*/
public Map<String, Object> getParams(){if (params == null){params = new HashMap<>();}return params;}
/** set() */
public void setParams(Map<String, Object> params){this.params = params;}

mapper.xml:

1
2
3
4
5
6
7
xml复制代码<if test="params.beginTime != null and params.beginTime !=''"><!-- 开始时间检索 -->
AND date_format(xxxxx,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
</if>

<if test="params.endTime != null and params.endTime !='' "><!-- 结束时间检索 -->
AND date_format(xxxxx,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
</if>

Table表格

刷新表格

1
2
javascript复制代码// 这是封装好的方法  不需要在去调用原生的JS了
$.table.refresh();

按钮颜色

后边加上 btn-xs 样式会使按钮缩小

1
2
3
4
5
6
7
html复制代码	深蓝色    btn btn-primary
​ 浅蓝色    btn btn-info
​ 绿色     btn btn-success
​ 黄色      btn btn-warning
​ 红色      btn btn-danger
​ 透明      btn btn-link
​ 默认 btn btn-default

输入图片说明

自定义按钮颜色

有时候bootstrap提供的按钮颜色并不能完全满足系统的需要 只有仅限的几个 所以在这时候需要增加自定义的按钮颜色

下边是两个在线生成bootstrap按钮颜色的网址

blog.koalite.com/bbg/v2/

twitterbootstrap3buttons.w3masters.nl/

以下为增加一个紫色按钮的示例

  1. 创建一个.css文件
  2. 将下方的css复制到css文件中
  3. 页面引入该css文件
  4. 页面创建个按钮
1. 
1
2
3
4
5
html复制代码<a class="btn btn-sample  single disabled">
<i class="fa fa-sun-o"></i> 审核
</a>

```**第二步所需要的代码:**

css复制代码.btn-sample {
color: #FFFFFF;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
background-color: #611BBD;
*background-color: #611BBD;
background-image: -moz-linear-gradient(top, #AF4CE8, #611BBD);
background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#AF4CE8), to(#611BBD));
background-image: -webkit-linear-gradient(top, #AF4CE8, #611BBD);
background-image: -o-linear-gradient(top, #AF4CE8, #611BBD);
background-image: linear-gradient(to bottom, #AF4CE8, #611BBD);
background-repeat: repeat-x;
border-color: #611BBD;
border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=’#AF4CE8’, endColorstr=’#611BBD’, GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled=false);
}

.btn-sample:hover,
.btn-sample:focus,
.btn-sample:active,
.btn-sample.active,
.btn-sample.disabled,
.btn-sample[disabled] {
color: #FFFFFF;
background-color: #611BBD;
*background-color: #003bb3;
}

1
2
3


#### 按钮大小

html复制代码






1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

![img](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/dde5359437f4a6e2cf46537753b11edeb216b1475503d36773b4079b2f82d9ce)


#### 关于徽章



> 参考文章
>
>
> [www.cnblogs.com/xiaohuochai…](https://www.cnblogs.com/xiaohuochai/p/7113645.html)
>
>
> [www.360doc.com/content/19/…](http://www.360doc.com/content/19/0429/22/59156820_832398875.shtml)


#### 格式化时间

java复制代码 /** 合同创建日期 */
@JsonFormat(pattern = “yyyy-MM-dd”, timezone=”GMT+8”)
private Date contractCreateTime;

1
2

**前端**

html复制代码th:value=”*{dates.format(reserveTime,’yyyy-MM-dd HH:mm:ss’)}”

1
2

#### 设置默认排序列

javascript复制代码sortName: ‘createTime’,
sortOrder: “desc”,

1
2
3
4
5
6
7
8

**例:**


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/75e7ee0a69c22c2eed95c7fb8cf3a3c6497e9f7ff48ca9dba5fef799a0786b1a)


#### 表格匹配字典值

javascript复制代码 var userType = [[${@dict.getType(‘sys_user_user_type’)}]];

// 在table相关属性字段的操作
{
field: ‘userType’,
title: ‘类型’,
align: “left”,
formatter: function (value, item, index) {
return $.table.selectDictLabel(userType, value);
}
},

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

#### 表格增加.减少功能项


###### 单页



> 若依的table是用BootstarpTable 而且若依也BootStarpTable简单封装了 如果想要去掉 table右上角的 下载 列表刷新搜索 几个按钮该怎么做呢?也很简单 增加这几个的属性该并为false即可:


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/974660c8d574c8f19bc73d1ae637673ec5a3ca2642dc7a94886ce90915dc7c45)


###### 全局



> 找到 **ry-ui.js** 将这几个属性设置为false即可


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/9ddfa85c8122a161cc924f4dc94181d8a28eb263c9b7f17a748ed88e7e9b24e8)


#### 表格初始化完成后执行的回调



> 其实就是BootstarpTable的回调函数 网上有很多介绍 这里直接放怎么使用就不介绍了

javascript复制代码onLoadSuccess: function (data) {
}

1
2

**用法:**

javascript复制代码

1
2
3
4
5
6
7
8

#### 表格固定左|右列后,滚动条被覆盖的bug以及解决


找到bootstrap-table-fixed-columns.js 的224行 heigth属性值减13就好了


更改后:

javascript复制代码this.$fixedBody.css({
width: this.$fixedHeader.width(),
height: height-13,
top: top + 1
}).show();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37



---




---




---


其他
--


#### JS循环



> 添加个链接这个博主写的很详细
>
>
> [blog.csdn.net/qq\_41899174…](https://blog.csdn.net/qq_41899174/article/details/82797089)


#### validator


1. ##### **动态校验提示信息**


来源:

javascript复制代码$.validator.addMethod(‘PD_password’, function (value, element) {
var len = value.length;
if(len<6){
$(element).data(‘error-msg’,’长度不能少于6位’);
return false;
}
if(len>15){
$(element).data(‘error-msg’,’长度不能大于15位’);
return false;
}
return true;
}, function(params, element) {
return $(element).data(‘error-msg’);
});

1
2. ##### **清空提示信息**

javascript复制代码$(“#form-consignor-add”).validate().resetForm();

1
2
3. ##### 单独校验指定输入框
4.

javascript复制代码// 某个表单里的指定行
$(“#form-xxx”).validate().element($(“#xxx”))

1
5. ##### validate使用tooltip提示错误信息

javascript复制代码
$(“#form-add”).validate({
rules: {
},
// 下边这些是重要的
unhighlight: function (element, errorClass, validClass) { //验证通过
$(element).tooltip(‘destroy’).removeClass(errorClass);
},
errorPlacement: function (error, element) {
if ($(element).next(“div”).hasClass(“tooltip”)) {
$(element).attr(“data-original-title”, $(error).text()).tooltip(“show”);
} else {
$(element).attr(“title”,
$(error).text()).tooltip(“show”);
}
},

});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
6. 


#### 放大图片



> 有时候为了页面美观显示的图片比较小 只能看到缩略图 但是在某些情况下又想看到放大后的图片 这时候就需要图片放大功能了 layui的就不介绍了 这里介绍两种其他的
>
>
> * 放大镜方法图片(鼠标悬浮在缩略图上就可以放大)
> * 弹出层放大图片(点击弹出遮罩层,放大图片)


**放大镜放大:**



> 使用 [jQuery Zoom Plugin](http://www.elevateweb.co.uk/image-zoom/)插件
>
>
> github: [github.com/elevateweb/…](https://github.com/elevateweb/elevatezoom)
>
>
> 文档地址: [www.myfreax.com/elevatezoom…](https://www.myfreax.com/elevatezoom-image-zoom/)



> **Html**
>
>
>
>

html复制代码

1
2
3
4
5
6
7
8
9
10
11



> **JQuery**
>
>
> 有六种显示效果 根据需要选择 推荐第一种 如果需要可以访问文档看它的其他属性
>
>
>
>

javascript复制代码
$(‘#zoom_01’).elevateZoom({});//默认效果

$(‘#zoom_01’).elevateZoom({ //内置镜头
zoomType: “inner”,//类型:内置镜头
cursor: “crosshair”, //光标:十字
zoomWindowFadeIn: 500,//镜头窗口淡入速度
zoomWindowFadeOut: 750 //镜头窗口淡出速度
});

$(“#zoom_01”).elevateZoom({ //镜头聚焦
zoomType: “lens”,//类型:透镜效果
lensShape: “round”, //透镜形状:圆形
lensSize: 200 //透镜尺寸:长和宽:200px
});

$(“#zoom_01”).elevateZoom({ //淡入/淡出设置
zoomWindowFadeIn: 500,//镜头窗口淡入速度
zoomWindowFadeOut: 500,//镜头窗口淡出速度
lensFadeIn: 500,//透镜淡入速度
lensFadeOut: 500//透镜淡出速度
});

$(“#zoom_01”).elevateZoom({ //动画
easing: true //是否开启动画效果
});

$(“#zoom_01”).elevateZoom({ //鼠标滚动
scrollZoom: true //是否开启鼠标滚动
});

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18


**弹出层放大:**



> 这个就是弹出一个遮罩层 显示图片 没什么好说的直接是上代码吧
>
>
> 原文链接: [blog.csdn.net/m0\_37865510…](https://blog.csdn.net/m0_37865510/article/details/84636488)



> **缩略图位置:**
>
>
>
>

html复制代码注意class为:pimg 下边会用到

1
2
3
4
5
6
7
8
9
10
11



> 在html最下边 添加下边这一段代码(**遮罩层**)
>
>
> 注意:**z-index:2;** 为遮罩层显示的高度如果想显示在最上层直接将2改为9999就行了
>
>
>
>

html复制代码




1
2
3
4
5
6
7
8



> JS:
>
>
>
>

javascript复制代码

1
2
3
4
5
6
7
8
9
10
11
12
13


#### 新建标签页



> 若依已经对创建新的标签页已经进行封装 JS方法为: createMenuItem()


1. $.modal.openTab()


现在 在ry-ui.js里已经又对新建菜单页(createMenuItem)进行了封装

javascript复制代码// 方式1 打开新的选项卡
function dept() {
var url = ctx + “system/dept”;
$.modal.openTab(“部门管理”, url);
}
// 方式2 选卡页同一页签打开
function dept() {
var url = ctx + “system/dept”;
$.modal.parentTab(“部门管理”, url);
}
// 方式3 html创建
部门管理

1
2. createMenuItem

javascript复制代码// 要打开的地址
var url=prefix+”/details?userId=”+userId;

// 调用createMenuItem()方法 1参:要打开的地址 ,2参:标签页名称
createMenuItem(url, “用户详情”);

1
2
3
4
5
6
7


> **注意**:
>
>
> 1. 如果提示调用 createMenuItem of undefined 那就记得引入 common.js 生成的代码里默认会引入
> 2. 提示random of undefined 就引入ry-ui.js

javascript复制代码

1
2
3
4
5
6


> **方法源代码:**
>
>
> 源代码就不贴出来了 贴出来也没什么意义 位置:

javascript复制代码common.js –> createMenuItem(dataUrl, menuName)

1
2

#### 关闭标签页

javascript复制代码// 源代码在index.js里
$(‘.tabCloseCurrent’).on(‘click’, function () {
$(‘.page-tabs-content’).find(‘.active i’).trigger(“click”);
});

// common.js增加了一个 closeItem方法
function closes() {
// 关闭当前页
closeItem();
// 关闭指定Item页, 123为指定的选项卡Id
closeItem(123);
}

1
2
3
4
5
6
7
8
9

#### 输入框锁定



> 这个相信大家都会 还是再写一下吧 这段话是从网站上直接复制过来的
>
>
> **disabled** 属性规定应该禁用 input 元素,被禁用的 input 元素,不可编辑,不可复制,不可选择,不能接收焦点,后台也不会接收到传值。设置后文字的颜色会变成灰色。disabled 属性无法与

javascript复制代码//disabled 属性无法与 一起使用。
示例:

1
2
3


> **readonly** 属性规定输入字段为只读可复制,但是,用户可以使用Tab键切换到该字段,可选择,可以接收焦点,还可以选中或拷贝其文本。后台会接收到传值. readonly 属性可以防止用户对值进行修改。

javascript复制代码// readonly 属性可与 或 配合使用。
示例:

1
2
3


> **readonly unselectable="on"** 该属性跟disable类似,input 元素,不可编辑,不可复制,不可选择,不能接收焦点,设置后文字的颜色也会变成灰色,但是后台可以接收到传值。

javascript复制代码示例:

1
2
3
4
5
6

#### 弹出某页面



> **弹窗**

javascript复制代码// 弹出添加用户积分日志页面
function open_account_log(userId) {
// 1. 调用方法弹出
$.modal.open(“用户积分修改”, ‘/system/accountDetailsLog/add’);
// 2. 指定弹窗宽高(后两个参数分别为宽,高)
$.modal.open(“用户积分修改”, ‘/system/accountDetailsLog/add’,’80’,’120’);

}

1
2

#### JS校验空值

javascript复制代码function isEmpty(obj){
if(typeof obj == “undefined” || obj == null || obj == “”){
return true;
}else{
return false;
}
}

1
2
3
4
5
6
7
8
9

#### JS绑定input事件



> js绑定input事件而不是使用改变值的change事件
>
>
> 可以实现输入值后就做某些操作 而不是在输入完然后失去焦点再进行触发

javascript复制代码 // 输入框自动去空格 其中propertychange 是对ie9以下浏览器的支持
$(“.form-control”).bind(“input propertychange”, function () {
$(this).val($(this).val().replace(/\s*/g, “”));
}
);

1
2
3
4
5
6
7
8
9

#### 自定义AJAX



> 这里是使用解绑按钮来进行示例
>
>
> 解绑操作不需要弹窗 如果直接调用封装好的修改的方法或者操作成功处理操作成功的方法会关闭弹窗刷新父级页面 导致全局刷新 这样写就可以 既可以向后台执行想要执行的操作 也可以弹出消息提醒 又不导致全局刷新 只刷新Table表格

javascript复制代码 // 上传文件
function sendFile(file, obj) {
var data = new FormData();
data.append(“file”, file);
$.ajax({
type: “POST”,
url: ctx + “common/upload”,
data: data,
cache: false,
contentType: false,
processData: false,
dataType: ‘json’,
success: function (result) {
if (result.code == web_status.SUCCESS) {
$(obj).summernote(‘editor.insertImage’, result.url, result.fileName);
} else {
$.modal.alertError(result.msg);
}
},
error: function (error) {
$.modal.alertWarning(“图片上传失败。”);
}
});
}

1
2

#### 添加Class元素

javascript复制代码 .abc{
background: red;
}
test div
var div = document.getElementById(‘d1’);
div.classlist.add(“abc”); //添加
div.classlist.remove(“abc”); //删除

1
2

#### 操作结果提示

javascript复制代码// 需要引入 ry-ui.js文件 content为提示文字

// 错误
$.modal.msg(content, modal_status.FAIL);
// 成功
$.modal.msg(content, modal_status.SUCCESS);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

#### 回显选中图片



> 如果需要**放大回显图片**可以看
>
>
> 前端 --> 其他 --> 放大图片



> 回显浏览器选中的图片
>
>
> 如果在选择文件的时候,只想显示图片文件可以这样写
>
>
>
>

html复制代码

1
2
3


**HTML示例:**

html复制代码








1
2

**JS:**

javascript复制代码 function changepic(obj) {
var reads = new FileReader();
f = document.getElementById(‘file’).files[0];
reads.readAsDataURL(f);
reads.onload = function (e) {
document.getElementById(‘show’).src = this.result;
};
}

1
2

#### JS创建集合对象

javascript复制代码// js中创建集合
var list=[];
// js中创建cs对象
var cs = {
id=1,
name=’admin’,
password=’admin’
}
//保存对象
list.push( cs );

1
2
3
4
5
6
7
8
9
10

#### 显示隐藏HTML



> 隐藏html代码块 分为两种方式隐藏
>
>
> 1. style="visibility: hidden;" (隐藏但是位置会占用)
> 2. style="display: none;" (隐藏并且位置会释放)

html复制代码 hello

1
2


javascript复制代码// visibility: none
document.getElementById(“id”).style.visibility=”hidden”;//隐藏

document.getElementById(“id”).style.visibility=”visible”;//显示

// display: none
var userType =2;
if (userType == 2) {
//获取要显示的div对象
document.getElementById(‘id’).style.display = “block”; //显示
} else {
document.getElementById(‘id’).style.display = “none”; // 隐藏
}

1
2

**Js版本**

js复制代码 $(“#id”).hide();// 隐藏
$(“#id”).show();// 显示

1
2
3
4
5
6
7
8
9
10

#### 页面加载完成执行



> 页面加载完成执行有两种加载时机
>
>
> 1. 页面所有资源加载完成后执行 (包括图片或者其他资源)
> 2. 页面的Dom结构在家完成就开始执行

javascript复制代码//1 资源加载完成才执行 (图片资源等等)
window.onload = function() {
};

//2 Dom加载完成就执行
$(document).ready(function() {
});

//2.1 简写
$(function() {
});

1
2

#### 默认全屏打开添加页

html复制代码
新增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158

Thymeleaf
---------


#### 标签


















































































































1.all:删除包含标签和所有的孩子。2.body:不包含标记删除,但删除其所有的孩子。3.tag:包含标记的删除,但不删除它的孩子。4.all-but-first:删除所有包含标签的孩子,除了第一个。5.none:什么也不做。这个值是有用的动态评估。









| **关键字** | **功能介绍** | **案例** |
| --- | --- | --- |
| th:id | 替换id | `<input th:id="'xxx' + ${collect.id}"/>` |
| th:text | 文本替换 | `<p th:text="${collect.description}">description</p>` |
| th:utext | 支持html的文本替换 | `<p th:utext="${htmlcontent}">content</p>` |
| th:object | 替换对象 | `<div th:object="${session.user}">` |
| th:value | 属性赋值 | `<input th:value = "${user.name}" />` |
| th:with | 变量赋值运算 | `<div th:with="isEvens = ${prodStat.count}%2 == 0"></div>` |
| th:style | 设置样式 | `<div th:style="'display:' + @{(${sitrue} ? 'none' : 'inline-block')} + ''"></div>` |
| th:onclick | 点击事件 | `<td th:onclick = "'getCollect()'"></td>` |
| th:each | 属性赋值循环 | `<tr th:each = "user,userStat:${users}">` |
| th:if | 判断条件 | `<a th:if = "${userId == collect.userId}">` |
| th:unless | 和th:if判断相反 | `<a th:href="@{/login} th:unless=${session.user != null}">Login</a>` |
| th:href | 链接地址 | `<a th:href="@{/login}" th:unless=${session.user != null}>Login</a>` |
| th:switch | 多路选择配合th:case使用 | `<div th:switch="${user.role}">` |
| th:fragment | th:switch的一个分支 | `<p th:case = "'admin'">User is an administrator</p>` |
| th:includ | 布局标签,替换内容到引入的文件 | `<head th:include="layout :: htmlhead" th:with="title='xx'"></head>` |
| th:replace | 布局标签,替换整个标签到引入的文件 | `<div th:replace="fragments/header :: title"></div>` |
| th:selectd | selected选择框选中 | `th:selected="(${xxx.id} == ${configObj.dd})"` |
| th:src | 图片类地址引入 | `<img class="img-responsive" alt="App Logo" th:src="@{/img/logo.png}" />` |
| th:inline | 定义js脚本可以使用变量 | `<script type="text/javascript" th:inline="javascript">` |
| th:action | 表单提交的地址 | `<form action="subscribe.html" th:action="@{/subscribe}">` |
| th:remove | 删除某个属性 | |
|
| th:attr | 设置标签属性,多个属性可以用逗号分隔 | 比如 th:attr="src=@{/image/aa.jpg},title=#{logo}",此标签不太优雅,一般用的比较少。 |


#### 循环

html复制代码
Onions
test@test.com.cn
yes
状态变量:index
状态变量:count
状态变量:size
状态变量:current
状态变量:even****
状态变量:odd
状态变量:first
状态变量:last

1
2

#### 判断

html复制代码<th:block th:if=”…”>





1
2

#### JS取值

javascript复制代码// 注意script属性

1
2
3
4
5
6

#### th:onclick



> 传递单个参数

html复制代码th:οnclick=”searchHot([[${hot.name}]])”

1
2
3


> 传递多个Model中参数

html复制代码th:οnclick=”‘javascript:searchHot('‘+${hot.name}+’','‘+${hot.hotType}+’')’”

1
2
3


> 传递一个Model中参数 一个非Model中参数

html复制代码th:οnclick=”‘javascript:searchHot('‘+${hot.name}+’',’+this+’)’”

1
2

#### 格式化时间

javascript复制代码th:value=”*{dates.format(reserveTime,’yyyy-MM-dd HH:mm:ss’)}”

1
2

#### 截取字符串

html复制代码msg.content为被截取的字符串 15为截取长度

1
2
3
4
5
6
7
8
9
10
11
12
13

##### 其他-网络文章



> [blog.csdn.net/zoubo0812/a…](https://blog.csdn.net/zoubo0812/article/details/54906297#commentBox)


JQuery相关
--------


#### 返回上页

javascript复制代码function goBack() {
window.history.go(-1);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123

后端
==


系统
--


### 当前用户



> 对当前登录用户的操作已经进行了封装
>
>
> 有获取当前登陆用户 获取当前登录用户ID 等这里不贴源码了
>
>
> 该工具类位置:
>
>
> **com.ruoyi.framework.util.ShiroUtils**


### 定时器


##### 关闭定时器



> 有时候定时器大家并不一定需要用到 又想把定时器阉割掉 时怎么做呢?
> 难道要删大量的定时器相关代码吗? 很显然不需要只需要将定时器在启动时初始化的注解注释掉就可以了


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/e871cc6fb7aa2314d52b22841c5df72048e794c59cb225569afeb224435791a1)


##### 新增定时器


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/a483fdca93b8e692898c8e770da6caffc22a709c2dfa4713628d18fbe9fadff4 "T$LC7~I4P[H8_QYDB[I33F4.png")


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/2dcbbb29626db9dd83d4065a8fa0cebf22f8f6bd18b62573ac1ccde5e40b09ce "屏幕截图.png")


**在系统监控 --> 定时任务 -->新增定时任务**


![输入图片说明](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/c870b6c64f347a0e8971c09738efac0a333b72b881a7aa8bce11b4411749197b "屏幕截图.png")




---




---




---




---


Controller
----------


### 关于权限



> 若依框架使用的是Shiro来进行权限控制
>
>
> 下边介绍一下在Controller新增一个请求地址 然后使这个地址被管理需要做那些操作


1. 添加注解
2. 插入sql
3. 按钮控制


Service
-------


Dao
---


Mapper
------


#### 属性封装



> 发现一篇很有意思的关于封装返回的文章 大概看了一下 这里记录一下 有时间再看
>
>
> [www.cnblogs.com/stefanking/…](https://www.cnblogs.com/stefanking/articles/5092474.html)



> 说到Mapper不得不说一下vo封装 之前都是写一个association然后再写相应的属性的result 然后导致mapper文件 全是result
>
>
> 这里介绍一下封装的时候引入本文件的其他 或**其他文件**里的


**POJO:**

java复制代码// 继承原实体类得到原实体类的所有属性
@Data
public class EquipmentVo extends Equipment implements Serializable
{
//增加两个属性
/** 供应商 */
private Supplier supplier;

    /** 所属用户 */
    private SysUser sysUser;

}
1
2
3
4
5
6
7


> **association** 标签中的属性
>
>
>
>

properties复制代码property: vo中的属性名
column: 该属性id对应的的字段名[非必须]
javaType: 该属性的Java类型
resultMap: 引入的本文件的话就是resultMap 的id
resultMap的id 引入其他文件的resultMap 时是xml文件所对应的Dao的全路径加 resultMap的Id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
> 
> ​ 例:
>
>
> **注意:**
>
>
> **Association和Collection使用时机区别:**
>
>
> **Association 在一对一,多对一时使用**
>
>
> **Collection 在一对多,多对多时使用**
>
>
> 在association标签中 javaType属性指向的是**实体类的属性**
>
>
> 在collection标签中 javaType属性指向的是**集合的类型** ofType指向的是集合的泛型类型


**Mapper:**

xml复制代码


    <!-- 供应商 引入其他文件的resultMap 路径dao层路径加resultMap 的id-->
    <association property="supplier" column="s.id" javaType="com.ruoyi.iot.domain.Supplier" resultMap="com.ruoyi.iot.mapper.SupplierMapper.SupplierResult">
        <result property="createTime" column="screate_time"/>
    </association>
    <!--用户 引入当前文件resultMap-->
    <association property="sysUser" column="user_id" javaType="com.ruoyi.system.domain.SysUser" resultMap="SysUserResult">
    </association>
</resultMap>
<resultMap type="com.ruoyi.system.domain.SysUser" id="SysUserResult">
    <id property="userId" column="user_id"/>
    <result property="deptId" column="dept_id"/>
    <result property="loginName" column="login_name"/>
</resultMap>
1
2
3
4
5
6
7
8

#### 集合遍历


##### 单参数


**Dao:**

java复制代码Integer selectEquipmentNumber(List statusList);

1
2

**XML:**

xml复制代码
equipment_status = #{item}

1
2
3
4
5
6
7
8

##### 多参数:


**Dao:**


**XML:**

xml复制代码

1
2
3
4
5
6

其他
--


#### MP忽略null值

java复制代码 // 修改时忽略null 和空值
@TableField(strategy = FieldStrategy.IGNORED)
private BigDecimal discountPrice;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

#### BigDecimal使用



> 直接复制过来的就不在重复写了
>
>
> 注意除法 divide 是重载的方法 如果直接 a.divide(b) 的话可能会出现该异常:
>
>
> **[ Non-terminating decimal expansion; no exact representable decimal result.]**
>
>
> 相当于10/3 无法除尽 尽量指定小数长度和规则

java复制代码BigDecimal bignum1 = new BigDecimal(“10”);
BigDecimal bignum2 = new BigDecimal(“5”);
BigDecimal bignum3 = null;

// 加法
bignum3 = bignum1.add(bignum2);
System.out.println(“和 是:” + bignum3);

// 减法
bignum3 = bignum1.subtract(bignum2);
System.out.println(“差 是:” + bignum3);

// 乘法
bignum3 = bignum1.multiply(bignum2);
System.out.println(“积 是:” + bignum3);

// 除法
bignum3 = bignum1.divide(bignum2);
System.out.println(“商 是:” + bignum3);

// 除法2
// scale表示保留小数位数 roundingMode表示为小数模式
divide(BigDecimal divisor, int scale, RoundingMode roundingMode) ;

BigDecimal bignum3 = bignum1.divide(bignum2,10,ROUND_HALF_DOWN);

1
2

**小数模式常量**

properties复制代码ROUND_CEILING=如果 BigDecimal 是正的,则做 ROUND_UP 操作;如果为负,则做 ROUND_DOWN 操作。
ROUND_DOWN=从不在舍弃(即截断)的小数之前增加数字。
ROUND_FLOOR=如果 BigDecimal 为正,则作 ROUND_UP ;如果为负,则作 ROUND_DOWN 。
ROUND_HALF_DOWN=若舍弃部分> .5,则作 ROUND_UP;否则,作 ROUND_DOWN 。
ROUND_HALF_EVEN=如果舍弃部分左边的数字为奇数,则作 ROUND_HALF_UP ;如果它为偶数,则作ROUND_HALF_DOWN 。
ROUND_HALF_UP=若舍弃部分>=.5,则作 ROUND_UP ;否则,作 ROUND_DOWN 。
ROUND_UNNECESSARY=该“伪舍入模式”实际是指明所要求的操作必须是精确的,因此不需要舍入操作。
ROUND_UP=总是在非 0 舍弃小数(即截断)之前增加数字。

1
2

正整数判断

java复制代码BigDecimal bigDecimal = new BigDecimal();
int num = bigDecimal.signum();
// num是-1, 负数
// num是0, 零
// num是1,正数

1
2

**比较大小**

java复制代码
BigDecimal a = new BigDecimal (101);
BigDecimal b = new BigDecimal (111);

//使用compareTo方法比较
//注意:a、b均不能为null,否则会报空指针
if(a.compareTo(b) == -1){
System.out.println(“a小于b”);
}

if(a.compareTo(b) == 0){
System.out.println(“a等于b”);
}

if(a.compareTo(b) == 1){
System.out.println(“a大于b”);
}

if(a.compareTo(b) > -1){
System.out.println(“a大于等于b”);
}

if(a.compareTo(b) < 1){
System.out.println(“a小于等于b”);
}

1
2

**转String**

java复制代码 // 浮点数的打印 10000000000
System.out.println(new BigDecimal(“10000000000”).toString());

// 普通的数字字符串 100.000
System.out.println(new BigDecimal("100.000").toString());

// 去除末尾多余的0 1E+2
System.out.println(new BigDecimal("100.000").stripTrailingZeros().toString());

// 避免输出科学计数法 100
System.out.println(new BigDecimal("100.000").stripTrailingZeros().toPlainString());
1
2
3
4
5
6
7
8
9
10
11
12
13

MYSQL
-----


#### 获取时间



> **mysql查询当前系统时间不保留时分秒,保留时分秒,当前时间加一天,当前时间减少一天等**
>
>
> 转发至CSDN **原文链接 :**[blog.csdn.net/liu\_yulong/…](https://blog.csdn.net/liu_yulong/article/details/90447555)

mysql复制代码SELECT
CURDATE( ),
now( ),
CURTIME( ),
date_sub( CURDATE( ), INTERVAL 1 DAY ) yestorday,
date_sub( CURDATE( ), INTERVAL 1 DAY ) today
FROM
DUAL;

1
2

![img](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/78ce6de828abe13278dbbc1e3fecc894dd3bfb78b1af8f9f4cfa7e024b097af1)

mysql复制代码
SELECT
CURDATE( ) date,
now( ) dateTime,
DATE_FORMAT( now( ), ‘%Y-%m-%d’ ) dateFmt,
DATE_FORMAT( now( ), ‘%Y-%m-%d’ ) dateTimeFmt,
DATE_FORMAT( now( ), ‘%Y-%m-%d %H:%i:%s’ ) daymins,
CURTIME( ) times,
date_sub( CURDATE( ), INTERVAL 1 DAY ) Yesterday ,
date_sub( CURDATE( ), INTERVAL 0 DAY ) Today ,
date_sub( CURDATE( ), INTERVAL 1 DAY ) Tomorrow
FROM
DUAL;


![img](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/396369448d3b7e99cd4c58f44db0096dcc16d0127c34077bba180fe986844899)



**本文转载自:** [掘金](https://juejin.cn/post/6971684849094492168)

*[开发者博客 – 和开发相关的 这里全都有](https://dev.newban.cn/)*

RocketMQ-顺序消息

发表于 2021-06-09

这一讲我们来讲顺序消息。

顺序消息是什么

首先,什么是顺序消息?

指的是按照消息的发送顺序来消费。RocketMQ中可以保证消息严格有序,可以分为局部有序和全局有序。

局部有序

什么是局部有序?

在每个MessageQueue里面的每一条消息之间都是保持相对有序的。但是不保证所有MessageQueue的消息都严格有序。

举例例子:
订单1:创建-下单-付款-完成
订单2:创建-下单-付款

订单1和订单2,分别在不同的MessageQueue上,它们只需要保证MessageQueue里面的消息有序即可。

一个MessageQueue只能由一个消费者消费,且只能单线程消费。但是这个消费者可以开启多线程,同时消费多个MessageQueue。

全局有序

既然你已经知道了局部有序,那全局有序就更加简单了。

就是只有一个MessageQueue。这样子所有的消息,都会被发送到这个MessageQueue上。这样子就能保证所有的消息严格有序。

一个MessageQueue只能由一个消费者消费,且只能单线程消费。

生产者顺序发送消息

接下来,我们用代码来展示一下局部有序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
ini复制代码public class OrderedProducer {
public static void main(String[] args) throws Exception {
//Instantiate with a producer group name.
DefaultMQProducer producer = new DefaultMQProducer("order_producer_group");


producer.setNamesrvAddr("localhost:9876");
//启动生产者
producer.start();


List<OrderEntity> list = buildOrderList();
for (int i = 0; i < list.size(); i++) {
int orderId = list.get(i).getId();
//Create a message instance, specifying topic, tag and message body.
Message msg = new Message("orderTopic", "TagA", "KEY" + i,
(list.get(i).toString()).getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer id = (Integer) arg;
int index = id % mqs.size();
return mqs.get(index);
}
}, orderId);

System.out.println("订单id:"+orderId+ " 发送结果:"+ sendResult);
}
//关闭生产者
producer.shutdown();
}

private static List<OrderEntity> buildOrderList() {
List<OrderEntity> res = new ArrayList<>();

OrderEntity order1 = new OrderEntity(147,"加入购物车");
OrderEntity order2 = new OrderEntity(147,"下单");
OrderEntity order3 = new OrderEntity(147,"付款");
OrderEntity order4 = new OrderEntity(147,"完成");

OrderEntity order5 = new OrderEntity(258,"加入购物车");
OrderEntity order6 = new OrderEntity(258,"下单");

OrderEntity order7 = new OrderEntity(369,"加入购物车");
OrderEntity order8 = new OrderEntity(369,"下单");
OrderEntity order9 = new OrderEntity(369,"付款");

res.add(order1);
res.add(order2);
res.add(order3);
res.add(order4);
res.add(order5);
res.add(order6);
res.add(order7);
res.add(order8);
res.add(order9);

return res;
}
}
//运行结果:
订单id:147 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B1F80000, offsetMsgId=0AFCA6FA00002A9F000000000009FBA7, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=3], queueOffset=44]
订单id:147 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B1FD0001, offsetMsgId=0AFCA6FA00002A9F000000000009FC96, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=3], queueOffset=45]
订单id:147 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B1FF0002, offsetMsgId=0AFCA6FA00002A9F000000000009FD7C, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=3], queueOffset=46]
订单id:147 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B2010003, offsetMsgId=0AFCA6FA00002A9F000000000009FE62, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=3], queueOffset=47]
订单id:258 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B2020004, offsetMsgId=0AFCA6FA00002A9F000000000009FF48, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=2], queueOffset=42]
订单id:258 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B2040005, offsetMsgId=0AFCA6FA00002A9F00000000000A0037, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=2], queueOffset=43]
订单id:369 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B2050006, offsetMsgId=0AFCA6FA00002A9F00000000000A011D, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=1], queueOffset=63]
订单id:369 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B2060007, offsetMsgId=0AFCA6FA00002A9F00000000000A020C, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=1], queueOffset=64]
订单id:369 发送结果:SendResult [sendStatus=SEND_OK, msgId=7F0000010A0118B4AAC22BE4B2070008, offsetMsgId=0AFCA6FA00002A9F00000000000A02F2, messageQueue=MessageQueue [topic=orderTopic, brokerName=aarondeMacBook-Pro.local, queueId=1], queueOffset=65]

总结:根据不同订单id的取模,把不同订单的消息分配到不同的MessageQueue,把相同订单消息分配到相同的MessageQueue。

你看,订单id=147的消息,都发送都queue3中;

订单id=258的消息,都发送都queue2中;

订单id=369的消息,都发送都queue1中。

消费者顺序消费消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ini复制代码public class OrderedConsumer {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order_consumer");
consumer.setNamesrvAddr("localhost:9876");
//设置从哪里开始消费
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

consumer.subscribe("orderTopic", "TagA");
//确保一个queue只被一个线程消费
consumer.registerMessageListener(new MessageListenerOrderly() {

@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeOrderlyContext context) {
for (MessageExt msg : msgs) {
System.out.println("["+Thread.currentThread().getName()+"] "+new String(msg.getBody()));
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
//启动消费者
consumer.start();

System.out.println("消费者已启动");
}
}
\\运行结果
[ConsumeMessageThread_1] OrderEntity{id=147, name='加入购物车'}
[ConsumeMessageThread_2] OrderEntity{id=258, name='加入购物车'}
[ConsumeMessageThread_3] OrderEntity{id=369, name='加入购物车'}
[ConsumeMessageThread_1] OrderEntity{id=147, name='下单'}
[ConsumeMessageThread_3] OrderEntity{id=369, name='下单'}
[ConsumeMessageThread_1] OrderEntity{id=147, name='付款'}
[ConsumeMessageThread_2] OrderEntity{id=258, name='下单'}
[ConsumeMessageThread_3] OrderEntity{id=369, name='付款'}
[ConsumeMessageThread_1] OrderEntity{id=147, name='完成'}

总结:可以看到,线程1,都是消费了id=147的消息,证明queue3只被一个线程所消息。

对比分析

我们看一下顺序消息的消费者,消费的时候,我们用的是MessageListenerOrderly。是用来告诉消费者,要顺序消费信息,并且只能一个线程去单独消费消息。

普通消息的消费者:MessageListenerConcurrently。看到Concurrent就知道是并发的意思,就是可以并发消费消息。

适用场景

天上飞的理论,终究还得有落地的实现。

适用场景:业务中,需要保持顺序的。比如:数据库的binlog消息,订单的创建、下单、付款等消息。

好了,这一节说得差不多了。

有问题的话,欢迎留言交流。

每日一问

最后,全局有序,你就把MessageQueue设置为1就好。那问题来了,如何设置MessageQueue为1呢?

欢迎留言

后续文章

  • RocketMQ-入门(已更新)
  • RocketMQ-架构和角色(已更新)
  • RocketMQ-消息发送(已更新)
  • RocketMQ-消费信息
  • RocketMQ-消费者的广播模式和集群模式(已更新)
  • RocketMQ-顺序消息(已更新)
  • RocketMQ-延迟消息
  • RocketMQ-批量消息
  • RocketMQ-过滤消息
  • RocketMQ-事务消息
  • RocketMQ-消息存储
  • RocketMQ-高可用
  • RocketMQ-高性能
  • RocketMQ-主从复制
  • RocketMQ-刷盘机制
  • RocketMQ-幂等性
  • RocketMQ-消息重试
  • RocketMQ-死信队列
    …

欢迎各位入(guan)股(zhu),后续文章干货多多。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Cloud & Alibaba 实战 第十

发表于 2021-06-09

目录

  • 一. Sentinel概念
+ [1. 什么是Sentinel?](#1-%E4%BB%80%E4%B9%88%E6%98%AFsentinel)
+ [2. Sentinel功能特性](#2-sentinel%E5%8A%9F%E8%83%BD%E7%89%B9%E6%80%A7)
+ [3. Sentinel VS Hystrix](#3-sentinel-vs-hystrix)
  • 二. Docker部署Sentinel Dashboard
+ [1. 拉取镜像](#1-%E6%8B%89%E5%8F%96%E9%95%9C%E5%83%8F)
+ [2. 启动容器](#2-%E5%90%AF%E5%8A%A8%E5%AE%B9%E5%99%A8)
+ [3. 访问测试](#3-%E8%AE%BF%E9%97%AE%E6%B5%8B%E8%AF%95)
  • 三. Sentinel网关流控
+ [1. 网关流控定义](#1-%E7%BD%91%E5%85%B3%E6%B5%81%E6%8E%A7%E5%AE%9A%E4%B9%89)
+ [2. 网关流控规则](#2-%E7%BD%91%E5%85%B3%E6%B5%81%E6%8E%A7%E8%A7%84%E5%88%99)
+ [3. 导入依赖](#3-%E5%AF%BC%E5%85%A5%E4%BE%9D%E8%B5%96)
+ [4. 网关配置](#4-%E7%BD%91%E5%85%B3%E9%85%8D%E7%BD%AE)
+ [5. 网关流控客户端标识](#5-%E7%BD%91%E5%85%B3%E6%B5%81%E6%8E%A7%E5%AE%A2%E6%88%B7%E7%AB%AF%E6%A0%87%E8%AF%86)
+ [6. 测试需求制定](#6--%E6%B5%8B%E8%AF%95%E9%9C%80%E6%B1%82%E5%88%B6%E5%AE%9A)
+ [7. Nacos添加网关流控规则](#7-nacos%E6%B7%BB%E5%8A%A0%E7%BD%91%E5%85%B3%E6%B5%81%E6%8E%A7%E8%A7%84%E5%88%99)
+ [8. 网关流控测试](#8-%E7%BD%91%E5%85%B3%E6%B5%81%E6%8E%A7%E6%B5%8B%E8%AF%95)
+ [9. 自定义网关流控异常](#9--%E8%87%AA%E5%AE%9A%E4%B9%89%E7%BD%91%E5%85%B3%E6%B5%81%E6%8E%A7%E5%BC%82%E5%B8%B8)
  • 四. Sentinel普通流控
+ [1. 普通流控定义](#1-%E6%99%AE%E9%80%9A%E6%B5%81%E6%8E%A7%E5%AE%9A%E4%B9%89)
+ [2. 普通流控的规则](#2-%E6%99%AE%E9%80%9A%E6%B5%81%E6%8E%A7%E7%9A%84%E8%A7%84%E5%88%99)
+ [3. 导入依赖](#3-%E5%AF%BC%E5%85%A5%E4%BE%9D%E8%B5%96)
+ [4. 微服务配置](#4-%E5%BE%AE%E6%9C%8D%E5%8A%A1%E9%85%8D%E7%BD%AE)
+ [5. Nacos添加流控规则](#5-nacos%E6%B7%BB%E5%8A%A0%E6%B5%81%E6%8E%A7%E8%A7%84%E5%88%99)
+ [6. 普通流控测试](#6--%E6%99%AE%E9%80%9A%E6%B5%81%E6%8E%A7%E6%B5%8B%E8%AF%95)
+ [7. 自定义异常](#7-%E8%87%AA%E5%AE%9A%E4%B9%89%E5%BC%82%E5%B8%B8)
  • 五. Sentinel熔断降级
+ [1. 熔断降级概述](#1-%E7%86%94%E6%96%AD%E9%99%8D%E7%BA%A7%E6%A6%82%E8%BF%B0)
+ [2. 熔断策略](#2-%E7%86%94%E6%96%AD%E7%AD%96%E7%95%A5)
+ [3. 熔断降级规则](#3-%E7%86%94%E6%96%AD%E9%99%8D%E7%BA%A7%E8%A7%84%E5%88%99)
+ [4. 微服务配置](#4-%E5%BE%AE%E6%9C%8D%E5%8A%A1%E9%85%8D%E7%BD%AE)
+ [5. Nacos添加熔断降级规则](#5--nacos%E6%B7%BB%E5%8A%A0%E7%86%94%E6%96%AD%E9%99%8D%E7%BA%A7%E8%A7%84%E5%88%99)
+ [6. 熔断异常模拟](#6-%E7%86%94%E6%96%AD%E5%BC%82%E5%B8%B8%E6%A8%A1%E6%8B%9F)
+ [7. 熔断降级测试](#7--%E7%86%94%E6%96%AD%E9%99%8D%E7%BA%A7%E6%B5%8B%E8%AF%95)
  • 六. Sentinel整合Feign熔断降级
+ [1. Feign与Sentinel整合意义](#1--feign%E4%B8%8Esentinel%E6%95%B4%E5%90%88%E6%84%8F%E4%B9%89)
+ [2. 导入依赖](#2--%E5%AF%BC%E5%85%A5%E4%BE%9D%E8%B5%96)
+ [3. 微服务配置](#3-%E5%BE%AE%E6%9C%8D%E5%8A%A1%E9%85%8D%E7%BD%AE)
+ [4. 熔断降级规则](#4-%E7%86%94%E6%96%AD%E9%99%8D%E7%BA%A7%E8%A7%84%E5%88%99)
+ [5. 熔断降级测试](#5-%E7%86%94%E6%96%AD%E9%99%8D%E7%BA%A7%E6%B5%8B%E8%AF%95)
  • 七. 结语
  • 八. 附录

一. Sentinel概念

1. 什么是Sentinel?

Sentinel是阿里中间件团队研发面向分布式服务架构的轻量级高可用流量控制组件,主要以流量为切入点,从限流、流量整形、熔断降级、系统负载保护、热点防护等多个维度来帮助开发者保障微服务的稳定性。于2012年诞生,后续在阿里巴巴集团内部迅速发展,成为基础技术模块,覆盖了所有的核心场景,Sentinel也因此积累了大量的流量归整场景及生产实践。最终在2018年7月宣布对外界开源。

Sentinel的基本概念:

  • 资源: Sentinel 的关键概念,可以是Java应用程序中任何内容,通过Sentinel API定义的代码,能够被Sentinel保护起来,大部份情况下,可以使用方法签名,URL,甚至服务名称作为资源名
  • 规则: 围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。

Sentinel分为两个部分:

  • 控制台(Dashboard)基于 Spring Boot 开发,打包后可以直接运行,不需要额外的 Tomcat 等应用容器,也就是sentinel-dashboard-1.8.1.jar。
  • 核心库(Java 客户端)不依赖任何框架/库,能够运行于所有 Java 运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。

2. Sentinel功能特性

从上图可知Sentinel的功能特性很多以及应用场景非常广泛,以下就限流、熔断降级这几个常见的功能特性以及意义进行简要说明

限流: 限流同时也叫做流量控制,原理是监控应用流量的QPS或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。

熔断降级: 除了流量控制以外,及时对调用链路中的不稳定因素进行熔断也是Sentinel的使命之一。由于调用关系的复杂性,如果调用链路中的某个资源不稳定,最终会导致请求发生堆积,进而导致级联错误。Sentinel当检测到调用链路中某个资源出现不稳定的表现(请求响应时间长或异常比例升高),则对这个资源的调用进行限制,让请求快速失败,避免影响到其他资源而导致级联故障。

  • 熔断: 拒绝流量访问,当系统恢复正常时关闭熔断。
  • 降级: 将次要服务降级,停止服务,将系统资源放出来给核心功能

3. Sentinel VS Hystrix

以下摘自Sentinel官方文档,详情点击 Sentinel 与 Hystrix 的对比

Sentinel Hystrix
隔离策略 信号量隔离 线程池隔离/信号量隔离
熔断降级策略 基于响应时间或失败比率 基于失败比率
实时指标实现 滑动窗口· 滑动窗口(基于 RxJava)
规则配置 支持多种数据源 支持多种数据源
扩展性 多个扩展点 插件的形式
基于注解的支持 支持 支持
限流 基于 QPS,支持基于调用关系的限流 有限的支持
流量整形 支持慢启动、匀速器模式 不支持
系统负载保护 支持 不支持
控制台 开箱即用,可配置规则、查看秒级监控、机器发现等 不完善
常见框架的适配 Servlet、Spring Cloud、Dubbo、gRPC 等 Servlet、Spring Cloud Netflix

Sentinel 和 Hystrix 的原则是一致的,但是在限制手段上,Sentinel和Hystrix采取了完全不一样的方法。

  • Hystrix: 通过线程池隔离,来对依赖(在Sentinel的概念对应资源)进行了隔离。好处在于资源之间做到最彻底的隔离,缺点是除了增加了线程切换的成本,还需要预先给各个资源做线程池大小的分配。
  • Sentinel : 通过并发线程数进行限制和响应时间对资源进行降级两种手段。

二. Docker部署Sentinel Dashboard

1. 拉取镜像

1
bash复制代码docker pull bladex/sentinel-dashboard

2. 启动容器

1
css复制代码docker run --name sentinel -d -p 8858:8858 -d bladex/sentinel-dashboard

3. 访问测试

访问:http://IP:8858

用户名/密码:sentinel/sentinel

image-20210404135319822

三. Sentinel网关流控

1. 网关流控定义

Sentinel支持对Spring Cloud Gateway、Zuul等主流的API Gataway 进行限流,作用在网关的流控称之为网关流控,其实现原理请点击网关限流进入官方Wiki查看。

这里只把原理图从官方文档摘出来,需多关注图中提到的模块名和几个类名,因为都是核心级别的存在。

规则类型gw-flow和gw-api-group为网关流控规则,具体类型请查看规则类型枚举RuleType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码/**
* flow 流控规则
*/
FLOW("flow", FlowRule.class),
/**
* degrade 降级规则
*/
DEGRADE("degrade", DegradeRule.class),
/**
* param flow 热点规则
*/
PARAM_FLOW("param-flow", ParamFlowRule.class),
/**
* system 系统规则
*/
SYSTEM("system", SystemRule.class),
/**
* authority 授权规则
*/
AUTHORITY("authority", AuthorityRule.class),
/**
* gateway flow 网关限流规则
*/
GW_FLOW("gw-flow","com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayFlowRule"),
/**
* api 用户自定义的 API 定义分组,可以看做是一些 URL 匹配的组合
*/
GW_API_GROUP("gw-api-group","com.alibaba.csp.sentinel.adapter.gateway.common.api.ApiDefinition");

image-20210408141627006

2. 网关流控规则

Field 说明 默认值
resource 资源名称,网关route或自定义API分组名称(注:网关route这里的值不是route.id,可调试
resourceMode 限流资源类型,网关route【0】或自定义API分组【1】(详细查看GatewayFlowRule和SentinelGatewayConstants) 网关route
grade 限流阈值类型,QPS【1】或线程数【0】 QPS
count 限流阈值,QPS阈值或线程数值
intervalSec 统计时间间隔,单位秒 1秒
controlBehavior 流控效果,目前支持快速失败【0】和匀速排队【1】 快速失败
burst 应对突发请求时额外允许的请求数目
maxQueueingTimeoutMs 匀速排队模式下的最长排队时间,单位毫秒,仅在匀速排队模式下生效
paramItem 参数属性配置,parseStrategy:提取参数策略(0:Clien IP,1:Remote HOST,2:Header,3:请求参数,4:Cookie);fieldName:若提取策略是Header模式或者URL参数模式,则需要指定header名称或URL参数名称;pattern:参数值的匹配模式;matchStrategy:参数值的匹配策略,支持精确匹配,子串匹配和正则匹配。

3. 导入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xml复制代码<!-- Sentinel流量控制、熔断降级 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!-- Sentinel规则持久化至Nacos配置 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>

4. 网关配置

先放在本地Spring Boot配置文件bootstrap-dev.yml中,后面测试通过把后再把Sentinel配置放至Nacos

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
yaml复制代码spring:
cloud:
nacos:
# 注册中心
discovery:
server-addr: http://localhost:8848
# 配置中心
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
file-extension: yaml
sentinel:
enabled: true # sentinel开关
eager: true
transport:
dashboard: localhost:8080 # Sentinel控制台地址
client-ip: localhost
datasource:
# 网关限流规则,gw-flow为key,随便定义
gw-flow:
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
dataId: ${spring.application.name}-gw-flow-rules # 流控规则配置文件名:youlai-gateway-gw-flow-rules
groupId: SENTINEL_GROUP
data-type: json
rule-type: gw-flow
# 自定义API分组,gw-api-group为key,随便定义
gw-api-group:
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
dataId: ${spring.application.name}-gw-api-group-rules # 流控规则配置文件名:youlai-gateway-gw-api-group-rules
groupId: SENTINEL_GROUP
data-type: json
rule-type: gw-api-group

这里解释下配置中的datasource,因为在Sentinel添加流控规则之后,如果重启服务,之前配置的规则就会消失,所以这里需要持久化Sentinel配置,从上面的配置可以看出选择的是Nacos。不过这里先别急在Nacos添加网关流控规则,下文在测试确认需求后配置。

5. 网关流控客户端标识

网关流控和普通流控有很多区别,其中网关流控类型是gw-flow,普通流控类型是flow

怎么标识流控是网关类型呢?

很多博客文章都没有着重此点,因为前阵子纠结于网关流控的面板和普通流控的面板不一致而去搜相关的资料,最后还是在Sentinel官方文档中找到此开关,就是需要在youlai-gateway网关应用添加JVM启动参数。

1
2
yaml复制代码# 注:通过 Spring Cloud Alibaba Sentinel 自动接入的 API Gateway 整合则无需此参数
-Dcsp.sentinel.app.type=1

具体如下图:

image-20210408133502518

6. 测试需求制定

在做好上面Sentinel Dashboard部署以及Spring Cloud Gateway整合Sentinel工作之后,Sentinel的两个部分(控制台和Java客户端)也就齐活了,那么接下来就进入测试。

下图简单的描述了OAuth2认证接口的流程,用户请求网关,网关(youlai-gateway)根据请求标识转发至认证中心,认证中心(youlai-auth)需要从系统服务(youlai-admin)获取数据库的用户信息再和请求携带的用户信息进行密码判读,成功则返回token给用户。

针对以上的OAuth2认证流程,提出来一个需求:

假设认证中心服务的QPS上限为10,系统服务的QPS上限为5,如何通过Sentinel实现限流控制?

温馨提示: 留意下上图中的红线部分,你认为网关的流控是否能限制到与其间接相关的系统服务youlai-admin吗?

7. Nacos添加网关流控规则

进入Nacos控制台,添加网关流控规则,具体内容参考网关流控字段说明。

需要注意的是资源名称resource不是路由中配置的route的id,在开启Sentinel时调试SentinelGatewayFilter#filter方法可以看到自动生成的是固定前缀ReactiveCompositeDiscoveryClient_拼接应用名称${spring.application.name},所以在配置文件中一定要按照自动生成的规则配置resource的值。填写网关路由route的id,Sentinel的网关流控是无法生效的。

image-20210410094637495

上面网关流控规则中,限制了认证中心youlai-auth的QPS上限为10,系统服务youlai-admin的QPS上限为5。

至于为什么这么设定,因为有个猜想需要验证,网关流控是否只能限制和直接关联的youlai-auth,而不能限制间接相关的youlai-admin。

如果通过的QPS是10,那说明网关流控不能控制间接相关youlai-admin,如果通过的QPS是5,则说明网关流控能控制间接相关的youlai-admin,至于结果,先留个悬念吧,看下文的测试结果。

已添加的网关流控规则如下图:

image-20210410094826077

在Nacos添加了网关流控规则之后,会同步到Sentinel,进入Sentinel控制台查看

image-20210410144417922

8. 网关流控测试

在完成上述步骤之后,接下来就进入真正的测试环节。

添加线程组

测试计划(鼠标右击)->添加->线程(用户)->线程组

image-20210410104749030

因为youlai-auth的QPS处理上限为10,所以这里的线程数大于10即可看到被限制的请求

image-20210410105330921

添加HTTP请求

OAuth2登录线程组(鼠标右击)->添加->取样器->HTTP请求

image-20210410105624953

接口是通过网关转发到认证中心的认证接口获取token

image-20210410110644266

添加察看结果树

因为要看请求的响应,所以这里添加察看结果树。

OAuth2登录线程组(鼠标右击)->添加->监听器->察看结果树

image-20210410112100659

启动线程组测试

启动线程组,每秒15次认证请求,需要注意的是,如果测试计划有多个线程组,需禁用除了测试之外的其他线程组。

image-20210410135357237

点击察看结果树查看请求的情况

image-20210411184249307

进入Sentinel控制台,查看实时监控

image-20210411184545190

可以看到1秒15次请求,因为流控设置的QPS上限是10,所以10次通过,被Sentinel拒绝了5次。

这个结果也直接说明了网关流控并不是万能的,不能限制OAuth2认证请求中与其间接相关youlai-admin的微服务,因为在网关流控设置了youlai-admin的QPS上线为5,但最后整条链路成功的却是10。既然网关流控无法应对此类场景,是否还有其他的办法来做到呢?当然有:普通流控。

9. 自定义网关流控异常

上面Sentinel限流的默认异常响应如下

1
json复制代码{"code":429,"message":"Blocked by Sentinel: ParamFlowException"}

假如想自定义网关流控异常响应,该如何实现呢?

可以通过在GatewayCallbackManager上通过setBlockHandler方法注册回调实现,当请求被限流后,实现自定义的异常响应。

image-20210411233958606

自定义异常代码:

1
2
3
4
5
6
7
8
java复制代码@PostConstruct
private void initBlockHandler() {
BlockRequestHandler blockRequestHandler = (exchange, t) ->
ServerResponse.status(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(ResultCode.FLOW_LIMITING.toString()));
GatewayCallbackManager.setBlockHandler(blockRequestHandler);
}

JMeter中查看被限流的响应,可以看到已按照自定义的响应异常返回,其中B0210 是Java开发手册上的关于系统限流的错误码

image-20210411234203870

四. Sentinel普通流控

1. 普通流控定义

1
2
3
4
java复制代码/**
* flow 流控规则,详情查看RuleType
*/
FLOW("flow", FlowRule.class)

作用在网关的流控称之为网关流控,相对的作用在除网关之外的微服务流控这里称为普通流控

在上一个章节中,发现网关流控并不是万能的,像认证中心youlai-auth调用系统服务youlai-admin这种微服务相互调用而不走网关的情况,网关流控表示无能为力,但不可否认的是网关流控确实能够应对大多数场景的流控。

所以在像上文中的网关流控无能为力的案例,则需要普通流控的救场。

2. 普通流控的规则

Field 说明 默认值
resource 资源名,资源名是限流规则的作用对象
count 限流阈值
grade 限流阈值类型,QPS 【1】或线程数模式【0】 QPS 模式
limitApp 流控针对的调用来源 default,代表不区分调用来源
strategy 判断的根据是资源自身,还是根据其它关联资源 (refResource),还是根据链路入口 根据资源本身
controlBehavior 流控效果(直接拒绝 / 排队等待 / 慢启动模式) 直接拒绝

3. 导入依赖

1
2
3
4
5
6
7
8
9
10
xml复制代码<!-- Sentinel流量控制、熔断降级 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!-- Sentinel规则持久化至Nacos配置 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>

4. 微服务配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
yaml复制代码spring:
application:
name: youlai-admin
cloud:
nacos:
discovery:
server-addr: http://localhost:8848
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
file-extension: yaml
sentinel:
enabled: true
eager: true # 取消控制台懒加载,项目启动即连接Sentinel
transport:
client-ip: localhost
dashboard: localhost:8080
datasource:
# 限流规则,flow为key,随便定义
flow:
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
dataId: ${spring.application.name}-flow-rules
groupId: SENTINEL_GROUP
data-type: json
rule-type: flow

5. Nacos添加流控规则

进入Nacos控制台,添加规则配置文件,接着上文的案例,在认证的时候,youlai-auth需通过feign调用youlai-admin根据用户名获取用户信息。

image-20210412221145722

进入Sentinel控制台查看,除了刚在Nacos添加的规则之外,还可以看到普通流控面板和网关流控面板的区别

image-20210412221507202

6. 普通流控测试

经过网关流控限制只能有10条请求到youlai-auth,接下来youlai-auth调用youlai-admin链路中,因为限制了youlai-admin的QPS上限为5,所以最终应该是只有5条请求是有效的。看测试结果:

image-20210412222948233

?

7. 自定义异常

上面被限流后的异常信息,显然不是想要的,那么如何自定义普通流控异常呢?

解决方案: 实现BlockExceptionHandler接口

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Component
public class DefaultBlockExceptionHandler implements BlockExceptionHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, BlockException e) throws Exception {
response.setStatus(HttpStatus.ok().status());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
if(e instanceof FlowException){
// objectMapper.writeValue 用于将java对象转位JSON格式返回调用方
new ObjectMapper().writeValue(response.getWriter(), Result.failed(ResultCode.FLOW_LIMITING));
}
}
}

为了测试普通流控,首先关闭网关流控,排除一些异常干扰

image-20210420234155626

添加获取当前登录用户信息的HTTP请求

image-20210420234021268

因为此HTTP接口需要认证,所以需要在请求头添加token。鼠标右击HTTP请求->添加->配置元件->HTTP信息头管理

image-20210420234827348

HTTP信息头管理器添加token

image-20210420234709242

执行线程组,查看自定义异常生效

image-20210420234933142

五. Sentinel熔断降级

1. 熔断降级概述

微服务架构都是分布式的,不同服务相互调用,组成复杂的调用链路。复杂的链路上某一环不稳定,就可能层层级联,最终导致整个链路都不可用。因此需要对不稳定的弱依赖服务调用进行熔断降级,暂时切断不稳定的调用,避免局部不稳定因素导致正题的雪崩。熔断降级作为保护自身的手段,通常在客户端(调用端)进行配置。

2. 熔断策略

image-20210422235109981

Sentinel提供了三种熔断策略:

  • 慢调用比例: 请求响应时间大于设置的RT(即最大的响应时间)则统计为慢调用。触发此熔断策略的条件需要满足两个条件,一是单位统计时长(statIntervalMs)内请求数大于设置的最小请求数,二是慢调用的比例大于阈值,接下来在熔断时长的范围内请求会自动的被熔断。过了熔断时长后,熔断器进入探测恢复状态(HALF-OPEN状态),若接下来的一个请求响应时间小于设置的慢调用RT则结束熔断,若大于设置的慢调用RT则会再次被熔断。
  • 异常比例:当单位统计时长请求数大于设置的最小请求数,并且异常的比例大于阈值,则接下来的熔断时长内请求会被自动熔断。
  • 异常数:当单位统计时长内的异常数目超过阈值之后会自动进行熔断。

3. 熔断降级规则

熔断降级规则(DegradeRule)包含下面几个重要的属性:

Field 说明 默认值
resource 资源名,即规则的作用对象
grade 熔断策略,支持慢调用比例/异常比例/异常数策略 慢调用比例
count 慢调用比例模式下为慢调用临界 RT(超出该值计为慢调用);异常比例/异常数模式下为对应的阈值
timeWindow 熔断时长,单位为 s
minRequestAmount 熔断触发的最小请求数,请求数小于该值时即使异常比率超出阈值也不会熔断(1.7.0 引入) 5
statIntervalMs 统计时长(单位为 ms),如 60*1000 代表分钟级(1.8.0 引入) 1000 ms
slowRatioThreshold 慢调用比例阈值,仅慢调用比例模式有效(1.8.0 引入)

4. 微服务配置

1
2
3
4
5
6
7
8
9
10
11
yml复制代码spring:
cloud:
sentinel:
# 降级规则,degrade为降级key,随便取名
degrade:
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
dataId: ${spring.application.name}-degrade-rules
groupId: SENTINEL_GROUP
data-type: json
rule-type: degrade

5. Nacos添加熔断降级规则

image-20210423005837408

方便看到异常熔断的效果,选择了异常数的熔断策略,毕竟数数比计算比例简单多了。资源名为getCurrentUser是怎么回事,不急下文提到。总之这里的配置的大概意思就是说针对getCurrentUser资源,如果请求该接口出现了异常,则进行熔断,熔断时长为5秒。

6. 熔断异常模拟

先模拟一段在运行时发生异常的代码,在获取当前登录用户信息的接口埋个雷吧,注意到@SentinelResource注解中的value值getCurrentUser,也就是资源名称,联系上文,降级规则就是针对这个接口的方法。和上面在普通流控测试使用的是请求的路径不同,这里显示指定了资源名称和降级规则配置去做匹配。

@SentinelResource注解中的属性除了定义资源名的属性值value,还有两个属性有关降级处理分别是blockHandlerClass和blockHandler。意思是在单位时间接口异常数超过设置的阈值后进入熔断,在熔断时长(上面降级规则中设置的是5秒)范围内,再有请求过来访问该接口时,不会再走接口方法体内的逻辑。因为前面几次接口出现异常,那么敢断定接下来短时间内大概率还是会发生异常,所以索性就把后续的请求拦截避免,这也是熔断的意义。

在5秒的熔断时长内,如果再有请求访问该接口则会走降级的逻辑,也就是上图中指定的UserBlockHandler#handleGetCurrentUserBlock降级处理方法。

image-20210424155414631

7. 熔断降级测试

在测试前,需要关闭网关那边流控,排除一些异常情况下的干扰。还有为了方便在JMeter查看结果,临时关闭全局异常处理器GlobalExceptionHandler。

还是拿上一节配置好的测试普通流控的线程组,获取登录用户的信息来作为熔断降级的测试案例,先看一下线程组的设置:

线程数为10,达到了熔断降级最小请求数(规则配置的5)的要求

获取登陆用户信息的接口信息

image-20210424183038021

请求头添加token

image-20210424183053841

测试线程组配置好之后看看使用不使用熔断降级和使用熔断降级的区别:

  • 不使用熔断降级

配置中关闭Sentinel

1
2
yaml复制代码sentinel:
enabled: false

image-20210424192227624

image-20210424192302313

发现了吗?这种请求异常处理模式头铁啊,即使撞了南墙也不会回头,下次继续撞,下下次继续撞。ok,你没关系,那你有考虑一直被你撞的墙(服务器)了没?

  • 使用熔断降级

配置中开启Sentinel

1
2
yaml复制代码sentinel:
enabled: true

image-20210424203541357

通过日志可以看到进入主线代码的只有一次,后续的请求直接进入降级支线。

image-20210424203649057

过了熔断时长5秒后,熔断器进入探测恢复状态(HALF-OPEN状态),这时候如果一个请求到主线没异常,关闭熔断器,让后续的请求都到主线过来;如果还是异常,打开熔断器。

六. Sentinel整合Feign熔断降级

1. Feign与Sentinel整合意义

在微服务架构中,声明式调用Feign在微服务之间调用充当重要的角色,在使用Feign的过程中如果为了系统的健壮性,一定会考虑如果因目标服务异常调用失败后的处理。

说到这里,相信对微服务有些了解的童鞋对下面的代码很熟悉:

image-20210428234713923

上面两张图反应了Feign客户端在远程调用目标服务失败后,继而选择了降级的逻辑,像做人一样随时要给自己留一条后路,也就是稳,折射到程序亦是如此。这里只是一个降级的自定义异常返回,实际情况根据业务而定。

看到上面的代码,Feign在设计上就支持了降级的处理。这时候相信大家都会有一个疑问,Feign本身已经支持降级,那还需要Sentinel做什么?

换句话说可能会好理解一点,Sentinel给Feign带来了什么好处?

这个问题其实不难理解,先直接从字面上切入。

Feign是能够做到降级,Sentinel能够实现熔断降级,突显出来也就是熔断这一词,其中熔断的具体表象是怎样的?举个栗子说明:

假如客户端a通过feign调用b服务100次,此时b服务故障

  1. 没有熔断

a的第1次请求走到b服务跟前,看着b躺在地上没动静,响应给客户端a说b没动静,让客户端a自己看着办吧。后面如此往复99次,每次a都需要走到b的面前然后再响应给客户端a,并告知b故障了。
2. 有熔断

a的第1次请求走到b服务跟前,看着b躺在地上没动静,这时候a就比较机智,判断b服务没有一时半刻是起不来了,就响应给客户端a并说这一时半刻钟的请求你自己看着处理吧,没有必要再到b面前,后面的99次请求就不会再到服务b那里了,省时省力。

想通过上面的举例说明熔断的意义和作用,因为Feign已经支持了降级,那再搭配上Sentinel的熔断,岂不是如虎添翼?

接下来将通过有来项目中的实例,认证中心【youlai-auth】在登录时需要远程feign调用系统服务【youlai-admin】的根据用户名获取用户信息的接口,来说明Sentinel如何整合Feign实现熔断降级及熔断降级的魅力。

2. 导入依赖

youlai-auth添加Sentinel和Nacos持久化规则依赖

1
2
3
4
5
6
7
8
9
10
xml复制代码<!-- Sentinel流量控制、熔断降级 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!-- Sentinel规则持久化至Nacos配置 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>

3. 微服务配置

youlai-auth开启Feign对Sentinel的支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
yaml复制代码spring:
application:
name: youlai-auth
cloud:
sentinel:
enabled: true
eager: true # 取消控制台懒加载,项目启动即连接Sentinel
transport:
client-ip: localhost
dashboard: localhost:8080
datasource:
# 降级规则
degrade:
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
dataId: ${spring.application.name}-degrade-rules
groupId: SENTINEL_GROUP
data-type: json
rule-type: degrade
# Feign开启对Sentinel支持
feign:
sentinel:
enabled: true

4. 熔断降级规则

在Nacos控制台添加youlai-auth的降级规则

image-20210429004026068

1
2
3
4
5
6
7
8
json复制代码[
{
"resource": "GET:http://youlai-admin/api.admin/v1/users/username/{username}",
"grade": 2,
"count": 1,
"timeWindow": 5
}
]

注意资源名称的生成规则,上面配置的意思是如果单位时间内出现了1次异常数,那没接下来5秒的时间窗口范围内的请求,因为熔断器打开,请求直接走降级逻辑。

5. 熔断降级测试

首先要模拟系统服务的根据用户名获取用户信息的接口异常,具体如下图:

image-20210429004501032

配置JMeter线程组,单位时间1s内执行10个请求,具体配置在普通流控有说明,这里不做赘述。

image-20210429004727792

结果在youlai-auth确实执行了10次请求,因为目标服务的异常走了降级的逻辑

image-20210429005106210

但是真正进入youlai-admin的根据用户名获取用户信息的接口方法却只有1次,后续的9次请求直接走feign客户端的降级逻辑

image-20210429005234219

上面的测试结果验证了Feign整合Sentinel之后实现了熔断和降级,至此Feign不再孤军奋战。

七. 结语

本文就Sentinel的流控、熔断降级从实战的角度去逐一验证。网关流控、普通流控能够在有限的资源能力保障系统的稳定运行,熔断降级能够在系统故障时提供兜底的处理逻辑保证系统的健壮性,支持降级的Feign整合Sentinel之后get到熔断的技能,至此熔断降级双剑合璧。

以前觉得微服务的限流、熔断降级是可有可无的存在,所以在开源项目中一直迟迟没有做相关的整合,当真正理解这其中的利害关系之后,微服务离不开这些。

当然本文提到的Sentinel功能的冰山一角,像限流延伸的还有热点key、IP限流、参数限流等等,具体选择使用根据场景,功能丰富,总会有你需要的。而且容易上手,是一个很不错的框架,内部的实现原理和还有算法很有必要去了解深入下。

如果有问题,欢迎加我微信(微信号:haoxianrui)

八. 项目源码

本文涉及的源码地址:

平台 地址
github github.com/hxrui/youla…
gitee gitee.com/youlaitech/…

文中的Sentinel规则配置已放置在项目document/nacos/SENTINEL_GROUP.zip,导入到Nacos控制台即可

image-20210430005807348

当然你也可以本地启动Sentinel控制台,已经把官方的jar包放置在项目youlai-middleware/setinel/sentinel-dashboard-1.8.1.jar

1
2
sh复制代码cd youlai-middleware/setinel
java -jar sentinel-dashboard-1.8.1.jar

image-20210430010126472

本地访问 http://localhost:8080即可进入Sentinel控制台

联系我

如果在您阅读本文的过程中遇到任何问题或有疑惑之处,欢迎通过开源组织首页加我好友。
有来开源组织:gitee.com/youlaiorg

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Cloud实战 第十一篇:Spring C

发表于 2021-06-09

一. 前言

hi,大家好,这应该是农历年前的关于开源项目有来商城 的最后一篇文章了。

有来商城 是基于 Spring Cloud OAuth2 + Spring Cloud Gateway + JWT实现的统一认证鉴权,Spring Cloud & Alibaba + vue-element-admin实现的微服务、前后端分离的全栈开源项目。

有来商城 的权限设计主要是为了实现以下几点目标:

  • 实现RBAC模式的权限管理设计
  • 实现基于vue-element-admin后台菜单权限管理系统
  • Spring Cloud Gateway网关针对RESTful接口权限控制
  • Vue自定义指令实现按钮级别权限控制

二. 项目介绍

1. 项目简介

有来商城 是基于Spring Boot 2.4、Spring Cloud 2020 & Alibaba、Vue、element-ui、uni-app快速构建的一套全栈开源商城平台,包括微服务应用、管理平台、微信小程序及APP应用。

2. 项目地址

项目预览地址: www.youlai.store

微信小程序体验码:

源码地址:

项目名称 Github 码云
微服务后台 youlai-mall youlai-mall
管理前端 youlai-mall-admin youlai-mall-admin
微信小程序 youlai-mall-weapp youlai-mall-weapp
APP应用 youlai-mall-app youlai-mall-app

3. 项目往期文章

后台微服务

  1. Spring Cloud实战 | 第一篇:Windows搭建Nacos服务
  2. Spring Cloud实战 | 第二篇:Spring Cloud整合Nacos实现注册中心
  3. Spring Cloud实战 | 第三篇:Spring Cloud整合Nacos实现配置中心
  4. Spring Cloud实战 | 第四篇:Spring Cloud整合Gateway实现API网关
  5. Spring Cloud实战 | 第五篇:Spring Cloud整合OpenFeign实现微服务之间的调用
  6. Spring Cloud实战 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT实现微服务统一认证授权
  7. Spring Cloud实战 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2集成统一认证授权平台下实现注销使JWT失效方案
  8. Spring Cloud实战 | 最八篇:Spring Cloud +Spring Security OAuth2+ Vue前后端分离模式下无感知刷新实现JWT续期
  9. Spring Cloud实战 | 最九篇:Spring Security OAuth2认证服务器统一认证自定义异常处理
  10. Spring Cloud实战 | 第十篇 :Spring Cloud + Nacos整合Seata 1.4.1最新版本实现微服务架构中的分布式事务,进阶之路必须要迈过的槛

后台管理前端

  1. vue-element-admin实战 | 第一篇: 移除mock接入微服务接口,搭建SpringCloud+Vue前后端分离管理平台
  2. vue-element-admin实战 | 第二篇: 最小改动接入后台实现根据权限动态加载菜单

微信小程序

  1. vue+uni-app商城实战 | 第一篇:从0到1快速开发一个商城微信小程序,无缝接入Spring Cloud OAuth2认证授权登录

应用部署

  1. Docker实战 | 第一篇:Linux 安装 Docker
  2. Docker实战 | 第二篇:Docker部署nacos-server:1.4.0
  3. Docker实战 | 第三篇:IDEA集成Docker插件实现一键自动打包部署微服务项目,一劳永逸的技术手段值得一试
  4. Docker实战 | 第四篇:Docker安装Nginx,实现基于vue-element-admin框架构建的项目线上部署
  5. Docker实战 | 第五篇:Docker启用TLS加密解决暴露2375端口引发的安全漏洞,被黑掉三台云主机的教训总结

三. 数据库设计

RBAC(Role-Based Access Control)基于角色访问控制,目前使用最为广泛的权限模型。

此模型有三个角色用户、角色和权限,在传统的权限模型用户直接关联加了角色层,解耦了用户和权限,使得权限系统有了更清晰的职责划分和更高的灵活度。

以下是*有来系统*关于RBAC权限模型的数据库

用户和角色关系不用过多说明,这里重点说下权限,首先系统的权限分为3类,具体如下表:

权限名称 表名 字段 权限标识
菜单权限 sys_menu
接口权限 sys_permission type=1 PUT_/users/**
按钮权限 sys_permission type=2 system:user:add

其实了解过目前主流开源系统的权限设计,大概率的把菜单和按钮放一块然后根据类别字段区分,以下就关于这种方式优劣发表下个人意见,仅供大家参考下不必较真:

优势:

  1. 理论上合理,按钮肯定属于某个菜单之下
  2. 省去了权限表(sys_permission)和关联中间表这两张表

劣势:

  1. 菜单模块变的复杂了,菜单表多了和菜单无关联的类型字段和权限标识字段
  2. 菜单和按钮查询要区分类型,给代码开发带来复杂和影响查询性能
  3. 不能同时满足按钮权限控制和网关根据请求路径Ant匹配鉴权(具体下文说)

四. 权限管理系统

先看下vue-element-admin下的RBAC模型下的后台权限管理界面,体验地址:www.youlai.store

  • 菜单权限管理

  • 角色分配权限

五. RESTful接口权限控制

1. 接口和按钮的权限标识的区别

上文说到的关于权限表的拆分,菜单单独一张表,按钮权限和接口权限合为一张表根据类型type字段区分,之所以这样因为接口和按钮权限有些共性,都有一个权限标识字段。

至于按钮和接口为什么要区分呢?都使用system:user:add权限标识不可以吗?

具体做法是接口方法加上Spring Security的注解@PreAuthorize(“hasPermission(‘system:user:add’)”),在执行方法前判断用户时候拥有该权限。

答案是一般场景这样设计绝对没问题。但这里使用网关作为统一鉴权的入口,肯定希望网关一次性把鉴权的活做的干脆利落,这样就不需要在各个微服务单独的把Spring Security权限模块引入鉴权,通过网关鉴权能把职责分工明确,减少开发工作量,无权限的请求直接被网关拦截返回,不会走到微服务那里再被告知无权访问,提高请求效率。

Spring Cloud Gateway网关使用请求路径Ant匹配请求标识进行权限判断的,例如/users/1经过Ant匹配到权限标识/users/**,而/users/**是被用户所持有的权限标识,这就标识用户允许访问/users/1的请求,所以和按钮的权限标识system:user:add是有区别的。

这样就完事了吗?当然还没,因为 有来系统 较于其他系统它是比较严格遵守REST接口设计规范,所以如果仅仅是上面根据请求路径URL判断权限肯定是不合理的,/users/1这个请求路径在RESTful接口下可能是GET类型的请求也有可能是PUT类型的请求,那该如何处理?

所以在sys_permission表里还有一个method字段来标识请求方法类型,值可能会是 、GET、POST、PUT、PATCH、DELETE等HTTP请求方法类型,其中是不限请求方法类型的意思,然后将请求方法类型和请求路径组合得到接口的权限标识是这样的PUT_users/1。

接下来就通过对 有来系统 的实战操作来演示网关如何细粒度对RESTful接口的权限控制。

2. 添加权限

新增用户管理的增删改查权限

3. 角色授权

赋予系统管理员(admin)用户查询权限,无其他权限

4. 加载角色权限规则数据至缓存

项目启动查看Redis中的角色权限规则:

看到系统管理员这个角色是没有用户修改权限的。你可以给角色添加用户修改权限后尝试是否可以修改成功。

5. 接口权限控制演示

admin系统管理员登录执行一个用户修改的提交的请求,看一下网关鉴权的流程:

结果可想而知,系统管理员不具有修改用户PUT_/youlai-admin/v1/users/2权限,从缓存查询只有超级管理员具有该接口请求方法访问权限。页面结果显示如下:

六. 按钮权限控制

1. 什么是Vue自定义指令?

Vue除了核心功能默认内置的指令 (v-model 和 v-show),Vue 也允许注册自定义指令。

这里主要使用Vue.directive注册一个全局自定义指令v-has- permission用于权限判断,然后在模板中的任何元素使用v-has- permission属性。

自定义指令学习传送门

2. 添加按钮权限

3. 角色授权

4. 加载角色按钮权限数据

完整代码:youlai-mall-admin

登录成功时获取用户信息,其中包含该用户拥有的权限字符串集合如下:

这里将用户权限拥有的字符串集合缓存到vuex的perms属性中:

5. 自定义和注册全局指令

有来管理前端 是基于vue-element-admin后台前端解决方案,在vue-element-admin项目我们可以看到自定义指令的应用。如下:

然后复制一份permission.js重命名为hasPermission.js,修改后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
javascript复制代码import store from '@/store'

// 校验用户是否拥有按钮权限

function hasPermission(el, binding) {
const {value} = binding
const perms = store.getters && store.getters.perms
if (value && value instanceof Array) {
if (value.length > 0) {
const requiredPerms = value
const hasPermission = perms.some(perm => {
return requiredPerms.includes(perm)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
} else {
throw new Error(`need perms! Like v-has-permission="['system:user:add','system:user:edit']"`)
}
}

export default {
inserted(el, binding) {
hasPermission(el,binding)
},
update(el, binding) {
hasPermission(el,binding)
}
}

注册hasPermission至全局指令:

指令在组件上的应用:

6. 按钮权限控制演示

系统管理员是没有修改按钮的权限的,结果如下页面不显示修改按钮。

那给系统管理员添加修改按钮的权限,再看看用户页面的显示情况

此时用户页面的修改按钮已经显示出来了,至此完成了系统的按钮权限控制。

七. 结语

本篇通过实战的方式讲述如何基于Spring Cloud Gateway + vue-element-admin技术设计一套符合RBAC规范的权限管理系统,通过网关就可以轻易实现RESTful接口方法细粒度的控制,无需将Spring Security模块引入各个微服务;以及使用Vue的自定义指令在组件中使用实现细粒度的按钮权限控制。

如果你对此系统权限设计有更好的建议,欢迎留言给我,在此感谢!如果对项目感兴趣的话,欢迎加我微信和项目交流群。

最后预祝大家新年愉快,有个完美充实的小假期。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Cloud实战 第十篇 Spring C

发表于 2021-06-09

Seata分布式事务在线体验地址: www.youlai.store

本篇完整源码地址:github.com/hxrui/youla…

有想加入开源项目开发的童鞋也可以联系我(微信号:haoxianrui),希望大家能够一起交流学习。觉得项目对你有帮助希望能给一个star或者关注,持续更新中。。。

一. 前言

相信了解过开源项目 youlai-mall 的童鞋应该知道该项目主要是基于Spring Cloud + Vue等当前最新最主流技术落地实现的一套微服务架构 + 前后端分离的全栈商城系统(App、微信小程序等)。

往期文章链接:

微服务

  1. Spring Cloud实战 | 第一篇:Windows搭建Nacos服务
  2. Spring Cloud实战 | 第二篇:Spring Cloud整合Nacos实现注册中心
  3. Spring Cloud实战 | 第三篇:Spring Cloud整合Nacos实现配置中心
  4. Spring Cloud实战 | 第四篇:Spring Cloud整合Gateway实现API网关
  5. Spring Cloud实战 | 第五篇:Spring Cloud整合OpenFeign实现微服务之间的调用
  6. Spring Cloud实战 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT实现微服务统一认证授权
  7. Spring Cloud实战 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2集成统一认证授权平台下实现注销使JWT失效方案
  8. Spring Cloud实战 | 最八篇:Spring Cloud +Spring Security OAuth2+ Vue前后端分离模式下无感知刷新实现JWT续期
  9. Spring Cloud实战 | 最九篇:Spring Security OAuth2认证服务器统一认证自定义异常处理

管理前端

  1. vue-element-admin实战 | 第一篇: 移除mock接入后台,搭建有来商城youlai-mall前后端分离管理平台
  2. vue-element-admin实战 | 第二篇: 最小改动接入后台实现根据权限动态加载菜单

微信小程序

  1. vue+uniapp商城实战 | 第一篇:【有来小店】微信小程序快速开发接入Spring Cloud OAuth2认证中心完成授权登录

部署篇

  1. Docker实战 | 第二篇:IDEA集成Docker插件实现一键自动打包部署微服务项目,一劳永逸的技术手段值得一试
  2. Docker实战 | 第三篇:Docker安装Nginx,实现基于vue-element-admin框架构建的项目线上部署

说到微服务,自然就少不了保证服务之间数据一致性的分布式事务,所以本篇就以Seata的AT模式如何在微服务的实际场景中应用进行实战说明,希望大家都能有个看其形知其意的效果。

1. 需求描述

会员提交订单,扣减商品库存,增加会员积分,完成前面步骤,更改订单状态为已完成。

根据需求可知这其中牵涉到订单、商品、会员3个微服务,分别对应 youlai-mall 商城项目的 mall-oms、mall-pms、mall-ums微服务。

2. 技术版本

技术 版本 说明
Spring Cloud Hoxton.SR9 微服务架构
Nacos 1.4.0 注册、配置中心
Seata 1.4.1 分布式事务

3. 环境准备

3.1 Nacos安装和配置

www.cnblogs.com/haoxianrui/…

进入Nacos控制台,创建seata命名空间

记住命名空间ID自定义为seata_namespace_id,后面需要

3.2 Seata数据库创建

创建数据库名为seata,执行Seata的Github官方源码中提供的的MySQL数据库脚本

MySQL脚本地址:

github.com/seata/seata…

二. seata-server安装

重点声明: 192.168.1.188 是安装Nacos和Seata的虚拟机IP

点击 Docker Hub链接 查看最新Seata版本

可以看到最新版本为1.4.1版本,复制指令获取最新版本镜像

1
bash复制代码docker pull seataio/seata-server:1.4.1

启动临时容器

1
css复制代码docker run -d --name seata -p 8091:8091 seataio/seata-server

从临时容器获取到 registry.conf 配置文件

1
2
bash复制代码mkdir /opt/seata
docker cp seata:/seata-server/resources/registry.conf /opt/seata

修改registry.conf配置,类型选择nacos,namesapce为上文中在nacos新建的命名空间id即seata_namespace_id,精简如下:

1
bash复制代码vim /opt/seata/registry.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ini复制代码registry {
type = "nacos"
loadBalance = "RandomLoadBalance"
loadBalanceVirtualNodes = 10

nacos {
application = "seata-server"
serverAddr = "192.168.1.188:8848"
namespace = "seata_namespace_id"
group = "SEATA_GROUP"
cluster = "default"
}
}
config {
type = "nacos"

nacos {
serverAddr = "192.168.1.188:8848"
namespace = "seata_namespace_id"
group = "SEATA_GROUP"
}
}

安排好 registry.conf 之后,删除临时容器

1
bash复制代码docker rm -f seata

接下来着手开始推送Seata依赖配置至Nacos

从Seata的GitHub官方源码获取配置文件(config.txt)和推送脚本文件(nacos/nacos-config.sh)

地址:github.com/seata/seata…

因为脚本的关系,文件存放目录如下

1
2
3
4
lua复制代码/opt/seata
├── config.txt
└── nacos
└── nacos-config.sh

修改配置文件 config.txt

1
arduino复制代码vim /opt/seata/config.txt

修改事务组和MySQL连接信息,修改信息如下:

1
2
3
4
5
6
7
ini复制代码service.vgroupMapping.mall_tx_group=default

store.mode=db
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://www.youlai.store:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=123456

执行推送命令

1
2
3
bash复制代码cd /opt/seata/nacos

bash nacos-config.sh -h 192.168.1.188 -p 8848 -g SEATA_GROUP -t seata_namespace_id -u nacos -w nacos
  • -t seata_namespace_id 指定Nacos配置命名空间ID
  • -g SEATA_GROUP 指定Nacos配置组名称

如果有 init nacos config fail. 报错信息,请检查修改信息,如果有属性修改提示failure,请修改config.txt中属性。

如果出现类似 cat: /tmp/tmp.rRGz1B7MUP: No such file or directory 的错误不用慌,重新执行推送命令直至成功。

推送执行完毕,到Nacos控制台查看配置是否已添加成功

做完上述准备工作之后,接下来最后一步:启动Seata容器

1
2
3
4
5
6
bash复制代码docker run -d --name seata --restart=always -p 8091:8091  \
-e SEATA_IP=192.168.1.188 \
-e SEATA_CONFIG_NAME=file:/seata-server/resources/registry.conf \
-v /opt/seata/registry.conf:/seata-server/resources/registry.conf \
-v /opt/seata/logs:/root/logs \
seataio/seata-server

三. Seata客户端

上文完成了Seata服务端应用安装、添加Seata配置至Nacos配置中心以及注册Seata到Nacos注册中心。

接下来的工作就是客户端的配置,通过相关配置把订单(mall-oms)、商品(mall-pms)、会员(mall-ums)这3个微服务关联seata-server。

1. 添加undo_log表

Seata的AT模式下之所以在第一阶段直接提交事务,依赖的是需要在每个RM创建一张undo_log表,记录业务执行前后的数据快照。

如果二阶段需要回滚,直接根据undo_log表回滚,如果执行成功,则在第二阶段删除对应的快照数据。

Seata官方Github源码库undo_log表脚本地址:

github.com/seata/seata…

注意第一行的注释说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql复制代码-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

分别在项目的 mall-oms、mall-pms、mall-ums 的三个数据库执行脚本创建 undo_log 表

2. 添加依赖

分别为 youlai-mall 的 mall-oms、mall-pms、mall-ums 微服务添加如下seata客户端依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
xml复制代码<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!-- 排除依赖 指定版本和服务器端一致 -->
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>1.4.1</version>
</dependency>

<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.1</version>
</dependency>
  • 使用Alibaba官方提供的Spring Cloud和Seata整合好的Spring Boot启动器 spring-cloud-starter-alibaba-seata
  • 需要指定seata版本和服务版本一致,这里也就是1.4.1

3. yml配置

Seata官方Github源码库Spring配置链接:

github.com/seata/seata…

配置精简如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
yaml复制代码# 分布式事务配置
seata:
tx-service-group: mall_tx_group
enable-auto-data-source-proxy: true
registry:
type: nacos
nacos:
server-addr: localhost:8848
namespace: seata_namespace_id
group: SEATA_GROUP
config:
type: nacos
nacos:
server-addr: localhost:8848
namespace: seata_namespace_id
group: SEATA_GROUP
  • tx-service-group: mall_tx_group 配置事务群组,其中群组名称 mall_tx_group 需和服务端的配置 service.vgroupMapping.mall_tx_group=default 一致
  • enable-auto-data-source-proxy: true 自动为Seata开启了代理数据源,实现集成对undo_log表操作
  • namespace: seata_namespace_id seata-server一致
  • group: SEATA_GROUP seata-server一致

将精简的配置分别放置到 mall-oms、mall-pms、mall-ums的配置文件中

4. 启动类调整

因为要使用Seata提供的代理数据源,所以在启动类移除SpringBoot自动默认装配的数据源

同样也是需要在3个微服务启动类分别调整,不然分布式事务不会生效

1
python复制代码@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)

四. 测试环境模拟

根据上诉步骤完成Seata服务安装以及客户端的配置之后

接下来就开始着手 透过现象看本质 的工作,根据业务需求创建业务表和编写业务代码

1. 业务表

提供业务表关键字段,完整表结构请点击 youlai-mall

订单表(oms_order):

1
2
3
4
5
sql复制代码CREATE TABLE `oms_order` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
`status` int NOT NULL DEFAULT '101' COMMENT '订单状态【101->待付款;102->用户取消;103->系统取消;201->已付款;202->申请退款;203->已退款;301->待发货;401->已发货;501->用户收货;502->系统收货;901->已完成】',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单表';

库存表(pms_sku):

1
2
3
4
5
sql复制代码CREATE TABLE `pms_sku` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品id',
`stock` int NOT NULL DEFAULT '0' COMMENT '库存',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='商品库存表';

会员表(ums_user):

1
2
3
4
5
6
sql复制代码CREATE TABLE `ums_user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`point` int DEFAULT '0' COMMENT '会员积分',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='会员信息表';

2. 业务代码

提供核心业务代码,完整代码请点击 youlai-mall

订单微服务(mall-oms):

代码定位:OmsOrderServiceImpl#submit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
less复制代码@Override
@GlobalTransactional(rollbackFor = Exception.class)
public boolean submit() {
log.info("扣减库存----begin");
productFeignService.updateStock(1l, -1);
log.info("扣减库存----end");

log.info("增加积分----begin");
memberFeignService.updatePoint(1l, 10);
log.info("增加积分----end");

log.info("修改订单状态----begin");
boolean result = this.update(new LambdaUpdateWrapper<OmsOrder>().eq(OmsOrder::getId, 1l).set(OmsOrder::getStatus, 901));
log.info("修改订单状态----end");
return result;
}
  • @GlobalTransactional注解,标识TM(事务管理器)开启全局事务

商品微服务(mall-pms):

代码定位:AppSkuController#updateStock

1
2
3
4
5
6
7
less复制代码@PutMapping("/{id}/stock")
public Result updateStock(@PathVariable Long id, @RequestParam Integer num) {
PmsSku sku = iPmsSkuService.getById(id);
sku.setStock(sku.getStock() + num);
boolean result = iPmsSkuService.updateById(sku);
return Result.status(result);
}

会员微服务(mall-ums):

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码 @PutMapping("/{id}/point")
public Result updatePoint(@PathVariable Long id, @RequestParam Integer num) {
UmsUser user = iUmsUserService.getById(id);
user.setPoint(user.getPoint() + num);
boolean result = iUmsUserService.updateById(user);
try {
Thread.sleep(15 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return Result.status(result);
}

Thread.sleep(15 * 1000); 模拟超时异常验证事务是否能正常回滚

注意15s的设定有讲究的

先看下订单微服务的feign调用配置,

1
2
yaml复制代码ribbon:
ReadTimeout: 10000

feign底层使用ribbon做负载均衡和远程调用,上面设置ribbon的超时时间为10s

然而在订单调用会员服务的时候需要至少15s才能获得结果,显然会造成接口请求超时的异常,接下来就看事务能不能进行正常回滚。

五. 验证测试

本篇源码包括测试用例均已整合到 youlai-mall ,大家有条件的话可以搭建一个本地环境调试一下,项目从无到有的搭建参考项目中的说明文档。

但如果你想快速验证Seata分布式事务和看到效果,ok,满足你,在项目中添加了一个 实验室 的菜单,计划用于技术点测试,也方便给大家提供一个完整的测试环境。

话不多说,看界面效果图:

看完上图标注的地方,接下来通过界面来进行分布式事务测试

首先确定一下前提订单提交肯定会因为会员积分服务超时出现异常

  • 关闭事务提交

可以看到在关闭事务提交订单异常的情况下,库存和积分更新成功了,然而订单确更新失败了

接下来再看下开启事务提交的结果又会是如何呢?

  • 开启事务提交

更新订单状态失败,因开启了全局事务,导致已更新的商品库存、积分被回滚至初始状态。

六. 结语

以上就Seata分布式事务结合实际场景应用的案例进行整合和测试,最后可以看到通过Seata实现了微服务调用链的最终数据一致性。最后提供了在线体验实验室功能模块,大家可以拉取到本地然后断点调试以及监听数据表的数据变化,相信应该会很快掌握Seata的执行流程和实现原理。

最后,觉得项目不错的话或对你有帮助的话,希望能给个star,持续更新中…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

两年工作经验,离职了

发表于 2021-06-09

Hi,大家好,我是3y

啊,好久没在掘金更新了…

当大家看到这篇文章的时候,我已经办完离职手续离开了蘑菇街。从19年初到蘑菇街实习,到现在不知不觉已经两年多了。两年时间说短不短,说长也不长。

之前在蘑菇街就已经送别了不少好友,但当主角是自己时,心里的感受却无以表达。

离开广州

当我还在大三读书时,某一天晚饭我曾对我爸妈说:“有可能,我毕业以后会去其他城市工作,这些城市不限于北京、上海、深圳或杭州”

我爸听了之后:“嗯?你毕业了在广州找不到工作吗?为什么要跑到其他的城市?”

我:“我感觉广州互联网机会可能相对没那么多”

我补充道:“也不是一定会跑到其他城市,只是提前跟你们说下。就是将来有可能会去这些城市,也有可能留在广州。到时候面试完看下机会吧,等我决定好了就会跟你们讲的。”

我妈:“嗯,到时候看看吧。如果你是真的去其他城市,我们都会支持你的,主要看你自己的想法吧”

结果在秋招期间,也就真的拿了各个城市的offer…

当我决定离开广州的时候也考虑过很多:周围身边朋友,家里人和女朋友都在广州,离开了广州意味这我得去一个陌生的城市一个人工作至少2年的时间。但可预见的即是,我会有更多机会去看看不同的人和事、学习并实践到业内常用的技术以及每个月拿着相对有竞争力的薪资。

这值不值得?

如果选择留在广州的话,待在一家非互联网公司,小日子可以过得相当舒服。周末没事就可以陪陪家里人出去走走逛逛,平时没事就约朋友出来饮茶吃饭…剧本已经写过了,因为我在大三实习时,过的就是这种生活。

年轻,致使我选择了离开广州。

当时离开广州的理由有很多:提升自己的技术和认识能力、平台相对更大、有潜力有发展有着更高的工资…

无论什么理由,最终的目的就是为了以后能够更好地生活(:刚毕业的年轻,又怎么会放过这样机会呢?

选择MOGU

经过秋招两个月的奋战,拿到了各大城市的offer之后,剩下的就是选择的问题了。

我跟所有人都一样,会去网上对比下各家公司的好坏(包括薪资、公司氛围、是否加班、技术能力、业务发展等等…)

经过最终的考虑,选择了MOGU作为第一家毕业去的公司。

当时主要看上MOGU这两个方面:

  • 技术
  • 公司氛围(不加班)

两年之后,再回头看这个决定,我一点也不后悔,甚至觉得自己很赚。

MOGU两年

在19年2月份去MOGU实习,当时在搜索组当实习生,那段时间我感觉非常有趣(:

我刚进去,自然什么都不懂。第一天进去,我的学长就给我拉了个会议室,单独花了1个小时给我介绍MOGU的搜索是怎么做的,为什么我们主搜用C++而不用Java,整块召回的流程大概是怎么样的…

他同时强调了:我只是给你做个大概的介绍,我估计你也有很多地方听不懂,可以慢慢来,这不着急的。

嗯,我就每天坐在那学习各种东西(大致就是gc日志、公司内部自研框架、TensorFlow框架等等…)

MOGU比较开放的是:很多时候同事们就拎着电脑跑到工位前去,问这块流程大致是什么样的,实现方案大概是怎么样的。

而我,就坐在隔壁听嘛,但总有几个名词会经常听到:

  • 金牌
  • ACM
  • 增量(dump)
  • …

我就蒙圈了,咋经常讨论ACM和金牌的呢?dump又是什么东西?

直到后来才发现:ACM是站内用的打点体系,只是取名叫ACM。”精排”而非”金牌”,增量DUMP一般指的是实时接收数据做ETL…

总有些时候,当我发现该术语/名词原来是这么一回事,我就忍不住吐槽。

实习期间,组内接了消息管理平台系统,但因为人手不够,搞不过来,于是派我就去支援了。请假了个回学校答辩,第一任学长就离职了(:

第一任学长离职之后,我就一直在搞消息管理平台,搞着搞着,正式入职3个月后,第二任学长也离职了…

于是,不小心就变成了消息管理平台唯一负责人了(:

又后来,由于组织架构的调整,我被分配至广告商业化团队。

随着我对消息管理平台地不断迭代,解放了维护的人力,我就逐渐开始搞广告相关的数据以及业务。将广告效果报表数据从Storm迁移至Flink体系,搭建以直播间为载体的CPS链路…毕业一年后成功得到了晋升。

待了两年,在技术和业务上我仍还有不少的东西学习,公司技术氛围还是一如既往地好。

这两年是我技术水平提升最快的时段,而我在这高速发展的阶段上选择了离职,为什么?

离开MOGU

离开总要有离开理由

很多小伙伴知道我要离职的时候,会惊讶地问我:“怎么就离职了啊”

我很多时候也笑着回答:“嗯,就是想回广州了”

也曾经无奈说过:“如果蘑菇街在广州也有一间,得多好啊,我就不用离职了。”

做了离职的决定后,我爸也不理解了:“你不是经常说公司好吗?怎么就离职啦?我还以为你会多干一段时间才回来咯,你回广州能找到这么好的公司吗?”

我直言:“嗯,恐怕是找不到的”

这么好条件的公司,那为什么我离职了呢,很多时候我也在自问。

在杭州两年期间,但凡有点中长假期,我基本都会往广州飞。回家陪陪家里人,见见女朋友,约同学出来吃顿宵夜。

每当我结束假期,孤身回到宿舍的那一刻,我会不禁自想:“哦,原来我又回来杭州了”

周末或小假期,有的时候下起了雨,我就不愿往公司里跑,于是就待在宿舍里吧,但我不知道要做什么。

我不怎么玩游戏,那就看会电影吧,看了会有些疲倦,那就上床看会B站吧,又好像有点儿困了,顺便拉上窗帘吧。哎,没睡着。房间很黑,很安静,磨磨蹭蹭着,不知不觉到了晚上6点了。这时候,我该点份外卖呢,还是自己出去吃一顿,我是吃什么好呢,周围的外卖我都吃吐了…浑浑噩噩的,带着倦意,才想起一天什么都没干,草!

在MOGU待了两年,在期间送走了两任学长,送走了组内的小伙伴,送走了业务方…本来还坐在隔壁或对面的同事,突然发现某一天他离职了,剩下的只有空荡荡的座位了。

孤独是真的

当熟悉同事离开公司、当周末孤身一人无事可做、当休假后独自回杭、当家里人生病无法照顾、当异地恋无法见面时,我就会有离职的念头。

只不过,这些,恰好是在4~5月份集中爆发了,于是我毅然提了离职。

MOGU&&杭州印象

这两年,杭州对我而言,我认为它是个有温度的城市。

  1. 过马路,司机会主动让人。
  2. 逢过节,房东送点小礼物。
  3. 离职时,朋友真诚地祝福。
  4. …

至于公司,我是个MOGU吹,我发现从MOGU离职后的小伙伴,同样也有很多MOGU吹。

“在杭州,能叫上名的公司,公司氛围都没有比蘑菇街要好的,蘑菇街不加班啊”

“我感觉在阿里的技术氛围,还没我当时在蘑菇街的要好。”

我在MOGU认识了很多优秀的小伙伴,不得不说就是我原来的小组几个同龄小伙伴。因为同龄,所以玩得比较近,拉了个小群《财富自由之路》

群里有4个人:三歪、鸡蛋、敖丙、米豆。鸡蛋去了字节,米豆去了阿里,敖丙已财富自由,剩下的三歪在等待广州某间公司给口饭吃。

在MOGU大多数离职的同学,很多都跳去字节和阿里去了(:

我之前组内有个小伙伴去面阿里的时候,他回来跟我们反馈,阿里面试官曾经对他说:“我觉得蘑菇街的小伙伴都很靠谱啊,我很喜欢蘑菇街的同学”。

他阿里和字节的offer都拿了,但最终去了字节…(哈哈哈哈

如果有同学想要跳槽或者校招未考虑入职哪家公司的,不妨考虑下蘑菇街。

展望

当我提了离职之后,小伙伴和同事们都纷纷开玩笑说我要回家继承家产了,要回去收租了。小伙伴在离别之际还给我送了T桖和拖鞋,希望我应该有广东人收租该有的样子。

我是裸辞的,没有找到下家公司我就辞了职。

以往的我,是接受不了裸辞的,还没找到下家就辞职,感觉风险太大了(:

裸辞主要我是想有一个较长的空窗期,这两年也算是勤勤恳恳工作,勤勤恳恳写公众号,算是给自己放个小长假。休息这段时间可以好好总结下这两年的事情,再重新启航。

至于后面,在广州找份工作(不那么忙的公司),努力努力写公众号,输出更多我的技术想法和观点。如果读了我的文章,在无意间或许能帮助小些人,我就很开心了。

我们江湖再见

欢迎关注我的公众号「Java3y」,跟你们来聊聊离职后的事,包括两年的成长以及更往后的面试。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一个测试工程师走进一家酒吧……

发表于 2021-06-09

一个测试工程师走进一家酒吧,要了一杯啤酒;

一个测试工程师走进一家酒吧,要了一杯咖啡;

一个测试工程师走进一家酒吧,要了 0.7 杯啤酒;

一个测试工程师走进一家酒吧,要了-1 杯啤酒;

一个测试工程师走进一家酒吧,要了 2^32 杯啤酒;

一个测试工程师走进一家酒吧,要了一杯洗脚水;

一个测试工程师走进一家酒吧,要了一杯蜥蜴;

一个测试工程师走进一家酒吧,要了一份 asdfQwer@24dg!&*(@;

一个测试工程师走进一家酒吧,什么也没要;

一个测试工程师走进一家酒吧,又走出去又从窗户进来又从后门出去从下水道钻进来;

一个测试工程师走进一家酒吧,又走出去又进来又出去又进来又出去,最后在外面把老板打了一顿;

一个测试工程师走进一家酒吧,要了一杯烫烫烫的锟斤拷;

一个测试工程师走进一家酒吧,要了 NaN 杯 Null;

一个测试工程师冲进一家酒吧,要了 500T 啤酒咖啡洗脚水野猫狼牙棒奶茶;

一个测试工程师把酒吧拆了;

一个测试工程师化装成老板走进一家酒吧,要了 500 杯啤酒并且不付钱;

一万个测试工程师在酒吧门外呼啸而过;

一个测试工程师走进一家酒吧,要了一杯啤酒’;DROP TABLE 酒吧;

测试工程师们满意地离开了酒吧。

然后一名顾客点了一份炒饭,酒吧炸了。

上面是网上流行的一个关于测试的笑话,其主要核心思想是——你永远无法把所有问题都充分测试。

在软件工程中,测试是极其重要的一环,比重通常可以与编码相同,甚至大大超过。那么在 Golang 里,怎么样把测试写好,写正确?本文将对这个问题做一些简单的介绍。 当前文章将主要分两个部分:

  • Golang 测试的一些基本写法和工具
  • 如何写“正确”的测试,这个部分虽然代码是用 golang 编写,但是其核心思想不限语言

由于篇幅问题,本文将不涉及性能测试,之后会另起一篇来谈。

为什么要写测试

我们举个不太恰当的例子,测试也是代码,我们假定写代码时出现 bug 的概率是 p(0<p<1),那么我们同时写测试的话,两边同时出现 bug 的概率就是(我们认为两个事件相互独立)

P(代码出现 bug) * P(测试出现 Bug) = p^2 < p

例如 p 是 1%的话,那么同时写出现 bug 的概率就只有 0.01%了。

测试同样也是代码,有可能也写出 bug,那么怎么保证测试的正确性呢?给测试也写测试?给测试的测试继续写测试?

我们定义 t(0)为原始的代码,任意的 i,i > 0,t(i+1)为对于 t(i)的测试,t(i+1)正确为 t(i)正确的必要条件,那么对所有的 i,i>0,t(i)正确都是 t(0)正确的必要条件。。。

测试的种类

测试的种类有非常多,我们这里只挑几个对一般开发者来说比较重要的测试,做简略的说明。

白盒测试、黑盒测试

首先是从测试方法上可以分为白盒测试和黑盒测试(当然还存在所谓的灰盒测试,这里不讨论)

  • 白盒测试 (White-box testing):白盒测试又称透明盒测试、结构测试等,软件测试的主要方法之一,也称结构测试、逻辑驱动测试或基于程序本身的测试。测试应用程序的内部结构或运作,而不是测试应用程序的功能。在白盒测试时,以编程语言的角度来设计测试案例。测试者输入数据验证数据流在程序中的流动路径,并确定适当的输出,类似测试电路中的节点。
  • 黑盒测试 (Black-box testing):黑盒测试,软件测试的主要方法之一,也可以称为功能测试、数据驱动测试或基于规格说明的测试。测试者不了解程序的内部情况,不需具备应用程序的代码、内部结构和编程语言的专门知识。只知道程序的输入、输出和系统的功能,这是从用户的角度针对软件界面、功能及外部结构进行测试,而不考虑程序内部逻辑结构。

我们写的单元测试一般属于白盒测试,因为我们对测试对象的内部逻辑有着充分了解。

单元测试、集成测试

从测试的维度上,又可以分为单元测试和集成测试:

  • 在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。
  • 整合测试又称组装测试,即对程序模块采用一次性或增值方式组装起来,对系统的接口进行正确性检验的测试工作。整合测试一般在单元测试之后、系统测试之前进行。实践表明,有时模块虽然可以单独工作,但是并不能保证组装起来也可以同时工作。

单元测试可以是黑盒测试,集成测试亦可以是白盒测试

回归测试

  • 回归测试是软件测试的一种,旨在检验软件原有功能在修改后是否保持完整。

回归测试主要是希望维持软件的不变性,我们举一个例子来说明。例如我们发现软件在运行的过程中出现了问题,在 gitlab 上开启了一个 issue。之后我们并且定位到了问题,我们可以先写一个测试(测试的名称可以带上 issue 的 ID)来复现问题(该版本代码运行此测试结果失败)。之后我们修复问题后,再次运行测试,测试的结果应当成功。那么我们之后每次运行测试的时候,通过运行这个测试,可以保证同样的问题不会复现。

一个基本的测试

我们先来看一个 Golang 的代码:

1
2
3
4
5
6
go复制代码// add.go
package add

func Add(a, b int) int {
return a + b
}

一个测试用例可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码// add_test.go
package add

import (
"testing"
)

func TestAdd(t *testing.T) {
res := Add(1, 2)
if res != 3 {
t.Errorf("the result is %d instead of 3", res)
}
}

在命令行我们使用 go test

1
bash复制代码go test

这个时候 go 会执行该目录下所有的以_test.go 为后缀中的测试,测试成功的话会有如下输出:

1
2
3
bash复制代码% go test
PASS
ok code.byted.org/ek/demo_test/t01_basic/correct 0.015s

假设这个时候我们把 Add 函数修改成错误的实现

1
2
3
4
5
6
go复制代码 // add.go
package add

func Add(a, b int) int {
return a - b
}

再次执行测试命令

1
2
3
4
5
6
bash复制代码% go test
--- FAIL: TestAddWrong (0.00s)
add_test.go:11: the result is -1 instead of 3
FAIL
exit status 1
FAIL code.byted.org/ek/demo_test/t01_basic/wrong 0.006s

会发现测试失败。

只执行一个测试文件

那么如果我们想只测试这一个文件,输入

1
go复制代码go test add_test.go

会发现命令行输出

1
2
3
4
5
arduino复制代码% go test add_test.go
# command-line-arguments [command-line-arguments.test]
./add_test.go:9:9: undefined: Add
FAIL command-line-arguments [build failed]
FAIL

这是因为我们没有附带测试对象的代码,修改测试后可以获得正确的输出:

1
2
go复制代码% go test add_test.go add.go
ok command-line-arguments 0.007s

测试的几种书写方式

子测试

通常来说我们测试某个函数和方法,可能需要测试很多不同的 case 或者边际条件,例如我们为上面的 Add 函数写两个测试,可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
go复制代码 // add_test.go
package add

import (
"testing"
)

func TestAdd(t *testing.T) {
res := Add(1, 0)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
}

func TestAdd2(t *testing.T) {
res := Add(0, 1)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
}

测试的结果:(使用-v 可以获得更多输出)

1
2
3
4
5
6
7
diff复制代码% go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestAdd2
--- PASS: TestAdd2 (0.00s)
PASS
ok code.byted.org/ek/demo_test/t02_subtest/non_subtest 0.007s

另一种写法是写成子测试的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go复制代码// add_test.go
package add

import (
"testing"
)

func TestAdd(t *testing.T) {
t.Run("test1", func(t *testing.T) {
res := Add(1, 0)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
})
t.Run("", func(t *testing.T) {
res := Add(0, 1)
if res != 1 {
t.Errorf("the result is %d instead of 1", res)
}
})
}

执行结果:

1
2
3
4
5
6
7
8
9
ini复制代码% go test -v
=== RUN TestAdd
=== RUN TestAdd/test1
=== RUN TestAdd/#00
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/test1 (0.00s)
--- PASS: TestAdd/#00 (0.00s)
PASS
ok code.byted.org/ek/demo_test/t02_subtest/subtest 0.007s

可以看到输出中会将测试按照嵌套的结构分类,子测试的嵌套没有层数限制,如果不写测试名的话,会自动按照顺序给予序号作为其测试名(例如上面的#00)

对 IDE(Goland)友好的子测试

有一种测试的写法是:

1
2
3
4
5
6
7
8
9
go复制代码tcList := map[string][]int{
"t1": {1, 2, 3},
"t2": {4, 5, 9},
}
for name, tc := range tcList {
t.Run(name, func(t *testing.T) {
require.Equal(t, tc[2], Add(tc[0], tc[1]))
})
}

看上去没什么问题,然而有一个缺点是,这个测试对 IDE 并不友好:

我们无法在出错的时候对单个测试重新执行 所以推荐尽可能对每个 t.Run 都要独立书写,例如:

1
2
3
4
5
6
7
go复制代码f := func(a, b, exp int) func(t *testing.T) {
return func(t *testing.T) {
require.Equal(t, exp, Add(a, b))
}
}
t.Run("t1", f(1, 2, 3))
t.Run("t2", f(4, 5, 9))

测试分包

我们上面的 add.go 和 add_test.go 文件都处于同一个目录下,顶部的 package 名称都是 add,那么在写测试的过程中,也可以为测试启用与非测试文件不同的包名,例如我们现在将测试文件的包名改为 add_test:

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码 // add_test.go
package add_test

import (
"testing"
)

func TestAdd(t *testing.T) {
res := Add(1, 2)
if res != 3 {
t.Errorf("the result is %d instead of 3", res)
}
}

这个时候执行 go test 会发现

1
2
3
4
shell复制代码% go test
# code.byted.org/ek/demo_test/t03_diffpkg_test [code.byted.org/ek/demo_test/t03_diffpkg.test]
./add_test.go:9:9: undefined: Add
FAIL code.byted.org/ek/demo_test/t03_diffpkg [build failed]

由于包名变化了,我们无法再访问到 Add 函数,这个时候我们增加 import 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go复制代码 // add_test.go
package add_test

import (
"testing"

. "code.byted.org/ek/demo_test/t03_diffpkg"
)

func TestAdd(t *testing.T) {
res := Add(1, 2)
if res != 3 {
t.Errorf("the result is %d instead of 3", res)
}
}

我们使用上面的方式来导入包内的函数即可。 但使用了这种方式后,将无法访问包内未导出的函数(以小写开头的)。

测试的工具库

github.com/stretchr/testify

我们可以使用强大的 testify 来方便我们写测试 例如上面的测试我们可以用这个库写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
go复制代码 // add_test.go
package correct

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestAdd(t *testing.T) {
res := Add(1, 2)
require.Equal(t, 3, res)

/*
must := require.New(t)
res := Add(1, 2)
must.Equal(3, res)
*/
}

如果执行失败,则会在命令行看到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
yaml复制代码% go test
ok code.byted.org/ek/demo_test/t04_libraries/testify/correct 0.008s
--- FAIL: TestAdd (0.00s)
add_test.go:12:
Error Trace: add_test.go:12
Error: Not equal:
expected: 3
actual : -1
Test: TestAdd
FAIL
FAIL code.byted.org/ek/demo_test/t04_libraries/testify/wrong 0.009s
FAIL

库提供了格式化的错误详情(堆栈、错误值、期望值等)来方便我们调试。

github.com/DATA-DOG/go-sqlmock

对于需要测试 sql 的地方可以使用 go-sqlmock 来测试

  • 优点:不需要依赖数据库
  • 缺点:脱离了数据库的具体实现,所以需要写比较复杂的测试代码

github.com/golang/mock

强大的对 interface 的 mock 库,例如我们要测试函数 ioutil.ReadAll

1
go复制代码func ReadAll(r io.Reader) ([]byte, error)

我们 mock 一个 io.Reader

1
2
3
4
5
go复制代码// package: 输出包名
// destination: 输出文件
// io: mock对象的包
// Reader: mock对象的interface名
mockgen -package gomock -destination mock_test.go io Reader

可以在目录下看到 mock_test.go 文件里,包含了一个 io.Reader 的 mock 实现 我们可以使用这个实现去测试 ioutil.Reader,例如

1
2
3
4
5
6
css复制代码ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockReader(ctrl)
m.EXPECT().Read(gomock.Any()).Return(0, errors.New("error"))
_, err := ioutil.ReadAll(m)
require.Error(t, err)

net/http/httptest

通常我们测试服务端代码的时候,会先启动服务,再启动测试。官方的 httptest 包给我们提供了一种方便地启动一个服务实例来测试的方法。

其他

其他一些测试工具可以前往 awesome-go#testing 查找

  • github.com/avelino/awe…

如何写好测试

上面介绍了测试的基本工具和写法,我们已经完成了“必先利其器”,下面我们将介绍如何“善其事”。

并发测试

在平时,大家写服务的时候,基本都必须考虑并发,我们使用 IDE 测试的时候,IDE 默认情况下并不会主动测试并发状态,那么如何保证我们写出来的代码是并发安全的? 我们来举个例子,比如我们有个计数器,作用就是计数。

1
2
3
4
5
go复制代码type Counter int32

func (c *Counter) Incr() {
*c++
}

很显然这个计数器在并发情况下是不安全的,那么我们如何写一个测试来做这个计数器的并发测试呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go复制代码import (
"sync"
"testing"

"github.com/stretchr/testify/require"
)

func TestA_Incr(t *testing.T) {
var a Counter
eg := sync.WaitGroup{}
count := 10
eg.Add(count)
for i := 0; i < count; i++ {
go func() {
defer eg.Done()
a.Incr()
}()
}
eg.Wait()
require.Equal(t, count, int(a))
}

通过多次执行上面的测试,我们发现有些时候,测试的结果返回 OK,有些时候测试的结果返回 FAIL。也就是说,即便写了测试,有可能在某次测试中被标记为通过测试。那么有没有什么办法直接发现问题呢?答案就是在测试的时候增加-race 的 flag

-race 标志不适合 benchmark 测试

1
bash复制代码go test -race

这时候终端会输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bash复制代码WARNING: DATA RACE
Read at 0x00c00001ca50 by goroutine 9:
code.byted.org/ek/demo_test/t05_race/race.(*A).Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race.go:6 +0x6f
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr.func1()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:18 +0x66

Previous write at 0x00c00001ca50 by goroutine 8:
code.byted.org/ek/demo_test/t05_race/race.(*A).Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race.go:6 +0x85
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr.func1()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:18 +0x66

Goroutine 9 (running) created at:
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:16 +0xe4
testing.tRunner()
/usr/local/Cellar/go/1.15/libexec/src/testing/testing.go:1108 +0x202

Goroutine 8 (finished) created at:
code.byted.org/ek/demo_test/t05_race/race.TestA_Incr()
/Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:16 +0xe4
testing.tRunner()
/usr/local/Cellar/go/1.15/libexec/src/testing/testing.go:1108 +0x202

go 主动提示,我们的代码中发现了竞争(race)态,这个时候我们就要去修复代码

1
2
3
4
5
go复制代码type Counter int32

func (c *Counter) Incr() {
atomic.AddInt32((*int32)(c), 1)
}

修复完成后再次伴随-race 进行测试,我们的测试成功通过!

Golang 原生的并发测试

golang 的测试类 testing.T 有一个方法 Parallel(),所有在测试中调用了该方法的都会被标记为并发,但是注意,如果需要使用并发测试的结果的话,必须在外层用一个额外的测试函数将其包住:

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码func TestA_Incr(t *testing.T) {
var a Counter
t.Run("outer", func(t *testing.T) {
for i := 0; i < 100; i++ {
t.Run("inner", func(t *testing.T) {
t.Parallel()
a.Incr()
})
}
})
t.Log(a)
}

如果没有第三行的 t.Run,那么 11 行的打印结果将不正确

Golang 的 testing.T 还有很多别的实用方法,大家可以自己去查看一下,这里不详细讨论

正确测试返回值

作为一个 gopher 平时要写大量的 if err != nil,那么在测试一个函数返回的 error 的时候,我们比如有下面的例子

1
2
3
4
5
6
7
8
go复制代码type I interface {
Foo() error
}

func Bar(i1, i2 I) error {
i1.Foo()
return i2.Foo()
}

Bar 函数希望依次处理 i1 和 i2 两个输入,当遇到第一个错误就返回,于是我们写了一个看起来“正确”的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
go复制代码import (
"errors"
"testing"

"github.com/stretchr/testify/require"
)

type impl string

func (i impl) Foo() error {
return errors.New(string(i))
}

func TestBar(t *testing.T) {
i1 := impl("i1")
i2 := impl("i2")
err := Bar(i1, i2)
require.Error(t, err) // assert err != nil
}

这个测试结果“看起来”很完美,函数正确返回了一个错误。但是实际上我们知道这个函数的返回值是错误的,所以我们应当把测试稍作修改,将 error 当作一个返回值来校验起内容,而不是简单的判 nil 处理

1
2
3
4
5
6
7
8
scss复制代码func TestBarFixed(t *testing.T) {
i1 := impl("i1")
i2 := impl("i2")
err := Bar(i1, i2)
// 两种写法都可
require.Equal(t, errors.New("i1"), err)
require.Equal(t, "i1", err.Error())
}

这个时候我们就能发现到,代码中出现了错误,需要修复了。 同理可以应用到别的返回值,我们不应当仅仅做一些简单的判断,而应当尽可能做“精确值”的判断。

测试输入参数

上面我们讨论过了测试返回值,输入值同样需要测试,这一点我们主要结合 gomock 来说,举个例子我们的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码type I interface {
Foo(ctx context.Context, i int) (int, error)
}

type bar struct {
i I
}

func (b bar) Bar(ctx context.Context, i int) (int, error) {
i, err := b.i.Foo(context.Background(), i)
return i + 1, err
}

我们想要测试 bar 类是否正确在方法中调用了 Foo 方法 我们使用 gomock 来 mock 出我们想要的 I 接口的 mock 实现:

1
go复制代码mockgen -package gomock -destination mock_test.go io Reader

接下来我们写了一个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
less复制代码import (
"context"
"testing"

. "code.byted.org/ek/testutil/testcase"
"github.com/stretchr/testify/require"
)

func TestBar(t *testing.T) {
t.Run("test", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := context.Background()
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))
}

测试运行成功,但实际上我们看了代码发现,代码中的 context 并没有被正确的传递,那么我们应该怎么去正确测试出这个情况呢? 一种办法是写一个差不多的测试,测试中修改 context.Background()为别的 context:

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码t.Run("correct", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := context.WithValue(context.TODO(), "k", "v")
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))

另一种办法是加入随机测试要素。

为测试加入随机要素

同样是上面的测试,我们稍做修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码import (
"context"
"testing"

randTest "code.byted.org/ek/testutil/rand"
. "code.byted.org/ek/testutil/testcase"
"github.com/stretchr/testify/require"
)

t.Run("correct", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := context.WithValue(context.TODO(), randTest.String(), randTest.String())
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))

这样就可以很大程度上避免由于固定的测试变量,导致的一些边缘 case 容易被误测为正确,如果回到之前的 Add 函数的例子,可以写成

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码import (
"math/rand"
"testing"

"github.com/stretchr/testify/require"
)

func TestAdd(t *testing.T) {
a := rand.Int()
b := rand.Int()
res := Add(a, b)
require.Equal(t, a+b, res)
}

经过修改的入参

如果我们修改一下之前的 Bar 的例子

1
2
3
4
5
go复制代码func (b bar) Bar(ctx context.Context, i int) (int, error) {
ctx = context.WithValue(ctx, "v", i)
i, err := b.i.Foo(ctx, i)
return i + 1, err
}

函数基本相同,只是传递给 Foo 方法的 ctx 变成了一个子 context,这个时候之前的测试就无法正确执行了,那么如何来判断传递的 context 是最上层的 context 的一个子 context 呢?

通过手写实现判断

一个方法是在测试中,传递给 Bar 一个 context.WithValue,然后在 Foo 的实现中去判断收到的 context 是否带有特定的 kv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
less复制代码t.Run("correct", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
k := randTest.String()
v := randTest.String()
ctx := context.WithValue(context.TODO(), k, v)
impl.EXPECT().Foo(gomock.Any(), i).
Do(func(ctx context.Context, i int) {
s, _ := ctx.Value(k).(string)
must.Equal(v, s)
}).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))

gomock.Matcher

还有一种方法是实现 gomock.Matcher 这个 interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
less复制代码import (
randTest "code.byted.org/ek/testutil/rand"
)

t.Run("simple", TF(func(must *require.Assertions, tc *TC) {
impl := NewMockI(tc.GomockCtrl)
i := 10
j := 11
ctx := randTest.Context()
impl.EXPECT().Foo(ctx, i).
Return(j, nil)
b := bar{i: impl}
r, err := b.Bar(ctx, i)
must.NoError(err)
must.Equal(j+1, r)
}))

randTest.Context 的主要代码如下:

1
2
3
4
5
6
7
8
go复制代码func (ctx randomContext) Matches(x interface{}) bool {
switch v := x.(type) {
case context.Context:
return v.Value(ctx) == ctx.value
default:
return false
}
}

gomock 会自动利用这个接口来判断输入参数的匹配情况。

测试含有很多子调用的函数

我们来看下面的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码func foo(i int) (int, error) {
if i < 0 {
return 0, errors.New("negative")
}
return i + 1, nil
}

func Bar(i, j int) (int, error) {
i, err := foo(i)
if err != nil {
return 0, err
}
j, err = foo(j)
if err != nil {
return 0, err
}
return i + j, nil
}

这里的逻辑看起来比较简单,但是如果我们想象 Bar 的逻辑和 foo 的逻辑都非常复杂,也包含比较多的逻辑分支,那么测试的时候会遇到两个问题

  • 测试 Bar 函数的时候可能需要考虑各种 foo 函数返回值的情况,需要根据 foo 的需求特别构造入参
  • 可能需要大量重复测试到 foo 的场景,与 foo 本身的测试重复

那么如何解决这个问题?我这里给大家提供一个思路,虽然可能不是最优解。有更好解法的希望能够在评论区提出。 我的思路是将 foo 函数从固定的函数变成一个可变的函数指针,可以在测试的时候被动态替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码var foo = func(i int) (int, error) {
if i < 0 {
return 0, errors.New("negative")
}
return i + 1, nil
}

func Bar(i, j int) (int, error) {
i, err := foo(i)
if err != nil {
return 0, err
}
j, err = foo(j)
if err != nil {
return 0, err
}
return i + j, nil
}

于是在测试 Bar 的时候,我们可以替换 foo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
go复制代码func TestBar(t *testing.T) {
f := func(newFoo func(i int) (int, error), cb func()) {
old := foo
defer func() {
foo = old
}()
foo = newFoo
cb()
}
t.Run("first error", TF(func(must *require.Assertions, tc *TC) {
expErr := randTest.Error()
f(func(i int) (int, error) {
return 0, expErr
}, func() {
_, err := Bar(1, 2)
must.Equal(expErr, err)
})
}))
t.Run("second error", TF(func(must *require.Assertions, tc *TC) {
expErr := randTest.Error()
first := true
f(func(i int) (int, error) {
if first {
first = false
return 0, nil
}
return 0, expErr
}, func() {
_, err := Bar(1, 2)
must.Equal(expErr, err)
})
}))
t.Run("success", TF(func(must *require.Assertions, tc *TC) {
f(func(i int) (int, error) {
return i, nil
}, func() {
r, err := Bar(1, 2)
must.NoError(err)
must.Equal(3, r)
})
}))
}

上面的写法就可以单独分别测试 foo 和 Bar 了

  • 使用了这个方法后可能需要多写比较多的 mock 相关的代码(这个部分可以考虑搭配使用 gomock)
  • 这个方法在做并发的测试时候,需要考虑到你 mock 的函数对并发的处理是否正确
  • 这个测试总体上正确的必要条件是 foo 函数的测试正确,并且 foo 函数的 mock 也与正确的 foo 函数的行为一致,所以必要时还是需要额外书写不 mock foo 函数的总体测试

测试的覆盖率

写测试的时候,我们经常会提到一个词,覆盖率。那么什么是测试覆盖率呢?

测试覆盖率是在软件测试或是软件工程中的软件度量,表示软件程式中被测试到的比例。覆盖率是一种判断测试严谨程度的方式。有许多不同种类的测试覆盖率: 代码覆盖率 特征覆盖率 情景覆盖率 屏幕项目覆盖率 模组覆盖率 每一种覆盖率都会假设待测系统已有存在形态基准。因此当系统有变化时,测试覆盖率也会随之改变。

一般情况下,我们可以认为,测试覆盖率越高,我们测试覆盖的情况越全面,测试的有效性就越高。

Golang 的测试覆盖率

在 golang 中,我们通过附加-cover 标志,在测试代码的同时,测试其覆盖率

1
2
3
4
bash复制代码% go test -cover
PASS
coverage: 100.0% of statements
ok code.byted.org/ek/demo_test/t10_coverage 0.008s

我们可以看到当前测试覆盖率为 100%。

100%测试覆盖率不等于正确的测试

测试覆盖率越高不等于测试正确,我们分几种情况分别举例。

并没有正确测试输入输出

这个在上面已经有所提及,可以参考上面“正确测试返回值”的例子,在例子中,测试覆盖率达到了 100%,但是并没有正确测试出代码的问题。

并没有覆盖到所有分支逻辑

1
2
3
4
5
6
go复制代码func AddIfBothPositive(i, j int) int {
if i > 0 && j > 0 {
i += j
}
return i
}

下面的测试用例覆盖率达到了 100%,但是并没有测试到所有的分支

1
2
3
4
scss复制代码func TestAdd(t *testing.T) {
res := AddIfBothPositive(1, 2)
require.Equal(t, 3, res)
}

并没有处理异常/边界条件

1
2
3
go复制代码func Divide(i, j int) int {
return i / j
}

Divide 函数并没有处理除数为 0 的情况,而单元测试的覆盖率是 100%

1
2
3
4
scss复制代码func TestAdd(t *testing.T) {
res := Divide(6, 2)
require.Equal(t, 3, res)
}

上面的例子说明 100%的测试覆盖并不是真的“100%覆盖”了所有的代码运行情况。

覆盖率的统计方法

测试覆盖率的统计方法一般是: 测试中执行到的代码行数 / 测试的代码的总行数 然而代码在实际运行中,每一行运行到的概率、出错的严重程度等等也是不同的,所以我们在追求高覆盖率的同时,不能迷信覆盖率。

测试是不怕重复书写的

这里的重复书写,可以一定程度上认为是“代码复用”的反义词。我们主要从下面的几方面来说。

重复书写类似的测试用例

测试用例只要不是完全一致,那么即便是比较雷同的测试用例,我们都可以认为是有意义的,没有必要为了代码的精简特地删除,例如我们测试上面的 Add 函数

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码func TestAdd(t *testing.T) {
t.Run("fixed", func(t *testing.T) {
res := Add(1, 2)
require.Equal(t, 3, res)
})
t.Run("random", func(t *testing.T) {
a := rand.Int()
b := rand.Int()
res := Add(a, b)
require.Equal(t, a+b, res)
})
}

虽然第二个测试看起来覆盖了第一个测试,但没有必要去特地删除第一个测试,越多的测试越能增加我们代码的可靠性。

重复书写(源)代码中的定义和逻辑

比如我们有一份代码

1
2
3
4
5
6
7
go复制代码package add

const Value = 3

func AddInternalValue(a int) int {
return a + Value
}

测试为

1
2
3
4
scss复制代码func TestAdd(t *testing.T) {
res := AddInternalValue(1)
require.Equal(t, 1+Value, res)
}

看起来非常完美,但是如果某天内部变量 Value 的值被不小心改动了,那么这个测试无法反应出这个改动,也就无法及时发现这个错误了。如果我们写成

1
2
3
4
5
go复制代码func TestAdd(t *testing.T) {
const value = 3
res := AddInternalValue(1)
require.Equal(t, 1+value, res)
}

就不用担心无法发现常量值的变化了。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

如何用策略模式,优化你代码里的的if-else?

发表于 2021-06-09

有情怀,有干货,微信搜索【三太子敖丙】关注这个有一点点东西的程序员。

本文 GitHub github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

最近有一个学妹在跟我沟通如何有效的去避免代码中一长串的if else判断或者switch条件判断?针对更多的回答就是合理的去使用设计来规避这个问题。

在设计模式中,可以使用工厂模式或者策略模式来处理这类问题,之前已经分享了工厂模式,感兴趣的同学可以去复习一下。

设计模式系列往期文章:

  • 单例模式
  • 工厂模式
  • 流程引擎
  • 建造者模式
  • 原型模式
  • 责任链模式
  • 观察者模式

那么工厂模式和策略模式有什么区别呢?

  • 工厂模式是属于创建型设计模式,主要用来针对不同类型创建不同的对象,达到解偶类对象。
  • 策略模式是属于行为型设计模式,主要是针对不同的策略做出对应行为,达到行为解偶

本次就来具体聊聊策略模式它是如何做到行为解耦

大纲

定义

什么是策略模式?它的原理实现是怎么样的?

定义一系列算法,封装每个算法,并使他们可以互换,不同的策略可以让算法独立于使用它们的客户而变化。 以上定义来自设计模式之美

感觉有点抽象?那就来看一张结构图吧

  • Strategy(抽象策略):抽象策略类,并且定义策略执行入口
  • ConcreteStrategy(具体策略):实现抽象策略,实现algorithm方法
  • Context(环境):运行特定的策略类。

这么看结构其实还是不复杂的,而且跟状态模式类似。

那么这个代码怎么实现?

举个例子,汽车大家肯定都不陌生,愿大家早日完成汽车梦,汽车的不同档(concreteStrategy)就好比不同的策略,驾驶者选择几档则汽车按几档的速度前进,整个选择权在驾驶者(context)手中。

1
2
3
4
5
java复制代码public interface GearStrategy {

// 定义策略执行方法
void algorithm(String param);
}

首先还是先定义抽象策略

这里是用接口的形式,还有一种方式可以用抽象方法abstract来写也是一样的。具体就看大家自己选择了。

1
2
3
4
5
6
> java复制代码public abstract class GearStrategyAbstract {
> // 定义策略执行方法
> abstract void algorithm(String param);
> }
>
>
1
2
3
4
5
6
7
java复制代码public class GearStrategyOne implements GearStrategy {

@Override
public void algorithm(String param) {
System.out.println("当前档位" + param);
}
}

其次定义具体档位策略,实现algorithm方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
java复制代码public class Context {
// 缓存所有的策略,当前是无状态的,可以共享策略类对象
private static final Map<String, GearStrategy> strategies = new HashMap<>();

// 第一种写法
static {
strategies.put("one", new GearStrategyOne());
}

public static GearStrategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
return strategies.get(type);
}

// 第二种写法
public static GearStrategy getStrategySecond(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
if (type.equals("one")) {
return new GearStrategyOne();
}
return null;
}


public static void main(String[] args) {
// 测试结果
GearStrategy strategyOne = Context.getStrategy("one");
strategyOne.algorithm("1档");
// 结果:当前档位1档
GearStrategy strategyTwo = Context.getStrategySecond("one");
strategyTwo.algorithm("1档");
// 结果:当前档位1档
}

}

最后就是实现运行时环境(Context),你可以定义成StrategyFactory,但都是一个意思。

在main方法里面的测试demo,可以看到通过不同的type类型,可以实现不同的策略,这就是策略模式主要思想。

在Context里面定义了两种写法:

  • 第一种是维护了一个strategies的Map容器。用这种方式就需要判断每种策略是否可以共享使用,它只是作为算法的实现。
  • 第二种是直接通过有状态的类,每次根据类型new一个新的策略类对象。这个就需要根据实际业务场景去做的判断。

框架的应用

策略模式在框架中也在一个很常见的地方体现出来了,而且大家肯定都有使用过。

那就是JDK中的线程池ThreadPoolExecutor

首先都是类似于这样定义一个线程池,里面实现线程池的异常策略。

这个线程池的异常策略就是用的策略模式的思想。

在源码中有RejectedExecutionHandler这个抽象异常策略接口,同时它也有四种拒绝策略。关系图如下:

这就是在框架中的体现了,根据自己的业务场景,合理的选择线程池的异常策略。

业务改造举例

在真实的业务场景中策略模式也还是应用很多的。

在社交电商中分享商品是一个很重要的环节,假设现在要我们实现一个分享图片功能,比如当前有 单商品、多商品、下单、会场、邀请、小程序链接等等多种分享场景。

针对上线这个流程图先用if else语句做一个普通业务代码判断,就像下面的这中方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
java复制代码public class SingleItemShare {
// 单商品
public void algorithm(String param) {
System.out.println("当前分享图片是" + param);
}
}
public class MultiItemShare {
// 多商品
public void algorithm(String param) {
System.out.println("当前分享图片是" + param);
}
}
public class OrderItemShare {
// 下单
public void algorithm(String param) {
System.out.println("当前分享图片是" + param);
}
}
public class ShareFactory {

public static void main(String[] args) throws Exception {
Integer shareType = 1;
// 测试业务逻辑
if (shareType.equals(ShareType.SINGLE.getCode())) {
SingleItemShare singleItemShare = new SingleItemShare();
singleItemShare.algorithm("单商品");
} else if (shareType.equals(ShareType.MULTI.getCode())) {
MultiItemShare multiItemShare = new MultiItemShare();
multiItemShare.algorithm("多商品");
} else if (shareType.equals(ShareType.ORDER.getCode())) {
OrderItemShare orderItemShare = new OrderItemShare();
orderItemShare.algorithm("下单");
} else {
throw new Exception("未知分享类型");
}
// .....省略更多分享场景
}

enum ShareType {
SINGLE(1, "单商品"),
MULTI(2, "多商品"),
ORDER(3, "下单");
/**
* 场景对应的编码
*/
private Integer code;
/**
* 业务场景描述
*/
private String desc;
ShareType(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public Integer getCode() {
return code;
}
// 省略 get set 方法
}
}

这里大家可以看到每新加一种分享类型,就需要加一次if else 判断,当如果有十几种场景的时候那代码整体就会非常的长,看起来给人的感觉也不是很舒服。

接下来就看看如何用策略模式进行重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
java复制代码public interface ShareStrategy {
// 定义分享策略执行方法
void shareAlgorithm(String param);
}

public class OrderItemShare implements ShareStrategy {
@Override
public void shareAlgorithm(String param) {
System.out.println("当前分享图片是" + param);
}
}

// 省略 MultiItemShare以及SingleItemShare策略

// 分享工厂
public class ShareFactory {
// 定义策略枚举
enum ShareType {
SINGLE("single", "单商品"),
MULTI("multi", "多商品"),
ORDER("order", "下单");
// 场景对应的编码
private String code;

// 业务场景描述
private String desc;
ShareType(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getCode() {
return code;
}
// 省略 get set 方法
}
// 定义策略map缓存
private static final Map<String, ShareStrategy> shareStrategies = new HashMap<>();
static {
shareStrategies.put("order", new OrderItemShare());
shareStrategies.put("single", new SingleItemShare());
shareStrategies.put("multi", new MultiItemShare());
}
// 获取指定策略
public static ShareStrategy getShareStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
return shareStrategies.get(type);
}

public static void main(String[] args) {
// 测试demo
String shareType = "order";
ShareStrategy shareStrategy = ShareFactory.getShareStrategy(shareType);
shareStrategy.shareAlgorithm("order");
// 输出结果:当前分享图片是order
}
}

这里策略模式就已经改造完了。在client请求端,根本看不到那么多的if else判断,只需要传入对应的策略方式即可,这里我们维护了一个策略缓存map,在直接调用的ShareFactory获取策略的时候就直接是从换种获取策略类对象。

这就已经达到了行为解偶的思想。同时也避免了长串的if else 判断。

优点:

  • 算法策略可以自由实现切换
  • 扩展性好,加一个策略,只需要增加一个类

缺点:

  • 策略类数量多
  • 需要维护一个策略枚举,让别人知道你当前具有哪些策略

总结

以上就讲完了策略模式,整体看上去其实还是比较简单的,还是那句话学习设计模式我们还是要学习每种设计模式的思想,任何一种设计模式存在即合理。当然也不要因为设计模式而设计代码,那样反而得不偿失。

我是敖丙,你知道的越多,你不知道的越多,感谢各位人才的:点赞、收藏和评论,我们下期见!


文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHub github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Golang 生成 Excel 文档

发表于 2021-06-08

这是我参与更文挑战的第7天,活动详情查看:更文挑战

基于数据生成 Excel 文档是一个很常见的需求,本文将介绍如何使用 Go 的 Excelize 库去生成 Excel 文档,以及一些具体场景下的代码实现。

关于 Excelize 库

Excelize 是 Go 语言编写的用于操作 Office Excel 文档基础库,基于 ECMA-376,ISO/IEC 29500 国际标准。可以使用它来读取、写入由 Microsoft Excel™ 2007 及以上版本创建的电子表格文档。支持 XLSX / XLSM / XLTM / XLTX 等多种文档格式,高度兼容带有样式、图片(表)、透视表、切片器等复杂组件的文档,并提供流式读写 API,用于处理包含大规模数据的工作簿。可应用于各类报表平台、云计算、边缘计算等系统。使用本类库要求使用的 Go 语言为 1.15 或更高版本。

性能对比

下图是一些主要的开源 Excel 库在生成 12800*50 纯文本矩阵时的性能对比(OS: macOS Mojave version 10.14.4, CPU: 3.4 GHz Intel Core i5, RAM: 16 GB 2400 MHz DDR4, HDD: 1 TB),包括 Go、Python、Java、PHP 和 NodeJS。

安装

最新的版本是 v2.4.0:

1
bash复制代码go get github.com/360EntSecGroup-Skylar/excelize/v2

创建 Excel 文档

下面的案例中,我们创建了一个 Excel 文档,并使用 NewSheet 方法新建了一个 Sheet2 工作表,Sheet1 是默认创建的工作表,然后我们使用 SetCellValue 方法分别在 Sheet2 工作表的 A2 单元格 和 Sheet1 表格的 B2 单元格设置值,并通过使用 SetActiveSheet 方法设置 Sheet2 工作表为默认的工作表,最终调用 SaveAs 方法将数据写入 Excel 文档中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码package main

import (
"fmt"

"github.com/360EntSecGroup-Skylar/excelize/v2"
)

func main() {
f := excelize.NewFile()
// 创建一个工作表
index := f.NewSheet("Sheet2")
// 设置单元格的值
f.SetCellValue("Sheet2", "A2", "Hello world.")
f.SetCellValue("Sheet1", "B2", 100)
// 设置工作簿的默认工作表
f.SetActiveSheet(index)
// 根据指定路径保存文件
if err := f.SaveAs("Book1.xlsx"); err != nil {
fmt.Println(err)
}
}

实际场景复现

创建工作表

工作表名称是大小写敏感的:

1
go复制代码index := f.NewSheet("Sheet2")

删除默认创建的工作表

默认创建的 Excel 文档是包含一个名为 Sheet1 的工作表,我们可能并不需要这个默认工作表,这个时候我们可以删除这个工作表:

1
go复制代码f.DeleteSheet("Sheet1")

合并单元格

合并 Sheet1 工作表上 F1:I2 区域内的单元格:

1
go复制代码excel.MergeCell("Sheet1", "F1", "I2")

单元格样式

给单元格设置样式会经常遇到,比如设置单元格的背景颜色,Excelize 库提供下面两个方法进行设置单元格样式(NewStyle 和 SetCellStyle):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
go复制代码// 通过给定的样式格式 JSON 或结构体的指针创建样式并返回样式索引。
// 请注意,颜色需要使用 RGB 色域代码表示。
style, err := f.NewStyle(`{
"border": [
{
"type": "left",
"color": "0000FF",
"style": 3
},
{
"type": "top",
"color": "00FF00",
"style": 4
},
{
"type": "bottom",
"color": "FFFF00",
"style": 5
},
{
"type": "right",
"color": "FF0000",
"style": 6
},
{
"type": "diagonalDown",
"color": "A020F0",
"style": 7
},
{
"type": "diagonalUp",
"color": "A020F0",
"style": 8
}]
}`)
if err != nil {
fmt.Println(err)
}
err = f.SetCellStyle("Sheet1", "D7", "D7", style)

文字水平居中

文字水平居中需要用到 Alignment 样式结构体:

1
2
3
4
5
6
7
8
9
10
11
go复制代码type Alignment struct {
Horizontal string `json:"horizontal"`
Indent int `json:"indent"`
JustifyLastLine bool `json:"justify_last_line"`
ReadingOrder uint64 `json:"reading_order"`
RelativeIndent int `json:"relative_indent"`
ShrinkToFit bool `json:"shrink_to_fit"`
TextRotation int `json:"text_rotation"`
Vertical string `json:"vertical"`
WrapText bool `json:"wrap_text"`
}

水平居中只要设置 Horizontal 的值为 center 即可:

1
2
3
4
5
go复制代码style, err := f.NewStyle(`{"alignment":{"horizontal":"center"}}`)
if err != nil {
fmt.Println(err)
}
err = excel.SetCellStyle("Sheet1", "B1", "B1", style)

给单元格设置纯色填充

给单元格填充颜色会使用到 Fill 样式结构体:

1
2
3
4
5
6
go复制代码type Fill struct {
Type string `json:"type"`
Pattern int `json:"pattern"`
Color []string `json:"color"`
Shading int `json:"shading"`
}

Style 结构体

从上面设置样式的代码中,我们可以发现 border 是一个数组,而 alignment 是一个结构体,这是由 Style 结构体决定的:

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码type Style struct {
Border []Border `json:"border"`
Fill Fill `json:"fill"`
Font *Font `json:"font"`
Alignment *Alignment `json:"alignment"`
Protection *Protection `json:"protection"`
NumFmt int `json:"number_format"`
DecimalPlaces int `json:"decimal_places"`
CustomNumFmt *string `json:"custom_number_format"`
Lang string `json:"lang"`
NegRed bool `json:"negred"`
}

参考文档

  • Excelize docs reference
  • Talks at Beijing Gopher Meetup

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

盘点 Flow SpringFlow 配置篇

发表于 2021-06-08

这是我参与更文挑战的第3天,活动详情查看: 更文挑战

总文档 :文章目录

Github : github.com/black-ant

一 . 前言

Spring Web Flow 是一个 Spring 的流处理框架 , 这是个使用不多的框架

一开始我也陷入一种误区 , 一直在查找他和 SpringMVC 的优劣 , 但是实际上 , 他们2者是不冲突的 .

SpringWebFlow 建立在 SpringMVC 之上,并允许实现 Web 应用程序的“流程”。流封装了指导用户执行某些业务任务的一系列步 .

也就是说 : 它可以作为一种完善 SpringMVC 的角色 , 以满足 SpringMVC 不能做到的复杂功能

作用官方说明 : Spring Web Flow 的最佳选择是有状态的 Web 应用程序,它具有可控导航,比如登机、申请贷款、购物车结账,甚至在表单中添加确认步骤 ,他们通常有以下特征 :

  • 有一个清晰的起点和终点
  • 用户必须按照特定的顺序浏览一组视图(表单)
  • 直到最后一步,更改才最终确定
  • 一旦完成,就不可能意外地重复一个事务

以上是官方说法 ,但是个人在生产中 , 也体验过该框架 , 说说感受 :

  • 对视图依赖高 , 可以做到但是不好做到前后端分离 (视图不限定于 Thymeleaf 等引擎 , 但是如果不使用引擎 , 会丢失很多特性)
  • invoke 代理复杂 , 对框架不熟悉基本是很难 debug 流程
  • 只适合单流程 , 不易做到多人审批操作
  • 没有可视化的配置途径 (至少我没看到)

但是他也有其他的优点:

  • 在不考虑前后端分离时 , scope 域用来渲染参数很方便
  • 集成简单 , 不需要对外部有过多依赖
  • 当需要做一个负载的单流程时 , 可以最大化的梳理流程减少耦合提高可视度 (不考虑其他 Flow 插件)
  • 与 MVC 无冲突

二 . 基础使用

2.1 Java Config 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
java复制代码@Configuration
public class WebFlowConfig extends AbstractFlowConfiguration {

private Logger logger = LoggerFactory.getLogger(this.getClass());

@Autowired
private ThymeleafViewResolver thymeleafViewResolver;

@Autowired
private ApplicationContext context;

@Bean
public FlowHandlerMapping flowHandlerMapping() {
logger.info("------> 构建映射关系 <-------");
FlowHandlerMapping handlerMapping = new FlowHandlerMapping();
handlerMapping.setOrder(-1);
handlerMapping.setFlowRegistry(flowRegistry());
return handlerMapping;
}


@Bean
public FlowHandlerAdapter flowHandlerAdapter() {
logger.info("------> 构建处理器 <-------");
FlowHandlerAdapter handlerAdapter = new FlowHandlerAdapter();
handlerAdapter.setFlowExecutor(flowExecutor());
handlerAdapter.setSaveOutputToFlashScopeOnRedirect(true);
return handlerAdapter;
}

@Bean
public FlowDefinitionRegistry flowRegistry() {
return new FlowDefinitionRegistryBuilder(context)
.setFlowBuilderServices(flowBuilderServices())
.setBasePath("classpath:/flows")
.addFlowLocation("activation-flow.xml", "activationFlow")
.build();
}


@Bean
public FlowExecutor flowExecutor() {
return getFlowExecutorBuilder(flowRegistry()).build();
}

@Bean
public FlowBuilderServices flowBuilderServices() {
return getFlowBuilderServicesBuilder()
.setViewFactoryCreator(mvcViewFactoryCreator())
.setDevelopmentMode(true).build();
}

@Bean
public MvcViewFactoryCreator mvcViewFactoryCreator() {
logger.info("------> [构建 View 工厂] <-------");
MvcViewFactoryCreator factoryCreator = new MvcViewFactoryCreator();
// PS : 此句会覆盖下方语句 . 这里感觉设计的有问题
factoryCreator.setUseSpringBeanBinding(true);
factoryCreator.setViewResolvers(Collections.singletonList(thymeleafViewResolver));
return factoryCreator;
}


}

2.2 WebFlow xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<flow xmlns="http://www.springframework.org/schema/webflow"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/webflow
http://www.springframework.org/schema/webflow/spring-webflow.xsd">

<view-state id="activation">
<transition on="activate" to="success"/>
<transition on="cancel" to="failure"/>
</view-state>

<view-state id="success" />

<view-state id="failure" />

</flow>

2.3 WebView 文件

其中包含 4 个 html 文件 , 可以查看源码获取 @GitHub 源码

三 . 源码解析

因为 Spring Web Flow 的文档较少 , 而如果因为一些原因而使用该框架 , 以下分析流程会对你有所帮助:

从上述流程中 ,可以看到配置了以下几个对象 , 按照依赖关系进行展示:

Step 1 : 配置和映射

  • MvcViewFactoryCreator : View 创建工厂
  • FlowBuilderServices : Web Flow build 构建
  • FlowDefinitionRegistry : Flow Definition 创建
  • FlowHandlerMapping : 映射处理器

Step 2 : 业务处理

  • FlowExecutor : WebFlow 执行器
  • flowHandlerAdapter : WebFlow 适配器

3.1 MvcViewFactoryCreator 的配置

Step 1 : 配置文件入口

1
2
3
4
5
6
7
8
9
java复制代码@Bean
public MvcViewFactoryCreator mvcViewFactoryCreator() {
MvcViewFactoryCreator factoryCreator = new MvcViewFactoryCreator();
// 设置视图解析器
factoryCreator.setViewResolvers(Collections.singletonList(thymeleafViewResolver));
// 设置是否启用Spring的BeanWrapper使用数据绑定
factoryCreator.setUseSpringBeanBinding(true);
return factoryCreator;
}

Step 2 : MvcViewFactoryCreator 创建详情

功能 : 返回ViewFactory视图工厂,该视图工厂创建基于Spring的原生视图。SpringMVC的视图工厂来配置流的视图状态

使用 :

  • 创建视图工厂,这些视图工厂通过加载流相关资源(比如位于流工作目录中的.jsp模板)来解析它们的视图
  • 这个类还支持呈现由预先存在的Spring MVC ViewResolver视图解析器解析的视图

从下图的方法中 ,我们大概可以看到提供了以下主要的功能 :

  • setDefaultViewSuffix : 设置视图的后缀
  • setUseSpringBeanBinding : 设置是否启用Spring的BeanWrapper使用数据绑定
  • setFlowViewResolver : 设置 View 解析器
  • setViewResolvers : 使用不同的 SpringMVC 解析器 , 托解析由流选择的视图
  • setMessageCodesResolver : 设置用于解析绑定和验证错误消息代码的消息代码解析器策略

SpringFlow_MVCViewFactoryCreatoer.jpg

小总结 : 可以看到 , 实际上 WebFlow 和 MVC 有很多共用的类 ,并不是2个完全独立的个体.

3.2 FlowBuilderServices 详情

FlowBuilderServices 是构建 Flow 的主流程 , 主要用于配置流构建器使用的服务的简单holder , 从其内部资源就可以看到一二 :

  • FlowArtifactFactory : 封装中心流构件(如流和状态)创建的工厂
  • ViewFactoryCreator : 视图工厂创建器,用于创建在流执行期间呈现的视图
  • ConversionService : 用于从一种对象类型转换为另一种对象类型的转换服务
  • ExpressionParser : 用于将表达式字符串解析为表达式对象的解析器。默认是Web Flow的默认表达式解析器实现
  • Validator : 验证器实例,用于验证在视图状态上声明的模型
  • ValidationHintResolver : 用于解析基于验证提示的字符串的ValidationHintResolver
  • ApplicationContext : 主容器

PS : 这个类其实就是一个综合类 , 用于将多个业务处理类进行整合

3.3 FlowDefinitionRegistry 详情

Step 1 : 配置的入口

1
2
3
4
5
6
7
8
java复制代码@Bean
public FlowDefinitionRegistry flowRegistry() {
// 通过构建器构建
return getFlowDefinitionRegistryBuilder(flowBuilderServices())
// 添加扫描路径
.addFlowLocation("/WEB-INF/flows/activation-flow.xml", "activationFlow")
.build();
}

[Pro] : FlowDefinitionRegistry 的作用是什么 ?

FlowDefinitionRegistry 用于访问在运行时执行的注册流定义 , 该对象会扫描 xml 文件

Step 2 : Builder 构建流程

可以看到 , 就是 new 一个对象 , 包括当前的 ApplicationContext 以及 BuildService

1
2
3
java复制代码protected FlowDefinitionRegistryBuilder getFlowDefinitionRegistryBuilder(FlowBuilderServices flowBuilderServices) {
return new FlowDefinitionRegistryBuilder(this.applicationContext, flowBuilderServices);
}

Step 3 : FlowDefinitionRegistryBuilder 构建

1
2
3
4
5
6
7
8
9
10
java复制代码public FlowDefinitionRegistryBuilder(ApplicationContext appContext, FlowBuilderServices builderServices) {
// 构建了一个 FlowDefinitionResourceFactory
this.flowResourceFactory = new FlowDefinitionResourceFactory(appContext);
if (builderServices != null) {
this.flowBuilderServices = builderServices;
} else {
this.flowBuilderServices = new FlowBuilderServicesBuilder().build();
this.flowBuilderServices.setApplicationContext(appContext);
}
}

[Pro] : FlowDefinitionResourceFactory 作用 ?

用于创建流定义资源的工厂,这些资源用作指向外部流定义文件的指针

  • setBasePath : 设置在确定默认流id时从流路径中删除的基础路径
  • createResource: 从提供的路径位置创建流定义资源
  • createFileResource : 从提供的文件路径创建基于文件的资源
  • getFlowId :

Step 4 : FlowLocation 构建

每一个 Flow.xml 会被映射为一个 FlowLocation 对象 , 该对象映射一个 xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码private static class FlowLocation {
// Flow.xml 路径
private final String path;

// 唯一 ID
private final String id;

// 额外属性 , 默认为 null
private final AttributeMap<Object> attributes;

//............

}

Step 5 : FlowLocation 的扫描

在 FlowDefinitionRegistryBuilder 中会扫描所有的 FlowLocation 对象 , 并且进行处理

1
2
3
4
5
6
7
8
9
10
11
java复制代码private void registerFlowLocations(DefaultFlowRegistry flowRegistry) {
for (FlowLocation location : this.flowLocations) {
String path = location.getPath();
String id = location.getId();
AttributeMap<Object> attributes = location.getAttributes();
updateFlowAttributes(attributes);
// 流定义资源的抽象表示。保存从外部文件构建流定义所需的数据,并在流定义注册表中注册流定义
FlowDefinitionResource resource = this.flowResourceFactory.createResource(path, attributes, id);
registerFlow(resource, flowRegistry);
}
}

[Pro] : registerFlowLocations 被调用的方式

在 build 方法中创建 FlowDefinitionRegistry

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码C- FlowDefinitionRegistryBuilder
public FlowDefinitionRegistry build() {

DefaultFlowRegistry flowRegistry = new DefaultFlowRegistry();
flowRegistry.setParent(this.parent);

registerFlowLocations(flowRegistry);
registerFlowLocationPatterns(flowRegistry);
registerFlowBuilders(flowRegistry);

return flowRegistry;
}

[Pro] : FlowDefinitionResource 构建方式

FlowDefinitionResource 通过其工程类构建 , FlowDefinitionResourceFactory在构造器中默认创建
this.flowResourceFactory = new FlowDefinitionResourceFactory(appContext);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public FlowDefinitionResource createResource(String path, AttributeMap<Object> attributes, String flowId) {
Resource resource;
// 判断是否存在根路径来决定如何使用相对路径
if (basePath == null) {
resource = resourceLoader.getResource(path);
} else {
try {
String basePath = this.basePath;
if (!basePath.endsWith(SLASH)) {
// basePath必须以斜杠结尾来创建一个相对资源
basePath = basePath + SLASH;
}
resource = resourceLoader.getResource(basePath).createRelative(path);
} catch (IOException e) {
throw new IllegalStateException(....);
}
}
if (flowId == null || flowId.length() == 0) {
flowId = getFlowId(resource);
}
return new FlowDefinitionResource(flowId, resource, attributes);
}

Step 6 : Flow 注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码private void registerFlow(FlowDefinitionResource resource, DefaultFlowRegistry flowRegistry) {
FlowModelBuilder flowModelBuilder;
if (resource.getPath().getFilename().endsWith(".xml")) {
flowModelBuilder = new XmlFlowModelBuilder(resource.getPath(), flowRegistry.getFlowModelRegistry());
} else {
throw new IllegalArgumentException(resource
+ " is not a supported resource type; supported types are [.xml]");
}
FlowModelHolder flowModelHolder = new DefaultFlowModelHolder(flowModelBuilder);
FlowBuilder flowBuilder = new FlowModelFlowBuilder(flowModelHolder);
FlowBuilderContext builderContext = new FlowBuilderContextImpl(
resource.getId(), resource.getAttributes(), flowRegistry, this.flowBuilderServices);
FlowAssembler assembler = new FlowAssembler(flowBuilder, builderContext);
DefaultFlowHolder flowHolder = new DefaultFlowHolder(assembler);

flowRegistry.getFlowModelRegistry().registerFlowModel(resource.getId(), flowModelHolder);
flowRegistry.registerFlowDefinition(flowHolder);
}

[Pro] : FlowModelBuilder 作用

用于构建流模型的构建器接口。构建流模型的过程包括以下步骤 >>

  1. 通过调用 #init()初始化这个生成器
  2. 调用#build()创建流模型
  3. 调用#getFlowModel()返回完全构建的FlowModel模型
  4. 释放此构建器,通过调用# Dispose()释放构建过程中分配的任何资源

Step 7 : FlowDefinitionRegistryImpl 注册

1
java复制代码flowDefinitions.put(definitionHolder.getFlowDefinitionId(), definitionHolder)

3.4 FlowHandlerMapping 构建

通过 FlowDefinitionRegistry 构建 FlowHandlerMapping , 用于后续处理

作用 : HandlerMapping的实现,遵循一个简单的约定,从注册的FlowDefinition的id来创建URL路径映射
返回 : 该实现返回一个FlowHandler,如果当前请求路径与配置的FlowDefinitionRegistry中的流的id匹配,该FlowHandler将调用流。

FlowUrlHandler 是一个接口 , 他有3个实现类 , 此处主要为 DefaultFlowUrlHandler

  • DefaultFlowUrlHandler
  • FilenameFlowUrlHandler
  • WebFlow1FlowUrlHandler
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码//  FlowHandlerMapping 属性
public class FlowHandlerMapping extends AbstractHandlerMapping {

private FlowDefinitionRegistry flowRegistry;
private FlowUrlHandler flowUrlHandler;

}

// FlowHandlerMapping # getHandlerInternal 处理请求 , 核心2句话

String flowId = flowUrlHandler.getFlowId(request);
Object handler = getApplicationContext().getBean(flowId);

3.5 HandlerAdapter

作用 : 一个自定义的MVC HandlerAdapter ,封装了与Servlet环境中执行流相关的通用工作流。委托映射的FlowHandler流处理程序来管理与特定流定义执行的交互

WebFlowHandlerAdapter.png

此处主要使用 FlowHandlerAdapter , 简单看一下其主要方法就知道其作用了

1
2
3
4
5
java复制代码// 可以看到还是使用 ModelAndView 返回视图对象
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)

// 同时提供重定向等功能
void sendRedirect(String url, HttpServletRequest request, HttpServletResponse response)

总结

总结一下 , FlowBuilderServices 作为全局业务类 , MvcViewFactoryCreator 用于创建 View 工厂 , ,通过 FlowHandlerMapping 拦截请求后, 再通过 FlowDefinitionRegistry 注册 Flow , 执行 FlowExecutor 和 flowHandlerAdapter 返回 view 对象

不过在我自己的使用中 , 通常会选用其他的 flow 框架 , Spring Flow 从社区到文档 , 都不是一个较好的选择 ,除非自己项目不想做的太复杂 , 也有相关的限制.

后续我会对比他和其他的 flow 框架的区别 , 拭目以待 >>>

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…647648649…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%