注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

困扰我 1 小时的 404 错误 别人 1 分钟解决了

上周五遇到了一个 Bug,没有任何异常,困扰了我一个小时,差点连周末都过不好了。最后没办法,请教了组内的同学,没想到竟被他 1 分钟解决了! 事情的起因,只是因为我把一个接口从 @GetMapping 改成了 @PostMapping ,然后接口就报以下的错误...
继续阅读 »

上周五遇到了一个 Bug,没有任何异常,困扰了我一个小时,差点连周末都过不好了。最后没办法,请教了组内的同学,没想到竟被他 1 分钟解决了!


事情的起因,只是因为我把一个接口从 @GetMapping 改成了 @PostMapping ,然后接口就报以下的错误:


image.png
没有任何的 WARN 或者 ERROR 日志!

网上搜了一下,也没有什么有效的信息,万能的 AI 给出了下面这样的回答:


image.png
404 的错误太常见了,有很多原因造成这一结果。


但可以确定的是,我的请求路径和控制器配置都是没有问题的,因为只要要把 @PostMapping 改回 @GetMapping ,一切都运行正常。


在这种情况下,搜索引擎和AI,除了给我造成干扰误导排查方向外,不能起到什么实质性的作用。


无奈,我只能硬着头皮打开 DEBUG 日志,尝试对照源码,去解决问题了。


不幸的是,DEBUG 日志实在太多了,里面也没有任何异常。我刚学 SpringBoot 不久,这些碎片化的日志,不能引起我的任何联想,因此,实质上也起不到辅助排查的作用。


折腾了一个多小时,还是没有什么头绪,明天就周末了,带着这个 Bug,周末恐怕都休息不好。于是,我就硬着头皮找了组内一个比较有经验的同学帮忙看一下。


他过来翻了翻日志,查看了一下配置类,淡淡地说到,你打开了 Csrf 验证,但是请求却没带 Token。说罢,指导我加上了一行代码:


.csrf().disable()

然后,再次访问,竟然就真的可以了!整个过程也就 1 分钟左右!


我这个小弱鸡的心灵着实有些触动。于是追问到,大神你是怎么看出来的呀。


“没什么,就是经验多了。日志里面有些信息,比如 token 相关的, 其实已经提示了你答案。不过,需要你对框架比较了解,才能 get 到这些信息。新手遇到这种没有明显异常的问题,确实会比较费劲。”


“那有没有什么办法,可以快速搞懂这种框架问题啊,每次遇到都挺烦躁的,不仅影响研发进度,也影响心情” , 上进的我还是想从大神这里获取更多的经验。


“额….我想想”,大神迟疑了一会儿,“你可以试试这个 XCodeMap 插件“,”它可以提供更丰富的信息,图形化的形式,可以较为容易看出可能存在的问题。实在看不出,你也可以基于这些信息再去问搜索引擎或者AI”。


“感谢大神,我去试一下”。


试用了一下,这个工具画出了下面的序列图:


image.png
我虽然不懂什么 Csrf 的原理,但是这个图已经可以清晰地表达出问题了,在 SpringBoot 的FilterChain 中,走到 CsrfFilter 就终止了,并且调用了一个 AccessDeniedHandler。


看起来,这个序列图是实时动态采集的,而且做了很多剪枝,把一些关键调用给标记了出来。对于 SpringBoot 系列,其会把各种 Filter 的调用情况展示出来,可以让人一眼看出来是哪个 Filter 出了问题。


点击 CsrfFilter 的 doFilter 方法,可以看到以下代码:


image.png
这个代码可以看出来,Csrf 的原理(以CookieCsrfToken为例)就是取两个token进行比对。其中一个从请求的 Header 或者 Parameter 中读出。另外一个,从 Cookie 中读出。


image.png
由于浏览器的同源策略,攻击网站无法获取本网站的Cookie,也即其无法完成下面这样的JS操作:


image.png
但是本网站可以通过上面的操作,把 Cookie 中的token设置到 Header 中,这样就达到了避免 CSRF 攻击的效果。


不过,这里还有一个小插曲,csrf 验证失败,本意应该是报 403 错误码,然后转发到 “/403” 页面,只是因为我没有配置 “/403” 页面,最终才报了404 错误。


image.png


这次由 Csrf 引起的 404 错误,就到此为止了。


我独自完成了后面的排查,还是很开心的。我没有大神那样丰富的经验,可以凭借只言片语的日志信息,就可以推断出问题所在。但我借助 XCodeMap 绘制的动态序列图,按图索骥,搞清楚了问题的来龙去脉。这让我想起了下面的一句话:


人类有了弓箭,拳头就不再是绝对硬实力了。好的工具,可以削平人与人的差距!


感觉自己与大神接近了不少!


参考资料:



作者:摸鱼总工
来源:juejin.cn/post/7362722064069427237
收起阅读 »

关于“明显没有bug的代码”的一些拙见

以前听过过一个有趣的说法:不要编写没有明显bug的代码,要去编写明显没有bug的代码。这里提到的两个概念:“没有明显bug的代码”和“明显没有bug的代码”。同样的文字,只是调换了下顺序,表达的就是完全不同的概念了。前者“没有明显bug的代码”大概是最常见的代...
继续阅读 »

以前听过过一个有趣的说法:不要编写没有明显bug的代码,要去编写明显没有bug的代码。这里提到的两个概念:“没有明显bug的代码”和“明显没有bug的代码”。同样的文字,只是调换了下顺序,表达的就是完全不同的概念了。前者“没有明显bug的代码”大概是最常见的代码了,特征就是:



  1. 每段程序看起来合理,但结果就是不对

  2. 程序看起来复杂、奇怪,但就是可以正常运行

  3. 天书一般的程序

  4. 待补充


平时工作中到处缝缝补补的代码大概就是这种代码吧。背后的原因一般比较复杂,有时还不可追溯,项目工期紧,人员交接等等都有可能。因此,与其思考“如何避免没有明显bug的代码”,还不如思考“如何写出明显没有bug的代码”。本文就何为“明显没有bug的代码”总结一些个人的思的胡思乱想,阐述这类代码的几个特征。


特征1:代码简短


“明显没有xx”意味着一眼能看出来,而“一眼”这个条件就有很大的限制。如果给我一个函数,包含1000多行代码,我鼠标滚轮要滚好久,才能过完一遍代码,那么这种代码一定不是“明显没有bug的代码”。那么,反过来说,“明显没有bug的代码”一定是短小的代码。比如,Java中的Objects.equals方法:


public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}

这一段代码简短到,代码跟功能定义的文字篇幅差不多,连写文档注释的必要都没有了。还有一个更极端的例子是Java的Objects.isNull方法:


public static boolean isNull(Object obj) {
return obj == null;
}

简直就是一段“废话”。


特征2:功能完整且连贯


“一眼能看出”还意味着功能不能太分散,如果一个功能,分散在十多个函数或文件中,那么看这段功能就得在很多代码片段中跳来跳去,这个就需要开发者来阅读代码时充当一个“人肉解释器”的角色,在大脑中把各个代码片段组合起来才能明白整个流程和细节,这无疑降低阅读代码的效率,bug也容易隐藏在各个代码片段的“缝隙”中。举个常见的例子:在图形界面应用中,用户登录后,弹出登录成功的提示,然后关闭登录页面。一种普通的实现是:


//1. 在登录按钮触发登录操作
loginButton.setOnClickListener(v -> controller.login(username, poassword))

//2. 在登录成功的回调中展示弹窗
public void onLoginSuccess(User user){
LoginSuccessDialog.show("login success")
}

//3. 在失败的回调中展示错误信息
public void onLoginFailed(String errorMsg){
MessageDialog.showMessage(errorMsg);
}

//4. 在LoginSuccessDialog确认后关闭页面
public void onLoginDialogConfirmed(){
loginPage.close();
}

看上去好像解耦很不错,但功能都变得七零八落。要拼凑出完整的功能大概得仔细阅读整段代码,更别提“一眼看出”了。那么一眼能看出的代码大概长啥样呢?我想,大概是这样:


loginButton.setOnClickListener(v -> {
controller.login(username, poassword)
.onSuccess(user ->
LoginSuccessDialog.总而言之,解耦需谨慎,不要因过度解耦而牺牲了内聚性和连贯性。show("login success")
.onConfirmed(() -> loginPage.close()))
.onFailed(errorMsg ->
MessageDialog.showMessage(errorMsg));
});

这是对该视图流程的一个连贯的描述,而且篇幅更短。至于获取到用户数据存储到本地数据库、通知其他页面更新等操作,跟当前视图没有关系,也就不需要放在这段代码里。总而言之,解耦需谨慎,不要因过度解耦而牺牲了内聚性和连贯性。


特征3:良好的表达


代码的篇幅得到控制后,要让人一眼看懂,还需要容易理解才行。设计心理学提出“设计传达所有必要的信息,创造一个良好的系统概念模型,引导用户理解系统状态,带来掌控感”。程序设计也是如此,代码是程序功能的文本表达,需要传达对应信息来让人产生该功能正确的概念模型。


以一个常见的上传图片的弹窗为例,思考一个菜单弹窗,包含取消和两个功能按钮:从相册选择和拍照上传,例如下图这样。


在这里插入图片描述


那么对应的代码可以表达为:


MenuDialog.create()
.withAction("拍照上传", dialog -> {
takePhoto();
})
.withAction("本地上传", dialog -> {
chooseFromGallery();
})
.onCancel(() -> {
//do something
})
.show();

或者


MenuDialog.create()
.withAction("拍照上传", controller::takePhoto)
.withAction("本地上传", controller::chooseFromGallery)
.onCancel(() -> {
//do something
})
.show();

没有多余的代码,该有的信息都表达到位,而且和实际功能有良好的对应关系。


4. 特征4:可验证正确性


代码可以让人一眼看懂之后,那么判断其有没有bug,还有一个重要前提:这个代码是有正确性可言的,可以被验证。


例如,来看下面这段随意的代码:


int type;
boolean isClosed;

void doSomething(String text) {
if (type == 0) {
if (isClosed) {
println(text);
} else {
error("something wrong");
}
} else if (type == 1) {
//do something
}
}

这段程序简短、易读,但是doSomething函数的行为依赖两个外部变量,而这两个外部变量又容易被其他地方随意改动。比如,type的定义域为1、2、3,但如果type新增类型4的时候或者被错误地赋值为-1的时候,这个doSomething函数的行为还是正确的吗?doSomething函数的正确性依赖于type变量的正确性,那么又依赖于读写type变量的程序的正确性,这样的程序是难以验证的。而且,对上下文依赖越多的程序,越难以产生明确的定义,因为这个定义也依赖上下文的定义。定义不明确,更难以验证内容的正确性。


相比之下,Objects.equalsObjects.isNull方法有着明确的定义,而且不受上下文影响,可以一眼就看出对错。而下面这段代码:


MenuDialog.create()
.withAction("拍照上传", controller::takePhoto)
.withAction("本地上传", controller::chooseFromGallery)
.onCancel(() -> {
//do something
})
.show();

表达明确,可以快速判断出程序行为是否正确、符合期望。即便MenuDialog出现异常,或者takePhotochooseFromGallery出了什么问题,也不需要来修改这段程序。


不过,程序验证是一个有点高深的科研方向,要严格验证一个程序的正确性是很困难的一件事,不过我们仍然可以试着去编写一些“看起来”正确的程序。(利用函数式编程思想写出来的代码通常容易验证一些)


作者:乐征skyline
来源:juejin.cn/post/7236010330051887164
收起阅读 »

室友打一把王者就学会了Java多线程

大家好,我是二哥呀。 对于 Java 初学者来说,多线程的很多概念听起来就很难理解。比方说: 进程,是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。 线程,是进程的子任务,是 CPU 调度和分派的基本单位,实现了进程内部的并发...
继续阅读 »


大家好,我是二哥呀。


对于 Java 初学者来说,多线程的很多概念听起来就很难理解。比方说:



  • 进程,是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。

  • 线程,是进程的子任务,是 CPU 调度和分派的基本单位,实现了进程内部的并发。


很抽象,对不对?打个比喻,你在打一把王者(其实我不会玩哈 doge):



  • 进程可以比作是你开的这一把游戏

  • 线程可以比作是你所选的英雄或者是游戏中的水晶野怪等之类的。


带着这个比喻来理解进程和线程的一些关系,一个进程可以有多个线程就叫多线程。是不是感觉非常好理解了?


进程和线程


❤1、线程在进程下进行


(单独的英雄角色、野怪、小兵肯定不能运行)


❤2、进程之间不会相互影响,主线程结束将会导致整个进程结束


(两把游戏之间不会有联系和影响。你的水晶被推掉,你这把游戏就结束了)


❤3、不同的进程数据很难共享


(两把游戏之间很难有联系,有联系的情况比如上把的敌人这把又匹配到了)


❤4、同进程下的不同线程之间数据很容易共享


(你开的那一把游戏,你可以看到每个玩家的状态——生死,也可以看到每个玩家的出装等等)


❤5、进程使用内存地址可以限定使用量


(开的房间模式,决定了你可以设置有多少人进,当房间满了后,其他人就进不去了,除非有人退出房间,其他人才能进)


创建线程的三种方式


搞清楚上面这些概念之后,我们来看一下多线程创建的三种方式:


继承 Thread 类


♠①:创建一个类继承 Thread 类,并重写 run 方法。


public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName() + ":打了" + i + "个小兵");
}
}
}

我们来写个测试方法验证下:


//创建MyThread对象
MyThread t1=new MyThread();
MyThread t2=new MyThread();
MyThread t3=new MyThread();
//设置线程的名字
t1.setName("鲁班");
t2.setName("刘备");
t3.setName("亚瑟");
//启动线程
t1.start();
t2.start();
t3.start();

来看一下执行后的结果:



实现 Runnable 接口


♠②:创建一个类实现 Runnable 接口,并重写 run 方法。


public class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {//sleep会发生异常要显示处理
Thread.sleep(20);//暂停20毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "打了:" + i + "个小兵");
}
}
}

我们来写个测试方法验证下:


//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");
//启动线程
t1.start();
t2.start();
t3.start();

来看一下执行后的结果:



实现 Callable 接口


♠③:实现 Callable 接口,重写 call 方法,这种方式可以通过 FutureTask 获取任务执行的返回值。


public class CallerTask implements Callable<String> {
public String call() throws Exception {
return "Hello,i am running!";
}

public static void main(String[] args) {
//创建异步任务
FutureTask<String> task=new FutureTask<String>(new CallerTask());
//启动线程
new Thread(task).start();
try {
//等待执行完成,并获取返回结果
String result=task.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}

关于线程的一些疑问


❤1、为什么要重写 run 方法?


这是因为默认的run()方法不会做任何事情。


为了让线程执行一些实际的任务,我们需要提供自己的run()方法实现,这就需要重写run()方法。


public class MyThread extends Thread {
public void run() {
System.out.println("MyThread running");
}
}

在这个例子中,我们重写了run()方法,使其打印出一条消息。当我们创建并启动这个线程的实例时,它就会打印出这条消息。


❤2、run 方法和 start 方法有什么区别?



  • run():封装线程执行的代码,直接调用相当于调用普通方法。

  • start():启动线程,然后由 JVM 调用此线程的 run() 方法。


❤3、通过继承 Thread 的方法和实现 Runnable 接口的方式创建多线程,哪个好?


实现 Runable 接口好,原因有两个:



  • ♠①、避免了 Java 单继承的局限性,Java 不支持多重继承,因此如果我们的类已经继承了另一个类,就不能再继承 Thread 类了。

  • ♠②、适合多个相同的程序代码去处理同一资源的情况,把线程、代码和数据有效的分离,更符合面向对象的设计思想。Callable 接口与 Runnable 非常相似,但可以返回一个结果。


控制线程的其他方法


针对线程控制,大家还会遇到 3 个常见的方法,我们来一一介绍下。


1)sleep()


使当前正在执行的线程暂停指定的毫秒数,也就是进入休眠的状态。


需要注意的是,sleep 的时候要对异常进行处理。


try {//sleep会发生异常要显示处理
Thread.sleep(20);//暂停20毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}

2)join()


等待这个线程执行完才会轮到后续线程得到 cpu 的执行权,使用这个也要捕获异常。


//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");
//启动线程
t1.start();
try {
t1.join(); //等待t1执行完才会轮到t2,t3抢
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.start();
t3.start();

来看一下执行后的结果:



3)setDaemon()


将此线程标记为守护线程,准确来说,就是服务其他的线程,像 Java 中的垃圾回收线程,就是典型的守护线程。


//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");

t1.setDaemon(true);
t2.setDaemon(true);

//启动线程
t1.start();
t2.start();
t3.start();

如果其他线程都执行完毕,main 方法(主线程)也执行完毕,JVM 就会退出,也就是停止运行。如果 JVM 都停止运行了,守护线程自然也就停止了。


小结


本文主要介绍了 Java 多线程的创建方式,以及线程的一些常用方法。最后再来看一下线程的生命周期吧,一图胜千言。



好了,如果你想学好 Java,GitHub 上标星 10000+ 的《二哥的 Java 进阶之路》不容错过,据说每一个优秀的 Java 程序员都喜欢她,风趣幽默、通俗易懂。内容包括 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发(Git、Nginx、Maven、Intellij IDEA、Spring、Spring Boot、Redis、MySql 等等)、Java 面试等核心知识点。学 Java,就认准二哥的 Java 进阶之路😄。


Github 仓库:github.com/itwanger/to…


码云仓库(国内访问更快):gitee.com/itwanger/to…


star 了这个仓库就等于你拥有了成为了一名优秀 Java 工程师的潜力。



把二哥的座右铭送给你:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟


作者:沉默王二
来源:juejin.cn/post/7329413905028186124
收起阅读 »

眼看他搭中台,眼看他又拆了

曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之际,我们可以发现各大企业又在反其道而行,纷纷不断进行“拆中台”,那么中台对于企业而言,究竟发...
继续阅读 »

曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之际,我们可以发现各大企业又在反其道而行,纷纷不断进行“拆中台”,那么中台对于企业而言,究竟发挥了哪些作用,当前又出现了哪些问题?今天,我们特邀了高级研发管理专家、腾讯云 TVP 程超老师,他将从搭中台到拆中台的风向转变,探讨企业软件架构的底层逻辑。



中台都在忽悠吗?都被忽悠瘸了?我们都在悄悄淘汰中台,你们还在建?最近网上充斥大量文章和观点,都在说中台过时。为什么会这样说?是因为成本与复杂性?技术限制与业务变化?还是因为组织变化?为什么会这样呢?且听我一一分析。


众所周知,中台是指企业内部的中间层平台,负责连接上下游系统,提供数据和功能服务。而在过去几年中台概念曾经风靡一时,甚至被认为是企业数字化转型的关键。然而,近年来,一些企业确实出现了对中台战略的重新评估,不再像之前那样盲目地追求中台建设。其实,中台的概念兴起于企业数字化转型的浪潮中,企业开始意识到传统的前台系统(如客户端应用)与后台系统(如企业资源规划系统)之间的断层,而中台则被认为是弥合这种断层的理想方式。


值得一提的是,关于中台的定义,业内大佬也曾经发表过一些观点:


提炼各个业务条线的共性需求,并将这些打造成组件化的资源/能力包,然后以接口的形式提供给前台各业务部门使用,这样就可以最大限度地避免“重复造轮子”的问题,也让每一个新的前台业务创新能够真正意义上“站在巨人的肩膀上”,而不用每次开辟一个新业务都像新建一家创业公司那么艰难,甚或更为艰难。——某企业资深架构师 钟华


总结而言,中台的核心点主要有以下三个:



  • 中台是为前台而生。

  • 提炼各业务条线的共性需求。

  • 减少“重复造轮子”的时间与资源浪费。


01四大层面解读中台备受追捧原因


2015年,业界首次提出“大中台、小前台”战略,是想打造统一技术架构、产品支撑体系、数据共享平台、安全体系等等,把整个组织“横”过来,支撑多种多样的业务形态。中台似乎已经成为行业标配,稍有规模的公司都建设了自己的中台,掀起了一股强劲的中台风。


中台能够解决哪些问题呢?在我看来,主要有以下四种:



  • 项目重复造轮子严重,无法形成抽象共用


中台提供了一种在企业内部建立统一的技术平台或者服务平台的模式。这个平台可以被不同部门或者项目共享和复用,从而减少了重复开发的情况。随着新业务的不断接入,共享服务也从仅能提供单一的业务功能,不断的自我进化成更健壮更强大的服务,不断适应各种业务线的新需求。同时在数据积累方面,通过数据中台将各业务的数据都沉淀下来,不断地积累数据,发挥数据的最大威力。



  • 业务变化快,缓慢的研发流程难以迅速响应


很多企业开发响应慢,其实大部分都是因为数据问题,没有做到实时、准确和统一。比如一家公司的订单,分为 C 端订单,B 端订单,共享单车订单等等,这些订单分管在不同部门中,想要做订单统计、预测等就比较困难,各类型订单彼此割裂,而如果企业只有一个订单中心的话,数据就能够在不同场景下感知到业务的变化和联动。



  • 提高资源利用率和研发效率



说起如何提高资源利用率和研发效率,我总结为中台建设五步法:插件化、服务化、配置化、异步化和数据化。这五步环环相扣,其中插件化就是提高研发效率的关键点,我们将对核心交易流程进行抽象建模设计,并通过流程引擎的改造,实现增加多个插件和扩展点。这样,不同的业务场景可以根据需求自定义其个性化逻辑,将整个交易环节抽象为一个流程框架,并在其基础上引入一系列业务扩展。这种设计使得各业务间互不干扰,更灵活地满足各自需求。


提高资源利用率,这也是必然的,服务、数据、组件等形成统一复用,各资源也不再分散,只需通过一套服务来做支撑,并且可以通过各业务线的忙闲情况,做资源的调控、比如某个业务线使用交易中台服务,高峰时期是在早上8点到晚上12点,凌晨以后基本没有业务量,则可以考虑把针对这个业务线的资源配置降低,从而实现降本增效。



  • 提高系统稳定性和可靠性


一般来说系统的故障由三个方面引起,系统 bug、变更配置、并发流量变化。而技术中台避免了各个部门为解决自身技术问题而随意修改系统设置和配置的情况,这样做有助于防止整个系统因为随意修改而出现不稳定和安全问题。


02拆分中台并非全盘否定中台


前面我主要介绍了中台能解决哪些问题,但其实很多企业在实际引入中台的过程中,也遇到了很多问题:



  • 中台与前台的边界模糊


很多前台的业务让中台接管开发,到底是接还是不接?中台的角色和范围缺乏明确界定,导致中台与业务之间的责任划分模糊不清,引发了重复建设、资源浪费和沟通成本等问题。



  • 稳定性与灵活性的冲突


稳定与灵活一直是个矛盾体,中台接入的业务线非常多,一旦出问题影响面巨大,代码质量如何把控、上线流程如何稳定、业务如何做好隔离,都需要考虑清楚。



  • 沟通障碍与目标差异


协调中台团队和业务团队之间的沟通和合作,平衡双方的需求和利益,以及处理中台和业务之间的依赖和变更,都是一项复杂的管理任务。



  • 中台规划与业务需求之间的平衡


中台的服务需求和响应之间存在不匹配,这导致中台无法满足业务的多样化和个性化需求。有时中台过度迎合业务的短期需求,却牺牲了其长期规划和可持续发展。



  • 利益分配


距离业务近的地方,比距离业务远的地方更能得到公司增长的成果,中台看似业务,其实只是沉淀,追求的是稳定和灵活。还有业务下沉的时候,会涉及到与中台的业务交接,前台业务必定会减少。如果是部门划到中台,是否会有人员变动?当中台的服务价值和收益缺乏清晰界定,将难以有效衡量自身的贡献和影响。


综上,中台看似很美好,但很多企业在实际落地的时候却因为遇到这些问题,导致陷入困境,中台建设越建越复杂,甚至有些企业对中台也逐渐失去了信心,反而成了阻碍企业发展的瓶颈。


近两年业界开始风行“拆中台”策略——将中台变“薄”,拆分到多个独立的业务单元。这使得很多企业又开始认为中台已成明日黄花,引进中台并不是一个好选择,甚至有些企业将自身发展不顺的原因也归在了中台上面,一时间中台被全盘否定了。


我个人则认为拆分中台并非全盘否定中台,而是基于自身发展阶段和市场环境的变化进行战略调整和优化。“天下大事,合久必分,分久必合”,这就意味着在中台的管理和战略中,必须根据具体情况来做出分合的决策。有时候,将中台进行分散管理或者分解成更小的部分可能更为合适,因为这样有助于更好地满足各个业务单位的需求,提高灵活性和适应性。互联网大厂们将庞大而僵化的共享中台重新组织为灵活的业务域中台,可以更好适应具体业务场景和用户需求,既能保留中台提供通用能力和协同效率的优势,又能增加中台的灵活性和个性化。


03企业应该因地制宜选择是否需要中台


首先,我想强调的是,“中台”本身并不是一个新的架构思想,这个架构思想早在若干年以前就已经有了,很多企业已经是这么做了,就像面向对象编程语言中(Java)高内聚,低耦合,便是这种思想。


当企业处在初创期,随着业务发展产生多条业务线或产品线的时候,就会面临协同方面的挑战,如果每条业务线都要自己成立技术、运维、数据等部门,这样显然是非常浪费人力和资源的。为了适应快速发展的业务,就需要成立中台部门,来抽取、复用共性的东西,形成统一,这样既能满足“小前台,大中台”策略,让业务快跑抢占市场,中台提供稳定的炮火支援,又能提高协同和研发效率。参考示意图如下:



当企业已经渡过初创期,发展已经具有较大规模时,各条业务线人员和业务场景也比初创时更加庞大和复杂,企业了将面临更加多样化的市场,以及强大的响应能力,甚至每条业务线都要独立去创新,这样统一的中台部门就会变成瓶颈,人员、响应时间、需求变化和沟通等都会成为阻碍多样化需求的绊脚石。这时候企业就需要根据市场需要,将庞大而僵化的大共享中台,拆分到各业务单元中,将中台下沉到各业务单元中,这样既能保留中台的通用和协同能力,又能针对具体业务和场景不断增加灵活性和定制性。参考示意图如下:



总而言之,中台不是一直不变的,它需要根据市场需求不断进化,演变成能够满足当前企业市场需要的形态。中台不是万能的,它只是企业数字化转型的一种重要实现路径,我们不能对中台有过高的期望,而是应该理性地回归到企业数字化转型的价值上来。


作者简介


程超,腾讯云 TVP,高级研发管理专家,14年 Java 研发经验,8年技术管理和架构经验,曾任京东架构师,易宝支付和松果出行架构技术负责人,熟悉支付和电商领域,擅长微服务生态建设和运维监控,对 Dubbo、Spring Cloud 和 gRPC 等微服务框架有深入研究,并应用于项目,帮助过多家公司进行过微服务建设和改造,目前正在建设业务中台。 合著作品《深入分布式缓存》和《高可用可伸缩微服务架构》,极客时间每日一课讲师和出品人,CSDN 博主专家。


作者:腾讯云开发者
来源:juejin.cn/post/7366175769602932755
收起阅读 »

如果按代码量算工资,也许应该这样写

前言 假如有一天我们要按代码量来算工资,那怎样才能写出一手漂亮的代码,同时兼顾代码行数和实际意义呢? 要在增加代码量的同时提高代码质量和可维护性,能否做到呢? 答案当然是可以,这可难不倒我们这种摸鱼高手。 耐心看完,你一定有所收获。 正文 1. 实现更多的...
继续阅读 »

前言


假如有一天我们要按代码量来算工资,那怎样才能写出一手漂亮的代码,同时兼顾代码行数和实际意义呢?


要在增加代码量的同时提高代码质量和可维护性,能否做到呢?


答案当然是可以,这可难不倒我们这种摸鱼高手。


耐心看完,你一定有所收获。


giphy.gif


正文


1. 实现更多的接口:


给每一个方法都实现各种“无关痛痒”的接口,比如SerializableCloneable等,真正做到不影响使用的同时增加了相当数量的代码。


为了这些代码量,其中带来的性能损耗当然是可以忽略的。


public class ExampleClass implements Serializable, Comparable<ExampleClass>, Cloneable, AutoCloseable {

@Override
public int compareTo(ExampleClass other) {
// 比较逻辑
return 0;
}

// 实现 Serializable 接口的方法
private void writeObject(ObjectOutputStream out) throws IOException {
// 序列化逻辑
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
// 反序列化逻辑
}

// 实现 Cloneable 接口的方法
@Override
public ExampleClass clone() throws CloneNotSupportedException {
// 复制对象逻辑
return (ExampleClass) super.clone();
}

// 实现 AutoCloseable 接口的方法
@Override
public void close() throws Exception {
// 关闭资源逻辑
}

}


除了示例中的SerializableComparableCloneableAutoCloseable,还有Iterable


2. 重写 equals 和 hashcode 方法


重写 equalshashCode 方法绝对是上上策,不仅增加了代码量,还为了让对象在相等性判断和散列存储时能更完美的工作,确保代码在处理对象相等性时更准确、更符合业务逻辑。


public class ExampleClass {
private String name;
private int age;

// 重写 equals 方法
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}

if (obj == null || getClass() != obj.getClass()) {
return false;
}

ExampleClass other = (ExampleClass) obj;
return this.age == other.age && Objects.equals(this.name, other.name);
}

// 重写 hashCode 方法
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}


giphy (2).gif


3. 增加配置项和参数:


不要管能不能用上,梭哈就完了,问就是为了健壮性和拓展性。


public class AppConfig {
private int maxConnections;
private String serverUrl;
private boolean enableFeatureX;

// 新增配置项
private String emailTemplate;
private int maxRetries;
private boolean enableFeatureY;

// 写上构造函数和getter/setter
}

4. 增加监听回调:


给业务代码增加监听回调,比如执行前、执行中、执行后等各种Event,这里举个完整的例子。


比如创建个 EventListener ,负责监听特定类型的事件,事件源则是产生事件的对象。通过EventListener 在代码中增加执行前、执行中和执行后的事件。


首先,我们定义一个简单的事件类 Event


public class Event {
private String name;

public Event(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

然后,我们定义一个监听器接口 EventListener


public interface EventListener {
void onEventStart(Event event);

void onEventInProgress(Event event);

void onEventEnd(Event event);
}

接下来,我们定义一个事件源类 EventSource,在执行某个业务方法时,触发事件通知:


public class EventSource {
private List<EventListener> listeners = new ArrayList<>();

public void addEventListener(EventListener listener) {
listeners.add(listener);
}

public void removeEventListener(EventListener listener) {
listeners.remove(listener);
}

public void businessMethod() {
Event event = new Event("BusinessEvent");

// 通知监听器:执行前事件
for (EventListener listener : listeners) {
listener.onEventStart(event);
}

// 模拟执行业务逻辑
System.out.println("Executing business method...");

// 通知监听器:执行中事件
for (EventListener listener : listeners) {
listener.onEventInProgress(event);
}

// 模拟执行业务逻辑
System.out.println("Continuing business method...");

// 通知监听器:执行后事件
for (EventListener listener : listeners) {
listener.onEventEnd(event);
}
}
}

现在,我们可以实现具体的监听器类,比如 BusinessEventListener,并在其中定义事件处理逻辑:


public class BusinessEventListener implements EventListener {
@Override
public void onEventStart(Event event) {
System.out.println("Event Start: " + event.getName());
}

@Override
public void onEventInProgress(Event event) {
System.out.println("Event In Progress: " + event.getName());
}

@Override
public void onEventEnd(Event event) {
System.out.println("Event End: " + event.getName());
}
}

最后,我们写个main函数来演示监听事件:


public class Main {
public static void main(String[] args) {
EventSource eventSource = new EventSource();
eventSource.addEventListener(new BusinessEventListener());

// 执行业务代码,并触发事件通知
eventSource.businessMethod();

// 移除监听器
eventSource.removeEventListener(businessEventListener);
}
}

如此这般那般,代码量猛增,还顺带实现了业务代码的流程监听。当然这只是最简陋的实现,真实环境肯定要比这个复杂的多。


5. 构建通用工具类:


同样的,甭管用不用的上,定义更多的方法,都是为了健壮性。


比如下面这个StringUtils,可以从ApacheCommons、SpringBoot的StringUtil或HuTool的StrUtil中拷贝更多的代码过来,美其名曰内部工具类。


public class StringUtils {
public static boolean isEmpty(String str) {
return str == null || str.trim().isEmpty();
}

public static boolean isBlank(String str) {
return str == null || str.trim().isEmpty();
}

// 新增方法:将字符串反转
public static String reverse(String str) {
if (str == null) {
return null;
}
return new StringBuilder(str).reverse().toString();
}

// 新增方法:判断字符串是否为整数
public static boolean isInteger(String str) {
try {
Integer.parseInt(str);
return true;
} catch (NumberFormatException e) {
return false;
}
}
}

6. 添加新的异常类型:


添加更多异常类型,对不同的业务抛出不同的异常,每种异常都要单独去处理


public class CustomException extends RuntimeException {
// 构造函数
public CustomException(String message) {
super(message);
}

// 新增异常类型
public static class NotFoundException extends CustomException {
public NotFoundException(String message) {
super(message);
}
}

public static class ValidationException extends CustomException {
public ValidationException(String message) {
super(message);
}
}
}

// 示例:添加不同类型的异常处理
public class ExceptionHandling {
public void process(int value) {
try {
if (value < 0) {
throw new IllegalArgumentException("Value cannot be negative");
} else if (value == 0) {
throw new ArithmeticException("Value cannot be zero");
} else {
// 正常处理逻辑
}
} catch (IllegalArgumentException e) {
// 异常处理逻辑
} catch (ArithmeticException e) {
// 异常处理逻辑
}
}
}


7. 实现更多设计模式:


在项目中运用更多设计模式,也不失为一种合理的方式,比如单例模式、工厂模式、策略模式、适配器模式等各种常用的设计模式。


比如下面这个单例,大大节省了内存空间,虽然它存在线程不安全等问题。


public class SingletonPattern {
// 单例模式
private static SingletonPattern instance;

private SingletonPattern() {
// 私有构造函数
}

public static SingletonPattern getInstance() {
if (instance == null) {
instance = new SingletonPattern();
}
return instance;
}

}

还有下面这个策略模式,能避免过多的if-else条件判断,降低代码的耦合性,代码的扩展和维护也变得更加容易。


// 策略接口
interface Strategy {
void doOperation(int num1, int num2);
}

// 具体策略实现类
class AdditionStrategy implements Strategy {
@Override
public void doOperation(int num1, int num2) {
int result = num1 + num2;
System.out.println("Addition result: " + result);
}
}

class SubtractionStrategy implements Strategy {
@Override
public void doOperation(int num1, int num2) {
int result = num1 - num2;
System.out.println("Subtraction result: " + result);
}
}

// 上下文类
class Context {
private Strategy strategy;

public Context(Strategy strategy) {
this.strategy = strategy;
}

public void executeStrategy(int num1, int num2) {
strategy.doOperation(num1, num2);
}
}

// 测试类
public class StrategyPattern {
public static void main(String[] args) {
int num1 = 10;
int num2 = 5;

// 使用加法策略
Context context = new Context(new AdditionStrategy());
context.executeStrategy(num1, num2);

// 使用减法策略
context = new Context(new SubtractionStrategy());
context.executeStrategy(num1, num2);
}
}

对比下面这段条件判断,高下立判。


public class Calculator {
public static void main(String[] args) {
int num1 = 10;
int num2 = 5;
String operation = "addition"; // 可以根据业务需求动态设置运算方式

if (operation.equals("addition")) {
int result = num1 + num2;
System.out.println("Addition result: " + result);
} else if (operation.equals("subtraction")) {
int result = num1 - num2;
System.out.println("Subtraction result: " + result);
} else if (operation.equals("multiplication")) {
int result = num1 * num2;
System.out.println("Multiplication result: " + result);
} else if (operation.equals("division")) {
int result = num1 / num2;
System.out.println("Division result: " + result);
} else {
System.out.println("Invalid operation");
}
}
}


8. 扩展注释和文档:


如果要增加代码量,写更多更全面的注释也不失为一种方式。


/**
* 这是一个示例类,用于展示增加代码数量的技巧和示例。
* 该类包含一个示例变量 value 和示例构造函数 ExampleClass(int value)。
* 通过示例方法 getValue() 和 setValue(int newValue),可以获取和设置 value 的值。
* 这些方法用于展示如何增加代码数量,但实际上并未实现实际的业务逻辑。
*/

public class ExampleClass {

// 示例变量
private int value;

/**
* 构造函数
*/

public ExampleClass(int value) {
this.value = value;
}

/**
* 获取示例变量 value 的值。
* @return 示范变量 value 的值
*/

public int getValue() {
return value;
}

/**
* 设置示例变量 value 的值。
* @param newValue 新的值,用于设置 value 的值。
*/

public void setValue(int newValue) {
this.value = newValue;
}
}

结语


哪怕是以代码量算工资,咱也得写出高质量的代码,合理合法合情的赚票子。


giphy (1).gif


作者:一只叫煤球的猫
来源:juejin.cn/post/7263760831052906552
收起阅读 »

基于SSE的实时消息推送

背景 小盟 AI 助手项目中需要服务端把 AI 模型回调回来内容,实时推送到客户端,展示给用户;整个流程需要一个能够快速支持上线的服务端推送方案。经过对现有的一些服务端推送方案进行调研,并结合项目的周期、实现成本、用户体验等多方综合考量,最终选择了 ...
继续阅读 »



背景


小盟 AI 助手项目中需要服务端把 AI 模型回调回来内容,实时推送到客户端,展示给用户;整个流程需要一个能够快速支持上线的服务端推送方案。经过对现有的一些服务端推送方案进行调研,并结合项目的周期、实现成本、用户体验等多方综合考量,最终选择了 Server-Sent Events(SSE)方案进行实践。


首先服务端推送,是一种允许应用服务器主动将信息发送到客户端的能力,为客户端提供了实时的信息更新和通知,增强了用户体验。


服务端推送主要基于以下几个诉求:


(1)实时通知:在很多情况下,用户期望实时接收到应用的通知,如新消息提醒、活动提醒等。


(2)节省资源:如果没有服务端推送,客户端需要通过轮询的方式来获取新信息,会造成客户端、服务端的资源损耗。通过服务端推送,客户端只需要在收到通知时做出响应,大大减少了资源的消耗。


(3)增强用户体验:通过服务端推送,应用可以针对特定用户或用户群发送有针对性的内容,如优惠活动、个性化推荐等。这有助于提高用户对应用的满意度和黏性。


方案对比



轮询: 是一种较为传统的方式,客户端定时地向服务端发送请求,询问是否有新数据。服务端只需要检查数据状态,然后将结果返回给客户端。轮询的优点是实现简单,兼容性好;缺点是可能产生较大的延迟,且对服务端资源消耗较高。


长轮询(Long Polling): 轮询的改进版。客户端向服务器发送请求,服务器收到请求后,如果有新的数据,立即返回给客户端;如果没有新数据,服务器会等待一定时间(比如30秒超时时间),在这段时间内,如果有新数据,就返回给客户端,否则返回空数据。客户端处理完服务器返回的响应后,再次发起新的请求,如此反复。长轮询相较于传统的轮询方式减少了请求次数,但仍然存在一定的延迟。   


WebSocket: 一种双向通信协议,同时支持服务端和客户端之间的实时交互。WebSocket 是基于 TCP 的长连接,和HTTP 协议相比,它能实现轻量级的、低延迟的数据传输,非常适合实时通信场景,主要用于交互性强的双向通信。


SSE: 是一种基于 HTTP 协议的推送技术。服务端可以使用 SSE 来向客户端推送数据,但客户端不能通过 SSE 向服务端发送数据。相较于 WebSocket,SSE 更简单、更轻量级,但只能实现单向通信。


图片


图片


小盟 AI 助手项目需要快速上线且保证要用户较好的使用体验。鉴于 SSE 技术的轻量、实现简单、不增加额外的资源成本;当前业务场景也只需要服务端到用户端的单向的字符推送。非常适合项目需要。所以决定使用 SSE 来实现内容推送。****


深入 SSE



SSE 服务端推送,它基于 HTTP 协议,易于实现和部署,特别适合那些需要服务器主动推送信息、客户端只需接收数据的场景。有以下特点:


1、简单:基于 HTTP,无须额外的协议或者类库支持。主流浏览器都支持。


2、事件流:使用"事件流"(Event Stream)将数据从服务器发送到客户端。每个事件都可以包含一个事件标识符、事件类型和数据字段。客户端可以根据这些信息来解析和处理接收到的数据。


3、自动重连:意外断开时会自动尝试重新连接。可以确保了在网络故障或连接中断后能够及时恢复通信,为用户提供连续的数据流。重连时会在 HTTP 头中的Last_Event_ID 带上上一次的数据 ID,便于服务端返回后续数据。


4、单向推送:只能从服务端推送数据到客户端。


图片


SSE 消息体介绍:


图片


SSE消息体示例:


图片


服务端主要使用 Spring,其对 SSE 主要提供了两种支持:



  • Spring WebMVC:传统的基于 Servlet 的同步阻塞编程模型,即 同步模型 Web 框架。

  • Spring WebFlux:异步非阻塞的响应式编程模型,即 异步模型 Web 框架。          


项目基于springboot,所以选择使用前者实现。SseEmitter emitter = new SseEmitter(); 一句代码就可以建立一个 SSE 连接。


实践



后端实现


建立一个SseEmitterManger,统一管理当前服务 SSE 连接的创建、释放以及数据推送。结合 Redis 缓存可实现集群环境 SSE 连接的管理。


核心逻辑如下:



  • 连接池维护,设定一个上限,避免过大,导致内存问题。


static final Map<String, SseEmitter> sseCache =     new ConcurrentHashMap<>(300)          


  • 建立SSE连接,为每个连接建立一个唯一的MsgId,用来维护SSE连接与客户端的关系了;在 Redis 缓存中存入MsgId和当前机器节点的IP和Port,这样可以找到SSE 连接所在的服务结点,然后通过 HTTP 请求转发需要发送的数据到对应的服务节点上进行处理。


sse = new SseEmitter()sseId = "sse_xxx";redisKey= "aisse:" + bosId + "_" + wid ipPort = "10.10.10.10:8080"redis.hset(redisKey, msgId, ipPort)sseCache.put(msgId, sseEmitter);


  • 获取持有连接的 pod ipPort;根据 IP 发起请求。


ipPort = redisUtil.hashGet(redisKey, msgId)


  • 获取当前服务结点的SSE连接,发送数据。


sseEmitter = sseCache.get(msgId)sseEmitter.send(msgJson)          


  • 释放SSE连接


SseEmitter sseEmitter = sseCache.get(msgId);sseEmitter.complete();sseCache.remove(msgId);redisUtil.hashDel(redisKey, msgId);

**核心流程图如下: **  


图片


需要注意的是开启 SSE 连接接口的整个链路都要支持长连接。例如使用 Nginx 则要开启长连接的配置:



  • keepalive 用于控制可连接整个 upstream servers 的 HTTP 长连接的个数,即控制总数。

  • proxy_http_verion 用于控制代理后端链接时使用的 HTTP 版本,默认为 1.0。要想使用长连接,必须配置为 1.1。

  • proxy_set_header 需要设置为 Connection "",否则则发往 upstream servers 的请求中,Connection header 的值将为close,导致无法建立长连接。   


http {        upstream keepAliveService {            server 10.10.131.149:8080;            keepalive 20;        }            server {            listen 80;            server_name keepAliveService;            location /keep-alive/hello {                proxy_http_version 1.1;                proxy_set_header Connection "";                proxy_pass http://keepAliveService;            }        }}

**前端实现 **  


前端可以使用组件 @microsoft/fetch-event-source 来实现。


npm i @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source';let controller = new AbortController(); let eventSource = fetchEventSource('apiUrl', { method: 'POST', headers: { 'Content-Type': 'application/json', 'token': '....' }, signal: controller.signal, body: JSON.stringify({ ... // 传参 }), onopen() { // 建立连接 }, onmessage(event) { // 接收信息 // 成功之后满足某些条件可以使用AbortController关闭连接 controller.abort() eventSource?.close && eventSource.close(); }, onerror() { // 服务异常 controller.abort() eventSource?.close && eventSource.close(); }, onclose() { // 服务关闭 },})

总结



SSE 轻量级的服务端单向推送技术;具有支持跨域、使用简单、支持自动重连等特点。相对于 WebSocket 更加轻量级,如果需求场景客户端和服务端单向通信,那么 SSE 是一个不错的选择。


作者:微盟技术中心
来源:juejin.cn/post/7317325043541032970
收起阅读 »

从密码到无密码:账号安全进化史(科普向)

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究! 本文是一篇科普文,五一结束了,大家看点轻松的~ 不知道大家在过去半年有没有发现 Github 强制开启了 2FA,而且还不可以关闭的,每次你打开 github 都会提...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!




本文是一篇科普文,五一结束了,大家看点轻松的~


不知道大家在过去半年有没有发现 Github 强制开启了 2FA,而且还不可以关闭的,每次你打开 github 都会提醒你的验证:


Image.png


简单的说,就是打开 Github 进行验证时,只依靠密码验证已经不被允许,你必须打开你手机上的验证软件,把里面随机码输入到 Github 才能完成身份验证,类似于十年前国内 QQ 安全中心的验证。


这是一种双重验证的手段,用于更好的保证我们的账号安全,今天就以此为引,给大家讲讲账号安全相关发展的历史。


第一幕:密码的独角戏 - 脆弱的防线


在互联网的蛮荒时代,密码就像原始人手中的木棍和石头,是守护账号安全的唯一屏障。然而,这道防线却是脆弱不堪的,面对黑客的攻击,如同纸糊的老虎,一戳就破。暴-力-破-解、字-典-攻-击、社会-工程-学手段,都足以让密码这道防线形同虚设。


1. 暴-力-破-解:暴-力-破-解就是通过遍历的方式尝试出你的密码组合,比如银行的六位取款密码实际上只有 46656种组合,利用现在任何一台电脑或者手机的算力都能瞬间算出来,为了对应这种情况,现在几乎所有网站都有密码输入次数限制。


2. 字-典-攻-击:字典-攻击就是利用常用密码来攻破你的密码,比暴力破解效率更好,比如 123456 这个密码就有很多人使用。


3. 社会-工程-学:社会-工程-学说人话就是套你的话,或者调查你的信息,比如在和你沟通的过程中知道了你的手机号、身-份-证号码、生日信息等,因为有大量的人用手机号后六位、身-份-证号后六位或者生日当作自己的密码,所以这种手段的成功率一般会更高。


在当前这个时代,由于互联网各种 App 的涌入,每个人都拥有大量的账号,如何记忆他们成为了一个难题,大量的人选择对所有网站使用同一个密码,这就又造成了账号安全问题。


重复使用密码就像是使用同一把钥匙开启不同的门,一旦一把钥匙被复制,所有的门都将面临危险。


每年,全球都会发生无数起数据泄露事件,大量的用户名和密码被公开曝光。这些泄露的密码成为了黑客攻击的利器,他们可以利用这些密码进行撞库攻击,尝试登录其他网站。


会在不同的网站使用相同的密码将会导致“一损俱损”的局面。一旦一个网站发生数据泄露,黑客便可以利用泄露的密码尝试登录其他网站,从而获取更多个人信息,造成更大的损失。


第二幕:多因素认证 (MFA) 登场 - 多重关卡,层层设防


所以,为了弥补密码的不足,MFA 应运而生,为账号安全加装了多重门锁。除了密码这把“钥匙”,你还需要其他的“通关密语”才能进入:



  • 验证码: 这是国内最常用的方式,甚至几乎所有 App 都已经不需要你记忆账号密码,只需要一个手机验证码即可,国外使用手机验证码的很少,因为他们更多使用邮箱来注册账号,比如我现在在使用的编辑软件Craft 在登录时就要求你提供邮箱验证码,它甚至不能设置密码。

  • 指纹识别: 你的指纹独一无二,所以它就像是你的专属的“魔法印记”,轻轻一按,就能保证你是你。

  • 面部识别: 对着摄像头眨眨眼,你的面容信息也是你的专属印记,苹果手机上甚至使用了虹膜识别来检测你是你,而不是别人。

  • 安全令牌: 一个小巧的硬件设备,可以生成一次性密码,就像古代的“虎符”一样,只有拥有它才能调兵遣将。令牌可以有软件和硬件两种方式,软件就是 Google Auth 这种软件,而硬件则是我们早古互联网时代网上购物常用的网银 U 盾形式。


在开头的时候,我曾提到了 2FA,它和本节的 MFA 听名字非常相似,实际上说的也几乎是一个东西。


2FA 是指:需要两种验证,才能完成整个验证,一般是密码和动态安全令牌。


MFA 是指:需要两种或以上验证方式,才能完成整个验证,一般也是密码和动态安全令牌。


所以在大多数语境下,这俩说的其实是一个东西,有些验证方式将两个验证方式合而为一,比如手机/邮箱验证码。


因为多因子验证的核心是:一个你知道的凭证 和 一个你刚刚才知道的凭证。


我们一开始就知道的凭证往往是邮箱 + 密码,一个刚刚才会知道的凭证往往就是动态安全令牌码了,所以手机验证码登录的方式也是 2FA,还是属于比较方便的那种。


注:我这里说的手机验证码登录是真的发给你验证码,而不是国内的那种手机号一键登录。


第三幕:单点登录 (SSO) 崛起 - 统一管理的钥匙


其实随着 MFA 的出现,安全问题已经不需要太担心了,所以接下来账号安全开始朝着:安全 + 高效的方向开始发展,所以开始出现了 SSO。


SSO 的第一个阶段是内部互信,它的概念最早可以追溯到 1990 年代,随着企业内部网络的发展而兴起。


后来随着互联网的发展,一个公司往往同时拥有多个业务,比如十年前还是百度的天下的时候,我们会同时使用百度知道、百度贴吧、百度网盘这些产品。


你只需要在某一个百度旗下的产品登录一次,打开其他产品的时候往往也会自动识别到你的账号。


比如你在百度贴吧登录了,此时你打开网页版的百度网盘你自动就是已登录状态。


不要以为这是一个 So easy 的操作,它的原理其实是使用你存储在同一个主域名下的 cookie 实现的。


比如百度贴吧的域名是:tieba.baidu.com/,而百度网盘的域名是:pan.baidu.com/,它俩都属于主域名 baidu.com,所以通过携带同域名下cookie 的方式,让同域名下的其他服务也能正确识别当前账号。


具体识别方案一般有两种:



  1. 通过共享 session + cookie 的方式做验证。

  2. 通过获取 cookie 内部跳转到 SSO 做验证。


无论使用哪种方案,携带 cookie 这个操作必不可少,所以这一阶段的 SSO 是基于 Cookie 的。


可能还有一个词大家比较常见:SAML,SAML标准也是用于内部系统互信,做的事和基于 Cookie 的 SSO 都是一样的,所以这里我不再赘述。


第四幕:OAuth 协议的诞生 - 授权管理的桥梁


经历完 SSO 的第一个阶段之后,我们就来到了 SSO 的第二阶段:外部互信


由于 Web 互联网的兴起,这一阶段也被称为基于 Web 的 SSO,这一阶段的代表是OAuth。


你有没有想过,如果我们在所有平台都使用同一个账号多好,就不用在记忆那么多的应用账号密码,减少心智负担。


在国内互联圈地的情况下,这种情况并没有实现,也可以说通过手机号实现了。


但是在国外,Google 账号体系几乎就是事实上的一号通行,你注册一个 Google 账号之后,几乎可以通过这个账号登录所有的网站,这就是 OAtuh 的作用。


想象一下,你拥有许多宝藏,分别存放于不同的宝库中。比如,你在 Facebook 上存储着你的社交关系,在 Google 上存储着你的邮件和文件,在 Spotify 上存储着你的音乐喜好。


现在,你想要使用一个新的游戏应用,而这个应用需要访问你在 Facebook 上的好友列表,以便你能够邀请好友一起玩游戏。


这时,你面临一个两难的选择:



  • 分享密码: 将你的 Facebook 密码告诉游戏应用,让它直接访问你的好友列表。但这存在着巨大的安全风险,一旦游戏应用泄露你的密码,你的所有 Facebook 数据都将暴露无遗。

  • 放弃使用: 由于担心安全问题,你放弃使用这个游戏应用,从而错过了与好友一起游戏的乐趣。


为了解决上面这种问题,Google 等公司在 2010 年发布了 OAuth1.0,由于它存在许多问题,所以又在 2012 年发布了 OAuth2.0。


所以在现如今,几乎所有公司都接入了 Google 的 OAuth 登录,当你在第三方平台想使用 Google 账号登录时,OAuth 协议会引导你到 Google 进行授权。


平台会询问你是否同意授权第三方应用访问你的部分数据 (例如好友列表),如果你同意,平台就会发放一个临时的“通行证”给第三方应用,让它可以访问你的数据,但不会泄露你的密码。


所以 OAuth 的核心是授权而非共享。


第五幕:无密码时代的曙光 - 告别繁琐的密码


我相信当大家看到第四节的时候,大家就会觉得应该就这些了,没有别的新意了,恰恰相反,为了彻底摆脱密码的束缚,世界巨头们正在探索新的“魔法”,那就是无密码


在 2019 年,WebAuthn 标准被 W3C 以建议的形式发布,它是 FIDO 联盟下 FIDO2 的核心组件,旨在减少人们对于密码的依赖。


它带了以下三个好处:



  • 消除密码依赖: 通过使用更加安全的认证方式,例如生物识别技术 (指纹、面部识别) 或安全密钥,消除用户对密码的依赖,降低密码泄露和网络钓鱼攻击的风险。

  • 提升用户体验: 简化登录流程,无需记忆和输入复杂的密码,只需轻触指纹或插入安全密钥,即可完成身份验证。

  • 增强安全性: 使用公钥加密技术,确保用户的认证信息不会被窃取或伪造,有效抵御网络攻击。


如果大家有在 Mac 上的 Safari 浏览器登录苹果账号的经历,就会发现它不需要你输入密码,只需要一次简单的指纹验证:


Image.png


这时你通过验证你的指纹就可以顺利登录成功,这就是基于 WebAuthn 标准的 Passkeys。


目前苹果、谷歌、微软等几乎所有大厂都支持了Passkeys,,由于它也是一个 W3C 标准,所以你可以通过这个网站查看支持列表。


看起来指纹验证就像开头我们说过的 MFA,但是它比 MFA 多了一个东西就是设备,通过生物信息 + 受信设备的方式完成了它的整个认证流程,它拥有两个比较大的特点:



  • 提供了一套标准化的用户界面和用户体验,简化了无密码登录的操作流程。

  • 将用户的登录凭证 (私钥) 存储在用户的设备 (例如手机、电脑) 中,并通过云端服务进行同步,方便用户在不同设备上登录。


说回我们开头的 Github 的 2FA,其实 Github 也接入了它,如果你完成 2FA 之后,之后就可以在浏览器中通过指纹验证登录。


身份认证的未来已来,无密码的出现,为我们在登录授权流程中带来了许多方便~




好了,以上就是本篇文章的全部内容了,希望大家多多点赞支持,我将更快提供更好更优质的内容。


注:本文小标题是借助 AI 能力起的,部分描述也借助了 AI 美化,AI 美化生成内容不会超过 300 字(本文 4000 字),请大家放心食用。


作者:和耳朵
来源:juejin.cn/post/7364764922339065890
收起阅读 »

JSON慢地要命: 看看有啥比它快!

是的, 你没听错! 网络开发中无处不在的数据交换格式JSON, 可能会拖慢你的应用程序. 在这个速度和响应速度至上的世界里, 检查 JSON 的性能影响至关重要, 而我们对此却常常忽略. 在本博客中, 我们将深入探讨 JSON 成为应用程序瓶颈的原因, 并探索...
继续阅读 »


是的, 你没听错! 网络开发中无处不在的数据交换格式JSON, 可能会拖慢你的应用程序. 在这个速度和响应速度至上的世界里, 检查 JSON 的性能影响至关重要, 而我们对此却常常忽略. 在本博客中, 我们将深入探讨 JSON 成为应用程序瓶颈的原因, 并探索更快的替代方案和优化技术, 以确保你的应用程序以最佳状态运行.


JSON 是什么? 为何我要关注这个问题?



JSON 教程 | w3resource


JSON是JavaScript Object Notation的缩写, 是一种轻量级数据交换格式, 已成为Web应用中传输和存储数据的首选. 它的简洁性和人类可读格式使人类和机器都能轻松使用. 但是, 为什么要在Web开发项目中关注 JSON 呢?


JSON 是应用中数据的粘合剂. 它是服务器和客户端之间进行数据通信的语言, 也是数据库和配置文件中存储数据的格式.


JSON 的流行以及人们使用它的原因…


JSON 在Web开发领域的受欢迎程度怎么强调都不为过. 它已成为数据交换的事实标准, 这其中有几个令人信服的原因:


它易于使用!



  1. 人类可读格式: JSON 使用简单明了, 基于文本的结构, 开发人员和非开发人员都能轻松阅读和理解. 这种人类可读的格式增强了协作, 简化了调试.

  2. 语言无关性: JSON 与任何特定的编程语言无关. 它是一种通用的数据格式, 几乎所有现代编程语言都能对其进行解析和生成, 因此它具有很强的通用性.

  3. 数据结构一致性: JSON 使用键值对, 数组和嵌套对象来实现数据结构的一致性. 这种一致性使其具有可预测性, 便于在各种编程场景中使用.

  4. 支持浏览器: 网络浏览器原生支持 JSON, 允许Web应用与服务器进行无缝通信. 这种本地支持极大地促进了 JSON 在Web开发中的应用.

  5. JSON API: 许多网络服务和应用接口默认以 JSON 格式提供数据. 这进一步巩固了 JSON 在Web开发中作为数据交换首选的地位.

  6. JSON Schema: 开发人员可以使用 JSON 模式来定义和验证 JSON 数据的结构, 从而为应用增加了一层额外的清晰度和可靠性.


鉴于这些优势, 难怪全球的开发人员都依赖 JSON 来满足他们的数据交换需求. 然而, 随着我们在本博客的深入探讨, 我们将发现与 JSON 相关的潜在性能挑战, 以及如何有效解决这些挑战.


速度需求


🚀🚀🚀


应用的速度和响应的重要性


在当今快节奏的数字环境中, 应用的速度和响应能力是不可或缺的. 用户希望在Web和移动应用中即时获取信息, 快速交互和无缝体验. 对速度的这种要求是由以下几个因素驱动的:



  1. 用户期望: 用户已习惯于从数字互动中获得闪电般快速的响应. 他们不想等待网页的加载或应用的响应. 哪怕是几秒钟的延迟, 都会导致用户产生挫败感并放弃使用.

  2. 竞争优势: 速度可以成为重要的竞争优势. 反应迅速的应用往往比反应迟缓的应用更能吸引和留住用户.

  3. 搜索引擎排名: 谷歌等搜索引擎将网页速度视为排名因素. 加载速度更快的网站往往在搜索结果中排名靠前, 从而提高知名度和流量.

  4. 转化率: 电子商务网站尤其清楚速度对转化率的影响. 网站速度越快, 转换率越高, 从而增加收入.

  5. 移动性能: 随着移动设备的普及, 对速度的需求变得更加重要. 移动用户的带宽和处理能力往往有限, 因此快速的应用性能是必要的.


JSON 会拖慢我们的应用吗?


现在, 让我们来讨论核心问题: JSON 是否会拖慢我们的应用?


如前所述, JSON 是一种非常流行的数据交换格式. 它灵活, 易用, 并得到广泛支持. 然而, 这种广泛的应用并不意味着它不会面临性能挑战.


某些情况下, JSON 可能是导致应用慢的罪魁祸首. 解析 JSON 数据的过程, 尤其是在处理大型或复杂结构时, 可能会耗费宝贵的毫秒时间. 此外, 低效的序列化和反序列化也会影响应用的整体性能.


在接下来的内容中, 我们将探讨 JSON 成为应用瓶颈的具体原因, 更重要的是, 探讨如何缓解这些问题. 在深入探讨的过程中, 请记住我们的目标不是诋毁 JSON, 而是了解其局限性并发现优化其性能的策略, 以追求更快, 反应更灵敏的应用.



LinkedIn将Protocal Buffers与Rest.li集成以提高微服务性能| LinkedIn工程


JSON 为什么会变慢


尽管 JSON 被广泛使用, 但它也难逃性能挑战. 让我们来探究 JSON 可能会变慢的原因, 并理解为什么 JSON 并不总是数据交换的最佳选择.


1. 解析带来的开销


当 JSON 数据到达应用时, 它必须经过解析过程才能转换成可用的数据结构. 解析过程可能相对较慢, 尤其是在处理大量或深度嵌套的 JSON 数据时.


2. 序列化和反序列化


JSON 要求在从客户端向服务器发送数据时进行序列化(将对象编码为字符串), 并在接收数据时进行反序列化(将字符串转换回可用对象). 这些步骤会带来开销, 影响应用的整体速度.


微服务架构的世界里, JSON 通常用于在服务之间传递消息. 但是, 很关键的是, 我们必须认识到, JSON 消息需要序列化和反序列化, 这两个过程会带来巨大的开销.



在有大量微服务不断通信的场景中, 这种开销可能会增加, 并有可能使应用变慢, 以至于影响用户体验.




我们面临的第二个挑战是, 由于 JSON 的文本性质, 序列化和反序列化的延迟和吞吐量都不理想.
— LinkedIn



1_74sQfiW0SjeFfcTcgNKupw.webp
序列化和反序列化


3. 字符串操作


JSON 基于文本, 在连接和解析等操作中严重依赖字符串操作. 与处理二进制数据相比, 处理字符串的速度会慢一些.


4. 缺乏数据类型


JSON 的数据类型(如字符串, 数字, 布尔值)非常有限. 复杂的数据结构可能需要效率较低的表示法, 从而导致内存使用量增加和处理速度减慢.



5. 冗余


JSON 的人类可读性设计可能会导致冗余. 不需要的键和重复的结构增加了有效载荷的大小, 导致数据传输时间延长.



第一个挑战是 JSON 是一种文本格式, 往往比较冗余. 这导致网络带宽使用量增加, 更高的延迟, 效果并不理想.
— LinkedIn



6. 不支持二进制


JSON 缺乏对二进制数据的本地支持. 在处理二进制数据时, 开发人员通常需要将其编解码为文本, 而这可能会降低效率.


7. 深度嵌套


在某些情况下, JSON 数据可能是深嵌套的, 需要递归解析和遍历. 这种计算复杂性会降低应用的运行速度, 尤其是在没有优化的情况下.


JSON的替代方案


虽然 JSON 是一种通用的数据交换格式, 但由于其在某些情况下的性能限制, 人们开始探索更快的替代格式. 让我们深入探讨其中的一些替代方案, 了解何时以及为何选择它们:


1. Protocol Buffers(protobuf)


Protocal Buffers通常被称为protobuf, 是由谷歌开发的一种二进制序列化格式. 它的设计宗旨是高效, 紧凑和快速. Protobuf 的二进制性质使其在序列化和反序列化方面的速度明显快于 JSON.



  • 何时选择: 当你需要高性能的数据交换时, 尤其是在微服务架构, 物联网应用或网络带宽有限的情况下, 请考虑使用Protobuf.


GitHub - vaishnav-mk/protobuf-example


2. MessagePack


MessagePack 是另一种二进制序列化格式, 以速度快, 结构紧凑而著称. 它比 JSON 更有效率, 同时与各种编程语言保持兼容.



  • 何时选择: 当你需要在速度和跨语言兼容性之间取得平衡时, MessagePack 是一个不错的选择. 它适用于实时应用和对减少数据大小至关重要的情况.


3. BSON (二进制 JSON)


BSON 或二进制 JSON 是一种从 JSON 衍生出来的二进制编码格式. 它保留了 JSON 的灵活性, 同时通过二进制编码提高了性能. BSON 常用于 MongoDB 等数据库.



  • 何时选择: 如果你正在使用 MongoDB, 或者需要一种格式来弥补 JSON 和二进制效率之间的差距, 那么 BSON 是一个很有价值的选择.


4. Apache Avro


Apache Avro 是一个数据序列化框架, 专注于提供一种紧凑的二进制格式. 它基于schema, 可实现高效的数据编解码.



  • 何时选择: Avro 适用于schema演进非常重要的情况, 如数据存储, 以及需要在速度和数据结构灵活性之间取得平衡的情况.


与 JSON 相比, 这些替代方案提供了不同程度的性能改进, 具体选择取决于你的具体使用情况. 通过考虑这些替代方案, 你可以优化应用的数据交换流程, 确保将速度和效率放在开发工作的首位.



JSON, Protobufs, MessagePack, BSON 和 Avro 之间的差异


每个字节都很重要: 优化数据格式


在效率和速度至上的数据交换世界中, 数据格式的选择会产生天壤之别. 本节将探讨从简单的 JSON 数据表示到更高效的二进制格式(如 Protocol Buffers, MessagePack, BSON 和 Avro)的过程. 我们将深入探讨每种格式的细微差别, 并展示为什么每个字节都很重要.


开始: JSON 数据


我们从简单明了的 JSON 数据结构开始. 下面是我们的 JSON 数据示例片段:


{
"id": 1, // 14 bytes
"name": "John Doe", // 20 bytes
"email": "johndoe@example.com", // 31 bytes
"age": 30, // 9 bytes
"isSubscribed": true, // 13 bytes
"orders": [ // 11 bytes
{ // 2 bytes
"orderId": "A123", // 18 bytes
"totalAmount": 100.50 // 20 bytes
}, // 1 byte
{ // 2 bytes
"orderId": "B456", // 18 bytes
"totalAmount": 75.25 // 19 bytes
} // 1 byte
] // 1 byte
} // 1 byte

JSON 总大小: ~ 139 字节


虽然 JSON 用途广泛且易于使用, 但它也有一个缺点, 那就是它的文本性质. 每个字符, 每个空格和每个引号都很重要. 在数据大小和传输速度至关重要的情况下, 这些看似微不足道的字符可能会产生重大影响.


效率挑战: 使用二进制格式减小尺寸



现在, 让我们提供其他格式的数据表示并比较它们的大小:


Protocol Buffers (protobuf):


syntax = "proto3";

message User {
int32 id = 1;
string name = 2;
string email = 3;
int32 age = 4;
bool is_subscribed = 5;
repeated Order orders = 6;

message Order {
string order_id = 1;
float total_amount = 2;
}
}

0A 0E 4A 6F 68 6E 20 44 6F 65 0C 4A 6F 68 6E 20 44 6F 65 65 78 61 6D 70 6C 65 2E 63 6F 6D 04 21 00 00 00 05 01 12 41 31 32 33 03 42 DC CC CC 3F 05 30 31 31 32 34 34 35 36 25 02 9A 99 99 3F 0D 31 02 42 34 35 36 25 02 9A 99 99 3F

Protocol Buffers 总大小: ~ 38 bytes


MessagePack:


(注意:MessagePack 是一种二进制格式, 此处的表示法非人工可读.)


二进制表示(十六进制):


a36a6964000000000a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d042100000005011241313302bdcccc3f0530112434353625029a99993f


MessagePack 总大小: ~34 字节


BSON (二进制 JSON):


(注意:BSON 是一种二进制格式, 此处的表示法非人工可读.)


二进制表示法 (十六进制):


3e0000001069640031000a4a6f686e20446f6502656d61696c006a6f686e646f65406578616d706c652e636f6d1000000022616765001f04370e4940

BSON 总大小: ~ 43 字节


Avro:


(注: Avro使用schema, 因此数据与schema信息一起编码.)


二进制表示法 (十六进制):


0e120a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d049a999940040a020b4108312e3525312e323538323539

Avro 总大小: ~ 32 字节



(这些替代方案的实际字节数可能会有所不同, 提供这些数字只是为了让大家有个大致的了解.)


现在你可能会感到奇怪, 为什么我们的程序会有这么多的字节数?


现在你可能想知道为什么有些格式输出的是二进制, 但它们的大小却各不相同. Avro, MessagePack 和 BSON 等二进制格式具有不同的内部结构和编码机制, 这可能导致二进制表示法的差异, 即使它们最终表示的是相同的数据. 下面简要介绍一下这些差异是如何产生的:


1. Avro:



  • Avro 使用schema对数据进行编码, 二进制表示法中通常包含该schema.

  • Avro 基于schema的编码可提前指定数据结构, 从而实现高效的数据序列化和反序列化.

  • Avro 的二进制格式设计为自描述格式, 这意味着schema信息包含在编码数据中. 这种自描述性使 Avro 能够保持不同版本数据模式之间的兼容性.


2. MessagePack:



  • MessagePack 是一种二进制序列化格式, 直接对数据进行编码, 不包含schema信息.

  • 它使用长度可变的整数和长度可变的字符串的紧凑二进制表示法, 以尽量减少空间使用.

  • MessagePack 不包含schema信息, 因此更适用于schema已预先知道并在发送方和接收方之间共享的情况.


3. BSON:



  • BSON 是 JSON 数据的二进制编码, 包括每个值的类型信息.

  • BSON 的设计与 JSON 紧密相连, 但它增加了二进制数据类型, 如 JSON 缺乏的日期和二进制数据.

  • 与 MessagePack 一样, BSON 不包含schema信息.


这些设计和编码上的差异导致了二进制表示法的不同:



  • Avro 包含模式信息并具有自描述性, 这导致二进制大小稍大, 但提供了schema兼容性.

  • MessagePack 因其可变长度编码而高度紧凑, 但缺乏模式信息, 因此适用于已知模式的情况.

  • BSON 与 JSON 关系密切, 包含类型信息, 与 MessagePack 等纯二进制格式相比, 会增加大小.


总之, 这些差异源于每种格式的设计目标和功能. Avro 优先考虑schema兼容性, MessagePack 注重紧凑性, 而 BSON 则在保持类似 JSON 结构的同时增加了二进制类型. 格式的选择取决于具体的使用情况和要求, 如schema兼容性, 数据大小和易用性.


优化 JSON 性能


JSON 虽然用途广泛, 在Web开发中被广泛采用, 但在速度方面也存在挑战. 这种格式的人类可读性会导致数据负载较大, 处理时间较慢. 因此, 问题出现了: 我们能够怎样优化JSON以使得它更快更高效? 在本文中, 我们将探讨可用于提高 JSON 性能的实用策略和优化方法, 以确保 JSON 在提供应用所需的速度和效率的同时, 仍然是现代 Web 开发中的重要工具.


以下是一些优化 JSON 性能的实用技巧以及代码示例和最佳实践:


1. 最小化数据大小:



  • 使用简短, 描述性的键名: 选择简洁但有意义的键名, 以减小 JSON 对象的大小.


// Inefficient
{
"customer_name_with_spaces": "John Doe"
}

// Efficient
{
"customerName": "John Doe"
}


  • 尽可能缩写:  在不影响清晰度的情况下, 考虑对键或值使用缩写.


// Inefficient
{
"transaction_type": "purchase"
}

// Efficient
{
"txnType": "purchase"
}

2. 明智地使用数组:



  • 最小化嵌套: 避免深度嵌套数组, 因为它们会增加解析和遍历 JSON 的复杂性.


// Inefficient
{
"order": {
"items": {
"item1": "Product A",
"item2": "Product B"
}
}
}

// Efficient
{
"orderItems": ["Product A", "Product B"]
}

3. 优化数字表示:



  • 尽可能使用整数:  如果数值可以用整数表示, 请使用整数而不是浮点数.


// Inefficient
{
"quantity": 1.0
}

// Efficient
{
"quantity": 1
}

4. 消除冗余:



  • 避免重复数据: 通过引用共享值来消除冗余数据.


// Inefficient
{
"product1": {
"name": "Product A",
"price": 10
},
"product2": {
"name": "Product A",
"price": 10
}
}

// Efficient
{
"products": [
{
"name": "Product A",
"price": 10
},
{
"name": "Product B",
"price": 15
}
]
}

5. 使用压缩:



  • 使用压缩算法:  如何可行的话, 使用压缩算法, 比如Gzip 或者Brotli, 以在传输过程中减少JSON负载大小.


// Node.js example using zlib for Gzip compression
const zlib = require('zlib');

const jsonData = {
// Your JSON data here
};

zlib.gzip(JSON.stringify(jsonData), (err, compressedData) => {
if (!err) {
// Send compressedData over the network
}
});

6. 采用服务器端缓存:



  • 缓存 JSON 响应:  实施服务器端缓存, 以便高效地存储和提供 JSON 响应, 减少重复数据处理的需要.


7. 剖析与优化:



  • 剖析性能:  使用剖析工具找出 JSON 处理代码中的瓶颈, 然后优化这些部分.



请记住, 你实施的具体优化措施应符合应用的要求和限制.



真实世界的优化: 在实践中加速


在这一部分, 我们将深入探讨现实世界中遇到 JSON 性能瓶颈并成功解决的应用和项目. 我们将探讨企业如何解决 JSON 的局限性, 以及这些优化为其应用带来的切实好处. 从 LinkedIn 和 Auth0 这样的知名平台到 Uber 这样的颠覆性科技巨头*, 这些示例为我们提供了宝贵的见解, 让我们了解在尽可能利用 JSON 的多功能性的同时提高速度和响应能力的策略.


1. LinkedIn集成Protocol Buffers:


挑战: LinkedIn 面临的挑战是 JSON 的冗长以及由此导致的网络带宽使用量增加, 从而导致延迟增加.
解决方案: 他们在微服务通信中采用了二进制序列化格式 Protocol Buffers 来取代 JSON.
影响: 这一优化将延迟降低了60%, 提高了 LinkedIn 服务的速度和响应能力.


2. Uber的H3地理索引:



  • 挑战: Uber 使用 JSON 表示各种地理空间数据, 但解析大型数据集的 JSON 会降低其算法的速度.

  • 解决方法 他们引入了H3地理索引, 这是一种用于地理空间数据的高效六边形网格系统, 可减少 JSON 解析开销.

  • 影响: 这一优化大大加快了地理空间操作, 增强了 Uber 的叫车和地图服务.


3. Slack的消息格式优化:



  • 挑战: Slack 需要在实时聊天中传输和呈现大量 JSON 格式的消息, 这导致了性能瓶颈.

  • 解决方法 他们优化了 JSON 结构, 减少了不必要的数据, 只在每条信息中包含必要的信息.

  • 影响: 这一优化提高了消息渲染速度, 改善了 Slack 用户的整体聊天性能.


4. Auth0的Protocal Buffers实现:



  • 挑战: Auth0 是一个流行的身份和访问管理平台, 在处理身份验证和授权数据时面临着 JSON 的性能挑战.

  • 解决方案: 他们采用Protocal Buffers来替代 JSON, 以编解码与身份验证相关的数据.

  • 影响: 这一优化大大提高了数据序列化和反序列化的速度, 从而加快了身份验证流程, 并增强了 Auth0 服务的整体性能.


这些真实案例表明, 通过优化策略解决 JSON 的性能难题, 可对应用的速度, 响应和用户体验产生重大积极影响. 它们强调了在各种应用场景中考虑使用替代数据格式和高效数据结构来克服 JSON 相关缓慢的问题的重要性.


总结一下


在开发领域, JSON 是数据交换不可或缺的通用工具. 其人类可读格式和跨语言兼容性使其成为现代应用的基石. 然而, 正如我们在本文中所探讨的, JSON 的广泛应用并不能使其免于性能挑战.


我们在优化 JSON 性能的过程中获得的主要启示是显而易见的:



  • 性能至关重要: 在当今的数字环境中, 速度和响应速度至关重要. 用户希望应用能够快如闪电, 即使是微小的延迟也会导致不满和机会的丧失.

  • 尺寸至关重要: 数据有效载荷的大小会直接影响网络带宽的使用和响应时间. 减少数据大小通常是优化 JSON 性能的第一步.

  • 替代格式: 当效率和速度至关重要时, 探索其他数据序列化格式, 如Protocal Buffers, MessagePack, BSON 或 Avro.

  • 真实世界案例: 从企业成功解决 JSON 速度变慢问题的实际案例中学习. 这些案例表明, 优化工作可以大幅提高应用的性能.


在继续构建和增强Web应用时, 请记住要考虑 JSON 对性能的影响. 仔细设计数据结构, 选择有意义的键名, 并在必要时探索其他序列化格式. 这样, 你就能确保你的应用在速度和效率方面不仅能满足用户的期望, 而且还能超越用户的期望.


在不断变化的Web开发环境中, 优化 JSON 性能是一项宝贵的技能, 它能让你的项目与众不同, 并确保你的应用在即时数字体验时代茁壮成长.


作者:bytebeats
来源:juejin.cn/post/7299353265099423753
收起阅读 »

告别轮询,SSE 流式传输可太香了!

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。 对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。 接下来,我将从 SSE ...
继续阅读 »

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。


对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。


接下来,我将从 SSE 的概念、与 Websocket 对比、SSE 应用场景多个方面介绍 SSE 流式传输,感兴趣的同学一起来了解下吧!


什么是 SSE 流式传输


SSE 全称为 Server-sent events , 是一种基于 HTTP 协议的通信技术,允许服务器主动向客户端(通常是Web浏览器)发送更新。


它是 HTML5 标准的一部分,设计初衷是用来建立一个单向的服务器到客户端连接,使得服务器可以实时地向客户端发送数据。


这种服务端实时向客户端发送数据的传输方式,其实就是流式传输。


我们在与 ChatGPT 交互时,可以发现 ChatGPT 的响应总是间断完成。细扒 ChatGPT 的网络传输模式,可以发现,用的也是流式传输。


图片


SSE 流式传输的好处


在 SSE 技术出现之前,我们习惯把需要等待服务端返回的过程称为长轮询。


长轮询的实现其实也是借助 http 请求来完成,一个完整的长轮询过程如下图所示:


图片


从图中可以发现,长轮询最大的弊端是当服务端响应请求之前,客户端发送的所有请求都不会被受理。并且服务端发送响应的前提是客户端发起请求。


前后端通信过程中,我们常采用 ajax 、axios 来异步获取结果,这个过程,其实也是长轮询的过程。


而同为采用 http 协议通信方式的 SSE 流式传输,相比于长轮询模式来说,优势在于可以在不需要客户端介入的情况下,多次向客户端发送响应,直至客户端关闭连接。


这对于需要服务端实时推送内容至客户端的场景可方便太多了!


SSE 技术原理


1. 参数设置

前文说到,SSE 本质是一个基于 http 协议的通信技术。


因此想要使用 SSE 技术构建需要服务器实时推送信息到客户端的连接,只需要将传统的 http 响应头的 contentType 设置为 text/event-stream 。


并且为了保证客户端展示的是最新数据,需要将 Cache-Control 设置为 no-cache 。


在此基础上,SSE 本质是一个 TCP 连接,因此为了保证 SSE 的持续开启,需要将 Connection 设置为 keep-alive 。


Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

完成了上述响应头的设置后,我们可以编写一个基于 SSE 流式传输的简单 Demo 。


2. SSE Demo

服务端代码:


const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.static('public'));

app.get('/events'function(req, res) {
    res.setHeader('Content-Type''text/event-stream');
    res.setHeader('Cache-Control''no-cache');
    res.setHeader('Connection''keep-alive');

    let startTime = Date.now();

    const sendEvent = () => {
        // 检查是否已经发送了10秒
        if (Date.now() - startTime >= 10000) {
            res.write('event: close\ndata: {}\n\n'); // 发送一个特殊事件通知客户端关闭
            res.end(); // 关闭连接
            return;
        }

        const data = { message'Hello World'timestampnew Date() };
        res.write(`data: ${JSON.stringify(data)}\n\n`);

        // 每隔2秒发送一次消息
        setTimeout(sendEvent, 2000);
    };

    sendEvent();
});

app.listen(PORT() => {
    console.log(`Server running on http://localhost:${PORT}`);
});

客户端代码:


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>SSE Example</title>
</head>

<body>
    <h1>Server-Sent Events Example</h1>
    <div id="messages"></div>

    <script>
        const evtSource = new EventSource('/events');
        const messages = document.getElementById('messages');

        evtSource.onmessage = function(event) {
            const newElement = document.createElement("p");
            const eventObject = JSON.parse(event.data);
            newElement.textContent = "Message: " + eventObject.message + " at " + eventObject.timestamp;
            messages.appendChild(newElement);
        };
    
</script>
</body>
</html>

当我们在浏览器中访问运行在 localhost: 3000 端口的客户端页面时,页面将会以 流式模式 逐步渲染服务端返回的结果:


图片


需要注意的是,为了保证使用 SSE 通信协议传输的数据能被客户端正确的接收,服务端和客户端在发送数据和接收数据应该遵循以下规范:


服务端基本响应格式

SSE 响应主要由一系列以两个换行符分隔的事件组成。每个事件可以包含以下字段:


data:事件的数据。如果数据跨越多行,每行都应该以data:开始。
id:事件的唯一标识符。客户端可以使用这个ID来恢复事件流。
event:自定义事件类型。客户端可以根据不同的事件类型来执行不同的操作。
retry:建议的重新连接时间(毫秒)。如果连接中断,客户端将等待这段时间后尝试重新连接。

字段之间用单个换行符分隔,而事件之间用两个换行符分隔。


客户端处理格式

客户端使用 EventSource 接口监听 SSE 消息:


const evtSource = new EventSource('path/to/sse');
evtSource.onmessage = function(event) {
    console.log(event.data); // 处理收到的数据
};

SSE 应用场景


SSE 作为基于 http 协议由服务端向客户端单向推送消息的通信技术,对于需要服务端主动推送消息的场景来说,是非常适合的:


图片


SSE 兼容性


图片


可以发现,除了 IE 和低版本的主流浏览器,目前市面上绝大多数浏览器都支持 SSE 通信。


SSE 与 WebSocket 对比


看完 SSE 的使用方式后,细心的同学应该发现了:


SSE 的通信方式和 WebSocket 很像啊,而且 WebSocket 还支持双向通信,为什么不直接使用 WebSocket ?


下表展示了两者之间的对比:


特性/因素SSEWebSockets
协议基于HTTP,使用标准HTTP连接单独的协议(ws:// 或 wss://),需要握手升级
通信方式单向通信(服务器到客户端)全双工通信
数据格式文本(UTF-8编码)文本或二进制
重连机制浏览器自动重连需要手动实现重连机制
实时性高(适合频繁更新的场景)非常高(适合高度交互的实时应用)
浏览器支持良好(大多数现代浏览器支持)非常好(几乎所有现代浏览器支持)
适用场景实时通知、新闻feed、股票价格等需要从服务器推送到客户端的场景在线游戏、聊天应用、实时交互应用
复杂性较低,易于实现和维护较高,需要处理连接的建立、维护和断开
兼容性和可用性基于HTTP,更容易通过各种中间件和防火墙可能需要配置服务器和网络设备以支持WebSocket
服务器负载适合较低频率的数据更新适合高频率消息和高度交互的场景

可以发现,SSE 与 WebSocket 各有优缺点,对于需要客户端与服务端高频交互的场景,WebSocket 确实更适合;但对于只需要服务端单向数据传输的场景,SSE 确实能耗更低,且不需要客户端感知


参考文档


developer.mozilla.org/zh-CN/docs/…


作者:veneno
来源:juejin.cn/post/7355666189475954725
收起阅读 »

三方接口不动声色将http改为了https,于是开启了我痛苦的一天

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。 原来是调的一个三方接口报错了: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorExc...
继续阅读 »

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。


原来是调的一个三方接口报错了:


javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
at sun.security.ssl.Alert.createSSLException(Alert.java:131)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:353)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:296)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:291)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:652)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:471)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:367)
at sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:376)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:422)
at sun.security.ssl.TransportContext.dispatch(TransportContext.java:183)
at sun.security.ssl.SSLTransport.decode(SSLTransport.java:154)
at sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1279)
at sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1188)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:401)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:373)

查看原因:



由于JVM默认信任证书不包含该目标网站的SSL证书,导致无法建立有效的信任链接。



奥...原来是他们把接口从http改为了https,导致我们获取数据报错了。再看看他们的证书,奥...新的。


image.png


好了,看看我们的逻辑,这其实是一个获取对方生成的PDF文件的接口


PdfReader pdfReader = new PdfReader(url);

url就是他们给的链接,是这行代码报的错。这时候,开始研究,在网上扒拉,找到了初版方案


尝试1


写一个程序专门获取安全证书,这代码有点长,全贴出来影响阅读。我给扔我hithub上了github.com/lukezhao6/I… 将这个文件贴到本地,执行javac InstallCert.java将其进行编译


image.png


编译完长这样:


image.png
然后再执行java InstallCert http://www.baidu.com (这里我们用百度举例子,实际填写的就是你想要获取证书的目标网站)


image.png
报错不用怕,因为它会去检查目标服务器的证书,如果出现了SSLException,表示证书可能存在问题,这时候会把异常信息打印出来。


在生成的时候需要输入一个1


image.png
这样,我们需要的证书文件就生成好了


image.png


这时候,将它放入我们本地的 jdk的lib\security文件夹内就行了


image.png


重启,这时候访问是没有问题了。阶段性胜利。


但是,但是。一顿操作下来,对于测试环境的docker,还有生产环境貌似不能这么操作。 放这个证书文件比较费事。


那就只能另辟蹊径了。


尝试2


搜到了,还有两种方案。



1.通过System.setProperty("javax.net.ssl.trustStore", "你的jssecacerts证书路径");


2.程序启动命令-Djavax.net.ssl.trustStore=你的jssecacerts证书路径 -Djavax.net.ssl.trustStorePassword=changeit



我尝试了第一种,System.setProperty可以成功,但是读不到文件,权限什么的都是ok的。
检查了蛮多地方



  • 路径格式问题

  • 文件是否存在

  • 文件权限

  • 信任库密码

  • 系统属性优先级


貌似都是没问题的,但肯定又是有问题的,因为没起作用。但是想着这样的接口有4个,万一哪天其他三个也改了,我又得来一遍。所以就算研究出来了,还是不能稳坐钓鱼台。有没有一了百了的方法嘞。


尝试3


还真找到了:这个错是因为对方网站的证书不被java信任么,那咱不校验了,直接全部信任。这样就算其他接口改了,咱也不愁。而且这个就是获取pdf,貌似安全性没那么重。那就开搞。


代码贴在了下方,上边的大概都能看懂吧,下方的我加了注释。


URL console = new URL(url);
HttpURLConnection conn = (HttpURLConnection) console.openConnection();
if (conn instanceof HttpsURLConnection) {
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[]{new TrustAnyTrustManager()}, new java.security.SecureRandom());
((HttpsURLConnection) conn).setSSLSocketFactory(sc.getSocketFactory());
((HttpsURLConnection) conn).setHostnameVerifier(new TrustAnyHostnameVerifier());
}
conn.connect();
InputStream inputStream = conn.getInputStream();
PdfReader pdfReader = new PdfReader(inputStream);
inputStream.close();
conn.disconnect();

private static class TrustAnyTrustManager implements X509TrustManager {
//这个方法用于验证客户端的证书。在这里,方法体为空,表示不对客户端提供的证书进行任何验证。
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法用于验证服务器的证书。同样,方法体为空,表示不对服务器提供的证书进行任何验证。
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法返回一个信任的证书数组。在这里,返回空数组,表示不信任任何证书,也就是对所有证书都不做任何信任验证。
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
//这个方法用于验证主机名是否可信。在这里,无论传入的主机名是什么,方法始终返回 true,表示信任任何主机名。这就意味着对于 SSL 连接,不会对主机名进行真实的验证,而是始终接受所有主机名。
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}

解决了解决了,这样改算是个比较不错的方案了吧。


作者:奔跑的毛球
来源:juejin.cn/post/7362587412066893834
收起阅读 »

我们开源啦!一键部署免费使用!Kubernetes上直接运行大数据平台!

导语:市场上首个 K8s 上的大数据平台,开源了!智领云自主研发的首个完全基于Kubernetes的容器化大数据平台Kubernetes Data Platform (简称KDP)开源啦!开发者只要准备好命令行工具,一键部署Hadoop,Hive,Spark,...
继续阅读 »

导语:市场上首个 K8s 上的大数据平台,开源了!

智领云自主研发的首个完全基于Kubernetes的容器化大数据平台

Kubernetes Data Platform (简称KDP)

开源啦!

开发者只要准备好命令行工具,一键部署

Hadoop,Hive,Spark,Kafka, Flink, MinIO ...

就可以创建以前要花几十万甚至几百万才可以买到的大数据平台

无需再花大量的时间和经费去做重复的研发

高度集成,单机即可体验大数据平台

在高级安装模式下

用户可在现有的K8s集群上集成运行大数据组件

不用额外单独建设大数据集群

项目地址:

https://github.com/linktimecloud/kubernetes-data-platform

辛辛苦苦研究出来的成果,为什么要开源?

这波格局开大,老板有话说

问题1:我们为什么要开源?

我们的产品一直是基于大数据开源生态体系建设的。之前就一直有开源回馈社区的计划,但是因为之前Kubernetes对于大数据组件的支持还不够成熟,我们也一直在迭代与Kubernetes的适配。现在我们的企业版已经在很多头部客户落地并且在生产环境下高效运行,觉得这个版本已经可以达到大部分生产级项目的需求,集成度以及可用性是能够帮到有类似需求的用户的,希望这次开源能够降低在Kubernetes上集成大数据组件的门槛,让更多Kuberenetes和big data社区的同行们可以使用。

问题2:开源版本的KDP,能干啥?

KDP可以很方便的在Kubenetes上安装和管理常用的大数据组件,Hadoop,Hive,Spark,Kafka, Flink, MinIO 等等,不需要自己一个一个去适配,可以直接开始使用。然后KDP也提供集成的运维管理界面,用户可以从界面管理所有组件的安装配置,运行状况,资源使用情况,修改配置。而且KDP会将一个大数据组件的所有负载(容器,pod)作为一个整体管理,用户不需要在Kubernetes的控制平面上去管理单独的负载。

问题3:最大的亮点是?

只要你已经在使用Kubernetes,那么在现有集群上十几分钟就可以启动一个完整的大数据集群,马上开始使用,极大的降低了大数据平台的使用门槛。因为我们这个流程是高度集成的,整个安装过程在一个单机环境下也都能启动(例如使用单机kind虚拟集群都可以),所以在测试和实验环境下都可以高效使用。当然,启动之后Day 2的很多好处,例如资源的高效利用和集成的运维管理,也是KDP提供的重要功能。

KDP,即在Kubernetes上使用原生的分布式功能搭建及管理大数据平台。

将多套大数据组件集成在Kubernetes之上,同时提供一个整体的管理及运维工具体系,形成一个完全基于Kubernetes的大数据平台。企业级KDP更是支持在同一个Kubernetes集群中同时运行多个大数据平台以及多租户管理的能力,充分发挥Kubernetes云原生体系的优势。

KDP,通过对开源大数据组件的扩展和集成,实现了传统大数据平台到K8s大数据平台的平稳迁移。

作为市场上首个可完全在Kubernetes上部署的容器化云原生大数据平台,智领云自主研发的KDP,深度整合云原生架构优势,将大数据组件、数据应用及资源调度混排,纳入Kubernetes管理体系,从而带你真正玩转云原生!

总体框架

简单来讲,KDP可以允许客户在Kubernetes上运行它所有的大数据组件,并把它们作为一个整体管理起来。

在Kubernetes上运行大数据平台有三个好处:

第一,更高效的大数据组件集成:KDP提供标准化自动化的大数据组件部署和配置,极大地缩短了大数据项目开发和上线时间;

第二,更高效的大数据集群运管:KDP通过大数据组件与K8s的集成,在K8s之上搭建了一个大数据组件管理抽象层,标准化大数据组件生命周期管理,并提供UI界面进一步提升了部署、升级等操作的效率;

第三,更高的集群资源利用率:利用K8s的资源管理和配额机制,与其它系统共享K8s资源池,精细化资源管理,对比传统大数据平台约30%左右的资源利用率,KDP可大幅提升至60%以上。

社区

我们期待您的贡献和建议!最简单的贡献方式是参与Github议题/讨论的讨论。 如果您有任何问题,请与我们联系,我们将确保尽快为您解答。

微信群:添加小助手微信拉您进入交流群

钉钉群:搜索公开群组号 82250000662

贡献

参考开发者指南,了解如何开发及贡献 KDP。

https://linktimecloud.github.io/kubernetes-data-platform/docs/zh/developer-guide/developer-guide.html

收起阅读 »

状态🐔到底如何优雅的实现

状态机的组成 状态机是一种抽象的数学模型,描述了对象或系统在特定时间点可能处于的各种状态以及状态之间的转换规则。它由一组状态、事件、转移和动作组成,用于模拟对象在不同条件下的行为和状态变化。 状态机包括以下基本组成部分: 状态(State):表示对象或系统...
继续阅读 »

状态机的组成


状态机是一种抽象的数学模型,描述了对象或系统在特定时间点可能处于的各种状态以及状态之间的转换规则。它由一组状态、事件、转移和动作组成,用于模拟对象在不同条件下的行为和状态变化。


image-20240423094124100

状态机包括以下基本组成部分:



  • 状态(State):表示对象或系统当前的状态,例如开、关、就绪等。

  • 事件(Event):触发状态转换的动作或条件,例如按钮点击、消息到达等。

  • 转移(Transition):定义了从一个状态到另一个状态的转换规则,通常与特定事件相关联。

  • 动作(Action):在状态转换过程中执行的操作或行为,例如更新状态、记录日志等。


状态机,也就是 State Machine ,不是指一台实际机器,而是指一个数学模型。说白了,一般就是指一张状态转换图。例如,根据自动门的运行规则,我们可以抽象出下面这么一个图。


image-20240423095540911


简单实现


在计算机中,状态机通常用编程语言来实现。在 C、C++、Java、Python 等编程语言中,可以通过使用 switch-case 语句、if-else 语句、状态转移表等来实现状态机。在下面还有更加优雅的方式,使用 Spring 状态机 来实现。


if-else 实现状态机


在上面的示例中,我们使用 if-else 结构根据当前活动来控制音乐的播放状态,并执行相应的行为。代码如下:


public class BasketballMusicStateMachineUsingIfElse {
private boolean isPlayingMusic;

public BasketballMusicStateMachineUsingIfElse() {
this.isPlayingMusic = false; // 初始状态为音乐未播放
}

public void playMusic() {
if (!isPlayingMusic) {
System.out.println("Music starts playing...");
isPlayingMusic = true;
}
}

public void stopMusic() {
if (isPlayingMusic) {
System.out.println("Music stops playing...");
isPlayingMusic = false;
}
}

public void performActivity(String activity) {
if ("basketball".equals(activity)) {
System.out.println("Music~");
playMusic(); // 打篮球时播放音乐
} else if ("sing_rap".equals(activity)) {
System.out.println("哎哟你干嘛!");
stopMusic(); // 唱跳Rap时停止音乐
} else {
System.out.println("Invalid activity!");
}
}

public static void main(String[] args) {
BasketballMusicStateMachineUsingIfElse stateMachine = new BasketballMusicStateMachineUsingIfElse();

// 测试状态机
stateMachine.performActivity("basketball"); // 打篮球,音乐开始播放
stateMachine.performActivity("sing_rap"); // 唱跳Rap,音乐停止播放
stateMachine.performActivity("basketball"); // 再次打篮球,音乐重新开始播放
}
}

switch-case 实现状态机


在这个示例中,我们使用 switch-case 结构根据不同的活动来控制音乐的播放状态,并执行相应的行为。代码如下:


public class BasketballMusicStateMachineUsingSwitchCase {
private boolean isPlayingMusic;

public BasketballMusicStateMachineUsingSwitchCase() {
this.isPlayingMusic = false; // 初始状态为音乐未播放
}

public void playMusic() {
if (!isPlayingMusic) {
System.out.println("Music starts playing...");
isPlayingMusic = true;
}
}

public void stopMusic() {
if (isPlayingMusic) {
System.out.println("Music stops playing...");
isPlayingMusic = false;
}
}

public void performActivity(String activity) {
switch (activity) {
case "basketball":
System.out.println("Music ~");
playMusic(); // 打篮球时播放音乐
break;
case "sing_rap":
System.out.println("哎哟 你干嘛 ~");
stopMusic(); // 唱跳Rap时停止音乐
break;
default:
System.out.println("Invalid activity!");
}
}

public static void main(String[] args) {
BasketballMusicStateMachineUsingSwitchCase stateMachine = new BasketballMusicStateMachineUsingSwitchCase();

// 测试状态机
stateMachine.performActivity("basketball"); // 打篮球,音乐开始播放
stateMachine.performActivity("sing_rap"); // 唱跳Rap,音乐停止播放
stateMachine.performActivity("basketball"); // 再次打篮球,音乐重新开始播放
}
}


是不是感觉状态机其实经常在我们的日常使用中捏~,接下来带大家使用更优雅的状态机 Spring 状态机。


image-20240423100302874

使用 Spring 状态机


1)引入依赖


<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-core</artifactId>
<version>2.0.1.RELEASE</version>
</dependency>

2)定义状态和事件的枚举


代码如下:


public enum States {
IDLE, // 空闲状态
PLAYING_BB, // 打篮球状态
SINGING // 唱跳Rap状态
}
public enum Event {
START_BB_MUSIC, // 开始播放篮球音乐事件
STOP_BB_MUSIC // 停止篮球音乐事件
}

3)配置状态机


代码如下:


@Configuration
@EnableStateMachine
public class BasketballMusicStateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Event> {

@Autowired
private BasketballMusicStateMachineEventListener eventListener;

@Override
public void configure(StateMachineConfigurationConfigurer<States, Event> config) throws Exception {
config
.withConfiguration()
.autoStartup(true)
.listener(eventListener); // 设置状态机事件监听器
}

@Override
public void configure(StateMachineStateConfigurer<States, Event> states) throws Exception {
states
.withStates()
.initial(States.IDLE)
.states(EnumSet.allOf(States.class));
}

@Override
public void configure(StateMachineTransitionConfigurer<States, Event> transitions) throws Exception {
transitions
.withExternal()
.source(States.IDLE).target(States.PLAYING_BB).event(Event.START_BB_MUSIC)
.and()
.withExternal()
.source(States.PLAYING_BB).target(States.SINGING).event(Event.STOP_BB_MUSIC)
.and()
.withExternal()
.source(States.SINGING).target(States.PLAYING_BB).event(Event.START_BB_MUSIC);
}
}

4)定义状态机事件监听器


代码如下:


@Component
public class BasketballMusicStateMachineEventListener extends StateMachineListenerAdapter<States, Event> {

@Override
public void stateChanged(State<States, Event> from, State<States, Event> to) {
if (from.getId() == States.IDLE && to.getId() == States.PLAYING_BB) {
System.out.println("开始打篮球,music 起");
} else if (from.getId() == States.PLAYING_BB && to.getId() == States.SINGING) {
System.out.println("唱跳,你干嘛");
} else if (from.getId() == States.SINGING && to.getId() == States.PLAYING_BB) {
System.out.println("继续打篮球,music 继续");
}
}
}

5)编写单元测试


@SpringBootTest
class ChatApplicationTests {
@Resource
private StateMachine<States, Event> stateMachine;

@Test
void contextLoads() {
//开始打球,music 起
stateMachine.sendEvent(Event.START_BB_MUSIC);
//开始唱跳,你干嘛
stateMachine.sendEvent(Event.STOP_BB_MUSIC);
//继续打球,music 继续
stateMachine.sendEvent(Event.START_BB_MUSIC);

}
}

效果如下:


image-20240423103523546


在上面的示例中,我们定义了一个状态机,用于控制在打篮球时音乐的播放和唱跳 Rap 的行为。通过触发事件来执行状态转移,并通过事件监听器监听状态变化并执行相应的操作。


image-20240423103604502


作者:cong_
来源:juejin.cn/post/7360647839448088613
收起阅读 »

数据连接已满,导致新连接无法成功

个人项目中经常会把每个项目的平台部署成开发、测试环境,而数据库就有可能是多个平台共用一个了,现在基本上都是用的微服务架构,那么数据库连接就不够用了。 我们用的是MySQL数据库,最近遇到了这个尴尬的问题,本地修改了代码启动的时候经常会连不上数据库既然连接太多,...
继续阅读 »

个人项目中经常会把每个项目的平台部署成开发、测试环境,而数据库就有可能是多个平台共用一个了,现在基本上都是用的微服务架构,那么数据库连接就不够用了。


我们用的是MySQL数据库,最近遇到了这个尴尬的问题,本地修改了代码启动的时候经常会连不上数据库既然连接太多,要么减少连接,要么扩大最大可连接数,


经过查询,MySQL 数据库 的默认最大连接数是经常设置在151的默认值,而最大连接数可以达到16384个


对应报错信息一般为:


com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure


too many connections



  1. 查看数据库当前连接信息,可以看到连接数据库的进程id,ip,用户名,连接的数据库,连接状态,连接时长等如果发现有大量的sleep状态的连接进程,则说明该参数设置的过大,可以进行适当的调整小些。




    1. SHOW FULL processlist;




    2. 在MySQL中,使用 SHOW FULL PROCESSLIST; 命令可以显示系统中所有当前运行的线程,包括每个线程的状态、是否锁定等信息。这个命令输出的表格中包含的字段具有以下含义:



      1. Id: 线程的唯一标识符。这个ID可以用来引用特定的线程,例如,在需要终止一个特定的线程时可以使用 KILL 命令。

      2. User: 启动线程的MySQL用户。

      3. Host: 用户连接到MySQL服务器的主机名和端口号,显示格式通常是 host_name:port

      4. db: 当前线程所在的数据库。如果线程没有使用任何数据库,这一列可能显示为NULL。

      5. Command: 线程正在执行的命令类型,例如 QuerySleepConnect 等。

      6. Time: 命令执行的时间,以秒为单位。对于 Sleep 状态,这表示线程处于空闲状态的时长。

      7. State: 线程的当前状态,提供了正在执行的命令的额外信息。这可以是 Sending datasorting resultLocked 等。

      8. Info: 如果线程正在执行查询,则这一列显示具体的SQL语句。对于其他类型的命令,这一列可能为空或显示为NULL。


      Command 列显示为 Sleep 时,这意味着该线程当前没有执行任何查询,只是在连接池中等待下一个查询命令。通常,应用程序执行完一个查询后,连接可能会保持打开状态而不是立即关闭,以便可以重用该连接执行后续的查询。在这种状态下,线程不会使用服务器资源来处理任何数据,但仍占用一个连接槽。如果看到很多线程处于 Sleep 状态且持续时间较长,这可能是一个优化点,例如,通过调整应用逻辑或连接池设置来减少空闲连接的数量。






  2. 查询当前最大连接数和超时时间




    1. # 查看最大连接数
      show variables like '%max_connections%';
      # 查看非交互式超时时间 单位秒
      show variables like 'wait_timeout';
      # 查看交互式叫号时间 单位秒
      show variables like 'interactive_timeout';





      1. max_connections:



        1. max_connections 参数定义了数据库服务器能够同时接受的最大客户端连接数。当达到这个限制时,任何新的尝试连接的客户端将会收到一个错误,通常是“Too many connections”。

        2. 默认值通常基于系统的能力和配置,但经常设置在151的默认值。这个值可以根据服务器的硬件资源(如CPU和内存)和负载要求进行调整。



      2. mysqlx_max_connections:



        1. mysqlx_max_connections 参数是专门为MySQL的X协议(一种扩展的协议,支持更复杂的操作,如CRUD操作和实时通知)设定的最大连接数。X协议使得开发者能够使用NoSQL风格的接口与数据库交互。

        2. 默认值通常较小,因为X协议的使用还不如传统SQL协议普遍。这个参数允许你独立于max_connections控制通过X协议可能的连接数。



      3. wait_timeout:



        1. wait_timeout 设置的是非交互式(非控制台)客户端连接在变成非活动状态后,在被自动关闭之前等待的秒数。非交互式连接通常指的是通过网络或API等进行的数据库连接,如应用程序服务器到数据库的连接。

        2. 默认值通常较长,如8小时(28800秒),但这可以根据需要进行调整,特别是在连接数资源受限的环境中。



      4. interactive_timeout:



        1. interactive_timeout 适用于MySQL服务器与客户端进行交互式会话时的连接超时设置。交互式会话通常是指用户通过MySQL命令行客户端或类似工具直接连接并操作数据库。

        2. 这个超时值只会在MySQL服务器识别连接为交互式时应用。它的默认值也通常是8小时。








  3. 修改最大连接数和超时时间



    1. SQL直接改




      1. # 重启后失效 这里直接设置1000
        SET GLOBAL max_connections = 1000;
        # 设置全局 非交互连接 超时时间 单位秒
        SET GLOBAL wait_timeout = 300;




    2. 配置文件改



      1.     MySQL的配置文件通常是 my.cnf(在Linux系统中)或 my.ini(在Windows系统中)。你应该在 [mysqld] 部分中设置这些参数

      2.     在Linux系统上,MySQL的配置文件一般位于以下几个路径之一:

      3. /etc/my.cnf

      4. /etc/mysql/my.cnf

      5. /var/lib/mysql/my.cnf

      6.     具体位置可能会根据不同的Linux发行版和MySQL安装方式有所不同。你可以使用 find 命令来搜索这个文件,例如:


      7. sudo find / -name my.cnf




    3.   找到到文件后,将这些值修改为下列的值 这里直接设置1000


    4. [mysqld]
      max_connections = 1000

      wait_timeout = 300


    5. docker情况



      1. 使用Docker命令行参数

      2.     你可以在运行MySQL容器时通过Docker命令行直接设置配置参数,例如:


      3. docker run -d \
        -p 3306:3306 \
        --name mysql \
        -e MYSQL_ROOT_PASSWORD=my-secret-pw \
        -e MYSQL_DATABASE=mydatabase \
        mysql:tag --max-connections=1000 --wait-timeout=300


      4.     在这个例子中,--max-connections=1000 是作为命令行参数传递给MySQL服务器的。

      5. 修改配置文件并挂载

      6.     如果你需要修改多个配置项或者希望使用配置文件来管理设置,可以创建一个自定义的 my.cnf 文件,然后在启动容器时将它挂载到容器中适当的位置。例如:


      7. docker run -d \
        -p 3306:3306 \
        --name mysql \
        -e MYSQL_ROOT_PASSWORD=my-secret-pw \
        -v /path/to/your/custom/my.cnf:/etc/mysql/my.cnf \
        mysql:tag






  4. 修改完后再查一遍看看有没有改成功




    1. # 查看最大连接数
      show variables like '%max_connections%';
      # 查看非交互式超时时间
      SHOW GLOBAL VARIABLES LIKE 'wait_timeout';




  5. 拓展



    1. wait_timeout 变量分为全局级别和会话级别



      •     执行 SET GLOBAL wait_timeout = 300;后,

      •     使用执行 show variables like 'wait_timeout'; 发现并没有改变

      •     是因为在MySQL中,当你执行 SET GLOBAL wait_timeout = 300; 这条命令时,理论上应该是会设置全局的 wait_timeout 值为300秒。在查询 wait_timeout 时,没有指定是查询全局变量,可能会返回会话级的值。会话级的 wait_timeout 并没有被改变。尝试使用以下命令来查看全局设置:


      • # 查看全局变量
        SHOW GLOBAL VARIABLES LIKE 'wait_timeout';
        # 与之对应的,查看会话级别的变量可以使用:
        SHOW SESSION VARIABLES LIKE 'wait_timeout';




    2. 全局变量和会话变量的区别



      • 全局变量:



        1. 全局变量对服务器上所有当前会话和未来会话都有效。

        2. 当你设置一个全局变量时,它的值会影响所有新建的连接。然而,对于已经存在的连接,全局变量的更改通常不会影响它们,除非这些连接被重新启动或者明确地重新读取全局设置。

        3. 通过 SET GLOBAL 命令修改全局变量,或者在服务器的配置文件中设置并重新启动服务器。



      • 会话变量:



        1. 会话变量只对当前连接的会话有效,并且当会话结束时,会话变量的设置就会失效。

        2. 修改会话变量的命令是 SET SESSION 或者简单的 SET,它不会影响其他会话或连接。

        3. 每个新的会话都会从当前的全局设置中继承变量的值,但在会话中对这些变量的修改不会影响到其他会话。



      • 关于是否改变全局变量,这取决于你试图解决的具体问题:



        • 如果你需要修改的设置应该对所有新的连接生效,例如,修改 wait_timeout 来减少空闲连接超时,那么修改全局变量是合适的。这样,所有新建立的连接都会采用新的超时设置。

        • 然而,如果你需要立即影响当前活动的会话,你必须在每个会话中单独设置会话变量。这在某些操作中可能是必需的,比如调整当前事务的隔离级别或者调试中动态改变某些性能调优参数。

        • 因此,如果改变是为了长期或持久的配置调整,修改全局变量通常是正确的做法。但如果需要对当前会话立即生效的改变,应该使用会话变量。








作者:不惊夜
来源:juejin.cn/post/7361056871673446437
收起阅读 »

一个小小的批量插入,被面试官追问了6次

嗨,你好呀,我是哪吒。 面试经常被问到“MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?”。 首先需要明确一点,优先使用批量插入,而不是在Java中通过循环单条插入。 很多小伙伴都知道这个结论,但是,为啥?很少有人...
继续阅读 »

嗨,你好呀,我是哪吒。


面试经常被问到“MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?”。


首先需要明确一点,优先使用批量插入,而不是在Java中通过循环单条插入。


很多小伙伴都知道这个结论,但是,为啥?很少有人能说出个所以然来。


就算我不知道,你也不能反反复复问我“同一个问题”吧?


1、MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?


批量入库时,如果通过Java循环语句一条一条入库,每一条SQL都需要涉及到一次数据库的操作,包括网络IO以及磁盘IO,可想而知,这个效率是非常低下的。


xml中使用foreach的方式会一次性发送给数据库执行,只需要进行一次网络IO,提高了效率。


但是,xml中的foreach可能会导致内存溢出OOM问题,因为它会一次性将所有数据加载到内存中。而java中的foreach可以有效避免这个问题,因为它会分批次处理数据,每次只处理一部分数据,从而减少内存的使用。


如果操作比较复杂,例如需要进行复杂的计算或者转换,那么使用java中的foreach可能会更快,因为它可以直接利用java的强大功能,而不需要通过xml进行转换。


孰重孰轻,就需要面试官自己拿捏了~


2、在MyBatis中,对于<foreach>标签的使用,通常有几种常见的优化方法?


比如避免一次性传递过大的数据集合到foreach中,可以通过分批次处理数据或者在业务层先进行数据过滤和筛选。


预编译SQL语句、优化SQL语句,减少foreach编译的工作量。


对于重复执行的SQL语句,可以利用mybatis的缓存机制来减少数据库的访问次数。


对于关联查询,可以考虑使用mybatis的懒加载特性,延迟加载关联数据,减少一次性加载的数据量。


3、MyBatis foreach批量插入会有什么问题?


foreach在处理大量数据时会消耗大量内存。因为foreach需要将所有要插入的数据加载到内存中,如果数据量过大,可能会导致内存溢出。


有些数据库对单条SQL语句中可以插入的数据量有限制。如果超过这个限制,foreach生成的批量插入语句将无法执行。


使用foreach进行批量插入时,需要注意事务的管理。如果部分插入失败,可能需要进行回滚操作。


foreach会使SQL语句变得复杂,可能影响代码的可读性和可维护性。


4、当使用foreach进行批量插入时,如何处理可能出现的事务问题?内存不足怎么办?


本质上这两个是一个问题,就是SQL执行慢,一次性执行SQL数量大的问题。


大多数数据库都提供了事务管理功能,可以确保一组操作要么全部成功,要么全部失败。在执行批量插入操作前,开始一个数据库事务,如果所有插入操作都成功,则提交事务;如果有任何一条插入操作失败,则回滚事务。


如果一次插入大量数据,可以考虑分批插入。这样,即使某一批插入失败,也不会影响到其他批次的插入。


优化foreach生成的SQL语句,避免因SQL语句过长或过于复杂而导致的问题。


比如MySQL的INSERT INTO ... VALUES语法 通常比使用foreach进行批量插入更高效,也更可靠。


5、MyBati foreach批量插入时如何处理死锁问题?


当使用MyBatis的foreach进行批量插入时,可能会遇到死锁问题。这主要是因为多个事务同时尝试获取相同的资源(如数据库的行或表),并且每个事务都在等待其他事务释放资源,从而导致了死锁。


(1)优化SQL语句


确保SQL语句尽可能高效,避免不必要的全表扫描或复杂的联接操作,这可以减少事务持有锁的时间,从而降低死锁的可能性。


不管遇到什么问题,你就回答优化SQL,基本上都没毛病。


(2)设置锁超时


为事务设置一个合理的锁超时时间,这样即使发生死锁,也不会导致系统长时间无响应。


(3)使用乐观锁


乐观锁是一种非阻塞性锁,它假设多个事务在同一时间不会冲突,因此不会像悲观锁那样在每次访问数据时都加锁。乐观锁通常用于读取频繁、写入较少的场景。


(4)分批插入


如果一次插入大量数据,可以考虑分批插入。这样,即使某一批插入失败,也不会影响到其他批次的插入。


(5)调整事务隔离级别


较低的隔离级别(如READ UNCOMMITTED)可能会减少死锁的发生,但可能会导致其他问题,如脏读或不可重复读。


6、mybatis foreach批量插入时如果数据库连接池耗尽,如何处理?


(1)增加最大连接数


数据库连接池耗尽了,增加最大连接数,这个回答,没毛病。


(2)优化SQL语句


减少每个连接的使用时间,从而减少连接池耗尽的可能性。


万变不离其宗,优化SQL,没毛病。


(3)分批插入


避免一次性占用过多的连接,从而减少连接池耗尽的可能性。


(4)调整事务隔离级别


降低事务隔离级别可以减少每个事务持有连接的时间,从而减少连接池耗尽的可能性。但需要注意,较低的事务隔离级别可能会导致其他问题,如脏读或不可重复读。


(5)使用更高效的批量插入方法


比如MySQL的INSERT INTO ... VALUES语法。这些方法通常比使用foreach进行批量插入更高效,也更节省连接资源。


感觉每道题的答案都是一样呢?这就对喽,数据库连接池耗尽,本质问题不就是入库的速度太慢了嘛。


(6)定期检查并关闭空闲时间过长的连接,以释放连接资源。


就前面的几个问题,做一个小总结,你会发现,它们的回答大差不差。


通过现象看本质,批量插入会有什么问题?事务问题?内存不足怎么办?如何处理死锁问题?数据库连接池耗尽,如何处理?


这些问题的本质都是因为SQL执行慢,一次性SQL数据量太大,事务提交太慢导致的。


回答的核心都是:如何降低单次事务时间?



  1. 优化SQL语句

  2. 分批插入

  3. 调整事务隔离级别

  4. 使用更高效的批量插入方法


作者:哪吒编程
来源:juejin.cn/post/7359900973991362597
收起阅读 »

我早就看现在的工作流不爽了!- 前端使用 Jenkins

背景目前笔者所在的小公司的前端项目还是推送到git仓库后由另一名后端拉取代码到他电脑上再build,然后再手动同步到服务器上,比较麻烦,而且出现一个bug就要立即修复,笔者一天要说100次“哥,代码更新了,打包上传下吧,球球了”,终于我实在受不了了(上传代码的...
继续阅读 »


背景

目前笔者所在的小公司的前端项目还是推送到git仓库后由另一名后端拉取代码到他电脑上再build,然后再手动同步到服务器上,比较麻烦,而且出现一个bug就要立即修复,笔者一天要说100次“哥,代码更新了,打包上传下吧,球球了”,终于我实在受不了了(上传代码的这位哥也受不了了),于是想通过 Jenkins 实现简单的前端项目自动打包部署。

通过 docker 安装 Jenkins

通过 ssh 连接上局域网服务器 192.168.36.2,在 home 目录下新建了一个 Jenkins 文件夹,后续我们的配置文件就放在其中。

 cd
 # 将 Jenkins 相关的文件都放在这里
 mkdir jenkins
 cd jenkins
 
 # 创建 Jenkins 配置文件存放的地址,并赋予权限
 mkdir jenkins_home
 chmod -R 777 jenkins_home
 
 pwd
 # /root/jenkins

创建docker-compose.yml

 touch docker-compose.yml
 vim docker-compose.yml
 version: '3'
 services:
  jenkins:
    image: jenkins/jenkins:latest
    container_name: 'jenkins'
    restart: always
    ports:
      - "8999:8080"
    volumes:
      - /root/jenkins/jenkins_home:/var/jenkins_home

Jenkins 启动后会挂在8080端口上,本文笔者将其映射到8999端口,读者可以自行更改。

关键在于将容器中的/var/jenkins_home目录映射到宿主机的/root/jenkins/jenkins_home目录,这一步相当于将 Jenkins 的所有配置都存放在宿主机而不是容器中,这样做的好处在于,后续容器升级、删除、崩溃等情况下,不需要再重新配置 Jenkins。

使用:wq保存后可以开始构建了:

 docker compose up -d

这一步会构建容器并启动,看到如下信息就说明成功了:

 [+] Running 1/1
  Container Jenkins   Started           1.3s

查看一下容器是否在运行:

 docker ps

image-20240403133238265

这个时候通过http://192.168.36.2:8999就可以访问 Jenkins 了。

Jenkins 初次配置向导

解锁

image-20240403133538015

第一次打开会出现向导,需要填入管理员密码,获取密码有三种方式:

  1. 通过宿主机

     cat /root/jenkins/jenkins_home/secrets/initialAdminPassword
     
     # 2bf4ca040f624716befd5ea137b70560
  2. 通过 docker 进入容器

     docker exec -it jenkins /bin/bash
     
     #进入了docker
     jenkins@1c151dfc2482:/$ cat /var/jenkins_home/secrets/initialAdminPassword
     
     # 2bf4ca040f624716befd5ea137b70560

    与方法一类似,因为目录映射,这两个目录其实是同一个。

  3. 通过查看 docker log

     docker logs jenkins

    会出现一大串,最后能找到密码:

    image-20240403134001532

填入密码,点击继续。

安装插件

image-20240403134122512

选择安装推荐插件即可。

安装插件可能会非常慢,可以选择换源。

更换 Jenkins 插件源(可选)

有两种方法:

  1. 直接输入地址:

    http://192.168.36.2:8999/manage/pluginManager/advanced,在Update Site中填入清华源地址:

     https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json

    点击Submit提交保存,并重启容器。

  2. 直接更改配置文件:

    宿主机中操作:

     cd /root/jenkins/jenkins_home
     vim hudson.model.UpdateCenter.xml

    替换其中的地址,然后重启容器即可。

    image-20240403135010339

创建用户

这一步建议用户名不为 admin ,不然会出现奇怪的问题,比如密码登录不上,需要用上一部的初始密码(2bf4ca040f624716befd5ea137b70560)才能登录。

我这里创建了一个 root 用户(只是名字叫 root,防止用户名太多记不住而已)。

image-20240403135909136

点击保存并完成。

实例配置按需调整即可,直接下一步,Jenkins 就准备就绪了。

image-20240403140101678

至此 Jenkins 安装就算完成了。

安装插件

笔者是一名前端,因此以前端项目为例。

前端项目的打包需要 node 环境,打包完成后通过 ssh 部署到服务器上,并且构建结果通过钉钉机器人推送到群里,因此需要三个插件。

  1. NodeJS
  2. Publish Over SSH
  3. DingTalk(可选)

在 系统管理 -> 插件管理 -> Available plugins 中搜索并安装。

image-20240403140852383

image-20240403140907525

勾选安装后重启,让插件生效。

插件配置

我们安装了三个插件,分别进行配置。

NodeJS

这个插件可以在不同的项目中使用不同的 node 环境,例如 A项目 使用 node14,B项目 使用 node20 这样。

进入 系统管理 -> 全局工具配置 -> NodeJS 安装 (在最下面)

点击新增:

image-20240403142305544

默认的这个使用的是 nodejs.org 的官方源,虽然现在 nodejs.org 的官方源国内访问也还可以,但为了保险起见,笔者还是换成阿里巴巴源。

image-20240403142424488

点击红框里的 X 删除当前安装,在点击新增安装,选择 Install from nodejs.org mirror

image-20240403142605321

镜像地址填入https://mirrors.aliyun.com/nodejs-release/,版本按需选择,笔者这里选择的是 node20-lts,并且安装了包管理工具 pnpm,如果读者的项目需要别的全局安装的包,也可以写在 Global npm packages to install ,比如 yarncnpm 之类的。

记得起一个别名:

image-20240403153355639

配置好后点击保存。

一般来说,在使用 npm 时,需要更改 npm 的源,同样在 Jenkins 中也是可以的。

安装完 NodeJS 插件后,系统设置中会多一项 Managed files

image-20240403143048480

进入后选择左侧的Add a new Config,然后选择 Npm config file,然后点击 Next

image-20240403143327739

image-20240403143449389

新增一个 NPM Registry,填入阿里巴巴镜像源:http://registry.npmmirror.com

至此 NodeJS 相关的配置就完成了。

SSH Server

打包后需要通过 SSH 部署到服务器上,因此需要先配置好 SSH 服务器。

打开 系统管理 -> 系统配置 -> Publish over SSH (在最下面):

image-20240403143805027

然后根据实际情况进行填写:

image-20240403144201158

字段解释
Name显示在 Jenkins 中的名称,可随意填写
Hostname服务器地址,ip 或 域名
UsernameSSH 登录的用户名
Remote DirectorySSH 登录后进入的目录,必须是服务器中已经存在的目录,设置好之后所有通过 SSH 上传的文件只能放在这个目录下

这里笔者使用用户名-密码的方式登录 SSH,如果要通过 SSH Key 的方式的话,需要在字段 Path to key 填入 key 文件的地址,或者直接将 key 的内容填入 Key 字段中:

image-20240403144737759

设置好可以通过Test Configuration,测试 SSH 连通性:

image-20240403144822057

出现 Success 代表 SSH 配置成功。

钉钉通知(可选)

如果不需要通过钉钉通知,可以不装 DingTalk 插件,并跳过本节内容。

钉钉部分设置

该功能需要一个钉钉群,并打开钉钉群机器人:

image-20240403145500789

点击添加机器人,选择自定义:

image-20240403145604092

这里笔者的安全设置选择了加签:

image-20240403145717147

将签名保存下来备用。

点击完成后,出现了钉钉机器人的 Webhook 地址。

image-20240403145823192

将地址保存下来备用。

至此钉钉部分的设置就结束了。

Jenkins 部分

打开 系统设置 -> 钉钉 (在最下面的未分类中):

image-20240403145150439

根据需要配置通知时机:

image-20240403145231249

然后点击机器人-新增:

image-20240403145303034

将刚刚的钉钉机器人的签名和 Webhook 地址填入对应的地方,并点击测试:

image-20240403150049799

此时钉钉机器人也在群中发了消息:

image-20240403150138516

至此钉钉机器人配置完毕。

创建任务(job)

本文中,笔者将以存储在 Git 仓库中的项目为例。

Github 项目

注意,如果想让 Github 项目全自动构建的话,需要你的 Jenkins 能被公网访问到,例如部署在云服务器上,像笔者这样部署在本地局域网中,是无法实现“提交代码 -> 自动构建 -> 自动部署”的,只能实现“提交代码 -> 手动点击开始构建 -> 自动部署”

如果在 Jenkins 新手向导里选择了 安装推荐插件,那么现在就不需要额外安装 Github 相关的插件了,否则的话需要手动安装 Github 相关的插件:

image-20240403151242880

创建项目

选择 Dashboard -> 新建任务:

image-20240403151424735

选择构建一个自由风格的软件项目,点击确定。

General

这部分可以添加钉钉机器人:

image-20240403151545166

源码管理

这里选择 Git:

输入仓库地址:https://github.com/baIder/homepage.git

image-20240403151822101

由于笔者这是一个私有仓库,因此会报错。

在下面的Credentials中,添加一个。

image-20240403151941812

image-20240403152135370

注意,这里的用户名是 Github 用户名,但是密码不是你的 Github 密码,而是你的 Github Access Token!!!

image-20240403152324115

image-20240403152429183

可以在这里创建 Token,需要勾选 admin:repo_hook 、repo 权限。

image-20240403152535951

image-20240403152729685

这里的报错是网络问题,连接 Github 懂得都懂。

image-20240403152824725

分支可以根据实际情况选择。

构建触发器

勾选GitHub hook trigger for GITScm polling,这样在 Git 仓库产生提交时,就会触发构建,属于是真正的核心。

image-20240403153134664

构建环境

勾选 Provide Node & npm bin/ folder to Path

image-20240403153444910

Build Steps

到这里,可以理解为 Jenkins 已经将仓库克隆到本地,并且已经安装好了nodenpmpnpm,接下来就是执行命令:

image-20240403153625159

我们需要执行命令:

 node -v
 pnpm -v
 
 rm -rf node_modules
 rm -rf dist
 
 pnpm install
 pnpm build

这里的pnpm build需要按情况更换为package.json中设定的命令。

image-20240403153850007

image-20240403153750787

构建后操作

经过所有的流程到这里,项目应该已经打包在dist目录下了。现在可以通过 SSH 将打包好的产物上传到服务器上了:

image-20240403154044658

image-20240403155757484

这里的 Source files 字段一定要写成dist/**/**,如果写成dist/*,则只会将第一层的文件上传。

Remove prefix 需要填写,否则会将dist这个目录也上传到服务器上。

Remote directory 是相对于配置 SSH Server 时的 Remote directory 的,本例中就是 /data/sites/homepage 。

Exec command 是文件上传后执行的命令,可以是任何命令,可以是让nginx有权限访问这些数据,重启nginx等等,根据服务器实际情况更改。

当然也可以在 Build Steps 中 build 完成后将 dist 目录打包,然后在通过 SSH 将压缩包上传到服务器,然后在 Exec command 中解压。

至此所有的配置已经完成,保存。

测试

点击左侧的 立即构建:

image-20240403154858929

image-20240403154950197

第一次构建会比较慢,因为需要下载node,安装依赖等等,可以从控制台看到,命令都如期执行了:

image-20240403155359524

构建成功,钉钉机器人也提示了(因为 Github 访问失败的原因,多试了几次):

image-20240403155855959

笔者已经配置好了nginx,因此可以直接访问网页,查看效果:

image-20240403160008179

通过 Git 提交触发构建

目前虽然构建成功了,但是需要手动点击构建,接下来实现如何将代码提交 Git 后自动触发构建。

打开仓库设置 -> Webhooks 添加一个:

image-20240403160353025

这里的 Payload URL 就是 Jenkins 地址 + /github-webhook,例如笔者的就如图所示。

但是由于笔者的 Jenkins 部署在本地局域网,因此是不行的,Github 肯定是无法访问到笔者的局域网的,有公网地址的读者可以试试,在笔者的阿里云服务器上是没有问题的。所以目前如果是 Github 项目的话,笔者需要提交代码后手动点击 立即构建:

image-20240403161026497

Gitlab 项目

实际上笔者所在公司是在局域网中部署了 Gitlab 的,因此针对 Gitlab 项目的自动化才是核心。

安装 Gitlab 插件:

image-20240403161442736

安装完毕后重启 Jenkins。

获取 Gitlab token

与 Github 的流程类似,也需要在 Gitlab 中创建一个 token:

image-20240403161807711

创建好之后保存 token 备用。

在 Jenkins 中配置 Gitlab

打开 Jenkins -> 系统管理 -> 系统配置 -> Gitlab

image-20240403162301361

这里需要新建一个Credentials,点击下方的添加:

类型选择GitLab API token,将刚刚保存的 token 填入到 API token 字段中。

image-20240403162144399

点击Test Connection

image-20240403162651637

出现Success说明配置成功。

创建项目

大多数过程与 Github 项目雷同。

General

会多出一个选项,选择刚刚添加的:

image-20240403163406501

源码管理

Git 仓库地址填 Gitlab 仓库地址,同样会报错,添加一个Credentials便可解决:

image-20240403163538105

用户名密码填登录 Gitlab 的用户名密码即可。

构建触发器

按需选择触发条件,这里笔者仅选择了提交代码:

这里红框中的 url 需要记下,后面要用。

image-20240403164252265

其他配置

与 Github 项目相同。

测试构建

点击立即构建,查看是否能构建成功:

image-20240403163945821

构建成功:

image-20240403164002293

提交代码自动构建

进入 Gitlab 仓库 -> 设置 -> 集成:

这里的 url 填入刚刚 Jenkins 构建触发器 中红框内的 url 地址。

image-20240403164203308

看情况是否开启 SSL verification。

点击 Add webhook:

image-20240403164449921

测试一下:

image-20240403164513668

可以看到 Jenkins 那边已经开始构建了:

image-20240403164551282

构建成功:

image-20240403164606737

测试 Git 提交触发构建

目前页面:

image-20240403164712339

我们将v2.0-f改成v2.0-g

image-20240403164817371

提交代码,Jenkins 开始了自动构建:

image-20240403164852625

构建成功,页面也发生了变化:

image-20240403164912343

至此,Gitlab 提交代码后自动打包并部署至服务器的流水线就完成了。

后记

本文实现了从提交代码到部署上线的自动化工作流,适合小公司的小型项目或自己的演示项目,大公司一定会有更规范更细节的流程,笔者也是从实际需求出发,希望本文能帮助到各位,由于笔者也是第一次使用 Jenkins,如有不足或错误之处,请读者批评指正。


作者:bald3r
来源:juejin.cn/post/7354406980784504870

收起阅读 »

Redis stream 用做消息队列完美吗 ?

Redis Stream 是 Redis 5.0 版本中引入的一种新的数据结构,它用于实现简单但功能强大的消息传递模式。 这篇文章,我们聊聊 Redis Stream 基本用法 ,以及如何在 SpringBoot 项目中应用 Redis Stream 。 1...
继续阅读 »

Redis Stream 是 Redis 5.0 版本中引入的一种新的数据结构,它用于实现简单但功能强大的消息传递模式。


这篇文章,我们聊聊 Redis Stream 基本用法 ,以及如何在 SpringBoot 项目中应用 Redis Stream 。



1 基础知识


Redis Stream 的结构如下图所示,它是一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。



每个 Redis Stream 都有唯一的名称 ,对应唯一的 Redis Key 。


同一个 Stream 可以挂载多个消费组 ConsumerGr0up , 消费组不能自动创建,需要使用 XGR0UP CREATE 命令创建


每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动 ,标识当前消费组消费到哪条消息了。


消费组 ConsumerGr0up 同样可以挂载多个消费者 Consumer , 每个 Consumer 并行的读取消息,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。


消费者内部有一个属性 pending_ids , 记录了当前消费者读取但没有回复 ACK 的消息 ID 列表 。


2 核心命令


01 XADD 向 Stream 末尾添加消息


使用 XADD 向队列添加消息,如果指定的队列不存在,则创建一个队列。基础语法格式:


XADD key ID field value [field value ...]


  • key :队列名称,如果不存在就创建

  • ID :消息 id,我们使用 * 表示由 redis 生成,可以自定义,但是要自己保证递增性。

  • field value : 记录。


127.0.0.1:6379> XADD mystream * name1 value1 name2 value2
"1712473185388-0"
127.0.0.1:6379> XLEN mystream
(integer) 1
127.0.0.1:6379> XADD mystream * name2 value2 name3 value3
"1712473231761-0"

消息 ID 使用 * 表示由 redis 生成,同时也可以自定义,但是自定义时要保证递增性。



消息 ID 的格式: 毫秒级时间戳 + 序号 , 例如:1712473185388-5 , 它表示当前消息在毫秒时间戳 1712473185388 产生 ,并且该毫秒内产生到了第5条消息。



在添加队列消息时,也可以指定队列的长度


127.0.0.1:6379> XADD mystream MAXLEN 100 * name value1 age 30
"1713082205042-0"

使用 XADD 命令向 mystream 的 stream 中添加了一条消息,并且指定了最大长度为 100。消息的 ID 由 Redis 自动生成,消息包含两个字段 nameage,分别对应的值是 value130


02 XRANGE 获取消息列表


使用 XRANGE 获取消息列表,会自动过滤已经删除的消息。语法格式:


XRANGE key start end [COUNT count]


  • key :队列名

  • start :开始值, - 表示最小值

  • end :结束值, + 表示最大值

  • count :数量


127.0.0.1:6379> XRANGE mystream - + COUNT 2
1) 1) "1712473185388-0"
  2) 1) "name1"
     2) "value1"
     3) "name2"
     4) "value2"
2) 1) "1712473231761-0"
  2) 1) "name2"
     2) "value2"
     3) "name3"
     4) "value3"

我们得到两条消息,第一层是消息 ID ,第二层是消息内容 ,消息内容是 Hash 数据结构 。


03 XREAD 以阻塞/非阻塞方式获取消息列表


使用 XREAD 以阻塞或非阻塞方式获取消息列表 ,语法格式:


XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]


  • count :数量

  • milliseconds :可选,阻塞毫秒数,没有设置就是非阻塞模式

  • key :队列名

  • id :消息 ID


127.0.0.1:6379> XREAD streams mystream 0-0
1) 1) "mystream"
  2) 1) 1) "1712473185388-0"
        2) 1) "name1"
           2) "value1"
           3) "name2"
           4) "value2"
     2) 1) "1712473231761-0"
        2) 1) "name2"
           2) "value2"
           3) "name3"
           4) "value3"

XRED 读消息时分为阻塞非阻塞模式,使用 BLOCK 选项可以表示阻塞模式,需要设置阻塞时长。非阻塞模式下,读取完毕(即使没有任何消息)立即返回,而在阻塞模式下,若读取不到内容,则阻塞等待。


127.0.0.1:6379> XREAD block 1000 streams mystream $
(nil)
(1.07s)

使用 Block 模式,配合 $ 作为 ID ,表示读取最新的消息,若没有消息,命令阻塞!等待过程中,其他客户端向队列追加消息,则会立即读取到。


因此,典型的队列就是 XADD 配合 XREAD Block 完成。XADD 负责生成消息,XREAD 负责消费消息。


04 XGR0UP CREATE 创建消费者组


使用 XGR0UP CREATE 创建消费者组,分两种情况:



  • 从头开始消费:


XGR0UP CREATE mystream consumer-group-name 0-0  


  • 从尾部开始消费:


XGR0UP CREATE mystream consumer-group-name $

执行效果如下:


127.0.0.1:6379> XGR0UP CREATE mystream mygroup 0-0
OK

05 XREADGR0UP GR0UP 读取消费组中的消息


使用 XREADGR0UP GR0UP 读取消费组中的消息,语法格式:


XREADGR0UP GR0UP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]


  • group :消费组名

  • consumer :消费者名。

  • count : 读取数量。

  • milliseconds : 阻塞毫秒数。

  • key : 队列名。

  • ID : 消息 ID。


示例:


127.0.0.1:6379>  XREADGR0UP group mygroup consumerA count 1 streams mystream >
1) 1) "mystream"
  2) 1) 1) "1712473185388-0"
        2) 1) "name1"
           2) "value1"
           3) "name2"
           4) "value2"

消费者组 mygroup 中的消费者 consumerA ,从 名为 mystream 的 Stream 中读取消息。



  • COUNT 1 表示一次最多读取一条消息

  • > 表示消息的起始位置是当前可用消息的 ID,即从当前未读取的最早消息开始读取。


06 XACK 消息消费确认


接收到消息之后,我们要手动确认一下(ack),语法格式:


xack key group-key ID [ID ...]

示例:


127.0.0.1:6379> XACK mystream mygroup 1713089061658-0
(integer) 1

消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 ack 确认消息已经被消费完成,整个流程的执行如下图所示:



我们可以使用 xpending 命令查看消费者未确认的消息ID


127.0.0.1:6379> xpending mystream mygroup
1) (integer) 1
2) "1713091227595-0"
3) "1713091227595-0"
4) 1) 1) "consumerA"
     2) "1"

07 XTRIM 限制 Stream 长度


我们使用 XTRIM 对流进行修剪,限制长度, 语法格式:


127.0.0.1:6379> XADD mystream * field1 A field2 B field3 C field4 D
"1712535017402-0"
127.0.0.1:6379> XTRIM mystream MAXLEN 2
(integer) 4
127.0.0.1:6379> XRANGE mystream - +
1) 1) "1712498239430-0"
  2) 1) "name"
     2) "zhangyogn"
2) 1) "1712535017402-0"
  2) 1) "field1"
     2) "A"
     3) "field2"
     4) "B"
     5) "field3"
     6) "C"
     7) "field4"
     8) "D"

3 SpringBoot Redis Stream 实战


1、添加 SpringBoot Redis 依赖


<dependency>
   <groupId>org.springframework.bootgroupId>
   <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

2、yaml 文件配置



3、RedisTemplate 配置



4、定义stream监听器



5、定义streamcontainer 并启动



6、发送消息



执行完成之后,消费者就可以打印如下日志:



演示代码地址:



github.com/makemyownli…



4 Redis stream 用做消息队列完美吗


笔者认为 Redis stream 用于消息队列最大的进步在于:实现了发布订阅模型


发布订阅模型具有如下特点:



  • 消费独立


    相比队列模型的匿名消费方式,发布订阅模型中消费方都会具备的身份,一般叫做订阅组(订阅关系),不同订阅组之间相互独立不会相互影响。


  • 一对多通信


    基于独立身份的设计,同一个主题内的消息可以被多个订阅组处理,每个订阅组都可以拿到全量消息。因此发布订阅模型可以实现一对多通信。



细品 Redis stream 的设计,我们发现它和 Kafka 非常相似,比如说消费者组,消费进度偏移量等。


我们曾经诟病 Redis List 数据结构用做队列时,因为消费时没有 Ack 机制,应用异常挂掉导致消息偶发丢失的情况,Redis Stream 已经完美的解决了。


因为消费者内部有一个属性 pending_ids , 记录了当前消费者读取但没有回复 ACK 的消息 ID 列表 。当消费者重新上线,这些消息可以重新被消费。


但 Redis stream 用做消息队列完美吗 ?


这个真没有!


1、Redis 本身定位是内存数据库,它的设计之初都是为缓存准备的,并不具备消息堆积的能力。而专业消息队列一个非常重要的功能是数据中转枢纽,Redis 的定位很难满足,所以使用起来要非常小心。


2、Redis 的高可用方案可能丢失消息(AOF 持久化 和 主从复制都是异步 ),而专业消息队列可以针对不同的场景选择不同的高可用策略。


所以,笔者认为 Redis 非常适合轻量级消息队列解决方案,轻量级意味着:数据量可控 + 业务模型简单 。




参考文章:



redis.io/docs/data-t…


http://www.runoob.com/redis/redis…


pdai.tech/md/db/nosql…





作者:勇哥Java实战
来源:juejin.cn/post/7357301805569687563
收起阅读 »

争论不休的一个话题:金额到底是用Long还是BigDecimal?

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。 比如说这个观点:算钱用BigDecimal是常识 有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long...
继续阅读 »

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。 比如说这个观点:算钱用BigDecimal是常识


image.png


image.png


有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long进行存储以及计算,这样不用担心小数点问题。


image.png


并且一些银行系统就会选择用Long


image.png


还有,最最最牛逼的万能大法:用String


image.png


成年人不做选择题,Long跟BigDecimal都用。。。


image.png


image.png


还有一种就是封装一个金额的基类,对金额进行统一处理。


image.png


排除float和double


当然,对于金额,首先我们要排除的就是float和double。它们不适合用于精确的金融计算,因为floatdouble是基于IEEE 754标准的浮点数表示,它们无法精确地表示所有的十进制小数。这会导致在进行财务计算时出现舍入误差,这些误差可能会累积并导致不可预测的结果。



关于带精度的计算,我们不推荐使用float以及double,推荐使用BigDecimal,具体原因请参考:聊一聊BigDecimal使用时的陷阱



选择Long


Long类型在Java中用于存储64位整数。它的主要优点是速度快,因为整数运算在CPU层面是非常高效的。另外,Long类型也占用较少的内存,并且整数类型(BIGINT)在数据库中占用较少的存储空间。


但是Long类型在处理金额时有几个明显的缺点:



  1. 1. 精度问题Long只能存储整数,无法直接表示小数。使用Long来表示以分为单位的金额(例如,100表示1元),此时就会失去小数的精度。即使使用某种方式来表示小数(例如,乘以100或10000),也会遇到舍入误差的问题。并且这种计算方式也会增加计算的复杂度。

  2. 2. 浮点数问题:虽然这不是直接使用Long的问题,但如果你尝试将Long与浮点数(如doublefloat)进行转换以进行计算(比如汇率计算等),还是会遇到浮点数精度问题,这可能导致在财务计算中出现不可接受的误差。


在阿里巴巴的开发手册中建议使用Long。


image.png


但是在一些金融系统当中,对小数位要求比较高的,比如精确到小数点后6位,那么我们使用Long进行存储,每次在计算时都要除以或者乘以1000000,那么计算的开销就很大了。


并且,如果在需求确认时,我们无法知道金额要求的小数位,那我们使用Long也是不行的,我们并不知道需要乘以或者除以多少个0。


选择BigDecimal


BigDecimal是Java提供的一个类,用于任意精度的算术运算。它的主要优点是提供了高精度的计算,这对于金融和货币计算来说是非常重要的。BigDecimal可以表示任意大小的正数、负数或零,并可以精确控制舍入行为。并且在数据库中存储时也有对应的类型进行匹配,比如MySQL的DECIMAL类型提供了精确的数值存储,可以匹配BigDecimal的精度。


但是BigDecimal也有一些缺点:



  1. 1. 性能:与Long相比,BigDecimal的性能较差。因为它的运算需要更多的内存和CPU时间。

  2. 2. 复杂性:使用BigDecimal进行运算比使用Long或基本数据类型更复杂。你需要考虑舍入模式、精度等因素。

  3. 3. 在数据库中需要更多的存储空间来存储小数部分。


而在Mysql的开发手册中,建议金额需要进行小数位计算时,存储要使用Decimal,否则我们要将金额乘以对应小数位的倍数变成BIGINT进行存储。


image.png


总结


基于上述对LongBigDecimal的优缺点分析,我们可以得出以下结论:


在金额计算层面,即代码实现中,推荐使用BigDecimal进行所有与金额相关的计算。BigDecimal提供了高精度的数值运算,能够确保金额计算的精确性,避免了因浮点数精度问题导致的财务误差。使用BigDecimal可以简化代码逻辑,减少因处理精度问题而引入的复杂性。


而在数据库存储方面,我们需要根据具体需求进行权衡。如果业务需求已经明确金额只需精确到分(如某些国家/地区的货币最小单位为分),并且我们确信不会涉及到需要更高精度的小数计算,那么可以使用Long类型进行存储,将金额转换为最小货币单位(如分)进行存储。这样可以节省存储空间并提高查询性能。


但是如果业务需求中金额的小数位数不确定,或者可能涉及多位小数的计算(如国际货币交易等),那么最好使用DECIMALNUMERIC类型进行存储。这些类型提供了精确的数值存储,可以确保数据库中的数据与应用程序中的BigDecimal对象保持一致,避免数据转换过程中可能引入的精度损失。




作者:码农Academy
来源:juejin.cn/post/7358670107902984229
收起阅读 »

公司新来一个架构师, 将消费金融系统重构了

1、背景 1.2 业务重组与合并 随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。 1.2 解决...
继续阅读 »

1、背景


1.2 业务重组与合并


随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。


1.2 解决技术债务


现阶段存在的主要问题:



  1. 代码模块之间边界感不强,需要通过模块拆分、服务拆分来区分业务边界。

  2. 代码实现缺少层次感,设计模式单一,一层到底的冗长代码。

    此前,微服务拆分原则是按消费分期、合作方分期产品等维度进行整体拆分的,优点是明确了项目职责,简单的从需求维度进行服务拆分,确实是一种行之有效的方式,缺点是没有对基础功能进行剥离,以至于很多场景需要维护重复的代码,增加了项目的维护成本。


1.3 影响开发效率


即使我们接手项目已经有一段时间,并对项目足够了解时,但排查问题起来依然费力费时,而且系统内部代码错综复杂,调用链路交错,难以正常维护,从长远的开发效率考虑,尽快提出新型方案来代替现有结构。


1.4 监控体系不够完善


线上异常机制不够敏感,缺少关键业务指标的告警看板,作为一个业务开发,应保持对核心指标数据的敏感性。


2、重构目标



  1. 不影响业务的正常运转和迭代;

  2. 改善现有代码结构设计,让代码易于扩展,提升开发效率;

  3. 采用新工程逐步替代原有接口,旧工程逐渐废弃。


3、设计


3.1 调研


开始重构之前,调研了互联网消金通用的架构解决方案:


通用方案


由于是外部调研的通用架构设计,所以并非完全契合转转消费分期产品,但可以借鉴其分层架构设计的思想,在代码设计阶段,可以先对核心模块进行拆解和规划。


3.2 规划


前端页面与后端重构计划分两次迭代进行,分阶段进行,可以有效分摊并降低项目上线风险,第一次迭代围绕后端主要模块进行剥离重新设计并上线;第二次重构目的是解决产品需求,对接前端新页面。


3.3 修缮者模式


作为一个一线的业务开发,需要开展重构工作的同时还得保证产品需求的正常迭代,修缮者模式无疑是最佳选择。
第一次迭代历程,对于历史工程边缘逻辑保留并隔离,只对核心代码进行重构后转移到到新工程,新工程逐步接手老旧逻辑,并对老工程提供RPC接口,逐渐取代。此方案整体风险最低,同时能兼顾到正常的需求迭代。

第二次迭代历程,经历了第一次迭代后,新系统运行稳定,同时也具备接手新产品的能力,新工程开始与前端对接、联调,在此之后,V2版本也正式上线。


3.4 领域设计(横向拆分)


模块拆分



  1. 聚合业务:涵盖了消费分期主要业务,根据其各自产品需求特点,作为上层业务代码,对前端、收银台提供聚合接口。

  2. 基础服务:用户信贷所产生的数据、或依托合作方数据,围绕金融分期服务提供的数据支持。

  3. 三方对接:基于转转标准API下的逻辑实现,同时具备灵活接入合作方接口的能力。


3.5 模块设计(纵向拆分)


基于以往项目存在的问题,再结合消费分期的特点,我们对分期购买到账单还款结清的整个流程进行拆解:用户主动填写申请信息,提交授信申请并获额,挑选商品分期下单,生成还款计划,提供绑卡、账单还款等功能。以上就是一个简单的分期购物流程,基于以上流程,我们把消费分期所包含的公共模块,如授信前获额、用信、账单还款,这些富有金融服务属性的功能进行剥离。消费分期作为转转的产品原型,在聚合层中各自维护,互不影响。

设计原则:在不改变原有代码逻辑的情况下,根据单一职责和依赖倒置原则的思想:对系统进行模块拆分与合并,以明确项目职责降低耦合度;对包进行重新规划,划分包与包之间的边界,进一步减少代码间的耦合。


3.6 代码设计


好的代码重构一定离不开设计模式,基于原有单一的策略模式,我们把合作方对接模块与基础服务模块进行了拆解,采用双层模板、策略、工厂模式的组合,分别对授信、用信、贷后几个模块单独设计接口,维护好对合作方通用标准API接口,同时具备灵活接入的特点,举个例子,以下为授信模块主要代码类图:


第一层作为基础服务的策略模式;

第二层作为合作方对接的策略模式。

主要类图设计:
类图设计


在定义接口与实现类后,形成了对合作方对接层依赖,同时对订单、用信、授信等核心数据进行落地,对消费分期提供数据支撑,举个例子,以下为授信模块主要代码:



  1. 基础服务接口定义


/**
* 授信接口定义
**/

public interface ICreditService {

/**
* appId,资方定义的一个唯一ID
*/

String getAppId();

/**
* app名称
*
*
@return zz or zlj
*/

String getAppName();

/**
* 获取授信结果
*
*
@return result
*/

CreditResult creditResult(String logStr, Long uid);
}




  1. 标准流程抽象


/**
* 标准API对接实现
*
**/

public abstract class AbstractCreditService implements ICreditService {

/**
* 标准API对接
*
*
@return IBaseApiService
*/

protected abstract IBaseApiService getApiThirdService();

@Override
public AppConfig getPartner() {
return commonConfigUtil.getAppConfig(getAppId());
}

@Override
public CreditResult creditResult(String logStr, Long uid) {
CreditResultInput input = new CreditResultInput();
input.setUid(uid);
ResponseProtocol output = getApiThirdService().creditResult(logStr, input);
String creditStatus = TransformUtil.approvalStatusTransform(output.getData());
return CreditResult.builder().result(creditStatus).build();
}
}

/**
* 合作方差异化接入
*/

@Service
@Slf4j
public class ZZABCCreditServiceImpl extends AbstractABCCreditService {

@Resource
ZZABCThirdServiceImpl abcThirdService;

@Override
public String getAppId() {
return PartnerEnum.ABC_ZZ_API.getAppId();
}
@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
@Override
protected IABCThirdService getABCThirdService() {
return abcThirdService;
}
}



  1. 标准API对接


/**
* 标准API对接
*
*
@author Rouse
*
@date 2022/4/24 13:57
*/

public interface IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(CreditResultInput input);
}



  1. 内部标准API实现


/**
* 合作方,标准API对接实现
*
*
@author Rouse
*
@date 2022/4/24 14:04
*/

@Slf4j
public abstract class AbstractBaseApiService implements IBaseApiService {
@Override
public ResponseProtocol creditResult(CreditResultInput input) {
// 通用加解密
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, CreditResultOutput.class);
}
}


  1. 差异化合作方接入



/**
* ABC合作方接口封装
**/

public interface IABCThirdService extends IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(ABCCreditResultInput input);
}

/**
* 合作方抽象方法封装
**/

@Slf4j
public abstract class AbstractABCThirdService extends AbstractBaseApiService implements IABCThirdService {
@Override
public ResponseProtocol creditResult(ABCCreditResultInput input) {
// 加解密差异化实现
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, ABCCreditResultOutput.class);
}
}


/**
* ABC合作方对接
*
*/

@Service
@Slf4j
public class ZZABCThirdServiceImpl extends AbstractABCThirdService{

@Override
public String getAppId() {
return PartnerEnum.ABC_API_ZZ.getAppId();
}

@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
}


4、上线过程


对于老系统的重构,新系统上线过度期也至关重要,因为采用了新的表结构进行重新设计,涉及到数据的同步,我们采用单向数据同步,逐渐弃用老系统数据,如果灰度期间需要回滚,首先对数据进行回滚,优先保证线上服务稳定。

以下是经历两次重构迭代的过程:
迁移过程


5、监控


监控面板

告警通知



  1. 项目重构监控先行,这次我们采用了转转告警机制和Prometheus线上监控,另外搭建了一套线上看板,及时发现各个模块的潜在隐患。

  2. 日志,一个完美的系统离不开合理的日志,日志往往是定位问题最便捷的工具。


6、总结


通过此次技术重构,我们不仅解决了过去存在的技术债务问题,还提升了服务的稳定性和用户体验,也提升产品交付效率。

技术重构并非一蹴而就,但只要我们有坚定的信念和不懈的努力,终将取得成功。引用一句名言:”不要因为懒惰而拒绝重构,不要因为无暇重构而成为你拖延的理由 。” 是的,重构是持续优化代码质量和可维护性的过程,需要我们时刻关注并付诸行动。

我认为,重构的另一种价值:一个重构好的系统、往往具备通用性,可移植性。简单说就是我们重构后的系统以最小的改动且能在同行中快速复用,因为你创造了一个稳定可靠的“轮子”,如果做到这点,无非你是这个行业技术解决方案的专家。


关于作者


罗思,金融技术部后端研发工程师。转转消费分期业务开发。


作者:转转技术团队
来源:juejin.cn/post/7356550566535495732
收起阅读 »

JAVA 一个简单查重的实现

JAVA 一个简单查重的实现 1. 前言 最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。...
继续阅读 »

JAVA 一个简单查重的实现


1. 前言


最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。于是只能亲自动手做一个 simple 版本的。


2. 实现思路


思路的话比较简单,想法是利用双指针的模式,找出两个文本中相似的文本。不过这样算法复杂度是 O(n2) ,不过由于我们是小网站,文章本来也不多。


image.png
image.png


核心就是有一个双层的循环做遍历,然后判断最小字符串是否相同,如果不相同,则指针B递增直到找到相同的文本


image.png


如果指针位置找到了相同文本,则增加最小字符串长度,直到找到最大的匹配的文本。


3. 代码实现



public static class SameResult {
// 存储相似文本关键词
private String keyword;
// 存储与关键词详细信息
private String detail;

public String getKeyword() {
return keyword;
}

public void setKeyword(String keyword) {
this.keyword = keyword;
}

public String getDetail() {
return detail;
}

public void setDetail(String detail) {
this.detail = detail;
}
}

/**
* 获取两个字符串中的相似文本片段
* @param a 文本a
* @param b 文本b
* @param minSize 最小相似字符数
* @return 返回相似文本片段的列表
*/

public static List<SameResult> getSameTextList(String a, String b, Integer minSize) {

List<SameResult> result = new ArrayList<>();
Map<String, String> stash = new HashMap<>();
if (a == null || b == null) {
return result;
}
if (a.length() < minSize || b.length() < minSize) {
return result;
}
int i = 0;
while (i <= a.length() - minSize) {
// 初始化窗口大小为最小相似字符数
int nowWindowSize = minSize;
// 遍历文本b,寻找与文本a当前片段相似的片段
int j = 0;
String nowMate = null; // 存储当前相似片段
String nowDetail = null; // 存储当前相似片段的详细信息
SameResult sameResult = new SameResult();
Boolean isMate = false; // 标记是否找到相似片段
while (j <= b.length() - minSize) {
// 如果文本a和文本b的当前片段相等
if (a.substring(i, nowWindowSize + i).equals(b.substring(j, nowWindowSize + j))) {
// 记录相似片段
nowMate = a.substring(i, nowWindowSize + i);
// 记录详细信息, 这里的5表示详细信息取前五个和后五个字符
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
// 设置找到相似片段的标记
isMate = true;
// 增加窗口大小
nowWindowSize++;
// 继续在文本b中寻找更长的相似片段
while (j <= b.length() - nowWindowSize) {
String ma1 = a.substring(i, nowWindowSize + i);
String ma2 = b.substring(j, nowWindowSize + j);
// 如果找到更长的相似片段
if (ma1.equals(ma2)) {
nowMate = a.substring(i, nowWindowSize + i);
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
nowWindowSize++;
} else {
// 如果不再相似,退出循环
break;
}
}
// 找到相似片段后,退出内部循环
break;
} else {
// 如果不相似,继续在文本b中寻找
j++;
}
}
// 如果找到相似片段,将其存储到映射中
if (isMate) {
// 移动文本a的索引
i += nowWindowSize - 1;
stash.put(sameResult.getKeyword(), sameResult.getDetail());
} else {
// 如果没有找到相似片段,移动文本a的索引
i++;
}
}
for (String key : stash.keySet()) {
SameResult sameResult = new SameResult();
sameResult.setKeyword(key);
sameResult.setDetail(stash.get(key));
result.add(sameResult);
}
return result;
}

public static void main(String[] args) {
// 调用getSameTextList方法,并打印结果
System.out.println(getSameTextList("test1", "test2", 10));
}

代码总体比较简单,就是获取到所有最小长度文本长度的所有相似文本,并放到一个 List 中,以便后续的业务处理。


最后可以整理为一个类似下面的表格


原文相似内容
脸哭声更为响亮。我问他是谁的悲他把他脸哭声更为响亮。我问他是谁使的打成这
情往往只是作为情的友爱和险情往往只是作为情可来及,正
着茂盛树叶的树下节了一棵已着茂盛树叶的树下,走的女棉花
再说我爹年轻时也我端人的子。再说我爹年轻时也好些一手,

4. 结尾


一般会用到查重的业务场景可能并不多,大部分都是学校、政府等才需要进行查重,本文算是抛砖引玉吧,只是为需要做查重内容展示时为大家提供一点点思路。


作者:码头的薯条
来源:juejin.cn/post/7355347789677035571
收起阅读 »

更适合年轻人体质的 git 工作流

关于如何使用 git,相信大家都见过下面这张图: 很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道...
继续阅读 »

关于如何使用 git,相信大家都见过下面这张图:


image.png


很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道有没有更好的流程可以用。本篇文章就来聊一聊这个问题。


现有 git flow 存在的问题


首先我们来分析一下上面这个流程中都存在哪些问题:


feature 分支要从 dev 分支创建,怎么保证代码是干净的?


举个例子,你要开发一个新功能,从 dev 切出一个新分支后之后发现怎么都跑不起来,群里问了一圈发现有人提交到 dev 的代码有问题,于是你就等到他重新提交了一个 commit 之后,你拉了下代码,这才开始正常开发。


发版时需要从 dev 分支创建 release 分支,怎么保证代码都是干净的?


再举个例子,本轮迭代共提交了 20 个 commit,其中 16 个 commit 需要发布,剩下 4 个 commit 因为还没测试完、bug 没改完不能发。这时候你能准确的把要发布的 commit 检出来么?


如果可以的话,咱们更进一步,本轮迭代由五位同事提交了 40 个 commit,在发版的时候其中两个请假了,这时候你能准确的知道哪些 commit 是要发布的,并准确将其检出来么?


如果还可以的话,那就更更进一步,你检出来之后,发布到 uat 环境,发现代码跑不起来了,结果发现,有个同事偷懒了,某个 commit 因为功能没开发完所以没有检进来,但是恰好这个 commit 里又包含了一些非常关键的代码,没有就跑不起来,这时候你会怎么做?


需要保持 dev 分支和 master 分支的同步,不同步的话可能导致合并冲突


回忆一下,你之前有没有处理过这种合并冲突:冲突的两方代码是完全一样的,但就是冲突了。


这种就是使用了 rebase(非 fast-forward)或者 cherry-pick 后导致的,因为这两种方法会产生代码完全一样,但是 id 不同的新 commit。就导致了 git 产生了混乱。


一个常见场景就是 hotfix 分支的 commit,合并到 master 之后又 cherry-pick 到了 dev 分支。这样下次再从 dev 往 master 合的时候就会出现这种问题。




不知道你什么感受,反正我是已经开始汗流浃背了,那么有没有更简单、更高效、心智负担更低的 git 工作流能解决这些问题呢?当然是有的。


正式介绍一下新的 git flow


首先我们还是以流程图的形式展示一下新的 flow:


image.png


和原本 git flow 的区别在于:



  • feature 分支不再从 dev 创建,而是从最稳定的 master 分支创建

  • dev 分支的代码不再向 release 分支合并,由 feature 直接发起到 release 的合并。

  • 当 release 分支测试完成要发版的时候,直接 fast-forward 到 master

  • 定期删除 dev 和 release,然后从 master 创建新的(例如每轮迭代结束之后)


那么这套工作流能解决刚才提到的问题么?答案是肯定的,老的工作流中存在的问题主要就是:


dev 分支过于重要


dev 需要接受来自多个 feat 以及 hotfix、master 的合并,并合并到 release 分支,这就会导致 dev 分支出现冲突的概率是成倍增加的。开发人员越多,其中存在的脏代码就越多,分支就越不稳定,冲突的情况就越多。


而这套新流程中 dev 的职责被弱化了,变得更加纯粹,即只对接测试环境的发布,其他的工作一概不管。也就是说 dev 本身就是合并路径的终点,从而消除了合并 commit 的回环,干掉了很多可能会产生迷惑冲突的场景。


从普通开发人员的视角看一下


现在我们从头开始,以普通开发的身份来走一遍这套流程,看会有什么效果:



  • 昨天版本发布了,master 代码上有了新的 commit,于是你执行了 git fetch会把远程的代码都同步到本地,比如远程的 master 分支同步到本地的 origin/master

  • 早上开会的时候给你安排了功能 a 和功能 b,你决定先做 a,于是你执行了 git checkout -b feat/a origin/master从刚才拉下来的 origin/master 分支创建了一个新分支

  • 你开始开发,随着开发进度的增加,中间可能执行了多次 git addgit commit

  • 几个小时后终于把功能做好了,自测也没问题,你决定发到测试环境让 QA 同事看一下,于是你执行了 git push 并且在远程仓库里提交了 feat/a 到 dev 分支的 pr,合并完成后流水线自动把代码发布到了测试环境。

  • 通知了 QA 之后,你决定开始开发 b 功能,于是你执行了 git checkout -b feat/b origin/master,然后开始开发。

  • 突然 QA 通知你功能 a 有 bug 需要修复,于是你执行了 git stash 把当前手头的工作暂存了起来,然后 git checkout feat/a 开始解决 bug。

  • 解决完了之后,你重新 git commitgit push 到了 dev 分支,QA 开始继续测试,你也切回了 feat/b 分支并 git stash pop 开始继续开发。

  • 过了一会,QA 通知你功能 a 测试没问题了,于是你在远程仓库里找到 feat/a 分支,并直接发起了一个到 release 分支的 pr。此时 release 分支触发了流水线,将功能 a 的代码更新到了预发环境。

  • 搞完之后,你切回 feat/b 分支继续开始功能 b 的开发...


故事到这里就结束了,你可能会好奇:版本发布的时候呢?不需要执行什么操作?


是的不需要。这套流程中发布生产环境极其简单。因为功能测试完成后会直接推到 release 分支。也就是说,只要和 release 分支绑定的环境(例如 uat)测试没问题,那么发布的时候只需要把 release 合并到 master 就行了。不会出现之前那种要在发版前检查很久要发布哪些 commit 的情况。


一些疑问解答


在实践过程中也有很多同事对这套流程产生了或多或少的疑问,这里就记录一下,希望对大家有帮助:


1、代码提交到 release 分支后出现 bug 怎么办?


切换到对应的分支(例如 feat/c),提交新的 commit 之后从 feat/c 合并到 dev,dev 测试没问题后从 feat/c 合并到 release 分支。


2、feat 分支合并到 dev 分支的时候代码冲突了怎么办?



首先,代码冲突很正常,没有任何一个工作流能完全避免代码冲突。我们应该尽力避免因工作流本身的问题产生的“令人困惑”的代码冲突。



比较正规的做法是:从最新的 dev 创建一个新分支,例如 dev-feat/a,然后把你的 feat/a 本地合并到 dev-feat/a 并解决冲突,然后 git push dev-feat/a 并在远程仓库发起 dev-feat/a 到 dev 的 pr。


比较随性的做法是:本地切到 dev 分支,git pull --rebase 拉取最新代码,然后直接 git rebase feat/a 解决冲突后直接 git push 到远程仓库的 dev 分支。


有些人可能会有疑问:"直接 push 到这种环境分支没问题么,之前我们这种分支都是写保护的,只能接受 pr"。


确实,老的工作流对环境分支的保护都是比较严格的,但是这一套工作流没有这些限制,因为最遭的情况也就是你把 dev 分支搞崩了。那直接把远程 dev 分支删掉再从 master 或者 release 分支拉一个就完事了嘛,反正大家的功能都在各自的 feat 分支上。再极端一点,只要你不搞坏其他人的代码,你就算直接 git push --force 强制推送到 dev 分支都没问题。


3、同事 A 和 B 的新功能要基于同事 C 的新代码,这时候怎么办?


假设同事 C 开发的功能在 feat/c,那么同事 A 和 B 的分支就应该从 feat/c 创建并继续开发。而不是等同事 C 合并到 dev 之后再从 dev 创建。


4、既然是 feat 直接合并到指定分支,那么为什么最后一步不是 feat 分支合并到 master 分支呢?


因为这套流程里,最重要的就是保证 master 分支的稳定性。所以 master 分支上的代码必须是经过严格验证的。


并且如果 feat 直接合到 master 的话还会导致一些其他的问题:



  • 有一个同事比较粗心,在提交 pr 的时候本来该合到 dev 分支,结果一不小心点到了 master,审核的人有不注意直接点了同意,这时候 master 就被污染了。

  • 合并到 dev 时如果出现合并冲突的话,那么合并到 release 分支大概率也会再出现一遍,你总不会想合到 master 的时候去解决第三遍吧,而且也无法保证冲突的解决一定是不会出问题的。


所以说,最稳妥,最省心的做法就是直接把 release 分支的代码合并到 master。


5、hotfix master 怎么办?


git flow 里 hotfix 分支中的 commit 一方面要合并到 master,另一方面要同步到 dev。但是由于后续 dev 也要再次更新到 master,这个 hotfix 的 commit 就可能会导致困惑冲突。


但是这套新流程里就不会出现冲突,因为 dev 分支自己就已经是终点了,不会合并到其他分支。所以 hotfix 里的提交无论怎么合并到 dev,不管是 merge、rebase 还是 cherry-pick,都是可以的。甚至不用管也没关系,因为只要是新 feat 合并到 dev,这个 hotfix commit 就被自动携带过来了。


总结


其实这一套工作流其实是 gitlab flow + git flow 的一个调优,使其在保证效率的同时更贴近 git 新手的心理认知。总结一下就是 dev 分支并不会“晋升”到 release 分支。而是由 feat 分支发起到 release 分支的合并,同时 master 只接受来自 release 的合并,由此减少了很多需要遵守的规则和发生冲突的情况。


参考



作者:HOHO
来源:juejin.cn/post/7355845860683202595
收起阅读 »

为了NullPointerException,你知道Java到底做了多少努力吗?

null 何错之有? 对于 Java 程序员而言,NullPointerException 是最令我们头疼的异常,没有之一 ,大明哥相信到这篇文章为止一定还有不少人在写下面这段代码: if (obj != null) { //... } NullPo...
继续阅读 »

null 何错之有?


对于 Java 程序员而言,NullPointerException 是最令我们头疼的异常,没有之一 ,大明哥相信到这篇文章为止一定还有不少人在写下面这段代码:


if (obj != null) {
//...
}


NullPointerException 是 Java 1.0 版本引入的,引入它的主要目的是为了提供一种机制来处理 Java 程序中的空引用错误。空引用(Null Reference)是一个与空指针类似的概念,是一个已宣告但其并未引用到一个有效对象的变量。它是伟大的计算机科学家Tony Hoare 早在1965年发明的,最初作为编程语言ALGOL W的一部分。嗯,就是这位老爷子




1965年,老爷子 Tony Hoare 在设计ALGOL W语言时,为了简化ALGOL W 的设计,引入空引用的概念,他认为空引用可以方便地表示“无值”或“未知值”,其设计初衷就是要“通过编译器的自动检测机制,确保所有使用引用的地方都是绝对安全的”。但是在2009年,很多年后,他开始为自己曾经做过这样的决定而后悔不已,把它称为“一个价值十亿美元的错误”。实际上,Hoare的这段话低估了过去五十年来数百万程序员为修复空引用所耗费的代价。因为在ALGOL W之后出现的大多数现代程序设计语言,包括Java,都采用了同样的设计方式,其原因是为了与更老的语言保持兼容,或者就像Hoare曾经陈述的那样,“仅仅是因为这样实现起来更加容易”。


在 Java 中,null 会带来各种问题(摘自:《Java 8 实战》):



  • 它是错误之源。 NullPointerException 是目前Java程序开发中最典型的异常。它会使你的代码膨胀。

  • 它让你的代码充斥着深度嵌套的null检查,代码的可读性糟糕透顶。

  • 它自身是毫无意义的。 null自身没有任何的语义,尤其是是它代表的是在静态类型语言中以一种错误的方式对缺失变量值的建模。

  • 它破坏了Java的哲学。 Java一直试图避免让程序员意识到指针的存在,唯一的例外是:null指针。

  • 它在Java的类型系统上开了个口子。 null并不属于任何类型,这意味着它可以被赋值给任意引用类型的变量。这会导致问题, 原因是当这个变量被传递到系统中的另一个部分后,你将无法获知这个null变量最初赋值到底是什么类型。


Java 做了哪些努力?


Java 为了处理 NullPointerException 一直在努力着。



  • Java 8 引入 Optional:减少 null而引发的NullPointerException异常

  • Java 14 引入 Helpful NullPointerExceptions:帮助我们更好地排查 NullPointerException


Java 8 的 Optional


Optional 是什么


Optional 是 Java 8 提供了一个类库。被设计出来的目的是为了减少因为null而引发的NullPointerException异常,并提供更安全和优雅的处理方式。


Java 中臭名昭著的 NullPointerException 是导致 Java 应用程序失败最常见的原因,没有之一,大明哥认为没有一个 Java 开发程序员没有遇到这个异常。为了解决 NullPointerException,Google Guava 引入了 Optional 类,它提供了一种在处理可能为null值时更灵活和优雅的方式,受 Google Guava 的影响,Java 8 引入 Optional 来处理 null 值。


在 Javadoc 中是这样描述它的:一个可以为 null 的容器对象。所以 java.util.Optional 是一个容器类,它可以保存类型为 T 的值,T 可以是实际 Java 对象,也可以是 null


Optional API 介绍


我们先看 Optional 的定义:


public final class Optional {

/**
* 如果非空,则为该值;如果为空,则表示没有值存在。
*/

private final T value;

//...
}

从这里可以看出,Optional 的本质就是内部存储了一个真实的值 T,如果 T 非空,就为该值,如果为空,则表示该值不存在。


构造 Optional 对象


Optional 的构造函数是 private 权限的,它对外提供了三个方法用于构造 Optional 对象。



Optional.of(T value)



    public static  Optional<T> of(T value) {
return new Optional<>(value);
}

private Optional(T value) {
this.value = Objects.requireNonNull(value);
}

所以 Optional.of(T value) 是创建一个包含非null值的 Optional 对象。如果传入的值为null,将抛出NullPointerException 异常信息。



Optional.ofNullable(T value)



    public static  Optional ofNullable(T value) {
return value == null ? empty() : of(value);
}

创建一个包含可能为null值的Optional对象。如果传入的值为null,则会创建一个空的Optional对象。



Optional.empty()



    public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}

private static final Optional EMPTY = new Optional<>();

创建一个空的Optional对象,表示没有值。


检查是否有值


Optional 提供了两个方法用来检查是否有值。



isPresent()



isPresent() 用于检查Optional对象是否包含一个非null值,源码如下:


    public boolean isPresent() {
return value != null;
}

示例如下:


User user = null;
Optional optional = Optional.ofNullable(user);
System.out.println(optional.isPresent());
// 结果......
false


ifPresent(Consumer action)



该方法用来执行一个操作,该操作只有在 Optional 包含非null值时才会执行。源码如下:


    public void ifPresent(Consumersuper T> consumer) {
if (value != null)
consumer.accept(value);
}

需要注意的是,这是 Consumer,是没有返回值的。


示例如下:


User user = new User("xiaoming");
Optional.ofNullable(user).ifPresent(value-> System.out.println("名字是:" + value.getName()));

获取值


获取值是 Optional 中的核心 API,Optional 为该功能提供了四个方法。



get()



get() 用来获取 Optional 对象中的值。如果 Optional 对象的值为空,会抛出NoSuchElementException异常。源码如下:


    public T get() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}


orElse(T other)



orElse() 用来获取 Optional 对象中的值,如果值为空,则返回指定的默认值。源码如下:


    public T orElse(T other) {
return value != null ? value : other;
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElse(new User("xiaohong"));
System.out.println(user);
// 结果......
User(name=xiaohong, address=null)


orElseGet(Supplier other)



orElseGet()用来获取 Optional 对象中的值,如果值为空,则通过 Supplier 提供的逻辑来生成默认值。源码如下:


    public T orElseGet(Supplierextends T> other) {
return value != null ? value : other.get();
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElseGet(() -> {
Address address = new Address("湖南省","长沙市","岳麓区");
return new User("xiaohong",address);
});
System.out.println(user);
// 结果......
User(name=xiaohong, address=Address(province=湖南省, city=长沙市, area=岳麓区))

orElseGet()orElse()的区别是:当 T 不为 null 的时候,orElse() 依然执行 other 的部分代码,而 orElseGet() 不会,验证如下:


public class OptionalTest {

public static void main(String[] args) {
User user = new User("xiaoming");
User user1 = Optional.ofNullable(user).orElse(createUser());
System.out.println(user);

System.out.println("=========================");

User user2 = Optional.ofNullable(user).orElseGet(() -> createUser());
System.out.println(user2);
}

public static User createUser() {
System.out.println("执行了 createUser() 方法");
Address address = new Address("湖南省","长沙市","岳麓区");
return new User("xiaohong",address);
}
}

执行结果如下:



是不是 orElse() 执行了 createUser() ,而 orElseGet() 没有执行?一般而言,orElseGet()orElse() 会更加灵活些。



orElseThrow(Supplier exceptionSupplier)



orElseThrow() 用来获取 Optional 对象中的值,如果值为空,则通过 Supplier 提供的逻辑抛出异常。源码如下:


    public extends Throwable> T orElseThrow(Supplier exceptionSupplier) throws X {
if (value != null) {
return value;
} else {
throw exceptionSupplier.get();
}
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElseThrow(() -> new RuntimeException("用户不存在"));

类型转换


Optional 提供 map()flatMap() 用来进行类型转换。



map(Function mapper)



map() 允许我们对 Optional 对象中的值进行转换,并将结果包装在一个新的 Optional 对象中。该方法接受一个 Function 函数,该函数将当前 Optional 对象中的值映射成另一种类型的值,并返回一个新的 Optional 对应,这个新的 Optional 对象中的值就是映射后的值。如果当前 Optional 对象的值为空,则返回一个空的 Optional 对象,且 Function 不会执行,源码如下:


    public Optional map(Functionsuper T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Optional.ofNullable(mapper.apply(value));
}
}

比如我们要获取 User 对象中的 name,如下:


User user = new User("xiaolan");
String name = Optional.ofNullable(user).map(value -> value.getName()).get();
System.out.println(name);
// 结果......
xiaolan


Function> mapper



flatMap()map() 相似,不同之处在于 flatMap()的映射函数返回的是一个 Optional 对象而不是直接的值,它是将当前 Optional 对象映射为另外一个 Optional 对象。


    public<U> Optional<U> flatMap(Functionsuper T, Optional<U>> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Objects.requireNonNull(mapper.apply(value));
}
}

上面获取 name 的代码如下:


String name = Optional.ofNullable(user).flatMap(value -> Optional.ofNullable(value.getName())).get();

flatMap() 内部需要再次封装一个 Optional 对象,所以 flatMap() 通常用于在一系列操作中处理嵌套的Optional对象,以避免层层嵌套的情况,使代码更加清晰和简洁。


过滤


Optional 提供了 filter() 用于在 Optional 对象中的值满足特定条件时进行过滤操作,源码如下:


    public Optional filter(Predicatesuper T> predicate) {
Objects.requireNonNull(predicate);
if (!isPresent())
return this;
else
return predicate.test(value) ? this : empty();
}

filter() 接受 一个Predicate 来对 Optional 中包含的值进行过滤,如果满足条件,那么还是返回这个 Optional;否则返回 Optional.empty


实战应用


这里大明哥利用 Optional 的 API 举几个例子。



  • 示例一


Java 8 以前:


    public static String getUserCity(User user) {
if (user != null) {
Address address = user.getAddress();
if (address != null) {
return address.getCity();
}
}
return null;
}

常规点的,笨点的方法:


    public static String getUserCity(User user) {
Optional userOptional = Optional.of(user);
return Optional.of(userOptional.get().getAddress()).get().getCity();
}

高级一点的:


    public static String getUserCity(User user) {
return Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.orElseThrow(() -> new RuntimeException("值不存在"));
}

是不是比上面高级多了?



  • 示例二


比如我们要获取末尾为"ming"的用户的 city,不是的统一返回 "深圳市"。


Java 8 以前


    public static String getUserCity(User user) {
if (user != null && user.getName() != null) {
if (user.getName().endsWith("ming")) {
Address address = user.getAddress();
if (address != null) {
return address.getCity();
} else {
return "深圳市";
}
} else {
return "深圳市";
}
}

return "深圳市";
}

Java 8


    public static String getUserCity2(User user) {
return Optional.ofNullable(user)
.filter(u -> u.getName().endsWith("ming"))
.map(User::getAddress)
.map(Address::getCity)
.orElse("深圳市1");
}

这种写法确实是优雅了很多。其余的例子大明哥就不一一举例了,这个也没有其他技巧,唯手熟尔!!


Java 14 的 Helpful NullPointerExceptions


我们先看如下报错信息:


Exception in thread "main" java.lang.NullPointerException
at com.skjava.java.feature.Test.main(Test.java:6)

从这段报错信息中你能看出什么? Test.java 中的第 6 行产生了 NullPointerException。还能看出其他什么吗?如果这段报错的代码是这样的:


public class Test {
public static void main(String[] args) {
User user = new User();
System.out.println(user.getAddress().getProvince().length());
}
}

你知道是哪里报空指针吗? 是user.getAddress() 还是 user.getAddress().getProvince() ?看不出来吧?从这个报错信息中,我们确实很难搞清楚具体是谁导致的 NullPointerException


在 Java 14 之前,当发生 NullPointerException 时,错误信息通常很简单,仅仅只指出了出错的行号。这会导致我们在排查复杂表达式时显得比较困难,因为无法确定是表达式中的哪一部分导致了 NullPointerException,我们需要花费额外的时间进行调试,特别是在长链式调用或者包含多个可能为空的对象的情况下。


为了解决这个问题,Java 14 对 NullPointerException 的提示信息进行了改进,当发生 NullPointerException 时,异常信息会明确指出哪个具体的变量或者表达式部分是空的。例如,对于表达式 a.b().c().d(), 如果 b() 返回的对象是 null,异常信息将明确指出 b() 返回的对象为 null。例如上面的信息:


Exception in thread "main" java.lang.NullPointerException: Cannot invoke "*****.Address.getProvince()" because the return value of "*****.User.getAddress()" is null
at com.skjava.java.feature.Test.main(Test.java:6)

他会明确告诉你 User.getAddress() 返回的对象为 null


这样的提示信息将会让我们能够快速准确地定位导致 NullPointerException 的具体原因,无需逐步调试或猜测,有助于快速修复问题,减少维护时间和成本。


作者:大明哥_
来源:juejin.cn/post/7315080231627194387
收起阅读 »

值得使用Lambda的8个场景,别再排斥它了!

前言 可能对不少人来说,Lambda显得陌生又复杂,觉得Lambda会导致代码可读性下降,诟病Lambda语法,甚至排斥。 其实所有的这些问题,在尝试并熟悉后,可能都不是问题。 对Lambda持怀疑态度的人,也许可以采取渐进式使用Lambda的策略。在一些简单...
继续阅读 »

前言


可能对不少人来说,Lambda显得陌生又复杂,觉得Lambda会导致代码可读性下降,诟病Lambda语法,甚至排斥。


其实所有的这些问题,在尝试并熟悉后,可能都不是问题。


对Lambda持怀疑态度的人,也许可以采取渐进式使用Lambda的策略。在一些简单和低风险的场景下先尝试使用Lambda,逐渐增加Lambda表达式的使用频率和范围。


毕竟2023年了,JDK都出了那么多新版本,是时候试试Lambda了!


耐心看完,你一定有所收获。


giphy.gif


正文


1. 对集合进行遍历和筛选:


使用Lambda表达式结合Stream API可以在更少的代码量下实现集合的遍历和筛选,更加简洁和易读。


原来的写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
for (Integer num : numbers) {
if (num % 2 == 0) {
System.out.println(num);
}
}

优化的Lambda写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
.filter(num -> num % 2 == 0)
.forEach(System.out::println);

2. 对集合元素进行排序:


使用Lambda表达式可以将排序逻辑以更紧凑的形式传递给sort方法,使代码更加简洁。


原来的写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
Collections.sort(names, new Comparator<String>() {
public int compare(String name1, String name2) {
return name1.compareTo(name2);
}
});

优化的Lambda写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
names.sort((name1, name2) -> name1.compareTo(name2));

3. 集合的聚合操作:


Lambda表达式结合Stream API可以更优雅地实现对集合元素的聚合操作,例如求和、求平均值等。


原来的写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = 0;
for (Integer num : numbers) {
sum += num;
}

优化的Lambda写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, Integer::sum);

4. 条件过滤和默认值设置:


使用Lambda的Optional类可以更加优雅地处理条件过滤和默认值设置的逻辑。


原来的写法:


String name = "Alice";
if (name != null && name.length() > 0) {
System.out.println("Hello, " + name);
} else {
System.out.println("Hello, Stranger");
}

Lambda写法:


String name = "Alice";
name = Optional.ofNullable(name)
.filter(n -> n.length() > 0)
.orElse("Stranger");
System.out.println("Hello, " + name);

5. 简化匿名内部类:


可以简化代码,同时提高代码可读性。


举个创建Thread的例子,传统方式使用匿名内部类来实现线程,语法较为冗长,而Lambda表达式可以以更简洁的方式达到相同的效果。


原来的写法:


new Thread(new Runnable() {
public void run() {
System.out.println("Thread is running.");
}
}).start();

Lambda写法:


new Thread(() -> System.out.println("Thread is running.")).start();

new Thread(() -> {
// 做点什么
}).start();

这种写法也常用于简化回调函数,再举个例子:


假设我们有一个简单的接口叫做Calculator,它定义了一个单一的方法calculate(int a, int b)来执行数学运算:


// @FunctionalInterface: 标识接口是函数式接口,只包含一个抽象方法,从而能够使用Lambda表达式来实现接口的实例化
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
}

现在,让我们创建一个名为CallbackExample的类。该类有一个名为operate的方法,它接受两个整数和一个Calculator接口作为参数。该方法将使用提供的Calculator接口执行计算并返回结果:


public class CallbackExample {

public static int operate(int a, int b, Calculator calculator) {
return calculator.calculate(a, b);
}

public static void main(String[] args) {
int num1 = 10;
int num2 = 5;

// 使用Lambda作为回调
int sum = operate(num1, num2, (x, y) -> x + y);
int difference = operate(num1, num2, (x, y) -> x - y);
int product = operate(num1, num2, (x, y) -> x * y);
int division = operate(num1, num2, (x, y) -> x / y);

System.out.println("Sum: " + sum);
System.out.println("Difference: " + difference);
System.out.println("Product: " + product);
System.out.println("Division: " + division);
}
}

通过在方法调用中直接定义计算的行为,我们不再需要为每个运算创建多个实现Calculator接口的类,使得代码更加简洁和易读


giphy (1).gif


6. 集合元素的转换:


使用Lambda的map方法可以更优雅地对集合元素进行转换,提高代码的可读性


原来的写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> uppercaseNames = new ArrayList<>();
for (String name : names) {
uppercaseNames.add(name.toUpperCase());
}

Lambda写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> uppercaseNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());

7. 对集合进行分组和统计:


以更紧凑的形式传递分组和统计的逻辑,避免了繁琐的匿名内部类的声明和实现。


通过groupingBy、counting、summingInt等方法,使得代码更加流畅、直观且优雅。


传统写法:



List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Amy", "Diana");

// 对名字长度进行分组
Map<Integer, List<String>> namesByLength = new HashMap<>();
for (String name : names) {
int length = name.length();
if (!namesByLength.containsKey(length)) {
namesByLength.put(length, new ArrayList<>());
}
namesByLength.get(length).add(name);
}
System.out.println("Names grouped by length: " + namesByLength);

// 统计名字中包含字母'A'的个数
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Amy", "Diana");
int namesWithA = 0;
for (String name : names) {
if (name.contains("A")) {
namesWithA++;
}
}
System.out.println("Number of names containing 'A': " + namesWithA);

Lambda写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Amy", "Diana");

// 使用Lambda表达式对名字长度进行分组
Map<Integer, List<String>> namesByLength = names.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println("Names grouped by length: " + namesByLength);

// 使用Lambda表达式统计名字中包含字母'A'的个数
long namesWithA = names.stream()
.filter(name -> name.contains("A"))
.count();
System.out.println("Number of names containing 'A': " + namesWithA);

8. 对大数据量集合的并行处理


当集合的数据量很大时,通过Lambda结合Stream API可以方便地进行并行处理,充分利用多核处理器的优势,提高程序的执行效率。


假设我们有一个包含一百万个整数的列表,我们想要计算这些整数的平均值:


import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

public class ParallelStreamExample {
public static void main(String[] args) {
// 创建一个包含一百万个随机整数的列表
List<Integer> numbers = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
numbers.add(ThreadLocalRandom.current().nextInt(100));
}

// 顺序流的处理
long startTimeSeq = System.currentTimeMillis();
double averageSequential = numbers.stream()
.mapToInt(Integer::intValue)
.average()
.getAsDouble();
long endTimeSeq = System.currentTimeMillis();
System.out.println("Sequential Average: " + averageSequential);
System.out.println("Time taken (Sequential): " + (endTimeSeq - startTimeSeq) + "ms");

// 并行流的处理
long startTimePar = System.currentTimeMillis();
double averageParallel = numbers.parallelStream()
.mapToInt(Integer::intValue)
.average()
.getAsDouble();
long endTimePar = System.currentTimeMillis();
System.out.println("Parallel Average: " + averageParallel);
System.out.println("Time taken (Parallel): " + (endTimePar - startTimePar) + "ms");
}
}

分别使用顺序流和并行流来计算列表中整数的平均值:



  • 顺序流:通过stream()方法获取流,使用mapToInt将Integer转换为int,然后使用average()方法计算平均值

  • 并行流:使用parallelStream()方法获取并行流,其他步骤与顺序流相同


查看输出结果:


Sequential Average: 49.517461
Time taken (Sequential): 10ms
Parallel Average: 49.517461
Time taken (Parallel): 3ms

可以看出,顺序流和并行流得到了相同的平均值,但并行流的处理时间明显少于顺序流。因为并行流能够将任务拆分成多个小任务,并在多个处理器核心上同时执行这些任务。


当然并行流也有缺点:



  • 对于较小的数据集,可能并行流更慢

  • 数据处理本身的开销较大,比如复杂计算、大量IO操作、网络通信等,可能并行流更慢

  • 可能引发线程安全问题


收尾


Lambda的使用场景远不止这些,在多线程、文件操作等场景中也都能灵活运用,一旦熟悉后可以让代码更简洁,实现精准而优雅的编程。


写代码时,改变偏见需要我们勇于尝试和付诸行动。有时候,我们可能会对某种编程语言、框架或设计模式持有偏见,认为它们不适合或不好用。但是,只有尝试去了解和实践,我们才能真正知道它们的优点和缺点。


当我们愿意打破旧有的观念,敢于尝试新的技术和方法时,我们就有机会发现新的可能性和解决问题的新途径。不要害怕失败或犯错,因为每一次尝试都是我们成长和进步的机会。


只要我们保持开放的心态,不断学习和尝试,我们就能够超越偏见,创造出更优秀的代码和解决方案。


所以,让我们在编程的路上,积极地去挑战和改变偏见。用行动去证明,只有不断地尝试,我们才能取得更大的进步和成功。让我们敢于迈出第一步,勇往直前,一同创造出更美好的编程世界!


ab4cb34agy1g4sgjkrgxlj20j60ahgm2.jpg


作者:一只叫煤球的猫
来源:juejin.cn/post/7262737716852473914
收起阅读 »

永不生锈的螺丝钉!一款简洁好用的数据库表结构文档生成器

大家好,我是 Java陈序员。 在企业级开发中,我们经常会有编写数据库表结构文档的需求,常常需要手写维护文档,很是繁琐。 今天,给大家介绍一款数据库表结构文档生成工具。 关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机...
继续阅读 »

大家好,我是 Java陈序员


在企业级开发中,我们经常会有编写数据库表结构文档的需求,常常需要手写维护文档,很是繁琐。


今天,给大家介绍一款数据库表结构文档生成工具。



关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。



项目介绍


screw —— 螺丝钉(代表企业级开发中一颗永不生锈的螺丝钉),是一款简洁好用的数据库表结构文档生成工具。



screw 主打简洁、轻量,支持多种数据库、多种格式文档,可自定义模板进行灵活拓展。



  • 支持 MySQL、MariaDB、TIDB、Oracle 多种数据库




  • 支持生成 HTML、Word、MarkDown 三种格式的文档



快速上手


screw 普通方式Maven 插件的两种方式来生成文档。


普通方式


1、引入依赖


<!-- 引入数据库驱动,这里以 MySQL 为例 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>

<!-- 引入 screw -->
<dependency>
<groupId>cn.smallbun.screw</groupId>
<artifactId>screw-core</artifactId>
<version>1.0.5</version>
</dependency>

2、编写代码


public class DocumentGeneration {

/**
* 文档生成
*/

@Test
public void documentGeneration() {

// 文档生成路径
String fileOutputPath = "D:\\database";

// 数据源
HikariConfig hikariConfig = new HikariConfig();
// 指定数据库驱动
hikariConfig.setDriverClassName("com.mysql.cj.jdbc.Driver");
// 设置数据库连接地址
hikariConfig.setJdbcUrl("jdbc:mysql://localhost:3306/database");
// 设置数据库用户
hikariConfig.setUsername("root");
// 设置数据库密码
hikariConfig.setPassword("root");
// 设置可以获取 tables remarks 信息
hikariConfig.addDataSourceProperty("useInformationSchema", "true");
hikariConfig.setMinimumIdle(2);
hikariConfig.setMaximumPoolSize(5);

DataSource dataSource = new HikariDataSource(hikariConfig);
// 生成配置
EngineConfig engineConfig = EngineConfig.builder()
// 生成文件路径
.fileOutputDir(fileOutputPath)
// 打开目录
.openOutputDir(true)
// 文件类型 HTML、WORD、MD 三种类型
.fileType(EngineFileType.HTML)
// 生成模板实现
.produceType(EngineTemplateType.freemarker)
// 自定义文件名称
.fileName("Document")
.build();

// 忽略表
ArrayList<String> ignoreTableName = new ArrayList<>();
ignoreTableName.add("test_user");
ignoreTableName.add("test_group");

//忽略表前缀
ArrayList<String> ignorePrefix = new ArrayList<>();
ignorePrefix.add("test_");

//忽略表后缀
ArrayList<String> ignoreSuffix = new ArrayList<>();
ignoreSuffix.add("_test");

ProcessConfig processConfig = ProcessConfig.builder()
// 指定生成逻辑、当存在指定表、指定表前缀、指定表后缀时,将生成指定表,其余表不生成、并跳过忽略表配置
// 根据名称指定表生成
.designatedTableName(new ArrayList<>())
// 根据表前缀生成
.designatedTablePrefix(new ArrayList<>())
// 根据表后缀生成
.designatedTableSuffix(new ArrayList<>())
// 忽略表名
.ignoreTableName(ignoreTableName)
// 忽略表前缀
.ignoreTablePrefix(ignorePrefix)
// 忽略表后缀
.ignoreTableSuffix(ignoreSuffix)
.build();
//配置
Configuration config = Configuration.builder()
// 版本
.version("1.0.0")
// 描述
.description("数据库设计文档生成")
// 数据源
.dataSource(dataSource)
// 生成配置
.engineConfig(engineConfig)
// 生成配置
.produceConfig(processConfig)
.build();

//执行生成
new DocumentationExecute(config).execute();
}
}

3、执行代码输出文档



Maven 插件


1、引入依赖


<build>
<plugins>
<plugin>
<groupId>cn.smallbun.screw</groupId>
<artifactId>screw-maven-plugin</artifactId>
<version>1.0.5</version>
<dependencies>
<!-- HikariCP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.5</version>
</dependency>
<!--mysql driver-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version>
</dependency>
</dependencies>
<configuration>
<!-- 数据库用户名 -->
<username>root</username>
<!-- 数据库密码 -->
<password>password</password>
<!-- 数据库驱动 -->
<driverClassName>com.mysql.cj.jdbc.Driver</driverClassName>
<!-- 数据库连接地址 -->
<jdbcUrl>jdbc:mysql://127.0.0.1:3306/xxxx</jdbcUrl>
<!-- 生成的文件类型 HTML、WORD、MD 三种类型 -->
<fileType>HTML</fileType>
<!-- 打开文件输出目录 -->
<openOutputDir>false</openOutputDir>
<!-- 生成模板 -->
<produceType>freemarker</produceType>
<!-- 文档名称 为空时:将采用[数据库名称-描述-版本号]作为文档名称 -->
<fileName>数据库文档</fileName>
<!-- 描述 -->
<description>数据库文档生成</description>
<!-- 版本 -->
<version>${project.version}</version>
<!-- 标题 -->
<title>数据库文档</title>
</configuration>
<executions>
<execution>
<phase>compile</phase>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

2、执行插件



3、使用 Maven 插件执行的方式会将文档输出到项目根目录的 doc 目录下



文档截图


HTML 类型文档



Word 类型文档



MarkDown 类型文档



自从用了 screw 后,编写数据库文档信息就很方便了,一键生成,剩下的时间就可以用来摸鱼了~


大家如果下次有需要编写数据库文档,可以考虑使用 screw ,建议先把本文收藏起来,下次就不会找不到了~


最后,贴上项目地址:


https://github.com/pingfangushi/screw

最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/


大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!



作者:Java陈序员
来源:juejin.cn/post/7354922285093683252
收起阅读 »

技术总监写的十个方法,让我精通了lambda表达式

前公司的技术总监写了工具类,对Java Stream 进行二次封装,使用起来非常爽,全公司都在用。 我自己照着写了一遍,改了名字,分享给大家。 一共整理了10个工具方法,可以满足 Collection、List、Set、Map 之间各种类型转化。例如 将 C...
继续阅读 »

前公司的技术总监写了工具类,对Java Stream 进行二次封装,使用起来非常爽,全公司都在用。


我自己照着写了一遍,改了名字,分享给大家。


一共整理了10个工具方法,可以满足 Collection、List、Set、Map 之间各种类型转化。例如



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>

  3. List<OrderItem> 转化为 List<Long>

  4. Set<OrderItem> 转化为 Set<Long>

  5. Collection<OrderItem> 转化为 List<Long>

  6. Collection<OrderItem> 转化为 Set<Long>

  7. Collection<OrderItem>中提取 Key, Map 的 Value 就是类型 OrderItem

  8. Collection<OrderItem>中提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。

  9. Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  10. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }


Collection 集合类型到 Map类型的转化。


Collection 转化为 Map


由于 List 和 Set 是 Collection 类型的子类,所以只需要实现Collection 类型转化为 Map 类型即可。
Collection转化为 Map 共分两个方法



  1. Collection<OrderItem> Map<Key, OrderItem>,提取 Key, Map 的 Value 就是类型 OrderItem

  2. Collection<OrderItem>Map<Key,Value> ,提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。


使用样例


代码示例中把Set<OrderItem> 转化为 Map<Long, OrderItem>Map<Long ,Double>


@Test
public void testToMap() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, OrderItem> map = toMap(set, OrderItem::getOrderId);
}

@Test
public void testToMapV2() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, Double> map = toMap(set, OrderItem::getOrderId, OrderItem::getActPrice);
}

代码展示


public static <T, K> Map<K, T> toMap(Collection<T> collection, Function<? super T, ? extends K> keyMapper) {
return toMap(collection, keyMapper, Function.identity());
}

public static <T, K, V> Map<K, V> toMap(Collection<T> collection,
Function<? super T, ? extends K> keyFunction,
Function<? super T, ? extends V> valueFunction)
{
return toMap(collection, keyFunction, valueFunction, pickSecond());
}

public static <T, K, V> Map<K, V> toMap(Collection<T> collection,
Function<? super T, ? extends K> keyFunction,
Function<? super T, ? extends V> valueFunction,
BinaryOperator<V> mergeFunction)
{
if (CollectionUtils.isEmpty(collection)) {
return new HashMap<>(0);
}

return collection.stream().collect(Collectors.toMap(keyFunction, valueFunction, mergeFunction));
}

public static <T> BinaryOperator<T> pickFirst() {
return (k1, k2) -> k1;
}
public static <T> BinaryOperator<T> pickSecond() {
return (k1, k2) -> k2;
}

Map格式转换


转换 Map 的 Value



  1. 将 Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  2. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }。


测试样例


@Test
public void testConvertValue() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, OrderItem> map = toMap(set, OrderItem::getOrderId);

Map<Long, Double> orderId2Price = convertMapValue(map, item -> item.getActPrice());
Map<Long, String> orderId2Token = convertMapValue(map, (id, item) -> id + item.getName());

}

代码展示


public static <K, V, C> Map<K, C> convertMapValue(Map<K, V> map, 
BiFunction<K, V, C> valueFunction,
BinaryOperator<C> mergeFunction)
{
if (isEmpty(map)) {
return new HashMap<>();
}
return map.entrySet().stream().collect(Collectors.toMap(
e -> e.getKey(),
e -> valueFunction.apply(e.getKey(), e.getValue()),
mergeFunction
));
}

public static <K, V, C> Map<K, C> convertMapValue(Map<K, V> originMap, BiFunction<K, V, C> valueConverter) {
return convertMapValue(originMap, valueConverter, Lambdas.pickSecond());
}

public static <T> BinaryOperator<T> pickFirst() {
return (k1, k2) -> k1;
}
public static <T> BinaryOperator<T> pickSecond() {
return (k1, k2) -> k2;
}

集合类型转化


Collection 和 List、Set 的转化



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>


public static <T> List<T> toList(Collection<T> collection) {
if (collection == null) {
return new ArrayList<>();
}
if (collection instanceof List) {
return (List<T>) collection;
}
return collection.stream().collect(Collectors.toList());
}

public static <T> Set<T> toSet(Collection<T> collection) {
if (collection == null) {
return new HashSet<>();
}
if (collection instanceof Set) {
return (Set<T>) collection;
}
return collection.stream().collect(Collectors.toSet());
}

测试样例


@Test//将集合 Collection 转化为 List
public void testToList() {
Collection<OrderItem> collection = coll;
List<OrderItem> list = toList(coll);
}

@Test//将集合 Collection 转化为 Set
public void testToSet() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);
}

List和 Set 是 Collection 集合类型的子类,所以无需再转化。


List、Set 类型之间的转换


业务中有时候需要将 List<A> 转化为 List<B>。如何实现工具类呢?


public static <T, R> List<R> map(List<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toList());
}

public static <T, R> Set<R> map(Set<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toSet());
}

public static <T, R> List<R> mapToList(Collection<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toList());
}

public static <T, R> Set<R> mapToSet(Collection<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toSet());
}

测试样例



  1. List<OrderItem> 转化为 List<Long>

  2. Set<OrderItem> 转化为 Set<Long>

  3. Collection<OrderItem> 转化为 List<Long>

  4. Collection<OrderItem> 转化为 Set<Long>


@Test
public void testMapToList() {
Collection<OrderItem> collection = coll;
List<OrderItem> list = toList(coll);

List<Long> orderIdList = map(list, (item) -> item.getOrderId());
}

@Test
public void testMapToSet() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(coll);

Set<Long> orderIdSet = map(set, (item) -> item.getOrderId());
}

@Test
public void testMapToList2() {
Collection<OrderItem> collection = coll;

List<Long> orderIdList = mapToList(collection, (item) -> item.getOrderId());
}

@Test
public void testMapToSetV2() {
Collection<OrderItem> collection = coll;

Set<Long> orderIdSet = mapToSet(collection, (item) -> item.getOrderId());

}

总结一下 以上样例包含了如下的映射场景



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>

  3. List<OrderItem> 转化为 List<Long>

  4. Set<OrderItem> 转化为 Set<Long>

  5. Collection<OrderItem> 转化为 List<Long>

  6. Collection<OrderItem> 转化为 Set<Long>

  7. Collection<OrderItem>中提取 Key, Map 的 Value 就是类型 OrderItem

  8. Collection<OrderItem>中提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。

  9. Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  10. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }


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

Java程序员快速提高代码质量建议

1、概述 相同的业务需求不同层级的程序员实现方式不一样,经验稍微欠缺一点的新手程序员,可能单纯的实现功能,经验丰富的程序员,开发的代码可能会具有很好的扩展性、易读性、健壮性。相信很多小伙伴在工作团队中,有时候会一起code review,互相review代码,...
继续阅读 »
1、概述

相同的业务需求不同层级的程序员实现方式不一样,经验稍微欠缺一点的新手程序员,可能单纯的实现功能,经验丰富的程序员,开发的代码可能会具有很好的扩展性、易读性、健壮性。相信很多小伙伴在工作团队中,有时候会一起code review,互相review代码,其实review代码时大家保持开放包容心态,是一种团队进度的方式。
今天分享的内容主要帮助大家从代码规范的角度,梳理出快速提升代码质量的建议,学完之后可以帮助大家在团队code review时,提供建议,帮大家写出高质量代码。


2、什么样的代码是高质量代码

如何评价一段代码的好与坏,其实是有一定主观性的,不同人有不同的标准和看法,但是总的概括下来优秀的代码一般具有如下特点:


高质量代码特点.png


3、如何提高代码质量

这里主要代码规范角度,小伙伴们可以快速理解掌握,并快速使用。


3.1 代码命名

项目名、模块名、包名、类名、接口名、变量名、参数名等,都会涉及命名,良好的代码命名是程序员的基本素养,对代码可读性非常重要。



  • 命名原则
    1、Java采用驼峰命名,代码命名要使用通俗易懂的词汇,不要采用生僻单词;
    2、团队内部或者项目中风格要统一,例如查询类方法,要么都使用findByXXX方式,或者queryByXXX、getByXXX等,不要几种混用,风格保持一致;
    3、命名长度:个人建议有时候为了易于理解,可以将命名适当长一些,例如:如下方法,一看就知道是上传照片到阿里云服务器,


public void uploadPhotoImageToAliyun(String userPhotoImageUri){}

可以利用上下文语义简化变量命名长度,如下用户实体类变量命名可以简化,更简洁


public class User {
private String userName;
private String userPassword;
private String userGender;
}

public class User {
private String name;
private String password;
private String gender;
}

4、抽象类通常带有Abstract前缀,接口命名和实现类命名,通常类似这样RoleService,实现类跟一个Impl,如RoleServiceImpl



  • 注释
    1、良好的代码注释对于可读性很重要,虽然有小伙伴可能会觉得好的命名可以替代注释;
    2、个人觉得注释很重要,注释可以起到代码分隔作用,代码块总结作用,文档作用;
    3、部分程序设计核心关键点,可以通过注释帮助其他研发人员理解;
    4、注释是否越多越好呢,然而并不是这样,太多注释反而让人迷惑,增加维护成本,代码变动之后也需要对注释进行修改。


3.2 代码风格

良好的代码风格,可以提升代码可读性,主要梳理以下几点:


良好的代码风格.png


3.3 实用代码技巧


  • 将代码分隔成多个单元
    代码逻辑太长不易阅读,将代码分隔成多个小的方法单元,更好理解和复用,如下所示,用户注册接口,包含账号、手机号校验及用户保存操作


public Long registerUser(String Account, String mobile, String password){
// 校验账号是否重复
if(StringUtils.isNotBlank(Account)){
User user = userService.getUserByName(Account);
AssertUtils.isNull(user, "用户账号已存在,不能重复");
}
// 校验手机号是否重复
if(StringUtils.isNotBlank(mobile)){
User user = userService.getUserByMobile(mobile);
AssertUtils.isNull(user, "手机号已存在,不能重复");
}
// 保存用户到DB
return userService.insert(Account, mobile, password);
}

重构之后的代码如下:


public Long registerUser(String Account, String mobile, String password){
// 校验账号是否重复
checkAccountIsExists(Account);
// 校验手机号是否重复
checkMobileIsExists(mobile);
// 保存用户到DB
return userService.insert(Account, mobile, password);
}

private void checkAccountIsExists(String Account){
if(StringUtils.isNotBlank(Account)){
User user = userService.getUserByName(Account);
AssertUtils.isNull(user, "用户账号已存在,不能重复");
}
}
private void checkMobileIsExists(String mobile){
if(StringUtils.isNotBlank(mobile)){
User user = userService.getUserByMobile(mobile);
AssertUtils.isNull(user, "手机号已存在,不能重复");
}
}



  • 避免方法太多参数
    方法太多参数影响代码可读性,当方法参数太多时可以采取将方法抽取为几个私有方法,如下所示:


public User getUser(String username, String telephone, String email);

// 拆分成多个函数
public User getUserByUsername(String username);
public User getUserByTelephone(String telephone);
public User getUserByEmail(String email);

也可以将参数封装为对象,通过抽取为对象对于C端项目还能更好兼容,如果是对外暴露的接口,可以避免新老接口兼容问题


public User getUser(String username, String telephone, String email);

// 重构后将方法入参封装为对象
public class SearchUserRequest{
private String username;
private String telephone;
private String email;
}
public User getUser(SearchUserRequest searchUserReq重构后将方法入参封装为对象


  • 不要使用参数null及boolean来判断
    使用参数非空和为空作为代码的if、else分支,以及boolean参数作为代码分支,这些都不建议,如果可以尽量拆分为多个细小的私有方法;当然也不是绝对的,实际情况具体分析;

  • ** 方法设计遵守单一职责**
    方法设计不要追求大而全,尽量做到职责单一,粒度细,更易理解和复用,如下所示:


public boolean checkUserIfExisting(String telephone, String username, String email)  { 
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}

if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}

if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}

return false;
}

// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);


  • 避免嵌套逻辑太深
    避免if else太多的方法,可以使用卫语句,将满足条件的结果提前返回,或者使用枚举、策略模式、switch case等;
    对于for循环太深嵌套,可以使用continue、break、return等提前结束循环,或者优化代码逻辑。

  • 使用解释性变量
    尽量不要使用魔法值,要使用常量来管理,代码中复杂的判断逻辑可以使用解释性变量,如下所示:


public double CalculateCircularArea(double radius) {
return (3.1415) * radius * radius;
}

// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
return PI * radius * radius;
}

if (date.after(SPRING_START) && date.before(SPRING_END)) {
// ...
} else {
// ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSpring = date.after(SPRING_START)&&date.before(SPRING_END);
if (isSpring) {
// ...
} else {
// ...
}



作者:美丽的程序人生
来源:juejin.cn/post/7352079427863920651
收起阅读 »

身份认证的尽头竟然是无密码 ?

概述 几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临...
继续阅读 »

概述


几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。


HTTP 认证


HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。


HTTP 认证的对话框


基本认证


常见的叫法是 HTTP Basic,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:


GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==

虽然这种方式简单,但并不安全,因为 base64 编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。


摘要认证


主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:


GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"

**补充:**另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回 401 Unauthorized 状态码,示例:


HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"

这一规范目前应用在所有的身份认证流程中,并且沿用至今。


Web 认证


表单认证


虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:



  1. 前端通过表单收集用户的账号和密码

  2. 通过协商的方式发送服务端进行验证的方式。


常见的表单认证页面通常如下:


html>
<html>
<head>
    <title>Login Pagetitle>
head>
<body>
    <h2>Login Formh2>
    <form action="/perform_login" method="post">
        <div class="container">
            <label for="username"><b>Usernameb>label>
            <input type="text" placeholder="Enter Username" name="username" required>
            
            <label for="password"><b>Passwordb>label>
            <input type="password" placeholder="Enter Password" name="password" required>
            
            <button type="submit">Loginbutton>
        div>
    form>
body>
html>

为什么表单认证会成为主流 ?主要有以下几点原因:



  • 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。

  • 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。

  • 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。


表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。


WebAuthn


WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。


webauthn registration


相比于传统的密码,WebAuthn 具有以下优势:



  1. 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。

  2. 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。

  3. 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。


总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。


实现效果


当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:


WebAuthn login


实现原理


WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:


webauthn 交互时序图


登录流程大致可以分为以下步骤:



  1. 用户访问登录页面,填入用户名后即可点击登录按钮。

  2. 服务器返回随机字符串 Challenge、用户 UserID。

  3. 浏览器将 Challenge 和 UserID 转发给验证器。

  4. 验证器提示用户进行认证操作。

  5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。


WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;



备注:你可以通过访问 webauthn.me 了解到更多消息的信息



文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:



作者:小二十七
来源:juejin.cn/post/7354632375446061083
收起阅读 »

经历定时任务事故,我学到了什么?一个案例的全面回顾

前情提要最近离职在家休息,手里的资金又比较有限,水费,电费,燃气费都比较头疼,有时候电费欠费断电了才去交,然后要等5-10分钟才重新送电,再加上家里有电压保护器,就更久了,水费,燃气亦是如此。事发突然对于我这种一般不会一次性充很多或者每月固定缴费的人来说,我没...
继续阅读 »

前情提要

最近离职在家休息,手里的资金又比较有限,水费,电费,燃气费都比较头疼,有时候电费欠费断电了才去交,然后要等5-10分钟才重新送电,再加上家里有电压保护器,就更久了,水费,燃气亦是如此。

854f1e58ly1hi20we1vr6j20u00u0wik.jpg

事发突然

对于我这种一般不会一次性充很多或者每月固定缴费的人来说,我没办法做到按时固定查看,可以说我有点懒。于是就想起家里有台服务器,只挂了一个NAS服务在上面,感觉到有点浪费,于是就看到宝塔面板上有定时任务管理器,前期用的感觉还不错,但是!!问题出现了,我有一次出远门直接拉闸,结果回家之后合闸听见服务器风扇狂转......

src=http __safe-img.xhscdn.com_bw1_3bc10c30-ee6d-4a7d-9acd-f3501b24c694 imageView2_2_w_1080_format_jpg&refer=http __safe-img.xhscdn.webp

于是我立刻打开电脑去看宝塔面板,首先是要我登录账号,我就有点汗流浃背了。登录之后立刻点到定时任务面板里去看,结果全没了,我以为是宝塔没了,但是思索片刻之后发现,宝塔面板的定时任务是设置到Linux的crontab命令中的。接着我抱着试试看的心态登录SSH查询了一下,确实有那么几条不认识的(看着完全不像我的)定时任务在控制台。

我想:既然有记录,那不是能正常执行? 果然猜的没错,可以运行,直到我想调整定时周期,给我整暴躁了。但有人可能就说:“你为什么不直接用命令控制台呢?”,“你为什么要用图形化界面?”,“你Linux命令都不熟,怎么做开发的?”诸如此类,可是我用图形化的东西不就是图个方便么?

思考

为什么宝塔面板的定时任务查不到?

设计缺陷?容错设计?我并不清楚

为什么SSH查询的定时任务我一个都不认识?

宝塔做了一次唯一编码转换

在Linux中的定时任务是怎么保存的?

我在宝塔面板的www目录下找到了一个cron的文件夹,并发现了成对出现的定时文件,名称和SSH界面查询出来的一模一样,用文本编辑器打开,果不其然,就是我设置的定时脚本内容

既然在特定目录下,为何宝塔不识别?

我尝试添加新的定时任务,cron文件夹中又出现了新的文件。猜测是宝塔的数据和文件是分开的,就意味着不是根据动态扫描配置来实现,而是单独储存数据映射


我想到一件事,既然Linux有crontab,那Windows是不是也有类似的东西可以支持?

确实是这样

微软提供了一个图形化操作界面来管理定时任务:

图片.png

图片.png

但是,这里又有一个问题回归本质。

我现在既需要定时任务功能帮我定时查询水电燃气费,但我又得省电,用过Win的都非常清楚,一旦超过24H不关机或重启,系统就会出点小毛病,就像安卓,但我服务器又是Linux,所以我得找个解决办法......

于是,我想到了另一个问题,既然crontab系统提供的这么方便,为什么软件开发不用?(脑子抽了) 因为:集成度不高且不方便定制

图片.png

解决之路

于是我就开始看定时任务框架,想到了之前面试经常提到的Quartz框架。

马上就下载源码看了起来。

看了一圈发现,Quartz框架使用了多线程技术来实现任务调度。

又回归到多线程,好好好!

图片.png

那就顺带狠狠的让我康康!

以下是Quartz框架的一些核心组成部分及其实现原理:

  1. Scheduler(调度器) :负责整个定时任务系统的调度工作。内部通过线程池来进行任务的执行和调度管理。
  2. Trigger(触发器) :定义了调度任务的时间规则,决定何时触发任务执行。Quartz支持多种类型的触发器,如SimpleTrigger、CronTrigger等。
  3. Job(任务) :实际执行的工作单元,通常实现了特定的接口以定义任务内容。
  4. JobDetail(任务详情) :保存了Job的实例和相关的配置信息。
  5. 线程池:Quartz使用线程池来管理和执行任务,这样可以有效地复用线程资源,提高系统性能。
  6. 数据存储:Quartz允许将Trigger和Job的相关信息存储在数据库中,以实现任务的持久化,确保即使在系统宕机后,任务也能恢复执行。
  7. 集群支持:Quartz还支持集群环境下的任务调度,能够在多个节点之间协调任务的执行。
  8. 容错机制:Quartz框架提供了一些容错机制,比如在任务执行过程中发生异常时,可以记录日志并尝试重新执行任务。
  9. 负载均衡:在集群环境中,Quartz可以通过一定的策略进行负载均衡,确保任务在各个节点上均匀分配。

综上所述,Quartz框架通过这些组件和机制,提供了一个强大而灵活的任务调度平台,广泛应用于需要定时或周期性执行任务的Java应用程序中。

好嘛,这里问题又来了,多线程。如果我的定时任务体量足够大,或者说我就是喜欢玩变态的,纯靠定时任务执行逻辑,是不是又遇到了面试的经典场景?

图片.png

那么,来回顾一下吧!

多线程应用在CPU占用中通常通过抢占时间片来执行任务的。

在多线程环境中,CPU的时间被分割成许多小的时间片,每个线程轮流使用这些时间片来执行任务。这种机制称为时间片轮转(Time Slice Scheduling) 。以下是多线程执行的一些关键点:

  1. 线程状态:线程可以处于就绪状态、运行状态或阻塞状态。在就绪状态下,线程准备好执行并等待CPU时间片。一旦抢到时间片,线程就会进入运行状态。
  2. 抢占式多任务:为了防止线程独占CPU,操作系统采用抢占式多任务策略,允许其他线程公平地分享CPU执行时间。这意味着即使一个线程仍在运行,CPU也可能强制中断它,让其他线程执行。
  3. 线程优先级:线程的优先级影响它们抢占时间片的概率。高优先级的线程更有可能被调度执行,但这并不意味着低优先级的线程永远不会执行。
  4. 多核CPU:在多核CPU的情况下,单进程的多线程可以并发执行,而多进程的线程也可以并行执行。每个核心上的线程按照时间片轮转,但一个线程在同一时间只能运行在一个核心上。

综上所述,多线程应用确实依赖于时间片轮转机制来实现多任务并行处理,这是现代操作系统中实现多线程并发执行的基础。通过这种方式,操作系统能够有效地管理多个线程,确保CPU资源的合理分配和充分利用。

线程过多会引发什么问题呢?

线程过多确实可能导致操作系统性能的下降。当系统中存在大量线程时,可能会引发以下问题:

  • 上下文切换开销增大:操作系统需要更频繁地在线程之间切换,这种上下文切换会消耗CPU时间,降低整体的CPU利用率。
  • 内存占用增加:每个线程都有自己的栈空间,大量的线程意味着需要更多的内存来存储这些栈空间,这可能导致内存资源紧张,甚至出现内存不足的情况。
  • 垃圾回收压力增大:在Java等环境中,过多的线程会增加垃圾回收器的工作压力,进一步影响程序性能。
  • 系统稳定性降低:过多的线程竞争CPU资源时可能产生其他性能开销,严重时可能导致系统不稳定,甚至出现OutOfMemoryError异常。

为了解决这些问题,可以采取以下措施:

  • 使用线程池:线程池可以有效地管理线程资源,避免频繁创建和销毁线程的开销,同时可以控制线程数量和任务队列,提高系统性能和可靠性。
  • 合理配置线程数:根据系统的硬件配置和应用需求,合理设置线程池的核心线程数和最大线程数,以达到最优的系统吞吐量和响应时间。
  • 动态调整参数:根据实际情况动态调节线程池的参数,确保线程池处于合适的状态,避免任务堆积导致死锁或长时间停滞。

综上所述,虽然多线程可以提高程序的并发性能,但是线程数量过多确实会给操作系统带来额外的负担,可能导致性能下降。因此,合理配置和管理线程是提高系统性能的关键。

所以Quartz用的就是线程池,那线程池怎么玩?

这道题的核心就是:任务密集型和CPU密集型分别如何设置线程池

图片.png

先写一个解,解代表人的自信

解: 针对CPU密集型任务,线程池的设置应侧重于核心数匹配;而针对任务密集型(通常指IO密集型),线程池可配置更多的线程以利用IO等待时间。具体设置如下:

  1. CPU密集型任务
  • 线程数量:一般建议将核心线程数 (corePoolSize) 和最大线程数 (maximumPoolSize) 设置为与CPU的核心数相等。这样可以避免过多的上下文切换,因为CPU密集型任务会持续占用CPU资源进行计算。
  • 存活时间:对于CPU密集型任务,线程的存活时间不需要设置太长,因为线程通常会一直忙碌。
  1. 任务密集型(IO密集型)任务
  • 线程数量:可以设置为核心数的两倍,即如果机器有N个CPU,那么线程数可以设置为2N。这是因为在执行IO操作时,线程会经常处于等待状态,此时可以处理其他任务,所以增加线程数可以更充分地利用CPU资源。
  • 存活时间:对于IO密集型任务,可以根据实际情况适当增加线程的存活时间,以保证在需要时能够快速响应。

此外,如果任务既包含计算工作又包含IO工作,可以考虑使用两个线程池分别处理不同类型的任务,以避免相互干扰。 综上所述,合理设置线程池参数可以帮助系统高效运行,减少资源争用和性能瓶颈。

是不是一下就清晰明了,面试题也不用死记硬背了?

那回归到上面说的,我是一个变态,我就是喜欢用定时任务去执行所有逻辑,就是喜欢定时任务多到离谱,那么这个时候因为任务多到离谱,所以任务执行会有时间差,但我又要精准执行怎么办?

答:买个线程撕裂者(笑)

哥们要是那么有钱,我为什么不直接挂Win,然后再多搞几台电脑?

解决方案

手搓一个定时任务执行系统+文件系统 MySQL5+SpringBoot2.x+Quartz+Linux

后续如果大家也有这需求,我看情况开源给大家用

引申思考

在实际生产中,由于都是分布式的架构,那么Quartz自然就慢慢的没办法满足需求了。

甚至有些系统需要专门为定时服务准备一台专用服务器

为了解决这一问题,众多定时框架应运而生,例如:XXL-job

相比之下他们之间有什么差异呢?

QuartzXXL-job
优点支持集群部署,能够实现高可用性和负载均衡。 是Java生态中广泛使用的定时任务标准,社区活跃,文档齐全。 可以通过数据库实现作业的高可用性。提供了可视化的管理界面,便于任务的监控和管理。 支持集群部署,且维护成本低,提供错误预警功能。 支持分片、故障转移等分布式场景下的关键特性。 相对Quartz来说,上手更容易,适用于分布式环境。
缺点缺少自带的管理界面,对用户而言不够直观便捷。 调度逻辑和执行任务耦合在一起,维护时需要重启服务,影响系统的连续性。 相对于其他分布式调度框架,如elastic-job,缺少分布式并行调度的功能。需要单独部署调度中心,相对于Quartz来说,增加了部署的复杂性。

不过在现代几乎都是容器开发的方式,部署的复杂程度已经没有那么高了。

结尾

至此

祝各位工作顺利,钱多事少离家近!!!

祝各位jy们清明安康!!!

图片.png


作者:小白858
来源:juejin.cn/post/7353208973879853106

收起阅读 »

JWT:你真的了解它吗?

       大家好,我是石头~        在数字化时代,网络安全和用户隐私保护成为了我们无法忽视的关键议题,也是我...
继续阅读 »

       大家好,我是石头~


       在数字化时代,网络安全和用户隐私保护成为了我们无法忽视的关键议题,也是我们作为一个后端开发的必修课。


       而在这个领域中,JWT(JSON Web Token)作为一种现代、安全且高效的会话管理机制,在各类Web服务及API接口中得到了广泛应用。


       那么,什么是JWT?


下载 (3).jfif


1、初识JWT


       JWT,全称为JSON Web Token,是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输信息。


       它本质上是一个经过数字签名的JSON对象,能够携带并传递状态信息(如用户身份验证、授权等)。


       了解了JWT之后,那么它的组成结构又是怎样的?


2、JWT的结构


u=2288314449,1048843062&fm=253&fmt=auto&app=138&f=JPEG.webp


       如上图,JWT由三部分组成,通过点号(.)连接,这三部分分别是头部(Header)、载荷(Payload)和签名(Signature)。



  • 头部(Header):声明了JWT的类型(通常是JWT)以及所使用的加密算法(例如HMAC SHA256或RSA)

  • 载荷(Payload):承载实际数据的部分,可以包含预定义的声明(如iss(签发者)、exp(过期时间)、sub(主题)等)以及其它自定义的数据。这些信息都是铭文的,但不建议存放敏感信息。

  • 签名(Signature):通过对前两部分进行编码后的信息,使用指定的密钥通过头部(Header)中声明的加密算法生成,拥有验证数据完整性和防止篡改。


3、JWT的常规认证流程


2020040121153580.png


       JWT的认证流程如上图。当用户登录时,服务器通过验证用户名和密码后,会生成一个JWT,并将其发送给客户端。这个JWT中可能包含用户的认证信息、权限信息以及其它必要的业务数据。


       客户端在接收到JWT后,通常将其保存在本地(如Cookie、LocalStorage或者SessionStorage)。


       客户端在后续的请求中,携带此JWT(通常是附在HTTP请求头中),无需再次提交用户名和密码。服务器只需对收到的JWT进行解码并验证签名,即可完成用户身份的确认和权限验证。


4、JWT的完整认证流程


       在上面的JWT常规认证流程中,我们可以正常完成登陆、鉴权等认证,但是你会发现在这个流程中,我们无法实现退出登陆。


       当服务端将JWT发放给客户端后,服务端就失去了对JWT的控制权,只能等待这些发放出去的JWT超过有效期,自然失效。


       为了解决这个问题,我们引入了缓存,如下图。


2020040121022176.png


       当服务端生成JWT之后,在返回给客户端之前,先将JWT存入缓存中。要鉴权的时候,需要检验缓存中是否存在这个JWT。


       这样的话,如果用户退出登陆,我们只需要将缓存中的JWT删除,即可保证发放出去的JWT无法再通过鉴权。


5、JWT的优势与挑战


       JWT的主要优点在于无状态性,服务器无需存储会话状态,减轻了服务器压力,同时提高了系统的可扩展性和性能。


       此外,由于JWT的有效期限制,增强了安全性。


       然而,JWT也面临一些挑战,比如密钥的安全保管、JWT过期策略的设计以及如何处理丢失或被盗用的情况。


       因此,在实际应用中,需要综合考虑业务场景和技术特性来合理运用JWT。


6、JWT示例


       概念讲完了,我们最后来看个实例吧。


// Java代码示例
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

// 假设有一个User类,其中包含用户ID
public class User {
private String id;
// 其他属性和方法...
}

// 创建JWT
public String generateJWT(User user) {
// 设置秘钥(在此处使用的是HMAC SHA-256算法)
String secret = "your-secret-key"; // 在实际场景中应当从安全的地方获取秘钥
long ttlMillis = 60 * 60 * 1000; // JWT的有效期为1小时

// 构建载荷,包含用户ID和其他相关信息
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("exp", System.currentTimeMillis() + ttlMillis); // 设置过期时间

// 生成JWT
    String jwt = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8))
.compact();
    // TODO JWT写入缓存
    return jwt;
}

// 验证JWT
public boolean validateJWT(String jwtToken, String secretKey) {
boolean flag = false;
try {
Jwts.parser().setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)).parseClaimsJws(jwtToken);
// 如果没有抛出异常,则JWT验证通过
flag = true;
} catch (ExpiredJwtException e) {
// 如果Token过期
System.out.println("JWT已过期");
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
// 其他非法情况,如格式错误、签名无效等
System.out.println("JWT验证失败:" + e.getMessage());
}
if (flag) {
        // TODO 校验缓存中是否有该JWT
}
return false;
}

// 使用示例
public static void main(String[] args) {
User user = new User("123"); // 假设这是合法用户的ID
String token = generateJWT(user); // 生成JWT
System.out.println("生成的JWT Token: " + token);

// 验证生成的JWT
boolean isValid = validateJWT(token, "your-secret-key");
if (isValid) {
System.out.println("JWT验证通过!");
} else {
System.out.println("JWT验证未通过!");
}
}



作者:石头聊技术
来源:juejin.cn/post/7354308608044072996
收起阅读 »

脱敏工具?整个全局的吧

咱又手痒造轮子啦!Hutool工具包有这个一个类DesensitizedUtil实现了一些常见业务数据类型的脱敏,像手机号,中文名,身-份-证号,银彳亍卡号等。那咱就基于它写一个全局切面,需要脱敏的用注解标识,思路有了说干就干。 咱先定义一个切入点注解@Dat...
继续阅读 »

咱又手痒造轮子啦!Hutool工具包有这个一个类DesensitizedUtil实现了一些常见业务数据类型的脱敏,像手机号,中文名,身-份-证号,银彳亍卡号等。那咱就基于它写一个全局切面,需要脱敏的用注解标识,思路有了说干就干。


咱先定义一个切入点注解@DataDesensitized


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

然后咱再定义一个注解标识字段脱敏@Desensitized


@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Desensitized {
//加上hutool类中的脱敏类型枚举选择脱敏策略
DesensitizedUtil.DesensitizedType type();
}

最后写切面类


@Aspect
@Component
@Slf4j
public class DataDesensitizedAspect {
@AfterReturning(pointcut = "@annotation(dd)", returning = "result")
public void doAfterReturning(JoinPoint joinPoint, DataDesensitized dd, Object result) {
//TODO 这里可以根据组织架构角色控制是否脱敏
boolean need = true;
if (!need) {
return;
}
//方法响应一般有分页实体,list集合,单实体对象,那就分类处理
if (result instanceof PageInfo) {
PageInfo page = (PageInfo) result;
List records = page.getList();
for (Object record : records) {
objReplace(record);
}
} else if (result instanceof List) {
List list = (List) result;
for (Object obj : list) {
objReplace(obj);
}
} else {
objReplace(result);
}
}

public static <T> void objReplace(T t) {
try {
Field[] declaredFields = ReflectUtil.getFields(t.getClass());
for (Field field : declaredFields) {
Desensitized des = field.getAnnotation(Desensitized.class);
//被脱敏注解修饰且string类型
if (des != null &&
"class java.lang.String".equals(field.getGenericType().toString())) {
Object fieldValue = ReflectUtil.getFieldValue(t, field);
if (fieldValue == null || StringUtils.isEmpty(fieldValue.toString())) {
continue;
}
DesensitizedUtil.DesensitizedType type = des.type();
String hide = DesensitizedUtil.desensitized(fieldValue.toString(),type);
ReflectUtil.setFieldValue(t, field, hide);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

在业务方法上标识切入点注解


@Override
@DataProtection
public PageInfo<OrderDetailsVo> queryOrderDetails(QueryParam param) {
return mapper.queryOrderDetails(param);
}

vo实体中需要脱敏的字段加@Desensitized


@Data
public class OrderDetailsVo {
private String orderNo;
private String sn;

@Desensitized(type = DesensitizedUtil.DesensitizedType.CHINESE_NAME)
private String username;

@Desensitized(type = DesensitizedUtil.DesensitizedType.MOBILE_PHONE)
private String mobile;

@Desensitized(type = DesensitizedUtil.DesensitizedType.ID_CARD)
private String idCard;
}

完成!


次日,产品经理要求这个20位的sn字符串从第五位脱敏到第十八位,hutool工具没有这个类型的枚举!成!咱再把轮子改造一下


自己写一个脱敏策略枚举DesensitizedType,对比hutool只加了CUSTOM自定义脱敏类型


public enum DesensitizedType {
//自定义脱敏标识
CUSTOM,
//用户id
USER_ID,
//中文名
CHINESE_NAME,
//身-份-证号
ID_CARD,
//座机号
FIXED_PHONE,
//手机号
MOBILE_PHONE,
//地址
ADDRESS,
//电子邮件
EMAIL,
//密码
PASSWORD,
//中国大陆车牌,包含普通车辆、新能源车辆
CAR_LICENSE,
//银彳亍卡
BANK_CARD
}

@Desensitized改造


@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Desensitized {
//替换成自己定义的枚举
DesensitizedType type() default DesensitizedType.CUSTOM;

/**
* 当type不指定时,可自定义脱敏起始位置(包含)
*/

int startInclude() default 0;

/**
* 当type不指定时,可自定义脱敏结束位置(不包含) ,-1代表字符串长度
*/

int endExclude() default -1;
}

切面类改造


public static <T> void objReplace(T t) {
try {
Field[] declaredFields = ReflectUtil.getFields(t.getClass());
for (Field field : declaredFields) {
Desensitized des = field.getAnnotation(Desensitized.class);
//被脱敏注解修饰且string类型
if (des != null &&
"class java.lang.String".equals(field.getGenericType().toString())) {
Object fieldValue = ReflectUtil.getFieldValue(t, field);
if (fieldValue == null || StringUtils.isEmpty(fieldValue.toString())) {
continue;
}
String value = fieldValue.toString();
String hide = "";
if (des.type() == DesensitizedType.CUSTOM) {
int startInclude = des.startInclude();
int endExclude = des.endExclude();
if (endExclude == -1) {
endExclude = value.length();
}
hide = StrUtil.hide(value, startInclude, endExclude);
} else {
DesensitizedUtil.DesensitizedType type =
DesensitizedUtil.DesensitizedType.valueOf(des.type().toString());
hide = DesensitizedUtil.desensitized(value, type);
}
ReflectUtil.setFieldValue(t, field, hide);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}

实体标识脱敏字段


@Data
public class OrderDetailsVo {
private String orderNo;

@Desensitized(startInclude = 5,endExclude = 18)
private String sn;

@Desensitized(type = DesensitizedType.CHINESE_NAME)
private String username;

@Desensitized(type = DesensitizedType.MOBILE_PHONE)
private String mobile;

@Desensitized(type = DesensitizedType.ID_CARD)
private String idCard;
}

这下可以开开心心把轮子给小伙伴用啦开心😘😘😘


作者:开大会汪汪队
来源:juejin.cn/post/7348830480789962787
收起阅读 »

高并发下单加锁吗?

一个简单的下单流程包括,商品校验,订单计价,扣库存,保存订单。其中扣库存的并发问题是整个流程中最麻烦,最复杂的环节,可以说聚集了所有的智慧和头发。 解决扣库存并发问题,很容易让人想到加锁,加锁的目的是为了限制同步代码块并发,进一步的保证原子性,可见性和重排序...
继续阅读 »

一个简单的下单流程包括,商品校验,订单计价,扣库存,保存订单。其中扣库存的并发问题是整个流程中最麻烦,最复杂的环节,可以说聚集了所有的智慧和头发。
image.png


解决扣库存并发问题,很容易让人想到加锁,加锁的目的是为了限制同步代码块并发,进一步的保证原子性,可见性和重排序,实现数据一致性。


单机加 jvm 锁,分布式加分布式锁。这让我不禁想起分布式系统一句黑话,分布式系统中,没有什么问题是不能通过增加中间环节解决的,但解决一个问题常常会带来另外的问题,是的,你没听错,以空间换时间,以并发换数据一致性,在这里,锁粒度和范围对并发影响是最直接的,设计的时候尽可能的缩小锁粒度和范围,一般粒度是 skuId,范围尽量减小。


锁时长,锁过期是另外两个不得不考虑的问题。最麻烦的锁过期,常用解决方案是依赖 redission 的看门狗机制,相当于定时任务给锁续命,但粗暴续命会增加 rt,同时增加其他请求的阻塞时长。


尽量避免牺牲并发的方案!

尽量避免牺牲并发的方案!

尽量避免牺牲并发的方案!


一次偶然的机会,我的同事向我推荐了 Google 的 chubby。为什么我们不能用悲观锁+乐观锁的组合呢?在锁过期的时候,乐观锁兜底,不影响请求 rt,也能保证数据一致性。这是个不错的方案,适合简单的场景!


一次偶然的机会,一条公式冲击我的大脑,redis = 高性能 + 原子性。机智的你肯定知道加锁就是为了保证原子性,基于 redis 实现分布式锁也是因为 redis 的原子性和高性能(想想什么情况用 mysql 和 zk),如果我用 redis 代替锁,是不是既能保证扣库存的原子性,同时因为没有锁,又不需要考虑加锁带来的问题。


说干就干,马上画个图。(图片被掘金压缩,有点糊,我上传到图床,点击能跳转到图床看清晰的,如果看不清楚图片,联系我,给我留言
pCGSEE6.png
我把订单流程分为5大块,有点复杂,且听我细细道来。


Order process:



扣库存是限制订单并发的瓶颈,依靠 redis 的原子性保证数据一致性,高性能提升并发



2pc


基于二阶段提交思想,第一步首先插入 INIT 状态的订单


冷热路由


第二步有个路由,冷门商品走 mysql 下单,热门商品并发大,依靠 redis 撑。


如何知道商品冷热,答案是 bitMap,所以我们还需要一个定时任务(job4)维护 bitMap。冷热数据的统计来源一般是购物车,埋点统计。大电商平台来源更丰富,大数据追踪,算法推荐等。


故障处理


lua 扣减库存时,需要考虑请求超时和 redis 宕机。请求超时比较好解决,可以 catch 超时异常,依据业务选择重试或返回。redis 宕机比较棘手,后面分析。


降级

这里说一下降级。redis 宕机之后,走冷门订单流程。但是这里的设计会变的很复杂,因为需要解决两个问题,如何断定 redis 宕机,走冷门路由会不会把 mysql 压垮?这两个问题继续谈论下去会衍生出更多,比如走冷门路由的时机,冷门路由会不会把 mysql 压垮等,所以这里触发熔断还需要马上开启限流,展开真的很复杂,下次有机会说。


扣库存后续动作突然变得顺畅,插入订单库存流水,修改订单状态 UNPAY,发送核销优惠券 mq,日志记录等。这几个步骤中,



  • 流水用于记录订单和库存的绑定,重建商品库存缓存会用到

  • 核销优惠券选择异步,发核销优惠券的 mq,需要考虑消息丢失和重复消费,上游订单服务记录本地消息表,同时有个定时任务(job1)扫描重发,下游做好幂等

  • 我们还需要关注该流程可能会出现 jvm 宕机,这是很严重的事故,按理说没有顺利走完订单流程的订单属于异常订单,异常订单的库存需要返还 redis,所以还需要一个定时任务处理异常订单。


JOB2



redis 没有库存流水,被扣库存 x 无法得知



订单流程有几处宕机需要考虑,一处是执行 lua 脚本时 redis 宕机,另一处是扣完库存之后,jvm 宕机。无论是 redis 还是 jvm 宕机,这些订单都会返回异常信息到前端,所以这些订单的是无效的,需要还库存到 redis。


mysql 和 redis 的流水描述同一件事情,即记录该笔订单所扣库存。在异常情况下,可能只有 redis 有流水,依然可以作为断定库存已经扣减的依据,在极端异常的情况,lua 脚本刚扣完库存,redis 进程死了或者宕机,虽然 lua 是原子性的,但宕机可不是原子性,库存 x 已经扣了,没有流水记录,无法知道 x (redis 的单点问题可以通过 redis ha 保证)。


如果 redis 恢复了,但数据没了,怎么办?

如果 redis 恢复了,但数据丢失了(库存变化还没持久化就宕机,redis 重启恢复的是旧数据),怎么办?


Rebuild stock cache of sku



剩余库存 = (库存服务的总库存减去预占库存) - (mysql 和 redis 流水去重,计算的库存)



把目光锁定到右下角,重建 sku 库存缓存的逻辑。一般地,在 redis 扣完库存,会发个 mq 消息到库存服务,持久化该库存变动。库存服务采用 a/b 库存的设计,分别记录商品总库存和预占库存,为的是解决高并发场景业务操作库存和用户下单操作库存时的锁冲突问题。库存服务里的库存是延迟的,订单服务没发的消息和库存服务没消费的消息造成延迟。


我们既然把库存缓存到 redis,不妨想一下如何准确计算库存的数量。



  • 在刚开始启动服务的时候,redis 没有数据,这时候库存 t = a - b(a/b库存)

  • 服务运行一段时间,redis 有库存 t, 此时 t = a - b - (库存服务还没消费的扣库存消息),所以拿 mysql 和 redis 的流水去重,计算出已扣未消费库存。redis 宕机后,会有一个未知已扣库存 x, x 几乎没有算出来的可能(鄙人尽力了),也没必要算出来,你想,当你 redis 异常了,库存 x 对应的订单是异常订单,异常订单不会返回给用户,用户只会收到下单异常的返回,所以库存 x 是无效的,丢掉就好。


Payment process


用户支付之后,才发扣库存消息到库存服务落地。落地库存服务的流程很简单,不再阐述。重点说说新增库存和减少库存。新增库存不会造成超卖,简单粗暴的加就好。减少库存相当于下单,需要小心超卖问题,所以现在 redis 扣了库存,再执行本地事务,简简单单,凄凄惨惨戚戚,乍暖还寒时候,最难将息,三杯两盏淡酒,咋敌...


多说两句


纵观整幅图,对比简单下单流程,可以发现,为了解决高并发下单,引入一个中间环节,而引入中间环节的副作用需要我们处理。虽然订单流程变复杂了,但并发提高了。一般来说,redis qps 10万,实际上没有10万,如果你的业务 qps 超过单机 redis 限制,记住,分布式的核心思想就是拆,把库存均匀打散到多台 redis。


打散之后需要解决库存倾斜问题,可能实例 a 已经卖完了,实例 b 还有部分库存,但部分用户请求打到实例 a,就会造成明明有货,但下单失败。这个问题也很棘手,感兴趣的同学可以自行研究,学会教教我。


上述流程经过简化,真实情况会更复杂,不一定适合实际场景。如果有错误的地方,烦请留言讨论,多多交流,互相学习,一起进步。


还有个问题需要提,流程中的事务问题。可以发现,订单流程是没有事务控制的。一方面我们认为,数据很宝贵,不分正常异常。异常的订单数据可作为分析系统架构缺陷的依据,另一方面接口尽量避免长事务,特别是高并发下,事务越短越好。


回答几个问题


为什么感觉拿掉分布式锁之后,流程变得很复杂?


其实我大可给订单流程前后包裹一个分布式锁,新的设计就变成下图,可以看到,核心库存扣减逻辑并没有变化,所以分布式锁的存在并不是让流程变复杂的原因。


image.png


为什么流程突然变的很复杂?



  • 为保证数据一致性,加了几个定时任务和一个重建缓存接口;为提高性能,加了冷热路由;为减少复杂度,把库存扣减消息延迟到支付,总体流程比简单下单流程多了几道工序

  • 因为引入异构数据库,数据源由一变多,就需要维护数据源数据一致性。可以说,这些流程纯纯是为了保证多个数据源的数据一致性。如果以前我们在 mysql 做库存扣减,基于 mysql 事务就能保证数据一致性。但是 mysql 的 qps 并不高,他适合并发不高的情况,所以我才会让冷门商品走 mysql 下单流程,因为冷门商品几乎没有并发

  • 所以流程变得复杂的原因是维护数据一致性


总结


场景一:并发较低,MySQL可承受


如果业务量不大,且并发只有几十或百来个,那么 MySQL 可以胜任。为了保证数据一致性,需要在外层套上分布式锁。同时,在使用 MySQL 时需要注意锁粒度和锁区间。此外,避免订单请求把 MySQL 连接数打满,影响其他业务,可以考虑使用 Sentinel 进行限流。


场景二:并发量大,MySQL存在瓶颈


当营销变得复杂时,不仅仅是普通的订单流程,还有秒杀、限时特价和热销推广等复杂场景,此类业务的并发集中在特定的 SKU 上。在这种情况下,接口并发可能没有太大问题,因为分布式锁有限流的作用。但对于用户而言,大量购买失败就会带来严重后果。此类场景的瓶颈在于 MySQL,在理论上,将库存打散到其他 MySQL 实例可以解决问题,但我们不会这样做,因为 MySQL 是有状态的,所以更推荐的做法是基于 Redis 扣库存。


场景三:商品数量过亿


如果有幸业务发展到亿级商品数量,此时如果将所有商品的库存都存储在 Redis,可能会带来非常大的内存开销。一般来说,库存的结构为 {SKU ID: 数量},每个 SKU 只需要占用两个 int(8个字节)的空间,因此在性能方面没有大问题。根据二八原则,非热销商品大约占80%,这些商品可能很久都没人买,把库存存到 redis 实属浪费 700多 m。基于分布式的拆分思想,以热度维度分流商品库存,热门商品库存存储到 Redis,冷门的商品库存存储到 MySQL


此外,可以参照 redis cluster,修改路由算法将商品库存分配到不同的 Redis 实例。不过从实际来说,当你商品过亿,也不差钱搭个 redis cluster。如果你细想,冷热路由相当于把库存分散到多个实例,这会带来一些问题,比如用户购买多件商品的库存跨了多个实例,如果确定扣库存顺序,如何解决库存不足的资损,还有库存的逆向流程等,这些问题展开很复杂,有机会讨论


最后,对于库存的消息落库问题,如果上游订单很多,而下游的库存服务处理速度较慢,可能会出现消息堆积现象。针对这种情况,可以采用生产者-消费者模型,通过合并数据并批量提交的方式来加快落库速度。这种优化方式可以有效地避免消息堆积现象,提高系统的性能和稳定性


作者:勤奋的Raido
来源:juejin.cn/post/7245753944181817403
收起阅读 »

领导问我:为什么一个点赞功能你做了五天?

领导希望做一个给文章点赞的功能,在文章列表页与文章详情页需要看到该文章的点赞总数,以及当前登录的用户有没有对该文章点赞,即用户与文章的点赞关系 前言 可乐是一名前端切图仔,最近他们团队需要做一个文章社区平台。由于人手不够,前后端部分都是由前端同学来实现,后端...
继续阅读 »

领导希望做一个给文章点赞的功能,在文章列表页与文章详情页需要看到该文章的点赞总数,以及当前登录的用户有没有对该文章点赞,即用户与文章的点赞关系



前言


可乐是一名前端切图仔,最近他们团队需要做一个文章社区平台。由于人手不够,前后端部分都是由前端同学来实现,后端部分用的技术栈是 nest.js


某一个周一,领导希望做一个给文章点赞的功能,在文章列表页与文章详情页需要看到该文章的点赞总数,以及当前登录的用户有没有对该文章点赞,即用户与文章的点赞关系。


交代完之后,领导就去出差了。等领导回来时已是周五,他问可乐:这期的需求进展如何?


可乐回答:点赞的需求我做完了,其他的还没开始。


领导生气的说:为什么点赞这样的一个小功能你做了五天才做完???


可乐回答:领导息怒。。请听我细细道来


往期文章


仓库地址



初步设计


对于上面的这个需求,我们提炼出来有三点最为重要的功能:



  1. 获取点赞总数

  2. 获取用户的点赞关系

  3. 点赞/取消点赞


所以这里容易想到的是在文章表中冗余一个点赞数量字段 likes ,查询文章的时候一起把点赞总数带出来。


idcontentlikes
1文章A10
2文章B20

然后建一张 article_lile_relation 表,建立文章点赞与用户之间的关联关系。


idarticle_iduser_idvalue
1100120011
2100120020

上面的数据就表明了 id2001 的用户点赞了 id1001 的文章; id2002 的用户对 id1001 的文章取消了点赞。


这是对于这种关联关系需求最容易想到的、也是成本不高的解决方案,但在仔细思考了一番之后,我放弃了这种方案。原因如下:



  1. 由于首页文章流中也需要展示用户的点赞关系,这里获取点赞关系需要根据当前文章 id 、用户 id 去联表查询,会增加数据库的查询压力。

  2. 有关于点赞的信息存放在两张表中,需要维护两张表的数据一致性。

  3. 后续可能会出现对摸鱼帖子点赞、对用户点赞、对评论点赞等需求,这样的设计方案显然拓展性不强,后续再做别的点赞需求时可能会出现大量的重复代码。


基于上面的考虑,准备设计一个通用的点赞模块,以拓展后续各种业务的点赞需求。


表设计


首先来一张通用的点赞表, DDL 语句如下:


CREATE TABLE `like_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`target_id` int(11) DEFAULT NULL,
`type` int(4) DEFAULT NULL,
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`value` int(4) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `like_records_target_id_IDX` (`target_id`,`user_id`,`type`) USING BTREE,
KEY `like_records_user_id_IDX` (`user_id`,`target_id`,`type`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

解释一下上面各个字段的含义:



  • id :点赞记录的主键 id

  • user_id :点赞用户的 id

  • target_id :被点赞的文章 id

  • type :点赞类型:可能有文章、帖子、评论等

  • value :是否点赞, 1 点赞, 0 取消点赞

  • created_time :创建时间

  • updated_time :更新时间


前置知识


在设计好数据表之后,再来捋清楚这个业务的一些特定属性与具体实现方式:



  1. 我们可以理解这是一个相对来说读比写多的需求,比如你看了 10 篇掘金的文章,可能只会对 1 篇文章点赞

  2. 应该设计一个通用的点赞模块,以供后续各种点赞需求的接入

  3. 点赞数量与点赞关系需要频繁地获取,所以需要读缓存而不是读数据库

  4. 写入数据库与同步缓存需考虑数据一致性


所以可乐针对这样的业务特性上网查找了一些资料,发现有一些前置知识是他所欠缺的,我们一起来看看。


mysql事务


mysql 的事务是指一系列的数据库操作,这些操作要么全部成功执行,要么全部失败回滚。事务是用来确保数据库的完整性、一致性和持久性的机制之一。


mysql 中,事务具有以下四个特性,通常缩写为 ACID



  1. 原子性: 事务是原子性的,这意味着事务中的所有操作要么全部成功执行,要么全部失败回滚。

  2. 一致性: 事务执行后,数据库从一个一致的状态转换到另一个一致的状态。这意味着事务执行后,数据库中的数据必须满足所有的约束、触发器和规则,保持数据的完整性。

  3. 隔离性: 隔离性指的是多个事务之间的相互独立性。即使有多个事务同时对数据库进行操作,它们之间也不会相互影响,每个事务都感觉到自己在独立地操作数据库。 mysql 通过不同的隔离级别(如读未提交、读已提交、可重复读和串行化)来控制事务之间的隔离程度。

  4. 持久性: 持久性指的是一旦事务被提交,对数据库的改变将永久保存,即使系统崩溃也不会丢失。 mysql 通过将事务的提交写入日志文件来保证持久性,以便在系统崩溃后能够恢复数据。


这里以商品下单创建订单并扣除库存为例,演示一下 nest+typeorm 中的事务如何使用:


import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { Order } from './order.entity';
import { Product } from './product.entity';

@Injectable()
export class OrderService {
constructor(
@InjectEntityManager()
private readonly entityManager: EntityManager,
) {}

async createOrderAndDeductStock(productId: number, quantity: number): Promise<Order> {
return await this.entityManager.transaction(async transactionalEntityManager => {
// 查找产品并检查库存是否充足
const product = await transactionalEntityManager.findOne(Product, productId);
if (!product || product.stock < quantity) {
throw new Error('Product not found or insufficient stock');
}

// 创建订单
const order = new Order();
order.productId = productId;
order.quantity = quantity;
await transactionalEntityManager.save(order);

// 扣除库存
product.stock -= quantity;
await transactionalEntityManager.save(product);

return order;
});
}
}


this.entityManager.transaction 创建了一个事务,在异步函数中,如果发生错误, typeorm 会自动回滚事务;如果没有发生错误,typeorm 会自动提交事务。


在这个实例中,尝试获取库存并创建订单和减库存,如果任何一个地方出错异常抛出,则事务就会回滚,这样就保证了多表间数据的一致性。


分布式锁



分布式锁是一种用于在分布式系统中协调多个节点并保护共享资源的机制。在分布式系统中,由于涉及多个节点并发访问共享资源,因此需要一种机制来确保在任何给定时间只有一个节点能够访问或修改共享资源,以防止数据不一致或竞争条件的发生。



对于同一个用户对同一篇文章频繁的点赞/取消点赞请求,可以加分布式锁的机制,来规避一些问题:



  1. 防止竞态条件: 点赞/取消点赞操作涉及到查询数据库、更新数据库和更新缓存等多个步骤,如果不加锁,可能会导致竞态条件,造成数据不一致或错误的结果。

  2. 保证操作的原子性: 使用分布式锁可以确保点赞/取消点赞操作的原子性,即在同一时间同一用户只有一个请求能够执行操作,从而避免操作被中断或不完整的情况发生。

  3. 控制并发访问: 加锁可以有效地控制并发访问,限制了频繁点击发送请求的数量,从而减少系统负载和提高系统稳定性。


redis 中实现分布式锁通常使用的是基于 SETNX 命令和 EXPIRE 命令的方式:



  1. 使用 SETNX 命令尝试将 lockKey 设置为 lockValue ,如果 lockKey 不存在,则设置成功并返回 1;如果 lockKey 已经存在,则设置失败并返回 0

  2. 如果 SETNX 成功,说明当前客户端获得了锁,可以执行相应的操作;如果 SETNX 失败,则说明锁已经被其他客户端占用,当前客户端需要等待一段时间后重新尝试获取锁。

  3. 为了避免锁被永久占用,可以使用 EXPIRE 命令为锁设置一个过期时间,确保即使获取锁的客户端在执行操作时发生故障,锁也会在一定时间后自动释放。


  async getLock(key: string) {
const res = await this.redis.setnx(key, 'lock');
if (res) {
// 10秒锁过期
await this.redis.expire(key, 10);
}
return res;
}

async unLock(key: string) {
return this.del(key);
}

redis中的set结构


redis 中的 set 是一种无序集合,用于存储多个不重复的字符串值,set 中的每个成员都是唯一的。


我们存储点赞关系的时候,需要用到 redis 中的 set 结构,存储的 keyvalue 如下:


article_1001:[uid1,uid2,uid3]


这就表示文章 id1001 的文章,有用户 iduid1uid2uid3 这三个用户点赞了。


常用的 set 结构操作命令包括:



  • SADD key member [member ...]: 将一个或多个成员加入到集合中。

  • SMEMBERS key: 返回集合中的所有成员。

  • SISMEMBER key member: 检查成员是否是集合的成员。

  • SCARD key: 返回集合元素的数量。

  • SREM key member [member ...]: 移除集合中一个或多个成员。

  • SPOP key [count]: 随机移除并返回集合中的一个或多个元素。

  • SRANDMEMBER key [count]: 随机返回集合中的一个或多个元素,不会从集合中移除元素。

  • SUNION key [key ...]: 返回给定所有集合的并集。

  • SINTER key [key ...]: 返回给定所有集合的交集。

  • SDIFF key [key ...]: 返回给定所有集合的差集。


下面举几个点赞场景的例子



  1. 当用户 iduid1 给文章 id1001 的文章点赞时:sadd 1001 uid1

  2. 当用户 iduid1 给文章 id1001 的文章取消点赞时:srem 1001 uid1

  3. 当需要获取文章 id1001 的点赞数量时:scard 1001


redis事务


redis 中,事务是一组命令的有序序列,这些命令在执行时会被当做一个单独的操作来执行。即事务中的所有命令要么全部执行成功,要么全部执行失败,不存在部分执行的情况。


以下是 redis 事务的主要命令:



  1. MULTI: 开启事务,在执行 MULTI 命令后,后续输入多个命令来组成一个事务。

  2. EXEC: 执行事务,在执行 EXEC 命令时,redis 会执行客户端输入的所有事务命令,如果事务中的所有命令都执行成功,则事务执行成功,返回事务中所有命令的执行结果;如果事务中的某个命令执行失败,则事务执行失败,返回空。

  3. DISCARD: 取消事务,在执行 DISCARD 命令时,redis 会取消当前事务中的所有命令,事务中的命令不会被执行。

  4. WATCH: 监视键,在执行 WATCH 命令时,redis 会监听一个或多个键,如果在执行事务期间任何被监视的键被修改,事务将会被打断。


比如说下面的代码给集合增加元素,并更新集合的过期时间,可以如下使用 redis 的事务去执行它:


  const pipeline = this.redisService.multi();
const setKey = this.getSetKey(targetId, type);
if (value === ELike.LIKE) {
pipeline.sadd(setKey, userId);
} else {
pipeline.srem(setKey, userId);
}
pipeline.expire(setKey, this.ttl);
await pipeline.exec();

流程图设计


在了解完这些前置知识之后,可乐开始画一些实现的流程图。


首先是点赞/取消点赞接口的流程图:


image.png


简单解释下上面的流程图:



  1. 先尝试获取锁,获取不到的时候等待重试,保证接口与数据的时序一致。

  2. 判断这个点赞关系是否已存在,比如说用户对这篇文章已经点过赞,其实又来了一个对此篇文章点赞的请求,直接返回失败

  3. 开启 mysql 的事务,去更新点赞信息表,同时尝试去更新缓存,在缓存更新的过程中,会有3次的失败重试机会,如果缓存更新都失败,则回滚mysql事务;整体更新失败

  4. mysql 更新成功,缓存也更新成功,则整个操作都成功


然后是获取点赞数量和点赞关系的接口


image.png


简单解释下上面的流程图:



  1. 首先判断当前文章 id 对应的点赞关系是否在 redis 中存在,如果存在,则直接从缓存中读取并返回

  2. 如果不存在,此时加锁,准备读取数据库并更新 redis ,这里加锁的主要目的是防止大量的请求一下子打到数据库中。

  3. 由于加锁的时候,可能很多接口已经在等待,所以在锁释放的时候,再加多一次从 redis 中获取的操作,此时 redis 中已经有值,可以直接从缓存中读取。


代码实现


在所有的设计完毕之后,可以做最后的代码实现了。分别来实现点赞操作与点赞数量接口。这里主要关注 service 层的实现即可。


点赞/取消点赞接口


  async toggleLike(params: {
userId: number;
targetId: number;
type: ELikeType;
value: ELike;
}
) {
const { userId, targetId, type, value } = params;
const LOCK_KEY = `${userId}::${targetId}::${type}::toggleLikeLock`;
const canGetLock = await this.redisService.getLock(LOCK_KEY);
if (!canGetLock) {
console.log('获取锁失败');
await wait();
return this.toggleLike(params);
}
const record = await this.likeRepository.findOne({
where: { userId, targetId, type },
});
if (record && record.value === value) {
await this.redisService.unLock(LOCK_KEY);
throw Error('不可重复操作');
}

await this.entityManager.transaction(async (transactionalEntityManager) => {
if (!record) {
const likeEntity = new LikeEntity();
likeEntity.targetId = targetId;
likeEntity.type = type;
likeEntity.userId = userId;
likeEntity.value = value;
await transactionalEntityManager.save(likeEntity);
} else {
const id = record.id;
await transactionalEntityManager.update(LikeEntity, { id }, { value });
}
const isSuccess = await this.tryToFreshCache(params);

if (!isSuccess) {
await this.redisService.unLock(LOCK_KEY);
throw Error('操作失败');
}
});
await this.redisService.unLock(LOCK_KEY);
return true;
}

private async tryToFreshCache(
params: {
userId: number;
targetId: number;
type: ELikeType;
value: ELike;
},
retry = 3,
) {
if (retry === 0) {
return false;
}
const { targetId, type, value, userId } = params;
try {
const pipeline = this.redisService.multi();
const setKey = this.getSetKey(targetId, type);
if (value === ELike.LIKE) {
pipeline.sadd(setKey, userId);
} else {
pipeline.srem(setKey, userId);
}
pipeline.expire(setKey, this.ttl);
await pipeline.exec();
return true;
} catch (error) {
console.log('tryToFreshCache error', error);
await wait();
return this.tryToFreshCache(params, retry - 1);
}
}


可以参照流程图来看这部分实现代码,基本实现就是使用 mysql 事务去更新点赞信息表,然后去更新 redis 中的点赞信息,如果更新失败则回滚事务,保证数据的一致性。


获取点赞数量、点赞关系接口


  async getLikes(params: {
targetId: number;
type: ELikeType;
userId: number;
}
) {
const { targetId, type, userId } = params;
const setKey = this.getSetKey(targetId, type);
const cacheExsit = await this.redisService.exist(setKey);
if (!cacheExsit) {
await this.getLikeFromDbAndSetCache(params);
}
const count = await this.redisService.getSetLength(setKey);
const isLike = await this.redisService.isMemberOfSet(setKey, userId);
return { count, isLike };
}

private async getLikeFromDbAndSetCache(params: {
targetId: number;
type: ELikeType;
userId: number;
}
) {
const { targetId, type, userId } = params;
const LOCK_KEY = `${targetId}::${type}::getLikesLock`;
const canGetLock = await this.redisService.getLock(LOCK_KEY);
if (!canGetLock) {
console.log('获取锁失败');
await wait();
return this.getLikeFromDbAndSetCache(params);
}
const setKey = this.getSetKey(targetId, type);
const cacheExsit = await this.redisService.exist(setKey);
if (cacheExsit) {
await this.redisService.unLock(LOCK_KEY);
return true;
}
const data = await this.likeRepository.find({
where: {
targetId,
userId,
type,
value: ELike.LIKE,
},
select: ['userId'],
});
if (data.length !== 0) {
await this.redisService.setAdd(
setKey,
data.map((item) => item.userId),
this.ttl,
);
}
await this.redisService.unLock(LOCK_KEY);
return true;
}

由于读操作相当频繁,所以这里应当多使用缓存,少查询数据库。读点赞信息时,先查 redis 中有没有,如果没有,则从 mysql 同步到 redis 中,同步的过程中也使用到了分布式锁,防止一开始没缓存时请求大量打到 mysql


同时,如果所有文章的点赞信息都同时存在 redis 中,那 redis 的存储压力会比较大,所以这里会给相关的 key 设置一个过期时间。当用户重新操作点赞时,会更新这个过期时间。保障缓存的数据都是相对热点的数据。


通过组装数据,获取点赞信息的返回数据结构如下:


image.png


返回一个 map ,其中 key 文章 idvalue 里面是该文章的点赞数量以及当前用户是否点赞了这篇文章。


前端实现


文章流列表发生变化的时候,可以监听列表的变化,然后去获取点赞的信息:


useEffect(() => {
if (!article.list) {
return;
}
const shouldGetLikeIds = article.list
.filter((item: any) => !item.likeInfo)
.map((item: any) => item.id);
if (shouldGetLikeIds.length === 0) {
return;
}
console.log("shouldGetLikeIds", shouldGetLikeIds);
getLikes({
targetIds: shouldGetLikeIds,
type: 1,
}).then((res) => {
const map = res.data;
const newList = [...article.list];
for (let i = 0; i < newList.length; i++) {
if (!newList[i].likeInfo && map[newList[i].id]) {
newList[i].likeInfo = map[newList[i].id];
}
}
const newArticle = { ...article };
newArticle.list = newList;
setArticle(newArticle);
});
}, [article]);

image.png


点赞操作的时候前端也需要加锁,接口执行完毕了再把锁释放。


   <Space
onClick={(e) => {
e.stopPropagation();
if (lockMap.current?.[item.id]) {
return;
}
lockMap.current[item.id] = true;
const oldValue = item.likeInfo.isLike;
const newValue = !oldValue;
const updateValue = (value: any) => {
const newArticle = { ...article };
const newList = [...newArticle.list];
const current = newList.find(
(_) => _.id === item.id
);
current.likeInfo.isLike = value;
if (value) {
current.likeInfo.count++;
} else {
current.likeInfo.count--;
}
setArticle(newArticle);
};
updateValue(newValue);
toggleLike({
targetId: item.id,
value: Number(newValue),
type: 1,
})
.catch(() => {
updateValue(oldValue);
})
.finally(() => {
lockMap.current[item.id] = false;
});
}}
>
<LikeOutlined
style={
item.likeInfo.isLike ? { color: "#1677ff" } : {}
}
/>

{item.likeInfo.count}
Space>

Kapture 2024-03-23 at 22.49.08.gif


解释


可乐:从需求分析考虑、然后研究网上的方案并学习前置知识,再是一些环境的安装,最后才是前后端代码的实现,领导,我这花了五天不过份吧。


领导(十分无语):我们平台本来就没几个用户、没几篇文章,本来就是一张关联表就能解决的问题,你又搞什么分布式锁又搞什么缓存,还花了那么多天时间。我不管啊,剩下没做的需求你得正常把它正常做完上线,今天周五,周末你也别休息了,过来加班吧。


最后


以上就是本文的全部内容,如果你觉得有意思的话,点点关注点点赞吧~


作者:可乐鸡翅kele
来源:juejin.cn/post/7349437605858066443
收起阅读 »

分支管理:master,release,hotfix,sit,dev等等,听着都麻烦。

背景 从一开始的svn到后来接触到git,到目前已经和git打交道比较多了,突然觉得可以把项目中所用到一些分支的管理方式分享出来,希望帮助到大家。 分支介绍 现在使用git一般很少是单个分支来使用,通常是多个分支来进行,接下来我以我最新的项目中所用到的分支,来...
继续阅读 »

背景


从一开始的svn到后来接触到git,到目前已经和git打交道比较多了,突然觉得可以把项目中所用到一些分支的管理方式分享出来,希望帮助到大家。


分支介绍


现在使用git一般很少是单个分支来使用,通常是多个分支来进行,接下来我以我最新的项目中所用到的分支,来做一些介绍。


master



  • master分支代码只能被release分支分支合并,且合并动作只能由特定管理员进行此操作。

  • master分支是保护分支,开发人员不可直接push到远程仓库的master分支


release



  • 命名规则:release/*,“*”一般是标识项目、第几期、日期等

  • 该分支是保护分支,开发人员不可直接push,一般选定某个人进行整体的把控和push

  • 该分支是生产投产分支

  • 该分支每次基于master分支拉取


dev



  • 这个是作为开发分支,大家都可以基于此分支进行开发

  • 这个分支的代码要求是本地启动没问题,不影响其他人的代码


hotfix



  • 这个分支一般是作为紧急修复分支,当前release发布后发现问题后需要该分支

  • 该分支一般从当前release分支拉取

  • 该分支开发完后需要合并到release分支以及dev分支


feat



  • 该分支一般是一个长期的功能需要持续开发或调整使用

  • 该分支基于release创建或者基于稳定的dev创建也可以

  • 一般开发完后需要合并到dev分支


分支使用


以上是简单介绍了几个分支,接下来我针对以上分支,梳理一些场景,方便大家理解。


首先从master创建一个release分支作为本次投产的分支,然后再从master拉取一个dev分支方便大家开发,dev分支我命名为:dev/soe,然后我就在这个分支上进行开发,其他人也是这样。


然后当我开发完某个任务后,又有一个任务,但是呢,这个任务需要做,只是是否要上这次的投产待定,所以为了不影响到大家的开发,我就不能在dev分支进行开发了,此时我基于目前已经稳定了的dev分支创建了一个feat分支,叫做:feat/sonar,主要是用来修复一些扫描的问题,在此期间,如果我又接到了开发的任务,仍然可以切换到dev来开发,并不影响。


当开发工作完成后,并且也基于dev分支进行了测试,感觉没问题之后,我就会把dev分支的代码合并到release上。


当release投产之后,如果业务验证过也没有问题,那么就可以由专人把release合并到master了,如果发现了问题,那么此时就需要基于release创建一个hotfix分支,开发人员在此分支进行问题的修复,修复完成并测试后,合并到release分支和sit分支。然后再使用release分支进行投产。


总结


以上就是我在项目中,对分支的使用,我觉得关于分支使用看团队以及项目的需要,不必要定死去如何如何,如果有的项目不规定必须要release投产,那么hotfix就不必使用,直接release修改完合并也未尝不可,所以大家在项目中是如何使用的呢?可以评论区一起讨论分享。


致谢


感谢你的耐心阅读,如果我的分享对你有所启发或帮助,就给个赞呗,很开心能帮到别人。


作者:bramble
来源:juejin.cn/post/7352075703859150899
收起阅读 »

面试官:线程调用2次start会怎样?我支支吾吾没答上来

写在开头 刚刚吃晚饭时,突然想到了多年前自己面试时的亲身经历,决定再回来补充一个小知识点! 记得是一个周末去面试Java后端开发工程师岗位,面试官针对Java多线程进行了狂轰乱炸般的考问,什么线程创建的方式、线程的状态、各状态间的切换、如果保证线程安全、各种锁...
继续阅读 »

写在开头


刚刚吃晚饭时,突然想到了多年前自己面试时的亲身经历,决定再回来补充一个小知识点!


记得是一个周末去面试Java后端开发工程师岗位,面试官针对Java多线程进行了狂轰乱炸般的考问,什么线程创建的方式、线程的状态、各状态间的切换、如果保证线程安全、各种锁的区别,如何使用等等,因为有好好背八股文,所以七七八八的也答上来了,但最后面试官问了一个现在看来很简单,但当时根本不知道的问题,他先是问了我,看过Thread的源码没,我毫不犹豫的回答看过,紧接着他问:



线程在调用了一次start启动后,再调用一次可以不?如果线程执行完,同样再调用一次start又会怎么样?



这个问题抛给你们,请问该如何作答呢?


线程的启动


我们知道虽然很多八股文面试题中说Java创建线程的方式有3种、4种,或者更多种,但实际上真正可以创建一个线程的只有new Thread().start();


【代码示例1】


public class Test {
public static void main(String[] args) {
Thread thread = new Thread(() -> {});
System.out.println(thread.getName()+":"+thread.getState());
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
}
}

输出:


Thread-0:NEW
Thread-0:RUNNABLE

创建一个Thread,这时线程处于NEW状态,这时调用start()方法,会让线程进入到RUNNABLE状态。


RUNNABLE的线程调用start


在上面测试代码的基础上,我们再次调用start()方法。


【代码示例2】


public class Test {
public static void main(String[] args) {
Thread thread = new Thread(() -> {});
System.out.println(thread.getName()+":"+thread.getState());
//第一次调用start
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
//第二次调用start
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
}
}

输出:


Thread-0:NEW
Thread-0:RUNNABLE
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.start(Thread.java:708)
at com.javabuild.server.pojo.Test.main(Test.java:17)

第二次调用时,代码抛出IllegalThreadStateException异常。


这是为什么呢?我们跟进start源码中一探究竟!


【源码解析1】


// 使用synchronized关键字保证这个方法是线程安全的
public synchronized void start() {
// threadStatus != 0 表示这个线程已经被启动过或已经结束了
// 如果试图再次启动这个线程,就会抛出IllegalThreadStateException异常
if (threadStatus != 0)
throw new IllegalThreadStateException();

// 将这个线程添加到当前线程的线程组中
group.add(this);

// 声明一个变量,用于记录线程是否启动成功
boolean started = false;
try {
// 使用native方法启动这个线程
start0();
// 如果没有抛出异常,那么started被设为true,表示线程启动成功
started = true;
} finally {
// 在finally语句块中,无论try语句块中的代码是否抛出异常,都会执行
try {
// 如果线程没有启动成功,就从线程组中移除这个线程
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
// 如果在移除线程的过程中发生了异常,我们选择忽略这个异常
}
}
}

这里有个threadStatus,若它不等于0表示线程已经启动或结束,直接抛IllegalThreadStateException异常,我们在start源码中打上断点,从第一次start中跟入进去,发现此时没有报异常。


new线程.png
此时的threadStatus=0,线程状态为NEW,断点继续向下走时,走到native方法start0()时,threadStatus=5,线程状态为RUNNABLE。此时,我们从第二个start中进入断点。


runnable线程.png
这时threadStatus=5,满足不等于0条件,抛出IllegalThreadStateException异常!


TERMINATED的线程调用start


终止状态下的线程,情况和RUNNABLE类似!


【代码示例3】


public class Test {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {});
thread.start();
Thread.sleep(1000);
System.out.println(thread.getName()+":"+thread.getState());
thread.start();
System.out.println(thread.getName()+":"+thread.getState());
}
}

输出:


Thread-0:TERMINATED
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.start(Thread.java:708)
at com.javabuild.server.pojo.Test.main(Test.java:17)

这时同样也满足不等于0条件,抛出IllegalThreadStateException异常!


我们其实可以跟入到state的源码中,看一看线程几种状态设定的逻辑。


【源码解析2】


// Thread.getState方法源码:
public State getState() {
// get current thread state
return sun.misc.VM.toThreadState(threadStatus);
}

// sun.misc.VM 源码:
// 如果线程的状态值和4做位与操作结果不为0,线程处于RUNNABLE状态。
// 如果线程的状态值和1024做位与操作结果不为0,线程处于BLOCKED状态。
// 如果线程的状态值和16做位与操作结果不为0,线程处于WAITING状态。
// 如果线程的状态值和32做位与操作结果不为0,线程处于TIMED_WAITING状态。
// 如果线程的状态值和2做位与操作结果不为0,线程处于TERMINATED状态。
// 最后,如果线程的状态值和1做位与操作结果为0,线程处于NEW状态,否则线程处于RUNNABLE状态。
public static State toThreadState(int var0) {
if ((var0 & 4) != 0) {
return State.RUNNABLE;
} else if ((var0 & 1024) != 0) {
return State.BLOCKED;
} else if ((var0 & 16) != 0) {
return State.WAITING;
} else if ((var0 & 32) != 0) {
return State.TIMED_WAITING;
} else if ((var0 & 2) != 0) {
return State.TERMINATED;
} else {
return (var0 & 1) == 0 ? State.NEW : State.RUNNABLE;
}
}

总结


OK,今天就讲这么多啦,其实现在回头看看,这仅是一个简单且微小的细节而已,但对于刚准备步入职场的我来说,却是一个难题,今天写出来,除了和大家分享一下Java线程中的小细节外,更多的是希望正在准备面试的小伙伴们,能够心细,多看源码,多问自己为什么?并去追寻答案,Java开发不可浅尝辄止。




作者:JavaBuild
来源:juejin.cn/post/7345071481375932451
收起阅读 »

Redis 大 key 问题一文通

1. 背景 最近对接了一个卧龙同事的接口,因为接口比较慢,所以打算对第三方接口加个缓存。但是会有大 key 的问题。设计过程中调研了一些解决方案,这里总结下。 关键字:Redis;大Key问题; 2. 大 key 会带来什么问题 我们都知道,redis 是单线...
继续阅读 »

1. 背景


最近对接了一个卧龙同事的接口,因为接口比较慢,所以打算对第三方接口加个缓存。但是会有大 key 的问题。设计过程中调研了一些解决方案,这里总结下。
关键字:Redis;大Key问题;


2. 大 key 会带来什么问题


我们都知道,redis 是单线程架构,日常的读写操作都是由一个线程完成。一旦某一个线程执行了大 key 的读写,就会影响之后所有命令的执行,进而影响 redis 实例甚至整个 redis 集群的稳定。


3. 什么才叫大 key


那么什么才叫大 key?普遍认同的规范是:



  1. value > 10kb,即认定为大 key

  2. 像list,set,hash 等容器类型的 redis key,元素数量 > 5000,即认定为大 key


现在我们知道了大 key 会来带什么问题,也知道了什么样的 key 才算大key。接下来我们看看都有哪些解决方案。


4. 解决方案一:压缩


适用于字符串类型的 redis key。采用压缩算法,将 key 压缩至可接受的范围内。压缩也是有讲究的,首先要选择无损的压缩算法,然后在压缩速率和压缩率之间也要权衡。比较常用的压缩算法/工具如下:



  • google snappy:无损压缩,追求压缩速度而不是压缩率(Compression rate)

  • message pack:无损压缩,仅适用于 json 字符串的压缩,可以得到一个更小的 JSON,官网是:msgpack.org/


5. 解决方案二:value 切片


适用于 list,set,hash 等容器类型的 redis key。规范要求容器的元素数量 < 5000,我们可以在写 redis 的时候做个逻辑,如果超过了 5000 的容器就做切片。


举个例子,现在有一个 list 类型的缓存 ,他包含 12000 个元素。是很典型的大key。
image.png
我们以 5000 为阈值,把 list 切分成三份:user_list_slice_1、user_list_slice_2、user_list_slice_3,另外还需要一个存储切片具体情况的key,所以还需要一个 user_list_ctl。
业务程序后续访问这个缓存的时候,先请求 user_list_ctl,解析出缓存的切分情况,再去请求具体的切片即可。


6. 解决方案三:抛弃大 key(discard)


大多数场景,我们是把 redis 当缓存用,缓存失效了就走数据库查出数据。我们可以设定一个阈值,如果缓存对象特别大的话,我们就抛弃这个key,不缓存,直接走数据库。这样不会影响 redis 正常的运作。


image.png


当然,这是个取巧的方案,灵感是来自线程池的拒绝策略(DiscardPolicy)。采用这个方案得确认直接抛弃不会影响业务,还需要确保不走缓存后的性能业务上能够接受。



7. 俯瞰一下,从架构的角度解决这个问题


千叮咛万嘱咐,大 key 问题造成的线上事故仍然没有断过,这个怎么解决?
我觉得有如下几个思路



  • 完善监控机制,有大 key 出现就及时告警

  • 封禁/限流能力,能够及时封禁大 key 的访问,降低业务影响(保命用)

  • 在服务和 redis 集群之间建设 proxy 层,在 proxy 做大 key 的处理(压缩或者切片处理),让业务开发无需感知大key。


8. 总结


总结一下,解决 redis 的大 key,我们常规有三种解决方案。一是压缩,而是切片,三是直接抛弃不缓存。


作者:小黑233
来源:juejin.cn/post/7261254961923768380
收起阅读 »

工作思考|研发环境好好的,怎么上线就出问题了?

场景再现 那是一个夜黑风高的晚上,某个版本迭代经过了完备的测试,正准备上线。研发同事A开完了上线评审后,信心满满地对运维同事B说:“开冲!” 几分钟后,同事B发了条消息过来,看着抖动的头像,同事A心想:小B效率真高啊,这么快!点开消息一看【启动报错了,你看一下...
继续阅读 »

场景再现


那是一个夜黑风高的晚上,某个版本迭代经过了完备的测试,正准备上线。研发同事A开完了上线评审后,信心满满地对运维同事B说:“开冲!”


几分钟后,同事B发了条消息过来,看着抖动的头像,同事A心想:小B效率真高啊,这么快!点开消息一看【启动报错了,你看一下】。


什么?启动还能报错,不可能啊,我研测环境都好好的。


小A火急火忙地连上堡垒机,看了下日志,报错信息大致为 【表tb_xxx没有找到】。


“怎么可能,我用了伟大的flyway,怎么可能会没有表呢?”小A如是说道。



提到flyway,这里简单介绍一下。Flyway是一款开源的数据库版本管理工具,可以实现管理并跟踪数据库变更,支持数据库版本自动升级,而且不需要复杂的配置,能够帮助团队更加方便、合理的管理数据库变更。只需要引入相应依赖,添加配置,再在resource目录下创建db/migration/xxxx.sql文件,在sql文件中写入用到的建表语句,插入语句即可。



不管怎么说,代码是不会骗人的。先找下是哪里出了问题!


小A很快就定位到了代码位置,是一个用于缓存的HashMap,这操作也没什么问题,相信大家都这么用过,对于一些一次查找,到处使用,还亘古不变的表信息,可以先查一次,把它用Map缓存起来,以便后续使用。


但是研发同事C把这段代码放在了afterPropertiesSet()​方法内部,没错,就是那个InitializingBean​接口的方法。看到这里,相信各位熟练背诵Bean生命周期的Java Boy已经明白了!查询数据库的操作在Bean还没有创建完成的时候就进行了!而此时,flyway脚本还没有执行,自然就找不到对应的表信息了。


那怎么办呢?


解决方法


解决方法很简单,sql执行的时候找不到表,那就让它在表创建完之后再执行!


1.CommandLineRunner接口


一个方法就是我们常用的CommandLineRunner​接口,重写run()​方法,把缓存逻辑移到run()​方法中。原因是run()方法的执行时机是在SpringBoot应用程序启动之后,此时flyway已经执行完毕,表结构已经存在,就没问题了!


2.@DependsOn注解


通过代码分析,flyway的加载是由flywayInitializer​这个Bean负责的。所以只需要我们的Bean在它之后加载就行了,这就用上了@DependsOn​注解。



@DependsOn注解可以定义在类和方法上,意思是我这个Bean要依赖于另一个Bean,也就是说被依赖的组件会比该组件先加载注册到IOC容器中。



也就是在我们的Bean上加上这么个注解@DependsOn("flywayInitializer")


总结


此次线上问题复习了Bean的生命周期,复习了InitializingBeanCommandLineRunner​两个接口,复习了@DependsOn​注解。


作者:钱思惘
来源:juejin.cn/post/7349750846898913332
收起阅读 »

正则表达式太难写?试试这个可视化工具

在工作中有没有觉得写正则表达式很难,我就一直很头疼。今天我们就介绍一个开源项目,它可以用可视化的方式查看、编辑和测试正则表达式,大大的提升效率,它就是:regex-vis regex-vis是什么 regex-vis是一个辅助学习、编写和验证正则的工具,你输入...
继续阅读 »

在工作中有没有觉得写正则表达式很难,我就一直很头疼。今天我们就介绍一个开源项目,它可以用可视化的方式查看、编辑和测试正则表达式,大大的提升效率,它就是:regex-vis


regex-vis是什么


regex-vis是一个辅助学习、编写和验证正则的工具,你输入一个正则表达式后,会生成它的可视化图形。然后可以点选或框选图形中的单个或多个节点,再在右侧操作面板对其进行操作,具体操作取决于节点的类型,比如在其右侧插入空节点、为节点编组、为节点增加量词等。



安装regex-vis


首先regex-vis提供了一个在线环境,可以直接到regex-vis.com/ 去试用,这是最简单的方式。



当然,作为一个开源项目,另外一种方式就是自己运行啦。按以下步骤:



  • 首先下载代码到本地。

  • 安装依赖:pnpm install

  • 安装完成后运行服务:pnpm start



启动完成后到3000端口访问即可。


这里可能会遇到一些小问题,比如SSL的问题,稍微修改一些运行命令的配置即可解决。


使用 regex-vis


首先我准备一个例子的正则表达式,验证身-份-证的正则:


^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$

可视化


直接把正则表达式贴进去就能看到可视化的效果了。



编辑


在右侧是正则表达式的编辑区,可以在这里修改、编辑正则的内容。



首先是一些图例,点击图形中想要编辑的部分,然后点击中间的编辑就可以进入到编辑页面了。



测试


修改完了正则的内容,想要验证一下写的对不对,那就到测试里去试一试吧。通过的会显示绿色,失败的则显示为红色。



示例


项目还自带了几个示例,如果你 刚一进来,不知道用什么来试用,可以直接打开示例来看看。



设置


本身是个小工具,这里有2个可用的设置,一个是切换语言,可以切成中文显示,另一个就是明/暗显示模式的转换。



总结


作为一个小工具还是挺不错的,对于像我这样不熟练正则的人有所帮助,一步步的编辑可以渐进式的编辑。


当然现在写正则最好的方式是让AI帮忙写,所以我建议可以AI帮忙写,然后通过这个工具来检查一下,通过可是的方式检查和AI的沟通有没有错误。


另外测试正则的功能里,不能显示出事那部分 正则出错略有可惜,如果能增强就更好用了。


项目信息



作者:IT咖啡馆
来源:juejin.cn/post/7350683679297290294
收起阅读 »

趣解适配器模式之《买了苹果笔记本的尴尬》

〇、小故事 小王考上了理想的大学,为了更好的迎接大学生活,他决定买一台苹果的笔记本电脑犒赏自己。 电脑很快买好了,用起来也非常的流畅,但是,当他想要插U盘传资料的时候,尴尬的事情来了,这台电脑两侧的插口非常少,只有1个耳机插孔和2个雷电插孔,根本没有USB插...
继续阅读 »

〇、小故事


小王考上了理想的大学,为了更好的迎接大学生活,他决定买一台苹果的笔记本电脑犒赏自己。



电脑很快买好了,用起来也非常的流畅,但是,当他想要插U盘传资料的时候,尴尬的事情来了,这台电脑两侧的插口非常少,只有1个耳机插孔2个雷电插孔根本没有USB插口!这咋办呀?



他赶快咨询了他的哥哥,他哥哥告诉他,去买一个扩展坞就可以了,然后他上网一看,原来买一个扩展坞之后,无论是U盘还是连接显示器的HDMI都可以连接啦!!他开心极了,本来要遗憾退掉这台心爱的苹果笔记本电脑,这回也不用退啦!



以上这个小故事,相信很多使用过苹果笔记本的同学们都会遇到,大多也都会购买这种扩展坞,那么,这种扩展坞其实就是适配器模式的一个具体实现例子了。那么,言归正传,我们来正式了解一下这个设计模式——适配器模式


一、模式定义


适配器模式定义:



该模式将一个类的接口,转换成客户期望的另一个接口。适配器模式让原本接口不兼容的类可以合作无间。



为了进一步加深该模式的理解,我们再举一个研发过程中会遇到的例子:



此时我们维护了一个员工管理系统,然后接入我们系统的第三方系统,我们都要求对方遵守我们的接口规范去开发,比如:提供方法名为queryAllUser()的方法等等。但是,这次接入的系统已经有类似功能了,他们不希望因为两个系统的接入而重新开发新的接口,那么这对这种情况,我们就可以采用适配器模式,将接口做中间层的适配转换。



如图下图所示:



二、模式类图


通过上面的介绍,相信大家对适配器模式也有了一定的了解了。那么,下面我们就来看一下如果要实现适配器模式,我们的类图应该是怎么样的。


首先,我们要说明两个重要的概念:AdapterAdaptee,其含义分别是适配器待适配的类。我们就是通过实现Target接口创建Adapter类,然后在具体的方法内部来通过调用Adaptee方法来实现具体的业务逻辑。具体类图如下所示:



三、代码实现


首先创建目标类接口——Target


public interface Target {
void prepare();
void execute();
}

实现Target接口,创建具体实现类——NormalTarget


public class NormalTarget implements Target {
public void prepare() {
System.out.println("NormalTarget prepare()");
}
public void execute() {
System.out.println("NormalTarget execute()");
}
}

创建待适配的类Adaptee,用于后续适配器对其进行适配工作:


public class Adaptee {
public void prepare1() {
System.out.println("Adaptee prepare1()");
}
public void prepare2() {
System.out.println("Adaptee prepare2()");
}
public void prepare3() {
System.out.println("Adaptee prepare3()");
}
public void doingSomething() {
System.out.println("Adaptee doingSomething()");
}
}

创建适配器Adapter,由于要适配目标对象Target,所以需要实现Target接口:


public class Adapter implements Target {
// 待适配的类
private Adaptee adaptee;

public Adapter(Adaptee adaptee) {
this.adaptee = adaptee;
}

public void prepare() {
adaptee.prepare1();
adaptee.prepare2();
adaptee.prepare3();
}

public void execute() {
adaptee.doingSomething();
}
}

创建客户端Client,用于操作Target目标对象执行某些业务逻辑:


public class Client {
Target target;
public void work() {
target.prepare();
target.execute();
}
public void setTarget(Target target) {
this.target = target;
}
}

创建测试类AdapterTest,使得Client操作NormalTarget和Adaptee:


public class AdapterTest {
public static void main(String[] args) {
Client client = new Client();

System.out.println("------------NormalTarget------------");
client.setTarget(new NormalTarget());
client.work();

System.out.println("------------Adaptee------------");
client.setTarget(new Adapter(new Adaptee())); // 适配器转换
client.work();
}
}

通过输出结果我们可以看到,适配器运行正常:


------------NormalTarget------------
NormalTarget prepare()
NormalTarget execute()
------------Adaptee------------
Adaptee prepare1()
Adaptee prepare2()
Adaptee prepare3()
Adaptee doingSomething()

今天的文章内容就这些了:



写作不易,笔者几个小时甚至数天完成的一篇文章,只愿换来您几秒钟的 点赞 & 分享



更多技术干货,欢迎大家关注公众号“爪哇缪斯” ~ \(^o^)/ ~ 「干货分享,每天更新」


作者:爪哇缪斯
来源:juejin.cn/post/7273125596951298060
收起阅读 »

腾讯女后端设计了一套短链系统,当场就想给她offer!

你好,我是猿java 如上图,对于这种客评短信,相信大家并不陌生,通过点击短信里“蓝色字体”,就能跳转到一个网页。其实,背后的秘密就是一套完整的短链系统,今天我们就来看看字节的后端女生是如何设计的? 上图中那串蓝色字符,有个专业的术语叫做“短链”,它可以是一...
继续阅读 »

你好,我是猿java


image.png


如上图,对于这种客评短信,相信大家并不陌生,通过点击短信里“蓝色字体”,就能跳转到一个网页。其实,背后的秘密就是一套完整的短链系统,今天我们就来看看字节的后端女生是如何设计的?


上图中那串蓝色字符,有个专业的术语叫做“短链”,它可以是一个链接地址,也可以设计成二维码。


为什么要用短链?


存在既合理,这里列举 3个主要原因。


1.相对安全


短链不容易暴露访问参数,生成方式可以完全迎合短信平台的规则,能够有效地规避关键词、域名屏蔽等风险,而原始 URL地址,很可能因为包含特殊字符被短信系统误判,导致链接无法跳转。


2.美观


对于精简的文字,似乎更符合美学观念,不太让人产生反感。


3.平台限制


短信发送平台有字数限制,在整条短信字数不变的前提下,把链接缩短,其他部分的文字描述就能增加,这样似乎更能达到该短信的实际目的(比如,营销)。


短链的组成


如下图,短链的组成通常包含两个部分:域名 + 随机码


image.png


短链的域名最好和其他业务域名分开,而且要尽量简短,可以不具备业务含义(比如:xyz.com),因为短链大部分是用于营销,可能会被三方平台屏蔽。


短链的随机码需要全局唯一,建议 10位以下。


短链跳转的原理


首先,我们先看一个短链跳转的简单例子,如下代码,定义了一个 302重定向的代码示例:


import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.servlet.view.RedirectView;

@Controller
public class RedirectController {

@GetMapping("/{shortCode}")
public RedirectView redirect(@PathVariable String shortCode) {
String destUrl = "https://yuanjava.com";
// destUrl = getDestUrlByShortCode(shortCode); //真实的业务逻辑
return new RedirectView(destUrl);
}
}

接着,在浏览器访问短链”http://127.0.0.1:8080/s2TYdWd” 后,请求会被重定向到 yuanjava.com ,下图为浏览器控制台信息:


image.png


从上图,我们看到了 302状态码并且请求被 Location到另外一个 URL,整个交互流程图如下:


image.png


是不是有一种偷梁换柱的感觉???


最后,总结下短链跳转的核心思想:


生成随机码,将随机码和目标 URL(长链)的映射关系存入数据库;


用域名+随机码生成短链,并推送给目标用户;


当用户点击短链后,请求会先到达短链系统,短链系统根据随机码查找出对应的目标 URL,接着将请求 302重定向到目标 URL(长链);


关于重定向有 301 和 302两种,如何选择?



  • 302,代表临时重定向:每次请求短链,请求都会先到达短链系统,然后重定向到目标 URL(长链),这样,方便短链系统做一些统计点击数等操作;通常采用 302

  • 301,代表永久重定向:第一次请求拿到目标长链接后,下次再次请求短链,请求不会到达短链系统,而是直接跳转到浏览器缓存的目标 URL(长链),短链系统只能统计到第一次访问的数据;一般不采用 301。


如何生成短链?


从短链组成章节可以知道短链=域名+随机码,随意如何生成短链的问题转换成了如何生成一个随机码,而且这个随机码需要全局唯一。通常会有 3种做法:


Base62


Base62 表示法是一种基数为62的数制系统,包含26个英文大写字母(A-Z),26个英文小写字母(a-z)和10个数字(0-9)。这样,共有62个字符可以用来表示数值。 如下代码:


import java.security.SecureRandom;

public class RandomCodeGenerator {
private static final String CHAR_62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final SecureRandom random = new SecureRandom();

public static String generateRandomCode(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
int rndCharAt = random.nextInt(CHAR_62.length());
char rndChar = CHAR_62.charAt(rndCharAt);
sb.append(rndChar);
}
return sb.toString();
}
}

对于 Base62算法,如果是生成 6位随机数有 62^6 - 1 = 56800235583, 568亿多,如果是生成 7位随机数有 62^7 - 1 = 3521614606208,合计3.5万亿多,足够使用。


Hash算法


Hash算法算法是我们最容易想到的办法,比如 MD5, SHA-1, SHA-256, MurmurHash, 但是这种算法生成的 Hash算法值还是比较长,常用的做法是把这个 Hash算法值进行 62/64进行压缩。


如下代码,通过 Google的 MurmurHash算法把长链 Hash成一个 32位的 10进制正数,然后再转换成62进制(压缩),这样就可以得到一个 6位随机数,


import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import java.nio.charset.StandardCharsets;

public class MurmurHashToBase62 {

private static final String BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static String toBase62(int value) {
StringBuilder sb = new StringBuilder();
while (value > 0) {
sb.insert(0, BASE62.charAt(value % 62));
value /= 62;
}
return sb.toString();
}
public static void main(String[] args) {
// 长链
String input = "https://yuanjava.cnposts/short-link-system/design?code=xsd&page=1";
// 长链利用 MurmurHash算法生成 32位 10进制数
HashFunction hashFunction = Hashing.murmur3_32();
int hash = hashFunction.hashString(input, StandardCharsets.UTF_8).asInt();
if (hash < 0) {
hash = hash & 0x7fffffff; // Convert to positive by dropping the sign bit
}
// 将 32位 10进制数 转换成 62进制
String base62Hash = toBase62(hash);
System.out.println("base62Hash:" + base62Hash);
}
}

全局唯一 ID


比如,很多大中型公司都会有自己全局唯一 ID 的生成服务器,可以使用这些服务器生成的 ID来保证全局唯一,也可以使用雪花算法生成全局唯一的ID,再经过 62/64进制压缩。


如何解决冲突


对于上述3种方法的前 2种:base62 或者 hash,因为都是哈希函数,所以,不可避免地会产生哈希冲突(尽管概率很低),该怎么解决呢?


要解决冲突,首先要检测冲突,通常来说有 3种检测方法。


数据库索


如下,这里以 MySQL数据库为例(也可以保存在 Redis中),表结构如下:


CREATE TABLE `short_url_map` (   
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`long_url` varchar(160) DEFAULT NULL COMMENT '长链',
`short_url` varchar(10) DEFAULT NULL COMMENT '短链',
`gmt_create` int(11) DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE INDEX 'short_url' ('short_url')
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

首先创建一张长链和短链的关系映射表,然后通过给 short_url字段添加唯一锁,这样,当数据插入时,如果存在 Hash冲突(short_url值相等),数据库就会抛错,插入失败,因此,可以在业务代码里捕获对应的错误,这样就能检测出冲突。


也可以先用 short_url去查询,如果能查到数据,说明 short_url存在 Hash冲突了。


对于这种通过查询数据库或者依赖于数据库唯一锁的机制,因为都涉及DB操作,所以对数据库是一个开销,如果流量比较大的话,需要保证数据库的性能。


布隆过滤器过滤器


在 DB操作的上游增加一个布隆过滤器,在长链生成短链后, 先用短链在布隆过滤器中进行查找,如果存在就代表冲突了,如果不存在,说明 DB里不存在此短链,可以插入。 对于布隆过滤器的选择,单机可以采用 Google的布隆过滤器,分布式可以使用 RedisBloom。


整体流程可以抽象成下图:


image.png


检测出了冲突,需要如何解决冲突?


再 Hash,可以在长链后面拼接一个 UUID之类的随机字符串,然后再次进行 Hash,用得出的新值再进行上述检测,这样 Hash冲突的概率又大大大的降低了。


高并发场景


在流量不大的情况,上述方法怎么折腾似乎都没有问题,但是,为了架构的健壮性,很多时候需要考虑高并发,大流量的场景,因此架构需要支持水平扩展,比如:



  • 采用微服务

  • 功能模块分离,比如,短链生成服务和长链查询服务分离

  • 功能模块需要支持水平扩容,比如:短链生成服务和长链查询服务能支持动态扩容

  • 缓解数据库压力,比如,分区,分库分表,主从,读写分离等机制

  • 服务的限流,自保机制

  • 完善的监控和预警机制


这里给出一套比较完整的设计思路图:


image.png


总结


本文通过一个客服评价的短信开始,分析了短链的构成,短链跳转的原理,同时也给出了业内的一些实现算法,以及一些架构上的建议。


对于业务体量小的公司,可以根据成本来搭建服务(单机或者少量服务器做负载),对于业务体量比较大的公司,更多需要考虑到高并发的场景,如何保证服务的稳定性,如何支持水平扩展,当服务出现问题时如何具备一套完善的监控和预警服务器。


其实,很多系统都是在一次又一次的业务流量挑战下成长起来的,我们需要不断打磨自己宏观看架构,微观看代码的能力,这样自己也就跟着业务,系统一起成长起来了。


作者:猿java
来源:juejin.cn/post/7350585600858898484
收起阅读 »

Mysql中Varchar(50)和varchar(500)区别是什么?

Mysql中Varchar(50)和varchar(500)区别是什么? 一. 问题描述 我们在设计表结构的时候,设计规范里面有一条如下规则: 对于可变长度的字段,在满足条件的前提下,尽可能使用较短的变长字段长度。 为什么这么规定,我在网上查了一下,主要基...
继续阅读 »

Mysql中Varchar(50)和varchar(500)区别是什么?


一. 问题描述


我们在设计表结构的时候,设计规范里面有一条如下规则:



  • 对于可变长度的字段,在满足条件的前提下,尽可能使用较短的变长字段长度。


为什么这么规定,我在网上查了一下,主要基于两个方面



  • 基于存储空间的考虑

  • 基于性能的考虑


网上说Varchar(50)和varchar(500)存储空间上是一样的,真的是这样吗?


基于性能考虑,是因为过长的字段会影响到查询性能?


本文我将带着这两个问题探讨验证一下


二.验证存储空间区别


1.准备两张表


CREATE TABLE `category_info_varchar_50` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(50) NOT NULL COMMENT '分类名称',
`is_show` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否展示:0 禁用,1启用',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '序号',
`deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_name` (`name`) USING BTREE COMMENT '名称索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类';

CREATE TABLE `category_info_varchar_500` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(500) NOT NULL COMMENT '分类名称',
`is_show` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否展示:0 禁用,1启用',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '序号',
`deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_name` (`name`) USING BTREE COMMENT '名称索引'
) ENGINE=InnoDB AUTO_INCREMENT=288135 DEFAULT CHARSET=utf8mb4 COMMENT='分类';

2.准备数据


给每张表插入相同的数据,为了凸显不同,插入100万条数据


DELIMITER $$
CREATE PROCEDURE batchInsertData(IN total INT)
BEGIN
DECLARE start_idx INT DEFAULT 1;
DECLARE end_idx INT;
DECLARE batch_size INT DEFAULT 500;
DECLARE insert_values TEXT;

SET end_idx = LEAST(total, start_idx + batch_size - 1);

WHILE start_idx <= total DO
SET insert_values = '';
WHILE start_idx <= end_idx DO
SET insert_values = CONCAT(insert_values, CONCAT('(\'name', start_idx, '\', 0, 0, 0, NOW(), NOW()),'));
SET start_idx = start_idx + 1;
END WHILE;
SET insert_values = LEFT(insert_values, LENGTH(insert_values) - 1); -- Remove the trailing comma
SET @sql = CONCAT('INSERT INTO category_info_varchar_50 (name, is_show, sort, deleted, create_time, update_time) VALUES ', insert_values, ';');

PREPARE stmt FROM @sql;
EXECUTE stmt;
SET @sql = CONCAT('INSERT INTO category_info_varchar_500 (name, is_show, sort, deleted, create_time, update_time) VALUES ', insert_values, ';');
PREPARE stmt FROM @sql;
EXECUTE stmt;

SET end_idx = LEAST(total, start_idx + batch_size - 1);
END WHILE;
END$$
DELIMITER ;

CALL batchInsertData(1000000);

3.验证存储空间


查询第一张表SQL


SELECT
table_schema AS "数据库",
table_name AS "表名",
table_rows AS "记录数",
TRUNCATE ( data_length / 1024 / 1024, 2 ) AS "数据容量(MB)",
TRUNCATE ( index_length / 1024 / 1024, 2 ) AS "索引容量(MB)"
FROM
informati0n—schema.TABLES
WHERE
table_schema = 'test_mysql_field'
and TABLE_NAME = 'category_info_varchar_50'
ORDER BY
data_length DESC,
index_length DESC;

查询结果


image.png


查询第二张表SQL


SELECT
table_schema AS "数据库",
table_name AS "表名",
table_rows AS "记录数",
TRUNCATE ( data_length / 1024 / 1024, 2 ) AS "数据容量(MB)",
TRUNCATE ( index_length / 1024 / 1024, 2 ) AS "索引容量(MB)"
FROM
informati0n—schema.TABLES
WHERE
table_schema = 'test_mysql_field'
and TABLE_NAME = 'category_info_varchar_500'
ORDER BY
data_length DESC,
index_length DESC;

查询结果


image.png


4.结论


两张表在占用空间上确实是一样的,并无差别


三.验证性能区别


1.验证索引覆盖查询


select name from category_info_varchar_50 where name = 'name100000'
-- 耗时0.012s
select name from category_info_varchar_500 where name = 'name100000'
-- 耗时0.012s
select name from category_info_varchar_50 order by name;
-- 耗时0.370s
select name from category_info_varchar_500 order by name;
-- 耗时0.379s

通过索引覆盖查询性能差别不大


1.验证索引查询


select * from category_info_varchar_50 where name = 'name100000'
--耗时 0.012s
select * from category_info_varchar_500 where name = 'name100000'
--耗时 0.012s
select * from category_info_varchar_50 where name in('name100','name1000','name100000','name10000','name1100000',
'name200','name2000','name200000','name20000','name2200000','name300','name3000','name300000','name30000','name3300000',
'name400','name4000','name400000','name40000','name4400000','name500','name5000','name500000','name50000','name5500000',
'name600','name6000','name600000','name60000','name6600000','name700','name7000','name700000','name70000','name7700000','name800',
'name8000','name800000','name80000','name6600000','name900','name9000','name900000','name90000','name9900000')
-- 耗时 0.011s -0.014s
-- 增加 order by name 耗时 0.012s - 0.015s

select * from category_info_varchar_50 where name in('name100','name1000','name100000','name10000','name1100000',
'name200','name2000','name200000','name20000','name2200000','name300','name3000','name300000','name30000','name3300000',
'name400','name4000','name400000','name40000','name4400000','name500','name5000','name500000','name50000','name5500000',
'name600','name6000','name600000','name60000','name6600000','name700','name7000','name700000','name70000','name7700000','name800',
'name8000','name800000','name80000','name6600000','name900','name9000','name900000','name90000','name9900000')
-- 耗时 0.012s -0.014s
-- 增加 order by name 耗时 0.014s - 0.017s

索引范围查询性能基本相同, 增加了order By后开始有一定性能差别;


3.验证全表查询和排序


全表无排序


image.png


image.png


全表有排序


select * from category_info_varchar_50 order by  name ;
--耗时 1.498s
select * from category_info_varchar_500 order by name ;
--耗时 4.875s

image.png
image.png


结论:


全表扫描无排序情况下,两者性能无差异,在全表有排序的情况下, 两种性能差异巨大;


分析原因


varchar50 全表执行sql分析

1711426760869.jpg
我发现86%的时花在数据传输上,接下来我们看状态部分,关注Created_tmp_files和sort_merge_passes
1711426760865.jpg


image.png
Created_tmp_files为3

sort_merge_passes为95


varchar500 全表执行sql分析

image.png


增加了临时表排序


image.png
image.png
Created_tmp_files 为 4

sort_merge_passes为645


关于sort_merge_passes, Mysql给出了如下描述:



Number of merge passes that the sort algorithm has had to do. If this value is large, you may want to increase the value of the sort_buffer_size.



其实sort_merge_passes对应的就是MySQL做归并排序的次数,也就是说,如果sort_merge_passes值比较大,说明sort_buffer和要排序的数据差距越大,我们可以通过增大sort_buffer_size或者让填入sort_buffer_size的键值对更小来缓解sort_merge_passes归并排序的次数。


四.最终结论


至此,我们不难发现,当我们最该字段进行排序或者其他聚合操作的时候,Mysql会根据该字段的设计的长度进行内存预估, 如果设计过大的可变长度, 会导致内存预估的值超出sort_buffer_size的大小, 导致mysql采用磁盘临时文件排序,最终影响查询性能;


作者:向显
来源:juejin.cn/post/7350228838151847976
收起阅读 »

聊一聊定时任务重复执行以及解决方案

大家好,我是小趴菜,关于定时任务大家都有接触过,项目中肯定也使用过,只需要在项目中的启动类上加上 @EnableScheduling 注解就可以实现了 现在是单个节点部署,倒是没什么问题。如果是多节点部署呢? 假设现在我们系统要每天给用户新增10积分,那么更新...
继续阅读 »

大家好,我是小趴菜,关于定时任务大家都有接触过,项目中肯定也使用过,只需要在项目中的启动类上加上 @EnableScheduling 注解就可以实现了


现在是单个节点部署,倒是没什么问题。如果是多节点部署呢?


假设现在我们系统要每天给用户新增10积分,那么更新的SQL如下


  update user set point = point + 10 where id = 1;

这时候你的服务部署了两台,那么每一台都会来执行这条更新语句,那么用户的积分就不是+10了。而是+20。


当然这里我们只是举了个例子来引出 @EnableScheduling 的定时任务会存在定时任务重复执行的问题。而且有可能会因为重复执行导致数据不一致的问题


使用数据库乐观锁


在使用乐观锁的时候,首先我们会新增一个字段,也就是更新日期,但是这个更新日期不是指我们修改数据的那个更新时间,比如说今天是2024-03-25,那么到了明天,第一台机器更新成功了,这个值就更新成2024-03-26,其它机器的线程来更新判断这个值是否是2024-03-25,如果不是,说明已经有线程更新了,那么就不需要再次更新了


  update user set point = point + 10,modifyTime = 2023-03-26 where id = 1 and modifyTime = 2024-03-25

基于乐观锁的方式有什么缺点呢??


现在我们只有两台服务器,那如果有1千台,1万台呢,对于同一条数据的,那么这1万台服务器都会去执行更新操作,但是其实在这1万次更新操作中,只有一次操作是成功的,其余的操作都是不需要执行的


所以使用这种方式当机器数量很多的时候,对数据库的压力是非常大的


分布式锁


我们还可以使用分布式锁的方式来实现,比如要执行这个定时任务之前要先获取一把锁,这个锁是对每一条记录都分别有对应的一把锁


当线程来更新某一条数据的时候,首先要获取这条记录的一个分布式锁,拿到锁了就可以去更新了,没有拿到锁的也不要去等待获取锁了,就直接更新下一条数据即可,同样的步骤,只有拿到某条数据的锁,才可以更新


image.png


但是这里有一个注意的点,就是比如服务-1先获取到id=100的这条记录的锁,然后执行更新,但是此时因为某些原因,导致某台服务器过了一会才来执行id=100的这条数据,因为服务-1已经执行完了,所以会释放掉这把锁,所以这台服务器来就能获取到锁,那么也会执行更新操作


所以在更新的时候,还是做一下判断,判断这条记录是否已经被更新了,如果已经更新了,那么就不要再次更新了


分布式锁相对于乐观锁来说,减少了大量的无用的更新操作,但还是会存在极少量的重复更新操作,但是相对来说,对数据库的压力就减少了很多


但是与此同时,这就依赖于Redis,要保证Redis的服务可用


消息队列


我们可以将要更新的数据提前加载到消息队列中去,然后每台服务就是一个消费者,保证一条记录只能让一个消费者消费,这样也就可以避免重复更新的问题了


但是消费失败的记录不要重回队列,可以在数据库记录,让人工进行处理


使用消息队列会有什么问题呢?


如果你的消息数据特别多,比如有上亿条,那么消息队列就会被堆满,,而且每天都要把数据库的数据都加载到消息队列中去


或许有人说,数据量大我可以多弄几个消息队列,这样确实可以解决一个消息队列堆积消息过多的问题,但是你要如何控制某些服务器只访问某个队列呢?不能每台服务都循环获取每一个队列中的消息吧


而且如果你的消息队列是单点的,那么服务宕机那么所有数据都没法更新了,这时候你还要去弄一个集群
,这成本就有点大了


所以不推荐使用这种方式


分布式任务调度-xxl-job


最后就是使用分布式定时任务调度框架 xxl-job了,关于xxl-job的使用大家可以网上自己搜一下资料。


XXL-JOB是一个开源的分布式任务调度框架,它主要用于解决大规模分布式任务的调度和执行问题。该框架提供了任务调度中心执行器任务日志等组件,支持任务的定时调度、动态添加和删除、执行情况监控和日志记录等功能。


总结


以上就是为大家提供的定时任务重复执行的解决方案,大家可以根据自己的实际情况来选择不同的方案来实现


作者:我是小趴菜
来源:juejin.cn/post/7350167062364979226
收起阅读 »

深入理解 CSS:基础概念、注释、选择器及优先级

在构建网页的过程中,我们不仅需要HTML来搭建骨架,还需要CSS来装扮我们的网页。那么,什么是CSS呢?本文将带大家了解css的基础概念,注释、选择器及优先级。一、CSS简介1.1 什么是CSSCSS,全称为Cascading Style Sheets(层叠样...
继续阅读 »

在构建网页的过程中,我们不仅需要HTML来搭建骨架,还需要CSS来装扮我们的网页。那么,什么是CSS呢?本文将带大家了解css的基础概念,注释、选择器及优先级。

一、CSS简介

1.1 什么是CSS

CSS,全称为Cascading Style Sheets(层叠样式表),是一种用于描述网页上的信息格式化和显示方式的语言。它的主要功能是控制网页的视觉表现,包括字体、颜色、布局等样式结构。

Description

通过CSS,开发者可以将文档的内容与其表现形式分离,这样不仅提高了网页的可维护性,还使得样式更加灵活和多样化。

CSS的应用非常广泛,它可以用来控制网页中几乎所有可见元素的样式,包括但不限于文本的字体、大小、颜色,元素的位置、大小、背景色,以及各种交互效果等。

CSS样式可以直接写在HTML文档中,也可以单独存储在样式单文件中,这样可以被多个页面共享使用。无论是哪种方式,样式单都包含了将样式应用到指定类型的元素的规则。

1.2 CSS 语法规范

所有的样式,都包含在

<head>
 <style>
 h4 {
 color: blue;
 font-size: 100px;
 }
 </style>
</head>

1.3 CSS 的三大特性

Css有三个非常重要的特性:层叠性、继承性、优先级。

层叠性

相同选择器给设置相同的样式,此时一个样式就会覆盖(层叠)另一个冲突的样式。层叠性主要解决样式冲突的问题。

层叠性原则:

  • 样式冲突,遵循的原则是就近原则,哪个样式离结构近,就执行哪个样式
  • 样式不冲突,不会层叠

继承性

CSS中的继承:子标签会继承父标签的某些样式,如文本颜色和字号。恰当地使用继承可以简化代码,降低 CSS 样式的复杂性子元素可以继承父元素的样式(text-,font-,line-这些元素开头的可以继承,以及color属性)。

行高的继承性:

body {
 font:12px/1.5 Microsoft YaHei;
}
  • 行高可以跟单位也可以不跟单位
  • 如果子元素没有设置行高,则会继承父元素的行高为 1.5
  • 此时子元素的行高是:当前子元素的文字大小 * 1.5
  • body 行高 1.5 这样写法最大的优势就是里面子元素可以根据自己文字大小自动调整行高

优先级

当同一个元素指定多个选择器,就会有优先级的产生。选择器相同,则执行层叠性,选择器不同,则根据选择器权重执行。

Description

  • 权重是有4组数字组成,但是不会有进位。
  • 可以理解为类选择器永远大于元素选择器, id选择器永远大于类选择器,以此类推…
  • 等级判断从左向右,如果某一位数值相同,则判断下一位数值。
  • 可以简单记忆法:通配符和继承权重为0, 标签选择器为1,类(伪类)选择器 为 10,id选择器 100, 行内样式表为1000,!important 无穷大。
  • 继承的权重是0, 如果该元素没有直接选中,不管父元素权重多高,子元素得到的权重都是 0。

权重叠加:如果是复合选择器,则会有权重叠加,需要计算权重。

1.4 Css注释的使用

在CSS中,注释是非常重要的一部分,它们可以帮助你记录代码的意图,提供有关代码功能的信息。CSS注释以/开始,以/结束,注释内容在这两个标记之间。例如:

/* 这是一个注释 */
body {
    background-color: #f0f0f0; /* 背景颜色设置为浅灰色 */
}

在上面的例子中,"/* 这是一个注释 */"是注释内容,它不会影响网页的显示效果。

二、CSS选择器

在CSS中,选择器是核心组成部分,它定义了哪些HTML元素将会被应用对应的样式规则。以下是一些常用的CSS选择器类型:

2.1 基础选择器

基础选择器是由单个选择器组成的,包括:标签选择器、类选择器、id 选择器和通配符选择器。

2.1.1 标签选择器

标签选择器(元素选择器)是指用 HTML 标签名称作为选择器,按标签名称分类,为页面中某一类标签指定统一的 CSS 样式。

标签名{
 属性1: 属性值1;
 属性2: 属性值2;
 ...
}

标签选择器可以把某一类标签全部选择出来,比如所有的 <div> 标签和所有的 <span> 标签。

Description

优点:能快速为页面中同类型的标签统一设置样式。
缺点:不能设计差异化样式,只能选择全部的当前标签。

2.1.2 类选择器

想要差异化选择不同的标签,单独选一个或者某几个标签,可以使用类选择器,类选择器在 HTML 中以 class 属性表示,在 CSS 中,类选择器以一个点“.”号显示。

.类名 {
 属性1: 属性值1;
 ...
}

在标签class 属性中可以写多个类名,多个类名中间必须用空格分开。

2.1.3 id选择器

id 选择器可以为标有特定 id 的 HTML 元素指定特定的样式。
HTML 元素以 id 属性来设置 id 选择器,CSS 中 id 选择器以“#" 来定义。

#id名 {
 属性1: 属性值1;
 ...
}

注意:id 属性只能在每个 HTML 文档中出现一次。

2.1.4 通配符选择器

在 CSS 中,通配符选择器使用“*”定义,它表示选取页面中所有元素(标签)。

* {
 属性1: 属性值1;
 ...
}

2.1.5 基础选择器小结

Description

2.2 复合选择器

常用的复合选择器包括:后代选择器、子选择器、并集选择器、伪类选择器等等。

2.2.1 后代选择器

后代选择器又称为包含选择器,可以选择父元素里面子元素。其写法就是把外层标签写在前面,内层标签写在后面,中间用空格分隔。当标签发生嵌套时,内层标签就成为外层标签的后代。

元素1 元素2 { 样式声明 }
  • 元素1 和 元素2 中间用空格隔开
  • 元素1 是父级,元素2 是子级,最终选择的是元素2
  • 元素2 可以是儿子,也可以是孙子等,只要是元素1 的后代即可
  • 元素1 和 元素2 可以是任意基础选择器

2.2.2 子选择器

子元素选择器(子选择器)只能选择作为某元素的最近一级子元素。简单理解就是选亲儿子元素。

元素1 > 元素2 { 样式声明 }
  • 元素1 和 元素2 中间用 大于号 隔开
  • 元素1 是父级,元素2 是子级,最终选择的是元素2
  • 元素2 必须是亲儿子,其孙子、重孙之类都不归他管,也可以叫他亲儿子选择器

2.2.3 并集选择器

并集选择器是各选择器通过英文逗号(,)连接而成,任何形式的选择器都可以作为并集选择器的一部分。

元素1,元素2 { 样式声明 }

2.2.4 伪类选择器

伪类选择器用于向某些选择器添加特殊的效果,比如给链接添加特殊效果,或选择第1个,第n个元素。伪类选择器书写最大的特点是用冒号(:)表示,比如 :hover 、 :first-child 。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!
2.2.4.1 链接伪类选择器

为了确保生效,请按照 LVHA 的循顺序声明 :link-:visited-:hover-:active。因为 a 链接在浏览器中具有默认样式,所以我们实际工作中都需要给链接单独指定样式。

 /* a 是标签选择器 所有的链接 */ 
a {
 color: gray; 

/* :hover 是链接伪类选择器 鼠标经过 */
 a:hover {
 color: red; /* 鼠标经过的时候,由原来的 灰色 变成了红色 */
 }
2.2.4.2 :focus 伪类选择器

:focus 伪类选择器用于选取获得焦点的表单元素。焦点就是光标,一般情况 <input> 类表单元素才能获取,因此这个选择器也主要针对于表单元素来说。

input:focus {
 background-color:yellow;
}

2.2.5 复合选择器小结

Description
以上就是常用的css选择器的相关知识了,正确并灵活地运用各种选择器,可以精准地对页面中的任何元素进行样式设定。

通过这篇文章,相信你现在已经对CSS有了基础的了解,它是如何作为网页设计的基础,以及如何使用注释、选择器和优先级来精确控制你的网页样式。记住,CSS是一门艺术,也是一种科学,掌握它,你就能创造出无限可能的网页体验。

收起阅读 »

缓存把我坑惨了..

故事 春天,办公室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着外面美好的春光神游着... 一声不和谐的座机电话声打破这份本该属于小猫的宁静,“hi,小猫,线上有个客户想购买A产品规格的商品,投诉说下单总是失败,帮忙看一下啥原因。”客服部小姐姐甜美的声音...
继续阅读 »

故事


春天,办公室外的世界总是让人神往的,小猫带着耳机,托着腮帮,望着外面美好的春光神游着...


一声不和谐的座机电话声打破这份本该属于小猫的宁静,“hi,小猫,线上有个客户想购买A产品规格的商品,投诉说下单总是失败,帮忙看一下啥原因。”客服部小姐姐甜美的声音从电话那头传来。“哦哦,好,我看一下,把商品编号发一下吧......”


由于前一段时间的系统熟悉,小猫对现在的数据表模型已经了然于胸,当下就直接定位到了商品规格信息表,发现数据库中客户想购买的规格已经被下架了,但是前端的缓存好像并没有被刷新。


小猫在系统中找到了之前开发人员留的后门接口,直接curl语句重新刷新了一下接口,缓存问题搞定了。


关于商品缓存和数据库不一致的情况,其实小猫一周会遇到好几个这样的客诉,他深受DB以及缓存不一致的苦,于是他下定决心想要从根本上解决问题,而不是curl调用后门接口......


写在前面


小猫的态度其实还是相当值得肯定的,当他下定决心从根本上排查问题的时候开始,小猫其实就是一名合格而且负责的研发,这也是我们每一位软件研发人员所需要具备的处理事情的态度。


在软件系统演进的过程中,只有我们在修复历史遗留的问题的时候,才是真正意义上地对系统进行了维护,如果我们使用一些极端的手段(例如上述提到的后门接口curl语句)来保持古老而陈腐的代码继续工作的时候,这其实是一种苟且。一旦系统有了问题,我们其实就需要及时进行优化修复,否则会形成不好的示范,更多的后来者倾向于类似的方式解决问题,这也是为什么FixController存在的原因,这其实就是系统腐化的标志。


言归正传,关于缓存和DB不一致相信大家在日常开发的过程中都有遇到过,那么我们接下来就和大家好好盘一盘,缓存和DB不一致的时候,咱们是如何去解决的。接下来,大家会看到解决方案以及实战。


缓存概要


常规接口缓存读取更新


常规缓存读取


看到上面的图,我们可以清晰地知道缓存在实际场景中的工作原理。



  1. 发生请求的时候,优先读取缓存,如果命中缓存则返回结果集。

  2. 如果缓存没有命中,则回归数据库查询。

  3. 将数据库查询得到的结果集再次同步到缓存中,并且返回对应的结果集。


这是大家比较熟悉的缓存使用方式,可以有效减轻数据库压力,提升接口访问性能。但是在这样的一个架构中,会有一个问题,就是一份数据同时保存在数据库和缓存中,如果数据发生变化,需要同时更新缓存和数据库,由于更新是有先后顺序的,并且它不像数据库中多表事务操作满足ACID特性,所以这样就会出现数据一致性的问题。


DB和缓存不一致方案与实战DEMO


关于缓存和DB不一致,其实无非就是以下四种解决方案:



  1. 先更新缓存,再更新数据库

  2. 先更新数据库,再更新缓存

  3. 先删除缓存,后更新数据库

  4. 先更新数据库,后删除缓存


先更新缓存,再更新数据库(不建议)


cache02.png


这种方案其实是不提倡的,这种方案存在的问题是缓存更新成功,但是更新数据库出现异常了。这样会导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。


先更新数据库,再更新缓存


先更新数据库,再更新缓存,如果缓存更新失败了,其实也会导致数据库和缓存中的数据不一致,这样客户端请求过来的可能一直就是错误的数据。


cache03.png


先删除缓存,后更新数据库


这种场景在并发量比较小的时候可能问题不大,理想情况是应用访问缓存的时候,发现缓存中的数据是空的,就会从数据库中加载并且保存到缓存中,这样数据是一致的,但是在高并发的极端情况下,由于删除缓存和更新数据库非原子行为,所以这期间就会有其他的线程对其访问。于是,如下图。


cache04.png


解释一下上图,老猫罗列了两个线程,分别是线程1和线程2。



  1. 线程1会先删除缓存中的数据,但是尚未去更新数据库。

  2. 此时线程2看到缓存中的数据是空的,就会去数据库中查询该值,并且重新更新到缓存中。

  3. 但是此时线程1并没有更新成功,或者是事务还未提交(MySQL的事务隔离级别,会导致未提交的事务数据不会被另一个线程看到),由于线程2快于线程1,所以线程2去数据库查询得到旧值。

  4. 这种情况下最终发现缓存中还是为旧值,但是数据库中却是最新的。


由此可见,这种方案其实也并不是完美的,在高并发的情况下还是会有问题。那么下面的这种总归是完美的了吧,有小伙伴肯定会这么认为,让我们一起来分析一下。


先更新数据库,后删除缓存


先说结论,其实这种方案也并不是完美的。咱们通过下图来说一个比较极端的场景。


cache05.png


上图中,我们执行的时间顺序是按照数字由小到大进行。在高并发场景下,我们说一下比较极端的场景。


上面有线程1和线程2两个线程。其中线程1是读线程,当然它也会负责将读取的结果集同步到缓存中,线程2是写线程,主要负责更新和重新同步缓存。



  1. 由于缓存失效,所以线程1开始直接查询的就是DB。

  2. 此时写线程2开始了,由于它的速度较快,所以直接完成了DB的更新和缓存的删除更新。

  3. 当线程2完成之后,线程1又重新更新了缓存,那此时缓存中被更新之后的当然是旧值了。


如此,咱们又发现了问题,又出现了数据库和缓存不一致的情况。


那么显然上面的这四种方案其实都多多少少会存在问题,那么究竟如何去保持数据库和缓存的一致性呢?


保证强一致性


如果有人问,那我们能否保证缓存和DB的强一致性呢?回答当然是肯定的,那就是针对更新数据库和刷新缓存这两个动作加上锁。当DB和缓存数据完成同步之后再去释放,一旦其中任何一个组件更新失败,我们直接逆向回滚操作。我们可能还得做快照便于其历史缓存重写。那这种设计显然代价会很大。


其实在很大一部分情况下,要求缓存和DB数据强一致大部分都是伪需求。我们可能只要达到最终尽量保持缓存一致即可。有缓存要求的大部分业务其实也是能接受数据在短期内不一致的情况。所以我们就可以使用下面的这两种最终一致性的方案。


错误重试达到最终一致


如下示意图所示:


cache06.png


上面的图中我们看到。当然上述老猫只是画了更新线程,其实读取线程也一样。



  1. 更新线程优先更新数据,然后再去更新缓存。

  2. 此时我们发现缓存更新失败了,咱们就将其重新放到消息队列中。

  3. 单独写一个消费者接收更新失败记录,然后进行重试更新操作。


说到消息队列重试,还有一种方式是基于异步任务重试,咱们可以把更新缓存失败的这个数据保存到数据库,然后通过另外的一个定时任务进而扫描待执行任务,然后去做相关的缓存更新动作。


当然上面我们提到的这两种方案,其实比较依赖我们的业务代码做出相对应的调整。我们当然也可以借助Canal组件来监控MySQL中的binlog的日志。通过数据库的 binlog 来异步淘汰 key,利用工具(canal)将 binlog日志采集发送到 MQ 中,然后通过 ACK 机制确认处理删除缓存。先更新DB,然后再去更新缓存,这种方式,被称为 Cache Aside Pattern,属于缓存更新的经典设计模式之一。


cache07.png


上述我们总结了缓存使用的一些方案,我们发现其实没有一种方案是完美的,最完美的方案其实还是得去结合具体的业务场景去使用。方案已经同步了,那么如何去撸数据库以及缓存同步的代码呢?接下来,和大家分享的当然是日常开发中比较好用的SpringCache缓存处理框架了。


SpringCache实战


SpringCache是一个框架,实现了基于注解缓存功能,只需要简单地加一个注解,就能实现缓存功能。
SpringCache提高了一层抽象,底层可以切换不同的cache实现,具体就是通过cacheManager接口来统一不同的缓存技术,cacheManager是spring提供的各种缓存技术抽象接口。


目前存在以下几种:



  • EhCacheCacheManager:将缓存的数据存储在内存中,以提高应用程序的性能。

  • GuavaCaceManager:使用Google的GuavaCache作为缓存技术。

  • RedisCacheManager:使用Redis作为缓存技术。


配置


我们日常开发中用到比较多的其实是redis作为缓存,所以咱们就可以用RedisCacheManager,做一下代码演示。咱们以springboot项目为例。


老猫这里拿看一下redisCacheManager来举例,项目开始的时候我们当忽然要在pom文件依赖的时候就肯定需要redis启用项。如下:


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--使用注解完成缓存技术-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

因为我们在application.yml中就需要配置redis相关的配置项:


spring:
redis:
host: localhost
port: 6379
database: 0
jedis:
pool:
max-active: 8 # 最大链接数据
max-wait: 1ms # 连接池最大阻塞等待时间
max-idle: 4 # 连接线中最大的空闲链接
min-idle: 0 # 连接池中最小空闲链接
cache:
redis:
time-to-live: 1800000

常用注解


关于SpringCache常用的注解,整理如下:


cache08.png


针对上述的注解,咱们做一下demo用法,如下:


用法简单盘点


@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class);
}
}

在service层我们注入所需要用到的cacheManager:


@Autowired
private CacheManager cacheManager;

/**
* 公众号:程序员老猫
* 我们可以通过代码的方式主动清除缓存,例如
**/

public void clearCache(String productCode) {
try {
RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;

Cache backProductCache = redisCacheManager.getCache("backProduct");
if(backProductCache != null) {
backProductCache.evict(productCode);
}
} catch (Exception e) {
logger.error("redis 缓存清除失败", e);
}
}

接下来我们看一下每一个注解的用法,以下关于缓存用法的注解,我们都可以将其加到dao层:


第一种@Cacheable


在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中。


@Cacheable 注解中的核心参数有以下几个:



  • value:缓存的名称,可以是一个字符串数组,表示该方法的结果可以被缓存到哪些缓存中。默认值为一个空数组,表示缓存到默认的缓存中。

  • key:缓存的 key,可以是一个 SpEL 表达式,表示缓存的 key 可以根据方法参数动态生成。默认值为一个空字符串,表示使用默认的 key 生成策略。

  • condition:缓存的条件,可以是一个 SpEL 表达式,表示缓存的结果是否应该被缓存。默认值为一个空字符串,表示不考虑任何条件,缓存所有结果。

  • unless:缓存的排除条件,可以是一个 SpEL 表达式,表示缓存的结果是否应该被排除在缓存之外。默认值为一个空字符串,表示不排除任何结果。


上述提及的SpEL是是Spring Framework中的一种表达式语言,此处不展开,不了解的小伙伴可以自己去查阅一下相关资料。


代码使用案例:


@Cacheable(value="picUrlPrefixDO",key="#id")
public PicUrlPrefixDO selectById(Long id) {
PicUrlPrefixDO picUrlPrefixDO = writeSqlSessionTemplate.selectOne("PicUrlPrefixDao.selectById", id);
return picUrlPrefixDO;
}

第二种@CachePut


表示将方法返回的值放入缓存中。
注解的参数列表和@Cacheable的参数列表一致,代表的意思也一样。
代码使用案例:


@CachePut(value = "userCache",key = "#users.id")
@GetMapping()
public User get(User user){
User users= dishService.getById(user);
return users;
}

第三种@CacheEvict


表示从缓存中删除数据。使用案例如下:


@CacheEvict(value="picUrlPrefixDO",key="#urfPrefix")
public Integer deleteByUrlPrefix(String urfPrefix) {
return writeSqlSessionTemplate.delete("PicUrlPrefixDao.deleteByUrlPrefix", urfPrefix);
}

上述和大家分享了一下SpringCache的用法,对于上述提及的三个缓存注解中,老猫在日常开发过程中用的比较多的是@CacheEvict以及@Cacheable,如果对SpringCache实现原理感兴趣的小伙伴可以查阅一下相关的源码。


使用缓存的其他注意点


当我们使用缓存的时候,除了会遇到数据库和缓存不一致的情况之外,其实还有其他问题。严重的情况下可能还会出现缓存雪崩。关于缓存失效造成雪崩,大家可以看一下这里【糟糕!缓存击穿,商详页进不去了】。


另外如果加了缓存之后,应用程序启动或服务高峰期之前,大家一定要做好缓存预热从而避免上线后瞬时大流量造成系统不可用。关于缓存预热的解决方案,由于篇幅过长老猫在此不展开了。不过方案概要可以提供,具体如下:



  • 定时预热。采用定时任务将需要使用的数据预热到缓存中,以保证数据的热度。

  • 启动时加载预热。在应用程序启动时,将常用的数据提前加载到缓存中,例如实现InitializingBean 接口,并在 afterPropertiesSet 方法中执行缓存预热的逻辑。

  • 手动触发加载:在系统达到高峰期之前,手动触发加载常用数据到缓存中,以提高缓存命中率和系统性能。

  • 热点预热。将系统中的热点数据提前加载到缓存中,以减轻系统压力。5

  • 延迟异步预热。将需要预热的数据放入一个队列中,由后台异步任务来完成预热。

  • 增量预热。按需预热数据,而不是一次性预热所有数据。通过根据数据的访问模式和优先级逐步预热数据,以减少预热过程对系统的冲击。


如果小伙伴们还有其他的预热方式也欢迎大家留言。


总结


上述总结了关于缓存在日常使用的时候的一些方案以及坑点,当然这些也是面试官最喜欢提问的一些点。文中关于缓存的介绍老猫其实并没有说完,很多其实还是需要小伙伴们自己去抽时间研究研究。不得不说缓存是一门以空间换时间的艺术。要想使用好缓存,死记硬背策略肯定是行不通的。真实的业务场景往往要复杂的多,当然解决方案也不同,老猫上面提及的这些大家可以做一个参考,遇到实际问题还是需要大家具体问题具体分析。


作者:程序员老猫
来源:juejin.cn/post/7345729950458282021
收起阅读 »

HTML表单标签详解:如何用HTML标签打造互动网页?

在互联网的世界中,表单是用户与网站进行互动的重要桥梁。无论是注册新账号、提交反馈、还是在线购物,表单都扮演着至关重要的角色。在网页中,我们需要跟用户进行交互,收集用户资料,此时就需要用到表单标签。HTML提供了一系列的表单标签,使得开发者能够轻松地创建出功能丰...
继续阅读 »

在互联网的世界中,表单是用户与网站进行互动的重要桥梁。无论是注册新账号、提交反馈、还是在线购物,表单都扮演着至关重要的角色。在网页中,我们需要跟用户进行交互,收集用户资料,此时就需要用到表单标签。

HTML提供了一系列的表单标签,使得开发者能够轻松地创建出功能丰富的表单。今天我们就来深入探讨这些标签,了解它们的作用以及如何使用它们来构建一个有效的用户界面。

一、表单的组成

在HTML中,一个完整的表单通常由表单域、表单控件(表单元素)和提示信息三个部分构成。

表单域

  • 表单域是一个包含表单元素的区域
  • 在HTML标签中,<form>标签用于定义表单域,以实现用户信息的收集和传递
  • <form>会把它范围内的表单元素信息提交给服务器

表单控件

这些是用户与表单交云的各种元素,如<input>(用于创建不同类型的输入字段)、<textarea>(用于多行文本输入)、<button>(用于提交表单或执行其他操作)、<select><option>(用于创建下拉列表)等。

提示信息

这些信息通常通过<label>标签提供,它为表单控件提供了描述性文本,有助于提高可访问性。<label>标签通常与<input>标签一起使用,并且可以通过for属性与<input>标签的id属性关联起来。

这三个部分共同构成了一个完整的HTML表单,使得用户可以输入数据,并通过点击提交按钮将这些数据发送到Web服务器进行处理。

二、表单元素

在表单域中可以定义各种表单元素,这些表单元素就是允许用户在表单中输入或者选择的内容控件。下面就来介绍HTML中常用的表单元素。

1、<form>标签:基础容器

作用:定义一个表单区域,用户可以在其中输入数据进行提交。

<form action="submit.php" method="post">

其中action属性指定了数据提交到的服务器端脚本地址,method属性定义了数据提交的方式(通常为GET或POST)。

2、<input>标签:数据输入

<input>标签是一个单标签,用于收集用户信息。允许用户输入文本、数字、密码等。

<input type="text" name="username" placeholder="请输入用户名">

type属性决定了输入类型,name属性定义了数据的键名,placeholder属性提供了输入框内的提示文本。

<input>标签的属性

Description

下面举个例子来说明:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <form>
               用户名:<input type="text" value="请输入用户名"><br> 

               密码:<input type="password"><br>

      性别:男<input type="radio" name="sex" checked="checked"><input type="radio" name="sex"><br>

               爱好:吃饭<input type="checkbox"> 睡觉<input type="checkbox"> 打豆豆<input type="checkbox"><br>

                <input type="submit" value="免费注册">
                <input type="reset" value="重新填写">
                <input type="button" value="获取短信验证码"><br>
                上传头像:<input type="file">
    </form>
</body>
</html>

Description

3、<label>标签:关联说明

它与输入字段如文本框、单选按钮、复选框等关联起来,以改善网页的可用性和可访问性。<label>标签有两种常见的用法:

1)包裹方式:

在这种用法中,<label>标签直接包裹住关联的表单元素。例如:

<label>用户名:<input type="text" name="username"></label>

这样做的好处是用户点击标签文本时,关联的输入字段会自动获取焦点,从而提供更好的用户体验。

2)使用for属性关联:

在这种用法中,<label>标签通过for属性与目标表单元素建立关联,for属性的值应与目标元素的id属性相匹配。例如:

<label for="username">用户名:</label><input type="text" id="username" name="username">

这样做的优势是单击标签时,相关的表单元素会自动选中(获取焦点),从而提高可用性和可访问性。

4、<select>和<option>标签:下拉选择

在页面中,如果有多个选项让用户选择,并且想要节约页面空间时,我们可以使用标签控件定义下拉列表。

注意点:

  • <select>中至少包含一对<option>
  • 在<option>中定义selected=“selected”时,当前项即为默认选中项
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <form>
        籍贯:
        <select>
            <option>山东</option>
            <option>北京</option>
            <option>西安</option>
            <option selected="selected">火星</option>
        </select>
    </form>
</body>
</html>

Description

5、<textarea>标签:多行文本输入

当用户输入内容较多的情况下,我们可以用表单元素标签替代文本框标签。

  • 允许用户输入多行文本。
<textarea name="message" rows="5" cols="30">默认文本</textarea>

rows和cols属性分别定义了文本区域的行数和列数。

Description

6、<button>标签:按钮控件

创建一个可点击的按钮,通常用于提交或重置表单。它允许用户放置文本或其他内联元素(如<i><b><strong><br><img>等),这使得它比普通的具有更丰富的内容和更强的功能。

<button type="submit">提交</button>

type属性为submit时表示这是一个提交按钮。

7、<fieldset>和<legend>标签:分组和标题

通常用于在HTML表单中对相关元素进行分组,并提供一个标题来描述这个组的内容。

<fieldset>标签: 该标签用于在表单中创建一组相关的表单控件。它可以将表单元素逻辑分组,并且通常在视觉上通过围绕这些元素绘制一个边框来区分不同的组。这种分组有助于提高表单的可读性和易用性。

<legend>标签: 它总是与<fieldset>标签一起使用。<legend>标签定义了<fieldset>元素的标题,这个标题通常会出现在浏览器渲染的字段集的边框上方。<legend>标签使得用户更容易理解每个分组的目的和内容。

代码示例:

<form>
  <fieldset>
    <legend>个人信息</legend>
    <label for="name">姓名:</label>
    <input type="text" id="name" name="name"><br><br>
    <label for="email">邮箱:</label>
    <input type="email" id="email" name="email"><br><br>
  </fieldset>
  <fieldset>
    <legend>兴趣爱好</legend>
    <input type="checkbox" id="hobby1" name="hobby1" value="music">
    <label for="hobby1">音乐</label><br>
    <input type="checkbox" id="hobby2" name="hobby2" value="sports">
    <label for="hobby2">运动</label><br>
    <input type="checkbox" id="hobby3" name="hobby3" value="reading">
    <label for="hobby3">阅读</label><br>
  </fieldset>  <input type="submit" value="提交">
</form>

在这个示例中,我们使用了两个<fieldset>元素来组织表单的不同部分。第一个<fieldset>包含姓名和邮箱字段,而第二个<fieldset>包含三个复选框,用于选择用户的兴趣爱好。每个<fieldset>都有一个<legend>元素,用于提供标题。这样,用户在填写表单时可以更清晰地了解每个部分的内容。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

8、<datalist>标签:预定义选项列表

<datalist>标签是HTML5中引入的一个新元素,它允许开发者为输入字段提供预定义的选项列表。当用户在输入字段中输入时,浏览器会显示一个下拉菜单,其中包含与用户输入匹配的预定义选项。

使用<datalist>标签可以提供更好的用户体验,因为它可以帮助用户选择正确的选项,而不必手动输入整个选项。此外,<datalist>还可以与<input>元素的list属性结合使用,以将预定义的选项列表与特定的输入字段关联起来。

下面是一个使用<datalist>标签的代码示例:

<form>
  <label for="color">选择你喜欢的颜色:</label>
  <input type="text" id="color" name="color" list="colorOptions">
  <datalist id="colorOptions">
    <option value="红色">
    <option value="蓝色">
    <option value="绿色">
    <option value="黄色">
    <option value="紫色">
  </datalist>
  <input type="submit" value="提交">
</form>

9、<output>标签:计算结果输出

<output>标签是HTML5中引入的一个新元素,它用于显示计算结果或输出。该标签通常与JavaScript代码结合使用,通过将计算结果赋值给<output>元素的value属性来显示结果。

<output>标签可以用于各种类型的计算和输出,例如数学运算、字符串处理、数组操作等。它可以与<input>元素一起使用,以实时更新计算结果。

下面是一个使用<output>标签的示例:

<form>
  <label for="num1">数字1:</label>
  <input type="number" id="num1" name="num1" oninput="calculate()"><br><br>
  <label for="num2">数字2:</label>
  <input type="number" id="num2" name="num2" oninput="calculate()"><br><br>
  <label for="result">结果:</label>
  <output id="result"></output>
</form>

<script>
function calculate() {
  var num1 = parseInt(document.getElementById("num1").value);
  var num2 = parseInt(document.getElementById("num2").value);
  var result = num1 + num2;  document.getElementById("result").value = result;
}
</script>

10、<progress>标签:任务进度展示

<progress>标签是HTML5中用于表示任务完成进度的一个新元素。它通过value属性和max属性来表示进度,其中value表示当前完成的值,而max定义任务的总量或最大值。

示例:

<!DOCTYPE html>
<html>
<head>
  <title>Progress Example</title>
</head>
<body>
  <h1>File Download</h1>
  <progress id="fileDownload" value="0" max="100"></progress>
  <br>
  <button onclick="startDownload()">Start Download</button>

  <script>
    function startDownload() {
      var progress = document.getElementById("fileDownload");
      for (var i = 0; i <= 100; i++) {
        setTimeout(function() {
          progress.value = i;
        }, i * 10);
      }
    }
  </script>
</body>
</html>

Description

在上面的示例中,我们创建了一个名为"fileDownload"的<progress>元素,并设置了初始值为0,最大值为100。我们还添加了一个按钮,当用户点击该按钮时,会触发名为"startDownload"的JavaScript函数。这个函数模拟了一个文件下载过程,通过循环逐步增加<progress>元素的value属性值,从而显示下载进度。

11、<meter>标签:度量衡指示器

<meter>标签在HTML中用于表示度量衡指示器,它定义了一个已知范围内的标量测量值或分数值,通常用于显示磁盘使用情况、查询结果的相关性等。例如:

<p>CPU 使用率: <meter value="0.6" min="0" max="1"></meter> 60%</p>
<p>内存使用率: <meter value="0.4" min="0" max="1"></meter> 40%</p>

在这个示例中,我们使用了两个<meter>标签来分别显示CPU和内存的使用率。value属性表示当前的测量值,min和max属性分别定义了测量范围的最小值和最大值。通过这些属性,<meter>标签能够清晰地显示出资源的使用情况。

需要注意的是,<meter>标签不应该用来表示进度条,对于进度条的表示,应该使用<progress>标签。

12、<details><summary>标签:详细信息展示

<details><summary>标签是HTML5中新增的两个元素,用于创建可折叠的详细信息区域。

<details>标签定义了一个可以展开或折叠的容器,其中包含一些额外的信息。它通常与<summary>标签一起使用,<summary>标签定义了<details>元素的标题,当用户点击该标题时,<details>元素的内容会展开或折叠。

示例:

<details>
  <summary>点击查看详细信息</summary>
  <p>这里是一些额外的信息,用户可以点击标题来展开或折叠这些信息。</p>
</details>

在这个示例中,我们使用了<details>标签来创建一个可折叠的容器,并在其中添加了一个<summary>标签作为标题。当用户点击这个标题时,容器的内容会展开或折叠。

总结:

HTML表单标签是构建动态网页的基石,它们使得用户能够与网站进行有效的交互。通过合理地使用这些标签,开发者可以创建出既美观又功能强大的表单,从而提升用户体验和网站的可用性。所以说,掌握这些标签的使用,对于前端开发者来说是至关重要的。

收起阅读 »

git 如何撤回已push的代码

在日常的开发中,我们经常使用Git来进行版本控制。有时候,我们可能会不小心将错误的代码 Push 到远程仓库,或者想要在本地回退到之前的某个版本重新开发。 或者像我一样,写了一些感觉以后很有用的优化方案push到线上,又接到了一个新的需求。但是呢,项目比较重要...
继续阅读 »



在日常的开发中,我们经常使用Git来进行版本控制。有时候,我们可能会不小心将错误的代码 Push 到远程仓库,或者想要在本地回退到之前的某个版本重新开发。


或者像我一样,写了一些感觉以后很有用的优化方案push到线上,又接到了一个新的需求。但是呢,项目比较重要,没有经过测试的方案不能轻易上线,为了承接需求只能先把push上去的优化方案先下掉。


现在我的分支是这样的,我想要在本地和远程仓库中都恢复到help文档提交的部分。


image.png

1.基础的手动操作(比较笨,不推荐)



这样的操作非常不推荐,但是如果你不了解git,确实是我们最容易理解的方式。



如果你的错误代码不是很多,那么你其实可以通过与你想要恢复到的commit进行对比,然后手动删除错误代码,然后删除不同的代码。


image.png

按住 ctrl 选择想要对比的两个commit,然后选择 Compare Versions 就能通过对比删除掉你想要删除的代码。



这个方案在代码很简单时时非常有效的,甚至还能通过删除后最新commit和想要退回的commit在Compare一下保障代码一致。


但是这个方法对于代码比较复杂的情况来说就不太好处理了,如果涉及到繁杂的配置文件,那更是让人头疼。只能通过反复的Compare Version来进行对比。


这样的手动操作显然显得有些笨拙了,对此git有一套较为优雅的操作流程,同样能解决这个问题。


2. git Revert Commit(推荐)


image.png

同样的,我第三次提交了错误代码,并且已经push到远程分支。想要撤回这部分代码,只需要右键点击错误提交记录


image.png

git自动产生一个Revert记录,然后我们会看到git自动将我第三次错误提交代码回退了,这个其实就相当于git帮我们手动回退了代码。


image.png

后续,只需要我们将本次改动push到远程,即可完成一次这次回退操作,


image.png

revert相当于自动帮我们进行版本回退操作,并且留下改动记录,非常安全。这也是评论区各位大佬非常推荐的。



但是revert还是存在一点不足,即一次仅能回退一次push。如果我们有几十次甚至上百次的记录,一次次的单击回退不仅费时费力而且还留下了每次的回退记录,我个人觉得revert在这种情况下又不太优雅。


3. 增加新分支(推荐撤回较多情况下使用)


如果真的需要回退到上百次提交之前的版本,我的建议是直接新建个分支。


在想要回到的版本处的提交记录右键,点击new branch


image.png
image.png
image.png

新建分支的操作仅仅增加了一个分支,既能保留原来的版本,又能安全回退到想要回退的版本,同时不会产生太多的回退记录。


但是此操作仍然建议慎用,因为这个操作执行多了,分支管理就又成了一大难题。



4. Reset Current Branch 到你想要恢复的commit记录(不太安全,慎用)


image.png


这个时候会跳出四个选项供你选择,我这里是选择hard


其他选项的含义仅供参考,因为我也没有一一尝试过。




  1. Soft:你之前写的不会改变,你之前暂存过的文件还在暂存。

  2. Mixed:你之前写的不会改变,你之前暂存过的文件不会暂存。

  3. Hard:文件恢复到所选提交状态,任何更改都会丢失。
    你已经提交了,然后你又在本地更改了,如果你选hard,那么提交的内容和你提交后又本地修改未提交的内容都会丢失。

  4. keep:任何本地更改都将丢失,文件将恢复到所选提交的状态,但本地更改将保持不变。
    你已经提交了,然后你又在本地更改了,如果你选keep,那么提交的内容会丢失,你提交后又本地修改未提交的内容不会丢失。



image.png

image.png


image.png


然后,之前错误提交的commit就在本地给干掉了。但是远程仓库中的提交还是原来的样子,你要把目前状态同步到远程仓库。也就是需要把那几个commit删除的操作push过去。


打开push界面,虽然没有commit需要提交,需要点击Force Push,强推过去。
image.png


需要注意的是对于一些被保护的分支,这个操作是不能进行的。需要自行查看配置,我这里因为不是master分支,所以没有保护。


image.png

可以看到,远程仓库中最新的commit只有我们的help文档。在其上的三个提交都没了。


image.png

注意:以上使用的是2023版IDEA,如果有出入的话可以考虑搜索使用git命令。


作者:DaveCui
来源:juejin.cn/post/7307066452290043958
收起阅读 »

神奇!一个命令切换测试和线上环境?

大家好,我是喜欢折腾,热爱分享的“一只韩非子”。 关注微信公众号:会编程的韩非子 添加微信号:Hanfz0712 免费加入问答群/知识交流群,一起交流技术难题与未来,让我们Geek起来! 今天跟大家分享一个小Tips,让大家能够更快的切换测试和线上环境。 1...
继续阅读 »

大家好,我是喜欢折腾,热爱分享的“一只韩非子”。

关注微信公众号:会编程的韩非子

添加微信号:Hanfz0712

免费加入问答群/知识交流群,一起交流技术难题与未来,让我们Geek起来!



今天跟大家分享一个小Tips,让大家能够更快的切换测试和线上环境。


1.前因


不知道大家会不会在开发中经常遇到需要切换测试环境和线上环境。比如本地开发完成后需要部署到测试环境查看,然后就需要在我们的主机上配置测试环境的DNS,从而使得相同的域名能够从线上指向到测试环境。


image.png


image.png
我们发现,需要点啊点啊点,真的太头痛了。估计配置玩这个,代码已经忘记写到哪一行了。


下载.jpeg


所以我们有没有更简单的方式来配置DNS呢,经过小韩一顿小脑瓜的思考,想起来那我们能不能够通过命令行代码的方式来解决这个问题呢。哎,你别说还真可以。

一般系统级的配置除了可视化操作外,会有对应的命令行代码(划重点喽)


2.上命令


又是熟悉的一顿Goole后,终于让我找到了,出来吧,My Code!


# 配置DNS
# Mac
networksetup -setdnsservers Wi-Fi x.x.x.x

# Windows
# WiFi
netsh interface ip set dns name="Wi-Fi" static x.x.x.x
# 网线 具体网线连接的名称例:本地连接、以太网...
netsh interface ip set dns name="具体网线连接的名称" static x.x.x.x

哎没错,就是上面这几个。但是!但是!但是!这他喵的也真的太长了!!!我还不如点啊点啊点!好好好把我骗进来杀是吧。


下载 (1).jpeg


别急别急,知道好兄弟记不住,所以我还有一招。那就是别名

友情提示:记忆力非常不错手速又特别快的好兄弟,可以点击左侧页面第一个大拇指和第三个小星星,然后退出群聊了。


3.什么是别名?


那么什么是别名呢?,让我们Google一波,找到你了。


image.png


啊?不对不对,搜索的姿势不对,让我们换个姿势。


image.png
这下姿势就对了,我们得到了我们的答案,原来别名就是用一个简单的命令替代完整的命令,好兄弟们有福了。


4.我要用别名!


别名需要存放在我们的配置文件中,文件的地址是:

Mac:~/.zshrc~/.bashrc。可以通过命令echo $SHELL查看默认使用的是zsh还是bash,来选择对应的配置文件。

Windows:查看文末


在Mac下我们别名的语法为:
alias 别名名称='具体的命令'

名称选择一个自己喜欢的即可,但是注意不要与已经注册的别名重复了,我们可以输入alias命令查看已经注册的别名。

所以我们最终的配置为:


# 别名配置
# 配置测试环境DNS
alias dtest='networksetup -setdnsservers Wi-Fi x.x.x.x'
# 清除测试环境DNS
alias dclear='networksetup -setdnsservers Wi-Fi empty'

然后我们输入dtest就可以进入测试环境,输入dclear就可以回到线上环境了,你也可以继续配置自己的预发环境等等。这简直太妙了。


634da736e9f24u06.gif







最后的最后,留给好兄弟们一个小作业

检索一下Windows下如何配置别名:别名的家在哪里,语法是什么


images.jpeg


作者:一只韩非子
来源:juejin.cn/post/7347161048572968975
收起阅读 »

回顾我这三年,都是泡沫

昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。 刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup… 虽然没见过面,不知道他长什么...
继续阅读 »

朋友圈


昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。


刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup…


虽然没见过面,不知道他长什么样,在我脑海里,他就是两样放着光,对技术充满好奇心、自我驱动力很强小伙子。


我就知道他能成,因为我多少也是这样子的,尽管我现在有些倦怠。


后来,随着工作越来越忙,博客也停更了,我们便很少联系了。


不过,后面我招人,尤其是校招生或者初级开发,我都是按照他这个范本来的。我也时常跟别人提起,我认识北京这样一个小伙子。


也有可能我们这边庙太小了,这样的小伙伴屈指可数。


平台和好奇心一样重要


大部分人智商条件不会有太多的差距,尤其是程序员这个群体,而好奇心可以让你比别人多迈出一步,经过长时间的积累就会拉开很大的差距。


而平台可以让你保持专注,与优秀的人共事,获得更多专业的经验和知识、财富,建立自己的竞争壁垒。








回到正题。


我觉得是时候阶段性地总结和回望回顾我过去这三年,却发现大部分都是泡沫。跨端、业务、质量管理、低代码、领域驱动设计... 本文话题可能会比较杂




2020 年七月,口罩第二年。我选择了跳槽,加入了一家创业公司




跨端开发的泡沫


2020 年,微信小程序已经成为国内重要的流量入口,事实也证明,我们过去几年交付的 C 端项目几乎上都是小程序。更严谨的说,应该是微信小程序,尽管很多巨头都推出了自己的小程序平台,基本上都是陪跑的。




Taro 2.x


进来后接手的第一个项目是原生小程序迁移到 Taro。


那时候,我们的愿景是“一码多端”,期望一套程序能够跑在微信小程序、支付宝小程序等小程序平台、H5、甚至是原生 App。


那时候 Taro 还是 2.x 版本,即通过语法静态编译成各端小程序的源码。


我们迁移花了不少的时间,尽管 Taro 官方提供了自动转换的工具,但是输出的结果是不可靠的,我们仍需要进行全量的回归测试,工作量非常大。 期间我也写了一个自动化代码迁移 CLI 来处理和 Lint 各种自动迁移后的不规范代码。




重构迁移只是前戏。难的让开发者写好 Taro,更难的是写出跨端的 Taro 代码。




我总结过,为什么 Taro(2.x) 这么难用:



  • 很多初级开发者不熟悉 React。在此之前技术栈基本是 Vue

  • 熟悉 React 的却不熟悉 Taro 的各种约束。

  • 即使 Taro 宣称一码多端,你还是需要了解对应平台/端的知识。 即使是小程序端,不同平台的小程序能力和行为都有较大的区别。而 Taro 本身在跨端上并没有提供较好的约束,本身 Bug 也比较多。

  • 如果你有跨端需求,你需要熟知各端的短板,以进行权衡和取舍。强调多端的一致和统一会增加很多复杂度, 对代码的健壮性也是一个比较大的考验。

  • 我们还背着历史包袱。臃肿、不规范、难以维护、全靠猜的代码。




在跨端上,外行人眼里‘一码多端’就是写好一端,其他端不用改就可以直接运行起来,那有那么简单的事情?


每个端都有自己的长板和短板:


短板效应


我们从拆分两个维度来看各端的能力:


维度




放在一个基线上看:


对比


跨端代码写不好,我们不能把锅扔给框架,它仅仅提供了一种通用的解决方案,很多事情还是得我们自己去做。




实际上要开发跨平台的程序,最好的开发路径就是对齐最短的板,这样迁移到其他端就会从而很多,当然代价就是开发者负担会很重:


路径


为了让开发者更好的掌握 Taro, 我编写了详细的 Wiki, 阐述了 React 的各种 trickTaro 如何阉割了 ReactTaro 的原理、开发调试、跨端开发应该遵循的各种规范






Taro 3.0


我们的 Taro 项目在 2020 年底正式在生产使用,而 Taro 3.0 在 2020 年 / 7 月就正式发布了,在次年 5 月,我们决定进行升级。


技术的发展就是这么快,不到 5 个月时间,Taro 2.x 就成为了技术债。


Taro 2.x 官方基本停止了新功能的更新、bug 也不修了,最后我们不得不 Fork Taro 仓库,发布在私有 npm 镜像库中。




Taro 2.x 就是带着镣铐跳舞,实在是太痛苦,我写了一篇文档来历数了它的各种‘罪行’:



  • 2.x 太多条条框框,学习成本高

  • 这是一个假的 React

  • 编译慢

  • 调试也太反人类







Taro 3.x 使用的是动态化的架构,有很多优势:


3.x 架构 和数据流


3.x 架构 和数据流



  • 动态化的架构。给未来远程动态渲染、低代码渲染、使用不同的前端框架(支持 Vue 开发)带来了可能

  • 不同端视图渲染方式差异更小,更通用,跨端兼容性更好。

  • 2.x 有非常多的条条框框,需要遵循非常多的规范才能写出兼容多端的代码。3.x 使用标准 React 进行开发,有更好的开发体验、更低的学习成本、更灵活的代码组织。

  • 可以复用 Web 开发生态。




使用类似架构的还有 Remax、Alita、Kbone, 我之前写过一篇文章实现的细节 自己写个 React 渲染器: 以 Remax 为例(用 React 写小程序)




而 Taro 不过是新增了一个中间层:BOM/DOM,这使得 Taro 不再直接耦合 React, 可以使用任意一种视图框架开发,可以使用 Vue、preact、甚至是 jQuery, 让 Web 生态的复用成为可能。




升级 3.x 我同样通过编写自动化升级脚本的形式来进行,这里记录了整个迁移的过程。








重构了再重构


我在 2B or not 2B: 多业态下的前端大泥球 讲述过我们面临的困境。


21 年底,随着后端开启全面的 DDD 重构(推翻现有的业务,重新梳理,在 DDD 的指导下重新设计和开发),我们也对 C 端进行了大规模的重构,企图摆脱历史债务,提高后续项目的交付效率




C 端架构


上图是重构后的结果,具体过程限于篇幅就不展开了:





  • 基础库:我们将所有业务无关的代码重新进行了设计和包装。

    • 组件库:符合 UI 规范的组件库,我们在这里也进行了一些平台差异的抹平

    • api: Taro API 的二次封装,抹平一些平台差异

    • utils: 工具函数库

    • rich-html、echart:富文本、图表封装

    • router:路由导航库,类型安全、支持路由拦截、支持命名导航、简化导航方法…




  • 模块化:我们升级到 Taro 3.x 之后,代码的组织不再受限于分包和小程序的约束。我们将本来单体的小程序进行了模块的拆分,即 monorepo 化。按照业务的边界和职责拆分各种 SDK

  • 方案:一些长期积累开发痛点解决方案,比如解决分包问题的静态资源提取方案、解决页面分享的跳板页方案。

  • 规范和指导实现。指导如何开发 SDK、编写跨平台/易扩展的应用等等




巨头逐鹿的小程序平台,基本上是微信小程序一家独大


跨端框架,淘汰下来,站稳脚跟的也只有 taro 和 uniapp


时至今日,我们吹嘘许久的“一码多端”实际上并没有实现;








大而全 2B 业务的泡沫


其实比一码多端更离谱的事情是“一码多业态”。


所谓一码多业态指的是一套代码适配多个行业,我在 2B or not 2B: 多业态下的前端大泥球 中已经进行了深入的探讨。


这是我过去三年经历的最大的泡沫,又称屎山历险记。不要过度追求复用,永远不要企图做一个大而全的 2B 产品






低代码的泡沫


2021 年,低代码正火,受到的资本市场的热捧。


广义的低代码就是一个大箩筐,什么都可以往里装,比如商城装修、海报绘制、智能表格、AI 生成代码、可视化搭建、审核流程编排…


很多人都在蹭热点,只要能粘上一点边的,都会包装自己是低代码,包括我们。在对外宣称我们有低代码的时候,我们并没有实际的产品。现在 AI 热潮类似,多少声称自己有大模型的企业是在裸泳呢?




我们是 2B 赛道,前期项目交付是靠人去堆的,效率低、成本高,软件的复利几乎不存在。


低代码之风吹起,我们也期望它能破解我们面临的外包难题(我们自己都在质疑这种软件交付方式和外包到底有什么区别)。


也有可能是为了追逐资本热潮,我们也规划做自己的 PaaS、aPaaS、iPaaS… 各种 “aaS”(不是 ass)。


但是我们都没做成,规划和折腾了几个月,后面不了了之,请来的大神也送回去了。




在我看来,我们那时候可能是钱多的慌。但并没有做低代码的相关条件,缺少必要的技术积累和资源。就算缩小范围,做垂直领域的低代码,我们对领域的认知和积累还是非常匮乏。




在这期间, 我做了很多调研,也单枪匹马撸了个 “前端可视化搭建平台”:


低代码平台


由于各种原因, 这个项目停止了开发。如今社区上也有若干个优秀的开源替代物,比如阿里的低代码引擎、网易云的 Tango、华为云的 TinyEngine。如果当年坚持开发下去,说不定今天也小有成就了。




不管经过这次的折腾,我越坚信,低代码目前还不具备取代专业编程的能力。我在《前端如何破解 CRUD 的循环》也阐述过相关的观点。


大型项目的规模之大、复杂度之深、迭代的周期之长,使用低代码无疑是搬石头砸自己的脚。简单预想一下后期的重构和升级就知道了。




低代码的位置


低代码是无代码和专业编码之间的中间形态,但这个中间点并不好把握。比如,如果倾向专业编码,抽象级别很低,虽然变得更加灵活,但是却丧失了易用性,最终还是会变成专业开发者的玩具。


找对场景,它就是一把利器。不要期望它能 100% 覆盖专业编码,降低预期,覆盖 10%?20%?再到 30%? 已经是一个不错的成就。


低代码真正可以提效不仅在于它的形式(可视化),更在于它的生态。以前端界面搭建为例,背后开箱即用的组件、素材、模板、应用,才是它的快捷之道。


在我看来,低代码实际上并不是一个新技术,近年来火爆,更像是为了迎合资本的炒作而稍微具象化的概念。


而今天,真正的’降本增效‘的大刀砍下来,又有多少’降本增效‘的低代码活下来了呢?








质量管理的泡沫


2021 年四月,我开始优化前端开发质量管理,设计的开发流程如下:


流程


开发环境:



  • 即时反馈:通过 IDE 或者构建程序即时对问题进行反馈。

  • 入库前检查:这里可以对变动的源代码进行统一格式化,代码规范检查、单元测试。如果检查失败则无法提交。


集成环境:



  • 服务端检查:聪明的开发者可能绕过开发环境本地检查,在集成环境我们可以利用 Gerrit + Jenkins 来执行检查。如果验证失败,该提交会被拒绝入库。

  • CodeReview:CodeReview 是最后一道防线,主要用于验证机器无法检验的设计问题。

  • 自动化部署:只有服务端检查和 CodeReview 都通过才能提交到仓库

    • 测试环境:即时部署,关闭安全检查、开启调试方便诊断问题

    • 生产环境:授权部署




生产环境:


前端应用在客户端中运行,我们通常需要通过各种手段来监控和上报应用的状态,以便更快地定位和解决客户问题。






原则一:我认为“自动化才是秩序”:


文档通常都会被束之高阁,因此单靠文档很难形成约束力。尤其在迭代频繁、人员构造不稳定的情况。规范自动化、配合有效的管理才是行之有效的解决办法。



  • 规范自动化。能够交给机器去执行的,都应该交给机器去处理, 最大程度降低开发者的心智负担、犯错率。可以分为以下几个方面:

    • 语言层面:类型检查,比如 Typescript。严格的 Typescript 可以让开发者少犯很多错误。智能提示对开发效率也有很大提升。

    • 风格层面:统一的代码格式化风格。例如 Prettier

    • 规范层面:一些代码规范、最佳实践、反模式。可以遵循社区的流行规范, 例如 JavaScript Standard

    • 架构层面:项目的组织、设计、关联、流程。可以通过脚手架、规范文档、自定义 ESLint 规则。



  • 管理和文化: 机器还是有局限性,更深层次的检查还是需要人工进行。比如单元测试、CodeReview。这往往需要管理来驱动、团队文化来支撑。这是我们后面需要走的路。






原则二:不要造轮子


我们不打算造轮子,建立自己的代码规范。社区上有很多流行的方案,它们是集体智慧的结晶,也最能体现行业的最佳实践:


社区规范


没必要自己去定义规则,因为最终它都会被废弃,我们根本没有那么多精力去维护。






实现


企业通知 Code Review


企业通知 Code Review






我们这套代码质量管理体系,主要基于以下技术来实现:



  • Jenkins: 运行代码检查、构建、通知等任务

  • Gerrit:以 Commit 为粒度的 CodeReview 工具

  • wkfe-standard: 我们自己实现渐进式代码检查 CLI






如果你想了解这方面的细节,可以查看以下文档:





我推崇的自动化就是秩序目的就是让机器来取代人对代码进行检查。然而它只是仅仅保证底线。


人工 CodeReview 的重要性不能被忽略,毕竟很多事情机器是做不了的。


为了推行 CodeReview,我们曾自上而下推行了 CCC(简洁代码认证) 运动,开发者可以提交代码让专家团队来 Code Review,一共三轮,全部通过可以获得证书,该证书可以成为绩效和晋升的加分项;除此之外还有代码规范考试…


然而,这场运动仅仅持续了几个月,随着公司组织架构的优化、这些事情就不再被重视。


不管是多么完善的规范、工作流,人才是最重要的一环,到最后其实是人的管理






DDD / 中台的泡沫


近年来,后端微服务、中台化等概念火热,DDD 也随之而起。


DDD 搜索趋势


上图的 DDD Google 趋势图,一定程度可以反映国内 DDD 热度的现实情况:



  • 在 14 年左右,微服务的概念开始被各方关注,我们可以看到这年 DDD 的搜索热度有明显的上升趋势

  • 2015 年,马某带领阿里巴巴集团的高管,去芬兰的赫尔辛基对一家名叫 supercell 的游戏公司进行商务拜访,中台之风随着而起,接下来的一两年里,DDD 的搜索热度达到了顶峰。

  • 2021 ~ 2022 年,口罩期间,很多公司业务几乎停摆,这是一个’内修‘的好时机。很多公司在这个阶段进行了业务的 DDD 重构,比较典型的代表是去哪儿业务瘦身 42%+效率提升 50% :去哪儿网业务重构 DDD 落地实践)。




上文提到,我们在 2021 年底也进行了一次轰轰烈烈的 DDD 重构战役,完全推翻现有的项目,重新梳理业务、重新设计、重新编码。


重构需要投入了大量的资源,基本公司 1 / 3 的研发资源都在里面了,这还不包括前期 DDD 的各种预研和培训成本。


在现在看来,这些举措都是非常激进的。而价值呢?现在还不’好说‘(很难量化)






DDD 落地难


其实既然开始了 DDD 重构, 就说明我们已经知道 ’怎么做 DDD‘ 了,在重构之前,我们已经有了接近一年的各种学习和铺垫,且在部分中台项目进行了实践。


但我至今还是觉得 DDD 很难落地,且不说它有较高的学习成本,就算是已落地的项目我们都很难保证它的连续性(坚持并贯彻初衷、规范、流程),烂尾的概率比较高。


为了降低开发者对 DDD 的上手门槛,我们也进行了一些探索。






低代码 + DDD?


可视化领域建模


可视化领域建模


2022 下半年,我们开始了 ’DDD 可视化建模‘ 的探索之路,如上图所示。


这个平台的核心理念和方法论来源于我们过去几年对 DDD 的实践经验,涵盖了需求资料的管理、产品愿景的说明、统一语言、业务流程图、领域模型/查询模型/领域服务的绘制(基于 CQRS),数据建模(ER)、对象结构映射(Mapper)等多种功能,覆盖了 DDD 的整个研发流程。


同时它也是一个知识管理平台,我们希望在这里聚合业务开发所需要的各种知识,包括原始需求资料、统一语言、领域知识、领域建模的结果。让项目的二开、新团队成员可以更快地入手。


最终,建模的结果通过“代码生成器”生成代码,真正实现领域驱动设计,而设计驱动编码。


很快我们会完全开源这套工具,可以关注我的后续文章。






DDD 泡沫


即使我们有’低代码‘工具 + 代码自动生成的加持,实现了领域驱动设计、设计驱动编码,结果依旧是虎头蛇尾,阻止不了 DDD 泡沫的破裂。




我也思考了很多原因,为什么我们没有’成功‘?





  • DDD 难?学习曲线高

  • 参与的人数少,DDD 受限在后端开发圈子里面,其他角色很少参与进来,违背了 DDD 的初衷

  • 重术而轻道。DDD 涵括了战略设计和战术设计,如果战略设计是’道‘、战术设计就是’术‘,大部分开发者仅仅着眼于术,具体来说他们更关注编码,思维并没有转变,传统数据建模思维根深蒂固

  • 中台的倒台,热潮的退去


扩展阅读:







一些零碎的事


过去三年还做了不少事情,限于篇幅,就不展开了:







过去三年经历时间轴:



  • 2020 年 7 月,换了公司,开始接手真正迁移中的 Taro 项目

  • 2020 年 10 月,Taro 2.x 小程序正式上线

  • 2020 年 10 月 ~ 11 月 优化代码质量管理体系,引入开发规范、Gerrit Code Review 流程

  • 2020 年 12 月 ~ 2021 年 4 月,业务开发

  • 2021 年 1 月 博客停更

  • 2021 年 5 月 Taro 3.x 升级

  • 2021 年 7 月 ~ 10 月 前端低代码平台开发

  • 2021 年 11 月 ~ 2022 年 5 月, DDD 大规模重构,C 端项目重构、国际化改造

  • 2022 年 6 月 ~ 2022 年 11 月,B 端技术升级,涉及容器化改造、微前端升级、组件库开发等

  • 2022 年 12 月~ 2023 年 4 月,可视化 DDD 开发平台开发

  • 2023 年 5 月 ~ 至今。业务开发,重新开始博客更新








总结


贝尔实验室


我们都有美好的愿望


重构了又重构,技术的债务还是高城不下


推翻了再推翻,我们竟然是为了‘复用’?


降本增效的大刀砍来


泡沫破碎,回归到了现实


潮水退去,剩下一些裸泳的人


我又走到了人生的十字路口,继续苟着,还是换个方向?


作者:荒山
来源:juejin.cn/post/7289718324857880633
收起阅读 »

一个高并发项目到落地的心酸路

前言 最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。 这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。 正文 需求及背景 先来介绍下需求,首先项目是一个志愿填报系统,既...
继续阅读 »

前言


最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。

这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。


正文


需求及背景


先来介绍下需求,首先项目是一个志愿填报系统,既然会扯上高并发,相信大家也能猜到大致是什么的志愿填报。

核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。

本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。

甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。

讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。

虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。


分析


既然开始做了,再说那些有的没的就没用了,直接开始分析需求。

首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下。



  1. 考生端登录接口、考生志愿信息查询接口需要4W QPS

  2. 考生保存志愿接口,需要2W TPS

  3. 报考信息查询4W QPS

  4. 老师端需要4k QPS

  5. 导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)

  6. 考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据

  7. 数据脱敏,防伪

  8. 资源是有限的,提供几台物理机

    大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。


方案研讨


接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的

首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求


MySQL


首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。

向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。

查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。

insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。

如果表中带索引,将降低1k-1.5k的TPS。

目前结论是,mysql不能达到要求,能不能考虑其他架构,比如mysql主从复制,写和读分开。

测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。

至此结论是,mysql直接上的方案肯定是不可行的


Redis


既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。

get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。

但是,redis容易丢失数据,需要考虑高可用方案


实现方案


既然redis满足要求,那么数据全从redis取,持久化仍然交给mysql,写库的时候先发消息,再异步写入数据库。

最后大体就是redis + rocketMQ + mysql的方案。看上去似乎挺简单,当时我们也这样以为 ,但是实际情况却是,我们过于天真了。

这里主要以最重要也是要求最高的保存志愿信息接口开始攻略


故障恢复

第一个想到的是,这些个节点挂了怎么办?

mysql挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。

rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。

原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。

然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。

数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。

于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。

保存接口的流程就变成了以下步骤:

1.redis 开启事务,更新redis数据

2.rocketMQ同步落盘

3.redis 提交事务

4.mysql异步入库

我们来看下这个接口可能存在的问题。

第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响

第二步,如果rocketMQ落盘报错,那么就会有两种情况。

情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。

情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致mysql和redis数据的最终不一致。

如何处理?怎么知道是redis的有问题还是mysql的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。

考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较mysql和redis不一致的情况,并自主修复数据。

首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。

然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于mysql,如果小于,便更新redis中数据。如果大于,则不做处理。

同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于mysql中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。

然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。

这样看下来,即使redis崩掉,也不会丢失数据。


第一轮压测


接口实现后,当时怀着期待,信息满满的去做了压测,结果也是当头棒喝。

首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。

但是,TPS却只有4k不到的样子,难道是节点少了?

于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。


重新分析


经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???

一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?

于是用arthas看了看到底慢在哪里?

结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。

结论是:

redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。

问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),

而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。

于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。

为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。

针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。


继续压测


又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。

节点不够?加了几个节点,有效果,但不多,最终过不了1W。

继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。

同步落盘效率太低?于是压测一波发现,确实如此。

因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。

问题到这突然停滞,不知道怎么处理rocketMQ这个点。

同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。

怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。

不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。

而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布及其不均匀。

后来稍作改变,根据正件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。


一点小意外


压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。

最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。

于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。

不错不错,但是也只到了2W,在想上去,又有了瓶颈。

不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!

既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。


压测


已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。

胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。

什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。

MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。

静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。

那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。

也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。

接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。

那么照理来说,现在的TPS应该会来到惊人的4W才对。


再再次压测


怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。

当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。

为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。

个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。

于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。

一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。

而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。

为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。


准备收工


至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。

于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。

这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。


提测后的问题


功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。

因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。

于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。

但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。

于是管理端单独写了一套获取数据分区的调度逻辑。

第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。

不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。


上线


一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。

3个ng,4个考生端,1个管理端。

4个RocketMQ。

4个redis。

2个mysql服务,一主一从,一个定时任务服务。

1个ES服务。

最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,

而真是反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有排上用场。


最后


整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举,

偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。

但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。

做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。

再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,

实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。

从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。

不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。


作者:青鸟218
来源:juejin.cn/post/7346021356679675967
收起阅读 »

HTML常用布局标签:提升网页颜值!不可不知的HTML布局技巧全解析!

在HTML的世界里,一切都是由容器和内容构成的。容器,就如同一个个盒子,用来装载各种元素;而内容,则是这些盒子里的珍宝。理解了这一点,我们就迈出了探索HTML布局的第一步。在HTML中,布局标签主要用于控制页面的结构和样式。本文将介绍一些常用的布局标签及其使用...
继续阅读 »

在HTML的世界里,一切都是由容器和内容构成的。容器,就如同一个个盒子,用来装载各种元素;而内容,则是这些盒子里的珍宝。理解了这一点,我们就迈出了探索HTML布局的第一步。

在HTML中,布局标签主要用于控制页面的结构和样式。本文将介绍一些常用的布局标签及其使用方法,并通过代码示例进行演示。

一、理解布局的重要性

布局在我们前端开发中担任什么样的角色呢?想象一下,你面前有一堆散乱的积木,无序地堆放在那里。

Description

而你的任务,就是将这些积木按照图纸拼装成一个精美的模型。HTML布局标签的作用就像那张图纸,它指导浏览器如何正确、有序地显示内容和元素,确保网页的结构和外观既美观又实用。

下面我们就来看看在HTML中常用的基础布局标签有哪些,如何使用这些布局标签完成我们的开发目标。

二、常用的布局标签

1、div标签

div标签是一个块级元素,它独占一行,用于对页面进行区域划分。它可以包含其他HTML元素,如文本、图片、链接等。通过CSS样式可以设置div的布局和样式。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  .box {
    width: 200px;
    height: 200px;
    background-color: red;
  }
</style>
</head>
<body>

<div>这是一个div元素

</div>

</body>
</html>

运行结果:

Description

2、span标签

span标签是一个内联元素,它不独占一行,用于对文本进行区域划分。它主要用于对文本进行样式设置,如字体、颜色等。与div类似,span也可以包含其他HTML元素。
示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  .text {
    color: blue;
    font-size: 20px;
  }
</style>
</head>
<body>

<p>这是一个<span>span元素</span></p>

</body>
</html>

运行结果:

Description

3、table标签

table标签用于创建表格,它包含多个tr(行)元素,每个tr元素包含多个td(单元格)或th(表头单元格)元素。

<table> 定义一个表格,<tr> 定义表格中的行,而 <td> 则定义单元格。通过这三个标签,我们可以创建出整齐划一的数据表,让信息的展示更加直观明了。

需要注意的是:

  • <table></table>标记着表格的开始和结束。
  • <tr></tr>标记着行的开始和结束,几组表示该表格有几行。
  • <td></td>标记着单元格的开始和结束,表示这一行中有几列。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  table, th, td {
    border: 1px solid black;
  }
</style>
</head>
<body>
<table>
  <tr>
    <th>姓名</th>
    <th>年龄</th>
  </tr>
  <tr>
    <td>张三</td>
    <td>25</td>
  </tr>
  <tr>
    <td>李四</td>
    <td>30</td>
  </tr>
</table>
</body>
</html>

运行结果:

Description

4、form标签

<form>标签的主要作用是定义一个用于用户输入的HTML表单。这个表单可以包含各种输入元素,如文本字段、复选框、单选按钮、提交按钮等。

<form>元素可以包含以下一个或多个表单元素:<input><textarea><button><select><option><optgroup><fieldset><label><output>等。

示例代码:

<!DOCTYPE html>
<html>
<head>
<style>
  form {
    display: flex;
    flex-direction: column;
  }
</style>
</head>
<body>

<form>
  <label for="username">用户名:</label>
  <input type="text" id="username" name="username">
  <br>
  <label for="password">密码:</label>
  <input type="password" id="password" name="password">
  <br>
  <input type="submit" value="提交">
</form>

</body>
</html>

运行结果:
Description

5、列表标签

1)无序列表

  • 指没有顺序的列表项目
  • 始于<ul>标签,每个列表项始于<li>
  • type属性有三个选项:disc实心圆、circle空心圆、square小方块。 默认属性是disc实心圆。

示例代码:

<!DOCTYPE html>
<htmml>
<head>
<meta charst = "UTF-8">
<title>html--无序列表</title>
</head>
<body>
<ul>
<li>默认的无序列表</li>
<li>默认的无序列表</li>
<li>默认的无序列表</li>
</ul>
<ul>
<li type = "circle">添加circle属性</li>
<li type = "circle">添加circle属性</li>
<li type = "circle">添加circle属性</li>
</ul>
<ul>
<li type = "square">添加square属性</li>
<li type = "square">添加square属性</li>
<li type = "squaare">添加square属性</li>
</ul>
</body>
</html>

运行结果:
Description
也可以使用CSS list-style-type属性定义html无序列表样式。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

2)有序列表

  • 指按照字母或数字等顺序排列的列表项目。
  • 其结果是带有前后顺序之分的编号,如果插入和删除一个列表项,编号会自动调整。
  • 始于<ol>标签,每个列表项始于<li>

示例代码:

<ol>
<li>默认的有序列表</li>
<li>默认的有序列表</li>
<li>默认的有序列表</li>
</ol>
<ol type = "a" start = "2">
<li>第1项</li>
<li>第2项</li>
<li>第3项</li>
<li value ="20">第四项</li>
</ol>
<ol type = "Ⅰ" start = "2">
<li>第1项</li>
<li>第2项</li>
<li>第3项</li>
</ol>

运行结果:
Description
同样也可以使用CSS list-style-type属性定义html有序列表样式。

3)自定义列表

  • 自定义列表不仅仅是一列项目,而是项目及其注释的组合。
  • <dl>标签开始。每个自定义列表项以<dt>开始。每个自定义列表项的定义以<dd>开始。
  • 用于对术语或名词进行解释和描述,自定义列表的列表项前没有任何项目符号。
    基本语法:
<dl>
<dt>名词1</dt>
<dd>名词1解释1</dd>
<dd>名词1解释2</dd>

<dt>名词2</dt>
<dd>名词2解释1</dd>
<dd>名词2解释2</dd>
</dl>

<dl>即“definition list(定义列表)”,
<dt>即“definition term(定义名词)”,
<dd>即“definition description(定义描述)”。

示例代码:

<dl>
<dt>计算机</dt>
<dd>用来计算的仪器</dd>

<dt>显示器</dt>
<dd>以视觉方式显示信息的装置</dd>
</dl>

运行结果:
Description
以上就是HTML中常用的布局标签及其使用方法。在实际开发中,还可以结合CSS和JavaScript来实现更复杂的布局和交互效果。

掌握了这些HTML常用布局标签,你已经拥有了构建网页的基础工具。记住,好的布局不仅需要技术,更需要创意和对细节的关注。现在,打开你的代码编辑器,开始你的布局设计之旅吧!

收起阅读 »

从发送短信验证码来研究几种常用的防刷策略

大家好,我是小趴菜,最近在做项目的时候有个发送短信验证码的需求,这个需求在大部分的项目中相信都会使用到,而发送短信验证码是需要收费的,所以我们要保证我们的接口不能被恶意刷, 1:前端控制 前端控制是指在用户点击发送验证码之后,在一分钟之内这个按钮就置灰,让用户...
继续阅读 »

大家好,我是小趴菜,最近在做项目的时候有个发送短信验证码的需求,这个需求在大部分的项目中相信都会使用到,而发送短信验证码是需要收费的,所以我们要保证我们的接口不能被恶意刷,


1:前端控制


前端控制是指在用户点击发送验证码之后,在一分钟之内这个按钮就置灰,让用户无法再次发起,这种方式有什么优点和缺点呢?


优点



  • 1: 实现简单,直接让前端进行控制


缺点



  • 1:安全性不够,别人完全可以绕过前端的控制,直接发起调用,这种方式只能作为防刷的第一道屏障


2:redis + 过期时间


在用户发送验证码之后,将用户的手机号作为redis的KEY,value可以设置为任意值,并且将该KEY的过期时间设置为1分钟,实现流程如下:



  • 1:用户客户端发起发送验证码

  • 2:后端收到请求以后,将该用户的手机号作为KEY,VALUE设置为任意值,并且是过期时间为1分钟

  • 3:当用户下次发起发送验证码请求,后端可以根据用户手机号作为KEY,从Redis中获取,如果这个KEY不存在,说明已经过去1分钟了,可以再次发送验证码

  • 4:如果这个KEY存在,说明这个用户在一分钟内这个用户已经发送过了,就提示用户一分钟后再试


那么这种方式又有什么优点和缺点呢???


优点



  • 1:实现简单

  • 2:由后端控制,安全性比前端控制高


缺点



  • 1:首先需要依赖Redis

  • 2:一分钟后这个KEY真的能被准时删除吗????


针对第2点我们深入分析下,正常来说,一个Redis的KEY,设置了1分钟过期时间,那么在1分钟后这个KEY就会被删除,所以这种redis+过期时间在正常情况下是可以满足防刷的,但是Reids真的能帮我们准时的删除这个KEY吗?


在此我们不得不了解下Redis的删除策略了,redis有三种删除策略



  • 1:定时删除:会给这个KEY设置一个定时器,在这个KEY的过期时间到了,就会由定时器来删除这个KEY,优点是可以快速释放掉内存,缺点就是会占用CPU,如果在某个点有大量的KEY到了过期时间,那么此时系统CPU就会被沾满

  • 2:惰性删除:当这个KEY过期了,但是不会自动释放掉内存,而是当下次有客户端来访问这个KEY的时候才会被删除,这样就会存在一些无用的KEY占用着内存

  • 3:定期删除:redis会每隔一段时间,随机抽取一批的KEY,然后把其中过期的KEY删除


如果reids设置的删除策略是定期删除,那么你这个KEY即使到了过期时间也不会被删除,所以你还是可以在Redis中获取到,这个时候客户端明明已经过了一分钟了,但是你还是能拿到这个KEY,所以这时候又会被限制发送验证码了,这明显不符合业务需求了


所以一般会采用惰性删除+定期删除的方式来实现,这样,即使定期删除没有删除掉这个KEY,但是在访问的时候,会通过惰性删除来删除掉这个KEY,所以这时候客户端就访问不到这个KEY,就可以实现一分钟内再次发送验证码的请求了


但是如果你的Redis是做了读写分离的,也就是写操作是写主,查询是从,那么这时候会有什么问题呢?


我们在设置Redis的过期时间有四种命令



  • 1:expire:从当前时间算起,过了设置的时间以后就过期

  • 2:pexpire:同expire,只是过期时间的单位不一样

  • 3:expireAt:设置未来的某个时间,当系统时间到了这个点之后就过期

  • 4:pexpireAt:同expireAt,只是过期时间单位不一样


如果我们使用的是expire命令来设置时间,redis主从同步是异步的,那么在这期间一定会有时间差,当主同步到从的时候,可能已经过去十几秒都有可能,那么这时候从redis收到这个KEY以后,是从当前时间开始算起,然后过去指定的时间以后才会过期,所以这时候主redis这个KEY过期了,但是从redis这个KEY可能还有十几秒以后才会过期


这时候你查的是从Redis,所以还是可以查到这个KEY的,这时候客户端其实已经过去一分钟了,但是由于你能从Redis查到这个KEY,所以客户端还是不能发送验证码


这时候我们可以使用expireAt命令来设置,只要系统到了这个时间点,这个KEY就会被删除,但是前提是要保证主从Redis系统的时间一致,如果你从库的时间比主库晚了几分钟,那么从库这个KEY存活的时间就会比主Redis存活的时间更长,那么这样也会有问题


redis + 特殊VALUE + 过期时间


这种的业务流程如下



  • 1:用户客户端发起发送验证码

  • 2:后端收到请求以后,将该用户的手机号作为KEY,VALUE设置为当前时间戳(重点)

  • 3:当用户下次发起发送验证码请求,后端可以根据用户手机号作为KEY,从Redis中获取,如果这个KEY不存在,可以再次发送验证码

  • 4:如果这个KEY存在,获取到这个KEY的VALUE,然后判断当前时间戳跟这个KEY的时间戳是否超过1分钟了,如果超过了就可以再次发送,如果没有就不能发送了


这种方式与其它几种方式的优点在哪呢?


无论你这个KEY有没有准时被删除,删除了说明可以发送,即使因为某些原因没有被删除,那么我们也可以通过设置的VALUE的值跟当前时间戳做一个比较。所以即使出现了上面 redis + 过期时间会出现的问题,那么我们也可以做好相应的判断,如果你过去一分钟还能拿到这个KEY,并且比较时间戳也已经超过一分钟了,那么我们可以重新给这个KEY设置VALUE,并且值为当前时间戳,就不会出现以上的几种问题了。


结尾


题外话,其实KEY即使时间到期了,但是我们还是能查到这个KEY,除了之前说的几个点,还有几种情况也会出现,Redis删除KEY是需要占用CPU的,如果此时你系统的CPU已经被其它进程占满了,那么这时候Redis就无法删除这个KEY了


作者:我是小趴菜
来源:juejin.cn/post/7341300805281087514
收起阅读 »

违反这些设计原则,系统就等着“腐烂”

分享是最有效的学习方式。 老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧 故事 这段时间以来,小猫按照之前的系统梳...
继续阅读 »

分享是最有效的学习方式。



老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧


故事


这段时间以来,小猫按照之前的系统梳理方案【系统梳理大法&代码梳理大法】一直在整理着文档。


系统中涉及的业务以及模型也基本了然于胸,但是这代码写的真的是...


小猫也终于知道了为什么每天都有客诉,为什么每天都要去调用curl语句去订正生产的数据,为什么每天都在Hotfix...


整理了一下,大概出于这些原因,业务流程复杂暂且不议,光从技术角度来看,整个代码体系臃肿不堪,出问题之后定位困难,后面接手的几任开发为了解决问题都是“曲线救国”,不从正面去解决问题,为了解决一时的客诉问题而去解决问题,于是定义了各种新的修复流程去解决问题,这么一来,软件系统“无序”总量一直在增加,整个系统体系其实在初版之后就已经在“腐烂”了,如此?且抛开运维稳定性不谈,就系统本身稳定性而言,能好?


整个系统,除了堆业务还是堆业务,但凡有点软件设计原则,系统也不会写成这样了。


关于设计原则


大家在产品提出需求之后,一般都会去设计数据模型,还有系统流程。但是各位有没有深度去设计一下代码的实现呢?还是说上手就直接照着流程图开始撸业务了?估计有很多的小伙伴由于各种原因不会去考虑代码设计,其实老猫很多时候也一样。主要原因比如:项目催的紧,哪有时间考虑那么多,功能先做出来,剩下的等到后面慢慢优化。然而随着时间的推移,我们会发现我们一直很忙,说好的把以前的代码重构好一点,哪有时间!于是,就这样“技术债”越来越多,就像滚雪球一样,整个系统逐渐“腐烂”到了根。最终坑的可能是自己,也有可能是“下一个他”。


虽然在日常开发的时候项目进度比较紧张,我们很多时候也不去深度设计代码实现,但是我们在写代码的时候保证心中有一杆秤其实还是必要的。


那咱们就结合各种案来聊聊“这杆秤”————软件设计原则。


design_rule.png


下面我们通过各种小例子来协助大家理解软件设计原则,案例是老猫构想的,有的时候不要太过较真,主要目的是讲清楚原则。另外后文中也会有相关的类图表示实体之间的关系,如果大家对类图不太熟悉的,也可以看一下这里【类图传送门


开闭原则


开闭原则,英文(Open-Closed Principle,简称:OCP)。只要指一个软件实体(例如,类,模块和函数),应该对扩展开放,对修改关闭。其重点强调的是抽象构建框架,实现扩展细节,从而提升软件系统的可复用性以及可维护性。


概念是抽象,但是案例是具体的,所以咱们直接看案例,通过案例去理解可能更容易。


由于小猫最近在维护商城类业务,所以咱们就从商品折价售卖这个案例出发。业务是这样的,商城需要对商品进行做打折活动,目前针对不同品类的商品可能打折的力度不一样,例如生活用品和汽车用品的打折情况不同。
创建一个基础商品接口:


public interface IProduct {
String getSpuCode(); //获取商品编号
String getSpuName(); //获取商品名称
BigDecimal getPrice(); //获取商品价格
}

基础商品实现该接口,于是我们就有了如下代码:


/**
*
@Author: 公众号:程序员老猫
*
@Date: 2024/2/7 23:39
*/

public class Product implements IProduct {
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;

public Product(String spuCode, String spuName, BigDecimal price, Integer categoryTag) {
this.spuCode = spuCode;
this.spuName = spuName;
this.price = price;
this.categoryTag = categoryTag;
}

public Integer getCategoryTag() {
return categoryTag;
}

@Override
public String getSpuCode() {
return spuCode;
}

@Override
public String getSpuName() {
return spuName;
}

@Override
public BigDecimal getPrice() {
return price;
}
}

按照上面的业务,现在搞活动,咱们需要针对不同品类的商品进行促销活动,例如生活用品需要进行折扣。当然我们有两种方式实现这个功能,如果咱们不改变原有代码,咱们可以如下实现。


public class DailyDiscountProduct extends Product {
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private static final Integer DAILY_PRODUCT = 1;

public DailyDiscountProduct(String spuCode, String spuName, BigDecimal price) {
super(spuCode, spuName, price, DAILY_PRODUCT);
}

public BigDecimal getOriginPrice() {
return super.getPrice();
}

@Override
public BigDecimal getPrice() {
return super.getPrice().multiply(daily_discount_factor);
}
}

上面我们看到直接打折的日常用品的商品继承了标准商品,并且对其进行了价格重写,这样就完成了生活用品的打折。当然这种打折系数的话我们一般可以配置到数据库中。


对汽车用品的打折其实也是一样的实现。继承之后重写价格即可。咱们并不需要去基础商品Product中根据不同的品类去更改商品的价格。


错误案例


如果我们一味地在原始类别上去做逻辑应该就是如下这样:



public class Product implements IProduct {
private static final Integer DAILY_PRODUCT = 1;
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;
....
@Override
public BigDecimal getPrice() {
if(categotyTag.equals(DAILY_PRODUCT)){
return price.multiply(daily_discount_factor);
}
return price;
}
}

后续随着业务的演化,后面如果提出对商品名称也要定制,那么咱们可能还是会动当前的代码,我们一直在改当前类,代码越堆越多,越来越臃肿,这种实现方式就破坏了开闭原则。


咱们看一下开闭原则的类图。如下:


kb_01.png


依赖倒置原则


依赖倒置原则,英文名(Dependence Inversion Principle,简称DIP),指的是高层模块不应该依赖低层模块,二者都应该依赖其抽象。通过依赖倒置,可以减少类和类之间的耦合性,从而提高系统的稳定性。这里主要强调的是,咱们写代码要面向接口编程,不要面向实现去编程。


定义看起来不够具体,咱们来看一下下面这样一个业务。针对不同的大客户,我们定制了很多商城,有些商城是专门售卖电器的,有些商城是专门售卖生活用品的。有个大客,由于对方是电器供应商,所以他们想售卖自己的电器设备,于是,我们就有了下面的业务。


//定义了一个电器设备商城,并且支持特有的电器设备下单流程
public class ElectricalShop {
public String doOrder(){
return "电器商城下单";
}
}
//用户进行下单购买电器设备
public class Consumer extends ElectricalShop {
public void shopping() {
super.doOrder();
}
}

我们看到,当客户可选择的只有一种商城的时候,这种实现方式确实好像没有什么问题,但是现在需求变了,马上要过年了,大客户不想仅仅给他们的客户提供电器设备,他们还想卖海鲜产品,这样,以前的这种下单模式好像会有点问题,因为以前我们直接继承了ElectricalShop,这样写的话,业务可拓展性就太差了,所以我们就需要抽象出一个接口,然后客户在下单的时候可以选择不同的商城进行下单。于是改造之后,咱们就有了如下代码:


//抽象出一个更高维度的商城接口
public interface Shop {
String doOrder();
}
//电器商城实现该接口实现自有下单流程
public class ElectricalShop implements Shop {
public String doOrder(){
return "电器商城下单";
}
}
//海鲜商城实现该接口实现自有下单流程
public class SeaFoodShop implements Shop{
@Override
public String doOrder() {
return "售卖一些海鲜产品";
}
}
//消费者注入不同的商城商品信息
public class Consumer {
private Shop shop;
public Consumer(Shop shop) {
this.shop = shop;
}
public String shopping() {
return shop.doOrder();
}
}
//消费者在不同商城随意切换下单测试
public class ConsumerTest {
public static void main(String[] args) {
//电器商城下单
Consumer consumer = new Consumer(new ElectricalShop());
System.out.println(consumer.shopping());
//海鲜商城下单
Consumer consumer2 = new Consumer(new SeaFoodShop());
System.out.println(consumer2.shopping());
}
}


上面这样改造之后,原本继承详细商城实现的Consumer类,现在直接将更高维度的商城接口注入到了类中,这样相信后面再多几个新的商城的下单流程都可以很方便地就完成拓展。


这其实也就是依赖倒置原则带来的好处,咱们最终来看一下类图。


DIP.png


单一职责原则


单一职责原则,英文名(SimpleResponsibility Pinciple,简称SRP)指的是不要存在多余一个导致类变更的原因。这句话看起来还是比较抽象的,老猫个人的理解是单一职责原则重点是区分业务边界,做到合理地划分业务,根据产品的需求不断去重新规划设计当前的类信息。关于单一职责老猫其实之前已经和大家分享过了,在此不多赘述,大家可以进入这个传送门【单一职责原则


接口隔离原则


接口隔离原则(Interface Segregation Principle,简称ISP)指的是指尽量提供专门的接口,而非使用一个混合的复杂接口对外提供服务。


聊到接口隔离原则,其实这种原则和单一职责原则有点类似,但是又不同:



  1. 联系:接口隔离原则和单一职责原则都是为了提高代码的可维护性和可拓展性以及可重用性,其核心的思想都是“高内聚低耦合”。

  2. 区别:针对性不同,接口隔离原则针对的是接口,而单一职责原则针对的是类。


下面,咱们用一个业务例子来说明一下吧。
我们用简单的动物行为这样一个例子来说明一下,动物从大的方面有能飞的,能吃,能跑,有的也会游泳等等。如果我们定义一个比较大的接口就是这样的。


public interface IAnimal {
void eat();
void fly();
void swim();
void run();
...
}

我们用猫咪实现了该方法,于是就有了。


public class Cat implements IAnimal{
@Override
public void eat() {
System.out.println("老猫喜欢吃小鱼干");
}
@Override
public void fly() {
}
@Override
public void swim() {
}
@Override
public void run() {
System.out.println("老猫还喜欢奔跑");
}
}

我们很容易就能发现,如果老猫不是“超人猫”的话,老猫就没办法飞翔以及游泳,所以当前的类就有两个空着的方法。
同样的如果有一只百灵鸟,那么实现Animal接口之后,百灵鸟的游泳方法也是空着的。那么这种实现我们发现只会让代码变得很臃肿,所以,我们发现IAnimal这个接口的定义太大了,我们需要根据不同的行为进行二次拆分。
拆分之后的结果如下:


//所有的动物都会吃东西
public interface IAnimal {
void eat();
}
//专注飞翔的接口
public interface IFlyAnimal {
void fly();
}
//专注游泳的接口
public interface ISwimAnimal {
void swim();
}

那如果现在有一只鸭子和百灵鸟,咱们分别去实现的时候如下:


public class Duck implements IAnimal,ISwimAnimal{
@Override
public void eat() {
System.out.println("鸭子吃食");
}

@Override
public void swim() {
System.out.println("鸭子在河里游泳");
}
}

public class Lark implements IAnimal,IFlyAnimal{
@Override
public void eat() {
System.out.println("百灵鸟吃食");
}

@Override
public void fly() {
System.out.println("百灵鸟会飞");
}
}

我们可以看到,这样在我们具体的实现类中就不会存在空方法的情况,代码随着业务的发展也不会变得过于臃肿。
咱们看一下最终的类图。


ISP.png


迪米特原则


迪米特原则(Law of Demeter,简称 LoD),指的是一个对象应该对其他对象保持最少的了解,如果上面这个原则名称不容易记,其实这种设计原则还有两外一个名称,叫做最少知道原则(Least Knowledge Principle,简称LKP)。其实主要强调的也是降低类和类之间的耦合度,白话“不要和陌生人说话”,或者也可以理解成“让专业的人去做专业的事情”,出现在成员变量,方法输入、输出参数中的类都可以称为成员朋友类,而出现在方法体内部的类不属于朋友类。


通过具体场景的例子来看一下。
由于小猫接手了商城类的业务,目前他对业务的实现细节应该是最清楚的,所以领导在向老板汇报相关SKU销售情况的时候总是会找到小猫去统计各个品类的sku的销售额以及销售量。于是就有了领导下命令,小猫去做统计的业务流程。


//sku商品
public class Sku {
private BigDecimal price;
public BigDecimal getPrice() {
return price;
}

public void setPrice(BigDecimal price) {
this.price = price;
}
}

//小猫统计总sku数量以及总销售金额
public class Kitty {
public void doSkuCheck(List skuList) {
BigDecimal totalSaleAmount =
skuList.stream().map(sku -> sku.getPrice()).reduce(BigDecimal::add).get();
System.out.println("总sku数量:" + skuList.size() + "sku总销售金额:" + totalSaleAmount);
}
}

//领导让小猫去统计各个品类的商品
public class Leader {
public void checkSku(Kitty kitty) {
//模拟领导指定的各个品类
List difCategorySkuList = new ArrayList<>();
kitty.doSkuCheck(difCategorySkuList);
}
}

//测试类
public class LodTest {
public static void main(String[] args) {
Leader leader = new Leader();
Kitty kitty = new Kitty();
leader.checkSku(kitty);
}
}


从上面的例子来看,领导其实并没有参与统计的任何事情,他只是指定了品类让小猫去统计。从而降低了类和类之间的耦合。即“让专门的人做专门的事”


我们看一下最终的类图。


LOD.png


里氏替换原则


里氏替换原则(Liskov Substitution Principle,英文简称:LSP),它由芭芭拉·利斯科夫(Barbara Liskov)在1988年提出。里氏替换原则的含义是:如果一个程序中所有使用基类的地方都可以用其子类来替换,而程序的行为没有发生变化,那么这个子类就遵守了里氏替换原则。换句话说,一个子类应该可以完全替代它的父类,并且保持程序的正确性和一致性。


上述的定义还是比较抽象的,老猫试着重新理解一下,



  1. 子类可以实现父类的抽象方法,但是不能覆盖父类的抽象方法。

  2. 子类可以增加自己特有的方法。

  3. 当子类的方法重载父类的方法的时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更加宽松。

  4. 当子类的方法实现父类的方法时,方法的后置条件比父类更严格或者和父类一样。


里氏替换原则准确来说是上述提到的开闭原则的实现方式,但是它克服了继承中重写父类造成的可复用性变差的缺点。它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。


下面咱们用里式替换原则比较经典的例子来说明“鸵鸟不是鸟”。我们看一下咱们印象中的鸟类:


class Bird {
double flySpeed;

//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}

//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//燕子
public class Swallow extends Bird{
}
//由于鸵鸟不能飞,所以我们将鸵鸟的速度设置为0
public class Ostrich extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}

光看这个实现的时候好像没有问题,但是我们调用其方法计算其指定距离飞行时间的时候,那么这个时候就有问题了,如下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
System.out.println(ostrich.getFlyTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


Infinity
4.0

显然鸵鸟出问题了,



  1. 鸵鸟重写了鸟类的 setSpeed(double speed) 方法,这违背了里氏替换原则。

  2. 燕子和鸵鸟都是鸟类,但是父类抽取的共性有问题,鸵鸟的飞行不是正常鸟类的功能,需要特殊处理,应该抽取更加共性的功能。


于是我们进行对其进行优化,咱们取消鸵鸟原来的继承关系,定义鸟和鸵鸟的更一般的父类,如动物类,它们都有奔跑的能力。鸵鸟的飞行速度虽然为 0,但奔跑速度不为 0,可以计算出其奔跑指定距离所要花费的时间。优化之后代码如下:


//抽象出更高层次的动物类,定义内部的奔跑行为
public class Animal {
double runSpeed;

//设置奔跑速度
public void setSpeed(double speed) {
runSpeed = speed;
}
//计算奔跑所需要的时间
public double getRunTime(double distance) {
return (distance / runSpeed);
}
}
//定义飞行的鸟类
public class Bird extends Animal {
double flySpeed;
//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}
//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//此时鸵鸟直接继承动物接口
public class Ostrich extends Animal {
}
//燕子继承普通的鸟类接口
public class Swallow extends Bird {
}

简单测试一下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
ostrich.setSpeed(40);
System.out.println(ostrich.getRunTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


3.0
4.0

优化之后,优点:



  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;

  2. 提高代码的重用性;

  3. 提高代码的可扩展性;

  4. 提高产品或项目的开放性;


缺点:



  1. 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;

  2. 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;

  3. 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果————大段的代码需要重构。


最终我们看一下类图:


LSP.png


老猫觉得里氏替换原则是最难把握好的,所以到后续咱们再进行深入涉及模式回归的时候再做深入探究。


合成复用原则


合成复用原则(Composite/Aggregate Reuse Principle,英文简称CARP)是指咱们尽量要使用对象组合而不是继承关系达到软件复用的目的。这样的话系统就可以变得更加灵活,同时也降低了类和类之间的耦合度。


看个例子,当我们刚学java的时候都是从jdbc开始学起来的。所以对于DBConnection我们并不陌生。那当我们实现基本产品Dao层的时候,我们就有了如下写法:


public class DBConnection {
public String getConnection(){
return "获取数据库链接";
}
}
//基础产品dao层
public class ProductDao {
private DBConnection dbConnection;

public ProductDao(DBConnection dbConnection) {
this.dbConnection = dbConnection;
}

public void saveProduct(){
String conn = dbConnection.getConnection();
System.out.println("使用"+conn+"新增商品");
}
}

上述就是最简单的合成服用原则应用场景。但是这里有个问题,DBConnection目前只支持mysql一种连接DB的方式,显然不合理,有很多企业其实还需要支持Oracle数据库链接,所以为了符合之前说到的开闭原则,我们让DBConnection交给子类去实现。于是我们可以将其定义成抽象方法。


public abstract class DBConnection {
public abstract String getConnection();
}
//mysql链接
public class MySqlConnection extends DBConnection{
@Override
public String getConnection() {
return "获取mysql链接";
}
}
//oracle链接
public class OracleConnection extends DBConnection{
@Override
public String getConnection() {
return "获取Oracle链接方式";
}
}

最终的实现方式我们一起看一下类图。


CARP.png


总结


之前看过一个故事,一栋楼的破败往往从一扇破窗户开始,慢慢腐朽。其实代码的腐烂其实也是一样,往往是一段拓展性极差的代码开始。所以这要求我们研发人员还是得心中有杆“设计原则”的秤,咱们可能不会去做刻意的代码设计,但是相信有这么一杆原则的秤,代码也不致于会写得太烂。


当然我们也不要刻意去追求设计原则,要权衡具体的场景做出合理的取舍。
设计原则是设计模式的基础,相信大家在了解完设计原则之后对后续的设计模式会有更加深刻的理解。


作者:程序员老猫
来源:juejin.cn/post/7332858431572049947
收起阅读 »