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

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


  • 首页

  • 归档

  • 搜索

手把手带你入门 Threejs Shader 系列(七)

发表于 2024-02-06

本系列教程的代码将开源到该仓库,前几篇文章的代码也会陆续补上,欢迎大家 Star:github.com/DesertsX/th…

本文的代码也同时放到了 Codepen,欢迎学习: codepen.io/GuLiu/pen/y…

此外,之前和之后的所有文章的例子都将更新到这里,方便大家和古柳一起见证本系列内容的不断壮大与完善过程:www.canva.com/design/DAF3…

放一条最近新加上的群友的赞扬!好听爱听,希望大家多多分享真实阅读感受,古柳也能更有动力更新。

正文

上一篇文章古柳带大家开启第二阶段顶点着色器的学习,通过应用 sin、random、noise 等函数能使顶点产生不同的偏移效果,从而改变几何体形状。

有两点之前没来得及提,因此在本文一开始先讲下。

动起来

其一,上回我们将 position 乘以不同大小的数值,使传给 noise 的坐标“相邻”程度不同,会发现几何体从较接近球体变化到较接近 random 的效果。

1
2
3
4
5
6
C#复制代码void main() {
vec3 newPos = position;
// newPos += normal * cnoise(position * 0.3);
newPos += normal * cnoise(position * 5.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

当我们固定下数值(比如这里用 5.0)后,可通过加上 uTime 使当前效果动起来;还可以使用接收 vec4 格式参数的 noise 函数,将 uTime 作为 position 之后的第四个分量同样可行,即 cnoise(vec4(position * 5.0, uTime)),这里古柳就不另外拷贝对应的 cnoise 函数进行演示了,大家可自行尝试。

1
2
3
4
5
6
C#复制代码void main() {
vec3 newPos = position;
newPos += normal * cnoise(position * 5.0 + uTime);
// newPos += normal * cnoise(vec4(position * 5.0, uTime)); // 需拷贝对应接收 vec4 格式参数的 cnoise 函数
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

GUI 控制参数

其二,上回古柳偷懒没用 GUI 而是借助 sin 函数来改变参数以使几何体形状变化,这里讲下如何通过 GUI(图形用户界面 Graphical user interface) 来改变和控制参数再借助 uniform 传到 shader 里进行使用。

我们常会碰到需要调整某些参数以实现最佳效果的情况,如果每次手动改数值再看效果会非常麻烦,此时就可以借助 GUI 来提高效率。以前用的比较多的是 dat.GUI,现在可以用 lil-gui、tweakpane 等,用法大同小异。

  • 链接:lil-gui.georgealways.com/#Guide#Inst…

如果是 vite 创建的项目,可以通过 npm install lil-gui 进行引入;不过在本系列第一篇里用了如下的方式引入,那这里也延续这一方式。对 vite、npm、GUI 不熟悉的朋友可以看看这两个链接,尤其 GUI 部分也很简单大家看下第二个视频就能快速入门,这里就不过多讲解了。

  • 链接:www.bilibili.com/video/BV1aS…
  • 链接:www.bilibili.com/video/BV1aS…

首先需要在 uniforms 里添加所需的变量 uFrequency 和 uStrength 并设置初始值,接着通过 gui 对这些变量的 value 值进行控制,并设置不同的范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
html复制代码<script type="module">
import * as THREE from 'https://unpkg.com/three@0.152.2/build/three.module.js';
import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.19/+esm';

// console.log(GUI);
const gui = new GUI();

const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uStrength: { value: 0.5 },
uFrequency: { value: 5.0 },
},
vertexShader,
fragmentShader,
});

gui
.add(material.uniforms.uStrength, "value", 0, 1, 0.01)
.name("uStrength");

gui
.add(material.uniforms.uFrequency, "value", 0, 20, 0.01)
.name("uFrequency");
</script>

出现控制面板即表示 gui 部分成功,不过为了在拖动滑块时几何体也随之变化,还需要在 shader 里声明和使用 uniforms 变量,这和之前传入 uTime 时的操作一样。

这里可以通过 uFrequency 来乘以 position 控制坐标相邻程度,通过 uStrength 控制 noise 函数产生的0-1数值的幅度大小。

1
2
3
4
5
6
7
8
9
10
11
C#复制代码uniform float uStrength;
uniform float uFrequency;

void main() {
vec3 newPos = position;
// newPos += normal * cnoise(position * (sin(uTime) + 1.0) * 4.0);
// newPos += normal * cnoise(position * 5.0 + uTime);
// newPos += normal * cnoise(position * uFrequency + uTime);
newPos += uStrength * normal * cnoise(position * uFrequency + uTime);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

然后分别拖动控制面板上的 uFrequency 和 uStrength 滑块几何体形状就能相应变化了。(注意:受限于古柳本系列所用的导出配图、GIF 动图的工具限制,拖动部分就没一起录制了,大家简单脑补或跟着代码自己尝试就可以了)

提前剧透新系列将推出

截止目前,我们学习了如何在球体每个顶点上计算 noise 数值,并根据该数值将顶点沿各自法线偏移,当然顶点移动后每个位置的法线其实也要进行更新,但法线更新的实现对新手来说可能还是略显复杂,而且在古柳的设想里也没必要太早涉及,毕竟根据二八原则还有蛮多更普遍、更常用的内容可以先讲。

不过最近古柳计划另外再开辟一个小小付费的“进阶”系列(可能是公众号付费合集的形式,还没来得及研究,大家可以理解成掘金小册也好(目前还没有出小册的打算)、一本书也好,总之可以先关注起本公众号,也可以加古柳微信 xiaoaizhj 进交流群及跟踪最新动态)以跳脱出本入门系列按部就班输出的限制。

一方面在那个系列古柳可以更随心所欲去分享,可以先讲法线更新、讲入坑之作 Pepyaka 的完整实现、分享其他 Yuri 油管视频的笔记、讲解前阵子在群里和朋友圈分享过的 Daily CSS Design 上简单却酷炫的效果等等…一方面对于本身就有 shader 基础、学有余力的朋友也能提前看到更精彩的内容。当然这里只是提前简单剧透下,后续推出时会做更多说明,敬请期待。

  • 链接:dailycssdesign.com/146/

应用颜色

言归正传,且不论顶点的法线是否需要更新,把法线直接作为颜色其实只是教程讲解所需而取巧为之,实际中应该以何种方式更好地应用颜色呢?方法有很多,这里既然讲到 noise 噪声函数,不妨看看如何使用 noise 数值来实现出类似下图中的颜色效果,直接讲也很简单,但古柳觉得可以先放一放,不如从 noise 数值从头讲起。

将 noise 数值转换成黑白颜色

我们将 cnoise 函数计算的数值单独拿出来,并通过 varying 将 vNoise 传到片元着色器里作为颜色显示,这里暂时不偏移顶点所以乘了0.0,球体形态下更方便看效果。uFrequency 数值如果你不想用 gui 也可以用具体数值如5.0代替。

1
2
3
4
5
6
7
8
9
C#复制代码varying float vNoise;

void main() {
vec3 newPos = position;
float noise = cnoise(position * uFrequency + uTime);
newPos += normal * noise * 0.0;
vNoise = noise;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

在片元着色器里将数值构造成 vec3 灰度值颜色后就可以在视觉上查看效果和数值分布情况。之前古柳就提过 shader 里无法打印数据和 debug,类似这样将数值转换成颜色进行查看就是不错的检查方式。

1
2
3
4
5
6
C#复制代码varying float vNoise;

void main() {
vec3 color = vec3(vNoise);
gl_FragColor = vec4(color, 1.0);
}

此时球体上出现这种黑白斑驳的效果,古柳猜想所有知道 noise 噪声函数的人应该对该效果都不陌生吧,不熟悉的话谷歌搜索“noise 噪声函数”也能看到很多类似的图片。以前一直不知道如何描述这种效果,当下突然想到一个词“鬼影幢幢”,觉得莫名贴切。(这里还对球体 mesh 进行了旋转)

如果将 noise 数值对0.5取 step,使得小于0.5的值变成0.0,大于0.5的值变成1.0,此时颜色只剩黑白两色不再有灰色,不过黑色区域明显占比较大,如果 cnoise 产生的值为0.0-1.0那么黑白区域面积应该接近,可见上篇文章时古柳漏讲了一点:有些 noise 函数会返回0-1,有些会返回-1-1,具体哪些会返回什么古柳也一直没特别搞清楚,印象里返回0-1的比较多,所以之前想当然的没去验证就以为这里用的 cnoise 返回的也是0-1,但问题不大,我们还是可以像这样将数值转换成颜色快速进行确认。

1
2
3
4
5
6
7
8
9
10
C#复制代码varying float vNoise;

void main() {
vec3 newPos = position;
float noise = cnoise(position * uFrequency + uTime);
noise = step(0.5, noise);
newPos += normal * noise * 0.0;
vNoise = noise;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

此时对0.0取 step 黑白颜色占比就接近了。多像奶牛上面的图纹。

1
2
C#复制代码// noise = step(0.5, noise);
noise = step(0.0, noise);

还不相信的话,用-1.0-0.0之间的负数试试,会发现仍有黑色存在,可见 cnoise 函数的返回值确实不是0.0-1.0,而且数值越接近-1.0黑色区域越接近消失。反之亦然,用0.0-1.0的数值去测试白色区域也会越来越少。

1
2
C#复制代码// noise = step(0.0, noise);
noise = step(-0.5, noise);

用 noise 数值 mix 两种颜色

当我们根据 noise 函数得到数值,并对0.0取 step 将数值变成0.0或1.0后,有了这样的数值大家是否觉得似曾相识,还记得第一阶段片元着色器出现过的好多0.0-1.0数值以及用0.0-1.0数值 mix 两种颜色的操作吗?因此可以通过 mix 使 vNoise 为0.0时返回 color1、1.0时返回 color2,从而实现出彩色 noise 小球效果。

1
2
3
4
5
6
7
8
9
10
C#复制代码// noise = step(0.0, noise);

varying float vNoise;

void main() {
vec3 color1 = vec3(1.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
vec3 color = mix(color1, color2, vNoise);
gl_FragColor = vec4(color, 1.0);
}

Pantone 2024年度代表色

我们不妨继续用更好看的颜色使 noise 小球更漂亮,这里古柳想到了 Pantone 2024年度代表色「柔和桃」。

搜索后在该页面找到了一组调色板。鼠标移动上去会显示对应颜色,直接选两个颜色看看效果,因为 shader 里要将 rgb 颜色转化成0.0-1.0范围,所以每个分量都要除以255.0,其中.0可以将0省略简写成.。

  • 链接:www.pantone.com/hk/tc/color…

1
2
3
4
5
6
C#复制代码void main() {
vec3 color1 = vec3(96./255., 200./255., 179./255.);
vec3 color2 = vec3(27./255., 80./255., 145./255.);
vec3 color = mix(color1, color2, vNoise);
gl_FragColor = vec4(color, 1.0);
}

上面颜色的写法还是太繁琐,干脆写个简单的函数,接收 r/g/b 后凑成 vec3 再除以 255. 然后返回。这样替换颜色时更方便。

1
2
3
4
5
6
7
8
9
10
C#复制代码vec3 rgb(float r, float g, float b) {
return vec3(r, g, b) / 255.;
}

void main() {
vec3 color1 = rgb(96., 200., 179.);
vec3 color2 = rgb(27., 80., 145.);
vec3 color = mix(color1, color2, vNoise);
gl_FragColor = vec4(color, 1.0);
}

为了方便大家测试,古柳把这组配色都提供给大家,省得每个人还得去原网站里拷贝再替换,麻烦。

1
2
3
4
5
6
7
8
C#复制代码rgb(206., 51., 117.)
rgb(232., 129., 166.)
rgb(255., 190., 152.)
rgb(255., 167., 79.)
rgb(96., 200., 179.)
rgb(39., 157., 159.)
rgb(27., 80., 145.)
rgb(110., 161., 212.)

下面是一些其他的颜色搭配。总之呢,noise 小球真的是挺简单但蛮好看的效果。

我们还能应用更多颜色,下图左上角的就是用了上述8种颜色后的效果;其他三张黑色背景的是古柳以前实现的一些彩色 noise 小球效果(用了以前直播讲过的自动配色大法),如果有人感兴趣的话可以评论或群里说,古柳可以优先放进阶系列里讲讲(随心所欲的好处),入门系列里还有好多其他内容要讲,这个排期还不一定在什么时候(边写边看吧)。这里正巧讲到就提一嘴,给大家看看效果。

通过 HSL 能更好地控制颜色

言归正传,上述内容是古柳觉得讲到 noise 时很有必要涉及的,所以不知不觉讲了挺多,但其实本文的目的是教大家下图中的颜色如何实现,而这并非用的 mix 插值颜色,到底用的啥?不急,马上揭秘。

我们知道颜色有很多种格式可以表示,同样的红色可以分别用关键词 red、16进制 #FF0000、RGB格式 rgb(255, 0, 0)、HSL格式 hsl(0deg, 100%, 50%) 等代替,这个大家学过 CSS 可能都早就知道了。

其中 HSL 格式最为直观好懂、最好控制,HSL 即色相 Hue、饱和度 Saturation、亮度 Lightness 的简称。色相是色彩的基本属性,一般我们说的红色、绿色、紫色指的就是色相,即不同颜色,范围为0-360deg,可以用圆环表示所有的色相;饱和度是指色彩的纯度,饱和度越高色彩越纯越浓,饱和度越低则色彩变灰变淡;亮度是指色彩的明暗程度,越高色彩越白,越低色彩越黑。拿 red 来说,H=0deg,S=100%,L=50%,后两者是一般颜色“正常”时的数值,S越小越灰、L越大越白、L越小越黑。

这些是一般的颜色常识,而现在我们想将其用到 shader 里。当我们有 noise 数值后,最简单的方法就是将数值变成不同色相值,这样颜色就能不同。不过在 shader 里颜色最终还得用 rgb 格式表示,所以我们需要将 hsl 颜色转换成 rgb 颜色,谷歌搜索 glsl hsl 或 glsl hsl2rgb 就能找到现成的函数。

  • 链接:github.com/Experience-…
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
C#复制代码float hue2rgb(float f1, float f2, float hue) {
if (hue < 0.0)
hue += 1.0;
else if (hue > 1.0)
hue -= 1.0;
float res;
if ((6.0 * hue) < 1.0)
res = f1 + (f2 - f1) * 6.0 * hue;
else if ((2.0 * hue) < 1.0)
res = f2;
else if ((3.0 * hue) < 2.0)
res = f1 + (f2 - f1) * ((2.0 / 3.0) - hue) * 6.0;
else
res = f1;
return res;
}

vec3 hsl2rgb(vec3 hsl) {
vec3 rgb;

if (hsl.y == 0.0) {
rgb = vec3(hsl.z); // Luminance
} else {
float f2;

if (hsl.z < 0.5)
f2 = hsl.z * (1.0 + hsl.y);
else
f2 = hsl.z + hsl.y - hsl.y * hsl.z;

float f1 = 2.0 * hsl.z - f2;

rgb.r = hue2rgb(f1, f2, hsl.x + (1.0/3.0));
rgb.g = hue2rgb(f1, f2, hsl.x);
rgb.b = hue2rgb(f1, f2, hsl.x - (1.0/3.0));
}
return rgb;
}

vec3 hsl2rgb(float h, float s, float l) {
return hsl2rgb(vec3(h, s, l));
}

我们想要红色就可以这样写 hsl2rgb(0.0, 1.0, 0.5),此时三个参数分别表示的就是 h/s/l,且 h 也用0.0-1.0的范围表示,0.0=0deg,1.0=360deg。s=1.0,l=0.5 就是前面说的“正常”颜色的数值。

1
2
3
4
C#复制代码void main() {
vec3 color = hsl2rgb(0.0, 1.0, 0.5);
gl_FragColor = vec4(color, 1.0);
}

将 uTime 取小数后确保在0.0-1.0范围后作为 hue 值就能使颜色随时间变化。

1
2
3
4
5
C#复制代码void main() {
// vec3 color = hsl2rgb(0.0, 1.0, 0.5);
vec3 color = hsl2rgb(fract(uTime), 1.0, 0.5);
gl_FragColor = vec4(color, 1.0);
}

将 noise 数值作为 hue 色相值

现在将原始的 noise 数值直接设置成 hue,因为h可以是-1.0-1.0的值,既然不会出错,那就偷个懒不转换到0.0-1.0范围。直接作为颜色效果是不是挺炸裂的!还记得我们说过 noise 数值是相邻的位置相近,如果直接这样转换成全部色彩的话,显然颜色变化太剧烈了。(当然如果你想要的艺术风格就是如此,那可以直接在此基础上创作)

1
2
3
4
5
6
7
C#复制代码// float noise = cnoise(position * uFrequency + uTime);
// vNoise = noise;

void main() {
vec3 color = hsl2rgb(vNoise, 1.0, 0.5);
gl_FragColor = vec4(color, 1.0);
}

我们可能将数值乘以0.1使变化幅度减小,从而使颜色仅对应一小段色相范围,这样整个球体的颜色更为和谐、不会五颜六色很难看。

1
2
3
4
C#复制代码void main() {
vec3 color = hsl2rgb(vNoise * 0.1, 1.0, 0.5);
gl_FragColor = vec4(color, 1.0);
}

通过 uTime 可以使主体颜色发生变化(记得取小数)。如果是放在网页里,我们希望颜色变化不要那么剧烈、那么快,缓慢、细微的变化更显优雅,此时可以将时间乘以较小值。这里仅是演示。

1
2
3
4
C#复制代码void main() {
vec3 color = hsl2rgb(fract(uTime + vNoise * 0.1), 1.0, 0.5);
gl_FragColor = vec4(color, 1.0);
}

我们将颜色偏移到金色多些的位置,但大家是否会觉得这效果还没前面双色、多色的 noise 小球好看?不急,接下来就是见证奇迹的时刻!

1
C#复制代码vec3 color = hsl2rgb(0.1 + vNoise * 0.1, 1.0, 0.5);

我们把 noise 偏移顶点的部分再加回去上,立马好看了很多。此时 noise 数值的大小不仅影响顶点偏移程度,使得球体表面高低不同,而且也决定不同高低位置的颜色差异。

1
2
3
4
5
6
7
C#复制代码void main() {
vec3 newPos = position;
float noise = cnoise(position * uFrequency + uTime);
newPos += normal * noise * 1.0;
vNoise = noise;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

而这就是本篇想讲的用 noise 数值控制 HSL 中的色相值来实现较为自然、和谐、漂亮颜色效果的方法,这一方法是古柳一开始入坑 shader 时从 Pepyaka 里早早学到的,这回终于可以分享给大家,其实讲起来很简单,三两句就可以讲完,但引申出去讲了不少其他内容,希望大家不厌其烦、觉得有所收获。

另外分享 codepen 上一个蛮漂亮的效果——像是一片片彩色花瓣组成的花球——其中每片花瓣之间有一个不同的主色进行区分并使得整体保持彩色,而每片花瓣内部会在该主色上通过 noise 数值对 hue 进行小幅度的偏移,使每片花瓣不是简单的纯色,而是更为生动自然有细微差异的颜色。感兴趣的朋友可以看下代码,后续在进阶篇或许也可以复现下、讲解下。

  • 链接:codepen.io/Mamboleoo/p…

小结

本篇文章古柳先教大家如何通过 GUI 控制 shader 里的参数以便能高效便捷地选出效果最佳的数值,这一技巧大家可以灵活应用到之前或之后的例子、shader 里或 shader 外的场景之中,非常实用;

接着古柳带大家看看 noise 数值转换成颜色后是什么效果,通过取 step、mix 插值颜色、应用 Pantone 年度代表色等方式使大家更加熟悉简单又强大的 noise 噪声函数;

