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

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


  • 首页

  • 归档

  • 搜索

js如何实现当文本内容过长时,中间显示省略号,两端正常

发表于 2024-01-31

前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。

产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号…表示,并且鼠标悬浮后,展示全部内容。

关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号…的代码。

实现思路

  1. 获取标题盒子的真实宽度, 我这里用的是clientWidth;
  2. 获取文本内容所占的实际宽度;
  3. 根据文字的大小计算出每个文字所占的宽度;
  4. 判断文本内容的实际宽度是否超出了标题盒子的宽度;
  5. 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;
  6. 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;

代码

html代码

1
html复制代码<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>

css代码: 设置文本不换行,同时设置overflow:hidden让文本溢出盒子隐藏

1
2
3
4
5
6
7
8
9
10
11
12
13
css复制代码.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}

javascript代码:

获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px,可以用parseInt特殊处理一下。

获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。

判断文本内容是否超出标题盒子

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
js复制代码 // 标题盒子dom
const dom = document.getElementById('test');

// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();

// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);

// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;

// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}

// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;

// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}

通过charCodeAt返回指定位置的字符的Unicode编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。

截取和计算文本长度

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
js复制代码// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}

// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');

// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);

// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');

// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}

最终实现的效果如下:

image.png

上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。

下面记录下从社区内学到的相关知识:

  1. js判断文字被溢出隐藏的几种方法;
  2. JS获取字符串长度的几种常用方法,汉字算两个字节;

1、 js判断文字被溢出隐藏的几种方法

1. Element-plus这个UI框架中的表格组件实现的方案。

通过document.createRange和document.getBoundingClientRect()这两个方法实现的。也就是我上面代码中实现的checkLength方法。

2. 创建一个隐藏的div模拟实际宽度

通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。

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
js复制代码function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}

3. 创建一个block元素来包裹inline元素

这种方法是在UI框架acro design vue中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>

// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}

4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度

通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。

1
2
3
4
5
6
7
8
js复制代码// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}

5. 使用css实现

这种方式来自评论区的掘友@S_mosar提供的思路。
先来看下效果:

2024-04-09 09.37.27.gif

代码如下:
css部分

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
css复制代码.con {
font-size: 14px;
color: #666;
width: 600px;
margin: 50px auto;
border-radius: 8px;
padding: 15px;
overflow: hidden;
resize: horizontal;
box-shadow: 20px 20px 60px #bebebe, -20px -20px 60px #ffffff;
}

.wrap {
position: relative;
line-height: 2;
height: 2em;
padding: 0 10px;
overflow: hidden;
background: #fff;
margin: 5px 0;
}

.wrap:nth-child(odd) {
background: #f5f5f5;
}

.title {
display: block;
position: relative;
background: inherit;
text-align: justify;
height: 2em;
overflow: hidden;
top: -4em;
}

.txt {
display: block;
max-height: 4em;
}
.title::before{
content: attr(title);
width: 50%;
float: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
}

html部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
html复制代码<ul class="con">
<li class="wrap">
<span class="txt">CSS 实现优惠券的技巧 - 2021-03-26</span>
<span class="title" title="CSS 实现优惠券的技巧 - 2021-03-26">CSS 实现优惠券的技巧 - 2021-03-26</span>
</li>
<li class="wrap">
<span class="txt">CSS 测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26</span>
<span class="title" title="CSS 测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26">CSS
测试标题,这是一个稍微有点长的标题,超出一行以后才会有title提示,标题是 实现优惠券的技巧 - 2021-03-26</span>
</li>
<li class="wrap">
<span class="txt">CSS 拖拽?</span>
<span class="title" title="CSS 拖拽?">CSS 拖拽?</span>
</li>
<li class="wrap">
<span class="txt">CSS 文本超出自动显示title</span>
<span class="title" title="CSS 文本超出自动显示title">CSS 文本超出自动显示title</span>
</li>
</ul>

思路解析:

  1. 文字内容的父级标签li设置line-height: 2;、overflow: hidden;、height: 2em;,因此 li 标签的高度是当前元素字体大小的2倍,行高也是当前字体大小的2倍,同时内容若溢出则隐藏。
  2. li 标签内部有两个 span 标签,二者的作用分别是:类名为.txt的标签用来展示不需要省略号时的文本,类名为.title用来展示需要省略号时的文本,具体是如何实现的请看第五步。
  3. 给.title设置伪类before,将伪类宽度设置为50%,搭配浮动float: right;,使得伪类文本内容靠右,这样设置后,.title和伪类就会各占父级宽度的一半了。
  4. .title标签设置text-align: justify;,用来将文本内容和伪类的内容两端对齐。
  5. 给伪类before设置文字对齐方式direction: rtl;,将伪类内的文本从右向左流动,即right to left,再设置溢出省略的css样式就可以了。
  6. .title标签设置了top: -4em,.txt标签设置max-height: 4em;这样保证.title永远都在.txt上面,当内容足够长,.txt文本内容会换行,导致高度从默认2em变为4em,而.title位置是-4em,此时正好将.txt覆盖掉,此时显示的就是.title标签的内容了。

知识点:text-align: justify;

  • 文本的两端(左边和右边)都会与容器的边缘对齐。
  • 为了实现这种对齐,浏览器会在单词之间添加额外的空间。这通常意味着某些单词之间的间距会比其他单词之间的间距稍大一些。
  • 如果最后一行只有一个单词或少数几个单词,那么这些单词通常不会展开以填充整行,而是保持左对齐。

需要注意的是,text-align: justify; 主要用于多行文本。对于单行文本,这个值的效果与 text-align: left; 相同,因为单行文本无法两端对齐。

2、JS获取字符串长度的几种常用方法

1. 通过charCodeAt判断字符编码

通过charCodeAt获取指定位置字符的Unicode编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}

2. 采取将双字节字符替换成”aa”的做法,取长度

1
2
3
js复制代码function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};

参考文章

1. JS如何判断文字被ellipsis了?

2. Canvas API 中文网

3. JS获取字符串长度的常用方法,汉字算两个字节

4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象

本文转载自: 掘金

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

新来个架构师,把xxl-job原理讲的炉火纯青~~

发表于 2024-01-31

大家好,我是三友~~

今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理

公众号:三友的java日记

核心概念

这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-Job中的概念和使用

如果你已经使用过了,可直接跳过本节和下一节,快进到后面原理部分讲解

1、调度中心

调度中心是一个单独的Web服务,主要是用来触发定时任务的执行

它提供了一些页面操作,我们可以很方便地去管理这些定时任务的触发逻辑

调度中心依赖数据库,所以数据都是存在数据库中的

调度中心也支持集群模式,但是它们所依赖的数据库必须是同一个

所以同一个集群中的调度中心实例之间是没有任何通信的,数据都是通过数据库共享的

2、执行器

执行器是用来执行具体的任务逻辑的

执行器你可以理解为就是平时开发的服务,一个服务实例对应一个执行器实例

每个执行器有自己的名字,为了方便,你可以将执行器的名字设置成服务名

3、任务

任务什么意思就不用多说了

一个执行器中也是可以有多个任务的

总的来说,调用中心是用来控制定时任务的触发逻辑,而执行器是具体执行任务的,这是一种任务和触发逻辑分离的设计思想,这种方式的好处就是使任务更加灵活,可以随时被调用,还可以被不同的调度规则触发。

来个Demo

1、搭建调度中心

调度中心搭建很简单,先下载源码

github.com/xuxueli/xxl…

然后改一下数据库连接信息,执行一下在项目源码中的/doc/db下的sql文件

启动可以打成一个jar包,或者本地启动就是可以的

启动完成之后,访问下面这个地址就可以访问到控制台页面了

http://localhost:8080/xxl-job-admin/toLogin

用户名密码默认是 admin/123456

2、执行器和任务添加

添加一个名为sanyou-xxljob-demo执行器

任务添加

执行器选择我们刚刚添加的,指定任务名称为TestJob,corn表达式的意思是每秒执行一次

创建完之后需要启动一下任务,默认是关闭状态,也就不会执行

创建执行器和任务其实就是CRUD,并没有复杂的业务逻辑

按照如上配置的整个Demo的意思就是

每隔1s,执行一次sanyou-xxljob-demo这个执行器中的TestJob任务

3、创建执行器和任务

引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
xml复制代码<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>com.xuxueli</groupId>
        <artifactId>xxl-job-core</artifactId>
        <version>2.4.0</version>
    </dependency>
</dependencies>

配置XxlJobSpringExecutor这个Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Configuration
public class XxlJobConfiguration {

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        //设置调用中心的连接地址
        xxlJobSpringExecutor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
        //设置执行器的名称
        xxlJobSpringExecutor.setAppname("sanyou-xxljob-demo");
        //设置一个端口,后面会讲作用
        xxlJobSpringExecutor.setPort(9999);
        //这个token是保证访问安全的,默认是这个,当然可以自定义,
        // 但需要保证调度中心配置的xxl.job.accessToken属性跟这个token是一样的
        xxlJobSpringExecutor.setAccessToken("default_token");
        //任务执行日志存放的目录
        xxlJobSpringExecutor.setLogPath("./");
        return xxlJobSpringExecutor;
    }

}

XxlJobSpringExecutor这个类的作用,后面会着重讲

通过@XxlJob指定一个名为TestJob的任务,这个任务名需要跟前面页面配置的对应上

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Component
public class TestJob {

    private static final Logger logger = LoggerFactory.getLogger(TestJob.class);

    @XxlJob("TestJob")
    public void testJob() {
        logger.info("TestJob任务执行了。。。");
    }

}

所以如果顺利的话,每隔1s钟就会打印一句TestJob任务执行了。。。

启动项目,注意修改一下端口,因为调用中心默认也是8080,本地起会端口冲突

最终执行结果如下,符合预期

讲完概念和使用部分,接下来就来好好讲一讲Xxl-Job核心的实现原理

从执行器启动说起

前面Demo中使用到了一个很重要的一个类

XxlJobSpringExecutor

这个类就是整个执行器启动的入口

这个类实现了SmartInitializingSingleton接口

所以经过Bean的生命周期,一定会调用afterSingletonsInstantiated这个方法的实现

这个方法干了很多初始化的事,这里我挑三个重要的讲,其余的等到具体的功能的时候再提

1、初始化JobHandler

JobHandler是个什么?

所谓的JobHandler其实就是一个定时任务的封装

一个定时任务会对应一个JobHandler对象

当执行器执行任务的时候,就会调用JobHandler的execute方法

JobHandler有三种实现:

  • MethodJobHandler
  • GlueJobHandler
  • ScriptJobHandler

MethodJobHandler是通过反射来调用方法执行任务

所以MethodJobHandler的任务的实现就是一个方法,刚好我们demo中的例子任务其实就是一个方法

所以Demo中的任务最终被封装成一个MethodJobHandler

GlueJobHandler比较有意思,它支持动态修改任务执行的代码

当你在创建任务的时候,需要指定运行模式为GLUE(Java)

之后需要在操作按钮点击GLUE IDE编写Java代码

代码必须得实现IJobHandler接口,之后任务执行的时候就会执行execute方法的实现

如果你需要修改任务的逻辑,只需要重新编辑即可,不需要重启服务

ScriptJobHandler,通过名字也可以看出,是专门处理一些脚本的

运行模式除了BEAN和GLUE(Java)之外,其余都是脚本模式

而本节的主旨,所谓的初始化JobHandler就是指,执行器启动的时候会去Spring容器中找到加了@XxlJob注解的Bean

解析注解,然后封装成一个MethodJobHandler对象,最终存到XxlJobSpringExecutor成员变量的一个本地的Map缓存中

缓存key就是任务的名字

至于GlueJobHandler和ScriptJobHandler都是任务触发时才会创建

除了上面这几种,你也自己实现JobHandler,手动注册到JobHandler的缓存中,也是可以通过调度中心触发的

2、创建一个Http服务器

除了初始化JobHandler之外,执行器还会创建一个Http服务器

这个服务器端口号就是通过XxlJobSpringExecutor配置的端口,demo中就是设置的是9999,底层是基于Netty实现的

这个Http服务端会接收来自调度中心的请求

当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理

这个类非常重要,所有调度中心的请求都是这里处理的

ExecutorBizImpl实现了ExecutorBiz接口

当你翻源码的时候会发现,ExecutorBiz还有一个ExecutorBizClient实现

ExecutorBizClient的实现就是发送http请求,所以这个实现类是在调度中心使用的,用来访问执行器提供的http接口

3、注册到调度中心

当执行器启动的时候,会启动一个注册线程,这个线程会往调度中心注册当前执行器的信息,包括两部分数据

  • 执行器的名字,也就是设置的appname
  • 执行器所在机器的ip和端口,这样调度中心就可以访问到这个执行器提供的Http接口

前面提到每个服务实例都会对应一个执行器实例,所以调用中心会保存每个执行器实例的地址

这里你可以把调度中心的功能类比成注册中心

任务触发原理

弄明白执行器启动时干了哪些事,接下来讲一讲Xxl-Job最最核心的功能,那就是任务触发的原理

任务触发原理我会分下面5个小点来讲解

  • 任务如何触发?
  • 快慢线程池的异步触发任务优化
  • 如何选择执行器实例?
  • 执行器如何去执行任务?
  • 任务执行结果的回调

1、任务如何触发?

调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机,这里我把这个线程称为调度线程

这个调度线程会去查询xxl_job_info这张表

这张表存了任务的一些基本信息和任务下一次执行的时间

调度线程会去查询下一次执行的时间 <= 当前时间 + 5s的任务

这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发

举个例子,假设当前时间是2023-11-29 08:00:10,这里的查询就会查出下一次任务执行时间在2023-11-29 08:00:15之前执行的任务

查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:

  • 当前时间已经超过任务下一次执行时间5s以上,也就是需要在2023-11-29 08:00:05(不包括05s)之前的执行的任务
  • 当前时间已经超过任务下一次执行时间,但是但不足5s,也就是在2023-11-29 08:00:05和2023-11-29 08:00:10(不包括10s)之间执行的任务
  • 还未到触发时间,但是一定是5s内就会触发执行的

对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行

调度过期策略就两种,就是字面意思

  • 直接忽略这个已经过期的任务
  • 立马执行一次这个过期的任务

对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行

对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行

当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info这张表的下一次执行时间

到此,一次调度的计算就算完成了

之后调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能

这里在任务触发的时候还有一个很有意思的细节

由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?

我猜你第一时间肯定想到分布式锁,但是怎么加呢?

XxlJob实现就比较有意思了,它是基于八股文中常说的通过数据库来实现的分布式锁的

在调度之前,调度线程会尝试执行下面这句sql

就是这个sql

select * from xxl_job_lock where lock_name = ‘schedule_lock’ for update

一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了

当调度任务执行完之后再去关闭连接,从而释放锁

由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务

最后画一张图来总结一下这一小节

2、快慢线程池的异步触发任务优化

当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行

调度线程会将这个触发的任务交给线程池去执行

所以上图中的最后一部分触发任务执行其实是线程池异步去执行的

那么,为什么要使用线程池异步呢?

主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务

这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率

所以就通过异步线程去做,调度线程只负责判断任务是否需要执行

并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池和慢线程池两个线程池

在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间

注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间,这个后面会详细说

当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1

如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行

所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生

3、如何选择执行器实例?

上一节说到,当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务

那么问题来了

由于一个执行器会有很多实例,那么应该向哪个实例请求?

这其实就跟任务配置时设置的路由策略有关了

从图上可以看出xxljob支持多种路由策略

除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的

这里简单讲一讲各种算法的原理,有兴趣的小伙伴可以去看看内部的实现细节

第一个、最后一个、轮询、随机都很简单,没什么好说的

一致性Hash讲起来比较复杂,你可以先看看这篇文章,再去查看Xxl-Job的代码实现

zhuanlan.zhihu.com/p/470368641

最不经常使用(LFU:Least Frequently Used):Xxl-Job内部会有一个缓存,统计每个任务每个地址的使用次数,每次都选择使用次数最少的地址,这个缓存每隔24小时重置一次

最近最久未使用(LRU:Least Recently Used):将地址存到LinkedHashMap中,它利用LinkedHashMap可以根据元素访问(get/put)顺序来给元素排序的特性,快速找到最近最久未使用(未访问)的节点

故障转移:调度中心都会去请求每个执行器,只要能接收到响应,说明执行器正常,那么任务就会交给这个执行器去执行

忙碌转移:调度中心也会去请求每个执行器,判断执行器是不是正在执行当前需要执行的任务(任务执行时间过长,导致上一次任务还没执行完,下一次又触发了),如果在执行,说明忙碌,不能用,否则就可以用

分片广播:XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据

