注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Java中使用for而不是forEach遍历List的10大理由

首发公众号:【赵侠客】 引言 我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用J...
继续阅读 »

首发公众号:【赵侠客】



引言


我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用Java8中的Stream.foreach()来遍历元素,在技术圈感觉使用新的技术就高大上,开发者们也都默许接受新技术的很多缺点,而使用老的技术或者传统的方法就会被人鄙视,被人觉得Low,那么使用forEach()真的很高大上吗?它真的比传统的for循环好用吗?本文就列出10大推荐使用for而不是forEach()的理由。


鄙视使用for的水文


鄙视使用for的水文


理由一、for性能更好


在我的固有认知中我是觉得for的循环性能比Stream.forEach()要好的,因为在技术界有一条真理:



越简单越原始的代码往往性能也越好



而且搜索一些文章或者大模型都是这么觉得的,可时我并没有找到专业的基准测试证明此结论。那么实际测试情况是不是这样的呢?虽然这个循环的性能差距对我们的系统性能基本上没有影响,不过为了证明for的循环性能真的比Stream.forEach()好我使用基准测试用专业的实际数据来说话。我的测试代码非常的简单,就对一个List<Integer> ids分别使用forStream.forEach()遍历出所有的元素,以下是测试代码:


@State(Scope.Thread)
public class ForBenchmark {
private List<Integer> ids ;
@Setup
public void setup() {
ids = new ArrayList<>();
//分别对10、100、1000、1万、10万个元素测试
IntStream.range(0, 10).forEach(i -> ids.add(i));
}
@TearDown
public void tearDown() {
ids = new ArrayList<>();
}
@Benchmark
public void testFor() {
for (int i = 0; i <ids.size() ; i++) {
Integer id = ids.get(i);
}
}

@Benchmark
public void testStreamforEach() {
ids.stream().forEach(x->{
Integer id=x;
});
}

@Test
public void testMyBenchmark() throws Exception {
Options options = new OptionsBuilder()
.include(ForBenchmark.class.getSimpleName())
.forks(1)
.threads(1)
.warmupIterations(1)
.measurementIterations(1)
.mode(Mode.Throughput)
.build();
new Runner(options).run();
}
}

我使用ArrayList分对10、100、1000、1万,10万个元素进行测试,以下是使用JMH基准测试的结果,结果中的数字为吞吐量,单位为ops/s,即每秒钟执行方法的次数:


方法10万
forEach4519453217187781250180220029220309
for12705665419310361253050220263219228
for对比↑181%↑12%↑1%↓1%↓5%

从使用Benchmark基准测试结果来看使用for遍历List比Stream.forEach性能在元素越小的情况下优势越明显,在10万元素遍历时性能反而没有Stream.forEach好了,不过在实际项目开发中我们很少有超过10万元素的遍历。


所以可以得出结论:



在小List(万元素以内)遍历中for性能要优于Stream.forEach



理由二、for占用内存更小


Stream.forEach()会占用更多的内存,因为它涉及到创建流、临时对象或者对中间操作进行缓存。for 循环则更直接,操作底层集合,通常不会有额外的临时对象。可以看如下求和代码,运行时增加JVM参数-XX:+PrintGCDetails -Xms4G -Xmx4G输出GC日志:



  • 使用for遍历


List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = 0;
for (int i = 0; i < ids.size(); i++) {
sum +=ids.get(i);
}
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 392540K->174586K(1223168K)] 392540K->212100K(4019712K), 0.2083486 secs] [Times: user=0.58 sys=0.09, real=0.21 secs]

从GC日志中可以看出,使用for遍历List在GC回收前年轻代使用了392540K,总内存使用了392540K,回收耗时0.20s



  • 使用stream


List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = ids.stream().reduce(0,Integer::sum);
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 539341K->174586K(1223168K)] 539341K->212118K(4019712K), 0.3747694 secs] [Times: user=0.55 sys=0.83, real=0.38 secs]

从GC日志中可以看出,回收前年轻代使用了539341K,总内存使用了539341K,回收耗时0.37s ,从内存占用情况来看使用for会比Stream.forEach()占用内存少37%,而且Stream.foreach() GC耗时比for多了85%。


理由三、for更易控制流程


我们使用for遍历List可以很方便的使用breakcontinuereturn来控制循环,而使用Stream.forEach在循环中是不能使用breakcontinue,特别指出的使用return是无法中断Stream.forEach循环的,如下代码:


List<Integer> ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i->{
System.out.println(""+i);
if(i>1){
return;
}
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
System.out.println(""+ids.get(i));
if(ids.get(i)>1){
return;
}
}

输出:


forEach-1
forEach-2
forEach-3
==

for-1
for-2

从输出结果可以看出在Stream.forEach中使用return后循环还会继续执行的,而在for循环中使用return将中断循环。


理由四、for访问变量更灵活


这点我想是很多人在使用Stream.forEach中比较头疼的一点,因为在Stream.forEach中引用的变量必须是final类型,也就是说不能修改forEach循环体之外的变量,但是我们很多业务场景就是修改循环体外的变量,如以下代码:


Integer sum=0;
for (int i = 0; i < ids.size(); i++) {
sum++;
}

ids.stream().forEach(i -> {
//报错
sum++;
});

像上面的这样的代码在实际中是很常见的,sum++在forEach中是不被允许的,有时为了使用类似的方法我们只能把变量变成一个引用类型:


AtomicReference<Integer> sum= new AtomicReference<>(0);
ids.stream().forEach(i -> {
sum.getAndSet(sum.get() + 1);
});

所以在访问变量方面for会更加灵活。


理由五、for处理异常更方便


这一点也是我使用forEach比较头疼的,在forEach中的Exception必须要捕获处理,如下代码:


public void testException() throws Exception {
List<Integer> ids = IntStream.range(1, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
//直接抛出Exception
System.out.println(div(i, i - 1));
}

ids.stream().forEach(x -> {
try {
//必须捕获Exception
System.out.println(div(x, x - 1));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}

private Integer div(Integer a, Integer b) throws Exception {
return a / b;
}

我们在循环中调用了div()方法,该方法抛出了Exception,如果是使用for循环如果不想处理可以直接抛出,但是使用forEach就必须要自己处理异常了,所以for在处理异常方面会更加灵活方便。


理由六、for能对集合添加、删除


在for循环中可以直接修改原始集合(如添加、删除元素),而 Stream 不允许修改基础集合,会抛出 ConcurrentModificationException,如下代码:


List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
if(i<1){
ids.add(i);
}
}
System.out.println(ids);

List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(x -> {
if(x<1){
ids2.add(x);
}
});
System.out.println(ids2);

输出:


[0, 1, 2, 3, 0]
java.util.ConcurrentModificationException

如果你想在循环中添加或者删除元素foreach是无法完成了,所以for处理集合更方便。


理由七、for Debug更友好


Stream.forEach()使用了Lambda表达示,一行代码可以搞定很多功能,但是这也给Debug带来了困难,如下代码:


List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
System.out.println(ids.get(i));
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(System.out::println);

以下是DeBug截图:


for DeBug过程


我们可以看出使用for循环Debug可以一步一步的跟踪程序执行步骤,但是使用forEach却做不到,所以for可以更方便的调试你的代码,让你更快捷的找到出现问题的代码。


理由八、for代码可读性更好


Lambda表达示属于面向函数式编程,主打的就是一个抽象,相比于面向对象或者面向过程编程代码可读性是非常的差,有时自己不写的代码过段时间后自己都看不懂。就比如我在文章《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》一文中使用函数式编程写了一个Tree工具类,我们可以对比一下面向过程和面向函数式编程代码可读性的差距:



  • 使用for面向过程编程代码:


 public static List<MenuVo> makeTree(List<MenuVo> allDate,Long rootParentId) {
List<MenuVo> roots = new ArrayList<>();
for (MenuVo menu : allDate) {
if (Objects.equals(rootParentId, menu.getPId())) {
roots.add(menu);
}
}
for (MenuVo root : roots) {
makeChildren(root, allDate);
}
return roots;
}
public static MenuVo makeChildren(MenuVo root, List<MenuVo> allDate) {
for (MenuVo menu : allDate) {
if (Objects.equals(root.getId(), menu.getPId())) {
makeChildren(menu, allDate);
root.getSubMenus().add(menu);
}
}
return root;
}


  • 使用forEach面向函数式编程代码:


public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}
private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}

对比以上两段代码,可以看出面向过程的代码思路非常的清晰,基本上可以一眼看懂代码要做什么,反观面向函数式编程的代码,我想大都人一眼都不知道代码在干什么的,所以使用for的代码可读性会更好。


理由九、for更好的管理状态


for循环可以轻松地在每次迭代中维护状态,这在Stream.forEach中可能需要额外的逻辑来实现。这一条可理由三有点像,我们经常需要通过状态能控制循环是否执行,如下代码:


boolean  flag = true;
for (int i = 0; i < 10; i++) {
if(flag){
System.out.println(i);
flag=false;
}
}

AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0, 10).forEach(x->{
if (flag1.get()){
flag1.set(false);
System.out.println(x);
}
});


这个例子说明了在使用Stream.forEach时,为了维护状态,我们需要引入额外的逻辑,如使用AtomicBoolean,而在for循环中,这种状态管理是直接和简单的。


理由十、for可以使用索引直接访问元素


在某些情况下,特别是当需要根据元素的索引(位置)来操作集合中的元素时,for就可以直接使用索引访问了。在Stream.forEach中就不能直接通过索引访问,比如我们需要将ids中的数字翻倍:


List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
ids.set(i,i*2);
}

List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2=ids2.stream().map(x->x*2).collect(Collectors.toList());

我们使用for循环来遍历这个列表,并在每次迭代中根据索引i来修改列表中的元素。这种操作直接且直观。而使用Stream.foreach()不能直接通过索引下标访问元素的,只能将List转换为流,然后使用map操作将每个元素乘以2,最后,我们使用Collectors.toList()将结果收集回一个新的List。


总结


本文介绍了在实际开发中更推荐使用for循环而不是Stream.foreach()来遍历List的十大理由,并给出了具体的代码和测试结果,当然这并不是说就一定要使用传统的for循环,要根据自己的实际情况来选择合适的方法。通过此案件也想让读者明白在互联网世界中你所看到的东西都是别人想让你看到的,这个世界是没有真相的,别人想让你看到的就是所谓的”真相“,做为吃瓜群众一定不能随波逐流,要有鉴别信息真假的能力和培养独立思考的能力。


作者:赵侠客
来源:juejin.cn/post/7416848881407524902
收起阅读 »

VSCode无限画布模式(可能会惊艳到你的一个小功能)

👇 该文章内容的受众是VSCode的用户,不满足条件的同学可以选择性阅读哈~❓现存的痛点VSCode是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口,组件B的tsx、css代码、工具类方法各一个...
继续阅读 »

👇 该文章内容的受众VSCode的用户,不满足条件的同学可以选择性阅读哈~

❓现存的痛点

image.png

VSCode是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口组件B的tsx、css代码、工具类方法各一个窗口,组件C的......

small.gif

当组件拆的足够多的时候,多个分栏会把本就不大的编辑器窗口分成N份,每一份的可视区域就小的可怜,切换组件代码时,需要不小的翻找成本,而且经常忘记我之前把文件放在了那个格子里,特别是下面的场景(一个小窗口内开了N个小tab),此时更难找到想要的窗口了...

多个tab.gif

问题汇总

  1. 分栏会导致每个窗口的面积变小,开发体验差(即使可以双击放大,但效果仍不符合预期);
  2. 编辑器窗口容易被新打开的窗口替换掉,常找不到之前打开的窗口;
  3. 窗口的可操作性不强,位置不容易调整

💡解题的思路

1. 自由 & 独立的编辑器窗口

  1. 分栏会导致每个窗口的面积变小,开发体验不好。

那就别变小了!每个编辑器窗口都还是原来的大小,甚至更大!

20240531-220533.gif

2. 无限画布

  1. 编辑器窗口容易被新打开的窗口替换掉,常找不到之前打开的窗口。
  2. 窗口的可操作性不强,位置不容易调整。

那就每个窗口都拥有一个自己的位置好了!拖一下就可以找到了!

scroll.gif

3. 画布体验

好用的画布是可以较大的提升用户体验的,下面重点做了四个方面的优化:

3.1 在编辑器里可以快速缩小 & 移动

因为不可避免的会出现一些事件冲突(比如编辑器里的滚动和画布的滚动、缩放等等),通过提供快捷键的解法,可以在编辑器内快速移动、缩放画布。

command + 鼠标上下滑动 = 缩放
option + 鼠标移动 = 画布移动

注意下图,鼠标还在编辑器窗口中,依然可以拖动画布👇🏻

single-editor.gif

3.2 快速放大和缩小编辑窗口

通过快捷按钮的方式,可以快速的放大和缩小编辑器窗口。

scale.gif

3.3 一键定位到中心点

不小心把所有窗口都拖到了画布视口外找不到了?没事儿,可以通过点击快捷按钮的方式,快速回到中心点。

center.gif

3.4 窗口的合并和分解

可以在窗口下进行编辑器的合并,即可以简单的把一些常用的窗口进行合并、分解。

add-remove.gif

💬 提出的背景

作为一名前端开发同学,避免不了接触UI同学的设计稿,我司使用的就是figma,以figma平台为例,其无限画布模式可以非常方便的平铺N个稿子,并快速的看到所有稿子的全貌、找到自己想要的稿子等等,效果如下:

figma.gif

没错!我就是基于这个思路提出了第一个想法,既然图片可以无限展示,编辑器为什么不能呢?

这个想法其实去年就有了,期间大概断断续续花了半年多左右的时间在调研和阅读VSCode的源码上,年后花了大概3个月的时间进行实现,最终在上个月做到了上面的效果。

经过约一个月的试用(目前我的日常需求均是使用这种模式进行的开发),发现效果超出预期,我经常会在画布中开启约10+个窗口,并频繁的在各个窗口之间来回移动,在这个过程中,我发现以下几点很让我很是欣喜:

  1. 空间感:我个人对“空间和方向”比较敏感,恰好画布模式会给我一种真实的空间感,我仿佛在一间房子里,里面摆满了我的代码,我穿梭在代码中,修一修这个,调一调这个~
  2. 满足感:无限画布的方式,相当于我间接拥有了无限大的屏幕,我只需要动动手指找到我的编辑窗口就好了,它可以随意的放大和缩小,所以我可以在屏幕上展示足够多的代码。
  3. 更方便的看源码:我可以把源码的每个文件单独开一个窗口,然后把每个窗口按顺序铺起来,摆成一条线,这条线就是源码的思路(当然可以用截图的方式看源码 & 缕思路,但是,需要注意一点,这个编辑器是可以交互的!)

⌨️ 后续的计划

后续计划继续增强画布的能力,让它可以更好用:

  1. 小窗口支持命名,在缩小画布时,窗口缩小,但是命名不缩小,可以直观的找到想要的窗口。
  2. 增强看源码的体验:支持在画布上添加其他元素(文案、箭头、连线),试想一下,以后在看源码时,拥有一个无限的画板来展示代码和思路,关键是代码是可以交互的,这该有多么方便!
  3. 类似MacOS的台前调度功能:把有关联的一些窗口分组,画布一侧有分组的入口,点击入口可以切换画布中的组,便于用户快速的进行批量窗口切换,比如A页面的一些JS、CSS等放在一个组,B页面放在另一个组,这样可以快速的切换文件窗口。

📔 其他的补充

调研过程中发现无法使用VSCode的插件功能来实现这个功能,所以只好fork了一份VSCode的开源代码,进行了大量修改,最终需要对源码进行编译打包才能使用(一个新的VSCode),目前只打包了mac的arm64版本来供自己试用。

另外,由于VSCode并不是100%开源(微软的一些服务相关的逻辑是闭源的),所以github上的开源仓库只是它的部分代码,经过编译之后,发现缺失了远程连接相关的功能,其他的功能暂时没有发现缺失。

image.png

🦽 可以试用吗

目前还没有对外提供试用版的打算,想自己继续使用一些时间,持续打磨一下细节,等功能细节更完善了再对外进行推广,至于这次的软文~ 其实是希望可以引起阅读到这里的同学进行讨论,可以聊一下你对该功能的一些看法,以及一些其他的好点子~,thx~

🫡 小小的致敬

  • 致敬VSCode团队,在阅读和改造他们代码的过程中学习到了不少hin有用的代码技能,也正是因为有他们的开源,才能有我的这次折腾👍🏻
  • 致敬锤子科技罗永浩老师,这次实现思路也有借鉴当年发布的“无限屏”功能,本文的头图就是来自当年的发布会截图。

image.png


作者:木头就是我呀
来源:juejin.cn/post/7375586227984220169
收起阅读 »

工作六年,看到这样的代码,内心五味杂陈......

工作六年,看到这样的代码,内心五味杂陈...... 那天下午,看到了令我终生难忘的代码,那一刻破防了...... ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起...... 📖一、历史背景 1.1 数据隔离 预发,灰度,线上环境共用一个数据库。每一张...
继续阅读 »

工作六年,看到这样的代码,内心五味杂陈......


那天下午,看到了令我终生难忘的代码,那一刻破防了......


ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起......


📖一、历史背景


1.1 数据隔离


预发,灰度,线上环境共用一个数据库。每一张表有一个 env 字段,环境不同值不同。特别说明: env 字段即环境字段。如下图所示:
image.png


1.2 隔离之前


🖌️插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;
有一天预发环境的操作影响到客户线上的数据。 为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。


当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。


1.3 隔离改造


其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。


每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:


SELECT XXX FROM tableName WHERE env = ${环境字段值} and ${condition}

1.4 隔离方案


最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!


image.png


具体方案:自定义 mybatis 拦截器进行统一处理。 通过这个方案可以解决以下几个问题:



  • 业务代码不用修改,包括 DO、Mapper、XML等。只修改 mybatis 拦截的逻辑。

  • 挨个添加补充字段,工程量很多,出错概率极高

  • 后续扩展容易


1.5 最终落地


在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件。真正实现改一处即可。 考虑历史数据过渡,将 env = ${当前环境}
修改成 env in (${当前环境},'all')


SELECT xxx FROM ${tableName} WHERE env in (${当前环境},'all') AND ${其他条件}

具体实现逻辑如下图所示:


image.png



  1. 其中 env 字段是从 application.properties 配置获取,全局唯一,只要环境不同,env 值不同

  2. 借助 JSqlParser 开源工具,改写 sql 语句,修改重新填充、查询拼接条件即可。链接JSQLParser


思路:自定义拦截器,填充环境参数,修改 sql 语句,下面是部分代码示例:


@Intercepts(
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})}
)

@Component
public class EnvIsolationInterceptor implements Interceptor {
......
@Override
public Object intercept(Invocation invocation) throws Throwable {
......
if (SqlCommandType.INSERT == sqlCommandType) {
try {
// 重写 sql 执行语句,填充环境参数等
insertMethodProcess(invocation, boundSql);
} catch (Exception exception) {
log.error("parser insert sql exception, boundSql is:" + JSON.toJSONString(boundSql), exception);
throw exception;
}
}

return invocation.proceed();
}
}

一气呵成,完美上线。


image.png


📚二、发展演变


2.1 业务需求


随着业务发展,出现了以下需求:



  • 上下游合作,我们的 PRC 接口在匹配环境上与他们有差异,需要改造


SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')


  • 有一些环境的数据相互相共享,比如预发和灰度等

  • 开发人员的部分后面,希望在预发能纠正线上数据等


2.2 初步沟通


这个需求的落地交给了来了快两年的小鲜肉。 在开始做之前,他也问我该怎么做;我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......


image.png


(●ˇ∀ˇ●)年纪大了需要给年轻人机会。


2.3 勤劳能干


小鲜肉,没多久就实现了。不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。(不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧🤔)


2.4 具体实现


大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。填充颜色部分为小鲜肉的改造逻辑。


image.png


大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。


SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}

2.5 错误原因


经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。 简化举例: A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。具体如下:


image.png


2.6 五味杂陈


当我看到代码的一瞬间,彻底破防了......


image.png


queryProject 方法里面调用 findProjectWithOutEnv,
在两个方法中,都有填充处理 env 的代码。


2.7 遍地开花


然而,这三行代码,随处可见,在业务代码中遍地开花.......


image.png


// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();

// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());

//....... 业务代码 ....

// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);

image.png


改了个遍,很勤劳👍......


2.8 灵魂开问


image.png


难道真的就只能这么做吗,当然还有......



  • 开闭原则符合了吗

  • 改漏了应该办呢

  • 其他人遇到跳过的检查的场景也加这样的代码吗

  • 业务代码和功能代码分离了吗

  • 填充到应用上下文对象 user 合适吗

  • .......


大量魔法值,单行字符超500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......


内心涌动😥,我觉得要重构一下。


📒三、重构一下


3.1 困难之处


在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。 只能通过栈帧查询到调用链。


3.2 问题列表



  • 尽量不要修改已有方法,保证不影响原有逻辑;

  • 尽量不要在业务方法中修改功能代码;关注点分离;

  • 尽量最小改动,修改一处即可实现逻辑;

  • 改造后复用能力,而不是依葫芦画瓢地添加这种代码


3.3 实现分析



  1. 用独立的 ThreadLocal,不与当前用户信息上下文混合使用

  2. 注解+APO,通过注解参数解析,达到目标功能

  3. 对于方法之间的调用或者循环调用,要考虑优化


同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。


3.4 使用案例


改造后的使用案例如下,案例说明:project 表在预发环境校验跳过


@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})


@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response)
{
......
}

在使用的调用入口处添加注解。


3.5 具体实现



  1. 方法上标记注解, 注解参数定义规则

  2. 切面读取方法上面的注解规则,并传递到应用上下文

  3. 拦截器从应用上下文读取规则进行规则判断


image.png


注解代码


@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {

/**
* 是否跳过环境。 默认 true,不推荐设置 false
*
*
@return
*/

boolean isKip() default true;

/**
* 赋值则判断规则,否则不判断
*
*
@return
*/

String[] skipEnvList() default {};

/**
* 赋值则判断规则,否则不判断
*
*
@return
*/

String[] skipTableList() default {};
}


3.6 不足之处



  1. 整个链路上的这个表操作都会跳过,颗粒度还是比较粗

  2. 注解只能在入口处使用,公共方法调用尽量避免


🤔那还要不要完善一下,还有什么没有考虑到的点呢? 拿起手机看到快12点的那一刻,我还是选择先回家了......


📝 四、总结思考


4.1 隔离总结


这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。


4.2 编码总结


同样的代码写两次就应该考虑重构了



  • 尽量修改一个地方,不要写这种边边角角的代码

  • 善用自定义注解,解决这种通用逻辑

  • 可以妥协,但是要有底线

  • ......


4.3 场景总结


简单梳理,自定义注解 + AOP 的场景


场景详细描述
分布式锁通过添加自定义注解,让调用方法实现分布式锁
合规参数校验结合 ognl 表达式,对特定的合规性入参校验校验
接口数据权限对不同的接口,做不一样的权限校验,以及不同的人员身份有不同的校验逻辑
路由策略通过不同的注解,转发到不同的 handler
......

自定义注解很灵活,应用场景广泛,可以多多挖掘。


4.4 反思总结



  • 如果一开始就做好技术方案或者直接使用不同的数据库

  • 是否可以拒绝那个所谓的需求

  • 先有设计再有编码,别瞎搞


4.5 最后感想


image.png



在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感;身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪; 突然一想,这么做的意义又有多大呢?



作者:uzong
来源:juejin.cn/post/7294844864020430902
收起阅读 »

ArrayList扩容原理

ArrayList扩容原理(源码理解) 从源码角度对ArrayList扩容原理进行简介,我们可以更深入地了解其内部实现和工作原理。以下是基于Java标准库中ArrayList扩容原理源码的简介 1、类定义与继承关系 ArrayList在Java中的定义如下: ...
继续阅读 »

ArrayList扩容原理(源码理解)


从源码角度对ArrayList扩容原理进行简介,我们可以更深入地了解其内部实现和工作原理。以下是基于Java标准库中ArrayList扩容原理源码的简介


1、类定义与继承关系

ArrayList在Java中的定义如下:


public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{

ArrayList是一个泛型类,继承自AbstractList并实现了List接口,同时还实现了RandomAccess、Cloneable和Serializable接口。这 些接口分别表示ArrayList支持随机访问、可以被克隆以及可以被序列化。


2、核心成员变量(牢记)

elementData:是实际存储元素的数组,它可以是默认大小的空数组(当没有指定初始容量且没有添加元素 时),也可以是用户指定的初始容量大小的数组,或者是在扩容后新分配的数组。
size:表示数组中当前元素的个数。


transient Object[] elementData; //数组

private int size; //元素个数


DEFAULT_CAPACITY是ArrayList的默认容量,当没有指定初始容量时,会使用这个值。


    //默认初始容量。
private static final int DEFAULT_CAPACITY = 10;

EMPTY_ELEMENTDATA表示空数组。


DEFAULTCAPACITY_EMPTY_ELEMENTDATA也表示空数组,为了区分而命名不同。


    
//用于创建空对象的共享空数组实例。
private static final Object[] EMPTY_ELEMENTDATA = {};

//用于默认大小的空数组实例的共享空数组实例。我们将它与EMPTY_ELEMENTDATA区分开来,以便在添加第一个元素时知道要扩容多少。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

3、构造方法

ArrayList提供了多个构造方法,包括无参构造方法、指定初始容量的构造方法。
java


   
//无参构造

//构造一个初始容量为10的空数组。
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

//有参构造

//构造具有指定初始容量的空数组。
public ArrayList(int initialCapacity) { //构建有参构造方法
if (initialCapacity > 0) { //如果传入参数>0
this.elementData = new Object[initialCapacity]; //创建一个数组,大小为传入的参数
} else if (initialCapacity == 0) { //如果传入的参数=0
this.elementData = EMPTY_ELEMENTDATA; //得到一个空数组
} else { //否则
throw new IllegalArgumentException("Illegal Capacity: "+ //抛出异常
initialCapacity);
}
}


这里可以看到无参构造方法用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA表示空数组,而有参构造方法中当传入参数=0,用的是EMPTY_ELEMENTDATA表示空数组。


4、扩容机制

具体流程:


1、开始添加元素前先判断当前数组容量是否足够(ensureCapacityInternal()方法),这里有个特例就是添加第一个元素时要先将数组扩容为初始容量大小(calculateCapacity()方法)。如果足够就向数组中添加元素。


2、如果当前数组容量不够,开始计算新容量的大小并赋值给新数组,复制原始数组中的元素到新数组中(grow()方法)


流程图如下:


屏幕截图 2024-10-17 123915.png


从向ArrayList添加元素来观察底层源码是如何实现的


观察add()方法,其中提到一个不认识的ensureCapacityInternal()方法,把他看做用来判断数组容量是否足够的方法,判断完后将元素添加到数组中


public boolean add(E e) {
ensureCapacityInternal(size + 1); //判断数组容量是否足够,传入的一个大小为(size+1)的参数
elementData[size++] = e; //添加元素
return true;
}

现在来看上面add()方法提到的ensureCapacityInternal()方法, 进入查看源码,又出现两个不认识的方法: calculateCapacity()方法和ensureExplicitCapacity()方法。


private void ensureCapacityInternal(int minCapacity) {       //这里minCapacity大小就是上面传入参数:size+1
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
```

calculateCapacity()方法:里面有一个判断语句,判断当前数组是不是空数组。如果是空数组那就将数组容量初始化为10,如果不是空数组,那就直接返回minCapacity
ensureExplicitCapacity()方法:重点观察判断语句,将calculateCapacity()方法中传进来的minCapacity与原数组的长度作比较,当原数组长度小于minCapacity的值就开始进行扩容。


//    calculateCapacity方法
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//判断数组是否为空
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//数组为空时比较,DEFAULT_CAPACITY=10,minCapacity=size+1,DEFAULT_CAPACITY一定比minCapacity大,所以空数组容量初始化为10
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
//数组不为空,minCapacity=size+1,相当于不变
return minCapacity;

}

//-------------------------------------------分割线-----------------------------------------------------//


// ensureExplicitCapacity方法
private void ensureExplicitCapacity(int minCapacity) {//这里的minCapacity是上面传过来的
modCount++;
if (minCapacity - elementData.length > 0) //判断数组长度够不够,不够才扩
grow(minCapacity);
}

举例



  1. 当向数组添加第1个元素时size=0,calculateCapacity()方法中判断数组为空,数组容量初始化为10。到了ensureExplicitCapacity()方法中,因为是空数组,所以elementData.length=0,判断成立,数组进行扩容大小为10。

  2. 当向数组添加第2个元素时size=1,calculateCapacity()方法中判断数组为非空,为minCapacity赋值为2。到了ensureExplicitCapacity()方法中,因为数组大小已经扩容为10,所以elementData.length=10,判断不成立,不扩容

  3. 当向数组添加第11个元素时size=10,calculateCapacity()方法中判断数组为非空,为minCapacity赋值为11。到了ensureExplicitCapacity()方法中,因为数组大小已经扩容为10,所以elementData.length=10,判断成立,开始扩容


前面都是判断数组要不要进行扩容,下面内容就是如何扩容。


首先,grow()方法是扩容的入口,它根据当前容量计算新容量,并调用Arrays.copyOf方法复制数组。hugeCapacity()方法用于处理超大容量的情况,确保不会超出数组的最大限制。


*   这一步是为了先确定扩容的大小,再将元素复制到新数组中


private void grow(int minCapacity) {
int oldCapacity = elementData.length; //定义一个oldCapacity接收当前数组长度
int newCapacity = oldCapacity + (oldCapacity >> 1); //定义一个newCapacity接收oldCapacity1.5倍的长度
if (newCapacity - minCapacity < 0) //如果newCapacity长度<minCapacity
newCapacity = minCapacity; //将minCapacity赋值给newCapacity
if (newCapacity - MAX_ARRAY_SIZE > 0) //如果newCapacity长度>最大的数组长度
newCapacity = hugeCapacity(minCapacity); //将进行hugeCapacity方法以后的值赋值给newCapacity
elementData = Arrays.copyOf(elementData, newCapacity);//开始扩容
}

查看hugeCapacity()方法 (防止扩容后的数组太大了)
MAX_ARRAY_SIZE 理解为:快接近integer的最大值了。
Integer.MAX_VALUE 理解为:integer的最大值。


private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) //如果minCapacity<0
throw new OutOfMemoryError(); //抛出异常
return (minCapacity > MAX_ARRAY_SIZE) ? //返回一个值,判断minCapacity是否大于MAX_ARRAY_SIZE
Integer.MAX_VALUE : //大于就返回 Integer.MAX_VALUE
MAX_ARRAY_SIZE; //小于就返回 MAX_ARRAY_SIZE
}

```

最后一步,了解是如何如何将元素添加到新数组的

查看Arrays.copyof源代码


用于将一个原始数组(original)复制到一个新的数组中,新数组的长度(newLength)可以与原始数组不同。

public static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) copyOf(original, newLength, original.getClass());
}

查看copyof()方法 (判断新数组与原数组类型是否一致)


public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
   ? (T[]) new Object[newLength]
   : (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
                Math.min(original.length, newLength));
return copy;
}

开始复制原数组的元素到新数组中


将一个数组`src`的从索引`srcPos`开始的`length`个元素复制到另一个数组`dest`的从索引`destPos`开始的位置。

public static native void arraycopy(Object src,  int  srcPos,
                               Object dest, int destPos,
                               int length);

参数说明:

参数说明:



  • src:原数组,类型为Object,表示可以接受任何类型的数组。

  • srcPos:原数组的起始索引,即从哪个位置开始复制元素。

  • dest:新数组,类型为Object,表示可以接受任何类型的数组。

  • destPos:新数组的起始索引,即从哪个位置开始粘贴元素。

  • length:要复制的元素数量。


从宏观上来说,ArrayList展现的是一种动态数组的扩容,当数组中元素个数到达一定值时数组自动会扩大容量,以方便元素的存放。


从微观上来说,ArrayList是在当数组中元素到达一定值时,去创建一个大小为原数组1.5倍容量的新数组,将原数组的元素复制到新数组当中,抛弃原数组。


作者:科昂
来源:juejin.cn/post/7426280686695710730
收起阅读 »

面试官:count(1) 和 count(*)哪个性能更好?

在数据库查询中,count(*) 和 count(1) 是两个常见的计数表达式,都可以用来计算表中行数。 很多人都以为 count(*) 效率更差,主要是因为在早期的数据库系统中,count(*) 可能会被实现为对所有列进行扫描,而 count(1) 则可能只...
继续阅读 »

在数据库查询中,count(*) 和 count(1) 是两个常见的计数表达式,都可以用来计算表中行数。


很多人都以为 count(*) 效率更差,主要是因为在早期的数据库系统中,count(*) 可能会被实现为对所有列进行扫描,而 count(1) 则可能只扫描单个列。


但事实真是如此吗?


执行原理


先来看看这两者的执行原理:


count(*) 查询所有满足条件的行,包括包含空值的行。在大多数数据库中,count(*) 会直接统计行数,并不会实际去读取每行中的详细数据,因为数据库引擎会自行优化该计数操作,以提高执行效率。


count(1) 也是计算表中的行数,这里的 1 是一个常量,只是作为一个占位符,并没有实际的含义。与 count(*) 类似,数据库引擎也会对 count(1) 进行优化,以快速确定表中的行数。


count(*) 和 count(1) 的 性能差异


再说性能,在大多数数据库中,其实 count(*)count(1) 的性能非常相似,甚至可以说没有区别,这是因为大多数数据库引擎对这两种计数方式进行相同的优化,并没有明显的执行效率上的差异。但是在特殊情况下可能会有细微的差异,造成这种差异的原因通常有以下几种:


1. 数据库引擎的差异


不同的数据库引擎可能对 count(*)count(1) 采取不同的优化策略,这在某些情况下可能会导致两种计数方式的性能差异。例如:



  • SQL Server:在某些版本的 SQL Server 中,count(1) 在特定的查询计划中可能稍微快一些,但这种差异通常微乎其微,只有在处理非常大的表或复杂查询时才会显现出来。

  • MyISAM 引擎:在不附加任何 WHERE 查询条件的情况下,统计表的总行数会非常快,因为 MyISAM 会用一个变量存储表的行数。如果没有 WHERE 条件,查询语句将直接返回该变量值,使得速度很快。然而,只有当表的第一列定义为 NOT NULL 时,count(1) 才能得到类似的优化。如果有 WHERE 条件,则该优化将不再适用。

  • InnoDB 引擎:尽管 InnoDB 表也存储了一个记录行数的变量,但遗憾的是,这个值只是一个估计值,并无实际意义。在 Innodb 引擎下, count(*)count(1) 哪个快呢?结论是:这俩在高版本的 MySQL 是没有什么区别的,也就没有 count(1) 会比 count(*) 更快这一说了。


另外,还有一个问题是 Innodb 是通过主键索引来统计行数的吗?


如果该表只有一个主键索引,没有任何二级索引的情况下,那么 count(*)count(1) 都是通过通过主键索引来统计行数的。


如果该表有二级索引,则 count(*)count(1) 都会通过占用空间最小的字段的二级索引进行统计。


2. 索引的影响


如果表上有合适的索引,无论是count(1) 还是 count(*) 都可以利用索引来快速确定行数,而不必扫描整个表。在这种情况下,两者的性能差异通常可以忽略不计。例如,如果有一个基于主键的索引,数据库可以快速通过索引确定表中的行数,而无需读取表中的每一行数据。


实战分析


话不多说,下面我们通过实验来验证上述理论:


第一步:创建表与插入数据


用 Chat2DB 给我们生成一个创建表的 sql 语句,直接用自然语言描述我们想要的字段名和字段类型即可生成建表语句,也可以生成测试数据。


11.png


然后用存储过程向 student 表中插入两万条测试数据。(存储过程执行两次)


22.png


插入数据后的 student 表如下:


33.png


这个时候执行 select count(*) from studentselect count(1) from student 可以看到解释器的结果如下,耗时均为 2 ms(两者一致,所以就只截了一张图),两者都用主键索引进行行数的统计:


44.png


第二步:执行计数查询


创建二级索引 IDCard 进行统计结果如下:


55.png


66.png


77.png


可以看出用二级索引进行统计的解释器结果还是一致。


结论


综上所述,count(1)count(*) 的性能基本相同,并不存在 COUNT(1)COUNT(*) 更快的说法。总体而言,在大多数情况下,两者之间的性能差异是可以忽略不计的。


在选择使用哪种方式时,应当优先考虑代码的可读性和可维护性。count(*) 在语义上更为明确,表示计算所有行的数量,而不依赖于任何特定的值。因此,从代码清晰度的角度出发,通常建议优先使用 count(*)


当然,如果在特定的数据库环境中,经过实际测试发现 count(1) 具有明显的性能优势,那么也可以选择使用 count(1)。但在一般情况下,不必过分纠结于这两种计数方式之间的性能差异。


希望本文能帮助你在使用计数操作时作出更为合理的选择。




Chat2DB 文档:docs.chat2db.ai/zh-CN/docs/…


Chat2DB 官网:chat2db.ai/zh-CN


Chat2DB GitHub:github.com/codePhiliaX…


作者:Chat2DB
来源:juejin.cn/post/7417521775587065907
收起阅读 »

【后端性能优化】接口耗时下降60%,CPU负载降低30%

大家好,我是五阳。 GC 话题始终霸占面试必问排行榜,很多人对 GC 原理了然于胸,但是苦于没有实践经验,因此本篇文章将分享我的GC 优化实践。一个很小的优化,产生了非常好的效果。 现在五阳将优化过程给大家汇报一下。 一、背景 我所负责的 A 服务每天的凌晨...
继续阅读 »

大家好,我是五阳。


GC 话题始终霸占面试必问排行榜,很多人对 GC 原理了然于胸,但是苦于没有实践经验,因此本篇文章将分享我的GC 优化实践。一个很小的优化,产生了非常好的效果。


现在五阳将优化过程给大家汇报一下。




一、背景


我所负责的 A 服务每天的凌晨会定时执行一个批量任务,每天执行时都会触发 GC 频率告警,偶尔单机 CPU 负载超过 60%时,会触发 CPU 高负载告警。


曾经有考虑过通过单机限流器,限制任务执行速率,从而降低机器负载。然而因为业务上希望定时任务尽快执行完,所以优化方向就放在了如何降低 CPU 负载,如何降低 GC 频率。


1.1 配置和负载



  • 版本:java8

  • GC 回收器:ParNew + CMS

  • 硬件:8 核 16G 内存,Centos6.8

  • 高峰期CPU 平均负载(分钟)超过 50%(每个公司计算口径可能不同。我司的历史经验超过 70%后,接口性能将会快速恶化)


1.2 优化前的 GC情况


不容乐观。



  • 高峰期 Young GC频率 70次/min,单次 ygc 平均时间 125ms;

  • 高峰期 Full GC频率 每 3 分钟 1 次;单次 fgc 平均时间 610ms。


1.3 GC 参数和 JVM 配置


参数配置说明
-Xmx6g -Xms6g堆内存大小为6G
-XX:NewRatio=4老年代的大小是新生代的 4 倍,即老年代占4.8G,新生代占1.2G
-XX:SurvivorRatio=8Eden:From:To= 8:1:1,即Eden区占0.96G,两个Survivor区分别占0.12G
-XX:ParallelCMSThreads=4设置 CMS 垃圾回收器使用的并行线程数为 4
XX:CMSInitiatingOccupancyFraction=72设置老年代使用率达到 72% 时触发 CMS 垃圾回收。
-XX:+UseParNewGC启用 ParNew 作为年轻代垃圾回收器
-XX:+UseConcMarkSweepGC启用 CMS 垃圾回收器

二、问题分析


2.1 增加 GC打印参数


由于打印GC信息不足,无法分析问题。因此添加了 以下GC 打印参数,以提供更多的信息


-XX:+PrintGCDetails 
-XX:+PrintGCTimeStamps
-XX:+PrintCommandLineFlags
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintReferenceGC

2.2 提前晋升现象


配置如上参数后,每次发生 younggc后,都会打印详细的 younggc 日志。通过分析 gc 日志,我发现日志中经常出现类似内容。
Desired survivor size 61054720 bytes, new threshold 2 (max 15)


new threshold是新的晋升阈值,是指对象在新生代经过 new threshold 轮 younggc后,就能晋升到老年代,这个值通过 MaxTenuringThreshold配置,默认值是 15,在原有理解中阈值是固定值 15,实际上这个值会动态调整。



为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄



Desired survivor 一般是 Survivor 区的一半。假设年龄 1至N 的对象大小,超过了 Desired size,那么下一次 GC 的晋升阈值就会调整为 N。举个例子,假设 age=1的对象为 80M,超过了 61M,那么下一次GC 的晋升阈值就是 1,所有超过 1 的对象都会晋升到老年代,无需等到年龄到 15。


如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志


2.3 老年代增长速度过快


为了印证是否发生提前晋升,我通过监控查看到在事发时间,老年代内存的涨幅和 Survivor的内存基本一致,看来新生代的对象确实提前晋升到老年代了。


grep 分析历次 GC 后的晋升阈值后,我发现绝大部分情况下,新生的对象无法在 15 次 GC后进入到老年代,基本上三次以后就会提前晋升到老年代…… 这解释了为什么会发生频繁的 FullGC。


假设每次提前晋升 100M 到老年代,每分钟超过 15 次 ygc,则每分钟将会有 1.5G 对象进入老年代。


因为频繁地提前晋升,老年代的增长速度极快。 在高峰期时,往往 2 至 3 分钟左右,老年代内存就会触达 72% 的阈值,从而发生 FullGC。


2.4 新生代内存不足


即便老年代配置 4.8G 的大内存,但频繁地发生提前晋升,老年代也很快被打满。这背后的根本原因在于 新生代的内存太小了。 新生代,总共 1.2G 大小,Survivor才 120M,这远远不够。


于是我们调整了内存分配。调整后如下



  • -Xmx10g -Xms10g -Xmn6g

  • -XX:SurvivorRatio=8



  1. 堆内存由 6G 增加到 10G

  2. 大部分堆内存(6G)分配给新生代。新生代内存从 1.2G 增加到 6G。

  3. Eden:From:To 的比例依然是 8:1:1

  4. Eden大小从 0.96 G 增加到 4.8 G。

  5. Survivor区由 120 M 增加到 600 M。


三、优化效果


虽然改动不大,但是优化效果十分显著。由于公司监控有水印,我无法截图取证,敬请谅解。


3.1 GC频率明显下降



  • 高峰期 ygc 70 次/min 降到了 12 次/min,下降幅度达83%(单机 500 QPS)

  • 高峰期 fgc 三分钟1 次,降到了 每天 1 次 Full GC。

  • younggc 和 fullgc 单次平均耗时保持不变。


3.2 CPU 负载降低 30%+



  • 优化之前高峰期 cpu 平均负载超过 50%;优化后降到了不足 30%,高峰期负载下降了 40%。

  • CPU负载每日平均值 由 29%,降到了 20%。日平均负载下降了 32%。


3.3 核心接口性能显著提升


核心接口耗时下降明显



  • 接口 A 高峰期 TPS 100/秒,tp999 由 200毫秒 降到了 150 毫秒, tp9999 由 400 毫秒降到了 300 毫秒,接口耗时下降超过 25%!

  • 接口 B 高峰期QPS 250/秒, tp999 由 190 毫秒降到了 120 毫秒, tp9999 由 450 毫秒下降到了 150 毫秒,接口耗时下降分别下降 37%和 67%!

  • 接口 B 低峰期降幅更加明显,tp999 由 80 毫秒降到了 10 毫秒,下降幅度接近 90%!


后来又适当微调了 JVM 内存分配比例,但是优化效果不明显。


四、总结


经过此次 GC 优化经历,我学到了如下经验



  • 要通过 GC 日志分析 GC 问题。

  • 调整JVM 内存,保证足够的新生代内存。

  • 优化 GC 可以降低接口耗时,提高接口可用性。

  • 优化 GC 可以有效降低机器 CPU 负载,提高硬件使用率。


反过来当接口性能差、cpu负载高的时候,不妨分析一下 GC ,看看有没有优化空间。


详细了解如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志


关注五阳~ 了解更多我在大厂的实际经历


作者:五阳
来源:juejin.cn/post/7423066953038741542
收起阅读 »

简易聊天机器人设计

1. 引言 Spring AI Alibaba 开源项目基于 Spring AI 构建,是阿里云通义系列模型及服务在 Java AI 应用开发领域的最佳实践,提供高层次的 AI API 抽象与云原生基础设施集成方案,帮助开发者快速构建 AI 应用。 2. 效...
继续阅读 »

1. 引言


Spring AI Alibaba 开源项目基于 Spring AI 构建,是阿里云通义系列模型及服务在 Java AI 应用开发领域的最佳实践,提供高层次的 AI API 抽象与云原生基础设施集成方案,帮助开发者快速构建 AI 应用。


image.png


2. 效果展示


20241112_223517.gif


源代码地址 DailySmileStart/simple-chatboot (gitee.com)


3. 代码实现


依赖


        <dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M2</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

注意:由于 spring-ai 相关依赖包还没有发布到中央仓库,如出现 spring-ai-core 等相关依赖解析问题,请在您项目的 pom.xml 依赖中加入如下仓库配置。


<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url><https://repo.spring.io/milestone></url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>

@SpringBootApplication
public class SimpleChatbootApplication {

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

}

配置自定义ChatClient


import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatClientConfig {

static ChatMemory chatMemory = new InMemoryChatMemory();
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}

}

controller类


import ch.qos.logback.core.util.StringUtil;
import com.hbduck.simplechatboot.demos.function.WeatherService;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

import java.util.UUID;

import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;

@RestController
@RequestMapping("/ai")
public class ChatModelController {

private final ChatModel chatModel;
private final ChatClient chatClient;

public ChatModelController(ChatModel chatModel, ChatClient chatClient) {
this.chatClient = chatClient;
this.chatModel = chatModel;
}

@GetMapping("/stream")
public String stream(String input) {

StringBuilder res = new StringBuilder();
Flux<ChatResponse> stream = chatModel.stream(new Prompt(input));
stream.toStream().toList().forEach(resp -> {
res.append(resp.getResult().getOutput().getContent());
});

return res.toString();
}
@GetMapping(value = "/memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> memory(@RequestParam("conversantId") String conversantId, @RequestParam("input") String input) {
if (StringUtil.isNullOrEmpty(conversantId)) {
conversantId = UUID.randomUUID().toString();
}
String finalConversantId = conversantId;

Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();

return Flux.concat(
// First event: send conversationId
Flux.just(ServerSentEvent.<String>builder()
.event("conversationId")
.data(finalConversantId)
.build()),
// Subsequent events: send message content
chatResponseFlux.map(response -> ServerSentEvent.<String>builder()
.id(UUID.randomUUID().toString())
.event("message")
.data(response.getResult().getOutput().getContent())
.build())
);
}
}

配置文件


server:
port: 8000

spring:
thymeleaf:
cache: true
check-template: true
check-template-location: true
content-type: text/html
enabled: true
encoding: UTF-8
excluded-view-names: ''
mode: HTML5
prefix: classpath:/templates/
suffix: .html
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
client:
enabled: false

前端页面


<!DOCTYPE html>
<html>
<head>
<title>AI Chat Bot</title>
<style>
#chatBox {
height: 400px;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
}
.message {
margin: 5px;
padding: 5px;
}
.user-message {
background-color: #e3f2fd;
text-align: right;
}
.bot-message {
background-color: #f5f5f5;
white-space: pre-wrap; /* 保留换行和空格 */
word-wrap: break-word; /* 长单词换行 */
}
</style>
</head>
<body>
<h1>AI Chat Bot</h1>
<div id="chatBox"></div>
<input type="text" id="userInput" placeholder="Type your message..." style="width: 80%">
<button onclick="sendMessage()">Send</button>

<script>
let conversationId = null;
let currentMessageDiv = null;

function addMessage(message, isUser) {
const chatBox = document.getElementById('chatBox');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user-message' : 'bot-message'}`;
messageDiv.textContent = message;
chatBox.appendChild(messageDiv);
chatBox.scrollTop = chatBox.scrollHeight;
return messageDiv;
}

async function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();

if (message) {
addMessage(message, true);
input.value = '';

// Create bot message container
currentMessageDiv = addMessage('', false);

const eventSource = new EventSource(`/ai/memory?conversantId=${conversationId || ''}&input=${encodeURIComponent(message)}`);

eventSource.onmessage = function(event) {
const content = event.data;
if (currentMessageDiv) {
currentMessageDiv.textContent += content;
}
};

eventSource.addEventListener('conversationId', function(event) {
if (!conversationId) {
conversationId = event.data;
}
});

eventSource.onerror = function(error) {
console.error('SSE Error:', error);
eventSource.close();
if (currentMessageDiv && currentMessageDiv.textContent === '') {
currentMessageDiv.textContent = 'Sorry, something went wrong!';
}
};

// Close the connection when the response is complete
eventSource.addEventListener('complete', function(event) {
eventSource.close();
currentMessageDiv = null;
});
}
}

// Allow sending message with Enter key
document.getElementById('userInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

带chat memory 的对话


可以使用 InMemoryChatMemory实现


   //初始化InMemoryChatMemory
static ChatMemory chatMemory = new InMemoryChatMemory();
//在ChatClient 配置memory
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
//调用时配置
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();

工具


“工具(Tool)”或“功能调用(Function Calling)”允许大型语言模型(LLM)在必要时调用一个或多个可用的工具,这些工具通常由开发者定义。工具可以是任何东西:网页搜索、对外部 API 的调用,或特定代码的执行等。LLM 本身不能实际调用工具;相反,它们会在响应中表达调用特定工具的意图(而不是以纯文本回应)。然后,我们应用程序应该执行这个工具,并报告工具执行的结果给模型。


通过工具来实现获取当前天气


天气获取的类,目前使用硬编码温度


import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.hbduck.simplechatboot.demos.entity.Response;

import java.util.function.Function;

public class WeatherService implements Function<WeatherService.Request, Response> {

@Override
public Response apply(Request request) {
if (request.city().contains("杭州")) {
return new Response(String.format("%s%s晴转多云, 气温32摄氏度。", request.date(), request.city()));
}
else if (request.city().contains("上海")) {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
else {

return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
}

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonClassDescription("根据日期和城市查询天气")
public record Request(
@JsonProperty(required = true, value = "city") @JsonPropertyDescription("城市, 比如杭州") String city,
@JsonProperty(required = true, value = "date") @JsonPropertyDescription("日期, 比如2024-08-22") String date) {
}
}

chatClient配置function


Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();

作者:平平无奇加油鸭
来源:juejin.cn/post/7436369701020516363
收起阅读 »

即梦AI推出“一句话改图”功能,助力用户发掘更多创意

近日,字节跳动旗下AI内容平台即梦AI上线了指令编辑功能,用户使用即梦AI的“图片生成”功能时,在上传导入参考图片后,选择“智能参考”,在文本框中输入想要如何调整图片的描述,就可以轻松对图片进行编辑。目前,该功能在即梦AI网页版和App移动端均可免费体验。图说...
继续阅读 »

近日,字节跳动旗下AI内容平台即梦AI上线了指令编辑功能,用户使用即梦AI的“图片生成”功能时,在上传导入参考图片后,选择“智能参考”,在文本框中输入想要如何调整图片的描述,就可以轻松对图片进行编辑。目前,该功能在即梦AI网页版和App移动端均可免费体验。

WPS拼图0.png

图说:即梦AI网页版指令编辑功能使用示意

据介绍,即梦AI的指令编辑功能支持包括修图、换装、美化、转化风格、在指定区域添加删除元素等各类编辑操作,通过简单的自然语言即可编辑图像,大幅降低了用户操作成本,有助于用户发掘和实现更多创意。

11.png

图说:即梦AI指令编辑功能创意玩法

据介绍,即梦AI的指令编辑功能由字节最新通用图像编辑模型SeedEdit支持。SeedEdit是国内首个实现产品化的通用图像编辑模型。过往,学术界在文生图和图生图领域已有较多研究,但做好生成图片的指令编辑一直是难题,二次修改很难保证稳定性和生成质量。今年以来,Dalle3、Midjourney接连推出产品化的生图编辑功能,相较业界此前方案,编辑生成图片的质量大大改善,但仍缺乏对用户编辑指令的精准响应和原图信息保持能力。SeedEdit在通用性、可控性、高质量等方面取得了一定突破,适用各类编辑任务,支持用户脑洞大开的奇思妙想,无需再训练微调即可快捷应用。

即梦AI支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。近期,即梦AI还面向用户开放了字节视频生成模型Seaweed的使用。

该平台相关负责人表示,AI能够和创作者深度互动,共同创作,带来很多惊喜和启发,即梦AI希望成为用户最亲密和有智慧的创作伙伴。(作者:李双)

收起阅读 »

终于知道公钥、私钥、对称、非对称加密是什么了

有接入过第三方服务的同学应该都接触过公钥、私钥这类的说明,尤其是一些对参数要求验证的服务,比如支付类的。通常对于高保密要求的参数都会有加密要求,这时候,如果你之前不了解加密算法,就很容易被公钥、私钥的使用给绕迷糊了。有时候虽然接口都调通了,但是还是一头雾水,这...
继续阅读 »

有接入过第三方服务的同学应该都接触过公钥、私钥这类的说明,尤其是一些对参数要求验证的服务,比如支付类的。

通常对于高保密要求的参数都会有加密要求,这时候,如果你之前不了解加密算法,就很容易被公钥、私钥的使用给绕迷糊了。有时候虽然接口都调通了,但是还是一头雾水,这就通了?

那接下来我们就来解开这团迷雾。咱们只讲逻辑和一部分逻辑,不讲数学算法,因为数学这块儿我也不是很懂。

加密算法在大类上分为对称机密和非对称加密,都用来加密,只不过使用场景、性能、安全性方面有些不同。

首先说什么是加密呢?

加密就是通过一种方式将一个显而易见的东西变成难以理解的东西。

比如这儿有个字符串“我真棒”,这三个字儿要摆在你面前不是一眼就知道我很棒了吗。

但是我要给你看这样一串东西呢?你还能一下子知道是什么意思吗?

232 10 5,33 50 12,109 45 1

其实这还是那三个字,只不过是经过加密的密文,只有知道了加密的方法才能还原出来,也就是解密。

加密的过程可能是这样的:

  1. 首先买一本叫做《人间清醒》的书;
  2. 然后按照逗号分隔,每一个逗号分隔开的表示一个字;
  3. 然后每一组是三个数字,第一个数字表示所在页,第二个数字表示所在行,第三个数字表示所在列(第几个字),所以 232 10 5,表示第232页,第10行,第5个字。

还有,比如我之前有个温州的同事,他打电话的时候对于我们来说就是语音加密了,能听见他的声音,但是说的是什么内容一个字也听不出来。这也可以理解为一种加密,把普通话的发音转换成温州方言的发音。

对称加密

对称加密中所说的对称是对加密和解密而言的,也就是加密和解密的过程使用相同的密钥。

我们经常用到的落库加密、文件加密都可以使用对称加密的方式。

目前最常用也是最安全的对称加密算法是 AES,AES 还分为 AES-128、AES-192和AES256,后面的数字代表加密密钥的位数,位数越高呢,加密效果也就越好,更加不容易被破解。同时,位数越高,加密和解密过程中的计算量也会越大,内存占用也就更大,消耗的资源更多,需要的时间也就更长。

有利有弊,看你的需求而定。基本上,一般场景下 128位就足够安全了。AES 到目前为止,可以说没有漏洞,而且128位就可以保证不会被暴力破解。而更高位数的可能会用到国家级的保密数据上。

AES 是分组加密算法,除此之外,大部分的加密算法都是分组加密算法。

块加密算法就是将需要加密的数据分成一个个的固定长度的分组,比如 128位一组,然后分别用算法对每一组进行加密,如果最后一组不足128位的话,还要用填充算法进行填充,保证达到128位。

常用的分组算法有CTR和GCM,CTR 和 GCM 有并行计算的能力,并且, GCM 还能额外提供对消息完整性、真实性的验证能力。

所以我们在某些地方可能看到 AES-128-GCM、AES-256-CTR 这样的写法,前面表示加密算法,后面代表分组算法。

不足之处

对称加密本身从算法层面来说已经足够安全了,但是在密钥分发方面有些不太容易管理。

因为加解密的密钥相同。我加密的数据想要被别人使用,我就要把密钥告诉要使用的人。知道密钥的人、保存密钥的服务器越多,风险就越大。约束自己容易,约束别人难啊。但凡有一方不小心把密钥泄露就完。一个木桶能装多少水是由最低的一块木板决定的。

非对称加密

由于对称加密的密钥分发问题,非对称加密算法可以完美的解决。

刚毕业不就的时候,和第三方服务做集成,有关于接口参数加密的指引文档,虽然按照人家提供的 demo 可以正常集成,但是文档上说的公钥、私钥还是搞的很迷糊。

现在就来捋一捋啊,就以第三方服务角度来说。假设我是一个支付服务商,为大家提供支付接口的。

公钥

公钥是开放的,谁都可以获取。我作为一个支付服务商,任何到我平台上注册的用户都可以获取到公钥,公钥可以是相同的。

私钥

私钥是绝密的,我作为一个支付服务商,必须将私钥妥善保存,无论是保存在数据库中还是保存在服务器,都必须保证私钥不对外,只有我自己可以使用。

  1. 使用我服务的用户获取公钥;
  2. 调用方(客户)用公钥对接口参数进行加密,然后调用接口;
  3. 我方(支付服务商)用私钥对参数进行解密,然后使用参数进行业务处理;

以上是公钥、私钥的使用过程,这样一看就很明确了。

之所以说是非对称加密,是因为私钥只是用来解密的,用公钥加密过的数据,只有用对应的私钥才能解密出来。所以说,即使有那么多人得到了相同的公钥,也无法获取别人加密过的数据。

最常用的非对称加密算法是 RSA ,RSA 有1024、2048、3072、4096、8129、16384 甚至更多位。目前 3072 位及以上的密钥长度被认为是安全的,曾经大量使用的 2048 位 RSA 现在被破解的风险在不断提升,已经不推荐使用了

RSA 算法的性能要比对称加密 AES 算法差1000倍左右,虽然数学原理上不太明白,但是有数学常识就可以知道,使用不同的密钥进行加密和解密,必然要比使用相同密钥的算法复杂很多,由此肯定会带来性能上的开销。

非对称加密适合用于那些对安全性要求更高的场景,例如支付场景、数字证书、数据加密传输等等。

还可以看看风筝往期文章

用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了

为什么我每天都记笔记,主要是因为我用的这个笔记软件太强大了,强烈建议你也用起来

「差生文具多系列」最好看的编程字体

我患上了空指针后遗症

一千个微服务之死

搭建静态网站竟然有这么多方案,而且还如此简单

被人说 Lambda 代码像屎山,那是没用下面这三个方法


作者:古时的风筝
来源:juejin.cn/post/7434615081394667571
收起阅读 »

Dart 语法原来这么好玩儿

说到到某个语言的语法可能大家会觉得很枯燥、乏味,而日常开发中我们往往更加注重的是业务逻辑和页面开发,语法的使用大多也停留在满足基本的需求。其实 Dart 语法有很多有意思的地方的,仔细探究一下你会发现,它的简洁清晰、灵活多样的语法会让人爱不释手。在本文中,我们...
继续阅读 »


说到到某个语言的语法可能大家会觉得很枯燥、乏味,而日常开发中我们往往更加注重的是业务逻辑和页面开发,语法的使用大多也停留在满足基本的需求。其实 Dart 语法有很多有意思的地方的,仔细探究一下你会发现,它的简洁清晰、灵活多样的语法会让人爱不释手。在本文中,我们将探索 Dart 语法的各种奇妙之处吧。


unwrap 操作


Flutter 中,unwrap 操作常常用于处理可能为空的数据,以便过滤掉空值并只保留非空值。其使用场景也相当广泛,例如 为 FutureStreams 添加 unwrap 来处理掉非空数据,或者从网络请求或其他异步操作中获取数据,并在数据流中处理结果等等,如下面这段代码:


extension Unwrap<T> on Future<T?> {
Future<T> unwrap() => then(
(value) => value != null
? Future<T>.value(value)
: Future.any([]),
);
}

unwrap 函数将可能为空的 Future 解包,如果 Future 返回的值不为 null,则将值包装在一个新的 Future 中返回,否则返回一个空的 Future。调用示例:


class ImagePickerHelper {
static final ImagePicker _imagePicker = ImagePicker();
static Future<File> pickImageFromGallery() => _imagePicker
.pickImage(source: ImageSource.gallery)
.unwrap()
.then((xFile) => xFile.path)
.then((filePath) => File(filePath));
}

这里用到图片选择器插件 image_picker,只有当返回的 xFile 不为空时才进行后续操作。如果不调用 unwrap 函数,此时这里返回的 xFileoptional 类型,要使用之前需要判断是否为 null。日常开发中这种情况还不少,给 Future 添加 Unwrap 函数之后这样非空判断集中在这一个函数里面处理。


unwrap 不仅在 Future 中使用,还可以为 Streams 添加 unwrap 操作,代码如下:


extension Unwrap<T> on Stream<T?> {
Stream<T> unwrap() => where((event) => event != null).cast();
}


unwrap 方法,通过 where 过滤掉了 null 的事件,并使用 cast() 方法将结果转换为 Stream<T> 类型,将可空的事件转换为非空的事件流,下面是调用代码:


void main() {
Stream<int?>.periodic(
const Duration(seconds: 1),
(value) => value % 2 == 0 ? value : null,
).unwrap().listen((evenValue) {
print(evenValue);
});
/* 输出结果
0
2
4
6
...
*/

}

通过 extensionFutureStreams 添加 unwrap 函数后让我们的代码看起来清晰简洁多了,有没有?


数组的展开、合并和过滤


下面代码为任意类型的可迭代对象(Iterable)添加名为 Flatten 的扩展。在这个扩展中,函数 flatten 使用了递归算法将多层嵌套的 Iterable 里面的所有元素扁平化为单层 Iterable


extension Flatten<T extends Object> on Iterable<T> {
Iterable<T> flatten() {
Iterable<T> _flatten(Iterable<T> list) sync* {
for (final value in list) {
if (value is List<T>) {
yield* _flatten(value);
} else {
yield value;
}
}
}
return _flatten(this);
}
}

注意了上面代码中使用了 yield 关键字,在 Flutter 中,yield 关键字用于生成迭代器,通常与sync*async* 一起使用。它允许您在处理某些数据时逐步生成数据,而不是在内存中一次性处理所有数据。对于处理大量数据或执行长时间运行的操作非常有用,因为它可以节省内存并提高性能。


这个和 ES6 中使用 function* 语法和 yield 关键字来生成值一个东西,也是逐个生成值,而不需要一次性生成所有值。以下是 JS 写法:


function* generateNumbers(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}

const numbers = generateNumbers(5);
for (const number of numbers) {
console.log(number);
}

我们来看看 Dart 中的 flatten() 函数的调用:


Future<void> main() async {
final flat = [
[[1, 2, 3], 4, 5],
[6, [7, [8, 9]], 10],
11,12
].flatten();
print(flat); // (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
}

嵌套的集合可能在数据处理、转换或展示中经常遇到,而将这些嵌套的集合扁平化可以简化数据处理过程,使代码更加简洁和易于理解。另外一点,递归展多维数组在面试中经常会出现,说不定哪天就用上了哈。


如果将两个数组合并成一个数组该怎么操作呢?其实和 Map 的合并相似,也是用到了自定义操作符 operator ,来看看怎么实现的。


extension InlineAdd<T> on Iterable<T> {
Iterable<T> operator +(T other) => followedBy([other]);
Iterable<T> operator &(Iterable<T> other) => followedBy(other);
}

void main() {
const Iterable<int> values = [10, 20, 30];
print((values & [40, 50]));
// 输出结果:(10, 20, 30, 40, 50)
}

添加了两个操作符:+&。将一个元素或者另一个可迭代对象添加到当前的可迭代对象中,然后返回一个新的可迭代对象,让可迭代对象 terable 有了合并数组的功能。


当数组中有一个为 null 的对象时,该如何过滤掉这个 null 对象呢,很简单可以这样做:


extension CompactMap<T> on Iterable<T?> {
Iterable<T> compactMap<E>([
E? Function(T?)? transform,
]) =>
map(transform ?? (e) => e).where((e) => e != null).cast();
}

void main() {
const list = ['Hello', null, 'World'];
print(list); // [Hello, null, World]
print(list.compactMap()); // [Hello, World]
print(list.compactMap((e) => e?.toUpperCase())); // [HELLO, WORLD]
}

Map 的过滤和合并


下面代码是 Map 类型的 extension,为 Map 类型添加了查找过滤的函数。


extension DetailedWhere<K, V> on Map<K, V> {
Map<K, V> where(bool Function(K key, V value) f) => Map<K, V>.fromEntries(
entries.where((entry) => f(entry.key, entry.value)),
);

Map<K, V> whereKey(bool Function(K key) f) =>
{...where((key, value) => f(key))};
Map<K, V> whereValue(bool Function(V value) f) =>
{...where((key, value) => f(value))};
}


  • where : 接受一个函数作为参数,该函数接受 Map 的键和值作为参数,并返回一个布尔值。

  • whereKey : 接受一个只接受键作为参数的函数。

  • whereValue : 这个方法接受一个只接受值作为参数的函数。


下面是调用:


void main(){
const Map<String, int> people = {'John': 20, 'Mary': 21, 'Peter': 22};
print(people.where((key, value) => key.length > 4 && value > 20)); // {Peter: 22}
print(people.whereKey((key) => key.length < 5)); // {John: 20, Mary: 21}
print(people.whereValue((value) => value.isEven)); // {John: 20, Peter: 22}
}

其中 where 方法先使用 entries 获取 Map 的键值对列表,然后使用 entries.where 方法对列表中的每个键值对进行过滤,最后使用 fromEntries 方法将过滤后的键值对列表转换回 Map,最后返回的新的 Map 中只包含满足条件的键值对,达到对 Map 中键值过滤的效果,也让代码更加简洁和易读。


Map 过滤还有另外一种写法


extension Filter<K, V> on Map<K, V> {
Iterable<MapEntry<K, V>> filter(
bool Function(MapEntry<K, V> entry) f,
) sync* {
for (final entry in entries) {
if (f(entry)) {
yield entry;
}
}
}
}

void main(){
const Map<String, int> people = {
'foo': 20,
'bar': 31,
'baz': 25,
'qux': 32,
};
final peopleOver30 = people.filter((e) => e.value > 30);
print(peopleOver30); // 输出结果:(MapEntry(bar: 31), MapEntry(qux: 32))
}

Map 其它一些更有趣的 extension,如 Merge 功能,将两个 Map 合并成一个,代码如下:


extension Merge<K, V> on Map<K, V> {
Map<K, V> operator |(Map<K, V> other) => {...this}..addEntries(
other.entries,
);
}

上面的代码用到了 operator 关键字,在 Dart 中,operator 关键字用于定义自定义操作符或者重载现有的操作符。通过 operator 关键字,我们可以为自定义类定义各种操作符的行为,使得我们的类可以像内置类型一样使用操作符。


operator + 来定义两个对象相加的行为,operator [] 来实现索引操作,operator == 来定义相等性比较。这种语义式的也更加符合直觉、清晰易懂。


下面来看看 MapMerge 功能调用代码例子:


const userInfo = {
'name': 'StellarRemi',
'age': 28,
};

const address = {
'address': 'shanghai',
'post_code': '200000',
};

void main() {
final allInfo = userInfo | address;
print(allInfo);
// 输出结果:{name: StellarRemi, age: 28, address: shanghai, post_code: 200000}
}

调用的时候也很简单直接 userInfo | address;,这种操作在处理数据更新或合并配置等情况下特别有用。使用的时候需要注意的是,如果两个 Map 中有重复的键,那么上述操作会保留最后一个 Map 中的值。


小结


怎么样,上面的这些 Dart 的语法是不是很有意思,有没有函数式编程那味儿,后面还会单独一篇来分享 Dart 语言面向对象的设计。好了,今天就到这里,也希望通过本文的分享,能够激发大家对 Dart 语言的兴趣,感谢您的阅读,记得关注点赞哈。


作者:那年星空
来源:juejin.cn/post/7361096760449466406
收起阅读 »

轻量级Nacos来了!占用资源极低,性能炸裂!

Nacos作为一款非常流行的微服务注册中心,我们在构建微服务项目时往往会使用到它。最近发现一款轻量级的Nacos项目r-nacos,占用内存极低,性能也很强大,分享给大家。本文就以我的mall-swarm微服务电商实战项目为例,来聊聊它在项目中的使用。 r-...
继续阅读 »

Nacos作为一款非常流行的微服务注册中心,我们在构建微服务项目时往往会使用到它。最近发现一款轻量级的Nacos项目r-nacos,占用内存极低,性能也很强大,分享给大家。本文就以我的mall-swarm微服务电商实战项目为例,来聊聊它在项目中的使用。



r-nacos简介


r-nacos是一款使用rust实现的nacos服务,对比阿里的nacos来说,可以提供相同的注册中心和配置中心功能。同时它占用的内存更小,性能也很优秀,能提供更稳定的服务。


下面是r-nacos管理控制台使用的效果图,大家可以参考下:



mall-swarm项目简介


由于之后我们需要用到mall-swarm项目,这里简单介绍下它。 mall-swarm项目(11k+star)是一套微服务商城系统,基于2024最新微服技术栈,涵盖Spring Cloud Alibaba、Spring Boot 3.2、JDK17、Kubernetes等核心技术。mall-swarm在电商业务的基础集成了注册中心、配置中心、监控中心、网关等系统功能。



项目演示:



安装



r-nacos支持Windows下的exe文件安装,也支持Linux下的Docker环境安装,这里以Docker安装为例。




  • 首先通过如下命令下载r-nacos的Docker镜像:


docker pull qingpan/rnacos:stable


  • 安装完成后通过如下命令运行r-nacos容器;


docker run --name rnacos -p 8848:8848 -p 9848:9848 -p 10848:10848 -d qingpan/rnacos:stable



项目实战



接下来就以我的mall-swarm微服务电商实战项目为例,来讲解下它的使用。由于mall-swarm项目中各个服务的配置与运行都差不多,这里以mall-admin模块为例。




  • 首先我们需要下载mall-swarm项目的代码,下载完成后修改项目的bootstrap-dev.yml文件,将其中的nacos连接地址改为r-nacos的地址,项目地址:github.com/macrozheng/…


spring:
cloud:
nacos:
discovery:
server-addr: http://192.168.3.101:8848
config:
server-addr: http://192.168.3.101:8848
file-extension: yaml



  • 接下来在r-nacos的配置列表中添加mall-admin-dev.yaml配置,该配置下项目的config目录下;





  • 之后把mall-admin模块运行起来,此时在r-nacos服务列表功能中就可以看到注册好的服务了;




  • 接下来把其他模块的配置也添加到r-nacos的配置列表中去;




  • 再运行其他模块,最终服务列表显示如下;






  • 这里我们再把mall-swarm项目的后台管理系统前端项目mall-admin-web给运行起来;




  • 最后我们再把mall-swarm项目的前台商城系统前端项目mall-app-web给运行起来,发现都是可以正常从网关调用API的。



其他使用



r-nacos除了提供了基本的注册中心和配置中心功能,还提供了一些其他的实用功能,这里我们一起来了解下。




  • 如果你想添加一些其他访问的用户,或者修改admin用户的信息,可以使用用户管理功能;




  • 如果你想对r-nacos中配置信息进行导入导出,可以使用数据迁移功能;




  • 如果你想对r-nacos中的运行状态进行监控,你可以使用系统监控功能,监控还是挺全的。



性能压测


r-nacos的性能还是非常好的,这里有个r-nacos官方提供的性能压测结果表,大家可以参考下。



对比Nacos


个人感觉对比阿里的nacos,占用的内存资源减少了非常多,运行不到10M内存,而nacos需要900M,服务器资源不宽裕的小伙伴可以尝试下它。



总结


今天以我的mall-swarm微服务电商实战项目为例,讲解了r-nacos的使用。从功能上来说r-nacos是完全可以替代nacos的,而且它占用内存资源非常低,性能也很强大,感兴趣的小伙伴可以尝试下它!


项目地址


github.com/nacos-group…


作者:MacroZheng
来源:juejin.cn/post/7434185097300475919
收起阅读 »

既生@Primary,何生@Fallback

个人公众号:IT周瑜,十二年Java开发和架构经验,一年大模型应用开发经验,爱好研究源码,比如Spring全家桶源码、MySQL源码等,同时也喜欢分享技术干货,期待你的关注 最近闲着的时候在看Spring 6.2的源码,发现了一些新特性,比如本文要介绍的@F...
继续阅读 »

个人公众号:IT周瑜,十二年Java开发和架构经验,一年大模型应用开发经验,爱好研究源码,比如Spring全家桶源码、MySQL源码等,同时也喜欢分享技术干货,期待你的关注



最近闲着的时候在看Spring 6.2的源码,发现了一些新特性,比如本文要介绍的@Fallback注解。


相信大家都知道@Primary注解,而@Fallback相当于是@Primary的反向补充


Spring在进行依赖注入时,会根据属性的类型去Spring容器中匹配Bean,但有可能根据类型找到了多个Bean,并且也无法根据属性名匹配到Bean时,就会报错,比如expected single matching bean but found 2,此时,就可以利用@Primary来解决。


加了@Primary的Bean表示是同类型多个Bean中的主Bean,换句话说,如果Spring根据类型找到了多个Bean,会选择其中加了@Primary的Bean来进行注入,因此,同类型的多个Bean中只能有一个加了@Primary,如果有多个也会报错more than one 'primary' bean found。


比如以下代码会使用orderService1来进行注入:


@Bean
@Primary
public OrderService orderService1() {
return new OrderService();
}

@Bean
public OrderService orderService2() {
return new OrderService();
}

而加了@Fallback注解的Bean为备选Bean,比如以下代码会使用orderService2来进行依赖注入:


@Bean
@Fallback
public OrderService orderService1() {
return new OrderService();
}

@Bean
public OrderService orderService2() {
return new OrderService();
}

因为orderService1加了@Fallback注解,相当于备胎,只有当没有其他Bean可用时,才会用orderService1这个备胎,有其他Bean就会优先用其他Bean。


@Primary和@Fallback都是用在依赖注入时根据类型找到了多个Bean的场景中:



  • @Primary比较强势,它在说:“直接用我就可以了,不用管其他Bean”

  • @Fallback比较弱势,它在说:“先别用我,先用其他Bean”


如果根据类型只找到一个Bean就用不着他两了,另外,同类型多个Bean中@Primary的Bean只能有一个,但可以有多个@Fallback。


大家觉得@Fallback注解怎么样?


实际上,@Primary和@Fallback两个注解的源码实现在同一个方法中,源码及注释如下,感兴趣的同学可以看看:


protected String determinePrimaryCandidate(Map<String, Object> candidates, Class<?> requiredType) {
String primaryBeanName = null;
// First pass: identify unique primary candidate
// 先找@Primary注解的Bean

// candidates就是根据类型找到的多个Bean,key为beanName, Value为bean对象
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
String candidateBeanName = entry.getKey();
Object beanInstance = entry.getValue();
if (isPrimary(candidateBeanName, beanInstance)) {
if (primaryBeanName != null) {
boolean candidateLocal = containsBeanDefinition(candidateBeanName);
boolean primaryLocal = containsBeanDefinition(primaryBeanName);

// 找到多个@Primary会报错
if (candidateLocal == primaryLocal) {
throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(),
"more than one 'primary' bean found among candidates: " + candidates.keySet());
}
else if (candidateLocal) {
primaryBeanName = candidateBeanName;
}
}
else {
// 找到一个@Primary注解的Bean就先存着,看是不是还有其他@Primay注解的Bean
primaryBeanName = candidateBeanName;
}
}
}

// Second pass: identify unique non-fallback candidate
// 没有@Primary注解的Bean情况下,才找没有加@Fallback注解的,加了@Fallback注解的Bean会被过滤掉
if (primaryBeanName == null) {
for (String candidateBeanName : candidates.keySet()) {

// 判断是否没有加@Fallback
if (!isFallback(candidateBeanName)) {

// 如果有多个Bean没有加@Fallback,会返回null,后续会根据属性名从多个bean中进行匹配,匹配不到就会报错
if (primaryBeanName != null) {
return null;
}
primaryBeanName = candidateBeanName;
}
}
}
return primaryBeanName;
}

作者:IT周瑜
来源:juejin.cn/post/7393311009686192147
收起阅读 »

Java程序员必知的9个SQL优化技巧

大多数的接口性能问题,很多情况下都是SQL问题,在工作中,我们也会定期对慢SQL进行优化,以提高接口性能。这里总结一下常见的优化方向和策略。避免使用select *,减少查询字段不要为了图省事,直接查询全部的字段,尽量查需要的字段,特别是复杂的SQL,能够避免...
继续阅读 »

大多数的接口性能问题,很多情况下都是SQL问题,在工作中,我们也会定期对慢SQL进行优化,以提高接口性能。这里总结一下常见的优化方向和策略。

避免使用select *,减少查询字段

不要为了图省事,直接查询全部的字段,尽量查需要的字段,特别是复杂的SQL,能够避免很多不走索引的情况。这也是最基本的方法。

检查执行计划,是否走索引

检查where和order by字段是否有索引,根据表的数据量和现有索引,考虑是否增加索引或者联合索引。 然而,索引并不是越多越好,原因有以下几点:

  1. 存储空间:每个索引都会占用额外的存储空间。如果为表中的每一列都创建索引,那么这些索引的存储开销可能会非常大,尤其是在大数据集上。
  2. 索引重建增加开销:当数据发生变更(如插入、更新或删除)时,相关的索引也需要进行更新,以确保数据的准确性和查询效率。这意味着更多的索引会导致更慢的写操作。
  3. 选择性:选择性是指索引列中不同值的数量与表中记录数的比率。选择性高的列(即列中有很多唯一的值)更适合创建索引。对于选择性低的列(如性别列,其中只有“男”和“女”两个值),创建索引可能不会产生太大的查询性能提升。
  4. 过度索引:当表中存在过多的索引时,可能会导致数据库优化器在选择使用哪个索引时变得困难。这可能会导致查询性能下降,因为优化器可能选择了不是最优的索引。

因此,在设计数据库时,需要根据查询需求和数据变更模式来仔细选择需要创建索引的列。通常建议只为经常用于查询条件、排序和连接的列创建索引,并避免为选择性低的列创建索引。

避免使用or连接

假设我们有一个数据表employee,包含以下字段:id, name, age。 原始查询使用OR操作符来筛选满足name为'John'或age为30的员工:

SELECT * FROM employee WHERE name = 'John' OR age = 30;

使用UNION操作符来实现同样的筛选:

SELECT * FROM employee WHERE name = 'John'
UNION
SELECT * FROM employee WHERE age = 30;

UNION操作符先查询满足name为'John'的记录,然后查询满足age为30的记录,并将两个结果集合并起来。这样可以减少查询的数据量,提高查询效率。 需要注意的是,UNION操作符会去除重复的记录如果想要保留重复的记录,可以使用UNION ALL操作符,例如: 判断两条记录是否为重复记录的标准是通过比较每个字段的值来确定的。

SELECT * FROM employee WHERE name = 'John'
UNION ALL
SELECT * FROM employee WHERE age = 30;

在使用UNION代替OR时,还需要注意查询语句的语义是否与原始查询相同。有些情况下,OR可能会产生更准确的结果,因此在使用UNION时需谨慎考虑语义问题。

减少in和not in的使用

说实话,这种情况有点难。实际工作中,使用in的场景很多,但是要尽量避免in后面的数据范围,范围太大的时候,要考虑分批处理等操作。

对于连续的数值,可以考虑使用between and 代替。

避免使用左模糊查询

在工作中,对于姓名、手机号、名称等内容,经常会遇到模糊查询的场景,但是要尽量避免左模糊,这种SQL无法使用索引。

  1. 左模糊查询: 假设我们有一个数据表customer,包含字段name,我们想要查询名字以"J"开头的客户:
SELECT * FROM customer WHERE name LIKE 'J%';
  1. 右模糊查询: 继续使用上述customer表,我们想要查询名字以"n"结尾的客户:
SELECT * FROM customer WHERE name LIKE '%n';

注意,在某些数据库中,对于右模糊查询,可能需要使用转义符号(如""),以防止通配符被误解。

  1. 全模糊查询: 还是使用上述customer表,我们想要查询名字中包含"son"的客户:
SELECT * FROM customer WHERE name LIKE '%son%';

连接查询join替代子查询

假设我们有两个表:订单表(orders)和客户表(customers)。 订单表包含了订单号(order_id)、客户ID(customer_id)和订单金额(amount),而客户表包含了客户ID(customer_id)和客户姓名(customer_name)。

我们要找出所有订单金额大于1000美元的客户姓名:

SELECT customer_name
FROM customers
WHERE customer_id IN (SELECT DISTINCT customer_id FROM orders WHERE amount > 1000);

以上查询首先在订单表中挑选出所有金额大于1000美元的客户ID,然后使用这个子查询的结果来过滤客户表并获取客户姓名。

使用 JOIN 来替代子查询的方式:

SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.amount > 1000;

改造后的查询通过使用 INNER JOIN 将客户表和订单表连接在一起,然后使用 WHERE 子句来过滤出金额大于1000美元的订单。

这种改造不仅使查询更加简洁,而且可能还会提高查询的性能。JOIN 操作通常比子查询的效率更高,特别是在处理大型数据集时。

join的优化

JOIN 是 SQL 查询中的一个操作,用于将两个或多个表连接在一起。JOIN 操作有几种类型,包括 LEFT JOIN、RIGHT JOIN 和 INNER JOIN。要选用正确的关联方式,确保查询内容的正确性。

  1. INNER JOIN(内连接):内连接返回满足连接条件的行,即两个表中相关联的行组合。只有在两个表中都存在匹配的行时,才会返回结果。
SELECT *
FROM table1
INNER JOIN table2 ON table1.column = table2.column;
  1. LEFT JOIN(左连接):左连接返回左侧表中的所有行,以及右侧表中满足连接条件的行。如果右表中没有匹配的行,则返回 NULL 值。在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。
SELECT *
FROM table1
LEFT JOIN table2 ON table1.column = table2.column;
  1. RIGHT JOIN(右连接):右连接返回右侧表中的所有行,以及左侧表中满足连接条件的行。如果左表中没有匹配的行,则返回 NULL 值。
SELECT *
FROM table1
RIGHT JOIN table2 ON table1.column = table2.column;

需要注意的是,LEFT JOIN 和 RIGHT JOIN 是对称的,只是左右表的位置不同。INNER JOIN 则是返回共同匹配的行。

这些不同类型的 JOIN 可以灵活地根据查询需求选择使用。INNER JOIN 用于获取两个表中的匹配行,LEFT JOIN 和 RIGHT JOIN 用于获取一个表中的所有行以及另一个表中的匹配行。使用 JOIN 可以将多个表连接在一起,使我们能够根据关联的列获取相关的数据,并更有效地处理复杂的查询需求。但是使用的时候要特别注意,左右表的关联关系,是一对一、一对多还是多对多,对查询的结果影响很大。

gr0up by 字段优化

假设我们要计算每个客户的订单总金额,原始的查询可能如下所示:

SELECT customer_id, SUM(amount) AS total_amount
FROM orders
GR0UP BY customer_id;

在这个查询中,我们使用 GR0UP BY 字段 customer_id 对订单进行分组,并使用 SUM 函数计算每个客户的订单总金额。

为了优化这个查询,我们可以考虑以下几种方法:

  1. 索引优化:

    • 确保在 customer_id 字段上创建索引,以加速 GR0UP BY 和 WHERE 子句的执行。
    • 如果查询还包含其他需要的字段,可以考虑创建聚簇索引,将相关的字段放在同一个索引中,以减少查询的IO操作。
  2. 使用覆盖索引:

    • 如果查询中只需要使用 customer_id 和 amount 两个字段,可以创建一个覆盖索引,它包含了这两个字段,减少了查找其他字段的开销。
  3. 子查询优化:

    • 如果订单表很大,可以先使用子查询将数据限制在一个较小的子集上,然后再进行 GR0UP BY 操作。例如,可以先筛选出最近一段时间的订单,然后再对这些订单进行分组。
  4. 条件优化:

    • 使用WHERE条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。而不是在分组后使用having过滤数据。

深分页limit优化

深分页通常指的是在处理大量数据时,用户需要浏览远离首页的页面,例如第100页、第1000页等。这种场景下,如果简单地一次性加载所有数据并进行分页,会导致性能问题,包括内存消耗、数据库查询效率等。

我们日常使用较多的分页一般是用的PageHelper插件,SQL如下:

select id,name from table_name where N个条件 limit 100000,10;

它的执行流程:

  1. 先去二级索引过滤数据,然后找到主键ID
  2. 通过ID回表查询数据,取出需要的列
  3. 扫描满足条件的100010,丢弃前面100000条,返回

这里很明显的不足就是只需要拿10条,但是却多回表了100000次。

可采用的策略:主要是使用子查询、关联查询、范围查询和标签记录法这四种方法,当然对于深分页问题,一般都是比较麻烦了,都需要采用标签记录法来改造代码。

标签记录法:就是记录上次查询的最大ID,再请求下一页的时候带上,从上次的下一条数据开始开始,前提是有序的。 主要需要对代码进行改造:

public Page fetchPageByKey(Long lastKey, int pageSize) {  
// lastKey是上一页最后一项的主键
// 查询数据库,获取主键大于lastKey的pageSize条记录
List items = itemRepository.findByPrimaryKeyGreaterThan(lastKey, pageSize);
// 如果没有更多数据,可以设置下一个lastKey为空或特定值(如-1)
Long nextLastKey = items.isEmpty() ? null : items.get(items.size() - 1).getId();
return new Page<>(items, nextLastKey);
}


作者:松语
来源:juejin.cn/post/7368377525859008522
收起阅读 »

把java接口写在数据库里(groovy)

业务复杂多变?那把接口写在数据库里吧,修改随改随用!本文使用了Groovy脚本,不了解的可以自行了解,直接上菜。引入依赖<dependency> <groupId>org.codehaus.groovygroupId> ...
继续阅读 »

业务复杂多变?那把接口写在数据库里吧,修改随改随用!本文使用了Groovy脚本,不了解的可以自行了解,直接上菜。

  1. 引入依赖
<dependency>
<groupId>org.codehaus.groovygroupId>
<artifactId>groovy-allartifactId>
<version>2.5.16version>
<type>pomtype>
dependency>
  1. 创建测试接口
public interface InterfaceA {

/**
* 执行规则
*/

void testMethod();
}
  1. resource目录下创建.groovy实现上面的接口
@Slf4j
class GroovyInterfaceAImpl implements InterfaceA {

@Override
void testMethod() {
log.info("我是groovy编写的InterfaceA接口实现类中的接口方法")
GroovyScriptService groovyScriptService = SpringUtils.getBean(GroovyScriptService.class)
GroovyScript groovyScript = Optional.ofNullable(groovyScriptService.getOne(new QueryWrapper()
.eq("name", "groovy编写的java接口实现类")
.eq("version", 1))).orElseThrow({ -> new RuntimeException("没有查询到脚本") })
log.info("方法中进行了数据库查询,数据库中的groovy脚本是这个:{}", "\n" + groovyScript.getScript())
}
}
  1. mysql数据库中建个表groovy_script

image.png 5. 将刚才编写的.groovy文件内容存入数据库

@RunWith(SpringRunner.class)
@SpringBootTest
public class GroovyTest {

@Resource
private GroovyScriptService groovyScriptService;

@Test
public void test01() {
GroovyScript groovyScript = new GroovyScript();
groovyScript.setScript("package groovy\n" +
"\n" +
"import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper\n" +
"import com.demo.groovy.entity.GroovyScript\n" +
"import com.demo.groovy.service.GroovyScriptService\n" +
"import com.demo.groovy.service.InterfaceA\n" +
"import com.demo.groovy.util.SpringUtils\n" +
"import groovy.util.logging.Slf4j\n" +
"\n" +
"\n" +
"@Slf4j\n" +
"class GroovyInterfaceAImpl implements InterfaceA {\n" +
"\n" +
" @Override\n" +
" void testMethod() {\n" +
" log.info("我是groovy编写的InterfaceA接口实现类中的接口方法")\n" +
" GroovyScriptService groovyScriptService = SpringUtils.getBean(GroovyScriptService.class)\n" +
" GroovyScript groovyScript = Optional.ofNullable(groovyScriptService.getOne(new QueryWrapper()\n" +
" .eq("name", "groovy编写的java接口实现类")\n" +
" .eq("version", 1))).orElseThrow({ -> new RuntimeException("没有查询到脚本") })\n" +
" log.info("方法中进行了数据库查询,数据库中的groovy脚本是这个:{}", "\n" + groovyScript.getScript())\n" +
" }\n" +
"}");
groovyScript.setVersion(1);
groovyScript.setName("groovy编写的java接口实现类");
groovyScriptService.save(groovyScript);
}
}
  1. 从数据读取脚本,GroovyClassLoader加载脚本为Class(注意将Class对象进行缓存)
@Service("groovyScriptService")
@Slf4j
public class GroovyScriptServiceImpl extends ServiceImpl<GroovyScriptServiceMapper, GroovyScript> implements GroovyScriptService {

private static final Map<String, Md5Clazz> SCRIPT_MAP = new ConcurrentHashMap<>();

@Override
public Object getInstanceFromDb(String name, Integer version) {
//查询脚本
GroovyScript groovyScript = Optional.ofNullable(baseMapper.selectOne(new QueryWrapper<GroovyScript>()
.eq("name", name)
.eq("version", version))).orElseThrow(() -> new RuntimeException("没有查询到脚本"));
//将groovy脚本转换为java类对象
Class clazz = getClazz(name + version.toString(), groovyScript.getScript());
Object instance;

try {
instance = clazz.newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
return instance;
}

private Class getClazz(String scriptKey, String scriptText) {
String md5Hex = DigestUtil.md5Hex(scriptText);
Md5Clazz md5Script = SCRIPT_MAP.getOrDefault(scriptKey, null);
if (md5Script != null && md5Hex.equals(md5Script.getMd5())) {
log.info("从缓存获取的Clazz");
return md5Script.getClazz();
} else {
CompilerConfiguration config = new CompilerConfiguration();
config.setSourceEncoding("UTF-8");
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader(), config);
try {
Class clazz = groovyClassLoader.parseClass(scriptText);
SCRIPT_MAP.put(scriptKey, new Md5Clazz(md5Hex, clazz));
groovyClassLoader.clearCache();
log.info("groovyClassLoader parseClass");
return clazz;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try {
groovyClassLoader.close();
} catch (IOException e) {
log.error("close GroovyClassLoader error", e);
}
}
}
}

@Data
private static class Md5Clazz {
private String md5;
private Class clazz;

public Md5Clazz(String md5, Class clazz) {
this.md5 = md5;
this.clazz = clazz;
}
}
}
  1. 测试
@RestController
@RequestMapping("/test")
@Slf4j
public class GroovyTestController {

@Resource
private GroovyScriptService groovyScriptService;

@GetMapping("")
public String testGroovy() {
InterfaceA interfaceA = (InterfaceA) groovyScriptService.getInstanceFromDb("groovy编写的java接口实现类", 1);
interfaceA.testMethod();
return "ok";
}
}
  1. 接口方法被执行。想要修改接口的话在idea里面把groovy文件编辑好更新到数据库就行了,即时生效。

image.png

本文简单给大家提供一种思路,希望能对大家有所帮助,如有不当之处还请大家指正。本人之前在项目中用的比较多的是Groovyshell,执行的是一些代码片段,而GroovyClassLoader则可以加载整个脚本为Class,Groovy对于java开发者来说还是比较友好的,上手容易。


作者:爸爸给你买GTI
来源:juejin.cn/post/7397013935106048051
收起阅读 »

utf8和utf8mb4有什么区别?

utf8或者utf-8是大家常见的一个词汇,它是一种信息的编码格式,特别是不同开发平台的系统进行对接的时候,编码一定要对齐,否则就容易出现乱码。 什么是编码? 先说说什么是编码。编码就像我们日常生活中的语言,不同的地方说不同的话,编码就是计算机用来表示这些“话...
继续阅读 »

utf8或者utf-8是大家常见的一个词汇,它是一种信息的编码格式,特别是不同开发平台的系统进行对接的时候,编码一定要对齐,否则就容易出现乱码。


什么是编码?


先说说什么是编码。编码就像我们日常生活中的语言,不同的地方说不同的话,编码就是计算机用来表示这些“话”的一种方式。比如我们使用汉字来说话,计算机用二进制数来表示这些汉字的方式,就是编码。


utf8就是这样一种编码格式,正式点要使用:UTF-8,utf8是一个简写形式。


为什么需要utf8?


在计算机早期,主要使用ASCII编码,只能表示128个字符,汉字完全表示不了。后来,才出现了各种各样的编码方式,比如GB2312、GBK、BIG5,但这些编码只能在特定的环境下使用,不能全球通用。


UTF-8就像一个万能翻译官,它的全称是“Unicode Transformation Format - 8 bit”,注意这里不是说UTF-8只能使用8bit来表示一个字符,实际上UTF-8能表示世界上几乎所有的字符。


它的特点是:



  • 变长编码:一个字符可以用1到4个字节表示,英文字符用1个字节(8bit),汉字用3个字节(24bit)。

  • 向后兼容ASCII:ASCII的字符在UTF-8中还是一个字节,这样就兼容了老系统。

  • 节省空间:对于英文字符,UTF-8比其他多字节编码更省空间。


UTF-8适用于网页、文件系统、数据库等需要全球化支持的场景。


经常接触代码的同学应该还经常能看到 Unicode 这个词,它和编码也有很大的关系,其实Unicode是一个字符集标准,utf8只是它的一种实现方式。Unicode 作为一种字符集标准,为全球各种语言和符号定义了唯一的数字码位(code points)。其它的Unicode实现方式还有UTF-16和UTF-32:



  • UTF-16 使用固定的16位(2字节)或者变长的32位(4字节,不在常用字符之列)来编码 Unicode 字符。

  • UTF-32 每一个字符都直接使用固定长度的32位(4字节)编码,不论字符的实际数值大小。这会消耗更多的存储空间,但是所有字符都可以直接索引访问。



图片来源:src: javarevisited.blogspot.com/2015/02/dif…


utf8mb4又是什么?


utf8mb4并不常见,它是UTF-8的一个扩展版本,专门用于MySQL数据库。MySQL在 5.5.3 之后增加了一个utf8mb4的编码,mb4就是最多4个字节的意思(most bytes 4),它主要解决了UTF-8不能表示一些特殊字符的问题,比如Emoji表情,这在论坛或者留言板中也经常用到。大家使用小红书时应该见过各种各样的表情符号,小红书后台也可能使用utf8mb4保存它们。


编码规则和特点:



  • 最多4个字节:utf8mb4中的每个字符最多用4个字节表示。

  • 支持更多字符:能表示更多的Unicode字符,包括Emoji和其他特殊符号。


utf8和utf8mb4的比较


存储空间



  • 数据库:utf8mb4每个字符最多用4个字节,比UTF-8多一个字节,存储空间会增加。

  • 文件:类似的,文件用utf8mb4编码也会占用更多的空间。


性能影响



  • 数据库:utf8mb4的查询和索引可能稍微慢一些,因为占用更多的空间和内存。

  • 网络传输:utf8mb4编码的字符会占用更多的带宽,传输速度可能会稍慢。


不过因为实际场景中使用的utf8mb4的字符也不多,其实对存储空间和性能的影响很小,大家基本没有必要因为多占用了一些空间和流量,而不是用utf8mb4。


只是我们在定义字段长度、规划数据存储空间、网络带宽的时候,要充分考虑4字节带来的影响,预留好足够的空间。


实战选择


在实际开发中,选择编码要根据具体需求来定。如果你的网站或者应用需要支持大量的特殊字符和Emoji,使用utf8mb4是个不错的选择。如果主要是英文和普通中文文本,utf8足够应付。


注意为了避免乱码问题,前端、后端、数据库都应该使用同一种编码,比如utf8,具体到编码时就是要确保数据库连接、网页头部、文件读写都设置为相同的编码。


另外还需要注意Windows和Linux系统中使用UTF-8编码的文件可能是有差别的,Windows中的UTF-8文件可能会携带一个BOM头,方便系统进行识别,但是Linux中不需要这个头,所以如果要跨系统使用这个文件,特别是程序脚本,可能需要在Linux中去掉这个头。




以上就是本文的主要内容,如有问题欢迎留言讨论。


关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7375504338758025254
收起阅读 »

黑神话:悟空——揭秘风灵月影的技术魔法

作为一名“手残党”,你是否常常因为复杂的操作和高难度的游戏内容而感到沮丧?不用担心,《黑神话:悟空》不仅仅是为硬核玩家准备的,它同样为我们这些操作不那么娴熟的玩家提供了无比精彩的游戏体验。 本文将带你深入探讨《黑神话:悟空》背后的技术原理,揭示风灵月影团队如何...
继续阅读 »

作为一名“手残党”,你是否常常因为复杂的操作和高难度的游戏内容而感到沮丧?不用担心,《黑神话:悟空》不仅仅是为硬核玩家准备的,它同样为我们这些操作不那么娴熟的玩家提供了无比精彩的游戏体验。


本文将带你深入探讨《黑神话:悟空》背后的技术原理,揭示风灵月影团队如何通过创新的技术手段,让每一位玩家,无论技术水平如何,都能在游戏中找到属于自己的乐趣。我们将揭秘那些让你在游戏中感受到无比真实和沉浸的细节,从逼真的角色动画到动态的环境效果,每一个细节都展示了团队的卓越才能和对游戏品质的追求。让我们一同走进这场技术与艺术的盛宴,感受《黑神话:悟空》背后的科技魔法,了解这些技术如何让你在游戏中无论是战斗还是探索,都能享受到极致的体验。


我的黑神话悟空数值


image.png


欢迎加入宗门


风灵月影修改器


Attach一个游戏进程,黑神话悟空进程名固定
image.png


Attach一个进程之后,可以修改对应的游戏数值
image.png


使用方式上每次启动游戏都要启动风灵月影,重启的游戏风铃月影也会重新Attach进程。


游戏修改器的工作原理


游戏修改器的技术原理主要涉及对游戏内存的实时修改和对游戏数据的动态调整。以下是修改器的主要技术原理和工作机制:


内存修改



  1. 内存扫描:修改器首先会扫描游戏进程的内存空间,找到存储特定游戏数据(如生命值、金钱、资源等)的内存地址。

  2. 地址定位:通过反复扫描和比较内存数据的变化,确定具体的内存地址。例如,玩家在游戏中增加或减少金钱,修改器会通过这些变化找到金钱的内存地址。

  3. 数据修改:一旦找到目标内存地址,修改器会直接修改该地址处的数据。例如,将生命值地址的数据修改为一个极大值,从而实现无限生命。


动态链接库(DLL)注入



  1. DLL注入:修改器可以通过将自定义的DLL文件注入到游戏进程中,来拦截和修改游戏的函数调用。

  2. 函数劫持:通过劫持游戏的关键函数,修改器可以在函数执行前后插入自定义代码。例如,拦截角色受伤的函数调用,将伤害值修改为零,从而实现无敌效果。

  3. 实时调整:DLL注入还可以实现对游戏数据的实时监控和调整,确保修改效果持续生效。


调试工具



  1. 调试接口:一些高级修改器使用调试工具(如Cheat Engine)提供的调试接口,直接与游戏进程交互。

  2. 断点调试:通过设置断点,修改器可以在特定代码执行时暂停游戏,进行数据分析和修改。

  3. 汇编指令修改:修改器可以修改游戏的汇编指令,改变游戏的逻辑。例如,将减血指令修改为加血指令。


数据文件修改



  1. 配置文件:一些游戏的关键数据存储在配置文件中(如INI、XML等),修改器可以直接修改这些文件来改变游戏设置。

  2. 存档文件:修改器可以修改游戏的存档文件,直接改变游戏进度和状态。例如,增加存档中的金钱数量或解锁所有关卡。


反作弊机制



  1. 反检测:为了避免被游戏的反作弊机制检测到,修改器通常会使用一些反检测技术,如代码混淆、动态加密等。

  2. 隐蔽操作:修改器可能会模拟正常的用户操作,避免直接修改内存或数据,降低被检测到的风险。


以上是游戏修改器的主要技术原理。通过这些技术,修改器能够对游戏进行各种修改和调整,提供丰富的功能来提升玩家的游戏体验。然而,使用修改器时应注意相关法律和游戏规定,避免影响游戏的公平性和他人体验。


blog.csdn.net/m0_74942241… (可部分阅读)


代码示例


以下是一个简单的内存扫描示例:


#include <windows.h>
#include <iostream>
#include <vector>

// 扫描目标内存
std::vector<LPVOID> ScanMemory(HANDLE hProcess, int targetValue) {
std::vector<LPVOID> addresses;
MEMORY_BASIC_INFORMATION mbi;
LPVOID address = 0;

while (VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi))) {
if (mbi.State == MEM_COMMIT && (mbi.Protect == PAGE_READWRITE || mbi.Protect == PAGE_WRITECOPY)) {
SIZE_T bytesRead;
std::vector<BYTE> buffer(mbi.RegionSize);
if (ReadProcessMemory(hProcess, address, buffer.data(), mbi.RegionSize, &bytesRead)) {
for (SIZE_T i = 0; i < bytesRead - sizeof(targetValue); ++i) {
if (memcmp(buffer.data() + i, &targetValue, sizeof(targetValue)) == 0) {
addresses.push_back((LPVOID)((SIZE_T)address + i));
}
}
}
}
address = (LPVOID)((SIZE_T)address + mbi.RegionSize);
}

return addresses;
}

int main() {
DWORD processID = 1234; // 替换为目标进程的实际PID
int targetValue = 100; // 要查找的值

HANDLE hProcess = OpenTargetProcess(processID);
if (hProcess) {
std::vector<LPVOID> addresses = ScanMemory(hProcess, targetValue);
for (auto addr : addresses) {
std::cout << "Found value at address: " << addr << std::endl;
ModifyMemory(hProcess, addr, 999); // 修改内存
}
CloseHandle(hProcess);
}

return 0;
}


修改目标进程的内存


#include <windows.h>
#include <iostream>

// 打开目标进程
HANDLE OpenTargetProcess(DWORD processID) {
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processID);
if (hProcess == NULL) {
std::cerr << "Failed to open process. Error: " << GetLastError() << std::endl;
}
return hProcess;
}

// 查找目标内存地址(假设我们已经知道地址)
void ModifyMemory(HANDLE hProcess, LPVOID address, int newValue) {
SIZE_T bytesWritten;
if (WriteProcessMemory(hProcess, address, &newValue, sizeof(newValue), &bytesWritten)) {
std::cout << "Memory modified successfully." << std::endl;
} else {
std::cerr << "Failed to modify memory. Error: " << GetLastError() << std::endl;
}
}

int main() {
DWORD processID = 1234; // 替换为目标进程的实际PID
LPVOID targetAddress = (LPVOID)0x00ABCDEF; // 替换为目标内存地址
int newValue = 999; // 要写入的新值

HANDLE hProcess = OpenTargetProcess(processID);
if (hProcess) {
ModifyMemory(hProcess, targetAddress, newValue);
CloseHandle(hProcess);
}

return 0;
}


以下是一个使用内联钩子实现函数劫持的简单示例(基于Windows平台):


#include <windows.h>

// 原始函数类型定义
typedef int (WINAPI *MessageBoxAFunc)(HWND, LPCSTR, LPCSTR, UINT);

// 保存原始函数指针
MessageBoxAFunc OriginalMessageBoxA = NULL;

// 自定义函数
int WINAPI HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {
// 修改参数或执行其他逻辑
lpText = "This is a hooked message!";
// 调用原始函数
return OriginalMessageBoxA(hWnd, lpText, lpCaption, uType);
}

// 设置钩子
void SetHook() {
// 获取原始函数地址
HMODULE hUser32 = GetModuleHandle("user32.dll");
OriginalMessageBoxA = (MessageBoxAFunc)GetProcAddress(hUser32, "MessageBoxA");

// 修改函数头部指令
DWORD oldProtect;
VirtualProtect(OriginalMessageBoxA, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
*(BYTE*)OriginalMessageBoxA = 0xE9; // JMP指令
*(DWORD*)((BYTE*)OriginalMessageBoxA + 1) = (DWORD)HookedMessageBoxA - (DWORD)OriginalMessageBoxA - 5;
VirtualProtect(OriginalMessageBoxA, 5, oldProtect, &oldProtect);
}

int main() {
// 设置钩子
SetHook();
// 测试钩子
MessageBoxA(NULL, "Original message", "Test", MB_OK);
return 0;
}


CheatEngine


上述通过代码的方式成本比较高,我们通过工具修改内存值和函数劫持
http://www.cheatengine.org/downloads.p…


使用教程 blog.csdn.net/CYwxh0125/a…


image.png


如何实现自动扫描


风灵月影如何实现自动修改值,不需要每次搜索变量内存地址?


猜想1:变量内存地址固定?


经过测试发现不是


猜想2:通过变量字符串搜索?


算了不猜了,直接问GPT,发现应该是通过指针内存扫描的方式。


指针扫描适合解决的问题


指针扫描是一种高级技术,用于解决动态内存地址变化的问题。在某些应用程序(特别是游戏)中,内存地址在每次运行时可能会变化,这使得简单的内存扫描方法难以长期有效地找到目标变量。指针扫描通过查找指向目标变量的指针链,可以找到一个稳定的基址(静态地址),从而解决动态内存地址变化的问题。


1. 动态内存分配


许多现代应用程序和游戏使用动态内存分配,导致每次运行时同一变量可能位于不同的内存地址。指针扫描可以找到指向这些变量的指针链,从而定位到一个稳定的基址。


2. 多次重启后的地址稳定性


通过指针扫描找到的静态基址和指针链,即使在应用程序或系统重启后,仍然可以有效地找到目标变量的位置。这样,用户无需每次重新扫描内存地址。


3. 多级指针


有些变量可能通过多级指针间接引用。指针扫描可以处理这种情况,通过多级指针链找到最终的目标变量。


指针扫描的基本步骤


以下是使用 Cheat Engine 进行指针扫描的基本步骤:


1. 初始扫描


首先,使用普通的内存扫描方法找到目标变量的当前内存地址。例如,假设在游戏中找到当前金钱值的地址是 0x00ABCDEF


2. 指针扫描



  1. 在找到的内存地址上右键单击,选择“指针扫描此地址”。

  2. Cheat Engine 会弹出一个指针扫描窗口。在窗口中设置扫描参数,例如最大指针级别和偏移量。

  3. 点击“确定”开始扫描。Cheat Engine 会生成一个包含可能的指针路径的列表。


3. 验证指针路径



  1. 重启游戏或应用程序,再次找到目标变量的当前内存地址。

  2. 使用新的内存地址进行指针扫描,验证之前找到的指针路径是否仍然有效。

  3. 通过多次验证,找到一个稳定的指针路径。


4. 使用指针路径



  1. 在 Cheat Engine 中保存指针路径。

  2. 以后可以直接使用这个指针路径来访问目标变量,无需每次重新扫描。


示例:使用 Cheat Engine 进行指针扫描


假设你在游戏中找到了当前金钱值的地址 0x00ABCDEF,并想通过指针扫描找到一个稳定的基址。


1. 初始扫描



  1. 启动 Cheat Engine 并附加到游戏进程。

  2. 使用普通的内存扫描方法找到当前金钱值的地址 0x00ABCDEF


2. 指针扫描



  1. 右键单击找到的地址 0x00ABCDEF,选择“指针扫描此地址”。

  2. 在弹出的指针扫描窗口中,设置最大指针级别为 5(可以根据需要调整),偏移量保持默认。

  3. 点击“确定”开始扫描。


3. 验证指针路径



  1. 重启游戏,重新找到当前金钱值的地址(假设新的地址是 0x00DEF123)。

  2. 使用新的地址进行指针扫描,验证之前找到的指针路径是否仍然有效。

  3. 通过多次验证,找到一个稳定的指针路径。例如,指针路径可能是 [game.exe+0x00123456] + 0x10 + 0x20


4. 使用指针路径



  1. 在 Cheat Engine 中保存这个指针路径。

  2. 以后可以直接使用这个指针路径来访问金钱值,无需每次重新扫描。


注意事项



  1. 指针级别:指针级别越高,扫描时间越长,但也能处理更复杂的多级指针情况。根据实际需要设置合适的指针级别。

  2. 验证指针路径:指针路径需要多次验证,以确保其稳定性和可靠性。重启游戏或应用程序,重新扫描并验证指针路径。

  3. 性能影响:指针扫描可能会对系统性能产生一定影响,特别是在大型游戏或应用程序中。建议在合适的环境下进行扫描。


通过以上步骤,指针扫描技术可以帮助用户找到稳定的基址,解决动态内存地址变化的问题,从而实现更可靠的内存修改。


作者:AuthorK
来源:juejin.cn/post/7426389669527207936
收起阅读 »

技术大佬 问我 订单消息乱序了怎么办?

技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了? 佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都揉捏的像一个麻花了嘛 技术大佬 :哦,这次又是遇到什么难题了? 佩琪: 由于和大佬讨论过消息不丢,消息防重等技能(见 kafka 消息“零丢失”的配方 和技...
继续阅读 »

技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了?


佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都揉捏的像一个麻花了嘛


技术大佬 :哦,这次又是遇到什么难题了?


佩琪: 由于和大佬讨论过消息不丢,消息防重等技能(见
kafka 消息“零丢失”的配方技术大佬问我 订单消息重复消费了 怎么办?
),所以在简历的技术栈里就夸大似的写了精通kafka消息中间件,然后就被面试官炮轰了里面的细节


佩琪: 其中面试官给我印象深刻的一个问题是:你们的kafka消息里会有乱序消费的情况吗?如果有,是怎么解决的了?


技术大佬 :哦,那你是怎么回答的了?


佩琪:我就是个crud boy,根本不知道啥是顺序消费啥是乱序消费,所以就回答说,没有


技术大佬 :哦,真是个诚实的孩子;然后呢?


佩琪:然后面试官就让我回家等通知了,然后就没有然后了。。。。


佩琪对了大佬,什么是消息乱序消费了?


技术大佬 :消息乱序消费,一般指我们消费者应用程序不按照,上游系统 业务发生的顺序,进行了业务消息的颠倒处理,最终导致消费业务出错。


佩琪低声咕噜了下你这说的是人话吗?大声问答:这对我的小脑袋有点抽象了,大佬能举个实际的栗子吗?


技术大佬 :举个上次我们做的促销数据同步的栗子吧,大概流程如下:


1700632936991.png


技术大佬 :上次我们做的促销业务,需要在我们的运营端后台,录入促销消息;然后利用kafka同步给三方业务。在业务流程上,是先新增促销信息,然后可能删除促销信息;但是三方消费端业务接受到的kafka消息,可能是先接受到删除促销消息;随后接受到新增促销消息;这样不就导致了消费端系统和我们系统的促销数据不一致了嘛。所以你是消费方,你就准备接锅吧,你不背锅,谁背锅了?


佩琪 :-_-||,此时佩琪心想,锅只能背一次,坑只能掉一次。赶紧问到:请问大佬,消息乱序了后,有什么解决方法吗?


技术大佬 : 此时抬了抬眼睛,清了清嗓子,面露自信的微笑回答道。一般都是使用顺序生产,顺序存储,顺序消费的思想来解决。


佩琪摸了摸头,能具体说说,顺序生产,顺序存储,顺序消费吗?


技术大佬 : 比如kafka,一般建议同一个业务属性数据,都往一个分区上发送;而kafka的一个分区只能被一个消费者实例消费,不能被多个消费者实例消费。


技术大佬 : 也就是说在生产端如果能保证 把一个业务属性的消息按顺序放入同一个分区;那么kakfa中间件的broker也是顺序存储,顺序给到消费者的。而kafka的一个分区只能被一个消费者消费;也就不存在多线程并发消费导致的顺序问题了。


技术大佬 :比如上面的同步促销消息;不就是两个消费者,拉取了不同分区上的数据,导致消息乱序处理,最终数据不一致。同一个促销数据,都往一个分区上发送,就不会存在这样的乱序问题了。


佩琪哦哦,原来是这样,我感觉这方案心理没底了,大佬能具体说说这种方案有什么优缺点吗?


技术大佬 :给你一张图,你学习下?


优点缺点
生产端实现简单:比如kafka 生产端,提供了按指定key,发送到固定分区的策略上游难保证严格顺序生产:生产端对同一类业务数据需要按照顺序放入同一个分区;这个在应用层还是比较的难保证,毕竟上游应用都是无状态多实例,多机器部署,存在并发情况下执行的先后顺序不可控
消费端实现也简单 :kafka消费者 默认就是单线程执行;不需要为了顺序消费而进行代码改造消费者处理性能会有潜在的瓶颈:消费者端单线程消费,只能扩展消费者应用实例来进行消费者处理能力的提升;在消息较多的时候,会是个处理瓶颈,毕竟干活的进程上限是topic的分区数。
无其它中间件依赖使用场景有取限制:业务数据只能指定到同一个topic,针对某些业务属性是一类数据,但发送到不同topic场景下,则不适用了。比如订单支付消息,和订单退款消息是两个topic,但是对于下游算佣业务来说都是同一个订单业务数据

佩琪大佬想偷懒了,能给一个 kafka 指定 发送到固定分区的代码吗?


技术大佬 :有的,只需要一行代码,你要不自己动手尝试下?


KafkaProducer.send(new ProducerRecord[String,String](topic,key,msg),new Callback(){} )

topic:主题,这个玩消息的都知道,不解释了

key: 这个是指定发送到固定分区的关键。一般填写订单号,或者促销ID。kafka在计算消息该发往那个分区时,会默认使用hash算法,把相同的key,发送到固定的分区上

msg: 具体消息内容


佩琪大佬,我突然记起,上次我们做的 订单算佣业务了,也是利用kafka监听订单数据变化,但是为什么没有使用固定分区方案了?


技术大佬 : 主要是我们上游业务方:把订单支付消息,和订单退款消息拆分为了两个topic,这个从使用固定分区方案的前提里就否定了,我们不能使用此方案。


佩琪哦哦,那我们是怎么去解决这个乱序的问题的了?


技术大佬 :主要是根据自身业务实际特性;使用了数据库乐观锁的思想,解决先发后至,后发先至这种数据乱序问题。


大概的流程如下图:


1700632983267.png


佩琪摸了摸头,大佬这个自身业务的特性是啥了?


技术大佬 :我们算佣业务,主要关注订单的两个状态,一个是订单支付状态,一个是订单退款状态
订单退款发生时间肯定是在订单支付后;而上游订单业务是能保证这两个业务在时间发生上的前后顺序的,即订单的支付时间,肯定是早于订单退款时间。所以主要是利用订单ID+订单更新时间戳,做为数据库佣金表的更新条件,进行数据的乱序处理。


佩琪哦哦,能详细说说 这个数据库乐观锁是怎么解决这个乱序问题吗?


技术大佬 : 比如:当佣金表里订单数据更新时间大于更新条件时间 就放弃本次更新,表明消息数据是个老数据;即查询时不加锁


技术大佬 :而小于更新条件时间的,表明是个订单新数据,进行数据更新。即在更新时 利用数据库的行锁,来保证并发更新时的情况。即真实发生修改时加锁


佩琪哦哦,明白了。原来一条带条件更新的sql,就具备了乐观锁思想


技术大佬 :我们算佣业务其实是只关注佣金的最终状态,不关注中间状态;所以能用这种方式,保证算佣数据的最终一致性,而不用太关注订单的中间状态变化,导致佣金的中间变化。


总结


要想保证消息顺序消费大概有两种方案


1700633024660.png


固定分区方案


1、生产端指定同一类业务消息,往同一个分区发送。比如指定发送key为订单号,这样同一个订单号的消息,都会发送同一个分区

2、消费端单线程进行消费


乐观锁实现方案


如果上游不能保证生产的顺序;可让上游加上数据更新时间;利用唯一ID+数据更新时间,+乐观锁思想,保证业务数据处理的最终一致性。


原创不易,请 点赞,留言,关注,收藏 4暴击^^


天冷了,多年不下雪的北京,下了一场好大的雪。如果暴击不能让您动心,请活动下小手
佩琪正在参与 掘金2023年度人气创作者打榜中,感谢掘友们的支持,为佩琪助助力,也是对我文章输出的鼓励和支持 ~ ~ 万分感谢 activity.juejin.cn/rank/2023/w…


作者:程序员猪佩琪
来源:juejin.cn/post/7303833186068086819
收起阅读 »

前端自动化部署的极简方案

打开服务器连接,找到文件夹,删掉,找到打包的目录,ctrl + C, ctrl + v 。。。。 烦的要死。。内网开发,node 的 ssh2 依赖库一时半会还导不进来。 索性,自己写一个! 原生 NodeJS 代码,不需要引用任何第三方库 win10 及以上...
继续阅读 »

打开服务器连接,找到文件夹,删掉,找到打包的目录,ctrl + C, ctrl + v 。。。。
烦的要死。。内网开发,node 的 ssh2 依赖库一时半会还导不进来。


索性,自己写一个!


原生 NodeJS 代码,不需要引用任何第三方库 win10 及以上版本,系统自带ssh 命令行工具,如果没有,需要自行安装


首先,需要生成本地秘钥;


ssh-keygen

执行上述命令后,系统会提示你输入文件保存位置和密码,如果你想使用默认位置和密码,直接按回车接受即可。这将在默认的SSH目录~/.ssh/下生成两个文件:id_rsa(私钥)和id_rsa.pub(公钥)


开启服务端使用秘钥登录


一般文件位置位于 /etc/ssh/sshd_config 中,


找到下面两行 ,取消注释,值改为 yes


RSAAuthentication yes
PubkeyAuthentication yes

将秘钥添加到服务端:


打开服务端文件 /root/.ssh/authorized_keys 将公钥 粘贴到新的一行中


重启服务端 ssh 服务


sudo service ssh restart

编写自动化上传脚本(nodejs 脚本)


// 创建文件 ./build/Autoactic.js

const { exec, spawn } = require('child_process');
const fs= require('fs');

// C:/Users/admin/.ssh/server/ServiceOptions.json
// 此处储存本地连接服务端相关配置数据(目的为不将秘钥暴露给 Git 提交代码)
// {
// 服务端关键字(记录为哪个服务器)
// "Test90": {
// 服务器登录用户名
// "Target": "root@255.255.255.255",
// 本地证书位置(秘钥)
// "Pubkey": "C:/User/admin/.shh/server/file"
// }
// }

// 温馨提示 本机储存的秘钥需要调整权限,需要删除除了自己以外其他的全部用户
const ServiceOption = Json.parse(fs.readFileSync("C:/Users/admin/.ssh/server/ServiceOptions.json"), "utf-8");

// 本地项目文件路径(dist 打包后的实际路径)
const LocalPath = "D:/Code/rmgm/jilinres/jprmcrm/dev/admin/dist"
// 服务端项目路径
const ServerPath = "/home/rmgmuser/web/pmr";

// 运行单行命令 (scp 命令,上传文件使用)
const RunSSHCode = function (code) {
return new Promise((resolve, reject) => {
const process = exec(code, (error, sodut, stderr) => {
if (error) {
console.error(`执行错误: ${error}`)
reject();
return;
};
console.log(`sodut:${sodut}`);
if (stderr) {
console.error(`stderr:${stderr}`)
};
if (process && !process.killed){
process.kill();
};
setTimeout(()=>{
resolve();
},10);
})
})
}

// 执行服务端命令 (执行 ssh 命令行)
const CommandHandle(command) {
return new Promise((resolve, reject) => {
const child = spawn('ssh', ['-i', ServiceOption.Test90.Pubkey, '-o', 'StrictHostKeyChecking=no', ServiceOption.Test90.Target], {
stdio: ['pipe']
});
child.on('close',(err)=>{
console.log(`--close--:${err}`);
if (err === 0) {
setTimeout(()=>{ resolve() },10)
} else {
reject();
}
})
child.on('error',(err)=>{
console.error(`--error--:${err}`)
});
console.log(`--command--:${command}`);
child.stdin.end(command);
child.stdout.on('data',(data)=>{
console.log(`Stdout:${data}`);
})
child.stderr.on('data',(data)=>{
console.log(`Stdout:${data}`);
})
}
};


// 按照顺序执行代码
!(async function (CommandHandle, RunSSHCode){
try {
console.log(`创建文件夹 => ${ServerPath}`);
await CommandHandle(`mkdir -p ${ServerPath}`);
console.log(`创建文件夹 => ${ServerPath} => 完成`);

console.log(`删除历史文件 => ${ServerPath}`);
await CommandHandle(`find ${ServerPath} -type d -exec rm -rf {} +`);
console.log(`删除历史文件 => ${ServerPath} => 完成`);

console.log(`上传本地文件 => 从 本地 ${LocalPath} 到 服务端 ${ServerPath}`);
await RunSSHCode(`scp -i ${serviceOption.Test90.pubkey} -r ${LocalPath}/ ${serviceOption.Test90.Target}:${ServerPath}/`);
console.log(`上传本地文件 => 从 本地 ${LocalPath} 到 服务端 ${ServerPath} => 完成`);

// 吉林环境个性配置 非必须(更改访问权限)
console.log(`更改访问权限 => ${ServerPath}`);
await CommandHandle(`chown -R rmgmuser:rmgmuser ${ServerPath}`);
console.log(`更改访问权限 => ${ServerPath} => 完成`);

} catch (error) {
console.error(`---END---`, error)
}
})(CommandHandle, RunSSHCode)

更改打包命令:


// package.json
{
// ....
"scripts": {
// .....

"uploadFile" : "node ./build/Autoactic.js"
// 原始的 build 改为 prebuild 命令
"preBuild" : "vue-cli-service build --mode production"
// npm 按顺序运行多个命令行
"build" : "npm run preBuild && npm run uploadFile"

// .....
}
//...
}

效果 打包结束后,直接上传到服务端。


有特殊需求,例如重启服务等,可自行添加。


作者:DevSpeed
来源:juejin.cn/post/7431591478508748811
收起阅读 »

小项目自动化部署用 Jenkins 太麻烦了怎么办

导读 本文介绍用 Webhooks 代替 Jenkins 更简单地实现自动化部署。不论用 Jenkins 还是 Webhooks,都需要一定的服务端基础。 Webhooks 的使用更简单,自然功能就不如 Jenkins 丰富,因此更适合小项目。 背景 笔者一...
继续阅读 »

导读


本文介绍用 Webhooks 代替 Jenkins 更简单地实现自动化部署。不论用 Jenkins 还是 Webhooks,都需要一定的服务端基础。


Webhooks 的使用更简单,自然功能就不如 Jenkins 丰富,因此更适合小项目。



背景


笔者一直在小厂子做小项目,只做前端的时候,部署项目就是 npm run build 然后压缩发给后端。后来到另一个小厂子做全栈,开始自己部署,想着捣鼓一下自动化部署。


Jenkins 是最流行的自动化部署工具,但是弄到一半我头都大了。我只是想部署一个小项目而已,结果安装、配置、启动 Jenkins 这工作量好像比我手动部署还大呢,必须找个更简单的办法才行。果然经过一番捣鼓,发现 Webhooks 又简单又实用,更适合我们小厂子小项目。


原理


首先我们的项目应该都放在 Git 平台上,主流的 Git 平台上都有 Webhooks。它的作用是:在你推送代码、发布版本等操作时,自动向你提供的地址发一个请求。


你的服务器收到这个请求后,要做的事情就是调用一段事先写好的脚本。这段脚本的任务是拉取最新代码、安装依赖(可选)、打包项目、重新启动项目。


这样当你在 Git 平台上发布版本后,服务器就会自动部署最新代码了。


实现


实现步骤可以和上面的原理反着来:先写好脚本,然后启动服务,最后创建 Webhooks。


在此之前,你的服务器需要先安装 Git,并能够拉取你的代码。这部分内容很常规,看官可以在其他地方搜到。


1. 自动部署脚本


Nuxt


自动部署脚本就是代替我们手动打包、部署的工作。在 Linux 中,它应该写在一个 .sh 文件里。我的前端项目用 Nuxt 开发,脚本主要内容如下:


# 进入项目文件
cd /usr/local/example

# 拉取最新代码
git pull

# 打包
npm run build

# 重启服务
pm2 reload ecosystem.config.js

你可以在 Git 上随便更新点内容,然后在 XShell 或其他工具打开服务器控制台,执行这段代码,然后到线上版本看更新有没有生效。


笔记一开始经过了一番折腾,发现最好得记录部署日志,那样方便排查问题。完整脚本如下:


# 日志文件路径
LOG_FILE="/usr/local/example/$(date).txt"

# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE

# 进入项目文件
cd /usr/local/example

# 拉取最新代码
git pull >> $LOG_FILE 2>&1

# 打包
npm run build >> $LOG_FILE 2>&1

# 重启服务
pm2 reload ecosystem.config.js >> $LOG_FILE 2>&1

# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE

Eggjs


笔者后端用了 Eggjs,其自动部署脚本如下:


# 日志文件
LOG_FILE="/usr/local/example/$(date).txt"

# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE

# 进入项目文件
cd /usr/local/example

# 拉取最新代码
git pull >> $LOG_FILE 2>&1

# Egg 没有重启命令,要先 stop 再 start
npm stop >> $LOG_FILE 2>&1

npm start >> $LOG_FILE 2>&1

# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE

Eggjs 项目没有构建的步骤,其依赖要事先安装好。因此如果开发过程中安装了新依赖,记得到服务端安装一下。


Midwayjs


由于 Eggjs 对 TypeScript 的支持比较差,笔者后来还用了 Midwayjs 来开发服务端,其脚本如下:


# 日志文件
LOG_FILE="/usr/local/example/$(date).txt"

# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE

# 进入项目文件
cd /usr/local/example

# 拉取最新代码
git pull >> $LOG_FILE 2>&1

# 重装依赖
export NODE_ENV=development
npm install >> $LOG_FILE 2>&1

# 构建
npm run build >> $LOG_FILE 2>&1

# 移除开发依赖
npm prune --omit=dev >> $LOG_FILE 2>&1

# 启动服务
npm start >> $LOG_FILE 2>&1

# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE

Midwayjs 的自动部署脚本比较特殊:在 npm install 之前需要先指定环境为 development,那样才会安装所有依赖,否则会忽略 devDependencies 中的依赖,导致后面的 npm run build 无法执行。这点也是费了笔者好长时间才排查清楚,因为它在 XShell 里执行的时候默认的环境就是 development,但是到了 Webhooks 调用的时候又变成了 product


2. 启动一个独立的小服务


上面这些脚本,应该由一个独立的小服务来执行。笔者一开始让项目的 Eggjs 服务来执行,也就是想让 Eggjs 服务自动部署自己,就失败了。原因是:脚本在执行到 npm stop 时,Eggjs 服务把自己关掉了,自然就执行不了 npm start


笔者启动了一个新的 Eggjs 服务来实现这个功能,使用其他语言、框架同理。其中执行脚本的控制器代码如下:


const { Controller } = require('egg');
const { exec } = require('child_process');

class EggController extends Controller {
async index() {
const { ctx } = this;
try {
// 执行 .sh 脚本
await exec('sh /usr/local/example/egg.sh');
ctx.body = {
'msg': 'Deployment successful'
};
} catch (error) {
ctx.body = {
'msg': 'Deployment failed:' + JSON.stringify(error)
};
}
}
}

module.exports = EggController;

如果启动成功,你应该可以在 Postman 之类的工具上发起这个控制器对应的请求,然后成功执行里面的 .sh 脚本。


注意这些请求必须是 POST 请求。


3. 到 Git 平台创建 Webhooks


笔者用的是GitCode,其他平台类似。到代码仓库 -> 项目设置 -> WebHook 菜单 -> 新建 Webhook:


image.png



  • URL:上面独立小服务的请求地址;

  • Token:在 Git 平台生成即可;

  • 事件类型:我希望是发布版本的时候触发,所以选 Tag推送事件


创建好之后,激活这个 hook,然后随便提交些新东西,到代码仓库 -> 代码 -> 创建发行版:


image.png


image.png


填写版本号、版本描述后,滑到底部,勾选“最新版本”,点击发布按钮。


image.png


这样就能触发前面创建的 WebHook,向你的独立小服务发送请求,小服务就会去调用自动部署脚本。


怎么样,是不是比 Jenkins 简单太多了。当然功能也比 Jenkins 简单太多,但是对小厂子小项目来说,也是刚好够用。


作者:前端知识Cool
来源:juejin.cn/post/7406238334215520291
收起阅读 »

go的生态真的一言难尽

前言 标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 Maven 或 Gradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设...
继续阅读 »

前言



标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 MavenGradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设计和利用现有的工具与库,可以实现一个功能强大且灵活的工具。



正文分为两部分:项目本身和如何使用



一、项目本身


1. 项目需求分析


核心需求



  1. 依赖管理

    • 解析和下载 Go 项目的依赖。

    • 支持依赖版本控制和冲突解决。



  2. 构建管理

    • 支持编译 Go 项目。

    • 支持跨平台编译。

    • 支持自定义构建选项。



  3. 发布管理

    • 打包构建结果。

    • 支持发布到不同的平台(如 Docker Hub、GitHub Releases)。



  4. 任务管理

    • 支持定义和执行自定义任务(如运行测试、生成文档)。



  5. 插件系统

    • 支持扩展工具的功能。




可选需求



  • 缓存机制:缓存依赖和构建结果以提升速度。

  • 并行执行:支持并行下载和编译。




2. 技术选型


2.1 编程语言



  • Go 语言:由于我们要构建的是 Go 项目的构建工具,选择 Go 语言本身作为开发语言是合理的。


2.2 依赖管理



  • Go Modules:Go 自带的依赖管理工具已经很好地解决了依赖管理的问题,可以直接利用 Go Modules 来解析和管理依赖。


2.3 构建工具



  • Go 标准库:Go 的标准库提供了强大的编译和构建功能(如 go build, go install 等命令),可以通过调用这些命令或直接使用相关库来进行构建。


2.4 发布工具



  • Docker:对于发布管理,可能需要集成 Docker 来构建和发布 Docker 镜像。

  • upx:用于压缩可执行文件。


2.5 配置文件格式



  • YAMLTOML:选择一种易于阅读和编写的配置文件格式,如 YAML 或 TOML。




3. 系统架构设计


3.1 模块划分



  1. 依赖管理模块

    • 负责解析和下载项目的依赖。



  2. 构建管理模块

    • 负责编译 Go 项目,支持跨平台编译和自定义构建选项。



  3. 发布管理模块

    • 负责将构建结果打包和发布到不同平台。



  4. 任务管理模块

    • 负责定义和执行自定义任务。



  5. 插件系统

    • 提供扩展点,允许用户编写插件来扩展工具的功能。




3.2 系统流程



  1. 初始化项目:读取配置文件,初始化项目环境。

  2. 依赖管理:解析依赖并下载。

  3. 构建项目:根据配置文件进行项目构建。

  4. 执行任务:执行用户定义的任务(如测试)。

  5. 发布项目:打包构建结果并发布到指定平台。




4. 模块详细设计与实现


4.1 依赖管理模块


4.1.1 设计

利用 Go Modules 现有的功能来管理依赖。可以通过 go list 命令来获取项目的依赖:


4.1.2 实现

package dependency

import (
"fmt"
"os/exec"
)

// ListDependencies 列出项目所有依赖
func ListDependencies() ([]byte, error) {
cmd := exec.Command("go", "list", "-m", "all")
return cmd.Output()
}

// DownloadDependencies 下载项目所有依赖
func DownloadDependencies() error {
cmd := exec.Command("go", "mod", "download")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("download failed: %s", output)
}
return nil
}

4.2 构建管理模块


4.2.1 设计

调用 Go 编译器进行构建,支持跨平台编译和自定义构建选项。


4.2.2 实现

package build

import (
"fmt"
"os/exec"
"runtime"
"path/filepath"
)

// BuildProject 构建项目
func BuildProject(outputDir string) error {
// 设置跨平台编译参数
var goos, goarch string
switch runtime.GOOS {
case "windows":
goos = "windows"
case "linux":
goos = "linux"
default:
goos = runtime.GOOS
}
goarch = "amd64"

output := filepath.Join(outputDir, "myapp")
cmd := exec.Command("go", "build", "-o", output, "-ldflags", "-X main.version=1.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build failed: %s", output)
}
fmt.Println("Build successful")
return nil
}

4.3 发布管理模块


4.3.1 设计

打包构建结果并发布到不同平台。例如,构建 Docker 镜像并发布到 Docker Hub。


4.3.2 实现

package release

import (
"fmt"
"os/exec"
)

// BuildDockerImage 构建 Docker 镜像
func BuildDockerImage(tag string) error {
cmd := exec.Command("docker", "build", "-t", tag, ".")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker build failed: %s", output)
}
fmt.Println("Docker image built successfully")
return nil
}

// PushDockerImage 推送 Docker 镜像
func PushDockerImage(tag string) error {
cmd := exec.Command("docker", "push", tag)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker push failed: %s", output)
}
fmt.Println("Docker image pushed successfully")
return nil
}

5. 任务管理模块

允许用户定义和执行自定义任务:


package task

import (
"fmt"
"os/exec"
)

type Task func() error

func RunTask(name string, task Task) {
fmt.Println("Running task:", name)
err := task()
if err != nil {
fmt.Println("Task failed:", err)
return
}
fmt.Println("Task completed:", name)
}

func TestTask() error {
cmd := exec.Command("go", "test", "./...")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tests failed: %s", output)
}
fmt.Println("Tests passed")
return nil
}

6. 插件系统

可以通过动态加载外部插件或使用 Go 插件机制来实现插件系统:


package plugin

import (
"fmt"
"plugin"
)

type Plugin interface {
Run() error
}

func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, err
}
symbol, err := p.Lookup("PluginImpl")
if err != nil {
return nil, err
}
impl, ok := symbol.(Plugin)
if !ok {
return nil, fmt.Errorf("unexpected type from module symbol")
}
return impl, nil
}

5. 示例配置文件


使用 YAML 作为配置文件格式,定义项目的构建和发布选项:


name: myapp
version: 1.0.0
build:
options:
- -ldflags
- "-X main.version=1.0.0"
release:
docker:
image: myapp:latest
tag: v1.0.0
tasks:
- name: test
command: go test ./...

6. 持续改进


后续我将持续改进工具的功能和性能,例如:



  • 增加更多的构建和发布选项。

  • 优化依赖管理和冲突解决算法。

  • 提供更丰富的插件。


二、如何使用




1. 安装构建工具


我已经将构建工具发布到 GitHub 并提供了可执行文件,用户可以通过以下方式安装该工具。


1.1 使用安装脚本安装

我将提供一个简单的安装脚本,开发者可以通过 curlwget 安装构建工具。


使用 curl 安装

curl -L https://github.com/yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash

使用 wget 安装

wget -qO- https://github.com//yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash

1.2 手动下载并安装

如果你不想使用自动安装脚本,可以直接从 GitHub Releases 页面手动下载适合你操作系统的二进制文件。



  1. 访问 GitHub Releases 页面。

  2. 下载适合你操作系统的二进制文件:

    • Linux: GoForge-linux-amd64

    • macOS: GoForge-darwin-amd64

    • Windows: GoForge-windows-amd64.exe



  3. 将下载的二进制文件移动到系统的 PATH 路径(如 /usr/local/bin/),并确保文件有执行权限。


# 以 Linux 系统为例
mv GoForge-linux-amd64 /usr/local/bin/GoForge
chmod +x /usr/local/bin/GoForge



2. 创建 Go 项目并配置构建工具


2.1 初始化 Go 项目

假设你已经有一个 Go 项目或你想创建一个新的 Go 项目。首先,初始化 Go 模块:


mkdir my-go-project
cd my-go-project
go mod init github.com/myuser/my-go-project

2.2 创建 build.yaml 文件

在项目根目录下创建 build.yaml 文件,这个文件类似于 Maven 的 pom.xml 或 Gradle 的 build.gradle,用于配置项目的依赖、构建任务和发布任务。


示例 build.yaml

project:
name: my-go-project
version: 1.0.0

dependencies:
- name: github.com/gin-gonic/gin
version: v1.7.7
- name: github.com/stretchr/testify
version: v1.7.0

build:
output: bin/my-go-app
commands:
- go build -o bin/my-go-app main.go

tasks:
clean:
command: rm -rf bin/

test:
command: go test ./...

build:
dependsOn:
- test
command: go build -o bin/my-go-app main.go

publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app

配置说明:


  • project: 定义项目名称和版本。

  • dependencies: 列出项目的依赖包及其版本号。

  • build: 定义构建输出路径和构建命令。

  • tasks: 用户可以定义自定义任务(如 cleantestbuild 等),并可以配置任务依赖关系。

  • publish: 定义发布到 GitHub 的配置,包括发布的仓库和需要发布的二进制文件。




3. 执行构建任务


构建工具允许你通过命令行执行各种任务,如构建、测试、清理、发布等。以下是一些常用的命令。


3.1 构建项目

执行以下命令来构建项目。该命令会根据 build.yaml 文件中定义的 build 任务进行构建,并生成二进制文件到指定的 output 目录。


GoForge build

构建过程会自动执行依赖任务(例如 test 任务),确保在构建之前所有测试通过。


3.2 运行测试

如果你想单独运行测试,可以使用以下命令:


GoForge test

这将执行 go test ./...,并运行所有测试文件。


3.3 清理构建产物

如果你想删除构建生成的二进制文件等产物,可以运行 clean 任务:


GoForge clean

这会执行 rm -rf bin/,清理 bin/ 目录下的所有文件。


3.4 列出所有可用任务

如果你想查看所有可用的任务,可以运行:


GoForge tasks

这会列出 build.yaml 文件中定义的所有任务,并显示它们的依赖关系。




4. 依赖管理


构建工具会根据 build.yaml 中的 dependencies 部分来处理 Go 项目的依赖。


4.1 安装依赖

当执行构建任务时,工具会自动解析依赖并安装指定的第三方库(类似于 go mod tidy)。


你也可以单独运行以下命令来手动处理依赖:


GoForge deps

4.2 更新依赖

如果你需要更新依赖版本,可以在 build.yaml 中手动更改依赖的版本号,然后运行 mybuild deps 来更新依赖。




5. 发布项目


构建工具提供了发布项目到 GitHub 等平台的功能。根据 build.yaml 中的 publish 配置,你可以将项目的构建产物发布到 GitHub Releases。


5.1 配置发布相关信息

确保你在 build.yaml 中正确配置了发布信息:


publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app


  • type: 发布的目标平台(GitHub 等)。

  • repo: GitHub 仓库路径。

  • token: 需要设置环境变量 GITHUB_TOKEN,用于认证 GitHub API。

  • assets: 指定发布时需要上传的二进制文件。


5.2 发布项目

确保你已经完成构建,并且生成了二进制文件。然后,你可以执行以下命令来发布项目:


GoForge publish

这会将 bin/my-go-app 上传到 GitHub Releases,并生成一个新的发布版本。


5.3 测试发布(Dry Run)

如果你想在发布之前测试发布流程(不上传文件),可以使用 --dry-run 选项:


GoForge publish --dry-run

这会模拟发布过程,但不会实际上传文件。




6. 高级功能


6.1 增量构建

构建工具支持增量构建,如果你在 build.yaml 中启用了增量构建功能,工具会根据文件的修改时间戳或内容哈希来判断是否需要重新构建未被修改的部分。


build:
output: bin/my-go-app
incremental: true
commands:
- go build -o bin/my-go-app main.go

6.2 插件机制

你可以通过插件机制来扩展构建工具的功能。例如,你可以为工具增加自定义的任务逻辑,或在构建生命周期的不同阶段插入钩子。


build.yaml 中定义插件:


plugins:
- name: custom-task
path: plugins/custom-task.go

编写 custom-task.go,并实现你需要的功能。




7. 调试和日志


如果你在使用时遇到了问题,可以通过以下方式启用调试模式,查看详细的日志输出:


GoForge --debug build

这会输出工具在执行任务时的每一步详细日志,帮助你定位问题。




总结


通过这个构建工具,你可以轻松管理 Go 项目的依赖、构建过程和发布任务。以下是使用步骤的简要总结:



  1. 安装构建工具:使用安装脚本或手动下载二进制文件。

  2. 配置项目:创建 build.yaml 文件,定义依赖、构建任务和发布任务。

  3. 执行任务:通过命令行执行构建、测试、清理等任务。

  4. 发布项目:将项目的构建产物发布到 GitHub 或其他平台。


作者:justseeit
来源:juejin.cn/post/7431545806085423158
收起阅读 »

白嫖微信OCR,实现批量提取图片中的文字

微信自带的OCR使用比较方便,且准确率较高,但是唯一不足的是需要手动截图之后再识别,无法批量操作,这里我们借助wechat-ocr这一开源工具,实现批量读取文件夹下的所有图片并提取文本的功能。下面介绍下操作步骤。 1. 安装wechat-ocr这个库 这里我们...
继续阅读 »

微信自带的OCR使用比较方便,且准确率较高,但是唯一不足的是需要手动截图之后再识别,无法批量操作,这里我们借助wechat-ocr这一开源工具,实现批量读取文件夹下的所有图片并提取文本的功能。下面介绍下操作步骤。


1. 安装wechat-ocr这个库


这里我们使用的是GoBot这一自动化工具(如对该软件感兴趣,可以关注公众号:RPA二师兄),他提供的可视化安装依赖的功能。打开依赖包管理的Tab页,在Python包名称处填写wechat-ocr,然后点击安装,就能完成wechat-ocr的安装,安装完成之后可以切换到管理已安装模块的Tab,可以看到已经成功安装。


Pasted image 20241101200809.png


Pasted image 20241101200954.png


2. 编写调用代码


这里我们直接给出代码,只需要创建一个代码流程,将我们给的代码复制进去就可以了。


from package import variables as glv #全局变量,例如glv['test']
from robot_base import log_util
import robot_basic
from robot_base import log_util
import os
import re
from wechat_ocr.ocr_manager import OcrManager, OCR_MAX_TASK_ID


def main(args):
#输入参数使用示例
# if args is :
# 输入参数1 = ""
#else:
# 输入参数1 = args.get("输入参数1", "")
log_util.Logger.info(args)
init_ocr_manger(args['微信安装目录'])
ocr_manager.DoOCRTask(args['待识别图片路径'])
while ocr_manager.m_task_id.qsize() != OCR_MAX_TASK_ID:
pass
global ocr_result
return ocr_result

ocr_result = {}
ocr_manager =
def ocr_result_callback(img_path:str, results:dict):
log_util.Logger.info(results)
ocr_result = results

def init_ocr_manger(wechat_dir):
wechat_dir = find_wechat_path(wechat_dir)
wechat_ocr_dir = find_wechatocr_exe()
global ocr_manager
if ocr_manager is :
ocr_manager = OcrManager(wechat_dir)
# 设置WeChatOcr目录
ocr_manager.SetExePath(wechat_ocr_dir)
# 设置微信所在路径
ocr_manager.SetUsrLibDir(wechat_dir)
# 设置ocr识别结果的回调函数
ocr_manager.SetOcrResultCallback(ocr_result_callback)
# 启动ocr服务
ocr_manager.StartWeChatOCR()


def find_wechat_path(wechat_dir):
# 定义匹配版本号文件夹的正则表达式
version_pattern = re.compile(r'\[\d+\.\d+\.\d+\.\d+\]')

path_temp = os.listdir(wechat_dir)
for temp in path_temp:
# 下载是正则匹配到[3.9.10.27]
# 使用正则表达式匹配版本号文件夹
if version_pattern.match(temp):
wechat_path = os.path.join(wechat_dir, temp)
if os.path.isdir(wechat_path):
return wechat_path

def find_wechatocr_exe():
# 获取APPDATA路径
appdata_path = os.getenv("APPDATA")
if not appdata_path:
print("APPDATA environment variable not found.")
return

# 定义WeChatOCR的基本路径
base_path = os.path.join(appdata_path, r"Tencent\WeChat\XPlugin\Plugins\WeChatOCR")

# 定义匹配版本号文件夹的正则表达式
version_pattern = re.compile(r'\d+')

try:
# 获取路径下的所有文件夹
path_temp = os.listdir(base_path)
except FileNotFoundError:
print(f"The path {base_path} does not exist.")
return

for temp in path_temp:
# 使用正则表达式匹配版本号文件夹
if version_pattern.match(temp):
wechatocr_path = os.path.join(base_path, temp, 'extracted', 'WeChatOCR.exe')
if os.path.isfile(wechatocr_path):
return wechatocr_path

# 如果没有找到匹配的文件夹,返回
return

然后点击流程参数,创建流程的输入参数


Pasted image 20241101201206.png


3. 调用OCR识别的方法,实现批量的文字提取


使用调用流程组件,填写对应的参数,即可实现图片文字的提取了。


Pasted image 20241101201445.png


作者:GoBot
来源:juejin.cn/post/7432193949765287962
收起阅读 »

BOE(京东方)2024年前三季度净利润三位数增长 “屏之物联”引领企业高质发展

10月30日,京东方科技集团股份有限公司(京东方A:000725;京东方B:200725)发布2024年第三季度报告,前三季度公司实现营业收入1437.32亿元,较去年同期增长13.61%;归属于上市公司股东净利润33.10亿元,同比大幅增长223.80%。其...
继续阅读 »

10月30日,京东方科技集团股份有限公司(京东方A:000725;京东方B:200725)发布2024年第三季度报告,前三季度公司实现营业收入1437.32亿元,较去年同期增长13.61%;归属于上市公司股东净利润33.10亿元,同比大幅增长223.80%。其中,第三季度实现营业收入503.45亿元,较去年同期增长8.65%;归属于上市公司股东净利润10.26亿元,同比增长258.21%。BOE(京东方)凭借稳健的经营策略和行业领先的技术优势,在保持半导体显示产业龙头地位的同时,持续推动“1+4+N+生态链”在各个细分市场的深度布局与成果落地,不断深化“屏之物联”战略在多业态场景的转化应用。面向下一个三十年,BOE(京东方)积极推动构建产业发展“第N曲线”,打造新的业务增长极,持续激发产业生态活力。

不仅业绩表现亮眼,BOE(京东方)还不断加大在前沿技术领域和物联网转型方面的投入与探索,为全球显示及物联网产业的未来发展注入新的活力。第三季度,BOE(京东方)全球创新伙伴大会成功举办,全面展示公司在前沿技术领域的重要突破以及物联网转型创新成果,并重磅发布了企业创新发展的战略升维“第N曲线”理论。这一理论不仅承载着企业文化的深厚底蕴,更是对核心优势资源的深度拓展,在“第N曲线”理论指导下,BOE(京东方)已在玻璃基、钙钛矿等新兴领域重点布局,其中,钙钛矿光伏电池中试线从设备搬入到首批样品产出,历时仅38天,创造了行业新记录,这一突破性进展标志着BOE(京东方)在钙钛矿光伏产业化道路上迈出了重要一步,以卓越的实力和高效的速度着力打造“第N曲线”关键增长极,持续引领行业走向智能化、可持续化发展。

稳居半导体显示领域龙头地位,技术创新引领行业发展

2024年前三季度,BOE(京东方)凭借前瞻性的全球市场布局,持续稳固半导体显示领域的龙头地位,不仅专利申请量保持全球领先,更有自主研发的ADS Pro顶尖技术引领行业发展,在柔性AMOLED市场也持续突破,各类技术创新成果丰硕。据市场调研机构Omdia数据显示,BOE(京东方)显示屏整体出货量和五大主流应用领域液晶显示屏出货量稳居全球第一。在专利方面,BOE(京东方)累计自主专利申请已超9万件,其中发明专利超90%,海外专利超30%,技术与产品创新能力稳步提升。同时,BOE(京东方)持续展现强大的创新实力和市场影响力,BOE(京东方)自主研发的、独有的液晶显示领域顶流技术ADS Pro,不仅是目前全球出货量最高的主流液晶显示技术,也是应用最广的硬屏液晶显示技术。凭借高环境光对比度、全视角无色偏、高刷新率和动态画面优化等方面的卓越性能表现,ADS Pro技术成为客户高端旗舰产品的首选,市场出货量和客户采纳度遥遥领先,展现了液晶显示技术蓬勃的生命力,更是极大推动了全球显示产业的良性健康发展。在柔性显示领域,2024年前三季度,BOE(京东方)柔性AMOLED产品出货量进一步增加,荣耀Magic6系列搭载BOE(京东方)首发的OLED低功耗解决方案,开启柔性OLED低功耗全新时代,获得市场和客户的广泛赞誉;与OPPO一加客户联合发布全新2K+LTPO全能高端屏幕标志着柔性显示的又一次全面技术革新,凭借在画质、性能及护眼等多方面的显著提升,再次定义高端柔性OLED屏幕行业新标准。同时,BOE(京东方)加快AMOLED产业布局,投建的国内首条第8.6代AMOLED生产线从开工到封顶仅用183天,以科学、高效、高质的速度树立行业新标杆,推动OLED显示产业快速迈进中尺寸发展阶段。

“1+4+N”业务布局成果显著,打造多元化发展格局

在持续深耕显示行业的同时,BOE(京东方)始终坚持创新发展,“1+4+N+生态链”业务也在创新技术的赋能下展现出全新活力,各个细分市场成果显著。BOE(京东方)物联网创新业务在智慧终端和系统方案两大领域持续高速发展,智慧终端领域,BOE(京东方)正式发布“Smart GOAL”业务目标,致力于打造软硬融合、服务全球、一站式、高效敏捷、绿色低碳的智造体系,并在白板、拼接、电子价签(ESL)等细分市场出货量保持全球第一(数据来源:迪显、Omdia等);系统方案领域,BOE(京东方)持续深耕智慧园区、智慧金融等多个物联网细分场景,积极拓展人机交互协同新边界。在传感业务方面,BOE(京东方)光幕技术及MEMS传感等技术加速赋能奇瑞汽车,推进汽车智能化转型;发布国内首个《乘用车用电子染料液晶调光玻璃技术规范》团体标准,并在智慧视窗领域超额完成极氪首款标配车型调光窗的量产交付,实现订单量增长200%,开启光幕技术创新与应用的新篇章;同时还在工业传感器领域导入6家战略客户,未来将在项目合作及产品研发等方面开展广泛合作。在MLED业务方面, BOE(京东方)MLED珠海项目全面启动,标志着公司在MLED领域进一步深入布局,为全球MLED市场的拓展奠定了坚实基础。在智慧医工业务方面,BOE(京东方)强化科技与医学相结合,打通“防治养”全链条,持续推动医疗健康服务的智慧化升级,成都京东方智慧医养社区正式投入运营,创新医养融合模式,成为BOE(京东方)布局智慧医养领域的重要里程碑;合肥京东方医院加入胸部肿瘤长三角联盟,携手优质专家资源造福当地患者;BOE(京东方)健康研究院与山西省肿瘤医院合作开展NK细胞治疗膀胱癌的临床研究,助力医疗技术创新。

BOE(京东方)还以“N”业务为着力点,为不同行业提供软硬融合的整体解决方案,包括智慧车载、数字艺术、AI+、超高清显示、智慧能源等多个细分领域,打造业务增长新曲线。在车载领域,BOE(京东方)持续保持车载显示屏出货量及出货面积全球第一(数据来源:Omdia),智能座舱产品全面应用到长安汽车、吉利汽车、蔚来、理想等全球各大主流汽车品牌中。在数字艺术领域,艺云科技在裸眼3D显示技术等方面取得新突破,裸眼3D屏亮相国家博物馆,艺云数字艺术中心(王府井)、艺云数字艺术中心(宜宾)正式开馆,创新显示技术为多个领域增光添彩。在AI+领域,BOE(京东方)已将人工智能技术产品、服务、解决方案应用于制造、办公、医疗、零售等细分场景,依托自主研发的人工智能平台及衍生技术集,打造AI良率分析系统、AI显示知识问答系统、显示工业大模型等,大幅度提高生产效率。在超高清领域,BOE(京东方)中联超清通过8K超高清显示技术、超薄全贴合电子站牌和户外LCD广告屏等产品,赋能合肥高新区公交站升级、成都双流国际机场T1航站楼,助力交通出行智能化服务水平大幅提升。在绿色发展方面,BOE(京东方)能源业务在工业、商业、园区等多个场景下加速推进零碳综合能源服务,成功落地多个能源托管项目和碳资产管理项目,助力社会实现二氧化碳减排约33万吨。

值得一提的是,BOE(京东方)在全球化布局与品牌建设的道路上也迈出了更加坚实的步伐。“你好,BOE”、《BOE解忧实验室》两大营销IP持续大热,BOE(京东方)年度标志性品牌活动“你好,BOE”首站亮相海外,助力中国非物质文化遗产艺术展览落地法国巴黎,向世界展示中国科技的创新活力;在上海北外滩盛大启幕的“你好,BOE”SUPER O SPACE影像科技展以“艺术x科技”为主题为观众带来了一场视觉盛宴,成为BOE(京东方)“屏之物联”战略赋能万千应用场景的又一次生动展现;《BOE解忧实验室》奇遇发现季节目以全网4.58亿传播的辉煌成绩,成为2024年度硬核技术科普综艺及科技企业破圈营销典范。2024年是体育大年,在巴黎全球体育盛会举办期间,BOE(京东方)还与联合国教科文组织(UNESCO)在法国巴黎总部签订合作协议,成为首个支持联合国“科学十年”的中国科技企业,更助力中国击剑队出征巴黎,在科技、体育、文化等多个维度树立中国科技企业出海的全新范式。

在新技术、新消费、新场景的多重驱动下,2024年前三季度,BOE(京东方)保持了稳健的发展态势,不断创新前沿技术成果,丰富多元应用场景,为半导体显示产业高质升维发展注入源源不断的动能。未来,BOE(京东方)将继续秉承“屏之物联”战略,以稳健的经营、前瞻性的技术研发和持续应用创新,携手全球合作伙伴共同构建“Powered by BOE”产业价值创新生态,推动显示技术、物联网技术与数字技术的深度融合,为显示行业高质量发展贡献力量,共创智慧美好未来。


收起阅读 »

开发小同学的骚操作,还好被我发现了

大家好,我是程序员鱼皮。今天给朋友们还原一个我们团队真实的开发场景。 开发现场 最近我们编程导航网站要开发 用户私信 功能,第一期要做的需求很简单: 能让两个用户之间 1 对 1 单独发送消息 用户能够查看到消息记录 用户能够实时收到消息通知 这其实是一...
继续阅读 »

大家好,我是程序员鱼皮。今天给朋友们还原一个我们团队真实的开发场景。


开发现场


最近我们编程导航网站要开发 用户私信 功能,第一期要做的需求很简单:



  1. 能让两个用户之间 1 对 1 单独发送消息

  2. 用户能够查看到消息记录

  3. 用户能够实时收到消息通知



这其实是一个双向实时通讯的场景,显然可以使用 WebSocket 技术来实现。


团队的后端开发小 c 拿到需求后就去调研了,最后打算采用 Spring Boot Starter 快速整合 Websocket 来实现,接受前端某个用户传来的消息后,转发到接受消息的用户的会话,并在数据库中记录,便于用户查看历史。


小 c 的代码写得还是不错的,用了一些设计模式(像策略模式、工厂模式)对代码进行了一些抽象封装。虽然在我看来对目前的需求来说稍微有点过度设计,但开发同学有自己的理由和想法,表示尊重~



前端同学小 L 也很快完成了开发,并且通过了产品的验收。


看似这个需求就圆满地完成了,但直到我阅读前端同学的代码时,才发现了一个 “坑”。



这是前端同学小 L 提交的私信功能代码,看到这里我就已经发现问题了,朋友们能注意到么?



解释一下,小 L 引入了一个 nanoid 库,这个库的作用是生成唯一 id。看到这里,我本能地感到疑惑:为什么要引入这个库?为什么前端要生成唯一 id?


难道。。。是作为私信消息的 id?


果不其然,通过这个库在前端给每个消息生成了一个唯一 id,然后发送给后端。



后端开发的同学可能会想:一般情况下不都是后端利用数据库的自增来生成唯一 id 并返回给前端嘛,怎么需要让前端来生成呢?


这里小 L 的解释是,在本地创建消息的时候,需要有一个 id 来追踪状态,不会出现消息没有 id 的情况。


首先,这么做的确 能够满足需求 ,所以我还是通过了代码审查;但严格意义上来说,让前端来生成唯一 id 其实不够优雅,可能会有一些问题。


前端生成 id 的问题


1)ID 冲突:同时使用系统的前端用户可能是非常多的,每个用户都是一个客户端,多个前端实例可能会生成相同的 ID,导致数据覆盖或混乱。


2)不够安全:切记,前端是没有办法保证安全性的!因为攻击者可以篡改或伪造请求中的数据,比如构造一个已存在的 id,导致原本的数据被覆盖掉,从而破坏数据的一致性。


要做这件事成本非常低,甚至不需要网络攻击方面的知识,打开 F12 浏览器控制台,重放个请求就行实现:



3)时间戳问题:某些生成 id 的算法是依赖时间戳的,比如当前时间不同,生成的 id 就不同。但是如果前端不同用户的电脑时间不一致,就可能会生成重复 id 或无效 id。比如用户 A 电脑是 9 点时生成了 id = 06030901,另一个用户 B 电脑时间比 A 慢了一个小时,现在是 8 点,等用户 B 电脑时间为 9 点的时候,可能又生成了重复 id = 06030901,导致数据冲突。这也被称为 “分布式系统中的全局时钟问题”。


明确前后端职责


虽然 Nanoid 这个库不依赖时间戳来生成 id,不会受到设备时钟不同步的影响,也不会因为时间戳重复而导致 ID 冲突。根据我查阅的资料,生成大约 10 ^ 9 个 ID 后,重复的可能性大约是 10 ^ -17,几乎可以忽略不计。但一般情况下,我个人会更建议将业务逻辑统一放到后端实现,这么做的好处有很多:



  1. 后端更容易保证数据的安全性,可以对数据先进行校验再生成 id

  2. 前端尽量避免进行复杂的计算,而是交给后端,可以提升整体的性能

  3. 职责分离,前端专注于页面展示,后端专注于业务,而不是双方都要维护一套业务逻辑


我举个典型的例子,比如前端下拉框内要展示一些可选项。由于选项的数量并不多,前端当然可以自己维护这些数据(一般叫做枚举值),但后端也会用到这些枚举值,双方都写一套枚举值,就很容易出现不一致的情况。推荐的做法是,让后端返回枚举值给前端,前端不用重复编写。



所以一般情况下,对于 id 的生成,建议统一交给后端实现,可以用雪花算法根据时间戳生成,也可以利用数据库主键生成自增 id 或 UUID,具体需求具体分析吧~




作者:程序员鱼皮
来源:juejin.cn/post/7376148503087169562
收起阅读 »

在老的Node.js服务器里“加点Rust”,我的服务性能飙升近 80%

你有没有遇到过这样的情况?服务器跑着跑着就卡了,明明只是一些普通的操作,却让资源“飚红”,甚至快撑不住了。特别是当你用JavaScript或者Python这些脚本语言写的服务器,遇到CPU密集型任务时,性能瓶颈似乎更是无可避免。这时候,是不是觉得有点力不从心?...
继续阅读 »

你有没有遇到过这样的情况?服务器跑着跑着就卡了,明明只是一些普通的操作,却让资源“飚红”,甚至快撑不住了。特别是当你用JavaScript或者Python这些脚本语言写的服务器,遇到CPU密集型任务时,性能瓶颈似乎更是无可避免。这时候,是不是觉得有点力不从心?

今天,我们安利一个解决方案——Rust!一种速度快、效率高的编程语言。它有点像是给你的Node.js或者Python服务器加了“肌肉”,尤其适合处理高强度的运算任务。下面,我就给大家讲讲如何一步步把Rust“融入”到现有的服务器里,用简单的策略大幅度提升性能。


引入Rust的三步策略

在这个策略中,我们从“0”开始,逐步引入Rust,分别通过Rust CLI工具和Wasm模块来提升服务器的性能。总的原则是:每一步都不搞大改动,让你的老服务器既能“焕发新生”,又能保持现有的代码框架。


第0步:从Node.js服务器开始

假设我们现在有一个Node.js服务器,用来生成二维码。这个需求其实并不复杂,但在高并发的情况下,这样的CPU密集型任务会让JavaScript显得吃力。

const express = require('express');
const generateQrCode = require('./generate-qr.js');

const app = express();
app.get('/qrcode'async (req, res) => {
    const { text } = req.query;

    if (!text) {
        return res.status(400).send('missing "text" query param');
    }

    if (text.length > 512) {
        return res.status(400).send('text must be <= 512 bytes');
    }

    try {
        const qrCode = await generateQrCode(text);
        res.setHeader('Content-Type''image/png');
        res.send(qrCode);
    } catch (err) {
        res.status(500).send('failed generating QR code');
    }
});

app.listen(42069'127.0.0.1');

基准测试:在纯Node.js的情况下,这个服务每秒能处理1464个请求,内存占用也不小。虽然勉强能跑起来,但一旦用户多了,可能会明显感觉到卡顿。


第1步:引入Rust CLI工具,效率提升近80%

这里的策略是保留Node.js的框架不变,把处理二维码生成的那段代码用Rust写成一个独立的命令行工具(CLI)。在Node.js中,我们直接调用这个CLI工具,分担高强度的计算工作。

/** qr_lib/lib.rs **/

use qrcode::{QrCode, EcLevel};
use image::Luma;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};

pub type StdErr = Box<dyn std::error::Error>;

pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, StdErr> {
    let qr = QrCode::with_error_correction_level(text, EcLevel::L)?;
    let img_buf = qr.render::u8>>()
        .min_dimensions(200, 200)
        .build();
    let mut encoded_buf = Vec::with_capacity(512);
    let encoder = PngEncoder::new_with_quality(
        &mut encoded_buf,
        // these options were chosen since
        // they offered the best balance
        // between speed and compression
        // during testing
        CompressionType::Default,
        FilterType::NoFilter,
    );
    img_buf.write_with_encoder(encoder)?;
    Ok(encoded_buf)
}

效果:重写后,我们的处理性能直接飙升到了每秒2572个请求!这是一个显著的提升,更让人欣慰的是,内存占用也跟着降了下来。Rust的高效编译和内存管理,确实比JavaScript强太多了。

实现步骤

    1. 首先,用Rust编写二维码生成的核心逻辑代码。
    1. 将这段Rust代码编译成一个可执行的CLI工具。
    1. 在Node.js代码中,通过子进程调用CLI工具,直接拿到生成的结果。
  • 在Node.js中调用Rust CLI工具的代码示例如下:

    const { exec } = require('child_process');
    exec('./qr_generator_cli', (error, stdout, stderr) => {
      if (error) {
        console.error(`执行出错: ${error}`);
        return;
      }
      console.log(`生成的二维码数据: ${stdout}`);
    });

    这个方法就像是给Node.js加了一个“外挂”,而且几乎不需要改动现有代码。也就是说,你可以在不动大框架的情况下,得到Rust的性能优势。


    第2步:编译Rust到WebAssembly(Wasm),性能提升再进一步

    在第1步中,我们通过CLI工具调用了Rust,但依旧会产生一定的通信开销。所以,接下来,我们可以进一步优化,将Rust代码编译成WebAssembly(Wasm)模块,并在Node.js中直接调用它。这样,整个过程就在内存中运行,不用通过子进程调用CLI,速度进一步提升。

    效果:使用Wasm后,处理性能再上升到了每秒2978个请求,而内存使用依旧维持在较低水平。

    实现步骤

      1. 将Rust代码编译为Wasm模块。可以使用wasm-pack这样的工具来帮助生成。
      1. 在Node.js中,通过wasm-bindgen等工具直接加载并调用Wasm模块。

    Node.js中加载Wasm模块的代码示例如下:

    const fs = require('fs');
    const wasmBuffer = fs.readFileSync('./qr_generator_bg.wasm');
    WebAssembly.instantiate(wasmBuffer).then(wasmModule => {
      const qrGenerator = wasmModule.instance.exports.qr_generate;
      console.log(qrGenerator('Hello, Rust with Wasm!'));
    });

    这种方法让我们完全绕过了CLI的通信环节,直接把Rust的性能用在Node.js中。这不仅提升了效率,还让代码更加紧凑,减少了延迟。


    思考

    通过以上三步策略,我们可以在不完全推翻现有代码的前提下,逐步引入Rust,极大地提升服务器的性能。这个过程既适用于Node.js,也可以推广到其他语言和环境中。

    为什么这个方法特别值得尝试呢?首先,它成本低。你不需要重写整个系统,只需要对瓶颈部分进行改进。其次,效果明显,尤其是对那些经常“吃力”的功能。最后,这个方法是可扩展的,你可以根据实际情况,灵活选择用CLI还是Wasm的方式来引入Rust。

    所以,如果你的服务器正被性能问题困扰,不妨试试这个三步引Rust法。正如一位资深开发者所说:“Rust不仅让你的服务器跑得更快,还让代码变得更加优雅。”


    作者:老码小张
    来源:juejin.cn/post/7431091997114843151
    收起阅读 »

    如何优雅的将MultipartFile和File互转

    我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。 前言 首先来区别一下MultipartFile和File: MultipartFile是 S...
    继续阅读 »

    我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。


    前言


    首先来区别一下MultipartFile和File:



    • MultipartFile是 Spring 框架的一部分,File是 Java 标准库的一部分。

    • MultipartFile主要用于接收上传的文件,File主要用于操作系统文件。


    MultipartFile转换为File


    使用 transferTo


    这是一种最简单的方法,使用MultipartFile自带的transferTo 方法将MultipartFile转换为File,这里通过上传表单文件,将MultipartFile转换为File格式,然后输出到特定的路径,具体写法如下。


    transferto.png


    使用 FileOutputStream


    这是最常用的一种方法,使用 FileOutputStream 可以将字节写入文件。具体写法如下。


    FileOutputStream.png


    使用 Java NIO


    Java NIO 提供了文件复制的方法。具体写法如下。


    copy.png


    File装换为MultipartFile


    从File转换为MultipartFile 通常在测试或模拟场景中使用,生产环境一般不这么用,这里只介绍一种最常用的方法。


    使用 MockMultipartFile


    在转换之前先确保引入了spring-test 依赖(以Maven举例)


    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-test</artifactId>
    <version>version</version>
    <scope>test</scope>
    </dependency>

    通过获得File文件的名称、mime类型以及内容将其转换为MultipartFile格式。具体写法如下。


    multi.png



    更多文章干货,推荐公众号【程序员老J】



    作者:程序员老J
    来源:juejin.cn/post/7295559402475667492
    收起阅读 »

    mysql到底是join性能好,还是in一下更快呢?

    大家好呀,我是楼仔。 今天发现一篇很有意思的文章,使用 mysql 查询时,是使用 join 好,还是直接 in 更好,这个大家工作时经常遇到。 为了方便大家查看,文章我重新进行了排版。 我没有直接用作者的结论,感觉可能会误导读者,而是根据实验结果,给出我自己...
    继续阅读 »

    大家好呀,我是楼仔。


    今天发现一篇很有意思的文章,使用 mysql 查询时,是使用 join 好,还是直接 in 更好,这个大家工作时经常遇到。


    为了方便大家查看,文章我重新进行了排版。


    我没有直接用作者的结论,感觉可能会误导读者,而是根据实验结果,给出我自己的建议。


    不 BB,上目录:



    01 背景


    事情是这样的,去年入职的新公司,之后在代码 review 的时候被提出说,不要写 join,join 耗性能还是慢来着,当时也是真的没有多想,那就写 in 好了。


    最近发现 in 的数据量过大的时候会导致 sql 慢,甚至 sql 太长,直接报错了。


    这次来浅究一下,到底是 in 好还是 join 好,仅目前认知探寻,有不对之处欢迎指正。


    以下实验仅在本机电脑试验。


    02 表结构


    2.1 用户表



     CREATE TABLE `user` (
    `id` int NOT NULL AUTO_INCREMENT,
    `name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '姓名',
    `gender` smallint DEFAULT NULL COMMENT '性别',
    `mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号',
    `create_time` datetime NOT NULL COMMENT '创建时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `mobile` (`mobile`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=1005 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

    2.2 订单表



    CREATE TABLE `order` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `price` decimal(18,2) NOT NULL,
    `user_id` int NOT NULL,
    `product_id` int NOT NULL,
    `status` smallint NOT NULL DEFAULT '0' COMMENT '订单状态',
    PRIMARY KEY (`id`),
    KEY `user_id` (`user_id`),
    KEY `product_id` (`product_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=202 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

    03 千条数据情况


    数据量:用户表插一千条随机生成的数据,订单表插一百条随机数据


    要求:查下所有的订单以及订单对应的用户


    耗时衡量指标:多表连接查询成本 = 一次驱动表成本 + 从驱动表查出的记录数 * 一次被驱动表的成本


    3.1 join


    select order.id, price, user.name from order join user on order.user_id = user.id;



    3.2 in


    select id,price,user_id from order;



    select name from user where id in (8, 11, 20, 32, 49, 58, 64, 67, 97, 105, 113, 118, 129, 173, 179, 181, 210, 213, 215, 216, 224, 243, 244, 251, 280, 309, 319, 321, 336, 342, 344, 349, 353, 358, 363, 367, 374, 377, 380, 417, 418, 420, 435, 447, 449, 452, 454, 459, 461, 472, 480, 487, 498, 499, 515, 525, 525, 531, 564, 566, 580, 584, 586, 592, 595, 610, 633, 635, 640, 652, 658, 668, 674, 685, 687, 701, 718, 720, 733, 739, 745, 751, 758, 770, 771, 780, 806, 834, 841, 856, 856, 857, 858, 882, 934, 942, 983, 989, 994, 995);


    其中 in 的是order查出来的所有用户 id。



    如此看来,分开查和 join 查的成本并没有相差许多。


    3.3 并发场景


    主要用php原生写了脚本,用ab进行10个同时的请求,看下时间,进行比较。


    > ab -n 100 -c 10 // 执行脚本

    下面是 join 查询的执行脚本:


    $mysqli = new mysqli('127.0.0.1', 'root', 'root', 'test');
    if ($mysqli->connect_error) {
    die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
    }

    $result = $mysqli->query('select order.id, price, user.`name` from `order` join user on order.user_id = user.id;');
    $orders = $result->fetch_all(MYSQLI_ASSOC);

    var_dump($orders);
    $mysqli->close();


    下面是 in 查询的执行脚本:


    $mysqli = new mysqli('127.0.0.1', 'root', 'root', 'test');
    if ($mysqli->connect_error) {
    die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
    }

    $result = $mysqli->query('select `id`,price,user_id from `order`');
    $orders = $result->fetch_all(MYSQLI_ASSOC);

    $userIds = implode(',', array_column($orders, 'user_id')); // 获取订单中的用户id
    $result = $mysqli->query("select `id`,`name` from `user` where id in ({$userIds})");
    $users = $result->fetch_all(MYSQLI_ASSOC);// 获取这些用户的姓名

    // 将id做数组键
    $userRes = [];
    foreach ($users as $user) {
    $userRes[$user['id']] = $user['name'];
    }

    $res = [];
    // 整合数据
    foreach ($orders as $order) {
    $current = [];
    $current['id'] = $order['id'];
    $current['price'] = $order['price'];
    $current['name'] = $userRes[$order['user_id']] ?: '';
    $res[] = $current;
    }
    var_dump($res);

    // 关闭mysql连接

    $mysqli->close();


    看时间的话,明显 join 更快一些。


    04 万条数据情况


    user表现在10000条数据,order表10000条试下。


    4.1 join



    4.2 in


    order 耗时:



    user 耗时:



    4.3 并发场景


    join 耗时:



    in 耗时:



    数据量达到万级别,非并发场景,in 更快,并发场景 join 更快。


    05 十万条数据情况


    随机插入后user表十万条数据,order表一百万条试下。


    5.1 join



    5.2 in


    order 耗时:



    user 耗时:


    order查出来的结果过长了...


    5.3 并发场景


    join 耗时:



    in 耗时:



    数据量达到十万/百万级别,非并发场景,in 过长,并发场景 join 更快。


    06 总结


    实验结论:



    • 数据量不到万级别,join 和 in 差不多;

    • 数据量达到万级别,非并发场景,in 更快,并发场景 join 更快;

    • 数据量达到十万/百万级别,非并发场景,in 过长,并发场景 join 更快。


    下面是楼仔给出的一些建议。


    当数据量比较小时,建议用 in,虽然两者的性能差不多,但是 join 会增加 sql 的复杂度,后续再变更,会非常麻烦。


    当数据量比较大时,建议用 join,主要还是出于查询性能的考虑。


    不过使用 join 时,小表驱动大表,一定要建立索引,join 的表最好不要超过 3 个,否则性能会非常差,还会大大增加 sql 的复杂度,非常不利于后续功能扩展。




    最后,把楼仔的座右铭送给你:我从清晨走过,也拥抱夜晚的星辰,人生没有捷径,你我皆平凡,你好,陌生人,一起共勉。


    原创好文:


    作者:楼仔
    来源:juejin.cn/post/7306322677039218724
    收起阅读 »

    sleep 和 wait深度对比!

    在计算机编程中,特别是在多线程或并发编程中,sleep 和 wait 是两个非常常见的函数,但它们有不同的用途和工作机制,这篇文章我们将详细地讨论 sleep 和 wait 的区别,包括它们的内部工作原理、应用场景以及详细的示例代码,以帮助更全面地理解它们。 ...
    继续阅读 »



    在计算机编程中,特别是在多线程或并发编程中,sleepwait 是两个非常常见的函数,但它们有不同的用途和工作机制,这篇文章我们将详细地讨论 sleepwait 的区别,包括它们的内部工作原理、应用场景以及详细的示例代码,以帮助更全面地理解它们。


    sleep


    工作机制



    1. 暂停当前线程: sleep 方法暂停当前执行的线程一段指定的时间,时间结束后线程再恢复执行。

    2. 不会释放锁: 即使线程在 sleep 状态下持有锁,它也不会释放。它依然占用着该锁,其他线程无法获得该锁。

    3. 线程状态转换: sleep 方法会使线程从运行(RUNNING)状态转换为计时等待(TIMED_WAITING)状态。

    4. 静态方法: 它是 Thread 类的静态方法,调用时通过 Thread.sleep 访问。


    应用场景



    • 限流: 控制任务执行的频率,防止线程过度占用CPU资源。

    • 定时任务: 在某个循环中,定时执行某些任务。


    示例代码


    public class SleepExample extends Thread {
    public void run() {
    try {
    System.out.println("Thread going to sleep for 2 seconds.");
    Thread.sleep(2000); // 睡眠 2 秒
    System.out.println("Thread woke up after sleeping.");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }

    public static void main(String[] args) {
    SleepExample thread = new SleepExample();
    thread.start();
    }
    }

    wait


    工作机制



    1. 释放锁并等待通知: wait 方法使当前线程等待,直到其他线程调用当前对象的 notifynotifyAll 方法。调用 wait 时,线程会释放它持有的锁。

    2. 必须在同步块或同步方法中使用: wait 方法必须在同步块或同步方法中调用,否则会抛出 IllegalMonitorStateException

    3. 线程状态转换: wait 方法会使线程从运行(RUNNING)状态转换为等待(WAITING)状态。

    4. 对象方法: 它是 Object 类的方法,所以任何对象都可以调用。


    应用场景



    • 线程间通信: 多个线程协同工作时,一个线程等待某个条件满足后,再被其他线程通知继续执行。

    • 生产者-消费者模型: 经常用于实现生产者-消费者模式中的同步。


    示例代码


    public class WaitNotifyExample {
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
    // 等待线程
    Thread waitingThread = new Thread(() -> {
    synchronized (lock) {
    try {
    System.out.println("Thread waiting for the lock to be released.");
    lock.wait(); // 进入等待状态并释放锁
    System.out.println("Thread resumed after lock released.");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    });

    // 通知线程
    Thread notifyingThread = new Thread(() -> {
    synchronized (lock) {
    System.out.println("Notifying other threads.");
    lock.notify(); // 通知其他等待该锁的线程
    System.out.println("Notified waiting thread.");
    }
    });

    waitingThread.start();
    Thread.sleep(1000); // 确保 waitingThread 先持有锁并进入等待状态
    notifyingThread.start();
    }
    }

    sleep 和 wait的对比


    特性sleepwait
    释放锁
    需要在同步块或方法中
    属于ThreadObject
    引发异常InterruptedExceptionInterruptedException 引发机制相同
    作用范围当前调用的线程当前拥有锁的线程
    线程状态改变变为计时等待(TIMED_WAITING)变为等待(WAITING)
    典型应用场景暂停线程的一段时间,用于控制节奏或定时操作线程间通信,生产者-消费者模型等

    sleep-and-wait.png


    总结


    本文,我们分析了sleepwaitsleep用于暂停当前线程一段指定时间,但仍保持锁,这常用来控制执行节奏或定时操作。wait使线程释放锁并进入等待状态,直到通过 notify/notifyAll 被唤醒,需在同步块中使用,适用于线程间通信如生产者-消费者模型。


    作者:猿java
    来源:juejin.cn/post/7420718386953355279
    收起阅读 »

    springboot + minio + kkfile实现文件预览(不暴露minio地址)

    前言 之前我写过一片文章【springboot + minio + kkfile实现文件预览】,该文章介绍了如何使用kkfile预览文件,但是文章中介绍的方案,会暴露minio的地址,实际的预览地址如下: http://kkfile-server/onlin...
    继续阅读 »

    前言



    之前我写过一片文章【springboot + minio + kkfile实现文件预览】,该文章介绍了如何使用kkfile预览文件,但是文章中介绍的方案,会暴露minio的地址,实际的预览地址如下:


    http://kkfile-server/onlinePreview?url=base64UrlEncode(minio生成的文件预览地址)

    但是大多数情况下,minio服务的地址是不允许暴露的,所有我们对其进行优化,依然使用kkfile预览文件,但是我们使用文件流的方式,并且在下载接口上校验用户认证的有效性,在保证不暴露minio地址的前提下,还加入了token认证,提高了安全性,话不多说,直接上代码。



    一、文件上传


    上传服务


    public void uploadFile(MultipartFile file) throws Exception {
    String fileName = System.currentTimeMillis() + "-" + file.getOriginalFilename();
    PutObjectArgs args = PutObjectArgs.builder().bucket(minioConfig.getBucketName()).object(fileName).stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build();
    client.putObject(args);
    }

    封装接口


    @PostMapping("upload")
    public RestResult upload(MultipartFile file) {
    try {
    sysFileService.uploadFile(file);
    } catch (Exception e) {
    log.error("上传文件失败", e);
    return RestResult.fail(e.getMessage());
    }
    }

    二、文件下载


    下载服务


    public void download(String filename, HttpServletResponse response) throws ServiceException {
    try {
    InputStream inputStream = client.getObject(GetObjectArgs.builder().bucket(minioConfig.getBucketName()).object(filename).build());


    // 设置响应头信息,告诉前端浏览器下载文件
    response.setContentType("application/octet-stream;charset=UTF-8");
    response.setCharacterEncoding("UTF-8");
    response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));

    // 获取输出流进行写入数据
    OutputStream outputStream = response.getOutputStream();
    // 将输入流复制到输出流
    byte[] buffer = new byte[4096];
    int bytesRead = -1;

    while ((bytesRead = inputStream.read(buffer)) != -1) {
    outputStream.write(buffer, 0, bytesRead);
    }
    // 关闭流资源
    inputStream.close();
    outputStream.close();
    } catch (Exception e) {
    log.error("文件下载失败:" + e.getMessage());
    throw new ServiceException("文件下载失败");
    }
    }

    封装接口


    @ApiOperation("文件下载")
    @GetMapping("/download/{token}/{filename}")
    public void getDownload(@PathVariable("token") String token, @PathVariable("filename") String filename, HttpServletResponse response) {
    tokenUtils.validateToken(token);
    sysFileService.download(filename, response);
    }


    上面的接口有两个地方需要注意



    1. @GetMapping("/download/{token}/{filename}")中filename参数必须放在最后

    2. tokenUtils.validateToken(token);
      该接口要在拦截器中放行,验证token在代码逻辑中,这里根据项目中实际场景去实现。该地址为kkfile请求获取文件流的地址,所以需要放开鉴权



    三、文件预览地址获取


    文件预览地址生成服务(该服务只是获取token并拼接到文件下载地址中,不对token做验证,因为该服务的接口在请求进入前要做校验)


    public String getPreviewUrl(String filename) throws UnsupportedEncodingException {
    ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = sra.getRequest();
    if (request ==null || StringUtils.isBlank(request.getHeader(TokenConstants.AUTHENTICATION))) {
    throw new ServiceException("未获取到有效token");
    }
    String previewUrl = filePreviewUrl + FileUploadUtils.base64UrlEncode(fileDownloadUrl + "/" + token + "/" + filename);
    return previewUrl + "&fullfilename=" + URLEncoder.encode(filename, "UTF-8");
    }

    FileUploadUtils中的base64UrlEncode方法


    public static String base64UrlEncode(String url) throws UnsupportedEncodingException {
    String base64Url = Base64.getEncoder().encodeToString(url.getBytes(StandardCharsets.UTF_8));
    return URLEncoder.encode(base64Url, "UTF-8");
    }

    封装接口,获取文件预览地址


    @GetMapping("/getPreviewUrl")
    public RestResult<String> getPreviewUrl(String filename) throws UnsupportedEncodingException {
    return RestResult.ok(sysFileService.getPreviewUrl(filename));
    }

    测试


    假设



    1. 文件服务地址为:http://file-server

    2. kkfile服务地址为:http://kkfile-server

    3. 文件名称为:xxxx.docx


    最后生成的文件预览地址为:


    http://kkfile-server/onlinePreview?url=aHR0cDovLzE3Mi4xNi41MC4y....&fullfilename=xxxx.docx

    其中aHR0cDovLzE3Mi4xNi41MC4y....为:


    FileUploadUtils.base64UrlEncode("http://file-server" + "/" + token + "/" + filename);


    截图为证


    image.png




    作者:小太阳381
    来源:juejin.cn/post/7424338056918761498
    收起阅读 »

    听我一句劝,业务代码中,别用多线程。

    你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
    继续阅读 »

    你好呀,我是歪歪。


    前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


    虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


    我只是微微一笑,这不是很正常吗?


    业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


    所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


    关于这个观点,我给你盘一下。


    Demo


    首先我们还是花五分钟搭个 Demo 出来。


    我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



    这个 Demo 我也是跟着网上的 quick start 搞的:



    cn.dubbo.apache.org/zh-cn/overv…




    可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


    我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



    在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


    而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



    只是发起调用的方式不一样而已,其他没啥大区别。


    需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


    上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



    上菜


    在上面的 Demo 中,这是消费者的代码:



    这是提供者的代码:



    整个调用链路非常的清晰:



    来,请你告诉我这里面有线程池吗?


    没有!


    是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


    同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


    所以,站在我,一个开发人员的角度,这个里面没有线程池。


    合理,非常合理。


    但是,当我们换个角度,再看看,它也是可以有的。


    比如这样:



    反应过来没有?


    我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


    那你说,这个里面有线程池吗?


    在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



    通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



    朋友,这不就是线程池吗?


    虽然不是你写的,但是你确实用了。


    我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



    同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


    比如 Dubbo 的线程池,你可以看一下官方的文档:



    cn.dubbo.apache.org/zh-cn/overv…




    而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


    比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



    我们主要关注这个业务线程池。


    反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



    那么问题来了,在当前的这个情况下?


    当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


    你怎么办?


    你会 duang 的一下在业务逻辑里面加一个线程池吗?



    大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


    web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



    tomcat.apache.org/tomcat-9.0-…




    再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



    你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


    甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


    比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



    由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


    这样来看,你的吞吐量确实上去了。


    在前端来看,非常的 nice,请求立马得到了响应。


    但是,你考虑过下游吗?


    你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



    而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


    所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


    或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


    有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


    巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


    什么时候使用线程池呢?


    比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



    用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


    这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



    这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


    但是你想想,我们最开始的这个案例,是这个场景吗?



    我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


    这已经不是一个概念了。


    还有一种场景下,使用线程池也是合理的。


    比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



    如果你的业务代码是这样的:


    //获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
    //select * from order where order_status=0;
    ArrayList initOrderInfoList = queryInitOrderInfoList();
    //循环处理这批数据
    for(OrderInfo orderInfo : initOrderInfoList){
        //捕获异常以免一条数据错误导致循环结束
        try{
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId,orderStatus);  
        } catch (Exception e){
            //打印异常
        }
    }

    虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


    为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


    //循环处理这批数据
    for(OrderInfo orderInfo : initOrderInfoList){
        //使用线程池
        executor.execute(() -> {
            //捕获异常以免一条数据错误导致循环结束
            try {
                //发起rpc调用
                String orderStatus = queryOrderStatus(orderInfo.getOrderId);
                //更新订单状态
                updateOrderInfo(orderInfo.getOrderId, orderStatus);
            } catch (Exception e) {
                //打印异常
            }
        });
    }


    需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


    同时这个线程池的定位,就类似于 web 容器线程池的定位。


    或者这样对比起来看更加清晰一点:



    定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


    而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


    如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


    好了,本文的技术部分就到这里啦。


    下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


    荒腔走板



    不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


    是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


    原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


    按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


    本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


    像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


    高中的时候,时间浪费了是真的可惜。


    现在,不一样了。


    荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


    我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


    很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


    这两年我不会了,允许自己做自己,允许别人做别人。


    作者:why技术
    来源:juejin.cn/post/7297980721590272040
    收起阅读 »

    都说PHP性能差,但PHP性能真的差吗?

    今天本能是想测试一个PDO持久化,会不会带来会话混乱的问题 先贴一下PHP代码, 代码丑了点,但是坚持能run就行,反正就是做个测试。 <?php $dsn = 'mysql:host=localhost;dbname=test;charset=utf8...
    继续阅读 »

    今天本能是想测试一个PDO持久化,会不会带来会话混乱的问题
    先贴一下PHP代码, 代码丑了点,但是坚持能run就行,反正就是做个测试。


    <?php
    $dsn = 'mysql:host=localhost;dbname=test;charset=utf8';
    $user = 'root';
    $password = 'root';

    // 设置 PDO 选项,启用持久化连接
    $options = [
    PDO::ATTR_PERSISTENT => true,
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
    ];

    try {
    // 创建持久化连接
    $pdo = new PDO($dsn, $user, $password, $options);

    $stmt = $pdo->prepare("INSERT INTO test_last_insert_id (uni) VALUES (:uni);");
    $uni = uniqid('', true);
    $stmt->bindValue(':uni', $uni);
    $aff = $stmt->execute(); //
    if ($aff === false) {
    throw new Exception("insert fail:");
    }
    $id = $pdo->lastInsertId();


    function getExecutedSql($stmt, $params)
    {
    $sql = $stmt->queryString;
    $keys = array();
    $values = array();

    // 替换命名占位符 :key with ?
    $sql = preg_replace('/\:(\w+)/', '?', $sql);

    // 绑定的参数可能包括命名占位符,我们需要将它们转换为匿名占位符
    foreach ($params as $key => $value) {
    $keys[] = '/\?/';
    $values[] = is_string($value) ? "'$value'" : $value;
    }

    // 替换占位符为实际参数
    $sql = preg_replace($keys, $values, $sql, 1, $count);

    return $sql;
    }


    $stmt = $pdo->query("SELECT id FROM test_last_insert_id WHERE uni = '{$uni}'", PDO::FETCH_NUM);
    $row = $stmt->fetch();
    $value = $row[0];
    if ($value != $id) {
    throw new Exception("id is diff");
    }

    echo "success" . PHP_EOL;

    } catch (PDOException $e) {
    header('HTTP/1.1 500 Internal Server Error');
    file_put_contents('pdo_perisistent.log', $e->getMessage() . PHP_EOL);
    die('Database connection failed: ' . $e->getMessage());
    } catch (Exception $e) {
    header('HTTP/1.1 500 Internal Server Error');
    file_put_contents('pdo_perisistent.log', $e->getMessage() . PHP_EOL);
    die('Exception: ' . $e->getMessage());
    }

    用wrk压测,一开始uniqid因为少了混淆参数还报了500,加了一下参数,用来保证uni值


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 52.17ms 7.48ms 103.38ms 80.57%
    Req/Sec 0.96k 133.22 1.25k 75.81%
    Latency Distribution
    50% 51.06ms
    75% 54.17ms
    90% 59.45ms
    99% 80.54ms
    5904 requests in 3.10s, 1.20MB read
    Requests/sec: 1901.92
    Transfer/sec: 397.47KB

    1900 ~ 2600 之间的QPS,其实这个数值还是相当满意的,测试会话会不会混乱的问题也算完结了。
    但是好奇心突起,之前一直没做过go和php执行sql下的对比,正好做一次对比压测


    package main

    import (
    "database/sql"
    "fmt"
    "net/http"
    "sync/atomic"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "log"
    )

    var id int64 = time.Now().Unix() * 1000000

    func generateUniqueID() int64 {
    return atomic.AddInt64(&id, 1)
    }

    func main() {
    dsn := "root:root@tcp(localhost:3306)/test?charset=utf8"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
    log.Fatalf("Error opening database: %v", err)
    }
    defer func() { _ = db.Close() }()

    //// 设置连接池参数
    //db.SetMaxOpenConns(100) // 最大打开连接数
    //db.SetMaxIdleConns(10) // 最大空闲连接数
    //db.SetConnMaxLifetime(time.Hour) // 连接最大存活时间

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    var err error
    uni := generateUniqueID()

    // Insert unique ID int0 the database
    insertQuery := `INSERT INTO test_last_insert_id (uni) VALUES (?)`
    result, err := db.Exec(insertQuery, uni)
    if err != nil {
    log.Fatalf("Error inserting data: %v", err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
    log.Fatalf("Error getting last insert ID: %v", err)
    }

    // Verify the last insert ID
    selectQuery := `SELECT id FROM test_last_insert_id WHERE uni = ?`
    var id int64
    err = db.QueryRow(selectQuery, uni).Scan(&id)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }

    if id != lastInsertID {
    log.Fatalf("ID mismatch: %d != %d", id, lastInsertID)
    }

    fmt.Println("success")
    })

    _ = http.ListenAndServe(":8080", nil)

    }

    truncate表压测结果,这低于预期了吧


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 54.05ms 36.86ms 308.57ms 80.77%
    Req/Sec 0.98k 243.01 1.38k 63.33%
    Latency Distribution
    50% 43.70ms
    75% 65.42ms
    90% 99.63ms
    99% 190.18ms
    5873 requests in 3.01s, 430.15KB read
    Requests/sec: 1954.08
    Transfer/sec: 143.12KB

    开个连接池,清表再测,结果半斤八两


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 54.07ms 35.87ms 281.38ms 79.84%
    Req/Sec 0.97k 223.41 1.40k 60.00%
    Latency Distribution
    50% 44.91ms
    75% 66.19ms
    90% 99.65ms
    99% 184.51ms
    5818 requests in 3.01s, 426.12KB read
    Requests/sec: 1934.39
    Transfer/sec: 141.68KB

    然后开启不清表的情况下,php和go的交叉压测


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 52.51ms 43.28ms 436.00ms 86.91%
    Req/Sec 1.08k 284.67 1.65k 65.00%
    Latency Distribution
    50% 40.22ms
    75% 62.10ms
    90% 102.52ms
    99% 233.98ms
    6439 requests in 3.01s, 471.61KB read
    Requests/sec: 2141.12
    Transfer/sec: 156.82KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 41.41ms 10.44ms 77.04ms 78.07%
    Req/Sec 1.21k 300.99 2.41k 73.77%
    Latency Distribution
    50% 38.91ms
    75% 47.62ms
    90% 57.38ms
    99% 69.84ms
    7332 requests in 3.10s, 1.50MB read
    Requests/sec: 2363.74
    Transfer/sec: 493.98KB

    // 这里骤降是我很不理解的不明白是因为什么
    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 156.72ms 75.48ms 443.98ms 66.10%
    Req/Sec 317.93 84.45 480.00 71.67%
    Latency Distribution
    50% 155.21ms
    75% 206.36ms
    90% 254.32ms
    99% 336.07ms
    1902 requests in 3.01s, 139.31KB read
    Requests/sec: 631.86
    Transfer/sec: 46.28KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 43.47ms 10.04ms 111.41ms 90.21%
    Req/Sec 1.15k 210.61 1.47k 72.58%
    Latency Distribution
    50% 41.17ms
    75% 46.89ms
    90% 51.27ms
    99% 95.07ms
    7122 requests in 3.10s, 1.45MB read
    Requests/sec: 2296.19
    Transfer/sec: 479.87KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 269.08ms 112.17ms 685.29ms 73.69%
    Req/Sec 168.22 125.46 520.00 79.59%
    Latency Distribution
    50% 286.58ms
    75% 335.40ms
    90% 372.61ms
    99% 555.80ms
    1099 requests in 3.02s, 80.49KB read
    Requests/sec: 363.74
    Transfer/sec: 26.64KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 41.74ms 9.67ms 105.86ms 91.72%
    Req/Sec 1.20k 260.04 2.24k 80.33%
    Latency Distribution
    50% 38.86ms
    75% 46.77ms
    90% 49.02ms
    99% 83.01ms
    7283 requests in 3.10s, 1.49MB read
    Requests/sec: 2348.07
    Transfer/sec: 490.71KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 464.85ms 164.66ms 1.06s 71.97%
    Req/Sec 104.18 60.01 237.00 63.16%
    Latency Distribution
    50% 467.00ms
    75% 560.54ms
    90% 660.70ms
    99% 889.86ms
    605 requests in 3.01s, 44.31KB read
    Requests/sec: 200.73
    Transfer/sec: 14.70KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 50.62ms 9.16ms 85.08ms 75.74%
    Req/Sec 0.98k 170.66 1.30k 69.35%
    Latency Distribution
    50% 47.93ms
    75% 57.20ms
    90% 61.76ms
    99% 79.90ms
    6075 requests in 3.10s, 1.24MB read
    Requests/sec: 1957.70
    Transfer/sec: 409.13KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 568.84ms 160.91ms 1.04s 66.38%
    Req/Sec 81.89 57.59 262.00 67.27%
    Latency Distribution
    50% 578.70ms
    75% 685.85ms
    90% 766.72ms
    99% 889.39ms
    458 requests in 3.01s, 33.54KB read
    Requests/sec: 151.91
    Transfer/sec: 11.13KB

    go 的代码随着不断的测试,很明显处理速度在不断的下降,这说实话有点超出我的认知了。
    PHP那边却是基本稳定的,go其实一开始我还用gin测试过,发现测试结果有点超出预料,还改了用http库来测试,这结果属实差强人意了。


    突然明白之前经常看到别人在争论性能问题的时候,为什么总有人强调PHP性能并不差。
    或许PHP因为fpm的关系导致每次加载大量文件导致的响应相对较慢,比如框架laravel 那个QPS只有一两百的家伙,但其实这个问题要解决也是可以解决的,也用常驻内存的方式就好了。再不行还有phalcon


    我一直很好奇一直说PHP性能问题的到底是哪些人, 不会是从PHP转到其他语言的吧。


    % php -v
    PHP 8.3.12 (cli) (built: Sep 24 2024 18:08:04) (NTS)
    Copyright (c) The PHP Gr0up
    Zend Engine v4.3.12, Copyright (c) Zend Technologies
    with Xdebug v3.3.2, Copyright (c) 2002-2024, by Derick Rethans
    with Zend OPcache v8.3.12, Copyright (c), by Zend Technologies

    % go version
    go version go1.23.1 darwin/amd64

    image.png


    这结果,其实不太能接受,甚至都不知道原因出在哪了,有大佬可以指出问题一下吗


    加一下时间打印再看看哪里的问题


    package main

    import (
    "database/sql"
    "fmt"
    "net/http"
    "sync/atomic"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "log"
    )

    var id int64 = time.Now().Unix() * 1000000

    func generateUniqueID() int64 {
    return atomic.AddInt64(&id, 1)
    }

    func main() {
    dsn := "root:root@tcp(localhost:3306)/test?charset=utf8"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
    log.Fatalf("Error opening database: %v", err)
    }
    defer func() { _ = db.Close() }()

    // 设置连接池参数
    db.SetMaxOpenConns(100) // 最大打开连接数
    db.SetMaxIdleConns(10) // 最大空闲连接数
    db.SetConnMaxLifetime(time.Hour) // 连接最大存活时间

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    reqStart := time.Now()
    var err error
    uni := generateUniqueID()

    start := time.Now()
    // Insert unique ID int0 the database
    insertQuery := `INSERT INTO test_last_insert_id (uni) VALUES (?)`
    result, err := db.Exec(insertQuery, uni)
    fmt.Printf("insert since: %v uni:%d \n", time.Since(start), uni)
    if err != nil {
    log.Fatalf("Error inserting data: %v", err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
    log.Fatalf("Error getting last insert ID: %v", err)
    }

    selectStart := time.Now()
    // Verify the last insert ID
    selectQuery := `SELECT id FROM test_last_insert_id WHERE uni = ?`
    var id int64
    err = db.QueryRow(selectQuery, uni).Scan(&id)
    fmt.Printf("select since:%v uni:%d \n", time.Since(selectStart), uni)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }

    if id != lastInsertID {
    log.Fatalf("ID mismatch: %d != %d", id, lastInsertID)
    }

    fmt.Printf("success req since:%v uni:%d \n", time.Since(reqStart), uni)
    })

    _ = http.ListenAndServe(":8080", nil)

    }

    截取了后面的一部分输出,这不会是SQL库的问题吧,


    success req since:352.310146ms uni:1729393975000652 
    insert since: 163.316785ms uni:1729393975000688
    insert since: 154.983173ms uni:1729393975000691
    insert since: 158.094503ms uni:1729393975000689
    insert since: 136.831695ms uni:1729393975000697
    insert since: 141.857079ms uni:1729393975000696
    insert since: 128.115216ms uni:1729393975000702
    select since:412.94524ms uni:1729393975000634
    success req since:431.383768ms uni:1729393975000634
    select since:459.596445ms uni:1729393975000601
    success req since:568.576336ms uni:1729393975000601
    insert since: 134.39147ms uni:1729393975000700
    select since:390.926517ms uni:1729393975000643
    success req since:391.622183ms uni:1729393975000643
    select since:366.098937ms uni:1729393975000648
    success req since:373.490764ms uni:1729393975000648
    insert since: 136.318919ms uni:1729393975000699
    select since:420.626209ms uni:1729393975000640
    success req since:425.243441ms uni:1729393975000640
    insert since: 167.181068ms uni:1729393975000690
    select since:272.22808ms uni:1729393975000671

    单次请求的时候输出结果是符合预期的, 但是并发SQL时会出现执行慢的问题,这就很奇怪了


    % curl localhost:8080
    insert since: 1.559709ms uni:1729393975000703
    select since:21.031284ms uni:1729393975000703
    success req since:22.62274ms uni:1729393975000703

    经群友提示还和唯一键的区分度有关,两边算法一致有点太难了,Go换了雪法ID之后就正常了。
    因为之前 Go这边生成的uni值是递增的导致区分度很低,最终导致并发写入查询效率变低。


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 44.51ms 24.87ms 187.91ms 77.98%
    Req/Sec 1.17k 416.31 1.99k 66.67%
    Latency Distribution
    50% 37.46ms
    75% 54.55ms
    90% 80.44ms
    99% 125.72ms
    6960 requests in 3.01s, 509.77KB read
    Requests/sec: 2316.02
    Transfer/sec: 169.63KB

    2024-10-23 更新


    今天本来是想验证一下有关,并发插入自增有序的唯一键高延迟的问题,发现整个有问题的只有一行代码。
    就是在查询时,类型转换的问题,插入和查询都转换之后,空表的情况下QPS 可以到4000多。即使在已有大数据量(几十万)的情况也有两千多的QPS。
    现在又多了一个问题,为什么用雪花ID时不会有这样的问题。雪花ID也是int64类型的,这是为什么呢。


    // 旧代码
    err = db.QueryRow(selectQuery, uni).Scan(&id)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }


    // 新代码 变化只有一个就是把uni 转成字符串之后就没有问题了
    var realId int64
    err = db.QueryRow(selectQuery, fmt.Sprintf("%d", uni)).Scan(&realId)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }

    作者:用户04116068870
    来源:juejin.cn/post/7427455855941976076
    收起阅读 »

    15 种超赞的 MyBatis 写法

    序言 MyBatis的前身是iBatis,最初是Apache的一个开源项目。随着时间的推移,为了更好地适应Java持久层框架的需求,iBatis在2010年重构并更名为MyBatis。 这一转变标志着MyBatis在功能和性能上的显著提升,同时也意味着它能够更...
    继续阅读 »

    序言


    MyBatis的前身是iBatis,最初是Apache的一个开源项目。随着时间的推移,为了更好地适应Java持久层框架的需求,iBatis在2010年重构并更名为MyBatis。


    这一转变标志着MyBatis在功能和性能上的显著提升,同时也意味着它能够更好地服务于日益复杂的企业级应用。


    今天,我们就来探讨 15 种超赞的 MyBatis 写法,让你的数据库操作更加高效和灵活。


    1. 批量操作优化


    批量操作是提高数据库操作效率的重要手段。MyBatis提供了<foreach>标签,可以有效地进行批量插入、更新或删除操作,从而减少与数据库的交互次数。


    批量插入示例:


    <insert id="batchInsert" parameterType="java.util.List">
        INSERT INTO user (username, email, create_time) VALUES
        <foreach collection="list" item="item" separator=",">
            (#{item.username}, #{item.email}, #{item.createTime})
        </foreach>
    </insert>

    批量更新示例:


    <update id="batchUpdate" parameterType="java.util.List">
        <foreach collection="list" item="item" separator=";">
            UPDATE user
            SET username = #{item.username}, email = #{item.email}
            WHERE id = #{item.id}
        </foreach>
    </update>

    批量删除示例:


    <delete id="batchDelete" parameterType="java.util.List">
        DELETE FROM user WHERE id IN
        <foreach collection="list" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>

    通过使用<foreach>标签,我们可以将多个操作合并为一条SQL语句,大大减少了数据库交互次数,提高了操作效率。


    2. 动态SQL


    动态SQL是MyBatis的强大特性之一,允许我们根据不同的条件动态构建SQL语句。<if>标签是实现动态SQL的核心。


    动态查询示例:


    <select id="findUsers" resultType="User">
        SELECT * FROM user
        WHERE 1=1
        <if test="username != null and username != ''">
            AND username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="email != null and email != ''">
            AND email = #{email}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </select>

    在这个例子中,我们根据传入的参数动态添加查询条件。如果某个参数为空,对应的条件就不会被添加到SQL语句中。


    3. 多条件分支查询


    对于更复杂的查询逻辑,我们可以使用<choose><when><otherwise>标签来实现多条件分支查询。


    多条件分支查询示例:


    <select id="findUsersByCondition" resultType="User">
        SELECT * FROM user
        WHERE 1=1
        <choose>
            <when test="searchType == 'username'">
                AND username LIKE CONCAT('%', #{keyword}, '%')
            </when>
            <when test="searchType == 'email'">
                AND email LIKE CONCAT('%', #{keyword}, '%')
            </when>
            <otherwise>
                AND (username LIKE CONCAT('%', #{keyword}, '%'OR email LIKE CONCAT('%', #{keyword}, '%'))
            </otherwise>
        </choose>
    </select>

    这个例子展示了如何根据不同的搜索类型选择不同的查询条件,如果没有指定搜索类型,则默认搜索用户名和邮箱。


    4. SQL语句优化


    使用<trim>标签可以帮助我们优化生成的SQL语句,避免出现多余的AND或OR关键字。


    SQL语句优化示例:


    <select id="findUsers" resultType="User">
        SELECT * FROM user
        <trim prefix="WHERE" prefixOverrides="AND |OR ">
            <if test="username != null and username != ''">
                AND username LIKE CONCAT('%'#{username}, '%')
            </if>
            <if test="email != null and email != ''">
                AND email = #{email}
            </if>
            <if test="status != null">
                AND status = #{status}
            </if>
        </trim>
    </select>

    在这个例子中,<trim>标签会自动去除第一个多余的AND或OR,并在有查询条件时添加WHERE关键字。


    5. 自动生成主键


    在插入操作中,我们经常需要获取数据库自动生成的主键。MyBatis提供了<selectKey>标签来实现这一功能。


    自动生成主键示例:


    <insert id="insertUser" parameterType="User" useGeneratedKeys="true" keyProperty="id">
        <selectKey keyProperty="id" order="AFTER" resultType="java.lang.Long">
            SELECT 2531020
        </selectKey>
        INSERT INTO user (username, email, create_time)
        VALUES (#{username}, #{email}, #{createTime})
    </insert>

    在这个例子中,插入操作完成后,会自动执行SELECT 2531020获取新插入记录的ID,并将其赋值给传入的User对象的id属性。


    6. 注解方式使用MyBatis


    除了XML配置,MyBatis还支持使用注解来定义SQL操作,这种方式可以使代码更加简洁。


    注解方式示例:


    public interface UserMapper {
        @Select("SELECT * FROM user WHERE id = #{id}")
        User getUserById(Long id);

        @Insert("INSERT INTO user (username, email, create_time) VALUES (#{username}, #{email}, #{createTime})")
        @Options(useGeneratedKeys = true, keyProperty = "id")
        int insertUser(User user);

        @Update("UPDATE user SET username = #{username}, email = #{email} WHERE id = #{id}")
        int updateUser(User user);

        @Delete("DELETE FROM user WHERE id = #{id}")
        int deleteUser(Long id);
    }

    这种方式适合简单的CRUD操作,但对于复杂的SQL语句,仍然建议使用XML配置。


    7. 高级映射


    MyBatis提供了强大的对象关系映射功能,可以处理复杂的表关系。


    一对多映射示例:


    <resultMap id="userWithOrdersMap" type="User">
        <id property="id" column="user_id"/>
        <result property="username" column="username"/>
        <collection property="orders" ofType="Order">
            <id property="id" column="order_id"/>
            <result property="orderNumber" column="order_number"/>
            <result property="createTime" column="order_create_time"/>
        </collection>
    </resultMap>

    <select id="getUserWithOrders" resultMap="userWithOrdersMap">
        SELECT u.id as user_id, u.username, o.id as order_id, o.order_number, o.create_time as order_create_time
        FROM user u
        LEFT JOIN orders o ON u.id = o.user_id
        WHERE u.id = #{userId}
    </select>

    这个例子展示了如何将用户和订单信息映射到一个复杂的对象结构中。


    8. MyBatis-Plus集成


    MyBatis-Plus是MyBatis的增强工具,它提供了许多便捷的CRUD操作和强大的条件构造器。


    MyBatis-Plus使用示例:


    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapperUserimplements UserService {
        public List<UserfindUsersByCondition(String username, String email) {
            return this.list(new QueryWrapper<User>()
                    .like(StringUtils.isNotBlank(username), "username", username)
                    .eq(StringUtils.isNotBlank(email), "email", email));
        }
    }

    在这个例子中,我们使用MyBatis-Plus提供的条件构造器来动态构建查询条件,无需编写XML。


    9. 代码生成器


    MyBatis Generator是一个强大的代码生成工具,可以根据数据库表自动生成MyBatis的Mapper接口、实体类和XML映射文件。


    MyBatis Generator配置示例:


    <!DOCTYPE generatorConfiguration PUBLIC
            "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
            "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

    <generatorConfiguration>
        <context id="DB2Tables" targetRuntime="MyBatis3">
            <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver"
                            connectionURL="jdbc:mysql://localhost:3306/mydb"
                            userId="root"
                            password="password">

            </jdbcConnection>

            <javaModelGenerator targetPackage="com.example.model" targetProject="src/main/java">
                <property name="enableSubPackages" value="true" />
                <property name="trimStrings" value="true" />
            </javaModelGenerator>

            <sqlMapGenerator targetPackage="mapper"  targetProject="src/main/resources">
                <property name="enableSubPackages" value="true" />
            </sqlMapGenerator>

            <javaClientGenerator type="XMLMAPPER" targetPackage="com.example.mapper"  targetProject="src/main/java">
                <property name="enableSubPackages" value="true" />
            </javaClientGenerator>

            <table tableName="user" domainObjectName="User" >
                <generatedKey column="id" sqlStatement="MySQL" identity="true" />
            </table>
        </context>
    </generatorConfiguration>

    使用这个配置文件,我们可以自动生成与user表相关的所有必要代码。


    10. 事务管理


    在Spring环境中,我们可以使用@Transactional注解来管理事务,确保数据的一致性。


    事务管理示例:


    @Service
    public class UserService {
        @Autowired
        private UserMapper userMapper;

        @Transactional
        public void createUserWithOrders(User user, List<Order> orders) {
            userMapper.insert(user);
            for (Order order : orders) {
                order.setUserId(user.getId());
                orderMapper.insert(order);
            }
        }
    }

    在这个例子中,创建用户和订单的操作被包装在一个事务中,如果任何一步失败,整个操作都会回滚。


    11. 缓存机制


    MyBatis提供了一级缓存和二级缓存,可以有效提高查询性能。


    二级缓存配置示例:


    <cache
      eviction="LRU"
      flushInterval="60000"
      size="512"
      readOnly="true"/>

    这个配置启用了LRU淘汰策略的二级缓存,缓存容量为512个对象,每60秒刷新一次。


    12. 插件使用


    MyBatis插件可以拦截核心方法的调用,实现如分页、性能分析等功能。


    分页插件示例 (使用PageHelper):


    @Service
    public class UserService {
        @Autowired
        private UserMapper userMapper;

        public PageInfo<User> getUserList(int pageNum, int pageSize) {
            PageHelper.startPage(pageNum, pageSize);
            List<User> users = userMapper.selectAll();
            return new PageInfo<>(users);
        }
    }

    这个例子展示了如何使用PageHelper插件实现简单的分页功能。


    13. 多数据源配置


    在某些场景下,我们需要在同一个应用中操作多个数据库。MyBatis支持配置多个数据源来实现这一需求。


    多数据源配置示例:


    @Configuration
    public class DataSourceConfig {
        @Bean
        @ConfigurationProperties("spring.datasource.primary")
        public DataSource primaryDataSource() {
            return DataSourceBuilder.create().build();
        }

        @Bean
        @ConfigurationProperties("spring.datasource.secondary")
        public DataSource secondaryDataSource() {
            return DataSourceBuilder.create().build();
        }

        @Bean
        public SqlSessionFactory primarySqlSessionFactory(@Qualifier("primaryDataSource") DataSource dataSource) throws Exception {
            SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
            factoryBean.setDataSource(dataSource);
            return factoryBean.getObject();
        }

        @Bean
        public SqlSessionFactory secondarySqlSessionFactory(@Qualifier("secondaryDataSource") DataSource dataSource) throws Exception {
            SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
            factoryBean.setDataSource(dataSource);
            return factoryBean.getObject();
        }
    }

    这个配置类定义了两个数据源和对应的SqlSessionFactory,可以在不同的Mapper中使用不同的数据源。


    14. 读写分离


    读写分离是提高数据库性能的常用策略。MyBatis可以通过配置多数据源来实现简单的读写分离。


    读写分离配置示例:


    @Configuration
    public class DataSourceConfig {
        @Bean
        @ConfigurationProperties("spring.datasource.master")
        public DataSource masterDataSource() {
            return DataSourceBuilder.create().build();
        }

        @Bean
        @ConfigurationProperties("spring.datasource.slave")
        public DataSource slaveDataSource() {
            return DataSourceBuilder.create().build();
        }

        @Bean
        public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
                                            @Qualifier("slaveDataSource") DataSource slaveDataSource) {
            Map<ObjectObjecttargetDataSources = new HashMap<>();
            targetDataSources.put(DataSourceType.MASTER, masterDataSource);
            targetDataSources.put(DataSourceType.SLAVE, slaveDataSource);

            AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
                @Override
                protected Object determineCurrentLookupKey() {
                    return DataSourceContextHolder.getDataSourceType();
                }
            };
            routingDataSource.setTargetDataSources(targetDataSources);
            routingDataSource.setDefaultTargetDataSource(masterDataSource);

            return routingDataSource;
        }
    }

    这个例子定义了一个动态数据源,可以根据上下文选择主库或从库。你需要实现一个DataSourceContextHolder来管理当前线程的数据源类型。


    15. SQL分析和优化


    MyBatis提供了SQL执行分析功能,可以帮助我们找出性能瓶颈。


    SQL分析配置示例:


    <plugins>
        <plugin interceptor="com.github.pagehelper.PageInterceptor">
            <!-- 设置为true时,会将RowBounds第一个参数offset当成pageNum页码使用 -->
            <property name="offsetAsPageNum" value="true"/>
            <!-- 设置为true时,使用RowBounds分页会进行count查询 -->
            <property name="rowBoundsWithCount" value="true"/>
        </plugin>
        <plugin interceptor="org.apache.ibatis.plugin.Interceptor">
            <property name="properties">
                sqlCollector=com.example.SqlCollector
            </property>
        </plugin>
    </plugins>

    在这个配置中,我们不仅加入了分页插件,还加入了一个自定义的SQL收集器,可以用于分析SQL执行情况。


    总结


    我们详细介绍了15种MyBatis的高级用法和技巧,涵盖了从基本的CRUD操作优化到复杂的多数据源配置和读写分离等高级主题。这些技巧可以帮助开发者更高效地使用MyBatis,构建出性能更好、可维护性更强的应用系统。


    作者:用户9768001977576
    来源:juejin.cn/post/7417681630884233268
    收起阅读 »

    MapStruct这么用,同事也开始模仿

    前言 hi,大家好,我是大鱼七成饱。 前几天同事review我的代码,发现mapstruct有这么多好用的技巧,遇到POJO转换的问题经常过来沟通。考虑到不可能每次都一对一,所以我来梳理五个场景,谁在过来问,直接甩出总结。 环境准备 由于日常使用都是spri...
    继续阅读 »

    前言


    hi,大家好,我是大鱼七成饱。


    前几天同事review我的代码,发现mapstruct有这么多好用的技巧,遇到POJO转换的问题经常过来沟通。考虑到不可能每次都一对一,所以我来梳理五个场景,谁在过来问,直接甩出总结。


    1641341087201917.jpeg


    环境准备


    由于日常使用都是spring,所以后面的示例都是在springboot框架中运行的。关键pom依赖如下:


    <properties>
    <java.version>1.8</java.version>
    <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
    <org.projectlombok.version>1.18.30</org.projectlombok.version>
    </properties>
    <dependencies>

    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>${org.mapstruct.version}</version>
    </dependency>

    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
    <scope>provided</scope>
    </dependency>

    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-mapstruct-binding</artifactId>
    <version>0.2.0</version>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    </dependencies>

    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

    场景一:常量转换


    这是最简单的一个场景,比如需要设置字符串、整形和长整型的常量,有的又需要日期,或者新建类型。下面举个例子,演示如何转换


    //实体类
    @Data
    public class Source {
    private String stringProp;
    private Long longProp;
    }
    @Data
    public class Target {
    private String stringProperty;
    private long longProperty;
    private String stringConstant;
    private Integer integerConstant;
    private Long longWrapperConstant;
    private Date dateConstant;
    }


    • 设置字符串常量

    • 设置long常量

    • 设置java内置类型默认值,比如date


    那么mapper这么设置就可以


    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface SourceTargetMapper {

    @Mapping(target = "stringProperty", source = "stringProp", defaultValue = "undefined")
    @Mapping(target = "longProperty", source = "longProp", defaultValue = "-1l")
    @Mapping(target = "stringConstant", constant = "Constant Value")
    @Mapping(target = "integerConstant", constant = "14")
    @Mapping(target = "longWrapperConstant", constant = "3001L")
    @Mapping(target = "dateConstant", dateFormat = "yyyy-MM-dd", constant = "2023-09-")
    Target sourceToTarget(Source s);
    }

    解释下,constant用来设置常量值,source的值如果没有设置,则会使用defaultValue的值,日期可以按dateFormat解析。


    Talk is cheap, show me the code !废话不多说,自动生成的转换类如下:


    @Component
    public class SourceTargetMapperImpl implements SourceTargetMapper {
    public SourceTargetMapperImpl() {
    }

    public Target sourceToTarget(Source s) {
    if (s == null) {
    return null;
    } else {
    Target target = new Target();
    if (s.getStringProp() != null) {
    target.setStringProperty(s.getStringProp());
    } else {
    target.setStringProperty("undefined");
    }

    if (s.getLongProp() != null) {
    target.setLongProperty(s.getLongProp());
    } else {
    target.setLongProperty(-1L);
    }

    target.setStringConstant("Constant Value");
    target.setIntegerConstant(14);
    target.setLongWrapperConstant(3001L);

    try {
    target.setDateConstant((new SimpleDateFormat("dd-MM-yyyy")).parse("09-01-2014"));
    return target;
    } catch (ParseException var4) {
    throw new RuntimeException(var4);
    }
    }
    }
    }

    是不是一目了然


    image-20231105105234857.png


    场景二:转换中调用表达式


    比如id不存在使用UUID生成一个,或者使用已有参数新建一个对象作为属性。当然可以用after mapping,qualifiedByName等实现,感觉还是不够优雅,这里介绍个雅的(代码少点的)。


    实体类如下:


    @Data
    public class CustomerDto {
    public Long id;
    public String customerName;

    private String format;
    private Date time;
    }
    @Data
    public class Customer {
    private String id;
    private String name;
    private TimeAndFormat timeAndFormat;
    }
    @Data
    public class TimeAndFormat {
    private Date time;
    private String format;

    public TimeAndFormat(Date time, String format) {
    this.time = time;
    this.format = format;
    }
    }

    Dto转customer,加创建TimeAndFormat作为属性,mapper实现如下:


    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = UUID.class)
    public interface CustomerMapper {

    @Mapping(target = "timeAndFormat",
    expression = "java( new TimeAndFormat( s.getTime(), s.getFormat() ) )")

    @Mapping(target = "id", source = "id", defaultExpression = "java( UUID.randomUUID().toString() )")
    Customer toCustomer(CustomerDto s);

    }

    解释下,id为空则走默认的defaultExpression,通过imports引入,java括起来调用。新建对象直接new TimeAndFormat。有的小伙伴喜欢用qualifiedByName自定义方法,可以对比下,哪个合适用哪个,都能调用转换方法。


    生成代码如下:


    @Component
    public class CustomerMapperImpl implements CustomerMapper {
    public CustomerMapperImpl() {
    }

    public Customer toCustomer(CustomerDto s) {
    if (s == null) {
    return null;
    } else {
    Customer customer = new Customer();
    if (s.getId() != null) {
    customer.setId(String.valueOf(s.getId()));
    } else {
    customer.setId(UUID.randomUUID().toString());
    }

    customer.setTimeAndFormat(new TimeAndFormat(s.getTime(), s.getFormat()));
    return customer;
    }
    }
    }

    场景三:类共用属性,如何复用


    比如下面的Bike和车辆类,都有id和creationDate属性,我又不想重复写mapper属性注解


    public class Bike {
    /**
    * 唯一id
    */

    private String id;

    private Date creationDate;

    /**
    * 品牌
    */

    private String brandName;
    }

    public class Car {
    /**
    * 唯一id
    */

    private String id;

    private Date creationDate;
    /**
    * 车牌号
    */

    private String chepaihao;
    }

    解决起来很简单,写个共用的注解,使用的时候引入就可以,示例如下:


    //通用注解
    @Retention(RetentionPolicy.CLASS)
    //自动生成当前日期
    @Mapping(target = "creationDate", expression = "java(new java.util.Date())")
    //忽略id
    @Mapping(target = "id", ignore = true)
    public @interface ToEntity { }

    //使用
    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface TransportationMapper {
    @ToEntity
    @Mapping( target = "brandName", source = "brand")
    Bike map(BikeDto source);

    @ToEntity
    @Mapping( target = "chepaihao", source = "plateNo")
    Car map(CarDto source);
    }

    这里Retention修饰ToEntity注解,表示ToEntity注解被保留到class文件,但jvm加载class文件时候被遗弃,这是默认的生命周期,辅助生成mapper实现类。上面定义了creationDate和id的转换规则,新建日期,忽略id。


    生成的mapper实现类如下:


    @Component
    public class TransportationMapperImpl implements TransportationMapper {
    public TransportationMapperImpl() {
    }

    public Bike map(BikeDto source) {
    if (source == null) {
    return null;
    } else {
    Bike bike = new Bike();
    bike.setBrandName(source.getBrand());
    bike.setCreationDate(new Date());
    return bike;
    }
    }

    public Car map(CarDto source) {
    if (source == null) {
    return null;
    } else {
    Car car = new Car();
    car.setChepaihao(source.getPlateNo());
    car.setCreationDate(new Date());
    return car;
    }
    }
    }

    坚持一下,还剩俩场景,剩下的俩更有意思


    image-20231105111309795.png


    场景四:lombok和mapstruct冲突了


    啥冲突?用了builder注解后,mapstuct转换不出来了。哎,这个问题困扰了我那同事两天时间。


    解决方案如下:


     <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-mapstruct-binding</artifactId>
    <version>0.2.0</version>
    </dependency>

    加上lombok-mapstruct-binding就可以了,看下生成的效果:


    @Builder
    @Data
    public class Person {
    private String name;
    }
    @Data
    public class PersonDto {
    private String name;
    }
    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface PersonMapper {

    Person map(PersonDto dto);
    }
    @Component
    public class PersonMapperImpl implements PersonMapper {
    public PersonMapperImpl() {
    }

    public Person map(PersonDto dto) {
    if (dto == null) {
    return null;
    } else {
    Person.PersonBuilder person = Person.builder();
    person.name(dto.getName());
    return person.build();
    }
    }
    }

    从上面可以看到,mapstruct匹配到了lombok的builder方法。


    场景五:说个难点的,转换的时候,如何注入springBean


    image-20231105112031297.png
    有时候转换方法比不是静态的,他可能依赖spring bean,这个如何导入?


    这个使用需要使用抽象方法了,上代码:


    @Component
    public class SimpleService {
    public String formatName(String name) {
    return "您的名字是:" + name;
    }
    }
    @Data
    public class Student {
    private String name;
    }
    @Data
    public class StudentDto {
    private String name;
    }
    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public abstract class StudentMapper {

    @Autowired
    protected SimpleService simpleService;

    @Mapping(target = "name", expression = "java(simpleService.formatName(source.getName()))")
    public abstract StudentDto map(StudentDto source);
    }

    接口是不支持注入的,但是抽象类可以,所以采用抽象类解决,后面expression直接用皆可以了,生成mapperimpl如下:


    @Component
    public class StudentMapperImpl extends StudentMapper {
    public StudentMapperImpl() {
    }

    public StudentDto map(StudentDto source) {
    if (source == null) {
    return null;
    } else {
    StudentDto studentDto = new StudentDto();
    studentDto.setName(this.simpleService.formatName(source.getName()));
    return studentDto;
    }
    }
    }

    思考


    以上场景肯定还有其他解决方案,遵循合适的原则就可以。驾驭不了的代码,可能带来更多问题,先简单实现,后续在迭代优化可能适合更多的业务场景。


    本文示例代码放在了github,需要的朋友请关注公众号大鱼七成饱,回复关键词MapStruct使用即可获得。


    image-20231105112515470.png


    作者:大鱼七成饱
    来源:juejin.cn/post/7297222349731627046
    收起阅读 »

    Spring 实现 3 种异步流式接口,干掉接口超时烦恼

    大家好,我是小富~ 如何处理比较耗时的接口? 这题我熟,直接上异步接口,使用 Callable、WebAsyncTask 和 DeferredResult、CompletableFuture等均可实现。 但这些方法有局限性,处理结果仅返回单个值。在某些场景下,...
    继续阅读 »

    大家好,我是小富~


    如何处理比较耗时的接口?


    这题我熟,直接上异步接口,使用 CallableWebAsyncTaskDeferredResultCompletableFuture等均可实现。


    但这些方法有局限性,处理结果仅返回单个值。在某些场景下,如果需要接口异步处理的同时,还持续不断地向客户端响应处理结果,这些方法就不够看了。


    Spring 框架提供了多种工具支持异步流式接口,如 ResponseBodyEmitterSseEmitterStreamingResponseBody。这些工具的用法简单,接口中直接返回相应的对象或泛型响应实体 ResponseEntity<xxxx>,如此这些接口就是异步的,且执行耗时操作亦不会阻塞 Servlet 的请求线程,不影响系统的响应能力。


    下面将逐一介绍每个工具的使用及其应用场景。


    ResponseBodyEmitter


    ResponseBodyEmitter适应适合于需要动态生成内容并逐步发送给客户端的场景,例如:文件上传进度、实时日志等,可以在任务执行过程中逐步向客户端发送更新。


    举个例子,经常用GPT你会发现当你提问后,得到的答案并不是一次性响应呈现的,而是逐步动态显示。这样做的好处是,让你感觉它在认真思考,交互体验比直接返回完整答案更为生动和自然。



    使用ResponseBodyEmitter来实现下这个效果,创建 ResponseBodyEmitter 发送器对象,模拟耗时操作逐步调用 send 方法发送消息。



    注意:ResponseBodyEmitter 的超时时间,如果设置为 0-1,则表示连接不会超时;如果不设置,到达默认的超时时间后连接会自动断开。其他两种工具也是同样的用法,后边不在赘述了



    @GetMapping("/bodyEmitter")
    public ResponseBodyEmitter handle() {
    // 创建一个ResponseBodyEmitter,-1代表不超时
    ResponseBodyEmitter emitter = new ResponseBodyEmitter(-1L);
    // 异步执行耗时操作
    CompletableFuture.runAsync(() -> {
    try {
    // 模拟耗时操作
    for (int i = 0; i < 10000; i++) {
    System.out.println("bodyEmitter " + i);
    // 发送数据
    emitter.send("bodyEmitter " + i + " @ " + new Date() + "\n");
    Thread.sleep(2000);
    }
    // 完成
    emitter.complete();
    } catch (Exception e) {
    // 发生异常时结束接口
    emitter.completeWithError(e);
    }
    });
    return emitter;
    }

    实现代码非常简单。通过模拟每2秒响应一次结果,请求接口时可以看到页面数据在动态生成。效果与 GPT 回答基本一致。



    SseEmitter


    SseEmitterResponseBodyEmitter 的一个子类,它同样能够实现动态内容生成,不过主要将它用在服务器向客户端推送实时数据,如实时消息推送、状态更新等场景。在我之前的一篇文章 我有 7种 实现web实时消息推送的方案 中详细介绍了 Server-Sent Events (SSE) 技术,感兴趣的可以回顾下。



    SSE在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。



    整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。


    客户端JS实现,通过一次 HTTP 请求建立连接后,等待接收消息。此时,服务端为每个连接创建一个 SseEmitter 对象,通过这个通道向客户端发送消息。


    <body>
    <div id="content" style="text-align: center;">
    <h1>SSE 接收服务端事件消息数据</h1>
    <div id="message">等待连接...</div>
    </div>
    <script>
    let source = null;
    let userId = 7777

    function setMessageInnerHTML(message) {
    const messageDiv = document.getElementById("message");
    const newParagraph = document.createElement("p");
    newParagraph.textContent = message;
    messageDiv.appendChild(newParagraph);
    }

    if (window.EventSource) {
    // 建立连接
    source = new EventSource('http://127.0.0.1:9033/subSseEmitter/'+userId);
    setMessageInnerHTML("连接用户=" + userId);
    /**
    * 连接一旦建立,就会触发open事件
    * 另一种写法:source.onopen = function (event) {}
    */

    source.addEventListener('open', function (e) {
    setMessageInnerHTML("建立连接。。。");
    }, false);
    /**
    * 客户端收到服务器发来的数据
    * 另一种写法:source.onmessage = function (event) {}
    */

    source.addEventListener('message', function (e) {
    setMessageInnerHTML(e.data);
    });
    } else {
    setMessageInnerHTML("你的浏览器不支持SSE");
    }
    </script>
    </body>

    在服务端,我们将 SseEmitter 发送器对象进行持久化,以便在消息产生时直接取出对应的 SseEmitter 发送器,并调用 send 方法进行推送。


    private static final Map<String, SseEmitter> EMITTER_MAP = new ConcurrentHashMap<>();

    @GetMapping("/subSseEmitter/{userId}")
    public SseEmitter sseEmitter(@PathVariable String userId) {
    log.info("sseEmitter: {}", userId);
    SseEmitter emitterTmp = new SseEmitter(-1L);
    EMITTER_MAP.put(userId, emitterTmp);
    CompletableFuture.runAsync(() -> {
    try {
    SseEmitter.SseEventBuilder event = SseEmitter.event()
    .data("sseEmitter" + userId + " @ " + LocalTime.now())
    .id(String.valueOf(userId))
    .name("sseEmitter");
    emitterTmp.send(event);
    } catch (Exception ex) {
    emitterTmp.completeWithError(ex);
    }
    });
    return emitterTmp;
    }

    @GetMapping("/sendSseMsg/{userId}")
    public void sseEmitter(@PathVariable String userId, String msg) throws IOException {
    SseEmitter sseEmitter = EMITTER_MAP.get(userId);
    if (sseEmitter == null) {
    return;
    }
    sseEmitter.send(msg);
    }

    接下来向 userId=7777 的用户发送消息,127.0.0.1:9033/sendSseMsg/7777?msg=欢迎关注-->程序员小富,该消息可以在页面上实时展示。



    而且SSE有一点比较好,客户端与服务端一旦建立连接,即便服务端发生重启,也可以做到自动重连



    StreamingResponseBody


    StreamingResponseBody 与其他响应处理方式略有不同,主要用于处理大数据量或持续数据流的传输,支持将数据直接写入OutputStream


    例如,当我们需要下载一个超大文件时,使用 StreamingResponseBody 可以避免将文件数据一次性加载到内存中,而是持续不断的把文件流发送给客户端,从而解决下载大文件时常见的内存溢出问题。


    接口实现直接返回 StreamingResponseBody 对象,将数据写入输出流并刷新,调用一次flush就会向客户端写入一次数据。


    @GetMapping("/streamingResponse")
    public ResponseEntity<StreamingResponseBody> handleRbe() {

    StreamingResponseBody stream = out -> {
    String message = "streamingResponse";
    for (int i = 0; i < 1000; i++) {
    try {
    out.write(((message + i) + "\r\n").getBytes());
    out.write("\r\n".getBytes());
    //调用一次flush就会像前端写入一次数据
    out.flush();
    TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    };
    return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(stream);
    }

    demo这里输出的是简单的文本流,如果是下载文件那么转换成文件流效果是一样的。



    总结


    这篇介绍三种实现异步流式接口的工具,算是 Spring 知识点的扫盲。使用起来比较简单,没有什么难点,但它们在实际业务中的应用场景还是很多的,通过这些工具,可以有效提高系统的性能和响应能力。



    文中 Demo Github 地址:github.com/chengxy-nds…



    作者:程序员小富
    来源:juejin.cn/post/7425399689825140786
    收起阅读 »

    为什么推荐用Redisson实现分布式锁,看完直呼好好好

    开心一刻 一男人站在楼顶准备跳楼,楼下有个劝解员拿个喇叭准备劝解劝解员:兄弟,别跳跳楼人:我不想活了劝解员:你想想你媳妇跳楼人:媳妇跟人跑了劝解员:你还有兄弟跳楼人:就是跟我兄弟跑的劝解员:你想想你家孩子跳楼人:孩子是他俩的劝解员:死吧,妈的,你活着也没啥意义...
    继续阅读 »

    开心一刻


    一男人站在楼顶准备跳楼,楼下有个劝解员拿个喇叭准备劝解
    劝解员:兄弟,别跳
    跳楼人:我不想活了
    劝解员:你想想你媳妇
    跳楼人:媳妇跟人跑了
    劝解员:你还有兄弟
    跳楼人:就是跟我兄弟跑的
    劝解员:你想想你家孩子
    跳楼人:孩子是他俩的
    劝解员:死吧,妈的,你活着也没啥意义了


    开心一刻

    写在前面


    关于锁,相信大家都不陌生,一般我们用其在多线程环境中控制对共享资源的并发访问;单服务下,用 JDK 中的 synchronizedLock 的实现类可实现对共享资源的并发访问,分布式服务下,JDK 中的锁就显得力不从心了,分布式锁也就应运而生了;分布式锁的实现方式有很多,常见的有如下几种



    1. 基于 MySQL,利用行级悲观锁(select ... for update)

    2. 基于 Redis,利用其 (setnx + expire) 或 set

    3. 基于 Zookeeper,利用其临时目录和事件回调机制   


    本文不讲这些,网上资料很多,感兴趣的小伙伴自行去查阅;本文的重点是基于 Redis 的 Redisson,从源码的角度来看看为什么推荐用 Redisson 来实现分布式锁;推荐大家先去看看



    搞懂了 Redis 的订阅、发布与Lua,Redisson的加锁原理就好理解了



    有助于理解后文


    分布式锁特点


    可以类比 JDK 中的锁



    1. 互斥


      不仅要保证同个服务中不同线程的互斥,还需要保证不同服务间、不同线程的互斥;如何处理互斥,是自旋、还是阻塞 ,还是其他 ?


    2. 超时


      锁超时设置,防止程序异常奔溃而导致锁一直存在,后续同把锁一直加不上


    3. 续期


      程序具体执行的时长无法确定,所以过期时间只能是个估值,那么就不能保证程序在过期时间内百分百能运行完,所以需要进行锁续期,保证业务是在加锁的情况下完成的


    4. 可重入


      可重入锁又名递归锁,是指同一个线程在外层方法已经获得锁,再进入该线程的中层或内层方法会自动获取锁;简单点来说,就是同个线程可以反复获取同一把锁


    5. 专一释放


      通俗点来讲:谁加的锁就只有它能释放这把锁;为什么会出现这种错乱释放的问题了,举个例子就理解了



      线程 T1 对资源 lock_zhangsan 加了锁,由于某些原因,业务还未执行完,锁已经过期自动释放了,此时线程 T2 对资源 lock_zhangsan 加锁成功,T2 还在执行业务的过程中,T1 业务执行完后释放资源 lock_zhangsan 的锁,结果把 T2 加的锁给释放了




    6. 公平与非公平


      公平锁:多个线程按照申请锁的顺序去获得锁,所有线程都在队列里排队,这样就保证了队列中的第一个先得到锁


      非公平锁:多个线程不按照申请锁的顺序去获得锁,而是同时直接去尝试获取锁


      JDK 中的 ReentrantLock 就有公平和非公平两种实现,有兴趣的可以去看看它的源码;多数情况下用的是非公平锁,但有些特殊情况下需要用公平锁



    你们可能会有这样的疑问



    引入一个简单的分布式锁而已,有必要考虑这么多吗?



    虽然绝大部分情况下,我们的程序都是在跑正常流程,但不能保证异常情况 100% 跑不到,出于健壮性考虑,异常情况都需要考虑到;下面我们就来看看 Redisson 是如何实现这些特点的


    Redisson实现分布式锁


    关于 Redisson,更多详细信息可查看官方文档,它提供了非常丰富的功能,分布式锁 只是其中之一;我们基于 Redisson 3.13.6,来看看分布式锁的实现



    1. 先将 Redis 信息配置给 Redisson,创建出 RedissonClient 实例


      Redis 的部署方式不同,Redisson 配置模式也会不同,详细信息可查看:Configuration,我们就以最简单的 Single mode 来配置


      @Before
      public void before() {
      Config config = new Config();
      config.useSingleServer()
      .setAddress("redis://192.168.1.110:6379");
      redissonClient = Redisson.create(config);
      }


    2. 通过 RedissonClient 实例获取锁


      RedissonClient 实例创建出来后,就可以通过它来获取锁


      /**
      * 多线程
      * @throws Exception
      */

      @Test
      public void multiLock() throws Exception {

      RLock testLock = redissonClient.getLock("multi_lock");
      int count = 5;
      CountDownLatch latch = new CountDownLatch(count);

      for (int i=1; i<=count; i++) {
      new Thread(() -> {
      try {
      System.out.println("线程 " + Thread.currentThread().getName() + " 尝试获取锁");
      testLock.lock();
      System.out.println(String.format("线程 %s 获取到锁, 执行业务中...", Thread.currentThread().getName()));
      try {
      TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(String.format("线程 %s 业务执行完成", Thread.currentThread().getName()));
      latch.countDown();
      } finally {
      testLock.unlock();
      System.out.println(String.format("线程 %s 释放锁完成", Thread.currentThread().getName()));
      }
      }, "t" + i).start();
      }

      latch.await();
      System.out.println("结束");
      }

      完整示例代码:redisson-demo



    用 Redisson 实现分布式锁就是这么简单,但光会使用肯定是不够的,我们还得知道其底层实现原理



    知其然,并知其所以然!



    那如何知道其原理呢?当然是看其源码实现


    客户端创建


    客服端的创建过程中,会生成一个 id 作为唯一标识,用以区分分布式下不同节点中的客户端


    client

    id 值就是一个 UUID,客户端启动时生成;至于这个 id 有什么用,大家暂且在脑中留下这个疑问,我们接着往下看


    锁获取


    我们从 lock 开始跟源码


    lock

    最终会来到有三个参数的 lock 方法


    private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();

    // 尝试获取锁;ttl为null表示锁获取成功; ttl不为null表示获取锁失败,其值为其他线程占用该锁的剩余时间
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
    return;
    }

    // 锁被其他线程占用而获取失败,使用redis的发布订阅功能来等待锁的释放通知,而非自旋监测锁的释放
    RFuture<RedissonLockEntry> future = subscribe(threadId);

    // 当前线程会阻塞,直到锁被释放时当前线程被唤醒(有超时等待,默认 7.5s,而不会一直等待)
    // 持有锁的线程释放锁之后,redis会发布消息,所有等待该锁的线程都会被唤醒,包括当前线程
    if (interruptibly) {
    commandExecutor.syncSubscriptionInterrupted(future);
    } else {
    commandExecutor.syncSubscription(future);
    }

    try {
    while (true) {
    // 尝试获取锁;ttl为null表示锁获取成功; ttl不为null表示获取锁失败,其值为其他线程占用该锁的剩余时间
    ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
    break;
    }

    // waiting for message
    if (ttl >= 0) {
    try {
    // future.getNow().getLatch() 返回的是 Semaphore 对象,其初始许可证为 0,以此来控制线程获取锁的顺序
    // 通过 Semaphore 控制当前服务节点竞争锁的线程数量
    future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
    if (interruptibly) {
    throw e;
    }
    future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
    }
    } else {
    if (interruptibly) {
    future.getNow().getLatch().acquire();
    } else {
    future.getNow().getLatch().acquireUninterruptibly();
    }
    }
    }
    } finally {
    // 退出锁竞争(锁获取成功或者放弃获取锁),则取消锁的释放订阅
    unsubscribe(future, threadId);
    }
    // get(lockAsync(leaseTime, unit));
    }

    主要三个点:尝试获取锁订阅取消订阅



    1. 尝试获取锁


      尝试获取锁

      尝试获取锁主要做了两件事:1、尝试获取锁,2、锁续期;尝试获取锁主要涉及到一段 Lua 代码


      尝试获取锁Lua脚本

      结合 搞懂了 Redis 的订阅、发布与Lua,Redisson的加锁原理就好理解了 来看这段 Lua 脚本,还是很好理解的




      1. 用 exists 判断 key 不存在,则用 hash 结构来存放锁,key = 资源名,field = uuid + : + threadId,value 自增 1;设置锁的过期时间(默认是 lockWatchdogTimeout = 30 * 1000 毫秒),并返回 nil

      2. 用 hexists 判断 field = uuid + : + threadId 存在,则该 field 的 value 自增 1,并重置过期时间,最后返回 nil


        这里相当于实现了锁的重入


      3. 上面两种情况都不满足,则说明锁被其他线程占用了,直接返回锁的过期时间



      给你们提个问题



      为什么 field = uuid + : + threadId,而不是 field = threadId


      友情提示:从多个服务(也就是多个 Redisson 客户端)来考虑


      这个问题想清楚了,那么前面提到的:在 Redisson 客户端创建的过程中生成的 id(一个随机的 uuid 值),它的作用也就清楚了



      尝试获取锁成功之后,会启动一个定时任务(即 WatchDog,亦称 看门狗)实现锁续期,也涉及到一段 Lua 脚本


      看门狗Lua

      这段脚本很简单,相信你们都能看懂



      默认情况下,锁的过期时间是 30s,锁获取成功之后每隔 10s 进行一次锁续期,重置过期时间成 30s


      若锁已经被释放了,则定时任务也会停止,不会再续期




    2. 订阅


      订阅

      获取锁的过程中,尝试获取锁失败(锁被其他线程锁占有),则会完成对该锁频道的订阅,订阅过程中线程会阻塞;持有锁的线程释放锁时会向锁频道发布消息,订阅了该锁频道的线程会被唤醒,继续去获取锁,


      给你们提个问题



      如果持有锁的线程意外停止了,未向锁频道发布消息,那订阅了锁频道的线程该如何唤醒



      Redisson 其实已经考虑到了,提供了超时机制来处理


      锁频道超时机制

      默认超时时长 = 3000 + 1500 * 3 = 7500 毫秒


      再给你们提个问题



      为什么要用 Redis 的发布订阅



      如果我们不用 Redis 的发布订阅,我们该如何实现,自旋?自旋有什么缺点? 自旋频率难以掌控,太高会增大 CPU 的负担,太低会不及时(锁都释放半天了才检测到);可以类比 生产者与消费者 来考虑这个问题


    3. 取消订阅


      有订阅,肯定就有取消订阅;当阻塞的线程被唤醒并获取到锁时需要取消对锁频道的订阅,当然,取消获取锁的线程也需要取消对锁频道的订阅


      取消订阅

      比较好理解,就是取消当前线程对锁频道的订阅



    锁释放


    我们从 unlock 开始


    unlock

    代码比较简单,我们继续往下跟


    unlock_跟源码

    主要有两点:释放锁取消续期定时任务



    1. 释放锁


      重点在于一个 Lua 脚本


      释放锁Lua脚本

      我们把参数具象化,脚本就好理解了



      KEYS[1] = 锁资源,KEYS[2] = 锁频道


      ARGV[1] = 锁频道消息类型,ARGV[2] = 过期时间,ARGV[3] = uuid + : + threadId



      1. 如果当前线程未持有锁,直接返回 nil

      2. hash 结构的 field 的 value 自减 1,counter = 自减后的 value 值


        如果 counter > 0,表示线程重入了,重置锁的过期时间,返回 0


        如果 counter <= 0,删除锁,并对锁频道发布锁释放消息(频道订阅者则可收到消息,然后唤醒线程去获取锁),返回 1


      3. 上面 1、2 都不满足,则直接返回 nil


      两个细节:1、重入锁的释放,2、锁彻底释放后的消息发布




    2. 取消续期定时任务


      取消续期定时任务

      比较简单,没什么好说的


      总结


      我们从分布式锁的特点出发,来总结下 Redisson 是如何实现这些特点的



      1. 互斥


        Redisson 采用 hash 结构来存锁资源,通过 Lua 脚本对锁资源进行操作,保证线程之间的互斥;互斥之后,未获取到锁的线程会订阅锁频道,然后进入一定时长的阻塞


      2. 超时


        有超时设置,给 hash 结构的 key 加上过期时间,默认是 30s


      3. 续期


        线程获取到锁之后会开启一个定时任务(watchdog 即 看门狗),每隔一定时间(默认 10s)重置 key 的过期时间


      4. 可重入


        通过 hash 结构解决,key 是锁资源,field(值:uuid + : + threadId) 是持有锁的线程,value 表示重入次数


      5. 专一释放


        通过 hash 结构解决,field 中存放了线程信息,释放的时候就能够知道是不是当前线程加上的锁,是才能够进行锁释放


      6. 公平与非公平


        由你们在评论区补充





    作者:青石路
    来源:juejin.cn/post/7425786548061683727
    收起阅读 »

    开发小同学的骚操作,还好被我发现了

    大家好,我是程序员鱼皮。今天给朋友们还原一个我们团队真实的开发场景。 开发现场 最近我们编程导航网站要开发 用户私信 功能,第一期要做的需求很简单: 能让两个用户之间 1 对 1 单独发送消息 用户能够查看到消息记录 用户能够实时收到消息通知 这其实是一...
    继续阅读 »

    大家好,我是程序员鱼皮。今天给朋友们还原一个我们团队真实的开发场景。


    开发现场


    最近我们编程导航网站要开发 用户私信 功能,第一期要做的需求很简单:



    1. 能让两个用户之间 1 对 1 单独发送消息

    2. 用户能够查看到消息记录

    3. 用户能够实时收到消息通知



    这其实是一个双向实时通讯的场景,显然可以使用 WebSocket 技术来实现。


    团队的后端开发小 c 拿到需求后就去调研了,最后打算采用 Spring Boot Starter 快速整合 Websocket 来实现,接受前端某个用户传来的消息后,转发到接受消息的用户的会话,并在数据库中记录,便于用户查看历史。


    小 c 的代码写得还是不错的,用了一些设计模式(像策略模式、工厂模式)对代码进行了一些抽象封装。虽然在我看来对目前的需求来说稍微有点过度设计,但开发同学有自己的理由和想法,表示尊重~



    前端同学小 L 也很快完成了开发,并且通过了产品的验收。


    看似这个需求就圆满地完成了,但直到我阅读前端同学的代码时,才发现了一个 “坑”。



    这是前端同学小 L 提交的私信功能代码,看到这里我就已经发现问题了,朋友们能注意到么?



    解释一下,小 L 引入了一个 nanoid 库,这个库的作用是生成唯一 id。看到这里,我本能地感到疑惑:为什么要引入这个库?为什么前端要生成唯一 id?


    难道。。。是作为私信消息的 id?


    果不其然,通过这个库在前端给每个消息生成了一个唯一 id,然后发送给后端。



    后端开发的同学可能会想:一般情况下不都是后端利用数据库的自增来生成唯一 id 并返回给前端嘛,怎么需要让前端来生成呢?


    这里小 L 的解释是,在本地创建消息的时候,需要有一个 id 来追踪状态,不会出现消息没有 id 的情况。


    首先,这么做的确 能够满足需求 ,所以我还是通过了代码审查;但严格意义上来说,让前端来生成唯一 id 其实不够优雅,可能会有一些问题。


    前端生成 id 的问题


    1)ID 冲突:同时使用系统的前端用户可能是非常多的,每个用户都是一个客户端,多个前端实例可能会生成相同的 ID,导致数据覆盖或混乱。


    2)不够安全:切记,前端是没有办法保证安全性的!因为攻击者可以篡改或伪造请求中的数据,比如构造一个已存在的 id,导致原本的数据被覆盖掉,从而破坏数据的一致性。


    要做这件事成本非常低,甚至不需要网络攻击方面的知识,打开 F12 浏览器控制台,重放个请求就行实现:



    3)时间戳问题:某些生成 id 的算法是依赖时间戳的,比如当前时间不同,生成的 id 就不同。但是如果前端不同用户的电脑时间不一致,就可能会生成重复 id 或无效 id。比如用户 A 电脑是 9 点时生成了 id = 06030901,另一个用户 B 电脑时间比 A 慢了一个小时,现在是 8 点,等用户 B 电脑时间为 9 点的时候,可能又生成了重复 id = 06030901,导致数据冲突。这也被称为 “分布式系统中的全局时钟问题”。


    明确前后端职责


    虽然 Nanoid 这个库不依赖时间戳来生成 id,不会受到设备时钟不同步的影响,也不会因为时间戳重复而导致 ID 冲突。根据我查阅的资料,生成大约 10 ^ 9 个 ID 后,重复的可能性大约是 10 ^ -17,几乎可以忽略不计。但一般情况下,我个人会更建议将业务逻辑统一放到后端实现,这么做的好处有很多:



    1. 后端更容易保证数据的安全性,可以对数据先进行校验再生成 id

    2. 前端尽量避免进行复杂的计算,而是交给后端,可以提升整体的性能

    3. 职责分离,前端专注于页面展示,后端专注于业务,而不是双方都要维护一套业务逻辑


    我举个典型的例子,比如前端下拉框内要展示一些可选项。由于选项的数量并不多,前端当然可以自己维护这些数据(一般叫做枚举值),但后端也会用到这些枚举值,双方都写一套枚举值,就很容易出现不一致的情况。推荐的做法是,让后端返回枚举值给前端,前端不用重复编写。



    所以一般情况下,对于 id 的生成,建议统一交给后端实现,可以用雪花算法根据时间戳生成,也可以利用数据库主键生成自增 id 或 UUID,具体需求具体分析吧~


    作者:程序员鱼皮
    来源:juejin.cn/post/7376148503087169562
    收起阅读 »

    请不要自己写,Spring Boot非常实用的内置功能

    在 Spring Boot 框架中,内置了许多实用的功能,这些功能可以帮助开发者高效地开发和维护应用程序。 松哥来和大家列举几个。 一 请求数据记录 Spring Boot提供了一个内置的日志记录解决方案,通过 AbstractRequestLoggingFi...
    继续阅读 »

    在 Spring Boot 框架中,内置了许多实用的功能,这些功能可以帮助开发者高效地开发和维护应用程序。


    松哥来和大家列举几个。


    一 请求数据记录


    Spring Boot提供了一个内置的日志记录解决方案,通过 AbstractRequestLoggingFilter 可以记录请求的详细信息。


    AbstractRequestLoggingFilter 有两个不同的实现类,我们常用的是 CommonsRequestLoggingFilter



    通过 CommonsRequestLoggingFilter 开发者可以自定义记录请求的参数、请求体、请求头和客户端信息。


    启用方式很简单,加个配置就行了:


    @Configuration
    public class RequestLoggingConfig {
    @Bean
    public CommonsRequestLoggingFilter logFilter() {
    CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
    filter.setIncludeQueryString(true);
    filter.setIncludePayload(true);
    filter.setIncludeHeaders(true);
    filter.setIncludeClientInfo(true);
    filter.setAfterMessagePrefix("REQUEST ");
    return filter;
    }
    }

    接下来需要配置日志级别为 DEBUG,就可以详细记录请求信息:


    logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG


    二 请求/响应包装器


    2.1 什么是请求和响应包装器


    在 Spring Boot 中,请求和响应包装器是用于增强原生 HttpServletRequestHttpServletResponse 对象的功能。这些包装器允许开发者在请求处理过程中拦截和修改请求和响应数据,从而实现一些特定的功能,如请求内容的缓存、修改、日志记录,以及响应内容的修改和增强。


    请求包装器



    • ContentCachingRequestWrapper:这是 Spring 提供的一个请求包装器,用于缓存请求的输入流。它允许多次读取请求体,这在需要多次处理请求数据(如日志记录和业务处理)时非常有用。


    响应包装器



    • ContentCachingResponseWrapper:这是 Spring 提供的一个响应包装器,用于缓存响应的输出流。它允许开发者在响应提交给客户端之前修改响应体,这在需要对响应内容进行后处理(如添加额外的头部信息、修改响应体)时非常有用。


    2.2 使用场景



    1. 请求日志记录:在处理请求之前和之后记录请求的详细信息,包括请求头、请求参数和请求体。

    2. 修改请求数据:在请求到达控制器之前修改请求数据,例如添加或修改请求头。

    3. 响应内容修改:在响应发送给客户端之前修改响应内容,例如添加或修改响应头,或者对响应体进行签名。

    4. 性能测试:通过缓存请求和响应数据,可以进行性能测试,而不影响实际的网络 I/O 操作。


    2.3 具体用法


    请求包装器的使用

    import org.springframework.web.filter.OncePerRequestFilter;
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    @Component
    public class RequestWrapperFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
    ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
    // 可以在这里处理请求数据
    byte[] body = requestWrapper.getContentAsByteArray();
    // 处理body,例如记录日志
    //。。。
    filterChain.doFilter(requestWrapper, response);
    }
    }

    响应包装器的使用

    import org.springframework.web.filter.OncePerRequestFilter;
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    @Component
    public class ResponseWrapperFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
    ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
    filterChain.doFilter(request, responseWrapper);

    // 可以在这里处理响应数据
    byte[] body = responseWrapper.getContentAsByteArray();
    // 处理body,例如添加签名
    responseWrapper.setHeader("X-Signature", "some-signature");

    // 必须调用此方法以将响应数据发送到客户端
    responseWrapper.copyBodyToResponse();
    }
    }

    在上面的案例中,OncePerRequestFilter 确保过滤器在一次请求的生命周期中只被调用一次,这对于处理请求和响应数据尤为重要,因为它避免了在请求转发或包含时重复处理数据。


    通过使用请求和响应包装器,开发者可以在不改变原有业务逻辑的情况下,灵活地添加或修改请求和响应的处理逻辑。


    三 单次过滤器


    3.1 OncePerRequestFilter


    OncePerRequestFilter 是 Spring 框架提供的一个过滤器基类,它继承自 Filter 接口。这个过滤器具有以下特点:



    1. 单次执行OncePerRequestFilter 确保在一次请求的生命周期内,无论请求如何转发(forwarding)或包含(including),过滤器逻辑只执行一次。这对于避免重复处理请求或响应非常有用。

    2. 内置支持:它内置了对请求和响应包装器的支持,使得开发者可以方便地对请求和响应进行包装和处理。

    3. 简化代码:通过继承 OncePerRequestFilter,开发者可以减少重复代码,因为过滤器的执行逻辑已经由基类管理。

    4. 易于扩展:开发者可以通过重写 doFilterInternal 方法来实现自己的过滤逻辑,而不需要关心过滤器的注册和执行次数。


    3.2 OncePerRequestFilter 使用场景



    1. 请求日志记录:在请求处理之前和之后记录请求的详细信息,如请求头、请求参数和请求体,而不希望在请求转发时重复记录。

    2. 请求数据修改:在请求到达控制器之前,对请求数据进行预处理或修改,例如添加或修改请求头,而不希望这些修改在请求转发时被重复应用。

    3. 响应数据修改:在响应发送给客户端之前,对响应数据进行后处理或修改,例如添加或修改响应头,而不希望这些修改在请求包含时被重复应用。

    4. 安全控制:实现安全控制逻辑,如身份验证、授权检查等,确保这些逻辑在一次请求的生命周期内只执行一次。

    5. 请求和响应的包装:使用 ContentCachingRequestWrapperContentCachingResponseWrapper 等包装器来缓存请求和响应数据,以便在请求处理过程中多次读取或修改数据。

    6. 性能监控:在请求处理前后进行性能监控,如记录处理时间,而不希望这些监控逻辑在请求转发时被重复执行。

    7. 异常处理:在请求处理过程中捕获和处理异常,确保异常处理逻辑只执行一次,即使请求被转发到其他处理器。


    通过使用 OncePerRequestFilter,开发者可以确保过滤器逻辑在一次请求的生命周期内只执行一次,从而避免重复处理和潜在的性能问题。这使得 OncePerRequestFilter 成为处理复杂请求和响应逻辑时的一个非常有用的工具。


    OncePerRequestFilter 的具体用法松哥就不举例了,第二小节已经介绍过了。


    四 AOP 三件套


    在 Spring 框架中,AOP(面向切面编程)是一个强大的功能,它允许开发者在不修改源代码的情况下,对程序的特定部分进行横向切入。AopContextAopUtilsReflectionUtils 是 Spring AOP 中提供的几个实用类。


    我们一起来看下。


    4.1 AopContext


    AopContext 是 Spring 框架中的一个类,它提供了对当前 AOP 代理对象的访问,以及对目标对象的引用。


    AopContext 主要用于获取当前代理对象的相关信息,以及在 AOP 代理中进行一些特定的操作。


    常见方法有两个:



    • getTargetObject(): 获取当前代理的目标对象。

    • currentProxy(): 获取当前的代理对象。


    其中第二个方法,在防止同一个类中注解失效的时候,可以通过该方法获取当前类的代理对象。


    举个栗子:


    public void noTransactionTask(String keyword){    // 注意这里 调用了代理类的方法
    ((YourClass) AopContext.currentProxy()).transactionTask(keyword);
    }

    @Transactional
    void transactionTask(String keyword) {
    try {
    Thread.sleep(5000);
    } catch (InterruptedException e) { //logger
    //error tracking
    }
    System.out.println(keyword);
    }

    同一个类中两个方法,noTransactionTask 方法调用 transactionTask 方法,为了使事务注解不失效,就可以使用 AopContext.currentProxy() 去获取当前代理对象。


    4.2 AopUtils


    AopUtils 提供了一些静态方法来处理与 AOP 相关的操作,如获取代理对象、获取目标对象、判断代理类型等。


    常见方法有三个:



    • getTargetObject(): 从代理对象中获取目标对象。

    • isJdkDynamicProxy(Object obj): 判断是否是 JDK 动态代理。

    • isCglibProxy(Object obj): 判断是否是 CGLIB 代理。


    举个栗子:


    import org.springframework.aop.framework.AopProxyUtils;
    import org.springframework.aop.support.AopUtils;

    public class AopUtilsExample {
    public static void main(String[] args) {
    MyService myService = ...
    // 假设 myService 已经被代理
    if (AopUtils.isCglibProxy(myService)) {
    System.out.println("这是一个 CGLIB 代理对象");
    }
    }
    }

    4.3 ReflectionUtils


    ReflectionUtils 提供了一系列反射操作的便捷方法,如设置字段值、获取字段值、调用方法等。这些方法封装了 Java 反射 API 的复杂性,使得反射操作更加简单和安全。


    常见方法:



    • makeAccessible(Field field): 使私有字段可访问。

    • getField(Field field, Object target): 获取对象的字段值。

    • invokeMethod(Method method, Object target, Object... args): 调用对象的方法。


    举个栗子:


    import org.springframework.util.ReflectionUtils;

    import java.lang.reflect.Field;
    import java.util.Map;

    public class ReflectionUtilsExample {
    public static void main(String[] args) throws Exception {
    ExampleBean bean = new ExampleBean();
    bean.setMapAttribute(new HashMap<>());

    Field field = ReflectionUtils.findField(ExampleBean.class, "mapAttribute");
    ReflectionUtils.makeAccessible(field);

    Object value = ReflectionUtils.getField(field, bean);
    System.out.println(value);
    }

    static class ExampleBean {
    private Map<String, String> mapAttribute;

    public void setMapAttribute(Map<String, String> mapAttribute) {
    this.mapAttribute = mapAttribute;
    }
    }
    }

    还有哪些实用内置类呢?欢迎小伙伴们留言~


    作者:江南一点雨
    来源:juejin.cn/post/7417630844100231206
    收起阅读 »

    花了一天时间帮财务朋友开发了一个实用小工具

    大家好,我是晓凡。 写在前面 不知道大家有没有做财务的朋友,我就有这么一位朋友就经常跟我抱怨。一到月底简直就是噩梦,总有加不完的班,熬不完的夜,做不完的报表。 一听到这儿,这不就一活生生的一个“大表哥”么,这加班跟我们程序员有得一拼了,忍不住邪恶一笑,心里平...
    继续阅读 »

    大家好,我是晓凡。


    写在前面


    不知道大家有没有做财务的朋友,我就有这么一位朋友就经常跟我抱怨。一到月底简直就是噩梦,总有加不完的班,熬不完的夜,做不完的报表。


    来自朋友的抱怨


    一听到这儿,这不就一活生生的一个“大表哥”么,这加班跟我们程序员有得一拼了,忍不住邪恶一笑,心里平衡了很多。



    身为牛马,大家都不容易啊。我不羡慕你数钱数到手抽筋,你也别羡慕我整天写CRUD 写到手起老茧🤣


    吐槽归吐槽,饭还得吃,工作还得继续干。于是乎,真好赶上周末,花了一天的时间,帮朋友写了个小工具


    一、功能需求


    跟朋友吹了半天牛,终于把需求确定下来了。就一个很简单的功能,通过名字,将表一和表二中相同名字的金额合计。


    具体数据整合如下图所示


    数据整合


    虽然一个非常简单的功能,但如果不借助工具,数据太多,人工来核对,整合数据,还是需要非常消耗时间和体力的。


    怪不得,这朋友到月底就消失了,原来时间都耗在这上面了。


    二、技术选型


    由于需求比较简单,只有excel导入导出,数据整合功能。不涉及数据库相关操作。


    综合考虑之后选择了



    • PowerBuilder

    • Pbidea.dll


    使用PowerBuilder开发桌面应用,虽然界面丑一点,但是开发效率挺高,简单拖拖拽拽就能完成界面(对于前端技术不熟的小伙伴很友好)


    其次,由于不需要数据库,放弃web开发应用,又省去了云服务器费用。最终只需要打包成exe文件即可跑起来


    Pbidea.dll 算是Powerbuilder最强辅助开发,没有之一。算是PBer们的福音吧


    三、简单界面布局


    界面布局1


    界面布局2


    界面布局3


    四、核心代码


    ① 导入excel



    string ls_pathName,ls_FileName //路径+文件名,文件名
    long ll_Net
    long rows
    dw_1.reset()
    uo_datawindowex dw
    dw = create uo_datawindowex
    dw_1.setredraw(false)
    ll_Net = GetFileSaveName("请选择文件",ls_pathName,ls_FileName,"xlsx","Excel文(*.xlsx),*.xlsx")

    rows = dw.ImportExcelSheet(dw_1,ls_pathName,1,0,0)
    destroy dw
    dw_1.setredraw(true)
    MessageBox("提示信息","导入成功 " + string(rows) + "行数据")


    ② 数据整合


    long ll_row,ll_sum1,ll_sum2
    long ll_i,ll_j
    long ll_yes

    string ls_err

    //重置表三数据

    dw_3.reset()

    //处理表一数据
    ll_sum1 = dw_1.rowcount( )

    if ll_sum1<=0 then
    ls_err = "表1 未导入数据,请先导入数据"
    goto err
    end if

    for ll_i=1 to ll_sum1
    ll_row = dw_3.insertrow(0)
    dw_3.object.num[ll_row] =ll_row                                                          //序号
    dw_3.object.name[ll_row]=dw_1.object.name[ll_i]                                 //姓名
    dw_3.object.salary[ll_row]=dw_1.object.salary[ll_i]                                //工资
    dw_3.object.endowment[ll_row]=dw_1.object.endowment[ll_i]               //养老
    dw_3.object.medical[ll_row]=dw_1.object.medical[ll_i]                          //医疗
    dw_3.object.injury[ll_row]=dw_1.object.injury[ll_i]                                        //工伤
    dw_3.object.unemployment[ll_row]=dw_1.object.unemployment[ll_i]      //失业
    dw_3.object.publicacc[ll_row]=dw_1.object.publicacc[ll_i]                      //公积金
    dw_3.object.annuity[ll_row]=dw_1.object.annuity[ll_i]                           //年金

    next

    //处理表二数据

    ll_sum2 = dw_2.rowcount( )

    if ll_sum2<=0 then
    ls_err = "表2未导入数据,请先导入数据"
    goto err
    end if

    for ll_j =1 to ll_sum2
    string ls_name
    ls_name = dw_2.object.name[ll_j]

    ll_yes = dw_3.Find("name = '"+ ls_name +"' ",1,dw_3.rowcount())

    if ll_yes<0 then
    ls_err = "查找失败!"+SQLCA.SQLErrText
    goto err
    end if

    if ll_yes = 0 then  //没有找到
    ll_row = dw_3.InsertRow (0)
    dw_3.ScrollToRow(ll_row)
    dw_3.object.num[ll_row]                   = ll_row                                                          //序号
    dw_3.object.name[ll_row]                 = dw_1.object.name[ll_j]                                 //姓名
    dw_3.object.salary[ll_row]                 = dw_1.object.salary[ll_j]                                //工资
    dw_3.object.endowment[ll_row]         = dw_1.object.endowment[ll_j]               //养老
    dw_3.object.medical[ll_row]              = dw_1.object.medical[ll_j]                          //医疗
    dw_3.object.injury[ll_row]                 = dw_1.object.injury[ll_j]                                        //工伤
    dw_3.object.unemployment[ll_row]    = dw_1.object.unemployment[ll_j]      //失业
    dw_3.object.publicacc[ll_row]            = dw_1.object.publicacc[ll_j]                      //公积金
    dw_3.object.annuity[ll_row]               = dw_1.object.annuity[ll_j]                           //年金
    end if

    if ll_yes >0 then  //找到        
    dec{2} ld_salary,ld_endowment,ld_medical,ld_injury,ld_unemployment,ld_publicacc,ld_annuity
    ld_salary = dw_3.object.salary[ll_yes] + dw_2.object.salary[ll_j]
    ld_endowment =  dw_3.object.endowment[ll_yes] + dw_2.object.endowment[ll_j]
    ld_medical = dw_3.object.medical[ll_yes] + dw_2.object.medical[ll_j]
    ld_injury = dw_3.object.injury[ll_yes] + dw_2.object.injury[ll_j]
    ld_unemployment = dw_3.object.unemployment[ll_yes] + dw_2.object.unemployment[ll_j]
    ld_publicacc = dw_3.object.publicacc[ll_yes] + dw_2.object.publicacc[ll_j]
    ld_annuity = dw_3.object.annuity[ll_yes] + dw_2.object.annuity[ll_j]

    dw_3.object.salary[ll_yes]=  ld_salary                             //工资
    dw_3.object.endowment[ll_yes]=ld_endowment               //养老
    dw_3.object.medical[ll_yes]=ld_medical                          //医疗
    dw_3.object.injury[ll_yes]=ld_injury                                     //工伤
    dw_3.object.unemployment[ll_yes]=ld_unemployment   //失业
    dw_3.object.publicacc[ll_yes]=ld_publicacc                    //公积金
    dw_3.object.annuity[ll_yes]=ld_publicacc                      //年金

    end if

    next

    return 0

    err:
    messagebox('错误信息',ls_err)

    ③ excel导出


    string ls_err
    string ls_pathName,ls_FileName //路径+文件名,文件名
    long ll_Net

    if dw_3.rowcount() = 0 then
    ls_err = "整合数据为空,不能导出"
    goto err
    end if

    uo_wait_box luo_waitbox
    luo_waitbox = create uo_wait_box
    luo_waitBox.OpenWait(64,RGB(220,220,220),RGB(20,20,20),TRUE,"正在导出 ", 8,rand(6) - 1)

    long rows
    CreateDirectory("tmp")
    uo_datawindowex dw
    dw = create uo_datawindowex

    ll_Net = GetFileSaveName("选择路径",ls_pathName,ls_FileName,"xlsx","Excel文(*.xlsx),*.xlsx")

    rows = dw.ExportExcelSheet(dw_3,ls_pathName,true,true)
    destroy dw
    destroy luo_waitbox
    MessageBox("提示信息","成功导出 " + string(rows) + " 行数据")

    return 0

    err:
    messagebox('错误信息',ls_err)

    五、最终效果


    财务辅助系统


    这次分享就到这吧,★,°:.☆( ̄▽ ̄)/$: .°★ 。希望对您有所帮助,也希望多来几个这样的朋友,不多说了, 蹭饭去了


    我们下期再见ヾ(•ω•`)o (●'◡'●)


    作者:程序员晓凡
    来源:juejin.cn/post/7404036818973245478
    收起阅读 »

    Nginx UI:全新的 Nginx 在线管理平台

    前言 Nginx在程序部署中扮演着至关重要的角色,其高性能、高安全性、易于配置和管理的特点,使得它成为现代Web应用部署中不可或缺的一部分。今天大姚给大家分享一款实用的 Nginx Web UI 工具,希望能够帮助到有需要的同学。 工具介绍 Nginx UI一...
    继续阅读 »

    前言


    Nginx在程序部署中扮演着至关重要的角色,其高性能、高安全性、易于配置和管理的特点,使得它成为现代Web应用部署中不可或缺的一部分。今天大姚给大家分享一款实用的 Nginx Web UI 工具,希望能够帮助到有需要的同学。


    工具介绍


    Nginx UI一个功能丰富、易于使用的 Nginx Web UI 工具,它极大地简化了 Nginx 服务器的管理和配置过程。



    主要功能



    • 在线统计:提供服务器指标如 CPU 使用率、内存使用率、负载平均值和磁盘使用率的在线统计。

    • ChatGPT 助手:内置 ChatGPT 助手,提供智能辅助功能。

    • 一键部署和自动续期:支持一键部署 Let's Encrypt 证书,并自动续期。

    • 在线编辑配置:在线编辑 Nginx 配置文件,编辑器支持 Nginx 配置语法高亮。

    • 查看 Nginx 日志:提供在线查看 Nginx 日志的功能。

    • 自动测试和重载:自动测试配置文件并在保存后重载 Nginx。

    • Web 终端:提供 Web 终端访问功能。

    • 暗色模式:支持暗色模式,保护用户视力。

    • 响应式网页设计:确保在不同设备上都能良好显示。



    支持语言


    支持多语言,包括英语、简体中文、繁体中文等。


    在线演示










    开源地址



    程序员常用的工具软件


    该工具已收录到程序员常用的工具软件栏目中,欢迎关注该栏目发现更多优秀实用的开发工具!



    github.com/YSGStudyHar…





    作者:追逐时光者
    来源:juejin.cn/post/7425885062922174498
    收起阅读 »

    处理异常的13条军规

    前言 在我们日常工作中,经常会遇到一些异常,比如:NullPointerException、NumberFormatException、ClassCastException等等。 那么问题来了,我们该如何处理异常,让代码变得更优雅呢? 苏三的免费刷题网站:...
    继续阅读 »

    前言


    在我们日常工作中,经常会遇到一些异常,比如:NullPointerException、NumberFormatException、ClassCastException等等。


    那么问题来了,我们该如何处理异常,让代码变得更优雅呢?


    图片



    苏三的免费刷题网站:http://www.susan.net.cn 里面:面试八股文、BAT面试真题、工作内推、工作经验分享、技术专栏等等什么都有,欢迎收藏和转发。



    1 不要忽略异常


    不知道你有没有遇到过下面这段代码:


    反例:


    Long id = null;
    try {
       id = Long.parseLong(keyword);
    catch(NumberFormatException e) {
      //忽略异常
    }

    用户输入的参数,使用Long.parseLong方法转换成Long类型的过程中,如果出现了异常,则使用try/catch直接忽略了异常。


    并且也没有打印任何日志。


    如果后面线上代码出现了问题,有点不太好排查问题。


    建议大家不要忽略异常,在后续的工作中,可能会带来很多麻烦。


    正例:


    Long id = null;
    try {
       id = Long.parseLong(keyword);
    catch(NumberFormatException e) {
      log.info(String.format("keyword:{} 转换成Long类型失败,原因:{}",keyword , e))
    }

    后面如果数据转换出现问题,从日志中我们一眼就可以查到具体原因了。


    2 使用全局异常处理器


    有些小伙伴,经常喜欢在Service代码中捕获异常。


    不管是普通异常Exception,还是运行时异常RuntimeException,都使用try/catch把它们捕获。


    反例:


    try {
      checkParam(param);
    catch (BusinessException e) {
      return ApiResultUtil.error(1,"参数错误");
    }

    在每个Controller类中都捕获异常。


    在UserController、MenuController、RoleController、JobController等等,都有上面的这段代码。


    显然这种做法会造成大量重复的代码。


    我们在Controller、Service等业务代码中,尽可能少捕获异常。


    这种业务异常处理,应该交给拦截器统一处理。


    在SpringBoot中可以使用@RestControllerAdvice注解,定义一个全局的异常处理handler,然后使用@ExceptionHandler注解在方法上处理异常。


    例如:


    @Slf4j
    @RestControllerAdvice
    public class GlobalExceptionHandler {

        /**
         * 统一处理异常
         *
         * @param e 异常
         * @return API请求响应实体
         */

        @ExceptionHandler(Exception.class)
        public ApiResult handleException(Exception e) {
            if (e instanceof BusinessException) {
                BusinessException businessException = (BusinessException) e;
                log.info("请求出现业务异常:", e);
                return ApiResultUtil.error(businessException.getCode(), businessException.getMessage());
            } 
            log.error("请求出现系统异常:", e);
            return ApiResultUtil.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器内部错误,请联系系统管理员!");
        }

    }

    有了这个全局的异常处理器,之前我们在Controller或者Service中的try/catch代码可以去掉。


    如果在接口中出现异常,全局的异常处理器会帮我们封装结果,返回给用户。


    3 尽可能捕获具体异常


    在你的业务逻辑方法中,有可能需要去处理多种不同的异常。


    你可能你会觉得比较麻烦,而直接捕获Exception。


    反例:


    try {
       doSomething();
    catch(Exception e) {
      log.error("doSomething处理失败,原因:",e);
    }

    这样捕获异常太笼统了。


    其实doSomething方法中,会抛出FileNotFoundException和IOException。


    这种情况我们最好捕获具体的异常,然后分别做处理。


    正例:


    try {
       doSomething();
    catch(FileNotFoundException e) {
      log.error("doSomething处理失败,文件找不到,原因:",e);
    catch(IOException e) {
      log.error("doSomething处理失败,IO出现了异常,原因:",e);
    }

    这样如果后面出现了上面的异常,我们就非常方便知道是什么原因了。


    4 在finally中关闭IO流


    我们在使用IO流的时候,用完了之后,一般需要及时关闭,否则会浪费系统资源。


    我们需要在try/catch中处理IO流,因为可能会出现IO异常。


    反例:


    try {
        File file = new File("/tmp/1.txt");
        FileInputStream fis = new FileInputStream(file);
        byte[] data = new byte[(int) file.length()];
        fis.read(data);
        for (byte b : data) {
            System.out.println(b);
        }
        fis.close();
    catch (IOException e) {
        log.error("读取文件失败,原因:",e)
    }

    上面的代码直接在try的代码块中关闭fis。


    假如在调用fis.read方法时,出现了IO异常,则可能会直接抛异常,进入catch代码块中,而此时fis.close方法没办法执行,也就是说这种情况下,无法正确关闭IO流。


    正例:


    FileInputStream fis = null;
    try {
        File file = new File("/tmp/1.txt");
        fis = new FileInputStream(file);
        byte[] data = new byte[(int) file.length()];
        fis.read(data);
        for (byte b : data) {
            System.out.println(b);
        } 
    catch (IOException e) {
        log.error("读取文件失败,原因:",e)
    finally {
       if(fis != null) {
          try {
              fis.close();
              fis = null;
          } catch (IOException e) {
              log.error("读取文件后关闭IO流失败,原因:",e)
          }
       }
    }

    在finally代码块中关闭IO流。


    但要先判断fis不为空,否则在执行fis.close()方法时,可能会出现NullPointerException异常。


    需要注意的地方时,在调用fis.close()方法时,也可能会抛异常,我们还需要进行try/catch处理。


    最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。


    你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。


    添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。


    5 多用try-catch-resource


    前面在finally代码块中关闭IO流,还是觉得有点麻烦。


    因此在JDK7之后,出现了一种新的语法糖try-with-resource。


    上面的代码可以改造成这样的:


    File file = new File("/tmp/1.txt");
    try (FileInputStream fis = new FileInputStream(file)) {
        byte[] data = new byte[(int) file.length()];
        fis.read(data);
        for (byte b : data) {
            System.out.println(b);
        }
    catch (IOException e) {
        e.printStackTrace();
        log.error("读取文件失败,原因:",e)
    }

    try括号里头的FileInputStream实现了一个AutoCloseable接口,所以无论这段代码是正常执行完,还是有异常往外抛,还是内部代码块发生异常被截获,最终都会自动关闭IO流。


    我们尽量多用try-catch-resource的语法关闭IO流,可以少写一些finally中的代码。


    而且在finally代码块中关闭IO流,有顺序的问题,如果有多种IO,关闭的顺序不对,可能会导致部分IO关闭失败。


    而try-catch-resource就没有这个问题。


    6 不在finally中return


    我们在某个方法中,可能会有返回数据。


    反例:


    public int divide(int dividend, int divisor) {
        try {
            return dividend / divisor;
        } catch (ArithmeticException e) {
            // 异常处理
        } finally {
            return -1;
        }
    }

    上面的这个例子中,我们在finally代码块中返回了数据-1。


    这样最后在divide方法返回时,会将dividend / divisor的值覆盖成-1,导致正常的结果也不对。


    我们尽量不要在finally代码块中返回数据。


    正解:


    public int divide(int dividend, int divisor) {
        try {
            return dividend / divisor;
        } catch (ArithmeticException e) {
            // 异常处理
            return -1;
        }
    }

    如果dividend / divisor出现了异常,则在catch代码块中返回-1。


    7 少用e.printStackTrace()


    我们在本地开发中,喜欢使用e.printStackTrace()方法,将异常的堆栈跟踪信息输出到标准错误流中。


    反例:


    try {
       doSomething();
    catch(IOException e) {
      e.printStackTrace();
    }

    这种方式在本地确实容易定位问题。


    但如果代码部署到了生产环境,可能会带来下面的问题:



    1. 可能会暴露敏感信息,如文件路径、用户名、密码等。

    2. 可能会影响程序的性能和稳定性。


    正解:


    try {
       doSomething();
    catch(IOException e) {
      log.error("doSomething处理失败,原因:",e);
    }

    我们要将异常信息记录到日志中,而不是保留给用户。


    8 异常打印详细一点


    我们在捕获了异常之后,需要把异常的相关信息记录到日志当中。


    反例:


    try {
       double b = 1/0;
    catch(ArithmeticException e) {
        log.error("处理失败,原因:",e.getMessage());
    }

    这个例子中使用e.getMessage()方法返回异常信息。


    但执行结果为:


    doSomething处理失败,原因:

    这种情况异常信息根本没有打印出来。


    我们应该把异常信息和堆栈都打印出来。


    正例:


    try {
       double b = 1/0;
    catch(ArithmeticException e) {
        log.error("处理失败,原因:",e);
    }

    执行结果:


    doSomething处理失败,原因:
    java.lang.ArithmeticException: / by zero
     at cn.net.susan.service.Test.main(Test.java:16)

    将具体的异常,出现问题的代码和具体行数都打印出来。


    9 别捕获了异常又马上抛出


    有时候,我们为了记录日志,可能会对异常进行捕获,然后又抛出。


    反例:


    try {
      doSomething();
    catch(ArithmeticException e) {
      log.error("doSomething处理失败,原因:",e)
      throw e;
    }

    在调用doSomething方法时,如果出现了ArithmeticException异常,则先使用catch捕获,记录到日志中,然后使用throw关键抛出这个异常。


    这个骚操作纯属是为了记录日志。


    但最后发现日志记录两次。


    因为在后续的处理中,可能会将这个ArithmeticException异常又记录一次。


    这样就会导致日志重复记录了。


    10 优先使用标准异常


    在Java中已经定义了许多比较常用的标准异常,比如下面这张图中列出的这些异常:图片


    反例:


    public void checkValue(int value) {
        if (value < 0) {
            throw new MyIllegalArgumentException("值不能为负");
        }
    }

    自定义了一个异常表示参数错误。


    其实,我们可以直接复用已有的标准异常。


    正例:


    public void checkValue(int value) {
        if (value < 0) {
            throw new IllegalArgumentException("值不能为负");
        }
    }

    11 对异常进行文档说明


    我们在写代码的过程中,有一个好习惯是给方法、参数和返回值,增加文档说明。


    反例:


    /*  
     *  处理用户数据
     *  @param value 用户输入参数
     *  @return 值 
     */

    public int doSomething(String value) 
         throws BusinessException 
    {
         //业务逻辑
         return 1;
    }

    这个doSomething方法,把方法、参数、返回值都加了文档说明,但异常没有加。


    正解:


    /*  
     *  处理用户数据
     *  @param value 用户输入参数
     *  @return 值
     *  @throws BusinessException 业务异常
     */

    public int doSomething(String value) 
         throws BusinessException 
    {
         //业务逻辑
         return 1;
    }

    抛出的异常,也需要增加文档说明。


    12 别用异常控制程序的流程


    我们有时候,在程序中使用异常来控制了程序的流程,这种做法其实是不对的。


    反例:


    Long id = null;
    try {
       id = Long.parseLong(idStr);
    catch(NumberFormatException e) {
       id = 1001;
    }

    如果用户输入的idStr是Long类型,则将它转换成Long,然后赋值给id,否则id给默认值1001。


    每次都需要try/catch还是比较影响系统性能的。


    正例:


    Long id = checkValueType(idStr) ? Long.parseLong(idStr) : 1001;

    我们增加了一个checkValueType方法,判断idStr的值,如果是Long类型,则直接转换成Long,否则给默认值1001。


    13 自定义异常


    如果标准异常无法满足我们的业务需求,我们可以自定义异常。


    例如:


    /**
     * 业务异常
     *
     * 
    @author 苏三
     * 
    @date 2024/1/9 下午1:12
     */

    @AllArgsConstructor
    @Data
    public class BusinessException extends RuntimeException {

        public static final long serialVersionUID = -6735897190745766939L;

        /**
         * 异常码
         */

        private int code;

        /**
         * 具体异常信息
         */

        private String message;

        public BusinessException() {
            super();
        }

        public BusinessException(String message) {
            this.code = HttpStatus.INTERNAL_SERVER_ERROR.value();
            this.message = message;
        }
    }

    对于这种自定义的业务异常,我们可以增加code和message这两个字段,code表示异常码,而message表示具体的异常信息。


    BusinessException继承了RuntimeException运行时异常,后面处理起来更加灵活。


    提供了多种构造方法。


    定义了一个序列化ID(serialVersionUID)。


    作者:苏三说技术
    来源:juejin.cn/post/7429267019445387276
    收起阅读 »

    SQLite这么小众的数据库,到底是什么人在用

    前几天在一个群里看到一位同学说:“SQLite这么小众的数据库,到底是什么人在用啊?”首先要说的是 SQLite 可不是小众的数据库,相反,SQLite 是世界上装机量最多的数据库,远超 MySQL,只不过比较低调而已。低调到我想在官网上找一个好看的用来当插图...
    继续阅读 »

    前几天在一个群里看到一位同学说:“SQLite这么小众的数据库,到底是什么人在用啊?”

    首先要说的是 SQLite 可不是小众的数据库,相反,SQLite 是世界上装机量最多的数据库,远超 MySQL,只不过比较低调而已。低调到我想在官网上找一个好看的用来当插图的图片都找不到,只能截一张官网首页来撑一撑,看起来十分朴素。

     我最早听说 SQLite 是刚毕业工作的时候,我们部门做微软内容管理产品的二次开发,其中有一个客户端即时沟通工具叫做 Lync,搭配上 LDAP 的组织架构,其功能就和现在的企业微信差不多。

    Lync 支持二次扩展,结合我们的产品需要在其中做一些功能拓展,负责这项工作的是一位厉害的 C++ 大佬。有一次我和他聊起来,我说客户端要记住用户自己的配置和数据,是不是要在目录下放一个配置文件啊,那数据量大了会不会很慢。他说,用配置文件也行,但是咱这个不用配置文件,用 SQLite。

    也是孤陋寡闻,那是我第一次听说 SQLite,才知道这也是个数据库,只不过多用在客户端而不是服务器上。

    SQLite

    SQLite是一个轻量级的嵌入式关系型数据库管理系统。它由D. Richard Hipp在2000年开发,它实现了一个小型、快速、独立、高可靠性、功能齐全的SQL数据库引擎。

    SQLite 用C语言开发,最开始的设计目标是嵌入式系统,它可以在不需要单独的服务器进程的情况下,直接嵌入到应用程序中。后来正好赶上智能手机等智能设备普及,正好契合 SQLite 的使用场景,于是大量的智能设备都在使用 SQLite 。这么说吧,你用的手机上,一定有 SQLite 存在。

    像 MySQL 一样,SQLite 也是开源且免费的,据官方统计,目前正在使用的 SQLite 数据库超过 1 万亿个。

    SQLite 也可以通过配置像MySQL 那样装在服务器上,通过网络连接访问,但是,完全没有必要。

    SQLite 支持C、C++、Java、Python、Swift等大多数语言直接使用。

    为什么说你的手机上肯定有 SQLite 呢?因为 SQLite 会随着应用程序代码一起打包,所以这样说来,你的手机上还不止一个 SQLite ,可能有很多,例如微信有一个、美团有一个、网易云音乐等等 APP ,都可能包含自己的 SQLite。

    使用场景有哪些

    移动应用

    前面也一直在说手机上的SQLite。Android就默认集成了SQLite作为应用数据存储的标准解决方案。

    Apple 的 IOS 其实提供了自己的数据存储方案,比如 CoreData,但是很多开发者都觉得官方提供的方案实在太难用,所以,有很多应用开发者还是选择 SQLite 作为本地存储方案使用。

    嵌入式系统

    SQLite 本来就是为了嵌入式系统设计的,所以它的特点就是轻量和高性能吗,这也使得他在嵌入式系统中被广泛使用。包括嵌入式Linux设备、物联网(IoT)设备、路由器,以及汽车电子系统等等。 

    桌面应用

    许多桌面应用程序使用SQLite作为其内部数据库,我第一次听说 SQLite 就是那位同事大佬为了拓展桌面客户端。

    尤其是一些纯的本地应用,不需要联网的,所有的配置和数据都会存在本地,这种场景正好适合SQLite 这种轻量级数据库。

    数据分析和处理

    SQLite还可以用于处理和分析小规模的数据集。例如,数据科学家可以使用SQLite来存储和操作中小型数据集,以进行数据清理、转换和分析。

    网站加速

    最近看了一篇文章,介绍 Notion 技术团队如何使用WASM SQLite在浏览器中加速Notion 的性能。

    WebAssembly (WASM) 是一种低级字节码格式,能够在现代浏览器中高效运行。它被设计为一个可移植的目标,可以被多种编程语言编译成它。 它有接近原生的性能,同时可以安全地运行在浏览器的沙箱环境中。

    所以为了追求更好的性能,有些像 Notion 这样的网站直接将 SQLite 编译到 WebAssembly,相当于在网站中加入了 SQLite。

    这样一来,更多的数据存到本地 SQLite ,减少不必要的网络交互,对于网站的速度和性能会有很大提升。


    作者:古时的风筝
    来源:juejin.cn/post/7396563478012051495
    收起阅读 »

    BOE(京东方)携手雷神联合发布全球首款仿生蜂鸟屏 以全新升级ACR技术引领显示产业高端化的升维发展

    2024年10月22日,BOE(京东方)携手雷神重磅发布双方联合研发的全球首款仿生科技蜂鸟屏,该屏幕应用了BOE(京东方)全新升级的ACR(Ambient Contrast Ratio)环境光对比度技术,具有高环境光对比度、低反射率、广色域及防眩光等显著优势,...
    继续阅读 »

    2024年10月22日,BOE(京东方)携手雷神重磅发布双方联合研发的全球首款仿生科技蜂鸟屏,该屏幕应用了BOE(京东方)全新升级的ACR(Ambient Contrast Ratio)环境光对比度技术,具有高环境光对比度、低反射率、广色域及防眩光等显著优势,这是双方共建京雷显示创新联合实验室后推出的首款标杆性新品,也是双方合作的一个新的里程碑。该产品的成功发布,不仅为广大用户带来前所未有的视觉体验,也充分彰显了BOE(京东方)以创新技术赋能合作伙伴,引领显示产业高端化升维发展的实力。

    BOE(京东方)高级副总裁、首席技术官刘志强表示,“长久以来,BOE(京东方)与雷神科技建立了稳固而紧密的合作伙伴关系,双方聚焦于市场洞察及消费者的实际需求,联合打造出一系列实现双方优势互补、引领行业的产品标杆案例。我们联合发布的雷神蜂鸟屏搭载了全球首发的ACR技术及360Hz超高刷新率,可以给用户带来更加流畅、细腻的画面效果,推动整个IT行业向更高端的显示技术标准迈进。未来,我们将继续携手共进,依托京雷显示创新联合实验室这一坚实后盾,共同探索更多创新技术和产品,为用户创造更大的价值。”

    相比实验室中的暗光对比度数值,在真实应用场景中,室内常规照明情况下的环境光对比度对用户才更有意义,也是决定用户真实体验的关键指标。BOE(京东方)此次全新升级的ACR技术能够实现200:1的超高环境光对比度,并配合1800:1的高对比度,600nit高亮度,DCI-P3 100%高色域,使屏幕在不同光线条件下均能够呈现出更深邃的黑色及更明亮的白色,为用户带来更加清晰、逼真的显示画面。同时,全新的ACR技术在低反射方面的表现也可圈可点,通过应用定制化低反偏光片材料及工艺技术的提升,实现显示模组反射率及出射光散射率大幅度降低,有效解决了显示产品在不同环境光条件下强反射、刺眼问题,保证用户在不同使用场景下都能够获得出色的观看体验。实测数据显示,搭载BOE(京东方)全新ACR技术的屏幕反射率平均值降低至2.7%,特别是在电竞游戏场景下,显著改善室内顶灯对屏幕带来的反射投影问题,降低外界环境光对屏幕的干扰,提升屏幕信息的可读性,为用户带来更具沉浸式的互动体验。值得关注的是,京雷显示创新联合实验室联合推出的全球首款搭载ACR防眩光 2.5K 360Hz仿生科技雷神蜂鸟屏的电竞本将于10月31日新品开售,让用户可畅享超高画质、超高对比度、超高刷新率的的极致视觉体验。

    雷神科技创始人、董事长路凯林表示,“多年来我们与BOE(京东方)紧密合作,共同攻克技术难关,不断探索电竞显示的新境界。雷神蜂鸟屏不仅是京雷显示创新联合实验室的骄傲,也是雷神科技的骄傲。我们相信,这款产品将重新定义电竞显示的标准,也是我们对未来电竞产业发展的一次有力推动。我们期待未来与BOE(京东方)继续携手,深入合作,为用户带来更多令人激动的创新产品。”

    作为电竞领域的重要合作伙伴,BOE(京东方)携手雷神科技在十余年间推出了多款全球首发的创新技术和产品,在笔记本、显示器等品类终端领域打造多款行业标杆案例,引领电竞显示发展的风向标。2018年,双方联合研发全球首款16.6英寸笔记本,以用户需求为导向在屏显尺寸优化方面取得全新突破;2023年,双方合作推出全球首发搭载4K HSR双模电竞屏的雷神ZERO旗舰电竞本,再次刷新电竞显示产品新高度。同年,BOE(京东方)还携手雷神共建京雷显示创新联合实验室,在电竞显示领域的技术研发、产品设计等多维度展开全面合作,助力显示技术持续升维。作为全球显示产业的龙头企业,BOE(京东方)始终坚持以“技术+品牌”双价值驱动,从技术、产品、生态等多个方面助力显示产业发展,持续以创新显示技术赋能全球众多一线品牌的首发新品发布,获得了国内外客户的一致支持和好评,充分彰显BOE(京东方)强大的技术实力与行业领导力。

    面向未来,BOE(京东方)始终秉持对技术的尊重和对创新的坚持,聚焦显示技术的革新及创新应用,将推出更多具有技术创新性及市场竞争力的产品,满足用户对高端技术与极致体验的美好追求。同时,BOE(京东方)也将秉承“屏之物联”发展战略,携手合作伙伴共创显示产业新高地,积极构建“Powered by BOE”产业价值创新生态,持续推动全产业链的高质量可持续发展。


    收起阅读 »

    在线人数统计功能怎么实现?

    一、前言 大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。 在线人数统计这个功能相信大家一眼就明白是啥,这个功能不难做,实现的方式也很多,这里说一下我常使用的方...
    继续阅读 »

    一、前言


    大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。


    在线人数统计这个功能相信大家一眼就明白是啥,这个功能不难做,实现的方式也很多,这里说一下我常使用的方式:使用Redis的有序集合(zset)实现。
    核心方法是这四个:zaddzrangeByScorezremrangeByScorezrem


    二、实现步骤


    1. 如何认定用户是否在线?


    认定用户在线的条件一般跟网站有关,如果网站需要登录才能进入,那么这种网站就是根据用户的token令牌有效性判断是否在线;
    如果网站是公开的,是那种不需要登录就可以浏览的,那么这种网站一般就需要自定一个规则来识别用户,也有很多方式实现如IPdeviceId浏览器指纹,推荐使用浏览器指纹的方式实现。


    浏览器指纹可能包括以下信息的组合:用户代理字符串 (User-Agent string)、HTTP请求头信息、屏幕分辨率和颜色深度、时区和语言设置、浏览器插件详情等。现成的JavaScript库,像 FingerprintJSClientJS,可以帮助简化这个过程,因为它们已经实现了收集上述信息并生成唯一标识的算法。


    使用起来也很简单,如下:


    // 安装:npm install @fingerprintjs/fingerprintjs

    // 使用示例:
    import FingerprintJS from '@fingerprintjs/fingerprintjs';

    // 初始化指纹JS Library
    FingerprintJS.load().then(fp => {
    // 获取访客ID
    fp.get().then(result => {
    const visitorId = result.visitorId;
    console.log(visitorId);
    });
    });


    这样就可以获取一个访问公开网站的用户的唯一ID了,当用户访问网站的时候,将这个ID放到访问链接的Cookie或者header中传到后台,后端服务根据这个ID标示用户。


    2. zadd命令添加在线用户


    (1)zadd命令介绍
    zadd命令有三个参数



    key:有序集合的名称。
    score1、score2 等:分数值,可以是整数值或双精度浮点数。
    member1、member2 等:要添加到有序集合的成员。
    例子:向名为 myzset 的有序集合中添加一个成员:ZADD myzset 1 "one"



    (2)添加在线用户标识到有序集合中


    // expireTime给用户令牌设置了一个过期时间
    LocalDateTime expireTime = LocalDateTime.now().plusSeconds(expireTimeout);
    String expireTimeStr = DateUtil.formatFullTime(expireTime);
    // 添加用户token到有序集合中
    redisService.zadd("user.active", Double.parseDouble(expireTimeStr), userToken);


    由于一个用户可能户会重复登录,这就导致userToken也会重复,但为了不重复计算这个用户的访问次数,zadd命令的第二个参数很好的解决了这个问题。
    我这里的逻辑是:每次添加一个在线用户时,利用当前时间加上过期时间计算出一个分数,可以有效保证当前用户只会存在一个最新的登录态。



    3. zrangeByScore命令查询在线人数


    (1)zrangeByScore命令介绍



    key:指定的有序集合的名字。
    min 和 max:定义了查询的分数范围,也可以是 -inf 和 +inf(分别表示“负无穷大”和“正无穷大”)。
    例子:查询分数在 1 到 3之间的所有成员:ZRANGEBYSCORE myzset 1 3



    (2)查询当前所有的在线用户


    // 获取当前的日期
    String now = DateUtil.formatFullTime(LocalDateTime.now());
    // 查询当前日期到"+inf"之间所有的用户
    Set userOnlineStringSet = redisService.zrangeByScore("user.active", now, "+inf");


    利用zrangeByScore方法可以查询这个有序集合指定范围内的用户,这个userOnlineStringSet也就是在线用户集,它的size就是在线人数了。



    4. zremrangeByScore命令定时清除在线用户


    (1)zremrangeByScore命令介绍



    key:指定的有序集合的名字。
    min 和 max:定义了查询的分数范围,也可以是 -inf 和 +inf(分别表示“负无穷大”和“正无穷大”)。
    例子:删除分数在 1 到 3之间的所有成员:ZREMRANGEBYSCORE myzset 1 3



    (2)定时清除在线用户


    // 获取当前的日期
    String now = DateUtil.formatFullTime(LocalDateTime.now());
    // 清除当前日期到"-inf"之间所有的用户
    redisService.zremrangeByScore(""user.active"","-inf", now);


    由于有序集合不会自动清理下线的用户,所以这里我们需要写一个定时任务去定时删除下线的用户。



    5. zrem命令用户退出登录时删除成员


    (1)zrem命令介绍



    key:指定的有序集合的名字。
    members:需要删除的成员
    例子:删除名为xxx的成员:ZREM myzset "xxx"



    (2)定时清除在线用户


    // 删除名为xxx的成员
    redisService.zrem("user.active", "xxx");


    删除 zset中的记录,确保主动退出的用户下线。



    三、小结一下


    这种方案的核心逻辑就是,创建一个在线用户身份集合为key,利用用户身份为member,利用过期时间为score,然后对这个集合进行增删改查,实现起来还是比较巧妙和简单的,大家有兴趣可以试试看。


    作者:summo
    来源:juejin.cn/post/7356065093060427816
    收起阅读 »

    nginx(前端必会-项目部署-精简通用篇)

    前言 最近在公司部署项目时遇上了一点关于nginx的问题,于是就想着写一篇关于nginx的文章... 主要给小白朋友分享,nginx是什么,nginx有什么用,最后到nginx的实际应用,项目部署。 nginx 公司项目刚刚上线,用户量少访问量不大,并发量低,...
    继续阅读 »

    前言


    最近在公司部署项目时遇上了一点关于nginx的问题,于是就想着写一篇关于nginx的文章...


    主要给小白朋友分享,nginx是什么,nginx有什么用,最后到nginx的实际应用,项目部署。


    nginx


    公司项目刚刚上线,用户量少访问量不大,并发量低,一个jar包启动应用就能够解决问题了。但是随着项目的不断扩大,用户体量大增加,一台服务器可能就无法满足我们的需求了(当初是一个人用一个服务器,现在是多人用一个服务器,时间长了服务器都要红温)。


    于是乎,我们就会想着增加服务器,那么我们多个项目就启动在多个服务器上面,用户需要访问,就需要做一个代理服务器,通过代理请求服务器来做前后端之间的转发和请求包括跨域等等问题。


    那么到这就不得不说一下nginx的反向代理了,正向代理指的其实就是比如我们通过VPN去请求xxx,这里就是因为用到了其他地方的代理服务器,这是一个从客户端到服务端的过程,然而反向代理其实就是,因为我们有多个服务器,最后都映射到了代理服务器身上,客户端最终访问的都是例如:baidu.com,但是事实上他底下是有多台服务器的。


    既然他有多台服务器,每台服务器的性能,各种条件都是不同的,这里就要说到nginx的另一个能力---负载均衡,可以给不同的服务器增加不同的权重,能力更强的服务器可以增大他的负荷,减轻其他服务器的负荷


    这就是大家常说的nginx:Nginx 是一个高性能的 HTTP反向代理服务器,它还支持 IMAP/POP3/SMTP 代理服务器。


    nginx的特点:



    1. 高性能



      • 高并发连接处理能力:Nginx 使用异步事件驱动模型(如 epoll, kqueue 等),能够高效地处理大量并发连接。

      • 低资源消耗:与 Apache 相比,Nginx 在相同硬件环境下通常消耗更少的内存和其他系统资源。



    2. 稳定性



      • 运行稳定:在高负载情况下依然保持稳定运行,崩溃或错误的发生率较低。

      • 平滑升级:可以在不停止服务的情况下进行升级或配置更改。



    3. 丰富的功能集



      • 反向代理:可以作为反向代理服务器,将请求转发到后端服务器。

      • URL 重写:通过简单的配置即可实现复杂的 URL 重写规则。

      • 动态内容与静态内容分离:可以配置为只处理静态文件请求,而动态请求则交给后端应用服务器处理。



    4. 高度可配置性



      • 灵活的配置选项:可以根据需要定制各种配置选项,以适应不同的应用场景。

      • 容易管理:配置文件结构清晰,易于理解和修改。



    5. 负载均衡



      • 支持多种负载均衡算法,例如轮询、加权轮询、最少连接数等,可以帮助分散到多个后端服务器的流量。



    6. 缓存功能



      • 可用作HTTP缓存服务器,减少对后端数据库或应用服务器的压力。



    7. 安全性



      • 提供 SSL/TLS 加密支持,保障数据传输安全。

      • 可以设置访问控制、防火墙规则等来增强安全性。



    8. 模块化架构



      • 支持第三方模块扩展功能,比如 Nginx+Lua 使得开发者可以在 Nginx 中直接使用 Lua 脚本语言编写插件或处理逻辑。



    9. 日志与监控



      • 详细的访问和错误日志记录,便于故障排查和性能分析。

      • 支持实时监控和统计,方便管理员了解当前系统的状态。




    nginx下载


    nginx.org/ 大家自行下载,我下载的是一个稳定版本,以防万一。下载完毕之后大家自行解压即可(默认大家是windows系统),解压完毕之后,可以看到nginx.exe就是我们的启动文件,conf就是配置文件,nginx.config中可以看到server的listen监听端口为80,这意味着当我们访问了80端口就会被nginx拦截,首先启动nginx,可以直接双击nginx.exe也可以通过cmd命令行直接输入nginx.exe运行(推荐,因为这样不会关闭窗口,双击的话就是一闪而过了)


    image.png


    接下来我们浏览器访问localhost:80


    image.png


    启动成功。


    nginx常用命令


    停止:nginx.exe -s stop
    安全退出:nginx.exe -s quit
    重新加载配置文件:nginx.exe -s reload(常用)例如我们更改了端口
    查看nginx进程:ps aux|grep nginx


    实际应用


    下载完毕后打开可以看到:


    image.png


    image.png


    于是我们建立aa、bb两个文件夹,我们将index.html 分别放入aa和bb,这两个页面都打上自己的标记aa/bb


    image.png


    然后我们对nginx进行配置 nginx.conf


    server {
    listen 8001;
    server_name localhost;

    location / {
    root html/aa;
    index index.html index.htm;
    }
    }

    server {
    listen 8002;
    server_name localhost;

    location / {
    root html/bb;
    index index.html index.htm;
    }
    }

    如果没结束,记得reload


    nginx.exe -s reload

    image.png


    image.png


    接下来我们放一个项目进去,打包后放入html中


    image.png


    修改配置文件,然后reload


     server {
    listen 80;
    server_name localhost;

    #charset koi8-r;

    #access_log logs/host.access.log main;

    location / {
    root html/dist;
    index index.html index.htm;
    }


    然后我们访问localhost,端口默认是80所以不用写,如果失败,可能是reload失败,再次reload就可


    image.png


    其他配置问题


    Nginx的主配置文件(conf/nginx.conf)按以下结构组织:



    • 全局块 与Nginx运行相关的全局设置

    • events 与网络连接有关的设置

    • http 代理、缓存、日志、虚拟主机等的配置

    • server 虚拟主机的参数设置(一个http块可包含多个server块)

    • location 定义请求路由及页面处理方式


    前端开发中经常会遇到跨域问题,nginx可以做代理轻松解决,事实上原理和cors一样,设置请求头


    server {
       location /api {
           proxy_pass http://backend_server;
           add_header Access-Control-Allow-Origin *;
           add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
           add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept';
      }
    }


    缓存问题:


    proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m max_size=1g inactive=60m use_temp_path=off;

    server {
       location / {
           proxy_cache my_cache;
           proxy_pass http://backend;
           add_header X-Cache-Status $upstream_cache_status;
      }
    }


    https提升安全性


    server {
       listen 443 ssl;
       server_name example.com;

       ssl_certificate /etc/nginx/ssl/example.com.crt;
       ssl_certificate_key /etc/nginx/ssl/example.com.key;

       location / {
           proxy_pass http://backend_server;
      }
    }


    一个比较全面的配置


    # 全局段配置
    # ------------------------------

    # 指定运行nginx的用户或用户组,默认为nobody。
    #user administrator administrators;

    # 设置工作进程数,通常设置为等于CPU核心数。
    #worker_processes 2;

    # 指定nginx进程的PID文件存放位置。
    #pid /nginx/pid/nginx.pid;

    # 指定错误日志的存放路径和日志级别。
    error_log log/error.log debug;

    # events段配置信息
    # ------------------------------
    events {
    # 设置网络连接序列化,用于防止多个进程同时接受到新连接的情况,这种情况称为"惊群"
    accept_mutex on;

    # 设置一个进程是否可以同时接受多个新连接。
    multi_accept on;

    # 设置工作进程的最大连接数。
    worker_connections 1024;
    }

    # http配置段,用于配置HTTP服务器的参数。
    # ------------------------------
    http {
    # 包含文件扩展名与MIME类型的映射。
    include mime.types;

    # 设置默认的MIME类型。
    default_type application/octet-stream;

    # 定义日志格式。
    log_format myFormat '$remote_addr–$remote_user [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent $http_x_forwarded_for';

    # 指定访问日志的存放路径和使用的格式。
    access_log log/access.log myFormat;

    # 允许使用sendfile方式传输文件。
    sendfile on;

    # 限制每次调用sendfile传输的数据量。
    sendfile_max_chunk 100k;

    # 设置连接的保持时间。
    keepalive_timeout 65;

    # 定义一个上游服务器组。
    upstream mysvr {
    server 127.0.0.1:7878;
    server 192.168.10.121:3333 backup; #此服务器为备份服务器。
    }

    # 定义错误页面的重定向地址。
    error_page 404 https://www.baidu.com;

    # 定义一个虚拟主机。
    server {
    # 设置单个连接上的最大请求次数。
    keepalive_requests 120;

    # 设置监听的端口和地址。
    listen 4545;
    server_name 127.0.0.1;

    # 定义location块,用于匹配特定的请求URI
    location ~*^.+$ {
    # 设置请求的根目录。
    #root path;

    # 设置默认页面。
    #index vv.txt;

    # 将请求转发到上游服务器组。
    proxy_pass http://mysvr;

    # 定义访问控制规则。
    deny 127.0.0.1;
    allow 172.18.5.54;
    }
    }
    }


    如果有不明白的地方,遇到问题可以通过ai去迅速了解,在ai时代,我们的学习成本也大大下降了。


    小结


    本次主要带小白朋友学习nginx是什么、用于做什么,nginx的常用命令,nginx如何进行配置,最后实际操作一次简单的nginx。


    作者:zykk
    来源:juejin.cn/post/7424168473423020066
    收起阅读 »

    Kafka 为什么要抛弃 Zookeeper?

    嗨,你好,我是猿java 在很长一段时间里,ZooKeeper都是 Kafka的标配,现如今,Kafka官方已经在慢慢去除ZooKeeper,Kafka 为什么要抛弃 Zookeeper?这篇文章我们来聊聊其中的缘由。 Kafka 和 ZooKeeper 的关...
    继续阅读 »

    嗨,你好,我是猿java


    在很长一段时间里,ZooKeeper都是 Kafka的标配,现如今,Kafka官方已经在慢慢去除ZooKeeper,Kafka 为什么要抛弃 Zookeeper?这篇文章我们来聊聊其中的缘由。


    Kafka 和 ZooKeeper 的关系


    ZooKeeper 是一个分布式协调服务,常用于管理配置、命名和同步服务。长期以来,Kafka 使用 ZooKeeper 负责管理集群元数据、控制器选举和消费者组协调等任务理,包括主题、分区信息、ACL(访问控制列表)等。


    ZooKeeper 为 Kafka 提供了选主(leader election)、集群成员管理等核心功能,为 Kafka提供了一个可靠的分布式协调服务,使得 Kafka能够在多个节点之间进行有效的通信和管理。然而,随着 Kafka的发展,其对 ZooKeeper的依赖逐渐显露出一些问题,这些问题也是下面 Kafka去除 Zookeeper的原因。


    抛弃ZooKeeper的原因


    1. 复杂性增加


    ZooKeeper 是独立于 Kafka 的外部组件,需要单独部署和维护,因此,使用 ZooKeeper 使得 Kafka的运维复杂度大幅提升。运维团队必须同时管理两个分布式系统(Kafka和 ZooKeeper),这不仅增加了管理成本,也要求运维人员具备更高的技术能力。


    2. 性能瓶颈


    作为一个协调服务,ZooKeeper 并非专门为高负载场景设计, 因此,随着集群规模扩大,ZooKeeper在处理元数据时的性能问题日益突出。例如,当分区数量增加时,ZooKeeper需要存储更多的信息,这导致了监听延迟增加,从而影响Kafka的整体性能34。在高负载情况下,ZooKeeper可能成为系统的瓶颈,限制了Kafka的扩展能力。


    3. 一致性问题


    Kafka 内部的分布式一致性模型与 ZooKeeper 的一致性模型有所不同。由于 ZooKeeper和 Kafka控制器之间的数据同步机制不够高效,可能导致状态不一致,特别是在处理集群扩展或不可用情景时,这种不一致性会影响消息传递的可靠性和系统稳定性。


    4. 发展自己的生态


    Kafka 抛弃 ZooKeeper,我个人觉得最核心的原因是:Kafka生态强大了,需要自立门户,这样就不会被别人卡脖子。纵观国内外,有很多这样鲜活的例子,当自己弱小时,会先选择使用别家的产品,当自己羽翼丰满时,再选择自建完善自己的生态圈。


    引入KRaft


    为了剥离和去除 ZooKeeper,Kafka 引入了自己的亲儿子 KRaft(Kafka Raft Metadata Mode)。KRaft 是一个新的元数据管理架构,基于 Raft 一致性算法实现的一种内置元数据管理方式,旨在替代 ZooKeeper 的元数据管理功能。其优势在于:



    1. 完全内置,自包含:KRaft 将所有协调服务嵌入 Kafka 自身,不再依赖外部系统,这样大大简化了部署和管理,因为管理员只需关注 Kafka 集群。

    2. 高效的一致性协议:Raft 是一种简洁且易于理解的一致性算法,易于调试和实现。KRaft 利用 Raft 协议实现了强一致性的元数据管理,优化了复制机制。

    3. 提高元数据操作的扩展性:新的架构允许更多的并发操作,并减少了因为扩展性问题导致的瓶颈,特别是在高负载场景中。

    4. 降低延迟:在消除 ZooKeeper 作为中间层之后,Kafka 的延迟性能有望得到改善,特别是在涉及选主和元数据更新的场景中。

    5. 完全自主:因为是自家产品,所以产品的架构设计,代码开发都可以自己说了算,未来架构走向完全控制在自己手上。


    KRaft的设计细节



    1. 控制器(Controller)节点的去中心化:KRaft 模式中,控制器节点由一组 Kafka 服务进程代替,而不是一个独立的 ZooKeeper 集群。这些节点共同负责管理集群的元数据,通过 Raft 实现数据的一致性。

    2. 日志复制和恢复机制:利用 Raft 的日志复制和状态机应用机制,KRaft 实现了对元数据变更的强一致性支持,这意味着所有控制器节点都能够就集群状态达成共识。

    3. 动态集群管理:KRaft 允许动态地向集群中添加或移除节点,而无需手动去 ZooKeeper 中更新配置,这使得集群管理更为便捷。


    下面给出一张 Zookeeper 和 KRaft的对比图:


    why-kafka-deprecates-zookeeper-1.jpg


    总结


    本文,我们分析了为什么 Kafka 要移除 ZooKeeper,主要原因有两个:ZooKeeper不能满足 Kafka的发展以及 Kafka想创建自己的生态。在面临越来越复杂的数据流处理需求时,KRaft 模式为 Kafka 提供了一种更高效、简洁的架构方案。不论结局如何,Kafka 和 ZooKeeper曾经也度过了一段美好的蜜月期,祝福 Kafka 在 KRaft模式越来越强大,为使用者带来更好的体验。


    学习交流


    如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。


    作者:猿java
    来源:juejin.cn/post/7425812523129634857
    收起阅读 »

    面试官:为什么你们项目中还在用多表关联!

    我们来看这样一个面试场景。面试官:“在你们的项目中,用到多表关联查询了吗?”候选人:“嗯,每个项目都用到了。”面试官听了似乎有些愤怒,说:“多表关联查询这么慢,为什么你们还要用它,那你们项目的性能如何保障呢?”面对这突如其来地质问,候选人明显有些慌了,解释道:...
    继续阅读 »

    我们来看这样一个面试场景。

    面试官:“在你们的项目中,用到多表关联查询了吗?”

    候选人:“嗯,每个项目都用到了。”

    面试官听了似乎有些愤怒,说:“多表关联查询这么慢,为什么你们还要用它,那你们项目的性能如何保障呢?”

    面对这突如其来地质问,候选人明显有些慌了,解释道:“主要是项目周期太紧张了,这样写在开发效率上能高一些,后期我们会慢慢进行优化的。”

    面试官听了,带着三分理解、三分无奈、四分恨铁不成钢地摇了摇头。

    面试之后,这个同学问我说:“学长,是不是在项目中使用多表关联太low了,可是从业这五年多,我换过三家公司,哪个公司的项目都是这么用的。”

    下面我来从实现原理的角度,回答一下这个问题。

    “是否应该使用多表关联”,这绝对是个王炸级别的话题了,其争议程度,甚至堪比“中医废存之争”了。

    一部分人坚定地认为,应该禁止使用SQL语句进行多表关联,正确的方式是把各表的数据读到应用程序中来,再由程序进行数据merge操作。

    而另一部分人则认为,在数据库中进行多表关联是没有问题的,但需要多关注SQL语句的执行计划,只要别产生过大的资源消耗和过长的执行时间即可。

    嗯,我完全支持后者的观点,况且MySQL在其底层算法的优化上,一直在努力完善。

    MySQL表连接算法

    我们以最常用的MySQL数据库为例,其8.0版本支持的表连接算法为Nested-Loops Join(嵌套循环连接)和Hash Join(哈希连接)。

    如下图所示:

    嵌套循环连接算法,顾名思义,其实现方式是通过内外层循环的方式进行表连接,SQL语句中有几个表进行连接就实现为几层循环。

    接下来,我们看看嵌套循环连接算法的四种细分实现方式。

    1、简单嵌套循环连接(Simple Nested-Loops Join)

    这是嵌套循环连接中最简单粗暴的一种实现方式,直接用驱动表A中符合条件的数据,一条一条地带入到被驱动表B中,进行全表扫描匹配。

    其伪代码实现为:

    for each row in table A matching range {
    for each row in table B matching join column{
    if row satisfies join conditions,send to client
    }
    }

    我们以下面的SQL语句举例:

    SELECT * FROM product p INNER JOIN order o ON p.id = o.product_id;

    其对应的详细执行步骤为:

    (1)外循环从product表中每次读取一条记录。

    (2)以内循环的方式,将该条记录与order表中的记录进行关联键的逐一比较,并找到匹配记录。

    (3)将product表中的记录和order表中的匹配记录进行关联,放到结果集中。

    (4)重复执行步骤1、2、3,直到内外两层循环结束。

    也就是说,如果驱动表A中符合条件的数据有一万条,那么就需要带入到被驱动表B中进行一万次全表扫描,这种查询效率会非常慢。因此,除非特殊场景,否则查询优化器不太会选择这种连接算法。

    2、索引嵌套循环连接(Index Nested-Loops Join)

    接上文,如果在被驱动表B的关联列上创建了索引,那MySQL查询优化器极大概率会选择这种这种实现方式,因为其非常高效。

    我们依然以下面的SQL语句举例:

    SELECT * FROM product p INNER JOIN order o ON p.id = o.product_id;

    其对应的详细执行步骤为:

    (1)外循环从product表中每次读取一条记录。

    (2)以内循环的方式,将该条记录与order表中关联键的索引进行匹配,直接找到匹配记录。

    (3)将product表中的记录和order表中的匹配记录进行关联,放到结果集中。

    (4)重复执行步骤1、2、3,直到内外两层循环结束。

    这里需要说明一下,若order表的关联列是主键索引,则可以直接在表中查到记录;若order表的关联列是二级索引,则通过索引扫描的方式查到记录的主键,然后再回表查到具体记录。

    3、缓存块嵌套循环连接(Block Nested-Loops Join)

    我们在上文中说过,在简单嵌套循环连接中,如果驱动表A中符合条件的数据有一万条,那么就需要带入到被驱动表B中进行一万次全表扫描,这种查询效率会非常慢。

    而缓存块嵌套循环连接,则正是针对于该场景进行的优化。

    当驱动表A进行循环匹配的时候,数据并不会直接带入到被驱动表B,而是使用Join Buffer(连接缓存)先缓存起来,等到Join Buffer满了再去一次性关联被驱动表B,这样可以减少被驱动表B被全表扫描的次数,提升查询性能。

    其伪代码实现为:

    for each row in table A matching range {
    store join column in join buffer
    ifjoin buffer is full){
    for each row in table B matching join column{
    if row satisfies join conditions,send to client
    }
    }
    }

    我们依然以下面的SQL语句举例:

    SELECT * FROM product p INNER JOIN order o ON p.id = o.product_id;

    其对应的详细执行步骤为:

    (1)外循环从product表中每次读取一定数量的记录,将Join Buffer装满。

    (2)以内循环的方式,将Join Buffer中记录与order表中的记录进行关联键的逐一比较,并找到匹配记录。

    (3)将product表中的记录和order表中的匹配记录进行关联,放到结果集中。

    (4)重复执行步骤1、2、3,直到内外两层循环结束。

    需要注意的是,从MySQL 8.0.20开始,MySQL就不再使用缓存块嵌套循环连接了,将以前使用缓存块嵌套循环连接的场景全部改为哈希连接。

    所以,MySQL的研发者一直在努力优化这款产品,其产品本身也没有大家所想的那么弱鸡。

    4、批量键访问连接(Batched Key Access Joins)

    说到这里,不得不先提一下MySQL5.6版本的新特性,多范围读(Multi-Range Read)。

    我们继续拿product表的场景进行举例。

    SELECT * FROM product WHERE price > 5 and price < 20;

    没有使用MRR的情况下:

    由上图可见,在MySQL InnoDB中使用二级索引的范围扫描时,所获取到的主键ID是无序的,因此在数据量很大的情况下进行回表操作时,会产生大量的磁盘随机IO,从而影响数据库性能。

    使用MRR的情况下:

    显而易见的是,在二级索引和数据表之间增加了一层buffer,在buffer中进行主键ID的排序操作,这样回表操作就从磁盘随机I/O转化为顺序I/O,并可减少磁盘的I/O次数(1个page里面可能包含多个命中的record),提高查询效率。

    而从MySql 5.6开始支持Batched Key Access Joins,则正是结合了MRR和Join Buffer,以此来提高多表关联的执行效率,其发生的条件为被驱动表B有索引,并且该索引为非主键。

    其伪代码实现和详细步骤为:

    for each row in table A matching range {
    store join column in join buffer
    ifjoin buffer is full){
    for each row in table B matching join column{
    send to MRR interface,and order by its primary key
    if row satisfies join conditions,send to client
    }
    }
    }

    另外,如果查询优化器选择了批量键访问连接的实现方式,我们可以在执行计划中的Extra列中看到如下信息:

    Using where; Using join buffer (Batched Key Access)

    结语

    在本文中,我们介绍了嵌套循环连接的四种实现方式,下文会继续讲解哈希连接算法,以及跟在程序中进行多表数据merge的实现方式进行对比。


    作者:托尼学长
    来源:juejin.cn/post/7387626171158675466
    收起阅读 »

    都说PHP性能差,但PHP性能真的差吗?

    今天本能是想测试一个PDO持久化,会不会带来会话混乱的问题 先贴一下PHP代码, 代码丑了点,但是坚持能run就行,反正就是做个测试。 <?php $dsn = 'mysql:host=localhost;dbname=test;charset=utf8...
    继续阅读 »

    今天本能是想测试一个PDO持久化,会不会带来会话混乱的问题
    先贴一下PHP代码, 代码丑了点,但是坚持能run就行,反正就是做个测试。


    <?php
    $dsn = 'mysql:host=localhost;dbname=test;charset=utf8';
    $user = 'root';
    $password = 'root';

    // 设置 PDO 选项,启用持久化连接
    $options = [
    PDO::ATTR_PERSISTENT => true,
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
    ];

    try {
    // 创建持久化连接
    $pdo = new PDO($dsn, $user, $password, $options);

    $stmt = $pdo->prepare("INSERT INTO test_last_insert_id (uni) VALUES (:uni);");
    $uni = uniqid('', true);
    $stmt->bindValue(':uni', $uni);
    $aff = $stmt->execute(); //
    if ($aff === false) {
    throw new Exception("insert fail:");
    }
    $id = $pdo->lastInsertId();


    function getExecutedSql($stmt, $params)
    {
    $sql = $stmt->queryString;
    $keys = array();
    $values = array();

    // 替换命名占位符 :key with ?
    $sql = preg_replace('/\:(\w+)/', '?', $sql);

    // 绑定的参数可能包括命名占位符,我们需要将它们转换为匿名占位符
    foreach ($params as $key => $value) {
    $keys[] = '/\?/';
    $values[] = is_string($value) ? "'$value'" : $value;
    }

    // 替换占位符为实际参数
    $sql = preg_replace($keys, $values, $sql, 1, $count);

    return $sql;
    }


    $stmt = $pdo->query("SELECT id FROM test_last_insert_id WHERE uni = '{$uni}'", PDO::FETCH_NUM);
    $row = $stmt->fetch();
    $value = $row[0];
    if ($value != $id) {
    throw new Exception("id is diff");
    }

    echo "success" . PHP_EOL;

    } catch (PDOException $e) {
    header('HTTP/1.1 500 Internal Server Error');
    file_put_contents('pdo_perisistent.log', $e->getMessage() . PHP_EOL);
    die('Database connection failed: ' . $e->getMessage());
    } catch (Exception $e) {
    header('HTTP/1.1 500 Internal Server Error');
    file_put_contents('pdo_perisistent.log', $e->getMessage() . PHP_EOL);
    die('Exception: ' . $e->getMessage());
    }

    用wrk压测,一开始uniqid因为少了混淆参数还报了500,加了一下参数,用来保证uni值


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 52.17ms 7.48ms 103.38ms 80.57%
    Req/Sec 0.96k 133.22 1.25k 75.81%
    Latency Distribution
    50% 51.06ms
    75% 54.17ms
    90% 59.45ms
    99% 80.54ms
    5904 requests in 3.10s, 1.20MB read
    Requests/sec: 1901.92
    Transfer/sec: 397.47KB

    1900 ~ 2600 之间的QPS,其实这个数值还是相当满意的,测试会话会不会混乱的问题也算完结了。
    但是好奇心突起,之前一直没做过go和php执行sql下的对比,正好做一次对比压测


    package main

    import (
    "database/sql"
    "fmt"
    "net/http"
    "sync/atomic"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "log"
    )

    var id int64 = time.Now().Unix() * 1000000

    func generateUniqueID() int64 {
    return atomic.AddInt64(&id, 1)
    }

    func main() {
    dsn := "root:root@tcp(localhost:3306)/test?charset=utf8"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
    log.Fatalf("Error opening database: %v", err)
    }
    defer func() { _ = db.Close() }()

    //// 设置连接池参数
    //db.SetMaxOpenConns(100) // 最大打开连接数
    //db.SetMaxIdleConns(10) // 最大空闲连接数
    //db.SetConnMaxLifetime(time.Hour) // 连接最大存活时间

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    var err error
    uni := generateUniqueID()

    // Insert unique ID int0 the database
    insertQuery := `INSERT INTO test_last_insert_id (uni) VALUES (?)`
    result, err := db.Exec(insertQuery, uni)
    if err != nil {
    log.Fatalf("Error inserting data: %v", err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
    log.Fatalf("Error getting last insert ID: %v", err)
    }

    // Verify the last insert ID
    selectQuery := `SELECT id FROM test_last_insert_id WHERE uni = ?`
    var id int64
    err = db.QueryRow(selectQuery, uni).Scan(&id)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }

    if id != lastInsertID {
    log.Fatalf("ID mismatch: %d != %d", id, lastInsertID)
    }

    fmt.Println("success")
    })

    _ = http.ListenAndServe(":8080", nil)

    }

    truncate表压测结果,这低于预期了吧


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 54.05ms 36.86ms 308.57ms 80.77%
    Req/Sec 0.98k 243.01 1.38k 63.33%
    Latency Distribution
    50% 43.70ms
    75% 65.42ms
    90% 99.63ms
    99% 190.18ms
    5873 requests in 3.01s, 430.15KB read
    Requests/sec: 1954.08
    Transfer/sec: 143.12KB

    开个连接池,清表再测,结果半斤八两


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 54.07ms 35.87ms 281.38ms 79.84%
    Req/Sec 0.97k 223.41 1.40k 60.00%
    Latency Distribution
    50% 44.91ms
    75% 66.19ms
    90% 99.65ms
    99% 184.51ms
    5818 requests in 3.01s, 426.12KB read
    Requests/sec: 1934.39
    Transfer/sec: 141.68KB

    然后开启不清表的情况下,php和go的交叉压测


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 52.51ms 43.28ms 436.00ms 86.91%
    Req/Sec 1.08k 284.67 1.65k 65.00%
    Latency Distribution
    50% 40.22ms
    75% 62.10ms
    90% 102.52ms
    99% 233.98ms
    6439 requests in 3.01s, 471.61KB read
    Requests/sec: 2141.12
    Transfer/sec: 156.82KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 41.41ms 10.44ms 77.04ms 78.07%
    Req/Sec 1.21k 300.99 2.41k 73.77%
    Latency Distribution
    50% 38.91ms
    75% 47.62ms
    90% 57.38ms
    99% 69.84ms
    7332 requests in 3.10s, 1.50MB read
    Requests/sec: 2363.74
    Transfer/sec: 493.98KB

    // 这里骤降是我很不理解的不明白是因为什么
    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 156.72ms 75.48ms 443.98ms 66.10%
    Req/Sec 317.93 84.45 480.00 71.67%
    Latency Distribution
    50% 155.21ms
    75% 206.36ms
    90% 254.32ms
    99% 336.07ms
    1902 requests in 3.01s, 139.31KB read
    Requests/sec: 631.86
    Transfer/sec: 46.28KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 43.47ms 10.04ms 111.41ms 90.21%
    Req/Sec 1.15k 210.61 1.47k 72.58%
    Latency Distribution
    50% 41.17ms
    75% 46.89ms
    90% 51.27ms
    99% 95.07ms
    7122 requests in 3.10s, 1.45MB read
    Requests/sec: 2296.19
    Transfer/sec: 479.87KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 269.08ms 112.17ms 685.29ms 73.69%
    Req/Sec 168.22 125.46 520.00 79.59%
    Latency Distribution
    50% 286.58ms
    75% 335.40ms
    90% 372.61ms
    99% 555.80ms
    1099 requests in 3.02s, 80.49KB read
    Requests/sec: 363.74
    Transfer/sec: 26.64KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 41.74ms 9.67ms 105.86ms 91.72%
    Req/Sec 1.20k 260.04 2.24k 80.33%
    Latency Distribution
    50% 38.86ms
    75% 46.77ms
    90% 49.02ms
    99% 83.01ms
    7283 requests in 3.10s, 1.49MB read
    Requests/sec: 2348.07
    Transfer/sec: 490.71KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 464.85ms 164.66ms 1.06s 71.97%
    Req/Sec 104.18 60.01 237.00 63.16%
    Latency Distribution
    50% 467.00ms
    75% 560.54ms
    90% 660.70ms
    99% 889.86ms
    605 requests in 3.01s, 44.31KB read
    Requests/sec: 200.73
    Transfer/sec: 14.70KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
    Running 3s test @ http://localhost/pdo_perisistent.php
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 50.62ms 9.16ms 85.08ms 75.74%
    Req/Sec 0.98k 170.66 1.30k 69.35%
    Latency Distribution
    50% 47.93ms
    75% 57.20ms
    90% 61.76ms
    99% 79.90ms
    6075 requests in 3.10s, 1.24MB read
    Requests/sec: 1957.70
    Transfer/sec: 409.13KB

    % ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 568.84ms 160.91ms 1.04s 66.38%
    Req/Sec 81.89 57.59 262.00 67.27%
    Latency Distribution
    50% 578.70ms
    75% 685.85ms
    90% 766.72ms
    99% 889.39ms
    458 requests in 3.01s, 33.54KB read
    Requests/sec: 151.91
    Transfer/sec: 11.13KB

    go 的代码随着不断的测试,很明显处理速度在不断的下降,这说实话有点超出我的认知了。
    PHP那边却是基本稳定的,go其实一开始我还用gin测试过,发现测试结果有点超出预料,还改了用http库来测试,这结果属实差强人意了。


    突然明白之前经常看到别人在争论性能问题的时候,为什么总有人强调PHP性能并不差。
    或许PHP因为fpm的关系导致每次加载大量文件导致的响应相对较慢,比如框架laravel 那个QPS只有一两百的家伙,但其实这个问题要解决也是可以解决的,也用常驻内存的方式就好了。再不行还有phalcon


    我一直很好奇一直说PHP性能问题的到底是哪些人, 不会是从PHP转到其他语言的吧。


    % php -v
    PHP 8.3.12 (cli) (built: Sep 24 2024 18:08:04) (NTS)
    Copyright (c) The PHP Gr0up
    Zend Engine v4.3.12, Copyright (c) Zend Technologies
    with Xdebug v3.3.2, Copyright (c) 2002-2024, by Derick Rethans
    with Zend OPcache v8.3.12, Copyright (c), by Zend Technologies

    % go version
    go version go1.23.1 darwin/amd64

    image.png


    这结果,其实不太能接受,甚至都不知道原因出在哪了,有大佬可以指出问题一下吗


    加一下时间打印再看看哪里的问题


    package main

    import (
    "database/sql"
    "fmt"
    "net/http"
    "sync/atomic"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "log"
    )

    var id int64 = time.Now().Unix() * 1000000

    func generateUniqueID() int64 {
    return atomic.AddInt64(&id, 1)
    }

    func main() {
    dsn := "root:root@tcp(localhost:3306)/test?charset=utf8"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
    log.Fatalf("Error opening database: %v", err)
    }
    defer func() { _ = db.Close() }()

    // 设置连接池参数
    db.SetMaxOpenConns(100) // 最大打开连接数
    db.SetMaxIdleConns(10) // 最大空闲连接数
    db.SetConnMaxLifetime(time.Hour) // 连接最大存活时间

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    reqStart := time.Now()
    var err error
    uni := generateUniqueID()

    start := time.Now()
    // Insert unique ID int0 the database
    insertQuery := `INSERT INTO test_last_insert_id (uni) VALUES (?)`
    result, err := db.Exec(insertQuery, uni)
    fmt.Printf("insert since: %v uni:%d \n", time.Since(start), uni)
    if err != nil {
    log.Fatalf("Error inserting data: %v", err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
    log.Fatalf("Error getting last insert ID: %v", err)
    }

    selectStart := time.Now()
    // Verify the last insert ID
    selectQuery := `SELECT id FROM test_last_insert_id WHERE uni = ?`
    var id int64
    err = db.QueryRow(selectQuery, uni).Scan(&id)
    fmt.Printf("select since:%v uni:%d \n", time.Since(selectStart), uni)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }

    if id != lastInsertID {
    log.Fatalf("ID mismatch: %d != %d", id, lastInsertID)
    }

    fmt.Printf("success req since:%v uni:%d \n", time.Since(reqStart), uni)
    })

    _ = http.ListenAndServe(":8080", nil)

    }

    截取了后面的一部分输出,这不会是SQL库的问题吧,


    success req since:352.310146ms uni:1729393975000652 
    insert since: 163.316785ms uni:1729393975000688
    insert since: 154.983173ms uni:1729393975000691
    insert since: 158.094503ms uni:1729393975000689
    insert since: 136.831695ms uni:1729393975000697
    insert since: 141.857079ms uni:1729393975000696
    insert since: 128.115216ms uni:1729393975000702
    select since:412.94524ms uni:1729393975000634
    success req since:431.383768ms uni:1729393975000634
    select since:459.596445ms uni:1729393975000601
    success req since:568.576336ms uni:1729393975000601
    insert since: 134.39147ms uni:1729393975000700
    select since:390.926517ms uni:1729393975000643
    success req since:391.622183ms uni:1729393975000643
    select since:366.098937ms uni:1729393975000648
    success req since:373.490764ms uni:1729393975000648
    insert since: 136.318919ms uni:1729393975000699
    select since:420.626209ms uni:1729393975000640
    success req since:425.243441ms uni:1729393975000640
    insert since: 167.181068ms uni:1729393975000690
    select since:272.22808ms uni:1729393975000671

    单次请求的时候输出结果是符合预期的, 但是并发SQL时会出现执行慢的问题,这就很奇怪了


    % curl localhost:8080
    insert since: 1.559709ms uni:1729393975000703
    select since:21.031284ms uni:1729393975000703
    success req since:22.62274ms uni:1729393975000703

    经群友提示还和唯一键的区分度有关,两边算法一致有点太难了,Go换了雪法ID之后就正常了。
    因为之前 Go这边生成的uni值是递增的导致区分度很低,最终导致并发写入查询效率变低。


    % ./wrk -c100 -t2 -d3s --latency  "http://localhost:8080"
    Running 3s test @ http://localhost:8080
    2 threads and 100 connections
    Thread Stats Avg Stdev Max +/- Stdev
    Latency 44.51ms 24.87ms 187.91ms 77.98%
    Req/Sec 1.17k 416.31 1.99k 66.67%
    Latency Distribution
    50% 37.46ms
    75% 54.55ms
    90% 80.44ms
    99% 125.72ms
    6960 requests in 3.01s, 509.77KB read
    Requests/sec: 2316.02
    Transfer/sec: 169.63KB

    2024-10-23 更新


    今天本来是想验证一下有关,并发插入自增有序的唯一键高延迟的问题,发现整个有问题的只有一行代码。
    就是在查询时,类型转换的问题,插入和查询都转换之后,空表的情况下QPS 可以到4000多。即使在已有大数据量(几十万)的情况也有两千多的QPS。
    现在又多了一个问题,为什么用雪花ID时不会有这样的问题。雪花ID也是int64类型的,这是为什么呢。


    // 旧代码
    err = db.QueryRow(selectQuery, uni).Scan(&id)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }


    // 新代码 变化只有一个就是把uni 转成字符串之后就没有问题了
    var realId int64
    err = db.QueryRow(selectQuery, fmt.Sprintf("%d", uni)).Scan(&realId)
    if err != nil {
    log.Fatalf("Error selecting data: %v", err)
    }

    作者:用户04116068870
    来源:juejin.cn/post/7427455855941976076
    收起阅读 »

    从2s优化到0.1s,我用了这5步

    前言 分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。 但就是这样一个简单的分类树查询功能,我们却优化了5次。 到底是怎么回事呢? 苏三的免费刷题网站:http://www.susan.net.cn 里面:面试八股文、BAT面试...
    继续阅读 »

    前言


    分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。


    图片但就是这样一个简单的分类树查询功能,我们却优化了5次。


    到底是怎么回事呢?



    苏三的免费刷题网站:http://www.susan.net.cn 里面:面试八股文、BAT面试真题、工作内推、工作经验分享、技术专栏等等什么都有,欢迎收藏和转发。



    背景


    我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。


    它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。


    它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。


    前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。


    由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询分类数据,组装成分类树,然后返回给前端。


    通过这种方式,简化了数据流程,快速把整个页面功能调通了。


    第1次优化


    我们将该接口部署到dev环境,刚开始没啥问题。


    随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。


    我们不得不做优化了。


    我们第一个想到的是:加Redis缓存


    流程图如下:图片于是暂时这样优化了一下:



    1. 用户访问接口获取分类树时,先从Redis中查询数据。

    2. 如果Redis中有数据,则直接数据。

    3. 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。

    4. 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。

    5. 将分类树返回给用户。


    我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。


    经过这样优化之后,dev环境的联调和自测顺利完成了。


    第2次优化


    我们将这个功能部署到st环境了。


    刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。


    于是,我们马上进行了第2次优化。


    我们决定使用Job定期异步更新分类树到Redis中,在系统上线之前,会先生成一份数据。


    当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。


    于是,流程图改成了这样:图片增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。


    其他的流程保持不变。


    此外,Redis的过期时间之前设置的5分钟,现在要改成永久。


    通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。


    第3次优化


    测试了一段时间之后,整个网站的功能快要上线了。


    为了保险起见,我们需要对网站首页做一次压力测试。


    果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。


    我们需要做第3次优化。


    该怎么优化呢?


    答:加内存缓存。


    如果加了内存缓存,就需要考虑数据一致性问题。


    内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。


    但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。


    因此,分类树这种业务场景,是可以使用内存缓存的。


    于是,我们使用了Spring推荐的caffine作为内存缓存。


    改造后的流程图如下:图片



    1. 用户访问接口时改成先从本地缓存分类数查询数据。

    2. 如果本地缓存有,则直接返回。

    3. 如果本地缓存没有,则从Redis中查询数据。

    4. 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。

    5. 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。



    需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。



    这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。


    最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。


    你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。


    添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。


    第4次优化


    之后,这个功能顺利上线了。


    使用了很长一段时间没有出现问题。


    两年后的某一天,有用户反馈说,网站首页有点慢。


    我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。


    原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。


    我们需要做第4次优化。


    这时要如何优化呢?


    限制分类树的数量?


    答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?


    这时我们想到最快的办法是开启nginxGZip功能。


    让数据在传输之前,先压缩一下,然后进行传输,在用户浏览器中,自动解压,将真实的分类树数据展示给用户。


    之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。


    这样简单的优化之后,性能提升了一些。


    第5次优化


    经过上面优化之后,用户很长一段时间都没有反馈性能问题。


    但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。


    我们不得不做第5次优化。


    为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。


    只保存需要用到的字段。


    例如:


    @AllArgsConstructor
    @Data
    public class Category {

        private Long id;
        private String name;
        private Long parentId;
        private Date inDate;
        private Long inUserId;
        private String inUserName;
        private List children;
    }

    像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。


    修改自动名称。


    例如:


    @AllArgsConstructor
    @Data
    public class Category {
        /**
         * 分类编号
         */

        @JsonProperty("i")
        private Long id;

        /**
         * 分类层级
         */

        @JsonProperty("l")
        private Integer level;

        /**
         * 分类名称
         */

        @JsonProperty("n")
        private String name;

        /**
         * 父分类编号
         */

        @JsonProperty("p")
        private Long parentId;

        /**
         * 子分类列表
         */

        @JsonProperty("c")
        private List children;
    }

    由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。


    由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。


    这还不够,需要对存储的数据做压缩。


    之前在Redis中保存的key/value,其中的value是json格式的字符串。


    其实RedisTemplate支持,value保存byte数组


    先将json字符串数据用GZip工具类压缩成byte数组,然后保存到Redis中。


    再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。


    这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。


    性能优化问题,无论在面试,还是工作中,都会经常遇到。



    作者:苏三说技术
    来源:juejin.cn/post/7425382886297600050
    收起阅读 »

    MapStruct这么用,同事也开始模仿

    前言 hi,大家好,我是大鱼七成饱。 前几天同事review我的代码,发现mapstruct有这么多好用的技巧,遇到POJO转换的问题经常过来沟通。考虑到不可能每次都一对一,所以我来梳理五个场景,谁在过来问,直接甩出总结。 环境准备 由于日常使用都是spri...
    继续阅读 »

    前言


    hi,大家好,我是大鱼七成饱。


    前几天同事review我的代码,发现mapstruct有这么多好用的技巧,遇到POJO转换的问题经常过来沟通。考虑到不可能每次都一对一,所以我来梳理五个场景,谁在过来问,直接甩出总结。


    1641341087201917.jpeg


    环境准备


    由于日常使用都是spring,所以后面的示例都是在springboot框架中运行的。关键pom依赖如下:


    <properties>
    <java.version>1.8</java.version>
    <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
    <org.projectlombok.version>1.18.30</org.projectlombok.version>
    </properties>
    <dependencies>

    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>${org.mapstruct.version}</version>
    </dependency>

    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
    <scope>provided</scope>
    </dependency>

    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-mapstruct-binding</artifactId>
    <version>0.2.0</version>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    </dependencies>

    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

    场景一:常量转换


    这是最简单的一个场景,比如需要设置字符串、整形和长整型的常量,有的又需要日期,或者新建类型。下面举个例子,演示如何转换


    //实体类
    @Data
    public class Source {
    private String stringProp;
    private Long longProp;
    }
    @Data
    public class Target {
    private String stringProperty;
    private long longProperty;
    private String stringConstant;
    private Integer integerConstant;
    private Long longWrapperConstant;
    private Date dateConstant;
    }


    • 设置字符串常量

    • 设置long常量

    • 设置java内置类型默认值,比如date


    那么mapper这么设置就可以


    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface SourceTargetMapper {

    @Mapping(target = "stringProperty", source = "stringProp", defaultValue = "undefined")
    @Mapping(target = "longProperty", source = "longProp", defaultValue = "-1l")
    @Mapping(target = "stringConstant", constant = "Constant Value")
    @Mapping(target = "integerConstant", constant = "14")
    @Mapping(target = "longWrapperConstant", constant = "3001L")
    @Mapping(target = "dateConstant", dateFormat = "yyyy-MM-dd", constant = "2023-09-")
    Target sourceToTarget(Source s);
    }

    解释下,constant用来设置常量值,source的值如果没有设置,则会使用defaultValue的值,日期可以按dateFormat解析。


    Talk is cheap, show me the code !废话不多说,自动生成的转换类如下:


    @Component
    public class SourceTargetMapperImpl implements SourceTargetMapper {
    public SourceTargetMapperImpl() {
    }

    public Target sourceToTarget(Source s) {
    if (s == null) {
    return null;
    } else {
    Target target = new Target();
    if (s.getStringProp() != null) {
    target.setStringProperty(s.getStringProp());
    } else {
    target.setStringProperty("undefined");
    }

    if (s.getLongProp() != null) {
    target.setLongProperty(s.getLongProp());
    } else {
    target.setLongProperty(-1L);
    }

    target.setStringConstant("Constant Value");
    target.setIntegerConstant(14);
    target.setLongWrapperConstant(3001L);

    try {
    target.setDateConstant((new SimpleDateFormat("dd-MM-yyyy")).parse("09-01-2014"));
    return target;
    } catch (ParseException var4) {
    throw new RuntimeException(var4);
    }
    }
    }
    }

    是不是一目了然


    image-20231105105234857.png


    场景二:转换中调用表达式


    比如id不存在使用UUID生成一个,或者使用已有参数新建一个对象作为属性。当然可以用after mapping,qualifiedByName等实现,感觉还是不够优雅,这里介绍个雅的(代码少点的)。


    实体类如下:


    @Data
    public class CustomerDto {
    public Long id;
    public String customerName;

    private String format;
    private Date time;
    }
    @Data
    public class Customer {
    private String id;
    private String name;
    private TimeAndFormat timeAndFormat;
    }
    @Data
    public class TimeAndFormat {
    private Date time;
    private String format;

    public TimeAndFormat(Date time, String format) {
    this.time = time;
    this.format = format;
    }
    }

    Dto转customer,加创建TimeAndFormat作为属性,mapper实现如下:


    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = UUID.class)
    public interface CustomerMapper {

    @Mapping(target = "timeAndFormat",
    expression = "java( new TimeAndFormat( s.getTime(), s.getFormat() ) )")

    @Mapping(target = "id", source = "id", defaultExpression = "java( UUID.randomUUID().toString() )")
    Customer toCustomer(CustomerDto s);

    }

    解释下,id为空则走默认的defaultExpression,通过imports引入,java括起来调用。新建对象直接new TimeAndFormat。有的小伙伴喜欢用qualifiedByName自定义方法,可以对比下,哪个合适用哪个,都能调用转换方法。


    生成代码如下:


    @Component
    public class CustomerMapperImpl implements CustomerMapper {
    public CustomerMapperImpl() {
    }

    public Customer toCustomer(CustomerDto s) {
    if (s == null) {
    return null;
    } else {
    Customer customer = new Customer();
    if (s.getId() != null) {
    customer.setId(String.valueOf(s.getId()));
    } else {
    customer.setId(UUID.randomUUID().toString());
    }

    customer.setTimeAndFormat(new TimeAndFormat(s.getTime(), s.getFormat()));
    return customer;
    }
    }
    }

    场景三:类共用属性,如何复用


    比如下面的Bike和车辆类,都有id和creationDate属性,我又不想重复写mapper属性注解


    public class Bike {
    /**
    * 唯一id
    */

    private String id;

    private Date creationDate;

    /**
    * 品牌
    */

    private String brandName;
    }

    public class Car {
    /**
    * 唯一id
    */

    private String id;

    private Date creationDate;
    /**
    * 车牌号
    */

    private String chepaihao;
    }

    解决起来很简单,写个共用的注解,使用的时候引入就可以,示例如下:


    //通用注解
    @Retention(RetentionPolicy.CLASS)
    //自动生成当前日期
    @Mapping(target = "creationDate", expression = "java(new java.util.Date())")
    //忽略id
    @Mapping(target = "id", ignore = true)
    public @interface ToEntity { }

    //使用
    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface TransportationMapper {
    @ToEntity
    @Mapping( target = "brandName", source = "brand")
    Bike map(BikeDto source);

    @ToEntity
    @Mapping( target = "chepaihao", source = "plateNo")
    Car map(CarDto source);
    }

    这里Retention修饰ToEntity注解,表示ToEntity注解被保留到class文件,但jvm加载class文件时候被遗弃,这是默认的生命周期,辅助生成mapper实现类。上面定义了creationDate和id的转换规则,新建日期,忽略id。


    生成的mapper实现类如下:


    @Component
    public class TransportationMapperImpl implements TransportationMapper {
    public TransportationMapperImpl() {
    }

    public Bike map(BikeDto source) {
    if (source == null) {
    return null;
    } else {
    Bike bike = new Bike();
    bike.setBrandName(source.getBrand());
    bike.setCreationDate(new Date());
    return bike;
    }
    }

    public Car map(CarDto source) {
    if (source == null) {
    return null;
    } else {
    Car car = new Car();
    car.setChepaihao(source.getPlateNo());
    car.setCreationDate(new Date());
    return car;
    }
    }
    }

    坚持一下,还剩俩场景,剩下的俩更有意思


    image-20231105111309795.png


    场景四:lombok和mapstruct冲突了


    啥冲突?用了builder注解后,mapstuct转换不出来了。哎,这个问题困扰了我那同事两天时间。


    解决方案如下:


     <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-mapstruct-binding</artifactId>
    <version>0.2.0</version>
    </dependency>

    加上lombok-mapstruct-binding就可以了,看下生成的效果:


    @Builder
    @Data
    public class Person {
    private String name;
    }
    @Data
    public class PersonDto {
    private String name;
    }
    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public interface PersonMapper {

    Person map(PersonDto dto);
    }
    @Component
    public class PersonMapperImpl implements PersonMapper {
    public PersonMapperImpl() {
    }

    public Person map(PersonDto dto) {
    if (dto == null) {
    return null;
    } else {
    Person.PersonBuilder person = Person.builder();
    person.name(dto.getName());
    return person.build();
    }
    }
    }

    从上面可以看到,mapstruct匹配到了lombok的builder方法。


    场景五:说个难点的,转换的时候,如何注入springBean


    image-20231105112031297.png
    有时候转换方法比不是静态的,他可能依赖spring bean,这个如何导入?


    这个使用需要使用抽象方法了,上代码:


    @Component
    public class SimpleService {
    public String formatName(String name) {
    return "您的名字是:" + name;
    }
    }
    @Data
    public class Student {
    private String name;
    }
    @Data
    public class StudentDto {
    private String name;
    }
    @Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
    public abstract class StudentMapper {

    @Autowired
    protected SimpleService simpleService;

    @Mapping(target = "name", expression = "java(simpleService.formatName(source.getName()))")
    public abstract StudentDto map(StudentDto source);
    }

    接口是不支持注入的,但是抽象类可以,所以采用抽象类解决,后面expression直接用皆可以了,生成mapperimpl如下:


    @Component
    public class StudentMapperImpl extends StudentMapper {
    public StudentMapperImpl() {
    }

    public StudentDto map(StudentDto source) {
    if (source == null) {
    return null;
    } else {
    StudentDto studentDto = new StudentDto();
    studentDto.setName(this.simpleService.formatName(source.getName()));
    return studentDto;
    }
    }
    }

    思考


    以上场景肯定还有其他解决方案,遵循合适的原则就可以。驾驭不了的代码,可能带来更多问题,先简单实现,后续在迭代优化可能适合更多的业务场景。


    本文示例代码放在了github,需要的朋友请关注公众号大鱼七成饱,回复关键词MapStruct使用即可获得。


    image-20231105112515470.png


    作者:大鱼七成饱
    来源:juejin.cn/post/7297222349731627046
    收起阅读 »

    请不要自己写,Spring Boot非常实用的内置功能

    在 Spring Boot 框架中,内置了许多实用的功能,这些功能可以帮助开发者高效地开发和维护应用程序。 松哥来和大家列举几个。 一 请求数据记录 Spring Boot提供了一个内置的日志记录解决方案,通过 AbstractRequestLoggingFi...
    继续阅读 »

    在 Spring Boot 框架中,内置了许多实用的功能,这些功能可以帮助开发者高效地开发和维护应用程序。


    松哥来和大家列举几个。


    一 请求数据记录


    Spring Boot提供了一个内置的日志记录解决方案,通过 AbstractRequestLoggingFilter 可以记录请求的详细信息。


    AbstractRequestLoggingFilter 有两个不同的实现类,我们常用的是 CommonsRequestLoggingFilter



    通过 CommonsRequestLoggingFilter 开发者可以自定义记录请求的参数、请求体、请求头和客户端信息。


    启用方式很简单,加个配置就行了:


    @Configuration
    public class RequestLoggingConfig {
    @Bean
    public CommonsRequestLoggingFilter logFilter() {
    CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
    filter.setIncludeQueryString(true);
    filter.setIncludePayload(true);
    filter.setIncludeHeaders(true);
    filter.setIncludeClientInfo(true);
    filter.setAfterMessagePrefix("REQUEST ");
    return filter;
    }
    }

    接下来需要配置日志级别为 DEBUG,就可以详细记录请求信息:


    logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG


    二 请求/响应包装器


    2.1 什么是请求和响应包装器


    在 Spring Boot 中,请求和响应包装器是用于增强原生 HttpServletRequestHttpServletResponse 对象的功能。这些包装器允许开发者在请求处理过程中拦截和修改请求和响应数据,从而实现一些特定的功能,如请求内容的缓存、修改、日志记录,以及响应内容的修改和增强。


    请求包装器



    • ContentCachingRequestWrapper:这是 Spring 提供的一个请求包装器,用于缓存请求的输入流。它允许多次读取请求体,这在需要多次处理请求数据(如日志记录和业务处理)时非常有用。


    响应包装器



    • ContentCachingResponseWrapper:这是 Spring 提供的一个响应包装器,用于缓存响应的输出流。它允许开发者在响应提交给客户端之前修改响应体,这在需要对响应内容进行后处理(如添加额外的头部信息、修改响应体)时非常有用。


    2.2 使用场景



    1. 请求日志记录:在处理请求之前和之后记录请求的详细信息,包括请求头、请求参数和请求体。

    2. 修改请求数据:在请求到达控制器之前修改请求数据,例如添加或修改请求头。

    3. 响应内容修改:在响应发送给客户端之前修改响应内容,例如添加或修改响应头,或者对响应体进行签名。

    4. 性能测试:通过缓存请求和响应数据,可以进行性能测试,而不影响实际的网络 I/O 操作。


    2.3 具体用法


    请求包装器的使用

    import org.springframework.web.filter.OncePerRequestFilter;
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    @Component
    public class RequestWrapperFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
    ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
    // 可以在这里处理请求数据
    byte[] body = requestWrapper.getContentAsByteArray();
    // 处理body,例如记录日志
    //。。。
    filterChain.doFilter(requestWrapper, response);
    }
    }

    响应包装器的使用

    import org.springframework.web.filter.OncePerRequestFilter;
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    @Component
    public class ResponseWrapperFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
    ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
    filterChain.doFilter(request, responseWrapper);

    // 可以在这里处理响应数据
    byte[] body = responseWrapper.getContentAsByteArray();
    // 处理body,例如添加签名
    responseWrapper.setHeader("X-Signature", "some-signature");

    // 必须调用此方法以将响应数据发送到客户端
    responseWrapper.copyBodyToResponse();
    }
    }

    在上面的案例中,OncePerRequestFilter 确保过滤器在一次请求的生命周期中只被调用一次,这对于处理请求和响应数据尤为重要,因为它避免了在请求转发或包含时重复处理数据。


    通过使用请求和响应包装器,开发者可以在不改变原有业务逻辑的情况下,灵活地添加或修改请求和响应的处理逻辑。


    三 单次过滤器


    3.1 OncePerRequestFilter


    OncePerRequestFilter 是 Spring 框架提供的一个过滤器基类,它继承自 Filter 接口。这个过滤器具有以下特点:



    1. 单次执行OncePerRequestFilter 确保在一次请求的生命周期内,无论请求如何转发(forwarding)或包含(including),过滤器逻辑只执行一次。这对于避免重复处理请求或响应非常有用。

    2. 内置支持:它内置了对请求和响应包装器的支持,使得开发者可以方便地对请求和响应进行包装和处理。

    3. 简化代码:通过继承 OncePerRequestFilter,开发者可以减少重复代码,因为过滤器的执行逻辑已经由基类管理。

    4. 易于扩展:开发者可以通过重写 doFilterInternal 方法来实现自己的过滤逻辑,而不需要关心过滤器的注册和执行次数。


    3.2 OncePerRequestFilter 使用场景



    1. 请求日志记录:在请求处理之前和之后记录请求的详细信息,如请求头、请求参数和请求体,而不希望在请求转发时重复记录。

    2. 请求数据修改:在请求到达控制器之前,对请求数据进行预处理或修改,例如添加或修改请求头,而不希望这些修改在请求转发时被重复应用。

    3. 响应数据修改:在响应发送给客户端之前,对响应数据进行后处理或修改,例如添加或修改响应头,而不希望这些修改在请求包含时被重复应用。

    4. 安全控制:实现安全控制逻辑,如身份验证、授权检查等,确保这些逻辑在一次请求的生命周期内只执行一次。

    5. 请求和响应的包装:使用 ContentCachingRequestWrapperContentCachingResponseWrapper 等包装器来缓存请求和响应数据,以便在请求处理过程中多次读取或修改数据。

    6. 性能监控:在请求处理前后进行性能监控,如记录处理时间,而不希望这些监控逻辑在请求转发时被重复执行。

    7. 异常处理:在请求处理过程中捕获和处理异常,确保异常处理逻辑只执行一次,即使请求被转发到其他处理器。


    通过使用 OncePerRequestFilter,开发者可以确保过滤器逻辑在一次请求的生命周期内只执行一次,从而避免重复处理和潜在的性能问题。这使得 OncePerRequestFilter 成为处理复杂请求和响应逻辑时的一个非常有用的工具。


    OncePerRequestFilter 的具体用法松哥就不举例了,第二小节已经介绍过了。


    四 AOP 三件套


    在 Spring 框架中,AOP(面向切面编程)是一个强大的功能,它允许开发者在不修改源代码的情况下,对程序的特定部分进行横向切入。AopContextAopUtilsReflectionUtils 是 Spring AOP 中提供的几个实用类。


    我们一起来看下。


    4.1 AopContext


    AopContext 是 Spring 框架中的一个类,它提供了对当前 AOP 代理对象的访问,以及对目标对象的引用。


    AopContext 主要用于获取当前代理对象的相关信息,以及在 AOP 代理中进行一些特定的操作。


    常见方法有两个:



    • getTargetObject(): 获取当前代理的目标对象。

    • currentProxy(): 获取当前的代理对象。


    其中第二个方法,在防止同一个类中注解失效的时候,可以通过该方法获取当前类的代理对象。


    举个栗子:


    public void noTransactionTask(String keyword){    // 注意这里 调用了代理类的方法
    ((YourClass) AopContext.currentProxy()).transactionTask(keyword);
    }

    @Transactional
    void transactionTask(String keyword) {
    try {
    Thread.sleep(5000);
    } catch (InterruptedException e) { //logger
    //error tracking
    }
    System.out.println(keyword);
    }

    同一个类中两个方法,noTransactionTask 方法调用 transactionTask 方法,为了使事务注解不失效,就可以使用 AopContext.currentProxy() 去获取当前代理对象。


    4.2 AopUtils


    AopUtils 提供了一些静态方法来处理与 AOP 相关的操作,如获取代理对象、获取目标对象、判断代理类型等。


    常见方法有三个:



    • getTargetObject(): 从代理对象中获取目标对象。

    • isJdkDynamicProxy(Object obj): 判断是否是 JDK 动态代理。

    • isCglibProxy(Object obj): 判断是否是 CGLIB 代理。


    举个栗子:


    import org.springframework.aop.framework.AopProxyUtils;
    import org.springframework.aop.support.AopUtils;

    public class AopUtilsExample {
    public static void main(String[] args) {
    MyService myService = ...
    // 假设 myService 已经被代理
    if (AopUtils.isCglibProxy(myService)) {
    System.out.println("这是一个 CGLIB 代理对象");
    }
    }
    }

    4.3 ReflectionUtils


    ReflectionUtils 提供了一系列反射操作的便捷方法,如设置字段值、获取字段值、调用方法等。这些方法封装了 Java 反射 API 的复杂性,使得反射操作更加简单和安全。


    常见方法:



    • makeAccessible(Field field): 使私有字段可访问。

    • getField(Field field, Object target): 获取对象的字段值。

    • invokeMethod(Method method, Object target, Object... args): 调用对象的方法。


    举个栗子:


    import org.springframework.util.ReflectionUtils;

    import java.lang.reflect.Field;
    import java.util.Map;

    public class ReflectionUtilsExample {
    public static void main(String[] args) throws Exception {
    ExampleBean bean = new ExampleBean();
    bean.setMapAttribute(new HashMap<>());

    Field field = ReflectionUtils.findField(ExampleBean.class, "mapAttribute");
    ReflectionUtils.makeAccessible(field);

    Object value = ReflectionUtils.getField(field, bean);
    System.out.println(value);
    }

    static class ExampleBean {
    private Map<String, String> mapAttribute;

    public void setMapAttribute(Map<String, String> mapAttribute) {
    this.mapAttribute = mapAttribute;
    }
    }
    }

    还有哪些实用内置类呢?欢迎小伙伴们留言~


    作者:江南一点雨
    来源:juejin.cn/post/7417630844100231206
    收起阅读 »

    谈谈我做 Electron 应用的这一两年

    大家好,我是徐徐。今天和大家谈谈我做 Electron 桌面端应用的这一两年,把一些经验和想法分享给大家。 前言 入职现在这家公司三年了,刚进公司的时候是 21 年年初,那时候会做一些稍微复杂的后台管理系统以及一些简单的 C 端 SDK。准备开始做 Elect...
    继续阅读 »

    大家好,我是徐徐。今天和大家谈谈我做 Electron 桌面端应用的这一两年,把一些经验和想法分享给大家。


    前言


    入职现在这家公司三年了,刚进公司的时候是 21 年年初,那时候会做一些稍微复杂的后台管理系统以及一些简单的 C 端 SDK。准备开始做 Electron 项目是因为我所在的是安全部门,急需一款桌面管控软件来管理(监控)员工的电脑安全以及入网准入,可以理解为一款零信任的桌面软件。其实之前公司也有一款安全管控的软件,但是Windows 和 Mac是分端构建的,而且维护成本极高,Windows 是使用的 C#, Mac 是用的 Objective-C,开发和发版效率低下,最后在研发老大的同意下,我和另外一个同事开始研究如何用 Electron 这个框架来做一款桌面端软件。


    我们发起这个项目大概是在 21 年年底,Windows 版本上线是在上海疫情封城期间,2022年4月份的时候,疫情结束后由于事业部业务方向的调整,又被抽调到了另外一个组去做一个 C 端的创业项目,后面项目结束了,又回来做 Electron 相关的工作直到现在,之所以是一两年,其实就是这个时间线。


    对桌面端开发的一些看法


    如果你是前端的话,多一门桌面端开发的技能也不是坏事,相当是你的一个亮点,进可攻,退可守。因为桌面端开发到后期的架构可以非常的复杂,不亚于服务端(chromium 就是一个例子),当然也取决于你所应对的场景的挑战,如果所做的产品跟普通前端无异,那也不能说是一个亮点,但是如果你的工作已经触及到一些操作系统的底层,那肯定是一个亮点。


    当然也有人说,做桌面端可能就路越走越窄了,但是我想说的是深度和广度其实也可以理解为一个维度,对于技术人来说,知道得越多就行,因为到后期你要成为某个方面的专家,就是可能会非常深入某一块,换一种思路其实也是叫知道得越多。所以,我觉得前端能有做桌面端的机会也是非常好的,即拓展了自己的技能,还能深入底层,因为现阶段由于业务方向的需要,我已经开始看 chromium 源码了,前端的老祖宗。当然,以上这些只代表自己的观点,大家自行斟酌。


    谈谈 Electron


    其实刚刚工作前两年我就知道这个框架了,当时也做过小 demo,而且还在当时的团队里面分享过这个技术,但是当时对这门技术的认知是非常浅薄的,就知道前端可以做桌面端了,好厉害,大概就停留在这个层面。后面在真正需要用到这门技术去做一个企业级的桌面应用的时候才去真正调研了一下这个框架,然后才发现它真的非常强大,强大到几乎可以实现你桌面端的任何需求。网上关于 Electron 与其他框架的对比实在是太多了,Google 或者 Baidu 都能找到你想要的答案,好与不好完全看自己的业务场景以及自己所在团队的情况。


    谈谈自己的感受,什么情况下可以用这门框架



    • 追求效率,节省人力财力

    • 团队前端居多

    • UI交互多


    什么情况下不适合这门框架呢?



    • 包体积限制

    • 性能消耗较高的应用

    • 多窗口应用


    我们当时的情况就是要追求效率,双端齐头并进,所以最后经过综合对比,选择了 Electron。毕竟 Vscode 就是用它做的,给了我们十足的信心和勇气,一点都不虚。


    一图抵千字,我拿出这张图你自己就有所判断了,还是那句话,仁者见仁,智者见智,完全看自己情况。


    image.png
    图片来源:http://www.electronjs.org/apps


    技术整体架构


    这里我画了一张我所从事 Electron 产品的整体技术架构图。

    整个项目基于 Vite 开发构建的,基础设施就是常见的安全策略,然后加上一些本地存储方案,外加一个外部插件,这个插件是用 Tauri 做的 Webview,至于为什么要做这个插件我会在后面的段落说明。应用层面的框架主要是分三个大块,下面主要是为了构建一些基础底座,然后将架构进行分层设计,添加一些原生扩展,上面就是基础的应用管理和 GUI 相关的东西,有了这个整体的框架在后面实现一些业务场景的时候就会变得易如反掌(夸张了一点,因为有的技术细节真的很磨人😐)。


    当然这里只是一个整体的架构图,其实还有很多技术细节的流程图以及业务场景图并没有在这里体现出来,不过我也会挑选一些方案在后面的篇幅里面做出相应的讲解。


    挑战和方案


    桌面端开发会遇到一些挑战,这些挑战大部分来源于特殊的业务场景,框架只能解决一些比较常见的应用场景,当然不仅仅是桌面端,其实移动端或者是 Web 端我相信大家都会遇到或多或少的挑战,我这里遇到的一些挑战和响应的方案不一定适合你,只是做单纯的记录分享,如果有帮助到你,我很开心。下面我挑选软件升级更新,任务队列设计,性能检测优化以及一些特殊的需求这几个方面来聊聊相应的挑战和方案。


    软件升级更新


    桌面端的软件更新升级是桌面端开发中非常重要的一环,一个好的商业产品必须有稳定好用的解决方案。桌面端的升级跟 C 端 App 的升级其实也是差不多的思路,虽然我所做的产品是公司内部人使用,但是用户也是你面向公司所有用户的,所以跟 C 端产品的解决思路其实是无异的。


    升级更新主要是需要做到定向灰度。这个功能是非常重要的,应该大部分的应用都有定向灰度的功能,所以我们为了让软件能够平滑升级,第一步就是实现定向灰度,达到效果可回收,性能可监控,错误可告警的目的。定向更新的功能实现了之后,后面有再多的功能需要实现都有基础保障了,下面是更新相关的能力图。



    整个更新模块的设计是分为两大块,一块是后台管理系统,一块是客户端。后台管理系统主要是维护相应的策略配置,比如哪些设备需要定向更新,哪些需要自动更新,不需要更新的白名单以及更新后是需要提醒用户相应的更新功能还是就静默更新。客户端主要就是拉取相应的策略,然后执行相应的更新动作。


    由于我们的软件是比较特殊的一个产品,他是需要长期保活的,Mac 端上了文件锁是无法删除的。所以我们在执行更新的时候和常规的软件更新是不一样的,软件的更新下载是利用了 electron-update 相应的钩子,但是安装的时候并没有使用相应的钩子函数,而是自己研究了 electron 的更新源码后做了自己的更新脚本。 因为electron 的更新它自己也会注册一个保活的更新任务的服务,但是这个和我们的文件锁和保活是冲突的,所以是需要禁用掉它的保活服务,完成自己的更新。


    整体来说,这一块是花了很多时间去研究的,windows 还好,没有破坏其整个生命周期,傻瓜式的配置一下electron-update 相关的函数钩子就可以了。Mac 的更新花了很多时间,因为破坏了文件的生命周期,再加上保活任务,所以会对 electron-update 的更新钩子进行毁灭性的破坏,最后也只能研究其源码然后自己去实现特殊场景下的更新了。


    任务队列设计


    任务模块的实现在我们这个软件里面也是非常重要的一环,因为客户端会跑非常多的定时任务。刚开始研发这个产品的时候其实还好,定时任务屈指可数,但是随着长时间的迭代,端上要执行的任务越来越多,每个任务的触发时间,触发条件都不一样,以及还要考虑任务的并发情况和对性能的影响,所以在中后期我们对整个任务模块都做了相应的改造。


    下面是整个任务模块的核心能力图。


    业界也有一些任务相关的开源工具包,比如 node-schedule、node-cron、cron,这些都是很优秀的库,但是我在使用过程中发现他们好像不具备并发限制的场景,比如有很多任务我们在开始设置的时候都会有个时间间隔,这些任务的时间间隔都是可以在后台随意配置的,如果端上不做并发限制会导致一个问题,就是用户某一瞬间会觉得电脑非常卡。


    比如你有 4 个 10 分钟间隔的任务 和 2 个五分钟间隔的任务,那么到某一个时间段,他最大并发可能就是 6,如果刚好这 6 个任务都是非常耗费 CPU 的任务,那他们一起执行的时候就会导致整个终端CPU 飙升,导致用户感觉卡顿,这样就会收到相应的 Diss。


    安全类的软件产品其实有的时候不需要太过醒目,后台默默运行就行,所以我们的宗旨就是稳定运行,不超载。为此我们就自己实现了相应的任务队列模式,然后去控制任务并发。其实底层逻辑也不难,就是一个 setInterval 的函数,然后不断的创建销毁,读取队列的函数,执行相应的函数。


    性能优化


    Electron相关的性能优化其实网上也有非常多的文章,我这里说说我的实践和感受。


    首先,性能优化你需要优化什么?这个就是你的出发点了,我们要解决一个问题,首先得知道问题的现状,如果你都不知道现在的性能是什么样子,如何去优化呢?所以发现问题是性能优化的最重要的一步。


    这里就推荐两个工具,一个是chrome dev-tool,一个是electron 的 inspector,第一个可以观测渲染进程相关的性能情况,第二个可以观测主进程相关的性能情况。


    具体可参考以下网址:



    有了工具之后我们就需要用工具去分析一些数据和问题,这里面最重要的就是内存相关的分析,你通过内存相关的分析可以看到 CPU 占用高的动作,以及提前检测出内存泄漏的风险。只要把这两个关键的东西抓住了,应用的稳定性就可以得到保障了,我的经验就是每次发布之前都会跑一遍内存快照,内存没有异常才进行发布动作,内存泄漏是最后的底线。


    我说说我大概的操作步骤。



    • 通过Performance确认大体的溢出位置

    • 使用Memory进行细粒度的问题分析

    • 根据heap snapshot,判断内存溢出的代码位置

    • 调试相应的代码块

    • 循环往复上面的步骤


    上面的步骤在主进程和渲染进程都适用,每一步实际操作在这里就不详细展开了,主要是提供一个思路和方法,因为 dev-tool 的面板东西非常多,扩展开来都可以当一个专题了。


    然后我再说说桌面端什么地方可能会内存泄漏或者溢出,下面这些都是我血和泪的教训。



    • 创建的子进程没有及时销毁


    如果子进程在完成任务后未被正确终止,这些进程会继续运行并占用系统资源,导致内存泄漏和资源浪费。


    假设你的 Electron 应用启动了一个子进程来执行某些计算任务,但在计算完成后未调用
    childProcess.kill() 或者未确保子进程已正常退出,那么这些子进程会一直存在,占用系统内存。


    const { spawn } = require('child_process');
    const child = spawn('someCommand');

    child.on('exit', () => {
    console.log('Child process exited');
    });

    // 未正确终止子进程可能导致内存泄漏


    • HTTP 请求时间过长没有正确处理


    长时间未响应的 HTTP 请求如果没有设定超时机制,会使得这些请求占用内存资源,导致内存泄漏。


    在使用 fetchaxios 进行 HTTP 请求时,如果服务器长时间不响应且没有设置超时处理,内存会被这些未完成的请求占用。


    const fetch = require('node-fetch');

    fetch('https://example.com/long-request')
    .then(response => response.json())
    .catch(error => console.error('Error:', error));

    // 应该设置请求超时
    const controller = new AbortController();
    const timeout = setTimeout(() => {
    controller.abort();
    }, 5000); // 5秒超时

    fetch('https://example.com/long-request', { signal: controller.signal })
    .then(response => response.json())
    .catch(error => console.error('Error:', error));


    • 事件处理器没有移除


    未正确移除不再需要的事件处理器会导致内存一直被占用,因为这些处理器仍然存在并监听事件。


    在添加事件监听器后,未在适当时机移除它们会导致内存泄漏。


    const handleEvent = () => {
    console.log('Event triggered');
    };

    window.addEventListener('resize', handleEvent);

    // 在不再需要时移除事件监听器
    window.removeEventListener('resize', handleEvent);


    • 定时任务未被正确销毁


    未在适当时候清除不再需要的定时任务(如 setInterval)会导致内存持续占用。


    使用 setInterval 创建的定时任务,如果未在不需要时清除,会导致内存泄漏。


    const intervalId = setInterval(() => {
    console.log('Interval task running');
    }, 1000);

    // 在适当时机清除定时任务
    clearInterval(intervalId);


    • JavaScript 对象未正确释放


    长时间保留不再使用的 JavaScript 对象会导致内存占用无法释放,特别是当这些对象被全局变量或闭包引用时。


    创建了大量对象但未在适当时机将它们置为 null 或解除引用。


    let bigArray = new Array(1000000).fill('data');

    // 当不再需要时,应释放内存
    bigArray = null;


    • 窗口实例未被正确销毁


    未关闭或销毁不再使用的窗口实例会继续占用内存资源,即使用户已经关闭了窗口界面。


    创建了一个新的 BrowserWindow 实例,但在窗口关闭后未销毁它。


    const { BrowserWindow } = require('electron');
    let win = new BrowserWindow({ width: 800, height: 600 });

    win.on('closed', () => {
    win = null;
    });

    // 应确保在窗口关闭时正确释放资源


    • 大文件或大数据量的处理


    处理大文件或大量数据时,如果没有进行内存优化和分批处理,会导致内存溢出和性能问题。


    在读取一个大文件时,未采用流式处理,而是一次性加载整个文件到内存中。


    const fs = require('fs');

    // 不推荐的方式:一次性读取大文件
    fs.readFile('largeFile.txt', (err, data) => {
    if (err) throw err;
    console.log(data);
    });

    // 推荐的方式:流式读取大文件
    const readStream = fs.createReadStream('largeFile.txt');
    readStream.on('data', (chunk) => {
    console.log(chunk);
    });

    一些特殊的需求


    做这个产品也遇到一些特殊的需求,有的需求还挺磨人的,这里也和大家分享一下。



    • 保活和文件锁


    作为一个前端,桌面端的保活和文件锁这种需求基本是之前不可能接触到的,为了做这个需求也去了解了一下业界的实现,其实实现都还好,主要是它会带来一些问题,诸如打包构建需要自定义前置脚本和后置脚本,root 用户环境下 mac 端无法输入中文,上面提到的用 Tauri 构建一个 webview 组件就是为了解决 root 用户无法输入中文的场景。



    • 静默安装应用


    这个需求也是很绝的一个需求。我想如果是做常规的前端开发,估计一般都不会遇到这种需求,你需要从头到尾实现一个下载器,一个软件安装器,而且还要双端适配,不仅如此,还需要实现 exe、zip、dmg、pkg 等各种软件格式的安装,里面包含重试机制,断点下载,队列下载等各种技术细节。当时接到这个需求头也特别大,不过技术方案做出来后感觉也还好,再复杂的需求只要能理清思路,其实都可以慢慢解决。



    • VPN 和 访问记录监控


    这种需求对一个前端来说更是无从入手,但是好在之前有老版本的 VPN 做参考,就是根据相应的代码翻译一遍也能实现,大部分可以用命令行解决。至于访问记录监控这个玩意咋说呢,客户端做其实也挺费神的,如果不借助第三方的开源框架,自己是非常难实现的,所以这种就是需要疯狂的翻国外的网站,就GitHub,Stackoverflow啰,总有一款适合你,这里就不具体说明了。



    • 进程禁用


    违规进程禁用其实在安全软件的应用场景是非常常见的,它需要实时性,而且对性能要求很高,一个是不能影响用户正常使用,还要精准杀掉后台配置的违规进程,这个地方其实也是做了很多版优化,但是最后的感觉还是觉得任务队列有性能瓶颈,无法达到要求,现阶段我们也在想用另外的方式去改造,要么就是上全局钩子,要么就是直接把相应的进程文件上锁或者改文件权限。


    上面所提到的需求只是一小部例子,还好很多奇奇怪怪的需求没有举例,这些奇怪的需求就像小怪物,不断挑战我的边界,让我也了解和学习和很多奇奇怪怪的知识,有的时候我就会发出这样的感叹:我去,还能这样?


    结语


    洋洋洒洒,不知不觉已经写了 5000 字了,其实做 Electron 桌面端应用的这一两年自我感觉还是成长了不少,不管是技术方面还是产品设计方面,自己的能力都有所提升。但是同样会遇到瓶颈,就是一个东西一直做一直做,到后面创新会比较难,取得的成就也会慢慢变少。


    另外就是安全类的桌面端产品在整个软件开发的里其实是非常冷门的一个领域,他有他的独特性,也有相应的价值,他需要默默的运行,稳定的运行,出问题可以监控到,该提醒的时候提醒用户。你说他低调吧,有时候也挺高调的,真的不好定论,你说没影响力吧,有的时候没他还真不行。让用户不反感这种软件,拥抱这种软件其实挺难的。从一个前端开发的视角来看,桌面端的体验的确很重要,不管是流畅度还是美观度,都不能太差,这也是我们现阶段追求的一个点,就是不断提升用户体验。


    路漫漫其修远兮,吾将上下而求索。前端开发这条路的确很长,如果你想朝某个方面深度发展,你会发现边界是非常难触达的,当然也看所处的环境和对应的机遇,就从技术来说的话,前端的天花板也可以很高,不管是桌面端,服务端,移动端,Web端,每个方向前端的天花板都非常难触摸到。


    最后,祝大家在自己的领域越来越深,早日触摸到天花板。


    原文链接


    mp.weixin.qq.com/s/SzN8wvqxj…


    作者:前端徐徐
    来源:juejin.cn/post/7399100662610395147
    收起阅读 »