最后通过将缩小范围后的 noise 数值作用到 HSL 中的 hue 色相值上,从而实现出漂亮的色彩,结合顶点偏移后就是极简版的 Pepyaka 效果。最终篇幅所限,没带大家实现更进一步的效果,但核心关键的应用颜色的方法已经告诉大家,其他都是次要的。

最后照旧是本文的所有例子合集,大家要好好复习哈。

相关阅读

「手把手带你入门 Three.js Shader 系列」目录如下:

  • 「断更19个月,携 Three.js Shader 归来!(上) - 牛衣古柳 - 20230416」
  • 「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳 - 20230421」
  • 「手把手带你入门 Three.js Shader 系列(六) - 牛衣古柳 - 20231220」
  • 「手把手带你入门 Three.js Shader 系列(五) - 牛衣古柳 - 20231126」
  • 「手把手带你入门 Three.js Shader 系列(四) - 牛衣古柳 - 20231121」
  • 「手把手带你入门 Three.js Shader 系列(三) - 牛衣古柳 - 20230725」
  • 「手把手带你入门 Three.js Shader 系列(二) - 牛衣古柳 - 20230716」
  • 「手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳 - 20230515」

照例

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

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

面试官:如何防止短信盗刷和短信轰炸?

发表于 2024-02-05

副本_最后一天__2024-02-05+16_28_53.jpeg

短信盗刷和短信轰炸是项目开发中必须要解决的问题之一,它的优先级不亚于 SQL 注入的问题,同时它也是面试中比较常见的一个经典面试题,今天我们就来看下,如何防止这个问题。

1.概念介绍

短信盗刷和短信轰炸的概念如下:

  • 短信盗刷是指使用某种技术手段,伪造大量手机号调用业务系统,盗取并发送大量短信的问题。这样会导致短信系统欠费,不能正常发送短信,同时也给业务系统方,带来了一定的经济损失和不必要的麻烦。
  • 短信轰炸是指攻击者利用某种技术手段,连续、大量地向目标手机号码发送短信,以达到骚扰、干扰或消耗目标用户的时间、流量与精力的目的。这种行为可能会对受害者造成骚扰、通信中断和手机电量消耗过快等问题。

2.解决方案

短信盗刷和短信轰炸属于一类问题,可以一起解决。但这类问题的解决,不能依靠某一种解决方案,而是多种解决方案共同作用,来预防此类问题的发生。

这类问题的综合解决方案有以下几个:

  1. 添加图形验证码:用户发送短信前,需要先输入正确的图形验证码,或拖动验证码等验证,验证通过之后,才能正常发送短信验证码。因为图形验证码的破解难度非常大,所以就避免了自动发送短信程序的执行。
  2. 添加 IP 限制:对请求 IP 的发送次数进行限制,避免短信盗刷和短信轰炸的问题。例如,每个 IP 每天只能发送 10 条短信。
  3. 开启 IP 黑名单:限制某个 IP 短信发送功能,从而禁止自动发送短信程序的执行。
  4. 限制发送频次:一个手机号不能一直不停的发送验证码(即使更换了多个 IP 也不行),设置一个手机号,每分钟内只能发送 1 次验证码;一小时之内,只能发送 5 次验证码;一天之内,只能发送 10 次验证码。
  5. 开启短信提供商的防控和报警功能:几乎所有的短信提供商都提供了,异常短信的防控和提醒功能,开启这些保护措施,可以尽可能的避免短信盗刷的问题。

具体实现如下。

3.具体解决方案

3.1 添加图形验证码

图形验证码的执行流程如下:

  1. 当用户点击“发送短信验证码”的时候,前端程序请求后端生成图形验证码,后端程序生成图形验证码的功能,可以借助 Hutool 框架来生成,它的核心实现代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@RequestMapping("/getcaptcha")
public AjaxResult getCaptcha(){
// 1.生成验证码到本地
// 定义图形验证码的长和宽
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(128, 50);
String uuid = UUID.randomUUID().toString().replace("-","");
// 图形验证码写出,可以写出到文件,也可以写出到流
lineCaptcha.write(imagepath+uuid+".png");
// url 地址
String url = "/image/"+uuid+".png";
// 将验证码存储到 redis
redisTemplate.opsForValue().set(uuid,lineCaptcha.getCode());
HashMap<String,String> result = new HashMap<>();
result.put("codeurl",url);
result.put("codekey",uuid);
return AjaxResult.succ(result);
}

上述执行代码中有两个关键操作:第一,生成图形验证码,并返回给前端程序;第二,将此图形验证的标识(ID)和正确的验证码保存到 Redis,方便后续验证。

  1. 前端用户拿到图形验证码之后,输入图形验证码,请求后端程序验证,并发送短信验证码。
  2. 后端程序拿到(图形)验证码之后,先验证(图形)验证码的正确性.如果正确,则发送短信验证码,否则,将不执行后续流程并返回执行失败给前端,核心实现代码如下:
1
2
3
4
5
6
7
8
9
10
java复制代码// redis 里面 key 对应的真实的验证码
String redisCodeValue = (String) redisTemplate.opsForValue().get(user.getCodeKey());
// 验证 redis 中的验证码和用户输入的验证码是否一致
if (!StringUtils.hasLength(redisCodeValue) || !redisCodeValue.equals(user.getCheckCode())) {
// 验证码不正确
return AjaxResult.fail(-1, "验证码错误");
}
// 清除 redis 中的验证码
redisTemplate.opsForValue().set(userInfoVO.getCodeKey(), "");
// 请求短信 API 发送短信业务......

3.2 添加 IP 限制

IP 限制可以在网关层 Spring Cloud Gateway 中实现,在 Gateway 中使用全局过滤器来完成 IP 限制,核心实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码@Component
public class IpFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求 IP
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
Long count = redisTemplate.opsForValue().increment(ip, 1); // 累加值
if(count >= 20){ // 大于最大执行次数
redisTemplate.opsForValue().decrement(ip, 1); // 变回原来的值
// 终止执行
response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED);
return response.setComplete();
}
redisTemplate.expire(ip, 1, TimeUnit.DAYS); // 设置过期时间
// 执行成功,继续执行后续流程
return chain.filter(exchange);
}
}

其中,访问次数使用 Redis 来存储。

3.3 开启 IP 黑名单

IP 黑名单可以在网管层面通过代码实现,也可以通过短信提供商提供的 IP 黑名单来实现。

例如,阿里云短信提供的 IP 黑名单机制,如下图所示:
image.png
image.png
通过以上设置之后,我们就可以将监控中有问题的 IP 设置为黑名单,此时 IP 黑名单中的 IP 就不能调用短信的发送功能了。

PS:网关层面实现 IP 黑名单,可以参考添加 IP 限制的代码进行改造。

3.4 限制验证码的发送频次

验证码的发送频次,可以通过网关或短信提供商来解决。以阿里云短信为例,它可以针对每个手机号设置每分钟、每小时、每天的短信最大发送数,如下图所示:
image.png
当然,这个值也可以人为修改,但阿里云短信每天单个手机号最多支持发送 40 条短信。

3.5 开启短信提供商的防控和报警功能

短信提供商通常会提供防盗、防刷的机制。例如,阿里短信它可以设置短信发送总量阈值报警、套餐余量提醒、每日/每月发送短信数量限制等功能,如下图所示:
image.png

课后思考

本文所描述的问题只是日常开发中遇到的问题之一,但是除了本文提供的问题之外,你还遇到了哪些记忆深刻的问题呢?你又是如何解决的呢?欢迎评论区留言互动,最佳留言者,将会获得一次免费的简历辅导服务,欢迎大家积极参与并讨论。

本文已收录到我的面试小站 www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

本文转载自: 掘金

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

分布式本地缓存刷新方案

发表于 2024-02-05

一、背景

最近公司做的一个需求中有个场景是关于分布式本地缓存刷新的。在跟领导和同事讨论技术方案的时候发现实现起来也不是很难,但是如何大规模使用或者结合不同框架中间件去实现也是有些挑战的,所以本篇文章就分布式本地缓存刷新的点进行深入探讨,对其不同方案的可行性等进行深入剖析。从一个点上来实现举一反三的实战效果。

二、需求

2.1 前提场景

  1. 多节点部署
  2. 本地缓存不容易改造成分布式缓存,或者不方便与分布式缓存配合实现多级缓存
  3. 缓存更新频率不是特别高

2.2 本地缓存刷新需求

  1. 业务系统中的每个节点的操作引发本地缓存变更之后将缓存刷新动作同步给其他节点进行刷新。
  2. 结合每个项目的不同特性给出相匹配的实现方案
  3. 满足节点动态伸缩功能(有难度)
  4. 定义通用缓存api和接口,支持rpc,http实现

三、方案

3.0 场景分析

要实现分布式本地缓存同步刷新功能,这里需要先分析一下大概有哪些组件模块参与其中,首先肯定有一个触发缓存刷新的组件或者模块,另外涉及到本地缓存的管理模块也会有对应的缓存刷新方法,然后需要一个事件模型来代表本地缓存刷新的动作,之后就是需要通过一定的机制来通知其他节点,最后就是其他节点触发本地刷新缓存的方法。整体流程如下:
分布式本地缓存刷新流程.png

3.1 消息订阅与发布

经过上面的分析之后,不同的实现方案就是对于如何通知其他节点来进行落地。这里先看一下基于MQ的方式来实现:

  1. 定义一个缓存刷新事件消息模型

WX20220919-151957@2x.png

  1. 消息Topic

refresh_local_cache

  1. 消息group

refresh_local_cache_GID/refresh_local_cache_${ip}
这里按Kafka和RocketMQ的实现方式做一下特殊说明:
Kafka实际上不支持广播消息,所以需要每个节点的消息GroupID是唯一的。
对于RocketMQ来说实现广播消息可以这样做,每个节点配置相同的topic和GroupId,在topic的配置处设置MessageModel.BROADCASTING:广播模式。

3.2 轮询广播

轮询广播的方式也比较简单,也可以实现异步调用,但是对于不同的框架而言可能实现方式也不太一样。
3.2.1 Springboot实现
实现一个SpringBoot Rest接口,通过HttpClientUtils调用,或者封装的调用框架实现循环调用
3.2.2 Dubbo广播调用
声明一个Dubbo接口,配置广播调用
3.2.3 SpringCloud广播
SpringCloud的广播调用可以采用配置变化的方式来走类似于Bus或者Stream的方式通知,这里相当于把缓存刷新事件模型当作一个配置,有变更就覆盖之前的配置,然后走配置发布同步的方式。

3.3 Redis

这里对于Redis的使用有以下几种方式:

  1. 把Redis当作注册中心,每个节点实例把自己的IP注册到Redis中,然后走轮询广播的方式,或者走第二步
  2. 把Redis当作消息中间件,使用Redis双端链表的数据结构,每个IP一个链表,同时每个IP只监听自己的链表内容。
  3. 基于Redis key-value的方式记录缓存刷新的事件模型,每个节点实现Redis key-value变化监听器

3.4 定时任务刷新

这里的定时任务可以使用线程池或者xxl-job,所以这里讨论两种实现方案
任务实现逻辑如下:基于redis+定时任务的本地缓存刷新.png
3.4.1 ScheduledThreadPoolExecutor
3.4.2 Xxl-Job
基于Xxl-Job的方式需要将job的路由策略设置为SHARD,同时速率需要根据实际业务场景设置,避免缓存更新不及时。

四、方案取舍

基于上述方案和思路基本可以实现分布式本地缓存刷新的功能了,但是各自的优缺点也要对比分析下,如下表格,分析了不同方案的优缺点:

方案说明 优点 缺点
基于消息订阅与发布的方式-Kakfa实现 集群内监听与消费解耦,借助消息中间件实现节点动态扩缩容 每个节点需要一个唯一的消费组ID,线上环境可能对于运维不是特别友好,有可能需要手动配置
实时性受限于消息消费速度
基于消息订阅与发布的方式-RocketMQ实现 集群内监听与消费解耦,借助消息中间件实现节点动态扩缩容 需要配置一次groupId,
实时性受限于消息消费速度
接口轮询的方式 不借助中间件,内部实现
实时性比较好 无法满足动态扩缩容,另外缓存变化频率比较快,节点数比较高的话可能会产生性能问题,可用性不高
Redis监听 使用Redis当作消息中间件,进行解耦 不同的实现方案都受限于Redis本身的可用性
定时任务刷新-ScheduledThreadPoolExecutor 使用内置的线程池,相对于xxl-job来说不需要引入特别的任务框架 可能受限于redis本身的可用性,另外周期性的任务实现实时性无法保障
定时任务刷新-Xxx-Job 借助于xxl-job提供的特性结合场景可以实现一定场景下的本地缓存刷新 可能受限于redis本身的可用性,另外周期性的任务实现实时性无法保障

五、总结

通过上面的方案探讨和优缺点分析发现,想做好分布式本地缓存刷新功能借助不同的中间件实现是没有太多难度的,重点是上量之后出现的性能问题和可用性问题(实时性)。这里的量有两个维度:

  1. 节点数量
  2. 一定时间内的刷新量

面对大规模的服务集群如果需要高可用性可能需要上面的多种方案结合才行。比如选消息中间件作为主方案,使用轮询的方式作为兜底方案等。

本文转载自: 掘金

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

PostgreSQL 也很强大,为何在中国大陆,MySQL

发表于 2024-02-04

问题:

  • PostgreSQL 也很强大,为何在中国大陆,MySQL 成为主流,PostgreSQL 屈居二线呢?
  • PostgreSQL 能否替代 MySQL?

当我们讨论为何 MySQL 在中国大陆成为主流而 PostgreSQL 屈居二线时,
我们其实在剖析的是一段技术演进与市场需求相互作用的历史。

首先,我们得承认每种技术都有其诞生和发展的背景。

起初,MySQL 更像是一种轻量级的解决方案,它提供了足以满足基本需求的关系数据库功能。
它的诞生正赶上了互联网的初期波潮,那时候,很多互联网初创企业都需要一种简单、易用且能够迅速部署的数据库系统来支持它们的业务,
而 MySQL 恰好符合这些需求。

MySQL 的优势在于其简单性和性能。
很多早期的应用并不需要复杂的事务管理或者高级的数据库功能;
它们更多地需要的是快速的读写能力和易于水平扩展的架构。MySQL 的 MyISAM 存储引擎在当时提供了非常高效的读取速度,
这使得 MySQL 在处理大量的简单查询时非常迅速。

&nbsp

举个例子来说,如果你的应用是一个内容发布系统,访问量非常大,但事务处理并不复杂,
如多数内容都是读取操作(例如新闻网站或博客平台),那么 MySQL 就很可能是一个合适的选择。
因为在这种场景下,你更关心的可能是如何快速地向用户展示内容,而不是如何处理复杂的数据关系或事务。

随着业务的发展,人们开始逐渐意识到 MySQL 除了简单性能外,也需要强化其事务处理的能力。
这时候,InnoDB 存储引擎开始受到青睐,因为它支持ACID事务,提供了行级锁定(row-level locking)和外键约束,
这些都是企业级应用所需的特性。

&nbsp

然而,PostgreSQL 一直以其作为“最先进的开源数据库”自居,它在事务处理、数据一致性和高级特性上拥有非常强大的能力。
比如,它的MVCC(多版本并发控制)为数据库提供了高级的事务隔离级别,
这对于需要处理复杂查询和保持强数据一致性的金融或者企业级应用来说是非常关键的。

以金融业为例,如果一个银行需要处理成百上千的交易,这些交易之间可能存在复杂的关系,需要确保数据的一致性和安全性。
在这种情况下,PostgreSQL 的高级事务控制、强数据一致性保障和丰富的数据类型(例如,对金融产品进行建模时可能会用到的范围类型或自定义复合类型)就显得尤为重要了。
这可以保证在多用户并发操作下,数据的完整性不会受到威胁。

&nbsp

但 PostgreSQL 在早期有其不足之处,尤其是在易用性和运维上。例如,它长期使用的是多进程模型,
这在管理大量连接时的资源开销要高于 MySQL 的多线程模型。并且,直到近些年 PostgreSQL 才引入了逻辑复制(logical replication),
而 MySQL 在这方面走在了前面。

当然,随着时间的推移,两者都发生了演变。MySQL 增添了诸如存储过程、触发器、视图等高级功能,
而 PostgreSQL 也变得更加易用,新增了对逻辑复制的支持,改善了性能和易用性。

在决定是否能够替换 MySQL时 ,PostgreSQL 需要面对的不仅仅是技术挑战,还有市场和生态的挑战。
MySQL 的普及为其构建了强大的社区支持和丰富的周边工具,而这些对于企业来说是一个不容忽视的优势。
另外,因为很多人才市场上的数据库管理员和开发者更加熟悉 MySQL,这也促成了它的主流地位。

推荐几个学习 MySQL 教程文章

  • 01、MySQL 简介
  • 02、MySQL 管理
  • 03、MySQL 创建连接
  • 04、MySQL 获取数据库列表
  • 05、MySQL 创建数据库
  • 06、MySQL 数据类型
  • 07、MySQL 创建数据表
  • 08、MySQL 插入数据
  • 09、MySQL SELECT FROM 查询数据
  • 10、MySQL WHERE 子句有条件的查询数据
  • 11、MySQL UPDATE 更新数据
  • 12、MySQL DELETE FROM 语句删除数据
  • 13、MySQL JOIN 进行多表查询
  • 14、MySQL 数据库事务
  • 15、MySQL 索引
  • ……

小结:

PostgreSQL 是否能够扛起大梁并不是一个技术层面能够简单回答的问题。
它涉及到对现有业务的影响、团队的技术栈熟悉度、以及维护和运营的成本等一系列复杂因素。
如果你的应用场景能够从 PostgreSQL 的高级特性中受益,同时你的团队对它也有足够了解,那么它绝对是一个可行的选择。
不过,在大多数情况下,企业可能会因为种种原因继续选择 MySQL,这是市场和技术共同作用的结果。

求一键三连:点赞、分享、收藏

点赞对我真的非常重要!在线求赞,加个关注我会非常感激!@小郑说编程i

本文转载自: 掘金

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

服了,一个ThreadLocal被问出了花

发表于 2024-02-03

分享是最有效的学习方式。
博客:blog.ktdaddy.com/

故事

地铁上,小帅无力地倚靠着杆子,脑子里尽是刚才面试官的夺命连环问,“用过TheadLocal么?ThreadLocal是如何解决共享变量访问的安全性的呢?你觉得啥场景下会用到TheadLocal? 我们在日常用ThreadLocal的时候需要注意什么?ThreadLocal在高并发场景下会造成内存泄漏吗?为什么?如何避免?……”

这些问题,如同阴影一般,在小帅的脑海里挥之不去。

是的,他万万没想到,自诩“多线程小能手”的他栽在了ThreadLocal上。

这是小帅苦投了半个月简历之后才拿到的面试机会,然而又丧失了。当下行情实在是卷到了极点。

都两个月了,面试机会少,居然还每次都被问翻,这样下去真要回老家另谋出路了,小帅内心五味成杂……

小伙伴们,试问一下,如果是你,面对上述的问题,你能否对答如流呢?

概要

既然被问到了,那么作为事后诸葛的老猫就和大家一起来接面试官的招吧。

我们将从以下点来全面剖析一下ThreadLocal。

概览.png

基本篇

什么是ThreadLocal?

ThreadLocal英文翻译过来就是:线程本地量,它其实是一种线程的隔离机制,保障了多线程环境下对于共享变量访问的安全性。

看到上面的定义之后,那么问题就来了,ThreadLocal是如何解决共享变量访问的安全性的呢?

其实ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。由于副本都归属于各自的线程,所以就不存在多线程共享的问题了。

便于理解,我们看一下下图。

p1.png

至于上述图中提及的threadLocals(ThreadLocalMap),我们后文看源代码的时候再继续来看。大家心中暂时有个概念。

既然都是保证线程访问的安全性,那么和Synchronized区别是什么呢?

在上面聊到共享变量访问安全性的问题上,其实大家还会很容易想起另外一个关键字Synchronized。聊聊区别吧,整理了一张图,看起来可能会更加直观一些,如下。