我们可以通过XxlJobHelper#getShardIndex获取到编号,XxlJobHelper#getShardTotal获取到执行器的总数据量

分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理

举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理

当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务

4、执行器如何去执行任务?

相信你一定记得我前面在说执行器启动是会创建一个Http服务器的时候提到这么一句

当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理

所以前面提到的故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的

执行器处理触发请求是这个ExecutorBizImpl的run方法实现的

当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程,这个线程被称为JobThread

每个任务在触发的时候都有单独的线程去执行,保证不同的任务执行互不影响

之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务

这里我相信你一定有个疑惑

为什么不直接处理,而是交给队列,从队列中获取任务呢?

那就得讲讲不正常的情况了

如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢?**

这时就跟阻塞处理策略有关了

阻塞处理策略总共有三种:

  • 单机串行
  • 丢弃后续调度
  • 覆盖之前调度

单机串行的实现就是将任务放到队列中,由于队列是先进先出的,所以就实现串行,这也是为什么放在队列的原因

丢弃调度的实现就是执行器什么事都不用干就可以了,自然而然任务就丢了

覆盖之前调度的实现就很暴力了,他是直接重新创建一个JobThread来执行任务,并且尝试打断之前的正在处理任务的JobThread,丢弃之前队列中的任务

打断是通过Thread#interrupt方法实现的,所以正在处理的任务还是有可能继续运行,并不是说一打断正在运行的任务就终止了

这里需要注意的一点就是,阻塞处理策略是对于单个执行器上的任务来生效的,不同执行器实例上的同一个任务是互不影响的

比如说,有一个任务有两个执行器A和B,路由策略是轮询

任务第一次触发的时候选择了执行器实例A,由于任务执行时间长,任务第二次触发的时候,执行器的路由到了B,此时A的任务还在执行,但是B感知不到A的任务在执行,所以此时B就直接执行了任务

所以此时你配置的什么阻塞处理策略就没什么用了

如果业务中需要保证定时任务同一时间只有一个能运行,需要把任务路由到同一个执行器上,比如路由策略就选择第一个

5、任务执行结果的回调

当任务处理完成之后,执行器会将任务执行的结果发送给调度中心

如上图所示,这整个过程也是异步化的

  • JobThread会将任务执行的结果发送到一个内存队列中
  • 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread
  • 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心
  • 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等

到此,一次任务的就算真正处理完成了

最后

最后我从官网捞了一张Xxl-Job架构图

奈何作者不更新呐,导致这个图稍微有点老了,有点跟现有的架构对不上

比如说图中的自研RPC(xxl-rpc)部分已经替换成了Http协议,这主要是拥抱生态,方便跨语言接入

但是不要紧,大体还是符合现在的整个的架构

从架构图中也可以看出来,本文除了日志部分的内容没有提到,其它的整个核心逻辑基本上都讲到了

而日志部分其实是个辅助的作用,让你更方便查看任务的运行情况,对任务的触发逻辑是没有影响的,所以就没讲了

所以从本文的讲解再到官方架构图,你会发现整个Xxl-Job不论是使用还是实现都是比较简单的,非常的轻量级

说点什么

好了,到这又又成功讲完了一款框架或者说是中间件的核心架构原理,不知道你有没有什么一点收获

如果你觉得有点收获,欢迎点赞、在看、收藏、转发分享给其他需要的人

你的支持就是我更新文章最大的动力,非常地感谢!

其实这篇文章我在十一月上旬的时候我就打算写了

但是由于十一月上旬之后我遇到一系列烦心事,导致我实在是没有精力去写

现在到月底了,虽然烦心事只增不少,但是我还是想了想,觉得不能再拖了,最后也是连续肝了几个晚上,才算真正完成

所以如果你发现文章有什么不足和问题,也欢迎批评指正

好了,本文就讲到这里了,让我们下期再见,拜拜!

本文转载自: 掘金

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

Java故障案例分析第一期:父子任务使用不当线程池死锁 引言

发表于 2024-01-31

引言

在Java多线程编程中,线程池是提高性能和资源利用率的常用工具。然而,当父子任务使用同一线程池时,可能导致潜在的死锁问题。本文将深入分析一个实际案例,阐述为何这种设计可能引发死锁,以及如何排查这类问题。

案例背景

考虑以下的伪代码,展示了一个可能导致死锁的场景:

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
java复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

class Scratch {
private static final ExecutorService pool1 = Executors.newFixedThreadPool(2);

public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 50; i++) {
pool1.submit(() -> {
// 一些任务逻辑
outerTask();
});
}
try {
boolean allDone = pool1.awaitTermination(10000, TimeUnit.MILLISECONDS);
if (allDone) {
System.out.println("任务完成!");
} else {
System.err.println("任务超时未完成!");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

private static void outerTask() {
Future<?> future = pool1.submit(() -> {
innerTask();
});
try {
// 获取结果
future.get();
} catch (Exception e) {
e.printStackTrace();
}
}

private static void innerTask() {
// 一些任务逻辑
}
}

简单解释下这个代码, 我们有一个固定线程数大小为2的线程池, 然后向线程池提交任务, 这个任务直接调用outerTask, 这个outerTask不做任何事情, 只通过线程池异步调用innerTask, 但是注意这里使用了同一个线程池提交innerTask.

最后通过awaitTermination等待线程池执行完毕线程终止就结束, 设置了超时10s, 如果任务都完成了打印”任务完成”否则打印”任务超时未完成”, 而由于outerTask和innerTask内部都没有其他逻辑, 理论上应该是很快执行完毕, 打印”任务完成”, 但实际如何呢, 执行一下, 结果是:

1
复制代码任务超时未完成!

好, 这是肯定的😳. 那我们分析下为什么? 这是一个线程故障因此首先想到通过jstack打印堆栈分析:

看到的线程调用栈为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
php复制代码"pool-1-thread-1@852" tid=0x19 nid=NA waiting
java.lang.Thread.State: WAITING
at jdk.internal.misc.Unsafe.park(Unsafe.java:-1)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:500)
at java.util.concurrent.FutureTask.get(FutureTask.java:190)
at Scratch.outerTask(scratch_18.java:32) // 注意这里
at Scratch.lambda$main$0(scratch_18.java:11)
at Scratch$$Lambda$14/0x00000008010029f0.run(Unknown Source:-1)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:577)
at java.util.concurrent.FutureTask.run$$$capture(FutureTask.java:317)
at java.util.concurrent.FutureTask.run(FutureTask.java:-1)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.lang.Thread.run(Thread.java:1589)

可以看到大量pool-1-thread-1开头线程阻塞在了outerTask提交任务的地方, 同时通过查看线程池的workQueue对象可以看到有很多任务堆积:

image.png

原因分析

子任务需要等待父任务完成,而父任务内部的子任务通过同一个线程池提交,又需要等待线程池有空闲线程才能得到执行,但父任务需要等待子任务执行完才能执行完毕释放出空闲线程, 陷入了“死锁”。

但在测试环境中可能无法发现,只要线程池线程数量够多,测试环境的并发请求数不够是发现不了这个问题的,只有并发请求数量足够才可能触发而这往往是上到生产环境才可能发生了,通常会造成严重事故,重启或者扩容后在一定时间内看上去恢复正常了但过不久可能又会出现阻塞情况(在我的公司实际发生过这种故障,开发不停重启和扩容但过一段时间仍然会发生这个问题,排查了很长时间才发现问题原因)

解决方案

为避免父子任务使用同一线程池造成死锁,可以考虑使用独立线程池:将父任务和子任务分别提交到不同的线程池,避免共享线程池资源,减少死锁的可能性。

1
2
java复制代码private static final ExecutorService parentPool = Executors.newFixedThreadPool(1);
private static final ExecutorService childPool = Executors.newFixedThreadPool(1);

总结

作为第一篇文章,这个故障实际非常基础,但却十分值得注意,因为这个故障很常见而且容易被误导为机器数量不够导致重启或扩容后依然无法恢复。

本文转载自: 掘金

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

分库分表已成为过去式,使用分布式数据库才是未来

发表于 2024-01-30

转载至我的博客 www.infrastack.cn ,公众号:架构成长指南

当我们使用 Mysql数据库到达一定量级以后,性能就会逐步下降,而解决此类问题,常用的手段就是引入数据库中间件进行分库分表处理,比如使用 Mycat、ShadingShpere、tddl,但是这种都是过去式了,现在使用分布式数据库可以避免分库分表

为什么不建议分库分表呢?

分库分表以后,会面临以下问题

  • 分页问题,例如:使用传统写法,随着页数过大性能会急剧下降
  • 分布式事务问题
  • 数据迁移问题,例如:需要把现有数据通过分配算法导入到所有的分库中
  • 数据扩容问题,分库分表的数据总有一天也会到达极限,需要增大分片
  • 开发模式变化,比如在请求数据时,需要带分片键,否则就会导致所有节点执行
  • 跨库跨表查询问题
  • 业务需要进行一定取舍,由于分库分表的局限性,有些场景下需要业务进行取舍

以上只是列举了一部分问题,为了避免这些问题,可以使用分布式数据库TiDB来处理

TiDB介绍

TiDB 是 PingCAP 公司研发的一款开源分布式关系型数据库,从 2015年 9 月开源,至今已经有9 年时间,可以说已经非常成熟,它是一款同时支持OLTP(在线事务处理)和OLAP(在线分析处理)的融合型分布式数据库产品,具备水平扩缩容,金融级高可用、实时 HTAP(Hybrid Transactional and Analytical Processing)、云原生的分布式数据库,兼容 MySQL 5.7 协议和 MySQL 生态等重要特性,它适合高可用、强一致要求较高、数据规模较大等各种应用场景。

核心特性

  • 金融级高可用
  • 在线水平扩容或者缩容,并且存算分离
  • 云原生的分布式数据库,支持部署在公有云,私有云,混合云中
  • 实时HTAP,提供TIKV行存储引擎和TiFlash列存储引擎
  • 兼容MySQL协议和MySQL生态
  • 分布式事务强一致性
  • 从 MySQL 无缝切换到 TiDB,几乎无需修改代码,迁移成本极低
  • PD在分布式理论CAP方面满足CP,是强一致性的

应用场景

  • 对数据一致性及高可靠、系统高可用、可扩展性、容灾要求较高的金融行业属性的场景
  • 对存储容量、可扩展性、并发要求较高的海量数据及高并发的OLTP场景
  • 数据汇聚、二次加工处理的场景

案例

TiDB 有1500 多家不同行业的企业应用在了生产环境,以下是一些有代表性企业,要想查看更多案例,可以访问TiDB 官网查询

系统架构

TIDB Server

SQL 层,对外暴露 MySQL 协议的连接 endpoint,负责接收SQL请求,处理SQL相关的逻辑,并通过PD找到存储计算所需数据的TiKV地址,与TiKV交互获取数据,最终返回结果。TiDB Server 是无状态的,其本身并不存储数据,只负责计算,可以无限水平扩展,可以通过负载均衡组件(LVS、HAProxy或F5)对外提供统一的接入地址,客户端的连接可以均匀地分摊在多个 TiDB 实例上以达到负载均衡的效果。

PD Server

整个集群的管理模块,其主要工作有三个:

  1. 存储集群的元信息(某个Key存储在那个TiKV节点);
  2. 对TiKV集群进行调度和负载均衡、Leader选举;
  3. 分配全局唯一且递增的事务ID。

PD 是一个集群,需要部署奇数个节点,一般线上推荐至少部署3个节点。PD在选举的过程中无法对外提供服务,这个时间大约是3秒。

TIKV Server

TiDB 现在同时支持OLTP 和 OLAP,而TiKV负责存储OLTP数据,从外部看TiKV是一个分布式的提供事务的Key-Value存储引擎。存储数据的基本单位是Region,每个Region负责存储一个Key Range(从StartKey到EndKey的左闭右开区间)的数据,每个TiKV节点会负责多个Region。

TiKV如何做到数据不丢失的?

简单理解,就是把数据复制到多台机器上,这样一个节点down 机,其他节点上的副本还能继续提供服务;复杂理解,需要这个数据可靠并且高效复制到其他节点,并且能处理副本失效的情况,那怎么做呢,就是使用 Raft一致性算法

Region 与副本之间通过 Raft 协议来维持数据一致性,任何写请求都只能在 Leader 上写入,并且需要写入多数副本后(默认配置为 3 副本,即所有请求必须至少写入两个副本成功)才会返回客户端写入成功。

分布式事务支持

TiKV 支持分布式事务,我们可以一次性写入多个 key-value 而不必关心这些 key-value 是否处于同一个数据切片 (Region) 上,TiKV 的分布式事务参考了Google 在 BigTable 中使用的事务模型Percolator,具体可以访问论文了解

与MySQL的对比

支持的特性

  • 支持分布式事务,原理是基于Google Percolator,Percolator是基于Bigtable的,所以数据结构直接使用了Bigtable的Tablet。详情可参考zhuanlan.zhihu.com/p/39896539
  • 支持锁,TIDB是乐观锁 +MVCC ,MySQL是悲观锁+MVCC,要注意TIDB执行Update、Insert、Delete时不会检查冲突,只有在提交时才会检查写写冲突,所以在业务端执行SQL语句后,要注意检查返回值,即使执行没有出错,提交的时候也可能出错。

不支持的功能特性

  • 不支持存储过程、函数、触发器
  • 自增id只支持在单个TIDB Server的自增,不支持多个TIDB Server的自增。
  • 外键约束
  • 临时表
  • Mysql追踪优化器
  • XA 语法(TiDB 内部使用两阶段提交,但并没有通过 SQL 接口公开)

资源使用情况

以下内容参考:pingcap.medium.com/an-8x-syste…

TiDB 具有很高的数据压缩比,MySQL 中的 10.8 TB 数据在 TiDB 中变成了 3.2 TB,还是三副本的总数据量。因此,MySQL 与 TiDB 的空间使用比例为 3.4:1。

同等量级,使用2 年以后,资源使用情况

  • MySQL使用32 个节点,而 TiDB 只有 14 个
  • MySql 用了 512 个 CPU 核心,而 TiDB 将仅使用 224 个,不到 MySQL 的一半。
  • MySQL 使用 48 TB 存储空间,而 TiDB 将使用 16 TB,仅为 MySQL 的 1/3。

性能测试

测试报告 1

来源:www.percona.com/blog/a-quic…

五个 ecs 实例,使用了不同配置,以此测试

  • t2.medium:2 个 CPU 核心
  • x1e.xlarge:4 个 CPU 核心
  • r4.4xlarge:16 个 CPU 核心
  • m4.16xlarge:64 个 CPU 核心
  • m5.24xlarge:96 个 CPU 核心

MySQL 中的数据库大小为 70Gb,TiDB 中的数据库大小为 30Gb(压缩)。该表没有二级索引(主键除外)。

测试用例

  1. 简单计数(*):
1
sql复制代码 select count(*) from ontime;
  1. 简单分组依据
1
sql复制代码 select count(*), year from ontime group by year order by year;
  1. 用于全表扫描的复杂过滤器
1
sql复制代码select * from ontime where UniqueCarrier = 'DL' and TailNum = 'N317NB' and FlightNum = '2' and Origin = 'JFK' and Dest = 'FLL' limit 10;
  1. 复杂的分组依据和排序依据查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sql复制代码select SQL_CALC_FOUND_ROWS 
FlightDate, UniqueCarrier as carrier,
FlightNum,
Origin,
Dest
FROM ontime
WHERE
DestState not in ('AK', 'HI', 'PR', 'VI')
and OriginState not in ('AK', 'HI', 'PR', 'VI')
and flightdate > '2015-01-01'
and ArrDelay < 15
and cancelled = 0 and Diverted = 0
and DivAirportLandings = '0'
ORDER by DepDelay DESC
LIMIT 10;

下图表示结果(条形表示查询响应时间,越小越好):

系统基准测试

在 m4.16xlarge 实例上使用 Sysbench 进行点选择(意味着通过主键选择一行,线程范围从 1 到 128)(内存限制:无磁盘读取)。结果在这里。条形代表每秒的交易数量,越多越好:

系统测试报告 2

来源:www.dcits.com/show-269-41…

硬件配置

测试场景

测试分两阶段进行,第一阶段测试数据为100万单,第二阶段测试数据为1300万单。在此基础上,使用Jmeter压力测试10万单结果如下:

从测试结果来看,在小数据量mysql性能是好于TiDB,因为 TiDB 是分布式架构,如果小数据量,在网络通讯节点分发一致性等方面花的时间就很多,然后各个节点执行完还要汇总返回,所以开销是比较大的,但是数据量一上来TiDB 优势就体现出来了,所以如果数据量比较小,没必要使用 TiDB

总结

