注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

2024转行前端第6年

前言 上一篇《外行转码农,焦虑到躺平》分享我的从业经历收到大家很多关注,这里继续分享一下我的生活 我是16毕业在厂里干了两年,18年7月转行前端开发。在后面4年,也就是18~22年是很投入的,这几年上完班回到家基本也在学习,学技术考证,可谓说没有自己的生活,这...
继续阅读 »

前言


上一篇《外行转码农,焦虑到躺平》分享我的从业经历收到大家很多关注,这里继续分享一下我的生活


我是16毕业在厂里干了两年,18年7月转行前端开发。在后面4年,也就是18~22年是很投入的,这几年上完班回到家基本也在学习,学技术考证,可谓说没有自己的生活,这几年是我技术进步最快的几年,也是很快乐的几年,大家懂得全身投入做一件事的快乐吧。


近几年,感觉自己很难突破自己的瓶颈了,慢慢将重心转为生活。也要声明一下,这两年我可不是完全躺平,对应工作我还是很负责的去做,只是工作不是我的全部了。分享一下这几年的收获吧


近几年收获


2019



  • 中级软件设计师


2020



  • 基金从业

  • 证券从业


2021



  • pmp

  • 彩铅绘画


2022



  • 计算机专业课程系统学习

  • 背了5000单词


2023



  • 算法 150道

  • 浅浅玩了区块链技术、android、ios开发

  • 中级经济师


2024



  • 缝纫

  • 计划考 中级会计


看书


我之前基本不怎么看书的,后面慢慢与书籍成为了朋友。这几年读了上百本书,主要涉及:哲学、心理学、经济学、文学、历史、政治。这些可是我之前不怎么关注的,曾经的我很封闭,对全球地理概念都不清楚那种。老爸也说我现在知识面扩展了很多。


醒悟


感觉28岁是我的醒悟元年,告别过往原谅也放过自己,生活从新开始吧。


之前的我,对于朋友没有秘密,很依赖别人,后面慢慢懂得自己的痛苦只能自己慢慢消化,我学会了用 “行为认知疗法”治愈自己,很推荐大家去了解。


分享


我把这些放到b站了,大概感兴趣可以去看哦,搜索 liucheng58


读书笔记


最近不开心看的两本书


-《蛤蟆先生去看心理医生》



“PLOM代表了四个英文单词,意思就是‘可怜弱小的我呀’。这个游戏你每局都赢了,也可以说是输了,这取决于你自己的看法。”



把我们的人生当作一次游戏,跳出自我,静静俯视它的发展。个人在这场游戏中,可能就是游戏背景中一颗小小草,在画面中一闪而过,没有人注意到它刚被怪兽踩了一脚。
而自我的人一直盯着想着那颗小草,而忽略了整个游戏的乐趣。世界上有很多有趣的事情,不要总是计较一时的得失——致自己。



“‘成人自我状态’指我们用理性而不是情绪化的方式来行事。它让我们能应对此时此地正在发生的现实状况。”苍鹭回答。“‘成人自我状态’指我们用理性而不是情绪化的方式来行事。它让我们能应对此时此地正在发生的现实状况。”苍鹭回答。



我这两年最大的三个变化

1、遇事不发脾气,冷静思考当下如何解决;

2、如果没法推脱,就怀着快乐的心态去做吧,和那个任性的小孩好好商量,讲解做这件事的好处,不要抱怨的把事情做了,费力不讨好;

3、聆听他人,角度不同想法不同,没有谁对谁错。


-《幸福之路》



一个人应该认真建立自己的意念,让它与理性相信的方向一致,不要让任何非理性的信仰不经过检视就占据自己的心灵。



回头看老一辈很多坚信的观念我们都无法接受,这就是他们小时候接受到的信息刺激。一个人改变自己的观念很难,改变既有的思维模式很难,但是如果能打破它们,我们就是放过了自己。之前看到做法就是每日自检,找出困扰你的想法,分解并破解。


史铁生说他只能躺在床上的时候他无比怀念坐轮椅的时候,只有体验人生角色的酸痛苦辣,才能做到感同身受,仅仅看书冥想很多东西无法吸收,我想这也是成佛要经历81难吧


image.png



一个把注意力转向自我内心的人,会找不到任何值得关注的事;而那些对外界事物兴致勃勃的人,当他偶尔把注意力转移到自己的灵魂,他会发现所有以前采集、累积的各式各样有趣的材料,都已经被转换重组成美丽且有价值的东西



我的理解是:不要太在乎一时的得失、不要经常悔恨自己的错误,其实命运早已注定。我们应该跳出对自身的注视,面临抉择充分收集信息,然后一条路走到黑,然后让它自由发展。其他时候我的精力要跳出自身,去体验大好河山,体验先人深邃精神。



所有需要技巧的工作都有带给人乐趣的共性,只要这技巧有价值和无限的进步空间



就像程序员很多人最开始是喜欢而从事,写代码是个建设的过程,看着代码运行成一个个应用。但是工作3-5年后,很熟练了,很难有进步空间,很多人就怠慢了。最重要的是很多人意识到,再牛赚的钱也是很有限的。


缝纫


最近朋友圈分享自己第一件缝纫作品 ,收到朋友很多赞赏,我感觉我有点飘了。这里我再分享一次


image.png


绘画


我五音不全,但是很喜欢绘画,21年报了个兴趣班,画了一些彩铅,这两年没画了。


买了平板、画笔和procreate软件,后面打算学习一下平板绘画,感觉还是电子的容易保存。后面如果开动了,再分享我的成果。


image.png


猫猫


2020年5月养了第一只中华田园猫,后面养过5只中华田园猫:莞莞、果果、蕉蕉;救助过两只流浪猫:小黄、小白;


小黄是在地下车库别人车底,小白是在公园草丛,两只猫猫都是连续叫了好几天,又怕人那种小猫;后面我蹲了几小时,用捕猫笼给抓住的;它们都是又瘦又脏,养肥了洗了澡,教了它们使用猫砂,后面给它们找了人家。


image.png


做饭


我现在每天都自己带饭,我做饭的效率很高,周末将肉切好,炒两个菜一般半小时可以搞定。


image.png


面食


买了蒸锅、烤箱、电饼铛,做了包子、面包和饼饼,这些也是我很爱吃的


image.png


最后


我的人生有很多遗憾,遗憾成熟太晚、遗憾高考失利没复读,但是现在也放下了,我总要体会我人生角色的起起伏伏,痛苦使我成长。


我的执行力还是差了点,很多想做的都搁置了。自己习惯依赖,随波逐流,总希望别人给自己人生一个规划,这几年也是一个去依赖的过程。


我之前对自己要求很高,现在功利心下降了,做的事情都是随性而行,才发现自己兴趣很多。


我买了中国三维地图和世界三维地图,曾经很想抛开现有的去外面看看,我爸说我幼稚不成熟,这几年这个想法也放弃了,不知道是成熟了还是向生活妥协了。


接下来,我希望自己用一个平静的心态度过此生,不以物喜不以己悲。


希望被大家温柔以待,不要中伤我。




作者:chengliu0508
来源:juejin.cn/post/7349931303787839499
收起阅读 »

外行转码农,焦虑到躺平

下一篇《2024转行前端第6年》展示我躺平后捣鼓的东西 介绍自己 本人女,16年本科毕业,学的机械自动化专业,和大部分人一样,选专业的时候是拍大腿决定的。 恍恍惚惚度过大学四年,考研时心比天高选了本专业top5学校,考研失败,又不愿调剂,然后就参加校招大军。可...
继续阅读 »

下一篇《2024转行前端第6年》展示我躺平后捣鼓的东西


介绍自己


本人女,16年本科毕业,学的机械自动化专业,和大部分人一样,选专业的时候是拍大腿决定的。


恍恍惚惚度过大学四年,考研时心比天高选了本专业top5学校,考研失败,又不愿调剂,然后就参加校招大军。可能外貌+绩点优势,很顺利拿到了很多工厂offer,然后欢欢喜喜拖箱带桶进厂。


每天两点一线生活,住宿吃饭娱乐全在厂区,工资很低但是也没啥消费,住宿吃饭免费、四套厂服覆盖春夏秋冬。


我的岗位是 inplan软件维护 岗位,属于生产资料处理部门,在我来之前6年该岗位一直只有我师傅一个人,岗位主要是二次开发一款外购的软件,软件提供的api是基于perl语言,现在很少有人听过这个perl吧。该岗位可能是无数人眼里的神仙岗位吧,我在这呆了快两年,硬是没写过一段代码...


inplan软件维护 岗位的诞生就是我的师傅开创的,他原本只是负责生产资料处理,当大家只顾着用软件时,他翻到了说明书上的API一栏,然后写了一段代码,将大家每日手工一顿操作的事情用一个脚本解决了,此后更是停不下来,将部门各种excel数据处理也写成了脚本,引起了部门经理的注意,然后就设定了该岗位。


然而,将我一个对部门工作都不了解的新人丢在这个岗位,可想我的迷茫。开始半年师傅给我一本厚厚的《perl入门到精通》英文书籍,让我先学会 perl 语言。(ps:当时公司网络不连外网,而我也没有上网查资料的习惯,甚至那时候对电脑操作都不熟练...泪目)


师傅还是心地很善良很单纯的人,他隔一段时间会检查我的学习进度,然而当他激情澎拜给我讲着代码时,我竟控制不住打起了瞌睡,然后他就不管我了~~此后我便成了部门透明人物,要是一直透明下去就好了。我懒散的工作态度引起了部门主管的关注,于是我成了他重点关注的对象,我的工位更是移到了他身后~~这便是我的噩梦,一不小心神游时,主管的脸不知啥时凑到了我的电脑屏幕上~~~😱


偶然发现我的师傅在学习 php+html+css+js,他打算给部门构建一个网站,传统的脚本语言还是太简陋了。我在网上翻到了 w3scool离线文档 ,这一下子打开了我的 代码人生。后面我的师傅跳槽了,我在厂里呆了两年觉得什么都没学到,也考虑跳槽了。


后面的经历也很魔幻,误打误撞成为了一名前端开发工程师。此时是2018年,算是前端的鼎盛之年吧,各种新框架 vue/react/angular 都火起来了,各种网站/手机端应用如雨后春笋。我的前端之路还算顺利吧,下面讲讲我的经验吧


如何入门


对于外行转码农还是有一定成本的,省心的方式就是报班吧,但是个人觉得不省钱呀。培训班快则3个月,多的几年,不仅要交上万的培训费用,这段时间0收入,对于家境一般的同学,个人不建议报班。


但是现在市场环境不好,企业对你的容忍度不像之前那么高。之前几年行业缺人,身边很多只懂皮毛的人都可以进入,很多人在岗位半年也只能写出简单的页面,逻辑复杂一点就搞不定~~即使被裁了,也可以快速找到下家。这样的日子应该一去不复返了,所以我们还是要具备的实力,企业不是做慈善的,我们入职后还是要对的起自己的一份工资。


讲讲具体怎么入门吧


看视频:


b站上有很多很多免费的视频,空闲之余少刷点段子,去看看这些视频。不要问我看哪个,点击量大的就进去看看,看看过来人的经验,看看对这个行业的介绍。提高你的信息量,普通人的差距最大就在信息量的多少


还是看视频:


找一个系统的课程,系统的学习 html+css+js+vue/react,我们要动手写一些demo出来。可以找一些优秀的项目,自己先根据它的效果自己实现,但后对着源码看看自己的局限,去提升。


做笔记:


对于新人来说,就是看了视频感觉自己会了,但是写起来很是费力。为啥呢?因为你不知道也记不住有哪些api,所以我们在看视频学习中,有不知道的语法就记下来。

我之前的经验就是手动抄写,最初几年抄了8个笔记本,但是后面觉得不是很方便,因为笔记没有归纳,后续整理笔记困难,所以我们完全可以用电子档的形式,这方便后面的归纳修改。


回顾:


我们的笔记做了就要经常的翻阅,温故而知新,经常翻阅我们的笔记,经常去总结,突然有一天你的思维就上升了一个高度。



  • 慢慢你发现写代码就是不停调用api的过程

  • 慢慢你会发现程序里的美感,一个设计模式、一种新思维。我身边很多人都曾经深深沉迷过写代码,那种成就感带来的心流,这是物质享受带来不了的


输出:


就是写文章啦,写文章让我们总结回顾知识点,发现知识的盲区,在这个过程中进行了深度思考。更重要的是,对于不严谨的同学来说,研究一个知识点很容易浅尝则止,写文章驱动自己去更深层系统挖掘。不管对于刚入行的还是资深人士,我觉得输出都是很重要的。


持续提升


先谈谈学历歧视吧,现在很多大厂招聘基本条件就是211、985,对此很是无奈,但是我内心还是认可这种要求的,我对身边的本科985是由衷的佩服的。我觉得他们高考能考上985,身上都是有过人之处的,学习能力差不了。


见过很多工作多年的程序员,但是他们的编码能力无法描述,不管是逻辑能力、代码习惯、责任感都是很差的,写代码完全是应付式的,他们开发的代码如同屎山。额,但是我们也不要一味贬低他人,后面我也学会了尊重每一个人,每个人擅长的东西不一样,他可能不擅长写代码,但是可能他乐观的心态是很多人不及的、可能他十分擅长交际...


但是可能的话,我们还是要不断提高代码素养



  • 广度:我们实践中,很多场景没遇到,但是我们要提前去了解,不要等需要用、出了问题才去研究。我们要具备一定的知识面覆盖,机会是给有准备的人的。

  • 深度:对于现在面试动不动问源码的情况,很多人是深恶痛绝的,曾经我也是,但是当我沉下心去研究的时候,才发现这是有道理的。阅读源码不仅挺高知识的广度,更多让我们了解代码的美感


具体咋做呢,我觉得几下几点吧。(ps:我自己也做的不好,道理都懂,很难做到优秀呀~~~)



  • 扩展广度:抽空多看看别人的文章,留意行业前沿技术。对于我们前端同学,我觉得对整个web开发的架构都要了解,后端同学的mvc/高并发/数据库调优啥的,运维同学的服务器/容器/流水线啥的都要有一定的了解,这样可以方便的与他们协作

  • 提升深度:首先半路出家的同学,前几年不要松懈,计算机相关知识《操作系统》《计算机网络》《计算机组成原理》《数据结构》《编译原理》还是要恶补一下,这是最基础的。然后我们列出自己想要深入研究的知识点,比如vue/react源码、编译器、低代码、前端调试啥啥的,然后就沉下心去研究吧。


职业规划


现在整个大环境不好了,程序员行业亦是如此,身边很多人曾经的模式就是不停的卷,卷去大厂,跳一跳年薪涨50%不是梦,然而现在不同了。寒风凌凌,大家只想保住自己的饭碗(ps:不同层次情况不同呀,很多大厂的同学身边的同事还是整天打了鸡血一般)


曾经我满心只有工作,不停的卷,背面经刷算法。22年下半年市场明显冷下来,大厂面试机会都没有了,年过30,对大厂的执念慢慢放下。


我慢慢承认并接受了自己的平庸,然后慢慢意识到,工作只是生活的一部分。不一定要担任ceo,才算走上人生巅峰。最近几年,我爱上了读书,以前只觉得学理工科还是实用的,后面慢慢发现每个行业有它的美感~


最后引用最近的读书笔记结尾吧,大家好好体会一下论语的“知天命”一词,想通了就不容易焦虑了~~~



自由就是 坦然面对生活,看清了世界的真相依然热爱生活。宠辱不惊,闲看庭前花开花落。去留无意,漫随天外云卷云舒。



image.png




作者:liucheng58
来源:juejin.cn/post/7343138429860347945
收起阅读 »

已老实!公司的代码再也不敢乱改了!

开篇 大家好,我是聪。想必对于很多初入职场,心中怀着无限激情的兄弟们,对于接手老代码都会有很多愤慨,碰到同事的代码十分丑陋应不应该改!我也是这样,我相信有很多人同样有跟我一样的经历。满打满算实习 + 正式工作,我也敲了两年多代码,我今天来说说我自己的看法吧。 ...
继续阅读 »

开篇


大家好,我是聪。想必对于很多初入职场,心中怀着无限激情的兄弟们,对于接手老代码都会有很多愤慨,碰到同事的代码十分丑陋应不应该改!我也是这样,我相信有很多人同样有跟我一样的经历。满打满算实习 + 正式工作,我也敲了两年多代码,我今天来说说我自己的看法吧。


亲身经历


我第一次接手老代码的时候,映入我眼帘的就是侧边栏满页的黄色提示以及代码下面的众多黄色波浪线,以及提交代码时的提示,如下图:


image-20240620084453396.png


我内心 OS:


1)大干一场,把黄色波浪线全干掉!


2)同事这写的也太不优雅了吧,改成我这样!


3)这代码怎么也没格式化,我来 Ctrl + Alt + L 格式化一波!


已老实,求放过


image-20240620095636440.png


干掉黄色波浪线,将代码改 ”优雅“ 结局如下:


1)不声不吭动了同事代码,换来同事怒骂,毕竟人家逻辑写好,然后你按你想法来搞,也没有跟人家商量。


2)后续领导找你加需求,你发现原来之前的代码有妙用,你悔不当初,被扣绩效。


3)格式化后,在项目修改记录上面是你的修改,这代码出问题,负责人先来找你。


说说我的看法


代码能跑不要动


前几日我要在老项目中,新增一点小功能,在新增完功能后,我扫了一眼代码,发现有几处逻辑根本不会执行,比如:抛异常后,执行删除操作类似,我也不会去义愤填膺的去干掉这块代码,毕竟我想到一点!项目都跑七八年没出问题了,能跑就别动它。


代码强迫症不要强加于别人


前几日在某金看见了这样一个沸点:


image-20240620095955748.png

这样的事情其实在小公司经常发生,你觉得它写的不优雅,封装少,可能是别人也有别人的难处,至少不能将自己想法强加于别人,比如领导突然来一个需求,跟你说今天你得完成,然后第二天这个需求,你要这样改、再给我加点新需求上去,你能想到的封装其实只是你冷静下来,而且没有近乎疯狂的迭代需求得到的想法,当你每天都要在原代码上面疯狂按照领导要求修改,可能你会有自己的看法。


新增代码,尽量不影响以前逻辑


image-20240618163649720.png


新增代码的时候,尽量按照以前的规则逻辑来进行,比如我改的一个老项目,使用的公司自己写的一套 SQL 处理逻辑,我总不能说不行!我用不惯这个!我要用 MyBatis!!!!那真的直接被 T 出门口了。


尊重他人代码风格


每个人的代码风格都有所不同,这个很正常,不同厨师的老师教法不一样,做出的味道还不一样呢,没有最好的代码,只有更适合的代码,刚好我就有这样的例子:


我注入 Spring 依赖喜欢用构造注入、用 Lombook 的注解 @RequiredArgsConstructor 注入,我同事喜欢 @Autowired ,我能说他不准用这个吗,这个是人家的习惯,虽然 Spring 也不推荐使用这个,但改不改这个都不会影响公司收益,反而能少一件事情,促进同事友好关系,哈哈哈哈,我是这样认为的。


处理好同事之间的关系


哈哈哈哈这个真的就是人情事故了,你换位想象一下,如果你写的幸幸苦苦的代码,新来的同事或者实习生,来批评你的代码不规范,要 Diss 你,偷偷改你代码,就算他说的超级对,你心里都十分不好受,会想一万个理由去反驳。


我一般如果需求需要改动同事的代码,我会先虚心的向同事请求,xx哥,我这个需求要改动你这边的代码来配合一下,你来帮我一起看看,你这部分的代码这样改合理吗,或者你自己改下你自己的部分,然后我合并一下~ 谢谢 xx哥。


image-20240620102755432.png


最后


希望大家都能遇到与自己志同道合的同事一起快乐的开发~,我经历的可能没有大家伙的多,大家如果还碰见因为改老代码发生的惨案,欢迎大家一起来分享,我也来跟大佬们学习一下~


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

离职前同事将下载大文件功能封装成了npm包,赚了145块钱

web
这几天有个同事离职了,本来那是last day,还有半个小时,但他还在那里勤勤恳恳的写着代码。我很好奇,就问:老张,你咋还不做好准备,都要撤了,还奋笔疾书呐。他说:等会儿和你说。 等了半个小时,他说:走,一起下班。我跟你说个好东西。 我说:好的。 老张一边走...
继续阅读 »

这几天有个同事离职了,本来那是last day,还有半个小时,但他还在那里勤勤恳恳的写着代码。我很好奇,就问:老张,你咋还不做好准备,都要撤了,还奋笔疾书呐。他说:等会儿和你说。



等了半个小时,他说:走,一起下班。我跟你说个好东西。


我说:好的。


老张一边走一边跟我说:公司的下载大文件代码不好。


我说哪里不好了,不是都用了很久了。


他说,那些代码,每次项目需要的时候,还得拷过来拷过去的,有时候拷着拷着就拷丢了,还得去网上现找代码,很不好。


我问:那然后呢?


他说:我这两天把这段代码封装了一下,封装成了npm包。以后,大家就直接调用就可以了,不用重复造轮子,或者担心轮子走丢了。我说那太好了。


他说:我把这个npm包给你,以后你就说自己写的,下个季度晋升的时候,你就说,为公司解决了代码冗余,重复造轮子的问题,而且让下载大文件功能更加便捷,节省开发时间,提升了开发效率。


我说:那怎么好啊,得请你吃个饭啊,你都要走了。不过,你先跟我说说,怎么用这个npm包啊。



下载大文件版


比如我们现有的成形的项目,大家使用axios或者fetch,一定在项目里已经封装好了请求,所以直接调用服务端给的请求地址,获取到blob数据流信息就可以了。但是拿到blob数据流以后,这段代码得四处拷贝,重复造轮子,很不好。所以可以这样使用,高效、便捷。


下载js-tool-big-box工具包


执行安装命令



npm install js-tool-big-box



项目中引入ajaxBox对象,下载文件的公共方法,downFile 在这个对象下面。


import { ajaxBox } from 'js-tool-big-box';

调用实现下载


比如你在项目中已经封装好了axios或者fetch的实现,那么只需要正常发送请求,然后调用方法即可,使用非常方便。


fetch('https://test.aaa.com/getPDF').then(res => res.blob()).then((blob) => {
ajaxBox.downFile(blob, '优乐的美.pdf');
});

在这个方法中,你只要将接口返回的信息流转为blob流,然后传入 downFile 方法中,然后再传入一个参数做为下载后的文件名即可。


fetch请求 + 下载实现版本


我又问他,的确是很多项目里,请求都已经封装好了。但我之前做过一个项目,功能很简单,大部分都是展示类的。但产品在一个详情页,让我加下载功能,我的请求并没有做封装。


然后呢,服务端告诉我,这个下载文件的接口,还需要传入参数params,需要传入headers,你这个方法就不适用了吧?


他想了一下,说。也是可以的,你听我说啊。


定义请求参数们


const url = 'https://test.aaaa.com/getPDF';
const headers = {
'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8',
'CCC-DDD': 'js-tool-big-box-demo-header'
}
const params = {
name: '经海路大白狗',
startDate: '2024-03-05',
endDate: '2024-04-05',
}

你看这些参数了吗?url就是下载文件需要的那个接口,如果是get请求呢,你就按照get形式把参数拼接上去,如果是post形式呢,你就需要后面的这个params变量做为入参数据。如果服务端需要headers呢,你就再将headers定义好,准备往过传。


调用实现


ajaxBox.downFileFetch(url, '相的约奶的茶.mp4', 'get', headers, dataParams);

你看到这个 downFileFetch 方法了吧,他也在 ajaxBox 对象下面。



第一个参数呢,表示服务端接口,如果是get请求呢,就把参数拼接上去;


第二个参数呢,表示下载后文件名,比如 down.pdf 这样;


第三个参数呢,默认是get请求,如果不想写get呢,你就写个null,但是你得写进去,如果服务端要求是个post请求呢,你就写post;
第四个参数呢,就是headers啦,服务端需要你就传过去,不需要你就写个null;
第五个参数呢,如果是psot请求,你就传入json对象过去,如果没有参数,你就不写也行,写个null也行。



我说:你这个工具库真是棒,js-tool-big-box,就是前端JS的一个大盒子啊。他说:是的,里面还有很多特别实用的方法,用了这个工具库后,前端项目可以少些很多公共方法,少引很多第三方库,很不错的。我也要离职了,你在公司就说这是你开发的。


我说:那我得请你吃饭啊。于是,我去买了一瓶茅台王子酒,花了260元,定了两份炒饼,花了30元。


等吃完,我说,你这个工具库可以啊,直接从我这里挣了290元。


他说:看你说的,酒你喝了一半,炒饼你吃了一份。我这顶多也就是145元啊。


看完不过瘾?这里有更全的js-tool-big-box使用指南哦,掘金链接直达(只会Vue的我,一入职就让用React,用了这个工具库,我依然高效 - 掘金 (juejin.cn))




最后告诉你个消息:
js-tool-big-box的npm地址是(js-tool-big-box 的npm地址)


js-tool-big-box的git仓库地址(js-tool-big-box的代码仓库地址)


作者:经海路大白狗
来源:juejin.cn/post/7379524605104848946
收起阅读 »

34岁程序员带全家离开北京的故事

哈喽大家好,我是大圣,正如标题所说,我离开北京了 视频链接 今天来聊一下我在北京的这17年,北京好在哪,以及为什么要选择离开, 以及下一步的打算 想 提前声明,我下面会说一些北京和英国的优点,我个人只在英国生活的一个月,我体验到的优点肯定非常片面,也欢迎评论区...
继续阅读 »

哈喽大家好,我是大圣,正如标题所说,我离开北京了


视频链接


今天来聊一下我在北京的这17年,北京好在哪,以及为什么要选择离开, 以及下一步的打算

提前声明,我下面会说一些北京和英国的优点,我个人只在英国生活的一个月,我体验到的优点肯定非常片面,也欢迎评论区讨论


离开北京的当天我还兴致勃勃的拿着gopro 准备拍个视频,但是下了楼看到北京天,突然很感慨,没了拍视频的兴致,拍几个照留个纪念吧


image.png


image.png


image.png


image.png


image.png


image.png


image.png


北京好在哪


我2007年来北京念书,咱们国家排名前50的那200所高校之一,我们家家庭条件不是特别好,但是北京让我开了眼界


我来自一个国家级贫困县,我跟我哥俩人上大学比较费钱,到我大学毕业那天起,我们家外面还欠着外债,经济虽然窘迫,但是我也感受到了后海步行街的灯红酒绿,鸟巢水立方世界级赛事,毕业之后互联网黄金时代的革命,也正是北京各地的创业咖啡厅里,很多投资人和创业者激情的演讲,让我接触到软件编程,成就了现在的我


可以说一线城市虽然压力很大,但还是给了普通人很多的机会,如果我毕业就在我们老家那边阜阳,或者努努力去省会合肥,生活应该也很棒,我可能就不会有现在这么多选择


北京很好,我在这里工作,学习编程,创业,我33岁的时候好像完成了人生第一阶段的任务,在北京有房有车,我媳妇有北京户口,看起来养娃也没什么问题,那为什么要离开呢


两个原因吧,养娃和个人职业实现


从我媳妇查出来怀孕的那天,我兴奋了一整天,但是到了晚上就开始和媳妇讨论,怎么让他成为一个快乐的孩子,讨论了俩星期吧


我其实已经卷出来了,但是我不想让孩子也走这条路,我觉得比较辛苦,他现在还只是一个胚胎,后面的中高考,考研考公,面试,裁员等等,有没有什么事情能让他快乐一些,以后大学报志愿的时候,也不会只看就业率如何来选择,讨论来 讨论去,最后冒出一个新想法,要不出国试试,都说老外快乐教育


第二个就是我跟媳妇个人的价值实现,我媳妇是低我三届的学妹,我俩处对象那会他还在读研,她学设计的,腾讯当产品经理实习生,为了北京户口去了国企,算是为家庭做了一些贡献,我感觉国企的工作稍显琐碎一些


我在的互联网行业,很刺激很好玩,很严重的35岁裁员危机,最近大家也能看到很多新闻,工资确实高,裁员也猛


我们都希望能有一个有趣的,持久的职业生涯,能够有大量的时间,去学习怎么成为一个合格的父母,少赚一些可以的,但是离开北京,国内的城市好像很难实现


而且我跟我媳妇一直都有一个环游世界的梦想,只是一只没机会实现,所以,出国这两个字 就在我们家出现了


为什么选英国


首先基本大家都有点英语基础,但是学起来很难受,所以真的不想学二外,并且咱们普通人,基本投资移民和咱们也没啥关系,不花钱的工签就是性价比最高的


所以符合这个要求的,能找到程序员工作的美国,新加坡,加拿大,澳洲,英国,新西兰,和一些欧洲的国家


然后不想努力奋斗了,排除了美国和新加坡


支持远程工作,不卷,最好时区和国内有重合,我国内的卖课服务可以继续


当然准备去后,英国有一些优点我觉得可以参考,可能其他国家也有 欢迎补充



  1. 英国好学校还挺多,以后对孩子教育估计不错

  2. 英国没有蚊子蛇,想想夏天就爽

  3. 英国PR拿到后,每年只需要入境一次就可以续,所以我可以五年后继续回国定居,同时保持英国PR,如果孩子以后回国念书,我这几年也不算浪费

  4. 英国有阿森纳,对我来说优势太大了,我已经期待去唱north london forever了,掉点眼泪估计是必须的

  5. 这边的文化生活还是挺丰富的,博物馆,演出之类的

  6. 比较严格的八小时工作制,是真的不加班,而且remote的还挺多的,很多非remote的也是每周去两天左右,有很多的时间来做课程,陪家人,年假也挺多的,我记得是20多天,准备度假的时候再用

  7. 空气我觉得也不错,无论晴天阴天,都没什么发白的雾霾的感觉


不过以上都不是硬性条件,比如美国,加拿大澳大利亚我也会看机会,我也会考虑去奋斗,整体比较随缘, 我之前也分享过一些学英语的内容,然后给英国这边remote了半年后,问了下工签政策,元旦那会就担保过来了,然后体验了一个月,觉得还能接受,然后回国 准备搬家的事


打算


英国当然也有特别多的缺点,比如我一个朋友就因为税务和天气问题,准备去新加坡,也有一个因为职业发展问题准备去北美奋斗,也同时有朋友努力学英语去澳洲,和从澳洲努力准备回国


都是个人选择,主要就是你想过什么样的生活


英国吃的也不咋地,我也不太喜欢这边的酒吧文化,也没有国内安全,所以我也在探索


我在英国还是remote,所以暂时就在伦敦和周边生活,反正5年后拿绿卡,后面需要很努力的带娃,花大量的时间生活,旅游,比如在欧洲多玩一玩


同时继续发展卖课视野,做一个好的讲师,研发远程,或者web3的开发课程,除了国内也开辟一下欧洲市场


伦敦这边公园特别多,我住的两个地方周边溜达十分钟,都有草坪非常好的公园,非常适合遛娃和遛狗


而且也很适合养狗,狗可以坐地铁,火车,非常方便


我家孩子也一岁了,我希望以后我能成为一个合格的父亲把,能够陪他成长,在我的能力范围内,给他多一些选择,多快乐一些


找一些能持续成长的爱好,能够和孩子一起成长,比如踢球网球,重新开始打dota2,黑悟空也准备配个电脑好好玩,回归生活


下一期聊一下英国这边的生活体验,成本啥的吧,开心工作,努力生活
视频版


image.png


image.png


作者:花果山大圣
来源:juejin.cn/post/7380513226155868214
收起阅读 »

一个小公司的技术开发心酸事

背景 长话短说,就是在2022年6月的时候加入了一家很小创业公司。老板不太懂技术,也不太懂管理,靠着一腔热血加上对实体运输行业的了解,加上盲目的自信,贸然开始创业,后期经营困难,最终散伙。 自己当时也是不察,贸然加入,后边公司经营困难,连最后几个月的工资都没给...
继续阅读 »

背景


长话短说,就是在2022年6月的时候加入了一家很小创业公司。老板不太懂技术,也不太懂管理,靠着一腔热血加上对实体运输行业的了解,加上盲目的自信,贸然开始创业,后期经营困难,最终散伙。


自己当时也是不察,贸然加入,后边公司经营困难,连最后几个月的工资都没给发。


当时老板的要求就是尽力降低人力成本,尽快的开发出来App(Android+IOS),老板需要尽快的运营起来。


初期的技术选型


当时就自己加上一个刚毕业的纯前端开发以及一个前面招聘的ui,连个人事、测试都没有。


结合公司的需求与自己的技术经验(主要是前端和nodejs的经验),选择使用如下的方案:



  1. 使用uni-app进行App的开发,兼容多端,也可以为以后开发小程序什么的做方案预留,主要考虑到的点是比较快,先要解决有和无的问题;

  2. 使用egg.js + MySQL来开发后端,开发速度会快一点,行业比较小众,不太可能会遇到一些较大的性能问题,暂时看也是够用了的,后期过渡到midway.js也方便;

  3. 使用antd-vue开发运营后台,主要考虑到与uni-app技术栈的统一,节省转换成本;


也就是初期选择使用egg.js + MySQL + uni-app + antd-vue,来开发两个App和一个运营后台,快速解决0到1的问题。


关于App开发技术方案的选择


App的开发方案有很多,比如纯原生、flutter、uniapp、react-native/taro等,这里就当是的情况做一下选择。



  1. IOS与Android纯原生开发方案,需要新招人,两端同时开发,两端分别测试,这个资金及时间成本老板是不能接受的;

  2. flutter,这个要么自己从头开始学习,要么招人,相对于纯原生的方案好一点,但是也不是最好的选择;

  3. react-native/taro与uni-app是比较类似的选择,不过考虑到熟练程度、难易程度以及开发效率,最终还是选择了uni-app。


为什么选择egg.js做后端


很多时候方案的选择并不能只从技术方面考虑,当是只能选择成本最低的,当时的情况是egg.js完全能满足。



  1. 使用一些成熟的后端开发方案,如Java、、php、go之类的应该是比较好的技术方案,但对于老板来说不是好的经济方案;

  2. egg.js开发比较简单、快捷,个人也比较熟悉,对于新成员的学习成本也很低,对于JS有一定水平的也能很快掌握egg.js后端的开发


中间的各种折腾


前期开发还算顺利,在规定的时间内,完成了开发、测试、上线。但是,老板并没有如前面说的,很快运营,很快就盈利,运营的开展非常缓慢。中间还经历了各种折腾的事情。



  1. 老板运营遇到困难,就到处找一些专家(基本跟我们这事情没半毛钱关系的专家),不断的提一些业务和ui上的意见,不断的修改;

  2. 期间新来的产品还要全部推翻原有设计,重新开发;

  3. 还有个兼职的领导非要说要招聘原生开发和Java开发重新进行开发,问为什么,也说不出什么所以然,也是道听途说。


反正就是不断提出要修改产品、设计、和代码。中间经过不断的讨论,摆出自己的意见,好在最终技术方案没修改,前期的工作成果还在。后边加了一些新的需求:系统升级1.1、ui升级2.0、开发小程序版本、开发新的配套系统(小程序版本)以及开发相关的后台、添加即时通信服务、以及各种小的功能开发与升级;


中间老板要加快进度了就让招人,然后又无缘无故的要开人,就让人很无奈。最大的运营问题,始终没什么进展,明显的问题并不在产品这块,但是在这里不断的折腾这群开发,也真是难受。


明明你已经很努力的协调各种事情、站在公司的角度考虑、努力写代码,却仍然无济于事。


后期技术方案的调整



  1. 后期调整了App的打包方案;

  2. 在新的配套系统中,使用midway.js来开发新的业务,这都是基于前面的egg.js的团队掌握程度,为了后续的开发规范,做此升级;

  3. 内网管理公用npm包,开发业务组件库;

  4. 规范代码、规范开发流程;


人员招聘,团队的管理


人员招聘


如下是对于当时的人员招聘的一些感受:



  1. 小公司的人员招聘是相对比较难的,特别是还给不了多少钱的;

  2. 好在我们选择的技术方案,只要对于JS掌握的比较好就可以了,前后端都要开发一点,也方便人员工作调整,避免开发资源的浪费。


团队管理


对于小团队的管理的一些个人理解:



  1. 小公司刚起步,就应该实事求是,以业务为导向;

  2. 小公司最好采取全栈的开发方式,避免任务的不协调,造成开发资源的浪费;

  3. 设置推荐的代码规范,参照大家日常的代码习惯来制定,目标就是让大家的代码相对规范;

  4. 要求按照规范的流程设计与开发、避免一些流程的问题造成管理的混乱和公司的损失;

    1. 如按照常规的业务开发流程,产品评估 => 任务分配 => 技术评估 => 开发 => 测试 => cr => 上线 => 线上问题跟踪处理;



  5. 行之有效可量化的考核规范,如开发任务的截止日期完成、核心流程开发文档的书写、是否有线上bug、严谨手动修改数据库等;

  6. 鼓励分享,相互学习,一段工作经历总要有所提升,有所收获才是有意义的;

  7. 及时沟通反馈、团队成员的个人想法、掌握开发进度、工作难点等;


最后总结及选择创业公司避坑建议!important



  1. 选择创业公司,一定要确认老板是一个靠谱的人,别是一个总是画饼的油腻老司机,或者一个优柔寡断,没有主见的人,这样的情况下,大概率事情是干不成的;

    1. 老板靠谱,即使当前的项目搞不成,也可能未来在别的地方做出一番事情;



  2. 初了上边这个,最核心的就是,怎么样赚钱,现在这种融资环境,如果自己不能赚钱,大概率是活不下去的@自己;

  3. 抓住核心矛盾,解决主要问题,业务永远是最重要的。至于说选择的开发技术、代码规范等等这些都可以往后放;

  4. 对上要及时反馈自己的工作进度,保持好沟通,老板总是站在更高一层考虑问题,肯定会有一些不一样的想法,别总自以为什么什么的;

  5. 每段经历最好都能有所收获,人生的每一步都有意义。




作者:qiuwww
来源:juejin.cn/post/7257085326471512119
收起阅读 »

关于鸿蒙开发,我暂时放弃了

起因 在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。 # 鸿蒙HarmonyOS从零实现类微信app效果第一篇,基础界面搭建 # 鸿蒙HarmonyOS从零实现类微信app效果第二篇,我的+发现页面实...
继续阅读 »

image.png


image.png


起因


在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。


企业微信截图_6f8acb94-bd68-4f56-9460-4a59d2370a4a.png



鸿蒙的arkui,使用typescript作为基调,然后响应式开发,对于我这个old android来说,确实挺惊艳的。而且在模拟器中运行起来也很快,写demo的过程鸡血满满,着实很愉快。


后面自己写的文章,也在掘金站点上获得了不错的评价。


企业微信截图_fa34f233-af43-4567-8dac-57ef5666f1bd.png


image.png


打击


今天下午,刚好同事有一个遥遥领先(meta 40 pro),鸿蒙4.0版本


怀着秀操作的想法,在同事手机上运行了起来。very nice。 一切出奇的顺利。


but ...


尼玛,点击的时候,直接卡住不对,黑屏。让人瞬间崩溃。


本着优先怀疑自己的原则,我找了一个官方的demo。 运行起来。


额...


尼玛。还是点击之后卡住了,大概30s之后,才跳转到新的页面。


image.png


这一切,让我熬夜掉的头发瞬间崩溃。


放弃了...


放弃了...


后续


和其他学习鸿蒙的伙伴沟通,也遇到了同样的问题,真机不能运行,会卡线程。但是按下home键,再次回到界面,页面会刷新过来


我个人暂时决定搁置对于鸿蒙开发的学习了,后续如果慢慢变得比较成熟之后,再次接触学习吧。



后续个人计划:




  • 1、还是会持续关注后续版本是否真机能运行,传言api 10对黑屏和真机无法运行的修复了。奈何官方所有渠道的编译器都没有api 10 的模拟器,真机4.0按道理是支持api10,但是还是黑屏,再持续观察吧。插个眼

  • 2、为了贯彻执行持续学习。后续可能会持续更新jetpack compose相关内容,包含且不局限于 compose desktop以及multi platform


最新情报:有网友告知我,在meta60上是运行没问题的,可能是最新版4.0是ok的,那么结论就是目前真机适配不完善


作者:王先生技术栈
来源:juejin.cn/post/7304538094736343052
收起阅读 »

全年免费!环信发布出海创新版,助力泛娱乐创业者扬帆起航

目前,以陌生人社交、直播、语聊、电商等热门场景为代表的社交泛娱乐出海正发展得如火如荼,成为企业新的增长曲线。但随着出海企业增多,海外市场争夺、资源竞争与技术博弈也愈加激烈。为了让更多创业者与创新者获得支持,快速高效地打通出海通道。社交出海的解决方案专家环信于近...
继续阅读 »

目前,以陌生人社交、直播、语聊、电商等热门场景为代表的社交泛娱乐出海正发展得如火如荼,成为企业新的增长曲线。但随着出海企业增多,海外市场争夺、资源竞争与技术博弈也愈加激烈。

为了让更多创业者与创新者获得支持,快速高效地打通出海通道。社交出海的解决方案专家环信于近期正式推出“出海创新版”,全年免费为创业者和开发者提供必要的工具和资源,打造下一个出海独角兽。


环信IM出海创新版,海外能力与热门场景应有尽有

环信IM出海创新版汇聚了丰富齐全的海外能力,并覆盖大多数热门应用场景,助力社交泛娱乐出海开发者和创业者,成为真正吃到螃蟹、拿到红利的人。

目前,热门场景应用覆盖了1V1视频、AI社交、陌生人社交、视频直播、语聊房。海外能力上,则能提供国际版UIKIT、多语言翻译、多语言内容审核、表情贴纸、表情回应、超级社区等,助力开发者快速构建个性化娱乐社交场景。

丰富齐全的海外能力



热门场景应用

超级优惠力度来袭:全年免费+超值生态资源

为了全力支持创业者与开发者更好的开启出海征程,此次环信 IM 出海创新版推出了专属套餐版本,只要参与的企业就能以0元/月,全年免费的价格获取包含IM所有核心功能和服务,详情如下:

立即免费申请:https://www.easemob.com/event/special2024

除此之外,环信还借助自身生态资源和技术优势,为出海企业提供包含实时音视频、内容审核、消息推送、美颜、数字人、超级社区在内的超值生态伙伴助力场景功能大礼包。


在生态资源上,出海企业可以通过环信,与资深出海社交泛娱乐创业者沟通交流、与产品技术大牛创新协作、不定期参加全球创新方向洞见分享、高价值场景交流等。

不仅如此,出海企业还可以加入由声网发起的“超音速计划”,与优秀的创业者们进行深度交流与竞技,还有机会获得投资与孵化、产品和技术支持、市场与品牌展示等机会等。用生态的力量为自己的创业项目赋能,共同探索如何让出海之路走得更顺、走得更远。

行业明星应用背书 环信IM质量服务有保障

目前,星野、Hungry Panda、Autel Maxifix、locklok等社交泛娱乐出海的明星应用都在使用环信IM的服务,并依托环信IM的高并发稳定服务支撑用户的快速增长。


以定位为沉浸式AI内容社区的星野为例,环信IM通过提供高效、稳定、安全的即时通讯服务,帮助星野提高了用户粘性和转化率,推动人与AI智能互动体验共同发展。

对于任何一个社交泛娱乐出海的企业而言,海外资源能力的配置都显得至关重要。环信IM作为一个成熟的即时通讯产品,无论是从技术、资源还是合规上,都有充分的能力为“出海”提供一套完整的技术支持和服务框架。

现在就扬帆起航,加入“环信IM出海创新版”吧!

参考文档:

收起阅读 »

我写了一个程序,让端口占用无路可逃

作为一个 Java 工程师,经常会遇到这么个场景:IDEA 里的程序正在运行,此时直接关闭了 IDEA 而没有先关闭正在运行的服务。 在绝大多数情境下,此方式都无伤大雅,但总有一些抽风的场景运行的程序并没有被正常的关闭,也就导致了重启项目时将会提示 xxxx ...
继续阅读 »

作为一个 Java 工程师,经常会遇到这么个场景:IDEA 里的程序正在运行,此时直接关闭了 IDEA 而没有先关闭正在运行的服务。


在绝大多数情境下,此方式都无伤大雅,但总有一些抽风的场景运行的程序并没有被正常的关闭,也就导致了重启项目时将会提示 xxxx 端口已被占用。


Windows 下此方式解决也十分简单,在命令行输入下述两个命令即可根据端口关闭对应的进程。


# 端口占用进程
netstat -ano | findstr <port>

# 进程关闭
taskkill -PID <pid> -F

虽然说也不麻烦但却很繁杂,试想一下当遇到这种情况下,我需要先翻笔记找出这两个命令,在打开命令行窗口执行,一套连招下来相当影响编程情绪。


因此,我决定写一个程序能够便捷的实现这个操作,最好是带 GUI 页面。


说干就干,整个程序功能其实并不复杂,对于页面的展示要求也不高,我就确定下来了直接通过 Java Swing 实现 GUI 部分。而对于命令执行部分,在 Java 中提供了 Process 类可用于执行命令。


先让我们看下 Process 的作用方式,以最简单的 ping baidu.com 测试为例。


public void demo() {  
ProcessBuilder processBuilder = new ProcessBuilder();
List<String> command = new ArrayList<>();
command.add("ping");
command.add("www.baidu.com");
processBuilder.command(command);

try {
Process process = processBuilder.start();
try (
InputStreamReader ir = new InputStreamReader(process.getInputStream(), "GBK");
BufferedReader br = new BufferedReader(ir)
) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

运行上述的代码,在控制台可以得到下图结果:


image.png


在上述程序中,ProcessBuilder 用于构建命令,processBuilder.start() 则相当于你敲下回车执行,而执行的结果的则以 IO 流的形式返回,这里通过 readLine() 将返回的结果逐行的形式进行读取。


了解的大概原理之后,剩下的事情就简单了,只需要将之前提到的两个命令以同样的方式通过 Process 执行就可以,再通过 Java Swing 进行一个页面展示就可以。


具体的实现并不复杂,这里就不详细展开介绍,完整的项目代码已经上传到 GitHub,感兴趣的小伙伴可自行前往查看,仓库地址:windows-process


下面主要介绍程序的使用与效果,开始前可以去上述提到的仓库 relase 里将打包完成的 exe 程序下载,下载地址


下载后启动 window process.exe 程序,在启动之后会先弹出下图的提示,这是因为使用了 exe4j 打包程序,选择确认即可。


image.png


选择确认之后即会展示下图页面,列表中展示的数据即 netstat -ano 命令返回的结果,


image.png


在选中列表任意一条进程记录后,会将该进程对应的端口号和 PID 填充至上面的输入框中。


20240630_104641.gif


同时,可在 Port 输入框中输入对应的端口号实现快速查询,若需要停止某个进程,则将点击对应端口进程记录其 PID 会自动填入输入框中,然后单击 Kill 按钮,成功停止进程后将会进行相应的提示。


最后的最后,再臭不要脸的给自己要个赞,觉得不错的可以去 GitHub 仓库上下载下来看看,如果能点个 star 更是万分感谢,这里再贴一下仓库地址:windows-process


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7385499574881026089
收起阅读 »

null 不好,我真的推荐你使用 Optional

"Null 很糟糕." - Doug Lea。 Doug Lea 是一位美国的计算机科学家,他是 Java 平台的并发和集合框架的主要设计者之一。他在 2014 年的一篇文章中说过:“Null sucks.”1,意思是 null 很糟糕。他认为 null 是...
继续阅读 »

"Null 很糟糕." - Doug Lea。



Doug Lea 是一位美国的计算机科学家,他是 Java 平台的并发和集合框架的主要设计者之一。他在 2014 年的一篇文章中说过:“Null sucks.”1,意思是 null 很糟糕。他认为 null 是一种不明确的表示,它既可以表示一个值不存在,也可以表示一个值未知,也可以表示一个值无效。这样就会导致很多逻辑错误和空指针异常,给程序员带来很多麻烦。他建议使用 Optional 类来封装可能为空的值,从而提高代码的可读性和健壮性。



"发明 null 引用是我的十亿美元错误。" - Sir C. A. R. Hoare。



Sir C. A. R. Hoare 是一位英国的计算机科学家,他是快速排序算法、Hoare 逻辑和通信顺序进程等重要概念的发明者。他在 2009 年的一个软件会议上道歉说:“I call it my billion-dollar mistake. It was the invention of the null reference in 1965.”,意思是他把 null 引用称为他的十亿美元错误。他说他在 1965 年设计 ALGOL W 语言时,引入了 null 引用的概念,用来表示一个对象变量没有指向任何对象。他当时认为这是一个很简单和自然的想法,但后来发现这是一个非常糟糕的设计,因为它导致了无数的错误、漏洞和系统崩溃。他说他应该使用一个特殊的对象来表示空值,而不是使用 null。


自作者从事 Java 编程一来,就与 null 引用相伴,与 NullPointerException 相遇已经是家常便饭了。


null 引用是一种表示一个对象变量没有指向任何对象的方式,它是 Java 语言中的一个特殊值,也是导致空指针异常(NullPointerException)的主要原因。虽然 null 引用可以用来表示一个值不存在或未知,也可以用来节省内存空间。但是它也不符合面向对象的思想,因为它不是一个对象,不能调用任何方法或属性。


可以看到,null 引用并不好,我们应该尽量避免使用 null,那么我们该怎么避免 null 引用引起的逻辑错误和运行时异常嘞?


其实这个问题 Java 的设计者也知道,于是他们在 Java8 之后设计引入了 Optional 类解决这个问题,本文将给大家详细介绍下 Optional 类的设计目的以及使用方法。



Optional 类是什么?


Optional 类是 java 8 中引入的一个新的类,它的作用是封装一个可能为空的值,从而避免空指针异常(NullPointerException)。Optional 类可以看作是一个容器,它可以包含一个非空的值,也可以为空。Optional 类提供了一些方法,让我们可以更方便地处理可能为空的值,而不需要显式地进行空值检查或者使用 null。



推荐作者开源的 H5 商城项目waynboot-mall,这是一套全部开源的微商城项目,包含三个项目:运营后台、H5 商城前台和服务端接口。实现了商城所需的首页展示、商品分类、商品详情、商品 sku、分词搜索、购物车、结算下单、支付宝/微信支付、收单评论以及完善的后台管理等一系列功能。 技术上基于最新得 Springboot3.0、jdk17,整合了 MySql、Redis、RabbitMQ、ElasticSearch 等常用中间件。分模块设计、简洁易维护,欢迎大家点个 star、关注我。


github 地址:github.com/wayn111/way…



Optional 类的设计


Optional 类的设计是基于函数式编程的思想,它借鉴了 Scala 和 Haskell 等语言中的 Option 类型。Optional 类实现了 java.util.function 包中的 Supplier、Consumer、Predicate、Function 等接口,这使得它可以和 lambda 表达式或者方法引用一起使用,形成更简洁和优雅的代码。


Optional 类被 final 修饰,因此它是一个不可变的类,它有两个静态方法用于创建 Optional 对象。


Optional.empty()


Optional.empty 表示一个空的 Optional 对象,它不包含任何值。


// 创建一个空的 Optional 对象
Optional empty = Optional.empty();

Optional.of(T value)


Optional.of 表示一个非空的 Optional 对象,它包含一个非空的值。


// 创建一个非空的 Optional 对象
Optional hello = Optional.of("Hello");

Optional.ofNullable(T value)


注意,如果我们使用 Optional.of 方法传入一个 null 值,会抛出 NullPointerException。如果我们不确定一个值是否为空,可以使用 Optional.ofNullable 方法,它会根据值是否为空,返回一个相应的 Optional 对象。例如:


// 创建一个可能为空的 Optional 对象
Optional name = Optional.ofNullable("Hello");

Optional 对象的使用方法


Optional 对象提供了一些方法,让我们可以更方便地处理可能为空的值,而不需要显式地进行空值检查或者使用 null。以下是一些常用的方法。


isPresent()


判断 Optional 对象是否包含一个非空的值,返回一个布尔值。


get()


如果 Optional 对象包含一个非空的值,返回该值,否则抛出 NoSuchElementException 异常。



// 使用 isPresent 和 get 方法
Optional name = Optional.ofNullable("tom");
if (name.isPresent()) {
System.out.println("Hello, " + name.get());
} else {
System.out.println("Name is not available");
}
// 输出:Hello tom

ifPresent(Consumer action)


如果 Optional 对象包含一个非空的值,执行给定的消费者操作,否则什么也不做。


// 使用 ifPresent(Consumer action)
Optional name = Optional.ofNullable("tom");
name.ifPresent(s -> {
System.out.println("Hello, " + name.get());
});
// 输出:Hello tom

orElse(T other)


如果 Optional 对象包含一个非空的值,返回该值,否则返回给定的默认值。


// 使用 orElse(T other)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElse("Guest");
System.out.println(greeting);
// 输出:Hello Guest

orElseGet(Supplier supplier)


如果 Optional 对象包含一个非空的值,返回该值,否则返回由给定的供应者操作生成的值。


// 使用 orElseGet(Supplier supplier)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElseGet(() -> "Guset");
System.out.println(greeting);
// 输出:Hello Guset

orElseThrow(Supplier exceptionSupplier)


如果 Optional 对象包含一个非空的值,返回该值,否则抛出由给定的异常供应者操作生成的异常。


// 使用 orElseThrow(Supplier exceptionSupplier)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElseThrow(() -> new NullPointerException("null"));
// 抛出 java.lang.NullPointerException: null 异常

map(Function mapper)


如果 Optional 对象包含一个非空的值,对该值应用给定的映射函数,返回一个包含映射结果的 Optional 对象,否则返回一个空的 Optional 对象。


// 使用 map(Function mapper)
Optional name = Optional.ofNullable("tom");
String greeting = "Hello, " + name.map(s -> s.toUpperCase()).get();
System.out.println(greeting);
// 输出:Hello TOM

flatMap(Function> mapper)


如果 Optional 对象包含一个非空的值,对该值进行 mapper 参数操作,返回新的 Optional 对象,否则返回一个空的 Optional 对象。


// 使用 flatMap(Function> mapper)
Optional name = Optional.ofNullable("tom");
String greeting = name.flatMap(s -> Optional.of("Hello " + s)).get();
System.out.println(greeting);
// 输出:Hello tom

filter(Predicate predicate)


如果 Optional 对象包含一个非空的值,并且该值满足给定的谓词条件,返回包含该值的 Optional 对象,否则返回一个空的 Optional 对象。


// filter(Predicate predicate)
Optional name = Optional.ofNullable("tom");
String greeting = "Hello " + name.filter(s -> !s.isEmpty()).get();
System.out.println(greeting);
// 输出:Hello tom

Java 9 中 Optional 改进


Java 9 中 Optional 类有了一些改进,主要是增加了三个新的方法,分别是 stream()、ifPresentOrElse() 和 or()。这些方法可以让我们更方便地处理可能为空的值,以及和流或其他返回 Optional 的方法结合使用。我来详细讲解一下这些方法的作用和用法。


stream()


这个方法可以将一个 Optional 对象转换为一个 Stream 对象,如果 Optional 对象包含一个非空的值,那么返回的 Stream 对象就包含这个值,否则返回一个空的 Stream 对象。这样我们就可以利用 Stream 的各种操作来处理 Optional 的值,而不需要显式地判断是否为空。我们可以用 stream() 方法来过滤一个包含 Optional 的列表,只保留非空的值,如下所示:


List> list = Arrays.asList(
Optional.empty(),
Optional.of("A"),
Optional.empty(),
Optional.of("B")
);

// 使用 stream() 方法过滤列表,只保留非空的值
List filteredList = list.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());

System.out.println(filteredList);
// 输出 [A, B]

ifPresentOrElse(Consumer action, Runnable emptyAction)


这个方法可以让我们在 Optional 对象包含值或者为空时,执行不同的操作。它接受两个参数,一个是 Consumer 类型的 action,一个是 Runnable 类型的 emptyAction。如果 Optional 对象包含一个非空的值,那么就执行 action.accept(value),如果 Optional 对象为空,那么就执行 emptyAction.run()。这样我们就可以避免使用 if-else 语句来判断 Optional 是否为空,而是使用函数式编程的方式来处理不同的情况。我们可以用 ifPresentOrElse() 方法来打印 Optional 的值,或者提示不可用,如下所示 :


Optional optional = Optional.of(1);
optional.ifPresentOrElse(
x -> System.out.println("Value: " + x),
() -> System.out.println("Not Present.")
);

optional = Optional.empty();
optional.ifPresentOrElse(
x -> System.out.println("Value: " + x),
() -> System.out.println("Not Present.")
);

// 输出:Value: 1
// 输出:Not Present.

or(Supplier> supplier)


这个方法可以让我们在 Optional 对象为空时,返回一个预设的值。它接受一个 Supplier 类型的 supplier,如果 Optional 对象包含一个非空的值,那么就返回这个 Optional 对象本身,如果 Optional 对象为空,那么就返回 supplier.get() 返回的 Optional 对象。这样我们就可以避免使用三元运算符或者其他方式来设置默认值,而是使用函数式编程的方式来提供备选值。我们可以用 or() 方法来设置 Optional 的默认值,如下所示:


Optional optional = Optional.of("Hello ");
Supplier> supplier = () -> Optional.of("tom");
optional = optional.or(supplier);
optional.ifPresent(x -> System.out.println(x));

optional = Optional.empty();
optional = optional.or(supplier);
optional.ifPresent(x -> System.out.println(x));

// 输出:Hello
// 输出:tom

为什么我推荐你使用 Optional 类


最后我总结一下使用 Optional 类的几个好处:



  1. 可以避免空指针异常,提高代码的健壮性和可读性。

  2. 可以减少显式的空值检查和 null 的使用,使代码更简洁和优雅。

  3. 可以利用函数式编程的特性,实现更灵活和高效的逻辑处理。

  4. 可以提高代码的可测试性,方便进行单元测试和集成测试。


总之,Optional 类是一个非常有用的类,它可以帮助我们更好地处理可能为空的值,提高代码的质量和效率。所以我强烈推荐你在 Java 开发中使用 Optional 类,你会发现它的魅力和好处。


作者:程序员wayn
来源:juejin.cn/post/7302322661957845028
收起阅读 »

搭建个人直播间,实现24小时B站、斗鱼、虎牙等无人直播!

不知道大家平时看不看直播呢?现在有各式各样的直播,游戏直播、户外直播、带货直播、经典电视/电影直播等等。 电视、电影直播是24小时不间断无人直播,如斗鱼/虎牙中的一起看,这种直播要如何实现呢? 其实非常简单,只需要一台服务器和视频资源就能完成。 再借助于直播...
继续阅读 »

不知道大家平时看不看直播呢?现在有各式各样的直播,游戏直播、户外直播、带货直播、经典电视/电影直播等等。


电视、电影直播是24小时不间断无人直播,如斗鱼/虎牙中的一起看,这种直播要如何实现呢?


其实非常简单,只需要一台服务器和视频资源就能完成。


再借助于直播推流工具,如 KPlayer,将电视剧、电影等媒体资源推流到直播间,就能实现24小时无人直播了!



关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。



KPlayer 简介


KPlayer —— ByteLang Studio 设计开发的一款用于在 Linux 环境下进行媒体资源推流的应用程序。


只需要简单的修改配置文件即可达到开箱即用的目的,不需要了解众多推流适配、视频编解码的细节即可方便的将媒体资源在主流直播平台上进行直播。意愿是提供一个简单易上手、扩展丰富、性能优秀适合长时间不间断推流的直播推流场景。


功能特色:



  • 本地/网络视频资源的无缝推流,切换资源不导致断流

  • 可自定义配置的编码参数,例如分辨率、帧率等

  • 自定义多输出源,适合相同内容一次编码多路推流节省硬件资源

  • 提供缓存机制避免相同内容二次编解码,大大降低在循环场景下对硬件资源的消耗

  • 丰富的API接口在运行时对播放行为和资源动态控制

  • 提供基础插件并具备自定义插件开发的能力


项目地址:https://github.com/bytelang/kplayer-go
在线文档:https:/
/docs.kplayer.net/v0.5.8/

安装 KPlayer


KPlayer 支持一键安装、手动安装和 Docker 安装。


一键安装


通过 ssh 进入到你的服务器中,找到合适的目录并运行以下的命令进行下载:


curl -fsSL get.kplayer.net | bash

手动安装(可选)


1、下载压缩包


wget http://download.bytelang.cn/kplayer-v0.5.8-linux_amd64.tar.gz

2、解压压缩包


tar zxvf kplayer-v0.5.8-linux_amd64.tar.gz

安装完成


1、执行 cd kplayer 进入到 kplayer 目录,使用 ll 查看文件列表:


-rw-r--r-- 1 root root 285 3  23 18:23 config.json.example
-rwxr-xr-x 1 root root 27M 7 29 11:12 kplayer


  • config.json.exampleKPlayer 最小化的配置信息示例

  • kplayerKPlayer 服务启动、停止的执行脚本命令


2、使用 ./kplayer 命令查看当前版本


创建配置文件


1、使用 cp 命令重命名并复制一份 config.json.example


cp config.json.example config.json

2、修改配置文件


{
"version": "2.0.0",
"resource": {
"lists": [
"/video/example_1.mp4",
"/video/example_2.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://127.0.0.1:1935/push"
}
]
}
}


  • resource.lists 视频资源文件路径

  • output.lists 直播推流地址,在B站、斗鱼、虎牙等直播平台中开启直播后,将会得到推流地址与推流码


开启直播


上传视频


上传视频资源到服务器,并修改 KPlayer 中的 resource.lists 视频路径



❗❗❗注意:直播的媒体文件必须得有平台版权,否则就会被投诉,封禁直播间❗




{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://127.0.0.1:1935/push"
}
]
}
}
}

获取推流地址



以开启B站直播为例。



1、点击首页直播


2、点击网页右侧的开播设置


3、选择分类,点击开播



前提需要身-份-证和姓名实名认证




4、复制直播间地址


rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1

5、将直播间地址配置到 KPlayer 配置文件中的 output.lists 直播推流地址


{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
}
}

运行 KPlayer


执行以下命令启动 KPlayer


./kplayer play start


后台运行 KPlayer


./kplayer play start --daemon

测试访问


打开直播间地址,可以看到已经开始直播了。



斗鱼、虎牙等其他直播平台的直播配置也是类似的流程,只需要获取到平台的直播推流地址,并进行配置即可!可以同时配置多个平台同时进行直播!



配置循环播放


KPlayer 提供了很多的配置项,有资源配置、播放配置等。


如:可以配置循环播放视频,这样就可以保证24小时不间断的循环播放视频。


{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
},
## 播放配置
"play": {
"fill_strategy": "ratio",
## 启用推流编码缓存,会生成缓存,命中缓存节约CPU资源
"skip_invalid_resource": true,
"cache_on": true,
# 播放模式为按顺序且循环播放
"play_model": "loop"
}
}


更多的配置信息可参考 KPlayer 提供的文档。



Docker 安装 KPlayer


1、创建缓存目录 /data/software/docker/kplayer/cache


cd /data/software/docker/kplayer
mkdir cache

2、创建配置文件 /data/software/docker/kplayer/config.json


cd /data/software/docker/kplayer
touch config.json

填入配置信息:


{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
},
## 播放配置
"play": {
"fill_strategy": "ratio",
## 启用推流编码缓存,会生成缓存,命中缓存节约CPU资源
"skip_invalid_resource": true,
"cache_on": true,
# 播放模式为按顺序且循环播放
"play_model": "loop"
}
}

2、创建 docker-compose.yml


version: "3.3"
services:
kplayer:
container_name: kplayer
volumes:
- "/data/software/movie:/video"
- "/data/software/docker/kplayer/config.json:/kplayer/config.json"
- "/data/software/docker/kplayer/cache:/kplayer/cache"
restart: always
image: "bytelang/kplayer"

3、启动容器


docker-compose up -d 

以上,就是利用服务器搭建个人直播间的全流程,整个步骤不是很复杂。


我们可以利用闲置的服务器,将自己收藏的电影、电视等资源进行全天候直播,每天还能获得一定的收益!



❗❗❗注意:直播的媒体文件必须得有平台版权,否则就会被投诉,封禁直播间❗



最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/


作者:Java陈序员
来源:juejin.cn/post/7385929329640226828
收起阅读 »

使用双异步后,从 191s 优化到 2s

大家好,我是哪吒。 在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。 一、一般我会这样做: 通过POI读取需要导入的Excel; 以文件名为表名、列头为列名、并将数据拼接成sql; 通过JDBC或mybatis插入数据库; 操作起来,...
继续阅读 »

大家好,我是哪吒。


在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。


一、一般我会这样做:



  1. 通过POI读取需要导入的Excel;

  2. 以文件名为表名、列头为列名、并将数据拼接成sql;

  3. 通过JDBC或mybatis插入数据库;



操作起来,如果文件比较多,数据量都很大的时候,会非常慢。


访问之后,感觉没什么反应,实际上已经在读取 + 入库了,只是比较慢而已。


读取一个10万行的Excel,居然用了191s,我还以为它卡死了呢!


private void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();

StringBuilder insertBuilder = new StringBuilder();

insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");

XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}

insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");

StringBuilder stringBuilder = new StringBuilder();
for (int i = 1; i <= maxRow; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}

boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}

List collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
int sum = JdbcUtil.executeDML(collect);
}

private static boolean isExisted(String id, String name) {
String sql = "select count(1) as num from " + static_TABLE + " where ID = '" + id + "' and NAME = '" + name + "'";
String num = JdbcUtil.executeSelect(sql, "num");
return Integer.valueOf(num) > 0;
}

private static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}

二、谁写的?拖出去,斩了!


优化1:先查询全部数据,缓存到map中,插入前再进行判断,速度快了很多。


优化2:如果单个Excel文件过大,可以采用 异步 + 多线程 读取若干行,分批入库。



优化3:如果文件数量过多,可以采一个Excel一个异步,形成完美的双异步读取插入。



使用双异步后,从 191s 优化到 2s,你敢信?


下面贴出异步读取Excel文件、并分批读取大Excel文件的关键代码。


1、readExcelCacheAsync控制类


@RequestMapping(value = "/readExcelCacheAsync", method = RequestMethod.POST)
@ResponseBody
public String readExcelCacheAsync() {
String path = "G:\\测试\\data\\";
try {
// 在读取Excel之前,缓存所有数据
USER_INFO_SET = getUserInfo();

File file = new File(path);
String[] xlsxArr = file.list();
for (int i = 0; i < xlsxArr.length; i++) {
File fileTemp = new File(path + "\\" + xlsxArr[i]);
String filename = fileTemp.getName().replace(".xlsx", "");
readExcelCacheAsyncService.readXls(path + filename + ".xlsx", filename);
}
} catch (Exception e) {
logger.error("|#ReadDBCsv|#异常: ", e);
return "error";
}
return "success";
}

2、分批读取超大Excel文件


@Async("async-executor")
public void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();
logger.info(filename + ".xlsx,一共" + maxRow + "行数据!");
StringBuilder insertBuilder = new StringBuilder();

insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");

XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}

insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");

int times = maxRow / STEP + 1;
//logger.info("将" + maxRow + "行数据分" + times + "次插入数据库!");
for (int time = 0; time < times; time++) {
int start = STEP * time + 1;
int end = STEP * time + STEP;

if (time == times - 1) {
end = maxRow;
}

if(end + 1 - start > 0){
//logger.info("第" + (time + 1) + "次插入数据库!" + "准备插入" + (end + 1 - start) + "条数据!");
//readExcelDataAsyncService.readXlsCacheAsync(sheet, row, start, end, insertBuilder);
readExcelDataAsyncService.readXlsCacheAsyncMybatis(sheet, row, start, end, insertBuilder);
}
}
}

3、异步批量入库


@Async("async-executor")
public void readXlsCacheAsync(XSSFSheet sheet, XSSFRow row, int start, int end, StringBuilder insertBuilder) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = start; i <= end; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}

// 先在读取Excel之前,缓存所有数据,再做判断
boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}

List collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
if (collect != null && collect.size() > 0) {
int sum = JdbcUtil.executeDML(collect);
}
}

private boolean isExisted(String id, String name) {
return ReadExcelCacheAsyncController.USER_INFO_SET.contains(id + "," + name);
}

4、异步线程池工具类


@Async的作用就是异步处理任务。



  1. 在方法上添加@Async,表示此方法是异步方法;

  2. 在类上添加@Async,表示类中的所有方法都是异步方法;

  3. 使用此注解的类,必须是Spring管理的类;

  4. 需要在启动类或配置类中加入@EnableAsync注解,@Async才会生效;


在使用@Async时,如果不指定线程池的名称,也就是不自定义线程池,@Async是有默认线程池的,使用的是Spring默认的线程池SimpleAsyncTaskExecutor。


默认线程池的默认配置如下:



  1. 默认核心线程数:8;

  2. 最大线程数:Integet.MAX_VALUE;

  3. 队列使用LinkedBlockingQueue;

  4. 容量是:Integet.MAX_VALUE;

  5. 空闲线程保留时间:60s;

  6. 线程池拒绝策略:AbortPolicy;


从最大线程数可以看出,在并发情况下,会无限制的创建线程,我勒个吗啊。


也可以通过yml重新配置:


spring:
task:
execution:
pool:
max-size: 10
core-size: 5
keep-alive: 3s
queue-capacity: 1000
thread-name-prefix: my-executor

也可以自定义线程池,下面通过简单的代码来实现以下@Async自定义线程池。


@EnableAsync// 支持异步操作
@Configuration
public class AsyncTaskConfig {

/**
* com.google.guava中的线程池
*
@return
*/

@Bean("my-executor")
public Executor firstExecutor() {
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("my-executor").build();
// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(curSystemThreads, 100,
200, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), threadFactory);
threadPool.allowsCoreThreadTimeOut();
return threadPool;
}

/**
* Spring线程池
*
@return
*/

@Bean("async-executor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 核心线程数
taskExecutor.setCorePoolSize(24);
// 线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
taskExecutor.setMaxPoolSize(200);
// 缓存队列
taskExecutor.setQueueCapacity(50);
// 空闲时间,当超过了核心线程数之外的线程在空闲时间到达之后会被销毁
taskExecutor.setKeepAliveSeconds(200);
// 异步方法内部线程名称
taskExecutor.setThreadNamePrefix("");

/**
* 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
* 通常有以下四种策略:
* ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
* ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
* ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
* ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
*/

taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.initialize();
return taskExecutor;
}
}


5、异步失效的原因



  1. 注解@Async的方法不是public方法;

  2. 注解@Async的返回值只能为void或Future;

  3. 注解@Async方法使用static修饰也会失效;

  4. 没加@EnableAsync注解;

  5. 调用方和@Async不能在一个类中;

  6. 在Async方法上标注@Transactional是没用的,但在Async方法调用的方法上标注@Transcational是有效的;


三、线程池中的核心线程数设置问题


有一个问题,一直没时间摸索,线程池中的核心线程数CorePoolSize、最大线程数MaxPoolSize,设置成多少,最合适,效率最高。


借着这个机会,测试一下。


1、我记得有这样一个说法,CPU的处理器数量


将核心线程数CorePoolSize设置成CPU的处理器数量,是不是效率最高的?


// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;

Runtime.getRuntime().availableProcessors()获取的是CPU核心线程数,也就是计算资源。



  • CPU密集型,线程池大小设置为N,也就是和cpu的线程数相同,可以尽可能地避免线程间上下文切换,但在实际开发中,一般会设置为N+1,为了防止意外情况出现线程阻塞,如果出现阻塞,多出来的线程会继续执行任务,保证CPU的利用效率。

  • IO密集型,线程池大小设置为2N,这个数是根据业务压测出来的,如果不涉及业务就使用推荐。


在实际中,需要对具体的线程池大小进行调整,可以通过压测及机器设备现状,进行调整大小。


如果线程池太大,则会造成CPU不断的切换,对整个系统性能也不会有太大的提升,反而会导致系统缓慢。


我的电脑的CPU的处理器数量是24。


那么一次读取多少行最合适呢?


测试的Excel中含有10万条数据,10万/24 = 4166,那么我设置成4200,是不是效率最佳呢?


测试的过程中发现,好像真的是这样的。


2、我记得大家都习惯性的将核心线程数CorePoolSize和最大线程数MaxPoolSize设置成一样的,都爱设置成200。


是随便写的,还是经验而为之?


测试发现,当你将核心线程数CorePoolSize和最大线程数MaxPoolSize都设置为200的时候,第一次它会同时开启150个线程,来进行工作。


这个是为什么?


3、经过数十次的测试



  1. 发现核心线程数好像差别不大

  2. 每次读取和入库的数量是关键,不能太多,因为每次入库会变慢;

  3. 也不能太少,如果太少,超过了150个线程,就会造成线程阻塞,也会变慢;


四、通过EasyExcel读取并插入数据库


EasyExcel的方式,我就不写双异步优化了,大家切记陷入低水平勤奋的怪圈。


1、ReadEasyExcelController


@RequestMapping(value = "/readEasyExcel", method = RequestMethod.POST)
@ResponseBody
public String readEasyExcel() {
try {
String path = "G:\\测试\\data\\";
String[] xlsxArr = new File(path).list();
for (int i = 0; i < xlsxArr.length; i++) {
String filePath = path + xlsxArr[i];
File fileTemp = new File(path + xlsxArr[i]);
String fileName = fileTemp.getName().replace(".xlsx", "");
List list = new ArrayList<>();
EasyExcel.read(filePath, UserInfo.class, new ReadEasyExeclAsyncListener(readEasyExeclService, fileName, batchCount, list)).sheet().doRead();
}
}catch (Exception e){
logger.error("readEasyExcel 异常:",e);
return "error";
}
return "suceess";
}

2、ReadEasyExeclAsyncListener


public ReadEasyExeclService readEasyExeclService;
// 表名
public String TABLE_NAME;
// 批量插入阈值
private int BATCH_COUNT;
// 数据集合
private List LIST;

public ReadEasyExeclAsyncListener(ReadEasyExeclService readEasyExeclService, String tableName, int batchCount, List list) {
this.readEasyExeclService = readEasyExeclService;
this.TABLE_NAME = tableName;
this.BATCH_COUNT = batchCount;
this.LIST = list;
}

@Override
public void invoke(UserInfo data, AnalysisContext analysisContext) {
data.setUuid(uuid());
data.setTableName(TABLE_NAME);
LIST.add(data);
if(LIST.size() >= BATCH_COUNT){
// 批量入库
readEasyExeclService.saveDataBatch(LIST);
}
}

@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
if(LIST.size() > 0){
// 最后一批入库
readEasyExeclService.saveDataBatch(LIST);
}
}

public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}

3、ReadEasyExeclServiceImpl


@Service
public class ReadEasyExeclServiceImpl implements ReadEasyExeclService {

@Resource
private ReadEasyExeclMapper readEasyExeclMapper;

@Override
public void saveDataBatch(List list) {
// 通过mybatis入库
readEasyExeclMapper.saveDataBatch(list);
// 通过JDBC入库
// insertByJdbc(list);
list.clear();
}

private void insertByJdbc(List list){
List sqlList = new ArrayList<>();
for (UserInfo u : list){
StringBuilder sqlBuilder = new StringBuilder();
sqlBuilder.append("insert int0 ").append(u.getTableName()).append(" ( UUID,ID,NAME,AGE,ADDRESS,PHONE,OP_TIME ) values ( ");
sqlBuilder.append("'").append(ReadEasyExeclAsyncListener.uuid()).append("',")
.append("'").append(u.getId()).append("',")
.append("'").append(u.getName()).append("',")
.append("'").append(u.getAge()).append("',")
.append("'").append(u.getAddress()).append("',")
.append("'").append(u.getPhone()).append("',")
.append("sysdate )");
sqlList.add(sqlBuilder.toString());
}

JdbcUtil.executeDML(sqlList);
}
}

4、UserInfo


@Data
public class UserInfo {

private String tableName;

private String uuid;

@ExcelProperty(value = "ID")
private String id;

@ExcelProperty(value = "NAME")
private String name;

@ExcelProperty(value = "AGE")
private String age;

@ExcelProperty(value = "ADDRESS")
private String address;

@ExcelProperty(value = "PHONE")
private String phone;
}



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

SpringBoot统一结果返回,统一异常处理,大牛都这么玩

引言 在开发Spring Boot应用时,我们经常面临着不同的控制器方法需要处理各种不同类型的响应结果,以及在代码中分散处理异常可能导致项目难以维护的问题。你是否曾经遇到过在不同地方编写相似的返回格式,或者在处理异常时感到有些混乱?这些看似小问题的积累,实际上...
继续阅读 »

引言


在开发Spring Boot应用时,我们经常面临着不同的控制器方法需要处理各种不同类型的响应结果,以及在代码中分散处理异常可能导致项目难以维护的问题。你是否曾经遇到过在不同地方编写相似的返回格式,或者在处理异常时感到有些混乱?这些看似小问题的积累,实际上可能对项目产生深远的影响。统一结果返回和统一异常处理并非只是为了规范代码,更是为了提高团队的协作效率、降低项目维护的难度,并使代码更易于理解和扩展。


本文的目的是帮助你更好地理解和应用Spring Boot中的统一结果返回和统一异常处理。通过详细的讨论和实例演示,我们将为你提供一套清晰的指南,让你能够在自己的项目中轻松应用这些技术,提高代码质量,减轻开发压力。


统一结果返回


统一结果返回是一种通过定义通用的返回格式,使所有的响应结果都符合同一标准的方法。这有助于提高代码的一致性,减少重复代码的编写,以及使客户端更容易理解和处理API的响应。统一结果返回不仅规范了代码结构,还能提高团队协作效率,降低项目维护的难度。


接下来让我们一起看看在SpringBoot中如何实现统一结果返回。


1. 定义通用的响应对象


当实现统一结果返回时,需要创建一个通用的响应对象,定义成功和失败的返回情况,并确保在接口中使用这个通用返回对象。


@Setter  
@Getter  
public class ResultResponse<T> implements Serializable {  
    private static final long serialVersionUID = -1133637474601003587L;  

    /**  
     * 接口响应状态码  
     */
  
    private Integer code;  

    /**  
     * 接口响应信息  
     */
  
    private String msg;  

    /**  
     * 接口响应的数据  
     */
  
    private T data;
}    

2. 定义接口响应状态码


统一结果返回的关键之一是规定一套通用的状态码。这有助于客户端更容易地理解和处理 API 的响应,同时也为开发者提供了一致的标准。通常,一些 HTTP 状态码已经被广泛接受,如:



  • 200 OK:表示成功处理请求。

  • 201 Created:表示成功创建资源。

  • 204 No Content:表示成功处理请求,但没有返回任何内容。


对于错误情况,也可以使用常见的 HTTP 状态码,如:



  • 400 Bad Request:客户端请求错误。

  • 401 Unauthorized:未授权访问。

  • 404 Not Found:请求资源不存在。

  • 500 Internal Server Error:服务器内部错误。


除了 HTTP 状态码外,你还可以定义自己的应用程序特定状态码,以表示更具体的情况。确保文档中清晰地说明了每个状态码所代表的含义,使开发者能够正确地解释和处理它们。


public enum StatusEnum {  
    SUCCESS(200 ,"请求处理成功"),  
    UNAUTHORIZED(401 ,"用户认证失败"),  
    FORBIDDEN(403 ,"权限不足"),  
    SERVICE_ERROR(500"服务器去旅行了,请稍后重试"),  
    PARAM_INVALID(1000"无效的参数"),  
    ;  
    public final Integer code;  

    public final String message;  

    StatusEnum(Integer code, String message) {  
        this.code = code;  
        this.message = message;  
    }  

}

3. 定义统一的成功和失败的处理方法


定义统一的成功和失败的响应方法有助于保持代码一致性和规范性,简化控制器逻辑,提高代码复用性,降低维护成本,提高可读性,促进团队协作,以及更便于进行测试。


/**  
 * 封装成功响应的方法  
 * @param data 响应数据  
 * @return reponse  
 * @param <T> 响应数据类型  
 */
  
public static <T> ResultResponse<T> success(T data) {  

    ResultResponse<T> response = new ResultResponse<>();  
    response.setData(data);  
    response.setCode(StatusEnum.SUCCESS.code);  
    return response;  
}  

/**  
 * 封装error的响应  
 * @param statusEnum error响应的状态值  
 * @return  
 * @param <T>  
 */
  
public static <T> ResultResponse<T> error(StatusEnum statusEnum) {  
   return error(statusEnum, statusEnum.message);  
}  

/**  
 * 封装error的响应  可自定义错误信息
 * @param statusEnum error响应的状态值  
 * @return  
 * @param <T>  
 */
  
public static <T> ResultResponse<T> error(StatusEnum statusEnum, String errorMsg) {  
    ResultResponse<T> response = new ResultResponse<>();  
    response.setCode(statusEnum.code);  
    response.setMsg(errorMsg);  
    return response;  
}

4. web层统一响应结果


在web层使用统一结果返回的目的是将业务逻辑的处理结果按照预定的通用格式进行封装,以提高代码的一致性和可读性。


@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    private IUserService userService;  

    /**  
     * 创建用户  
     * @param requestVO  
     * @return  
     */
  
    @PostMapping("create")  
    public ResultResponse<VoidcreateUser(@Validated @RequestBody UserCreateRequestVO requestVO){  
        return ResultResponse.success(null);  
    }

    /**  
     * 根据用户ID获取用户信息  
     * @param userId 用户id  
     * @return 用户信息  
     */
  
    @GetMapping("info")  
    public ResultResponse<UserInfoResponseVOgetUser(@NotBlank(message = "请选择用户") String userId){  
        final UserInfoResponseVO responseVO = userService.getUserInfoById(userId);  
        return ResultResponse.success(responseVO);  
    }  

    @Autowired  
    public void setUserService(IUserService userService) {  
        this.userService = userService;  
    }  
}

调用接口,响应的信息统一为:


{
    "code": 200,
    "msg": null,
    "data": null
}

{
    "code": 200,
    "msg": null,
    "data": {
        "userId": "121",
        "userName": "码农Academy"
    }
}

统一结果返回通过定义通用的返回格式、成功和失败的返回情况,以及在控制器中使用这一模式,旨在提高代码的一致性、可读性和可维护性。采用统一的响应格式简化了业务逻辑处理流程,使得开发者更容易处理成功和失败的情况,同时客户端也更容易理解和处理 API 的响应。这一实践有助于降低维护成本、提高团队协作效率,并促进代码的规范化。


统一异常处理


统一异常处理的必要性体现在保持代码的一致性、提供更清晰的错误信息、以及更容易排查问题。通过定义统一的异常处理方式,确保在整个应用中对异常的处理保持一致,减少了重复编写相似异常处理逻辑的工作,同时提供友好的错误信息帮助开发者和维护人员更快地定位和解决问题,最终提高了应用的可维护性和可读性。


1.定义统一的异常类


我们需要定义服务中可能抛出的自定义异常类。这些异常类可以继承自RuntimeException,并携带有关异常的相关信息。即可理解为局部异常,用于特定的业务处理中异常。手动埋点抛出。


@Getter  
public class ServiceException extends RuntimeException{  

    private static final long serialVersionUID = -3303518302920463234L;  

    private final StatusEnum status;  

    public ServiceException(StatusEnum status, String message) {  
        super(message);  
        this.status = status;  
    }  

    public ServiceException(StatusEnum status) {  
        this(status, status.message);  
    }  
}

2.异常处理器


创建一个全局的异常处理器,使用@ControllerAdvice 或者 @RestControllerAdvice注解和@ExceptionHandler注解来捕获不同类型的异常,并定义处理逻辑。


2.1 @ControllerAdvice注解

用于声明一个全局控制器建言(Advice),相当于把@ExceptionHandler@InitBinder@ModelAttribute注解的方法集中到一个地方。常放在一个特定的类上,这个类被认为是全局异常处理器,可以跨足多个控制器。



当时用@ControllerAdvice时,我们需要在异常处理方法上加上@ResponseBody,同理我们的web接口。但是如果我们使用@RestControllerAdvice 就可以不用加,同理也是web定义的接口



2.2 @ExceptionHandler注解

用于定义异常处理方法,处理特定类型的异常。放在全局异常处理器类中的具体方法上。


通过这两个注解的配合,可以实现全局的异常处理。当控制器中抛出异常时,Spring Boot会自动调用匹配的@ExceptionHandler方法来处理异常,并返回定义的响应。


@Slf4j  
@ControllerAdvice  
public class ExceptionAdvice {  

    /**  
     * 处理ServiceException  
     * @param serviceException ServiceException  
     * @param request 请求参数  
     * @return 接口响应  
     */
  
    @ExceptionHandler(ServiceException.class)  
    @ResponseBody  
    public ResultResponse<Void> handleServiceException(ServiceException serviceException, HttpServletRequest request) {  
        log.warn("request {} throw ServiceException \n", request, serviceException);  
        return ResultResponse.error(serviceException.getStatus(), serviceException.getMessage());  
    }  

    /**  
     * 其他异常拦截  
     * @param ex 异常  
     * @param request 请求参数  
     * @return 接口响应  
     */
  
    @ExceptionHandler(Exception.class)  
    @ResponseBody  
    public ResultResponse<VoidhandleException(Exception ex, HttpServletRequest request) {  
        log.error("request {} throw unExpectException \n", request, ex);  
        return ResultResponse.error(StatusEnum.SERVICE_ERROR);  
    }
}    

3.异常统一处理使用


在业务开发过程中,我们可以在service层处理业务时,可以手动抛出业务异常。由全局异常处理器进行统一处理。


@Service  
@Slf4j  
public class UserServiceImpl implements IUserService {  

    private IUserManager userManager;

    /**  
     * 创建用户  
     *  
     * @param requestVO 请求参数  
     */
  
    @Override  
    public void createUser(UserCreateRequestVO requestVO) {  
        final UserDO userDO = userManager.selectUserByName(requestVO.getUserName());  
        if (userDO != null){  
            throw new ServiceException(StatusEnum.PARAM_INVALID, "用户名已存在");  
        }  
    }

    @Autowired  
    public void setUserManager(IUserManager userManager) {  
        this.userManager = userManager;  
    }  
}

@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    private IUserService userService;  

    /**  
     * 创建用户  
     * @param requestVO  
     * @return  
     */
  
    @PostMapping("create")  
    public ResultResponse<VoidcreateUser(@Validated @RequestBody UserCreateRequestVO requestVO){  
        userService.createUser(requestVO);  
        return ResultResponse.success(null);  
    }

    @Autowired  
    public void setUserService(IUserService userService) {  
        this.userService = userService;  
    }  
}

当我们请求接口时,假如用户名称已存在,接口就会响应:


{
    "code": 1000,
    "msg": "用户名已存在",
    "data": null
}

统一异常处理带来的好处包括提供一致的异常响应格式,简化异常处理逻辑,记录更好的错误日志,以及更容易排查和解决问题。通过统一处理异常,我们确保在整个应用中对异常的处理方式一致,减少了重复性代码的编写,提高了代码的规范性。简化的异常处理逻辑降低了开发者的工作负担,而更好的错误日志有助于更迅速地定位和解决问题,最终提高了应用的可维护性和稳定性。


其他类型的异常处理


在项目开发过程中,我们还有一些常见的特定异常类型,比如MethodArgumentNotValidExceptionUnexpectedTypeException等,并为它们定义相应的异常处理逻辑。这些特定异常可能由于请求参数校验失败或意外的数据类型问题而引起,因此有必要为它们单独处理,以提供更具体和友好的异常响应。


1.MethodArgumentNotValidException


由于请求参数校验失败引起的异常,通常涉及到使用@Valid注解或者@Validated进行请求参数校验。我们可以在异常处理器中编写@ExceptionHandler方法,捕获并处理MethodArgumentNotValidException,提取校验错误信息,并返回详细的错误响应。


/**  
 * 参数非法校验  
 * @param ex  
 * @return  
 */
  
@ExceptionHandler(MethodArgumentNotValidException.class)  
@ResponseBody  
public ResultResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {  
    try {  
        List<ObjectErrorerrors = ex.getBindingResult().getAllErrors();  
        String message = errors.stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(","));  
        log.error("param illegal: {}", message);  
        return ResultResponse.error(StatusEnum.PARAM_INVALID, message);  
    } catch (Exception e) {  
        return ResultResponse.error(StatusEnum.SERVICE_ERROR);  
    }  
}

当我们使用@Valid注解或者@Validated进行请求参数校验不通过时,响应结果为:


{
    "code": 1000,
    "msg": "请输入地址信息,用户年龄必须小于60岁,请输入你的兴趣爱好",
    "data": null
}


关于@Valid注解或者@Validated进行参数校验的功能请参考:SpringBoot优雅校验参数



2.UnexpectedTypeException


意外的数据类型异常,通常表示程序运行时发生了不符合预期的数据类型问题。一个常见的使用场景是在数据转换或类型处理的过程中。例如,在使用 Spring 表单绑定或数据绑定时,如果尝试将一个不符合预期类型的值转换为目标类型,就可能抛出 UnexpectedTypeException。这通常会发生在将字符串转换为数字、日期等类型时,如果字符串的格式不符合目标类型的要求。


我们可以在异常处理器中编写@ExceptionHandler方法,捕获并处理UnexpectedTypeException,提供适当的处理方式,例如记录错误日志,并返回合适的错误响应。


@ExceptionHandler(UnexpectedTypeException.class)  
@ResponseBody  
public ResultResponse<Void> handleUnexpectedTypeException(UnexpectedTypeException ex,  
                                                        HttpServletRequest request) {  
    log.error("catch UnexpectedTypeException, errorMessage: \n", ex);  
    return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());  
}

当发生异常时,接口会响应:


{
    "code": 500,
    "msg": "服务器去旅行了,请稍后重试",
    "data": null
}

3.ConstraintViolationException


javax.validation.ConstraintViolationException 是 Java Bean Validation(JSR 380)中的一种异常。它通常在使用 Bean Validation 进行数据校验时,如果校验失败就会抛出这个异常。即我们在使用自定义校验注解时,如果不满足校验规则,就会抛出这个错误。


@ExceptionHandler(ConstraintViolationException.class)  
@ResponseBody  
public ResultResponse<Void> handlerConstraintViolationException(ConstraintViolationException ex, HttpServletRequest request) {  
    log.error("request {} throw ConstraintViolationException \n", request, ex);  
    return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());  
}


案例请参考:SpringBoot优雅校验参数,注册ConstraintValidator示例中的@UniqueUser校验。



4.HttpMessageNotReadableException


表示无法读取HTTP消息的异常,通常由于请求体不合法或不可解析。


@ResponseBody  
@ResponseStatus(HttpStatus.BAD_REQUEST)  
@ExceptionHandler(HttpMessageNotReadableException.class)  
public ResultResponse<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex,  
HttpServletRequest request) {  
    log.error("request {} throw ucManagerException \n", request, ex);  
    return ResultResponse.error(StatusEnum.SERVICE_ERROR);  
}

5.HttpRequestMethodNotSupportedException


Spring Framework 中的异常类,表示请求的 HTTP 方法不受支持。当客户端发送了一个使用不被服务器支持的 HTTP 方法(如 GET、POST、PUT、DELETE等)的请求时,可能会抛出这个异常。


@ExceptionHandler({HttpRequestMethodNotSupportedException.class, HttpMediaTypeException.class})  
@ResponseBody  
public ResultResponse<Void> handleMethodNotSupportedException(Exception ex) {  
    log.error("HttpRequestMethodNotSupportedException \n", ex);  
    return ResultResponse.error(StatusEnum.HTTP_METHOD_NOT_SUPPORT);  
}

全局异常处理与局部异常处理在Spring Boot应用开发中扮演不同角色。全局异常处理通过统一的异常处理器确保了整个应用对异常的处理一致性,减少了冗余代码,提高了代码的整洁度。然而,这种方式可能在灵活性上略显不足,无法满足每个具体控制器或业务场景的个性化需求。


相比之下,局部异常处理能够为每个控制器或业务场景提供更具体、灵活的异常处理逻辑,允许定制化的异常响应。这使得在复杂的项目中更容易处理特定的异常情况,同时提供更详细的错误信息。然而,局部异常处理可能带来代码冗余和维护难度的问题,特别是在大型项目中。


在实际应用中,选择全局异常处理还是局部异常处理应根据项目规模和需求进行权衡。对于小型项目或简单场景,全局异常处理可能是一种更简单、合适的选择。而对于大型项目或需要个性化异常处理的复杂业务逻辑,局部异常处理则提供了更为灵活的方案。最佳实践是在项目中根据具体情况灵活使用这两种方式,以平衡一致性和个性化需求。


最佳实践与注意事项


1. 最佳实践



  • 统一响应格式:  在异常处理中,使用统一的响应格式有助于客户端更容易理解和处理错误。通常,返回一个包含错误码、错误信息和可能的详细信息的响应对象。

  • 详细错误日志:  在异常处理中记录详细的错误日志,包括异常类型、发生时间、请求信息等。这有助于快速定位和解决问题。

  • 使用HTTP状态码:  根据异常的性质,选择适当的HTTP状态码。例如,使用HttpStatus.NOT_FOUND表示资源未找到,HttpStatus.BAD_REQUEST表示客户端请求错误等。

  • 异常分类:  根据异常的种类,合理分类处理。可以定义不同的异常类来表示不同的异常情况,然后在异常处理中使用@ExceptionHandler分别处理。

  • 全局异常处理:  使用全局异常处理机制来捕获未被特定控制器处理的异常,以确保应用在整体上的健壮性。


2 注意事项



  • 不滥用异常:  异常应该用于表示真正的异常情况,而不是用作控制流程。滥用异常可能导致性能问题和代码可读性降低。

  • 不忽略异常:  避免在异常处理中忽略异常或仅仅打印日志而不进行适当的处理。这可能导致潜在的问题被掩盖,难以追踪和修复。

  • 避免空的catch块:  不要在catch块中什么都不做,这样会使得异常难以被发现。至少在catch块中记录日志,以便了解异常的发生。

  • 适时抛出异常:  不要过于吝啬地抛出异常,但也不要无谓地滥用。在必要的时候使用异常,例如表示无法继续执行的错误情况。

  • 测试异常场景:  编写单元测试时,确保覆盖异常场景,验证异常的正确抛出和处理。


总结


异常处理在应用开发中是至关重要的一环,它能够提高应用的健壮性、可读性和可维护性。全局异常处理和局部异常处理各有优劣,需要根据项目的规模和需求来灵活选择。通过采用统一的响应格式、详细的错误日志、适当的HTTP状态码等最佳实践,可以使异常处理更为有效和易于管理。同时,注意避免滥用异常、忽略异常、适时抛出异常等注意事项,有助于确保异常处理的质量。在开发过程中,持续关注和优化异常处理,将有助于提高应用的稳定性和用户体验。


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


作者:码农Academy
来源:juejin.cn/post/7322463748006248459
收起阅读 »

不要再用 StringBuilder 拼接字符串了,来试试字符串模板

引言 字符串操作是 Java 中使用最频繁的操作,没有之一。其中非常常见的操作之一就是对字符串的组织,由于常见所以就衍生了多种方案。比如我们要实现 x + y = ?,方案有如下几种 使用 + 进行字符串拼接 String s = x + " + " + ...
继续阅读 »


引言


字符串操作是 Java 中使用最频繁的操作,没有之一。其中非常常见的操作之一就是对字符串的组织,由于常见所以就衍生了多种方案。比如我们要实现 x + y = ?,方案有如下几种



  • 使用 + 进行字符串拼接


String s = x + " + " + y + " = " + (x + y);


  • 使用 StringBuilder


String s = new StringBuilder()
.append(x)
.append(" + ")
.append(y)
.append(" = ")
.append(x + y)
.toString()


  • String::formatString::formatted 将格式字符串从参数中分离出来


String s = String.format("%2$d + %1$d = %3$d", x, y, x + y);
or
String s = "%2$d + %1$d = %3$d".formatted(x, y, x + y);


  • java.text.MessageFormat


String s = MessageFormat.format("{0} + {1} = {2}", x,y, x + y);

这四种方案虽然都可以解决,但很遗憾的是他们或多或少都有点儿缺陷,尤其是面对 Java 13 引入的文本块(Java 13 新特性—文本块)更是束手无措。


字符串模板


为了简化字符串的构造和格式化,Java 21 引入字符串模板功能,该特性主要目的是为了提高在处理包含多个变量和复杂格式化要求的字符串时的可读性和编写效率。


它的设计目标是:



  • 通过简单的方式表达混合变量的字符串,简化 Java 程序的编写。

  • 提高混合文本和表达式的可读性,无论文本是在单行源代码中(如字符串字面量)还是跨越多行源代码(如文本块)。

  • 通过支持对模板及其嵌入式表达式的值进行验证和转换,提高根据用户提供的值组成字符串并将其传递给其他系统(如构建数据库查询)的 Java 程序的安全性。

  • 允许 Java 库定义字符串模板中使用的格式化语法(java.util.Formatter ),从而保持灵活性。

  • 简化接受以非 Java 语言编写的字符串(如 SQL、XML 和 JSON)的 API 的使用。

  • 支持创建由字面文本和嵌入式表达式计算得出的非字符串值,而无需通过中间字符串表示。


该特性处理字符串的新方法称为:Template Expressions,即:模版表达式。它是 Java 中的一种新型表达式,不仅可以执行字符串插值,还可以编程,从而帮助开发人员安全高效地组成字符串。此外,模板表达式并不局限于组成字符串——它们可以根据特定领域的规则将结构化文本转化为任何类型的对象。


STR 模板处理器


STR 是 Java 平台定义的一种模板处理器。它通过用表达式的值替换模板中的每个嵌入表达式来执行字符串插值。使用 STR 的模板表达式的求值结果是一个字符串。


STR 是一个公共静态 final 字段,会自动导入到每个 Java 源文件中。


我们先看一个简单的例子:


    @Test
public void STRTest() {
String sk = "死磕 Java 新特性";
String str1 = STR."{sk},就是牛";
System.out.println(str1);
}
// 结果.....
死磕 Java 新特性,就是牛

上面的 STR."{sk},就是牛" 就是一个模板表达式,它主要包含了三个部分:



  • 模版处理器:STR

  • 包含内嵌表达式({blog})的模版

  • 通过.把前面两部分组合起来,形式如同方法调用


当模版表达式运行的时候,模版处理器会将模版内容与内嵌表达式的值组合起来,生成结果。


这个例子只是 STR模版处理器一个很简单的功能,它可以做的事情有很多。



  • 数学运算


比如上面的 x + y = ?


    @Test
public void STRTest() {
int x = 1,y =2;
String str = STR."{x} + {y} = {x + y}";
System.out.println(str);
}

这种写法是不是简单明了了很多?



  • 调用方法


STR模版处理器还可以调用方法,比如:


String str = STR."今天是:{ LocalDate.now()} ";

当然也可以调用我们自定义的方法:


    @Test
public void STRTest() {
String str = STR."{getSkStr()},就是牛";
System.out.println(str);
}

public String getSkStr() {
return "死磕 Java 新特性";
}


  • 访问成员变量


STR模版处理器还可以访问成员变量,比如:


public record User(String name,Integer age) {
}

@Test
public void STRTest() {
User user = new User("大明哥",18);
String str = STR."{user.name()}今年{user.age()}";
System.out.println(str);
}

需要注意的是,字符串模板表达式中的嵌入表达式数量没有限制,它从左到右依次求值,就像方法调用表达式中的参数一样。例如:


    @Test
public void STRTest() {
int i = 0;
String str = STR."{i++},{i++},{i++},{i++},{i++}";
System.out.println(str);
}
// 结果......
0,1,2,3,4

同时,表达式中也可以嵌入表达式:


    @Test
public void STRTest() {
String name = "大明哥";
String sk = "死磕 Java 新特性";
String str = STR."{name}的{STR."{sk},就是牛..."}";
System.out.println(str);
}
// 结果......
大明哥的死磕 Java 新特性,就是牛...

但是这种嵌套的方式会比较复杂,容易搞混,一般不推荐。


多行模板表达式


为了解决多行字符串处理的复杂性,Java 13 引入文本块(Java 13 新特性—文本块),它是使用三个双引号(""")来标记字符串的开始和结束,允许字符串跨越多行而无需显式的换行符或字符串连接。如下:


String html = """
<html>
<body>
<h2>skjava.com</h2>
<ul>
<li>死磕 Java 新特性</li>
<li>死磕 Java 并发</li>
<li>死磕 Netty</li>
<li>死磕 Redis</li>
</ul>
</body>
</html>
""";

如果字符串模板表达式,我们就只能拼接这串字符串了,这显得有点儿繁琐和麻烦。而字符串模版表达式也支持多行字符串处理,我们可以利用它来方便的组织html、json、xml等字符串内容,比如这样:


    @Test
public void STRTest() {
String title = "skjava.com";
String sk1 = "死磕 Java 新特性";
String sk2 = "死磕 Java 并发";
String sk3 = "死磕 Netty";
String sk4 = "死磕 Redis";

String html = STR."""
<html>
<body>
<h2>{title}</h2>
<ul>
<li>{sk1}</li>
<li>{sk2}</li>
<li>{sk3}</li>
<li>{sk4}</li>
</ul>
</body>
</html>
"""
;
System.out.println(html);
}

如果决定定义四个 sk 变量麻烦,可以整理为一个集合,然后调用方法生成 <li> 标签。


FMT 模板处理器


FMT 是 Java 定义的另一种模板处理器。它除了与STR模版处理器一样提供插值能力之外,还提供了左侧的格式化处理。下面我们来看看他的功能。比如我们要整理模式匹配的 Switch 表达在 Java 版本中的迭代,也就是下面这个表格


Java 版本更新类型JEP更新内容
Java 17第一次预览JEP 406引入模式匹配的 Swith 表达式作为预览特性。
Java 18第二次预览JEP 420对其做了改进和细微调整
Java 19第三次预览JEP 427进一步优化模式匹配的 Swith 表达式
Java 20第四次预览JEP 433
Java 21正式特性JEP 441成为正式特性

如果使用 STR 模板处理器,代码如下:


    @Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};

String history = STR."""
Java 版本 更新类型 JEP 更新内容
{switchHistories[0].javaVersion()} {switchHistories[0].updateType()} {switchHistories[0].jep()} {switchHistories[0].content()}
{switchHistories[1].javaVersion()} {switchHistories[1].updateType()} {switchHistories[1].jep()} {switchHistories[1].content()}
{switchHistories[2].javaVersion()} {switchHistories[2].updateType()} {switchHistories[2].jep()} {switchHistories[2].content()}
{switchHistories[3].javaVersion()} {switchHistories[3].updateType()} {switchHistories[3].jep()} {switchHistories[3].content()}
{switchHistories[4].javaVersion()} {switchHistories[4].updateType()} {switchHistories[4].jep()} {switchHistories[4].content()}
""";
System.out.println(history);
}

得到的效果是这样的:


Java 版本     更新类型    JEP 更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性

是不是很丑?完全对不齐,没法看。为了解决这个问题,就可以采用FMT模版处理器,在每一列左侧定义格式:


   @Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};

String history = FMT."""
Java 版本 更新类型 JEP 更新内容
%-10s{switchHistories[0].javaVersion()} %-9s{switchHistories[0].updateType()} %-10s{switchHistories[0].jep()} %-20s{switchHistories[0].content()}
%-10s{switchHistories[1].javaVersion()} %-9s{switchHistories[1].updateType()} %-10s{switchHistories[1].jep()} %-20s{switchHistories[1].content()}
%-10s{switchHistories[2].javaVersion()} %-9s{switchHistories[2].updateType()} %-10s{switchHistories[2].jep()} %-20s{switchHistories[2].content()}
%-10s{switchHistories[3].javaVersion()} %-9s{switchHistories[3].updateType()} %-10s{switchHistories[3].jep()} %-20s{switchHistories[3].content()}
%-10s{switchHistories[4].javaVersion()} %-9s{switchHistories[4].updateType()} %-10s{switchHistories[4].jep()} %-20s{switchHistories[4].content()}
""";
System.out.println(history);
}

输出如下:


Java 版本     更新类型        JEP             更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性

作者:大明哥_
来源:juejin.cn/post/7323251349302706239
收起阅读 »

SpringBoot接收参数的19种方式

1. Get 请求 1.1 以方法的形参接收参数 1.这种方式一般适用参数比较少的情况 @RestController @RequestMapping("/user") @Slf4j public class UserController { @Ge...
继续阅读 »

1. Get 请求


1.1 以方法的形参接收参数


1.这种方式一般适用参数比较少的情况


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}


2.参数用 @RequestParam 标注,表示这个参数需要必传,否则会报错。


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam String name,@RequestParam String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}


1.2 以实体类接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}




注:Get 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。




1.3 通过 HttpServletRequest 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(HttpServletRequest request) {
String name = request.getParameter("name");
String phone = request.getParameter("phone");
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}



1.4 通过 @PathVariable 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail/{name}/{phone}")
public Result<User> getUserDetail(@PathVariable String name,@PathVariable String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}



1.5 接收数组参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



1.6 接收集合参数



springboot 接收集合参数,需要用 RequestParam 注解绑定参数,否则会报错!!



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



2. Post 请求


2.1 以方法的形参接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}




注:和 Get 请求一样,如果方法形参用 RequestParam 注解标注,表示这个参数需要必传。



2.2 通过 param 提交参数,以实体类接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}




注:Post 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。




2.3 通过 HttpServletRequest 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(HttpServletRequest httpServletRequest) {
log.info("name:{}",httpServletRequest.getParameter("name"));
log.info("phone:{}",httpServletRequest.getParameter("phone"));
return Result.success(null);
}
}



2.4 通过 @PathVariable 注解进行接收


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
log.info("name:{}",name);
return Result.success(null);
}
}



2.5 请求体以 form-data 提交参数,以实体类接收参数


form-data 是表单提交的一种方式,比如常见的登录请求。


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.6 请求体以 x-www-form-urlencoded 提交参数,以实体类接收参数


x-www-form-urlencoded 也是表单提交的一种方式,只不过提交的参数被进行了编码,并且转换成了键值对。


例如你用form-data 提交的参数:


name: 知否君
age: 22

用 x-www-form-urlencoded 提交的参数:


name=%E5%BC%A0%E4%B8%89&age=22

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.7 通过 @RequestBody 注解接收参数



注:RequestBody 注解主要用来接收前端传过来的 body 中 json 格式的参数。



2.7.1 接收实体类参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.7.2 接收数组和集合


接收数组


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}


接收集合


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



2.8 通过 Map 接收参数


1.以 param 方式传参, RequestParam 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestParam Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}



2.以 body json 格式传参,RequestBody 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}



2.9 RequestBody 接收一个参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String name) {
System.out.println(name);
return Result.success(null);
}
}



3. Delete 请求


3.1 以 param 方式传参,以方法形参接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestParam String name) {
System.out.println(name);
return Result.success(null);
}
}



3.2 以 body json 方式传参,以实体类接收参数



注:需要用 RequestBody 注解,否则接收的参数为 null



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody User user) {
System.out.println(user);
return Result.success(null);
}
}



3.3 以 body json 方式传参,以 map 接收参数



注:需要用 RequestBody 注解,否则接收的参数为 null



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
return Result.success(null);
}
}



3.4 PathVariable 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
System.out.println(name);
return Result.success(null);
}
}



作者:知否技术
来源:juejin.cn/post/7343243744479625267
收起阅读 »

在上海做程序员这么多年,退休后我的工资是多少?

大家好,我是拭心。 最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。 之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。 吸取...
继续阅读 »

image.png


大家好,我是拭心。


最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。


之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。


吸取教训,作为在上海工作多年的打工人,为了老有所依,我花了些时间学习了养老金相基本知识,并且估算了一下退休后每月能拿到的钱,这篇文章来聊聊。


文章主要内容:



  1. 如何能在上海领退休工资

  2. 我退休后大概能领多少钱

  3. 退休工资的组成


如何能在上海领退休工资


image.png


上海作为全国 GDP 第一的城市,居民的收入也是很可观的。平均工资在 2023 年达到了 12183,平均退休工资在全国也位列前茅:


image.png


从上图可以看到,上海的平均退休工资居然有五千多!不用早起不用挤地铁,躺在家里每月就能领五千多,花不完,根本花不完啊!


那么问题来了,我们如何能领到上海的退休工资?


主要有 2 个条件:



  1. 达到退休年龄:男性满 60 周岁,女性满 50 周岁(灵活就业人员需要满 55 周岁)

  2. 退休前累计缴费社保 >= 180 个月(也就是 15 年),其中在上海至少缴满 120 个月(10 年)


第二点很关键:在上海需要缴满社保 10 年,上海 + 其他地方累计需要缴满 15 年。 比如小张在上海工作并缴纳社保满 10 年,然后去青岛缴纳 5 年,最后可以在上海领退休工资;但如果在青岛缴纳了 10 年,在上海缴纳了 5 年,就无法在上海领退休工资了。


需要强调的是,这里说的是「累计缴满」,即使中间有断开也没关系。


还有一个细节是,发工资不等于缴纳社保,个别不正规公司会漏缴社保,这需要我们打工人自己多关注。 身边有朋友遇到过:刚毕业加入的公司规模很小,人力资源不靠谱,干了半年多只缴纳了两个月社保。


怎么看公司有没有给自己缴社保呢?我们可以从随申办上查询:


image.png


OK,这就是在上海领退休工资的条件。


退休后大概能领多少钱


掐指一算,社保缴满 15 年的任务我已经完成了一半,但还有二十九年才能领钱,心里苦啊😭。


虽然拿不到,但我对能领多少钱还是非常好奇的,究竟比平均退休工资高还是被平均?🤔


经过一番搜索,我终于发现了退休工资的计算方法。


国家社会保险公共服务平台网站中,有一个「企业职工养老保险待遇测算」的功能:


image.png



国家社会保险公共服务平台 -> 养老保险 -> 企业职工养老保险待遇测算


链接:si.12333.gov.cn/157569.jhtm…



我们只需要输入年龄、预计退休年龄、当前缴纳年限、目前及之前平均工资、养老保险个人账户余额及未来大概工资即可测算退休工资。


如果不知道你的「养老保险个人账户余额」有多少,可以从随申办 app 搜索「养老金」查询余额:


image.png


在填完所有需要的信息后,我的预算结果是这样的:


image.png


我的天,个十百千万,居然有两万五?这还花的完??


几秒后冷静下来,我才发现算错了。。。


两万五应该是这样算的:按照当前收入,再连续缴纳 29 年。 😂


image.png


对于我们程序员,保持收入 29 年基本是不可能的,我还是重新调整参数再看看吧。


我现在社保缴纳了七年半,如果缴够 15 年社保,退休后我能领多少钱呢?


image.png


答案是一万四!看着还不错哈,每天能有 480 元左右,就是不知道 30 年后的物价怎么样了😂。


如果再悲观一点,社保缴纳到 35 岁(然后最低标准缴够 15 年),退休后大概能领多少呢?


image.png


答案是一万元!


看了下人民币的贬值率,30 年后的一万元不知道有没有今天三千块钱的购买力😷。


OK,这就是我退休后大概能领到的工资范围。


退休工资的组成


从上面的预算结果中我们可以看到,养老金由三部分组成:基础养老金、个人账户养老金和过渡性养老金,它们都是什么意思呢?


1.基础养老金 = 退休时平均工资 ×(1+平均缴费指数)÷ 2 × 累计缴费年限 × 1%。


退休时平均工资指的是退休时所在地区上年度的社会平均工资。也就是说经济发达地区的基础养老保险金,要高于欠发达地区。


平均缴费指数指的参保人选择的缴纳比例(一般在0.6-3之间)。每个月社保的缴费比例越高,相应的基础养老金越高。


例如,小张退休时,上年度的社会平均工资是 12000。虎虎的缴费指数平均值是 1,累计缴存了15年,他的基础养老金约为:12000*(1+1)/2 * 15 * 1% = 1800。


2.个人账户养老金 = 养老保险个人账户累计金额 ÷ 养老金计发月数。


我们缴纳社保时,一部分会进入个人社保账户,一部分会进入国家统筹账户。个人账户的部分,直接影响退休养老金的计算。


计发月数和我们退休的年龄有直接的关系,退休的越晚,计发月数越少;退休的时间越早,计发月数越多。一般来说,按照 60岁 退休,计发月数是 139 个月。



退休金的计发月数只是用来计算退休金,而不是说只能领这么久的退休金。



例如,小张社保的个人缴纳比例为 8%,社保的计算基数是 9339,他选择在 60 岁退休,那他的个人账户养老金约为:9339 * 8% * 12 * 15 / 139 = 967.49。


3.过渡性养老金 = 退休时平均工资 × 建立个人账户前的缴费年限 × 1.3% × 平均缴费指数


过渡性养老金,是指在养老保险制度发生变化(比如缴费标准提高、计算方法改变、退休年龄调整)的时候,给予受影响群体的资金补充。


这个奖金的计算规则说法不一,一种比较广泛的计算方法是:退休时所在地区的平均工资 x 缴费指数 x 缴费年限 x 过渡系数,其中过渡系数大概在 1% 到 1.4% 之间。


OK,这就是养老金三部分组成的含义。


总结


好了,这篇文章到这里就结束了,主要讲了:



  1. 如何能上海领退休工资:缴纳 10~15 年社保,到达退休年龄

  2. 我退休后大概能领多少钱:30 年后的一万左右

  3. 退休工资的三部分组成:基础养老金、个人账户养老金和过渡性养老金


通过写这篇文章,我对养老金的认识更多了一些,希望国家繁荣昌盛,让我退休的时候能多领点钱!




作者:张拭心
来源:juejin.cn/post/7327480122407141388
收起阅读 »

计算机还值得学吗?互联网还能来吗?

这几天,高考的话题热度不减,作为一名有着数百位粉丝的微V,我决定来蹭一波流量😎。 2004年,高三,最后一次模拟考试,班级第1,年级第10,能上中科大。 半个月后,高考,班级第30名,比一本线还少了20来分,史上最烂。 我想学电子信息类的,报了一些之前根本瞧不...
继续阅读 »

这几天,高考的话题热度不减,作为一名有着数百位粉丝的微V,我决定来蹭一波流量😎。


2004年,高三,最后一次模拟考试,班级第1,年级第10,能上中科大。


半个月后,高考,班级第30名,比一本线还少了20来分,史上最烂。


我想学电子信息类的,报了一些之前根本瞧不上的学校,全部被拒。不得已,去了医科大。


医科大录取通知书


医学,甚是无趣和枯燥;自学了1年的数学和计算机专业课后,跨考中科大的计算机系,一战上岸。


中科大录取通知书


2012年,毕业,北漂,辗转了3个知名互联网公司,直接下属20余人,负责的项目日流水数千万。


2022年,因为家庭和户口的原因,回老家上班,断崖式降薪。从头开始,下属归零,继续当大头兵。


四处降本增笑的今天,互联网公司还能去吗?还能报计算机专业吗?实话说,我不知道,也没有答案。


我只能说我从不后悔放弃医学,读研时选择了计算机专业,更不后悔进入了互联网行业。


理由如下:



  1. 我对医学实在是没兴趣,无论是教书还是当医生,肯定都是混日子,误人子弟或害人性命,天理不容。

  2. 读研,虽然没能研究出什么名堂,但是凭着兴趣做了一些 APP,顺利敲开了互联网大厂的的大门。

  3. 相对来说,互联网还是比较公平的,只要技术过的去,迟早会晋升涨薪;北京10年,我薪资翻了10余倍。

  4. 我父母都是农民,无法在经济上提供帮助。靠着相对不错的收入,我完成了结婚、生娃、买房、买车的大事。

  5. 开发,没有太多人际关系的破事,安心做好自己的事就行。喝酒?谁爱喝谁喝,反正我不喝,问就是吃头孢。


奖杯


肯定会有人喷我,站在了风口上,赶上了互联网的红利。今时不同往日,广进搞的飞起,保住饭碗就不错了。


另外,996太辛苦,有命赚没命花;ChatGPT 太牛逼,码农的饭碗迟早被砸;最难受的是,35岁就得滚蛋。


以上,大部分是事实,而且很操蛋。以下是我的个人观点,不喜勿喷:



  1. 对于没背景的小镇做题家来说,互联网依然是不错的选择,至少能让你前期的财务状况比较好

  2. 广进、35岁魔咒,我也不知道怎么办。说句废话,降低负债,降低欲望,趁年轻,尽量多赚点

  3. 互联网加班虽然多,但996是少数,我周末几乎没加过;ChatGPT,未来不好说,现在干不掉码农


甘蔗没有两头甜,如果能找到「钱多事少离家近」的金饭碗,谁特么愿意做社畜?


QQ公仔


总结,在没有更好的选择的前提下,计算机值得一学,互联网也可以来。当然,不能像我对医学那么抵触。


以上,不构成志愿和职业的选择建议,风险自负。


作者:野生的码农
来源:juejin.cn/post/7385054068525514788
收起阅读 »

是的,JDK 也有不为人知的“屎山”!

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()。 评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天...
继续阅读 »

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()


评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天就让我们好好的扒一扒 Map 的底裤,看看这背后不为人知的故事。


要讲 Map,可以说 HashMap 是日常开发使用频次最高的,我愿称其为古希腊掌管性能的神。


举个简单的例子,如何判断两个集合是否存在交集?最简单也最粗暴的方式,两层 for 遍历暴力检索,别跟我提什么时间空间复杂度,给我梭哈就完事。


public void demo() {  
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);
for (Integer l1 : list1) {
for (Integer l2 : list2) {
if (Objects.equals(l1, l2)) {
duplicateList.add(l1);
}
}
}
System.out.println(duplicateList);
}

image.png


敲下回车提交代码之后,当还沉浸在等待领导夸你做事又稳又快的时候,却发现领导黑着脸向你一步步走来。


刚准备开始摸鱼的你吓得马上回滚了提交,在一番资料查询之后你发现了原来可以通过 Map 实现 O(n) 级的检索效率,你意气风发的敲下一段新的代码:


public void demo() {  
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);

Map<Integer, Integer> map = new HashMap<>();
list2.forEach(it -> map.put(it, it));
for (Integer l : list1) {
if (Objects.nonNull(map.get(l))) {
duplicateList.add(l);
}
}
System.out.println(duplicateList);
}

重新提交代码起身上厕所,你昂首挺胸的特地从领导面前路过,领导回了你一个肯定的眼神。


image.png


让我们回到 HashMap 的身上,作为八股十级选手而言的你,什么数据结构红黑树可谓信手拈来,但我们今天不谈八股,只聊聊背后的一些设计理念。


众所周知,在 HashMap 中有且仅允许存在一个 keynull 的元素,当 key 已存在默认的策略是进行覆盖,比如下面的示例最终 map 的值即 {null=2}


Map<Integer, Integer> map = new HashMap<>();  
map.put(null, 1);
map.put(null, 2);
System.out.println(map);

同时 HashMap 对于 value 的值并没有额外限制,只要你愿意,你甚至可以放几百万 value 为空的元素像下面这个例子:


Map<Integer, Integer> map = new HashMap<>();  
map.put(1, null);
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
System.out.println(map);

这也就引出了今天的重点!


stream 中使用 Collectors.toMap() 时,如果你不注意还是按照惯性思维那么它就会让你感受一下什么叫做暴击。就像上一篇文章提到的其异常触发机制,但却并不知道为什么要这么设计?


作为网络冲浪小能手,我反手就是在 stackoverflow 发了提问,咱虽然笨但主打一个好学。


image.png


值得一提的是,评论区有个老哥回复的相当戳我,他的回复如下:


image.png


用我三脚猫的英语水平翻译一下,大概意思如下:



因为人家 toMap() 并没有说返回的是 HashMap,所以你凭什么想要人家遵循跟 HashMap 一样的规则呢?



我滴个乖乖,他讲的似乎好有道理的样子。


image.png


我一开始也差点信了,但其实你认真看 toMap() 的内部实现,你会发现其返回的不偏不倚正好就是 HashMap


image.png


如果你还不信,以上篇文章的代码为例,执行后获取其类型可以看到输出就是 HashMap


image.png


这时候我的 CPU 又烧了,这还是我认识的 HashMap,怎么开始跟 stream 混之后就开始六亲不认了,是谁说的代码永远不会变心的?


image.png


一切彷佛又回到了起点,为什么在新的 stream 中不遵循大家已经熟悉规范,而是要改变习惯对此做出限制?


stackoverflow 上另外的一个老哥给出的他的意见:


image.png


让我这个四级 751 分老手再给大家做个免费翻译官简化一下观点:



Collectors.toMap() 的文档中已经标注其并不保证返回 Map 的具体类型,以及是否可变、序列化性以及是否线程安全,而 JDK 拥有众多的版本,可能在你的环境已经平稳运行了数年,但换个环境之后在不同的 JDK 下可能程序就发生了崩溃。因此,这些额外的保障实际上还帮了你的忙。



回头去看 toMap() 方法上的文档说明,确实也像这位老哥提到的那样。


image.png


而在 HashMap 中允许 KeyValue 为空带来的一个问题在此时也浮现了出来,当存入一个 value 为空的元素时,再后续执行 get() 再次读取时,存在一个问题那就是二义性。


很显然执行 get() 返回的结果将为空,那这个空究竟是 Map 中不存在这个元素?还是我存入的元素其 value 为空?这一点我想只有老天爷知道,而这种二义性所带来的问题在设计层面显然是一个失误。


那么到这里,我们就可以得到一个暴论:HashMap 允许 key 和 value 为空就是 JDK 留下的“屎山”!


为了验证这一结论,我们可以看看在新的 ConcurrentHashMapJDK 是怎么做的?查看源码可以看到,在 put() 方法的一开始就执行了 keyvalue 的空值校验,也验证了上面的猜想。


image.png


这还原不够支撑我们的结论,让我们继续深挖这背后还有什么猫腻。


首先让我看看是谁写的 ConcurrentHashMap,在 openjdkGitHub 仓库类文档注释可以看到主要的开发者是 Doug Lea


image.png


Doug Lea 又是何方大佬,通过维基百科的可以看到其早期是 Java 并发社区的主席,他参与了一众的 JDK 并发设计工作,可谓吾辈偶像。


image.png


在网络搜罗相关的资讯找到对应的话题,虽然图中的链接已经不存在了,但还是能从引用的内容看出其核心的原因正是为了规避的结果的模糊性,与前文我们讨论的二义性不尽相同。


image.png


那为什么 JDK 不同步更新 HashMap 的设计理念,在新版 HashMap 中引入 keyvalue 的非空校验?


我想剩下的理由只有一个:HashMap 的使用范围实在太广,就算是 JDK 自己也很难在不变更原有结构的基础上进行改动,而在 JDK 1.2 便被提出并广泛应用,对于一个发展了数十年的语言而言,兼容性是十分重要的一大考量。


因此,我们可以看到,在后续推出的 Map 中,往往对 keyValue 都作了进一步的限制,而对于 HashMap 而言,可能 JDK 官方也是有心无力吧。


到这里基本也就盖棺定论了,但本着严谨的态度大胆假设小心求证,让我们再来看看大家伙的意见,万一不小心就被人网暴了。


image.png


stackoverflow 上另外几篇有关 Map 回答下可以看到,许多人都认为 HashMap 支持空值是一个存在缺陷的设计。


image.png


感兴趣的小伙伴可以去原帖查看,这里我就不再展开介绍了,原帖链接:Why does Map.of not allow null keys and values?


看到这里,下次别人或者老板再说你写的代码是屎山的时候,请昂首挺胸自信的告诉他 JDk 都会犯错,我写的这点又算得了什么?


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7384629198130610215
收起阅读 »

掘金滑块验证码安全升级,继续破解

web
去年发过一篇文章,《使用前端技术破解掘金滑块验证码》,我很佩服掘金官方的气度,不但允许我发布这篇文章,还同步发到了官方公众号。最近发现掘金的滑块验证码升级了,也许是我那篇文章起到了一些作用,逼迫官方加强了安全性,这是一个非常好的现象。不过,这并不是终点,我们还...
继续阅读 »

去年发过一篇文章,《使用前端技术破解掘金滑块验证码》,我很佩服掘金官方的气度,不但允许我发布这篇文章,还同步发到了官方公众号。最近发现掘金的滑块验证码升级了,也许是我那篇文章起到了一些作用,逼迫官方加强了安全性,这是一个非常好的现象。

不过,这并不是终点,我们还是可以继续破解。验证码的安全性是在用户体验和安全性之间的一个平衡,如果安全性太高,用户体验就会变差,如果用户体验太好,安全性就会变差。掘金的滑块验证码是一个很好的例子,它的安全性和用户体验之间的平衡做得非常好,并且我们破解的难度体验也非常好。 😄

本次升级的内容

掘金的滑块验证码升级了,主要有以下几个方面的改进:

  1. 首先验证码不再是掘金自己的验证码了,而是使用了字节的校验服务,可以看到弹窗是一个 iframe,并且域名是 bytedance.com

我们都知道掘金被字节收购了,可以猜测验证码的升级是字节跳动的团队做的。

  1. 验证码的图形不再是拼图,而是随机的不同形状,比如爱心、六角星、圆环、月亮、盾牌等。
  2. 增加了干扰缺口,主要是大小或旋转这种操作。

下面看一下改版后的滑块验证码:

我在文章的评论区看到了一些关于这次升级或相关的讨论:

本文将继续破解这次升级后的滑块验证码,看看这次升级对破解的难度有多大影响,如果你还没有了解过如何破解滑块验证码,请先看我之前的文章。

iframe

这次升级,整个滑块都掉用的是外部链接,使用 iframe 呈现,那么在 puppeteer 中如何处理呢?

await page.waitForSelector('iframe');
const elementHandle = await page.$('iframe');
const frame = await elementHandle.contentFrame();

实际上,我们只需要等待 iframe 加载完成,然后获取 iframe 的内容即可。

Frame 对象和 Page 对象有很多相似的方法,比如 frame.$frame.evaluate 等,我们可以直接使用这些方法来操作 iframe 中的元素。

验证码的识别

上一篇文章采用比较简单的判断方式,当时缺口处有明显的白边,所以只需要找到这个白边即可。

但是本次升级后,缺口不再是白边,而是阴影的效果,并且缺口的形状也不再是拼图,大概率都是曲线的边,所以再判断缺口的方式就不再适用了。

现在我们可以采用一种新的方式,通过对比滑块图片和缺口区域的像素值相似程度来判断缺口位置。

首先还是二值化处理,将图片转换为黑白两色:

可以看到左侧缺口和右侧缺口非常相似,只是做了一点旋转作为干扰。

再看一下,iframe 中还有一个很重要的东西,就是校验的图片:

 

它是一个 png 图片,所以我们可以把它也转换成二值化,简单的方式就是将透明色转换为白色,非透明色转换为黑色,如果想提高识别精度,可以与背景图一样,通过灰度、二值化的转换方式。

// 获取缺口图像
const captchaVerifyImage = document.querySelector(
'#captcha-verify_img_slide',
) as HTMLImageElement;
// 创建一个画布,将 image 转换成canvas
const captchaCanvas = document.createElement('canvas');
captchaCanvas.width = captchaVerifyImage.width;
captchaCanvas.height = captchaVerifyImage.height;
const captchaCtx = captchaCanvas.getContext('2d');
captchaCtx.drawImage(
captchaVerifyImage,
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
const captchaImageData = captchaCtx.getImageData(
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
// 将像素数据转换为二维数组,同样处理灰度、二值化,将像素点转换为0(黑色)或1(白色)
const captchaData: number[][] = [];
for (let h = 0; h < captchaVerifyImage.height; h++) {
captchaData.push([]);
for (let w = 0; w < captchaVerifyImage.width; w++) {
const index = (h * captchaVerifyImage.width + w) * 4;
const r = captchaImageData.data[index] * 0.2126;
const g = captchaImageData.data[index + 1] * 0.7152;
const b = captchaImageData.data[index + 2] * 0.0722;
if (r + g + b > 30) {
captchaData[h].push(0);
} else {
captchaData[h].push(1);
}
}
}

为了对比图形的相似度,二值化后的数据我们页采用二维数组的方式存储,这样可以方便的对比两个图形的相似度。

如果想观测二值化后的真是效果,可以把二位数组转换为颜色,并覆盖到原图上:

// 通过 captchaData 0 黑色  1 白色 的值,绘制到 canvas 上,查看效果
for (let h = 0; h < captchaVerifyImage.height; h++) {
for (let w = 0; w < captchaVerifyImage.width; w++) {
captchaCtx.fillStyle =
captchaData[h][w] == 1 ? 'rgba(0,0,0,0)' : 'black';
captchaCtx.fillRect(w, h, 1, 1);
}
}
captchaVerifyImage.src = captchaCanvas.toDataURL();

数据拿到后,我们可以开始对比两个图形的相似度,这里就采用非常简单的对比方式,从左向右,逐个像素点对比,横向每个图形的像素一致的点数量纪录下来,然后取最大值,这个最大值就是缺口的位置。

这里我们先优化一下要对比的数据,我们只需要对比缺口的顶部到底部这段的数据,截取这一段,可以减少对比的性能消耗。

// 获取captchaVerifyImage 相对于 .verify-image 的偏移量
const captchaVerifyImageBox = captchaVerifyImage.getBoundingClientRect();
const captchaVerifyImageTop = captchaVerifyImageBox.top;
// 获取缺口图像的位置
const imageBox = image.getBoundingClientRect();
const imageTop = imageBox.top;
// 计算缺口图像的位置,top 向上取整,bottom 向下取整
const top = Math.floor(captchaVerifyImageTop - imageTop);
// data 截取从 top 列到 top + image.height 列的数据
const sliceData = data.slice(top, top + image.height);

然后循环对比两个图形的像素点,计算相似度:

// 循环对比 captchaData 和 sliceData,从左到右,每次增加一列,返回校验相同的数量
const equalPoints = [];
// 从左到右,每次增加一列
for (let leftIndex = 0; leftIndex < sliceData[0].length; leftIndex++) {
let equalPoint = 0;
// 新数组 sliceData 截取 leftIndex - leftIndex + captchaVerifyImage.width 列的数据
const compareSliceData = sliceData.map((item) =>
item.slice(leftIndex, leftIndex + captchaVerifyImage.width),
);
// 循环判断 captchaData 和 compareSliceData 相同值的数量
for (let h = 0; h < captchaData.length; h++) {
for (let w = 0; w < captchaData[h].length; w++) {
if (captchaData[h][w] === compareSliceData[h][w]) {
equalPoint++;
}
}
}
equalPoints.push(equalPoint);
}
// 找到最大的相同数量,大概率为缺口位置
return equalPoints.indexOf(Math.max(...equalPoints));

对比时像素较多,不容易直接看到效果,这里写一个简单的二位数组对比,方便各位理解:

[
[0, 1, 0],
[1, 0, 1],
[0, 1, 0],
]
[
[0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 1, 0],
[0, 0, 0, 1, 0, 0],
]

循环对比,那么第3列开始,匹配的数量可以达到9,所以返回 3,这样就是滑块要移动的位置。

干扰缺口其实对我们这个识别方式没什么影响,最多可能会增加一些失败的概率,我个人测试了一下,识别成功率有 95% 左右。

总结

这次升级后,掘金的滑块验证码的安全性有了一定的提升,还是可以继续破解的,只是难度有所增加。最后再奉劝大家不要滥用这个技能,这只是为了学习和研究,不要用于非法用途。如果各位蹲局子,可不关我事啊。 🤔️


作者:codexu
来源:juejin.cn/post/7376276140595888137
收起阅读 »

队友升职,被迫解锁 Jenkins(所以,前端需要学习Jenkins吗?🤔)

web
入坑 Jenkins 作为一个前端,想必大家都会有这个想法:“Jenkins 会用就行了,有啥好学的”。 我一直都是这么想的,不就会点个开始构建就行了嘛! 可是碰巧我们之前负责 Jenkins 的前端同事升了职,碰巧这个项目组就剩了两个人,碰巧我比较闲,于是这...
继续阅读 »

入坑 Jenkins


作为一个前端,想必大家都会有这个想法:“Jenkins 会用就行了,有啥好学的”。


我一直都是这么想的,不就会点个开始构建就行了嘛!


可是碰巧我们之前负责 Jenkins 的前端同事升了职,碰巧这个项目组就剩了两个人,碰巧我比较闲,于是这个“活”就落在我的头上了。


yali.jpeg


压力一下就上来了,一点不懂 Jenkins 可咋整?


然而现实是没有一点儿压力。


刚开始的时候挺轻松,也就是要发版的流程到我这了,我直接在对应项目上点击开始构建,so easy!可是某一天,突然遇到一个 bug:我们每次 web 端项目发完后,桌面端的 hybrid 包需要我手动改 OSS 上配置文件的版本号,正巧那天忘记更新版本号了,导致桌面端应用本地的 hybrid 没有更新。。。


领导:你要不就别手动更新了,弄成自动化的

我:😨 啊!什么,我我我不会,是不可能的


小弟我之前没有接触过 Jenkins,看着那一堆配置着实有点费脑,于是就只能边百度学习边输出,从 Jenkins 安装开始到配置不同类型的构建流程,踩过不少坑,最后形成这篇文章。如果有能帮到大家的点,我就很开心了,毕竟我也是刚接触的!


说说我经历过的前端部署流程


按照我的经历,我把前端部署流程分为了以下几个阶段:即原始时代 -> 脚本化时代 -> CI/CD 时代。


jenkins-history.png


原始时代


最开始的公司运维是一个小老头,他只负责管理服务器资源,不管各种项目打包之类的。我们就只能自己打包,再手动把构建的文件丢到服务器上。


整体流程就是:本地合并代码 --> 本地打包 --> 上传服务器;


上传服务器可以分为这几个小步骤:打开 xshell --> 连接服务器 --> 进入 tomcat 目录 --> 通过 ftp 上传本地文件。


可能全套下来需要 5 分钟左右。


脚本化时代


为了简化,我写了一个 node 脚本,通过ssh2-sftp-client上传服务器这一步骤脚本化:


const chalk = require('chalk')
const path = require('path')
const fs = require('fs')
const Client = require('ssh2-sftp-client')
const sftp = new Client()
const envConfig = require('./env.config')

const defalutConfig = {
port: '22',
username: 'root',
password: '123',
localStatic: './dist.tar.gz',
}

const config = {
...defalutConfig,
host: envConfig.host,
remoteStatic: envConfig.remoteStatic,
}

const error = chalk.bold.red
const success = chalk.bold.green
function upload(config, options) {
if (!fs.existsSync('./dist') && !fs.existsSync(options.localStatic)) {
return
}
// 标志上传dist目录
let isDist = false
sftp
.connect(config)
.then(() => {
// 判断gz文件存在时 上传gz 不存在时上传dist
if (fs.existsSync(options.localStatic)) {
return sftp.put(options.localStatic, options.remoteStatic)
} else if (fs.existsSync('./dist')) {
isDist = true
return sftp.uploadDir('./dist', options.remoteStatic.slice(0, -12))
}
})
.then(() => {
sftp.end()
if (!isDist) {
const { Client } = require('ssh2')
const conn = new Client()
conn
.on('ready', () => {
// 远程解压
const remoteModule = options.remoteStatic.replace('dist.tar.gz', '')
conn.exec(
`cd ${remoteModule};tar xvf dist.tar.gz`,
(err, stream) => {
if (err) throw err
stream
.on('close', (code) => {
code === 0
conn.end()
// 解压完成 删除本地文件
fs.unlink(options.localStatic, (err) => {
if (err) throw err
})
})
.on('data', (data) => {})
}
)
})
.connect(config)
}
})
.catch((err) => {
sftp.end()
})
}

// 上传文件
upload(config, {
localStatic: path.resolve(__dirname, config.localStatic), // 本地文件夹路径
remoteStatic: config.remoteStatic, // 服务器文件夹路径器
})

upload-dist.png


最后只要通过执行yarn deploy即可实现打包并上传,用了一段时间,队友也都觉得挺好用的,毕竟少了很多手动操作,效率大大提升。


CI/CD 时代


不过用了没多久后,来了个新的运维小年轻,一上来就整了个 Jenkins ,取代了我们手动打包的过程,只要我们点击部署就可以了,当时就感觉 Jenkins 挺方便的,但又觉得和前端没多大关系,也就没学习。


不过也挺 Jenkins 的,为啥呢?



当时和测试说的最多的就是“我在我这试试.....我这没问题啊,你刷新一下”,趁这个时候,赶紧打包重新部署下。有了 Jenkins 后,打包都有记录了,测试一看就知道我在哄她了 🙄



Jenkins 解决了什么问题


我觉得在了解一个新事物前,应该先了解下它的出现解决了什么问题。


以我的亲身经历来看,Jenkins 的出现使得 拉取代码 -> 打包 -> 部署 -> 完成后工作(通知、归档、上传CDN等)这一繁琐的流程不需要人为再去干预,一键触发 🛫。


jenkins-vs-old.png


只需要点击开始构建即可,如何你觉得还得每次打开 jenkins 页面去点击构建,可以通过设置代码提交到 master 或合并代码时触发构建,这样就不用每次手动去点击构建了,省时更省力 🚴🏻‍♂️。


Jenkins 部署


Jenkins 中文帮助文档


Jenkins 提供了多种安装方式,我的服务器是 Centos,按照官方教程进行部署即可。


官方提供两种方式进行安装:


方式一:


sudo wget -O /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/redhat-stable/jenkins.repo
sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key

yum install jenkins

方式二:


直接下载 rpm 包进行安装,地址:mirrors.jenkins-ci.org/redhat/


wget https://pkg.jenkins.io/redhat/jenkins-2.449-1.1.noarch.rpm
rpm -ivh jenkins-2.449-1.1.noarch.rpm

安装过程


我是使用方式二进行安装的,来看下具体过程。


首先需要安装 jdk17 以上的版本



  1. 下载对应的 jdk


    wget https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz


  2. 解压并放到合适位置


    tar xf jdk-17_linux-x64_bin.tar.gz
    mv jdk-17.0.8/ /usr/lib/jvm


  3. 配置 Java 环境变量


    vim /etc/profile
    export JAVA_HOME=/usr/lib/jvm/jdk-17.0.8
    export CLASSPATH=$JAVA_HOME/lib:$JRE_HOME/lib:$CLASSPATH
    export PATH=$JAVA_HOME/bin:$JRE_HOME/bin:$PATH


  4. 验证


    java -version

    jenkins-java-version.png



接着安装 Jenkins,需要注意:Jenkins 一定要安装最新版本,因为插件要求最新版本,最新的 2.449。



  1. 下载 rpm 包


    cd /usr/local/jenkins
    wget https://mirrors.jenkins-ci.org/redhat/jenkins-2.449-1.1.noarch.rpm


  2. 安装 Jenkins


    rpm -ivh jenkins-2.449-1.1.noarch.rpm


  3. 启动 Jenkins


    systemctl start jenkins



jenkins-install-error.png


你以为就这么简单?肯定会报错的,通过百度报错信息,报错原因是:Java 环境不对,百度到的解决方法:


修改/etc/init.d/jenkins文件,添加 JDK,但是目录下并没有这个文件,继续百度得知:


使用 systemctl 启动 jenkins 时,不会使用 etc/init.d/jenkins 配置文件,而是使用 /usr/lib/systemd/system/jenkins.service文件


于是修改:


vim /usr/lib/systemd/system/jenkins.service

jenkins-service-java.png


搜索 Java,找到上面这一行,打开注释,修改为对应的 JDK 位置:


Environment="JAVA_HOME=/usr/lib/jvm/jdk-17.0.10"

重新启动 Jenkins:


systemctl restart jenkins

查看启动状态,出现如下则说明 Jenkins 启动完成:


jenkins-install-success.png
接着在浏览器通过 ip:8090 访问,出现如下页面,说明安装成功。


jenkins-install-success-ip.png


此时需要填写管理员密码,通过 cat /var/lib/jenkins/secrets/initialAdminPassword 即可获取。


Jenkins 配置


出现上述界面,填写密码成功后等待数秒,即可出现如下界面:


jenkins-install-plugins.png


选择 安装推荐的插件


jenkins-install-plugins-wait.png


这个过程稍微有点慢,可以整理整理文档,等待安装完成。


安装完成后,会出现此页面,需要创建一个管理员用户。


jenkins-install-ok.png


点击开始使用 Jenkins,即可进入 Jenkins 首页。


jenkins-home.png


至此,Jenkins 安装完成 🎉🎉🎉。


安装过程遇到的问题



  1. 没有经验第一次安装,参考网上文档推荐的是 JDK8,结果安装的 Jenkins 至少需要 JDK 11,导致安装失败;

  2. 第二次安装,按照网上的文档安装,不是最新版本,导致部分插件安装失败;


    release

    版本


  3. 配置修改问题



    • Jenkins 默认的配置文件位于 /usr/lib/systemd/system/jenkins.service

    • 默认目录安装在 /var/lib/jenkins/

    • 默认工作空间在 /var/lib/jenkins/workspace



  4. 修改端口号为 8090


    vim /usr/lib/systemd/system/jenkins.service

    修改 Environment="JENKINS_PORT=8090",修改完后执行:


    systemctl daemon-reload
    systemctl restart jenkins



如何卸载 Jenkins


安装过程遇到了不少坑,基本都是卸载了重新安装,于是就总结了以下卸载的命令。


# 查找是否存在 Jenkins 安装包
rpm -ql jenkins
# 卸载 Jenkins
rpm -e jenkins
# 再次查看 此时会提示:未安装软件包 jenkins
rpm -ql jenkins
# 删除所有 Jenkins 相关目录
find / -iname jenkins | xargs -n 1000 rm -rf

Jenkins 版本更新


Jenkins 发布版本很频繁,基本为一周一次,参考 Jenkins 更新


项目创建


点击 + 新建Item,输入名称,选择类型:


jenkins-create-project.png


有多种类型可供选择,这里我们主要讲这两种:Freestyle project 和 Pipeline。


Freestyle project


jenkins-create-freestyle.jpeg


选择这种类型后,就可以通过各种 web 表单(基础信息、源码、构建步骤等),配置完整的构建步骤,对于新手来说,易上手且容易理解,如果第一次接触,创建项目就选择 Freestyle project 即可。


总共有以下几个环节需要配置:



  • General

  • 源码管理

  • 构建触发器

  • 构建环境

  • Build Steps

  • 构建后操作


此时我们点击 OK,创建完如下所示都是空白的,也可以通过创建时的复制选项,复制之前项目的配置:


jenkins-create-configure.png


接着就如同填写表单信息,一步步完成构建工作。


General


项目基本信息也就是对所打包项目的描述信息:


jenkins-configure-general.png


比如描述这里,可以写项目名称、描述、输出环境等等。


Discard old builds 丢弃旧的构建

可以理解为清初构建历史,Jenkins 每打包一次就会产生一个构建历史记录,在构建历史中可以看到从第一次到最新的构建信息,这会导致磁盘空间消耗。


点击配置名称或勾选,会自动展开配置项。这里我们可以设置保持构建的最大个数5,则当前项目的构建历史记录只会保留最新的 5 个,并自动删除掉最老的构建。


jenkins-configure-discard.png


这个可以按照自己的需求来设置,比如保留 7 天的构建记录或保留最多 100 个构建记录。


Jenkins 的大多数配置都有 高级 选项,在高级选项中可以做更详细的配置。


This project is parameterized

可以理解为此构建后续过程可能用到的参数,可以是手动输入或选项等,如:git 分支、构建环境、特定的配置等等。通过这种方式,项目可以更加灵活和可配置,以适应不同的构建需求和环境。


默认有 8 种参数类型:



  1. Boolean Parameter: checkbox 选择,如果需要设置 true/false 值时,可以添加此参数类型

  2. Choice Parameter:选择,多个选项

  3. Credentials Parameter:账号证书等参数

  4. File Parameter:文件上传

  5. Multi-line String parameters:多行文本参数

  6. Password Parameter:密码参数

  7. Run Parameter:用于选择执行的 job

  8. String Parameter:单行文本参数


Git Parameter 需要在 系统管理 -> 插件管理 搜索 Git Parameter 插件进行安装,安装完成后重启才会有这个参数。


通过 添加参数 来设置后续会用到的参数,比如设置名称为 delopyTagGit Parameter 参数来指定要构建的分支,设置名称为 DEPLOYPATHChoice Parameter 参数来指定部署环境等等。


jenkins-configure-parameter.png


源码管理


Repositories

一般公司项目都是从 gitlab 上拉代码,首先设置 Repository URL,填写 git 仓库地址,比如:https://gitlab.com/xxx/xxx.git


填写完后会报错如下:


jenkins-configure-git-error.png


可以通过添加 Credentials 凭证解决,在 Jenkins 中,Git 的 Credentials 是用于访问 Git 仓库的认证信息,这些凭据可以是用户名和密码、SSH 密钥或其他认证机制,以确保 Jenkins 能够安全的与 Git 仓库进行交互,即构建过程中自动拉取代码、执行构建任务等


方式一:在当前页面填写帐号、密码

选择添加 -> Jenkins -> 填写 git 用户名、密码等信息生成一个新的 Credentials,然后重新选择我们刚刚添加的 Credentials,报错信息自动消失


jenkins-configure-git.png


这样添加会有一个问题,就是如果有多个项目时,每次都需要手动填写 Git 账户和密码信息。


方式二:Jenkins 全局凭证设置

Global Credentials 中设置全局的凭证。


jenkins-configure-git-credentials.png


然后在项目中配置时可以直接选择我们刚刚添加的 Credentials,报错信息自动消失。


Branches to build

这里构建的分支,可以设置为我们上面设置的 delopyTag 参数,即用户自己选择的分支进行构建。


构建触发器


特定情况下出发构建,如定时触发、代码提交或合并时触发、其他任务完成时触发等。


如果没有特殊的要求时,这一步完全可以不用设置,在需要构建时我们只需要手动点击开始构建即可。


构建环境


构建环境是在构建开始之前的准备工作,如清除上次构建、指定构建工具、设置 JDK 、Node 版本、生成版本号等。


Provide Node & npm bin/folder to PATH

默认是没有这一项的,但前端部署需要 Node 环境支持,所以需要在 系统管理 -> 插件管理 搜索 nodejs 插件进行安装,安装完成后重启才会展示这项配置。


但此时还是不能选择的,需要在 系统管理 -> 全局工具配置 中先安装 NodeJs,根据不同环境配置,可同时安装多个 NodeJs 版本


jenkins-configure-nodeJs.png


之后在 Provide Node 处才有可供选择的 Node 环境。


jenkins-configure-provide-node.png


Create a formatted version number

这个就是我用来解决了一开始问题的配置项,也就是把每次打包的结果上传到 OSS 服务器上时生成一个新的版本号,在 Electron 项目中通过对比版本号,自动更新对应的 hybrid 包,领导都爱上我了 😜。


首先需要安装插件 Version Number Plugin,在 系统管理 -> 插件管理 中搜索安装,然后重启 Jenkins 即可


jenkins-configure-version.png



  1. Environment Variable Name


    类似于第一步的构建参数,可以在其他地方使用。


  2. Version Number Format String


    用于设置版本号的格式,如1.x.x,Jenkins 提供了许多内置的环境变量:



    • BUILD_DAY:生成的日期

    • BUILD_WEEK:生成年份中的一周

    • BUILD_MONTH:生成的月份

    • BUILD_YEAR:生成的年份

    • BUILDS_TAY:在此日历日期完成的生成数

    • BUILDS_THIS_WEEK:此日历周内完成的生成数

    • BUILDS_THIS_MONTH:此日历月内完成的生成数

    • BUILDS_THIS_YEAR:此日历年中完成的生成数

    • BUILDS_ALL_TIME:自项目开始以来完成的生成数



  3. 勾选 Build Display Name Use the formatted version number for build display name 后


    此时每次构建后就会生成一个个版本号:


    jenkins-configure-version-result.png


  4. 把这个参数传递到后续的 OSS 上传的 Shell 脚本中即可。


如果想要重置版本号,只要设置Number of builds since the start of the project为 0 即可,此时就会从 1.7.0 重新开始。


Build Steps


这是最为重要的环节,主要用于定义整个构建过程的具体任务和操作,包括执行脚本、编译代码、打包应用等。


我们可以通过 Shell 脚本来完成前端项目常见的操作:安装依赖、打包、压缩、上传到 OSS 等。


点击 增加构建步骤 -> Execute shell,在上方输入 shell 脚本,常见的如下:


#环境变量
echo $PATH
#node版本号
node -v
#npm版本号
npm -v


#进入jenkins workspace的项目目录
echo ${WORKSPACE}
cd ${WORKSPACE}

#下载依赖包
yarn
#开始打包
yarn run build

#进入到打包目录
cd dist
#删除上次打包生成的压缩文件
rm -rf *.tar.gz

#上传oss,如果没有需要可删除此段代码
ossurl="xxx"
curl "xxx" > RELEASES.json
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=tmp.zip ossUrl=xxx/v${BUILD_VERSION}.zip
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=RELEASES.json ossUrl=xxx/RELEASES.json

#把生成的项目打包成压缩包方便传输到远程服务器
tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
#回到上层工作目录
cd ../

构建后操作


通过上面的构建步骤,我们已经完成了项目的打包,此时我们需要执行一些后续操作,如部署应用、发送通知、触发其他 Job等操作。


Send build artifacts over SSH


通过 Send build artifacts over SSH,我们可以将构建好的产物(一般是压缩后的文件)通过 ssh 发送到指定的服务器上用于部署,比如 Jenkins 服务器是 10.10,需要将压缩文件发送到 10.11 服务器进行部署,需要以下步骤:



  1. 安装插件


    系统管理 -> 插件管理 中搜索插件 Publish over SSH 安装,用于处理文件上传工作;


  2. 配置服务器信息


    系统管理 -> System 中搜索 Publish over SSH 进行配置。


    jenkins-publish-over-SSH.png


    需要填写用户名、密码、服务器地址等信息,完成后点击 Test Configuration,如果配置正确,会显示 Success,否则会出现报错信息。


    这里有两种方式连接远程服务器,第一种是密码方式,输入服务器账户密码等信息即可;


    第二种是秘钥方式,在服务器生成密钥文件,并且将私钥全部拷贝,记住是全部,要携带起止标志-----BEGIN RSA PRIVATE KEY-----或-----END RSA PRIVATE KEY----,粘贴在 高级 -> key 即可。


    此处的 Remote Directory 是远程服务器接收 Jenkins 打包产物的目录,必须在对应的服务器手动创建目录,如 /home/jenkins


  3. 项目配置


    选择需要上传的服务器,接着设置需要传输的文件,执行脚本,移动文件到对应的目录。


    jenkins-configure-ssh.png



Transfer Set 参数配置


  • Source files:需要传输的文件,也就是通过上一步 Build Steps 后生成的压缩文件,这个路径是相对于“工作空间”的路径,即只需要输入 dist/*.tar.gz 即可

  • Remove prefix:删除传输文件指定的前缀,如 Source files 设置为dist/*.tar.gz ,此时设置 Remove prefix/dist,移除前缀,只传输 *.tar.gz 文件;如果不设置酒会传输 dist/*.tar.gz 包含了 dist 整个目录,并且会自动在上传后的服务器中创建 /dist 这个路径。如果只需要传输压缩包,则移除前缀即可

  • Remote directory:文件传输到远程服务器上的具体目录,会与 Publish over SSH 插件系统配置中的 Remote directory 进行拼接,如我们之前设置的目录是 /home/jenkins,此处在写入 qmp_pc_ddm,那么最终上传的路径为 /home/jenkins/qmp_pc_ddm,与之前不同的是,如果此路径不存在时会自动创建,这样设置后,Jenkins 服务器构建后的产物会通过 ssh 上传到此目录,供下一步使用。

  • Exec command


    文件传输完成后执行自定义 Shell 脚本,比如移动文件到指定目录、解压文件、启动服务等。


    #!/bin/bash

    #进入远程服务器的目录
    project_dir=/usr/local/nginx/qmp_pc_ddm/${DEPLOYPATH}
    cd $project_dir

    #移动压缩包
    sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .

    #找到新的压缩包
    new_dist=`ls -ltr *.tar.gz | awk '{print $NF}' |tail -1`
    echo $new_dist

    #解压缩
    sudo tar -zxvf $new_dist

    #删除压缩包
    sudo rm *.tar.gz

    这一步可以使用之前定义的参数,如 ${DEPLOYPATH},以及 Jenkins 提供的变量:如 ${WORKSPACE} 来引用 Jenkins 的工作空间路径等。



Build other projects


添加 Build other projects,在项目构建成功后,触发相关联的应用开始打包。


jenkins-configure-other.png
另外还可以配置企业微信通知、生成构建报告等工作。


此时,所有的配置都设置完成,我们点击保存配置,返回到构建页。


构建


jenkins-start-build.png


点击 Build with parameters 选择对应的分支和部署环境,点击开始构建


在控制台输出中,可以看到打包的详细过程,


可以看到我们在Build Steps中执行的 Shell 脚本的输出如下:


jenkins-result-build.png


以及我们通过 Publish Over SSH 插件将构建产物传输的指定服务器的输出:


jenkins-result-ssh.png


最终需要部署的服务器就有了以下文件:


jenkins-remote-directory.png


Pipeline


对于简单的构建需求或新手用户来说,我们可以直接选择 FreeStyle project。而对于复杂的构建流程或需要更高灵活性和扩展性的场景来说,Pipeline 则更具优势。


通过 新建任务 -> 流水线 创建一个流水线项目。


jenkins-pipeline-white.png


开始配置前请先阅读下流水线章节。


生成方式


首先,Jenkins 流水线是一套插件,在最开始的插件推荐安装时会自动安装,如果选择自定义安装时,需要手动安装这一套插件。


Jenkins 流水线的定义有两种方式:Pipeline scriptPipeline script from SCM


jenkins-pipeline-type.png


Pipeline script


Pipeline script 是直接在 Jenkins 页面的配置中写脚本,可直接定义和执行,比较直观。


jenkins-pipeline-page.png


Pipeline script from SCM


Pipeline script from SCM 是将脚本文件和项目代码放在一起,即 Jenkinsfile,也可自定义名称。


jenkins-pipeline-code.png


当 Jenkins 执行构建任务时,会从 git 中拉取该仓库到本地,然后读取 Jenkinsfile 的内容执行相应步骤,通常认为在 Jenkinsfile 中定义并检查源代码控制是最佳实践


当选择 Pipeline script from SCM 后,需要设置 SCM 为 git,告诉 Jenkins 从指定的 Git 仓库中拉取包含 Pipeline 脚本的文件。


jenkins-pipeline-code-scm.png


如果没有对应的文件时,任务会失败并发出报错信息。


jenkins-pipeline-code-error.png


重要概念


了解完上面的基础配置,我们先找一段示例代码,粘贴在项目的配置中:


pipeline {
agent any
stages {
stage('Build') {
steps {
echo 'Build'
}
}
stage('Test') {
steps {
echo 'Test'
}
}
stage('Deploy') {
steps {
echo 'Deploy'
}
}
}
}

看下它的输出结果:


jenkins-pipeline-result.png


接着看一下上面语法中几个重要的概念。


流水线 pipline


定义了整个项目的构建过程, 包括:构建、测试和交付应用程序的阶段。


流水线顶层必须是一个 block,pipeline{},作为整个流水线的根节点,如下:


pipeline {
/* insert Declarative Pipeline here */
}

节点 agent


agent 用来指定在哪个代理节点上执行构建,即执行流水线,可以设置为 any,表示 Jenkins 可以在任何可用的代理节点上执行构建任务。


但一般在实际项目中,为了满足更复杂的构建需求,提高构建效率和资源利用率,以及确保构建环境的一致性,会根据项目的具体需求和资源情况,设置不同的代理节点来执行流水线。


如:


pipeline {
agent {
node {
label 'slave_2_34'
}
}
...
}

可以通过 系统管理 -> 节点列表 增加节点,可以看到默认有一个 master 节点,主要负责协调和管理整个 Jenkins 系统的运行,包括任务的调度、代理节点的管理、插件的安装和配置等。


jenkins-agent-master.png


阶段 stage


定义流水线的执行过程,如:Build、Test 和 Deploy,可以在可视化的查看目前的状态/进展。


注意:参数可以传入任何内容。不一定非得 BuildTest,也可以传入 打包测试,与红框内的几个阶段名对应。


jenkins-pipeline-console.png


步骤 steps


执行某阶段具体的步骤。


语法


了解上述概念后,我们仅仅只能看懂一个 Pipeline script 脚本,但距离真正的动手写还有点距离,此时就需要来了解下流水线语法


我将上面通过 Freestyle project 的脚本翻译成 Pipeline script 的语法:


pipeline {
agent any
triggers {
gitlab(triggerOnPush: true, triggerOnMergeRequest: true, branchFilterType: 'All')
}
parameters {
gitParameter branchFilter: 'origin/(.*)', defaultValue: 'master', name: 'delopyTag', type: 'PT_BRANCH'
}
stages {
stage('拉取代码') {
steps {
git branch: "${params.delopyTag}", credentialsId: 'xxx', url: 'https://xxx/fe/qmp_doc_hy.git'
}
}
stage('安装依赖') {
steps {
nodejs('node-v16.20.2') {
sh '''
#!/bin/bash
source /etc/profile
echo "下载安装包"
yarn config set registry https://registry.npmmirror.com
yarn
'''
}
sleep 5
}
}
stage('编译') {
steps {
sh '''
#!/bin/bash
source /etc/profile
yarn run build
sleep 5
if [ -d dist ];then
cd dist
rm -rf *.tar.gz

tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
fi
'''
sleep 5
}
}
stage('解压') {
steps {
echo '解压'
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'server(101.201.181.27)',,
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '''#!/bin/bash
#进入远程服务器的目录
project_dir=/usr/local/nginx/qmp_pc_ddm_${DEPLOYPATH}/${DEPLOYPATH}
if [ ${DEPLOYPATH} == "ddm" ]; then
project_dir=/usr/local/nginx/qmp_pc_ddm/dist
fi
cd $project_dir

sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .

#找到新的压缩包
new_dist=`ls -ltr *.tar.gz | awk \'{print $NF}\' |tail -1`

#解压缩
sudo tar -zxvf $new_dist

#删除压缩包
sudo rm *.tar.gz

#发布完成
echo "环境发布完成"
''',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: 'qmp_pc_ddm',
remoteDirectorySDF: false,
removePrefix: 'dist/',
sourceFiles: 'dist/*.tar.gz'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: false
)
]
)
}
}
}
post {
success {
echo 'success.'
deleteDir()
}
}
}

接下来,我们一起来解读下这个文件。


首先,所有的指令都是包裹在 pipeline{} 块中,


agent


enkins 可以在任何可用的代理节点上执行构建任务。


environment


用于定义环境变量,它们会保存为 Groovy 变量和 Shell 环境变量:定义流水线中的所有步骤可用的环境变量 temPath,在后续可通过 $tmpPath 来使用;


环境变量可以在全局定义,也可在 stage 里单独定义,全局定义的在整个生命周期里可以使用,在 stage 里定义的环境变量只能在当前步骤使用。


Jenkins 有一些内置变量也可以通过 env 获取(env 也可以读取用户自己定义的环境变量)。


steps {
echo "Running ${env.BUILD_ID} on ${env.JENKINS_URL}"
}

这些变量都是 String 类型,常见的内置变量有:



  • BUILD_NUMBER:Jenkins 构建序号;

  • BUILD_TAG:比如 jenkins-JOBNAME{JOB_NAME}-{BUILD_NUMBER};

  • BUILD_URL:Jenkins 某次构建的链接;

  • NODE_NAME:当前构建使用的机器


parameters


定义流水线中可以接收的参数,如上面脚本中的 gitParameter,只有安装了 Git Parameters 插件后才能使用,name 设置为delopyTag,在后续可通过 ${params.delopyTag} 来使用;


还有以下参数类型可供添加:


parameters {
booleanParam(name: 'isOSS', defaultValue: true, description: '是否上传OSS')
choice(name: 'select', choices: ['A', 'B', 'C'], description: '选择')
string(name: 'temp', defaultValue: '/temp', description: '默认路径')
text(name: 'showText', defaultValue: 'Hello\nWorld', description: '')
password(name: 'Password', defaultValue: '123', description: '')
}

triggers


定义了流水线被重新触发的自动化方法,上面的配置是:当 Git 仓库有新的 push 操作时触发构建


stages 阶段



  • 阶段一:拉取代码


    git:拉取代码,参数 branch 为分支名,我们使用上面定义的 ${params.delopyTag}credentialsId 以及 url,如果不知道怎么填,可以在 流水线语法 -> 片段生成器 中填写对应信息后,自动生成,如下:


    jenkins-stage-git.png


    再复制到此处即可。


  • 阶段二:安装依赖


    steps 中,sh 是 Jenkins pipeline 的语法,通过它来执行 shell 脚本。


    #!/bin/bash表示使用 bash 脚本;
    source /etc/profile 用于将指定文件中的环境变量和函数导入当前 shell。


    执行 yarn 安装依赖。


  • 阶段三:编译


    执行 yarn build 打包,


    if [ -d dist ]; 是 shell 脚本中的语法,用于测试 dist 目录是否存在,通过脚本将打包产物打成一个压缩包。


  • 阶段四:解压


    将上步骤生成的压缩包,通过 Publish over SSH 发送到指定服务器的指定位置,执行 Shell 命令解压。


    不会写 Publish over SSH 怎么办?同样,可以在 流水线语法 -> 片段生成器 中填写对应信息后,自动生成,如下:


    jenkins-generate-publish.png



post


当流水线的完成状态为 success,输出 success。


deleteDir() 函数用于删除当前工作目录中的所有文件和子目录。这通常用于清理工作区,确保在下一次构建之前工作区是干净的,以避免由于残留文件或目录引起的潜在问题。


构建看看效果


可以直接通过 Console Output 查看控制台输出,当然在流水线项目中自然要通过流水线去查看了。


jenkins-pipeline-result-in.png



  1. 效果一


    jenkins-pipeline-result1.png


    Pipeline Overview 中记录了每个步骤的执行情况、开始时间和耗时等信息,但是没有详细信息,详细信息就要在 Pipeline Console 中进行查看。


  2. 效果二


    安装插件 Blue Ocean,相当于同时结合了 Pipeline Overview 和 Pipeline Console,可以同时看到每个步骤的执行情况等基本信息,以及构建过程中的详细信息。


    jenkins-pipeline-result2.png


    通过 Blue Ocean 也可以直接创建流水线,选择代码仓库,然后填写对应的字段,即可快速创建流水线项目,如创建 gitlab 仓库:


    jenkins-blue-create.png


    或者直接连接 github 仓库,需要 token,直接点击红框去创建即可:


    jenkins-blue-create1.png



通过项目中的 Jenkinsfile 构建


再把对应的 Pipeline script 代码复制到对应代码仓库的 Jenkinsfile 文件,设置为 Pipeline script from SCM,填写 git 信息。


jenkins-pipeline-config-scm.png


正常情况下,Jenkins 会自动检测代码仓库的 Jenkinsfile 文件,如果选择的文件没有 Jenkinsfile 文件时就会报错,如下:


jenkins-pipeline-scm-error.png


正常按照流水线的执行流程,打开 Blue Ocean,查看构建结果,如下:


jenkins-pipeline-scm-result.png


片段生成器


如果你觉得上述代码手写麻烦,刚开始时又不会写,那么就可以使用片段代码生成器来帮助我们生成流水线语法。


进入任务构建页面,点击 流水线语法 进入:


配置构建过程遇到的问题



  1. Jenkins 工作空间权限问题


    jenkins-pipeline-error.png


    修复:


    chown -R jenkins:jenkins /var/lib/jenkins/workspace


  2. Git Parameters 不显示问题


    当配置完 Git Parameters 第一次点击构建时,会报如下错误,找了很久也没有找到解决方法,于是就先使用 master 分支构建了一次,构建完成之后再次点击构建这里就正常显示了,猜测是没构建前没有 git 仓库的信息,构建完一次后就有了构建信息,于是就正常显示了。


    jenkins-pipeline-error1.png



总结


本文对 Jenkins 的基本教程就到此为止了,主要讲了 Jenkins 的安装部署,FreeStyle project 和 Pipeline 的使用,以及插件安装、配置等。如果想要学,跟着我这个教程实操一遍,Jenkins 就基本掌握了,基本工作中遇到的问题都能解决,剩下的就只能在实际工作中慢慢摸索了。


再说回最初的话题,前端需不需要学习 Jenkins。我认为接触新的东西,然后学习并掌握,拓宽了技术面,虽然是一种压力,也是得到了成长的机会,在这个前端技术日新月异的时代,前端们不仅要熟练掌握前端技术,还需要具备一定的后端知识和自动化构建能力,才能不那么容易被大环境淘汰。


以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏 🙏,如果发现有什么错误或者更好的解决方案及建议,欢迎随时联系。


作者:翔子丶
来源:juejin.cn/post/7349561234931515433
收起阅读 »

三十而立却未立,缺少的是女朋友还是技术能力?

作为一个从事 Web 工作 8 年来的相关人员的一点心路历程,希望我的经历能给大家带来稍许乐趣。 迷茫,特别迷茫 俗话说得好:“岂能尽如人意,但求无愧于心”,工作 8 年来,我经常这样自我安慰。不过这并不影响我也经常感觉无所适从,烦闷与迷茫。尤其是到了一些特殊...
继续阅读 »

作为一个从事 Web 工作 8 年来的相关人员的一点心路历程,希望我的经历能给大家带来稍许乐趣。


迷茫,特别迷茫


俗话说得好:“岂能尽如人意,但求无愧于心”,工作 8 年来,我经常这样自我安慰。不过这并不影响我也经常感觉无所适从,烦闷与迷茫。尤其是到了一些特殊的年月节点,这种焦虑感总是更加强烈。


那到底有什么迷茫的呢?一言以蔽之,有了对比,就有了伤害。正如标题所言,女朋友和技术能力,换一个通俗的话,也可以叫“美女与金钱”,当然更常规的说法,是“家庭与事业”。


如果简单横向对比起来,我迷茫确实看起来不意外:



  • 我好歹也是正儿八经 985 大学软件工程方向本科毕业,也算是科班出身;

  • 工作了 8 年,不仅是被同学、绝大部分同行从业人员从薪资水平、发展前景、人际交往、生活质量等各方向甩在身后,甚至都比不上复读一年考上不知名二本学校、去年才毕业的表弟;

  • 没房没车,没有成婚,还背井离乡,漂泊千里之外;

  • 日子看起来浑浑噩噩,没有什么远大志向,也没什么乐衷的兴趣……


怎么就变成这样了呢,我觉得我有老老实实、脚踏实地地做事情啊。回想自己从业这些年:



  • 从一开始的 JSP + Spring MVC + MySQL 这套原始的 Java Web 开发;

  • 到当时外面还比较时髦的 MEAN(MongoDB、Express.js、Angular 和 Node.js);

  • 后来回归到 Angular + Spring 这套,然后改为现在常用的 Vue + Spring,其中还一度以为 WebFlux 会有大用;

  • 当然前几年除了做些全栈开发,还不得不兼备 K8s 相关一大套的运维技能;

  • TiDB、Redis、ES、Prometheus 什么的都要搞一搞,Flink 什么的也得弄一弄,加上一大堆第三方自动化、监控等工具的使用配置;

  • 现在没事时用 Python 写个脚本处理一些批量任务,自己搞搞 Flutter 练手自己用的 APP。


我都觉得自己还是挺厉害的,因为这些就没一个是学校里教的东西,都是出来挨打自学的。


但实际上的现状呢,我还是呆在一个电子厂里面,拿着千把块,做着鸡毛蒜皮的事情,下班就回到公司的宿舍,龟缩起来。这样 855 毫无意义的日子,居然一呆就是 8 年了。


“可怜之人必有可恨之处?”


那我当然是自以为是的可怜了,毕竟如果真得像我说的那样出色,是金子自然会发光了,也怎么可能愿意继续呆在这种地方,离最近的地铁站、火车站都要30多分钟公交的制造业工厂里面?


确实,扯开嘴巴滋哇乱叫谁不会,有什么因就有什么果了。



  • 大四的时候,跨专业自学准备心理学方向的考研,错过了秋招;没考上之后,当时的技术能力,已经不支撑找个满意的工作了。

  • 做中学,两年后的 18 年正是行业发展高潮,准备出去看看。结果年轻,血气方刚,在领导的 PUA 和自以为是没能干出一点功绩就离开,不满意,然后留下来。

  • 又之后的一年之余,已经发现技术水平和人生阅历和同行差距过大,还是骑驴找马。在得到几个 offer 之后,却不知原因突然想回老家城市,这些深圳广州的机会就莫名其妙放弃了,重庆的眼高手低又没找到满意的。

  • 之后疫情时代,在一些大城市比如 SH、SZ 等出现强烈的排外现象之后,越发想要回家。但重庆的互联网行业,和主流城市差距可太大了。当时当地政府甚至在大力发展实体制造业,老家区县招商建工厂,租 100 亩送 100 亩。

  • 疫情尾期和这两年,什么“前端已死”、行业落寞,找工作难度陡升,试想,什么样的公司会找一个 8 年工作经验的初中级前端?全栈?运维?……


去年我找工作从 5 月份找到 10 月份,沟通了 200 多个岗位,只有 20 多个接收了简历,约到 3 个网上面试,最后一个没过。除了一些需要线下面试的没法去,也有面试的匹配度也不够、岁数不够年轻等其他因素。8 年来最多就管理过不到 10 人的小团队,当然不到一年就结束了,也没有能力发展管理岗。


与自己和解是不是自欺欺人?


会不会有种“咎由自取”的感觉,我偶尔也会想:



  • 如果 18 年我去了深圳而不是听信领导的话留在了东莞这里,我的发展轨迹会不会有所改善?

  • 更有甚,如果大学不是脑袋一热为了自救去考什么心理学专业的研究生,好好学习技能找工作或者考本校,会不会又是另一番风景?

  • 甚至更早,如果当年高考没有发挥失常,或者要是考得更差一点,去个师范,实现我儿时的理想,成为一名教师,情感上是不是更能自洽?


有句网络流行语是这样说的:有人看段子,有人照镜子。曾几何时,我也这样觉得:



  • 反正现在没车没房没女友,离家又远没外债;

  • 物质能力虽不高,但消费欲望不强;

  • 不能为国家做大贡献,但也还没有给社会添乱;

  • 下班回宿舍看看视频、打打游戏、玩玩手机,偶尔出去打打球,散散步……


没有复杂的人际关系,没有太大的家庭工作压力,清闲时间也比较充足,简简单单三餐一宿,我明明很惬意的,也明明已经惬意了 8 年来。


——“你一个月多少工资?” 、“怎么才这点?”

——“你现在什么级别?” 、“怎么才这个级别?”

——“你开什么车?” 、“什么?你连驾-照都没有?”

——“你孩子几岁了?” 、“啊,你还单身?”

——“天啦,你怎么混成这样了?”
……


“人的悲喜并不相通,我只觉得他们吵闹”。“墨镜一带,谁都不爱”,我脑袋摇成螺旋桨,我飞走咯,千里之外~


未立,缺少的是女朋友?


我的看法认为:可能不是。


没有什么是一成不变的,比如年龄。我这个年纪可能不仅和更年轻的同行抢岗位抢不过,也可能在另一个相亲市场也抢不过。


虽然嘴巴上可能有的人觉得单身好,而且现在这个男女关系和社会认同比较复杂的时代。前段时候和老同学聊天聊到近况,他们都一直以为我是一个不婚主义者。当然,这并不影响我们老一辈甚至再老一辈亲戚的期盼,他们偶尔也会认为,结婚之后,一个人才成长了,他们才会放心。


你别说,你还真别说。这半年我没有写博客,也没有太多了解“行业寒冬”的发展情况,有一部分原因还真是因为年初聊见了个相亲对象。这对我是一个完全没有经历过的赛道,难得的是我感觉还不差,虽然发展极为缓慢,但还没有遇到网上那样的“悲惨经历”,当然,也可能是异地的原因。


我要经历这种事,只能是亲戚朋友帮忙,加上微信之后聊了聊,整体氛围很好,就这么聊了一个多月。本来过年的时候约个见面的,但没想到升级了,直接他们父母到我家来坐了坐,然后又邀请我父母去她家吃了饭。这在农村的意思就是老一辈的过场已经走完了,双方家长没有意见,我们能不能成、就全看自己了。


这半年虽然几乎天天都有聊,绝大多数情况下都很愉快,我也变得有些期待每次的聊天;平时也有礼尚往来,偶尔互有一些小惊喜小礼物;五一节我也回去见了面,牵牵小手,后来得知当天她出门之后才发现来例假、身体不适但还是陪我走了将近三万步的路、甚至没让我发现异样……


但问题的关键在于,似乎都没有聊到什么重点和关键的问题,没有实际的发展,感觉温度没有理想上升。仔细想想,把这每天和她相关的一两个小时删除掉,那和我这些年的日子几乎没什么区别,好像一样是挺自在惬意的,她甚至都没有给我一些需要我去翻视频学点“人情世故”才能处理的问题和情景。


本来以为是好事,但我的榆木脑袋才终于不得不承认异地一定是个大问题。所以到现在,我这股子想回家的心情就变成了内因和外因相结合的无懈可击的推力。但是却还没有热切到一拍脑袋裸辞先回家,再看天的程度。


未立,缺少的是技术能力?


我的看法认为:可能也不是。


虽然我个人学的东西有一点点乱,但怎么说呢,并不影响我自娱自乐。偶尔开发一个自用的小玩意儿,还盲目觉得挺有成就感。


而且,从实际情况来讲,现在的“技术能力”真的不是那么的重要,如果是做产品,可能一些经验能力也不可或缺,但会写代码的人,可是一抓一大把。


比如说,现在的 AI 大模型几乎是热到爆的话题,也算是百花齐放,也各自杀红了眼,现在的新东西,不说自己有个 AI,都不好意思大声讲话,新出的 PC 都挂上 AI PC,魅族都不做手机,改名为 AI 终端了。


作为普通用户和普通个人开发者角度来讲,现在使用这些大模型 API 其实非常便宜了。价格战百万 token 才几十块甚至几块钱,文本对话、文生图、图生文,也都有一定的可用性了。


但是呢,但是呢,能拿来做什么呢?有创造性的同行都已经借着东风,扶摇直上九万里了,我还在感慨好便宜啊,除了BAT平台,这两天还去零一万物、深度求索等平台注册了账号,部分也少少充值了些。但是,虽然好便宜啊,可是能用来做点什么呢?我还真的没有创造性。




既然都说到这里,也厚脸皮顺便说一句,五月底主流厂商大模型在线服务大幅度降价时,还有一些主流厂商推出永久免费的版本。我就简单拿 BAT 的免费版本来试了一下,顺带加上之前的极简记账、随机菜品功能,使用Flutter开发,想做个了简单自用的生活工具助手类的 APP,放在 github 了: ai-light-life(智能轻生活) ,虽然很简陋也不完善,但感兴趣的朋友可以看看。


ai-light-life截图.jpg


当然也希望可以到 我 Github 仓库 看看一些其他可能有点意思的东西,比如运动健身相关、听歌休闲娱乐、Web 基础知识什么的。万一能帮到大家了,也不忘点 Star 支持下,谢谢。


生活不需要别人来定义


可能“三十而立”意思是指人在三十岁前后有所成就。少年老成的例子很多,大器晚成的人物也不少,但到最后,这都是别人来定义的这个“立”的含义。


就如见世面,有的人是“周游列国、追求自由”,有的人是“四体勤、五谷分”,有的人的成就是“成家立业,香车美女环绕”,有的人是“著作等身”,也有的人却是成为“艾尔登之王”……外面的人看到的或许不同,但那份自己内心的快乐,是为了、也是应该能够取悦自己的。


今天是我三十岁生日,大概500天前我列了三十岁前想要完成的 10 件小事,结果当然只完成了小部分:



  • 体重减到正常 BMI 值;

  • 开发一个能自用的 APP/入门一门外语;

  • LOL 上个白金/LOLM 上个宗师;

  • 谈一次恋爱;

  • 出去旅游一次;

  • 换一份工作,换一个城市;

  • 补上自己的网站博客,整理自己的硬盘;

  • 看 10 本名著,并写下每本不多于 5000 字的读后感;

  • 完成一部中篇小说;

  • 完成 50 篇用心写的博文,可包含那 10 篇读后感。


人生是一条连续的时间线,除了起止点,中间这段旅程,并不会因为某一刻的变化而停下来,最多是慢下来;三十岁之前没有完成的事情,三十岁之后依旧可以去做;以前看得太重的东西,以后还可以改变很多;珍惜的事情太多,抱怨的时间太少;人生这段路,就这么些年,就该为自己走走看;路虽然走得不同,但走路的心情,却可以自己来定。


取悦自己真的比迎合他人要轻松和快乐许多。


共勉吧诸君,感谢垂阅。


作者:小流苏生
来源:juejin.cn/post/7385474787698065417
收起阅读 »

为什么都放弃了LangChain?

或许从诞生那天起,LangChain 就注定是一个口碑两极分化的产品。 看好 LangChain 的人欣赏它丰富的工具和组建和易于集成等特点,不看好 LangChain 的人,认为它注定失败 —— 在这个技术变化如此之快的年代,用 LangChain 来构建一...
继续阅读 »

或许从诞生那天起,LangChain 就注定是一个口碑两极分化的产品。


看好 LangChain 的人欣赏它丰富的工具和组建和易于集成等特点,不看好 LangChain 的人,认为它注定失败 —— 在这个技术变化如此之快的年代,用 LangChain 来构建一切根本行不通。


夸张点的还有:


「在我的咨询工作中,我花了 70% 的精力来说服人们不要使用 langchain 或 llamaindex。这解决了他们 90% 的问题。」


最近,一篇 LangChain 吐槽文再次成为热议焦点:


图片


作者 Fabian Both 是 AI 测试工具 Octomind 的深度学习工程师。Octomind 团队会使用具有多个 LLM 的 AI Agent 来自动创建和修复 Playwright 中的端到端测试。


图片


这是一个持续一年多的故事,从选择 LangChain 开始,随后进入到了与 LangChain 顽强斗争的阶段。在 2024 年,他们终于决定告别 LangChain。


让我们看看他们经历了什么:


「LangChain 曾是最佳选择」


我们在生产中使用 LangChain 超过 12 个月,从 2023 年初开始使用,然后在 2024 年将其移除。


在 2023 年,LangChain 似乎是我们的最佳选择。它拥有一系列令人印象深刻的组件和工具,而且人气飙升。LangChain 承诺「让开发人员一个下午就能从一个想法变成可运行的代码」,但随着我们的需求变得越来越复杂,问题也开始浮出水面。


LangChain 变成了阻力的根源,而不是生产力的根源。


随着 LangChain 的不灵活性开始显现,我们开始深入研究 LangChain 的内部结构,以改进系统的底层行为。但是,由于 LangChain 故意将许多细节做得很抽象,我们无法轻松编写所需的底层代码。


众所周知,人工智能和 LLM 是瞬息万变的领域,每周都会有新的概念和想法出现。而 LangChain 这样围绕多种新兴技术创建的抽象概念,其框架设计很难经得起时间考验。


LangChain 为什么如此抽象


起初,当我们的简单需求与 LangChain 的使用假设相吻合时,LangChain 还能帮上忙。但它的高级抽象很快就让我们的代码变得更加难以理解,维护过程也令人沮丧。当团队用在理解和调试 LangChain 的时间和用在构建功能上的时间一样时,这可不是一个好兆头。


LangChain 的抽象方法所存在的问题,可以通过「将一个英语单词翻译成意大利语」这一微不足道的示例来说明。


下面是一个仅使用 OpenAI 软件包的 Python 示例:


图片


这是一段简单易懂的代码,只包含一个类和一个函数调用。其余部分都是标准的 Python 代码。


将其与 LangChain 的版本进行对比:


图片


代码大致相同,但相似之处仅此而已。


我们现在有三个类和四个函数调用。但令人担忧的是,LangChain 引入了三个新的抽象概念:



  • Prompt 模板: 为 LLM 提供 Prompt;

  • 输出解析器: 处理来自 LLM 的输出;

  • 链: LangChain 的「LCEL 语法」覆盖 Python 的 | 操作符。


LangChain 所做的只是增加了代码的复杂性,却没有带来任何明显的好处。


这种代码对于早期原型来说可能没什么问题。但对于生产使用,每个组件都必须得到合理的理解,这样在实际使用条件下才不至于意外崩溃。你必须遵守给定的数据结构,并围绕这些抽象设计应用程序。


让我们看看 Python 中的另一个抽象比较,这次是从 API 中获取 JSON。


使用内置的 http 包:


图片


使用 requests 包:


图片


高下显而易见。这就是好的抽象的感觉。


当然,这些都是微不足道的例子。但我想说的是,好的抽象可以简化代码,减少理解代码所需的认知负荷。


LangChain 试图通过隐藏细节,用更少的代码完成更多的工作,让你的生活变得更轻松。但是,如果这是以牺牲简单性和灵活性为代价的,那么抽象就失去了价值。


LangChain 还习惯于在其他抽象之上使用抽象,因此你往往不得不从嵌套抽象的角度来思考如何正确使用 API。这不可避免地会导致理解庞大的堆栈跟踪和调试你没有编写的内部框架代码,而不是实现新功能。


LangChain 对开发团队的影响


一般来说,应用程序大量使用 AI Agent 来执行不同类型的任务,如发现测试用例、生成 Playwright 测试和自动修复。


当我们想从单一 Sequential Agent 的架构转向更复杂的架构时,LangChain 成为了限制因素。例如,生成 Sub-Agent 并让它们与原始 Agent 互动。或者多个专业 Agent 相互交互。


在另一个例子中,我们需要根据业务逻辑和 LLM 的输出,动态改变 Agent 可以访问的工具的可用性。但是 LangChain 并没有提供从外部观察 Agent 状态的方法,这导致我们不得不缩小实现范围,以适应 LangChain Agent 的有限功能。



一旦我们删除了它,我们就不再需要将我们的需求转化为适合 LangChain 的解决方案。我们只需编写代码即可。



那么,如果不使用 LangChain,你应该使用什么框架呢?也许你根本不需要框架。


**我们真的需要构建人工智能应用程序的框架吗?

**


LangChain 在早期为我们提供了 LLM 功能,让我们可以专注于构建应用程序。但事后看来,如果没有框架,我们的长期发展会更好。


LangChain 一长串的组件给人的印象是,构建一个由 LLM 驱动的应用程序非常复杂。但大多数应用程序所需的核心组件通常如下:



  • 用于 LLM 通信的客户端

  • 用于函数调用的函数 / 工具 

  • 用于 RAG 的向量数据库

  • 用于跟踪、评估等的可观察性平台。


Agent 领域正在快速发展,带来了令人兴奋的可能性和有趣的用例,但我们建议 —— 在 Agent 的使用模式得到巩固之前,暂时保持简单。人工智能领域的许多开发工作都是由实验和原型设计驱动的。


以上是 Fabian Both 一年多来的切身体会,但 LangChain 并非全然没有可取之处。


另一位开发者 Tim Valishev 表示,他会再坚持使用 LangChain 一段时间:



我真的很喜欢 Langsmith:



  • 开箱即用的可视化日志 

  • Prompt playground,可以立即从日志中修复 Prompt,并查看它在相同输入下的表现 

  • 可直接从日志轻松构建测试数据集,并可选择一键运行 Prompt 中的简单测试集(或在代码中进行端到端测试) 

  • 测试分数历史 

  • Prompt 版本控制 



而且它对整个链的流式传输提供了很好的支持,手动实现这一点需要一些时间。


何况,只依靠 API 也是不行的,每家大模型厂商的 API 都不同,并不能「无缝切换」。


图片


图片


图片


你怎么看?


原文链接:http://www.octomind.dev/blog/why-we…


作者:机器之心
来源:juejin.cn/post/7383894854152437811
收起阅读 »

Nest:常用 15 个装饰器知多少?

web
nest 很多功能基于装饰器实现,我们有必要好好了解下有哪些装饰器:创建 nest 项目: nest new all-decorator -p npm @Module({}) 这是一个类装饰器,用于定义一个模块。模块是 Nest.js 中组织代码的单元,可以...
继续阅读 »

nest 很多功能基于装饰器实现,我们有必要好好了解下有哪些装饰器:
创建 nest 项目:


nest new all-decorator -p npm

@Module({})


这是一个类装饰器,用于定义一个模块。
模块是 Nest.js 中组织代码的单元,可以包含控制器、提供者等:
image.png


@Controller() 和 @Injectable()


这两个装饰器也是类装饰器,前者控制器负责处理传入的请求和返回响应,后者定义一个服务提供者,可以被注入到控制器或其他服务中。
通过 @Controller@Injectable 分别声明 controller 和 provider:
image.png


@Optional、@Inject


创建可选对象(无依赖注入),可以用 @Optional 声明一下,这样没有对应的 provider 也能正常创建这个对象。
image.png
注入依赖也可以用 @Inject 装饰器。


@Catch


filter 是处理抛出的未捕获异常,通过 @Catch 来指定处理的异常:
image.png


@UseXxx、@Query、@Param


使用 @UseFilters 应用 filter 到 handler 上:
image.png
image.png
除了 filter 之外,interceptor、guard、pipe 也是这样用:
image.png


@Body


如果是 post、put、patch** **请求,可以通过 @Body 取到 body 部分:
image.png
我们一般用 dto 定义的 class 来接收验证请求体里的参数。


@Put、@Delete、@Patch、@Options、@Head


@Put、@Delete、@Patch、@Options、@Head 装饰器分别接受 put、delete、patch、options、head 请求:
image.png


@SetMetadata


通过 @SetMetadata 指定 metadata,作用于 handler 或 class
image.png
然后在 guard 或者 interceptor 里取出来:
image.png


@Headers


可以通过 @Headers 装饰器取某个请求头或者全部请求头:
image.png


@Ip


通过 @Ip 拿到请求的 ip,通过 @Session 拿到 session 对象:
image.png


@HostParam


@HostParam 用于取域名部分的参数。
下面 host 需要满足 xxx.0.0.1 到这个 controller,host 里的参数就可以通过 @HostParam 取出来:
image.png


@Req、@Request、@Res、@Response


前面取的这些都是 request 里的属性,当然也可以直接注入 request 对象:
image.png
@Req 或者 @Request 装饰器,这俩是同一个东西。


使用 @Res 或 @Response 注入 response 对象,但是注入 response 对象之后,服务器会一直没有响应。
因为这时候 Nest 就不会把 handler 返回值作为响应内容了。我们可以自己返回响应:
image.png
Nest 这么设计是为了避免相互冲突。
如果你不会自己返回响应,可以设置 passthrough 为 true 告诉 Nest:
image.png


@Next


除了注入 @Res 不会返回响应外,注入 @Next 也不会。
当你有两个 handler 来处理同一个路由的时候,可以在第一个 handler 里注入 next,调用它来把请求转发到第二个 handler。
image.png


@HttpCode


handler 默认返回的是 200 的状态码,你可以通过 @HttpCode 修改它:
image.png


@Header


当然,你也可以修改 response header,通过 @Header 装饰器:
image.png


作者:云牧
来源:juejin.cn/post/7340554546253611023
收起阅读 »

零 rust 基础前端使直接上手 tauri 开发一个小工具

起因 有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。 在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动...
继续阅读 »

起因


有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。


在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动考试的插件,原本需要十来天的学习时间,分分钟就解决了。


有兴趣的可以看一下,好医生自动学习+考试插件源码


正因为这次的经历,我直接接下了这个需求,毕竟可以在家人面前利用自己的能力去帮他们解决问题,是一件非常骄傲的事。


事情并没有那么简单


我回家一看,他们的学习平台是个桌面端的软件(毕竟是银行的平台,做的比那个好医生严谨的多),内嵌的浏览器,无法打开控制台,更没办法装插件,甚至视频学习调了什么接口,有什么漏洞都无法发现,我感觉有点无能为力。


但是牛逼吹出去了,也得想办法做。


技术选型


既然没办法找系统漏洞去快速学习,那只能按部就班的去听课了,我第一想到的方式是用按键精灵写个脚本,去自动点击就可以了。但是我爸又想给他的同事用,再教他们用按键精灵还是有点上手成本的,所以我打算自己开发一个小工具去实现。


由于我是个前端开发者,做桌面端首先想到的是 Electron,因为我有一些开发经验,所以并不难,但打包后的体积太大,本来一个小工具,做这么大,这不是显得我技术太烂嘛。


所以我选择了 tauri 去开发。


需求分析


首先我想到的方式就是:



  1. 用鼠标框选一个区域,然后记录这个区域的颜色信息,记录区域坐标。

  2. 不断循环识别这个区域,匹配颜色。

  3. 如果匹配到颜色,则点击这个区域。


例如,本节课程学习后,会弹出提示框,进入下一节学习,那么可以识别这个按钮,如果屏幕出现这个按钮,则点击,从而实现自动学习的目的。


我还给它起了个很形象的名字,叫做打地鼠。


image.png


由于要点击的不一定只有一个下一节,可能还有其他章节的可能要学习,所以还实现了多任务执行,这样可以识别多个位置。


有兴趣可以看一下源码


零基础入门 rust


Tauri 已经提供了很多可以在前端调用的接口去实现很多桌面端的功能,但也不能完全能满足我本次开发的需求,所以还是要学习一点 rust 的语法。


这里简单说一下我学到的一些简单语法,方便大家快速入门。由于功能简单,我们并不需要了解 rust 那些高深的内容,了解基础语法即可,不然想学会 rust 我觉得真心很难。我们完全可以先入门,再深入。


适合人群


有一定其他编程语言(C/Java/Go/Python/JavaScript/Typescript/Dart等)基础。你至少得会写点代码是吧。


环境安装


推荐使用 rustup 安装 rust,rustup 是官方提供的的安装工具。


curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装后,检查版本,这类似安装 node 后查看版本去验证是否成功安装。


rustc --version

>>> rustc 1.73.0 (cc66ad468 2023-10-03)

cargo 是 rust 官方的包管理工具,类似 npm,这里也校验一下是否成功安装。


cargo --version


如果提示不存在指令,重新打开终端再尝试。



编辑器


官方推荐 Clion,是开发 rust 首选开发工具。


不过作为前端,我们依然希望可以使用 vscode 去开发,当然,这也是没有问题的。


vscode 需要搭配 rust-analyzer 一起使用。


除了上面提到的两个命令,还有 rustup 命令也可以直接使用了:


rustup component add rust-analyzer

执行后就都配置好了,可以进行语法的学习了。


变量与常量的声明


定义变量和常量的声明 javascript 和 rust 是一样的,都是通过 let 和 const,但是在定义变量时还是有一些区别的:



  • 默认情况下,变量是不可变的。(这点对于前端同学来说是不是很奇怪?)

  • 如果你想定义一个可变的变量,需要在变量名前面加上 mut


let x = 1;
x = 2; ❌

let mut x = 1;
x = 2; ✅

如果你不想用 mut,你也可以使用相同的名称声明新的变量:


let x = 1;
let x = x + 1;

Rust 里常量的命名规范是使用全大写字母,每个单词之间使用下划线分开,虽然 JS 没有强制的规范,但是我们也是这么做的。


数据类型


对于只了解 javascript 的同学,这个是非常重要的一环,因为 rust 需要在定义变量时做出类型的定义。即使是有过 typescript 开发经验的同学,这里也有着非常大的区别。这里只说一些与 js 区别较大的地方。


数字


首先 ts 对于数字的类型都是统一的 number,但是 rust 区别就比较大了,分为有符号整型,无符号整型,浮点型。


有 i8、i16、i32、i64、i128、u8、u16、u32、u64、isize、usize、f32、f64。


虽然上面看起来有这么多种类型去定义一个数字类型,实际上它们只是去定义了这个值所占用的空间,新手其实不用太过于纠结这里。如果你不知道应该选择哪种类型,直接使用默认的i32即可,速度也很快。有符号就是分正负(+,-),无符号只有正数。浮点型在现代计算机里上 f64 和 f32 运行速度差不多,f64 更加精确,所以不用太纠结。


数组


数组定义也有很大区别,你需要一开始就定义好数组的长度:


let a: [i32; 5] = [0; 5];

这表示定义一个包含 5 个元素的数组,所有元素都初始化为 0。一旦定义,数组的大小就不能改变了。


这是不是让前端同学很难理解,那么如何定义一个可变的数组呢?这好像更符合前端的思维。


在 Rust 中,Vec 是一个动态数组,也就是说,它可以在运行时增加或减少元素。


let v: Vec<i32> = Vec::new();
v.push(4);

这是不是更符合前端的直觉?毕竟后面我们要使用鼠标框选一个范围的颜色,这个颜色数组是不固定的,所以要用到 Vec


数据类型就说到这,其他的有兴趣自行了解即可。


引用包


rust 同 javascript 一样,也可以引入其他包,但语法上就不太一样了,例如:


use autopilot::{geometry::Point, screen, mouse};

强行翻译成 es module 引入:


import { Point, screen, mouse } from 'autopilot';

看到 :: 是不是有点懵逼,javascript 可没有这样的东西,你可以直觉的把它和 . 想象成一样就行。


:: 主要用于访问模块(module)或类型(type)的成员。例如,你可以使用 :: 来访问模块中的函数或常量,或者访问枚举的成员。


. 用于访问结构体(struct)、枚举(enum)或者 trait 对象的实例成员,包括字段(field)和方法(method)。


其他语法


循环:


for i in 0..colors.len() {}

条件判断:


if colors[i] != screen_colors[i] {

}

他们就是少了括号,还有一些高级的语法是 ES 没有的,这都很好理解。


那么我说这样就算入门了,不算过分吧?如果你要学一个语言,千万别因为它难而不敢上手,你直接上手去做,遇坑就填,你会进步很快。


如果你觉得这样很难写代码,那么我建议你买个 copilot 或者平替通义灵码,你上手写点小东西应该就不成问题了,毕竟我就这样就开始做了。


软件开发


Tauri 官网翻译还不全,读起来可能有点吃力,借助翻译工具将就着看吧,我有心帮大家翻译,但是提了 pr,好几天也没人审核。


你可以把 tauri 当作前端和后端不分离的项目,webview 就是前端,rust 写后端。


创建项目


tauri 提供了很多方式去帮你创建一个新的项目:


image.png


这里初始化一个 vite + vue + ts 的项目:


image.png


最后的目录结构可以看一下:


image.png


src 就是前端的目录。


src-tauri 就是后端的目录。


前端


前端是老本行,不想说太多的东西,大家都很熟悉,把页面写出来就可以了。


值得一提的就是 tauri 提供的一些接口,这些接口可以让我们实现一些浏览器上无法实现的功能。


与后端通讯


import { invoke } from "@tauri-apps/api";

invoke('event_name', payload)

通过 invoke 可以调用 rust 方法,并通过 payload 去传递参数。


窗口间传递信息


这里的窗口指的是软件的窗口,不是浏览器的标签页。由于我们要框选一块显示器上的区域,所以要创建一个新的窗口去实现,而选择后要将数据传递给主窗口。


import { listen } from '@tauri-apps/api/event';

listen<{ index: number}>("location", async (event) => {
const index = event.payload.index;
// ...
})

获取窗口实例


例如隐藏当前窗口的操作:


import { getCurrent } from '@tauri-apps/api/window';

const win = getCurrent()
win.hide() // 显示窗口即 win.show()

与之相似的还有:



  • appWindow 获取主窗口实例。

  • getAll 获取所有窗口实例,可以通过 label 来区分窗口。


最主要的是 WebviewWindow,可以通过他去创建一个新的窗口。


const screenshot = new WebviewWindow("screenshot", {
title: "screenshot",
decorations: false,
// 对应 views/screenshot.vue
url: `/#/screenshot?index=${props.index}`,
alwaysOnTop: true,
transparent: true,
hiddenTitle: true,
maximized: true,
visible: false,
resizable: false,
skipTaskbar: false,
})

这里我们创建了一个最大化、透明的窗口,且它位于屏幕最上方,页面指向就是 vue-router 的路由,index 是因为我们不确定要创建多少个窗口,用于区分。


可以通过创建这样的透明窗口,然后实现一个框选区域的功能,这对于前端来说,并不难。


例如鼠标点击左键,滑动鼠标,再松开左键,绘制这个矩形,再加一个按钮。


image.png


随后将位置信息传递给主窗口,并关闭这个透明窗口。


后端


首先,src-tauri/src/main.rs 是已经创建好的入口文件,里面已有一些内容,不用都了解。


暴露给前端的方法


tauri::Builder::default().invoke_handler(tauri::generate_handler![scan_once, ...])

通过 invoke_handler 可以暴露给前端 invoke 调用的方法。



! 在 rust 中是指宏调用,主要是方便,并不是 javascript 里的非的含义,这里注意下。



获取屏幕颜色


这里为了性能,我只获取了 x 起始位置到 x 结束位置,y 轴取中间一行的颜色。


use autopilot::{geometry::Point, screen};

pub fn scan_colors(start_x: f64, end_x: f64, y: f64) -> Vec<[u8; 3]> {
// 双重循环,根据 start_x, end_x, y 定义坐标数组
let mut points: Vec<Point> = Vec::new();
let mut x = start_x;
while x < end_x {
points.push(Point::new(x, y));
x += 1.0;
}
// 循环获取坐标数组的颜色
let mut colors: Vec<[u8; 3]> = Vec::new();
for point in points {
let pixel = screen::get_color(point).unwrap();
colors.push([pixel[0], pixel[1], pixel[2]]);
}
return colors;
}

这样就获取到一组颜色数组,包含了 RGB 信息。


这里安装了一个叫 autopilot 的包,可以通过 cargo add autopilot 安装,他可以获取屏幕的颜色,也可以操作鼠标。


鼠标操作


使用 autopilot::mouse 可以进行鼠标操作,移动至 x、y 坐标、病点击鼠标左键。


use autopilot::{geometry::Point, mouse};

mouse::move_to(Point::new(x, y));
mouse::click(mouse::Button::Left, );

配置权限


src-tauri/tauri.conf.json 中配置 allowlist,如果不想了解都有哪些权限,直接 all: true,全部配上,以后再慢慢了解。


"tauri": {
"macOSPrivateApi": true,
"allowlist": {
"all": true,
},
}

注意 mac 上如果使用透明窗口,还需要配置 macOSPrivateApi。


整体流程就是这样的,其他都是细节处理,有兴趣可以看下源码。


构建


我爸的电脑是 windows,而我的是 mac,所以需要构建一个 windows 安装包,但是 tauri 依赖本机库和开链,所以想跨平台编译是不可能的,最好的方法就是托管在 GitHub Actions 这种 CI/CD 平台去做。


在项目下创建 .github/workflows/release.yml,它将会在你发布 tag 时触发构建。


name: Release

on:
push:
tags:
- 'v*'
workflow_dispatch:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true

jobs:
publish:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest]

runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
run_install: true

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable

- name: Build Vite + Tauri
run: pnpm build

- name: Create release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download and install this version.'
releaseDraft: true
prerelease: false

这里提供一个实例,具体情况具体修改。


secrets.GITHUB_TOKEN 并不需要你配置,他是自动获取的,主要是获得权限去操作你的仓库。因为构建完成会自动创建 release,并上传安装包。


你还需要修改一下仓库的配置:


image.png


选中 Read and write permissions,勾选
Allow GitHub Actions to create and approve pull requests。


image.png


当你发布 tag 后,会触发 action 执行。


image.png


可见,打包速度真的很慢。


Actions 执行完毕后,进入 Releases 页面,可以看到安装包已经发布。


image.png


总结



  • 关于 taurielectron,甚至是 flutterqt 这种技术方向没必要讨论谁好谁坏,主要还是考虑项目的痛点,去选择适合自己的方式,没必要捧高踩低。

  • Rust 真的很难学,我上文草草几句入门,其实并没有那么简单,刚上手会踩很多坑,甚至无从下手不会写代码。我主要的目的是希望大家有想法就要着手去做,毕竟站在岸上学不会游泳。Flutter 使用 dart,我曾经写写过两个 app,相比于 rustdart 对于前端同学来说可以更轻松的学习。

  • Tauri 我目前还是比较看好,也很看好 rust,大家有时间的话还是值得学习一下,尤其是 2.0 版本还支持了移动端。

  • 看到很多同学,在学习一门语言或技术时,总是不知道做什么,不只是工作,其实我们身边有很多事情都可以去做,可能只是你想不到。我平时真的是喜欢利用代码去搞一些奇奇怪怪的事,例如我写过 vscode 摸鱼插件、自动学习视频的 chrome 插件、互赞平台、小电影爬虫等等,这些都是用 javascript 就实现的。你可以做的很多,给自己提一个需求,然后不要怕踩坑,踩坑的过程是你进步最快的过程,享受它。


作者:codexu
来源:juejin.cn/post/7320288231194755122
收起阅读 »

告别破解版烦恼!Navicat Premium Lite免费版它来了

作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,...
继续阅读 »

作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,由于侵权问题,我们通常被禁止使用,这令我们感到很不便。然而,最近Navicat推出了一款免费的产品——Navicat Premium Lite。


_20240628065825.jpg


Navicat Premium Lite


Navicat Premium Lite 是 Navicat 的精简版,拥有基本数据库操作所需的核心功能。它允许你从单个应用程序同时连接到各种数据库平台,包括 MySQL、Redis、PostgreSQL、SQL Server、Oracle、MariaDB、SQLite 和 MongoDB。Navicat Premium Lite 提供简化的数据库管理体验,使其成为用户的实用选择。


下载地址:https://www.navicat.com.cn/download/direct-download?product=navicat170_premium_lite_cs_x64.exe&location=1


文档地址: https://www.navicat.com.cn/products/navicat-premium-lite


安装及功能对比



  • 由于这个版本是免费版,不需要破解,所以安装我们此处就不多作介绍。

  • 功能对比


功能对比列表地址:https://www.navicat.com.cn/products/navicat-premium-feature-matrix


Navicat Premium Lite 基础功能都是有的,但是和企业版的相比,还是缺失了一些功能,具体大家可查看官网地址,我们此处列举部分


_20240628063823.jpg


_20240628063823.jpg


使用感受


整体使用了下,感觉和破解版使用的差别基本不大,缺失的功能几乎无影响。


_20240628064405.jpg


_20240628064405.jpg


总结


Navicat Premium Lite不仅仅是一款功能全面的数据库管理工具,更是因其免费且功能强大而备受青睐的原因。对于个人开发者、小型团队以及教育用途来说,Navicat Premium Lite提供了一个完全满足需求的解决方案,而无需支付高昂的许可费用。其稳定性、易用性和丰富的功能使得它在数据库管理领域中具备了极高的竞争力。


作者:修己xj
来源:juejin.cn/post/7384997446219743272
收起阅读 »

语言≠思维,大模型学不了推理:一篇Nature让AI社区炸锅了

方向完全搞错了? 大语言模型(LLM)为什么空间智能不足,GPT-4 为什么用语言以外的数据训练,就能变得更聪明?现在这些问题有 「标准答案」了。 近日,一篇麻省理工学院(MIT)等机构发表在顶级学术期刊《自然》杂志的文章观察到,人类大脑生成和解析语言的神经...
继续阅读 »

方向完全搞错了?



大语言模型(LLM)为什么空间智能不足,GPT-4 为什么用语言以外的数据训练,就能变得更聪明?现在这些问题有 「标准答案」了。


近日,一篇麻省理工学院(MIT)等机构发表在顶级学术期刊《自然》杂志的文章观察到,人类大脑生成和解析语言的神经网络并不负责形式化推理,而且提出推理并不需要语言作为媒介。


这篇论文声称「语言主要是用于交流的工具,而不是思考的工具,对于任何经过测试的思维形式都不是必需的」,引发了科技领域社区的大讨论。


图片


难道真的如语言学家乔姆斯基所言,追捧 ChatGPT 是浪费资源,大语言模型通向通用人工智能(AGI)的路线完全错了?


让我们看看这篇论文《Language is primarily a tool for communication rather than thought》是怎么说的。


图片


论文链接:http://www.nature.com/articles/s4…


语言是人类智能的一个决定性特征,但它所起的作用或多或少一直存在争议。该研究提供了神经科学等相关学科角度的最新证据,以论证现代人类的语言是一种交流工具,这与我们使用语言进行思考的流行观点相反。


作者首先介绍了支持人类语言能力的大脑网络。随后回顾语言和思维双重分离的证据,并讨论语言的几种特性,这些特性表明语言是为交流而优化的。该研究得出结论认为,尽管语言的出现无疑改变了人类文化,但语言似乎并不是复杂思维(包括符号思维)的先决条件。相反,语言是传播文化知识的有力工具,它可能与我们的思维和推理能力共同进化,并且只反映了人类认知的标志性复杂性,而不是产生这种复杂性。


图片


图 1


研究证据挑战了语言对于思维的重要性。如图 1 所示,使用 fMRI 等成像工具,我们可以识别完整、健康的大脑中的语言区域,然后检查在完成需要不同思维形式的任务时,语言区域的相关响应。


 人类大脑中的语言网络


从人脑的生物学结构来看,语言生成和语言理解由左半球一组相互连接的大脑区域支持,通常称为语言网络(图 1a;Box 2 描述了它与语言神经生物学经典模型的关系)。


图片


Box 2。许多教科书仍然使用 Wernicke 提出的语言神经基础模型,并由 Lichteim 和 Geschwind 进行了阐述和修订。该模型包括两个皮层区域:Broca 区位于下额叶皮层,Wernicke 区位于后上颞叶皮层。这两个区域分别支持语言产生和理解,并通过一条背侧纤维束(弓状束)连接。


语言网络有两个非常重要的特性:


首先,语言区域表现出输入和输出模态的独立性,这是表征抽象性的关键特征。主要表现为在理解过程中,这些大脑区域对跨模态(口头、书面或手语)的语言输入做出反应。同样,在语言生成过程中,无论我们是通过口语还是书面语来产生信息,这些区域都是活跃的。这些区域支持语言理解和生成(图 1a)这一事实表明,它们很可能存储了我们的语言知识,这对于编码和解码语言信息都是必需的。


其次,语言区还能对词义和句法结构进行表征和处理。特别是,关于脑磁图和颅内记录研究的证据表明,语言网络的所有区域都对词义以及词间句法和语义依赖性敏感(图 1a)。总之,语言网络中语言表征的抽象性以及网络对语言意义和结构的敏感性使其成为评估语言在思维和认知中的作用假设的明确目标((Box 3)。


我们对人类语言和认知能力,以及它们之间关系的理解仍然不完整,还有一些悬而未决的问题:



  • 语言表征的本质是什么?

  • 思维是否依赖于符号表征?

  • 儿童学习语言时,语言网络是如何成长的?


语言对于任何经过检验的思维形式都不是必需的


经典的方法是通过研究大脑损伤或疾病的个体来推断大脑与行为之间的关联和分离。这种方法依赖于观察大脑某部分受损时个体行为的变化,从而推测不同大脑区域的功能和行为之间的联系。


有证据表明 —— 有许多个体在语言能力上有严重的障碍,影响到词汇和句法能力,但他们仍然表现出在许多思考形式上的完整能力:他们可以解决数学问题,进行执行规划和遵循非言语指令,参与多种形式的推理,包括形式逻辑推理、关于世界的因果推理和科学推理(见图 1b)。  


研究表明,尽管失去了语言能力,一些患有严重失语症的人仍然能够进行所有测试形式的思考和推理,他们在各种认知任务中的完整表现就是明证。他们根本无法将这些想法映射到语言表达上,无论是在语言生成中(他们无法通过语言向他人传达自己的想法),还是在理解中(他们无法从他人的单词和句子中提取意义)(图 1b)。当然,在某些脑损伤病例中,语言能力和(某些)思维能力都可能受到影响,但考虑到语言系统与其他高级认知系统的接近性,这是可以预料的。


尤其是一些聋哑儿童,他们长大后很少或根本没有接触过语言,因为他们听不见说话,而他们的父母或看护人不懂手语。缺乏语言接触会对认知的许多方面产生有害影响,这是可以预料的,因为语言是了解世界的重要信息来源。尽管如此,语言剥夺的个体无疑表现出复杂的认知功能能力:他们仍然可以学习数学、进行关系推理、建立因果链,并获得丰富而复杂的世界知识。换句话说,缺乏语言表征并不会使人从根本上无法进行复杂的(包括符号的)思考,尽管推理的某些方面确实表现出延迟。因此,在典型的发展中,语言和推理是平行发展的。


完整的语言并不意味着完整的思维


以上证据表明,迄今为止测试的所有类型的思维都可以在没有语言的情况下实现。


接下来,论文讨论了语言和思维双重分离的另一面:与语言介导思维的观点相反,完整的语言系统似乎并不意味着完整的推理能力。


图片


图片


人类语言是由交流压力塑造的。


来自发育性和后天性脑部疾病的证据表明,即使语言能力基本完好,也可能存在智力障碍。


例如,有些遗传疾病导致智力受损程度不同,但患有这些疾病的人的语言能力似乎接近正常水平;还有一些精神层面有缺陷的人,会影响思考和推理能力,但同样不会影响语言。最后,许多获得性脑损伤的个体在推理和解决问题方面表现出困难,但他们的语言能力似乎完好无损。换句话说,拥有完整的语言系统并不意味着自动具备思考能力:即使语言能力完好无损,思考能力也可能受损。


总的来说,这篇论文回顾了过去二十年的相关工作。失语症研究的证据表明:所有经过检验的思维形式在没有语言的情况下都是可能的。fMRI 成像证据表明:参与多种形式的思考和推理并不需要语言网络。因此,语言不太可能成为任何形式思维的关键基础。


MIT 研究得出结论的同时,顶尖 AI 领域学者最近也发表了对大模型发展的担忧。上个星期四 Claude 3.5 的发布号称拥有研究生水平的推理能力,提升了行业的标准。不过也有人表示经过实测可见,它仍然具有 Transformer 架构的局限性。


对此,图灵奖获得者 Yann LeCun 表示,问题不在于 Transformer,而是因为 Claude 3.5 仍然是一个自回归大模型。无论架构细节如何,使用固定数量的计算步骤来计算每个 token 的自回归 LLM 都无法进行推理。


图片


LeCun 也评论了这篇 Nature 论文,对思维不等于语言表示赞同。


图片


对此,你怎么看?


参考内容:


news.ycombinator.com/item?id=407…


x.com/ylecun/stat…


作者:机器之心
来源:juejin.cn/post/7383934765370425353
收起阅读 »

还在使用 iconfont,上传图标审核好慢,不如自己做一个

web
之前使用 iconfont 是非常方便的,上传之后立马生效,项目里面直接引用即可,但是现在因为政策的收紧,每次上传图标都要等待十几分钟二十分钟的审核时间,这怎么能忍,有这个时间我都能写一个页面了好吧。 忍受不了就自己做,说干就干,于是我写了一个 svg 转图标...
继续阅读 »

之前使用 iconfont 是非常方便的,上传之后立马生效,项目里面直接引用即可,但是现在因为政策的收紧,每次上传图标都要等待十几分钟二十分钟的审核时间,这怎么能忍,有这个时间我都能写一个页面了好吧。


忍受不了就自己做,说干就干,于是我写了一个 svg 转图标字体的脚手架,所有的内容都自己维护,不再受制于人,感觉就是爽。


svg2font: 一个高效的 SVG 图标字体生成工具


github.com/tenadolante…


在现代 Web 开发中,使用图标是一种常见的做法。图标不仅能美化界面,还能提高可用性和可访问性。传统上,我们使用图片文件(如 PNG、JPG 等)来显示图标,但这种方式存在一些缺陷,例如图片文件较大、不能任意缩放、无法通过 CSS 设置颜色等。相比之下,使用字体图标具有许多优势,如文件体积小、可无限缩放、可通过 CSS 设置颜色和阴影等。


svg2font 就是一个用于将 SVG 图标转换为字体图标的工具,它可以帮助我们轻松地在项目中集成和使用字体图标。本文将详细介绍 svg2font 的使用方法、应用场景和注意事项。


安装


svg2font 是一个基于 Node.js 的命令行工具,因此需要先安装 Node.js 环境。安装完成后,可以使用 npm 或 yarn 在项目中安装 svg2font:


# 使用npm
npm install @tenado/svg2font -D

# 使用yarn
yarn add @tenado/svg2font -D

初始化配置


安装完成后,需要初始化 svg2font 的配置文件。在项目根目录执行以下命令:


npx svg2font init

该命令会在项目根目录下生成一个 svg2font.config.js 文件,内容如下:


module.exports = {
inputPath: "src/assets/svgs", // SVG图标文件夹路径
outputPath: "src/assets/font", // 生成字体文件的输出路径
fontFamily: "tenadoIcon", // 字体名称
fontPrefix: "", // 字体前缀
};

你可以根据实际需求修改这些配置项。


生成字体图标


配置完成后,就可以执行以下命令生成字体图标了:


npx svg2font sync

该命令会读取 inputPath 指定的 SVG 图标文件夹,将其中的 SVG 文件转换为字体文件(包括.eot、.ttf、.woff、.woff2 等格式),并输出到 outputPath 指定的路径下。同时,它还会生成一个 config.json 文件,记录了每个图标的 Unicode 编码和 CSS 类名。


在项目中使用字体图标


生成字体文件后,需要在项目中引入相应的 CSS 文件,才能正常使用字体图标。svg2font 会自动生成一个 index.min.css 文件,包含了所有字体图标的 CSS 定义。你可以在项目的入口文件(如 main.js)中导入该 CSS 文件:


import "./src/assets/font/index.min.css";

之后,你就可以在 HTML 中使用字体图标了。例如,如果你有一个名为 ticon-color-pick 的图标,可以这样使用:


<span class="ticon-color-pick"></span>

查看图标列表


如果你想查看当前项目包含的所有图标,可以执行以下命令:


npx svg2font example

该命令会根据 config.json 文件生成一个静态 HTML 页面,列出了所有图标及其对应的 CSS 类名和 Unicode 编码。它还会启动一个本地服务器,方便你在浏览器中预览这个页面。


注意事项


使用 svg2font 时,需要注意以下几点:


1.SVG 文件命名: 确保 SVG 文件名不包含特殊字符或空格,否则可能会导致生成字体时出错。


2.SVG 文件优化: 在将 SVG 文件转换为字体之前,建议先对 SVG 文件进行优化,以减小文件大小。你可以使用工具如 SVGO 或 SVG Optimizer 来优化 SVG 文件。


3.字体支持:不同浏览器和操作系统对字体格式的支持程度不同。为了最大程度地兼容各种环境,svg2font 会生成多种字体格式(.eot、.ttf、.woff、.woff2 等)。


4.字体缓存: 浏览器会缓存字体文件,因此在更新字体图标时,需要确保浏览器加载了最新的字体文件。你可以在 CSS 文件中为字体文件添加版本号或时间戳,以强制浏览器重新加载字体文件。


总结


svg2font 是一个功能强大且易于使用的 SVG 图标字体生成工具。它可以帮助你轻松地将 SVG 图标转换为字体格式,并在 Web 应用程序、跨平台应用程序或图标库中使用这些字体图标。通过使用 svg2font,你可以提高页面性能、确保图标显示一致性,并享受字体图标带来的诸多优势。


无论你是 Web 开发人员、移动应用程序开发人员,还是 UI 设计师,svg2font 都值得一试。它简单易用,且具有丰富的功能和配置选项,可以满足不同项目的需求。快来试试 svg2font,让你的项目与众不同吧!


作者:是阿派啊
来源:juejin.cn/post/7384808085348483087
收起阅读 »

时隔5年重拾前端开发,却倒在了环境搭建上

web
背景 去年不是降本增“笑”,“裁员”广进来着吗,公司有个项目因此停止了,最近又说这个项目还是很有必要的,就又重新启动这个项目了,然后让我这个“大聪明”把环境重新跑起来。让我无奈的是,原项目的团队成员都已经被增“笑”了,只留下了一堆不知从哪开始着手的文档。 后端...
继续阅读 »

背景


去年不是降本增“笑”,“裁员”广进来着吗,公司有个项目因此停止了,最近又说这个项目还是很有必要的,就又重新启动这个项目了,然后让我这个“大聪明”把环境重新跑起来。让我无奈的是,原项目的团队成员都已经被增“笑”了,只留下了一堆不知从哪开始着手的文档。


后端还好,前端我心里就犯嘀咕了,毕竟已经5年没有关注过前端了,上次写前端代码用的还是一个基于Angular构建的移动框架inoic,不知道大家用过没有。


好在这个项目前端也用的Angular框架,本以为整个过程会很顺利,然而,结果总是事与愿违。果不其然,在搭建前端开发环境时就给我上了一课,整个过程让我抓耳挠腮,遂特此记录。


环境搭建心路历程


跟着文档操作


前端文档中对环境搭建有进行说明,一共有4个步骤,大概是这样的:



  1. 确认node环境,需要某个及以上版本。

  2. 安装@angular/cli。

  3. 安装依赖。

  4. 启动项目。


看到这里,我第一反应是“啊?现在前端这么麻烦的吗?”,我记得以前在浏览器直接打开页面就可以访问了。咱也不懂,跟着说明操作就行。



  1. 我本地不知道啥时候装了nodejs,执行node -v后输出v18.13.0,符合要求。ok

  2. @angular/cli这是啥,咋也不懂,执行安装命令就行,输出看上去是没有问题。ok

  3. 安装依赖我理解跟Maven的依赖管理一样,先不管,执行。ok

  4. 到这一步,我觉得应该可以顺利启动,看一看这个项目的庐山真面目了,结果执行 npm start 后报下面这个错。


出现问题一:nodeJS版本过高


Error: error:0308010C:digital envelope routines::unsupported
......
......

{
'opensslErrorStack': [ 'error:03000086:digital envelope routines::initialization error' ],
'library': 'digital envelope routines',
'reason': 'unsupported',
'code': 'ERR_OSSL_EVP_UNSUPPORTED'
}
......
......

百度一看,原因是node 17版本之后,OpenSSL3.0对算法和密钥大小增加了严格的限制。


解决呗,降版本呗,node官网 下载了v14.12.0。


出现问题二:nodeJS版本低于Angular CLI版本


降版本之后重新运行npm start,您猜猜怎么着


在这里插入图片描述


Node.js version v14.12.0 detected.
The Angular CLI requires a minimum Node.js version of v18.13.

Please update your Node.js version or visit https://nodejs.org/ for additional instructions.

很明显,新老版本冲突了,又是版本问题,又是一顿百度之后,发现知乎上的一个帖子跟我这问题现象是一样的:“node是最新版,npm启动项目使用的不是最新版的node,请问这个怎么解决?


跟着下面的评论又安装了nvm(Node Version Manager),最后一顿操作后,莫名其妙的启动了。


事后才反应过来,这个问题的根本原因是:Angular CLI是在node版本为18.3时安装的,版本更新到14.12.0后需要删除依赖重新安装。


但是我不确定的是对应的npm版本会不会一同更新,有知道的小伙伴评论区交流一下。\color{blue}{但是我不确定的是对应的npm版本会不会一同更新,有知道的小伙伴评论区交流一下。}


不过nvm确实好用,至少不用担心node和npm版本问题,比如下面的命令:


[xxx % ] nvm use --delete-prefix v18.13.0
Now using node v18.13.0 (npm v8.19.3)

学到的第一个知识:nvm


这里记录下nvm安装过程



  1. clone this repo in the root of your user profile


  2. cd ~/.nvm and check out the latest version with git checkout v0.39.7

  3. activate nvm by sourcing it from your shell: . ./nvm.sh


配置环境变量


export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion

引发的思考


技术发展日新月异


早在几年前,程序员是要前后端一起开发的,不分什么前后端,我从最开始的HTML、JavaScript开始用到AngularJS这些框架,印象最深刻的是还要解决兼容IE浏览器。没想到现在的前端也会有版本管理、组件化等等,可见技术更新迭代速度之快。


前端的重要性


当初在选择后端的时候认为前端技术无非就那些,没有什么挑战。事实上,前后端没有分离之前,市场上的应用页面也是极其简洁的,前后端一起兼顾是没有精力写出那么好看的界面和交互的。所以“前端已死”的观点我是不认可的。


降本增“笑”被迫全栈


前几天参加了开发者社区的线下聚会,聊了一下行情。有小伙伴吐槽,因为在降本增“笑”的原因,现在他们被公司要求要写前端,被迫向全栈发展,竟意外发现开发效率极其高。还有小伙伴说“前端被裁的剩下几个人,一个前端对接十个后端。”。是呀,在降本增“笑”之后,老板恨不得让一个人干十个人的活。


与时俱进


不论是几年前的前后端分离还是降本增“笑”带来的被迫全栈,还是最近“前端已死”的观点,一切都是行业发展所需要的。我们需要做到的是:不断学习和更新自己的知识和技能,以适应行业的发展和变化。


作者:王二蛋呀
来源:juejin.cn/post/7327599804325052431
收起阅读 »

cesium 鼠标动态绘制墙及墙动效

web
实现在cesium中基于鼠标动态绘制墙功能 1. 基本架构设计 绘制墙的交互与绘制线的交互几乎一模一样,只是一些生成wall实体的计算方法不一样,所以可以看这篇文章 cesium 鼠标动态绘制线及线动效 juejin.cn/post/728826… 了解相关...
继续阅读 »

实现在cesium中基于鼠标动态绘制墙功能



1. 基本架构设计


绘制墙的交互与绘制线的交互几乎一模一样,只是一些生成wall实体的计算方法不一样,所以可以看这篇文章 cesium 鼠标动态绘制线及线动效 juejin.cn/post/728826… 了解相关的架构设计


2. 关键代码实现


2.1 绘制线交互相关事件


事件绑定相关与动态绘制线一样,这里不再重复代码


绘制形状代码有区别:
为了实现墙贴地,要实时计算minimumHeights,maximumHeights的值,min中算出地形高度,max中再地形高度的基础上再加上墙的高度


  /**
* 绘制形状,用于内部临时画墙
* @param positionData 位置数据
* @param config 墙的配置项
* @returns
*/

private drawShape(positionData: Cartesian3[], config?: WallConfig) {
const wallConfig = config || new WallConfig();
const material = this.createMaterial(wallConfig);
// @ts-ignore
const pArray = positionData._callback();

const shape = this.app.viewerCesium.entities.add({
wall: {
positions: positionData,
material: material,
maximumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights, (x) => x + wallConfig.height);
return data;
}, false),
minimumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights);
return data;
}, false)
}
});
return shape;
}

2.2 创建材质相关


  /**
* 创建材质
* @param config 墙的配置项
* @returns
*/

private createMaterial(config: WallConfig) {
let material = new ColorMaterialProperty(Color.fromCssColorString(config.style.color));
if (config.style.particle.used) {
material = new WallFlowMaterialProperty({
image: config.style.particle.image,
forward: config.style.particle.forward ? 1.0 : -1.0,
horizontal: config.style.particle.horizontal,
speed: config.style.particle.speed,
repeat: new Cartesian2(config.style.particle.repeat, 1.0)
});
}

return material;
}

创建WallFlowMaterialProperty.js(具体为何如此请看这篇文章,cesium自定义材质 juejin.cn/post/728795…


import { Color, defaultValue, defined, Property, createPropertyDescriptor, Material, Event, Cartesian2 } from 'cesium';

const defaultColor = Color.TRANSPARENT;
import defaultImage from '../../../assets/images/effect/line-color-yellow.png';
const defaultForward = 1;
const defaultHorizontal = false;
const defaultSpeed = 1;
const defaultRepeat = new Cartesian2(1.0, 1.0);

class WallFlowMaterialProperty {
constructor(options) {
options = defaultValue(options, defaultValue.EMPTY_OBJECT);

this._definitionChanged = new Event();
// 定义材质变量
this._color = undefined;
this._colorSubscription = undefined;
this._image = undefined;
this._imageSubscription = undefined;
this._forward = undefined;
this._forwardSubscription = undefined;
this._horizontal = undefined;
this._horizontalSubscription = undefined;
this._speed = undefined;
this._speedSubscription = undefined;
this._repeat = undefined;
this._repeatSubscription = undefined;
// 变量初始化
this.color = options.color || defaultColor; //颜色
this.image = options.image || defaultImage; //材质图片
this.forward = options.forward || defaultForward;
this.horizontal = options.horizontal || defaultHorizontal;
this.speed = options.speed || defaultSpeed;
this.repeat = options.repeat || defaultRepeat;
}

// 材质类型
getType() {
return 'WallFlow';
}

// 这个方法在每次渲染时被调用,result的参数会传入glsl中。
getValue(time, result) {
if (!defined(result)) {
result = {};
}

result.color = Property.getValueOrClonedDefault(this._color, time, defaultColor, result.color);
result.image = Property.getValueOrClonedDefault(this._image, time, defaultImage, result.image);
result.forward = Property.getValueOrClonedDefault(this._forward, time, defaultForward, result.forward);
result.horizontal = Property.getValueOrClonedDefault(this._horizontal, time, defaultHorizontal, result.horizontal);
result.speed = Property.getValueOrClonedDefault(this._speed, time, defaultSpeed, result.speed);
result.repeat = Property.getValueOrClonedDefault(this._repeat, time, defaultRepeat, result.repeat);

return result;
}

equals(other) {
return (
this === other ||
(other instanceof WallFlowMaterialProperty &&
Property.equals(this._color, other._color) &&
Property.equals(this._image, other._image) &&
Property.equals(this._forward, other._forward) &&
Property.equals(this._horizontal, other._horizontal) &&
Property.equals(this._speed, other._speed) &&
Property.equals(this._repeat, other._repeat))
);
}
}

Object.defineProperties(WallFlowMaterialProperty.prototype, {
isConstant: {
get: function get() {
return (
Property.isConstant(this._color) &&
Property.isConstant(this._image) &&
Property.isConstant(this._forward) &&
Property.isConstant(this._horizontal) &&
Property.isConstant(this._speed) &&
Property.isConstant(this._repeat)
);
}
},

definitionChanged: {
get: function get() {
return this._definitionChanged;
}
},

color: createPropertyDescriptor('color'),
image: createPropertyDescriptor('image'),
forward: createPropertyDescriptor('forward'),
horizontal: createPropertyDescriptor('horizontal'),
speed: createPropertyDescriptor('speed'),
repeat: createPropertyDescriptor('repeat')
});

Material.WallFlowType = 'WallFlow';
Material._materialCache.addMaterial(Material.WallFlowType, {
fabric: {
type: Material.WallFlowType,
uniforms: {
// uniforms参数跟我们上面定义的参数以及getValue方法中返回的result对应,这里值是默认值
color: defaultColor,
image: defaultImage,
forward: defaultForward,
horizontal: defaultHorizontal,
speed: defaultSpeed,
repeat: defaultRepeat
},
// source编写glsl,可以使用uniforms参数,值来自getValue方法的result
source: `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);

vec2 st = materialInput.st;
vec4 fragColor;
if (horizontal) {
fragColor = texture(image, fract(vec2(st.s - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
} else {
fragColor = texture(image, fract(vec2(st.t - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
}

material.emission = fragColor.rgb;
material.alpha = fragColor.a;

return material;
}`

},
translucent: true
});

export { WallFlowMaterialProperty };


2.3 添加wall实体


  /**
* 根据已知数据添加一个墙
* @param config 墙的配置项
*/

add(config: WallConfig) {
const configCopy = cloneDeep(config);

const positions = configCopy.positions;

const material = this.createMaterial(configCopy);

let distance = new DistanceDisplayCondition();
if (configCopy.distanceDisplayCondition) {
distance = new DistanceDisplayCondition(
configCopy.distanceDisplayCondition.near,
configCopy.distanceDisplayCondition.far
);
}

let heights: number[] = [];
for (let i = 0; i < positions.length; i++) {
const cartographic = Cartographic.fromCartesian(positions[i]);
const height = cartographic.height;
heights.push(height);
}

this.app.viewerCesium.entities.add({
id: 'wallEntity_' + configCopy.id,
wall: {
positions: positions,
maximumHeights: Array.from(heights, (x) => x + configCopy.height),
minimumHeights: Array.from(heights),
material: material,
distanceDisplayCondition: distance
}
});

this._wallConfigList.set('wallEntity_' + configCopy.id, config);
}

3. 业务端调用


调用方式与动态绘制线一样,是同一种架构设计,这里不再重复代码


4. 效果


wall动画.webp


wall动画1.webp


wall动画2.webp


wall动画3.webp


作者:山河木马
来源:juejin.cn/post/7288606110335565883
收起阅读 »

前端如何生成临时链接?

web
前言 前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURL和FileReader.readAsDataURAPI来实现。 URL.createObjectURL() URL.createObjectURL() 静态...
继续阅读 »



前言


前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURLFileReader.readAsDataURAPI来实现。


URL.createObjectURL()


URL.createObjectURL() 静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的URL 对象表示指定的 File 对象或 Blob 对象。


1. 语法


let objectURL = URL.createObjectURL(object);

2. 参数


用于创建 URL 的 File 对象、Blob 对象或者 MediaSource 对象。


3. 返回值


一个DOMString包含了一个对象URL,该URL可用于指定源 object的内容。


4. 示例


"file" id="file">

document.querySelector('#file').onchange = function (e) {
console.log(e.target.files[0])
console.log(URL.createObjectURL(e.target.files[0]))
}

0f40e1fff9674142889f8bacc6d455b9.png


将上方console控制台打印的blob文件资源地址粘贴到浏览器中


blob:http://localhost:8080/1ece2bb1-b426-4261-89e8-c3bec43a4020

5cc4d088c5c941b7950f6f930cb9a1bc.png


URL.revokeObjectURL()


在每次调用 createObjectURL() 方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL() 方法来释放。


浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。


1. 语法


window.URL.revokeObjectURL(objectURL);

2. 参数 objectURL


一个 DOMString,表示通过调用 URL.createObjectURL() 方法返回的 URL 对象。


3. 返回值


undefined


4. 示例


"file" id="file">
<img id="img1" style="width: 200px;height: auto" />
<img id="img2" style="width: 200px;height: auto" />

document.querySelector('#file').onchange = function (e) {
const file = e.target.files[0]

const URL1 = URL.createObjectURL(file)
console.log(URL1)
document.querySelector('#img1').src = URL1
URL.revokeObjectURL(URL1)

const URL2 = URL.createObjectURL(file)
console.log(URL2)
document.querySelector('#img2').src = URL2
}

ecba01284f034c42a2bf4200054b0e9f.png


与FileReader.readAsDataURL(file)区别


1. 主要区别



  • 通过FileReader.readAsDataURL(file)可以获取一段data:base64的字符串

  • 通过URL.createObjectURL(blob)可以获取当前文件的一个内存URL


2. 执行时机



  • createObjectURL是同步执行(立即的)

  • FileReader.readAsDataURL是异步执行(过一段时间)


3. 内存使用



  • createObjectURL返回一段带hashurl,并且一直存储在内存中,直到document触发了unload事件(例如:document close)或者执行revokeObjectURL来释放。

  • FileReader.readAsDataURL则返回包含很多字符的base64,并会比blob url消耗更多内存,但是在不用的时候会自动从内存中清除(通过垃圾回收机制)


4. 优劣对比



  • 使用createObjectURL可以节省性能并更快速,只不过需要在不使用的情况下手动释放内存

  • 如果不在意设备性能问题,并想获取图片的base64,则推荐使用FileReader.readAsDataURL




作者:sorryhc
来源:juejin.cn/post/7333236033038778409
收起阅读 »

Vue3 实现最近很火的酷炫功能:卡片悬浮发光

web
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ 有趣的动画效果 前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果 那么在 Vue3 中应该如何去实现这个效果呢...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~


有趣的动画效果


前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果


那么在 Vue3 中应该如何去实现这个效果呢?



基本实现思路


其实实现思路很简单,无非就是分几步:



  • 首先,卡片是相对定位,光是绝对定位

  • 监听卡片的鼠标移入事件mouseenter,当鼠标进入时显示光

  • 监听卡片的鼠标移动事件mouseover,鼠标移动时修改光的left、top,让光跟随鼠标移动

  • 监听卡片的鼠标移出事件mouseleave,鼠标移出时,隐藏光


我们先在 Index.vue 中准备一个卡片页面,光的CSS效果可以使用filter: blur() 来实现



可以看到现在的效果是这样



实现光源跟随鼠标


在实现之前我们需要注意几点:



  • 1、鼠标移入时需要设置卡片 overflow: hidden,否则光会溢出,而鼠标移出时记得还原

  • 2、获取鼠标坐标时需要用clientX/Y而不是pageX/Y,因为前者会把页面滚动距离也算进去,比较严谨


刚刚说到实现思路时我们说到了mouseenter、mousemove、mouseleave,其实mouseenter、mouseleave 这二者的逻辑比较简单,重点是 mouseover 这个监听函数


而在 mouseover 这个函数中,最重要的逻辑就是:光怎么跟随鼠标移动呢?


或者也可以这么说:怎么计算光相对于卡片盒子的 left 和 top


对此我专门画了一张图,相信大家一看就懂怎么算了




  • left = clientX - x - width/2

  • height = clientY - y - height/2


知道了怎么计算,那么逻辑的实现也很明了了~封装一个use-light-card.ts



接着在页面中去使用



这样就能实现基本的效果啦~



卡片视差效果


卡片的视差效果需要用到样式中 transform 样式,主要是配置四个东西:



  • perspective:定义元素在 3D 变换时的透视效果

  • rotateX:X 轴旋转角度

  • rotateY:Y 轴旋转角度

  • scale3d:X/Y/Z 轴上的缩放比例



现在就有了卡片视差的效果啦~



给所有卡片添加光源


上面只是给一个卡片增加光源,接下来可以给每一个卡片都增加光源啦!!!




让光源变成可配置


上面的代码,总感觉这个 hooks 耦合度太高不太通用,所以我们可以让光源变成可配置化,这样每个卡片就可以展示不同大小、颜色的光源了~像下面一样



既然是配置化,那我们希望是这么去使用 hooks 的,我们并不需要自己在页面中去写光源的dom节点,也不需要自己去写光源的样式,而是通过配置传入 hooks 中



所以 hooks 内部要自己通过操作 DOM 的方式,去添加、删除光源,可以使用createElement、appendChild、removeChild 去做这些事~



完整源码


<!-- Index.vue -->

<template>
<div class="container">
<!-- 方块盒子 -->
<div class="item" ref="cardRef1"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef2"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef3"></div>
</div>
</template>

<script setup lang="ts">
import { useLightCard } from './use-light-card';

const { cardRef: cardRef1 } = useLightCard();
const { cardRef: cardRef2 } = useLightCard({
light: {
color: '#ffffff',
width: 100,
},
});
const { cardRef: cardRef3 } = useLightCard({
light: {
color: 'yellow',
},
});
</script>

<style scoped lang="less">
.container {
background: black;
width: 100%;
height: 100%;
padding: 200px;
display: flex;
justify-content: space-between;

.item {
position: relative;
width: 125px;
height: 125px;
background: #1c1c1f;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>


// use-light-card.ts

import { onMounted, onUnmounted, ref } from 'vue';

interface IOptions {
light?: {
width?: number; // 宽
height?: number; // 高
color?: string; // 颜色
blur?: number; // filter: blur()
};
}

export const useLightCard = (option: IOptions = {}) => {
// 获取卡片的dom节点
const cardRef = ref<HTMLDivElement | null>(null);
let cardOverflow = '';
// 光的dom节点
const lightRef = ref<HTMLDivElement>(document.createElement('div'));
// 设置光源的样式

const setLightStyle = () => {
const { width = 60, height = 60, color = '#ff4132', blur = 40 } = option.light ?? {};
const lightDom = lightRef.value;
lightDom.style.position = 'absolute';
lightDom.style.width = `${width}px`;
lightDom.style.height = `${height}px`;
lightDom.style.background = color;
lightDom.style.filter = `blur(${blur}px)`;
};

// 设置卡片的 overflow 为 hidden
const setCardOverflowHidden = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardOverflow = cardDom.style.overflow;
cardDom.style.overflow = 'hidden';
}
};
// 还原卡片的 overflow
const restoreCardOverflow = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.style.overflow = cardOverflow;
}
};

// 往卡片添加光源
const addLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.appendChild(lightRef.value);
}
};
// 删除光源
const removeLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.removeChild(lightRef.value);
}
};

// 监听卡片的鼠标移入
const onMouseEnter = () => {
// 添加光源
addLight();
setCardOverflowHidden();
};

// use-light-card.ts

// 监听卡片的鼠标移动
const onMouseMove = (e: MouseEvent) => {
// 获取鼠标的坐标
const { clientX, clientY } = e;
// 让光跟随鼠标
const cardDom = cardRef.value;
const lightDom = lightRef.value;
if (cardDom) {
// 获取卡片相对于窗口的x和y坐标
const { x, y } = cardDom.getBoundingClientRect();
// 获取光的宽高
const { width, height } = lightDom.getBoundingClientRect();
lightDom.style.left = `${clientX - x - width / 2}px`;
lightDom.style.top = `${clientY - y - height / 2}px`;

// 设置动画效果
const maxXRotation = 10; // X 轴旋转角度
const maxYRotation = 10; // Y 轴旋转角度

const rangeX = 200 / 2; // X 轴旋转的范围
const rangeY = 200 / 2; // Y 轴旋转的范围

const rotateX = ((clientX - x - rangeY) / rangeY) * maxXRotation; // 根据鼠标在 Y 轴上的位置计算绕 X 轴的旋转角度
const rotateY = -1 * ((clientY - y - rangeX) / rangeX) * maxYRotation; // 根据鼠标在 X 轴上的位置计算绕 Y 轴的旋转角度

cardDom.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; //设置 3D 透视
}
};
// 监听卡片鼠标移出
const onMouseLeave = () => {
// 鼠标离开移出光源
removeLight();
restoreCardOverflow();
};

onMounted(() => {
// 设置光源样式
setLightStyle();
// 绑定事件
cardRef.value?.addEventListener('mouseenter', onMouseEnter);
cardRef.value?.addEventListener('mousemove', onMouseMove);
cardRef.value?.addEventListener('mouseleave', onMouseLeave);
});

onUnmounted(() => {
// 解绑事件
cardRef.value?.removeEventListener('mouseenter', onMouseEnter);
cardRef.value?.removeEventListener('mousemove', onMouseMove);
cardRef.value?.removeEventListener('mouseleave', onMouseLeave);
});

return {
cardRef,
};
};


结语 & 加学习群 & 摸鱼群


我是林三心



  • 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;

  • 一个偏前端的全干工程师;

  • 一个不正经的掘金作者;

  • 一个逗比的B站up主;

  • 一个不帅的小红书博主;

  • 一个喜欢打铁的篮球菜鸟;

  • 一个喜欢历史的乏味少年;

  • 一个喜欢rap的五音不全弱鸡


如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点


作者:Sunshine_Lin
来源:juejin.cn/post/7373867360019742758
收起阅读 »

CSDN 搬运了 Github 所有项目,骚操作一波接一波

最近几天,CSDN 旗下的代码平台 GitCode 在未获得任何作者授权的情况下,批量搬运了 Github 上的几乎所有开源项目:GitCode 不仅把项目所有信息都搬运到自己平台上,还给每个开发者、组织都创建了主页。如果作者想要编辑和操作自己的主页和项目,只...
继续阅读 »

最近几天,CSDN 旗下的代码平台 GitCode 在未获得任何作者授权的情况下,批量搬运了 Github 上的几乎所有开源项目:


GitCode 不仅把项目所有信息都搬运到自己平台上,还给每个开发者、组织都创建了主页。如果作者想要编辑和操作自己的主页和项目,只能使用 Github 授权登录并创建 GitCode 平台的账号才能操作。


GitCode 甚至把项目 README 中的 github 字样都替换成了gitcode...

CSDN 的骚操作远不止这些,它们甚至创建了一批 CSDN 小号,并使用 AI 发布大量 GitCode 项目的相关内容,以进行引流。大家都知道,CSDN 的内容在很多搜索引擎中的权重是比较高的,这一骚操作就回导致搜索结果又多了很多垃圾信息。


AI 盛行的今天,大模型需要使用大量互联网信息进行训练。而 CSDN 用 AI 生成垃圾内容发布到网络上,多多少少会对大模型的质量产生影响,大模型又会生成更多垃圾内容,最终形成恶性循环,想想都可怕。

最搞笑的是,GitCode 在搬运 Github 项目时似乎没有做筛选,搬运了很多违法、违规的项目(懂得都懂,导致网站短暂 404,真是搬起石头砸了自己的脚。


此事发生后,很多开发者都出来声讨 GitCode,并要求其删除账号和项目:


最后不得不吐槽一句,这么一个半成品网站(网站随处可见的Bug),就不要拿出来搞事情了,很难看的。

有网友整理了 CSDN 的五宗罪:


图源:
https://github.com/Catherina0/evil-CSDN

作者:极速星空4DO

来源:www.toutiao.com/article/7384999821570064950/

收起阅读 »

你想活出怎样的人生?

hi~好久不见,距离上次发文隔了有段时间了,这段时间,我是裸辞去感受了一下前端市场的水深火热,那么这次咱们不聊技术,就说一说最近这段时间的经历和一些感触吧。 先说一下自己的个人情况,目前做前端四年,双非本,非科班,技术栈Vue和小程序,读过源码,刷过算法,写过...
继续阅读 »

hi~好久不见,距离上次发文隔了有段时间了,这段时间,我是裸辞去感受了一下前端市场的水深火热,那么这次咱们不聊技术,就说一说最近这段时间的经历和一些感触吧。


先说一下自己的个人情况,目前做前端四年,双非本,非科班,技术栈Vue和小程序,读过源码,刷过算法,写过开源,工作地点在武汉。


我是在三月初裸辞向公司提的离职,并在四月初离开。在做出裸辞这个决定之前,其实也是犹豫了好久,因为在上家公司做开发还是很愉快的,同时看网上大家对于如今的市场行情评价都是寒气逼人,所以对于这次的裸辞我思考了有半年之久。


我的想法有几个点:



  1. 上家公司整体规模偏小,而且项目的复杂度并不太高,技术上的成长主要靠个人,所以如果在这里继续做下去,技术,眼界,薪资可能都会比较受限,越往后越会出现技术不匹配年限的问题。如果公司一旦出现了点什么问题,那么个人在市面上可选择的岗位就会十分受限。

  2. 互联网下行的情况在前两年就已经出现了,然而每年又都会有一大批新的大学生加入到这个行业,那么可能真的今年就是往后十年中最好的一年了,之后一定是会越来越卷的。

  3. 对自己的技术还算是有些信心,觉得不至于会找不到合适的工作。


综合考虑了以上几点,决定就勇敢一次,迈出这一步,不论后面的结果如何都是自己的选择。


面试


然后,就聊一聊最近这段时间面试的感触吧。先说结论,别的城市倒不清楚,就只说武汉,行情的确是有些差的,主要体现在小公司开不起价,大点公司(武汉其实也没什么大公司)又很难过简历筛,再加之岗位有限,所以整体的感受就是水深火热


从三月中下旬开始投递简历,一直到五月底决定去向,这期间在招聘软件上打了上百次招呼,拿到十二个面试机会,通过的有七家,最终选择了离家还算比较近,工作流程以及规模还不错的一家公司入了职。


这段时间可以说是要比平时上班还要累的,工作日每天起来就会去刷一刷招聘软件,去看看有没有新出的职位可以聊一下的,但渐渐的就会发现,招聘软件翻来覆去就那么几家公司,还都是常年招聘的,新出的机会可能要好久才会遇到一次。


能约到面试的几天心态还会好一些,可一旦连续几天没有约到面试,投递简历都石沉大海,那个时候内心就会开始有些焦虑,很容易会想要不要随便找一家将就下得了,但好在每次有这种想法的时候,都会有新的面试邀约出现,也算是挺幸运的了。而且根据每次面试的过程来看,目前我点的技能点是完全够用了的,甚至面一些小公司的时候,有时能清晰的感受到在吊打面试官,这也算是无形中增加了我的信心吧,能够让我继续战斗下去~ 而且也非常感谢在找工作时给我鼓励的掘友,当时面了一家公司,而面试官是一位掘友的朋友,可能下去后面试官和掘友提起了我的面试,晚上在掘金收到了掘友的私信,说我的技术一定没问题的,而且算法可以,一定要去投一投大公司~ 当天收到私信时,可以说真的是热泪盈眶,感受到了寒冬中的小小温暖,真的非常感谢~


然后说一下面试体验吧,面试体验真的和公司规模成正比的。


窒息的面试体验


我面的这几家,有一些小公司的面试官或者hr真的各种作妖:



  • 有的时候吊打了面试官,然后hr来谈薪想压价,拿什么压我都能理解,毕竟公司给到hr的预算可能有限,但是拿技术来压,真就不理解,面试官都没说什么,甚至当场说技术确实很不错,然后一个hr来尝试根据之前做的项目找漏洞去聊技术,聊复杂度去压价,真的是让人难以理解。

  • 有的公司则是非常的小,然后面试官应该就是公司领导吧,给了一份笔试题,做完后去面试,笔试当时做了15分钟,面试只12分钟,而面试的时候在刚进行2分钟我就已经想结束面试直接走人了,面试官就是对着他出的一份稀烂的笔试题一个个问,我也一个个给他答,每答一个他都先把你的答案给否定,然后尝试从回答中找漏洞,没有找到那就再问一个他自己现编的很奇怪的问题,真就离谱,也真是我素质还算好,没有当场去怼他,当时面的12分钟真的是折磨

  • 再不然有些面试官,就是简历也不细看,就会去问一些冷门API的用法,这一家当时我已经面到后期了,见了形形色色的面试官,所以也不惯着,直接就问他,你问这个有什么用呢?你是想招干活的人,还是想招可培养的人?那你面试问一个API能问出来什么呢?


愉快的面试体验


说完了小公司的体验,再说一些体验还不错的面试吧,一个体验比较好的面试给人的感觉就是,对方是能把我掌握的技术深度和广度都给探到,并且双方面试过程更像是探讨的过程



  • 有的面试官会在听你介绍项目难点以及解决方案的时候,逐步的引导你去思考出更优的解决方案

  • 有的面试官则会给你一种感觉就是,这个面试官真的很大佬,比如我遇到的一个面试官精通源码,虽然我也看过并且写过源码文章,但在很多细节的地方还是会有所遗忘,在面试的过程中,有的地方思路乱了,面试官则会在我把我知道的都讲完之后,去完整的给梳理一次思路,并说明整个的运行流程。


这两种面试官其实都有一个共同的点,就是他是在找你技术的深度和解决问题的能力,让你尽可能的展示自己,而不是对着一份面试题或者就是想刁难你找优越感


最后的选择


最终,选择的这家,其实薪资上的涨幅很小,但工作强度会比上一家大上不少。面了2个月,这个过程很累,我也没有太多的能量去接着去面试了,而这家公司整体面试体验给我的感觉还可以,就先入职看看喽~


然后,关于自己的职业发展,目前其实是有些迷茫的,刚入行前端的时候,感觉当时的机会还是很多的,能看到很多大厂的招聘要求以及结合一些在网上看到的一些大佬的经历,然后我就做出了规划:去研究源码和算法参与一些开源,当工作经验够3年之后,去尝试投递一下大厂,看一看新的机会。可是现在,当经验,技能可以达到要求之后,市场却凉下来了,不是92的学历或者大厂的履历,连简历筛都很难过的去,小一点的公司也想用较低的工资去招一个经验丰富的人,然后面试就还会问对加班的看法,甚至有的还会问无效加班接不接受,感觉整个市场都是一个让人无法理解的样子


最后


上面聊了这么多,不管怎样,也确实是当前武汉前端求职环境的现状大佬当然无所畏惧),所以,如果有朋友还跃跃欲试想换个环境,那我建议也是,如果可以的话找好再走,不要着急但这个问题的点就在于,很多公司会要求线下面试,就算线上面试,时间安排其实也会很不方便),可以投递一下先试试水,感受一下市场。但如果是有自己的规划或者实在是想要换个环境的朋友,可以根据我上面说的,只要能做好心理预期可能会连续打招呼两三天,甚至一周都没有回应),确保自己的心态稳定因为这本来就不是个人的问题,我们能做的就是把所掌握的技术准备充分就可以了),其实也可以一试,机会是有的,但是不多,需要自己去争取,并把握住


最后的最后,关于起这个标题,其实是我在一开始写这篇文章的时候脑海中就浮现的宫崎骏的这个电影和这句话。。。关于这个电影,网上有很多的评价,有的人会觉得这个电影不知道到底想说些什么,教会我们些什么。那有没有可能,老爷子其实也没打算教我们什么,当下的环境已经塞给我们太多东西,可以单纯的感受一下宫崎骏为我们创造的奇幻世界也是挺好的~ 你想活出怎样的人生其实都没有问题,或奋斗,或躺平,或去大城市,或留在小城市都只是一个选择,一种体验而已,没什么对错之分。所以这句话是在问掘友,也是在问我自己吧~


后续的个人规划,其实我也还没有很明确,现阶段,打算先继续搞一搞自己感兴趣的技术吧,不管环境怎样,个人的状态怎样,只要是在向前的,我想总归是好的吧,后续也会继续输出一些有意思的内容,掘友们共勉~


作者:沽汣
来源:juejin.cn/post/7376177615441117238
收起阅读 »

移动前端混合开发技术演进之路

本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂 前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮...
继续阅读 »

本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂



前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮演的关键角色及带来的变革。原生能力缺失、长时间白屏、用户操作响应不及时等web开发的问题是如何被解决的?


一、诞生背景


早期移动应用开发,由于机器硬件性能的方面影响,为了更好的用户体验(操作响应、流畅度和原生的能力),主要集中在原生应用开发上。


1.1 原生开发的缺点


原生应用开发周期和更新周期长,也逐渐在快速的迭代的互联网产品产生矛盾。


缺点:



  • 开发周期长:开发调试需要编译打包,动辄就需要几分钟甚至十几分钟,相比H5的亚秒级别的热更能力,是在太长了;

  • 更新周期长:正常的发版需要用户手动更新,无法做到H5这种发布即更新的效率。

  • 使用前需要安装;

  • 需要多端开发;(Android和iOS两端开发人力成本高)


1.2 web开发的缺点


原生应用的研发效率问题,也逐渐在快速的迭代的互联网产品产生矛盾。这时候,开发人就自然而然的想到web技术能力,快速开发和发版生效和跨平台能力。


web技术开发的H5界面,相比原生应用,缺点也很明显:



  1. 缺少系统的提供原生能力;

  2. 页面白屏时间长(原生基本可以做到1秒内,h5普遍在2秒以上);

  3. 用户操作响应不及时(动画卡、点击没有反应);


把Native开发和web开发的优缺点整合一下,就诞生了Hybrid App。Hybrid App技术从诞生到现在一直在解决这3个问题。


二、 提供原生能力


JSBridge技术是由 Hybrid 鼻祖框架phoneGap带到开发者的视野中,解决了第一个问题。它通过webview桥接(JSBridge)的方式层解决web开发能力不足的问题,让web页面可以用系统提供原生能力。


2.1 技术原理


Android原生开发提供了各种view控件(类比Dom元素:div、canvas、iframe),其中就用一个webview(类比iframe)。JSBridge 就像其名称中的『Bridge』的意义一样,是 Native 和非 Native 之间的桥梁,它的核心是 构建 Native 和非 Native 间消息通信的通道,而且是 双向通信的通道


image.png


双向通信的通道:



  • JS 向 Native 发送消息 : 调用相关功能、通知 Native 当前 JS 的相关状态等。

  • Native 向 JS 发送消息 : 回溯调用结果、消息推送、通知 JS 当前 Native 的状态等。


2.2 实现细节


Android可以通过webview将一些原生的Java方法注入到window上供Javascript调用。Javascript也可以直接在window上挂着全局对象给webview执行。


2.2.1  JavaScript 调用 Native


Android 可以采用下面的方式:


public class JSBridgeActivity extends Activity{ 
private WebView Wv;

@Override
publicvoidonCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
Wv = (WebView)findViewById(R.id.webView);
Wv.getSettings().setJavaScriptEnabled(true);
// 4.2 使用 @JavascriptInterface
Wv.addJavascriptInterface(new JavaScriptInterface(this), "nativeBridge");
// TODO 显示 WebView
}
}


public class JavaScriptInterface{
@JavascriptInterface
public void postMessage(String webMessage){
// Native 逻辑
}
}

前端调用方式:


// android会在window上注入nativeBridge对象
window.nativeBridge.postMessage(message);

native层除了上述方式被Javascript调用,还有可以拦截alert、confirm、console的日志输出、请求URL(伪协议)等方式,来的获取到Javascript调用native的意图。


2.2.2 Native 调用 JavaScript


相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单, WebView 组件,都以子组件的形式存在于 View/Activity 中,直接调用相应的 API 即可(类比浏览器的window中的原生方法)。


// android 4.4之前
webView.loadUrl("javascript:"+javascriptString)

// android 4.4之后
webView.evaluateJavascript(
javaScriptString, // js表达式
new ValueCallback<String>() { // 表达式的值通过回调给native
@Override
public void onReceiveValue(String value){
// 鉴权拦截,一般估计页面域名白名单的方式
JSONObject json = new JSONObject(value)
switch(json.bridgeName){
// 处理
}

}
}
);

2.3  JSBridge 接口


JSBridge 技术是对JavaScript 和 Native之间的封装成JS SDK方便前端JS调用,主要功能有两个:调用 Native和 接收Native 被调。


(function () {
var id = 0,
callbacks = {};


window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
var thisId = id ++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage(JSON.stringify{
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId; // Native 将 callbackId 原封不动传回
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} elseif (bridgeName) {


}
}
};
})();

JSBridge通过建立一个通信桥梁,使得JavaScript和原生代码可以相互调用,实现高效的数据传输和交互。这个过程是跨线程异步调用的,数据传输一般会经过两次序列化(还有提升的空间)


三、解决白屏


3.1 白屏产生的原因


原生APP安装后启动页面,在正常情况是不用再从网络获取资源,只需要请求后端接口获取数据就可以完成渲染了,网页不需要安装才,每次打开web页面都会从远程服务加载资源后,再请求后端数据后才能渲染。在用户等待资源加载过程和浏览器渲染未完成中,就会出现白屏。造成白屏的主要原因 -- 资源网络加载


首屏渲染SSR.drawio.png


3.2 离线包技术


离线包主要是识别特定url地址(通常是url参数=离线批次id,即:_bid=1221)后保存到用户手机硬盘。用户下次打开H5页面就可以不用走网络请求。离线包一包也会提供预下载能力,保证首次打开H5页面也可以获得收益。



离线包是完整的资源分发系统,需要一个完整的技术团队来建设和维护的。



3.2.1 离线包分发过程


分发流程中主要涉及4种角色:



  • 离线配置平台:配置平台可以提供离线配置能力、离线包管理(上传、禁用、清空)、离线包使用统计、离线包准入审核(自动(包大小限制)+人工(解决特殊case))

  • 离线配置服务: 配置服务主要提供服务层能力,实现离线配置服务,离线包更新服务,离线资源长传下载服务、离线资源使用统计服务

  • 离线SDK: 端内接入离线SDK,SDK主要与离线配置服务进行交互,完成离线资源的管理和接入配置能力

  • Native侧 : 实现拦截请求在特定的协议下接入离线资源


image.png


3.2.2 离线包加载过程


离线包的加载流程


image.png


3.2.3 拦截实现细节


实现WebViewClient: 继承WebViewClient类,并重写shouldInterceptRequest方法。这个方法会在WebView尝试加载一个URL时被调用,你可以在这里检查请求的URL,并决定是否拦截这个请求。


public class MyWebViewClient extends WebViewClient {  
private InputStream getOfflineResource(String url) {
// ... 你的实现代码 ...
return null; // 示例返回null,实际中应该返回InputStream
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();

// 检查这个URL是否在你的离线包中
InputStream inputStream = getOfflineResource(url);
if (inputStream != null) {
// 如果在离线包中找到了资源,就返回一个WebResourceResponse对象
return new WebResourceResponse(
"text/html", // MIME类型,这里以HTML为例
"UTF-8", // 编码
inputStream
);
}
// 如果没有在离线包中找到资源,就返回null,让WebView按照默认的方式去加载这个URL
// 走网络请求获取
}
}

// 在你的Activity或Fragment中
WebView webView = findViewById(R.id.webview);
webView.setWebViewClient(new MyWebViewClient());

3.3 服务端渲染(SSR )


在3.1 白屏产生的原因,影响白屏的因素是JS和CSS资源和数据请求。如果,html请求得到的内容中直接包含首屏内容所需要内联的CSS和Dom结构。


首屏渲染.drawio (4).png


SSR通过在服务端(BFF)直接完成有内容的HTML组装。webview获取到html内容就可以直接渲染。减少白屏时间和不可交互时间。


3.3.1 增量更新和并行请求


SSR将本来一个简单框架HTML,增加了首屏内容所需要的完整CSS和Dom内容。这样的话,HTML请求的包体积就增大了多。其中:



  • 跟版本相关的样式文件CSS (变更频率低)

  • 跟用户信息相关的Dom内容(变更频率高)


HTML根据内容变更频率进行页面分割如下:


<!DOCTYPE html>
<html lang="en">
<head>
<title>OPPO用户体验评价</title>
<meta charset="UTF-8">
<script content="head">window._time = Date.now()</script>
<meta name="renderer" content="webkit|chrome">
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="x5-orientation" content="portrait">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-COMPATIBLE" content="IE=Edge,chrome=1">
<meta name="nightmode" content="disable">
<meta name="color-scheme" content="light">
<!-- css内联内容开始 -->
<style>
/*http://www.xxx.com/wj-prod/style.css*/
/**
* 替换url的css内容,内容比较多
*/

</style>
<!-- css内联内容结束 -->
</head>
<body>
<!-- dom内容开始 -->
<div id="app">
<!-- 拼接好的html结果 -->
<div>
<span></span>
</div>
</div>
<!-- dom内容结束 -->
<!-- 数据内容开始 -->
<script content="page-data">
// 直出的数据,方便vue、react等框架回填状态,声明式UI才必须
window.syncData = {/**服务端获取的数据**/}
</script>
<!-- 数据内容结束 -->
<script crossorigin="anonymous" src="//cdn.xxx.com/wj-prod/client.bundle.js?_t=1"></script>
</body>
</html>

客户端和BFF层大概工作流程如下:


image.png


首屏渲染.drawio (5).png


手机QQ将这套方案开源了:github.com/Tencent/Vas… (我曾经也是这套方案的参与者和使用者)


3.4 总结


为了更快的渲染出页面,发展了离线包技术、服务器端渲染(SSR)、Webview启动并行等一系列的技术方案,这些技术可以单个使用,也可以组合使用。



  • 对于首次加载的页面,使用服务器端渲染(SSR)和Webview启动并行,是可以很好的解决白屏问题,适用H5活动页面。

  • 对于二次加载的页面,使用离线包技术、服务器端渲染(SSR)和Webview启动并行,可以在不经过网络请求也可以展示页面,适用固定入口客户端页面;


四、解决卡顿


使用过程发现H5网页相比于原生页面,更容卡顿,甚至造成页面卡死的问题。这个章节就主要解决为啥浏览器渲染的H5会比原生卡?Hybrid开发用哪些技术如何解决这个问题?


4.1 浏览器渲染的慢


浏览器技术的发展历程已有超过30年的历史,Chrome内核有超过2400万行代码,有很重的历史包袱。


4.1.2 渲染流程


浏览器渲染页面使用了多线程的架构,发生卡顿的主要原因在:渲染线程和JS引擎线程,他两是互斥的,Javascript长时间执行会导致渲染线程无法工作。
image.png


GUI渲染线程(GUI Thread):



  1. 负责渲染浏览器界面。

  2. 解析HTML、CSS,构建DOM树和CSS规则树,并合成渲染树。

  3. 布局(Layout)和渲染(Paint)页面内容。

  4. 与JS引擎线程互斥,当JS引擎线程执行时GUI渲染线程被挂起,GUI更新会被保存在一个队列中,等JS引擎空闲时立即执行。


JS引擎线程(JS Engine Thread):



  1. 也称为JS内核(在Chrome中为V8)。

  2. 负责解析和执行JavaScript代码。

  3. 单线程设计,JS运行过长会阻塞GUI渲染。


事件触发线程(Event Dispatch Thread):



  1. 用于控制事件循环。

  2. 当事件(如点击、鼠标移动等)被触发时,该线程会将事件放到对应的事件队列中,等待JS引擎线程处理。


合成器线程(Compositor Thread)和光栅线程(Raster Thread):



  1. 这两个线程在渲染器进程中运行,以高效流畅地渲染页面。

  2. 合成器线程负责将不同的图层组合成最终用户看到的页面。

  3. 光栅线程则负责将图层内容转换为位图,以便在屏幕上显示。


以用户点击操作为例:


image.png


如果界面的刷新帧率是60帧,在不掉帧的情况。执行时间只有 1000 ms / 60 = 16.66 ms。上图中间的JS引擎线程和渲染线程的执行是串行,而且不能超过16.66 ms。(留给JS引擎和渲染线程执行的时间本身不多,60帧只有有16ms,120帧只有8ms)这就是浏览器为啥比原生渲染卡。


4.2 声明式UI


浏览器渲染慢的主要原因是JS引擎线程和渲染进程的执行互斥, 那么,最简单解决方式就是将渲染线程改造按照帧率来调度,不再等JS引擎线程全部执行完再去渲染。但是,由于浏览器最初涉及的JS引擎线程是为了应对命令式UI渲染方案,命令式UI对界面的修改是不可预测。


4.2.1 命令式UI


命令式UI关注于如何达到某个特定的用户界面状态,通过编写具体的操作指令来直接操纵界面元素。关注于操作步骤和过程,需要编写具体的代码来实现每个步骤。


// dom找到需要变更的节点
const list = document.querySelector('#content')
// 修改样式
list.style.display = 'none'
// 增加内容
list.innerHTML += `<div class="item">列表内容</div>`

优点: 是入门简单,讲究一个精确控制直接操作。


缺点: 直接操作界面,带来对UI界面渲染的不可以预测性;


4.2.1 声明式UI


声明式UI(Declarative UI)是一种用户界面编程范式,它关注于描述UI的期望状态,而不是直接编写用于改变UI的命令。在声明式UI中,开发者通过声明性的方式定义UI的结构、样式和行为,而具体的渲染和更新工作则由框架或库自动完成。


声明式UI编程范式:


image.png


function List(people) {
const listItems = people.map(person =>
<li key={person.id}>
<img
src={getImageUrl(person)}
alt={person.name}
/>

<p>
<b>{person.name}</b>
{' ' + person.profession + ' '}
known for {person.accomplishment}
</p>
</li>

);
return <ul>{listItems}</ul>;
}

优点: 入门难度有所增加,代码更加简洁,带来更高和可维护性,可以直接根据数据预测UI更新


缺点: 入门难度有所增加,灵活性没有命令式UI高;


4.2.1 虚拟DOM


声明式UI强调数据驱动UI更新,一般声明式UI框架中,都还会引入虚拟DOM技术。虚拟DOM(Virtual DOM)是一种在前端开发中广泛使用的技术,它通过JavaScript对象来模拟真实的DOM结构,从而优化Web应用程序的性能和渲染效率。



  • 核心思想:将页面的状态抽象为JavaScript对象表示,避免直接操作真实的DOM,从而提高性能和渲染效率。

  • 工作流程:



    • 初始渲染:首先,通过JavaScript对象(虚拟DOM)表示整个页面的结构。这个虚拟DOM是一个轻量级的映射,保存着真实DOM的层次结构和信息。

    • 更新状态:当应用程序的状态发生变化时,如用户交互或数据更新,虚拟DOM会被修改。这个过程操作的是内存中的JavaScript对象,而不是直接操作真实的DOM。

    • 生成新的虚拟DOM:状态变化后,会生成一个新的虚拟DOM,反映更新后的状态。

    • 对比和更新:通过算法(如Diff算法)将新的虚拟DOM与旧的虚拟DOM进行对比,找出它们之间的差异。

    • 生成变更操作:根据对比结果,找出需要更新的部分,并生成相应的DOM操作(如添加、删除、修改节点等)。

    • 应用变更:将生成的DOM操作应用到真实的DOM上,只更新需要变更的部分,而不是整个页面重新渲染。




virtual-dom为例,虚拟Dom的渲染流程大致如下:


import h from 'virtual-dom/h'
import diff from 'virtual-dom/diff'
import patch from 'virtual-dom/patch'

// 第一步:定义渲染函数,UI = F( state)中的f,
// 开发人员编写渲染模版(react对于是jsx,vue对应的template),由构建工具生成;
function render(count) {
return h('text', { attributes: { count } }, [String(count)])
}

// 第二步:初始化vtree
let tree = render(count) // We need an initial tree

// UI变更
setTimeout(function () {
// 第三步:更新state,重新生成vtree
count++
const newTree = render(count)

// 第四步:对比新旧vtree的差异
const patches = diff(tree, newTree)
console.info('patches', patches)

// 第五步:增量更新dom
// patch(rootNode, patches)

tree = newTree
}, 1000)

相比于命令式UI的开发,声明式UI和虚拟DOM技术结合后,UI渲染过程表示用简单的数据结构就可以表述(第四步骤得到结果序列化),能序列化的好处就是可以很简单完成跨线程处理。


4.3 React Native


声明式UI和虚拟DOM是由React带到开发的视野中。虚拟DOM除了提供声明式UI的高性能渲染能力,它还有一个强大的能力--抽象能力。



4.3.1 组件抽象


在开发者的代码与实际的渲染之间加入一个抽象层,这就可以带来很多可能性。对于React Native 渲染实现:



  • 在IOS平台中则调用Objective-C 的API 去渲染iOS 组件;

  • 在Android平台则调用Java API 去渲染Android 组件,而不是渲染到浏览器DOM 上。


image.png


React Native的渲染是使用不同的平台UI Manager 来渲染UI。因此,React Native对UI开发的基础组件进行整合和对应


React NativeAndroid ViewIOS ViewWeb Dom
<view><ViewGr0up><UIView<div>
<Text><TextView><UITextView><p>
<Image><ImageView><UIImageView><img>

4.3.2 样式渲染


组件结构通过抽象的基础可以完成每个平台的转换。UI界面开发出来结构还需要样式编写。React Native引用了Yoga。Yoga是 C语言写的一个 CSS3/Flexbox 的跨平台 实现的Flexbox布局引擎,意在打造一个跨iOS、Android、Windows平台在内的布局引擎,兼容Flexbox布局方式,让界面布局更加简单。


4.3.3 线程模型


在React Native中,渲染由一个JS线程和原生线程。JS线程负责解析和执行JavaScript代码,而原生线程则负责渲染界面和执行原生操作。JS执行的结果(dom diff)异步通知原生层。


image.png


4.3.3 总结


React Native借助虚拟DOM的抽象能力,把逻辑层的JS代码执行单独抽到JS引擎中执行,不再与UI渲染互斥,可以留更多时间给UI渲染线程。


UI渲染相比浏览器渲染性能提升主要在两点:



  • JS层不再互斥UI渲染;

  • UI渲染由浏览器渲染改成原生渲染;


UI放到Natie层渲染,逻辑放在JS层执行,Natice层与JS层通过JSBridge(24年底会默认替换成JSI,以提高数据通信性能,有兴趣可以去了解)进行通信。


Weex和快应用的实现原理跟React Native类似,主要的差异是在编写声明式UI的DSL,这里就不一一讲解


4.4 微信小程序


微信小程序是从公众号的H5演变而来的。2015年微信对外发布JS-SDK(JS Bridge)提供微信的原生能力(类似早期的phoneGap的),解决了移动网页能力不足的问题。但是,页面加载白屏、网页安全和卡顿问题依旧没被解决。


微信在2017年设计一个全新的系统来解决这些问题,它需要使得所有的开发者都能做到:



  • 快速的加载

  • 更强大的能力

  • 原生的体验

  • 易用且安全的微信数据开放

  • 高效和简单的开发


4.4.1 双线程架构


有了虚拟DOM这个抽象层,UI界面开发的的逻辑层和视图层可以分离。小程序的渲染层和逻辑层分别由两个线程管理(视图层是 WebView,逻辑层是 JS 引擎


image.png



  • 视图层主要负责页面的渲染,每一个页面Page View对应一个Webview(不能超过10个页面栈)。

  • 逻辑层负责js的执行,一个JS执行的沙箱环境;


微信小程序的双线程有如下主要优点:



  1. javascript脚本执行不会抢占ui渲染资源,使整体页面渲染更快;

  2. 每个PageView是由一个webview单独渲染,页面切换效果上更接近原生,比公众号h5网页浏览体验要好;

  3. 安全管控,独立的沙箱环境运行javascript逻辑代码,避免了浏览器的开放api操作dom、跳转页面等,更加安全。


4.4.2 开发的DSL


小程序包含一个描述整体程序的 app 和多个描述各自页面的 page。


个小程序主体部分由三个文件组成,必须放在项目的根目录,如下:


文件必需作用
app.js小程序逻辑
app.json小程序公共配置
app.wxss小程序公共样式表

一个小程序页面由四个文件组成,分别是:


文件类型必需作用
js页面逻辑
wxml页面结构
json页面配置
wxss页面样式表

WXML和WXSS是微信官方创造的DSL,需要进行编译后才能被Webview解析执行。可以从微信开发者工具包文件中找到 wcc 和 wcsc 两个编译工具



  • wcc 编译器可以将 wxml 文件编译成 JS 文件

  • wcsc 编译器可以将 wxss 文件编译成 JS 文件。


 
WXML(WeiXin Markup Language)是框架设计的一套标签语言,结合基础组件事件系统,可以构建出页面的结构。(类比虚拟DOM中的Render函数)


<!--wxml-->
<view>
<text class="text">{{message}}</text>
</view>

将wcc拷贝到当前的index.wxml同级目录, 执行


./wcc -js index.wxml >> wxml.js

将wxml.js的内容复制到浏览器的console中执行后,输入:


$gwx('index.wxml')({
message: 'hello world'
})

可以获得vtree:


{
"tag": "wx-page",
"children": [
{
"tag": "wx-view",
"attr": {},
"children": [
{
"tag": "wx-text",
"attr": {
"class": "text"
},
"children": [
"hello world"
],
"raw": {},
"generics": {}
}
],
"raw": {},
"generics": {}
}
]
}

WXSS (WeiXin Style Sheets)是一套样式语言,用于描述 WXML 的组件样式。(跟CSS类似,增加了rpx相对尺寸,可以参考REM的响应式布局)


page{
display:flex;
background-color: #fff;
}
.wrap{
width:320rpx;
height: 200rpx;
}
.text{
color:red;
font-size:12px
}

将wcsc拷贝到当前的index.wxss同级目录, 执行


./wcsc -js index.wxss >> wxss.js

最后将wxss.js的内容拷贝到浏览器去运行,即可得到:


image.png


(page的样式转化成了body,rpx转成px)


4.4.3 逻辑层和渲染层


逻辑层主要执行app.js和每个页面Page构造器。最终将Page中data修改后的结果通过setData同步给渲染进程。


image.png


逻辑层是一个沙箱的执行环境,该环境不存在DOM API、window、document等对象API和全局对象。换句话来说,小程序相比传统H5是更加安全。小程序中访问用户相关信息是不能像H5直接调用浏览器API,需要经过用户授权才或者由用户操作触发才可以被调用。


小程序的渲染层是在webview执行的,主要将运行wxml和wxss编译后的代码;



  • wxss文件编译成js,之后后会往head中插入style样式

  • wxml编译成声明式UI的render函数,接受逻辑层的data来更新vtree,dom diff ,增量更新dom


render函数中的data由逻辑层调用setData跨线程传给渲染层, 渲染层相比传统的浏览器渲染页面少了渲染前的data生成。相比React Native,渲染层仍然会执行JS(主要虚拟Dom更新)。


image.png


逻辑层和渲染层的在不同平台的实现方式:


运行环境逻辑层渲染层
iOSJavaScriptCoreWKWebView
AndroidV8XWeb(腾讯自研,基于Mobile Chrome内核)
PCChrome内核Chrome内核
小程序开发工具NW.jsChrome WebView

4.4.4 Skyline渲染引擎


小程序早期的渲染层是使用webview,每个PageView对一个webview,内存开销是很多。



Skyline渲染引擎其实可以被看作一个被优化后的webview,并在其内置了更加优秀的动画系统、跨线程传说方案



微信增加了渲染引擎 Skyline,其使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。


image.png


Skyline 创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。这种新的架构相比原有的 WebView 架构,有以下特点:



  • 界面更不容易被逻辑阻塞,进一步减少卡顿

  • 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销

  • 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销

  • 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销


 Skyline 的首屏时间比 WebView 快 66%


image.png


Skyline 的内存占用比 WebView 减少 50%


image.png


详细可以参考:developers.weixin.qq.com/miniprogram…


4.4.5 总结


微信小程序采用双线程的架构方案,即解决web困扰已久的安全问题,而且也在一定程度上优化了页面渲染性能。虚拟DOM的抽象能力,使得PageView可以是WebView、React-Native-Like、Flutter 等来渲染


微信小程序也有类似离线包的技术,将用户访问的小程序缓存在微信APP的安装目录中,来解决页面白屏问题。首次加载白屏问题通过native层loading页面来遮盖,因此,小程序首次使用也会有2到3秒的加载过程(小程序分包要求,加载包不能超过2M,加载时间可以做到可控😄)。。


4.5 总结


React Native、Weex、微信小程序、快应用等技术,提供了一整套开发完备的技术和工具来实现混合开发。包括不限于:



  • 平台提供基础UI组件为基础;

  • 声明式UI作为首选,虚拟DOM的抽象能力,UI渲染框架可以多层级多语言实现;

  • 双线程和JSBridge(JSI),使得JS逻辑执行和UI渲染分离;

  • 完整工具类,编译、打包、HMR;

  • 分包,一个应用可以由多个模块包组成;

  • 亚秒级别的热更新能力;


后面出现的Flutter、ArkUI框架也基本围绕这些技术理念进行整合(当然还有编译技术的优化JIT向AOT,带来更快的启动速度)。


(Flutter、ArkTS带来更快的启动速度的技术方案后面再补到文章内吧)


五、发展历程


混合开发的发展史是一段技术革新和演进的过程,它标志着移动应用开发从单一平台向跨平台、高效率的方向转变。


image.png



  • JSBridge让JavaScript拥有原生能力,JSI等技术让JavaScript直面C++,带来更加高效的传输速度;

  • 离线包技术,兼顾加载和留存,SRR仍是很有效优化首屏速度的手段;

  • 分包技术是提高加载速度和开发效率;

  • 声明式U开发范式,加上虚拟Dom抽象能力,解偶上层开发与底层渲染框架,新的渲染框架不断涌现;

  • JSCore引擎的双线程架构,打破逻辑层和UI层间的互斥,即解决Web困扰已久的安全问题,也缓解浏览器渲染性能问题;


作者:azuo
来源:juejin.cn/post/7382051737362284559
收起阅读 »

扫码出入库与web worker

web
我为什么会用到这个呢,那还得从最近项目的一个扫码出入库的需求说起,之前客户的扫码出入库都是c端的,在效率方面没有明显的问题,但是后面这个项目的升级,就把c端的扫码部分摞到了B端了 大体需求是这样,客户用无线扫码枪扫回运单上的条码,然后扫码枪使用HID键盘模式(...
继续阅读 »

我为什么会用到这个呢,那还得从最近项目的一个扫码出入库的需求说起,之前客户的扫码出入库都是c端的,在效率方面没有明显的问题,但是后面这个项目的升级,就把c端的扫码部分摞到了B端了


大体需求是这样,客户用无线扫码枪扫回运单上的条码,然后扫码枪使用HID键盘模式(扫码枪相当于一个键盘),在一个一直聚焦的输入框输入扫到的条码,然后我这边监听到条码调接口录入库,成功后再语音播报扫码结果,同时刷新结果,刷新统计信息。


听上去很简单是不是,想象是美好的,可现实就残酷了,在初始版的时候,功能是做出来了,本地出入库都没问题,但是发到生产就悲催了,乱七八糟的问题


比如



  • 1.扫码枪精度的问题,扫码识别率低下,扫10次才能正确识别1次

  • 2.扫出来的码,断码,原本以为扫码枪扫一次就等同于我复制一个条码进输入框,可结果是扫码枪一次扫入,输入框接受的条码就像一个字符串流一样,一个字符一个字符进入的,这就导致中间间隔稍微长一点,就被错误的识别为另外一个条码(扫码是多个码连续扫入的)

  • 3.语音播报延迟,经常会有语音播放不出或者播放一半,这个..


这个就很让人无语,明明本地啥问题也没有


第一个问题,扫码枪精度,确实是有,因为我做的时候拿的扫码枪是一个有线的扫码枪,那识别率才叫一个高,准确率差不多95%,几乎没遇到解码啥的问题,可换成无线的扫码枪就傻眼,第一个问题就很烦,想到几千个客户没办法统一更换扫码枪,于是就想想优化一下条码编码呢,
我这边条码是用的jsBarcode组件,默认的编码类型CODE128,嗯~~问题会不会出在条码规范上呢


我去查了一下,条码的编码规范大致有以下几种


条码类型类别描述常见应用编码长度
UPC-A1D通用产品代码,常见于零售业零售商品12位数字
UPC-E1DUPC-A的压缩版本小型零售商品6位数字
EAN-131D欧洲商品编号,国际通用图书、零售商品13位数字
EAN-81DEAN-13的压缩版本小型商品8位数字
Code 391D可变长度,包含字母、数字和特殊字符工业、政府可变长度
Code 1281D高密度条码,表示所有128个ASCII字符物流、运输可变长度
Interleaved 2 of 5 (ITF)1D数字条码,每两个数字组成一对交错编码分销、仓储偶数位数字
QR Code2D可存储大量数据,包括文字、数字、二进制数据和汉字支付、信息分享、广告可变长度
Data Matrix2D高密度编码,适用于小型物品标识电子元器件、医疗设备可变长度
PDF4172D可编码大量数据身-份-正件、运输标签可变长度
Aztec Code2D高容错性,适用于票务和登机牌票务、登机牌可变长度

我这里着重说说CODE39和CODE128;我发现CODE39生成的条码比CODE128生成的长很多,我这把无线扫码枪扫很久都扫不出来,识别超慢,这个很奇怪,之前客户C端系统找技术查了一下,编码规范是CODE39,我就懵逼了,都是CODE39,为啥我们生成的码就识别这么慢,捣鼓了很久也没个结果,如果有哪位知道的可以给我说一下,就索性放弃这种编码模式,改用CODE128吧,查了一下,这是一种效率更高的编码方式,CODE39条码较长的主要原因在于它的编码效率较低,每个字符占用的空间较大,而CODE128通过更加紧凑和高效的编码方式,能够在同样的内容下生成更短的条码,于是撺掇同事把所有的条码都用CODE128生成,至此,扫码枪识别效率低的问题算事过去了


然后就是第二点,扫出来的码,断码问题,这个也因为换了短码好那么一点,可扫出来也经常有解码内容变长,的问题,暂时还在想办法优化


最后就是语音播报延迟,卡壳,甚至没有语音的情况,这个问题比较恼火,我这边组件是使用的开源库howler.js,这个库的优点就是兼容性好,可以播放包括mp3, opus, ogg, wav, aac, m4a, m4b, mp4, webm, 等多种格式,而且还支持分轨sprite播放,这个是我的最初的代码


import config from "./config";
import "./lib/howler.min";
const ENV = import.meta.env;

class VoiceReport {
public list = [];
constructor() {
this.initVoice();
}
// 目录放在@/assets/voice/ 下面
public voiceList: any = import.meta.globEager("@/assets/voice/*.mp3");
public voiceNameList = Object.keys(this.voiceList);
// 初始化语音播报器列表
initVoice = () => {
config.forEach((v) => {
const item = {
name: `Ref${v.codeType}${v.codeKey}`,
code: v.codeKey,
codeName: v.codeName,
voice: "",
};
const voiceIndex = this.voiceNameList.findIndex((voice) =>
String(voice).includes(v.codeKey)
);
if (voiceIndex > -1) {
item.path = this.voiceNameList[voiceIndex];
item.voice = this.voiceList[this.voiceNameList[voiceIndex]];
}
this.list.push(item);
});
};
// 播放
play = (code: string) => {
const Stream = this.list.find((v) => v.code == code);
let StreamVoide = null;
if (ENV?.DEV) {
StreamVoide = Stream?.path;
} else {
StreamVoide = Stream?.voice?.default;
}
// 提供的条码不在列表中
if (!StreamVoide) return;
try {
const sound = new Howl({
src: [StreamVoide],
volume: 1.0,
html5: true,
onplayerror: (e) => {
console.log("error", e);
},
});
sound.play();
} catch (e) {
console.log(e);
}
};
}

export default VoiceReport;


这个倒是能放,可能不能优化呢


我首先想到的是就从播放器本身优化呢,我想着会不会是加载的延迟或者加载文件过多,想着将所有的文件进行合并,再生成sprite信息,弄是弄了,可是不论如何就是load报错,我再把这个多个mp3合并成一个文件@/assets/voice/fullStack.mp3,进行生成sprite,来加载,加载是加载上来了,可同样遇到播放错误,播放的track根本不是我期望的那个


这个是错误代码:



import config from "./config";
import "./lib/howler.min";
import fullVoice from "@/assets/voice/fullStack.mp3";
const ENV = import.meta.env;

class player {
public list: any = [];
public player: any = {};
constructor() {
this.initVoice();
}
// 目录放在@/assets/voice/ 下面
public voiceList: any = import.meta.globEager("@/assets/voice/*.mp3");
public fullVoice: any = fullVoice;
public voiceNameList = Object.keys(this.voiceList);
public sprite: any = {};
public streamVoide: any = [];
// 时间戳转换为秒
timeStringToSeconds = (timeStr: string) => {
const parts = timeStr.split(":");
const hours = parseInt(parts[0]);
const minutes = parseInt(parts[1]);
const seconds = parseInt(parts[2]);

return hours * 3600 + minutes * 60 + seconds;
}
// 初始化语音播报器列表
initVoice = () => {
config.forEach((v, index) => {
const item = {
name: `Ref${v.codeType}${v.codeKey}`,
code: v.codeKey,
codeName: v.codeName,
voice: {},
path: "",
duration: this.timeStringToSeconds(v.duration ?? 0) * 1000,
durationStart: 0,
durationEnd: 0,
};
item.durationStart = !index ? 0 : this.list[index - 1].durationEnd;
item.durationEnd = item.durationStart + item.duration;
this.sprite[v.codeKey] = [item.durationStart, item.durationEnd];
const voiceIndex = this.voiceNameList.findIndex((voice) =>
String(voice).includes(v.codeKey)
);
if (voiceIndex > -1) {
item.path = this.voiceNameList[voiceIndex];
item.voice = this.voiceList[this.voiceNameList[voiceIndex]];
}
this.list.push(item);
/* eslint-disable */
// @ts-ignore
this.streamVoide.push(ENV?.DEV ? item.path : item.voice?.default);
});
/* eslint-disable */
// @ts-ignore
this.player = new Howl({
src: this.streamVoide,
volume: 1.0,
html5: true,
sprite: this.sprite,
onplayerror: (e: any) => {
console.log("play error", e);
},
onload: (e: any) => {
console.log("error", e);
}
});
window.player = this.player;
console.log(this.sprite, this.player, fullVoice)
};
// 播放
play = (code: string) => {
try {
this.player.play(code);
} catch (e) {
console.log(e);
}
};
}

export default player;

到现在还在持续找解决方案中,
最后,不得不把希望寄托在异步任务请求导致阻塞主线程这个猜想上,因为每完成一次扫码,会发起三个请求



  • 入库请求

  • 刷新结果列表请求

  • 刷新统计请求


这么多请求一起,接口稍微一慢就有可能导致播放卡顿的问题
这个在我经过一段时间的搜索之后发现,发现webworker可以处理这个问题


web worker

根据MDN的说法

Web Workers 是 Web 内容在后台线程中运行脚本的一种简单方法。工作线程可以在不干扰用户界面的情况下执行任务。此外,他们还可以使用 fetch() 或 XMLHttpRequest API 发出网络请求。创建后,工作人员可以通过将消息发布到该代码指定的事件处理程序来向创建它的 JavaScript 代码发送消息(反之亦然)。


既然是独立于主线程之外的一个,那就不可避免的会遇到身份验证和通信的问题,对于发起的请求没有携带身份信息,这个好办,就自己在封装一个axios方法fetch,将身份信息传过去ok,这里主要贴一下worker的内容,也很简单


import type { WorkerMessageDataType } from "../types/types";
import fetch from "@/utils/fetch";
import { throttle } from "lodash";
let Ajax: any = null;

// 从主线程接受数据
self.onmessage = function (e: WorkerMessageDataType) {
console.log("Worker: 收到请求", e);
const type = e.data?.type || "";
const data = e.data?.data || {};
// 一定要初始化
if (type == "init") {
const headers: any = e.data?.headers;
Ajax = fetch(headers);
}
// 请求刷新统计数据
if (type == "refreshScanCountData") refreshScanCountData();
// 请求刷新列表扫码结果
if (type == "refreshDataList") refreshDataList();
// 请求入库
if (type == "checkAddIntoStock") checkAddIntoStock(data);
};

// 向主线程发送数据
const sedData = (type: string, data: object) => {
const param = {
type,
data: data || {},
};
self.postMessage(param);
};

// 刷新统计数据,查询统计信息api
const refreshScanCountData = throttle(() => {
Ajax({
method: "post",
url: `/api/CountStatistics`,
data: {},
}).then((res: any) => {
sedData("refreshScanCountData", res);
});
}, 500);

// 刷新扫码结果数据
const refreshDataList = throttle(() => {
Ajax({
method: "post",
url: `/api/scanToStorage/page`,
data: {},
}).then((res: any) => {
sedData("refreshDataList", res);
});
}, 500);

// 请求入库
const checkAddIntoStock = (data: { barcode: string; [x: string]: any }) => {
Ajax({
method: "post",
url: `/api/scanToStorage`,
data,
})
.then((res: any) => {
// 刷新统计数据
refreshScanCountData();
// 刷新列表
refreshDataList();
sedData("checkAddIntoStock", {
barcode: data.barcode,
...res,
status: true,
});
})
.catch(() => {
sedData("checkAddIntoStock", {
barcode: data.barcode,
status: false,
});
});
};

在主线程页面写一个方法,初始化一下这个worker


// 加载worker
const initWorker = () => {
const headers = {
Authorization: "bearer " + sessionStorage.getItem("token"),
token: sessionStorage.getItem("token"),
currRoleId: sessionStorage.getItem("roleId"),
};
// 初始化,加入身份信息
WebWorker.postMessage({ type: "init", headers });
// 从worker接受消息
WebWorker.onmessage = (e) => {
console.log("Main script: Received result", e.data);
const type = e.data?.type || "";
const data = e.data?.data || {};

// 异步更新统计信息
if (type == "refreshScanCountData") {
ScanCountData.value = data;
}
// 刷新表格数据
if (type == "refreshDataList") {
dataTable.value.updateData(data);
}
};
};


这样就可以了,即便是这样,依然还有好多问题没解决,这个是我的第一篇文章,难免有错误疏漏,这个需求并没结束,我还会持续跟进更新的


作者:kiohang
来源:juejin.cn/post/7380342160581492747
收起阅读 »

用空闲时间做了一个小程序-二维码生成器

web
一直在摸鱼中赚钱的大家好呀~ 先向各位鱼友们汇报一下情况,目前小程序已经有900+的鱼友注册使用过。虽然每天都有新的鱼友注册,但是鱼友增长的还很缓慢。自从国庆前的文字转语音的工具上线到现在已经将近有1个月没有更新小程序了。但是今天终终终终终于又有个小工具上线了...
继续阅读 »

一直在摸鱼中赚钱的大家好呀~


先向各位鱼友们汇报一下情况,目前小程序已经有900+的鱼友注册使用过。虽然每天都有新的鱼友注册,但是鱼友增长的还很缓慢。自从国庆前的文字转语音的工具上线到现在已经将近有1个月没有更新小程序了。但是今天终终终终终于又有个小工具上线了,希望这个小工具可以帮助到更多的鱼友们(没错就是你们)


这次更新的工具是一个二维码生成器,虽然很多小程序存在这个工具,但是本人也是想尝试一下实现这个工具。老规矩,先来看下知名UI设计师设计的页面。







同样在工具tab页中增加了二维码生成器模块。从UI图中可以看出第一个表单页面不是很难,就是一个文本框、两个颜色选择、一个图片上传。这个页面我在开发中也是很快就完成了,没有什么技术含量。


当我做到颜色选择弹窗的时候是想从网上找一个现成的插件。但是找了半天没有找到合适的,只能自己手动开发一个。既然要做颜色选择器的功能就要先了解一下颜色的两种格式 (我这边的实现就这两种格式)


颜色的HEX格式

颜色的HEX格式是#+六位数字/字母,其中六位数字/字母是一种十六进制的表达方式。这六位分别两个一组,从左到右分别表示绿00表示最小,十进制是0FF表示最大,十进制是255。通俗点讲,某个颜色的数值越大,包含这个颜色就越多。如:#000000-黑色、#FFFFFF-白色、#FF0000-红色、#00FF00-绿色、#0000FF-蓝色。


颜色的RGB格式

颜色的RGB格式是rgb(0-255,0-255,0-255), 其中0-255就是HEX格式的十进制表达方式。这三个数值从左到右分别表示绿0表示最小;255表示最大。通俗点讲,某个颜色的数值越大,包含这个颜色就越多。如:rgb(0,0,0)-黑色、rgb(255,255,255)-白色、rgb(255,0,0)-红色、rgb(0,255,0)-绿色、rgb(0,0,255)-蓝色。


有了上面的概念,我的思路也就出来了。让用户分别选择这三种颜色的数值,然后通过用户选择的三种颜色的数值转成目标颜色,就可以完成颜色选择的功能。思路出来了之后就告知了UI,然后按照我的思路将效果图出了出来 (没错,就是先实现后出图)。实现中主要使用了vant-ui组件库的popupslider两个组件 (聪明人都喜欢用现成的)。贴一下部分实现代码:


show="{{ show }}" 
title="展示弹出层"
position="bottom"
bind:close="cancelHandle"
custom-style="background-color: #F3F3F9;border-radius: 40rpx 40rpx 0rpx 0rpx;"
root-portal>
class="color-popup">
class="popup-header flex flex_j_c--space-between flex_a_i--center">
class="flex-item_f-1">
class="title flex-item_f-1">{{ title }}
class="flex-item_f-1 flex flex_j_c--flex-end">
name="cross" size="32rpx" bind:tap="cancelHandle" />


class="color-picker" wx:for="{{ pickers }}" wx:key="index" wx:if="{{ index !== 3 }}">
class="color-picker-label">{{ item.label }}
class="flex flex_a_i--center">
class="slider-wrap flex-item_f-1 {{ item.field }}">
value="{{ item.value }}" min="{{ 0 }}" max="{{ 255 }}" data-index="{{ index }}" bind:change="changeHandle" bind:drag="changeHandle" custom-class="slider" bar-height="60rpx" active-color="transparent" use-button-slot>
class="slider-button" slot="button">


class="slider-value">{{ item.value }}


class="color-preview-box flex flex_a_i--center">
class="preview-box-wrap">
class="preview-box" style="background-color: {{ rgbaStyle }};">
class="preview-label">颜色预览

class="presets-box-wrap flex-item_f-1 flex flex_j_c--space-between">
class="presets-box flex flex_j_c--center flex_a_i--center {{ rgbaStyle === item.rgbaStyle ? 'active' : '' }}" wx:for="{{ presets }}" wx:key="index" style="background-color: {{ item.rgbaStyle }};" data-row="{{ item }}" bind:tap="chooseHandle">
class="active-box">



class="confirm-wrap flex">
class="hex-box flex flex_a_i--center flex_j_c--space-between">
#
{{ hex }}

class="confirm-button-box flex-item_f-1">
type="primary" custom-class="confirm-button" bind:click="confirmHandle" round>确定





import { rgb2Hex } from '../../utils/util'

const presets = [
[0, 0, 0, 255], [102, 102, 102, 255],
[0, 95, 244, 255], [100, 196, 102, 255],
[247, 206, 70, 255], [235, 77, 61, 255],
]

Component({
options: {
addGlobalClass: true
},
properties: {
show: {
type: Boolean,
value: false
},
title: {
type: String,
value: ''
},
value: {
type: Array,
value: [0, 0, 0, 255],
observer: function(val) {
const { pickers } = this.data
if(val.length) {
this.setData({
pickers: pickers.map((item, index) => {
return {...item, value: val[index]}
}),
})
this.setColor(val)
} else {
this.setData({
pickers: pickers.map((item, index) => {
return {...item, value: index === 3 ? 255 : 0}
}),
})
const rgba = [0, 0, 0, 255]
this.setColor(rgba)
}
}
}
},
data: {
pickers: [
{ field: 'r', label: '红色', value: 0 },
{ field: 'g', label: '绿色', value: 0 },
{ field: 'b', label: '蓝色', value: 0 },
{ field: 'a', label: '透明度', value: 255 },
],
rgba: [],
hex: '',
rgbaStyle: '',
presets: [
...presets.map(rgba => {
return {
rgba,
rgbaStyle: `rgba(${ rgba.join(',') })`
}
})
]
},
methods: {
changeHandle(e) {
const { detail, currentTarget: { dataset: { index } } } = e
const key = `pickers[${ index }].value`
this.setData({
[key]: typeof detail === 'object' ? detail.value : detail
})
const rgba = this.data.pickers.map(item => item.value)
this.setColor(rgba)
},
chooseHandle(e) {
const { rgba } = e.currentTarget.dataset.row
this.setData({
pickers: this.data.pickers.map((item, index) => {
return {...item, value: rgba[index]}
}),
})
this.setColor(rgba)
},
// 设置颜色
setColor(rgba) {
const hex = rgb2Hex(...rgba)
const rgbaStyle = `rgba(${ rgba.join(',') })`
this.setData({ rgba, hex: hex.replace('#', ''), rgbaStyle })
},
confirmHandle(e) {
this.triggerEvent('confirm', { rgba: this.data.rgba, rgbaStyle: this.data.rgbaStyle })
},
cancelHandle() {
this.triggerEvent('cancel')
},
}
})

到此颜色选择器的组件已经实现了,还剩下一个预览下载的页面。我这边的实现并不是直接页面跳转,因为这边预览之后返回是希望还保留预览之前的数据的。如果直接离开当前页面并清除了数据,不符合用户预期的。所以使用了一个假页。微信小程序提供了一个 page-container 的页面容器,效果类似于 popup 弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。


如果二维码中含中文的静态码使用微信扫描后是无法正常展示内容的(后期安排上二维码解析的功能)


感谢大家观看我今日的水文,文笔实在是不行,欢迎鱼友们给小程序提提意见,或者有什么有趣的想法也可以与楼主提一提。最后希望大家到我的小程序来多坐坐。





作者:拖孩
来源:juejin.cn/post/7384350475736989731
收起阅读 »

多级校验、工作流,这样写代码才足够优雅!

责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。 请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。 责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。 操作需要经...
继续阅读 »

责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。


请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。


图片


责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。



  • 操作需要经过一系列的校验,通过校验后才执行某些操作。

  • 工作流。企业中通常会制定很多工作流程,一级一级的去处理任务。


下面通过两个案例来学习一下责任链模式。


案例一:创建商品多级校验场景


以创建商品为例,假设商品创建逻辑分为以下三步完成:


①创建商品、


②校验商品参数、


③保存商品。


第②步校验商品又分为多种情况的校验,必填字段校验、规格校验、价格校验、库存校验等等。


这些检验逻辑像一个流水线,要想创建出一个商品,必须通过这些校验。如下流程图所示:


图片


图片


伪代码如下:


创建商品步骤,需要经过一系列的参数校验,如果参数校验失败,直接返回失败的结果;通过所有的参数校验后,最终保存商品信息。


图片


图片


如上代码看起来似乎没什么问题,它非常工整,而且代码逻辑很清晰。



PS:我没有把所有的校验代码都罗列在一个方法里,那样更能产生对比性,但我觉得抽象并分离单一职责的函数应该是每个程序员最基本的规范!



但是随着业务需求不断地叠加,相关的校验逻辑也越来越多,新的功能使代码越来越臃肿,可维护性较差。


更糟糕的是,这些校验组件不可复用,当你有其他需求也需要用到一些校验时,你又变成了Ctrl+C , Ctrl+V程序员,系统的维护成本也越来越高。如下图所示:


图片


图片


伪代码同上,这里就不赘述了。


终于有一天,你忍无可忍了,决定重构这段代码。


使用责任链模式优化:创建商品的每个校验步骤都可以作为一个单独的处理器,抽离为一个单独的类,便于复用。


这些处理器形成一条链式调用,请求在处理器链上传递,如果校验条件不通过,则处理器不再向下传递请求,直接返回错误信息;若所有的处理器都通过检验,则执行保存商品步骤。


图片


图片


案例一实战:责任链模式实现创建商品校验


UML图:一览众山小


图片


图片


AbstractCheckHandler表示处理器抽象类,负责抽象处理器行为。其有3个子类,分别是:



  • NullValueCheckHandler:空值校验处理器

  • PriceCheckHandler:价格校验处理

  • StockCheckHandler:库存校验处理器


AbstractCheckHandler 抽象类中, handle()定义了处理器的抽象方法,其子类需要重写handle()方法以实现特殊的处理器校验逻辑;


protected ProductCheckHandlerConfig config 是处理器的动态配置类,使用protected声明,每个子类处理器都持有该对象。


该对象用于声明当前处理器、以及当前处理器的下一个处理器nextHandler,另外也可以配置一些特殊属性,比如说接口降级配置、超时时间配置等。


AbstractCheckHandler nextHandler 是当前处理器持有的下一个处理器的引用,当前处理器执行完毕时,便调用nextHandler执行下一处理器的handle()校验方法;


protected Result next() 是抽象类中定义的,执行下一个处理器的方法,使用protected声明,每个子类处理器都持有该对象。


当子类处理器执行完毕(通过)时,调用父类的方法执行下一个处理器nextHandler。


HandlerClient 是执行处理器链路的客户端,HandlerClient.executeChain()方法负责发起整个链路调用,并接收处理器链路的返回值。


商品参数对象:保存商品的入参


ProductVO是创建商品的参数对象,包含商品的基础信息。


并且其作为责任链模式中多个处理器的入参,多个处理器都以ProductVO为入参进行特定的逻辑处理。


实际业务中,商品对象特别复杂。咱们化繁为简,简化商品参数如下:


/**
 * 商品对象
 */

@Data
@Builder
public class ProductVO {
    /**
     * 商品SKU,唯一
     */

    private Long skuId;
    /**
     * 商品名称
     */

    private String skuName;
    /**
     * 商品图片路径
     */

    private String Path;
    /**
     * 价格
     */

    private BigDecimal price;
    /**
     * 库存
     */

    private Integer stock;
}

抽象类处理器:抽象行为,子类共有属性、方法


AbstractCheckHandler:处理器抽象类,并使用@Component注解注册为由Spring管理的Bean对象,这样做的好处是,我们可以轻松的使用Spring来管理这些处理器Bean。


/**
 * 抽象类处理器
 */

@Component
public abstract class AbstractCheckHandler {

    /**
     * 当前处理器持有下一个处理器的引用
     */

    @Getter
    @Setter
    protected AbstractCheckHandler nextHandler;


    /**
     * 处理器配置
     */

    @Setter
    @Getter
    protected ProductCheckHandlerConfig config;

    /**
     * 处理器执行方法
     * @param param
     * @return
     */

    public abstract Result handle(ProductVO param);

    /**
     * 链路传递
     * @param param
     * @return
     */

    protected Result next(ProductVO param) {
        //下一个链路没有处理器了,直接返回
        if (Objects.isNull(nextHandler)) {
            return Result.success();
        }

        //执行下一个处理器
        return nextHandler.handle(param);
    }

}

在AbstractCheckHandler抽象类处理器中,使用protected声明子类可见的属性和方法。


使用 @Component注解,声明其为Spring的Bean对象,这样做的好处是可以利用Spring轻松管理所有的子类,下面会看到如何使用。


抽象类的属性和方法说明如下:



  • public abstract Result handle():表示抽象的校验方法,每个处理器都应该继承AbstractCheckHandler抽象类处理器,并重写其handle方法,各个处理器从而实现特殊的校验逻辑,实际上就是多态的思想。

  • protected ProductCheckHandlerConfig config:表示每个处理器的动态配置类,可以通过“配置中心”动态修改该配置,实现处理器的“动态编排”和“顺序控制”。配置类中可以配置处理器的名称、下一个处理器、以及处理器是否降级等属性。

  • protected AbstractCheckHandler nextHandler:表示当前处理器持有下一个处理器的引用,如果当前处理器handle()校验方法执行完毕,则执行下一个处理器nextHandler的handle()校验方法执行校验逻辑。

  • protected Result next(ProductVO param):此方法用于处理器链路传递,子类处理器执行完毕后,调用父类的next()方法执行在config 配置的链路上的下一个处理器,如果所有处理器都执行完毕了,就返回结果了。


ProductCheckHandlerConfig配置类 :


/**
 * 处理器配置类
 */

@AllArgsConstructor
@Data
public class ProductCheckHandlerConfig {
    /**
     * 处理器Bean名称
     */

    private String handler;
    /**
     * 下一个处理器
     */

    private ProductCheckHandlerConfig next;
    /**
     * 是否降级
     */

    private Boolean down = Boolean.FALSE;
}

子类处理器:处理特有的校验逻辑


AbstractCheckHandler抽象类处理器有3个子类分别是:



  • NullValueCheckHandler:空值校验处理器

  • PriceCheckHandler:价格校验处理

  • StockCheckHandler:库存校验处理器


各个处理器继承AbstractCheckHandler抽象类处理器,并重写其handle()处理方法以实现特有的校验逻辑。


NullValueCheckHandler:空值校验处理器。针对性校验创建商品中必填的参数。如果校验未通过,则返回错误码ErrorCode,责任链在此截断(停止),创建商品返回被校验住的错误信息。注意代码中的降级配置!


super.getConfig().getDown()是获取AbstractCheckHandler处理器对象中保存的配置信息,如果处理器配置了降级,则跳过该处理器,调用super.next()执行下一个处理器逻辑。


同样,使用@Component注册为由Spring管理的Bean对象,


/**
 * 空值校验处理器
 */

@Component
public class NullValueCheckHandler extends AbstractCheckHandler{

    @Override
    public Result handle(ProductVO param) {
        System.out.println("空值校验 Handler 开始...");
        
        //降级:如果配置了降级,则跳过此处理器,执行下一个处理器
        if (super.getConfig().getDown()) {
            System.out.println("空值校验 Handler 已降级,跳过空值校验 Handler...");
            return super.next(param);
        }
        
        //参数必填校验
        if (Objects.isNull(param)) {
            return Result.failure(ErrorCode.PARAM_NULL_ERROR);
        }
        //SkuId商品主键参数必填校验
        if (Objects.isNull(param.getSkuId())) {
            return Result.failure(ErrorCode.PARAM_SKU_NULL_ERROR);
        }
        //Price价格参数必填校验
        if (Objects.isNull(param.getPrice())) {
            return Result.failure(ErrorCode.PARAM_PRICE_NULL_ERROR);
        }
        //Stock库存参数必填校验
        if (Objects.isNull(param.getStock())) {
            return Result.failure(ErrorCode.PARAM_STOCK_NULL_ERROR);
        }
        
        System.out.println("空值校验 Handler 通过...");
        
        //执行下一个处理器
        return super.next(param);
    }
}

PriceCheckHandler:价格校验处理。


针对创建商品的价格参数进行校验。这里只是做了简单的判断价格>0的校验,实际业务中比较复杂,比如“价格门”这些防范措施等。


/**
 * 价格校验处理器
 */

@Component
public class PriceCheckHandler extends AbstractCheckHandler{
    @Override
    public Result handle(ProductVO param) {
        System.out.println("价格校验 Handler 开始...");

        //非法价格校验
        boolean illegalPrice =  param.getPrice().compareTo(BigDecimal.ZERO) <= 0;
        if (illegalPrice) {
            return Result.failure(ErrorCode.PARAM_PRICE_ILLEGAL_ERROR);
        }
        //其他校验逻辑...

        System.out.println("价格校验 Handler 通过...");

        //执行下一个处理器
        return super.next(param);
    }
}

StockCheckHandler:库存校验处理器。


针对创建商品的库存参数进行校验。


/**
 * 库存校验处理器
 */

@Component
public class StockCheckHandler extends AbstractCheckHandler{
    @Override
    public Result handle(ProductVO param) {
        System.out.println("库存校验 Handler 开始...");

        //非法库存校验
        boolean illegalStock = param.getStock() < 0;
        if (illegalStock) {
            return Result.failure(ErrorCode.PARAM_STOCK_ILLEGAL_ERROR);
        }
        //其他校验逻辑..

        System.out.println("库存校验 Handler 通过...");

        //执行下一个处理器
        return super.next(param);
    }
}

客户端:执行处理器链路


HandlerClient客户端类负责发起整个处理器链路的执行,通过executeChain()方法。


如果处理器链路返回错误信息,即校验未通过,则整个链路截断(停止),返回相应的错误信息。


public class HandlerClient {

  public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
      //执行处理器
      Result handlerResult = handler.handle(param);
      if (!handlerResult.isSuccess()) {
          System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
          return handlerResult;
      }
      return Result.success();
  }
}

以上,责任链模式相关的类已经创建好了。


接下来就可以创建商品了。


创建商品:抽象步骤,化繁为简


createProduct()创建商品方法抽象为2个步骤:①参数校验、②创建商品。


参数校验使用责任链模式进行校验,包含:空值校验、价格校验、库存校验等等,只有链上的所有处理器均校验通过,才调用saveProduct()创建商品方法;否则返回校验错误信息。


createProduct()创建商品方法中,通过责任链模式,我们将校验逻辑进行解耦。createProduct()创建商品方法中不需要关注都要经过哪些校验处理器,以及校验处理器的细节。


/**
 * 创建商品
 * 
@return
 */

@Test
public Result createProduct(ProductVO param) {

    //参数校验,使用责任链模式
    Result paramCheckResult = this.paramCheck(param);
    if (!paramCheckResult.isSuccess()) {
        return paramCheckResult;
    }

    //创建商品
    return this.saveProduct(param);
}

参数校验:责任链模式


参数校验paramCheck()方法使用责任链模式进行参数校验,方法内没有声明具体都有哪些校验,具体有哪些参数校验逻辑是通过多个处理器链传递的。如下:


/**
 * 参数校验:责任链模式
 * 
@param param
 * 
@return
 */

private Result paramCheck(ProductVO param) {

    //获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
    ProductCheckHandlerConfig handlerConfig = this.getHandlerConfigFile();

    //获取处理器
    AbstractCheckHandler handler = this.getHandler(handlerConfig);

    //责任链:执行处理器链路
    Result executeChainResult = HandlerClient.executeChain(handler, param);
    if (!executeChainResult.isSuccess()) {
        System.out.println("创建商品 失败...");
        return executeChainResult;
    }

    //处理器链路全部成功
    return Result.success();
}

paramCheck()方法步骤说明如下:


👉 步骤1:获取处理器配置。


通过getHandlerConfigFile()方法获取处理器配置类对象,配置类保存了链上各个处理器的上下级节点配置,支持流程编排、动态扩展。


通常配置是通过Ducc(京东自研的配置中心)、Nacos(阿里开源的配置中心)等配置中心存储的,支持动态变更、实时生效。


基于此,我们便可以实现校验处理器的编排、以及动态扩展了。


我这里没有使用配置中心存储处理器链路的配置,而是使用JSON串的形式去模拟配置,大家感兴趣的可以自行实现。


/**
 * 获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
 * @
return
 */

private ProductCheckHandlerConfig getHandlerConfigFile() {
    //配置中心存储的配置
    String configJson = "{"handler":"nullValueCheckHandler","down":true,"next":{"handler":"priceCheckHandler","next":{"handler":"stockCheckHandler","next":null}}}";
    //转成Config对象
    ProductCheckHandlerConfig handlerConfig = JSON.parseObject(configJson, ProductCheckHandlerConfig.class);
    return handlerConfig;
}

ConfigJson存储的处理器链路配置JSON串,在代码中可能不便于观看,我们可以使用json.cn等格式化看一下,如下,配置的整个调用链路规则特别清晰。


图片


图片


getHandlerConfigFile()类获到配置类的结构如下,可以看到,就是把在配置中心储存的配置规则,转换成配置类ProductCheckHandlerConfig对象,用于程序处理。



注意,此时配置类中存储的仅仅是处理器Spring Bean的name而已,并非实际处理器对象。



图片


图片


接下来,通过配置类获取实际要执行的处理器。


👉 步骤2:根据配置获取处理器。


上面步骤1通过getHandlerConfigFile()方法获取到处理器链路配置规则后,再调用getHandler()获取处理器。


getHandler()参数是如上ConfigJson配置的规则,即步骤1转换成的ProductCheckHandlerConfig对象;


根据ProductCheckHandlerConfig配置规则转换成处理器链路对象。代码如下:


 * 使用Spring注入:所有继承了AbstractCheckHandler抽象类的Spring Bean都会注入进来。Map的Key对应Bean的name,Value是name对应相应的Bean
 */
@Resource
private Map handlerMap;

/**
 * 获取处理器
 * 
@param config
 * 
@return
 */

private AbstractCheckHandler getHandler (ProductCheckHandlerConfig config) {
    //配置检查:没有配置处理器链路,则不执行校验逻辑
    if (Objects.isNull(config)) {
        return null;
    }
    //配置错误
    String handler = config.getHandler();
    if (StringUtils.isBlank(handler)) {
        return null;
    }
    //配置了不存在的处理器
    AbstractCheckHandler abstractCheckHandler = handlerMap.get(config.getHandler());
    if (Objects.isNull(abstractCheckHandler)) {
        return null;
    }
    
    //处理器设置配置Config
    abstractCheckHandler.setConfig(config);
    
    //递归设置链路处理器
    abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));

    return abstractCheckHandler;
}

👉 👉 步骤2-1:配置检查。


代码14~27行,进行了配置的一些检查操作。如果配置错误,则获取不到对应的处理器。代码23行handlerMap.get(config.getHandler())是从所有处理器映射Map中获取到对应的处理器Spring Bean。



注意第5行代码,handlerMap存储了所有的处理器映射,是通过Spring @Resource注解注入进来的。注入的规则是:所有继承了AbstractCheckHandler抽象类(它是Spring管理的Bean)的子类(子类也是Spring管理的Bean)都会注入进来。



注入进来的handlerMap中 Map的Key对应Bean的name,Value是name对应的Bean实例,也就是实际的处理器,这里指空值校验处理器、价格校验处理器、库存校验处理器。如下:


图片


图片


这样根据配置ConfigJson(👉 步骤1:获取处理器配置)中handler:"priceCheckHandler"的配置,使用handlerMap.get(config.getHandler())便可以获取到对应的处理器Spring Bean对象了。


👉 👉 步骤2-2:保存处理器规则。


代码29行,将配置规则保存到对应的处理器中abstractCheckHandler.setConfig(config),子类处理器就持有了配置的规则。


👉 👉 步骤2-3:递归设置处理器链路。


代码32行,递归设置链路上的处理器。


//递归设置链路处理器 abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));

这一步可能不太好理解,结合ConfigJson配置的规则来看,似乎就很很容易理解了。


图片


图片


由上而下,NullValueCheckHandler 空值校验处理器通过setNextHandler()方法设置自己持有的下一节点的处理器,也就是价格处理器PriceCheckHandler。


接着,PriceCheckHandler价格处理器,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,并且最重要的是,它也需要设置下一节点的处理器StockCheckHandler库存校验处理器。


StockCheckHandler库存校验处理器也一样,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,但请注意StockCheckHandler的配置,它的next规则配置了null,这表示它下面没有任何处理器要执行了,它就是整个链路上的最后一个处理节点。


通过递归调用getHandler()获取处理器方法,就将整个处理器链路对象串联起来了。如下:


图片


图片



友情提示:递归虽香,但使用递归一定要注意截断递归的条件处理,否则可能造成死循环哦!



实际上,getHandler()获取处理器对象的代码就是把在配置中心配置的规则ConfigJson,转换成配置类ProductCheckHandlerConfig对象,再根据配置类对象,转换成实际的处理器对象,这个处理器对象持有整个链路的调用顺序。


👉 步骤3:客户端执行调用链路。


public class HandlerClient {

  public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
      //执行处理器
      Result handlerResult = handler.handle(param);
      if (!handlerResult.isSuccess()) {
          System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
          return handlerResult;
      }
      return Result.success();
  }
}

getHandler()获取完处理器后,整个调用链路的执行顺序也就确定了,此时,客户端该干活了!


HandlerClient.executeChain(handler, param)方法是HandlerClient客户端类执行处理器整个调用链路的,并接收处理器链路的返回值。


executeChain()通过AbstractCheckHandler.handle()触发整个链路处理器顺序执行,如果某个处理器校验没有通过!handlerResult.isSuccess(),则返回错误信息;所有处理器都校验通过,则返回正确信息Result.success()


总结:串联方法调用流程


基于以上,再通过流程图来回顾一下整个调用流程。


图片


图片


测试:代码执行结果


场景1:创建商品参数中有空值(如下skuId参数为null),链路被空值处理器截断,返回错误信息


//创建商品参数
ProductVO param = ProductVO.builder()
      .skuId(null).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(1))
      .stock(1)
      .build();

测试结果


图片


图片


场景2:创建商品价格参数异常(如下price参数),被价格处理器截断,返回错误信息


ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(-999))
      .stock(1)
      .build();

测试结果


图片


图片


场景 3:创建商品库存参数异常(如下stock参数),被库存处理器截断,返回错误信息。


//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(1))
      .stock(-999)
      .build();

测试结果


图片


图片


场景4:创建商品所有处理器校验通过,保存商品。


![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(999))
      .stock(1).build();

测试结果


图片


责任链的优缺点


图片


图片


作者:程序员蜗牛
来源:juejin.cn/post/7384632888321179659
收起阅读 »

dockerhub国内镜像站集体下线?别慌,教你丝滑拉取镜像~

web
前言想必大家都听说了,国内镜像站几乎都用不了,对于开发者来说,无疑是个不好的消息。在docker pull时直接超时失败,拉取不下来镜像。那么有没有什么办法解决呢?有!还不止一种。通过docker配置文件配置可用的国内镜像源设置代理自建镜像仓库方法1已经不太好...
继续阅读 »

前言

想必大家都听说了,国内镜像站几乎都用不了,对于开发者来说,无疑是个不好的消息。在docker pull时直接超时失败,拉取不下来镜像。那么有没有什么办法解决呢?有!还不止一种。

  1. 通过docker配置文件配置可用的国内镜像源
  2. 设置代理
  3. 自建镜像仓库

方法1已经不太好使了,能找到可用的不多,有的还存在没有最新的镜像问题。

方法2可行,不过得要有科学上网的工具,再会一点配置代理的知识,操作起来稍稍复杂。

本文主要介绍第三种方法,上手快,简单,关键还0成本!

准备工作

  1. 登录阿里云,找到容器镜像服务,创建一个个人版实例。(第一次使用的话,会让设置访问密码。记住,后面会用)
  2. 找到仓库管理-命名空间,新建一个命名空间且设置为公开

微信截图_20240626174632.png 3.不要创建镜像仓库,回到访问凭证

可以看到,如下2个信息,一个是你的阿里云用户名,一个是你的仓库地址(后面有用)

sudo docker login --username=阿里云用户名 registry.cn-beijing.aliyuncs.com

github配置

  1. fork项目,地址: docker_image_pusher

(感谢tech-shrimp提供的工具)

  1. 在fork后的项目中通过Settings-Secret and variables-Actions-New Repository secret路径,配置4个环境变量
  • ALIYUN_NAME_SPACE-命名空间
  • ALIYUN_REGISTRY_USER-阿里云用户名
  • ALIYUN_REGISTRY_PASSWORD-访问密码
  • ALIYUN_REGISTRY-仓库地址

企业微信截图_20240626203514.png

3.配置要拉取的镜像 打开项目images.txt,每一行配置一个镜像,格式:name:tag 比如

企业微信截图_20240626213138.png

提交修改的文件,则会自动在Actions中创建一个workflow。等待片刻即可(1分钟左右)

企业微信截图_20240626212730.png

5.回到阿里云容器镜像服务控制台-镜像仓库

企业微信截图_20240626213555.png

可以看到镜像已成功拉取并同步到你自己的仓库中。

测试效果

我自己操作了下把nginx的镜像给拉了过来,找台服务器测试一下速度

演示.gif 哈哈!这速度杠杠的吧! 用这个方式的好处是,借助github的action机制,直接从dockerhub上拉取任何你想要的镜像,也不用担心国内镜像站版本更新不及时的问题。再从自建的仓库中pull下来就可以啦! 如果有小伙伴没捣鼓成功的,可以留言给我。


作者:临时工
来源:juejin.cn/post/7384623060199473171
收起阅读 »

微信小程序全新渲染引擎Skyline(入门篇)

web
前言 最近看小程序文档的时候发现了 swiper 组件新增了 Skyline 特有的属性,直接使用竟然没有效果。 不信邪的我打算来研究研究究竟什么是 Skyline!经过一系列文档阅读与实践,长时间闭门造车的我打开了新世界的大门,我惊讶的发现 Skyline...
继续阅读 »

前言


最近看小程序文档的时候发现了 swiper 组件新增了 Skyline 特有的属性,直接使用竟然没有效果。



不信邪的我打算来研究研究究竟什么是 Skyline!经过一系列文档阅读与实践,长时间闭门造车的我打开了新世界的大门,我惊讶的发现 Skyline 引擎很可能是微信小程序未来发展的重点方向,有着更类似原生的交互体验,新增的特性让人连连称叹,特以此文来总结性地介绍一下 Skyline。


双线程模型


了解 Skyline 之前,我们有必要重新复习一下什么是小程序的双线程模型。


如官方文档所言,小程序的运行环境分成渲染层和逻辑层,其中 WXML 模板和 WXSS 样式工作在渲染层,JS 脚本工作在逻辑层。小程序的渲染层和逻辑层分别由2个线程管理:



  • 渲染层的界面使用了WebView 进行渲染,一个小程序存在多个界面,所以渲染层存在多个WebView线程;

  • 逻辑层采用JsCore线程运行JS脚本。


这两个线程的通信会经由微信客户端(原生) 做中转,逻辑层发送网络请求也经由微信客户端 (原生) 转发,有了微信小程序客户端 (原生) 作为媒介系统,使得我们开发者能够专注于数据与逻辑。


如上所述,小程序的通信模型如下图所示。



什么是 Skyline 引擎


前文提到,基于 WebView 和原生控件混合渲染的方式,小程序优化扩展了 Web 的基础能力,所以小程序相对于普通的Web页面有着更为良好的性能与体验。


由于 Web 在移动端的表现与原生应用仍有一定差距,亦或许是 Web 的优化遇到了瓶颈,为了进一步优化小程序性能,微信在 WebView 渲染之外新增了一个渲染引擎,也就是我们本文的重磅主角: Skyline,它使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。


Skyline 引擎 vs Webview 引擎


我们知道:WebView 的 JS 逻辑、DOM 树创建、CSS 解析、样式计算、Layout、Paint (Composite) 都发生在同一线程,在 WebView 上执行过多的 JS 逻辑可能阻塞渲染,导致界面卡顿,大致流程如下图所示。



但是,在 Skyline 环境下改变了这个情况,它创建了一条渲染线程来负责计算图层布局,图层的绘制以及整合图层页面等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。



据官方统计数据表明,Skyline 与 WebView 性能相比,具有如下优势:


Skyline 的首屏时间比 WebView 快约 66%



单个页面 Skyline 的占用比 WebView 减少约 35%


单个页面 Skyline 的占用比 WebView 减少 35%,两个页面 Skyline 的内存占用比 WebView 减少 50%,随着打开的页面变多,内存差距越明显。



Skyline 引擎的优点



  • 界面更不容易被逻辑阻塞,进一步减少卡顿

  • 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销

  • 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销

  • 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销

  • 保持和原有架构的兼容性,基于 WebView 环境的小程序代码基本上无需任何改动即可直接在新的架构下运行


更多Skyline的特性更新请详见Skyline 渲染引擎 / 概览 / 特性 | 微信开放文档


Skyline 引擎的缺点



  • WXS效率可能有所下降 (WXS 由于被移到 AppService 中,虽然逻辑本身无需改动,但询问页面信息等接口会变为异步,效率也可能有所下降)


但是,也不必过多的担心,微信推出了新的 Worklet 机制,它比原有的 WXS 更靠近渲染流程,用以高性能地构建各种复杂的动画效果。


Skyline 引擎的使用


前文提到,我想使用 swiper 组件新增的 Skyline 特有属性无果,是因为我没有完成 Skyline的配置。如果想要使用 Skyline引擎,我们可以按页面级别来选择性的配置是走 Skyline 引擎或是 Webview 引擎来渲染。


// page.json
{
"renderer": "skyline"
}

// page.json
{
"renderer": "webview"
}

配置完成之后,我们就可以愉快的使用 Skyline 专有的新特性了。


Skyline 引擎的兼容性


我们可能会担心开启了 Skyline 的渲染模式会不会带来兼容性问题。官方表示:



所以我们完全可以放下对兼容性的顾虑,拥抱新的 Skyline 引擎,让大部分的用户优先体验到新一代微信小程序的渲染技术,做第一批吃螃蟹的人!对于我们开发者而言,有必要深入了解一下Skyline引擎的更新带来了哪些开发层面的变化与创新,毕竟,吃螃蟹的人会越来越多嘛。


后记


感谢您的阅读,本文仅为微信小程序 Skyline 引擎的入门介绍篇,后续会持续更新有关 Skyline 引擎相关实际操作及使用的文章,如有兴趣,欢迎持续关注。


作者:阿李贝斯
来源:juejin.cn/post/7298927261210361882
收起阅读 »

2024年令人眼前一亮的Web框架

web
本文翻译自 dev.to/wasp/web-fr… 感谢您的阅读! 介绍 2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新...
继续阅读 »

本文翻译自 dev.to/wasp/web-fr…

感谢您的阅读!



介绍


2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新星名单为指引,力求保持客观公正的态度。对于每一个特色框架,我们都将突出其最大的优势,使您能够全面理解它们的优点,从而选择适合自己的框架进行尝试!


HTMX - 返璞归真🚲


htmx-演示


为谁而设:



  • 你希望减少JavaScript的编写量

  • 你希望代码更简单,以超媒体为中心


HTMX在2023年迅速走红,过去一年间在GitHub上赢得了大量星标。HTMX并非普通的JS框架。如果你使用HTMX,你将大部分时间都花在超媒体的世界中,以与我们通常对现代Web开发的JS密集型视角完全不同的视角看待Web开发。HTMX利用HATEOAS(Hypermedia作为应用程序状态的引擎)的概念,使开发人员能够直接从HTML访问浏览器功能,而不是使用Javascript。


此外,它还证明了通过发布令人惊叹的表情符号并以口碑作为主要营销手段,你可以获得人气和认可。不仅如此,你还可能成为HTMX的CEO!它吸引了许多开发人员尝试这种构建网站的方法,并重新思考他们当前的实践。所有这些都使2024年对于这个库的未来发展充满了激动人心的可能性。


Wasp - 全栈,开箱即用🚀


开放SaaS


为谁而设:



  • 你希望快速构建全栈应用

  • 你希望在一个出色的一体化解决方案中继续使用React和Node.js,而无需手动挑选堆栈的每一部分

  • 你希望获得一个为React和Node.js预配置的免费SaaS模板—— Open SaaS


对于希望简单轻松地全面控制其堆栈的工具的用户,无需再寻找!Wasp是一个有主见的全栈框架,利用其编译器以快速简便的方式为你的应用创建数据库、后端和前端。它使用React、Node.js和Prisma,这些都是全栈Web开发人员正在使用的一些最著名的工具。


Wasp的核心是main.wasp文件,它作为你大部分需求的一站式服务。在其中,你可以定义:



  • 全栈身份验证

  • 数据库架构

  • 异步作业,无需额外的基础设施

  • 简单且灵活的部署

  • 全栈类型安全

  • 发送电子邮件(Sendgrid、MailGun、SMTP服务器等)

  • 等等……


最酷的事情是?经过编译器步骤后,你的Wasp应用程序的输出是一个标准的React + Vite前端、Node.js后端和PostgreSQL数据库。从那里,你可以使用单个命令轻松将一切部署到Fly.io等平台。


尽管有些人可能会认为Wasp的有主见立场是负面的,但它却是Wasp众多全栈功能的驱动力。使用Wasp,单个开发人员或小型团队启动全栈项目变得更加容易,尤其是如果你使用预制的模板或OpenSaaS作为你的SaaS起点。由于项目的核心是定义明确的,因此开始一个项目并可能在几天内创建自己的全栈SaaS变得非常容易!


此外,还有一点很酷的是,大多数Web开发人员对大多数现有技术的预先存在的知识仍然在这里适用,因为Wasp使用的技术已经成熟。


Solid.js - 一流的reactivity库 ↔️


扎实的例子


适合人群:



  • 如果你希望代码具有高响应性

  • 现有的React开发人员,希望尝试一种对他们来说学习曲线较低的高性能工具


Solid.js是一个性能很高的Web框架,与React有一些相似之处。例如,两者都使用JSX,采用基于函数的组件方法,但Solid.js不使用虚拟DOM,而是将你的代码转换为纯JavaScript。然而,Solid.js因其利用信号、备忘录和效果实现细粒度响应性的方法而更加出名。信号是Solid.js中最简单、最知名的基本元素。它们包含值及其获取和设置函数,使框架能够观察并在DOM中的确切位置按需更新更改,这与React重新渲染整个组件的方式不同。


Solid.js不仅使用JSX,还对其进行了增强。它提供了一些很酷的新功能,例如Show组件,它可以启用JSX元素的条件渲染,以及For组件,它使在JSX中更轻松地遍历集合变得更容易。另一个重要的是,它还有一个名为Solid Start的元框架(目前处于测试版),它使用户能够根据自己的喜好,使用基于文件的路由、操作、API路由和中间件等功能,以不同的方式渲染应用程序。


Astro - 静态网站之王👑


天文示例


适合人群:



  • 如果您需要一款优秀的博客、CMS重型网站工具

  • 需要一个能够集成其他库和框架的框架


如果您在2023年构建了一个内容驱动的网站,那么很有可能您选择了Astro作为首选框架来实现这一目标!Astro是另一个使用不同架构概念来脱颖而出的框架。对于Astro来说,这是岛屿架构。在Astro的上下文中,岛屿是页面上的任何交互式UI组件,与静态内容的大海形成鲜明对比。由于这些岛屿彼此独立运行,因此页面可以有任意数量的岛屿,但它们也可以共享状态并相互通信,这非常有用。


关于Astro的另一个有趣的事情是,他们的方法使用户能够使用不同的前端框架,如React、Vue、Solid来构建他们的网站。因此,开发人员可以轻松地在其当前知识的基础上构建网站,并利用可以集成到Astro网站中的现有组件。


Svelte - 简单而有效🎯


精简演示


适合人群:



  • 您希望学习一个简单易上手的框架

  • 追求简洁且代码执行速度快的开发体验


Svelte是另一个尝试通过尽可能直接和初学者友好的方式来简化和加速Web开发的框架。它是一个很容易学习的框架,因为要使一个属性具有响应性,您只需声明它并在HTML模板中使用它。 每当在JavaScript中程序化地更新值时(例如,通过触发onClick事件按钮),它将在UI上反映出来,反之亦然。


Svelte的下一步将是引入runes。runes将是Svelte处理响应性的方式,使处理大型应用程序变得更加容易。类似于Solid.js的信号,符文通过使用类似函数的语句提供了一种直接访问应用程序响应性状态的方式。与Svelte当前的工作方式相比,它们将允许用户精确定义整个脚本中哪些部分是响应性的,从而使组件更加高效。类似于Solid和Solid Start,Svelte也有其自己的框架,称为SvelteKit。SvelteKit为用户提供了一种快速启动其由Vite驱动的Svelte应用程序的方式。它提供了路由器、构建优化、不同的渲染和预渲染方式、图像优化等功能。


Qwik - 非常快🚤


qwik演示


适合人群:



  • 如果您想要一个高性能的Web应用

  • 现有的React开发人员,希望尝试一种高性能且学习曲线平缓的框架


最后一个但同样重要的框架是Qwik。Qwik是另一个利用JSX和函数组件的框架,类似于Solid.js,为基于React的开发人员提供了一个熟悉的环境,以便尽快上手。正如其名字所表达的,Qwik的主要目标是实现您应用程序的最高性能和最快执行速度。


Qwik通过利用可恢复性(resumability)的概念来实现其速度。简而言之,可恢复性基于在服务器上暂停执行并在客户端上恢复执行而无需重新播放和下载全部应用程序逻辑的想法。这是通过延迟JavaScript代码的执行和下载来实现的,除非有必要处理用户交互,这是一件非常棒的事情。它使整体速度提高,并将带宽降低到绝对最小值,从而实现近乎瞬间的加载。


结论


在我们所提及的所有框架和库中,最大的共同点是它们的熟悉度。每个框架和库都试图以构建在当前知识基础上的方式吸引潜在的新开发者,而不是做一些全新的事情,这是一个非常棒的理念。


当然,还有许多我们未在整篇文章中提及但值得一提的库和框架。例如,Angular 除了新的标志和文档外,还包括信号和新的控制流。还有 Remix,它增加了对 Vite、React Server Components 和新的 Remix SPA 模式的支持。最后,我们不能忘记 Next.js,它在过去几年中已成为 React 开发者的默认选择,为新的 React 功能铺平了道路。


作者:腾讯TNTWeb前端团队
来源:juejin.cn/post/7339830464000213027
收起阅读 »

我们都被困在系统里

前言 Hi 你好,我是东东拿铁,一个正在探索个人IP的后端程序员。 2020年外卖最火热的时候,有一篇文章《外卖骑手,困在系统里》。 作为一个互联网从业人员,我之前从未有机会体会到,当每一个工作都要被时间和算法压榨时,我会是一种怎样的感受。 而最近的一段经历...
继续阅读 »

前言


Hi 你好,我是东东拿铁,一个正在探索个人IP的后端程序员。


2020年外卖最火热的时候,有一篇文章《外卖骑手,困在系统里》。



作为一个互联网从业人员,我之前从未有机会体会到,当每一个工作都要被时间和算法压榨时,我会是一种怎样的感受。


而最近的一段经历,我感觉也被困在系统里了。


起因


如果你是一个研发人员,免不了要值班、处理线上问题。当然这都很正常,每个系统都有bug或者咨询类的问题。


由于我们面临的客户比较多,加上系统有一些易用性的问题或bug,提出来的问题不少。


公司有一项政策,当客服人员提交工单之后,系统对每一个单子有超时时间,如果超出一定时间你还未提交,罚款50元。


挺奇葩的,谁能保证1个小时就一定能排查出问题呢?


于是就会有一个场景,如果赶上问题多,一下子来5、6个工单,恰巧遇到不容易排查的耽误时间的话,处理后面的工单,都面临着超时的压力。



之前同事们对值班这件事,充满了怨言,大多都会吐槽几个点



  1. 系统bug太多了,又是刚刚某某需求改出来的问题

  2. 需求设计不合理,很多奇怪的操作导致了系统问题

  3. 客服太懒了,明明可以自己搜,非得提个工单问

  4. 基础设施差,平台不好用


我不太爱吐槽,但当工单一下子来的太多的时候,我不由自主的陷入机械的处理问题中,压缩思考的时间,只求不要超时就好。


明明系统有很多问题需要解决、流程也有很多可以优化,可当系统给到我们的压力越来越多时,我们便不去思考,陷入只有吐槽、怨言和避免罚款的状态。


当陷入了系统的支配,只能被动接受,甚至有了一些怨言的时候,我意识到,这样的状态,是有问题的。


被困住的打工人


外卖员为什么不遵守交通规则呢?


外卖小哥为了多赚钱、避免处罚,我之前也很不理解,为什么为了避免处罚,连自己的生命安全都可以置之不顾。



但转念一想,我们虽然不用在马路上奔波,可受到“系统”的压力,可是一点也不比外卖员少。


大家一定有过类似的经历:你骑车或者开车去上班,距离打卡时间所剩无几,你在迟到的边缘疯狂试探,可能多一个红绿灯,你就赶不上了,这时候你会不会狠踩几脚油门、闯一个黄灯,想要更快一点呢?


但随着裁员、降本增效、各类指标的压力越来越大,我们被迫不停的内卷,不断压榨自己,才能满足职场要求越来越严格的“算法”,比如,每半年一次的绩效考核,月度或者季度的OKR、KPI,还有处理不完的线上问题、事故,充斥在我们的脑海里面。


其实我们何尝不是“外卖员”呢?外卖员是为了不被扣钱,我们是为了年终奖、晋升罢了。


所以回过头来看,其实我们早早的就被困在“系统”中了,为了满足系统的要求,我们不得不埋头苦干,甚至加班透支身体,作出很多非常短线思维的事情。


但为什么,我之前从来没有过被困住的感觉,为什么我现在才回过神来,意识到这个问题呢?


我想,大概是越简单的事情,你作出的反应就越快、越激烈。而越复杂、时间越长的事情,你作出的反应就越缓慢,甚至忽略掉。


比如上班即将迟到的你,你会立刻意识到,迟到可能会受到处罚。但是年终评估你的绩效目标时,你或许只有在最后的几个月才会意识到,某某事情没完成,年终奖或许要少几个月而感到着急。


积极主动


最近正好在读《高效能人士的七个习惯》,其中第一个习惯就是积极主动


书中说到:人性的本质是主动而非被动的,人类不仅能针对特定环境选择回应方式,更能主动创造有利的环境。


我们面对的问题可以分为三类:



  • 可直接控制的(问题与自身的行为有关)

  • 可间接控制的(问题与他人的行为有关)

  • 无法控制的(我们无能为力的问题,例如我们的过去或现实的环境)


对于这三类问题,积极主动的话,应该如何加以解决呢。


可直接控制的问题


针对可直接控制的问题,可以通过培养正确习惯来解决。


从程序员角度来看,线上bug多,可以在开发前进行技术设计,上线前进行代码CR,自动化测试,帮助自己避免低级的问题。


面对处理工单时咨询量特别多的问题,随手整理个文档出来,放到大家都可以看到的地方。


可间接控制的


对于可间接控制的,我们可以通过改进施加影响的方法来解决。


比如流程机制的不合理,你可以通过向上反馈的方式施加影响,提出自己的建议而不是吐槽。


无法控制的


对于无法控制的,我们要做的就是改变面部曲线,以微笑、真诚与平和来接受现实。


虽然反馈问题的人或许能力参差不齐,导致工单量很多,但我们意识到这一点是无法避免的,不如一笑而过,这样才不至于被问题左右。


说在最后


好了,文章到这里就要结束了。


最近由于值班的原因,陷入了一段时间的无效忙碌中,每一天都很累,几乎抽不出时间来思考,所以更新的频率也降下来了。


但还好,及时的意识到问题,把最近的一点思考分享出来,希望我们每个人都不会被“系统”困住。




作者:东东拿铁
来源:juejin.cn/post/7385098943942656054
收起阅读 »

第一次使用缓存,因为没预热,翻车了

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。 悲惨的上线时刻 事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状...
继续阅读 »

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。


悲惨的上线时刻


事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状态,提单时也需要校验库存状态是否可售卖。但是由于库存状态的计算包含较复杂的业务逻辑,耗时比较高,在500ms以上。如果要在商品页面透出库存状态那么商品页面耗时增加500ms,这几乎是无法忍受的事情。


如何实现呢?最合适的方案当然是缓存了,我当时设计的方案是如果缓存有库存状态直接读缓存,如果缓存查不到,则计算库存状态,然后加载进缓存,同时设定过期时间。何时写库存呢? 答案是过期后,cache miss时重新加载进缓存。 由于计算逻辑较复杂,库存扣减等用户写操作没有同步更新缓存,但是产品认可库存状态可以有几分钟的状态不一致。为什么呢?


因为仓库有冗余库存,就算库存状态不一致导致超卖,也能容忍。同时库存不足以后,需要运营补充库存,而补充库存的时间是肯定比较长的。虽然补充库存完成几分钟后,才变为可售卖的,产品也能接受。 梳理完缓存的读写方案,我就沉浸于学习Redis的过程。


第一次使用缓存,我把时间和精力都放在Redis存储结构,Redis命令,Redis为什么那么快等方面的关注。如饥似渴的学习Redis知识。


直到上线阶段我也没有意识到系统设计的缺陷。


代码写的很快,测试验证也没有问题。然而上线过程中,就开始噼里啪啦的报警,开始我并没有想到报警这事和我有关。直到有人问我,“XXX,你是不是在上线库存状态的需求?”。


我人麻了,”怎么了,啥事”,我颤抖的问


“商品页面耗时暴涨,赶紧回滚”。一个声音传来


“我草”,那一瞬间,我的血压上涌,手心发痒,心跳加速,头皮发麻,颤抖的手不知道怎么在发布系统点回滚,“我没回滚过啊,咋回滚啊?”


“有降级开关吗”? 一个声音传来。


"没写..."。我回答的时候觉得自己真是二笔,为啥没加降级啊。(这也是复盘被骂的重要原因)


那么如何对缓存进行预热呢?


如何预热缓存


灰度放量


灰度放量实际上并不是缓存预热的办法,但是确实能避免缓存雪崩的问题。例如这个需求场景中,如果我没有放开全量数据,而是选择放量1%的流量。这样系统的性能不会有较大的下降,并且逐步放量到100%。


虽然这个过程中,没有主动同步数据到缓存,但是通过控制放量的节奏,保证了初始化缓存过程中,不会出现较大的耗时波动。


例如新上线的缓存逻辑,可以考虑逐渐灰度放量。


扫描数据库刷缓存


如果缓存维度是商品维度或者用户维度,可以考虑扫描数据库,提前预热部分数据到缓存中。


开发成本较高。除了开发缓存部分的代码,还需要开发扫描全表的任务。为了控制缓存刷新的进度,还需要使用线程池增加并发,使用限流器限制并发。这个方案的开发成本较高。


通过数据平台刷缓存


这是比较好的方式,具体怎么实现呢?


数据平台如果支持将数据库离线数据同步到Hive,Hive数据同步到Kafka,我们就可以编写Hive SQL,建立ETL任务。把业务需要被刷新的数据同步到Kafka中,再消费Kafka,把数据写入到缓存中。在这个过程中通过数据平台控制并发度,通过Kafka 分片和消费线程并发度控制 缓存写入的速率。


这个方案开发逻辑包括ETL 任务,消费Kafka写入缓存。这两部分的开发工作量不大。并且相比扫描全表任务,ETL可以编写更加复杂的SQL,修改后立即上线,无需自己控制并发、控制限流。在多个方面ETL刷缓存效率更高。


但是这个方案需要公司级别支持 多个存储系统之间可以进行数据同步。例如mysql、kafka、hive等。


除了首次上线,是否还有其他场景需要预热缓存呢?


需要预热缓存的其他场景


如果Redis挂了,数据怎么办


刚才提到上线前,一定要进行缓存预热。还有一个场景:假设Redis挂了,怎么办?全量的缓存数据都没有了,全部请求同时打到数据库,怎么办。


除了首次上线需要预热缓存,实际上如果缓存数据丢失后,也需要预热缓存。所以预热缓存的任务一定要开发的,一方面是上线前预热缓存,同时也是为了保证缓存挂掉后,也能重新预热缓存。


假如有大量数据冷启动怎么办


假如促销场景,例如春节抢红包,平时非活跃用户会在某个时间点大量打开App,这也会导致大量cache miss,进而导致雪崩。 此时就需要提前预热缓存了。具体的办法,可以考虑使用ETL任务。离线加载大量数据到Kafka,然后再同步到缓存。


总结



  1. 一定要预热缓存,不然线上接口性能和数据库真的扛不住。

  2. 可以通过灰度放量,扫描全表、ETL数据同步等方式预热缓存

  3. Redis挂了,大量用户冷启动的促销场景等场景都需要提前预热缓存。


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

半夜被慢查询告警吵醒,limit深度分页的坑

故事梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小...
继续阅读 »

故事

梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小猫想起来,今天在下班之前上线了一个版本,由于新增了一个业务字段,所以小猫写了相关的刷数据的接口,在下班之前调用开始刷历史数据。

考虑到表的数据量比较大,一次性把数据全部读取出来然后在内存里面去刷新数据肯定是不现实的,所以小猫采用了分页查询的方式依次根据条件查询出结果,然后进行表数据的重置。没想到的是,数据量太大,分页的深度越来越深,渐渐地,慢查询也就暴露出来了。

慢查询告警

强迫症小猫瞬间睡意全无,翻起来打开电脑开始解决问题。

那么为什么用使用limit之后会出现慢查询呢?接下来老猫和大家一起来剖析一下吧。

剖析流程

limit分页为什么会变慢?

在解释为什么慢之前,咱们来重现一下小猫的慢查询场景。咱们从实际的例子推进。

做个小实验

假设我们有一张这样的业务表,商品Product表。具体的建表语句如下:

CREATE TABLE `Product` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`type` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
`spuCode` varchar(50) NOT NULL DEFAULT '' ,
`spuName` varchar(100) NOT NULL DEFAULT '' ,
`spuTitle` varchar(300) NOT NULL DEFAULT '' ,
`channelId` bigint(20) unsigned NOT NULL DEFAULT '0',
`sellerId` bigint(20) unsigned NOT NULL DEFAULT '0'
`mallSpuCode` varchar(32) NOT NULL DEFAULT '',
`originCategoryId` bigint(20) unsigned NOT NULL DEFAULT '0' ,
`originCategoryName` varchar(50) NOT NULL DEFAULT '' ,
`marketPrice` decimal(10,2) unsigned NOT NULL DEFAULT '0.00',
`status` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
`isDeleted` tinyint(3) unsigned NOT NULL DEFAULT '0',
`timeCreated` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`timeModified` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_spuCode` (`spuCode`,`channelId`,`sellerId`),
KEY `idx_timeCreated` (`timeCreated`),
KEY `idx_spuName` (`spuName`),
KEY `idx_channelId_originCategory` (`channelId`,`originCategoryId`,`originCategoryName`) USING BTREE,
KEY `idx_sellerId` (`sellerId`)
) ENGINE=InnoDB AUTO_INCREMENT=12553120 DEFAULT CHARSET=utf8mb4 COMMENT='商品表'

从上述建表语句中我们发现timeCreated走普通索引。 接下来我们根据创建时间来执行一下分页查询:

当为浅分页的时候,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 0,10

此时执行的时间为: "executeTimeMillis":1

当调整分页查询为深度分页之后,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 10000000,10

此时深度分页的查询时间为: "executeTimeMillis":27499

此时看到这里,小猫的场景已经重现了,此时深度分页的查询已经非常耗时。

剖析一下原因

简单回顾一下普通索引和聚簇索引

我们来回顾一下普通索引和聚簇索引(也有人叫做聚集索引)的关系。

大家可能都知道Mysql底层用的数据结构是B+tree(如果有不知道的伙伴可以自己了解一下为什么mysql底层是B+tree),B+tree索引其实可以分为两大类,一类是聚簇索引,另外一类是非聚集索引(即普通索引)。

(1)聚簇索引:InnoDB存储表是索引组织表,聚簇索引就是一种索引组织形式,聚簇索引叶子节点存放表中所有行数据记录的信息,所以经常会说索引即数据,数据即索引。当然这个是针对聚簇索引。

02.png

由图可知在执行查询的时候,从根节点开始共经历了3次查询即可找到真实数据。倘若没有聚簇索引的话,就需要在磁盘上进行逐个扫描,直至找到数据为止。显然,索引会加快查询速度,但是在写入数据的时候,由于需要维护这颗B+树,因此在写入过程中性能也会下降。

(2)普通索引:普通索引在叶子节点并不包含所有行的数据记录,只是会在叶子节点存本身的键值和主键的值,在检索数据的时候,通过普通索引子节点上的主键来获取想要找到的行数据记录。

03.png

由图可知流程,首先从非聚簇索引开始寻找聚簇索引,找到非聚簇索引上的聚簇索引后,就会到聚簇索引的B+树上进行查询,通过聚簇索引B+树找到完整的数据。该过程比较专业的叫法也被称为“回表”。

看一下实际深度分页执行过程

有了以上的知识基础我们再来回过头看一下上述深度分页SQL的执行过程。 上述的查询语句中idx_timeCreated显然是普通索引,咱们结合上述的知识储备点,其深度分页的执行就可以拆分为如下步骤:

1、通过普通索引idx_timeCreated,过滤timeCreated,找到满足条件的记录ID;

2、通过ID,回到主键索引树,找到满足记录的行,然后取出展示的列(回表);

3、扫描满足条件的10000010行,然后扔掉前10000000行,返回。

结合看一下执行计划:

04.png

原因其实很清晰了: 显然,导致这句SQL速度慢的问题出现在第2步。其中发生了10000010次回表,这前面的10000000条数据完全对本次查询没有意义,但是却占据了绝大部分的查询时间。

再深入一点从底层存储来看,数据库表中行数据、索引都是以文件的形式存储到磁盘(硬盘)上的,而硬盘的速度相对来说要慢很多,存储引擎运行sql语句时,需要访问硬盘查询文件,然后返回数据给服务层。当返回的数据越多时,访问磁盘的次数就越多,就会越耗时。

替换limit分页的一些方案。

上述我们其实已经搞清楚深度分页慢的原因了,总结为“无用回表次数过多”。

那怎么优化呢?相信大家应该都已经知道了,其核心当然是减少无用回表次数了。

有哪些方式可以帮助我们减少无用回表次数呢?

子查询法

思路:如果把查询条件,转移回到主键索引树,那就不就可以减少回表次数了。 所以,咱们将实际的SQL改成下面这种形式:

select * FROM Product where id >= (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000, 1) LIMIT 10;

测试一下执行时间: "executeTimeMillis":2534

我们可以明显地看到相比之前的27499,时间整整缩短了十倍,在结合执行计划观察一下。

05.png

我们综合上述的执行计划可以看出,子查询 table p查询是用到了idx_timeCreated索引。首先在索引上拿到了聚集索引的主键ID,省去了回表操作,然后第二查询直接根据第一个查询的 ID往后再去查10个就可以了!

显然这种优化方式是有效的。

使用inner join方式进行优化

这种优化的方式其实和子查询优化方法如出一辙,其本质优化思路和子查询法一样。 我们直接来看一下优化之后的SQL:

select * from Product p1 inner join (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000,10) as p2 on p1.id = p2.id

测试一下执行的时间: "executeTimeMillis":2495

06.png

咱们发现和子查询的耗时其实差不多,该思路是先通过idx_timeCreated二级索引树查询到满足条件的主键ID,再与原表通过主键ID内连接,这样后面直接走了主键索引了,同时也减少了回表。

上面两种方式其核心优化思想都是减少回表次数进行优化处理。

标签记录法(锚点记录法)

我们再来看下一种优化思路,上述深度分页慢原因我们也清楚了,一次性查询的数据太多也是问题,所以我们从这个点出发去优化,每次查询少量的数据。那么我们可以采用下面那种锚点记录的方式。类似船开到一个地方短暂停泊之后继续行驶,那么那个停泊的地方就是抛锚的地方,老猫喜欢用锚点标记来做比方,当然看到网上有其他的小伙伴称这种方式为标签记录法。其实意思也都差不多。

这种方式就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。我们直接看一下SQL:

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id>10000000 limit 10

显然,这种方式非常快,耗时如下: "executeTimeMillis":1

但是这种方式显然是有缺陷的,大家想想如果我们的id不是连续的,或者说不是自增形式的,那么我们得到的数据就一定是不准确的。与此同时咱们也不能跳页查看,只能前后翻页。

当然存在相同的缺陷,我们还可以换一种写法。

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id between 10000000 and 10000010  

这种方式也是一样存在上述缺陷,另外的话更要注意的是between ...and语法是两头都是闭区域间。上述语句如果ID连续不断地情况下,咱们最终得到的其实是11条数据,并不是10条数据,所以这个地方还是需要注意的。

存入到es中

上述罗列的几种分页优化的方法其实已经够用了,那么如果数据量再大点的话咋整,那么我们可能就要选择其他中间件进行查询了,当然我们可以选择es。那么es真的就是万能药吗?显然不是。ES中同样存在深度分页的问题,那么针对es的深度分页,那么又是另外一个故事了,这里咱们就不展开了。

写到最后

那么半夜三更爬起来优化慢查询的小猫究竟有没有解决问题呢?电脑前,小猫长吁了一口气,解决了! 我们看下小猫的优化方式:

select * from InventorySku isk inner join (select id from InventorySku where inventoryId = 6058 limit 109500,500 ) as d on isk.id = d.id

显然小猫采用了inner join的优化方法解决了当前的问题。

相信小伙伴们后面遇到这类问题也能搞定了。


作者:程序员老猫
来源:juejin.cn/post/7384652811554308147
收起阅读 »

零成本搭建个人图床服务器

前言 图床服务器是一种用于存储和管理图片的服务器,可以给我们提供将图片上传后能外部访问浏览的服务。这样我们在写文章时插入的说明图片,就可以集中放到图床里,既方便多平台文章发布,又能统一管理和备份。 当然下面通过在 GitHub 上搭建的图床,不光不用成本,而且...
继续阅读 »

前言


图床服务器是一种用于存储和管理图片的服务器,可以给我们提供将图片上传后能外部访问浏览的服务。这样我们在写文章时插入的说明图片,就可以集中放到图床里,既方便多平台文章发布,又能统一管理和备份。


当然下面通过在 GitHub 上搭建的图床,不光不用成本,而且还能上传视频或音乐。操作方法和以前在 GitHub 上搭建静态博客类似,但是中间会多一些一些工具介绍和技巧。


流程



  • 创建仓库

  • 设置仓库

  • 连接仓库

  • 应用 Typora


创建仓库


创建仓库和平时的代码托管一样,添加一个 public 权限仓库,用默认的 main 分支。当然也可以提前创建一个目录,但是根目录最好有一个 index.html。



设置仓库


设置仓库主要是添加提交 Token,和配置 GitHub Pages 参数。而这两小步的设置,在前面文章 "Hexo 博客搭建" 有比较详细介绍,所以这里就稍微文字带过了。


Token 生成


登陆 GitHub -> Settings -> Developer settings -> Personal access tokens -> Tokens (classic),然后点击 "Generate new token",填写备注和过期时间,权限主要勾选 "repo"、"workflow"、"user"。最后生成 "ghp_" 前缀的字符串就是 Token 了,复制并保存下来。


GitHub Pages 配置


进入仓库页 -> Settings -> Pages,设置 Branch,指定仓库的分支和分支根目录,Source 选择 "Deploy from a branch",最后刷新或者重新进入,把访问链接地址复制保存下来。



连接仓库


连接可以除了 API 方式,也可以用第三方的工具,比如 "PicGo"。工具位置自行搜索哈,下面以他为例,演示工具的连接配置、文件上传和访问测试。


连接配置


找到 "图床设置" -> "GitHub",下面主要填写仓库名(需带上账户名),分支名(默认 main 即可),Token(上面生成保存下来的),存储路径(后带斜杠)可以填写已存在,如果不存在则在仓库根目录下新建。



文件上传


文件格式除了下面指定的如 Markdown、HTML、URL 外,还能上传图片音乐视频等(亲测有效)。点击 "上传区",将文件直接拖动到该窗口,提示上传成功后,进入 GitHub 仓库下查看是否存在。 



访问测试


访问就是能将仓库里的图片或视频以外链的方式展示,就像将文件放在云平台的存储桶一样。将前面 GitHub Pages 开启的链接复制下来,然后拼接存储路径和文件名就可以访问了。



应用 Typora


Typora 通过 PicGo 软件自动上传图片到 GitHub 仓库中。打开 Typora 的文件 -> 偏好设置 -> 图像 -> 上传图片 -> 配置 PicGo 路径,然后指定一下 PicGo 的安装位置。 



开始使用


可以点击 "验证图片上传选项",验证成功就代表已经将 Typora 的图标上传到仓库,也可以直接将图片复制到当前 md 文档位置。



![image-20240608145607117](https://raw.githubusercontent.com/z11r00/zd_image_bed/main/img/image-20240608145607117.png)

上传成功后会将返回一个如上面的远程链接,并且无法打开和显示,这是就要在 PicGo 工具的图床设置中。将自己 GitHUb 上的域名设定为自定义域名,格式 "域名 / 仓库名", 在 Typora 上传图片后重启就可展示了。


image-20240612104856943


作者:北桥苏
来源:juejin.cn/post/7384320850722553867
收起阅读 »

12306全球最大票务系统与Gemfire介绍

全球最大票务系统 自2019年12月12日发售春运首日车票,截至2020年1月9日,12306全渠道共发售车票4.12亿张,日均售票能力达到了2000万张,平均一年售出30亿张火车票,也就是说12306已经发展成全球交易量最大的实时票务系统。 12306发布数...
继续阅读 »

全球最大票务系统


自2019年12月12日发售春运首日车票,截至2020年1月9日,12306全渠道共发售车票4.12亿张,日均售票能力达到了2000万张,平均一年售出30亿张火车票,也就是说12306已经发展成全球交易量最大的实时票务系统。


12306发布数据显示,2020年春运期间,40天的春运期间,12306最高峰日网站点击量为1495亿次,这相当于每个中国人一天在12306上点击了100次,平均每秒点击量为170多万次。而全球访问量最大的搜索引擎网站, 谷歌日访问量也不过是56亿次,一个12306的零头。 再看一下大家习惯性做对比的淘宝,2019年双十一当天,淘宝的日活跃用户为4.76亿,相当于每个人也在淘宝上点击300多次,才能赶上12306的峰值点击量。


上亿人口,40天时间,30亿次出行,12306之前,全球没有任何一家公司和产品接手过类似的任务。这个网站是在数亿人苛刻的目光中,做一件史无前例却又必须成功的事情。


历史发展


10年前铁道部顶着重重压力决心要解决买车票这个全民难题,2010年春运首日12306网站开通并试运行,2011年12月23日网站正式上线,铁道部兑现了让网络售票覆盖所有车次的承诺,不料上线第一天,全民蜂拥而入,流量暴增,网站宕机,除此之外,支付流程繁琐支付渠道单一,各种问题不断涌现,宕机可能会迟到,但永远不会缺席,12306上线的第二年,网站仍然难以支撑春运的巨大流量,很多人因为网站的各种问题导致抢票失败,甚至耽误了去线下买票的最佳时机,铁道部马不解鞍听着批评,一次又一次给12306改版升级,这个出生的婴儿几乎是在骂声中长大的。2012年9月,中秋国庆双节来临之前,12306又一次全站崩溃,本来大家习以为常的操作,却被另一个消息彻底出炉,这次崩溃之前,铁道部曾花了3.3亿对系统进行升级,中标的不是IBM惠普EMC等大牌厂商,而是拥有国字号背景的太极股份和同方股份,铁道部解释说3.3亿已经是最低价了,但没人能听进去,大家只关心他长成了什么样,没人关心他累不累,从此之后,铁道部就很少再发声明了。


2013年左右,各种互联网公司表示我行我上,开发了各种抢票网站插件。当时360浏览器靠免费抢票创下国内浏览器使用率的最高纪录,百度猎豹搜狗UC也纷纷加入,如今各类生活服务APP,管他干啥的,都得植入购票抢票功能和服务,12306就这样被抢票软件围捕了。不同的是,过去抢票是免费,现在由命运馈赠的火车票,都在明面上标好了价格,比如抢票助力包,一般花钱买10元5份,也可以邀请好友砍一刀,抢票速度上,分为低快高级光速VIP等等速度,等级越高就越考验钱包。


2017年12306上线了选座和接续换乘功能,从此爱人可以自由抢靠窗座,而且夹在两人之间坐立不安,换乘购票也变得简单。2019年上线官方捡漏神器候补购票功能,可以代替科技黄牛,自动免费为旅客购买退票余票。......


阿里云当时主要是给他们提供虚拟机服务,主要是做IaaS这一层,就是基础设施服务这一层,2012年熟悉阿里云历史的应该都知道,那个时候阿里云其实还是很小的一个厂商,所以不要盲目夸大阿里云在里面起的作用。


技术难点


1、巨大流量,高请求高并发。


2、抢票流量。每天放出无数个爬虫机器人,模拟真人登陆12306,不间断的刷新网站余票,这会滋生很多的灰色流量,也会给12306本身的话造成非常大的压力。


3、动态库存。电商的任务是购物结算,库存是唯一且稳定的,而12306每卖出一张车票,不仅要减少首末站的库存,还要同时减少一趟列车所有过路站的。



以北京西到深圳福田的G335次高铁为例,表面上看起来中间有16个车站及16个SKU,但实际上不同的起始站都会产生新的SKU。我们将所有起始和终点的可能性相加,就是16+15+14一直加到一,一共136个SKU,而每种票对应三种座位,所以一共是408个商品。然后更复杂的是用户每买一张票会影响其他商品的库存,假如用户买了一张北京西的高碑店东的票,那北京始发的16个SKU库存都要减一,但是它并不影响非北京始发车票的库存,
更关键的是这些SKU间有的互斥,有的不互斥,优先卖长的还是优先卖短程的呢,每一次火车票的出售都会引发连锁变化,让计算量大大增加,如果再叠加当前的选座功能,计算数量可能还要再翻倍,而这些计算数据需要在大量购票者抢票的数秒,甚至数毫秒内完成,难度可想而知有多多大。



4、随机性。你永远都不知道哪一个人会在哪一天,去到哪一个地点,而双十一的预售和发货,其实已经提前准备了一个月,甚至几个月,并不是集中在双十一那天爆发的那一天。所以必须要有必须要有动态扩容的能力。


读扩散和写扩散


上面说的动态库存,就比如 A -> B -> C -> D 共 4 个车站,假如乘客买了 B -> C 的车票,那么同时会影响到 A->C,A->D,B->C,B->D,涉及了多个车站的排列组合,这里计算是比较耗费性能的。


那么这里就涉及到了 “读扩散” 和 “写扩散” 的问题,在 12 年的时候,12306 使用的就是读扩散,也就是在扣减余票库存的时候,直接扣减对应车站,而在查询的时候,进行动态计算。而写扩散就是在写的时候,就动态计算每个车站应该扣除多少余票库存,在查询的时候直接查即可。


12306本身他其实是读的流量远远大于写的流量,我个人是认为写扩散其实会更好一点。


Pivotal Gemfire


Redis 在互联网公司中使用的是比较多的,而在银行、12306 很多实时交易的系统中,很多采用 Pivotal Gemfire作为解决方案。Redis 是开源的缓存解决方案,而 Pivotal Gemfire 是商用的,我们在互联网项目中为什么使用 Redis 比较多呢,就是因为 Redis 是开源的,不要钱,开源对应的也就是稳定性不是那么的强,并且开源社区也不会给你提供解决方案,毕竟你是白嫖的,而在银行以及 12306 这些系统中,它们对可靠性要求非常的高,因此会选择商用的 Pivotal Gemfire,不仅性能强、高可用,而且 Gemfire 还会提供一系列的解决方案,据说做到了分布式系统中的 CAP


12306 的性能瓶颈就在于余票的查询操作上,上边已经说了,12306 是采用读扩散,也就是客户买票之后,扣减库存只扣减对应车站之间的余票库存,在读的时候,再来动态的计算每个站点应该有多少余票,因此读性能是 12306 的性能瓶颈


当时 12306 也尝试了许多其他的解决方案,比如 cassandra 和 mamcached,都扛不住查询的流量,而使用 Gemfire 之后扛住了流量,因此就使用了 Gemfire。2012年6月一期先改造12306的主要瓶颈——余票查询系统。 9月份完成代码改造,系统上线。2012年国庆,又是网上订票高峰期间,大家可以显著发现,可以登录12306,虽然还是很难订票,但是查询余票很快。2012年10月份,二期用GemFire改造订单查询系统(客户查询自己的订单记录)2013年春节,又是网上订票高峰期间,大家可以显著发现,可以登录12306,虽然还是很难订票,但是查询余票很快,而且查询自己的订票和下订单也很快。


技术改造之后,在只采用10几台X86服务器实现了以前数十台小型机的余票计算和查询能力,单次查询的最长时间从之前的15秒左右下降到0.2秒以下,缩短了75倍以上。 2012年春运的极端高流量并发情况下,系统几近瘫痪。而在改造之后,支持每秒上万次的并发查询,高峰期间达到2.6万个查询/秒吞吐量,整个系统效率显著提高;订单查询系统改造,在改造之前的系统运行模式下,每秒只能支持300-400个查询/秒的吞吐量,高流量的并发查询只能通过分库来实现。改造之后,可以实现高达上万个查询/秒的吞吐量,而且查询速度可以保障在20毫秒左右。新的技术架构可以按需弹性动态扩展,并发量增加时,还可以通过动态增加X86服务器来应对,保持毫秒级的响应时间。


通过云计算平台虚拟化技术,将若干X86服务器的内存集中起来,组成最高可达数十TB的内存资源池,将全部数据加载到内存中,进行内存计算。计算过程本身不需要读写磁盘,只是定期将数据同步或异步方式写到磁盘。GemFire在分布式集群中保存了多份数据,任何一台机器故障,其它机器上还有备份数据,因此通常不用担心数据丢失,而且有磁盘数据作为备份。GemFire支持把内存数据持久化到各种传统的关系数据库、Hadoop库和其它文件系统中。大家知道,当前计算架构的瓶颈在存储,处理器的速度按照摩尔定律翻番增长,而磁盘存储的速度增长很缓慢,由此造成巨大高达10万倍的差距。这样就很好理解GemFire为什么能够大幅提高系统性能了。Gemfire 的存储和计算都在一个地方,它的存储和实时计算的性能目前还没有其他中间件可以取代。


但是 Gemfire 也存在不足的地方,对于扩容的支持不太友好的,因为它里边有一个 Bucket 类似于 Topic 的概念,定好 Bucket 之后,扩容是比较难的,在 12306 中,也有过测试,需要几十个T的内存就可以将业务数据全部放到内存中来,因此直接将内存给加够,也就不需要很频繁的扩容。


12306业务解决方案


当然在优化中,我们靠改变架构加机器可以提升速度效率,剩下的也需要业务上的优化。


1、验证码。如果说是淘宝啊这种网站,他用这种验证码,用12306的验证码,可能大家都不会用了,对不对,但是12306他比较特殊,因为铁路全国就他一家,所以说他可以去做这个事情,他不用把用户体验放在第一位
。他最高的优先级是怎么把票给需要的人手上。


当然这个利益的确是比较大,所以也会采用这种人工打码的方式,可以雇一批大学生去做这个验证码识别。


2、候补。候补车票其实相当于整个系统上,它是一个异步的过程,你可以在这里排队,后面的话也没有抢到票,后面再通知你。


3、分时段售票。对于抢票来说,瞬时抢票会导致对服务器有瞬间很大的压力,因此从业务设计上来说需要将抢票的压力给分散开,比如今天才开启抢15天之后的车票。2点抢票,3点抢票等等。


总结


只有程序员才知道,一个每天完成超过1500万个订单,承受近1500亿次点击的系统到底有多难,在高峰阶段的时候,平均每秒就要承受170多万次的点击,面对铁路运输这种特殊的运算模式,也能够保证全国人民在短时间内抢到回家的票,12306就是在无数国人的苛责和质疑中,创造了一个世界的奇迹。


12306除了技术牛,还有着自己的人情关怀,系统会自动识别购票者的基本信息,如果识别出订单里有老人会优先给老人安排下铺儿童和家长会尽量安排在邻近的位置,12306 在保证所有人都能顺利抢到回家的票的同时,还在不断地增加更多的便利,不仅在乎技术问题,更在乎人情异味,12306可能还不够完美,但他一直在努力变得更好,为我们顺利回家提供保障,这是背后无数程序员日夜坚守的结果,我们也应该感谢总设计师单杏花女士,所以你可以调侃,可以批评,但不能否认12306背后所做出的所有努力!


作者:jack_xu
来源:juejin.cn/post/7381747852831653929
收起阅读 »

秒懂双亲委派机制

前言 最近有位小伙伴问了我一个问题:JDBC为什么会破坏双亲委派机制? 这个问题挺有代表性的。 双亲委派机制是Java中非常重要的类加载机制,它保证了类加载的完整性和安全性,避免了类的重复加载。 这篇文章就跟大家一起聊聊,Java中类加载的双亲委派机制到底是怎...
继续阅读 »

前言


最近有位小伙伴问了我一个问题:JDBC为什么会破坏双亲委派机制?


这个问题挺有代表性的。


双亲委派机制是Java中非常重要的类加载机制,它保证了类加载的完整性和安全性,避免了类的重复加载。


这篇文章就跟大家一起聊聊,Java中类加载的双亲委派机制到底是怎么回事,有哪些破坏双亲委派机制的案例,为什么要破坏双亲委派机制,希望对你会有所帮助。


1 为什么要双亲委派机制?


我们的Java在运行之前,首先需要把Java代码转换成字节码,即class文件。


然后JVM需要把字节码通过一定的方式加载到内存中的运行时数据区


这种方式就是类加载器(ClassLoader)。


再通过加载、验证、准备、解析、初始化这几个步骤完成类加载过程,然后再由jvm执行引擎的解释器和JIT即时编译器去将字节码指令转换为本地机器指令进行执行。


我们在使用类加载器加载类的时候,会面临下面几个问题:



  1. 如何保证类不会被重复加载?类重复加载会出现很多问题。

  2. 类加载器是否允许用户自定义?

  3. 如果允许用户自定义,如何保证类文件的安全性?

  4. 如何保证加载的类的完整性?


为了解决上面的这一系列的问题,我们必须要引入某一套机制,这套机制就是:双亲委派机制


2 什么是双亲委派机制?


接下来,我们看看什么是双亲委派机制。


双亲委派机制的基本思想是:当一个类加载器试图加载某个类时,它会先委托给其父类加载器,如果父类加载器无法加载,再由当前类加载器自己进行加载。


这种层层委派的方式有助于保障类的唯一性,避免类的重复加载,并提高系统的安全性和稳定性。


在Java中默认的类加载器有3层:



  1. 启动类加载器(Bootstrap Class Loader):负责加载 %JAVA_HOME%/jre/lib 目录下的核心Java类库,比如:rt.jar、charsets.jar等。它是最顶层的类加载器,通常由C++编写。

  2. 扩展类加载器(Extension Class Loader):负责加载Java的扩展库,一般位于/lib/ext目录下。

  3. 应用程序类加载器(Application Class Loader):也称为系统类加载器,负责加载用户类路径(ClassPath)下的应用程序类。


用一张图梳理一下,双亲委派机制中的3种类加载器的层次关系:图片


但这样不够灵活,用户没法控制,加载自己想要的一些类。


于是,Java中引入了自定义类加载器。


创建一个新的类并继承ClassLoader类,然后重写findClass方法。


该方法主要是实现从那个路径读取 ar包或者.class文件,将读取到的文件用字节数组来存储,然后可以使用父类的defineClass来转换成字节码。


如果想破坏双亲委派的话,就重写loadClass方法,否则不用重写。


类加载器的层次关系改成:图片


双亲委派机制流程图如下:图片


具体流程大概是这样的:



  1. 需要加载某个类时,先检查自定义类加载器是否加载过,如果已经加载过,则直接返回。

  2. 如果自定义类加载器没有加载过,则检查应用程序类加载器是否加载过,如果已经加载过,则直接返回。

  3. 如果应用程序类加载器没有加载过,则检查扩展类加载器是否加载过,如果已经加载过,则直接返回。

  4. 如果扩展类加载器没有加载过,则检查启动类加载器是否加载过,如果已经加载过,则直接返回。

  5. 如果启动类加载器没有加载过,则判断当前类加载器能否加载这个类,如果能加载,则加载该类,然后返回。

  6. 如果启动类加载器不能加载该类,则交给扩展类加载器。扩展类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  7. 如果扩展类加载器不能加载该类,则交给应用程序类加载器。应用程序类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  8. 如果应用程序类加载器不能加载该类,则交给自定义类加载器。自定义类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  9. 如果自定义类加载器,也无法加载这个类,则直接抛ClassNotFoundException异常。


这样做的好处是:



  1. 保证类不会重复加载。加载类的过程中,会向上问一下是否加载过,如果已经加载了,则不会再加载,这样可以保证一个类只会被加载一次。

  2. 保证类的安全性。核心的类已经被启动类加载器加载了,后面即使有人篡改了该类,也不会再加载了,防止了一些有危害的代码的植入。


3 破坏双亲委派机制的场景


既然Java中引入了双亲委派机制,为什么要破坏它呢?


答:因为它有一些缺点。


下面给大家列举一下,破坏双亲委派机制最常见的场景。


3.1 JNDI


JNDI是Java中的标准服务,它的代码由启动类加载器去加载。


但JNDI要对资源进行集中管理和查找,它需要调用由独立厂商在应用程序的ClassPath下的实现了JNDI接口的代码,但启动类加载器不可能“认识”这些外部代码。


为了解决这个问题,Java后来引入了线程上下文类加载器(Thread Context ClassLoader)。


这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置。


如果创建线程时没有设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。


有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这样就打破了双亲委派机制。


3.2 JDBC


原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。


例如,MySQL的mysql-connector.jar中的Driver类具体实现的。


原生的JDBC中的类是放在rt.jar包,是由启动类加载器进行类加载的。


在JDBC中需要动态去加载不同数据库类型的Driver实现类,而mysql-connector.jar中的Driver实现类是用户自己写的代码,启动类加载器肯定是不能加载的,那就需要由应用程序启动类去进行类加载。


为了解决这个问题,也可以使用线程上下文类加载器(Thread Context ClassLoader)。


3.3  Tomcat容器


Tomcat是Servlet容器,它负责加载Servlet相关的jar包。


此外,Tomcat本身也是Java程序,也需要加载自身的类和一些依赖jar包。


这样就会带来下面的问题:



  1. 一个Tomcat容器下面,可以部署多个基于Servlet的Web应用,但如果这些Web应用下有同名的Servlet类,又不能产生冲突,需要相互独立加载和运行才行。

  2. 但如果多个Web应用,使用了相同的依赖,比如:SpringBoot、Mybatis等。这些依赖包所涉及的文件非常多,如果全部都独立,可能会导致JVM内存不足。也就是说,有些公共的依赖包,最好能够只加载一次。

  3. 我们还需要将Tomcat本身的类,跟Web应用的类隔离开。


这些原因导致,Tomcat没有办法使用传统的双亲委派机制加载类了。


那么,Tomcat加载类的机制是怎么样的?


图片



  • CommonClassLoader:是Tomcat最基本的类加载器,它加载的类可以被Tomcat容器和Web应用访问。

  • CatalinaClassLoader:是Tomcat容器私有的类加载器,加载类对于Web应用不可见。

  • SharedClassLoader:各个Web应用共享的类加载器,加载的类对于所有Web应用可见,但是对于Tomcat容器不可见。

  • WebAppClassLoader:各个Web应用私有的类加载器,加载类只对当前Web应用可见。比如不同war包应用引入了不同的Spring版本,这样能加载各自的Spring版本,相互隔离。


3.4 热部署


由于用户对程序动态性的追求,比如:代码热部署、代码热替换等功能,引入了OSGi(Open Service Gateway Initiative)。


OSGi中的每一个模块(称为Bundle)。


当程序升级或者更新时,可以只停用、重新安装然后启动程序的其中一部分,对企业来说这是一个非常诱人的功能。


OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。


各个Bundle加载器是平级关系。


不是双亲委派关系。




作者:苏三说技术
来源:juejin.cn/post/7383894631312769074
收起阅读 »

ThreadLocal不香了,ScopedValue才是王道

ThreadLocal的缺点在Java中,当多个方法要共享一个变量时,我们会选择使用ThreadLocal来进行共享,比如:  以上代码将字符串“dadudu”通过设置到ThreadLocal中,从而可以做到在main()方法中赋值,在a(...
继续阅读 »

ThreadLocal的缺点

在Java中,当多个方法要共享一个变量时,我们会选择使用ThreadLocal来进行共享,比如:  以上代码将字符串“dadudu”通过设置到ThreadLocal中,从而可以做到在main()方法中赋值,在a()b()方法中获取值,从而共享值。

生命在于思考,我们来想想ThreadLocal有什么缺点:

  1. 第一个就是权限问题,也许我们只需要在main()方法中给ThreadLocal赋值,在其他方法中获取值就可以了,而上述代码中a()b()方法都有权限给ThreadLocal赋值,ThreadLocal不能做权限控制。
  2. 第二个就是内存问题,ThreadLocal需要手动强制remove,也就是在用完ThreadLocal之后,比如b()方法中,应该调用其remove()方法,但是我们很容易忘记调用remove(),从而造成内存浪费

ScopedValue

而JDK21中的新特性ScopedValue能不能解决这两个缺点呢?我们先来看一个ScopedValue的Demo: 

首先需要通过ScopedValue.newInstance()生成一个ScopedValue对象,然后通过ScopedValue.runWhere()方法给ScopedValue对象赋值,runWhere()的第三个参数是一个lambda表达式,表示作用域,比如上面代码就表示:给NAME绑定值为"dadudu",但是仅在调用a()方法时才生效,并且在执行runWhere()方法时就会执行lambda表达式。

比如上面代码的输出结果为: 

从结果可以看出在执行runWhere()时会执行a()a()方法中执行b()b()执行完之后返回到main()方法执行runWhere()之后的代码,所以,在a()方法和b()方法中可以拿到ScopedValue对象所设置的值,但是在main()方法中是拿不到的(报错了),b()方法之所以能够拿到,是因为属于a()方法调用栈中。

所以在给ScopedValue绑定值时都需要指定一个方法,这个方法就是所绑定值的作用域,只有在这个作用域中的方法才能拿到所绑定的值。

ScopedValue也支持在某个方法中重新开启新的作用域并绑定值,比如: 

以上代码中,在a()方法中重新给ScopedValue绑定了一个新值“xiaodudu”,并指定了作用域为c()方法,所以c()方法中拿到的值为“xiaodudu”,但是b()中仍然拿到的是“dadudu”,并不会受到影响,以上代码的输出结果为: 

甚至如果把代码改成: 

以上代码在a()方法中有两处调用了c()方法,我想大家能思考出c1c2输出结果分别是什么: 

所以,从以上分析可以看到,ScopedValue有一定的权限控制:就算在同一个线程中也不能任意修改ScopedValue的值,就算修改了对当前作用域(方法)也是无效的。另外ScopedValue也不需要手动remove,关于这块就需要分析它的实现原理了。

实现原理

大家先看下面代码,注意看下注释: 

执行main()方法时,main线程执行过程中会执行runWhere()方法三次,而每次执行runWhere()时都会生成一个Snapshot对象,Snapshot对象中记录了所绑定的值,而Snapshot对象有一个prev属性指向上一次所生成的Snapshot对象,并且在Thread类中新增了一个属性scopedValueBindings,专门用来记录当前线程对应的Snapshot对象。

比如在执行main()方法中的runWhere()时:

  1. 会先生成Snapshot对象1,其prev为null,并将Snapshot对象1赋值给当前线程的scopedValueBindings属性,然后执行a()方法
  2. 在执行a()方法中的runWhere()时,会先生成Snapshot对象2,其prevSnapshot对象1,并将Snapshot对象2赋值给当前线程的scopedValueBindings属性,使得在执行b()方法时能从当前线程拿到Snapshot对象2从而拿到所绑定的值,runWhere()内部在执行完b()方法后会取prev,从而取出Snapshot对象1,并将Snapshot对象1赋值给当前线程的scopedValueBindings属性,然后继续执行a()方法后续的逻辑,如果后续逻辑调用了get()方法,则会取当前线程的scopedValueBindings属性拿到Snapshot对象1,从Snapshot对象1中拿到所绑定的值就可以了,而对于Snapshot对象2由于没有引用则会被垃圾回收掉。

所以,在用ScopedValue时不需要手动remove。

好了,关于ScopedValue就介绍到这啦,下次继续分享JDK21新特性,欢迎大家关注我的公众号:Hoeller,第一时间接收我的原创技术文章,谢谢大家的阅读。


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

开发经理:谁在项目里面用Stream. paraller()直接gun

大家好,我是小玺,今天给大家分享一下项目中关于Stream.parallel() 碰到的坑。 Stream.parallel() 是Java 8及以上版本引入的Stream API中的一个方法,它用于将一个串行流转换为并行流。并行流可以在多个处理器上同时执行操...
继续阅读 »

大家好,我是小玺,今天给大家分享一下项目中关于Stream.parallel() 碰到的坑。


Stream.parallel() 是Java 8及以上版本引入的Stream API中的一个方法,它用于将一个串行流转换为并行流。并行流可以在多个处理器上同时执行操作,从而显著提高对大量数据进行处理的性能。


踩坑日记


某个大型项目,晚上十一点多有个用户对小部分数据进行某项批量操作后,接口大半天没有反应最后返回超时报错,但是过了一段时间后,出现了部分数据被修改成功,部分数据则没有反应。用户立马跳起来,打电话投诉到公司领导层,于是乎领导层对上至开发经理和PM,下至小开发进行会议批斗,要求马上排查并解决问题,毕竟项目这么大,当初也是要求测试做过压测的,怎么出现这么大的生产事故。


1712648893920.png


于是乎开发和实施运维分头行事,开发人员排查问题,实施人员先把问题数据维护好,不能应该用户使用。一群开发也是很疑惑,开发和测试环境都没法复现出问题,简单过一下代码也没看出个所以然,由于时间问题,不得不呼叫一手开发经理帮忙看看,开发经理后台接口看完Stream.parallel()进行的操作代码立马就炸了,git看了下提交人【会笑】,把这个开发从头到脚喷了一遍。


在对会笑单独进行了长达半小时的“耐心教育”后(ps:问题安排另一名开发同事修复),开发经理给团队的所有后端开发人员又都教育了一遍。原来会笑在用并行流的时候,没有考虑线程池配置和事务问题,把一堆数据进行了批量更新,Stream.parallel()并行流默认使用的是ForkJoinPool.commonPool()作为线程池,该线程池默认最大线程数就是CPU核数。


1712648957687.png


雀食对于一些初中级开发来说,开发过程中往往喜欢用一些比较新颖的写法来实现但是对新语法又是一知半解的,Stream.parallel()作为Java的新特性,也就成了其中一个反面教材。如果操作数据量不大的情况,其实没有必要用到Stream.parallel(),效率反而会变差。


注意事项



  1. 线程安全:并行流并不能保证线程安全性,因此,如果流中的元素是共享资源或操作本身不是线程安全的,你需要确保正确同步或使用线程安全的数据结构。

  2. 数据分区:Java的并行流机制会自动对数据进行分区,但在某些情况下,数据分区的开销可能大于并行带来的收益,特别是对于小规模数据集。

  3. 效率考量:并非所有的流操作都能从并行化中受益,有些操作(如短流操作或依赖于顺序的操作)并行执行反而可能导致性能下降。而且,过多的上下文切换也可能抵消并行带来的优势。

  4. 资源消耗:并行流默认使用的线程池大小可能与机器的实际物理核心数相适应,但也可能与其他并发任务争夺系统资源。

  5. 结果一致性:并行流并不保证执行的顺序性,也就是说,如果流操作的结果依赖于元素的处理顺序,则不应该使用并行流。

  6. 事务处理:在涉及到事务操作时,通常需要避免在并行流中直接处理,如上述例子所示,应当将事务边界放在单独的服务方法内,确保每个线程内的事务独立完成。


Tips:线程数可以通JVM启动参数-Djava.util.concurrent.ForkJoinPool.common.parallelism=20进行修改


作者:小玺
来源:juejin.cn/post/7355431482687864883
收起阅读 »

记一次难忘的json反序列化问题排查经历

前言 最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。 案发现场 我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的...
继续阅读 »

前言


最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。


案发现场


我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的token信息。


然后根据token信息,获取到用户信息。


在转发到业务接口之前,将用户信息设置到用户上下文当中。


这样接口中的业务代码,就能通过用户上下文,获取到当前登录的用户信息了。


我们的token和用户信息,为了性能考虑都保存到了Redis当中。


用户信息是一个json字符串。


当时在用户登录接口中,将用户实体,使用fastjson工具,转换成了字符串:


JSON.toJSONString(userDetails);

保存到了Redis当中。


然后在filter中,通过一定的key,获取Redis中的字符串,反序列化成用户实体。


使用的同样是fastjson工具:


JSON.parseObject(json, UserEntity.class);

但在反序列化的过程中,filter抛异常了:com.alibaba.fastjson.JSONException: illegal identifier : \pos 1, line 1, column 2{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}


2 分析问题


我刚开始以为是json数据格式有问题。


将json字符串复制到在线json工具:http://www.sojson.com,先去掉化之后,再格式数据,发现json格式没有问题:![图片](p3-juejin.byteimg.com/tos-cn-i-k3…)


然后写了一个专门的测试类,将日志中打印的json字符串复制到json变量那里,使用JSON.parseObject方法,将json字符串转换成Map对象:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        // 输出解析后的 JSON 对象
        System.out.println(map);
    }
}

执行结果:


{password=$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe, credentialsNonExpired=true, roles=["admin"], accountNonExpired=true, id=13, authorities=[{"authority":"admin"}], enabled=true, accountNonLocked=true, username=admin}

竟然转换成功了。


这就让我有点懵逼了。。。


为什么相同的json字符串,在Test类中能够正常解析,而在filter当中却不行?


当时怕搞错了,debug了一下filter,发现获取到的json数据,跟Test类中的一模一样:图片


带着一脸的疑惑,我做了下面的测试。


8000页BAT大佬写的刷题笔记,让我offer拿到手软


莫非是反序列化工具有bug?


3 改成gson工具


我尝试了一下将json的反序列化工具改成google的gson,代码如下:


 Map map = new Gson().fromJson(userJson, Map.class);

运行之后,报了一个新的异常:com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 2 path $


这里提示json字符串中包含了:$


$是特殊字符,password是做了加密处理的,里面包含$.,这两种特殊字符。


为了快速解决问题,我先将这两个特字符替换成空字符串:


json = json.replace("$","").replace(".","");

日志中打印出的json中的password,已经不包含这两个特殊字符了:


2a10o3XfeGr0SHStAwLuJRW6ykE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe

但调整之后代码报了下面的异常:com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Expected name at line 1 column 2 path $.


跟刚刚有点区别,但还是有问题。


4 改成jackson工具


我又尝试了一下json的反序列化工具,改成Spring自带的的jackson工具,代码如下:


ObjectMapper objectMapper = new ObjectMapper();
try {
    Map map = objectMapper.readValue(json, Map.class);
catch (JsonProcessingException e) {
    e.printStackTrace();
}

调整之后,反序列化还是报错:com.fasterxml.jackson.core.JsonParseException: Unexpected character ('' (code 92)): was expecting double-quote to start field name


3种反序列化工具都不行,说明应该不是fastjson的bug导致的当前json字符串,反序列化失败。


到底是什么问题呢?


5 转义


之前的数据,我在仔细看了看。


里面是对双引号,是使用了转义的,具体是这样做的:"


莫非还是这个转义的问题?


其实我之前已经注意到了转义的问题,但使用Test类测试过,没有问题。


当时的代码是这样的:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        // 输出解析后的 JSON 对象
        System.out.println(map);
    }
}

里面也包含了一些转义字符。


我带着试一试的心态,接下来,打算将转义字符去掉。


看看原始的json字符串,解析有没有问题。


怎么去掉转义字符呢?


手写工具类,感觉不太好,可能会写漏一些特殊字符的场景。


8000页BAT大佬写的刷题笔记,让我offer拿到手软


我想到了org.apache.commons包下的StringEscapeUtils类,它里面的unescapeJava方法,可以轻松去掉Java代码中的转义字符。


于是,我调整了一下代码:


json = StringEscapeUtils.unescapeJava(json);
JSON.parseObject(json, UserEntity.class);

这样处理之后,发现反序列化成功了。


总结


这个问题最终发现还是转义的问题。


那么,之前Test类中json字符串,也使用了转义,为什么没有问题?


当时的代码是这样的:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        System.out.println(map);
    }
}

但在filter中的程序,在读取到这个json字符串之后,发现该字符串中包含了``转义符号,程序自动把它变成了\


调整一下Test类的main方法,改成三个斜杠的json字符串:


public static void main(String[] args) {
    String json = "{\"accountNonExpired\":true,\"accountNonLocked\":true,\"authorities\":[{\"authority\":\"admin\"}],\"credentialsNonExpired\":true,\"enabled\":true,\"id\":13,\"password\":\"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe\",\"roles\":[\"admin\"],\"username\":\"admin\"}";
    Map map = JSON.parseObject(json, Map.class);
    System.out.println(map);
}

执行结果:Exception in thread "main" com.alibaba.fastjson.JSONException: illegal identifier : \pos 1, line 1, column 2{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}抛出了跟文章最开始一样的异常。


说明其实就是转义的问题。


之前,我将项目的日志中的json字符串,复制到idea的Test的json变量中,当时将最外层的双引号一起复制过来了,保存的是1个斜杠的数据。


这个操作把我误导了。


而后面从在线的json工具中,把相同的json字符串,复制到idea的Test的json变量中,在双引号当中粘贴数据,保存的却是3个斜杠的数据,它会自动转义。


让我意识到了问题。


好了,下次如果遇到类似的问题,可以直接使用org.apache.commons包下的StringEscapeUtils类,先去掉转义,再反序列化,这样可以快速解决问题。


此外,这次使用了3种不同的反序列化工具,也看到了其中的一些差异。


作者:苏三说技术
来源:juejin.cn/post/7385081262871003175
收起阅读 »

当了程序员之后?(真心话)

分享是最有效的学习方式。 博客:blog.ktdaddy.com/ 地铁上刷到一个话题,觉得挺有意思的,如下。 看到很多朋友在下面吐槽,有说加班是真的多,有说找对象是真的难,有说程序员爱穿格子衫是假爱背电脑是真的等等,大家吐槽得都挺欢乐的。 老猫也开始复...
继续阅读 »

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



地铁上刷到一个话题,觉得挺有意思的,如下。


1709213979295.png


看到很多朋友在下面吐槽,有说加班是真的多,有说找对象是真的难,有说程序员爱穿格子衫是假爱背电脑是真的等等,大家吐槽得都挺欢乐的。


1709824704115.png


老猫也开始复盘这些年的经历,更多想聊的可能还是一个后端程序员的真实感悟。


入行


俗话说“男怕入错行,女怕嫁错郎。”相信很多朋友在进入一个行业之前都是深思熟虑的,亦或者是咨询过一些人,亦或者是查阅了挺多资料。然而老猫入行则相当奇葩,不是蓄谋已久,而是心血来潮。


一切都得从一部电视剧开始,不晓得大家有没有看过这部电视剧,佟丽娅主演的“我的经济适用男”。


1709215705556.png


12年的一部电视剧,挺老了,主要女主放弃富二代的追求和"成熟稳重老实巴交的IT男"好上了的桥段。当时心智单纯的老猫可谓看的是热血沸腾啊。一拍桌子,“发可油,劳资今后就要当那个男主,这结局多好啊,抱得美人归啊这是,我要学IT!”。当时老猫的专业是电子信息类的专业,后来基本就放弃了本专业,大学基本逃课就跑去学软件去了。


就这么上了贼船,一晃十年过去了。多年前,躲在实验室里,开发了一个简单的坦克大战的游戏,感觉自己是最牛逼的,子弹爱怎么飞怎么飞,坦克能开多块就开多快,那时候觉得自己就是这个IT世界的主角,“control evety thing”。在这样一个程序的世界里,所有的事儿都是自己说了算。


踏上社会后,遭遇社会惨无人道地毒打之后,发现要做的就是提升造火箭吹牛逼的能力,工作中是个crud-boy。键盘上磨损最严重的那几个键是“ctrl”,“c”,“v”,“x”。当年那个意气风发的少年已经不复存在,我是一个弱鸡螺丝钉。


1709217726156.png


工作十年


大部分后端程序员也主要是围绕着业务在转,所以crud可能占了大部分时间。


话虽如此,但还是有点除此以外的收获,这些收获甚至潜移默化地影响着我的生活。


技术日新月异,今天这个框架,明天那个架构,今天这种实现牛逼,明天那种部署更6等等,到头来发现自己一直都是在追着技术跑。也确实如果不奔跑的话,可能就会被淘汰。作为程序员来说适应变化也是程序员的一种品质,但是老猫觉得具备下面这些可能会更加重要一些,这些可能也是唯一不变的。


抽象思维很重要


第一次听到“架构师”这个职位的时候,觉得那一定是一个需要超强技术能力的人才能胜任的岗位。


后来才发现原来架构师也分种类,“业务架构”,“技术架构”等等。再后来发现无论哪种架构,其实他们身上会有一种共同的东西,那就是优秀的抽象思维。


啥是抽象思维?百度百科上是这么说的:


抽象思维,又称词的思维或者逻辑思维,是指用词进行判断、推理并得出结论的过程。
抽象思维以词为中介来反映现实。这是思维的最本质特征,也是人的思维和动物心理的根本区别。

说的比较官方,甚至有点不好懂。


大家以前上语文课的时候,有没有做过阅读理解,老师在讲课的时候常常我们概述一下这段文字到底讲了什么东西,越精简越好,可能要求20个字以内。其实这个过程就是在锻炼咱们的抽象思维能力以及概括能力。


在软件后端领域,当业务传达某一个需求的时候,往往需要产品去提炼一道,那么此时就是产品抽象,继而产品又将需求传达给相关的研发负责人,研发负责人设计出相关的实现模型,那么这又是技术抽象,这些抽象的过程就是将复杂的业务流程和逻辑转化为可管理和可重用的组件的过程。它的目的是简化系统的实现,聚焦于应用程序的核心功能,同时隐藏不必要的细节。抽象后设计出各种基础能力,通过对基础能力的组合和拼接,支持复杂多变的业务逻辑和业务形态。


gw1.png


具备抽象思维,能够让我们从复杂的业务中迅速切入业务关键点。在生活中可能表现在透过现象看到本质,或者碰到问题能够快速给出有效解决方案或思路。例如老猫上次遇到的“真-丢包事件”。


分层思维很重要


说到分层思维,应该准确地来说是建立在能够清晰的抽象出事务本质的基础上,而后再去做分层。


很多地方都会存在分层思想。生活中就有,大家双休日没事的时候估计会逛商场,商城的模式一般就是底层停车场,一层超市,二层卖服装的,三层儿童乐园,卖玩具的,四层吃饭看电影娱乐的等等。


再去聊到技术上的分层思想,例如OSI七层模型,大家在面试的时候甚至都碰到过。


gw2.png


抛开这些,其实我们对自己当前负责的一些业务,一些系统也需要去做一些分层划分,这些分层可以让我们更好地看清业务系统之间的关系。例如老猫之前梳理的一张图。


gw3.png


通过这样的分层梳理,我们可能更好地理解当前的系统组成以及层级关系。(备注一下,老猫这里画图工具用的还是wps绘制的)。


结构化思维很重要


结构化思维又是咋回事儿?
不着急,打个比方,咱们看下面一组数据:
213421790346567560889
现在有个要求,咱们需要记下这些数字,以及出现的次数。短时间内想要记住可能比较困难
如果我们把这些数字的内容调整下,变成下面这样:
00112233445566778899
是不是清晰了很多?


所谓的结构化思维,就是从无序到有序的一种思考过程,将搜集到的信息、数据、知识等素材按一定的逻辑进行分析、整理,呈现出有序的结构,继而化繁为简。有结构的信息更适合大脑记忆和理解。


人类大脑在处理信息的时候,有两个特点:


第一,不能一次太多,太多信息会让我们的大脑觉得负荷过大;乔治·米勒在他的论文《奇妙的数字7±2》中提出,人类大脑短期记忆无法一次容纳7个以上的记忆项目,比较容易记住的是3个项目,当然最容易的是1个。


第二,喜欢有规律的信息。有规律的信息能减少复杂度,Mitchell Waldrop在《复杂》一书中,提出一种用信息熵来进行复杂性度量的方法,所谓信息熵就是一条信息包含信息量的大小。举个例子,假设一条消息由符号A、C、G和T组成。如果序列高度有序,很容易描述,例如“A A A A A A A … A”,则熵为零。而完全随机的序列则有最大熵值。


ccfc037aa9b4e852ef2a16f8e58c4a86.png


老猫在写文章的时候喜欢先列一下要写的提纲,然后再跟着提纲一点一点的往下写,写定义,写实现,写流程。


虽然本文偷了个懒,没有写思维导图,老猫一般再聊到干货的时候都会和大家先列一下提纲。这种提纲其实也是结构化的一种。当我们遇到复杂系统需求的时候,咱们不妨先列个提纲,将需要做的按照自己定义好的顺序罗列好,这样解决起来会更加容易一些。


太过理性可能也不好


程序员做久了,做一件事情的时候都会去想着先做什么然后做什么一步一步,有时候会显得过于机械,不知变通,
有时候可能也会太过较真,大直男显得情商比较低,会多多少少给别人带去一些不便,记得在银行办理业务的时候会指出业务员说话的逻辑漏洞,然后不停地追问,最终可能导致业务员尴尬地叫来业务经理解释等等。


程序员思维做事情,可能在日常生活中比较严谨,但是很多时候还是会显得比较死板。


总结


以上是老猫觉得除了技术以外,觉得一个后端程序员应该具备的一些思考方式以及工作方式,当然也可能只是老猫的方法论,如果大家有其他的工作领悟,也欢迎大家留言,大家一起分享一下经验。


作者:程序员老猫
来源:juejin.cn/post/7343493283073507379
收起阅读 »