p2.png

通过上图,我们发现ThreadLocal其实是一种线程隔离机制。Synchronized则是一种基于Happens-Before规则里的监视器锁规则从而保证同一个时刻只有一个线程能够对共享变量进行更新。

Synchronized加锁会带来性能上的下降。ThreadLocal采用了空间换时间的设计思想,也就是说每个线程里面都有一个专门的容器来存储共享变量的副本信息,然后每个线程只对自己的变量副本做相对应的更新操作,这样避免了多线程锁竞争的开销。

ThreadLocal的使用

上面说了这么多,咱们来使用一下。就拿SimpleDateFormat来做个例子。当然也会有一道这样的面试题,SimpleDateFormat是否是线程安全的?在阿里Java开发规约中,有强制性的提到SimpleDateFormat 是线程不安全的类。其实主要的原因是由于多线程操作SimpleDateFormat中的Calendar对象引用,然后出现脏读导致的。

踩坑代码:

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
java复制代码/**
* @author 公众号:程序员老猫
* @date 2024/2/1 22:58
*/
public class DateFormatTest {
private static final SimpleDateFormat simpleDateFormat =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static Date parse(String dateString) {
Date date = null;
try {
date = simpleDateFormat.parse(dateString);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}

public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(20);

for (int i = 0; i < 20; i++) {
executorService.execute(()->{
System.out.println(parse("2024-02-01 23:34:30"));
});
}
executorService.shutdown();
}
}

上述咱们通过线程池的方式针对SimpleDateFormat进行了测试(如果大家需要深入了解一下线程池的相关原理,可以戳“线程池”)。其输出结果如下。

p3.png

我们可以看到刚开始好好的,后面就异常了。

我们通过ThreadLocal的方式将其优化一下。代码如下:

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
java复制代码/**
* @author 公众号:程序员老猫
* @date 2024/2/1 22:58
*/
public class DateFormatTest {

private static final ThreadLocal<SimpleDateFormat> dateFormatThreadLocal =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public static Date parse(String dateString) {
Date date = null;
try {
date = dateFormatThreadLocal.get().parse(dateString);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}

public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);

for (int i = 0; i < 20; i++) {
executorService.execute(()->{
System.out.println(parse("2024-02-01 23:34:30"));
});
}
executorService.shutdown();
}
}

运行了一下,完全正常了。

1
2
3
4
5
6
7
8
9
yaml复制代码Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024
Thu Feb 01 23:34:30 CST 2024

TheadLocal使用场景

那么我们什么时候会用到ThreadLocal呢?

  1. 上面针对SimpleDateFormat的封装也算是一个吧。
  2. 用来替代参数链传递:在编写API接口时,可以将需要传递的参数放入ThreadLocal中,从而不需要在每个调用的方法上都显式地传递这些参数。这种方法虽然不如将参数封装为对象传递来得常见,但在某些情况下可以简化代码结构。
  3. 数据库连接和会话管理:在某些应用中,如Web应用程序,ThreadLocal可以用来保持对数据库连接或会话的管理,以简化并发控制并提高性能。例如,可以使用ThreadLocal来维护一个连接池,使得每个请求都能共享相同的连接,而不是每次都需要重新建立连接。
  4. 全局存储信息:例如在前后端分离的应用中,ThreadLocal可以用来在服务端维护用户的上下文信息或者一些配置信息,而不需要通过HTTP请求携带大量的用户信息。这样做可以在不改变原有架构的情况下,提供更好的用户体验。

如果大家还能想到其他使用的场景也欢迎留言。

升华篇

ThreadLocal原理

上述其实咱们聊的相对而言还是比较浅的。那么接下来,咱们丰富一下之前提到的结构图,从源代码侧深度剖一下ThreadLocal吧。

constructor.png

对应上述图中,解释一下。

  1. 图中有两个线程Thread1以及Thread2。
  2. Thread类中有一个叫做threadLocals的成员变量,它是ThreadLocal.ThreadLocalMap类型的。
  3. ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值。

对应的我们看一下Thread的源代码,如下:

1
2
3
4
5
java复制代码public class Thread implements Runnable {
...
ThreadLocal.ThreadLocalMap threadLocals = null;
...
}

在源码中threadLocals的初始值为Null。

抽丝剥茧,咱们继续看一下ThreadLocalMap在调用构造函数进行初始化的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码
static class ThreadLocalMap {

private static final int INITIAL_CAPACITY = 16; //初始化容量
private Entry[] table; //ThreadLocalMap数据真正存储在table中
private int size = 0; //ThreadLocalMap条数
private int threshold; // 默认为0,达到这个大小,则扩容
//类Entry的实现
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//构造函数
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY]; //初始化table数组,INITIAL_CAPACITY默认值为16
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); //key和16取得哈希值
table[i] = new Entry(firstKey, firstValue);//创建节点,设置key-value
size = 1;
setThreshold(INITIAL_CAPACITY); //设置扩容阈值
}
}

在源码中涉及比较核心的还有set,get以及remove方法。我们依次来看一下:

set方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码 public void set(T value) {
Thread t = Thread.currentThread(); //获取当前线程t
ThreadLocalMap map = getMap(t); //根据当前线程获取到ThreadLocalMap
if (map != null) //如果获取的ThreadLocalMap对象不为空
map.set(this, value); //K,V设置到ThreadLocalMap中
else
createMap(t, value); //创建一个新的ThreadLocalMap
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals; //返回Thread对象的ThreadLocalMap属性
}

void createMap(Thread t, T firstValue) { //调用ThreadLocalMap的构造函数
t.threadLocals = new ThreadLocalMap(this, firstValue); //this表示当前类ThreadLocal
}

get方法如下:

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
java复制代码    public T get() {
//1、获取当前线程
Thread t = Thread.currentThread();
//2、获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
//3、如果map数据不为空,
if (map != null) {
//3.1、获取threalLocalMap中存储的值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//如果是数据为null,则初始化,初始化的结果,TheralLocalMap中存放key值为threadLocal,值为null
return setInitialValue();
}


private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

remove方法:

1
2
3
4
5
java复制代码 public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

那么为什么需要remove方法呢?其实这里会涉及到内存泄漏的问题了。后面咱们细看。

对照着上述的结构图以及源码,如果面试官问ThreadLocal原理的时候,相信大家应该可以说出个所以然来。

  1. Thread线程类有一个类型为ThreadLocal.ThreadLocalMap的变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap。
  2. ThreadLocalMap方法内部维护者Entry数组,其中key是ThreadLocal本身,而value则为其泛型值。
  3. 并发场景下,每个线程都会存储当前变量副本到自己的ThreadLocalMap中,后续这个线程对于共享变量的操作,都是从TheadLocalMap里进行变更,不会影响全局共享变量的值。

高并发场景下ThreadLocal会造成内存泄漏吗?什么原因导致?如何避免?

造成内存泄漏的原因

这个问题其实还是得从ThreadLocal底层源码的实现去看。高并发场景下,如果对ThreadLocal处理得当的话其实就不会造成呢村泄漏。我们看下面这样一组源代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码static class ThreadLocalMap {
...
//类Entry的实现
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
...
}

上文中其实我们已经知道Entry中以key和value的形式存储,key是ThreadLocal本身,上面代码中我们看到entry进行key设置的时候用的是super(k)。那就意味着调用的父类的方法去设置了key,我们再看一下父类是什么,父类其实是WeakReference。关于WeakReference底层的实现,大家有兴趣可以展开去看看源代码,老猫在这里直接说结果。

WeakReference 如字面意思,弱引用,当一个对象仅仅被weak reference(弱引用)指向, 而没有任何其他strong reference(强引用)指向的时候, 如果这时GC运行, 那么这个对象就会被回收,不论当前的内存空间是否足够,这个对象都会被回收。

关于这些引用的强弱,稍微聊一下,这里其实涉及到jvm的回收机制。在JDK1.2之后,java对引用的概念其实做了扩充的,分为强引用,软引用,弱引用,虚引用。

强引用:其实就是咱们一般用“=”的赋值行为,如 Student s = new Student(),只要强引用还在,对象就不会被回收。

软引用:不是必须存活的对象,jvm在内存不够的情况下即将内存溢出前会对其进行回收。例如缓存。

弱引用:非必须存活的对象,引用关系比软引用还弱,无论内存够还是不够,下次的GC一定会被回收。

虚引用:别名幽灵引用或者幻影引用。等同于没有引用,唯一的目的是对象被回收的时候会受到系统通知。

明白这些概念之后,咱们再看看上面的源代码,我们就会发现,原来Key其实是弱引用,而里面的value因为是直接赋值行为所以是强引用。

如下图:

p5.png

图中我们可以看到由于threadLocal对象是弱引用,如果外部没有强引用指向的话,它就会被GC回收,那么这个时候导致Entry的key就为NULL,如果此时value外部也没有强引用指向的话,那么这个value就永远无法访问了,按道理也该被回收。但是由于entry还在强引用value(看源代码)。那么此时value就无法被回收,此时内存泄漏就出现了。本质原因是因为value成为了一个永远无法被访问也无法被回收的对象。

那肯定有小伙伴会有疑问了,线程本身生命周期不是很短么,如果短时间内被销毁,就不会内存泄漏了,因为只要线程销毁,那么value也会被回收。这话是没错。但是咱们的线程是计算机珍贵资源,为了避免重复创建线程带来开销,系统中我们往往会使用线程池(线程池传送门),如果使用线程池的话,那么线程的生命周期就被拉长了,那么就可想而知了。

如何避免

解法如下:

  1. 每次使用完毕之后记得调用一下remove()方法清除数据。
  2. ThreadLocal变量尽量定义成static final类型,避免频繁创建ThreadLocal实例。这样可以保证程序中一直存在ThreadLocal强引用,也能保证任何时候都能通过ThreadLocal的弱引用访问Entry的value值,从而进行清除。

不过话说出来,其实ThreadLocal内部也做了优化的。在set()的时候也会采样清理,扩容的时候也会检查(这里希望大家自己深入看一下源代码),在get()的时候,如果没有直接命中或者向后环形查找的时候也会进行清理。但是为了系统的稳健万无一失,所以大家尽量还是将上面的两个注意点在写代码的时候注意下。

总结

面试的时候大家总会去背一些八股文,但是这种也只是临时应付面试官而已,真正的懂其中的原理才是硬道理。无论咋问,万变不离核心原理。当然这些核心原理在我们的日常编码中也会给我们带来很大的帮助,用法很简单,翻车了如何处理,那还不是得知其所以然么,伙伴们,你们觉得呢?

本文转载自: 掘金

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

SpringBoot + LiteFlow 轻松应对复杂业务

发表于 2024-02-03

LiteFlow.png

LiteFlow简介

LiteFlow是什么?

LiteFlow是一款专注于逻辑驱动流程编排的轻量级框架,它以组件化方式快速构建和执行业务流程,有效解耦复杂业务逻辑。通过支持热加载规则配置,开发者能够即时调整流程步骤,将复杂的业务如价格计算、下单流程等拆分为独立且可复用的组件,从而实现系统的高度灵活性与扩展性,避免了牵一发而动全身的问题。旨在优化开发流程,减少冗余工作,让团队能够更聚焦于核心业务逻辑,而将流程控制层面的重任托付给该框架进行自动化处理。

LiteFlow整合了流程编排与规则引擎的核心特性,提供XML、JSON或YAML格式的灵活流程定义,以及本地文件系统、数据库、ZooKeeper、Nacos、Apollo、Redis等多种规则文件存储方案。其内建插件如liteflow-rule-nacos,以及开放的扩展机制,赋予开发人员自定义规则解析器的能力,满足多样化场景下的规则管理需求。

对于基于角色任务流转的场景,LiteFlow并非最佳选择,推荐使用Flowable或Activiti等专门的工作流引擎。

LiteFlow的架构

LiteFlow是从获取上下文开始的,这个上下文通常包含了执行流程所需的数据和环境信息。通过解析这些上下文数据,LiteFlow能够理解并执行对应的规则文件,驱动业务流程的执行。在LiteFlow中,业务流程被组织成一系列的链路(或节点),每个链路代表一个业务步骤或决策点。这些链路上的节点,也就是业务组件,是独立的,可以支持多种脚本语言,如Groovy、JavaScript、Python、Lua等,以便根据具体业务需求进行定制。下图为LiteFlow的整体架构图。

LiteFlow执行流程.png

LiteFlow的作用

  • LiteFlow将瀑布式代码进行组件化、灵活的编排体系,组件可独立调整替换,规则引擎语法简单易学。

利用LiteFlow可以把传统的瀑布式代码重构为以组件为中心的概念体系,从而获得灵活的编排能力。在这种结构里,各个组件彼此分离,允许轻松调整和替换。组件本身可通过脚本定制,而且组件间的过渡完全受规则引导。此外,LiteFlow具备简单易懂的DSL规则引擎语法,能快速入门掌握。

image.png

  • LiteFlow强大的编排能力

LiteFlow的编排语法强大到可以编排出任何你想要的逻辑流程。如下图复杂的语法,如果使用瀑布式的代码去写,那种开发以及维护难度可想而知,但是使用LiteFlow你可以轻松完成逻辑流程的编排,易于维护。

image.png

  • LiteFlow支持组件热部署

通过LiteFlow,你可以实现组件的实时热替换,同时也能在已有的逻辑流程中随时插入新的组件,以此动态调整你的业务逻辑。

image.png

LiteFlow的环境支持

  • JDK

LiteFlow要求的最低的JDK版本为8,支持JDK8~JDK17所有的版本。当然如果使用JDK11以上,确保LiteFlow的版本为v2.10.6及其以上版本。

如果你使用JDK11及其以上的版本,请确保jvm参数加上以下参数:–add-opens java.base/sun.reflect.annotation=ALL-UNNAMED

  • Spring

LiteFlow要求的Spring的最低版本为Spring 5.0。支持的范围是Spring 5.X ~ Spring 6.X。

  • SpringBoot

LiteFlow要求的Springboot的最低的版本是2.0。支持的范围是Springboot 2.X ~ Springboot 3.X。

LiteFlow的性能

LiteFlow框架在启动时完成大部分工作,包括解析规则、注册组件和组装元信息,执行链路时对系统资源消耗极低。在设计之初就注重性能表现,对核心代码进行了优化。

实际测试中,LiteFlow表现出色,50多个业务组件组成的链路在压测中单点达到1500 TPS,成功应对双11、明星顶流带货等大规模流量挑战。

尽管LiteFlow框架自身性能卓越,但实际执行效率取决于业务组件的性能。若组件包含大量循环数据库查询、不良SQL 或大量RPC同步调用,整体TPS也会较低。但这归咎于业务组件的性能问题,而非LiteFlow框架本身的性能问题。整体系统吞吐量的高低不只依赖于某个框架,而是需要整体优化业务代码才能提升。

数据来源于LiteFlow官方文档说明。

LiteFlow使用

以下我们结合SpringBoot环境使用。

LiteFlow在使用上可以按照引入依赖,LiteFlow相关配置,规则文件,定义组件,节点编排,执行流程进行。

引入依赖

1
2
3
4
5
xml复制代码<dependency>  
    <groupId>com.yomahub</groupId>  
    <artifactId>liteflow-spring-boot-starter</artifactId>  
    <version>2.11.1</version>  
</dependency>

目前liteflow的稳定版本已经更新到2.11.4.2。本文依托于2.11.1做讲解演示。好多新的功能均在2.9.0以后的版本中才有。

配置项

LiteFlow有诸多配置项,大多数配置项有默认值,可以不必配置,同时官方也建议某个配置项不了解它有什么用时,就不要去随意的改它的值。

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
python复制代码liteflow:
  #规则文件路径
  rule-source: config/flow.el.xml
  #-----------------以下非必须-----------------
  #liteflow是否开启,默认为true
  enable: true
  #liteflow的banner打印是否开启,默认为true
  print-banner: true
  #zkNode的节点,只有使用zk作为配置源的时候才起作用,默认为/lite-flow/flow
  zk-node: /lite-flow/flow
  #上下文的最大数量槽,默认值为1024
  slot-size: 1024
  #FlowExecutor的execute2Future的线程数,默认为64
  main-executor-works: 64
  #FlowExecutor的execute2Future的自定义线程池Builder,LiteFlow提供了默认的Builder
  main-executor-class: com.yomahub.liteflow.thread.LiteFlowDefaultMainExecutorBuilder
  #自定义请求ID的生成类,LiteFlow提供了默认的生成类
  request-id-generator-class: com.yomahub.liteflow.flow.id.DefaultRequestIdGenerator
  #并行节点的线程池Builder,LiteFlow提供了默认的Builder
  thread-executor-class: com.yomahub.liteflow.thread.LiteFlowDefaultWhenExecutorBuilder
  #异步线程最长的等待时间(只用于when),默认值为15000
  when-max-wait-time: 15000
  #异步线程最长的等待时间(只用于when),默认值为MILLISECONDS,毫秒
  when-max-wait-time-unit: MILLISECONDS
  #when节点全局异步线程池最大线程数,默认为16
  when-max-workers: 16
  #并行循环子项线程池最大线程数,默认为16
  parallelLoop-max-workers: 16
  #并行循环子项线程池等待队列数,默认为512
  parallelLoop-queue-limit: 512
  #并行循环子项的线程池Builder,LiteFlow提供了默认的Builder
  parallelLoop-executor-class: com.yomahub.liteflow.thread.LiteFlowDefaultParallelLoopExecutorBuilder
  #when节点全局异步线程池等待队列数,默认为512
  when-queue-limit: 512
  #是否在启动的时候就解析规则,默认为true
  parse-on-start: true
  #全局重试次数,默认为0
  retry-count: 0
  #是否支持不同类型的加载方式混用,默认为false
  support-multiple-type: false
  #全局默认节点执行器
  node-executor-class: com.yomahub.liteflow.flow.executor.DefaultNodeExecutor
  #是否打印执行中过程中的日志,默认为true
  print-execution-log: true
  #是否开启本地文件监听,默认为false
  enable-monitor-file: false
  #是否开启快速解析模式,默认为false
  fast-load: false
  #简易监控配置选项
  monitor:
    #监控是否开启,默认不开启
    enable-log: false
    #监控队列存储大小,默认值为200
    queue-limit: 200
    #监控一开始延迟多少执行,默认值为300000毫秒,也就是5分钟
    delay: 300000
    #监控日志打印每过多少时间执行一次,默认值为300000毫秒,也就是5分钟
    period: 300000

只要使用规则,则必须配置rule-source配置,但是如果你是用代码动态构建规则,则rule-source自动失效。

规则文件

从上面LiteFlow的整体架构图中可以看出LiteFlow支持多种规则文件源配置:本地文件,数据库,zk,Nacos,Apollo,Etcd,Redis以及自定义配置源。本文将会以本地规则文件为例讲解,其余配置源将在后续文章中讲解实时修改流程中在进行分享,

LiteFlow支持3种规则文件格式:XML,JSON,YML,3种文件的配置相差无几。LiteFlow的组成很轻量,主要由Node以及Chain元素构成。值得一提的是:如果在非Spring环境下,Node节点是必须的,配置配置,否则会导致报错找不到节点。当然在Spring环境下,我们可以不必配置Node节点,只需要将相应的节点注册到Spring上下文即可。

1
2
3
4
5
6
7
8
9
10
xml复制代码<?xml version="1.0" encoding="UTF-8"?>  
<flow>  
    <chain name="chain1">  
        THEN(a, b, c);  
    </chain>  

    <chain name="scChain">  
        SWITCH(s1).to(s2, THEN(a,b).id("d"));  
    </chain>
</flow>

组件

在介绍具体的组件之前,我们先来了解下@LiteflowComponent注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
less复制代码@Target({ ElementType.TYPE })  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
@Inherited  
@Component  
public @interface LiteflowComponent {  
    @AliasFor(annotation = Component.class, attribute = "value")  
    String value() default "";  
    @AliasFor(annotation = Component.class, attribute = "value")  
    String id() default ""; 
    /**
    * 可以给节点起别名
    **/
    String name() default "";  

}

@LiteflowComponent继承自@Component注解,在Spring环境中,可以将组件注入到容器中。它的value或者id即对应规则文件中的node的id。例如上述规则文件中的a,b,c等。

普通组件:NodeComponent

普通组件节点需要继承NodeComponent,需要实现process方法。可用于THEN和WHEN编排中。

1
2
3
4
5
6
7
8
java复制代码@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {  
        System.out.println("执行A规则");  
    }  
}