以上介绍了 TiDB架构,以及它的一些特性,同时也与 mysql 进行了对比,如果贵司的数据量比较大,正在考虑要分库分表,那么完全可以使用它,来避免分库分表,分库分表是一个过渡方案,使用分布式数据库才是终极方案。同时如果贵司的数据量比较小,那么就没必要引入了

本文转载自: 掘金

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

分享一款前端网页代码编辑器 Monaco Editor

发表于 2024-01-30

把一个 vscode 代码编辑器引入到 vue 项目分几步?

  1. 安装 Monaco Editor
  2. 封装组件
  3. 使用组件

搞定~! 言归正传,下面我们来深入浅出的分析一下如何将 VSCode编辑器引入到 vue 前端项目。

Monaco Editor

Monaco Editor 是由 Microsoft 开发的一款基于 Web 技术的开源代码编辑器,它是 Visual Studio Code 编辑器的核心。Monaco Editor 可以嵌入到网页中,提供类似于 Visual Studio Code 的编辑体验。

官方地址: microsoft.github.io/monaco-edit…

image-20240130090727429

image-20240130092049311

优势及功能

  1. 轻量且高性能: Monaco Editor 是一款轻量级的编辑器,具有出色的性能。它专注于提供快速的代码编辑体验,适用于大型前端项目。
  2. 支持多种语言: Monaco Editor 支持多种编程语言,包括但不限于 JavaScript、TypeScript、HTML、CSS、JSON、Python 等。你可以根据项目需要切换不同的语言模式。
  3. 强大的语法高亮和代码提示: Monaco Editor 提供了高度可定制的语法高亮和代码提示功能,使得代码更易于阅读和编写。
  4. 智能代码补全: 编辑器具有智能的代码补全功能,可以根据你的输入和上下文提供准确的建议,提高编码效率。
  5. 支持多光标编辑: Monaco Editor 允许你使用多个光标进行同时编辑,从而更加灵活地进行代码重构和编辑。
  6. 集成调试支持: 在 VSCode 中内置的调试器可以与 Monaco Editor 集成,使得在编辑器中进行代码调试变得更加方便。
  7. 支持多种主题: Monaco Editor 提供了多种主题,你可以根据个人喜好选择适合你的编辑器外观。
  8. 可定制性强: Monaco Editor 具有丰富的配置选项和 API,可以根据项目需求进行高度的定制。你可以更改字体、颜色主题、键绑定等。
  9. 支持远程开发: 由于 Monaco Editor 是基于 Web 技术的,它支持远程开发,你可以在浏览器中访问代码并进行编辑,而不必在本地安装开发环境。
  10. 开源和活跃的社区: Monaco Editor 是开源项目,拥有活跃的社区支持和更新。这意味着你可以从社区中获得帮助,解决问题,并参与到项目的发展中。

安装方式

支持本地下载、npm 以及 CDN 等多种方式

1
bash复制代码npm install monaco-editor@0.45.0

在 vue 2 中封装 monaco-editor 组件

  1. 创建 MonacoEditor.vue 文件: 创建一个 Vue 组件文件,例如 MonacoEditor.vue:
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
html复制代码<!-- MonacoEditor.vue -->
<template>
<div ref="editor" class="monaco-editor-container"></div>
</template>

<script>
// 引入 Monaco Editor
import * as monaco from 'monaco-editor';

export default {
props: {
// 代码内容
code: {
type: String,
default: '',
},
// 编辑器语言
language: {
type: String,
default: 'javascript',
},
// 编辑器主题
theme: {
type: String,
default: 'vs-light', // 或 'vs-dark'
},
},
data() {
return {
editor: null,
};
},
mounted() {
// 初始化编辑器
this.initEditor();
},
watch: {
// 监听代码内容变化
code(newCode) {
if (this.editor) {
// 设置编辑器内容
this.editor.setValue(newCode);
}
},
},
methods: {
// 初始化编辑器
initEditor() {
// 创建 Monaco Editor 实例
this.editor = monaco.editor.create(this.$refs.editor, {
value: this.code,
language: this.language,
theme: this.theme,
});

// 监听编辑器内容变化
this.editor.onDidChangeModelContent(() => {
// 触发父组件更新代码内容
this.$emit('update:code', this.editor.getValue());
});
},
},
beforeDestroy() {
// 在组件销毁前销毁编辑器实例
if (this.editor) {
this.editor.dispose();
}
},
};
</script>

<style scoped>
.monaco-editor-container {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
  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
html复制代码<template>
<div>
<!-- 使用封装的 MonacoEditor 组件,并传递相关属性 -->
<MonacoEditor
v-model="code"
:language="language"
:theme="theme"
/>
</div>
</template>

<script>
// 引入 MonacoEditor 组件
import MonacoEditor from '@/components/MonacoEditor.vue'; // 请根据实际路径进行调整

export default {
components: {
MonacoEditor,
},
data() {
return {
// 初始化代码内容
code: 'console.log("你好,Monaco 编辑器!");',
// 初始化编辑器语言
language: 'javascript',
// 初始化编辑器主题
theme: 'vs-light',
};
},
};
</script>

## 常用配置属性:

属性 类型 默认值 说明
value string '' 设置编辑器的初始代码内容
language string 'javascript' 设置编辑器的语言模式
theme string 'vs' 设置编辑器的主题
fontSize number 14 设置编辑器的字体大小
readOnly boolean false 设置编辑器是否为只读模式

## 常用方法:

方法 参数 返回值 说明
create(domElement, options) domElement: HTMLElement, options: IStandaloneEditorConstructionOptions IStandaloneCodeEditor 创建一个新的 Monaco Editor 实例
setValue(newValue) newValue: string void 设置编辑器的代码内容
getValue() - string 获取编辑器的代码内容
setLayout(newLayout) newLayout: Dimension void 调整编辑器的布局,通常在编辑器容器大小变化时使用
setTheme(theme) theme: string void 设置编辑器的主题
updateOptions(newOptions) newOptions: IEditorOptions void 更新编辑器的选项,可以用于动态改变编辑器的配置
dispose() - void 销毁编辑器实例,释放资源

更多的配置选项和 API,可以参考 Monaco Editor 的官方文档 microsoft.github.io/monaco-edit…

monaco-editor 如何隐藏右键菜单的默认项

以隐藏默认 Copy 和 Paste项为例

题库管理系统-架构图

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
js复制代码import * as monaco from 'monaco-editor/esm/vs/editor/editor.main';
import * as actions from 'monaco-editor/esm/vs/platform/actions/common/actions';

mounted(){
// 此处省略 初始化manaco-editor

// 获取 Monaco Editor 的菜单项注册表
let menus = actions.MenuRegistry._menuItems;

// 查找编辑器上下文菜单的注册条目
let contextMenuEntry = [...menus].find(entry => entry[0]._debugName == 'EditorContext');

// 从上下文菜单的注册条目中提取菜单项
let contextMenuLinks = contextMenuEntry[1];

// 要移除的菜单项的命令ID列表
let removableIds = ['editor.action.clipboardCopyAction', 'editor.action.clipboardPasteAction'];

// 移除菜单项的函数
let removeById = (list, ids) => {
let node = list._first;
do {
// 检查当前菜单项是否应该移除
let shouldRemove = ids.includes(node.element?.command?.id);
if (shouldRemove) {
// 如果应该移除,则从链表中移除当前节点
list._remove(node);
}
} while ((node = node.next)); // 遍历链表
};

// 通过调用 removeById 函数移除特定的菜单项
removeById(contextMenuLinks, removableIds);
}

本文转载自: 掘金

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

用上了Jenkins,个人部署项目真方便!

发表于 2024-01-30

作者:小傅哥
博客:bugstack.cn
项目:gaga.plus

沉淀、分享、成长,让自己和他人都能有所收获!😄

本文的宗旨在于通过简单干净实践的方式教会读者,如何在 Docker 中部署 Jenkins,并通过 Jenkins 完成对项目的打包构建并在 Docker 容器中部署。

Jenkins 的主要作用是帮助你,把需要在本地机器完成的 Maven 构建、Docker 镜像发布、云服务器部署等系列动作全部集成在一个服务下。简化你的构建部署操作过程,因为 Jenkins 也被称为 CI&CD(持续集成&持续部署) 工具。提供超过 1000 个插件(Maven、Git、NodeJs)来支持构建、部署、自动化, 满足任何项目的需要。

官网:

  • 英文:www.jenkins.io/
  • 中文:www.jenkins.io/zh/

本文涉及的工程:

  • xfg-dev-tech-jenkins:gitcode.net/KnowledgePl…
  • 提示:
    • 推荐使用云服务器做本节的案例 5.5元/1个月,50元/1年
    • 本节会需要用到的环境 Docker&Portainer

一、操作说明

本节小傅哥会带着大家完成 Jenkins 环境的安装,以及以最简单的方式配置使用 Jenkins 完成对 xfg-dev-tech-jenkins 案例项目的部署。部署后可以访问 xfg-dev-tech-jenkins 项目提供的接口进行功能验证。整个部署操作流程如下;

  • 左侧竖列为核心配置部署流程,右侧是需要在配置过程中处理的细节。
  • 通过把本地对项目打包部署的过程拆解为一个个模块,配置到 Jenkins 环境中。这就是 Jenkins 的作用。

二、环境配置

  1. 确保你已经在(云)服务器上配置了 Docker 环境,以及安装了 docker-compose。同时最好已经安装了 Portainer 管理界面这样更加方便操作。
  2. 在配置和后续的验证过程中,会需要访问(云)服务的地址加端口。如果你在云服务配置的,记得开放端口;9000 - portainer、9090 - jenkins、8091 - xfg-dev-tech-app 服务

1. Jenkins 部署

1.1 上传文件

  • 如图;以上配置内容已经放到 xfg-dev-tech-jenkins 工程中,如果你是云服务器部署则需要将 dev-ops 部分全部上传到服务器的根目录下。
  • compose-down.sh 是 docker-compose 下载文件,只有你安装了 docker-compose 才能执行 docker-compose -f docker-compose-v1.0.yml up -d
  • jdk-down.sh 是 jdk1.8 下载路径,以及解压脚本。如果你在云服务器下载较慢,也可以本地搜索 jdk1.8 下载,并上传到云服务器上解压。注意:本步骤是可选的,如果你的项目不强依赖于 jdk1.8 也可以使用 Jenkins 默认自带的 JDK17。可以通过在安装后的 Jenkins 控制台执行 which java 找到 JDK 路径。
  • maven 下的 settings.xml 配置,默认配置了阿里云镜像文件,方便在 Jenkins 构建项目时,可以快速地拉取下载下来包。

1.2 脚本说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
shell复制代码version: '3.8'
# 执行脚本;docker-compose -f docker-compose-v1.0.yml up -d
services:
jenkins:
image: jenkins/jenkins:2.439
container_name: jenkins
privileged: true
user: root
ports:
- "9090:8080"
- "50001:50000"
volumes:
- ./jenkins_home:/var/jenkins_home # 如果不配置到云服务器路径下,则可以配置 jenkins_home 会创建一个数据卷使用
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/local/bin/docker
- ./maven/conf/settings.xml:/usr/local/maven/conf/settings.xml # 这里只提供了 maven 的 settings.xml 主要用于修改 maven 的镜像地址
- ./jdk/jdk1.8.0_202:/usr/local/jdk1.8.0_202 # 提供了 jdk1.8,如果你需要其他版本也可以配置使用。
environment:
- JAVA_OPTS=-Djenkins.install.runSetupWizard=false # 禁止安装向导「如果需要密码则不要配置」docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
restart: unless-stopped

volumes:
jenkins_home:

Jenkins Docker 执行安装脚本。

  • ./jenkins_home:/var/jenkins_home 是在云服务器端挂一个映射路径,方便可以重新安装后 Jenkins 依然存在。你也可以配置为 jenkins_home:/var/jenkins_home 这样是自动挂在 volumes jenkins_home 数据卷下。
  • docker 两个 docker 的配置是为了可以在 Jenkins 中使用 Docker 命令,这样才能在 Docker 安装的 Jenkins 容器内,使用 Docker 服务。
  • ./maven/conf/settings.xml:/usr/local/maven/conf/settings.xml 为了在 Jenkins 中使用映射的 Maven 配置。
  • ./jdk/jdk1.8.0_202:/usr/local/jdk1.8.0_202 用于在 Jenkins 中使用 jdk1.8
  • JAVA_OPTS=-Djenkins.install.runSetupWizard=false 这个是一个禁止安装向导,配置为 false 后,则 Jenkins 不会让你设置密码,也不会一开始就安装一堆插件。如果你需要安装向导可以注释掉这个配置。并且当提示你获取密码时,你可以执行;docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword 获取到登录密码。

1.3 执行安装

1
2
3
4
shell复制代码[root@lavm-aqhgp9nber dev-ops]# docker-compose -f docker-compose-v1.0.yml up -d
[+] Building 0.0s (0/0)
[+] Running 1/0
✔ Container jenkins Running

执行脚本 docker-compose -f docker-compose-v1.0.yml up -d 后,这样执行完毕后,则表明已经安装成功了💐。

2. 插件安装

地址:http://localhost:9090/ - 登录Jenkins

  • 1~2步,设置镜像源,设置后重启一下 Jenkins。
  • 3~4步,下载插件,先下载安装 chinese 汉化插件,方便不太熟悉 Jenkins 的伙伴更好的知道页面都是啥内容。
  • 5步,所有的插件安装完成后,都需要重启才会生效。安装完 chinese 插件,重启在进入到 Jenkins 就是汉化的页面了
  • 除了以上步骤,你还需要同样的方式安装 maven、git、docker 插件。
  • 注意,因为网络问题你可以再做过程中,提示失败。没关系,你可以再搜这个插件,再重新下载。它会把失败的继续下载。

3. 全局工具配置

地址:http://localhost:9090/manage/configureTools/

用于构建部署的 SpringBoot 应用的环境,都需要在全局工具中配置好。包括;Maven、JDK、Git、Docker。注意这里的环境路径配置,如果配置了是会提示你没有对应的路径文件夹。

4. 添加凭证

地址:http://localhost:9090/manage/credentials/store/system/domain/_/

  • 配置了Git仓库的连接凭证,才能从Git仓库拉取代码。
  • 如果你还需要操作如 ssh 也需要配置凭证。

三、新建任务

一个任务就是一条构建发布部署项目的操作。

1. 配置任务

1
复制代码xfg-dev-tech-jenkins

2. 配置Git

1
2
java复制代码# 你可以 fork 这个项目,到自己的仓库进行使用
https://gitcode.net/KnowledgePlanet/ddd-scene-solution/xfg-dev-tech-content-moderation.git

3. 配置Maven

  • 在高级中设置 Maven 配置的路径 /usr/local/maven/conf/settings.xml。这样才能走自己配置的阿里云镜像仓库。
1
shell复制代码clean install -Dmaven.test.skip=true

3. 配置Shell

1
2
3
4
5
6
7
8
9
10
11
12
13
shell复制代码# 先删除之前的容器和镜像文件
if [ "$(docker ps -a | grep xfg-dev-tech-app)" ]; then
docker stop xfg-dev-tech-app
docker rm xfg-dev-tech-app
fi
if [ "$(docker images -q xfg-dev-tech-app)" ]; then
docker rmi xfg-dev-tech-app
fi

# 重新生成
cd /var/jenkins_home/workspace/xfg-dev-tech-jenkins/xfg-dev-tech-app
docker build -t xiaofuge/xfg-dev-tech-app .
docker run -itd -p 8091:8091 --name xfg-dev-tech-app xiaofuge/xfg-dev-tech-app

  • 当你熟悉后还可以活学活用,比如这里只是做build 但不做run执行操作。具体的部署可以通过 docker compose 执行部署脚本。
  • 另外如果你有发布镜像的诉求,也可以在这里操作。

四、测试验证

1. 工程准备

工程:https://gitcode.net/KnowledgePlanet/road-map/xfg-dev-tech-jenkins 你可以fork到自己的仓库进行使用,你的账号密码就是 CSDN 的账号密码。

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
java复制代码@SpringBootApplication
@RestController()
@RequestMapping("/api/")
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class);
}

/**
* http://localhost:8091/api/test
*/
@RequestMapping(value = "/test", method = RequestMethod.GET)
public ResponseBodyEmitter test(HttpServletResponse response) {
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");

ResponseBodyEmitter emitter = new ResponseBodyEmitter();

String[] words = new String[]{"嗨,臭宝。\r\n", "恭喜💐 ", "你的", " Jenkins ", " 部", "署", "测", "试", "成", "功", "了啦🌶!"};
new Thread(() -> {
for (String word : words) {
try {
emitter.send(word);
Thread.sleep(250);
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
}).start();

return emitter;
}

}
  • 工程中提供了接口;http://localhost:8091/api/test

2. CI&CD - 构建发布

地址:http://localhost:9090/job/xfg-dev-tech-jenkins/

  • 点击构建项目,最终会完成构建和部署成功。运行到这代表你全部操作完成了。

3. 验证结果

地址:http://localhost:9000/#!/2/docker/containers

访问:http://localhost:8091/api/test

  • 运行到这代表着你已经完整的走完了 Jenkins CI&CD 流程。

本文转载自: 掘金

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

Jetpack Compose -> MutableStat

发表于 2024-01-29

前言


上章我们讲解了分包和自定义Composable,它的自动更新 UI 机制,本章我们讲解下 MutableState 和 mustableStateOf() 它的自动更新式的动态 UI 写法以及这个写法背后的自动订阅机制还有自动订阅机制背后可能影响我们开发的诡异东西;

mustableStateOf()


当我们在声明一个 Text 函数的时候,通过会传递一个 String 参数给这个 Text 函数,当这个 String 发生改变的时候,Text 自动更新成这个变化后的值,也就是声明式 UI 的本质

1
2
3
4
5
6
kotlin复制代码val name = mutableStateOf("老A")

@Composable
fun ui() {
Text(text = name.value)
}

当我们给 name 赋值的是一个 mustableStateOf 包了一层的 “老A” 的时候,这个 name 持有的就不是一个字符串了,而是一个内部包含了 String 值的 MutableState 对象,好处就是这样的话它就可以被订阅了,坏处就是在使用它的时候不能直接传 name 而是要传 name.value 了;

到这时候可能就会有人有疑问了,为什么要这么写而不是直接传入 name,因为它内部的这个 value 的值才是我们真正要的值,才是对应的 “老A”,那个可以真正改变的值已经不是 name 而不是 name.value 了,所以 name 的声明可以不用写成 var 而是可以写成 val 了,name 在这里已经变成对内部进行操作的操作元了,所以它就是不变,它内部也是可以改变的,不影响它内部属性的改变,所以 name 可以声明成 val 类型的,当这个 name.value 发生改成的时候,界面就可以直接更新这个 name.value 的最新值了,例如我们这样来实际看下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码private var name = mutableStateOf("老A")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ui()
}
}
}
lifecycleScope.launch {
delay(3000)
name.value = "Mars"
}
}
@Composable
fun ui() {
Text(text = name.value)
}

启动一个协程,延迟 3s 更新 name.value 的值为 Mars,我们运行看下效果:

SVID_20240127_113042_1.gif

可以看到,3s 后更新成了 Mars;
那么问题来了,这到底是怎么做到的呢?这个自动订阅又是怎么订阅的呢?我们进入源码看下,完整的路径链路是

mutableStateOf -> createSnapshotMutableState -> ParcelableSnapshotMutableState -> SnapshotMutableStateImpl 我们进入这个 SnapshotMutableStateImpl 看下

infoflow 2024-01-27 11-58-16.png
可以看到我们所使用的 value 了,我们可以看到它的 get 和 set 函数都是有具体的实现的,简单来说当这个 value 被读的时候,会通过 next.readable 来记录下在哪被读了,同样的在它被写的时候,不仅仅是把这个值给修改了,还要找一找在哪里读过,然后去通知这些被读过的地方,让它们进行刷新;另外这个刷新是包含了三个部分的(组合、布局、绘制)这个组合在官方原表达中是 Composition,这个 Composition 就是 Compose 的意思,只不过一个是名词,一个是动词,也就是说 Compose 的实际过程就是先进行 Compose 再进行布局再进行绘制,这个跟我们所认知的传统界面绘制是不同的,传统界面绘制是只有布局和绘制,测量其实也是布局的过程,Compose 的布局过程也是分测量和布局的过程的;

组合

这个组合(Compose、Compostition)其实就是执行我们 Compose 代码的过程,就是执行那些被 Composable 注解标记的函数的过程,之所以单独搞这么一个过程,就是因为它是用来拼凑出实际的界面内容的过程;

那什么是拼凑出界面实际内容呢?Text(“”) 这不是界面实际内容吗?那么什么是界面实际内容呢?

按照 Compose 官方说法,它确实不是界面的实际元素,而是用于生成界面元素的,因为在布局和绘制的时候,Compose 并不是直接使用了我们的 Composable 函数来进行布局和绘制的,而是先用我们一个个的 Composable 函数来组合出一个个实际的对象,这些对象才是最终用来做布局和绘制工作的,

那么这个被组合被实际生成的对象又是什么呢?
我们在 setContent{} 中写的 Composable 函数,它们都会被装载进一个 ComposeView 这么一个类的对象里面,他里面又会包裹着一个 AndroidComposeView 的对象,这个 AndroidComposeView 对象中又会使用一个叫作 LayoutNode 类型的对象,用这个对象来进行真正的布局和绘制,而这些 LayoutNode 类型的对象就是在组合过程中生成的,Compose 通过组合过程拼凑出了 LayoutNode 这些对象,然后在后续的布局和绘制过程中利用这些 LayoutNode 去进行真正的布局和绘制;这个拼凑过程就是所谓的组合过程,也就是 Compose 过程;

value 的 get 具体实现

1
2
3
kotlin复制代码get() = next.readable(this).value

private var next: StateStateRecord<T> = StateStateRecord(value)

这个 StateStateRecord 我们点击去看下:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码private class StateStateRecord<T>(myValue: T) : StateRecord() {
override fun assign(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
this.value = (value as StateStateRecord<T>).value
}

override fun create(): StateRecord = StateStateRecord(value)

var value: T = myValue
}

继承自 StateRecord,我们这里标记下,下面会提到它

而 SnapshotMutableStateImpl 又是继承自 SnapshotMutableState

1
2
3
4
5
6
csharp复制代码interface SnapshotMutableState<T> : MutableState<T> {
/**
* A policy to control how changes are handled in a mutable snapshot.
*/
val policy: SnapshotMutationPolicy<T>
}

SnapshotMutableState 又是继承自 MutableState,看起来是关联上来了,我们通过声明一个 mutableStateOf() 最终和 MutableState 关联起来,但是真正实现订阅能力的其实并不是这个,而是 StateObject,也就是 SnapshotMutableStateImpl 继承的另一个类

1
2
3
4
kotlin复制代码internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {}

我们进入这个 StateObject 看下

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码interface StateObject {

val firstStateRecord: StateRecord

fun prependStateRecord(value: StateRecord)

fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? = null
}

实现比较简单,但这里有一个比较核心的点就是 StateRecord,而这个 StateRecord 就是我们前面 next 的类型 StateStateRecord 的父类,这个 StateRecord 才是真正存储状态订阅的类,也就是下面的关系图:

mutableStateOf -> createSnapshotMutableState -> ParcelableSnapshotMutableState -> SnapshotMutableStateImpl -> StateObject -> StateRecord

那么为什么要存到这个 StateRecord 中呢?而不是直接存到 StateObject 中呢?因为每个变量只存一份是不够的,旧值对 Compose 来说也是有用的,也是要存起来的,

为什么要存旧值呢?最主要的一个原因是 Compose 是支持事务功能的(可以批量进行,可以并发进行,可以撤销,可以事后进行合并的变量更新),如果更新可以撤销,那么旧值就需要存下来了,不然从哪里撤销呢?所以 Compose 的变量管理需要对变量存多个新旧值;

每个变量需要存多个新旧值,Compose 是怎么存的呢?Compose 采用的是链表的形式;链表这里简单说一下,它就是一个 List,但它的每一个元素不是放在一个数组中挨着排放的,而是依靠引用来连接的,它的每一个元素内部保存着对下一个元素的引用;

因为 Compose 采用的链表的形式,那么它只需要往 StateObject 的对象中保存一个 StateRecord 就可以了,就是我们看到的 firstStateRecord

1
2
csharp复制代码override val firstStateRecord: StateRecord
get() = next

而 StateRecord 中有一个 next 属性

1
csharp复制代码internal var next: StateRecord? = null

这个 next 属性是 StateRecord 类型的,通过这种方式,我们就可以访问到一个 StateObject 中的所有的 StateRecord 了,我们只需要获取到 firstStateRecord 之后,就可以获取所有的 StateRecord 了;

我们接着回去看 value:T 的 get 方法,next 也就清楚了,它就是链表的头节点,我们进入这个 next.readable 方法看下,最终调用到的是:

1
2
3
4
5
kotlin复制代码fun <T : StateRecord> T.readable(state: StateObject, snapshot: Snapshot): T {
// invoke the observer associated with the current snapshot.
snapshot.readObserver?.invoke(state)
return readable(this, snapshot.id, snapshot.invalid) ?: readError()
}

snapshot.readObserver?.invoke(state) 这行代码就是进行记录的,它会记录SnapshotMutableStateImpl(StateObject) 对象被使用了,指的就是我们在执行 Text(text = name.value) 调用 name.value 的时候会被记录下来,这个记录它实际上是一个订阅类型的,因为这些 MustableState 对象每次被更新的时候,都会去遍历这些做过的记录,去看一看这个变量曾经都在哪些地方被读了,然后对这些读过的地方把它们标记为失效,这些失效的位置,在下一帧的时候这些失效的位置就会被刷新,更确切的说是会被重组,我们前面有说到 Compose 在绘制的时候分为三个部分:组合、布局、绘制,而这个重组,Compose 官方原表达是 Recompose,官方翻译过来就是重组,这个记录的动作,本质上就是订阅;

接着调用了三个参数的 readable 方法,这个三个参数的 readable 方法发生了什么?

主要是用来取值,取什么值呢?取得就是从链表表头的那个 StateRecord 开始遍历,去找到一个最新的、可用的 StateRecord,那这个最新的、可用的怎么理解呢?首先这个 StateRecord 是用来保存修改前和修改后的值的,这些值会随着状态的改变而失效,有的值在这个地方是有效的,在另一个地方就可能是无效的,所以针对有效性来说,我们不能只拿最新的,还得是有效的才行,所以针对这个三参数的 readable 方法,它的作用就是拿到最新的、可用的 StateRecord,然后把它给返回;

所以这个

1
2
csharp复制代码override var value: T
get() = next.readable(this).value

做了两件事情:遍历这个 StateRecord 链表,拿到最新的可用的 StateRecord,顺便记录下这个 SnapshotMutableStateImpl(StateObject)它在这个地方被用到了;

.value 就是获取这个 StateRecord 中包裹的实际的值;

小结

为什么 mutableStateOf 这个返回的对象可以被订阅?

因为它的那个 value 属性的 get 函数被定制了,定制之后,它每次被取值的时候,都会先进行记录操作,记录下这个值在哪被取用了,然后再从保存的一大堆值中取出最新的、可用的那一个值然后返回

value 的 set 具体实现

1
2
3
4
5
6
kotlin复制代码override var value: T
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}

我们来看下这个 withCurrent 做了什么?

1
2
kotlin复制代码inline fun <T : StateRecord, R> T.withCurrent(block: (r: T) -> R): R =
block(current(this))

实现很简单,就是直接调用了 block 函数,也就是直接调用了 withCurrent 后面跟的那个 lambda 函数

1
2
3
4
5
javascript复制代码{ it:StateStateRecord<T>
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}

可以看到这个 lambda 是有入参的,它是一个 StateStateRecord 类型的,也就是 current(this) 返回是一个 StateStateRecord 类型的,我们进入这个 current 看下

1
2
3
4
5
6
7
8
kotlin复制代码internal fun <T : StateRecord> current(r: T) =
Snapshot.current.let { snapshot ->
readable(r, snapshot.id, snapshot.invalid) ?: sync {
Snapshot.current.let { syncSnapshot ->
readable(r, syncSnapshot.id, syncSnapshot.invalid)
}
} ?: readError()
}

可以看到,最终调用到了 readable 函数,我们前面讲了,这个 readable 就是取值的作用,所以 current 函数就是取一个最新的值作为参数传给 block 函数,我们来看看 block 做了什么?

1
2
3
4
5
javascript复制代码if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) {
this.value = value
}
}

比较一下新旧值是不是不一样,如果不一样,就进入 next.overwritable 函数,我们来看下这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码internal inline fun <T : StateRecord, R> T.overwritable(
state: StateObject,
candidate: T,
block: T.() -> R
): R {
var snapshot: Snapshot = snapshotInitializer
return sync {
snapshot = Snapshot.current
this.overwritableRecord(state, snapshot, candidate).block()
}.also {
notifyWrite(snapshot, state)
}
}

可以看到,它内部最终调用了一个 overwriteableRecord 函数,我们进入这个函数看下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码internal fun <T : StateRecord> T.overwritableRecord(
state: StateObject,
snapshot: Snapshot,
candidate: T
): T {
if (snapshot.readOnly) {
// If the snapshot is read-only, use the snapshot recordModified to report it.
snapshot.recordModified(state)
}
val id = snapshot.id

if (candidate.snapshotId == id) return candidate

val newData = sync { newOverwritableRecordLocked(state) }
newData.snapshotId = id

snapshot.recordModified(state)

return newData
}

这里面可以分为两部分来看,

一部分是:

1
bash复制代码if (candidate.snapshotId == id) return candidate

这部分说的是:如果你传递过来的 StateRecord 的 snapshotId 正好等于传过来的 SnapShot,那么就直接把这个 StateRecord 返回;

一部分是:

1
2
3
4
5
6
ini复制代码val newData = sync { newOverwritableRecordLocked(state) }
newData.snapshotId = id

snapshot.recordModified(state)

return newData

如果前面没有返回,就用 newOverwritableRecordLocked 获取一个 StateRecord 并返回;

Snapshot

这里额外多了一个 Snapshot,那么这个 Snapshot 是什么呢?

StateRecord 每次修改的新旧值都会被记录下来串成一个链表,这个链表上的各个节点其实都对应了某一个时刻的 Compose 的整个内部状态,Compose 记录每个变量的每个状态,用的是 StateRecord 的链表,而具体各个链表上的哪些节点它们共属于同一个状态,它也有记录,而这个记录就是 Snapshot;

StateRecord 对应的是变量;

Snapshot 对应的是整个状态,可以对应多个 StateRecord,一个 StateRecord 对应一个 Snapshot,这个 Snapshot 是对整个系统做快照的,有了这个快照之后,就可以在一些变量值发生变化的时候,不必把它马上应用到内部显示到界面,而是在它跑完整个 Compose 的流程之后,把所有改变的变量一起应用,然后拿着这个最终的结果,去进行接下来的布局和绘制,这样性能会好些,Snapshot 机制就对这种批量应用改变提供了下层技术可行性的支持;

所以,系统有多个 Snapshot 的时候,它们是有先后关系的;

同一个 StateObject 的每个 StateRecord,都有它们对应的 Snapshot 的 id,StateRecord 和 Snapshot 就算不直接对应,只要 StateRecord 的 Snapshot 对另一个是有效的,另一个就能取到这个 StateRecord;

newOverwritableRecordLocked 获取一个 StateRecord 并返回,它内部并不是直接创建一个新的,而是直接拿或者废物利用或者用新创建的方式来获取一个 StateRecord;

当我们通过

1
kotlin复制代码this.writableRecord(state, snapshot).block()

拿到 StateRecord 之后,就会调用一个 block 函数,这个 block 函数对应的就是

1
ini复制代码{ this.value = value }

很简单的一个赋值操作,把传入的新值赋值给内部的 value;
赋值完之后,还有一步操作:

1
2
3
kotlin复制代码internal fun notifyWrite(snapshot: Snapshot, state: StateObject) {
snapshot.writeObserver?.invoke(state)
}

找到这个变量在哪里被读了,然后把这部分的 Compisition 组合结果标记为失效,然后等着到下一帧的时候,这些失效的部分,会重新 ReCompose 重组

到这里,value 的 get 和 set 就都分析完了,get 负责订阅,set 负责通知,每一次变量更新的时候就会应用到界面,就会更新了;

但是 这并不完全是 Compose 的自动订阅,因为 Compose 是有两套订阅系统的,它们共同工作才让变量真正被订阅了

我们回到代码中看下:

get 中的

1
arduino复制代码snapshot.readObserver?.invoke(state)

set 中的

1
arduino复制代码snapshot.writeObserver?.invoke(state)

它们都是 Snapshot 中的两个 Observer,而 Observer 就是订阅通知中的被通知对象,也就是说读,通知 readObserver,写通知 writeObserver,它们属于两个不同订阅中的两个被通知对象,readObserver 对读做了订阅,writeObserver 对写做了订阅,它们会分别收到读和写的通知;