当然NodeComponent中还有一些其他方法可以重写,以达到自己的业务需求。例如:

  • isAccess():表示是否进入该节点,可以用于业务参数的预先判断。
  • isContinueOnError():表示出错是否继续往下执行下一个组件,默认为false
  • isEnd():是否结束整个流程(不往下继续执行)。

如果返回true,则表示在这个组件执行完之后立马终止整个流程。此时由于是用户主动结束的流程,属于正常结束,所以流程结果中(LiteflowResponse)的isSuccess是true。

  • beforeProcess()和afterProcess():流程的前置和后置处理器,其中前置处理器,在isAccess 之后执行。
  • onSuccess()和onError():流程的成功失败事件回调
  • rollback():流程失败后的回滚方法。

在任意组件节点的内部,还可以使用this关键字调用对应的方法:

  • 获取流程初始入参参数

我们在组件节点内部可以通过this.getRequestData()去获取流程初始的入参。例如:

1
2
3
4
5
6
7
8
9
java复制代码@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {  
    DataRequest dataRequest = this.getRequestData();  
        System.out.println("执行A规则");  
    }  
}
  • 获取上下文

在组件节点里,随时可以通过方法this.getContextBean(clazz)获取当前你自己定义的上下文,从而可以获取到上下文的数据。例如:

1
2
3
4
5
6
7
8
9
java复制代码@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {  
        ConditionContext context = this.getContextBean(ConditionContext.class);  
        System.out.println("执行A规则");  
    }  
}
  • setIsEnd

是否立即结束整个流程 ,用法为this.setIsEnd(true)。

还有一些其他的方法,可以参考源码。

选择组件:NodeSwitchComponent

实际业务中,我们针对不同的业务类型,有不同的业务处理逻辑,例如上一篇文章中的订单类型一样,此时就需要节点动态的判断去执行哪些节点或者链路,所以就出现了选择组件。

选择组件需要实现NodeSwitchComponent,并且需要实现processSwitch()方法。用于SWITCH编排中。

processSwitch()方法返回值是一个String,即下一步流程执行的节点ID或者链路tag。

1
2
3
4
5
6
7
8
9
java复制代码@LiteflowComponent("s)
public class SwitchComponent extends NodeSwitchComponent {  

    @Override  
    public String processSwitch() throws Exception {  
        System.out.println("执行switch规则");  
        return "a";  
    }  
}

规则文件中,配置的SWITCH编排信息为:

1
2
3
ini复制代码<chain name="scChain">  
    SWITCH(s).to(a, b, c);  
</chain>

此时s节点就会返回要执行的节点id为a,即要执行a流程。通常switch的节点的逻辑我们需要具体结合业务类型,例如订单类型枚举去使用。

除了可以返回id以外,我们还可以返回tag(标签)。例如我们在规则文件中这么写:

在规则表达式中我们可以这样使用:

1
2
3
css复制代码<chain name="scChain">  
    SWITCH(s).to(a.tag("td"), b.tag("td"), c.tag("td));  
</chain>

然后在SWITCH中返回tag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@LiteflowComponent("s)
public class SwitchComponent extends NodeSwitchComponent {  

    @Override  
    public String processSwitch() throws Exception {  
        System.out.println("执行switch规则");  
        return ":td"      // 进入 b 节点,含义:选择第一个标签为td的节点
        return "tag:td"   // 进入 b 节点,含义:选择第一个标签为td的节点
        return "a";       // 进入 b 节点,含义:选择targetId是b的节点
        return "b:";      // 进入 b 节点,含义:选择第一个targetId是b的节点
        return "b:td";    // 进入 b 节点,含义:选择targetId是b且标签是td的节点
        return ":";       // 进入 b 节点,含义:选择第一个节点
        return "d";       // 进入 d 节点,含义:选择targetId是d的节点
        return "d:";      // 进入 d 节点,含义:选择第一个targetId是d的节点
        return "d:td";    // 进入 d 节点,含义:选择targetId是d且标签是td的节点
        return "b:x";     // 报错,原因:没有targetId是b且标签是x的节点
        return "x";       // 报错,原因:没有targetId是x的节点
        return "::";      // 报错,原因:没有找到标签是":"的节点 
    }  
}

NodeSwitchComponent继承至NodeComponent,其节点的内部可以覆盖的方法和this关键字NodeComponent。

条件组件:NodeForComponent

条件组件,也是IF组件,返回值是一个boolean。需要继承NodeForComponent,实现processIf()方法。可用于IF...ELIF...ELSE编排。例如:

1
2
3
ini复制代码<chain name = "ifChain">  
    IF(x, a, b);  
</chain>

该例中x就是一个条件组件,如果x返回true,则会执行a节点,否则执行b节点。

1
2
3
4
5
6
7
8
9
java复制代码@LiteflowComponent("x")  
public class IfXComponent extends NodeIfComponent {  

    @Override  
    public boolean processIf() throws Exception {  
        System.out.println("执行X节点");  
        return false;  
    }  
}

NodeIfComponent继承至NodeComponent,其节点内部可以覆盖的方法和this关键字NodeComponent。

次数循环组件:NodeForComponent

次数循环组件。返回的是一个int值的循环次数。继承NodeForComponent,实现processFor()方法, 主要用于FOR...DO...表达式。在紧接着DO编排中的节点中,可以通过this.getLoopIndex()获取下标信息,可以从对应数组或者集合中通过下表获取对应的元素信息。

1
2
3
ini复制代码<chain name = "forChain">  
    FOR(f).DO(a);  
</chain>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@LiteflowComponent("f")  
public class ForComponent extends NodeForComponent {  

    @Override  
    public int processFor() throws Exception {  
        DataContext dataContext = this.getContextBean(DataContext.class);  
        List<String> dataList = dataContext.getDataList();  
        return dataList.size();  
    }  
}

@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {  
        Integer loopIndex = this.getLoopIndex();  
        DataContext dataContext = this.getContextBean(DataContext.class);  
        List<String> dataList = dataContext.getDataList();  
        String str = dataList.get(loopIndex);  
        System.out.println("执行A规则:"+str);  
    }  
}

其中f组件相当于定义一个数组或者集合的元素个数,类似

1
2
3
arduino复制代码for(int i=0;i<size;i++){ // size = f 
//逻辑处理  = a
}

NodeForComponent继承至NodeComponent,其节点内部可以覆盖的方法和this关键字NodeComponent。

条件循环组件:NodeWhileComponent

条件循环组件,主要用于WHILE...DO...表达式。继承NodeWhileComponent,需要实现processWhile()方法。processWhile()方法返回一个boolean类型的值,即while循环跳出的条件,如果为false则循环结束,同次数循环,可以在DO编排中的节点中,可以通过this.getLoopIndex()获取下标信息,可以从对应数组或者集合中通过下表获取对应的元素信息。

1
2
3
ini复制代码<chain name = "whileChain">  
    WHILE(w).DO(a);  
</chain>
1
2
3
4
5
6
7
8
9
10
11
java复制代码@LiteflowComponent("w")  
public class WhileComponent extends NodeWhileComponent {  

    @Override  
    public boolean processWhile() throws Exception {  
        DataContext dataContext = this.getContextBean(DataContext.class);  
        Integer count = Optional.ofNullable(dataContext.getCount()).orElse(0);  
        List<String> dataList = dataContext.getDataList();  
        return count < dataList.size();  
    }  
}

NodeWhileComponent继承至NodeComponent,其节点内部可以覆盖的方法和this关键字NodeComponent。

迭代循环组件:NodeIteratorComponent

迭代循环组件,相当于Java语言的Iterator关键字,功能上相当于for循环,主要用于ITERATOR...DO...表达式。需要继承NodeIteratorComponent,实现processIterator()方法。在DO编排的节点中,可以通过this.getCurrLoopObj()获取集合中的信息。这个组件在使用liteflow的循环组件时用的比较多,就像日常开发代码,集合遍历大部分都会使用for循环(特殊情况必须使用下标除外)。

1
2
3
ini复制代码<chain name = "iteratorChain">  
    ITERATOR(iterator).DO(a);  
</chain>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码@LiteflowComponent("iterator")  
public class MyIteratorComponent extends NodeIteratorComponent {  

    @Override  
    public Iterator<?> processIterator() throws Exception {  
        DataContext dataContext = this.getContextBean(DataContext.class); 
        return Optional.ofNullable(dataContext.getDataList())
            .orElse(Lists.newArrayList()).iterator();  
    }  
}

@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {  
        String str = this.getCurrLoopObj();  
        System.out.println("执行A规则:"+str); 
    }  
}

NodeIteratorComponent继承至NodeComponent,循环组件节点的内部可以覆盖的方法和this关键字NodeComponent。

退出循环组件:NodeBreakComponent

退出循环组件,即BREAK组件。返回的是一个布尔值的循环退出标志。 需要继承NodeBreakComponent,实现processBreak方法。主要用于FOR...DO...BREAK,WHILE...DO...BREAK,ITERATOR...DO...BREAK表达式。即Java的for,while循环退出。

1
2
3
ini复制代码<chain name = "iteratorChain">  
    ITERATOR(iterator).DO(a).BREAK(break_flag);  
</chain>
1
2
3
4
5
6
7
8
9
java复制代码@LiteflowComponent("break_flag")  
public class BreakComponent extends NodeBreakComponent {  

    @Override  
    public boolean processBreak() throws Exception {  
        String str = this.getCurrLoopObj();  
        return Objects.equals("c", str);  
    }  
}

同理NodeBreakComponent也是继承NodeComponent,其节点内部可以覆盖的方法和this关键字NodeComponent。

接下来我们聊一下组件的另外一种定义方式:声明式组件。我比较喜欢用。。。

声明式组件

在上述介绍组件时,都是通过定义一个类继承某一个组件,例如NodeComponent或者NodeIteratorComponent,这样的定义组件会有一些弊端,比如当你的业务庞大时类也会快速的膨胀增加,即使一个跳出循环或者循环组件都要单独去定义一个类(个人认为循环组件其实不会包含太多的复杂业务逻辑),再比如说Java中类是单继承,这样就会造成这个写组件类无法再去继承一些其他的超类供我们使用。基于此,LiteFlow推出依靠注解完成组件的声明,即使一个普通类中的方法不需要继承任何组件类,也可以声明为一个组件,一个类可以定义很多个组件。可以分别对类或者方法进行生命组件。目前声明式组件只能在springboot环境中使用。

类级别声明

类级别式声明主要用处就是通过注解形式让普通的java bean变成LiteFlow的组件。无需通过继承类或者实现接口的方式。但是类级别声明有一个缺点就是他和常规组件一样,需要一个类对应一个组件。使用@LiteflowCmpDefine注解,通过NodeTypeEnum指定当前类是什么类型的组件。NodeTypeEnum值如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vbnet复制代码public enum NodeTypeEnum {  
    COMMON("common", "普通", false, NodeComponent.class),  
    SWITCH("switch", "选择", false, NodeSwitchComponent.class),  
    IF("if", "条件", false, NodeIfComponent.class),  
    FOR("for", "循环次数", false, NodeForComponent.class),  
    WHILE("while", "循环条件", false, NodeWhileComponent.class),  
    BREAK("break", "循环跳出", false, NodeBreakComponent.class),  
    ITERATOR("iterator", "循环迭代", false, NodeIteratorComponent.class),  
    SCRIPT("script", "脚本", true, ScriptCommonComponent.class),  
    SWITCH_SCRIPT("switch_script", "选择脚本", true, ScriptSwitchComponent.class),  
    IF_SCRIPT("if_script", "条件脚本", true, ScriptIfComponent.class),  
    FOR_SCRIPT("for_script", "循环次数脚本", true, ScriptForComponent.class),  
    WHILE_SCRIPT("while_script", "循环条件脚本", true, ScriptWhileComponent.class),  
    BREAK_SCRIPT("break_script", "循环跳出脚本", true, ScriptBreakComponent.class);
}

组件类中的再通过@LiteflowMethod注解将方法映射为组件方法。通过@LiteflowMethod中value值指定方法类型LiteFlowMethodEnum,通过nodeType指定节点类型NodeTypeEnum。LiteFlowMethodEnum对应各组件中的抽象类方法(isMainMethod=true)(或者可覆盖的方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
arduino复制代码public enum LiteFlowMethodEnum {  

    PROCESS("process", true),  
    PROCESS_SWITCH("processSwitch", true),  
    PROCESS_IF("processIf", true),  
    PROCESS_FOR("processFor", true),  
    PROCESS_WHILE("processWhile", true),  
    PROCESS_BREAK("processBreak", true),  
    PROCESS_ITERATOR("processIterator", true),  
    IS_ACCESS("isAccess", false),  
    IS_END("isEnd", false),  
    IS_CONTINUE_ON_ERROR("isContinueOnError", false),  
    GET_NODE_EXECUTOR_CLASS("getNodeExecutorClass", false),  
    ON_SUCCESS("onSuccess", false),  
    ON_ERROR("onError", false),  
    BEFORE_PROCESS("beforeProcess", false),  
    AFTER_PROCESS("afterProcess", false),  
    GET_DISPLAY_NAME("getDisplayName", false), 
    ROLLBACK("rollback", false)  
    ;

    private String methodName;  
    private boolean isMainMethod;
}

对于方法的要求:

组件内的方法的参数必须传入NodeComponent类型的参数,而且必须是第一个参数。这个参数值就替代常规组件中的this,从这个参数中可以获取流程入参,上线文等信息。然后方法的返回值必须跟常规组件中的抽象方法的返回值保持一致,否则可能吹出现错误。对于方法名称并无限制。

  • 普通组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Component("d")  
@LiteflowCmpDefine(value = NodeTypeEnum.COMMON)  
public class MyDefineCmp {  

    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeType = NodeTypeEnum.COMMON)  
    public void processA(NodeComponent nodeComponent){  
        System.out.println("processA");  
    }  

    @LiteflowMethod(value = LiteFlowMethodEnum.BEFORE_PROCESS, nodeType = NodeTypeEnum.COMMON)  
    public void beforeA(NodeComponent nodeComponent){  
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
        System.out.println("beforeA");  
    }  

    @LiteflowMethod(value = LiteFlowMethodEnum.AFTER_PROCESS, nodeType = NodeTypeEnum.COMMON)  
    public void afterA(NodeComponent nodeComponent){  
        System.out.println("afterA");  
    }  
}
  • 条件组件

声明选择组件在类和方法上都需要加上NodeTypeEnum.IF参数。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码@Component("define_if")  
@LiteflowCmpDefine(NodeTypeEnum.IF)  
public class MyDefineIfCpm {  
    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_IF, nodeType = NodeTypeEnum.IF)  
    public boolean processIf(NodeComponent nodeComponent){ 
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);   
        System.out.println("执行if");  
        return false;  
    }  
}
  • 选择组件

声明选择组件在类和方法上都需要加上NodeTypeEnum.SWITCH参数。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@Component("define_w")  
@LiteflowCmpDefine(NodeTypeEnum.SWITCH)  
public class MyDefineSwitchCpm {  

    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_SWITCH, nodeType = NodeTypeEnum.SWITCH)  
    public String processSwitch1(NodeComponent nodeComponent){ 
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
        System.out.println("执行switch");  
        return "b";  
    }  
}
  • 次数循环组件

声明选择组件在类和方法上都需要加上NodeTypeEnum.FOR参数。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@Component("define_for")  
@LiteflowCmpDefine(NodeTypeEnum.FOR)  
public class MyDefineForCmp {  

    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_FOR, nodeType = NodeTypeEnum.FOR)  
    public int processFor(NodeComponent nodeComponent){  
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
        System.out.println("执行for");  
        return 10;  
    }  
}
  • 条件循环组件

声明选择组件在类和方法上都需要加上NodeTypeEnum.WHILE参数。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@Component("define_while")  
@LiteflowCmpDefine(NodeTypeEnum.WHILE)  
public class MyDefineWhileCmp {  

    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_WHILE, nodeType = NodeTypeEnum.WHILE)  
    public boolean processWhile(NodeComponent nodeComponent){  
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
        System.out.println("执行while");  
        return true;  
    }  
}
  • 迭代循环组件

声明选择组件在类和方法上都需要加上NodeTypeEnum.ITERATOR参数。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@Component("define_iterator")  
@LiteflowCmpDefine(NodeTypeEnum.ITERATOR)  
public class MyDefineIteratorCpm {  

@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_ITERATOR, nodeType = NodeTypeEnum.ITERATOR)  
public Iterator<String> processSwitch1(NodeComponent nodeComponent){  
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
        System.out.println("执行iterator");  
        return dataContext.getDataList().iterator();  
    }  
}
  • 退出循环组件

声明选择组件在类和方法上都需要加上NodeTypeEnum.BREAK参数。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@Component("define_break")  
@LiteflowCmpDefine(NodeTypeEnum.BREAK)  
public class MyDefineWhileCmp {  

    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_BREAK, nodeType = NodeTypeEnum.BREAK)  
    public boolean processBreak(NodeComponent nodeComponent){  
        DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
        System.out.println("执行break");  
        return true;  
    }  
}
方法级别式声明

因为类级别式声明还是会造成类定义过多的问题,LiteFlow又提供了方法级别式声明。方法级别式声明可以让在一个类中通过注解定义多个组件。在类上使用@LiteflowComponent进行声明这是一个组件类,然后在方法使用@LiteflowMethod声明方法是一个组件节点。如下:

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
typescript复制代码@Slf4j  
@LiteflowComponent  
public class OrderHandlerCmp {  


    /**  
    * 普通组件 等价于 继承`NodeComponent` 实现process()方法  
    * @param nodeComponent  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.COMMON, value = LiteFlowMethodEnum.PROCESS, nodeId = "common", nodeName = "普通组件")  
    public void processCommon(NodeComponent nodeComponent){  
        // 业务逻辑  
    }  

    /**  
    * IF组件 等价于 继承 `NodeIfComponent` 实现processIf()方法  
    * @param nodeComponent  
    * @return  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.IF, value = LiteFlowMethodEnum.PROCESS_IF, nodeId = "if", nodeName = "IF组件")  
    public boolean processIf(NodeComponent nodeComponent){  
        // 业务逻辑  
        return false;  
    }  

    /**  
    * SWITCH组件 等价于 继承 `NodeSwitchComponent` 实现processSwitch()方法  
    * @param nodeComponent  
    * @return  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.SWITCH, value = LiteFlowMethodEnum.PROCESS_SWITCH, nodeId = "switch", nodeName = "SWITCH组件")  
    public String processSwitch(NodeComponent nodeComponent){  
        // 业务逻辑  
        return "nodeId";  
    }  

    /**  
    * 次数循环组件 等价于 继承 `NodeForComponent` 实现processFor()方法  
    * @param nodeComponent  
    * @return  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.FOR, value = LiteFlowMethodEnum.PROCESS_FOR, nodeId = "for", nodeName = "FOR组件")  
    public int processFor(NodeComponent nodeComponent){  
    // 业务逻辑  
    return 10;  
    }  

    /**  
    * 条件循环组件 等价于 继承 `NodeWhileComponent` 实现processWhile()方法  
    * @param nodeComponent  
    * @return  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.WHILE, value = LiteFlowMethodEnum.PROCESS_WHILE, nodeId = "while", nodeName = "WHILE组件")  
    public boolean processWhile(NodeComponent nodeComponent){  
        // 业务逻辑  
        return false;  
    }  

    /**  
    * 迭代循环组件 等价于 继承 `NodeIteratorComponent` processIterator()方法  
    * @param nodeComponent  
    * @return  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.ITERATOR, value = LiteFlowMethodEnum.PROCESS_ITERATOR, nodeId = "iterator", nodeName = "ITERATOR组件")  
    public Iterator<Object> processIterator(NodeComponent nodeComponent){  
    // 业务逻辑  
    List<Object> list = Lists.newArrayList();  
    return list.iterator();  
    }  

    /**  
    * 跳出循环组件 等价于 继承 `NodeBreakComponent` processBreak()方法  
    * @param nodeComponent  
    * @return  
    */  
    @LiteflowMethod(nodeType = NodeTypeEnum.BREAK, value = LiteFlowMethodEnum.PROCESS_BREAK, nodeId = "break", nodeName = "BREAK组件")  
    public boolean processBreak(NodeComponent nodeComponent){  
        // 业务逻辑  
        return false;  
    }  
}