到这的时候,可能会有人有疑问了,前面说 readObserver 是订阅,writeObserver 是通知,这里又说两个都是订阅,那么到底哪个是合理的?其实这两种都合理,因为它本来就有两套订阅,分别是对不同的对象做的订阅;

首先 Compose 要先去订阅 Snapshot,对它内部的读和写行为分别做订阅,对它们分别读写一个个的 StateObject 的行为做订阅,这样当我们的 Snapshot 去读和写任何一个 StateObject 对象的时候,我们的readObserver 和 writeObserver 就会收到相应的通知,通知某个对象被读或者被写了,这个对象就是 StateObject,所以我们其实是对 Snapshot 中的读和写进行订阅,并在读和写发生变化的时候进行通知,这两个订阅的通知部分就是上面的 readObserver.invoke 和 writeObserver.invoke,而它们的订阅行为是在 Snapshot 对象被创建的时候自动发生的;

这是 Compose 对 Snapshot 读、写 StateObject 行为的订阅,所以有两个接受者,readObserver 和 writeObserver;订阅发生在 Snapshot 创建的时候,通知发生了读和写的时候

另外 Compose 还会对具体每一个 StateObject 它的应用事件做订阅,订阅发生在它的第一个 readObserver 被调用(通知)的时候,通知发生在 StateObject 新值被应用的时候;

我们可以来看一个具体的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码private var text = mutableStateOf("1")

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface {
Ui()
}
}
}
}

fun Ui() {
Column(modifier = Modifier.fillMaxWidth().height(200.dp)
.border(2.dp, Color.Black)
.clickable {
text.value = "2"
}) {
Text(text = text.value)
text.value = "3"
}
}

我们前面有说到订阅发生在组合的时候,也就是

1
2
3
4
ini复制代码{
Text(text = text.value)
text.value = "3"
}

这是组合的过程,当执行到 Text(text = text.value) 的时候,它的读被记录了,接下来又通过 text.value = “3” 触发了写,就会触发通知,通知这块区域将被标记为失效,如果没有 Text(text = text.value) 这一行将不会被标记为失效,因为 text.value 在任何地方都没有被读过,所以不会被标记为失效,只有被读过了,当执行到写的时候,才会被标记为失效,在下一帧的时候进行刷新;而 clickable 区域中发生点击的时候,text 的值会发生改变,但是这块区域不属于 Compose(组合)过程,不是在组合过程,发生的写事件不会被通知到 writeObserver,可能就会有人有疑问了,那界面不就不会更新了吗?不会的,我们还有一个应用事件,这个加起来才是一个完整的通知订阅机制;

mutableStateOf 的简化写法


使用 by 关键字

1
csharp复制代码private var name by mutableStateOf("老A")

这样我们在调用 name.value 的时候,就可以直接省略成 name

1
ini复制代码Text(text = name)

好了 MutableState 和 mutableStateOf 今天就讲到这里吧

下一章预告


重组作用域和 remember

欢迎三连


来都来了,点个关注,点个赞吧,你的支持是我最大的动力~

本文转载自: 掘金

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

Docker 快速入门实操教程(完结) Docker 快速入

发表于 2024-01-29

Docker 快速入门实操教程(完结)

Docker,启动!

如果安装好Docker不知道怎么使用,不理解各个名词的概念,不太了解各个功能的用途,这篇文章应该会对你有帮助。

前置条件:已经安装Docker并且Docker成功启动。

实操内容:使用Docker容器替换本地安装的程序并迁移数据(MySQL、redis)。

最终目的:熟练使用Docker各项功能。

理解概念

Docker官方提供了一个分发平台DockerHub,可以从上面拉取已经提供好的镜像直接构建容器运行。

这个过程会涉及到Docker的一些概念,在刚接触的时候比较抽象,这里以烘焙出一个蛋糕为例子说明一下:

  • Dockerfile: 蛋糕的配方。配方上详细列出了需要的材料(如面粉、糖、鸡蛋)以及烘焙的步骤(如先将面粉和糖混合,然后加入鸡蛋搅拌)。
  • 镜像(Image): 按照配方做出了一个半成品蛋糕,这就是蛋糕的”镜像” 。这个蛋糕可以被任何人复制,每一个复制品都会和原蛋糕一模一样。
  • 容器(Container): 将半成品蛋糕烘焙后,得到一个可食用的蛋糕。可以根据同一个镜像制作出很多个完全一样的蛋糕,也可以在烘焙时自己加一些材料。每个蛋糕都是独立的,和其他蛋糕没有关联。

所以从DockerHub拉取镜像并且跑起来的过程就可以理解为:

  1. 镜像提供者编写好了配方( Dockerfile ),将其制成( 构建 )了半成品蛋糕( 镜像 )。
  2. 用户购买( 拉取 )这个半成品蛋糕。
  3. 烘焙( 创建 )后得到了一个可食用的蛋糕( 容器 ),食用蛋糕( 运行容器 )。
  4. 通常创建容器和运行容器都会归拢在同一步:创建并运行。

还有另外两个比较重要的概念: 层(Layers) 和 缓存(Cache) ,目前不会接触到,可以在看 构建/推送镜像 这一节时再去深入理解。

创建/运行容器

每一步都提供了Docker desktop(简称桌面版)的操作截图和终端命令(桌面版界面友好但局限较大,仅适合初步上手)。

拉取镜像

从DockerHub拉取MySQL镜像到本地,这一步可能会因为网络原因失败,可以配置其他镜像源或者使用代理,网上教程很多。

image-20240128210738711

终端命令

1
bash复制代码docker pull 仓库地址/命名空间/镜像名称:标签
  • 仓库地址: 没有显式指定仓库地址时,默认会从DockerHub查找镜像;拉取私有仓库的镜像,需要指定仓库地址。
  • 命名空间: 截图最后有一个名为 ubuntu/mysql 的镜像,其中 ubuntu 是命名空间,用以区分不同的个人或组织发布的镜像。没有显式指定命名空间时,默认会查找官方团队发布的镜像。
  • 镜像名称: 需要拉取的镜像的名称。
  • 标签: 没有显式指定标签时,默认会拉取 latest 标签, latest 表示这是最新的版本。

通过 docker pull 拉取镜像并不是必须的,在 docker run 时,如果本地不存在指定镜像,Docker会自动拉取。

创建并运行容器

拉取完成后,通过 docker run 创建容器并运行前进行一些配置:

image-20240128213738553

终端命令

1
2
3
4
5
bash复制代码# 截图对应命令
docker run -d --name mysql_8.3.0 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:latest

# 完整命令
docker run [选项参数] 仓库地址/命名空间/镜像名称:标签 [命令行] [命令行参数]
  • 选项参数:

--name :设置容器名称,不能重复,这里使用的是 镜像名_版本号 的方式。

-p :设置端口映射,将宿主机的 3306 端口映射到容器的 3306 端口,宿主机上的其他进程通过该端口才能访问到容器内的服务。如果不配置端口映射,则只能在容器内部访问服务或通过虚拟网络让容器可以相互通信。

-e :设置环境变量,配置 MYSQL_ROOT_PASSWORD=root 用以指定root用户密码,这是由镜像创建者约定的,不同的镜像配置项会有所不同。

-v :设置目录挂载,用法参考 目录挂载 章节。

-d :让容器在后台运行

  • 命令行: 在容器启动时执行命令(如 ls ),可以省略。
  • 命令行参数: 传给 命令行 的额外参数(如 /etc ,这样在容器启动时就会执行 ls /etc ),可以省略。

常用命令

容器已经创建好后就不再适用于 docker run 命令了, docker run 命令主要是用于创建新的容器并运行,如果需要启动已经存在的容器,则使用 docker start 命令。

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复制代码# 列出所有容器
docker ps -a

# 列出所有镜像
docker image ls
docker images

# 启动容器
docker start 容器名称/容器ID

# 停止容器
docker stop 容器名称/容器ID

# 强制停止容器
docker kill 容器名称/容器ID

# 重启容器
docker restart 容器名称/容器ID

# 删除容器
docker rm 容器名称/容器ID

# 删除镜像
docker rmi 容器名称/容器ID

目录挂载

现存问题:

  • 数据没有保存到宿主机中,当容器删除后,数据就丢失了。
  • 宿主机和容器之间的文件传递比较麻烦。
  • 多个容器需要共享数据。

目录挂载可以解决以上问题,Docker为目录挂载提供了三种方式:

  • bind mount: 把宿主机目录映射到容器内,双向文件传递。适合变动比较频繁的场景,比如代码目录、配置文件等。
  • volume: 由容器创建和管理,存储在宿主机中,官方推荐,Linux 文件系统。适合存储不需要关心的数据,如数据库数据。
  • tmpfs mount: 适合存储临时文件,存储在宿主机内存中。不可多容器共享。

以MySQL镜像为例,其 Dockerfile 中写了创建 volume 用于持久化保存数据的命令(其他镜像也可以通过这种方式查看需要持久化的目录)。

image-20240128224015528

虽然 Dockerfile 中有创建 volume 的命令,但是如果创建容器时没有主动为 volume 命名,其就是匿名 volume ,Docker会为匿名 volume 随机生成一个名称,当挂载该 volume 的容器被删除后,该 volume 也会被删除。

当创建容器时主动指定的 volume 路径和 Dockerfile 约定的路径一致,则该镜像创建的 volume 就不会被挂载为匿名 volume 了,容器删除后该 volume 也会保留,这也是最方便的一种命名方式。

image-20240128220629652

终端命令

1
bash复制代码docker run -d --name mysql_8.3.0 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root -v=mysql_volume:/var/lib/mysql -v D:\mount:/pc_mount mysql:latest

挂载目录时,如果只赋予了名称则是 volume 方式,如果指定了具体目录就是 bind mount 方式。

所以在这个容器中:

  1. 将容器内的 /var/lib/mysql 目录挂载为 volume 并且命名为 mysql_volume 。
  2. 将宿主机的 D:\mount目录映射至容器中的 /pc_mount 。

在挂载目录时,如果你指定的目录不存在于容器中,则会自动创建,这里的 /pc_mount 目录就会自动被创建。

迁移实操

现在需要将宿主机中MySQL数据迁移到容器中,打算采用navicat的数据迁移工具,那么就需要同时运行两个数据库,端口同为 3306 会冲突,宿主机上MySQL端口改起来并不方便,容器创建后端口也不能修改,那么就可以使用数据挂载的方式。

迁移方案:

  1. 停止运行端口为 3306 的MySQL容器。
  2. 新创建一个MySQL容器,端口指定为 3305 (或其他任意未被占用的端口),指定 volume 的名称和端口 3306 的容器一致。
  3. 迁移数据,删除端口为 3305 的容器,运行端口为 3306 的容器,数据迁移成功。

当数据库文件较大时,使用navicat迁移则会显得有些性能不足了,这时候就需要通过命令行导入:

  1. 将需要导入的SQL文件放在宿主机挂载的目录下(宿主机: D:\mount ;容器: /pc_mount )。
  2. 打开容器的终端
1
bash复制代码docker exec -it mysql_8.3.0 bash
  1. 登入MySQL并选择需要导入的数据库
1
2
3
bash复制代码mysql -u root -proot
use test-base;
source /pc_mount/001.sql;

从容器中导出SQL文件到宿主机同理,将SQL文件导出至挂载的 /pc_mount 目录下,在宿主机的 D:\mount 就可以看到。

虚拟网络

每个Docker容器都运行在自己的环境中,互不干扰,所以上述内容中都依赖宿主机的端口映射进行容器通信。但是有些时候我们只要让这个项目能在宿主机上访问到,并不在意其所依赖的服务是否能够被宿主机操作和管理。就可以通过Docker提供的虚拟网络实现容器之间的通信,再映射项目入口到宿主机即可。

桌面版并没有为虚拟网络提供较好的GUI支持,需要终端执行。

1
2
bash复制代码# 查看已存在的虚拟网络
docker network ls

默认已经存在了三个虚拟网络,这是由Docker创建的,对应着不同的网络驱动类型,驱动类型的区别如下:

  • Bridge网络: 默认值。容器在独立的网络空间运行,可以相互通信并访问外部网络。容器内服务能通过端口映射被外部访问。
  • Host网络: 容器共享宿主机的网络空间,不再需要端口映射,直接使用宿主机的端口。这种模式提供了最高的网络性能,但是失去了隔离性。
  • None网络: 容器拥有自己的网络空间,但不配置任何网络接口。它只有本地回环接口,没有任何外部网络访问能力,提供了最高的网络隔离性。
1
2
3
4
5
6
bash复制代码# 创建名为 test_net 的网络
docker network create test_net

# 在该网络下创建两个容器
docker run --name redis_temp --network=test_net -d redis:latest
docker run --name redisinsight -p 8001:8001 --network test_net -d redislabs/redisinsight:latest

创建了redis容器,但是并没有为其映射端口。所以现在在宿主机中并不能访问到这个redis容器。

创建了redisInsight容器并且映射了8001端口,这是一个redis的GUI工具,用于测试是否可以通过虚拟网络访问到redis容器。

访问 http://localhost:8001/ 进入redisInsight的主页,添加一个redis数据库。

Docker内部的DNS服务会自动将容器名称解析为容器对应的IP地址(即容器名称就是域名),所以主机地址填写容器名称即可。

image-20240129172212539

连接成功,这样既可以操作容器内的redis数据,又不会占用宿主机自身的redis应用抢占端口。同理,部署其他项目时,如果项目容器需要连接数据库容器,也可以通过虚拟网络实现。

如果容器已经被创建,可以更改已存在的容器的连接的网络

1
bash复制代码docker network connect 网络名称 容器名称

使用技巧

查看软件版本

部分镜像的 Tag 是 latest ,并没有明确指出具体的版本号,想要查看版本号就只能手动查看。

桌面版点击容器右侧 ··· 打开更多选项,选择 Open in terminal 进入容器的终端,执行该软件查看版本的命令。

终端命令

1
bash复制代码docker exec mysql_8.3.0 mysql -V

但是问题就来了,如果需要版本号是为了给容器命名,这种方案需要先运行容器,将容器删除,再重新创建容器,很麻烦。

通常镜像的环境变量中会指明版本号,可以直接点开镜像查看

image-20240128220336815

终端命令

1
bash复制代码docker inspect mysql:latest

这个方式虽然比较方便,但是需要进行推测,并非一定正确。

保持容器运行

当在桌面版运行ubuntu等容器时,会发现容器启动后就停止了,进入 Exited 状态,如果想要容器持续运行,就需要需要在容器内部执行一个持续运行的进程。

桌面版已经不能满足需求了,需要终端执行

1
bash复制代码docker run -it --name ubuntu_22.04 ubuntu:latest

-t 指令分配一个虚拟的终端或控制台,可以让容器持续运行不会关闭。

-i 指令可以让打开的控制台能够接受用户输入。

构建/推送镜像

想要通过Docker将项目部署到服务器上或是分发项目供他人使用,就需要将项目构建为镜像,官方主要推荐通过 Dockerfile 构建镜像。 Dockerfile 是一个文本文件(无文件后缀),由一系列的命令和参数构成,这些命令对应了在构建镜像时的操作步骤。

编写Dockerfile文件

Dockerfile常用指令:

  • FROM: 指定基础镜像。所有后续的操作都是基于这个基础镜像进行的。
  • WORKDIR: 设定后续命令的执行目录。
  • COPY: 复制文件、指定目录中的所有内容(不含目录本身)到镜像中。
  • ADD: 复制文件、指定目录中的所有内容(不含目录本身)到镜像中。对tar格式的压缩文件会自动解压。
  • RUN: 构建过程中执行命令。比如安装一些软件,创建一些文件等。
  • CMD: 为容器提供默认的执行命令,会被 docker run 的命令行参数覆盖。
  • ENTRYPOINT: 为容器提供默认的执行命令,不会被 docker run 的命令行参数覆盖。
  • EXPOSE: 公开容器的一个端口供外部访问。

通过maven执行 package 手动将项目打包,命名为 output-dem.jar ,在项目根目录下新建一个 Dockerfile 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dockerfile复制代码# 使用JDK17基础镜像
FROM openjdk:17-jdk-slim

# 设置工作目录,容器也会使用该目录作为工作目录
WORKDIR /app

# 将jar包复制到/app路径下
COPY target/output-demo.jar app.jar

# 设置在运行此镜像后默认执行的命令,让它运行刚才的jar包
ENTRYPOINT ["java", "-jar", "app.jar"]

# 暴露端口,取决于项目实际使用的端口号
EXPOSE 8080