对于方法级别声明特性来说,@LiteflowMethod注解上的nodeId一定要写。nodeName的属性。方便对声明式的组件进行命名。定义方法时,返回值要和常规组件里的对应方法返回值一致。例如普通组件的process方法是不返回的,比如IF组件的processIf方法是返回布尔值的。如果写错误,会造成一些异常。。。。

我个人是比较喜欢用这种方式进行组件的定义,可以按照业务逻辑将代码拆分成一个个的模块,在各自的模块中进行业务逻辑的实现,也会非常清晰。

EL规则

LiteFlow2.8.x以后版本提供了一款强大的规则表达式。一切复杂的流程在LiteFlow表达式的加持下,都变得非常简便明了。配合一些流程图简直是通熟易懂。让整个业务流程在处理上看起来并没有那么黑盒。它可以设置各种编排规则,包括:

  • 串行编排

串行编排,即组件要顺序执行,使用THEN关键字, THEN必须大写

1
2
3
4
ini复制代码<chain name="thenchain">  
    THEN(a, b, c);  
    THEN(a, THEN(b, c));  
</chain>
  • 并行编排

并行编排即并行执行若干个个组件,使用用WHEN关键字, WHEN必须大写。

1
2
3
ini复制代码<chain name="whenchain">  
    WTHEN(a, b, c);  
</chain>

当然,WHEN跟THEN还可以结合使用:

1
2
3
ini复制代码<chain name="testChain">  
    THEN(a, WHEN(b, c, d), e);  
</chain>

当a节点执行完成之后,并行执行b,c,d节点,完成之后在执行e节点。

image.png

image.png

我们在看到并行执行的时候,就会联想到多线程处理,那么LiteFlow是怎么创建多线程的呢?答案是LiteFlow内部默认维护了一个when线程池,这个线程池是供给所有WHEN流程使用的。当然你可以在LiteFlow执行器执行之前给你的流程通过LiteflowConfig传入一些线程池参数或者实现ExecutorBuilder接口,自定义线程池。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public class LiteFlowThreadPool implements ExecutorBuilder {  

    @Override  
    public ExecutorService buildExecutor() {  
        ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("mythread-pool-%s").build();  
        return new ThreadPoolExecutor(  
                    // 核心线程数,即2个常开窗口  
                    2,  
                    // 最大的线程数,银行所有的窗口  
                    5,  
                    // 空闲时间  
                    5,  
                    TimeUnit.SECONDS,  
                    // 工作队列  
                    new LinkedBlockingQueue<>(5),  
                    // 线程工厂  
                    threadFactory,  
                    // 拒绝策略  
                    new ThreadPoolExecutor.AbortPolicy()  
        );  

    }  
}

然后我们在LiteflowConfig设置并行线程执行器class路径threadExecutorClass:

1
2
3
4
ini复制代码LiteflowConfig liteflowConfig = new LiteflowConfig();  
liteflowConfig.setThreadExecutorClass("LiteFlowThreadPool的类路径");  
flowExecutor.setLiteflowConfig(liteflowConfig);  
LiteflowResponse response = flowExecutor.execute2Resp("testChain", null);

执行结果,可以看见线程池使用的是自定义的:

image.png

LiteFlow从2.11.1开始,提供一个liteflow.when-thread-pool-isolate参数,默认为false,如果设为true,则会开启WHEN的线程池隔离机制,这意味着每一个when都会有单独的线程池。

在多线程执行下,我们还有一个疑问,如果其中某个或者某几个并行分支发生异常执行失败那么后面的节点会不会收到影响?假如我们把C节点抛出一个异常,发现流程直接就结束了,并没有执行最后的E节点:

image.png

对于这种情况,LiteFlow的WHEN关键字提供了ignoreError(默认为false)来提供忽略错误的特性。我们修改流程如下:

1
2
3
ini复制代码<chain name="testChain">  
    THEN(test_a, WHEN(test_b, test_c, test_d).ignoreError(true), test_e);  
</chain>

再次执行发现流程执行到了E节点:

image.png

LiteFlow还提供了对WHEN并行流程中,使用子关键字any(默认为false)可以设置任一条分支先执行完即忽略其他分支,继续执行的特性。

1
2
3
sql复制代码<chain name="testChain">  
    THEN(test_a, WHEN(test_b, test_c, test_d).ignoreError(true).any(true), test_e);  
</chain>

我们将C节点Sleep 10秒,可以发现C节点并没有执行,就执行到了E节点:

image.png

除此之外,LiteFlow还支持了并行编排中指定节点的执行则忽略其他,WHEN关键字子关键字 must (不可为空),可用于指定需等待执行的任意节点,可以为 1 个或者多个,若指定的所有节点率先完成,则继续往下执行,忽略同级别的其他任务。我们将流程调节如下:

1
2
3
ini复制代码<chain name="testChain">  
    THEN(test_a, WHEN(test_b, test_c, test_d).ignoreError(true).must(test_c), test_e);  
</chain>

我们还是将C节点Sleep 10秒,发现流程一直等到C节点执行结束才会执行后面的节点:

image.png

image.png

must子关键字在LiteFlow从v2.11.1版本之后才有。

  • 选择编排

在实现业务逻辑过程中,我们常见的就是根据某种标识去进行不同的业务流程,通常我们也可以使用策略模式进行实现。在LiteFlow中可以通过SWITCH..TO()选择编排,即SWITCH中的流程返回后面TO中那个节点就会执行那个节点,我们只需要处理好SWITCH中条件于TO中分支的关系即可。增加一个Switch组件:

1
2
3
4
5
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_SWITCH, nodeType = NodeTypeEnum.SWITCH, nodeId = "test_w", nodeName = "测试组件W")  
public String processSwitch(NodeComponent nodeComponent){  
    log.info("执行W节点");  
    return "test_a";  
}

然后我们规则编排:

1
2
3
ini复制代码<chain name="testSwitchChain">  
    SWITCH(test_w).TO(test_a, test_b, test_c, test_d, test_e);  
</chain>

执行流程:

image.png

SWITCH还提供了子关键字DEFAULT,如果SWITCH返回的节点不是TO中的节点,则就走DEFAULT中指定的节点。

1
2
3
ini复制代码<chain name="testSwitchChain">  
    SWITCH(test_w).TO(test_a, test_b, test_c, test_d, test_e).DEFAULT(test_y);  
</chain>

image.png

由选择组件章节中我们知道,SWITCH可以返回ID或者链路Tag,上述例子中返回的test_a就是一个节点ID(对应@LiteflowMethod中指定的nodeId中的值)。当让在规则中我们也可以给表达式设置一个id。LiteFlow中规定,每个表达式都可以有一个id值,你可以设置id值来设置一个表达式的id值。然后在选择组件里返回这个id即可。

1
2
3
scss复制代码<chain name="testSwitchChain">  
    SWITCH(test_w).TO(test_a, THEN(test_b, test_c, test_d).id("test_bcd"), test_e).DEFAULT(test_y);  
</chain>

假如此时test_w表达式返回的是test_bcd,则流程就会执行test_b, test_c, test_d节点:

image.png

除了给表达式赋值id属性之外,还可以给表达式赋值tag属性。在SWITCH中返回tag。

1
2
3
scss复制代码<chain name="testSwitchChain">  
    SWITCH(test_w).TO(test_a, THEN(test_b, test_c, test_d).tag("test_tag"), test_e).DEFAULT(test_y);  
</chain>

我们SWITCH组件中返回tag标签:

1
2
3
4
5
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_SWITCH, nodeType = NodeTypeEnum.SWITCH, nodeId = "test_w", nodeName = "测试组件W")  
public String processSwitch(NodeComponent nodeComponent){  
    log.info("执行W节点");  
    return "tag:test_tag";  
}

image.png

  • 条件编排

条件编排类似Java中的if…else,它有IF,IF…ELIF,ELSE几种写法。其中IF以及ELIF中的表达式对应IF组件中返回的boolean结果。对与IF有二元表达式:

1
2
3
ini复制代码<chain name="testIfChain1">  
    IF(test_f, test_a);  
</chain>

IF后面还可以跟ELSE。类似Java中的else。

1
2
3
ini复制代码<chain name="testIfChain">  
    IF(test_f, test_a).ELSE(test_b);  
</chain>

IF还支持三元表达式,上面的二元表达式等价于如下三元表达式写法:

1
2
3
ini复制代码<chain name="testIfChain">  
    IF(test_f, test_a, test_b);  
</chain>

上面两种表达式都可以解读为:如果test_f中返回true则执行test_a节点,否则执行test_b节点。

1
2
3
4
5
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_IF, nodeType = NodeTypeEnum.IF, nodeId = "test_f", nodeName = "测试组件F")  
public boolean processF(NodeComponent nodeComponent){  
    log.info("执行F节点");  
    return true;  
}

image.png

我们再看一下ELIF的写法,ELIF类似Java中的else if的写法,它的后面也可以跟ELSE。

1
2
3
ini复制代码<chain name="testIfChain">   
    IF(test_f, test_a).ELIF(test_x, test_b);  
</chain>

我们在订一个test_x的IF组件:

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_IF, nodeType = NodeTypeEnum.IF, nodeId = "test_f", nodeName = "测试组件F")  
public boolean processF(NodeComponent nodeComponent){  
    log.info("执行F节点");  
    return false;  
}  

@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_IF, nodeType = NodeTypeEnum.IF, nodeId = "test_x", nodeName = "测试组件X")  
public boolean processX(NodeComponent nodeComponent){  
    log.info("执行X节点");  
    return true;  
}

image.png

image.png

test_f节点返回false,所以不会执行test_a,继续执行test_x节点,返回true,则会执行test_b节点。

当然ELIF后面也可以使用ELSE。

1
2
3
ini复制代码<chain name="testIfChain">   
    IF(test_f, test_a).ELIF(test_x, test_b).ELSE(test_c);
</chain>

此时如果test_f以及test_x都返回false,就会走test_c。

image.png

值得注意的是,当我们使用IF的二元表达式时才会去跟上ELIF以及ELSE。如果是三元表达式我们不可以使用ELIF以及ELSE,会报错。