如果不是Java开发,设备上并没有安装 JDK 和 maven 等构建需要的环境(其他语言同理),但是又有打包项目的需求,则可以通过多阶段构建的方式,在镜像中完成编译等操作:

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
dockerfile复制代码# 使用包含JDK17和Maven3.8.5的基础镜像
# 将本构建阶段命名为 build ,以便在后面的阶段中引用
FROM maven:3.8.5-openjdk-17-slim AS build

# 设置工作目录,容器也会使用该目录作为工作目录
WORKDIR /app

# 将当前目录下的所有文件添加到工作目录下(.和./都可以表示当前目录)
ADD . .

# 使用Maven构建项目为jar包
RUN mvn clean package
# 使用Maven构建项目为jar包(跳过测试阶段)
# RUN mvn clean package -DskipTests=true

# 新的构建阶段
# 引入JDK17的基础镜像
FROM openjdk:17-jdk-slim

# 设置工作目录,容器也会使用该目录作为工作目录
WORKDIR /app

# 将 build 阶段构建jar包复制到新阶段的/app路径下
COPY --from=build /app/target/output-demo.jar app.jar

# 设置在运行此镜像后默认执行的命令,让它运行刚才的jar包
ENTRYPOINT ["java", "-jar", "app.jar"]

# 暴露端口,取决于项目实际使用的端口号
EXPOSE 8080

在Docker的多阶段构建中,每次使用新的 FROM 指令,都会开始一个新的构建阶段,上一阶段的指令会创建为一个临时的镜像。在新阶段中,前一阶段的层和设置都被丢弃,只有 --from 指定的之前阶段的内容被保留,就像是开始了一个全新的 Dockerfile 一样。

构建镜像

写好 Dockerfile 后,就可以通过该文件构建镜像了

1
2
bash复制代码 docker build -t 仓库地址/命名空间/镜像名:标签 .
docker build -t 仓库地址/命名空间/镜像名:标签 -f /path/myDockerfile .

-t 指定镜像名称(如果不推送到私有仓库,仅本地使用, 仓库地址/命名空间/ 可以省略)。

-f 指定 Dockerfile 所在的目录,也可以指定 自定义名称的Dockerfile ( -f 参数可省略)。

. 使用当前目录下作为上下文环境, COPY 等命令会从该目录查找文件。未指定 -f 参数时,则使用上下文环境中名为 Dockerfile 的文件。

推送镜像

每次发布更改内容都需要打包镜像后上传到生产环境再部署很麻烦,将镜像推送至私有仓库中,在生产环境直接从仓库中拉取镜像则更加高效。

1
2
3
4
5
bash复制代码# 登录仓库
docker login 仓库地址

# 推送镜像
docker push 仓库地址/命名空间/镜像名:标签

没有显式指定仓库地址时,默认会将DockerHub作为仓库地址。

层与缓存

层(Layers): 根据 Dockerfile 构建镜像时,每一个会改变文件系统状态的指令( RUN 、COPY 、 ADD 等)都会新建一个层 ,每个层都是前一层的改动的结果,并且每个层都只保留改动的部分,共享未改动的部分。依次将所有的层合并起来就是完成的镜像。

根据这个例子可以方便理解层的概念:

1
2
3
bash复制代码# Dockerfile A
RUN apt-get install -y software-package # 第一层
RUN rm -rf /var/lib/apt/lists/* # 第二层

在这个片段中,第一层中安装了一个软件包,第二层中删除了软件包。

因为 每个层都是前一层的改动的结果 ,所以第二层的删除文件并不能影响到第一层,只是会在第二层中对该文件打上一个 删除 的标记,这个文件会作为一个无用的文件存在于最终构建的镜像中,增加了镜像的体积。

1
2
bash复制代码# Dockerfile B
RUN apt-get install -y software-package && rm -rf /var/lib/apt/lists/*

在这个片段中,安装和删除软件包是在同一个指令下执行的,所以他们是处于同一层的操作,当这一层的构建结束时,这些文件就会被清理掉,它们也就不会存在于最终的镜像中了。


缓存: 缓存多个镜像之间可以共享层,如果多镜像都是基于同一个基础镜像进行构建的。那么,这个基础镜像的所有层都只需要存储一次,就能在所有的镜像中共享。如果一个镜像的大部分层已经在本地存在,那么在拉取这个镜像时,只有不存在的层需要被下载,这可以极大地节省时间和网络带宽。

Docker Compose

当项目依赖的服务较多时,每个容器都要单独配置运行,指定网络。使用Docker Compose,可以通过一个YAML文件定义服务,并同时运行它们。

Docker Compose将所管理的容器分为三层:工程(Project)、服务(Service)、容器(Container)。

通过一个例子来理解三层结构:

  1. 工程: 一个工程可以被视为一家公司,它为所有服务提供了整体的工作环境和资源配置。
  2. 服务: 公司内设有各种部门,如财务和行政等,每个部门有自己特定的职责和任务。每个部门都可以被看作一个服务。
  3. 容器: 每个部门由一个或多个员工组成。尽管每个员工都是独立的,但他们共享同样的环境。一个员工相当于一个容器。

所有部门都在同一家公司工作并使用该公司的资源,所以所有服务在工程中共享同样的网络、卷等资源。

各个部门之间还是会进行交流和协作,所以各个服务之间可以互相通信。

同一部门的所有员工都具有相同的工作环境,所以属于同一服务的所有容器都有统一的配置。员工各自做自己的项目,所以容器间有一定的隔离性。

在要部署项目的目录创建一个 docker-compose.yml 文件:

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
yaml复制代码# 指定Docker Compose配置文件的版本
version: '3.8'

services:
# 定义应用服务,名为 app
app:
image: '仓库地址/命名空间/镜像名称:标签'
# 将容器的8080端口映射到宿主机的8080端口
ports:
- 8080:8080
volumes:
# 将 docker-compose.yml 所在目录映射到容器中的 /app 目录(在 Dockerfile 中给定的工作目录)
- ./:/app
# 定义启动依赖,会先启动 mysqldb 和 redisdb,再启动 app
depends_on:
- mysqldb
- redisdb
# 指定容器启动后执行的命令
command: ["java", "-jar", "app.jar"]
# 如果服务非手动停止,Docker会自动尝试重启服务
restart: always

# 定义一个MySQL服务,名为 mysqldb
# 其他服务连接MySQL数据库时,主机地址就是 mysqldb:3306
mysqldb:
image: mysql:8.0.30
environment:
- MYSQL_ROOT_PASSWORD=root
volumes:
- db_data:/var/lib/mysql

# 定义一个Redis服务,名为 redisdb
# 其他服务连接Redis数据库时,主机地址就是 redisdb:6379
redisdb:
image: redis:7.2.4
volumes:
- redis_data:/data

volumes:
db_data:
redis_data:

在 docker-compose.yml 文件所在的目录执行

1
bash复制代码docker compose up -d

docker compose up 根据 docker-compose.yml 文件内容启动、创建、连接服务。

-d 参数表示以后台方式运行。

-f 如果文件名称不是 docker-compose.yml ,可以通过 -f 命令指定,使用方法与 构建镜像 章节一致。

每次更改了 docker-compose.yml 文件,都需要重新运行 docker-compose up -d 命令以应用更改。

结语

任何技术都有其深度与复杂性,难以通过一篇文章详尽阐述。本文的初衷是为你在遭遇问题时,提供一个寻找解答的方向指引。

本文转载自: 掘金

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

踩了一堆坑,终于把微服务系统全面升级 JDK17 和 Spr

发表于 2024-01-29

最近正在给自己的开源项目校园博客升级到 JDK17 以及 SpringBoot3,正好记录下升级和踩坑的过程,给大家提供一些解决方案的参考。

先说结论:非常推荐升级JDK17,成本低收益高。至于SpringBoot3.0,迁移成本比较高,坑也会比较多,但如果是新项目的话,还是可以试试的。

PS:项目原来的版本是 JDK8 + SpringBoot2.6。

公众号【程序员阿杆】

GitHub博客仓库:🎉阿杆的博客仓库🎉

  1. 为什么要升级?

  • JDK17和SpringBoot3也发布了一段时间了,自己对一些新特性也比较感兴趣,尤其是 Native Image 这个玩意。
  • 自己手上刚好有校园博客这个项目,可以用来给进行升级,项目不复杂,但也算五脏俱全,全量升级既可以感受一下变化,也不会太费事。
  • JDK17 是一个长期支持的版本(LTS),现在很多开源应用或者一些组件都在往这上面靠,并且大有一种最低支持 JDK17 的趋势。
  • 自己在公司所接触到的项目也有一部分是使用的JDK17,并且整体有往这方面靠的趋势,新项目都会直接用JDK17。

总的来说就是 兴趣 + 资源 + 趋势。

  1. 升级有什么好处?

先来看看 JDK8 -> JDK17 的好处。

  • ZGC垃圾回收器,性能提升
  • 可以使用 var 作为局部变量类型推断标识符
  • 一个文件中可以包含多个public类
  • switch 使用起来更加简洁,可以不用再break了。
  • instanceof 增强
  • 增加不可修改的数据类 record(感觉还是 kotlin 的 data class 好用)
  • Text Blocks文本块

实用性很强,非常舒服。

image.png
再看看 SpringBoot3.0 的一些新特性。

  • 更好的支持 Native Image,使用 GraalVM 构建原生镜像,可以提供显著的内存和启动性能改进
  • 升级到 Spring6.0
  • 升级到 Spring Security 6.0
  • ……

好吧,感觉上是不如 JDK17 要更有性价比,如果对 Native Image 兴趣不大的话,建议不要升级SpringBoot3.x,因为升级SpringBoot的成本可要比升级JDK高多了。

image.png

  1. 升级过程分享

以下的一切内容均基于我已有的项目【校园博客】进行升级和讲解,源码地址:github.com/stick-i/scb…

既然一切都是基于JDK17的,那我们就先升级JDK吧!

3.1. 升级JDK17

3.1.1. 下载安装

安装JDK17,这里我直接在IDEA里面下载安装了,很方便:

为了便于自己以后使用 Native Image,这里我直接下载了 GraalVM。

在IDEA中更新项目SDK和模块SDK:

3.1.2. Maven构建

更新Maven编译配置:

1
2
3
4
5
xml复制代码<properties>
<java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>

Maven重新打包下:

这一步主要是为了更新下内部组件的 JDK 版本。

3.1.3. 启动服务

测试下有没有其他问题,启动所有微服务项目:

竟然一切正常,也可能与我的项目比较简单有一定的关系,所有服务都成功跑起来了。

3.1.4. 更新Dockerfile

原来使用的基础镜像是 java:8-alpine,更新到了亚马逊的openjdk17版本 amazoncorretto:17-alpine。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dockerfile复制代码# 设置JAVA版本
FROM amazoncorretto:17-alpine
# 指定存储卷, 任何向/tmp写入的信息都不会记录到容器存储层
VOLUME /tmp
# 拷贝运行JAR包
ARG JAR_FILE
ADD ${JAR_FILE} app.jar
# 设置JVM运行参数,限定内存大小,并设置时区为东八区
ENV JAVA_OPTS="\
-server \
-Xms256m \
-Xmx512m \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-Duser.timezone=GMT+08 "
#空参数,方便创建容器时传参
ENV PARAMS=""
# 入口点, 执行JAVA运行命令
ENTRYPOINT ["sh","-c","java -jar $JAVA_OPTS /app.jar $PARAMS"]

3.1.5. 源代码

看起来没什么问题了,先提交上JDK升级的代码,有需要的同学可以查看提交记录:

build(all): 全量升级到jdk17,更新dockerfile和pom文件。 by stick-i · Pull Request #198 · stick-i/scblogs

3.2. 升级SpringBoot3.2

为什么选择直接升级到 SpringBoot3.2 而不是 3.0呢?

主要是我开始升级的时候,SpringBoot已经更新到3.2了,而此时的3.0的生命周期已经过半了,目前也还没有推出3.0以上的LTS版本,这么看来我以后总还是要升级的,倒不如现在一起弄了。

3.2.1. 升级pom依赖

跟SpringBoot相关的依赖还是比较多的,尤其是依赖了SpringCore的三方依赖,肯定也是要统一进行升级的。

截至到我写这篇文章的时间,SpringBoot的最新GA版本为 3.2.1:

我选择相信Spring,直接升级最新版!

对应的SpringCloud版本为2023.0.0

其他主要依赖对应升级的情况:

依赖项 升级前版本 升级后版本 备注
SpringBoot 2.6.11 3.2.1 目前的最新版,要踩坑就踩最新的坑🤡
SpringCloud 2021.0.4 2023.0.0 对应SpringBoot3.2.x
SpringCloudAlibaba 2021.0.4.0 2022.0.0 这个库还没出2023的版本,但是2022版也是基于SpringBoot3.0的,应该不会差太多
Mybatis-Plus 3.5.3.1 3.5.5 注意:artifactId 从mybatis-plus-boot-starter改成了mybatis-plus-spring-boot3-starter
druid 1.2.11 1.2.20 注意:artifactId 从druid-spring-boot-starter改成了druid-spring-boot-3-starter

对了,建议顺便升级下Maven。

3.2.2. 解决依赖异常

修改完pom文件之后刷新一下本地依赖包,噢呦,一堆报错:

我看了一下,就两个问题,分别是 mysql-connector-java 和 javax.servlet-api 这两个包的版本没有被指定,所以Maven找不到对应的包。

为什么没有指定呢?之前也没有指定,但是之前没有报错,说明这两个包之前是有被 spring-boot-starter-parent 所管理的,但是现在它不管了。

这得去看看SpringBoot3.0的迁移文档:github.com/spring-proj…

3.2.2.1. MySQL

在网页里搜索关键字 MySQL,这不就来了:

就是说 mysql:mysql-connector-java 这个包的坐标改成了 com.mysql:mysql-connector-j,让我们更新的时候也顺带改一下。这个简单,全局搜索然后改一下就好了。

一改完,版本继承的小图标就出来了,不错不错。

3.2.2.2. javax -> jakarta

然后再搜一下关键字 javax,这不就又来了:

这个就稍微麻烦一点了,不仅Maven坐标从 jakarta.servlet:jakarta.servlet-api 改成了 javax.servlet:javax.servlet-api,而且包名也从 javax.xxx.xxx 改成了 jakarta.xxx.xxx,所有导入了 javax 的包都得改。

先更新下pom文件:

然后再全局搜索 javax 替换下:

我试过了,升级完后唯一出现问题的地方就只有一处,但也很容易修改:

ResponseStatusException 中没有 getStatus() 这个方法了,我使用HttpStatus.valueOf(statusException.getStatusCode().value()) 代替了原来的方法。

做完上面这些后,我的项目已经可以成功编译了,但还不能正常的跑起来。

3.2.3. 配置文件属性迁移

SpringBoot3 更改了一些配置属性,例如:spring.redis.host改为了spring.data.redis.host。

这一变更几乎对所有项目都会有影响,要查看所有的变更项,可以在官方文档中进行查找:github.com/spring-proj…

但这太silly了,很显然官方也这么认为,所以给开发者提供了一个简单的迁移方法,引入 spring-boot-properties-migrator ,它会帮你自动检测配置文件中需要修改的地方:

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>

配置文件属性迁移完毕后,记得删除这里添加的 spring-boot-properties-migrator 依赖。

然后运行项目,当然你的项目大概率是运行不起来的,但别着急,看看你的控制台输出,有没有像我这样的输出内容:

上面的异常信息其实分为了两个部分,前面部分是需要进行修改的配置:

The use of configuration keys that have been renamed was found in the environment:

Property source ‘bootstrapProperties-default-redis.yaml,DEFAULT_GROUP’:

Key: spring.redis.host

Replacement: spring.data.redis.host

Key: spring.redis.password

Replacement: spring.data.redis.password

Key: spring.redis.port

Replacement: spring.data.redis.port

Each configuration key has been temporarily mapped to its replacement for your convenience. To silence this warning, please update your configuration to use the new keys.

它也给出了重命名之后的key,这里直接对着描述把自己的配置文件改改就好了,比较简单。


后面部分是说有一些配置已经被弃用了,但是它也给出了弃用的原因:

The use of configuration keys that are no longer supported was found in the environment:

Property source ‘bootstrapProperties-default-springmvc.yaml,DEFAULT_GROUP’:

Key: spring.mvc.throw-exception-if-no-handler-found

Reason: DispatcherServlet property is deprecated for removal and should no longer need to be configured

Property source ‘bootstrapProperties-default-redis.yaml,DEFAULT_GROUP’:

Key: spring.redis.lettuce.pool.max-active

Reason: none

Key: spring.redis.lettuce.pool.max-idle

Reason: none

Key: spring.redis.lettuce.pool.max-wait

Reason: none

Key: spring.redis.lettuce.pool.min-idle

Reason: none

Please refer to the release notes or reference guide for potential alternatives.

好吧,这里其实有点小坑,只有上面第一个Key给了弃用原因,说是DispatcherServlet属性已经被移除了。但是后面几个redis相关的Key都是没有给弃用原因的。

既然这样,那我只能自己去官方文档里找了:github.com/spring-proj…

全局搜索下 lettuce.pool,你别说,还真让我找到了:

明明也没有弃用,就是把redis前面加个data罢了,看来 spring-boot-properties-migrator也偶有瞎说的情况啊。

再次提醒:配置文件属性迁移完毕后,记得删除之前添加的 spring-boot-properties-migrator 依赖。

3.2.4. ES版本兼容

如果你的es客户端版本和es服务端版本一致(均为8.x),可以直接跳过这部分内容。

项目里使用了 spring-boot-starter-data-elasticsearch,升级SpringBoot3.x 之后,这个依赖的版本也·提高了,对应ES的版本是8.x,而我服务器使用的ES版本是7.x,所以有一些不兼容的问题,启动时出现异常:

Caused by: java.lang.RuntimeException: node: http://xxxxxx, status: 200, [es/indices.exists] Missing [X-Elastic-Product] header. Please check that you are connecting to an Elasticsearch instance, and that any networking filters are preserving that header.

本来想通过降低 elasticsearch-rest-client 的版本来解决这个问题,但是降低之后又不能兼容 SpringBoot3 了,于是只能另辟蹊径了。

这个说起来比较麻烦,我在 stackoverflow 上找到一篇帖子,里面有对这个问题的描述,可以参考下:stackoverflow.com/questions/7…

它讲到了两个问题:

  1. 客户端向服务端发送了未知的 Content-Type ,因此其请求被拒绝并返回 406(其实是请求头 compatible-with 不受支持)
  2. 客户端需要验证 response 中是否具有 X-Elastic-Product=Elasticsearch 标头,但服务端并没有返回这个。

问题其实蛮清晰的,但是给出的解决方案让我不太满意,还需要自己重新去构建一个 RestClient,自己读取配置文件然后set进去,又得设置账号密码、又得解析Host的,这我可受不了。

于是经过我的一顿研究之后,我发现了 RestClientBuilderCustomizer 这个类:

翻译:回调接口,可以由希望通过RestClientBuilder进一步定制RestClient的bean实现,同时保留默认的自动配置。

只要用这个玩意,就可以在原有的 RestClient 基础上,进行一些定制化的操作,比如说解决上面那两个问题。于是乎,我就写了下面这一段代码:

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
java复制代码/**
* Es 兼容性配置,添加响应头,兼容服务端版本
* <p>
* 如果客户端与服务端版本一致,可移除此配置。
*
* @author 阿杆
* @version 1.0
* @date 2024/1/25 22:29
*/
@Component
public class EsCompatibilityConfig implements RestClientBuilderCustomizer {

@Override
public void customize(RestClientBuilder builder) {
}

@Override
public void customize(HttpAsyncClientBuilder builder) {
// 添加响应头,兼容X-Elastic-Product
HttpResponseInterceptor httpResponseInterceptor =
(response, context) -> response.addHeader("X-Elastic-Product", "Elasticsearch");
builder.addInterceptorLast(httpResponseInterceptor);
// 自定义默认请求头,目的是禁用兼容性请求头 compatible-with
builder.setDefaultHeaders(List.of(new BasicHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())));
}

}