在2.10.0以前可以使用,但是在IF中的最后一个表达式会被ELIF或者`ELSE中的表达式

覆盖掉。本人只调研到了2.10.0,在这个版本中还是会报错。

  • 循环编排

循环编排类似Java中的循环,分为次数循环(FOR...DO()),条件循环(WHILE...DO())以及迭代循环(ITERATOR...DO()),同时还涉及跳出循环编排(BREAK)。

我们定义一个固定次数的FOR循环:

1
2
3
ini复制代码<chain name="testForChain">  
    FOR(3).DO(THEN(test_a, test_b));  
</chain>

此时会将test_a,test_b循环执行3次。

image.png

当然实际开发中,我们需要搭配FOR循环组件使用,即在FOR循环中返回需要执行的次数:

1
2
3
ini复制代码<chain name="testForChain">  
    FOR(test_w).DO(THEN(test_a, test_b));  
</chain>

假如test_w组件中返回次数是3,则执行效果如上固定次数。

接下来我们看一下WHILE条件循环,WHILE的表达式需要结合WHILE组件使用,返回一个

boolean类型的值,去控制循环的流程,如果为true则继续循环,否则结束循环。

1
2
3
ini复制代码<chain name="testWhileChain">  
    WHILE(test_h).DO(THEN(test_a, test_b));  
</chain>

接下来我们继续看一下迭代循环ITERATOR,类似于Java中的for循环。这里我们要配合ITERATOR组件使用,返回一个集合的迭代器。

我们定义一个迭代循环编排:

1
2
3
ini复制代码<chain name = "testIteratorChain">  
    ITERATOR(test_i).DO(test_p);  
</chain>

然后我们在定义一个迭代组件以及一个普通组件用于打印集合中的元素:

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_ITERATOR, nodeType = NodeTypeEnum.ITERATOR, nodeId = "test_i", nodeName = "测试组件I")  
public Iterator<String> processI(NodeComponent nodeComponent){  
    List<String> list = Lists.newArrayList("A", "B", "C","D");  
    return list.iterator();  
}  

@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeType = NodeTypeEnum.COMMON, nodeId = "test_p", nodeName = "测试打印组件")  
public void printData(NodeComponent nodeComponent){  
    String str = nodeComponent.getCurrLoopObj();  
    System.out.println(str);  
}

image.png

有循环编排,就相应的要有跳出循环编排,我们可以使用BREAK编排,配合BREAK组件使用。

1
2
3
4
5
6
7
8
9
10
11
ini复制代码<chain name="testForChain">  
    FOR(test_j).DO(THEN(test_a, test_b)).BREAK(test_break);  
</chain>  

<chain name="testWhileChain">  
    WHILE(test_h).DO(THEN(test_a, test_b)).BREAK(test_break);  
</chain>  

<chain name = "testIteratorChain">  
    ITERATOR(test_i).DO(test_p).BREAK(test_break);  
</chain>

我们以迭代循环跳出为例:

1
2
3
4
5
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_BREAK, nodeType = NodeTypeEnum.BREAK, nodeId = "test_break", nodeName = "测Break组件")  
public boolean processBreak(NodeComponent nodeComponent){  
    String str = nodeComponent.getCurrLoopObj();  
    return Objects.equals("C", str);  
}

image.png

即执行到C元素时返回true,跳出循环。

LiteFlow从v2.9.0开始,提供了循环编排表达式组合。

  • 异常捕获

EL表达式中还增加了异常捕获表达式,用于捕获节点中的异常。类似Java中的try…catch。用法为CATCH…DO(DO关键字非必须),如果在CATCH的表达式中捕获到了异常,可以在DO表达式中的节点进行处理,可以使用nodeComponent.getSlot().getException()获取异常信息。但是有一点我们需要注意,假如我们使用了CATCH,如果其中的节点中发生了异常,那么我们在流程执行的结果中也会看到流程执行成功的标识(isSuccess=true),可以理解,发生的异常被你捕获处理了。

1
2
3
4
5
ini复制代码<chain name="testCatchChain">  
    CATCH(  
    THEN(test_a, test_b)  
    ).DO(test_catch);  
</chain>

我们在test_b节点中手动抛出一个RuntimeException,在test_catch中使用nodeComponent.getSlot().getException()打印捕获到的异常,同时我们在流程执行结果中打印isSuccess看流程是否执行成功:

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeType = NodeTypeEnum.COMMON, nodeId = "test_catch", nodeName = "测试Catch组件")  
public void processCatch(NodeComponent nodeComponent){  
    log.error("执行Catch节点,捕获到了异常\n", nodeComponent.getSlot().getException());  
}

@Test  
public void testCatch(){  
    LiteflowResponse response = flowExecutor.execute2Resp("testCatchChain", null);  
    System.out.println(response.isSuccess() ? "执行成功" : "执行失败");  
    System.out.println("结果中的异常信息:" + response.getCause());  
}

image.png

image.png

可以看到test_catch打印了异常信息,同时我们可以看到流程执行结果中返回执行成功,没有异常信息。

image.png

同时CATCH配合迭代循环还可以达到JavaforEach循环的continue的效果。

1
2
3
ini复制代码<chain name = "testIteratorCatchChain">  
    ITERATOR(test_i).DO(CATCH(THEN(test_pa, test_pb, test_pc)));  
</chain>

我们在test_pb在打印C时抛出异常

1
2
3
4
5
6
7
8
typescript复制代码@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeType = NodeTypeEnum.COMMON, nodeId = "test_pb", nodeName = "测试打印组件B")  
public void printPB(NodeComponent nodeComponent){  
    String str = nodeComponent.getCurrLoopObj();  
    System.out.println("B组件打印:"+ str);  
    if (Objects.equals("B", str)){  
        throw new RuntimeException("B组件发生异常了。。。。");  
    }  
}

image.png

image.png

此时没有执行C组件打印B,直接跳过了test_pc节点。

LiteFlow从2.10.0开始提供CATCH表达式

  • 与或非表达式

与或非表达式即AND,OR,NOT表达式。可以用于返回boolean值的组件的编排。可以将若干个这种组件编排在一起返回应该boolean值进行后续流程的判断。

1
2
3
ini复制代码<chain name = "testAndOrNotChain">  
    IF(AND(test_f, test_x), test_a, test_c);  
</chain>

此时只有test_f和test_x 节点都返回true,就会走test_a,否则走test_c。

1
2
3
ini复制代码<chain name = "testAndOrNotChain">  
    IF(OR(test_f, test_x), test_a, test_c);  
</chain>

如果是OR,test_f和test_x 节点都返回false,就会走test_c,否则走test_c。

1
2
3
ini复制代码<chain name = "testAndOrNotChain">  
    IF(NOT(test_f), test_a, test_c);  
</chain>

NOT即非的意思,如果test_f返回true,则就会走test_c节点,否则走test_a节点。

AND,OR,NOT三种表达式可以相互组合使用。但是只能用于返回boolean值的组件上。

  • 子流程

在日常处理复杂业务时,流程编排的规则会嵌套很多层,可以想象一下那样的流程读起来也比较头疼,而且事实上我们在开发中是需要将复杂的流程业务去拆分成一个个独立的子流程去实现。

image.png

如上图它的规则如下:

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
less复制代码<chain name = "order_handle">  
THEN(  
    SWITCH(order_x).TO(  
    THEN(  
        order_a,  
        order_c,  
        IF(  
            order_k,  
                THEN(  
                    order_d,  
                    order_f  
                ),  
            order_e)  
        ).id("to_c"),  
    THEN(  
        order_b,  
        order_c,  
            IF(  
                order_k,  
                THEN(  
                    order_d,  
                    order_f  
            ),  
        order_e)  
    ).id("to_b")  
    )  
);  
</chain>

这样写其实也可以,但是读起来理解起来不号。这时我们就可以子流程进行改造。我们按照to_c,to_b流程进行拆分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ini复制代码<chain name="order_handle">
    // 主流程  
    THEN(SWITCH(order_x).TO(THEN(to_c).id("to_c"), THEN(to_b).id("to_b")), order_h);  
</chain>

<chain name="order_if">  
    IF(order_k, THEN(order_d, order_f), order_e);  
</chain>  

<chain name = "to_c">  
    THEN(order_a, order_c, order_if);  
</chain>  

<chain name = "to_b">  
    THEN(order_b, order_c, order_if);  
</chain>

这样流程上就清晰了很多。

  • 子变量

在复杂流程的编排上,我们不仅可以使用子流程,还可以使用子变量的方式。我们可以直接在流程中定义变量。如上述例子使用子变量可以改造为:

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码<chain name="order_handle">  
    // 定义一个if节点处理df/e  
    order_if = IF(order_k, THEN(order_d, order_f), order_e);  

    // 定义to_c的订单流程 用id标识流程为to_c  
    to_c = THEN(order_a, order_c, order_if).id("to_c");  

    // 定义to_b的订单流程 用id标识为流程to_b  
    to_b = THEN(order_b, order_c, order_if).id("to_b");  

    // 主流程  
    THEN(SWITCH(order_x).TO(to_c, to_b), order_h);  
</chain>

这样也可以清晰。

  • 其他

我们在上述一些示例中,每个语句后都加了分号:;,关于规则中的分号,我们链路中只有一条规则的时候(没有自变量)可以不加也可以运行,但是如果存在自变量,一定要在自变量中加上分号,否则汇报错。同时官方也建议不管是否存在子变量,都要加上分号。

另外,EL中我们使用//定义注释。

执行器

在上述的一些示例中,我们使用了flowExecutor去执行规则。FlowExecutor就是流程的执行器,是一个流程执行的触发点。在Spring或者SprigBoot环境下我们可以直接注入FlowExecutor进行使用。

FlowExecutor中提供同步以及异步两种类型的方法,同步方法直接返回LiteflowResponse,而异步返回的是Future<LiteflowResponse>。同步方法如下:

1
2
3
4
5
6
7
8
9
10
typescript复制代码//参数为流程ID,无初始流程入参,上下文类型为默认的DefaultContext
public LiteflowResponse execute2Resp(String chainId)
//第一个参数为流程ID,第二个参数为流程入参。上下文类型为默认的DefaultContext
public LiteflowResponse execute2Resp(String chainId, Object param);
//第一个参数为流程ID,第二个参数为流程入参,后面可以传入多个上下文class
public LiteflowResponse execute2Resp(String chainId, Object param, Class<?>... contextBeanClazzArray)
//第一个参数为流程ID,第二个参数为流程入参,后面可以传入多个上下文的Bean
public LiteflowResponse execute2Resp(String chainId, Object param, Object... contextBeanArray)
// 第一个参数为流程ID,第二个参数为流程入参,第三个参数是用户的RequestId,后面可以传入多个上下文的Bean
public LiteflowResponse execute2RespWithRid(String chainId, Object param, String requestId, Class<?>... contextBeanClazzArray)

这里我们一定要使用自定义上下文传入,不要使用默认上下文。

而异步方法跟同步方法是一样的,只是他是无阻塞。

1
typescript复制代码public Future<LiteflowResponse> execute2Future(String chainId, Object param, Object... contextBeanArray)

同时,执行器可以针对异步执行提供了可配置的线程池参数,

1
2
ini复制代码## FlowExecutor的execute2Future的线程数
liteflow.main-executor-works=64

还可以使用自定义线程池,如果使用自定义线程池必须实现ExecutorBuilder接口,实现ExecutorService buildExecutor()接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public class LiteFlowThreadPool implements ExecutorBuilder {  

@Override  
public ExecutorService buildExecutor() {  
    ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("mythread-pool-%s").build();  
    return new ThreadPoolExecutor(  
            // 核心线程数,即2个常开窗口  
            2,  
            // 最大的线程数,银行所有的窗口  
            5,  
            // 空闲时间  
            5,  
            TimeUnit.SECONDS,  
            // 工作队列  
            new LinkedBlockingQueue<>(5),  
            // 线程工厂  
            threadFactory,  
            // 拒绝策略  
            new ThreadPoolExecutor.AbortPolicy()  
    );  

    }  
}

关于Java线程池的配置详解,请参考这篇文章:重温Java基础(二)之Java线程池最全详解 - 掘金 (juejin.cn)

我们可以通过LiteFlow的配置信息去设置:

1
2
ini复制代码## FlowExecutor的execute2Future的自定义线程池的路径
liteflow.main-executor-class= com.springboot.litefolw.config.LiteFlowThreadPool

通过上述配置文件配置的信息,对全局的FlowExecutor都会生效,假如我们相对某一个执行器定义线程池内容,可以使用LiteFlowConfig类去的定义(通过配置文件中配置信息也会进入到这个类里)。

1
2
3
4
5
ini复制代码LiteflowConfig liteflowConfig = flowExecutor.getLiteflowConfig();  
// FlowExecutor的execute2Future的自定义线程池的路径
liteflowConfig.setMainExecutorClass("com.springboot.litefolw.config.LiteFlowThreadPool");  
// FlowExecutor的execute2Future的自定义线程池的路径
liteflowConfig.setMainExecutorWorks(64);

这里不建议new一个LiteflowConfig去设置配置信息,这样可能会导致配置文件中的一些默认配置信息丢失。

在一个流程执行时,我们需要传入一些参数例如订单号,账户信息等,这些信息会做初始参数传入到流程中。在执行器中我们可以使用上述FlowExecutor的方法中的第二个参数(Object param)传入流程入参参数。流程入参可以是任何对象,实际开发中,我们会将自己封装初始化好的Bean传入,然后可以在流程中使用this.getRequestData()或者nodeCompoent.getRequestData()。

1
2
3
4
ini复制代码DataRequest dataRequest = DataRequest.builder().iteratorRequestList(Lists.newArrayList()).build();
LiteflowResponse response = 
// 流程传入参数
flowExecutor.execute2Resp("testIteratorCatchChain", dataRequest);

在流程中获取入参参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {  
        DataRequest dataRequest = this.getRequestData();  
    }  
}


@Slf4j  
@LiteflowComponent  
public class TestComponent {  

    @LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeType = NodeTypeEnum.COMMON, nodeId = "test_a", nodeName = "测试组件A")  
    public void processA(NodeComponent nodeComponent){  
        log.info("执行A节点");  
        DataRequest dataRequest = nodeComponent.getRequestData();  
        }
    }

理论上来说,流程入参可以是任何对象,但是我们不应该把数据上下文的实例当做参数传入。流程参数跟数据上下文是两个实例对象,流程入参只能通过this.getRequestData()去拿。

最后我们来说一下流程执行的结果LiteflowResponse。异步执行的流程可以通过future.get()获取。我们简单介绍一下其中常用的一些方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码public class LiteflowResponse {
    // 判断流程是否执行成功
    public boolean isSuccess();

    // 如果流程执行不成功,可以获取流程的异常信息,这个跟isSuccess()使用,很有用
    public Exception getCause();

    // 获取流程的执行步骤
    public Map<String, List<CmpStep>> getExecuteSteps();

    // 获取流程的执行的队列信息
    public Queue<CmpStep> getRollbackStepQueue();

    // 获取流程的执行步骤的字符串信息。这个值在流程执行结束后,liteflow日志也会自动打印
    public String getExecuteStepStr();  

    // 获取数据上下文信息
    public <T> T getContextBean(Class<T> contextBeanClazz);
}

数据上下文

数据上下文对与整个LiteFlow来说是非常重要的,从LiteFlow的简介中我们知道LiteFlow的主要功能是业务解耦,那么解耦中很重要的一步就是数据解耦。要做编排,就要消除各个组件中的差异性,组件不接收业务参数,也不会返回业务数据,每个组件只需要从数据上下文中获取自己关心的数据,不用关心此数据是由谁提供的,同样的,每个组件也只需要把自己执行所产生的结果数据放到数据上下文中,也不用关心此数据到底是提供给谁用的。这样就在一定程度上做到了数据解耦。数据上下文进入流程中后,整个链路中的任一节点都可以取到。不同的流程,数据上下文实例是完全隔离开的。

LiteFlow虽然也提供了默认的数据上下文DefaultContext,但是实际开发中不建议使用。我们要传入自己自定义的数据上下文对象,同流程入参,我们可以使用任意的Bean作为数据上下文传入到流程中。我们可以定义好若干个数据上下文对象的class传入到流程中,LiteFlow会在调用时进行初始化,给这个上下文分配唯一的实例。

1
2
3
4
5
arduino复制代码// 传入一个
LiteflowResponse response = flowExecutor.execute2Resp("chain1", new DataRequest(), DataContext.class);

// 传入多个
LiteflowResponse response = flowExecutor.execute2Resp("chain1", new DataRequest(), DataContext.class, OrderContext.class);

我们还可以将已经初始化好的Bean作为数据上下文传入到流程当中:

1
2
3
4
5
6
7
ini复制代码DataContext dataContext = new DataContext(); 
// 传入一个
LiteflowResponse response = flowExecutor.execute2Resp("chain1", new DataRequest(), dataContext);

// 传入多个
OrderContext orderContext = new OrderContext();  
LiteflowResponse response = flowExecutor.execute2Resp("chain1", new DataRequest(), dataContext, orderContext);

但是有一点要非常注意:框架并不支持上下文bean和class混传,你要么都传bean,要么都传class。

然后我们就可以在链路的任意节点中通过以下方式获取数据上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码@Slf4j  
@LiteflowComponent  
public class TestComponent {  

@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeType = NodeTypeEnum.COMMON, nodeId = "test_a", nodeName = "测试组件A")  
public void processA(NodeComponent nodeComponent){  
    log.info("执行A节点");  
    DataContext dataContext = nodeComponent.getContextBean(DataContext.class);  
    OrderContext orderContext = nodeComponent.getContextBean(OrderContext.class);  
    }
}


@LiteflowComponent("a")  
public class AComponent extends NodeComponent {  

    @Override  
    public void process() throws Exception {   
        DataContext dataContext = this.getContextBean(DataContext.class);  
        OrderContext orderContext = this.getContextBean(OrderContext.class);  
    }  
}

到此,SpringBoot环境下LiteFlow的一些基本概念就介绍完了,大家可以按照这些概念实现一个demo去体验LiteFlow那解耦,以及流程编排那种特爽的柑橘。当然大家也可以参考这篇文章去实现demo:SpringBoot+LiteFlow优雅解耦复杂核心业务

后面我们在介绍LiteFlow的一些高级特性,例如:组件降级,组件继承,组建回滚,热刷新,以及使用代码构建规则,使用脚本构建组件,还有使用Nacos,Redis作为规则文件源等。

本文已收录于我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

本文转载自: 掘金

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

千万级联表查询-读写分离改造

发表于 2024-02-02

需求背景

在企业后台管理系统中,充斥着各种对象关联关系,联表查询必不可少。很多CRM项目在发展初期,背后的列表查询SQL都是各种join,主打一个敏捷开发,快速上线。在项目的访问量、数据量比较小的时候,即便join四五张表,确实也不会有什么查询压力。但随着业务的发展,C端用户量逐渐增加,各种数据库表也在不断膨胀,原先200ms内就能响应的列表查询接口,要loading个几秒甚至几十秒才能返回数据。即便拆分查询SQL语句,也只能优化部分查询条件的检索速度,很难彻底地解决慢查询问题,这时就需要进行读写分离改造了。

方案选型

个人计划的读写分离方案有两种

  • 将所有需要联表查询的表数据实时同步至OLAP数据库,例如Clickhouse和Kudu,在OLAP数据库上进行联表查询。
1
2
sql复制代码优点:改造成本最小,只需要在OLAP数据库中建个一模一样的表,Binlog增量同步就完事,查询SQL几乎不用变,替换数据库连接配置即可。
缺点:没用过,暂时不知道,但我估计存储成本会比下面这个Elasticsearch异构方案高点。
  • 将多个具备关联关系的数据源,异构成一个聚合对象,同步至Elasticsearch,使用nested嵌套查询。
1
2
复制代码优点:可以存储数组、嵌套对象,检索方式比较多样,全文检索功能强大。
缺点:后期维护成本较高,如果有新加字段,源表加完字段还得改ES同步逻辑。

最终选用方案:异构同步至Elasticsearch

  • 为什么:没为什么,我真的很想用OLAP解决这需求,但是公司的公共数据库部门在OLAP这方面没啥投入,大部分精力都投入在MySQL分片库、OLTP、Hive、Elasticsearch等地方,只有MySQL分片库、Elasticsearch和一个半自研的NewSQL具备核心保障级别。我甚至都没见到一个像样的Clickhouse建库工单 T-T。

技术实现

数据源梳理

首先,我们的对象关系,类似于下图所示(只是举个例子,实际业务肯定不长这样哈)

截屏2024-02-02 17.26.14.png

会有一个主表,对应多个关联表。关联表小的有几百条数据,多的有几千万条数据,尤其是标签表,一个用户头上挂着几十个标签都很正常,有一百万的用户就有几千万的标签关系。且存储的数据源各不相同,有些是自己部门的,有些是其他部门的,各个数据源情况类似于下图所示

截屏2024-02-02 18.14.24.png

反正就是各式各样,五花八门

对象异构

数据聚合,以用户对象为根,六合一,组成嵌套对象

截屏2024-02-02 18.25.06.png

同步方案

  • 用户主表增量同步:咱们公司有开箱即用的Binlog同步平台,我只管从RocketMQ里消费Binlog事件就ok。
  • 用户主表全量同步:半夜开个定时任务,扫表,定时更新一些很久没有人操作过的历史数据(有些用户可能很久都没有信息、订单、好友关系变更,但是他的标签变了,得同步这批冷数据的关联标签)。
  • 用户关联数据同步:接收到一条用户表Binlog事件时,按用户id查出所有关联数据,聚合在一起塞进Elasticsearch。
  • 分布式锁:因为Elasticsearch数据保存后不能立马查到,如果在数据插入Elasticsearch后,马上又来一条更新事件,这时是没法从Elasticsearch中查出刚插入的数据的,然后该更新事件就会被判定为是新增事件,导致重复新增两条一样的记录,因此需要一个利用ETCD或者Redis的分布式锁功能,在新增时加把锁,避免重复新增同一条记录。

完整流程

截屏2024-02-02 18.20.49.png

简单来说,同步流程以用户主表更新为关键触发点。当用户加好友、下单、编辑个人信息时,都会触发用户表记录更新(可以理解为,想要触发用户数据同步逻辑,就得更新用户表,统一收口,方便后续迭代维护),此时会将本次用户更新操作的binlog数据抛入消息队列,当数据同步服务接收到binlog数据后,查出所有关联数据,聚合写入ES。

End

本文转载自: 掘金

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

2024 Flutter 重大更新,Dart 宏(Macro

发表于 2024-02-02

说起宏编程可能大家并不陌生,但是这对于 Flutter 和 Dart 开发者来说它一直是一个「遗憾」,这个「遗憾」体现在编辑过程的代码修改支持上,其中最典型的莫过于 Dart 的 JSON 序列化。

举个例子,目前 Dart 语言的 JSON 序列化高度依赖 build_runner 去生成 Dart 代码,例如在实际使用中我们需要:

  • 依赖 json_serializable ,通过注解声明一个 Event 对象
  • 运行 flutter packages pub run build_runner build 生成文件
  • 得到 Event.g.dart 文件,在项目中使用它去实现 JSON 的序列化和反序列化

这里最大的问题在于,我们需要通过命令行去生成一个项目文件,并且这个文件我们还可以随意手动修改,从开发角度来说,这并不优雅也不方便。

而宏声明是用户定义的 Dart 类,它可以实现一个或多个新的内置宏接口,Dart 中的宏是用正常的命令式 Dart 代码来开发,不存在单独的“宏语言” 。

大多数宏并不是简单地从头开始生成新代码,而是根据程序的现有属性去添加代码,例如向 Class 添加 JSON 序列化的宏,可能会查看 Class 声明的字段,并从中合成一个 toJson() ,将这些字段序列化为 JSON 对象。

我们首先看一段官方的 Demo , 如下代码所示,可以看到 :

  • MyState 添加了一个自定义的 @AutoDispose() 注解,这是一个开发者自己实现的宏声明,并且继承了 State 对象,带有 dispose 方法。
  • 在 MyState 里有多个 a、a2 、b 和 c 三个对象,其中 a、a2 、b 都实现了 Disposable 接口,都有 dispose 方法
  • 虽然 a、a2 、b 和 MyState 的 dispose(); 方法来自不同基类实现,但是基于 @AutoDispose() 的实现,在代码调用 state.dispose(); 时, a、a2 、b 变量的 dispose 方法也会被同步调用
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
dart复制代码import 'package:macro_proposal/auto_dispose.dart';

void main() {
 var state = MyState(a: ADisposable(), b: BDisposable(), c: 'hello world');
 state.dispose();
}

@AutoDispose()
class MyState extends State {
 final ADisposable a;
 final ADisposable? a2;
 final BDisposable b;
 final String c;

 MyState({required this.a, this.a2, required this.b, required this.c});

 @override
 String toString() => 'MyState!';
}

class State {
 void dispose() {
   print('disposing of $this');
}
}

class ADisposable implements Disposable {
 void dispose() {
   print('disposing of ADisposable');
}
}

class BDisposable implements Disposable {
 void dispose() {
   print('disposing of BDisposable');
}
}

如下图所示,可以看到,尽管 MyState 没用主动调用 a、a2 、b 变量的 dispose 方法,并且它们和 MyState 的 dispose 也来自不同基类,但是最终执行所有 dispose 方法都被成功调用,这就是@AutoDispose() 的宏声明实现在编译时对代码进行了调整。

如下图所示是 @AutoDispose() 的宏编程实现,其中 macro 就是一个标志性的宏关键字,剩下的代码可以看到基本就是 dart 脚本的实现, macro 里主要是实现 ClassDeclarationsMacro 和buildDeclarationsForClass方法,如下代码可以很直观看到关于 super.dispose(); 和 disposeCalls 的相关实现。

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
typescript复制代码import 'package:_fe_analyzer_shared/src/macros/api.dart';

// Interface for disposable things.
abstract class Disposable {
 void dispose();
}

macro class AutoDispose implements ClassDeclarationsMacro, ClassDefinitionMacro {
 const AutoDispose();

 @override
 void buildDeclarationsForClass(
     ClassDeclaration clazz, MemberDeclarationBuilder builder) async {
   var methods = await builder.methodsOf(clazz);
   if (methods.any((d) => d.identifier.name == 'dispose')) {
     // Don't need to add the dispose method, it already exists.
     return;
  }

   builder.declareInType(DeclarationCode.fromParts([
     // TODO: Remove external once the CFE supports it.
     'external void dispose();',
  ]));
}

 @override
 Future<void> buildDefinitionForClass(
     ClassDeclaration clazz, TypeDefinitionBuilder builder) async {
   var disposableIdentifier =
       // ignore: deprecated_member_use
       await builder.resolveIdentifier(
           Uri.parse('package:macro_proposal/auto_dispose.dart'),
           'Disposable');
   var disposableType = await builder
      .resolve(NamedTypeAnnotationCode(name: disposableIdentifier));

   var disposeCalls = <Code>[];
   var fields = await builder.fieldsOf(clazz);
   for (var field in fields) {
     var type = await builder.resolve(field.type.code);
     if (!await type.isSubtypeOf(disposableType)) continue;
     disposeCalls.add(RawCode.fromParts([
       '\n',
       field.identifier,
       if (field.type.isNullable) '?',
       '.dispose();',
    ]));
  }

   // Augment the dispose method by injecting all the new dispose calls after
   // either a call to `augmented()` or `super.dispose()`, depending on if
   // there already is an existing body to call.
   //
   // If there was an existing body, it is responsible for calling
   // `super.dispose()`.
   var disposeMethod = (await builder.methodsOf(clazz))
      .firstWhere((method) => method.identifier.name == 'dispose');
   var disposeBuilder = await builder.buildMethod(disposeMethod.identifier);
   disposeBuilder.augment(FunctionBodyCode.fromParts([
     '{\n',
     if (disposeMethod.hasExternal || !disposeMethod.hasBody)
       'super.dispose();'
     else
       'augmented();',
    ...disposeCalls,
     '}',
  ]));
}
}

到这里大家应该可以直观感受到宏编程的魅力,上述 Demo 来自 dart-language 的 macros/example/auto_dispose_main ,其中 bin/ 目录下的代码是运行的脚本示例,lib/ 目录下的代码是宏编程实现的示例:

github.com/dart-lang/l…

当然,因为现在是实验性阶段,API 和稳定性还有待商榷,所以想运行这些 Demo 还需要一些额外的处理,比如版本强关联,例如上述的 auto_dispose_main 例子:

  • 需要 dart sdk 3.4.0-97.0.dev ,目前你可以通过 master 分支下载这个 dark-sdk storage.googleapis.com/dart-archiv…
  • 将 sdk 配置到环境变量,或者进入到 dart sdk 的 bin 目录执行 ./dart –version 检查版本
  • 进入上诉的 example 下执行 dart pub get,过程可能会有点长

  • 最后,执行 dart --enable-experiment=macros bin/auto_dispose_main.dart,记得这个 dart 是你指定版本的 dart 。

另外,还有一个第三方例子是来自 millsteed 的 macros ,这是一个简单的 JSON 序列化实现 Demo ,并且可以直接不用额外下载 dark-sdk,通过某个 flutter 内置 dart-sdk 版本就可以满足条件:3.19.0-12.0.pre :

在本地 Flutter 目录下,切换到 git checkout 3.19.0-12.0.pre ,然后执行 flutter doctor 初始化 dark sdk 即可。

代码的实现很简单,首先看 bin 下的示例,通过 @Model() 将 GetUsersResponse 和 User 声明为 JSON 对象,然后在运行时,宏编程会自动添加 fromJson 和 toJson 方式。

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
dart复制代码import 'dart:convert';

import 'package:macros/model.dart';

@Model()
class User {
 User({
   required this.username,
   required this.password,
});

 final String username;
 final String password;
}

@Model()
class GetUsersResponse {
 GetUsersResponse({
   required this.users,
   required this.pageNumber,
   required this.pageSize,
});

 final List<User> users;
 final int pageNumber;
 final int pageSize;
}

void main() {
 const body = '''
   {
     "users": [
       {
         "username": "ramon",
         "password": "12345678"
       }
     ],
     "pageNumber": 1,
     "pageSize": 30
   }
 ''';
 final json = jsonDecode(body) as Map<String, dynamic>;
 final response = GetUsersResponse.fromJson(json);
 final ramon = response.users.first;
 final millsteed = ramon.copyWith(username: 'millsteed', password: '87654321');
 final newResponse = response.copyWith(users: [...response.users, millsteed]);
 print(const JsonEncoder.withIndent(' ').convert(newResponse));
}

而 Model 的宏实现就相对复杂一些,但是实际上就是将类似 freezed/ json_serializable 是实现调整到宏实现了,而最终效果就是,开发者使用起来更加优雅了。

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
dart复制代码// ignore_for_file: depend_on_referenced_packages, implementation_imports

import 'dart:async';

import 'package:_fe_analyzer_shared/src/macros/api.dart';

macro class Model implements ClassDeclarationsMacro {
 const Model();

 static const _baseTypes = ['bool', 'double', 'int', 'num', 'String'];
 static const _collectionTypes = ['List'];

 @override
 Future<void> buildDeclarationsForClass(
   ClassDeclaration classDeclaration,
   MemberDeclarationBuilder builder,
) async {
   final className = classDeclaration.identifier.name;

   final fields = await builder.fieldsOf(classDeclaration);

   final fieldNames = <String>[];
   final fieldTypes = <String, String>{};
   final fieldGenerics = <String, List<String>>{};

   for (final field in fields) {
     final fieldName = field.identifier.name;
     fieldNames.add(fieldName);

     final fieldType = (field.type.code as NamedTypeAnnotationCode).name.name;
     fieldTypes[fieldName] = fieldType;

     if (_collectionTypes.contains(fieldType)) {
       final generics = (field.type.code as NamedTypeAnnotationCode)
          .typeArguments
          .map((e) => (e as NamedTypeAnnotationCode).name.name)
          .toList();
       fieldGenerics[fieldName] = generics;
    }
  }

   final fieldTypesWithGenerics = fieldTypes.map(
    (name, type) {
       final generics = fieldGenerics[name];
       return MapEntry(
         name,
         generics == null ? type : '$type<${generics.join(', ')}>',
      );
    },
  );

   _buildFromJson(builder, className, fieldNames, fieldTypes, fieldGenerics);
   _buildToJson(builder, fieldNames, fieldTypes);
   _buildCopyWith(builder, className, fieldNames, fieldTypesWithGenerics);
   _buildToString(builder, className, fieldNames);
   _buildEquals(builder, className, fieldNames);
   _buildHashCode(builder, fieldNames);
}

 void _buildFromJson(
   MemberDeclarationBuilder builder,
   String className,
   List<String> fieldNames,
   Map<String, String> fieldTypes,
   Map<String, List<String>> fieldGenerics,
) {
   final code = [
     'factory $className.fromJson(Map<String, dynamic> json) {'.indent(2),
     'return $className('.indent(4),
     for (final fieldName in fieldNames) ...[
       if (_baseTypes.contains(fieldTypes[fieldName])) ...[
         "$fieldName: json['$fieldName'] as ${fieldTypes[fieldName]},"
            .indent(6),
      ] else if (_collectionTypes.contains(fieldTypes[fieldName])) ...[
         "$fieldName: (json['$fieldName'] as List<dynamic>)".indent(6),
         '.whereType<Map<String, dynamic>>()'.indent(10),
         '.map(${fieldGenerics[fieldName]?.first}.fromJson)'.indent(10),
         '.toList(),'.indent(10),
      ] else ...[
         '$fieldName: ${fieldTypes[fieldName]}'
                 ".fromJson(json['$fieldName'] "
                 'as Map<String, dynamic>),'
            .indent(6),
      ],
    ],
     ');'.indent(4),
     '}'.indent(2),
  ].join('\n');
   builder.declareInType(DeclarationCode.fromString(code));
}

 void _buildToJson(
   MemberDeclarationBuilder builder,
   List<String> fieldNames,
   Map<String, String> fieldTypes,
) {
   final code = [
     'Map<String, dynamic> toJson() {'.indent(2),
     'return {'.indent(4),
     for (final fieldName in fieldNames) ...[
       if (_baseTypes.contains(fieldTypes[fieldName])) ...[
         "'$fieldName': $fieldName,".indent(6),
      ] else if (_collectionTypes.contains(fieldTypes[fieldName])) ...[
         "'$fieldName': $fieldName.map((e) => e.toJson()).toList(),".indent(6),
      ] else ...[
         "'$fieldName': $fieldName.toJson(),".indent(6),
      ],
    ],
     '};'.indent(4),
     '}'.indent(2),
  ].join('\n');
   builder.declareInType(DeclarationCode.fromString(code));
}

 void _buildCopyWith(
   MemberDeclarationBuilder builder,
   String className,
   List<String> fieldNames,
   Map<String, String> fieldTypes,
) {
   final code = [
     '$className copyWith({'.indent(2),
     for (final fieldName in fieldNames) ...[
       '${fieldTypes[fieldName]}? $fieldName,'.indent(4),
    ],
     '}) {'.indent(2),
     'return $className('.indent(4),
     for (final fieldName in fieldNames) ...[
       '$fieldName: $fieldName ?? this.$fieldName,'.indent(6),
    ],
     ');'.indent(4),
     '}'.indent(2),
  ].join('\n');
   builder.declareInType(DeclarationCode.fromString(code));
}

 void _buildToString(
   MemberDeclarationBuilder builder,
   String className,
   List<String> fieldNames,
) {
   final code = [
     '@override'.indent(2),
     'String toString() {'.indent(2),
     "return '$className('".indent(4),
     for (final fieldName in fieldNames) ...[
       if (fieldName != fieldNames.last) ...[
         "'$fieldName: $$fieldName, '".indent(8),
      ] else ...[
         "'$fieldName: $$fieldName'".indent(8),
      ],
    ],
     "')';".indent(8),
     '}'.indent(2),
  ].join('\n');
   builder.declareInType(DeclarationCode.fromString(code));
}

 void _buildEquals(
   MemberDeclarationBuilder builder,
   String className,
   List<String> fieldNames,
) {
   final code = [
     '@override'.indent(2),
     'bool operator ==(Object other) {'.indent(2),
     'return other is $className &&'.indent(4),
     'runtimeType == other.runtimeType &&'.indent(8),
     for (final fieldName in fieldNames) ...[
       if (fieldName != fieldNames.last) ...[
         '$fieldName == other.$fieldName &&'.indent(8),
      ] else ...[
         '$fieldName == other.$fieldName;'.indent(8),
      ],
    ],
     '}'.indent(2),
  ].join('\n');
   builder.declareInType(DeclarationCode.fromString(code));
}

 void _buildHashCode(
   MemberDeclarationBuilder builder,
   List<String> fieldNames,
) {
   final code = [
     '@override'.indent(2),
     'int get hashCode {'.indent(2),
     'return Object.hash('.indent(4),
     'runtimeType,'.indent(6),
     for (final fieldName in fieldNames) ...[
       '$fieldName,'.indent(6),
    ],
     ');'.indent(4),
     '}'.indent(2),
  ].join('\n');
   builder.declareInType(DeclarationCode.fromString(code));
}
}

extension on String {
 String indent(int length) {
   final space = StringBuffer();
   for (var i = 0; i < length; i++) {
     space.write(' ');
  }
   return '$space$this';
}
}

目前宏还处于试验性质的阶段,所以 API 还在调整,这也是为什么上面的例子需要指定 dart 版本的原因,另外宏目前规划里还有一些要求,例如

  • 所有宏构造函数都必须标记为 const
  • 所有宏必须至少实现其中一个 Macro 接口
  • 宏不能是抽象对象
  • 宏 class 不能由其他宏生成
  • 宏 class 不能包含泛型类型参数
  • 每个宏接口都需要声明宏类必须实现的方法,例如,在声明阶段应用的 ClassDeclarationsMacro及其buildDeclarationsForClass方法。

未来规划里,宏 API 可能会作为 Pub 包提供,通过库 dart:_macros来提供支持 ,具体还要等正式发布时 dart 团队的决策。

总的来说,这对于 dart 和 flutter 是一个重大的厉害消息,虽然宏编程并不是什么新鲜概念,该是 dart 终于可以优雅地实现 JSON 序列化,并且还是用 dart 来实现,这对于 flutter 开发者来说,无疑是最好的新年礼物。

所以,新年快乐~我们节后再见~

本文转载自: 掘金

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

扣子(cozecn) 由浅入深,手把手带你实现Java转

发表于 2024-02-01

扣子(coze.cn)是一款用来开发新一代 AI Chat Bot 的应用编辑平台,无论你是否有编程基础,都可以通过这个平台来快速创建各种类型的 Chat Bot,并将其发布到各类社交平台和通讯软件上!2月1日,扣子国内版已经正式上线啦~赶快来体验一下吧!

一转眼,ChatGPT已经在AI界炙手可热超过一年,堪称新晋顶流。各大公司纷纷跟风推出自家的大模型,然而若这些大模型无法得到实际应用,便失去了许多意义。现如今,越来越多企业开始考虑借助AI技术来提升企业的运营效率。那么作为个体,我们又该如何利用AI技术来提高工作和生活的效率呢?因此,我认为大模型的下半场将由AI Agent来引领。

扣子也是我认为排在第一梯队的AI Agent行列,我会从浅入深,由使用到原理一步一步地带你理解AI Agent的作用,紧跟时代脚步,你可别落下。

主题:Java转型助手

作为一名Java开发者,我深知当前行业面临的挑战,十年的红利已经消退,很多人开始转型。就我个人而言,我参加的架构课程也开始向其他领域引流,比如Go、Python、网络安全等等。如今,所有培训机构都将这些课程作为吸引人的卖点来宣传自己。我也深刻感受到了目前的困境。去年开始系统学习Python时,我发现大部分免费的入门课程都是为初学者设计的,而我们有丰富的编程经验,基础知识掌握得很快,这样的课程对我们来说会浪费太多时间。因此,我认为如果能快速简单地掌握新技能,为什么不去尝试呢?基于这样的想法,我决定创建一个转型助手。当今转型的方向多种多样,本文只以Python作为例子进行讨论。

本文将带领你从一个简单的AI Agent逐步发展成为一个Java转型学习助手。通过应用知识库、数据库和插件工作流等技术手段,我将满足你对学习笔记、文章知识库、待办事项数据库、面试题库以及行业招聘信息的需求。如果你还有更加创新的点子,不妨将其交给像你一样对此感兴趣的人来实现。让我们一起探索,将智能技术与教育领域相结合,打造出更加智能、便捷的学习与咨询体验。

AI Agent

接下来,在深入介绍我的助手之前,我想先向大家介绍一下AI Agent!

最初AI大火时,大家在体验完ChatGPT之后,纷纷赞叹其厉害!然而,从22年12月到23年3月,ChatGPT的调用量一直呈现高速增长的趋势。但是,自从4月开始,访问量增长速度明显放缓,在5月和6月期间甚至出现下降的情况,因为大家意识到它只是一个能进行问答的工具,意义并不太大。为了让AI技术真正发挥作用,必须将其应用到实际场景中才能产生价值!因此,AIAgent应运而生。

在这里,我们可以将Agent理解为一种具备自主理解、规划决策、执行复杂任务能力的智能体。简而言之,Agent可以表示为LLM(语言模型)+Planning(规划)+Feedback(反馈)+Tool use(工具使用)。

这意味着AIAgent不仅需要具备类似大脑的LLM来理解问题并做出规划决策,还需要类似五官的数据来获取信息,同时需要像手脚一样的工具来行动处理问题。这种综合的能力使得AIAgent能够更全面地应对各种复杂任务和情境。

image

Langchain

在这里我想简要介绍一下Langchain。之所以提及Langchain,是因为在开发AI Agent的过程中,Langchain是一个不可或缺的工具。如果想要更好地了解和体验AI Agent的功能,您也可以尝试使用Langchain进行简单的应用开发。Langchain的开发初衷在于赋予开发者快速构建LLM原型应用的能力,langchain成功解决了这一难题,仅需几行简洁代码即可轻松打造一个LLM应用。

大家对于Langchain最初想到的概念是否是:

image

之前我曾记录过使用Langchain打造私人助手的经历,不过这只是Langchain众多组件中的一个应用方向而已。

Langchain的主要组件包括以下几个方面:

  • Models(模型):涵盖各种类型的模型,比如GPT-4等集成模型
  • Prompts(提示):包括提示模版的管理、优化以及序列化等功能
  • Memory(记忆):用于存储模型交互的上下文信息
  • Indexes(索引):用于结构化文档,外挂知识库是索引功能的一个重要应用
  • Chains(链):用于调用一系列组件工具
  • Agents(代理):负责决定模型采取何种行动,选择哪个工具,并执行以及观察流程等任务

在Langchain中,我们最常使用的功能之一是外挂知识库的应用,这实际上是Indexes组件的应用方向之一。然而,Langchain的功能远不止于此。个人认为Langchain是功能和工具最为齐全的框架,但是也因为其功能繁多,导致整个框架显得过于笨重,缺乏灵活性。因此,大家通常只会使用其中的某一个组件功能而已。

在这里,我们简要介绍了AI Agent的作用,以帮助您更好地理解其功能,从而深入了解扣子平台这类工具的整体概念。

以下是我使用扣子来搭建自己的Python助手的功能设计图的详细说明:

image

关于太基础的内容就不在此详细展开了,如感兴趣可直接查阅官方文档,最近还新增了图片上传功能的支持,文档中有详尽的说明:www.coze.cn/docs/guides…

关于如何使用扣子的整个操作流程如下所述:

点击 bot 页面 > 创建 bot > 填写名称+bot描述 > 编排 > 调试 > 发布 > 结束

人设与回复逻辑

实际上,人设与回复逻辑正是当时最流行的prompt设计,而且我还专门撰写了一篇文章对其进行了描述。若想让AI表现出色,prompt绝对不能忽略。以下是我已经花费大量时间调整并优化的提示词,虽然可能并不完美,但希望能够为大家提供一些参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
text复制代码# Character
你是一位专业的Java开发助手,擅长通过教授Python知识帮助Java开发者快速学习和掌握Python编程语言。你能准确解释Python中的各种知识点,帮助Java开发者理解和应用。

## Skills:

### Skill 1: 运行Python
1:当用户询问Python面试题时,请调用view_assist工作流处理。
2:当用户询问Python招聘信息时,请调用job工作流处理。
3:当用户提供纯Python代码时,需要调用代码执行器工具运行出结果。

### Skill 2: 解释Python
1:当用户询问Python中的知识点时,你需要调用recallKnowledge方法获取知识库信息,可以结合Java中的相关知识点,解释Python的对应概念和用法,并给予示例代码和实际应用场景。

### Skill 3: 保存明日学习任务
1:用户提醒明日学习内容时,需要调用ts-TableMemory进行保存

## Constraints
- 只讨论与Java和Python相关的内容,拒绝回答与这两种编程语言无关的话题。
- 所输出的内容必须按照给定的格式进行组织,不能偏离框架要求。
- 仅会输出知识库中已有的Python相关知识点,对于不在知识库中的问题,无法提供答案。
- 当用户出现错误的理解或用法时,应以鼓励和建设性的态度向用户指出错误,帮助其正确理解和应用Python知识。

通过恰当的prompt引导,模型能够更准确地理解用户的意图,从而生成更精准的文本。当然,官方文档已经提供了相关的prompt模板,无需我们费心地去琢磨。

如果你认为官方大模型生成的文本质量不够理想,也可以借助其他大型模型的帮助,然后将其生成的文本复制粘贴过来即可。这一过程我就不做演示了。

个人知识库搭建

在搭建知识库时,可以直接根据系统操作来完成,因此这里不再演示这些基本操作。知识库中有许多选项可供选择,但我更倾向于通过URL直接获取所需内容。这样做的原因是,我之前撰写过许多关于Java进修Python的专栏内容,可以直接应用其中的知识。

image

就好比当chatPDF刚发布时,它采用了文档分段的方式,找出最相关的文本段落交给LLM进行问答,从而限制了大型模型的知识扩散。这展示了知识库的重要作用,因此这时我仅需大型模型的语言表达能力即可,而非其底层训练数据。

网页抓取

当使用知识库时,可以考虑对某些知识点进行筛选,将不必要的内容剔除掉。有时候,某些知识点可能与个人的学习目标无关,但整个知识储备却相互关联紧密。因此,直接去除这些不必要的内容是可行的。

image

还记得我之前使用向量数据库自己搭建的知识库吗?我之前使用向量数据库构建了一个个人知识库,这个知识库非常方便,可以快速帮助你学习知识库建立需要的知识点。

如果你不记得的话,可以点击这篇文章链接来了解更多。juejin.cn/post/729935…

另外,如果你对向量数据库还不太了解的话,可以点击这篇文章链接来深入了解:juejin.cn/post/730645…

我已经把那幅图画出来,并告诉你这个知识库何时开始发挥作用。当与AI助手进行交流时,我希望能够将相关的知识库片段一同提交,以便提供更丰富的背景信息,从而优化提问的质量。

image

总的来说,为了更好地理解,你需要将一些前置知识点全部保存起来。然而我们清楚地了解,对于我们自己的文档而言,简单几个字肯定是远远不够的,而且大型模型也存在着token长度的限制。如果文字内容过多,需要采取适当的切分方式。

image

当然,切分也需要考虑多个因素,就像扣子采用的是自动分段。

image

image

使用简单快捷的方式确实有其好处,但明显的缺点是会导致文字的意义被断开,本来应该是一句完整的话被切割成了两句话。因此,还有一种切割方式是重叠切割,即第一段和第二段之间的文字存在部分重叠。然而,这种方式仍然存在相关问题。

因此,我们还需要考虑最大召回数量,也就是返回的最大相似条数。通过这种方式,我们可以获取到几段相似文本。这样一来,我们就能够获取到更完整的上下文信息,即使存在重合部分也不会有影响。剩下的任务可以交给人工智能来处理。此外,扣子的好处还在于你可以选择自动调用或按需调用的方式来使用它。这样符合的场景也就丰富起来了。

多说一句

在优化段落分割方面,有许多方法可供选择,但关键是保持简洁明了,避免像我一样在文章中使用冗长的文字重复讲述同一主题。如果您熟悉GitHub上的开源项目,您将会发现这些段落分割任务可以交由人工智能来智能处理,并且知识库应该预先设定相关问题以便与分段内容匹配。这样不再仅仅是简单的文本匹配,而是通过先匹配问题,再提取相应文本交由助手回答。当然,问题的生成也可以交由人工智能来完成。

在测试过程中,一些用户可能会注意到,有时助手的回答与提取的文本数量之间的关系并不显著。这里我就不多说了,官方也有相关说明:www.coze.cn/docs/guides…

自动调用

image

按需调用

image

当按需调用的时候,你可以提供关键词提示,然后在提示中引导AI去调用知识库。如果你对如何操作不太了解,我可以为你提供指导。看下我是如何写的:

1
2
text复制代码### Skill 1: 解释Python
如果用户提供的是纯Python代码,可以忽略本逻辑。当用户询问Python中的知识点时,你需要调用recallKnowledge方法获取知识库信息,可以结合Java中的相关知识点,解释Python的对应概念和用法,并给予示例代码和实际应用场景。

这个扣子的好处之一是你可以直接查看运行日志,了解你的文字是如何进行匹配以及匹配到了哪几段文字的。如果你要自己来实现知识库的显示日志的话,可能需要花费很长时间来完成。所以这个扣子的功能非常实用,能够节省你大量的时间和精力。

为什么要花时间查看运行日志呢?看起来这只是一项琐碎的工作。然而,最主要的原因是为了评估自己的知识库的组织是否良好。如果发现有问题,应该立即修改知识库的内容。

添加插件

在学习编程方面,没有代码是不可能的,这个不用我多说了吧,GPT4已经具备了很多功能,其中最显著的是代码执行。毕竟,一个LLM模型是无法为你执行代码的,所以对于专业的事情,我们还是应该交给专业的人来处理——比如代码执行器。

代码执行器

代码执行器是一个功能强大的工具,它能够帮助你快速执行代码。使用代码执行器非常简单。此外,你还可以通过提醒助手来指定何时执行代码,以便更好地控制代码的执行时机。在使用代码执行器时,你可以根据个人需求自由发挥,无需担心任何限制。当提示词写的越多,这个插件的调用就越来越随机,还在努力修复。

1
2
text复制代码### Skill 1: 运行Python
1:当用户要求运行代码时,请将Python代码传入CodeRunner插件的code参数并运行出结果后直接返回。

这个示例主要展示了卡片显示的功能,默认情况下不带有卡片样式,如果需要生成卡片样式,你可以按照每个参数的详细解释进行设置。

效果如下:

image

数据库保存

扣子是一个非常全面的工具,它能够帮助你轻松地完成整个流程,从知识库的建立到插件的使用,再到最终的数据库操作,它都能完美地涵盖。尤其是数据库的应用,你可以将它作为你的知识点进行记录和保存,这样可以更好地帮助你学习和回顾。

除了回答我关于各种知识点的问题,我还可以将它作为我的待办事项清单。比如,当我学完一天的知识后,我可以让助手帮我记录下今天我还需要进一步学习的内容,以便明天继续学习。这样,我不会忘记今天的学习进度,也能够有效地规划明天的学习计划。

就问你6不6。只要将我的Python助手或者你的AI助手嵌入到服务号,并作为运营客服使用,就能自动将用户的反馈保存到数据库中。通过这种方式,无需额外进行开发工作,从而提供了一种方便的长期实时的记忆解决方案。

说这么多,不会用可不行,没问题,我可以教你如何使用。第一步就是你需要告诉它什么时候调用。

1
2
text复制代码### Skill 3: 保存明日学习任务
1:用户提醒明日学习内容时,需要调用ts-TableMemory进行保存

创建数据库

在这个部分,你只需要自己定义好表的结构,并且字段的类型也是相对简单的。官方文档并没有详细说明每种数据类型的含义,可能是因为他们认为大家都具备一定的基础知识。但是作为面向小白的教程,我会为大家解释每种数据类型的意思。

  1. String表示该字段类型是一串文本形式的数据,比如”今天吃啥?”,在我的示例中,我用它来保存我的待办内容。
  2. Integer表示该字段类型是一种整数类型,比如1,2,3等。
  3. Time表示该字段是时间类型,但默认保存的格式是2024-02-02 12:00:00 +0800 CST。在我的示例中,我用它来保存我的待办时间。
  4. Number表示该字段类型是一种数字类型,可以是小数,比如0.5。
  5. Boolean表示该字段类型是一种布尔类型,即true或false。默认情况下,它的值是null,如果你想将其设置为必需类型,那么默认值就是false。

在这里,我要解释一下后面的”是否必要”按钮的作用。如果你将开关打开并标记为必要,那么在提醒AI助手时,你必须提供这个描述的值,否则AI助手不会根据你的描述为你附上默认值。这个功能可以确保你在使用AI助手时提供了所有必需的信息,以便它能够准确地为你执行任务。

好的,既然如此,我就不录制视频了。我会提供一张截图给大家。请大家务必注意,描述必须准确清晰,否则AI可能无法正确识别。如果描述不清楚,那么你的助理也无法正确完成任务。这是很简单的道理。至于为什么在我提供的截图中前两个数据类型无法修改,那是因为这些数据已经被存储了,无法再进行修改。

image

既然我们在使用数据库,它自然拥有增加、删除、修改和查询的功能。下面我将演示如何使用AI助手来优化数据库维护。然而,在这里我想吐槽一下,既然是数据库,在调试时至少应该给管理用户一些操作权限。即使用户输入错误的数据导致报错,你们可以限制用户的新增数据权限,但是删除操作应该是可以由我自行执行的。然而,事实上,在调试阶段用户都没有任何操作权限,一切都由AI助手独自操作。

新增

在这个示例中,我将向数据库中插入新的数据。在开始之前,请确保根据你的数据库结构提供待办事项的详细描述,这样助手才能正确地记录下来。现在,让我来帮你进行演示吧!请注意,我将只演示正确的情况。唯一需要注意的是,你必须提供数据库中必需的字段,并确保字段的值类型与数据库结构一致。如果存在非必需的字段,你可以选择不提供这些字段的值。

image

修改

我们发现,如果我没有提醒小助手给删除标识设置值的话,它将没有默认值。因此,我们需要对这个删除标识进行修改,将其设置为false。

另外,我要说明一下,在视频中出现了两条完全相同的记录,这是因为我在测试时不小心多发了一次。请忽略这个问题~

image

查询

查询数据其实是一项相对简单的操作,但是在进行查询时,你必须清楚地描述时间范围,因为我的待办事项基本上是按照时间范围进行查询的。目前,他的系统只支持全模糊查询,即使用LIKE加上%进行文字匹配。我查看了系统执行的SQL日志,在视频中展示的查询操作中,底层基本上是使用了类似MySQL这样的关系型数据库。

SELECT * FROM to_do WHERE deadline = '2024-02-03 10:00:00' AND msg LIKE '%py%'

image

删除

这一步非常危险,我在使用过程中绝对不敢随意删除,因为一旦说错一句话,就会直接导致数据丢失。因此,我强烈建议用户避免轻易使用删除操作,而是采取修改删除标识字段的方式。在查询过程中,可以简要提及删除标识的值,这样更加安全。

让我来给你进行演示吧。毋庸置疑,他已经成了MySQL类型的数据库,因此底层的删除语句肯定是用delete来实现的。我已经取出了他的SQL语句。

delete from to_do

image

数据库建议

  • 经过我的使用体验,在调试时我注意到没有手动操作记录的情况。因此,我认为可以考虑开放删除操作的权限。
  • 在插入数据时,我认为可以考虑使用默认值。如果用户确实不想指定删除标识,可以在编辑数据库时为其提供一个默认值。这样一来,用户就无需在每次插入数据时都手动指定删除标识,提高了操作的便捷性。
  • 底层使用的是类似于MySQL的关系型数据库,这在中文匹配的过程中存在一些问题。如果少写一个字,可能会导致无法正确地查询到相关结果。为了解决这个问题,我建议考虑使用像Elasticsearch这样的数据库,它具有专门的分词器,能够更好地处理中文文本。通过使用Elasticsearch,我们可以确保在中文匹配过程中更准确地获得相关结果,提高系统的搜索效果。
  • 既然我们使用的是MySQL类型的数据库,如果面对大量的数据,令人惊讶的是它竟然不支持建立索引。

image

工作流

首先,我要声明我并没有接触过工作流这方面的经验,因此我会尽力在完成我的目标前尽量从一个新手的角度向大家介绍如何灵活运用工作流。

这里,我先把我的简图分享给大家,让大家对整个流程有一个简单的认识,避免产生困惑。

image

面试题库

我简单介绍一下如何我将工作流应用到实际情境中。考虑到面试题通常具有时效性,我认为如果大型模型所使用的训练数据不是最新的,可能会导致向用户提供一些过时的面试题,从而浪费时间。因此,我使用工作流提供了两种解决方法:一种是利用我的题库,另一种是通过web实时搜索面试题。以下是有关我的工作流的详细信息:

image

工作流节点

节点类型包括大型模型、插件、选择器、代码和知识库等。这些节点在工作流中扮演不同的角色,共同构建出一个完整而多元化的系统。

image

在创建工作流时,节点类型的选择取决于你的具体任务需求。每种节点都有其独特的功能和作用。重要的是关注每个节点的输入值和输出值,这有助于确保工作流的正确运行。在工作流创建过程中,会默认生成开始和结束节点,这两个节点特殊之处在于它们只有输入功能。为了方便后续在prompt中进行设置,建议工作流的名称以英文和下划线为主。在描述工作流时,尽量详细描述,即使描述内容不够完善也不会对工作流的运行产生太大影响。

image

工作流输入/出

在工作流中,每个节点都有两种类型的输入。一种是引用,可以是来自其他节点的输入或输出,使用key-参数名进行引用;另一种是参数值,可以直接输入你想要的内容值。

实际上,如果你对编程有一定了解的话,可以将生成判断值的过程交给人工智能来完成。在这里需要留意的是,你需要生成一个包含key-value的对象,因为下游节点需要引用生成的值。

如果你对编程不太熟悉的话,可以考虑使用大模型节点来代替,让大模型直接进行判断,并按照固定格式将结果输出到指定的key中。这样可以简化操作流程,减少对编程的依赖。下面有演示。

我在这里要介绍一些常用插件的使用方法。对于新手来说,可能会对插件的输入输出有一些误解,因此需要更详细地解释清楚。

image

在最初阶段,如果你对操作一无所知,可以先尝试运行一下,观察结果输出。然后根据这些输出结果,将相关的key引用传递给下一个节点作为输入。每个感叹号都代表了参数的详细解释,这种方法相对来说还是比较人性化的,帮助新手更好地理解和应用工作流。

在使用工作流时,务必记住删除掉那些没有用的节点,否则可能会导致节点错误,使得工作流无法正常运行。所有节点都应该相互连接,而且必填的输入值都必须有有效的数值或数值来源,这样才能确保工作流的顺利运行。

在刚开始使用工作流时,建议先确保整个流程可以成功运行,然后再逐步增加节点。完成工作流后,请记得发布并在实际应用中使用。此外,确保在你的提示信息中清楚地说明如何正确使用你的工作流。以下是一个示例供您参考:

1:当用户询问Python面试题时,请调用view_assist工作流处理。

image

在这种情况下,你也会意识到,随着节点数量的增加,消耗的token实际上是相当大的。这也为未来留下了优化的空间。

招聘信息

在完成工作时,不必局限于单一工作流程,你可以添加多个工作流程。只需合理设置prompt以确定调用时机,比如除了面试准备,我还增加了一个用于查询招聘信息的工作流程,以便了解当前行业形势。

image

由于已经进行了详细的讲解,因此我决定直接将图发给你。

image

最后的步骤也是相同的,只需在添加完工作流之后将prompt稍作修改即可完成。

2:当用户询问Python招聘信息时,请调用job工作流处理。

image

当您点击链接时,可以顺利跳转至猎聘网站并查看详细信息。唯一的瑕疵在于大型模型一直存在一些识别问题,导致有些参数无法正确辨识,而在ChatGPT上却没有此问题。希望今后能够优化或者切换其他大模型,使其能够顺利切换至更加有效的状态。

有时候bot回答的很简略,就像这样:

image.png

你根本不知道这些信息是啥,所以这种时候可以考虑加一个列表卡片回复,里面的参数也就是你工作流中传入的参数。一一对应即可。

image.png

这样数据信息就可观了,但是还是有问题,毕竟我想要了解行业形式,所以以后我会考虑将这个工作流产出数据报表(待更新)。

image.png

发布到社交平台

最关键的问题是如何吸引他人使用我们的bot助手?一旦 Bot 完成开发和测试,你可以将其发布到不同的社交平台上,如微信公众号、飞书等。你可以将其作为客服使用,为用户提供实时的帮助和解答。当然,需要注意的是,如果要发布到微信公众号,需要是服务号,个人号无法实现此功能。

当我最初开始使用时,并没有豆包平台供选择,我只能将其发布在飞书上进行使用。最近,豆包进行了授权更新,且默认设置为可用,因此我想分享给大家如何充分利用这一更新。

image

在发布时选择合适的平台非常重要,如果你不想费心编写发布记录,当然也可以让AI帮你生成。只需一键发布,就能轻松完成任务。

image

image

接着,我们可以登录豆包平台。值得注意的是,我们的智能体在这个平台上是隐形的,其他小伙伴无法察觉到它的存在。接下里这么办。

image

通过等待审核完成,就可以让其他人轻松地发现我们的智能体,并与其互动。

image

效果演示

视频-1.gif

浅谈商业化

  • 提供客服服务方面,我认为这个例子非常典型,因为无需人工介入,可以实现与公众号后台的无缝连接,只需简单几步即可建立起自己的24小时客服系统。举个例子,就像我的Java转型助手一样,并不一定要使用Python,可以根据不同的需求选择各种技术方向,包括技术、运维、管理等。然而,如果雇佣一个客服人员,如果他们不了解Java技术,也不了解转型方向,沟通将会变得非常困难。有了这样一个客服系统,我觉得可以有效缩小沟通的隔阂。
  • 在Bot中进行广告推广是一种很好的方式,可以向用户展示与编程相关的产品和服务。这其实是构建生态系统的一部分,你并不一定需要只推广自家的产品。如果你的bot非常受欢迎,那么你完全可以在提示词中展示其他广告,并直接发送相关链接。但需要注意的是,这部分内容会接受平台的审核,所以需要谨慎选择广告内容,不要接收一切广告。其中最典型的例子就是各种考证,比如网络安全、PMP等方向。Bot只是一个助手,并不提供考证渠道,但如果通过用户的问题检测到他们对考证感兴趣,你完全可以介绍相关信息并获取提成。
  • 提供个性化定制解决方案:关键在于充分利用知识库和工作流程。举个简单的以公司内部交接为例。假如每位开发人员都精心维护各种文档,我们只需将这些文档输入到智能助手中,便可轻松应对后续问题。这样,后来者只需向智能助手提问,即可得到解答。相比之下,离职交接员工怎么可能会耐心解决你的问题呢,因此询问智能助手会更为高效。再谈谈公司所在的行业问题。每家公司都提供不同的服务,需要业务相关的人员统一维护好客户问题文档,精心维护单个bot模块助手不也是一种解决方法?

总结

经过这么多的探讨和阐述,文章已经达到了尾声,但是对于追求AI的激情仍然澎湃,我也努力避免被潮流所抛弃。虽然我们大多数无法发明AI,但我会全力以赴将其运用到最佳状态,这是我的宗旨。

在文章中,我还提到了关于bot玩法的详细说明,但由于篇幅过长,无法一一赘述。同时,我也强调了Python只是编程的一个方向,而有很多人Java开发人员选择转型。Java教育机构也开始将自己的资源流量分散到其他机构。因此,即使我们不转型,努力学习也是非常重要的。毕竟,我们并不是编程的初学者,可以直接跳过大量的入门视频。正因如此,我产生了创建这个转型助手的想法。

在文章中,我已经尽力表达了我的想法和观点。然而,我也意识到自己的文章可能存在不足之处。因此,如果你有任何更好的想法,我非常希望能够与你进行交流和讨论。

欢迎大家使用我的机器人~~,点击链接即可:

Coze平台:https://www.coze.cn/store/bot/7338773502511792137?from=bots_card

豆包平台:https://www.doubao.com/chat/9037572862722

bot ID: 7330510284496502803

本文转载自: 掘金

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

反射为什么慢? 1 背景 2 文章给出的解释 3 结合

发表于 2024-01-31
  1. 背景

今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。

  1. 文章给出的解释

文章中给出的理由是因为以下4点:

  1. 反射涉及动态解析的内容,不能执行某些虚拟机优化,例如JIT优化技术
  2. 在反射时,参数需要包装成object[]类型,但是方法真正执行的时候,又使用拆包成真正的类型,这些动作不仅消耗时间,而且过程中会产生很多的对象,这就会导致gc,gc也会导致延时
  3. 反射的方法调用需要从数组中遍历,这个遍历的过程也比较消耗时间
  4. 不仅需要对方法的可见性进行检查,参数也需要做额外的检查
  1. 结合实际理解

3.1 第一点分析

首先我们需要知道,java中的反射是一种机制,它可以在代码运行过程中,获取类的内部信息(变量、构造方法、成员方法);操作对象的属性、方法。
然后关于反射的原理,首先我们需要知道一个java项目在启动之后,会将class文件加载到堆中,生成一个class对象,这个class对象中有一个类的所有信息,通过这个class对象获取类相关信息的操作我们称为反射。

其次是JIT优化技术,首先我们需要知道在java虚拟机中有两个角色,解释器和编译器;这两者各有优劣,首先是解释器可以在项目启动的时候直接直接发挥作用,省去编译的时候,立即执行,但是在执行效率上有所欠缺;在项目启动之后,随着时间推移,编译器逐渐将机器码编译成本地代码执行,减少解释器的中间损耗,增加了执行效率。

我们可以知道JIT优化通常依赖于在编译时能够知道的静态信息,而反射的动态性可能会破坏这些假设,使得JIT编译器难以进行有效的优化。

3.2 第二点

关于第二点,我们直接写一段反射调用对象方法的demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Test
public void methodTest() {
Class clazz = MyClass.class;

try {
//获取指定方法
//这个注释的会报错 java.lang.NoSuchMethodException
//Method back = clazz.getMethod("back");
Method back = clazz.getMethod("back", String.class);
Method say = clazz.getDeclaredMethod("say", String.class);
//私有方法需要设置
say.setAccessible(true);
MyClass myClass = new MyClass("abc", 99);
//反射调用方法
System.out.println(back.invoke(myClass, "back"));

say.invoke(myClass, "hello world");
} catch (Exception e) {
e.printStackTrace();
}
}

在上面这段代码中,我们调用了一个invoke 方法,并且传了class对象和参数,进入到invoke方法中,我们可以看到invoke方法的入参都是Object类型的,args更是一个Object 数组,这就第二点,关于反射调用过程中的拆装箱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

3.3 第三点

关于调用方法需要遍历这点,还是上面那个demo,我们在获取Method 对象的时候是通过调用getMethod、getDeclaredMethod方法,点击进入这个方法的源码,我们可以看到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)
{
Method res = null;
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}

return (res == null ? res : getReflectionFactory().copyMethod(res));
}

我们可以看到,底层实际上也是将class对象的所有method遍历了一遍,最终才拿到我们需要的方法的,这也就是第二点,执行具体方法的时候需要遍历class对象的方法。

3.4 第四点

第4点说需要对方法和参数进行检查,也就是我们在执行具体的某一个方法的时候,我们实际上是需要校验这个方法是否可见的,如果不可见,我们还需要将这个方法设置为可见,否则如果我们直接调用这个方法的话,会报错。

同时还有一个点,在我们调用invoke方法的时候,反射类会对方法和参数进行一个校验,让我们来看一下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

我们可以看到还有quickCheckMemberAccess、checkAccess 等逻辑

  1. 总结

平时在反射这块用的比较少,也没针对性的去学习一下。在工作之余,还是得保持一个学习的习惯,这样子才不会出现今天这种被一个问题难倒的情况,而且才能产出更多、更优秀的方案。

本文转载自: 掘金

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

1…585960…956

开发者博客

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