这段代码很简单,在构建 RestClient 的过程中插入了一段代码,修改了请求头和响应头,用来兼容ES版本。只需要把这个类注入到Spring Bean中,就可以被 ElasticsearchRestClientConfigurations 自动加载。

3.2.5. WARN trationDelegate$BeanPostProcessorChecker: is not eligible for getting processed….

如图所示,我的项目在升级到 SpringBoot3.x 后出现了大量的 WARN:

虽然不影响项目运行,但是看得我很不爽,那只能想想办法看怎么解决掉这个warn了。


随机截取的一段异常信息,其他的也都差不多:

2024-01-28T11:58:45.587+08:00 WARN 1228 — [user-server] [ main] trationDelegate$BeanPostProcessorChecker : Bean ‘org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration’ of type [org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected into a currently created BeanPostProcessor [lbRestClientPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies.

注意看上面的异常信息,有任何跟我项目有关的东西吗?没有吧

那有任何跟依赖冲突有关的东西吗?看上去也没有

那这个异常什么时候才开始有的?Spring整体升级之后

好,那既然这样,我们可以大胆的认为这是一个SpringBoot的bug。

image.png
一顿搜索之后,我在github上找到了这个 issue:github.com/spring-clou…

还真是Spring的bug,不过不是SpringBoot,而是SpringCloud的bug。

这位官方大佬也说了,将会在下一个版本(2023.0.1)中修复它,预计2月20日(今天是1月28日),不过他们会先发布新的Commons,用以修复这个bug。

在我看到这个issue的时候,新版的 SpringCloudCommons已经发布了:spring.io/blog/2024/0…

于是我对项目中的依赖进行替换,由于这个依赖是从其他Spring-Cloud的组件中自动继承过来的,所以我们只需要在依赖管理里面指定下版本就可以了。

1
2
3
4
5
6
7
8
9
10
xml复制代码<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-commons</artifactId>
<version>4.1.1</version>
<scope>compile</scope>
</dependency>
</dependencies>
</dependencyManagement>

添加完之后,果然没有再报warn了,之后等 SpringCloud2023.0.1 发布了,再做一下替换就好了。

3.2.6. 更新自动注入文件

SpringBoot2.7时已经提出使用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 代替 spring.factories:

github.com/spring-proj…

我升级到 SpringBoot3.2 时,还是支持 spring.factories 的,但再过几个版本可能就不支持了,这边建议直接迁移下,这块几乎没什么成本的。

3.2.7. 源代码

这部分升级的改动有点多,以为已经搞好了,就提PR到main分支了,结果又蹦出来新的问题。

建议需要升级 SpringBoot3.x 的朋友,在看完这篇文章之后,还是再去把官方文档过一遍,看看有没有其他受影响的地方,这样稳妥一点。

代码已提交到GitHub:

  • github.com/stick-i/scb…
  • github.com/stick-i/scb…
  • github.com/stick-i/scb…

源码建议单个 commit 结合 commit message 来查看,这样会更有条理,而不是整个 pr 一起看。

最后也附上一些我参考到的官方链接:

  • SpringBoot3.0升级指南:github.com/spring-proj…
  • 3.2发布记录:github.com/spring-proj…
  • 3.0发布记录:github.com/spring-proj…
  • 2.7发布记录:github.com/spring-proj…
  • 3.0配置更新记录:github.com/spring-proj…
  1. 后记

本来以为我这小项目简单升级下一两天就弄好了,结果前前后后搞了两周,尤其升级 SpringBoot 的时候,出了一顿问题,踩了不少坑。

看在作者这么认真的份上,建议关注趁早关注下,等我以后火了,在坐的各位就都是老粉了!

本文转载自: 掘金

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

数据脱敏实现:"想在哪脱就在哪脱,想脱谁就脱谁! ! !"

发表于 2024-01-29

1.背景

在当下互联网高速发展的时代下,涉及到用户的隐私数据安全越发重要,一旦泄露将造成不可估量的后果。所以现在的业务系统开发中都会对用户隐私数据加密之后存储落库,同时还要求后端返回数据给前台之前进行数据脱敏。所谓脱敏处理其实就是将数据进行混淆隐藏,如将用户的手机号脱敏展示为`178****5939,采用 * 进行隐藏,以免泄露个人隐私信息。其实我之前就总结过相关联的功能实现:《实现数据加密存储、模糊匹配和脱敏》 但是强调一下今天这里并不是重复再讲一遍,而是在之前总结的基础上进行延伸拓展,重点在于标题里的:动态灵活可配置。那什么是动态灵活可配置呢?且听我娓娓道来。

那是一个惬意的下午时光,我听着歌,敲着一手代码……,突然间产品就来到我桌前,打断了我短暂的文思泉涌高光时刻,企图给我安排一个折磨的活,打开了奶茶app,暗示没有一杯奶茶解决不了的需求。是的最后这活我接了,也就是我们今天所要讲的数据脱敏由之前的前端脱敏改为后端,当然脱敏功能本身并不复杂难实现,那为啥说它是折磨人的活呢?普通的脱敏功能大概是这样的,也就是脱敏上面所说的用户固定隐私数据:姓名、手机号、身份证号、地址、身份证...等,但是我们的系统需求要求不只是前面的字段,还需要支持其他字段,一句话生动形象地总结概括就是客户想脱啥就脱啥,想在哪脱就在哪脱,包括我们系统支持的自定义字段,都可以通过配置进行脱敏,如下图所示:

由图可知,这个脱敏需求功能涉及到以下几点:

① 哪些字段可以脱敏是可配置的,脱敏规则也是可配置的,比如说什么开头、中间、结尾,区间啥的。

② 脱敏设计到组织架构过滤,也就是说需要实现某个部门下的用户看到的数据是脱敏的,某个部门下的用户看到的数据是不脱敏的。这感觉就像后端接口功能菜单权限检验,判断当前用户是否有调用某个功能菜单接口的权限。

③ 涉及到角色的判断,某些角色需要脱敏(小喽啰不给看,防止把客户数据卖了),而某些角色不需要(管理员随便看,随便卖)。

这里我们暂且不讨论脱敏功能这么设计是否合理,反正产品是这样要求实现的,按照上面的列出来的点感觉也还好,也不至于上升到折磨的程度,折磨的是系统的历史原因,要脱敏的字段信息遍布都整个业务系统表单,每个表单的接口数据有冗余的有共用的,这就意味着每个页面表单接口都需要去梳理一遍。同时脱敏字段还涉及到编辑更新功能,而且之前的更新接口都是一个表单整体提交,这就导致一个脱敏字段没有修改,但是前端把脱敏数据传回后端来,又是一个一个去适配啊~,难顶。闹骚发完了,言归正传我们接下来看看是如何优雅地实现这个难顶的功能需求。

2.实现思路

所谓”优雅“,就是多写一行代码都算我输…..,所以在接口controller层返回之前一个一个地进行脱敏操作是不可取的,重复的工作量太多。思来想去,肯定是需要通过切面思想去解决,也就是通过对接口返回的VO类需要脱敏的字段使用注解打上标识,然后切面统一逻辑处理,这时候想到之前总结的接口响应结果结构统一封装返回:@ControllerAdvice, 不清楚的可跳转:《Spring Boot如何优雅实现结果统一封装和异常统一处理》自行查看,但我们发现使用@ControllerAdvice意味着每次调接口都需要我们自己去反射类获取注解判断字段是否需要脱敏,当返回对象比较复杂,需要递归去反射,性能一下子就会降低。反射这个东西确实是框架的灵魂,但是在业务接口中用多了也的确对性能有一定影响,所以再三斟酌于是换种了中思路,我们想到了平时使用的@JsonFormat,跟我们现在的需求功能场景很类似,通过自定义注解跟字段解析器,对字段进行自定义解析。

1
2
ini复制代码  @JsonFormat(pattern = "yyyy-MM-dd")
 private Date recycleDate;

这样就可以把字段recycleDate转换为日期格式,不需要我们单独代码处理,这就是优雅!!!按照这个思路,我们首先需要定义一个注解进行脱敏字段标注:

1
2
3
4
5
6
7
8
9
10
11
less复制代码@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = MaskSerializer.class)
public @interface MaskField {

   /**
    * 脱敏类型
    * @return
    */
   MaskEnum value();
}

这个注解很简单,只有一个默认属性value()指定字段的脱敏枚举类型MaskEnum

@Retention(RetentionPolicy.RUNTIME):运行时生效。

@Target(ElementType.FIELD):可用在字段上。

@JacksonAnnotationsInside:此注解可以点进去看一下是一个元注解,主要是用户打包其他注解一起使用。

@JsonSerialize(using = MaskSerializer.class):该注解的作用就是可自定义序列化,可以用在注解上,方法上,字段上,类上,运行时生效等等,根据提供的序列化类里面的重写方法实现自定义序列化。关于MaskSerializer就是我们自定义序列化实现脱敏的核心实现所在,后面会详细分析。从注解定义可以,我们给脱敏字段使用注解打标识时,需要指定该字段脱敏类型是什么,所以接下来我们还需要定义脱敏类型枚举类,我们系统大概分为两类:系统字段如(姓名,手机号,身份证号…..等等),自定义字段(婚姻状况,月供金额这些额外信息,会存储在数据库一个JSON字段里)。

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
arduino复制代码public enum MaskEnum {
   /**
    * 中文名
    */
   NAME,
   /**
    * 身份证号
    */
   ID_CARD,
   /**
    * 手机号
    */
   MOBILE,
   /**
    * 地址
    */
   ADDRESS,
   /**
    * 电子邮件
    */
   EMAIL,
   /**
    * 银行卡
    */
   BANK_CARD,

   /**
    * 自定义字段
    */
   CUSTOM_FIELD
}

注解和脱敏类型我们都定义好,看样子万事俱备只欠东风啦,我们只需要接下来自定义实现序列化解析器完成脱敏即可,但是你可能忘了文章开头一直强调的:动态灵活,我们的脱敏配置信息不是固定的,而是动态配置保存的,这就意味着一个接口的某个字段上一次调用还需要脱敏,紧接着脱敏配置被改了,再调接口该字段就不再需要脱敏,要求我们做到动态的同时还要保证实时性。这如同需要判断功能菜单权限那样通过切面实现判断是否需要脱敏,将脱敏配置信息上下文贯穿整次请求,这里我们在登录认证的过滤器中实现,因为脱敏配置涉及到角色、组织架构,自然是要登录之后才能进行是否需要脱敏判断。

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
less复制代码@Component
@Slf4j
public class AuthFilter implements Filter {

 @Autowired private StringRedisTemplate stringRedisTemplate;

 
 
 @Override
 public void doFilter( ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
     
      //登录认证逻辑
      // ........
   
     // 登录验证通过之后,就可以登录用户的公司id,设置脱敏配置上下文
     String rule = stringRedisTemplate.opsForValue().get(KeyCache.ORG_DESENSITIZATION_SETTING + company.getId());
     if (StringUtils.isNotBlank(rule)) {
       MaskSetting maskSetting = JSON.parseObject(rule, MaskSetting.class);
       Boolean isMask = false;
       Boolean enable = maskSetting.getEnable() == null ? false : maskSetting.getEnable();
       // 1.先判断脱敏总开关
       if (enable) {
         // 脱敏的组织架构
         List<Long> depTeamIds = maskSetting.getDepTeamIds();
         Boolean userLevelSwitch = maskSetting.getUserLevelSwitch();
         Boolean adminLevelSwitch = maskSetting.getAdminLevelSwitch();
         // 2.脱敏权限角色判断, 如果登录人是坐席或者外访员
         if (Objects.nonNull(roleId) && (UserUtils.isChairMan() || UserUtils.isVisitor())) {
           // 判断催员属于部门是否脱敏
           if (userLevelSwitch && !CollectionUtils.isEmpty(depTeamIds) &&
                  (depTeamIds.contains(userSession.getDepId()) || depTeamIds.contains(userSession.getTeamId()))) {
             isMask = true;
          }
        } else if (Objects.nonNull(roleId) && adminLevelSwitch) {
           // 其余一律按管理员处理
           isMask = true;
        }
      }
       MaskContextHolder.setMask(isMask);
       if (isMask) {
         MaskContextHolder.setMaskSetting(maskSetting);
      }
    }
     filterChain.doFilter(servletRequest, servletResponse);
  } finally {
     MaskContextHolder.clear();
  }
}

}

可以看到我们在过滤器filter中等登录验证校验通过之后,再进行脱敏配置的上下文设置,这里我们是直接在缓存redis中获取登录用户对应公司的脱敏信息配置,脱敏配置在我们SaaS系统是以公司维度配置的,也就是配置了脱敏信息那么对这个公司整体用户都有效果,而且我们在配置脱敏信息保存落库的同时也会同步保存redis,我们每次调接口都需要设置脱敏信息上下文,如果这些信息都从数据库获取(由于之前的设计脱敏配置信息要查3张表…),性能自然是吃不消的,所以我们需要从缓存redis中取脱敏配置,当然上面的实现不太完善,因为直接从redis中取有可能缓存缺失这时候就拿不到脱敏配置了,所以我们需要有一个兜底实现,在缓存中获取不到再去数据库中查询,取到之后设置上下文的同时更新缓存redis,这样下次我们再从redis中就能获取到了,这个兜底实现很简单,可自行实现哦,接下来看看定义的缓存脱敏配置信息:

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
csharp复制代码public class MaskContextHolder {

   /**
    * 当前请求线程上下文:脱敏配置
    */
   private static final ThreadLocal<MaskSetting> maskSettingHolder = new ThreadLocal<>();

   /**
    * 当前请求线程上下文:是否脱敏标识
    */
   private static final ThreadLocal<Boolean> mask = new ThreadLocal<>();


   /**
    * 设置脱敏妹子
    * @param ms
    */
   public static void setMaskSetting(MaskSetting ms) {
       maskSettingHolder.set(ms);
  }

   /**
    * 获取脱敏配置
    * @return
    */
   public static MaskSetting getMaskSetting() {
       return maskSettingHolder.get();
  }


   public static void setMask(Boolean isMask) {
       mask.set(isMask);
  }

   public static Boolean getMask() {
       return mask.get() == null ? Boolean.FALSE : mask.get();
  }


   /**
    * 清除
    */
   public static void clear() {
       maskSettingHolder.remove();
       mask.remove();
  }
}
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
swift复制代码@Data
public class MaskSetting implements Serializable {

   /**
    * 脱敏总开关
    */
   private Boolean enable;

   /**
    * 坐席,外访员界面信息脱敏
    */
   private Boolean userLevelSwitch;

   /**
    * 管理员界面信息脱敏
    */
   private Boolean adminLevelSwitch;

   /**
    * 绑定的需要脱敏的小组或部门id
    */
   private List<Long> depTeamIds;

   /**
    * 脱敏规则
    */
   private List<DesensitizationRuleCreate> rules;
   
}

脱敏规则:DesensitizationRuleCreate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码@ApiModel(description = "脱敏规则")
@Data
public class DesensitizationRuleCreate {

   @ApiModelProperty(value = "字段英文名称")
   @NotEmpty
   private String name;
   @ApiModelProperty(value = "0:隐藏,1:显示")
   @NotNull
   private Integer type;
   @ApiModelProperty(value = "规则:开头:0 中间:1 末尾: -1 全部: 2 区间:3")
   @NotNull
   private Integer scope;
   @ApiModelProperty(value = "位数")
   private Integer count;
   @ApiModelProperty(value = "开始位数")
   private Integer start;
   @ApiModelProperty(value = "结束位数")
   private Integer end;

}

完成脱敏信息上下文设置之后,接下来就是真正自定义实现序列化解析器完成脱敏的时刻啦,话不多说,先看看实现类:

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
ini复制代码public class MaskSerializer<T> extends JsonSerializer<T> implements ContextualSerializer{

   /**
    * 脱敏类型
    */
   private MaskEnum type;

   private static List<String> ADDRESS = Lists.newArrayList("address", "house_address", "company_address",
           "native_address", "bill_path", "other_address");


   @Override
   public void serialize(T t, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
       if (Objects.isNull(t)) {
           jsonGenerator.writeObject(t);
           return;
      }
       Boolean isMask = MaskContextHolder.getMask();
       if (!isMask) {
           jsonGenerator.writeObject(t);
           return;
      }
       MaskSetting maskSetting = MaskContextHolder.getMaskSetting();
       List<DesensitizationRuleCreate> rules = maskSetting.getRules();
       if (CollectionUtils.isEmpty(rules)) {
           jsonGenerator.writeObject(t);
           return;
      }
       switch (this.type) {
           case NAME: {
               String s = (String)t;
               DesensitizationRuleCreate rule = rules.stream()
                      .filter(r -> Objects.equals(r.getName(), "name"))
                      .findFirst()
                      .orElse(null);
               jsonGenerator.writeString(MaskUtils.commonMask(s, rule));
               break;
          }
           case ID_CARD: {
               String s = (String)t;
               DesensitizationRuleCreate rule = rules.stream()
                      .filter(r -> Objects.equals(r.getName(), "idCard"))
                      .findFirst()
                      .orElse(null);
               jsonGenerator.writeString(MaskUtils.commonMask(s, rule));
               break;
          }
           case MOBILE: {
               String s = (String)t;
               DesensitizationRuleCreate rule = rules.stream()
                      .filter(r -> Objects.equals(r.getName(), "mobile"))
                      .findFirst()
                      .orElse(null);
               jsonGenerator.writeString(MaskUtils.commonMask(s, rule));
               break;
          }
           case ADDRESS: {
               String s = (String)t;
               DesensitizationRuleCreate rule = rules.stream()
                      .filter(r -> Objects.equals(r.getName(), "address"))
                      .findFirst()
                      .orElse(null);
               jsonGenerator.writeString(MaskUtils.commonMask(s, rule));
               break;
          }
           
           // 自定义字段,是一个<String, String> map
           case CUSTOM_FIELD: {
               // 自定义字段逻辑
               Map<String, String> map = (Map)t;
               map.forEach((k,v) -> {
                   String maskKey = k;
                   // 特殊自定义字段处理
                   if (k.contains("|")) {
                       maskKey = k.substring(0, k.indexOf("|"));
                  }
                   // 一个地址脱敏需要脱敏多个 如:家庭地址,公司地址等
                   if (ADDRESS.contains(k)) {
                       maskKey = "address";
                  }
                   DesensitizationRuleCreate rule = null;
                   // 匹配规则
                   for (DesensitizationRuleCreate r : rules) {
                       if (Objects.equals(r.getName(), maskKey)) {
                           rule = r;
                           break;
                      }
                  }
                   // 脱敏工具类脱敏
                   String maskText = MaskUtils.commonMask(v, rule);
                   map.put(k, maskText);
              });
               jsonGenerator.writeObject(map);
               break;
          }
      }
  }

   /**
   * 统一判断返回的vo类的哪些字段需要脱敏
   * 这里也是通过反射判断字段是否打上自己标识,但是这个方法只会执行一次,
   * 也就是接口第一次调用会执行,后面就不需要执行,因为这些Spring考虑到这些打注解标识解析就不会再变,没别要每次都来解析浪费性能
   */
   @Override
   public JsonSerializer <?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {
       // 为空直接跳过
       if (beanProperty == null) {
           return serializerProvider.findNullValueSerializer(beanProperty);
      }
       // 非String类直接跳过
       if (Objects.equals(beanProperty.getType().getRawClass(), String.class) || Objects.equals(beanProperty.getType().getRawClass(), Map.class)) {
           MaskField maskField = beanProperty.getAnnotation(MaskField.class);
           if (maskField == null) {
               maskField = beanProperty.getContextAnnotation(MaskField.class);
          }
           if (maskField != null) {
               // 如果能得到注解,就将注解的 value 传入 MaskSerialize
               return new MaskSerializer(maskField.value());
          }
      }
       return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty);
  }


   public MaskSerializer() {}

   public MaskSerializer(final MaskEnum type) {
       this.type = type;
  }
}

JsonSerializer可以实现的附加接口以获取回调,该回调可用于创建序列化程序的上下文实例以用于处理支持类型的属性。这对于可以通过注释配置的序列化程序很有用,或者应该根据正在序列化的属性类型而具有不同的行为

#createContextual()可以获得字段的类型以及注解,该方法只会在第一次序列化字段时调用(因为字段的上下文信息在运行期不会改变),所以不用担心影响性能。

最后来看看脱敏工具类实现,流行的Hutool工具包实现了脱敏,先引入依赖:

1
2
3
4
5
xml复制代码<dependency>
   <groupId>cn.hutool</groupId>
   <artifactId>hutool-all</artifactId>
   <version>5.8.16</version>
</dependency>

现阶段最新版本的Hutool支持的脱敏数据类型如下,基本覆盖了常见的敏感信息:用户id、中文姓名、身份证号、座机号、手机号、地址、电子邮件、密码 、中国大陆车牌、银行卡

注意 :Hutool 脱敏是通过*来代替敏感信息的,具体实现是在StrUtil.hide方法中,如果我们想要自定义隐藏符号,则可以把Hutool的源码拷出来,重新实现即可。

但是根据上文介绍我们脱敏规则是自定义的,所以我们需要得自己实现一个脱敏工具类MaskUtils:

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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
ini复制代码@Slf4j
public class MaskUtils {


   /**
    * [中文姓名] 只显示第一个汉字,其他隐藏为2个星号<例子:李**>
    */
   public static String chineseName(final String fullName) {
       if (StringUtils.isBlank(fullName)) {
           return "";
      }
       final String name = StringUtils.left(fullName, 1);
       return StringUtils.rightPad(name, StringUtils.length(fullName), "*");
  }

   /**
    * [中文姓名] 只显示第一个汉字,其他隐藏为2个星号<例子:李**>
    */
   public static String chineseName(final String familyName, final String givenName) {
       if (StringUtils.isBlank(familyName) || StringUtils.isBlank(givenName)) {
           return "";
      }
       return chineseName(familyName + givenName);
  }

   /**
    * [身份证号] 显示最后四位,其他隐藏。共计18位或者15位。<例子:420**********5762>
    */
   public static String idCardNum(final String id) {
       if (StringUtils.isBlank(id)) {
           return "";
      }

       return StringUtils.left(id, 3).concat(StringUtils
              .removeStart(StringUtils.leftPad(StringUtils.right(id, 4), StringUtils.length(id), "*"),
                       "***"));
  }

   /**
    * [固定电话] 后四位,其他隐藏<例子:****1234>
    */
   public static String fixedPhone(final String num) {
       if (StringUtils.isBlank(num)) {
           return "";
      }
       return StringUtils.leftPad(StringUtils.right(num, 4), StringUtils.length(num), "*");
  }

   /**
    * [手机号码] 前三位,后四位,其他隐藏<例子:138******1234>
    */
   public static String mobilePhone(final String num) {
       if (StringUtils.isBlank(num)) {
           return "";
      }
       return StringUtils.left(num, 3).concat(StringUtils
              .removeStart(StringUtils.leftPad(StringUtils.right(num, 4), StringUtils.length(num), "*"),
                       "***"));

  }

   /**
    * [地址] 只显示到地区,不显示详细地址;我们要对个人信息增强保护<例子:北京市海淀区****>
    *
    * @param sensitiveSize 敏感信息长度
    */
   public static String address(final String address, final int sensitiveSize) {
       if (StringUtils.isBlank(address)) {
           return "";
      }
       final int length = StringUtils.length(address);
       return StringUtils.rightPad(StringUtils.left(address, length - sensitiveSize), length, "*");
  }

   /**
    * [电子邮箱] 邮箱前缀仅显示第一个字母,前缀其他隐藏,用星号代替,@及后面的地址显示<例子:g**@163.com>
    */
   public static String email(final String email) {
       if (StringUtils.isBlank(email)) {
           return "";
      }
       final int index = StringUtils.indexOf(email, "@");
       if (index <= 1) {
           return email;
      } else {
           return StringUtils.rightPad(StringUtils.left(email, 1), index, "*")
                  .concat(StringUtils.mid(email, index, StringUtils.length(email)));
      }
  }

   /**
    * [银行卡号] 前六位,后四位,其他用星号隐藏每位1个星号<例子:6222600**********1234>
    */
   public static String bankCard(final String cardNum) {
       if (StringUtils.isBlank(cardNum)) {
           return "";
      }
       return StringUtils.left(cardNum, 6).concat(StringUtils.removeStart(
               StringUtils.leftPad(StringUtils.right(cardNum, 4), StringUtils.length(cardNum), "*"),
               "******"));
  }

   /**
    * [api秘钥] 前3位,后3位,其他用星号隐藏每位1个星号<例子:Aj3**********8Kl>
    */
   public static String apiSecret(final String cardNum) {
       if (StringUtils.isBlank(cardNum)) {
           return "";
      }
       return StringUtils.left(cardNum, 3).concat(StringUtils.removeStart(
               StringUtils.leftPad(StringUtils.right(cardNum, 3), StringUtils.length(cardNum), "*"),
               "******"));
  }

   public static String commonMask(String text, DesensitizationRuleCreate rule) {
       if (StringUtils.isBlank(text) || Objects.isNull(rule)) {
           return text;
      }
       int length = text.length();
       // 0:隐藏,1:显示
       Integer type = rule.getType();
       // 开头:0 中间:1 末尾: -1 全部: 2 区间:3
       Integer scope = rule.getScope();
       Integer count = rule.getCount();
       Integer start = rule.getStart();
       Integer end = rule.getEnd();

       try {
           StringBuilder ms = new StringBuilder();
           // 开头count位
           if (scope == 0) {
               for (int i = 0; i < length; i++) {
                   if (i < count) {
                       if (type == 0) ms.append("*");
                       if (type == 1) ms.append(text.charAt(i));
                  } else {
                       if (type == 0) ms.append(text.charAt(i));
                       if (type == 1) ms.append("*");
                  }
              }
          }
           if (scope == 1) {
               // 中间count位
               int mid = length/2;
               int left = mid - count/2;
               left = left < 0 ? 0 : left;
               int right = mid + count/2 - (count%2==0? 1: 0);
               for (int i = 0; i < length; i++) {
                   if (i >= left && i <= right) {
                       if (type == 0) ms.append("*");
                       if (type == 1) ms.append(text.charAt(i));
                  } else {
                       if (type == 0) ms.append(text.charAt(i));
                       if (type == 1) ms.append("*");
                  }
              }
          }
           if (scope == -1) {
               // 末尾屏蔽count位
               int n = length - count;
               n = n < 0 ? 0 : n;
               for (int i = 0 ; i < length; i++) {
                   if (i >= n) {
                       if (type == 0) ms.append("*");
                       if (type == 1) ms.append(text.charAt(i));
                  } else {
                       if (type == 0) ms.append(text.charAt(i));
                       if (type == 1) ms.append("*");
                  }
              }
          }
           if (scope == 2) {
               // 全部
               for (int i = 0; i < length; i++) {
                   if (type == 0) ms.append("*");
                   if (type == 1) ms.append(text.charAt(i));
              }
          }
           if (scope == 3) {
               // 区间
               for (int i = 0; i < length; i++) {
                   if (i >= start -1  && i <= end - 1) {
                       if (type == 0) ms.append("*");
                       if (type == 1) ms.append(text.charAt(i));
                  } else {
                       if (type == 0) ms.append(text.charAt(i));
                       if (type == 1) ms.append("*");
                  }
              }
          }
           return ms.toString();
      } catch (Exception e) {
          log.error("脱敏转换失败:", e);
      }
       return text;
  }

   public static void main(String[] args) {
       String text = "17816875939";
       DesensitizationRuleCreate rule = new DesensitizationRuleCreate();
       rule.setType(0);
       rule.setScope(1);
       rule.setCount(8);
       String s = commonMask(text, rule);
       System.out.println(s);

  }


}

使用示例:对某个接口的VO类字段进行脱敏打上注解:

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
scala复制代码@Data
public class CaseInfoVO extends Base {
 private static final long serialVersionUID = -3251366134027639518L;



 @ApiModelProperty(value = "姓名")
 @MaskField(MaskEnum.NAME)
 private String name;

 @ApiModelProperty(value = "身份证号码")
 @MaskField(MaskEnum.ID_CARD)
 private String idCard;

 @ApiModelProperty(value = "电话号码")
 @MaskField(MaskEnum.MOBILE)
 private String mobile;
 
 @ApiModelProperty(value = "自定义字段")
 @MaskField(MaskEnum.CUSTOM_FIELD)
 private Map<String, String> fields;
 
......
 
}

掉接口的响应结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
json复制代码{
   "status": 200,
   "message": "success",
   "data": {
       "id": 11592359,
       "name": "郑**",
       "idCard": "33010219601031****",
       "mobile": "*******0030",
       "fields": {
           "company_phone": "10922220040",
           "penalty_date": "2023-05-30",
           "amount_paid": "51.00",
           "capital": "129.00",
           "occupation": "职业030",
           "late_fee": "44.00",
           "nation": "壮*",
           "age": "*3",
           "contacts": "*******0040"
      },
       
      ......
  },
   "success": true
}

3.总结

以上全部就是本期关于数据脱敏知识点的总结介绍啦。 首先介绍了数据脱敏需求的背景、概念和重要性,紧接着我们逐步探讨实现方案,权衡利弊了相关实现选择,最终选择Spring Boot的自带的jackson自定义序列化实现,它的实现原来其实就是在json进行序列化渲染给前端时,进行脱敏,这样可以有效降低性能损耗,并且也不会侵入系统业务层逻辑这样可以保证我们的业务逻辑不会因为数据脱敏出现逻辑错误。与此同时也强调了动态灵活可配置的脱敏信息配置,我们通过拦截器实现脱敏信息上下文设置,在上面思路我们进行代码实现剖析和实操,借助于Hutool工具类提供的脱敏功能,完美实现了字段脱敏。

本文转载自: 掘金

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

1…596061…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%