注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

那些阻碍我们前进的学生思维!

每个人都是从学生时代过来的,但是有人在短暂的时间里面脱胎换骨,而有人却保持学生思维五年,十年甚至更久。 说点心里话! 一、务虚与务实 埋头苦干,只能感动自己! 遗憾的事,我上大学的时候并没有参加过任何比赛,倒不是因为我的能力不如别的同学强,而是过于务实,觉得这...
继续阅读 »

每个人都是从学生时代过来的,但是有人在短暂的时间里面脱胎换骨,而有人却保持学生思维五年,十年甚至更久。


说点心里话!


一、务虚与务实


埋头苦干,只能感动自己!


遗憾的事,我上大学的时候并没有参加过任何比赛,倒不是因为我的能力不如别的同学强,而是过于务实,觉得这些太虚了,相反,很多能力不如我的同学,甚至啥也不懂的,他们也参加了不少比赛,也拿了不少奖,这在他们后续的求职中带了不少亮光!


因为大学生参加的比赛,特别是普通大学,说白了,都没什么难度,学校也不会要求做出什么牛逼的东西,更多的是让大学生能够积极参与,扩展自己的视野。


所以,这时候就别去在乎什么技术深浅,因为你做的东西不会有人拿去用的,而抓住每一次演讲,每一次表演,那才是真的锻炼你的能力的时候。


现在,曾经那些我觉得太务虚的人,有的人已经开始创业并且做得不错了,他们的技术虽然不强,但是他们的思维已经发生了改变,说白了,吹牛逼的时候都要硬气一点,这是实话。


职场也是,并不是你埋头苦干就能得到老板的赏识,老板一天那么忙,你不找机会展示自己,他怎么记住你?


换句话来说,PPT的好坏程度大于埋头苦干,无声的付出不如技巧性的展示。


不过话又说回来,务实是一种品质,也是我们应该坚守的,但是更多的时候要做到有策略的务实,而不是埋头的务实,因为我们大多人搞不了科研,做不了什么轰动的学术。


二、人与人的本质是价值的交换


人与人之间要么有情绪价值的交换,要么有经济价值的交换,如果因为情怀,因为理想主义,那么是毫无意义的。


起初不少同学找我解决问题的时候,我都会花费大量的时间去帮他们解决问题,我免费服务的同学个数不下于100个,那段时间我也才毕业参加工作,所以下班时候都花费了很多时间去无偿服务。


一开始我觉得挺开心,毕竟那时候觉得帮助他人,快乐自己嘛!


但是我却花费了这个世界上最宝贵的东西,那就是时间,我本可以去学习,去提升自己,但是却为了自己所谓的情怀,白白浪费时间,可笑的是免费帮助很多人,却没有得到一句感谢,那么这样的情怀有何意义!


后面我改为付费服务,用时间来换取金钱,这才是成年人该做的事。


这一点对于我的改变很大,我觉得最重要的是我有了付费的习惯,告别白嫖思维,我也会花费金钱去购买服务,购买知识,因为我明白免费的大多都不值钱。


三、以弱势来当挡箭牌


之前一个学弟说,他到了新公司,就给他安排了很有难度的工作,他抱怨道,为什么不能对毕业生友好一点,我回了他一句,“去你妈的,想做简单的,那就滚蛋”,拿毕业生来当挡箭牌,首先就是对自己极度不肯定,不相信自己。


也遇到一些同事和朋友总是抱怨道,“我他妈就这点工资,干嘛给我安排这么多,干嘛让我总是出差,干嘛让我做脏活累活”。


如果总是以工资低,自己工作年限短这些来抱怨,那么就别加入职场,职场本来就是残酷的,并不是请你来过好日子的,如果觉得不公,那么就换,没能力换,那么就要硬着头皮干。


四、别特立独行


“同事都是一群傻逼,一群卷狗,我才不想和他们有任何交集,不想同流合污!”


最近看到朋友圈有人骂同事,领导,但是何不自己回想一下,难道自己就那么清高,那么正确吗?


当你在骂别人的时候,其实大多时候骂的是自己。


在职场中如果太特立独行,总觉得谁不对,谁不行,那么就容易树敌,对于自己的职业发展很不好。


可能你离开了这个公司就觉得逃离了,但是如果把矛盾搞大,对于自己后面的职业都或多或少有一定的影响,试想,如果背调比较严格,那么对自己有利吗?


就算我的同事是傻逼,我也依然要和他做“好朋友”,这是一句很有智慧的话!


圆滑当道的世界,太过于尖锐未必是一件好事!


作者:追梦人刘牌
来源:juejin.cn/post/7300781073376509987
收起阅读 »

如何提升人的思考能力和深度?

除非是天才型如爱因斯坦、图灵等可以推动一个时代的人,否则绝大多数人和人之间的差异并不是很大,大家都是聪明的、有能力的。唯一影响人的是思维方式、自律能力和执行能力。而思维方式是其中最影响人的。那么如何提升人的思考能力和深度呢? 如果我们给到两个看起来同等聪明的人...
继续阅读 »

除非是天才型如爱因斯坦、图灵等可以推动一个时代的人,否则绝大多数人和人之间的差异并不是很大,大家都是聪明的、有能力的。唯一影响人的是思维方式、自律能力和执行能力。而思维方式是其中最影响人的。那么如何提升人的思考能力和深度呢?


如果我们给到两个看起来同等聪明的人同样的时间,那么思维方式更高效的人一定是成长更快的。我们在考虑一个问题的时候,我们应当是思考用什么的方式去思考这个问题更好。考虑思考方式,就是思考用什么思路、思维模式才能得到结果。也就是说先思考工作的方法然后再实施。


举个例子,我们想要将工作安排进一个日程表中,那么什么样的工作安排方式才能保证做出来的日程表上没有疏漏呢?那在安排之前我们就需要先确认工作顺序。如果这个日程表还涉及到了其它人,我们可能要先有大致的计划找到其它相关人确认和同意后再去安排时间更好。


比如一个简单的例子:“三个人都是朋友打算一起去海外旅行,应该选择哪个国家作为目的地比较好?”



  • 首先,对照大家的日程,看看三个人的假期的交集是哪些天数。

  • 列出 10 个在交集天数能去的国家,并且通过搜索等方式提取出这些国家的三个特色的关键词(如海边、法餐、卢浮宫等等具有特色的词语。)

  • 三人分别从观光、美食、娱乐、费用等作出预估和评价、一起讨论。

  • 将三人评价最高的国家作为最终去的地方。


从上述的例子中我们可以看出来,即使是一个复杂的、庞大的问题我们都可以将其分解为小问题,针对每个小问题设计论点,分析每个论点就能得到答案。


无论是什么课题,它需要的方法和流程都一样。首先整理和分解;然后对每个方法做数据分析;最后在每一种方法中找出重点,把重点落实到方案上。


那么我们要如何去训练呢?你可以在看到广告啊、新闻的时候立马设计一个课题,比如“香飘飘奶茶_一年卖出三亿多杯,能_环绕地球一圈”,那它是怎么计算出来的呢?是将销售额 x 杯子大小算出来的长度吗?绕地球一圈是指赤道吗?等等。可以哪个小本子记下来所有想到的问题。我们在看一个新闻时可以先对着标题思考几分钟,再去看内容,如此反复你就会越来越厉害。


为了帮助思考,我们可以利用脑图工具,将问题逐步的梳理到更细节的地方。当然我们是需要反馈的,我们可以找到这些领域的专家、朋友帮忙看看有没有什么建议或者存在有没有什么问题。


如果是一个比较大的课题,我们可以把很多现象(事实)收集起来,然后对他们进行分析,得到结论后给出行动建议。在《靠谱》中有提到“天上出现乌云,眼看就要下雨,带上伞比较好。”,这其实是对事实、分析和行动三者的比喻。


其次我们要特别区分现状事实和意见建议。这个意见是基于客观的数据得出来的,还是你个人的推测?还是最近的普遍趋势?谁都不知道。这样的话也就不能进行严密的讨论。区分事实现状、分析研究、行动方案,明确回答“结论”和“依据”。


《简单聊聊因果推断》


但就像我在因果推断里聊的一样,“厘清影响问题的各个元素并理解其间的关系 , 我们才能更好找出所谓问题的杠杆解。” 。我们在解决问题时需要关注问题所处的上下文、系统关系,以及各个元素之间的内部因果与联系。复杂问题之间互为因果、环环嵌套,因而有时难以分析和筛选、聚焦于某个单一问题而创造的方案,往往只是局部优化。为此我们需要分析每个问题的产生原因和影响结果,找到某个问题所关联的上下游问题。我们只能判断发生事件在时间上的先后是否有统计显著性,并不能判断因果。相关性并不能准确的说明因果关系。


我们人工推论出来的是一个在我们收集到的数据上的局部的(我们不是上帝视角,无法全知全能)相关性分析成果,它不一定是事实,可能只是一个猜测、预测。


我们做个复杂一点的课题的训练吧:“Louis Vuitton 将于 2 月 18 日起上调全球产品售价,幅度将在 8% 至 20% 之间。”


我们可以把这个课题拆解为下面几个问题:



  • 为什么 LV 要涨价?

  • 涨价之后会有什么影响?

  • 是否会影响未来销量?


对于第一个问题来说,无论 LV 为什么要涨价,它一定是基于未来奢侈品市场的销售增长预计与成本之间的博弈决定的,所以我们要先分析出未来的销售额预计。这里引申一个概念叫需求弹性。



一个人对某种商品的需求的唯一普遍规律就是:如果其他情况不变,他对此商品的需求会随着对其拥有量的增加而递减。这种递减也许缓慢也许迅速。如果缓慢,那么他对此商品所出的价格,就不会因为他对此商品的拥有量的大量增加而大幅度下降;而且价格的小幅度下降会使他的购买量大幅度增加。



在下方的问题下,我会补上一些来自于权威新闻网站的 “证据”,大家可以自行看看问题的答案是什么。


为什么 LV 要涨价?



  • 未来奢侈品的销售额预测是怎么样的?


欧洲时报11月16日,咨询公司贝恩(Bain)15 日发布的一项研究显示,到 2022 年,全球奢侈品行业的收入将达到 1.4 万亿欧元,按固定汇率计算,比 2021 年增长13%,这一增长应该使 95% 的奢侈品品牌受益。在美国和欧洲市场的推动下,这是“相当可观”的一年。研究预测,2030 年之前,奢侈品行业增长将保持在 3% 到 8% 。



  • 同类品牌是否涨价?


2022 年1月,Chanel 在去年十一月已经提价的基础上,部分款式将再度涨价。其中,Coco Handle 涨价 2000 元,BusinessAffinity 与 Le Boy 等包型的价格会有8%-12% 的涨幅‍‍。过去三年,Chanel 的涨幅已经高达 60%。


Hermès 的涨价基本固定在每年的1月进行,本轮调价每款涨价范围从 500 元至 4300 元不等。Lindy mini 从原来的 46500 元涨到 48700 元,升幅为 4.7%;


Dior 于 1 月 18 日,小号 Lady Dior 手袋售价从 3.6 万涨至 4.1 万,增幅达 13.9%。



  • 官方的理由是什么?


价格调整考虑了生产成本、原材料、运输以及通货膨胀的变化。



  • 官方的理由是否成立?


根据 LVMH 2022 年的财报来看,经常性业务的利润方面,除了下降 3% 的香水和化妆品外,其他业务的利润均有两位数增长,其中精品零售 2022 年的利润较 2021 年激增 48%,时装与皮具的利润增长 22%,手表和珠宝增长 20%,酒类业务的增速 16% 垫底,但酒类的营收和盈利都创自身年度新高。


管全年总销售额增长 17%,但 LVMH 的利润率却持平。该公司决定将其数十亿美元的营销预算增加三分之一,这已经成为奢侈品行业的普遍趋势。品牌需要在广告上投入巨资,以继续证明价格大幅上涨的合理性。瑞银(UBS)分析师估计,去年奢侈品品牌平均涨价 8%,与全球通胀水平大致一致。



  • 中国放开疫情管控是否有影响?


中国在全球个人奢侈品消费中的份额激增,从 2019 年的 38% 至 39% 猛增到 80%。


在12月26日中国内地宣布将于1月8日取消将近三年的防疫政策后,据《华尔街日报》报导,这不仅让世界各国迎来全球最大的旅游消费来源群体,放鬆政策的消息还提振了全球股市,奢侈品股尤其受益,因这意味佔奢侈品市场约很大部份的中国消费者正式归来。


著名奢侈品巨头 LVMH 集团(LVMH Moet Hennessy Louis Vuitton)早前表示,其亚洲(不包括日本)第一季度的收入增长放缓至8%,但仍是其最大的单一市场地区,佔全球总收入的 37%。


为了迎合日益增加的中国消费者,许多欧洲奢侈品牌自疫情前几年已增聘了会中文的员工,并开始专注开发中国游客喜爱的产品。国际管理谘询公司贝恩公司(Bain & Company)的数字显示,虽然中国消费者受疫情限制而有所放缓,今年只佔全球奢侈品支出最多19%,但预计到2030年他们将佔多达四成的市场份额。


预计中国游客要到2024年才会大规模重返欧洲,进一步提振公司销售业绩。


涨价之后有什么影响?


我们拿它在 2022 年 2 月涨价后的表现来看看:



  • 2022 年四季度营业收入 226.99 亿欧元,同比增长 13.3 %,分析师预期 223.5 亿欧元,全年营收 791.84 亿欧元,较 2021 年增长 23%,分析师预期 787.2 亿欧元。

  • 2022年净利润140.84亿欧元,同比增长17%;来自经常性业务的利润为210.55亿欧元,同比增长23%。


是否会影响未来销量?


2022 财年 LVMH 集团在地缘政治和经济形势的不利影响下,仍然创下历史业绩新高,集团销售收入达 792 亿欧元,营业利润达 211 亿欧元,皆增长 23%。


尽管全年成绩单亮眼,但相较第三季度数据,第四季度所有地区的增长有所放缓。去年第四季度,除日本外,亚洲地区的内生性营收同比下降了8%(该公司只报告每季度的内生性变化)。奢侈品研究所的 Milton Pedraza 分享说:“对于所有顶级奢侈品牌和集团来说,2022年的增长主要来自强劲的定价,而来自同店销量增长的比例则要小得多。”


随着中国政府计划逐步重新开放国际旅游,可能还要有几个月时间,中国游客才能重新大量出现在欧洲。这应当会释放消费,因为考虑到价差和退税,巴黎或米兰等城市的奢侈品价格要比中国便宜多达 40%。


按照 LVMH 的预估,中国要到 2024 年才会完全恢复对奢侈品的消费。


英国央行周四也将关键利率上调 0.5 个百分点,连续第 10 次上调关键利率,但释出信号可能很快就会暂停这一系列加息行动,因同比通胀率下降,经济步履蹒跚。


欧洲央行此举是连续第五次大幅加息,使关键利率达到2.5%,创下2008年以来的最高水平。不过这一水平仍然低于美联储和英国央行的利率,前者在周三将利率提高到4.5%至4.75%,后者则在周四早些时候将利率提高到4%。


天达(Investec) 经济学家在给客户的一份报告中写道,中国放松严格的动态清零政策提振了增长前景,而欧洲天气转暖已帮助缓和能源危机的严重程度。他们把对今年全球经济增长的预测从 2.2% 提高到 2.4%。


总结


LVMH 的涨价本质上是希望在通货膨胀下保持增长,如果希望保持增长就需要投放更多的广告和营销,如果要有钱投入就必须要涨价,而要证明涨价是对的那就得继续投入更多的钱。好在是奢侈品集团们对价格的调控都比较“自由”。


其次考虑到欧洲加息和天气转暖,欧洲的整体区域市场份额也会获得提升。按照现在的预测来看全球的经济似乎是在往好的方向增长,中国放开了疫情管控后以及全球旅游业的逐渐恢复可以有效的给 LVMH 带来一个更大的前景,那么在市场仍然是有很大的需要的情况下,即使维持目前的销售量不变,当价格提升时盈利就会上涨。综合上述在经济回暖的情况下、中国疫情管控放开销售量可能会增加,各大奢侈品集团自然而然就会开始进一步涨价追求更高的利润。LVMH 公司表示,即使面对不确定的地缘政治和宏观经济环境,该公司也有信心通过成本控制和选择性投资的政策,保持目前的增长水平。


作者:Andy_Qin
来源:juejin.cn/post/7196140566110470203
收起阅读 »

创业一年 | 一名普通前端的血泪史

前言 年初我裸辞创业了,跟一个朋友一起合伙做项目,我主要还是做技术部分,开发一个回收类的项目 也是第一次创业,虽然听过很多道理,自己经历过又是另外一回事 我们的项目经历过高峰,现在算是谷底,基本的情况基本就是在苦苦挣扎 这篇文章我会把我所经历的过程讲述出来,在...
继续阅读 »

前言


年初我裸辞创业了,跟一个朋友一起合伙做项目,我主要还是做技术部分,开发一个回收类的项目


也是第一次创业,虽然听过很多道理,自己经历过又是另外一回事


我们的项目经历过高峰,现在算是谷底,基本的情况基本就是在苦苦挣扎


这篇文章我会把我所经历的过程讲述出来,在最后也会总结一些创业过程的一些经验和避坑指南


希望对你有所帮助


自我介绍 & 背景


我是一名快35岁的前端码农,离职前是在银行做的外包开发。2014年开始从事开发,不过刚开始是做的iOS开发,后来又转了web前端开发


眼看35岁大限快到,内心比较着急,着急的去探索一条以后的出路,我想这应该是码农们以后必须要面对的问题


只是年初的时候,正好有一个契机,我就想趁着还没到35岁之前,主动出击,做一番尝试


当前也是做好了充分的准备


契机


这件事的契机就是我给朋友开发一个小程序,业务量在慢慢的起来;一商量就决定去做这件事了,当然也跟家里商量一下,用一年时间来试错


我的理由是:


对于码农来说,年龄是个很难跨过去的坎,这一步迟早是要来的,我不太想被动的等到那一天,我还是想主动的寻找出路


离开上海


我走之前跟一些朋友做了道别,把自己的行李全部塞进来车里,开着车又去了一个陌生的城市(去朋友的城市),离开的时候真的是感慨万千


有家人的地方才有温暖,老婆小孩都走了,一个人呆在上海特不是滋味


业务的高速发展


我们的业务在上半年还是发展很顺利的,通过抖音直播等方式,吸引用户,用户越来越多,业务也慢慢放量了


我们的注册用户有28万多,高峰时期Api调用量有250万


我们的收入来源有以下几部分


会员费


我们的利润来源之一是 会员费


截止目前为止,合伙人有350个左右,早期的话一个合伙人大概赚1000块,后期一个会员赚2000块,代理商有1700个,前期一个赚100,后来涨价一个赚288元,具体前期多少个,已经不清楚了


细算下来,我们收的会员费都大几十万了,应该是赚钱的


产品的差价


我们利润来源之二是产品的差价


我们回收的产品也是有一点利润的,只是不多,一个才几毛钱,甚至几分钱,但也都是有利润的,需要走量


最高峰的时候,一天的订单有10万单,目前已经萎缩到了5万单左右


这其实也是一部分利润,到目前为止,累计应该有1000万单


广告费


我们利润来源之三就是小程序广告费


想必大家都开发过小程序,小程序后台是有广告费的,我们的广告费也不少


但是很遗憾我刚开始开发的时候用的是 别人的 serverless 的框架,对的,就是 LeanCould,这玩意每天有3W的免费的Api调用量,而我们的量非常大,所以被收取的服务器也是不少的


小程序的广告费基本覆盖了服务器的费用,还略微有些结余


最高的一天广告费有1000多,后期慢慢稳定了就是300左右,而服务器的费用大概在200左右


关于小程的广告费,如果没有新人进入的话。他的广告费会下降的很厉害,做过的都知道


下面贴几张图,下面的小程序的广告收入,大部分就在这里了


image.png


image.png


前期一直用的LeanCould也是有对应服务器的 5W多


image.png


后来我自己买了服务器,写了后端,就迁移了平台,也就是目前在用的平台


image.png


隐患


随着时间的不断的推移,慢慢的发现平台有点入不敷出了,太奇怪了


现在才知道,其实出货不太好出了,我们负责出货的朋友,没有及时沟通,导致我们的囤货大量积压,卖不出去


其实这个现象提前就已经有了,但是负责推广和运营的朋友并不知情,一直都是在推广,提现秒到,风控没有做好


等发现问题的时候,其实是有点晚了,我们的货囤了很多了


现在我们的现金流已经非常的吃紧了


最可怕的是我们囤货的东西市场价值越来越低,很多甚至下游都不收了


失败总结


失败的原因肯是很多的,但总结下来有以下几点


1.公司的流水一定要清晰透明


因为很多小团队,甚至就2-3个人,不管有没有盈利,都要做到财务公开和透明,这样有两个好处


第一 防止钱进入私人口袋


第二 每日的现金流可以让随时有危机感,并且可以随时意识到危机


每日公开现金流真的很重要,重要的事情说三遍


每日公开现金流真的很重要,重要的事情说三遍


每日公开现金流真的很重要,重要的事情说三遍


如果实在做不到每日对账,最少一个礼拜一次


2.每个人的股份也要分清楚


很多时候都是好朋友,熟人,就没有太谈这个方面的事情。但是确切的告诉你一定要大方的谈,因为这个影响到后面的利润分成,很容易出现矛盾


关于技术方面


1. 先用LeanClund 开发


为什么一开始是使用serverLess开发



  • 因为速度快,前期产品从0到1,全部都一个人写,功能还是蛮多的如果又去写前端,又去写后端,还要运维,确实开发速度很慢

  • 自己做前端出生,后端的不太会,虽说增删改查比较简单,但是那只是最基础的功能,这是真实的线上项目,从没有弄过,还是不太会

  • serverLess 我比较熟,之前也开发过一个线上项目,只是 调用量比较小


2. 自己写买服务器,全部自己开发


为什么要自己开发全部代码



  • Api调用量大了之后,LeanCould的费用太高了,我们需要节省成本

  • 我们要做自己的APP,因为小程序很多时候会被投诉被封,APP可以规避风险(当然我们运营的好,一直都比较稳定,没有被封过,虽然也开发了APP,后来还是主要用小程序,而当时用LeanCould里面有一些代码与小程序耦合了一点)

  • LeanCould 无法没有实现事务等,随着使用的加深,发现它在底层上有些不足,自己写云端写很多逻辑也是比较麻烦(比如微信里面的很多都是要 服务器去调用腾讯的接口,不得不在云端写代码)、


主要技术栈



  • 前端小程序用 uni-app

  • 后台管理系统 是用 Vue

  • 后端使用Koa

  • 数据库使用 MySqlRedies


系统的一些数据



  • 目前系统有 28W用户

  • 目前300W的订单量,每日月5W的新增订单(以前的平台有1000多W的订单量,暂停了)


后面的打算


虽然还有2个月时候才过年,但是 今天的尝试 基本算失败了,正在思考明年干什么


看看还有什么项目做,或者有什么生意做(前提是有一些资源,否则就不做)


实在不行,可能还得去先找个工作


希望我的经历可以帮助到你,大家有什么问题可以评论区交流


作者:三年三月
来源:juejin.cn/post/7295353579001806902
收起阅读 »

完爆90%的性能毛病,数据库优化八大通用绝招!

毫不夸张的说咱们后端工程师,无论在哪家公司,呆在哪个团队,做哪个系统,遇到的第一个让人头疼的问题绝对是数据库性能问题。如果我们有一套成熟的方法论,能让大家快速、准确的去选择出合适的优化方案,我相信能够快速准备解决咱么日常遇到的80%甚至90%的性能问题。 从解...
继续阅读 »

毫不夸张的说咱们后端工程师,无论在哪家公司,呆在哪个团队,做哪个系统,遇到的第一个让人头疼的问题绝对是数据库性能问题。如果我们有一套成熟的方法论,能让大家快速、准确的去选择出合适的优化方案,我相信能够快速准备解决咱么日常遇到的80%甚至90%的性能问题。


从解决问题的角度出发,我们得先了解到**问题的原因;其次我们得有一套思考、判断问题的流程方式,**让我们合理的站在哪个层面选择方案;最后从众多的方案里面选择一个适合的方案进行解决问题,找到一个合适的方案的前提是我们自己对各种方案之间的优缺点、场景有足够的了解,没有一个方案是完全可以通吃通用的,软件工程没有银弹。


下文的我工作多年以来,曾经使用过的八大方案,结合了平常自己学习收集的一些资料,以系统、全面的方式整理成了这篇博文,也希望能让一些有需要的同行在工作上、成长上提供一定的帮助。



文章首发公众号:码猿技术专栏



为什么数据库会慢?


慢的本质:


慢的本质
查找的时间复杂度查找算法
存储数据结构存储数据结构
数据总量数据拆分
高负载CPU、磁盘繁忙

无论是关系型数据库还是NoSQL,任何存储系统决定于其查询性能的主要有三种:



  • 查找的时间复杂度

  • 数据总量

  • 高负载


而决定于查找时间复杂度主要有两个因素:



  • 查找算法

  • 存储数据结构


无论是哪种存储,数据量越少,自然查询性能就越高,随着数据量增多,资源的消耗(CPU、磁盘读写繁忙)、耗时也会越来越高。


从关系型数据库角度出发,索引结构基本固定是B+Tree,时间复杂度是O(log n),存储结构是行式存储。因此咱们对于关系数据库能优化的一般只有数据量。


而高负载造成原因有高并发请求、复杂查询等,导致CPU、磁盘繁忙等,而服务器资源不足则会导致慢查询等问题。该类型问题一般会选择集群、数据冗余的方式分担压力。



应该站在哪个层面思考优化?



从上图可见,自顶向下的一共有四层,分别是硬件、存储系统、存储结构、具体实现。层与层之间是紧密联系的,每一层的上层是该层的载体;因此越往顶层越能决定性能的上限,同时优化的成本也相对会比较高,性价比也随之越低。以最底层的具体实现为例,那么索引的优化的成本应该是最小的,可以说加了索引后无论是CPU消耗还是响应时间都是立竿见影降低;然而一个简单的语句,无论如何优化加索引也是有局限的,当在具体实现这层没有任何优化空间的时候就得往上一层【存储结构】思考,思考是否从物理表设计的层面出发优化(如分库分表、压缩数据量等),如果是文档型数据库得思考下文档聚合的结果;如果在存储结构这层优化得没效果,得继续往再上一次进行考虑,是否关系型数据库应该不适合用在现在得业务场景?如果要换存储,那么得换怎样得NoSQL?


所以咱们优化的思路,出于性价比的优先考虑具体实现,实在没有优化空间了再往上一层考虑。当然如果公司有钱,直接使用钞能力,绕过了前面三层,这也是一种便捷的应急处理方式。


该篇文章不讨论顶与底的两个层面的优化,主要从存储结构、存储系统中间两层的角度出发进行探讨


八大方案总结



 数据库的优化方案核心本质有三种:减少数据量用空间换性能选择合适的存储系统,这也对应了开篇讲解的慢的三个原因:数据总量、高负载、*查找的时间复杂度。*


  这里大概解释下收益类型:短期收益,处理成本低,能紧急应对,久了则会有技术债务;长期收益则跟短期收益相反,短期内处理成本高,但是效果能长久使用,扩展性会更好。


  静态数据意思是,相对改动频率比较低的,也无需过多联表的,where过滤比较少。动态数据与之相反,更新频率高,通过动态条件筛选过滤。


减少数据量


减少数据量类型共有四种方案:数据序列化存储、数据归档、中间表生成、分库分表。


就如上面所说的,无论是哪种存储,数据量越少,自然查询性能就越高,随着数据量增多,资源的消耗(CPU、磁盘读写繁忙)、耗时也会越来越高。目前市面上的NoSQL基本上都支持分片存储,所以其天然分布式写的能力从数据量上能得到非常的解决方案。而关系型数据库,查找算法与存储结构是可以优化的空间比较少,因此咱们一般思考出发点只有从如何减少数据量的这个角度进行选择优化,因此本类型的优化方案主要针对关系型数据库进行处理。



数据归档



注意点:别一次性迁移数量过多,建议低频率多次限量迁移。像MySQL由于删除数据后是不会释放空间的,可以执行命令OPTIMIZE TABLE释放存储空间,但是会锁表,如果存储空间还满足,可以不执行。



关注公众号:码猿技术专栏,回复关键词:1111 获取阿里内部的java性能调优手册



建议优先考虑该方案,主要通过数据库作业把非热点数据迁移到历史表,如果需要查历史数据,可新增业务入口路由到对应的历史表(库)。



中间表(结果表)



中间表(结果表)其实就是利用调度任务把复杂查询的结果跑出来存储到一张额外的物理表,因为这张物理表存放的是通过跑批汇总后的数据,因此可以理解成根据原有的业务进行了高度的数据压缩。以报表为例,如果一个月的源数据有数十万,我们通过调度任务以月的维度生成,那么等于把原有的数据压缩了几十万分之一;接下来的季报和年报可以根据月报*N来进行统计,以这种方式处理的数据,就算三年、五年甚至十年数据量都可以在接受范围之内,而且可以精确计算得到。


那么数据的压缩比率是否越低越好?下面有一段口诀:



  • 字段越多,粒度越细,灵活性越高,可以以中间表进行不同业务联表处理。

  • 字段越少,粒度越粗,灵活性越低,一般作为结果表查询出来。


数据序列化存储




在数据库以序列化存储的方式,对于一些不需要结构化存储的业务来说是一种很好减少数据量的方式,特别是对于一些M*N的数据量的业务场景,如果以M作为主表优化,那么就可以把数据量维持最多是M的量级。另外像订单的地址信息,这种业务一般是不需要根据里面的字段检索出来,也比较适合。


这种方案我认为属于一种临时性的优化方案,无论是从序列化后丢失了部份字段的查询能力,还是这方案的可优化性都是有限的。


分库分表


分库分表作为数据库优化的一种非常经典的优化方案,特别是在以前NoSQL还不是很成熟的年代,这个方案就如救命草一般的存在。


如今也有不少同行也会选择这种优化方式,但是从我角度来看,分库分表是一种优化成本很大的方案。这里我有几个建议:



  1. 分库分表是实在没有办法的办法,应放到最后选择。

  2. 优先选择NoSQL代替,因为NoSQL诞生基本上为了扩展性与高性能。

  3. 究竟分库还是分表?量大则分表,并发高则分库

  4. 不考虑扩容,一部做到位。因为技术更新太快了,每3-5年一大变。


拆分方式



只要涉及到这个拆,那么无论是微服务也好,分库分表也好,拆分的方式主要分两种:垂直拆分、水平拆分


垂直拆分更多是从业务角度进行拆分,主要是为了**降低业务耦合度;**此外以SQL Server为例,一页是8KB存储,如果在一张表里字段越多,一行数据自然占的空间就越大,那么一页数据所存储的行数就自然越少,那么每次查询所需要IO则越高因此性能自然也越慢;因此反之,减少字段也能很好提高性能。之前我听说某些同行的表有80个字段,几百万的数据就开始慢了。


水平拆分更多是从技术角度进行拆分,拆分后每张表的结构是一模一样的,简而言之就是把原有一张表的数据,通过技术手段进行分片到多张表存储,从根本上解决了数据量的问题。




路由方式



进行水平拆分后,根据分区键(sharding key)原来应该在同一张表的数据拆解写到不同的物理表里,那么查询也得根据分区键进行定位到对应的物理表从而把数据给查询出来。


路由方式一般有三种区间范围、Hash、分片映射表,每种路由方式都有自己的优点和缺点,可以根据对应的业务场景进行选择。


区间范围根据某个元素的区间的进行拆分,以时间为例子,假如有个业务我们希望以月为单位拆分那么表就会拆分像 table_2022-04,这种对于文档型、ElasticSearch这类型的NoSQL也适用,无论是定位查询,还是日后清理维护都是非常的方便的。那么缺点也明显,会因为业务独特性导致数据不平均,甚至不同区间范围之间的数据量差异很大。


Hash也是一种常用的路由方式,根据Hash算法取模以数据量均匀分别存储在物理表里,缺点是对于带分区键的查询依赖特别强,如果不带分区键就无法定位到具体的物理表导致相关所有表都查询一次,而且在分库的情况下对于Join、聚合计算、分页等一些RDBMS的特性功能还无法使用。



一般分区键就一个,假如有时候业务场景得用不是分区键的字段进行查询,那么难道就必须得全部扫描一遍?其实可以使用分片映射表的方式,简单来说就是额外有一张表记录额外字段与分区键的映射关系。举个例子,有张订单表,原本是以UserID作为分区键拆分的,现在希望用OrderID进行查询,那么得有额外得一张物理表记录了OrderID与UserID的映射关系。因此得先查询一次映射表拿到分区键,再根据分区键的值路由到对应的物理表查询出来。可能有些朋友会问,那这映射表是否多一个映射关系就多一张表,还是多个映射关系在同一张表。我优先建议单独处理,如果说映射表字段过多,那跟不进行水平拆分时的状态其实就是一致的,这又跑回去的老问题。


用空间换性能


该类型的两个方案都是用来应对高负载的场景,方案有以下两种:分布式缓存、一主多从。


与其说这个方案叫用空间换性能,我认为用空间换资源更加贴切一些。因此两个方案的本质主要通数据冗余、集群等方式分担负载压力。


对于关系型数据库而言,因为他的ACID特性让它天生不支持写的分布式存储,但是它依然天然的支持分布式读



分布式缓存



缓存层级可以分好几种:客户端缓存API服务本地缓存分布式缓存,咱们这次只聊分布式缓存。一般我们选择分布式缓存系统都会优先选择NoSQL的键值型数据库,例如Memcached、Redis,如今Redis的数据结构多样性,高性能,易扩展性也逐渐占据了分布式缓存的主导地位。


缓存策略也主要有很多种:Cache-AsideRead/Wirte-ThroughWrite-Back,咱们用得比较多的方式主要**Cache-Aside,**具体流程可看下图:



我相信大家对分布式缓存相对都比较熟悉了,但是我在这里还是有几个注意点希望提醒一下大家:



关注公众号:码猿技术专栏,回复关键词:1111 获取阿里内部的java性能调优手册



避免滥用缓存


缓存应该是按需使用,从28法则来看,80%的性能问题由主要的20%的功能引起。滥用缓存的后果会导致维护成本增大,而且有一些数据一致性的问题也不好定位。特别像一些动态条件的查询或者分页,key的组装是多样化的,量大又不好用keys指令去处理,当然我们可以用额外的一个key把记录数据的key以集合方式存储,删除时候做两次查询,先查Key的集合,然后再遍历Key集合把对应的内容删除。这一顿操作下来无疑是非常废功夫的,谁弄谁知道。



避免缓存击穿


当缓存没有数据,就得跑去数据库查询出来,这就是缓存穿透。假如某个时间临界点数据是空的例如周排行榜,穿透过去的无论查找多少次数据库仍然是空,而且该查询消耗CPU相对比较高,并发一进来因为缺少了缓存层的对高并发的应对,这个时候就会因为并发导致数据库资源消耗过高,这就是缓存击穿。数据库资源消耗过高就会导致其他查询超时等问题。


该问题的解决方案也简单,对于查询到数据库的空结果也缓存起来,但是给一个相对快过期的时间。有些同行可能又会问,这样不就会造成了数据不一致了么?一般有数据同步的方案像分布式缓存、后续会说的一主多从、CQRS,只要存在数据同步这几个字,那就意味着会存在数据一致性的问题,因此如果使用上述方案,对应的业务场景应允许容忍一定的数据不一致。


不是所有慢查询都适用


一般来说,慢的查询都意味着比较吃资源的(CPU、磁盘I/O)。举个例子,假如某个查询功能需要3秒时间,串行查询的时候并没什么问题,我们继续假设这功能每秒大概QPS为100,那么在第一次查询结果返回之前,接下来的所有查询都应该穿透到数据库,也就意味着这几秒时间有300个请求到数据库,如果这个时候数据库CPU达到了100%,那么接下来的所有查询都会超时,也就是无法有第一个查询结果缓存起来,从而还是形成了缓存击穿。


一主多从



常用的分担数据库压力还有一种常用做法,就是读写分离、一主多从。咱们都是知道关系型数据库天生是不具备分布式分片存储的,也就是不支持分布式写,但是它天然的支持分布式读。一主多从是部署多台从库只读实例,通过冗余主库的数据来分担读请求的压力,路由算法可有代码实现或者中间件解决,具体可以根据团队的运维能力与代码组件支持视情况选择。


一主多从在还没找到根治方案前是一个非常好的应急解决方案,特别是在现在云服务的年代,扩展从库是一件非常方便的事情,而且一般情况只需要运维或者DBA解决就行,无需开发人员接入。当然这方案也有缺点,因为数据无法分片,所以主从的数据量完全冗余过去,也会导致高的硬件成本。从库也有其上限,从库过多了会主库的多线程同步数据的压力。



选择合适的存储系统


NoSQL主要以下五种类型:键值型、文档型、列型、图型、搜素引擎,不同的存储系统直接决定了查找算法存储数据结构,也应对了需要解决的不同的业务场景。NoSQL的出现也解决了关系型数据库之前面临的难题(性能、高并发、扩展性等)。


例如,ElasticSearch的查找算法是倒排索引,可以用来代替关系型数据库的低性能、高消耗的Like搜索(全表扫描)。而Redis的Hash结构决定了时间复杂度为O(1),还有它的内存存储,结合分片集群存储方式以至于可以支撑数十万QPS。


因此本类型的方案主要有两种:**CQRS、替换(选择)存储,**这两种方案的最终本质基本是一样的主要使用合适存储来弥补关系型数据库的缺点,只不过切换过渡的方式会有点不一样。



CQRS


CQS(命令查询分离)指同一个对象中作为查询或者命令的方法,每个方法或者返回的状态,要么改变状态,但不能两者兼备 



讲解CQRS前得了解CQS,有些小伙伴看了估计还没不是很清晰,我这里用通俗的话解释:某个对象的数据访问的方法里,要么只是查询,要么只是写入(更新)。而CQRS(命令查询职责分离)基于CQS的基础上,用物理数据库来写入(更新),而用另外的存储系统来查询数据。因此我们在某些业务场景进行存储架构设计时,可以通过关系型数据库的ACID特性进行数据的更新与写入,用NoSQL的高性能与扩展性进行数据的查询处理,这样的好处就是关系型数据库和NoSQL的优点都可以兼得,同时对于某些业务不适于一刀切的替换存储的也可以有一个平滑的过渡。


从代码实现角度来看,不同的存储系统只是调用对应的接口API,因此CQRS的难点主要在于如何进行数据同步。


数据同步方式



一般讨论到数据同步的方式主要是分拉:


推指的是由数据变更端通过直接或者间接的方式把数据变更的记录发送到接收端,从而进行数据的一致性处理,这种主动的方式优点是实时性高。


拉指的是接收端定时的轮询数据库检查是否有数据需要进行同步,这种被动的方式从实现角度来看比推简单,因为推是需要数据变更端支持变更日志的推送的。


而推的方式又分两种:CDC(变更数据捕获)和领域事件。对于一些旧的项目来说,某些业务的数据入口非常多,无法完整清晰的梳理清楚,这个时候CDC就是一种非常好的方式,只要从最底层数据库层面把变更记录取到就可。


对于已经服务化的项目来说领域事件是一种比较舒服的方式,因为CDC是需要数据库额外开启功能或者部署额外的中间件,而领域事件则不需要,从代码可读性来看会更高,也比较开发人员的维护思维模式。



替换(选择)存储系统


因为从本质来看该模式与CQRS的核心本质是一样的,主要是要对NoSQL的优缺点有一个全面认识,这样才能在对应业务场景选择与判断出一个合适的存储系统。这里我像大家介绍一本书马丁.福勒《NoSQL精粹》,这本书我重复看了好几遍,也很好全面介绍各种NoSQL优缺点和使用场景。


当然替换存储的时候,我这里也有个建议:加入一个中间版本,该版本做好数据同步与业务开关,数据同步要保证全量与增加的处理,随时可以重来,业务开关主要是为了后续版本的更新做的一个临时型的功能,主要避免后续版本更新不顺利或者因为版本更新时导致的数据不一致的情况出现。在跑了一段时间后,验证了两个不同的存储系统数据是一致的后,接下来就可以把数据访问层的底层调用替换了。如此一来就可以平滑的更新切换。


结束


本文到这里就把八大方案介绍完了,在这里再次提醒一句,每个方案都有属于它的应对场景,咱们只能根据业务场景选择对应的解决方案,没有通吃,没有银弹。


这八个方案里,大部分都存在数据同步的情况,只要存在数据同步,无论是一主多从、分布式缓存、CQRS都好,都会有数据一致性的问题导致,因此这些方案更多适合一些只读的业务场景。当然有些写后既查的场景,可以通过过渡页或者广告页通过用户点击关闭切换页面的方式来缓解数据不一致性的情况。


作者:码猿技术专栏
来源:juejin.cn/post/7185338369860173880
收起阅读 »

早起、冥想、阅读、写作、运动

周岭在《认知觉醒》一书中提出了快速改变人生的五件事,即:「早起」、「冥想」、「阅读」、「写作」、「运动」。低调务实优秀中国好青年交流群也正是从这 5 件事入手,帮你养成好习惯。我也试着实践了有将近一年的时间,今谈谈收获与心得。 早起 一日之计在于晨,一年之计在...
继续阅读 »

周岭在《认知觉醒》一书中提出了快速改变人生的五件事,即:「早起」、「冥想」、「阅读」、「写作」、「运动」。低调务实优秀中国好青年交流群也正是从这 5 件事入手,帮你养成好习惯。我也试着实践了有将近一年的时间,今谈谈收获与心得。


早起


一日之计在于晨,一年之计在于春;早起是个老生常谈的话题了,鲁迅先生小时候为了上课不迟到,还把「早」刻在桌上告诫自己。我小时候每天晚上吃完饭,没什么事早早地就睡了,甚至觉得十点睡觉都是一件很可怕的事。如今呢,自从步入互联网时代,十点?不好意思,十点夜生活才刚刚开始。


秉承着先僵化、后优化、再固化的原则,我决定尝试一段时间。起初几天是真的很难受,白天浑浑噩噩的完全提不起精神。不过慢慢的,晚上倒是越来越早的睡了。差不多半个月时间几乎都习惯了 10 点左右睡觉,6 点前起床。正常早上六点起床后,稍微锻炼一会回来坐那下下汗,冲个凉水澡,然后吃个早饭就去工作了。


持续了有半年时间,直观感受就是身体越来越好,精神头越来越棒;但我并不认为这是早起带来的,潜移默化改变了我的是生活规律。毕竟美国人时差和咱们完全反着来,也没见几个英年嗝屁的。现在为止,我想早起也许就真的只是早点起来罢了。


但有一天,我翻看着旧日的朋友圈:星光不问赶路人,豁然开朗。也深刻地认识到了自己的肤浅,早起其实并不只意味着早点起来罢了。想象一下,如果明天要和女神约会?或者新工作的第一天?不用考虑肯定早早的就起来收拾了,因为你开心,快乐,幸福;甚至要迎来人生新阶段了。所以早起真谛可能不仅仅是早点起来,更重要的是进一步寻找人生的意义,创造生命的价值,为我所热爱奋斗终生!


冥想


关于冥想,老实说太高端了,高端到有点不接地气,反正 100 个人有 100 个见解。刚开始还看了各种视频、翻了有关的书、试了各种动作体验冥想、有没有效果我不清楚,不过睡得倒很快。


感受呼吸、扫描身体、提升专注力,但越努力就越凌乱……由于不能形成持续的正反馈,所以我有点消极。去你的冥想,浪费生命。后续冥想也是断断续续的持续了好久,那天想起来就尝试一下,想不起来就算了。


直到有阵子,忘记具体在做什么,总之就是在写代码。从上班来坐那,要不是同事喊我,还真没感觉一个上午都过去了……也是瞬间明白了《十分钟冥想》中:心流。


我把冥想定义为心无杂念、极致专注。但是早期的努力只是停留在表面上而没有透彻地理解。我认为冥想最重要的一点:感知力、尝试学会深入感受身体各个部位,体会情绪在大脑波动,品尝茶水在身体流淌,体会世间万物。


一个小和尚问得道的师父:“您得道之前做什么?”

老和尚说:“砍柴、挑水、做饭。”

“那得道之后呢?”小和尚继续问道。

老和尚回答:“还是砍柴、挑水、做饭。”

小和尚一脸疑惑:“师父,这不是一样吗?”

老和尚说:“当然不一样。得道前,我砍柴时惦记着挑水,挑水时惦记着做饭;得道后,砍柴即砍柴,挑水即挑水,做饭即做饭。”

阅读


生命是有限的,但书籍却是无限的,怎么用有限的生命阅读无限的书籍呢?根据不科学统计,人的一生最多只能阅读 15000 本书籍,那估计是没有一个人可以活着读完。所以我们应该要追求精读细阅和高质量的阅读。


首先要会读书,读好书。《如何阅读一本书》就非常详细的讨论了怎么样阅读一本书,尽管有关读书的方法论确实很好,但我觉得阐述得太过重复啰嗦。其实读书喜欢什么就读什么,不要拘泥于阅读世界名著,人文哲理。但我建议少读都市言情,穿越爽文,其可吸收的营养价值较少。具体想怎么读就怎么读,咬文嚼字、一目十行都无所谓,但是这一种读法仅限于是一本好书的情况下。可是究竟什么是好书呢?追随那些走的快的人,阅读其推荐的书单。


假如面临的是一本新书,那么你可以尝试:



  1. 深入了解书的作者、写作的背景。

  2. 详细阅读书的自序、引言、大纲、目录等重要信息。

  3. 快速翻阅书中的部分章节。如果感觉这本书很有价值,那就接着继续。

  4. 带着疑问追随作者的步伐,选择最适合的方式阅读。

    1. 这本书讲了什么?

    2. 作者细说了什么?

    3. 作者的观点是否正确?

    4. 作者讲的和我有什么关系?



  5. 收获体会,记录笔记。


再分享一种进一步的阅读方法:主题阅读。在某个类目中挑选同方向的若干本书,然后确认自己研究的主题和方向。



  1. 依次阅读每本书。

  2. 理清问题、界定主题。

  3. 与不同作者达成共识。

  4. 分析讨论。


写作



我学生时期其实最厌恶写作了……为什么会是你给我段话,让我来研究一下它怎么想的,然后再为你阐述一下自己的观点。我 TM 怎么知道他想什么,爱想什么想什么。



写作实际上可以和阅读相结合,从而构成完美的闭环。


不知道是不是只有自己写作效率低,感觉自己就像间歇泉,总是时不时的迸发灵感。但有时候喷多了,我还写不下来。所以我一般阅读书籍的时候总是会主动掺杂一些技术类目书籍,这样既有助于提高专业技能,又能留足思考时间。


写作我倒没啥可分享心得的,随心所欲,不必整的很累。但必须重视以下三点:



  1. 务必不要出现错字。

  2. 一定要正确地运用标点符号和合理地分段。

  3. 确保文章整体阅读流畅性。


运动


生命在于运动,如只老乌龟一样冲击活 100 年!


运动锻炼不局限于任何形式,爬楼梯也可以,最重要的是生活态度。千万不要眼高手低,今天运动明天就想超越博尔特,持续保持正反馈,日拱一卒,冲吧骚年!


如果不知道如何下手,可以参考我的 wiki 手册:健身手册




其实吧,哪怕你尝试了「早起」、「冥想」、「阅读」、「写作」、「运动」,也不可能立刻获得收获。过去既然无法改变,未来更不知道何去。


那么请尝试着慢一点,慢一点,再慢一点,也许当你回头那刻,轻舟已过万重山。



来源:早起、冥想、阅读、写作、运动 - 7Wate‘s blog



作者:7Wate
来源:juejin.cn/post/7210298403070722105
收起阅读 »

低p程序员互联网求生指南

大家好,我小j。 先做个自我介绍,我在国内大厂担任数年的开发工作,但是回顾我的职业生涯,我认为还是充满遗憾和失望的,中间做过几次错误的选择。在此,刚好借助这个文章,我想回顾下之这数年的职业生涯,点出失败的教训,描述下在我认为的互联网公司能安全度日,谋求晋升的要...
继续阅读 »

大家好,我小j。


先做个自我介绍,我在国内大厂担任数年的开发工作,但是回顾我的职业生涯,我认为还是充满遗憾和失望的,中间做过几次错误的选择。在此,刚好借助这个文章,我想回顾下之这数年的职业生涯,点出失败的教训,描述下在我认为的互联网公司能安全度日,谋求晋升的要点以及说点大实话,希望能给各位读者学习的地方。想法很跳跃,大家根据章节观看。如果你有不认可的地方,都是你对,是我太失败。


观点


要对大厂祛魅


我一定要第一个提这个点。

起因是最近在很多技术群看到大家都对大厂开发的身份非常崇拜,觉得大厂的人一定比小厂中厂优秀,大厂的人说话一定是对的云云,大厂的技术一定更好,而且在国内论坛上和学生辩论过大厂的观念,让我这个前员工深受震撼。所以一定要找机会聊一下这个话题,可能会伤害朋友们的感情,但是还是想聊一下。



  1. 大厂人是不是一定非常优秀

    不是,国内大厂在黄金时期大部分是冗余性招人,以应对每年的绩效考核和未来可能的业务拓展,一个管理一定要懂怎么要新财年的hc,这样才能保住完成手下大部分的开发任务。

    大厂面试的默认逻辑会导致大量学校好、学历好、掌握面试技巧的人进入大厂,这也是为什么很多知识星球,面试指南,面经,小册盛行的原因。

  2. 剖析大厂开发

    在我们了解了这个前提下,解析下大厂开发,其实也符合二八原则,大厂20%左右的员工是真的有经验有天赋的超能力者,他们去实现架构,完成整个开发流程套件、开发系统的开发。而大量的员工实际上是在其他大佬规划好的线路上填api糊业务罢了,完成基础职务,之后再开发各种okr项目来满足绩效的要求。从技术的角度来看,大部分大厂开发实际平均水平也没有那么高,也是业务仔罢了。

  3. 大厂真正优秀的是什么


    • 大厂真正优秀的是有一些内部架构大佬完成一套完善的开发套件以及设置开发流程,让每个参与大厂的开发都有相对不难受的开发体验以及完整的上线监控流程。让不同水平的开发都足以完成他们被要求完成的任务。




    • 大厂优秀的是有远超中小厂的业务体量、薪资福利。




    • 大厂优秀的是身边同事基本都很优秀,都有自己能学习的点,也是未来的社交圈子。




    • 大厂优秀的是能让你熟悉一整套成熟的开发流程,需求评审-开发评审-业务开发-发布提测-正式上线-日志监控-热修回滚。让你了解一整个应用的开发方式。




    • 大厂优秀的是能给你简历加分,带来未来可能的发展机会。




    • 大厂优秀的还有像ata、学城这种前人留下的各种资料可供学习,虽然很多水文但是也远比简中外面的文章靠谱。




    • 大厂优秀的是有更多owner项目的机会,让你能有机会发挥自己的能力大展拳脚。






身为大厂人,应该清楚现在的成就是自己的能力还是平台给的机会,戒骄戒躁。

身为非大厂人,也不要太神话大厂,其实屎山一样很多,大家还是理性讨论。


说句政治不正确的,很多大厂的成功除了依托本身人才和领导层的慧眼以外,更多还是依托时代背景,时势造英雄。 为什么目前环境小厂发育艰难,因为一旦你业务达到一定水平足以引起大厂注意以后,大部分大厂都会提出收购,如果你统一收购就会并入大厂之中。如果你不同意收购,他们会利用自己的雄厚财力定点爆破你的员工,抄袭你的创意,诋毁你的业务,抢走你的客户。当前创业不仅要选对市场,还要顶得住大厂的摧残。


高考很重要,学历很重要,专业很重要


虽然可能看到文章的人大多数已经就业或者在大学学习,但是我还是想提这个点。

诚然,互联网开发已经算是不看学历,不看出身的几个职业之一,但是在相同水平的一群求职者中,面试官还是更愿意招自己的校友、学历好看的人、专业对口的人。这个也算是一个潜规则,从好学校毕业中得到一个好员工的概率确实比从一般的学校中挑到前几名的概率大。虽然我们说宁做宁做鸡头不做凤尾,但是现实生活往往是凤尾的平均境遇比鸡头被伯乐适中的概率高,不要去赌自己能被人发掘,要尽量凑到高水平人群中,往往被人选择的机会更大。


选择高校的排名大概就是综合排名>行业内专业知名度>高校所在城市(影响你的实习选择)。


要承认和接受互联网里的圈子


首先叠个甲,这块并不是说圈子一定是好事,但是目前的环境圈子确实能在职业发展中帮助你迅速提高,这个圈子包括老乡圈、学校圈、公司圈(比如bidu帮、ali帮)、技术圈、老同事圈(etc.),大家在一个陌生环境中还是会倾向去找自己有关系的人,结成圈子,铁打一块,在一个圈子里,对你面试过关,绩效提高,晋升都有帮助。


互联网也需要情商,也有嫡系


很多人包括我之前对程序员的理解也是不用跟人打交道,只需要在电脑上完成开发任务即可,但是实际的工作生涯中,因为你的绩效是人评,你的晋升是人评。不可避免还是要多跟人打交道。跟+1(组长)的关系,跟+2(部门老板)的关系或多或少还是对你的结果有一些影响,我并不是说让大家去舔,但是起码要有一些眼力见,做该做的事情。


聊完了前面几个很油的话题之后,我们回归到实际开发生活中


尽量选择大厂,注意识别子公司和外包


虽然我们之前想让大家对大厂祛魅,但是目前来看进入大厂还是能带来更多的收入和晋升机会以及未来的跳槽机会,而且你未来的同事圈层也会更为优秀,要知道这些人就是你未来的内推池,在互联网,互帮互助永远比单打独斗更好。在同等情况下,我们肯定推荐大厂offer>小厂offer,核心offer>边缘bu offer。大厂的卷虽然不一定能一定让你收益,但是很多小厂卷都卷不出收益,从roi来看,大厂核心部门是我们的就职首选。

但是也要分清大厂、大厂子公司和外包。有些公司虽然名义上是大厂子公司,但是无法享受大厂的权益,属于是披着羊皮卖狗肉,环境不好的时候选择先去子公司呆着无可厚非,但是如果你一心想参加大厂,却选错了bu,可能会浪费一段时间。

尽量不要选择外包,国内目前对外包的开发经历还是或多或少有一些歧视的,这个歧视不会表现在jd里,而是hr简历关、面试中可能因为你的背景一键否定。


领导的意义远大于你的想象


一定要珍惜一个好的领导
在相同水平的公司选择下,重要性上我认为有资源的部门>领导nice程度>>>>>>其他因素。

有潜力/大老板亲自抓的业务能带来更多的晋升机会,而且窗口期进入也很容易,一旦做大了容易成为骨干,后续owner项目机会大(前提不被后续老板亲信空降摘桃子)。
不好但是有资源的部门也能分一杯羹。
但是领导作为你天天见面的人,对你的影响比任何都大,一个理想中的领导不一定技术非常牛逼,但是一定是懂得对外抢肉抢功劳,对内帮助内部成员成长,懂得规划成员的晋升路径,及时跟进组员问题,适当提携帮助的人。由此可以看出来,跟着一个好的领导,不仅有利于工作情绪,也会让你一路顺利的走上晋升之路。

相反,遇见一个不合适的领导,不仅经常pua,不下班,还经常让你背c,没有晋升机会,不如趁早活水骑驴找马。离职原因无非钱给少了,心受委屈了,坏领导能让你同时体会两种感受。


学会和领导汇报工作


新人经常做错的一个事情就是闷头干活,不会汇报,不会报功。要知道领导不可能了解每个人的进度和开发内容,每周的周报是唯一的汇报途径。如果你所做的内容不被领导知道,那么又怎么表现你的价值呢?所以,要学会跟领导汇报进度,可以是每次做完一个阶段后发一个简略的阶段报告,亦或是遇到问题时及时和老板沟通目前的困难以及可能能解决的方法。让老板充分了解你的工作,才能帮你去谋求进一步向+2的汇报,不要做一个只会闷头干活的老黄牛。


学会跟领导提出想法


承接上个话题,举一个例子。如果我们想晋升涨薪,完全可以oneone的时候跟老板提出想法:老板你看我如果想晋升/涨薪,我应该去做哪些内容/完成哪些目标呢。从领导的回答也可以看得出他对你的态度



  1. 如果他认真回答,给你列好路径,那么说明晋升/涨薪还是很有希望的,这也是身为领导应该去做的事——规划自己小弟晋升,那么就按着他的路子付出努力实现

  2. 如果他给你画饼,打哈哈。说明你不是嫡系,可能需要在多做一些事情引起他的注意。

  3. 如果他完全无视这个话题,说明他完全没考虑你的晋升情况,那么这个时候就该考虑后路了


要学会onoene找老板沟通,不仅是让老板知道你最近的情况,也是了解老板对你的态度的时候,要学会双向沟通。


不要过多的嫌弃分配的业务


大部分大厂的业务并没有太高的技术含量,尤其像业务部门的活动业务和基建部门的客服业务,我们要清楚的认识到工作就是给自己赚窝囊费的,只要钱给足,业务什么样都是可以接受的。但是在完成日常业务的时候,我们可以考虑如何优化自己手里的活,怎么让自己手里的活效果更好,这方面的助力是有助于老板看到你的亮点,理解到你的能力的。而不是经常抱怨任务烂,不想做。


学会与人为善,维护自己的朋友圈


要知道,你身边的朋友大多技术不弱于你,未来这些人都是你可能的内推对象和被内推对象,要学会与人为善,尽量不要和同事闹冲突,最好之后也经常保持联系,万一之后有内推的机会这些都是潜在的大腿,要知道无论是国内还是国外,招人的第一选择永远是内推,维护好自己的朋友圈,早晚会得到助力。


多贴近业务,了解业务流程


不要只会做一个执行者,在日常的业务开发中要尽量的去学习业务的流程,了解整个bu的运转方法,盈利方法,这样在需求会上你也能提出自己的意见。多和产品和运营聊天,了解业务数据。这样你也能对bu下一步是进一步发展还是收缩有一定预期,提前规划下一步自己的努力方向。


要学会投巧的发展


首先感谢群友大编制的提出。人不可能是全能的,一定要有一定的不可替代性和独特性,如何在一个团队中脱颖而出,除了本身真的足够优秀以外,还可以投巧的发展,举个例子,在一个前端业务团队,普遍大家都会脚手架配置和组件化,拥有这些技能不稀奇,但是如果A会可视化开发,B会nodejs,那么这两个同学在这个团队中就容易显得更亮眼,如果大家的业务都是糊业务,这两个同学在所学技能上稍微多点优化就容易获得更好的绩效。


要有技术追求,但不要太沉迷在公司成长


虽然现在大家也基本上认识到成长不能靠公司了,大部分公司的日常业务开发技术含量并没有那么高,不要妄想在日常日复一日的业务中提高自己的技术水平,那只能提高你糊业务的熟练度和经验。如果想追求技术的提高,还是要靠工作之余的okr项目或者是自己的私下的学习。但是,想在目前国内这个环境中稳定成长厚积薄发,还是不能放弃技术追求,技术经验在身,就算面对裁员风险也不慌。我们要卷,要以提高技术、增加晋升机会,有目的的卷,无效的卷不仅带不来收益,还能带来同事的鄙夷和icu的风险。


承认天赋的差距,在团队中不掉队


程序开发确实是一个天赋的职业,要承认天赋和能力的差距,达到日常业务线上0bug、0delay的60分目标不难,但是想更进一步确实需要正确的方向和努力,我们做不到比所有人都优秀,只需要做到在团队中不掉队,不是最差的几个,就能尽可能的保证在裁员大潮幸存。


在开发之余,考虑自己的长处


目前国内环境比较恶劣,35的达摩克利斯之剑悬在每个人头上,一方面大厂hc在缩小,创新项目在关停,就算你再自信,一但没有hc,也没有换岗位的机会。另一方面随着年龄的增长,初级中级开发的大门也随之关闭,一但你在某个年龄段没有达到对应的职级,就容易被视作失败。而跳槽更要看目标公司有没有对应职级的坑位,职级越高坑位越少。目前高龄开发的环境还是比较恶劣的。

在这之上,我们要考虑是否找到另一个赛道,发挥自己的长处。能有效延缓焦虑,降低未来的风险


总结


说了这么多,也是我目前的一些浅薄经验纸上谈兵,至少从我的职业经验来看,并没有做到以上的内容,还是一个owner项目的大头兵,写了这些内容,也是希望新人不撞我的南墙,老人提前规划后路。欢迎大家多多交流,让国内有一个更好的程序员成长环境。

永远不要忘了,家人爱人和朋友是你永远的后盾,在你坚持不住,想投降的时候,记得你永远有一个避风港存在,一个成功的人永远离不开背后默默支持的人,工作只是我们生活中的一小方面,善待朋友,珍爱亲人,才是我们一直要做的事情。不要因为工作伤害爱你的人。


作者:valkyrja
来源:juejin.cn/post/7299853894733168681
收起阅读 »

开发企业微信群机器人,实现定时提醒

大家好,我是鱼皮,今天分享一个用程序解决生活工作问题的真实案例。 说来惭愧,事情是这样的,在我们公司,每天都要轮流安排一名员工(当然也包括我)去楼层中间一个很牛的饮水机那里接水。但由于大家每天都有自己的工作,经常出现忘记接水的情况,导致大家口渴难耐。 怎么解决...
继续阅读 »

大家好,我是鱼皮,今天分享一个用程序解决生活工作问题的真实案例。


说来惭愧,事情是这样的,在我们公司,每天都要轮流安排一名员工(当然也包括我)去楼层中间一个很牛的饮水机那里接水。但由于大家每天都有自己的工作,经常出现忘记接水的情况,导致大家口渴难耐。


怎么解决这个问题呢?


我想到了几种方法:


1)每天大家轮流提醒。但是别说提醒别人了,自己都不记得什么时候轮到自己接水。


2)由一个员工负责提醒大家接水,必要时招募一个 “接水提醒员”。


3)在企业微信的日历功能给员工安排接水日程,就像下面这样:



但问题是我们的人数和天数不是完全对应的、反复安排日程也很麻烦。


你觉得上面哪种方案好呢?其实我觉得第二个方案是最好的 —— 招募一个 “接水提醒员”。


别笑,我认真的!


只不过这个 “接水提醒员” 何必是人?


没错,作为一名程序员,我们可以搞一个机器人,让它在企业微信群聊中每天提醒不同的员工去接水即可。


其实这个功能和员工排班打卡系统是很类似的,只不过更轻量一些。我也调研了很多排班系统,但是都要收费,索性自己开发一个好了。


在企业微信中接入机器人其实非常简单,因为企业微信官方就支持群聊机器人功能,所以这次的任务我就安排给了实习生,他很快就完成了,所以我相信大家应该也都能学会~


企微群聊机器人开发


学习开发第三方应用时,一定要先完整阅读官方文档,比如企业微信群机器人配置文档。



指路:developer.work.weixin.qq.com/document/pa…




设计 SDK 结构


虽然我们的目标是做一个提醒接水机器人,但是企业微信群聊机器人其实是一个通用的功能,所以我们决定开发一个企微机器人 SDK,以后公司其他业务需要时都能够快速复用。(比如开发一个定时喝水提醒机器人)


设计好 SDK 是需要一定技巧的,之前给大家分享过:如何设计一个优秀的 SDK ,可以阅读参考。


在查阅企微机器人文档后,了解到企业微信机器人支持发送多种类型的消息,包括文本、 Markdown 、图片、图文、文件、语音和模块卡片等,文档中对每一种类型的请求参数和字段含义都做了详尽的解释。



吐槽一下,跟微信开发者文档比起来,企微机器人的文档写得清晰多了!



企微文本消息格式


企微文本消息格式


由于每种消息最终都是要转换成 JSON 格式作为 HTTP 请求的参数的,所以我们可以设计一个基础的消息类(Message)来存放公共参数,然后定义各种不同的具体消息类来集成它(比如文本消息 TextMessage、Markdown 消息 MarkdownMessage 等)。


为了简化开发者使用 SDK 来发送消息,定义统一的 MessageSender 类,在类中提供发送消息的方法(比如发送文本消息 sendText),可以接受 Message 并发送到企业微信服务器。


最终,客户端只需调用统一的消息发送方法即可。SDK 的整体结构如下图所示:



值得一提的是,如果要制作更通用的消息发送 SDK。可以将 MessageSender 定义成接口,编写不同的子类比如飞书 MessageSender、短信 MessageSender 等。


开发 SDK


做好设计之后,接下来就可以开始开发 SDK 了。


步骤如下:



  1. 获取 webhook

  2. 创建 SDK 项目

  3. 编写代码

  4. SDK 打包

  5. 调用 SDK


1、获取 webhook


首先,必须在企业微信群聊中创建一个企业微信机器人,并获取机器人的 webhook。


webhook 是一个 url 地址,用于接受我们开发者自己服务器的请求,从而控制企业微信机器人。后续所有的开发过程,都需要通过 webhook 才可以实现。



复制并保存好这个 Webhook 地址,注意不要泄露该地址!



2、创建 SDK 项目


SDK 通常是一个很干净的项目,此处我们使用 Maven 来构建一个空的项目,并在 pom.xml 文件中配置项目信息。


需要特别注意的是,既然我们正在创建一个 SDK,这意味着它将被更多的开发者使用。因此,在配置 groupId 和 artifactId 时,我们应当遵循以下规范:



  • groupId:它是项目组织或项目开发者的唯一标识符,其实际对应的是 main 目录下的 Java 目录结构。

  • artifactId:它是项目的唯一标识符,对应的是项目名称,即项目的根目录名称。通常,它应当为纯小写,并且多个词之间使用中划线(-)隔开。

  • version:它指定了项目的当前版本。其中,SNAPSHOT 表示该项目仍在开发中,是一个不稳定的版本。


以下是我们配置好的项目信息:


<groupId>com.yupi</groupId>
<artifactId>rtx-robot</artifactId>
<version>1.0-SNAPSHOT</version>

为了让我们的项目更加易用,我们还要能做到让开发者通过配置文件来传入配置(比如 webhook),而不是通过硬编码重复配置各种信息。


所以此处我们把项目只作为 Spring Boot 的 starter,需要在 pom.xml 文件中引入依赖:


<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-configuration-processor</artifactId>
  <optional>true</optional>
</dependency>

最后,我们还需要添加一个配置,配置项 <skip>true</skip> 表示跳过执行该插件的默认行为:


<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <skip>true</skip>
            </configuration>
        </plugin>
    </plugins>
</build>

这样,一个 SDK 项目的初始依赖就配置好了。


3、编写配置类


现在我们就可以按照之前设计的结构开发了。


首先,我们要写一个配置类,用来接受开发者在配置文件中写入的 webhook。


同时,我们可以在配置类中,将需要被调用的 MessageSender 对象 Bean 自动注入到 IOC 容器中,不用让开发者自己 new 对象了。


示例代码如下:


@Configuration
@ConfigurationProperties(prefix = "wechatwork-bot")
@ComponentScan
@Data
public class WebhookConfig {

    private String webhook;

    @Bean
    public RtxRobotMessageSender rtxRobotMessageSender() {
        return new RtxRobotMessageSender(webhook);
    }
}

接下来,为了让 Spring Boot 项目在启动时能自动识别并应用配置类,需要把配置类写入到 resources/META-INF/spring.factories 文件中,示例代码如下:


org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.yupi.rtxrobot.config.WebhookConfig

4、编写消息类


接下来,我们要按照官方文档的请求参数把几种类型的消息对象编写好。


由于每个消息类都有一个固定的字段 msgtype,所以我们定义一个基类 Message,方便后续将不同类型的消息传入统一的方法:


public class Message {

    /**
     * 消息类型
     **/

    String msgtype;
}

接下来编写具体的消息类,比如纯文本类型消息 TextMessage,示例代码如下:


@Data
public class TextMessage extends Message {

    /**
     * 消息内容
     */

    private String content;

    /**
     * 被提及者userId列表
     */

    private List<String> mentionedList;

    /**
     * 被提及者电话号码列表
     */

    private List<String> mentionedMobileList;
  
    /**
     * 提及全体
     */

    private Boolean mentionAll = false;

    public TextMessage(String content, List<String> mentionedList, List<String> mentionedMobileList, Boolean mentionAll) {
        this.content = content;
        this.mentionedList = mentionedList;
        this.mentionedMobileList = mentionedMobileList;
        this.mentionAll = mentionAll;

        if (mentionAll) {
            if (CollUtil.isNotEmpty(this.mentionedList) || CollUtil.isNotEmpty(this.mentionedMobileList)) {
                if (CollUtil.isNotEmpty(mentionedList)) {
                    this.mentionedList.add("@all");
                } else {
                    this.mentionedList = CollUtil.newArrayList("@all");
                }
            } else {
                this.mentionedList = CollUtil.newArrayList("@all");
            }
        }
    }

    public TextMessage(String content) {
        this(content, nullnullfalse);
    }
}

上面的代码中,有个代码优化小细节,官方文档是使用 “@all” 字符串来表示 @全体成员的,但 “@all” 是一个魔法值,为了简化调用,我们将其封装为 mentionAll 布尔类型字段,并且在构造函数中自动转换为实际请求需要的字段。


5、编写消息发送类


接下来,我们将编写一个消息发送类。在这个类中,定义了用于发送各种类型消息的方法,并且所有的方法都会依赖调用底层的 send 方法。send 方法的作用是通过向企微机器人的 webhook 地址发送请求,从而驱动企微机器人发送消息。


以下是示例代码,有很多编码细节:


/**
 * 微信机器人消息发送器
 * @author yuyuanweb
 */

@Slf4j
@Data
public class RtxRobotMessageSender {

    private final String webhook;
  
    public WebhookConfig webhookConfig;

    public RtxRobotMessageSender(String webhook) {
        this.webhook = webhook;
    }

    /**
     * 支持自定义消息发送
     */

    public void sendMessage(Message message) throws Exception {
        if (message instanceof TextMessage) {
            TextMessage textMessage = (TextMessage) message;
            send(textMessage);
        } else if (message instanceof MarkdownMessage) {
            MarkdownMessage markdownMessage = (MarkdownMessage) message;
            send(markdownMessage);
        } else {
            throw new RuntimeException("Unsupported message type");
        }
    }

    /**
     * 发送文本(简化调用)
     */
 
    public void sendText(String content) throws Exception {
        sendText(content, nullnullfalse);
    }
  
    public void sendText(String content, List<String> mentionedList, List<String> mentionedMobileList) throws Exception {
        TextMessage textMessage = new TextMessage(content, mentionedList, mentionedMobileList, false);
        send(textMessage);
    }
    
    /**
     * 发送消息的公共依赖底层代码
     */

    private void send(Message message) throws Exception {
        String webhook = this.webhook;
        String messageJsonObject = JSONUtil.toJsonStr(message);
       // 未传入配置,降级为从配置文件中寻找
        if (StrUtil.isBlank(this.webhook)) {
            try {
                webhook = webhookConfig.getWebhook();
            } catch (Exception e) {
                log.error("没有找到配置项中的webhook,请检查:1.是否在application.yml中填写webhook 2.是否在spring环境下运行");
                throw new RuntimeException(e);
            }
        }
        OkHttpClient client = new OkHttpClient();
        RequestBody body = RequestBody.create(
                MediaType.get("application/json; charset=utf-8"),
                messageJsonObject);
        Request request = new Request.Builder()
                .url(webhook)
                .post(body)
                .build();
        try (Response response = client.newCall(request).execute()) {
            if (response.isSuccessful()) {
                log.info("消息发送成功");
            } else {
                log.error("消息发送失败,响应码:{}", response.code());
                throw new Exception("消息发送失败,响应码:" + response.code());
            }
        } catch (IOException e) {
            log.error("发送消息时发生错误:" + e);
            throw new Exception("发送消息时发生错误", e);
        }
    }
}

代码部分就到这里,是不是也没有很复杂?


6、SDK 打包


接下来就可以对 SDK 进行打包,然后本地使用或者上传到远程仓库了。


SDK 的打包非常简单,通过 Maven 的 install 命令即可,SDK 的 jar 包就会被导入到你的本地仓库中。



在打包前建议先执行 clean 来清理垃圾文件。




7、调用 SDK


最后我们来调用自己写的 SDK,首先将你的 SDK 作为依赖引入到项目中,比如我们的接水提醒应用。


引入代码如下:


<dependency>
  <groupId>com.yupi</groupId>
  <artifactId>rtx-robot</artifactId>
  <version>1.0-SNAPSHOT</version>
</dependency>

然后将之前复制的 webhook 写入到 Spring Boot 的配置文件中:


wechatwork-bot:
  webhook: 你的webhook地址

随后你就可以用依赖注入的方式得到一个消息发送者对象了:


@Resource
public RtxRobotMessageSender rtxRobotMessageSender;

当然你也可以选择在一个非 Spring 环境中手动创建对象,自己传入 webhook:


String webhook = "你的webhook地址";
RtxRobotMessageSender rtxRobotMessageSender = new RtxRobotMessageSender(webhook);

现在,就可以轻松实现我们之前提到的提醒接水工具了。


这里我们就用最简单的方式,定义一个员工数组,分别对应到每周 X,然后用定时任务每日执行消息发送。


示例代码如下:


@Component
public class WaterReminderTask {

    @Resource
    public RtxRobotMessageSender rtxRobotMessageSender;

    private String[] names = {"员工a""员工b""员工c""员工d""员工e"};

    @Scheduled(cron = "0 55 9 * * MON-FRI")
    public void remindToGetWater() {
        LocalDate today = LocalDate.now();
        DayOfWeek dayOfWeek = today.getDayOfWeek();
        String nameToRemind;
        switch (dayOfWeek) {
            case MONDAY:
                nameToRemind = names[0];
                break;
            case TUESDAY:
                nameToRemind = names[1];
                break;
            case WEDNESDAY:
                nameToRemind = names[2];
                break;
            case THURSDAY:
                nameToRemind = names[3];
                break;
            case FRIDAY:
                nameToRemind = names[4];
                break;
            default:
                return;
        }
      
        String message = "提醒:" + nameToRemind + ",是你接水的时间了!";
        rtxRobotMessageSender.sendText(message);
    }
}

好了,现在大家每天都有水喝了,真不错 👍🏻



最后


虽然开发企微机器人 SDK 并不难,但想做一个完善的、易用的 SDK 还是需要两把刷子的,而且沉淀 SDK 对自己未来做项目帮助会非常大。


希望本文对大家有帮助,学会的话 点个赞在看 吧,谢谢大家~


作者:程序员鱼皮
来源:juejin.cn/post/7300611640017813513
收起阅读 »

听我一句劝,业务代码中,别用多线程。

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);  
    } catch (Exception e){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


作者:why技术
来源:juejin.cn/post/7297980721590272040
收起阅读 »

2023行情不好,大龄员工如何跳槽

每一次找工作,都像一次职场大考,既是一次好的整理机会,也是一场对过去工作全方位的检验。--2023年换工作记。 来新团队快两周了,从年初二月份开始准备,到现在近9个月时间,换工作这事才算告一段落。 我今年35了,本科毕业十多年了,级别也不高,一线大兵,职场人的...
继续阅读 »

每一次找工作,都像一次职场大考,既是一次好的整理机会,也是一场对过去工作全方位的检验。--2023年换工作记。


来新团队快两周了,从年初二月份开始准备,到现在近9个月时间,换工作这事才算告一段落。


我今年35了,本科毕业十多年了,级别也不高,一线大兵,职场人的窘境在我身上齐活了--大龄Low T。


这篇文章总结了此次跳槽前后的一些思考与实践,供朋友们参考。


充分的准备


因为这次跳槽中间穿插了内部转岗、换城市、搬家,所以时间比较长。实际有效的求职时间大概5个月。


内容时长
复习专业技术1个月
刷题1个月
写简历3天
看机会&面试3个月


客观上行情确实不好,机会少,所以要留更长的时间等待机会,注意!是等!并不是你刚好投简历,就能遇到好的机会,你要留足够的时间等市面上合适的机会放出来。




公司一般年初会做好招聘预算,所以有金三银四,有的团队是下半年做完调研定好战略,启动招聘为明年做准备,这大概在金九银十。除了这两个窗口,其他时间大部分是淡季,除了少数公司遇到风口了会临时增加预算,或者业绩很好继续追加人,比如OpenAI的窗口,国内今年很多公司在补大模型和算法相关的人才。



准备-复习专业技术


我在百度呆了8年多,培养了一个习惯,重要的事情,全力以赴。



人和人的差距,其实就是几个关键点的差距。冲上去了就上去了。把每一个能做好的点都做到极致,你大概率就能超过别人。



时间有限,复习要有重点。


复习之前,你要明确几个简单的问题:



  1. 你出去要应聘的岗位和级别是什么?

  2. 这个岗位的要求是什么?

  3. 和别人比,你有什么优势?


上常用的招聘app(我主要用的是boss直聘)上搜一下相关的岗位是否有招聘,看看都有哪些公司、哪些岗位在招人,有什么要求。


如果你不介意换城市,多看几个城市,可以增加面试成功的几率。


然后,明确你要打造自己的"人设",不管你过去在公司干的怎么样,通过这几个月的复习你想成为什么样的人?成为哪个领域的专家,精通哪些技术、熟悉哪些技术,列出来,挑两三样重点复习,没有的话,就现整一个,临阵磨枪不快也光!B站上大把的免费教程教你如何快速成为专家。



求职中,最忌讳样样都会、啥都一知半解,好的简历和面试通常是精通一两门,其他的能横向拓展、融会贯通。




能精通一点意味着候选人能长期有耐心的钻研技术、深入解决问题、追求卓越,面试官有理由相信其他的问题他也一定能做的好。



我从事的是图形领域的开发工作,我花了一个月的时间,整理了OpenGL的常见API及用法、常见的渲染算法、C++高频问题,以及游戏引擎的架构和渲染管线,算是准备的比较充分了。


图形C++
OpenGL知识点整理(1)c++进阶知识点复习(1)
OpenGL知识点整理(2)c++进阶知识点复习(2)
深入理解opengl坐标系统c++3
游戏引擎(1)-ECS模式/
PBR(Physically Based Rendering)-理论/
PBR(Physically Based Rendering)-IBL/
图形学自学之路/

另外还有实时渲染算法、业务工程架构、引擎分析之类的整理,涉及到公司工作就没有发到公众平台上。


刷算法题


平时工作用到算法的地方并不多,想趁这个机会,把算法再熟悉一遍,温故知新。算法赋予了计算机灵魂,大厂考算法是有道理的。


提前给自己打了预防针,做好了打持久战的准备,所以直接买了力扣一年的vip会员,方便刷题和看题解。


另外专门读了一本系统的讲算法的书--《计算之魂-吴军》,从认知上提升对计算机、算法的理解。差不多刷了170多道题,基本上够用了。


关于刷题,之前写过一篇文章:重学数据结构算法(刷题心得)


投简历-找到有效的工作机会


节省时间,在网上找了个付费的简历网站,把履历填进去,能很方便的生成整洁的简历。也是直接买一年的,哥我就打算死磕了。


我已经工作十多年了,不能像刚毕业那样海投,那时是广撒网,有机会就去。现在有非常明确的目标,能接得住总包的工作机会就那么几家,一只手都数得过来,连预演练手的机会都没有,面一家就少一家。


锚定了意向中的那几家公司和岗位,有熟人的找熟人推一推,没熟人的硬投。实际发现,这年头HR都不靠谱了,更不用说猎头了。BOSS直聘上,研发自己跑出来找简历的一般都是真实的,那是真的着急招人,几率也更大,至少他要了你的简历,简历筛选这一关是过了,比HR效率高。


我最后投的几个岗位,都是字节研发侧主动来要简历的,加微信问了下,都是有大量真实的HC。


多说两句,其他的公司我就不黑了,字节我问了招聘方,他们明确表示不太在意年龄,冲这个人才观,我也更笃定了去字节。


面试-全力以赴每一场面试


我当了很多年面试官,也参加了很多次校招。面试其实很看眼缘,很难做到完全公平,除非你特别牛逼,有绝对的优势过面试,或者特别垃圾狗都嫌弃你,大多数候选人都在中间徘徊。


面试你的人大概率以后是你的leader,或者peer,他看你气质顺眼很重要,他认可你了,只要你不太差,也会给你过的。不喜欢你的气质,就无意中会有些刁难,过和不过都是一念之差。


这里说几个面试相关的细节。


不会的问题怎么办


说不知道、不会、没做过,是最差的回答。我面试中,会坦言自己没遇到过,请给我几分钟思考下,尝试找到合理的答案。


没有思路怎么办?


我会和面试官沟通,能否给一些提示,或者换一道题。


感觉自己没面好,直接放弃吗?


人生不要轻易言弃!!举两个我这次面试中的案例:



  • 案例1.


我一面的算法题写的有点问题,面试完回到家9点多。回忆代码逻辑,重新写了一遍,调试没问题了微信上和面试官沟通了下。


我表达的意思是:我不会放过任何一个有问题的代码,永不放弃!



  • 案例2.


二面的面试官问的很细,问了几个游戏引擎中很深入的问题,没回答好,我感觉自己应该是跪了。回到家我找了之前自己学习和整理的相关笔记发给面试官,告诉他,这些问题我之前真的有认真研究过,只是这次面试没回答好。


另外,我把在当前这家公司的历史绩效也截图发给了面试官,连着几年都拿了团队最高绩效,告诉他我真的很靠谱,恳求再给一次面试机会。


大概是我的真诚和坚持打动了面试官,第二天电话聊了下,还真给过了,推到了第三面。后面的面试就都比较顺利了。


涨幅


今年的行情,我了解到的,大部分公司都是卡30%的涨幅。HR问我期望薪资时,我很坚决的说,我看中的是这个机会,我热爱这个领域,薪酬差不多就行。


我心里能接受的最差的结果是降薪20%。这个年纪了,还能去一个往上走、充满机会的团队,持续成长,对职业发展来说是莫大的幸运,单纯的追求薪资是在杀鸡取卵、饮鸩止渴。


题外话-天赋是什么?


整个求职过程有点坎坷,有一些机会面的很好没有后话也很费解,后面也释怀了,大概是用人团队没有HC、或者给不上价,给内推人一个面子、走走过场,然后随便找个借口fail掉。


一个好朋友,也是前同事,一直关注我的面试进展,比我还紧张,他说,如果我这么努力这么牛逼都找不到工作,他的职业发展该何去何从。


第二天早上,我给他发了一个易建联退役的演讲视频[1分钟]:

v.douyin.com/iRSfCUAe/


真正的天赋,是你有多少热爱和付出-易建联


有多少朋友抱怨职场不公、运气不好,请问,你对自己的热爱有多少坚持,你对你的职业又有多少热爱,你又有多少勇气和毅力去改变这一切。



"没有人能随随便便成功,我也不例外"。



这是那天早上,我还在等offer、去上家公司上班的地铁上,在微信上打给这位朋友的最后一句。


作者:sumsmile
来源:juejin.cn/post/7300118821533089807
收起阅读 »

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

背景 长话短说,就是在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
收起阅读 »

语雀又崩了?今天咱们玩点花的,手把手教你写出令人窒息的“烂代码”

web
Hello,大家好,我是Sunday。 10月23日 2023年10月23日,语雀崩溃 10 个小时,作为一款知名度极高的产品,这样的一次崩溃可以说对语雀的口碑影响极大。 不过,好在语雀的公关团队处理的还不错,没有甩锅而是及时发布公告,明确是自己的问题。同时在...
继续阅读 »

Hello,大家好,我是Sunday。


10月23日


2023年10月23日,语雀崩溃 10 个小时,作为一款知名度极高的产品,这样的一次崩溃可以说对语雀的口碑影响极大。


不过,好在语雀的公关团队处理的还不错,没有甩锅而是及时发布公告,明确是自己的问题。同时在问题解决之后,给大家 六个月的会员补偿 也可以说是诚意满满(以下为10月24日语雀团队公告)。




毕竟大家都是程序员嘛,这种事也不是不能接受。毕竟:谁还没搞崩过系统呢?😂



本以为这件事就这么过去了,哪知道昨天的一个故障,再次让语雀登上了“风口浪尖”......


11月12日


昨天下午,我在正常使用语雀记录同学学习情况的时候,突然出现了无法保存的情况。心想:“这不会是又崩了吧~~”


看了眼语雀群的微信,果然......




说实话,当时我的第一反应是:“又有瓜可以吃了~~~~~,开心😂”



反正也写不成了,坐等吃瓜就行了。正好恰逢双十一,看看买的硬盘到哪了。


结果打开淘宝才发现,这次不对劲啊,淘宝也崩了!!!




最终我们了解了事情的全貌:



本次事故是由于阿里云 OSS 的故障导致的。钉钉、咸鱼、淘宝、语雀都崩了....



从语雀的公告也体现出了这点:



公告内容如下:



尊敬的客户:您好!北京时间2023年11月12日 17:44起,阿里云监控云产品控制台访问及API调用出现出现使用异常,阿里云工程师正在紧急介入排查。非常抱歉给您的使用带来不便,若有任何问题,请随时联系我们。



可以说,语雀这次有点躺枪了(谁让你刚崩过呢~~~)。


玩点花的!教你写出令人窒息的“烂代码”


好啦,瓜吃完啦。



关于语雀崩溃的反思,网上有很多文章,我就不凑这个热闹了,想要看的同学可以自行搜索~~



“回归正题”,接下来咱们就来看看咱们的文章正题:“如何写出烂代码”。



以下共有十三条烂代码书写准则,可能并没有面面俱到,如果大家发现有一些难以忍受的烂代码习惯,也可以留言发表意见~~



第一条:打字越少越好


  // Good 👍🏻
const a = 18

// Bad 👎
const age = 18

第二条:变量/函数混合命名风格


  // Good 👍🏻
const my_name = 'Suday'
const mName = 'Sunday'
const MnAme = 'Sunday'

// Bad 👎
const myName = 'Sunday'

第三条:不要写注释


  // Good 👍🏻
const cbdr = 666

// Bad 👎
// 666ms 是根据 UX A/B 测试结果进行经验计算的。
// 具体可查看 xxxxxx
const callbackDebounceRate = 666

第四条:使用母语写注释


  // Good 👍🏻
// 666 мс было эмпірычна вылічана на аснове вынікаў UX A/B.
const callbackDebounceRate = 666

// Bad 👎
// 666ms 是根据 UX A/B 测试结果进行经验计算的。
// 具体可查看 xxxxxx
const callbackDebounceRate = 666

第五条:尽可能混合不同的格式


  // Good 👍🏻
const n = 'Sunday';
const a = "18"
const g = "MAN"

// Bad 👎
const name = 'sunday'
const age = '18'
const gender = 'man'

第六条:尽可能把代码写成一行


  // Good 👍🏻
document.location.search.replace(/(^\?)/, '').split('&').reduce(function (o, n) { n = n.split('=') })

// Bad 👎
document.location.search
.replace(/(^\?)/, '')
.split('&')
.reduce((searchParams, keyValuePair) => {
keyValuePair = keyValuePair.split('=')
searchParams[keyValuePair[0]] = keyValuePair[1]
return searchParams
})

第七条:发现错误要保持静默


   // Good 👍🏻
try {
...
} catch () {🤐}

// Bad 👎
try {
...
} catch (error) {
setErrorMessage(error.message)
logError(error)
}

第八条:广泛使用全局变量


  // Good 👍🏻
let count = 1
function addCount() {
count += 1
}

// Bad 👎
function addCount() {
let count = 1
count += 1
}

第九条:构建备用变量


  // Good 👍🏻
let count = 1
function addCount() {
count += 1
}

// Bad 👎
function addCount() {
let count = 1
count += 1
}

第十条:Type 使用需谨慎


  // Good 👍🏻
function sum(a, b) {
return a + b
}

// Bad 👎
function sum(a: number, b: number) {
return a + b
}

第十一条:准备「Plan B」


  // Good 👍🏻
function square(num) {
if (typeof num === 'undefined') {
return undefined
} else {
return num ** 2
}
return null
}

// Bad 👎
function square(num) {
if (typeof num === 'undefined') {
return undefined
}
return num ** 2
}

第十二条:嵌套的三角法则


    // Good 👍🏻
function somFun(num) {
if (condition1) {
if (condition2) {
asyncFunction(param, (result) => {
if (result) {
for (; ;) {
if (condition3) {

}
}
}
})
}
}
}

// Bad 👎
async function somFun(num) {
if (!condition1 || !condition2) {
return;
}
const result = await asyncFunction(params);
if (!result) {
return;
}
for (; ;) {
if (condition3) {

}
}
}

第十三条:混合缩进


      // Good 👍🏻
const f = ['zs'
, 'lisi', 'wangwu']
const d = {
name: 'zs',
age: '18'
}

// Bad 👎
const f = ['zs'
, 'lisi', 'wangwu']
const d = {
name: 'zs',
age: '18'
}

总结


所谓的“烂代码”,是大家一定 不要 照着写的哈。


“教你写出令人窒息的“烂代码”“ 是一个反义,这个大家肯定是可以明白的哈~~~~。



”烂代码“内容参考自:zhuanlan.zhihu.com/p/516564022



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

实战来了,基于DDD实现库存扣减~

大家好,让我们继续DDD&微服务系列,今天,我们看看在DailyMart项目中如何基于DDD实现库存扣减功能。 1. 库存模型 1.1 核心概念 库存是一个非常复杂的概念,涉及在仓库存,计划库存,渠道库存等多个领域实体,在我们《DailyMart微服务...
继续阅读 »

大家好,让我们继续DDD&微服务系列,今天,我们看看在DailyMart项目中如何基于DDD实现库存扣减功能。


1. 库存模型


1.1 核心概念


库存是一个非常复杂的概念,涉及在仓库存,计划库存,渠道库存等多个领域实体,在我们《DailyMart微服务&DDD》实战中,主要关注的是在仓库存模型。


image-20231028224038257


在这个模型中有三个重要的概念:可售库存、预售库存、占用库存,他们的定义如下:


可售库存数(Sellable Quantity,SQ)
可售库存即用户在客户端所见的商品可销售数量。当SQ为0时,用户不能下单。


预扣库存数(Withholding Quantity,WQ)
预扣库存是指被未付款的订单占用的库存数量。这种库存的存在是因为用户在下单后可能不会立刻付款。预扣库存的作用是为用户保留库存,直到用户完成付款,才会从中扣减相应数量的库存。如果用户未能在规定时间内付款,预扣库存WQ将被释放回可售库存SQ上。


占用库存数(Occupy Quantity,OQ)
占用库存是指已经完成付款,但尚未发货的订单所占用的库存数量。这种库存与仓库相关,并且牵涉到履约流程。一旦订单发货,占用库存会相应减少。


根据上述定义,对于一个商品,可售库存数量与预扣库存数量之间的关系是:可售库存(SQ) + 预扣库存(WQ) = 可用库存。


由于每种商品通常包含多个不同的 SKU,在商品交易链路中,无法通过商品id来精确定位库存。为了更高效地管理库存查询和更新请求,我们需要设计一个具有唯一标识能力的 ID,即库存 ID(inventory_id)。此外,在库存扣减操作中还需要存储库存扣减记录,一旦用户取消订单或退货时,可以根据扣减记录返还相应的库存数量。


1.2 领域模型


通过对库存领域概念的分析,我们很容易完成DDD领域建模,如下图所示:


image-20231030160921576


库存 (Inventory): 库存对象充当库存领域的聚合根,负责管理和跟踪商品的可售库存、预扣库存和占用库存等信息。库存对象也具备唯一标识能力,使用库存 ID(inventory_id)来标识不同库存。


库存记录 (InventoryRecord): 库存记录是一个实体,用于记录库存的各种操作,例如扣减、占用、释放、退货等。每个库存记录都有一个唯一的记录 ID(record_id)来标识。


库存 ID(InventoryId)和记录 ID(RecordId): 这两者都是值对象,它们负责提供唯一标识,分别用于标识库存和库存记录。


库存扣减状态(InventoryRecordStateEnum):这也是个值对象,用于标识扣减库存的状态。


2. 库存扣减


库存扣减看似简单,只需在用户支付后减少库存即可,但实际情况要复杂得多。不同的扣减顺序可能导致不同的问题。比如我们先减库存后付款,可能会出现用户下单后放弃支付,导致商品少买或未售出。另一方面,如果我们先付款后减库存,可能出现用户成功支付但商家没有足够的库存来满足订单,这又非常影响用户体验。


一般来说,库存扣减有三种主要模式:


2.1 库存扣减的三种模式




  • 拍减模式:在用户下单时,直接扣减可售库存(SQ)。这种模式不会出现超卖问题,但它的防御能力相对较弱。如果用户大量下单而不付款,库存会一直被占用,从而影响正常交易,导致商家少卖。




  • 预扣模式:在用户下单时,会预先扣减库存,如果订单在规定时间内未完成支付,系统将释放库存。具体来说,当用户下单时,会预扣库存(SQ-、WQ+),此时库存处于预扣状态;一旦用户完成付款,系统会减少预扣库存(WQ-、OQ+),此时库存进入扣减状态




  • 付减模式:在用户完成付款时,直接扣减可售库存(SQ)。这种模式存在超卖风险,因为无法确保用户付款后一定有足够的库存。




对于实物商品,库存扣减主要采用拍减模式预扣模式,付减模式应用较少,在我们DailyMart系统中采用的正是预扣模式。


2.2 预扣模式核心链路


接下来我们重点介绍库存预扣模式的核心链路,包括正向流程和逆向操作。


2.2.1 正向流程


正向流程涉及用户下单、付款和发货的关键步骤。以下是正向流程的具体步骤:


1)用户将商品加入购物车,点击结算后进入订单确认页,点击提交订单后,订单中心服务端发起交易逻辑。


2)调用库存服务执行库存预扣逻辑


3)调用支付服务发起支付请求


4)用户付款完成以后,调用库存平台扣减库存


5)订单服务发送消息给仓储中心,仓储中心收到消息后创建订单,并准备配货发货


6)仓储中心发货以后调用库存平台扣减占用库存数。


image-20231029215629997


2.2.2 逆向操作


逆向操作包括取消订单或退货等情况,我们需要考虑如何回补库存。逆向操作的步骤如下:


1)用户取消订单或退货。
2)更新扣减记录行,状态为释放状态。
3)同时更新库存行,以回补库存。


2.2 库存扣减的执行流程


每一件商品的库存扣减都至少涉及两次数据库写操作:更新库存表(inventory_item)和扣减记录表(inventory_record)。


image-20231030171653428


为了确保库存扣减操作的幂等性,通常需要在扣减记录表中给业务流水号字段创建唯一索引。此外,为了保证数据一致性,修改库存数量与操作流水记录的两个步骤必须在同一个事务中。



关于系统的幂等性实现方案,我在知识星球进行了详细介绍,感兴趣的可以通过文末链接加入。



在数据库层面,库存扣减操作包括以下关键步骤:




  • 用户下单时:insert 扣减记录行,状态为预扣中,同时 update 库存行(减少可销售库存,增加预扣库存,sq-,wq+);




  • 用户付款时:update 扣减记录行,状态为扣减状态,同时update库存行(减少预扣库存,增加占用库存,wq-,oq+);




  • 仓库发货时:update 扣减记录行,状态为发货状态,同时update库存行(减少占用库存数,oq-);




  • 逆向操作时:update 扣减记录行,状态为释放状态,同时update库存行(增加可销售库存,sq+);




通过下图可以清晰看到库存扣减时相关相关数据状态的变化。
image-20231030163042599


3. 核心代码实现


接下来,让我们从接口层、应用层、领域层和基础设施层的角度来分析库存扣减的代码实现。(考虑到篇幅原因,省略了部分代码。)


3.1 接口层


接口层是库存操作的入口,定义了库存操作的接口,如下所示:


@RestController
@Tag(name = "InventoryController", description = "库存API接口")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class InventoryController {
...

@Operation(summary = "库存预扣",description = "sq-,wq+,创建订单时调用")
@PostMapping("/api/inventory/withholdInventory")
public void withholdInventory(@Valid @RequestBody InventoryLockRequest lockRequest) {
inventoryService.withholdInventory(lockRequest);
}

@Operation(summary = "库存扣减",description = "wq-,oq+,付款时调用")
@PutMapping("/api/inventory/deductionInventory")
public void deductionInventory(@RequestParam("transactionId") Long transactionId) {
inventoryService.deductionInventory(transactionId);
}

@Operation(summary = "库存发货",description = "oq-,发货时调用")
@PutMapping("/api/inventory/shipInventory")
public void shipInventory(@RequestParam("transactionId") Long transactionId) {
inventoryService.shipInventory(transactionId);
}

@Operation(summary = "释放库存")
@PutMapping("/api/inventory/releaseInventory")
public void releaseInventory(@RequestParam("transactionId") Long transactionId) {
inventoryService.releaseInventory(transactionId);
}
...
}

3.2 应用层


应用层负责协调领域服务和基础设施层,完成库存扣减的业务逻辑。库存服务不涉及跨聚合操作,因此只需调用基础设施层的能力,并让领域层完成一些直接的业务逻辑。


@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class InventoryServiceImpl implements InventoryService {
...
@Override
@Transactional
public void withholdInventory(InventoryLockRequest inventoryLockRequest) {
Long inventoryId = inventoryLockRequest.getInventoryId();
//1. 获取库存
Inventory inventory = Optional.ofNullable(inventoryRepository.find(new InventoryId(inventoryId)))
.orElseThrow(()->new BusinessException("No inventory found with id:" + inventoryId));

// 2. 幂等校验
boolean exists = inventoryRepository.existsWithTransactionId(inventoryLockRequest.getTransactionId());

if(exists ){
log.error("Inventory record with transaction ID {} already exists, no deduction will be made.", inventoryLockRequest.getTransactionId());
return;
}

//3. 库存预扣
inventory.withholdInventory(inventoryLockRequest.getQuantity());

//4. 生成扣减记录
InventoryRecord inventoryRecord = InventoryRecord.builder()
.inventoryId(inventoryId)
.userId(inventoryLockRequest.getUserId())
.deductionQuantity(inventoryLockRequest.getQuantity())
.transactionId(inventoryLockRequest.getTransactionId())
.state(InventoryRecordStateEnum.PRE_DEDUCTION.code())
.build();

inventory.addInventoryRecord(inventoryRecord);

inventoryRepository.save(inventory);
}
...
}

3.3 领域层


领域层负责处理直接涉及业务规则和逻辑的操作,将库存预扣、扣减、库存释放等操作封装在聚合对象 Inventory 中。同时,领域层定义了仓储接口,如下所示:


@Data
public class Inventory implements Aggregate {
@Serial
private static final long serialVersionUID = 2139884371907883203L;
private InventoryId id;

...

/**
* 库存预扣 sq-,wq+
*
@param quantity 数量
*/

public void withholdInventory(int quantity){
if (quantity <= 0) {
throw new BusinessException("扣减库存数量必须大于零");
}

if (getInventoryQuantity() - quantity < 0) {
throw new BusinessException("库存不足,无法扣减库存");
}

sellableQuantity -= quantity;
withholdingQuantity += quantity;
}

/**
* 释放库存
*
@param currentState 当前状态
*
@param quantity 数量
*/

public void releaseInventory(int currentState, Integer quantity) {
InventoryRecordStateEnum stateEnum = InventoryRecordStateEnum.of(currentState);
switch (stateEnum){
//sq+,wq-
case PRE_DEDUCTION -> {
sellableQuantity += quantity;
withholdingQuantity -= quantity;
}
//sq+,oq-
case DEDUCTION -> {
sellableQuantity += quantity;
occupyQuantity -= quantity;
}
//sq+
case SHIPPED -> {
sellableQuantity += quantity;
}
}
}
...
}

/**
* 仓储接口定义
*/

public interface InventoryRepository extends Repository {
boolean existsWithTransactionId(Long transactionId);

Inventory findByTransactionId(Long transactionId);
}

3.4 基础设施层


基础设施层负责数据库操作,持久化库存状态,如下所示:


@Repository
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class InventoryRepositoryImpl implements InventoryRepository {
...
@Override
public Inventory find(InventoryId inventoryId) {

InventoryItemDO inventoryItemDO = inventoryItemMapper.selectById(inventoryId.getValue());
return itemInventoryConverter.fromData(inventoryItemDO);
}

@Override
public Inventory save(Inventory aggregate) {
InventoryItemDO inventoryItemDO = itemInventoryConverter.toData(aggregate);

if(inventoryItemDO.getId() == null){
inventoryItemMapper.insert(inventoryItemDO);
}else{
inventoryItemMapper.updateById(inventoryItemDO);
}

InventoryRecord inventoryRecord = aggregate.getInventoryRecordList().get(0);
InventoryRecordDO inventoryRecordDO = inventoryRecordConverter.toData(inventoryRecord);

if(inventoryRecordDO.getId() == null){
inventoryRecordMapper.insert(inventoryRecordDO);
}else{
inventoryRecordMapper.updateById(inventoryRecordDO);
}

return aggregate;
}
...
}

小结


本文详细介绍了库存领域的关键概念以及库存扣减的三种模式,同时基于DDD的分层模型,成功实现了预扣模式的业务逻辑。在库存的预扣接口中,通过业务流水表确保了接口的幂等性,不过更新库存的接口暂时还没实现幂等,幂等会在下篇文章中统一解决。同时,值得注意的是,本文所展示的方案采用了纯数据库实现,可能在高并发情况下性能略有下降,当然这也是我们后面需要优化的点。


作者:飘渺Jam
来源:juejin.cn/post/7299037876636696615
收起阅读 »

独自一人时写代码 VS 朋友在旁边时写代码

网友评论:@TAO大鑫:程序员是不允许别人站在身后的@Bibala-Bong:朋友在身边,不停的ls ls ls ps ps ps top top top 怎么能让屏幕上东西多怎么来 @贼王卍冬:一个人写代码:百度有啥我会啥; 朋友在旁边:今天气不错啊@铁柱未...
继续阅读 »


网友评论:


@TAO大鑫:程序员是不允许别人站在身后的

@Bibala-Bong:朋友在身边,不停的ls ls ls ps ps ps top top top 怎么能让屏幕上东西多怎么来 

@贼王卍冬:一个人写代码:百度有啥我会啥; 朋友在旁边:今天气不错啊

@铁柱未来科技有限公司董事长:有时候感觉后边有人就是猛一回头

@新浪云: 程序员不要面子啊

作者:程序员的幽默
来源:mp.weixin.qq.com/s/A-MdLvqmOPxTFBPTJVZ0OQ
e>

收起阅读 »

21亿!李佳琦或面临破产,网友:想看他努力工作样子

双11还没有到来,一场由京东、李佳琦、小杨哥之间的大战却因1台烤箱拉开了大幕。而正是这台烤箱,在李佳琦迫使品牌方起诉京东,得罪小杨哥的那一刻,也扯下了超级头部主播“全网最低价”的遮羞布。事情起因是这样的:海氏向市管局举报京东近日,海氏(品牌方)向市管总局实名举...
继续阅读 »
收起阅读 »

Uniapp Record:获取手机号

web
前言:渠道 -> 产品:"我需要收集用户信息"。产品 -> 开发:"那就新增一个功能来获取用户的手机号地址相关信息"。最近项目中增加了获取用户信息相关需求,这个功能怎么说呢,对于我甚至是大部分人来说都是比较抵触的吧,毕竟无缘无故获取个人信息就是感觉...
继续阅读 »

前言:渠道 -> 产品:"我需要收集用户信息"。产品 -> 开发:"那就新增一个功能来获取用户的手机号地址相关信息"。最近项目中增加了获取用户信息相关需求,这个功能怎么说呢,对于我甚至是大部分人来说都是比较抵触的吧,毕竟无缘无故获取个人信息就是感觉不爽,哈哈!但是没也没法,身为打工人的无奈,照做呗。



由于目前项目技术栈是 uniapp,所以先去官方文档查阅相关资料,了解到目前有是三种方式涉及手机号相关的,自然也是能够获取到手机号。


1. uni一键登录


uni一键登录是DCloud公司联合个推公司推出的,整合了三大运营商网关认证能力的服务。实现流程如下:



  1. App 界面弹出请求授权,询问用户是否同意该App获取手机号。这个授权界面是运营商SDK弹出的,可以有限定制;

  2. 用户同意授权后,SDK底层访问运营商网关鉴权,获得当前设备access_token等信息;

  3. 在服务器侧通过 uniCloud 将access_token等信息 置换为当前设备的真实手机号码。然后服务器直接入库,避免手机号传递到前端发生的不可信情况。


对该方法大致了解了下,其中流程相对比较简单,但是结合当前项目来说:



  1. 每次验证需要收费,虽然很便宜(2分)

  2. 需要开通uni一键登录服务,uniCloud 服务


因为项目不涉及云开发,而且不考虑产品使用时产生的额外费用,所以暂时pass掉。


2. OAuth 登录鉴权


App端OAuth(登录鉴权)模块封装了市场上主流的三方登录SDK,提供JS API统一调用登录鉴权功能。也看下实现流程:



  1. 向三方登录平台申请开通,有些平台(如微信登录)申请成功后会获取appid;

  2. 在HBuilder中配置申请的参数(如appid等),提交云端打包生成自定义基座;

  3. 在App项目中用API进行登录,成功后获取到授权标识提交到业务服务器完成登录操作。


该方式需要在项目 mainifest.json 中去开启 OAuth 鉴权模块:


uni02.png


可以看到里面除了前面提到的 一键登录,还包含 苹果登录、微信登录、QQ登录等三方登录平台,因为要涉及开通相关服务,并且当前登录业务鉴权逻辑比较简单(手机号、密码验证),并且app也为上架应用市场,所以这种相对繁琐的方式也就不考虑了。


3. 微信小程序登录


前面两种方式都pass掉了,意味着要获取手机号相关信息在APP中是行不通了的,但是不慌,不是还有微信小程序版嘛,正好产品也包含小程序平台,前段时间做公众号网页开发时也是包含登录授权,所以小程序的授权登录应该也差不多,而且小程序对比APP来说相对便捷(缺点是某些涉及原生插件相关的功能暂时无法使用)。


同样,先去微信官方文档查阅,看到有两种方式可以获取:


uni03.png


下面具体介绍下实现方案:


3-1. 纯前端实现

<button open-type="getPhoneNumber" @getphonenumber="getPhoneNumber" plain="true">获取手机号</button>

这个 button 里面的一些属性及事件的具体用法说明可以去看文档说明:uniapp button 用法,文档解释的很清楚,写法也是固定的。


这里还需要用到一个加解密插件:WXBizDataCrypt,下载链接如下,


https://res.wx.qq.com/wxdoc/dist/assets/media/aes-sample.eae1f364.zip

可以去下载选择对应的版本,目前有 Java、C++、Node、Python四个版本,我们这里选择Node版本,将 WXBizDataCrypt.js 添加到项目中


完整代码如下:


<!-- testPhone.vue -->
<template>
<view class="wrap">
<view class="box-container">
<input v-model="phone" />
<view class="action-btn">
<button open-type="getPhoneNumber" @getphonenumber="getPhoneNumber" plain="true">获取手机号</button>
</view>
</view>
</view>
</template>

<script>
import WXBizDataCrypt from '@/utils/WXBizDataCrypt.js'

export default {
data() {
return {
phone: "",
phone_iv: "",
js_code: "",
session_key: "",
phone_encryptedData: null,
}
},
onShow() {
this.initLogin()
},
methods: {
initLogin() {
uni.login({
provider: 'weixin',
success: res => {
this.js_code = res.code
uni.request({
url: 'https://api.weixin.qq.com/sns/jscode2session', // 请求微信服务器
method: 'GET',
data: {
appid: 'xxxxxxxx', // 微信appid
secret: 'xxxxxxxxxxxxx', // 微信秘钥
js_code: this.js_code,
grant_type: 'authorization_code'
},
success: (res) => {
console.log('获取信息', res.data);
this.session_key = res.data.session_key
}
});
}
});
},
getPhoneNumber(res) {
console.log(res, '---手机号回调信息')
this.phone_encryptedData = res.detail.encryptedData;
this.phone_iv = res.detail.iv;
let pc = new WXBizDataCrypt('填写微信appid', this.session_key);
try {
let data = pc.decryptData(this.phone_encryptedData, this.phone_iv);
if (data.phoneNumber !== '') {
this.phone = data.phoneNumber;
}
} catch (error) {
console.error('获取失败:', error);
}
}
}
}
</script>

<style lang="less">
.wrap {
width: 100vw;
height: 100vh;
background-color: #F1F2F6;
display: flex;
align-items: center;
justify-content: center;

.box-container {
width: 70vw;
height: 30vh;

input {
border: 2rpx solid black;
}

.action-btn {
width: 50%;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
margin: 40rpx auto;
}
}
}
</style>

大致流程是:


先通过 uni.login 拿到一个 code,用这 code 作为js_code、appid(微信小程序后台设置中获取)、secret(微信公众号后台获取的密钥)、grant_type(固定值:authorization_code) 去请求 https://api.weixin.qq.com/sns/jscode2session 这个地址,返回结果如下:


{"session_key":"zkJJOfHPYHc\/cVK2kydibg==","openid":"oHXOj5NJMH78yWdVcf6loGOL4cno"}

然后点击按钮调起微信手机号授权页:


999.png


@getphonenumber 事件的回调中获取的信息打印结果如下:


888.png


框选的信息就是我们需要的,是一个加密后的数据。


最后使用 WXBizDataCrypt 对信息进行解密,解密后就是我们需要的手机号信息了。


3-2. 前后端实现


前端代码逻辑改了下:


<script>
export default {
data() {
return {
phone: "",
}
},
methods: {
getPhoneNumber(res) {
console.log(res, '---手机号回调信息')
// 注:这里的code和前面登录返回的code是不同的
const { code } = res.detail
// 根据code去请求后端提供的接口,即可从响应数据中拿到手机号
}
}
}
</script>

后端做了哪些事情呢?


首先会去 获取接口调用凭证 ,官方文档描述如下:


777.png


// 参数说明

{
grant_type: client_credential, // 固定值
appid: '', // 填写微信小程序的appid
secret: '', // 填写微信小程序的密钥
}

返回参数为:access_token(接口凭证)expire_in(过期时间,默认为2小时)


然后再去调获取手机号接口(getPhoneNumber),


666.png


参数携带前面返回的 access_token,再加上前端传过来的 code,即可获取到手机号信息。


下面是我用 Postman 对三个接口做了测试验证:


weixin08.png


weixin07.png


weixin06.png


对比两种方式,个人建议还是采用第二种好一点,让相关的业务都在后端去处理,除此之外还有一个原因就是涉及一个安全性相关问题,前面代码中可以看到我们在请求小程序登录接口是将 appid、screct等信息放在请求参数中的,这种极易通过源码拿到,所以存在相关信息泄露问题,事实证明这种方式也是不建议使用的:


555.png


踩坑点




  1. 注意区分登陆时返回的 code 和 button 按钮获取手机号回调返回的 code 是不相同的




  2. @getphonenumber 回调函数的返回信息如果信息为:api scope is not declared in the privacy agreement ,这种是小程序的【隐私保护策略】限制的,排查下你的小程序中用户隐私保护指引设置送是否添加了相关的用户隐私类型(手机号、通讯录、位置信息等)




444.png


以上就是结合项目需求场景对获取手机号的实现做的一个记录!


作者:瓶子丶
来源:juejin.cn/post/7300036605099343926
收起阅读 »

我们为什么要使用Java的弱引用?

哈喽,各位小伙伴们,你们好呀,我是喵手。   今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。   我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都...
继续阅读 »

哈喽,各位小伙伴们,你们好呀,我是喵手。



  今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。


  我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。



小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!



前言


在Java开发中,内存管理一直是一个重要的话题。由于Java自动内存分配和垃圾回收机制的存在,我们不需要手动去管理内存,但是有时候我们却需要一些手动控制的方式来减少内存的使用。本文将介绍其中一种手动控制内存的方式:弱引用。


摘要


本文主要介绍了Java中弱引用的概念和使用方法。通过源代码解析和应用场景案例的分析,详细阐述了弱引用的优缺点以及适用的场景。最后,给出了类代码方法介绍和测试用例,并进行了全文小结和总结。


Java之弱引用


简介


弱引用是Java中一种较为特殊的引用类型,它与普通引用类型的最大不同在于,当一个对象只被弱引用所引用时,即使该对象仍然在内存中存在,也可能被垃圾回收器回收。


源代码解析


在Java中,弱引用的实现是通过WeakReference类来实现的。该类的定义如下:


public class WeakReference<T> extends Reference<T> {
public WeakReference(T referent);
public WeakReference(T referent, ReferenceQueue<? super T> q);
public T get();
}

其中,构造方法分别是无参构造方法、有参构造方法和获取弱引用所引用的对象。


与强引用类型不同,弱引用不会对对象进行任何引用计数,也就是说,即使存在弱引用,对象的引用计数也不会增加。


  如下是部分源码截图:


在这里插入图片描述


应用场景案例


缓存


在开发中,缓存是一个很常见的场景。但是如果缓存中的对象一直存在,就会导致内存不断增加。这时,我们就可以考虑使用弱引用,在当缓存中的对象已经没有强引用时,该对象就会被回收。


Map<String, WeakReference<User>> cache = new HashMap<>();

public User getUser(String userId) {
User user;
// 判断是否在缓存中
if (cache.containsKey(userId)) {
WeakReference<User> reference = cache.get(userId);
user = reference.get();
if (user == null) {
// 从数据库中读取
user = db.getUserById(userId);
// 加入缓存
cache.put(userId, new WeakReference<>(user));
}
} else {
// 从数据库中读取
user = db.getUserById(userId);
// 加入缓存
cache.put(userId, new WeakReference<>(user));
}
return user;
}

上述代码中,我们在使用缓存时,首先判断该对象是否在缓存中。如果存在弱引用,我们先通过get()方法获取对象,如果对象不为null,则直接返回;如果对象为null,则说明该对象已经被回收了,此时需要从数据库中重新读取对象,并加入缓存。


监听器


在Java开发中,我们经常需要使用监听器。但是如果监听器存在强引用,当我们移除监听器时,由于其存在强引用,导致内存无法释放。使用弱引用则可以解决该问题。


public class Button {
private List<WeakReference<ActionListener>> listeners = new ArrayList<>();

public void addActionListener(ActionListener listener) {
listeners.add(new WeakReference<>(listener));
}

public void removeActionListener(ActionListener listener) {
listeners.removeIf(ref -> ref.get() == null || ref.get() == listener);
}

public void click() {
for (WeakReference<ActionListener> ref : listeners) {
ActionListener listener = ref.get();
if (listener != null) {
listener.perform();
}
}
}
}

上述代码中,我们使用了一个List来保存所有的监听器。在添加监听器时,我们使用了WeakReference进行包装,以保证该监听器不会导致内存泄漏。在移除监听器时,通过removeIf()方法来匹配弱引用是否已经被回收,并且判断是否与指定的监听器相同。在触发事件时,我们通过get()方法获取弱引用所引用的对象,并判断是否为null,如果不为null,则执行监听器的perform()方法。


优缺点分析


优点



  1. 可以有效地降低内存占用;

  2. 适用于一些生命周期较短的对象,可以避免内存泄漏;

  3. 使用方便,只需要将对象包装为弱引用即可。


缺点



  1. 对象可能被提前回收,这可能会导致某些操作失败;

  2. 弱引用需要额外的开销,会对程序的性能产生一定的影响。


类代码方法介绍


WeakReference类


构造方法


public WeakReference(T referent);
public WeakReference(T referent, ReferenceQueue<? super T> q);

其中,第一个构造方法是无参构造方法,直接使用该方法会创建一个没有关联队列的弱引用。第二个构造方法需要传入一个ReferenceQueue队列,用于关联该弱引用。在目标对象被回收时,该队列会触发一个通知。


get()方法


public T get();

该方法用于获取弱引用所包装的对象,如果对象已经被回收,则返回null。


ReferenceQueue类


构造方法


public ReferenceQueue();

无参构造方法,直接使用该方法可以创建一个新的ReferenceQueue对象。


poll()方法


public Reference<? extends T> poll();

该方法用于获取ReferenceQueue队列中的下一个元素,如果队列为空,则返回null。


测试用例


测试代码演示


package com.example.javase.se.classes.weakReference;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* @Author ms
* @Date 2023-11-05 21:43
*/

public class WeakReferenceTest {

public static void main(String[] args) throws InterruptedException {
testWeakReference();
testCache();
testButton();
}

public static void testWeakReference() throws InterruptedException {
User user = new User("123", "Tom");
WeakReference<User> weakReference = new WeakReference<>(user);
user = null;
System.gc();
Thread.sleep(1000);
assert weakReference.get() == null;
}

public static void testCache() throws InterruptedException {
User user = new User("123", "Tom");
Map<String, WeakReference<User>> cache = new HashMap<>();
cache.put(user.getId(), new WeakReference<>(user));
user = null;
System.gc();
Thread.sleep(1000);
assert cache.get("123").get() == null;
}

public static void testButton() {
Button button = new Button();
ActionListener listener1 = new ActionListener();
ActionListener listener2 = new ActionListener();
button.addActionListener(listener1);
button.addActionListener(listener2);
button.click();
listener1 = null;
listener2 = null;
System.gc();
assert button.getListeners().get(0).get() == null;
assert button.getListeners().get(1).get() == null;
button.click();
}

static class User {
private String id;
private String name;

public User(String id, String name) {
this.id = id;
this.name = name;
}

public String getId() {
return id;
}

public String getName() {
return name;
}
}

static class ActionListener {
public void perform() {
System.out.println("Button clicked");
}
}

static class Button {
private List<WeakReference<ActionListener>> listeners = new ArrayList<>();

public void addActionListener(ActionListener listener) {
listeners.add(new WeakReference<>(listener));
}

public void click() {
for (WeakReference<ActionListener> ref : listeners) {
ActionListener listener = ref.get();
if (listener != null) {
listener.perform();
}
}
}

public List<WeakReference<ActionListener>> getListeners() {
return listeners;
}
}
}

测试结果


  根据如上测试用例,本地测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加更多的测试数据或测试方法,进行熟练学习以此加深理解。


在这里插入图片描述


测试代码分析


  根据如上测试用例,在此我给大家进行深入详细的解读一下测试代码,以便于更多的同学能够理解并加深印象。


此代码演示了 Java 中弱引用的使用场景,以及如何使用弱引用来实现缓存和事件监听器等功能。主要包括以下内容:


1.测试弱引用:定义一个 User 类,通过 WeakReference 弱引用来持有此对象,并在程序运行时将 User 对象设为 null,通过 System.gc() 手动触发 GC,验证弱引用是否被回收。


2.测试缓存:定义一个 Map 对象,将 User 对象通过 WeakReference 弱引用的形式存入,保留 User 对象的 ID,在后续程序运行时手动触发 GC,验证弱引用是否被回收。


3.测试事件监听器:定义一个 Button 类,通过 List<WeakReference> 弱引用来持有 ActionListener 对象,定义一个 addActionListener 方法,用于向 List 中添加 ActionListener 对象,定义一个 click 方法,用于触发 ActionListener 中的 perform 方法。在测试中,向 Button 中添加两个 ActionListener 对象,将它们设为 null,通过 System.gc() 手动触发 GC,验证弱引用是否被回收。


总的来说,弱引用主要用于缓存、事件监听器等场景,可以避免内存泄漏问题,但需要注意使用时的一些问题,比如弱引用被回收后,需要手动进行相应的处理等。


全文小结


本文介绍了Java中弱引用的概念和使用方法,通过源代码解析和应用场景案例的分析,详细阐述了弱引用的优缺点以及适用的场景。同时,也给出了类代码方法介绍和测试用例,最后进行了全文小结和总结。


总结


本文介绍了Java中弱引用的概念和使用方法,弱引用是一种较为特殊的引用类型,与普通引用类型不同的是,当一个对象只被弱引用所引用时,即使该对象仍然在内存中存在,也可能被垃圾回收器回收。


弱引用主要适用于一些生命周期较短的对象,可以有效地降低内存占用。同时,在一些需要监听器、缓存等场景中,使用弱引用可以避免内存泄漏。


在使用弱引用时,我们可以使用WeakReference类来实现,并通过get()方法获取弱引用所包装的对象。同时,我们也可以使用ReferenceQueue类来关联弱引用,当目标对象被回收时,该队列会触发一个通知。


但是弱引用也有其缺点,例如对象可能被提前回收,这可能会导致某些操作失败,同时弱引用也需要额外的开销,会对程序的性能产生一定的影响。


因此,在使用弱引用时,我们需要根据具体场景具体分析,权衡其优缺点,选择合适的引用类型来进行内存管理。


... ...


文末


好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。


... ...


学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!


wished for you successed !!!




⭐️若喜欢我,就请关注我叭。


⭐️若对您有用,就请点赞叭。


⭐️若有疑问,就请评论留言告诉我叭。


作者:喵手
来源:juejin.cn/post/7299659033970851875
收起阅读 »

做好人的意义是什么?

今天看抖音上“三根葱”演绎了一个片段: 老张指使老王干活,别人问为啥老让他干,他说:谁让他听话呢? 老李骂老王磨磨蹭蹭,别人问为啥不骂老张,他说:老张脾气大,不好惹! 最后总结,懂事的会承担更多,脾气好的都在受气,照顾人的没人照顾。 看到这里,我多少有点共...
继续阅读 »

今天看抖音上“三根葱”演绎了一个片段:



老张指使老王干活,别人问为啥老让他干,他说:谁让他听话呢?


老李骂老王磨磨蹭蹭,别人问为啥不骂老张,他说:老张脾气大,不好惹!


最后总结,懂事的会承担更多,脾气好的都在受气,照顾人的没人照顾



看到这里,我多少有点共鸣。


我想到,我经历过两类不同的领导。


第一种,善用权衡之术,有时特意挑起内部斗争。比如挑起产品部和技术部对立,加剧技术部和市场部不和。一开始,我还不明白,他为什么要破坏团结


后来,我的技术领导给了我答案:



我们和产品部吵架,要找人评理,这个裁判就是他(副总),他会站在谁那边呢?这全看我们平时的表现。我们比产品部更讨好他,他就会偏向我们。下一次,产品部让他更满意了,他又会站在产品部那边。



这种权衡之术,是公开的。但是,只要你还有欲有求,你就无法破解。帮派越多,各方越对立,领导就越如帝王般尊贵。这一套,封建社会玩了几千年。看到这里,估计你不会再骂那些“昏君”了,因为他们比你清亮。


我还有一个领导。他从不挑起各部门的对立。反而,他默默地维持和谐,调和职能部门各司其职。这一点,我也能看出来。因为,如果产品部对技术部有意见,他不但不会煽风点火、添油加醋,而且都不说是产品部提的意见,他说是自己的想法。


结果呢,如同当年“不知尧舜是吾君”一样,老百姓说:我们自己过得好,和皇帝什么关系?真格的,我们皇帝是谁啊?


员工也觉得,我们部门间完全可以自驱运转。那位领导如不存在一般,不受敬畏,员工都不向他谄媚


在我们传统的观念里,会将挑事者定义为小人,将默默行善者定义为好人。


我们从小受的教育是要当个好人。然而,长大了,我们发现,当个小人却能少受罪,更容易获得世俗眼里的成功。



想要赢了竞争,设局毁了对手效果最快。


想要笼络人心,搬弄是非,一起诋毁对方,能够快速团结一批人。



你看,利用别人的人风生水起,成就别人的人却穷困潦倒。


既然做好人没有好结果,那么当好人的意义又是什么呢?


说实话,我也一度迷茫、动摇,甚至否定。


我想,历史长河中,有没有人和我有同样的疑惑,但是他却找到了答案,而且还流传了下来,并且还恰好被我能看到。


于是,我就去古籍中找。我喜欢去找古籍,解读古籍。最好只看文言文,别有翻译。因为有时候,你读不懂,读不懂又没有翻译,你就会自己去脑补。看白纸是脑补不出来的,有那么零星几个能看懂的字,其实是古人给你一个引子,其他是你自己想出来的。你想出来的答案,肯定合乎你的逻辑,你不会抵触,反而正好解决了你的困惑。你又不是想让答案变成金条,你不就是要一个能通顺的解释吗?


这个疑惑,我在《幽梦影》中找到了答案:



黑与白交,黑能污白,白不能掩黑。此君子小人相攻之大势也。


然人必喜白而恶黑,此又君子必胜小人之理也。



把君子比做白色,小人比作比黑色。黑色很容易就能污染白色,但是多少白色都很难掩盖住一点黑色


从这里看,君子是干不过小人。因为,小人的势力很大。


但是下一句神奇的事情来了,你喜欢君子?还是喜欢小人?


人们肯定是喜欢君子讨厌小人。谁又愿意和尔虞我诈的人一起共事呢?从这个角度看,“君子又必胜小人”。


现实中,大家虽然对小人前呼后拥,但是每个人心里却是向往光明的,选择是无奈的。


很多坚持当好人的人。并非不知道当小人的套路,只是坚守一份志向罢了。


那么,做好人的意义就有了:在这个纷纷扰扰的世态下,公序良俗依然是人们美好的向往。多数普通人只是向往,而好人却已经做到了。难道这不是碾压式的胜利吗?


至于代价,都是每个人自己选的。


作者:TF男孩
来源:juejin.cn/post/7175306788995072056
收起阅读 »

码农如何提高自己的品味

作者:京东科技 文涛 前言 软件研发工程师俗称程序员经常对业界外的人自谦作码农,一来给自己不菲的收入找个不错的说辞(像农民伯伯那样辛勤耕耘挣来的血汗钱),二来也是自嘲这个行业确实辛苦,辛苦得没时间捯饬,甚至没有驼背、脱发加持都说不过去。不过时间久了,行外人还真...
继续阅读 »

作者:京东科技 文涛


前言


软件研发工程师俗称程序员经常对业界外的人自谦作码农,一来给自己不菲的收入找个不错的说辞(像农民伯伯那样辛勤耕耘挣来的血汗钱),二来也是自嘲这个行业确实辛苦,辛苦得没时间捯饬,甚至没有驼背、脱发加持都说不过去。不过时间久了,行外人还真就相信了程序员就是一帮没品味,木讷的low货,大部分的文艺作品中也都是这么表现程序员的。可是我今天要说一下我的感受,编程是个艺术活,程序员是最聪明的一群人,我们的品味也可以像艺术家一样。


言归正转,你是不是以为我今天要教你穿搭?不不不,这依然是一篇技术文章,想学穿搭女士学陈舒婷(《狂飙》中的大嫂),男士找陈舒婷那样的女朋友就好了。笔者今天教你怎样有“品味”的写代码。



以下几点可提升“品味”


说明:以下是笔者的经验之谈具有部分主观性,不赞同的欢迎拍砖,要想体系化提升编码功底建议读《XX公司Java编码规范》、《Effective Java》、《代码整洁之道》。以下几点部分具有通用性,部分仅限于java语言,其它语言的同学绕过即可。


优雅防重


关于成体系的防重讲解,笔者之后打算写一篇文章介绍,今天只讲一种优雅的方式:


如果你的业务场景满足以下两个条件:


1 业务接口重复调用的概率不是很高


2 入参有明确业务主键如:订单ID,商品ID,文章ID,运单ID等


在这种场景下,非常适合乐观防重,思路就是代码处理不主动做防重,只在监测到重复提交后做相应处理。


如何监测到重复提交呢?MySQL唯一索引 + org.springframework.dao.DuplicateKeyException


代码如下:


public int createContent(ContentOverviewEntity contentEntity) {
try{
return contentOverviewRepository.createContent(contentEntity);
}catch (DuplicateKeyException dke){
log.warn("repeat content:{}",contentEntity.toString());
}
return 0;
}

用好lambda表达式


lambda表达式已经是一个老生常谈的话题了,笔者认为,初级程序员向中级进阶的必经之路就是攻克lambda表达式,lambda表达式和面向对象编程是两个编程理念,《架构整洁之道》里曾提到有三种编程范式,结构化编程(面向过程编程)、面向对象编程、函数式编程。初次接触lambda表达式肯定特别不适应,但如果熟悉以后你将打开一个编程方式的新思路。本文不讲lambda,只讲如下例子:


比如你想把一个二维表数据进行分组,可采用以下一行代码实现


List<ActionAggregation> actAggs = ....
Map<String, List<ActionAggregation>> collect =
actAggs.stream()
.collect(Collectors.groupingBy(ActionAggregation :: containWoNosStr,LinkedHashMap::new,Collectors.toList()));

用好卫语句


各个大场的JAVA编程规范里基本都有这条建议,但我见过的代码里,把它用好的不多,卫语句对提升代码的可维护性有着很大的作用,想像一下,在一个10层if 缩进的接口里找代码逻辑是一件多么痛苦的事情,有人说,哪有10层的缩进啊,别说,笔者还真的在一个微服务里的一个核心接口看到了这种代码,该接口被过多的人接手导致了这样的局面。系统接手人过多以后,代码腐化的速度超出你的想像。


下面举例说明:


没有用卫语句的代码,很多层缩进


if (title.equals(newTitle)){
if (...) {
if (...) {
if (...) {

}
}else{

}
}else{

}
}

使用了卫语句的代码,缩进很少


if (!title.equals(newTitle)) {
return xxx;
}
if (...) {
return xxx;
}else{
return yyy;
}
if (...) {
return zzz;
}

避免双重循环


简单说双重循环会将代码逻辑的时间复杂度扩大至O(n^2)


如果有按key匹配两个列表的场景建议使用以下方式:


1 将列表1 进行map化


2 循环列表2,从map中获取值


代码示例如下:


List<WorkOrderChain> allPre = ...
List<WorkOrderChain> chains = ...
Map<String, WorkOrderChain> preMap = allPre.stream().collect(Collectors.toMap(WorkOrderChain::getWoNext, item -> item,(v1, v2)->v1));
chains.forEach(item->{
WorkOrderChain preWo = preMap.get(item.getWoNo());
if (preWo!=null){
item.setIsHead(1);
}else{
item.setIsHead(0);
}
});

@see @link来设计RPC的API


程序员们还经常自嘲的几个词有:API工程师,中间件装配工等,既然咱平时写API写的比较多,那种就把它写到极致**@see @link**的作用是让使用方可以方便的链接到枚举类型的对象上,方便阅读


示例如下:


@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContentProcessDto implements Serializable {
/**
* 内容ID
*/

private String contentId;
/**
* @see com.jd.jr.community.common.enums.ContentTypeEnum
*/

private Integer contentType;
/**
* @see com.jd.jr.community.common.enums.ContentQualityGradeEnum
*/

private Integer qualityGrade;
}

日志打印避免只打整个参数


研发经常为了省事,直接将入参这样打印


log.info("operateRelationParam:{}", JSONObject.toJSONString(request));

该日志进了日志系统后,研发在搜索日志的时候,很难根据业务主键排查问题


如果改进成以下方式,便可方便的进行日志搜索


log.info("operateRelationParam,id:{},req:{}", request.getId(),JSONObject.toJSONString(request));

如上:只需要全词匹配“operateRelationParam,id:111”,即可找到业务主键111的业务日志。


用异常捕获替代方法参数传递


我们经常面对的一种情况是:从子方法中获取返回的值来标识程序接下来的走向,这种方式笔者认为不够优雅。


举例:以下代码paramCheck和deleteContent方法,返回了这两个方法的执行结果,调用方通过返回结果判断程序走向


public RpcResult<String> deleteContent(ContentOptDto contentOptDto) {
log.info("deleteContentParam:{}", contentOptDto.toString());
try{
RpcResult<?> paramCheckRet = this.paramCheck(contentOptDto);
if (paramCheckRet.isSgmFail()){
return RpcResult.getSgmFail("非法参数:"+paramCheckRet.getMsg());
}
ContentOverviewEntity contentEntity = DozerMapperUtil.map(contentOptDto,ContentOverviewEntity.class);
RpcResult<?> delRet = contentEventHandleAbility.deleteContent(contentEntity);
if (delRet.isSgmFail()){
return RpcResult.getSgmFail("业务处理异常:"+delRet.getMsg());
}
}catch (Exception e){
log.error("deleteContent exception:",e);
return RpcResult.getSgmFail("内部处理错误");
}
return RpcResult.getSgmSuccess();
}



我们可以通过自定义异常的方式解决:子方法抛出不同的异常,调用方catch不同异常以便进行不同逻辑的处理,这样调用方特别清爽,不必做返回结果判断


代码示例如下:


public RpcResult<String> deleteContent(ContentOptDto contentOptDto) {
log.info("deleteContentParam:{}", contentOptDto.toString());
try{
this.paramCheck(contentOptDto);
ContentOverviewEntity contentEntity = DozerMapperUtil.map(contentOptDto,ContentOverviewEntity.class);
contentEventHandleAbility.deleteContent(contentEntity);
}catch(IllegalStateException pe){
log.error("deleteContentParam error:"+pe.getMessage(),pe);
return RpcResult.getSgmFail("非法参数:"+pe.getMessage());
}catch(BusinessException be){
log.error("deleteContentBusiness error:"+be.getMessage(),be);
return RpcResult.getSgmFail("业务处理异常:"+be.getMessage());
}catch (Exception e){
log.error("deleteContent exception:",e);
return RpcResult.getSgmFail("内部处理错误");
}
return RpcResult.getSgmSuccess();
}

自定义SpringBoot的Banner


别再让你的Spring Boot启动banner千篇一律,spring 支持自定义banner,该技能对业务功能实现没任何卵用,但会给枯燥的编程生活添加一点乐趣。


以下是官方文档的说明: docs.spring.io/spring-boot…


另外你还需要ASCII艺术字生成工具: tools.kalvinbg.cn/txt/ascii


效果如下:


   _ _                   _                     _                 _       
(_|_)_ __ __ _ __| | ___ _ __ __ _ | |__ ___ ___ | |_ ___
| | | '_ \ / _` | / _` |/ _ \| '_ \ / _` | | '_ \ / _ \ / _ \| __/ __|
| | | | | | (_| | | (_| | (_) | | | | (_| | | |_) | (_) | (_) | |_\__ \
_/ |_|_| |_|\__, | \__,_|\___/|_| |_|\__, | |_.__/ \___/ \___/ \__|___/
|__/ |___/ |___/

多用Java语法糖


编程语言中java的语法是相对繁琐的,用过golang的或scala的人感觉特别明显。java提供了10多种语法糖,写代码常使用语法糖,给人一种 “这哥们java用得通透” 的感觉。


举例:try-with-resource语法,当一个外部资源的句柄对象实现了AutoCloseable接口,JDK7中便可以利用try-with-resource语法更优雅的关闭资源,消除板式代码。


try (FileInputStream inputStream = new FileInputStream(new File("test"))) {
System.out.println(inputStream.read());
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}

利用链式编程


链式编程,也叫级联式编程,调用对象的函数时返回一个this对象指向对象本身,达到链式效果,可以级联调用。链式编程的优点是:编程性强、可读性强、代码简洁。


举例:假如觉得官方提供的容器不够方便,可以自定义,代码如下,但更建议使用开源的经过验证的类库如guava包中的工具类


/**
链式map
*/

public class ChainMap<K,V> {
private Map<K,V> innerMap = new HashMap<>();
public V get(K key) {
return innerMap.get(key);
}

public ChainMap<K,V> chainPut(K key, V value) {
innerMap.put(key, value);
return this;
}

public static void main(String[] args) {
ChainMap<String,Object> chainMap = new ChainMap<>();
chainMap.chainPut("a","1")
.chainPut("b","2")
.chainPut("c","3");
}
}

未完,待续,欢迎评论区补充


作者:京东云开发者
来源:juejin.cn/post/7197604280705908793
收起阅读 »

DDD学习与感悟——总是觉得自己在CRUD怎么办?

一、DDD是什么? DDD全名叫做Dominos drives Design;领域驱动设计。再说的通俗一点就是:通过领域建模的方式来实现软件设计。 问题来了:什么是软件设计?为什么要进行软件设计? 软件开发最主要的目的就是:解决一个问题(业务)而产生的一个交付...
继续阅读 »

一、DDD是什么?


DDD全名叫做Dominos drives Design;领域驱动设计。再说的通俗一点就是:通过领域建模的方式来实现软件设计。


问题来了:什么是软件设计?为什么要进行软件设计?


软件开发最主要的目的就是:解决一个问题(业务)而产生的一个交付物(系统)。而软件设计旨在高效的实现复杂项目软件。也就是说软件设计是从业务到系统之间的桥梁。


而DDD则是在复杂业务场景下一种更高效更合理的软件设计思维方式和方法论。


二、以前的软件设计思维是什么?


绝大部分从事软件开发的人,不管是在学校还是刚开始工作,都是从ER图开始。即直接通过业务设计数据库模型和数据关联关系。这种思维根深蒂固的印在了这些人的头脑里(包括我自己)。因此在软件设计过程中习惯性的直接将业务转化为数据模型,面向数据开发。也就是我们所说的CRUD。我们有时候也会看到一些博客看到或者听到一些同事在说:这个业务有什么难的,不就是CRUD么?


不可否认的是,在软件生命周期初期,通过CRUD这种方式我们可以快速的实现业务规则,交付项目。然而一个系统的生命周期是很长的并且维护阶段的生命周期占绝大部分比例。
随着业务的发展,业务规则越来越复杂,通过CRUD这种粗暴方式,让工程代码越来越复杂,通常一个方法可能会出现几百甚至上千行代码,各种胶水代码和业务逻辑混合在一起,导致很难理解。



这种系统交接给另一个同学或者新进来的同学后,可能需要花费很长的时间才能理解这个方法,原因就是因为这种胶水代码淹没了业务核心规则。所以在现实场景中,我们经常会听到,上一个开发是SB,或者自嘲自己是在屎山上面继续堆屎。



三、DDD思想下的软件设计


DDD的思想是基于领域模型来实现软件设计。那么,什么是领域模型?领域模型怎么得来呢?


DDD思想,将软件的复杂程度提前到了设计阶段。基于DDD思想,我们的设计方式完全变了。


统一语言


首先,将业务方、领域专家以及相关的产研人员都聚拢在一起,共同探讨出业务场景和要解决的问题,统一语言。来确保所有人对于业务的理解都是一致的。



这里的统一语言不是指某种具体的技术语言,而是一种业务规则语言。所有人必须要能够理解这种统一语言。



战略设计


其次,我们根据待解决的问题空间,进行战略设计。所谓的战略设计就是根据问题空间在宏观层面识别出限界上下文。比如说一个电商业务,我们需要交付一个电商系统,根据电商业务的特点,需要划分出用户、商品、订单、仓储等限界上下文,每一个限界上下文都是一个独立的业务单元,具有完整的业务规则。


识别领域模型


然后,再分别针对上下文内的业务领域进行建模,得到领域模型。在DDD思想中,领域模型中通常包含实体、值对象、事件、领域服务等概念。我们可以通过“事件风暴”的方式来识别出这些概念。



注意,“事件风暴”和“头脑风暴”是有区别的。“头脑风暴”的主要目的是通过发散思维进行创新,而“事件风暴”是DDD中的概念,其主要目的是所有人一起根据统一语言和业务规则识别出事件。再根据事件识别出实体、值对象、领域服务、指令、业务流等领域模型中的概念。




所谓事件指的是已经发生了的事情。比如用户下了一个订单、用户取消了订单、用户支付了订单等



根据事件,我们可以识别出实体,比如上面这个例子中的订单实体,以及指令:取消、支付、下单等。


程序设计


识别出领域模型之后,我们就可以根据领域模型来指导我们进行程序设计了。这里的程序设计包括业务架构、数据架构、核心业务流程、系统架构、部署架构等。需要注意的是,在进行程序设计时,我们依然要遵循DDD中的设计规范。否则很容易走偏方向。


编写代码


有了完整的程序设计之后,我们就可以进行实际的工程搭建以及代码编写了。


这个阶段需要注意的是,我们需要遵循DDD思想中的架构设计和代码设计。实际上这个阶段也是非常困难的。因为基于DDD思想下的工程架构和我们传统的工程架构不一样。



基于DDD思想下,编码过程中我们经常会遇到的一个问题是:这个代码应该放在哪里合适。



工程结构


在DDD中,标准的工程结构分为4层。用户接口层、应用层、领域层和基础设施层。


截屏2023-06-22 18.00.33.png

DDD中,构建软件结构思维有六边形架构、CQRS架构等,它们是一种思想,是从逻辑层面对工程结构进行划分,而我们熟知的SOA架构以及微服务架构是从物理逻辑层面对工程结构进行划分,它们有着本质的区别,但是目标都是一样的:构建可维护、可扩展、可测试的软件系统。


代码编写


在DDD中,最为复杂的便是领域层,所有的业务逻辑和规则都在这里实现。因此我们经常会遇到一个问题就是代码应该放在哪里。


在具体落地过程中会遇到这些问题,解决这些问题没有银弹,因为不同的业务有不同的处理方式,这个时候我们需要与领域专家们讨论,得出大家都满意的处理方案。


代码重构


没有不变的业务。因此我们需要结合业务的发展而不断迭代更新我们的领域模型,通过重构的方式来挖掘隐形概念,再根据这些隐形概念去不断的调整我们的战略设计以及领域模型。使得整个软件系统的发展也是螺旋式迭代更新的过程。


通过以上的介绍,我们实现DDD的过程如下:


截屏2023-06-22 18.14.26.png


四、总结


通过对于DDD的理解,其实不难发现,程序员的工作重心变了,程序员其实不是在编写代码,而是在不断的摸索业务领域知识,尤其是复杂业务。


所以如果你总是觉得自己在CRUD,有可能不是你做的业务没价值,而是自己对于业务的理解还不够深;如果你总是沉迷于代码编写,可能你的发展空间就会受限了。


作者:浪漫先生
来源:juejin.cn/post/7299741943441457192
收起阅读 »

没用过微服务?别慌,丐版架构图,让你轻松拿捏面试官

大家好,我是哪吒。 很多人都说现在是云原生、大模型的时代,微服务已经过时了,但现实的是,很多人开发多年,都没有在实际的开发中用过微服务,更别提搭建微服务框架和技术选型了。 面试的时候都会问,怎么办? 今天分享一张微服务的丐版架构图,让你可以和面试官掰扯掰扯~ ...
继续阅读 »

大家好,我是哪吒。


很多人都说现在是云原生、大模型的时代,微服务已经过时了,但现实的是,很多人开发多年,都没有在实际的开发中用过微服务,更别提搭建微服务框架和技术选型了。


面试的时候都会问,怎么办?


今天分享一张微服务的丐版架构图,让你可以和面试官掰扯掰扯~


脑中有图,口若悬河,一套组合拳下来,面试官只能拍案叫好,大呼快哉,HR更是惊呼,我勒个乖乖,完全听不懂。


话不多说,直接上图。



由此可见,Spring Cloud微服务架构是由多个组件一起组成的,各个组件的交互流程如下。



  1. 浏览器通过查询DNS服务器,获取可用的服务实例的网络位置信息,从而实现服务的自动发现和动态更新;

  2. 通过CDN获取静态资源,提高访问速度,解决跨地域请求速度慢的问题;

  3. 通过LVS负载均衡器,实现负载均衡和网络协议;

  4. 通过Nginx反向代理服务器,将请求转发到gateway做路由转发和安全验证​;

  5. 访问注册中心和​配置中心Nacos,获取后端服务和配置项;

  6. 通过Sentinel进行限流;

  7. 通过Redis进行缓存服务、会话管理、分布式锁控制;

  8. 通过Elasticsearch进行全文搜索,存储日志,配合Kibana,对ES中的数据进行实时的可视化分析​。


一、域名系统DNS


在微服务中,域名系统DNS的作用主要是进行服务发现和负载均衡。



  1. 每个微服务实例在启动时,将自己的IP地址和端口号等信息注册到DNS服务器,浏览器通过查询DNS服务器,获取可用的服务实例的网络位置信息,从而实现服务的自动发现和动态更新。

  2. DNS服务器可以根据一定的策略,比如轮询、随机等,将请求分发到不同的负载均衡器LVS上,提高系统的并发处理能力和容错性。


二、LVS(Linux Virtual Server),Linux虚拟服务器


LVS是一个开源的负载均衡软件,基于Linux操作系统实现。它在Linux内核中实现负载均衡的功能,通过运行在用户空间的用户进程实现负载均衡的策略。



  1. LVS支持多种负载均衡算法,例如轮询、随机、加权轮询、加权随机等。

  2. LVS支持多种网络协议,例如TCP、HTTP、HTTPS,可以满足不同应用的需求。

  3. LVS具有高可用和可扩展性。它支持主从备份和冗余配置,当主服务器出现故障时,备份服务器可以自动接管负载,确保服务的连续性。此外,LVS还支持动态添加和删除服务器节点,方便管理员进行扩容和缩容的操作。


三、CDN静态资源


CDN静态资源图片、视频、JavaScript文件、CSS文件、静态HTML文件等。这些静态资源的特点是读请求量极大,对访问速度的要求很高,并占据了很高的宽带。如果处理不当,可能导致访问速度慢,宽带被占满,进而影响动态请求的处理。


CDN的作用是将这些静态资源分发到多个地理位置的机房的服务器上。让用户就近选择访问,提高访问速度,解决跨地域请求速度慢的问题。


四、Nginx反向代理服务器


1、Nginx的主要作用体现在以下几个方面:



  1. 反向代理,Nginx可以作为反向代理服务器,接收来自客户端的请求,然后将请求转发到后端的微服务实例。

  2. 负载均衡,Nginx可以根据配置,将请求分发到微服务不同的实例上,实现负载均衡。

  3. 服务路由,Nginx可以根据不同的路径规则,将请求路由到不同的微服务上。

  4. 静态资源服务,Nginx可以提供静态资源服务,如图片、视频、JavaScript文件、CSS文件、HTML静态文件等,减轻后端服务的压力,提高系统的响应速度和性能。


2、Nginx静态资源服务和CDN静态资源服务,如何选择?


在选择Nginx静态资源服务和CDN静态资源服务时,可以根据以下几个因素进行权衡和选择:



  1. 性能和速度:CDN静态资源服务通常具有更广泛的分布式节点和缓存机制,可以更快地响应用户的请求,并减少传输距离和网络拥塞。如果静态资源的加载速度和性能是首要考虑因素,CDN可能是更好的选择。

  2. 控制和自定义能力:Nginx静态资源服务提供更高的灵活性和控制能力,可以根据具体需求进行定制和配置。如果需要更精细的控制和自定义能力,或者在特定的网络环境下进行部署,Nginx可能更适合。

  3. 成本和预算:CDN静态资源服务通常需要支付额外的费用,而Nginx静态资源服务可以自行搭建和部署,成本相对较低。在考虑选择时,需要综合考虑成本和预算的因素。

  4. 内容分发和全球覆盖:如果静态资源需要分发到全球各地的用户,CDN静态资源服务的分布式节点可以更好地满足这个需求,提供更广泛的内容分发和全球覆盖。


选择Nginx静态资源服务还是CDN静态资源服务取决于具体的需求和场景。如果追求更好的性能和全球覆盖,可以选择CDN静态资源服务;如果更需要控制和自定义能力,且对性能要求不是特别高,可以选择Nginx静态资源服务。


五、Gateway网关


在微服务架构中,Gateway的作用如下:



  1. 统一入口:Gateway作为整个微服务架构的统一入口,所有的请求都会经过Gateway,这样做可以隐藏内部微服务的细节,降低后台服务受攻击的概率;

  2. 路由和转发:Gateway根据请求的路径、参数等信息,将请求路由到相应的微服务实例。这样可以让服务解耦,让各个微服务可以独立的开发、测试、部署;

  3. 安全和认证:Gateway通常集成了身份验证和权限验证的功能,确保只有经过验证的请求才能访问微服务。Gateway还具备防爬虫、限流、熔断的功能;

  4. 协议转换:由于微服务架构中可以使用不同的技术和协议,Gateway可以作为协议转换中心,实现不同协议之间的转换和兼容性;

  5. 日志和监控,Gateway可以记录所有的请求和响应日志,为后续的故障排查、性能分析、安全审计提供数据支持。Gateway还集成了监控和报警功能:实时反馈系统的运行状态;

  6. 服务聚合:在某些场景中,Gateway可以将来自多个微服务的数据进行聚合,然后一次性返回给客户端,减少客户端和微服务之间的交互次数,提高系统性能;


六、注册中心Nacos


在微服务架构中,Nacos的作用主要体现在注册中心、配置中心、服务健康检查等方面。



  1. 注册中心:Nacos支持基于DNS和RPC的服务发现,微服务可以将接口服务注册到Nacos中,客户端通过nacos查找和调用这些服务实例。

  2. 配置中心:Nacos提供了动态配置服务,可以动态的修改配置中心中的配置项,不需要重启后台服务,即可完成配置的修改和发布,提高了系统的灵活性和可维护性。

  3. 服务健康检查:Nacos提供了一系列的服务治理功能,比如服务健康检查、负载均衡、容错处理等。服务健康检查可以阻止向不健康的主机或服务实例发送请求,保证了服务的稳定性和可靠性。负载均衡可以根据一定的策略,将请求分发到不同的服务实例中,提高系统的并发处理能力和性能。


七、Redis缓存


1、在微服务架构中,Redis的作用主要体现在以下几个方面:



  1. 缓存服务:Redis可以作为高速缓存服务器,将常用的数据存储在内存中,提高数据访问速度和响应时间,减轻数据库的访问压力,并加速后台数据的查询;

  2. 会话管理:Redis可以存储会话信息,并实现分布式会话管理。这使会话信息可以在多个服务之间共享和访问,提供一致的用户体验;

  3. 分布式锁:Redis提供了分布式锁机制,可以确保微服务中多个节点对共享资源的访问的合理性和有序性,避免竞态条件和资源冲突;

  4. 消息队列:Redis支持发布订阅模式和消息队列模式,可以作为消息中间件使用。微服务之间可以通过Redis实现异步通信,实现解耦和高可用性;


2、竞态条件


竞态条件是指在同一个程序的多线程访问同一个资源的情况下,如果对资源的访问顺序敏感,就存在竞态条件。


竞态条件可能会导致执行结果出现各种问题,例如计算机死机、出现非法操作提示并结束程序、错误的读取旧的数据或错误的写入新数据。在串行的内存和存储访问能防止这种情况,当读写命令同时发生的时候,默认是先执行读操作的。


竞态条件也可能在网络中出现,当两个用户同时试图访问同一个可用信道的时候就会发生,系统同意访问之前没有计算机能得到信道被占用的提示。统计上说这种情况通常是发生在有相当长的延迟时间的网络里,比如使用地球同步卫星。


为了防止这种竞态条件发生,需要制定优先级列表,比如用户的用户名在字母表里排列靠前可以得到相对较高的优先级。黑客可以利用竞态条件这一弱点来赢得非法访问网络的权利。


竞态条件是由于多个线程或多个进程同时访问共享资源而引发的问题,它可能会导致不可预测的结果和不一致的状态。解决竞态条件的方法包括使用锁、同步机制、优先级列表等。


3、Redis会话管理如何实现?


Redis会话管理的一般实现步骤:



  1. 会话创建:当用户首次访问应用时,可以在Redis中创建一个新的会话,会话可以是一个具有唯一标识符的数据结构,例如哈希表或字符串;

  2. 会话信息存储:将会话信息关联到会话ID存储到Redis中,会话信息可以包括用户身份、登录状态、权限等。

  3. 会话过期时间设置:为会话设置过期时间,以确保会话在一定时间后自动失效。Redis提供了设置键值对过期时间的机制,可以通过EXPIRE命令为会话设置过期时间;

  4. 会话访问和更新:在每次用户访问应用时,通过会话ID获取相应的会话信息,并对其进行验证和更新。如果会话已过期,可以要求用户重新登录;

  5. 会话销毁:当用户主动退出或会话到期后,需要销毁会话,通过删除Redis中存储的会话信息即可。


八、Elasticsearch全文搜索引擎


在微服务架构中,Elasticsearch全文搜索引擎的应用主要体现在如下几个方面:



  1. 全文搜索引擎:ES是一个分布式的全文搜索引擎,它可以对海量的数据进行实时的全文搜索,返回与关键词相关的结果;

  2. 分布式存储:ES提供了分布式的实时文件存储功能,每个字段都可以被索引并可被搜索,这使得数据在ES中的存储和查询都非常高效;

  3. 数据分析:配合Kibana,对ES中的数据进行实时的可视化分析,为数据决策提供数据支持;

  4. 日志和监控:ES可以作为日志和监控数据的存储和分析平台。通过收集系统的日志信息,存入ES,可以实现实时的日志查询、分析、告警、展示;

  5. 扩展性:ES具有很好的扩展性,可以水平扩展到数百台服务器,处理PB级别的数据,使得ES能够应对海量数据的挑战。


九、感觉Redis和Elasticsearch很像?微服务中Redis和Elasticsearch的区别



  1. 数据存储和查询方式:Redis是一种基于键值对的存储系统,它提供高性能的读写操作,适用于存储结构简单、查询条件同样简单的应用场景。而Elasticsearch是一个分布式搜索和分析引擎,适用于全文搜索、数据分析等复杂场景,能够处理更复杂的查询需求;

  2. 数据结构与处理能力:Redis支持丰富的数据结构,如字符串、哈希、列表、集合等,并提供了原子性的操作,适用于实现缓存、消息队列、计数器等功能。而Elasticsearch则是基于倒排索引的数据结构,提供了强大的搜索和分析能力。但相对于Redis,Elasticsearch的写入效率较低;

  3. 实时性和一致性:Redis提供了很高的实时性,Redis将数据存储到内存中,能够很快的进行读写操作;而Elasticsearch是一个近实时的搜索平台,实时性不如Redis;

  4. 扩展性:Redis是通过增加Redis实例的形式实现扩展,对非常大的数据集可能要进行数据分片;而Elasticsearch具有水平扩展的能力,可以通过添加更多的节点来提高系统的处理能力,适用于大量数据的场景;



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

token 和 cookie 还在傻傻分不清?

web
token 概念和作用 Token是一种用于身份验证和授权的令牌。在Web应用程序中,当用户进行登录或授权时,服务器会生成一个Token并将其发送给客户端。客户端在后续的请求中将Token作为身份凭证携带,以证明自己的身份。 Token可以是一个字符串,通常是...
继续阅读 »

token 概念和作用


Token是一种用于身份验证和授权的令牌。在Web应用程序中,当用户进行登录或授权时,服务器会生成一个Token并将其发送给客户端。客户端在后续的请求中将Token作为身份凭证携带,以证明自己的身份。


Token可以是一个字符串,通常是经过加密和签名的,以确保其安全性和完整性。服务器收到Token后,会对其进行解析和验证,以验证用户的身份并授权对特定资源的访问权限。


Token的使用具有以下特点:



  • 无状态:服务器不需要在数据库中存储会话信息,所有必要的信息都包含在Token中。

  • 可扩展性:Token可以存储更多的用户信息,甚至可以包含自定义的数据。

  • 安全性:Token可以使用加密算法进行签名,以确保数据的完整性和安全性。

  • 跨域支持:Token可以在跨域请求中通过在请求头中添加Authorization字段进行传递。


Token在前后端分离的架构中广泛应用,特别是在RESTful API的身份验证中常见。它比传统的基于Cookie的会话管理更灵活,并且适用于各种不同的客户端,例如Web、移动应用和第三方接入等。


cookie 和 token 的关系


Cookie和Token是两种不同的概念,但它们在身份验证和授权方面可以有关联。


Cookie是服务器在HTTP响应中通过Set-Cookie标头发送给客户端的一小段数据。客户端浏览器将Cookie保存在本地,然后在每次对该服务器的后续请求中将Cookie作为HTTP请求的一部分发送回服务器。Cookie通常用于在客户端和服务器之间维护会话状态,以及存储用户相关的信息。


Token是一种用于身份验证和授权的令牌。它是一个包含用户身份信息的字符串,通常是服务器生成并返回给客户端。客户端在后续的请求中将Token作为身份凭证发送给服务器,服务器通过验证Token的有效性来确认用户的身份和权限。


Cookie和Token可以结合使用来实现身份验证和授权机制。服务器可以将Token存储在Cookie中,然后发送给客户端保存。客户端在后续的请求中将Token作为Cookie发送给服务器。服务器通过验证Token的有效性来判断用户的身份和权限。这种方式称为基于Cookie的身份验证。另外,也可以将Token直接存储在请求的标头中,而不是在Cookie中进行传输,这种方式称为基于Token的身份验证。


需要注意的是,Token相对于Cookie来说更加灵活和安全,可以实现跨域身份验证,以及客户端和服务器的完全分离。而Cookie则受到一些限制,如跨域访问限制,以及容易受到XSS和CSRF攻击等。因此,在实现身份验证和授权机制时,可以选择使用Token替代或辅助Cookie。


token 一般在客户端存在哪儿


Token一般在客户端存在以下几个地方:



  • Cookie:Token可以存储在客户端的Cookie中。服务器在响应请求时,可以将Token作为一个Cookie发送给客户端,客户端在后续的请求中会自动将Token包含在请求的Cookie中发送给服务器。

  • Local Storage/Session Storage:Token也可以存储在客户端的Local Storage或Session Storage中。这些是HTML5提供的客户端存储机制,可以在浏览器中长期保存数据。

  • Web Storage API:除了Local Storage和Session Storage,Token也可以使用Web Storage API中的其他存储机制,比如IndexedDB、WebSQL等。

  • 请求头:Token也可以包含在客户端发送的请求头中,一般是在Authorization头中携带Token。


需要注意的是,无论将Token存储在哪个地方,都需要采取相应的安全措施,如HTTPS传输、加密存储等,以保护Token的安全性。


存放在 cookie 就安全了吗?


存放在Cookie中相对来说是比较常见的做法,但是并不是最安全的方式。存放在Cookie中的Token可能存在以下安全风险:



  • 跨站脚本攻击(XSS) :如果网站存在XSS漏洞,攻击者可以通过注入恶意脚本来获取用户的Cookie信息,包括Token。攻击者可以利用Token冒充用户进行恶意操作。

  • 跨站请求伪造(CSRF) :攻击者可以利用CSRF漏洞,诱使用户在已经登录的情况下访问恶意网站,该网站可能利用用户的Token发起伪造的请求,从而执行未经授权的操作。

  • 不可控的访问权限:将Token存放在Cookie中,意味着浏览器在每次请求中都会自动携带该Token。如果用户在使用公共计算机或共享设备时忘记退出登录,那么其他人可以通过使用同一个浏览器来访问用户的账户。


为了增加Token的安全性,可以采取以下措施:



  • 使用HttpOnly标识:将Cookie设置为HttpOnly,可以防止XSS攻击者通过脚本访问Cookie。

  • 使用Secure标识:将Cookie设置为Secure,只能在通过HTTPS协议传输时发送给服务器,避免明文传输。

  • 设置Token的过期时间:可以设置Token的过期时间,使得Token在一定时间后失效,减少被滥用的风险。

  • 使用其他存储方式:考虑将Token存储在其他地方,如Local Storage或Session Storage,并采取加密等额外的安全措施保护Token的安全性。


token 身份验证代码实现


服务端使用 JWT 进行 token 签名和下发


可以参考使用这个库 node-jsonwebtoken


后端代码示例 (Node.js / Express),代码简单实现如下:


const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();

const secretKey = 'mysecretkey';

app.use(express.json());

app.post('/api/login', (req, res) => {
// 从请求中获取用户名和密码
const { username, password } = req.body;

// 验证用户名和密码
if (username === 'admin' && password === 'password') {
// 用户名和密码验证成功,生成Token并返回给前端
const token = jwt.sign({ username }, secretKey, { expiresIn: '1h' });
res.json({ token });
} else {
// 用户名和密码验证失败,返回错误信息给前端
res.status(401).json({ error: 'Authentication failed' });
}
});

app.get('/api/protected', verifyToken, (req, res) => {
// Token验证成功,可以访问受保护的路由
res.json({ message: 'Protected API endpoint' });
});

function verifyToken(req, res, next) {
const token = req.headers.authorization;

if (!token) {
return res.status(401).json({ error: 'Missing token' });
}

// 验证Token
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).json({ error: 'Invalid token' });
}

// Token验证通过,将解码后的数据存储在请求中,以便后续使用
req.user = decoded;
next();
});
}

app.listen(3000, () => {
console.log('Server is running on port 3000');
});

在上述后端代码中,我们使用了jsonwebtoken库来生成和验证Token。在登录路由/api/login中,验证用户名和密码成功后,生成一个Token并返回给前端。在受保护的路由/api/protected中,我们使用verifyToken中间件来验证请求中的Token,只有通过验证的请求才能访问该路由。


当然实际开发中, 可以使用中间件来进行 jwt 的验证, 下发方式也因人而异, 可以放在 cookie 中, 也可以作为 response 返回均可, 上述代码仅作参考;


前端代码实现示范如下


前端获取到了Token后将其存储在Cookie中,并在后续请求中自动发送给后端,可以通过以下方式实现前端代码:


import React, { useState, useEffect } from 'react';

function App() {
const [token, setToken] = useState('');

useEffect(() => {
// 检查本地是否有保存的Token
const savedToken = localStorage.getItem('token');
if (savedToken) {
setToken(savedToken);
}
}, []);

const handleLogin = async () => {
// 发送请求到后端进行登录验证
const response = await fetch('http://example.com/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: 'admin', password: 'password' }),
});

if (response.ok) {
// 登录成功,获取Token并保存到前端
const data = await response.json();
setToken(data.token);
// 保存Token到本地
localStorage.setItem('token', data.token);
}
};

const handleLogout = () => {
// 清除保存的Token
setToken('');
// 清除本地保存的Token
localStorage.removeItem('token');
};

return (
<div>
{token ? (
<div>
<p>Token: {token}</p>
<button onClick={handleLogout}>Logout</button>
</div>
) : (
<button onClick={handleLogin}>Login</button>
)}
</div>

);
}

export default App;

作者:晴小篆
来源:juejin.cn/post/7299731897626443785
收起阅读 »

3 个技巧,让你像技术专家一样解决编码问题

「我应该如何提高解决问题的能力?尽管我掌握了 JavaScript,却无法解决实际问题或理解复杂的 JavaScript 代码。」 经常有年轻的开发者朋友问我类似的问题。对开发者来说,解决问题非常重要。编写优秀的代码是一门创造性的艺术,而要成为出色的开发者,就...
继续阅读 »

「我应该如何提高解决问题的能力?尽管我掌握了 JavaScript,却无法解决实际问题或理解复杂的 JavaScript 代码。」


经常有年轻的开发者朋友问我类似的问题。对开发者来说,解决问题非常重要。编写优秀的代码是一门创造性的艺术,而要成为出色的开发者,就必须成为富有创造力的能够解决问题的人。



我发现新手程序员犯的最大的错误是专注于学习语法,而不是学习如何解决问题。—— V. Anton Spraul



尽管我观察到,解决问题的技能需要时间和经验的积累,但我坚信掌握它并不需要很多年;只要勇敢地直面问题,就会有所提高。我曾与许多初级开发人员一起工作,年轻人们也比他们的老伙计更善于解决问题。


本文将详细讲解三个技巧,让开发者可以像技术专家一样解决问题。重头戏开始之前,我们先来看看技术专家和技术小白在解决问题方面有哪些不同。


专家思维 vs 新手思维



大多数人会回避或胡乱处理问题。优秀的思考者和领导者则会主动寻找问题,他们也拥有能够更好地解决问题的方法。—— Michael Simmons



Robert J. Sternberg 教授根据美国心理学家 Herbert A. Simon、Robert Glaser 和 Micheline Chi 等人的研究,揭示了技术专家解决问题比技术小白更有效的秘密。



Arnaud Chevallier 在 Work forward in solving problems, not backward 一文中犀利写道,「逆向工作法是一种从假设出发的方法。如果想要提高利润率,逆向工作法会指引你去寻找增加收入的办法,因为增加收入可以带来更高的利润率。那减少成本呢?难道在确定最终的解决方案之前,我们不应该先全面地了解各种可以实现目标的办法吗?」


可以看到,技术专家通常会花大量的时间寻找、明确和定义问题,并且使用正向工作法解决问题,同时密切关注问题解决的过程。下面就跟大家分享,技术专家们常用的问题解决技巧。


三个专家级的问题解决技巧


01 问题十二连 The 12 What Elses


提问题听上去没什么难度,但要找到正确的、缺失的问题并准确地描述出来却不容易。Lenedra J. Carroll 介绍的「问题十二连 The 12 What Elses」可以有效帮助我们摆脱这个苦恼。


在头脑风暴时,先提出一个问题,并生成 12 个答案;然后选取其中一个答案转化为下一个问题,再生成 12 个答案。不断重复此过程,直到获得一个明确的解决方案。


通过连续地提问,我们会得到一个「问题回答地图」,它对假设的测试和结构化解决复杂问题很有帮助。


使用「问题回答地图」测试假设


提问和追问是如何将我们往正确方向上引导的?下面两个例子可能会给你答案。




02 根本原因分析法 Root Cause Analysis


我们经常在多次解决失败后,才发现问题的情况跟预期有所不同,所以在开始解决问题之前,就要先了解其根本原因是什么。


只有消除错误的选择,才能更好地定义问题并找到有效的解决方案。根本原因分析法有助于避免在错误的方向上浪费时间和精力。


根本原因分析的几种方法


根本原因分析法的示例


当需要修复 Bug 时,开发者可以使用以下任意方式,进行根本原因分析:



  • 确定问题在哪个环境出现,并尝试在相同和不同的环境中重现它,以掌握更好的理解。

  • 如果与 Web 性能有关,可以分析捆绑文件。

  • 进行单元测试和集成测试。

  • 进行日志文件分析。

  • 进行交互式调试。


03 使用多元思维 Spectrum Thinking


二元思维认为事情的状态是非黑即白的,只有互相对立的两种可能。有些时候它是正确的,但其他时候,它可能是一种错误的简化。


二元思维


与二元思维对应的是多元思维,也可以称作频谱思维(Spectrum Thinking)。它会考虑更多选择、更多替代方案和可能性,比如「两者共存」「介于两者之间」「其他的可能性」或「二者皆否」等。


频谱思维


通过培养多元思维,开发者可以有效提升创造力;你会惊讶地发现,修复 Bug、解决冲突、设计/执行客户需求的实现方案等居然会有这么多种解决方案和方式。


以展示信息详情为例,二元思维认为,信息详情要么通过弹窗展示,要么跳转到一个带返回箭头的新页面进行展示。




多元思维认为还有其他可能性,比如新增 Tab 页直接查看和更新信息,无需关闭当前列表页面。



多元思维还可能认为,可以提供一个支持三种布局的动态模板,让用户自主选择要用以上哪种方式。


二元思维和多元思维各有利弊,在实际工作中可以配合使用。


写在最后


解决问题能力是一个超出软件开发范畴的话题,它高度取决于我们的心态和态度。要想培养和提高解决(复杂)问题的能力,首先要对问题和挑战充满好奇心,而不是感到沮丧。


就像 Tim Hicks 说的那样,「问题就像赛车道上的弯道。处理得好,便可以在接下来的直道中状态满分;如果过弯太快,很可能会引发侧翻,影响后续赛程。」


(原文作者:Rakia Ben Sassi)




了解更多开发者提效、研发效能管理、前沿技术等消息,欢迎关注 LigaAI@稀土掘金


LigaAI 助力开发者扬帆远航,欢迎体验我们的产品,期待与你一路同行!


作者:LigaAI
来源:juejin.cn/post/7243592123803009083
收起阅读 »

看完还学不会正则,快来锤我!

web
前言 各位同仁在表单验证规则,或者在验证数据的时候,是不是经常都是xxx.xxxx,然后对数据处理来处理去,最后进行后续操作……,不仅费时费力,而且消耗精神,于是乎: ps(我):要不要用用正则,但是想不起来怎么写啊,百度搜一下吧,chatGpt搜一下,贴上去...
继续阅读 »

前言


各位同仁在表单验证规则,或者在验证数据的时候,是不是经常都是xxx.xxxx,然后对数据处理来处理去,最后进行后续操作……,不仅费时费力,而且消耗精神,于是乎:


ps(我):要不要用用正则,但是想不起来怎么写啊,百度搜一下吧,chatGpt搜一下,贴上去


我:欧哟?刚刚好啊,效果还怪好的嘞,哈哈哈,天助我也!


这还是好的情况,刚刚好符合,要是不好……


ps(我):服了,这正则不得行,chatGpt搜的错的,自己写吧又不会,找吧又没有……
我:乖乖写if else吧


又是啃哧啃哧,耗时耗力……可能大家伙都是差不多的哈,谁也别说谁(大佬除外)。本着多一事不如少一事的原则,直接学习一波!


js正则表达式


正则(正则表达式)是一种用于描述文本模式的工具,它通过使用特定的符号和语法规则来定义一个字符串的模式。正则表达式通常由各种字符和特殊元字符组成,用于进行字符串匹配、查找、替换和验证等操作。


使用正则表达式,可以执行以下操作:



  1. 模式匹配:正则表达式可以用于查找和匹配具有特定模式的字符串。通过定义一个模式,可以搜索和识别符合该模式的字符串。

  2. 字符串查找与替换:正则表达式可以用于在文本中进行字符串查找和替换。通过指定要查找或替换的模式,可以对目标字符串进行修改和处理。

  3. 数据验证:正则表达式可以用于验证用户输入或其他数据的格式和有效性。例如,可以使用正则表达式验证电子邮件地址、电话号码、日期等的格式是否符合预期。

  4. 文本提取:在文本处理中,可以使用正则表达式从大量文本数据中提取出所需的信息。例如,可以使用正则表达式从日志文件中提取特定的时间戳或关键字。

  5. 数据清洗与转换:使用正则表达式,可以进行文本数据的清洗和转换。可以根据模式匹配和替换规则,删除非法字符、规范化日期格式、提取关键信息等。


正则表达式提供了一种强大和灵活的文本处理工具,它被广泛应用于编程语言、文本编辑器、数据处理工具等各种软件中。虽然正则表达式的语法可能会显得复杂,但掌握它可以极大地提高对文本模式处理的能力。


应用


正则表达式在计算机科学和文本处理中具有广泛的应用。以下是一些常见的正则表达式应用:



  • 模式匹配:正则表达式可用于检测字符串是否与特定模式匹配。例如,可以使用正则表达式来验证电子邮件地址、检查电话号码的格式、识别日期等。

  • 字符串搜索与替换:正则表达式可以用于在文本中搜索特定的模式,并进行替换或提取。这对于在大量文本中进行批量操作非常有用,如查找和替换文本文件中的特定单词或短语。

  • 表单验证:在前端开发中,可以使用正则表达式验证用户输入的表单数据。例如,验证用户名是否只包含字母和数字、检查密码是否符合指定的复杂度要求等。

  • URL路由:许多Web框架使用正则表达式来解析URL路由和处理动态路由。它们通过正则表达式匹配URL字符串并将其映射到相应的处理程序或控制器。

  • 日志分析:使用正则表达式可以解析和提取日志文件中的有用信息。例如,可以使用正则表达式从服务器日志中提取IP地址、日期时间戳、错误消息等。

  • 数据清洗与转换:正则表达式可用于清洗和转换数据,如从多种格式的文本数据中提取特定字段、规范化日期格式、去除特殊字符等。

  • 编程工具与编辑器:许多编程工具和文本编辑器支持正则表达式搜索和替换功能。这使得开发人员能够更灵活地进行代码重构和批量修改操作。


创建正则


js 中内置了正则表达式对象 RegExp,我们要创建一个正则表达式,可以:



  • 第一种创建方式,接收patternmodifiers两个参数


// 构造函数RegExp
var regex = new RegExp(pattern, modifiers);
// 构造函数创建
var regex = new RegExp('xyz', 'i');
// 等价于 字面量创建
var regex = /xyz/i;

RegExp 构造函数接收两个参数,pattern 描述了表达式的模式,为字符串,modifiers 是正则表达式的修饰符,用于执行区分大小写和全局匹配。



  • 第二种创建方式,参数是一个正则表示式,这时会返回一个原有正则表达式的拷贝。


var regex = new RegExp(/xyz/i);
// 等价于
var regex = /xyz/i;

在构造函数创建时 pattern 是正则字符串,字面量创建时,pattern 是一个类似 /正则规则/ 表达式,是放在双斜杠里的。
modifiers有三个值,分别为


描述
i不区分大小写。在匹配时忽略大小写
g全局匹配。查找所有可能的匹配,而不仅仅是第一个匹配。
s单行匹配。启用单行模式,使点号(.)可以匹配包括换行符在内的任意字符。
u启用完整的 Unicode 匹配支持,会正确处理四个字节的 UTF-16 编码。
m多行匹配。启用多行模式,^ 和 $ 可以匹配行的开始和结束。

例如,使用 ig 修饰符创建一个忽略大小写和全局匹配的正则表达式:


javascript
var pattern = new RegExp("pattern", "ig");

或者使用字面量表示法也可以传递修饰符:


var pattern = /pattern/ig;

正则对象的方法


正则表达式对象通常提供一组方法,用于在字符串上执行不同的操作。以下是一些常见的正则表达式对象的方法:




  1. test(string): 检测指定字符串是否与正则表达式匹配。返回一个布尔值,表示是否找到匹配项。一般开发当中,我们使用这种方法较多。


    const regex = /apple/;
    console.log(regex.test("I love apples")); // true
    console.log(regex.test("I prefer oranges")); // false



  2. exec(string): 在给定字符串中搜索匹配项,并返回一个数组或 null。数组包含有关匹配项的详细信息,如匹配的子字符串、捕获组等。


    const regex = /\d+/;
    console.log(regex.exec("I have 100 apples")); // ["100"]
    console.log(regex.exec("There are no numbers here")); // null



  3. match(regexp): 在字符串中查找与正则表达式匹配的内容,并返回一个数组或 null。类似于 exec() 方法,但是 match() 是在字符串上调用,而不是在正则表达式上调用。


    const string = "I have 100 apples";
    const regex = /\d+/;
    console.log(string.match(regex)); // ["100"]



  4. search(string): 在字符串中搜索与正则表达式匹配的内容,并返回匹配项的索引。如果没有找到匹配项,则返回 -1。


    const string = "I prefer oranges";
    const regex = /oranges/;
    console.log(string.search(regex)); // 8



  5. replace(regexp, replacement): 替换字符串中与正则表达式匹配的部分。可以将匹配项替换为指定的字符串或使用函数进行替换。


    const string = "I like cats and dogs";
    const regex = /cats/;
    const replacement = "birds";
    const newString = string.replace(regex, replacement);
    console.log(newString); // "I like birds and dogs"



  6. split(regexp): 将字符串分割为由正则表达式匹配的子字符串组成的数组。正则表达式定义了分隔符。


    const string = "apple,banana,orange";
    const regex = /,/;
    const parts = string.split(regex);
    console.log(parts); // ["apple", "banana", "orange"]



正则规则


分为基本字符匹配;元字符匹配,如\w;锚点匹配指定匹配发生的位置, 如^ 表示匹配行的开头;量词和限定符, 如*; 分组和捕获();零宽断言:正向肯定断言 (?=...):匹配满足断言条件的位置,但不会消耗字符;


接下来一一进行介绍。


基本字符匹配


匹配字面量字符/ /


如果想在javaScript当中直接匹配java,可以直接在我们的字面量当中写入想要匹配的值,即java直接进行匹配。


正则: /java/


可以匹配的不能匹配的
javascriptJavascript
javajaava

字符组[ ]


如果不仅仅想要匹配java还想要匹配Java,那光光/java/是不够的。这时候还需要用到我们的字符组。


正则:/[Jj]ava/


可以匹配的不能匹配的
javascriptjaava
Javascriptjvav

[]匹配规则当中,目标字符可以匹配中括号里面的任意一个字符即可,转为javaScript语言就是 ||的意思。观察两个目标字符串,java与Java的区别也仅仅是首字母不同,那么只需要兼容开头的大小字母即可。


拓展

若是想匹配java Java JAva,正则需要如何编写?通过观察各个字符当中的差别,即前两个字母的可能性都可能为大小写,便得出前两个位置的匹配使用字符组即可。


正则:/[J][Aa]va/


字符组区间 -


如果说只想匹配前缀为123,后面是二十六个字母当中任何一个的字符怎么办?


这简单,刚刚学完字符组,我直接一手/123[a,b,c,d....]/把二十六个字母全部列一遍,话虽如此,但大可不必!


此处若是可选匹配字母过多的话,可直接使用字符组区间连接


正则: /123[a-zA-Z]/


可以匹配的不能匹配的
123a123
123B12345

同时还可以匹配多个数字,比如我只想匹配[3-9]的数字,那么也可以使用连接符


正则123[3-9][a-zA-Z]


可以匹配的不能匹配的
1233a123a9
1236B123B

字符组取反:[^]


有的时候你可能也不想匹配某些字符,比如只晕小写字母,那么这个时候你可以对你所要匹配的字符组进行取反,那就匹配不到了。


正则:/[^a-z]/


可以匹配的不能匹配的
1233ABCDEabcde
12345678adasd
123adasdadasd


注意: 此处需要全部为小写字母test匹配结果才是false,若字符包含其他的字符,test的匹配结果仍然为true。



const pattern = /[^a-z]/ // 表示的意思为所有字符都不是小写
const string = '123adasd' // 此处还有数字
pattern.test(string) // true

元字符匹配


日常开发当中,元字符单独使用的情况并不多,更多的是跟随后续的量词一块使用,最终形成限定字符格式的正则。


单点 .


. 是一个特殊的元字符,可以用于匹配除了换行符 \n(或其他行终止符,如 \r\n)之外的任意单个字符。


正则:/./


可以匹配的不能匹配的
1\n(换行)
a\r(回车)

数字 \d


\d 可以匹配任意一个数字字符,包括 0 到 9 的数字。


字符 \w


用于匹配字母字符、数字和下划线。


具体来说,\w 匹配以下字符:



  • 小写字母(a-z)

  • 大写字母(A-Z)

  • 数字(0-9)

  • 下划线(_)


空白符 \s


用于匹配空白字符



  • 空格符(Space)

  • 制表符(\t)

  • 换行符(\n)

  • 回车符(\r)

  • 垂直制表符(\v)

  • 换页符(\f)



注意:如果说想要匹配正则当中的匹配规则符号,例如只想匹配单点字符.,则需要使用反斜杠进行转义,即/\./ 任何匹配正则当中具有意义的字符都需要进行转义。



量词


量词用于指定模式重复出现的次数。允许你匹配一定数量的字符或子模式,是正则当中见怪不怪的玩意。与上述字符相互搭配,能获得意想不到的结果。


量词 {}


用于匹配前面的字符或子表达式指定的精确的重复次数。


比如需要匹配重复多个字符,如需要匹配出现两次a的字符串。


正则:/a{2}/


可以匹配的不能匹配的
aaabab
aabbabb

但是我只知道会出现a字符,可能是两到三个呢?这个时候就可以使用区间来表示,囊括出现的次数。


正则:/a{2,3}/


可以匹配的不能匹配的
aaabbbb
aabbabb
aaababab

如果只知道出现一次,但是不清楚具体有几次,便直接可以不写右区间,表示至少出现n次,比如下面的正则就表示至少出现3次a


正则:/a{3,}/


可以匹配的不能匹配的
aaabbbb
baaaaaabb

量词 +


用于匹配前面的字符或子表达式至少一次或多次出现。
实际上,+的表现形式,还可以用{1,}来表示


正则: /a+/ 等价于 /a{1,}/


可以匹配的不能匹配的
abbbb
aabbb

量词 *


用于匹配前面的字符或子表达式出现0次或多次出现。实际上,*的表现形式,也可以用{0,}来表示


正则: /a*/ 等价于 /a{0,}/


可以匹配的不能匹配的
abbbb
aabbb

量词 ?


用于匹配前面的字符或子表达式零次或一次。实际上,*的表现形式,也可以用{0,1}来表示


正则: /a?b/ 等价于 /a{0,1}b/


可以匹配的不能匹配的
babcde
bad


正则表达式的贪婪匹配和非贪婪匹配是用来描述匹配模式时的两种不同行为。
贪婪匹配是指正则表达式尽可能地匹配更长的文本片段。它会尽量多地消耗输入字符串,并尝试匹配满足整个正则表达式模式的最长可能结果,是默认的行为,
反之,非贪婪匹配(也称为懒惰匹配或最小匹配) 则是指正则表达式尽可能地匹配更短的文本片段。它会尽量少地消耗输入字符串,并尝试匹配满足整个正则表达式模式的最短可能结果。
通常非贪婪匹配通过在正则字符串后面加?号来表示。



示例
正则表达式 /a+/,它表示匹配一个或多个连续出现的字符 "a"。


对于字符串 "aaa",贪婪匹配将尽量匹配更长的连续的 "a" 字符串,在这种情况下会匹配整个字符串 "aaa"。


使用非贪婪匹配需要在量词后面添加 ?。正则表达式 /a+?/ 表示非贪婪匹配,将匹配一个或多个连续出现的字符 "a",但只尽量匹配最短的结果。非贪婪匹配将尽量匹配最短的连续的 "a" 字符串。在这个例子中,非贪婪匹配会匹配第一个 "a" 字符,因为它是最短的满足正则表达式模式的子串。


锚点匹配


锚点是正则表达式中的特殊字符,用于匹配字符串的位置而不是具体的字符,可用于指定匹配发生的位置,常用的锚点有^$\b


^ 起始位置


表示匹配行的开头。下面正则表示匹配以a为开头的字符


正则:^a


可以匹配的不能匹配的
ada
abbc

$ 结束位置


表示匹配行的结尾。下面正则表示匹配以a为结尾的字符


正则:a$


可以匹配的不能匹配的
aab
dabc

\b 边界


表示匹配单词边界。下面正则表示匹配独立的单词


正则:/\bapple\b/


可以匹配的不能匹配的
I love applepineapple
applepinapple

\b还有很多其他的应用,比如



  • \b\w+\b:匹配一个或多个连续的单词字符,可以用来分割句子为单词数组。

  • \b\d{4}\b:匹配仅包含4位数字的字符串


在转义\b的时候需要使用\\b


分组和捕获:


分组 ()


括号 ( ):用于将一组模式作为单个单元进行匹配,并将其视为一个分组。


比如,我要匹配以jstsjava后缀的文件
正则:/.*\.(js|ts|java)/


可以匹配的不能匹配的
index.js1.png
1.ts2.jpg
calss.java3.mp3

再比如 正则:/(ab){1,}/,可以匹配一个或出现多个连续的ab,利用分组实现的


可以匹配的不能匹配的
abaa
ababba

捕获组


通过圆括号捕获分组内的内容,可以在后续操作中进行引用。


可能这比较难理解,我们举例说明,比如,我们有1-82-2这种类型的数据,我们可以使用正则的分组将两边的数据包裹,并使用exec进行捕获。分组符号的数据就是把这些想要捕获的数据标记出来。


如果我们想要 () 的分组能力,但是又不想捕获数据,可以使用 (?:) 表达式。可以提高正则表达式的性能和简洁性。


image.png


零宽断言



  1. 正向肯定预查(?=...):表示在当前位置后面,如果满足括号内的表达式,则继续匹配成功。

  2. 正向否定预查(?!...):表示在当前位置后面,如果不满足括号内的表达式,则继续匹配成功。

  3. 反向肯定预查(?<=...):表示在当前位置前面,如果满足括号内的表达式,则继续匹配成功。

  4. 反向否定预查(?<!...):表示在当前位置前面,如果不满足括号内的表达式,则继续匹配成功。



  • /(?=\d)\w+/ 匹配由数字紧随其后的单词字符。
    | 可以匹配的 | 不能匹配的 |
    | --- | --- |
    | 1 | w |
    | 1w | ww |


为什么这里能匹配1呢?1首先同样属于字符,其次还是数字,在断言的时候,不消耗字符,符合数字随其后的规则(本身)



  • /(?<!\d)\w+/ 匹配没有数字紧随在前面的单词字符。(js不支持)



js并不支持反向预查,只支持正向预查。这是因为正向预查在匹配时,可以当前位置后面的内容进行断言判断,如果不符合预期,则无法继续匹配成功。这种类型的预查可以通过回溯来实现。


然而,反向否定预查需要从当前位置回溯到前面的位置进行条件判断,这就使得正则引擎需要逆序地扫描前面的内容,增加了匹配的复杂度。因此,实现反向否定预查的算法相对更为复杂,并且可能导致性能下降。


反向否定预查在某些特定情况下可以被其他模式替代,比如使用捕获组结合后续的处理代码来达到类似的效果。



正则表达式大全



  1. 邮箱验证


/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(.\w{2,3})+$/

^\w+匹配以字符开头,([.-]?\w+)* 部分出现两次,品牌包含一个或多个由-或点.连接的部分,(.\w{2,3})+匹配域名




  1. URL 验证:包括 HTTP 和 HTTPS 协议。


/^(https?://)?[\w-]+(.[\w-]+)+[/#?]?.*$/


  1. 身-份-证号码验证:验证中国大陆身-份-证号码的有效性。


低配:
/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/

高配:
身-份-证号匹配
/^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[1-2]\d|3[0-1])\d{3}[0-9Xx]$/


  • ^[1-9]\d{5}:匹配 6 位行政区划代码、

  • (19|20)\d{2}:年份,匹配以 19 或 20 开头的四位数字、

  • (0[1-9]|1[0-2]):月份,取值范围为 01 到 12、

  • (0[1-9]|[1-2]\d|3[0-1]):日期,取值范围为 01 到 31、

  • \d{3}:顺序码,任意三位数字、

  • [0-9Xx]:校验码,可以是数字或字母 X 或 x、



  1. 数字验证:用于验证一个字符串是否只由数字组成。


`/^\d+$/`


  1. 字母验证:用于验证一个字符串是否只由字母组成。


`/^[a-zA-Z]+$/`


  1. 小数验证:匹配的数字可包含小数点,此处转义了小数点,


/^\d+(\.\d+)?$/


  1. 整数验证(包括负数):用于验证一个字符串是否为整数,可以包含正负号。


`/^[-+]?\d+$/`


  1. IP 地址验证: 用于验证 IPv4 地址的有效性。


/^((25[0-5]|2[0-4]\d|[01]?\d\d?).){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/


  1. 手机号码验证:


低配版本,仅表示11位数字


```
^\d{11}$ 低配版本,11位数字
```

高配版本


```
/^(13[0-9]|14[5|7]|15[0|1|2|3|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$/

如果不想这么复杂,可以写为
/^1[3-9]d{9}$/


还能匹配*特殊符号的,但是会失去匹配11位数功能
/^1[3-9]\d{1}(?:\*{1,})*\d+$/
```

如果确定符号个数,可改为/^1[3-9]\d{1}((?:\*{4})|\d{4})\d{4}$/,就能匹配固定11位数的号码



  • 可以匹配152702365242

  • 可以匹配152****65242


10.密码复杂度要求




  • 8位任意密码


    /^.{8,}$/



  • 包括至少8个字符,包含大写字母、小写字母和数字


    /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/



?=为正向断言,判断条件是否符合.*\d,即任意字符但是需要出现一个数字,其余类似


这个正则表达式用于强制密码应至少包含一个数字(?=.*\d)、一个小写字母(?=.*[a-z])和一个大写字母(?=.*[A-Z]),并且长度至少为8个字符.{8,}



  • 包括至少8个字符,包含大写字母、小写字母和数字,包括特殊字符
    /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*?]).{8,}$/



11.以8结尾,且位数在6位以内的数字


/^\d{0,5}8$/


  1. 时间匹配,匹配时分,年月日的匹配建议还是按照Date的API,正则在匹配闰年的二月份时候无法匹配


/^(?:[01]\d|2[0-3]):(?:[0-5]\d)$/


  • 可以匹配09:10 12:12 23:01 23:59


/^(?:(?!0000)[0-9]{4}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$/



  • (?!0000) 表示后面不能跟着四个0,即年份不能为0000。

  • [0-9]{4} 表示匹配四个数字,即年份的格式为四位数字。

  • - 表示匹配“-”字符。

  • (?:…) 表示非捕获型分组,用于提高正则表达式的效率。

  • (?:0[1-9]|1[0-2]) 表示匹配01-12月份,其中0[1-9]表示01-09月份,1[0-2]表示10-12月份。

  • (?:0[1-9]|1[0-9]|2[0-8]) 表示匹配01-28日,其中0[1-9]表示01-09日,1[0-9]表示10-19日,2[0-8]表示20-28日。

  • (?:0[13-9]|1[0-2])-(?:29|30) 表示匹配01、03、05、07、08、10、12月份的29或30日。

  • (?:0[13578]|1[02])-31 表示匹配01、03、05、07、08、10、12月份的31日。

  • (?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29 表示匹配闰年的2月29日,其中[0-9]{2}表示匹配两位数字的年份,(?:0[48]|[2468][048]|[13579][26])表示匹配闰年的年份,即能被4整除但不能被100整除,或者能被400整除。

  • $ 表示匹配字符串的结束位置。



  1. 用户名:4-10位的用户名,包含下划线、连接符


/^[a-zA-Z0-9_-]{4,10}$/

总结


以上就是目前能想到的常用的正则,大家如果也有或者说常用的正则,也可以在评论区反馈,谢谢各位!


作者:原野风殇
来源:juejin.cn/post/7299376141451411490
收起阅读 »

如何正确遍历删除List中的元素

删除List中元素这个场景很场景,很多人可能直接在循环中直接去删除元素,这样做对吗?我们就来聊聊。 for循环索引删除 删除长度为4的字符串元素。    List<String> list = new ArrayList<String>...
继续阅读 »

删除List中元素这个场景很场景,很多人可能直接在循环中直接去删除元素,这样做对吗?我们就来聊聊。


for循环索引删除


删除长度为4的字符串元素。


    List<String> list = new ArrayList<String>();
   list.add("AA");
   list.add("BBB");
   list.add("CCCC");
   list.add("DDDD");
   list.add("EEE");

   for (int i = 0; i < list.size(); i++) {
       if (list.get(i).length() == 4) {
           list.remove(i);
      }
  }
   System.out.println(list);
}

实际上输出结果:


[AA, BBB, DDDD, EEE]

DDDD 竟然没有删掉!


原因是:删除某个元素后,list的大小size发生了变化,而list的索引也在变化,索引为i的元素删除后,后边元素的索引自动向前补位,即原来索引为i+1的元素,变为了索引为i的元素,但是下一次循环取的索引是i+1,此时你以为取到的是原来索引为i+1的元素,其实取到是原来索引为i+2的元素,所以会导致你在遍历的时候漏掉某些元素。


比如当你删除第1个元素后,继续根据索引访问第2个元素时,因为删除的关系后面的元素都往前移动了一位,所以实际访问的是第3个元素。不会报出异常,只会出现漏删的情况。


foreach循环删除元素


for (String s : list) {
       if (s.length() == 4) {
           list.remove(s);

      }
  }
   System.out.println(list);

如果没有break,会报错:



java.util.ConcurrentModificationException at java.util.ArrayListItr.checkForComodification(ArrayList.java:911)atjava.util.ArrayListItr.checkForComodification(ArrayList.java:911) at java.util.ArrayListItr.next(ArrayList.java:861) at com.demo.ApplicationTest.testDel(ApplicationTest.java:64) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)



报ConcurrentModificationException错误的原因:


看一下JDK源码中ArrayList的remove源码是怎么实现的:


public boolean remove(Object o) {
       if (o == null) {
           for (int index = 0; index < size; index++)
               if (elementData[index] == null) {
                   fastRemove(index);
                   return true;
              }
      } else {
           for (int index = 0; index < size; index++)
               if (o.equals(elementData[index])) {
                   fastRemove(index);
                   return true;
              }
      }
       return false;
  }

一般情况下程序会最终调用fastRemove方法:


private void fastRemove(int index) {
       modCount++;
       int numMoved = size - index - 1;
       if (numMoved > 0)
           System.arraycopy(elementData, index+1, elementData, index,
                            numMoved);
       elementData[--size] = null; // clear to let GC do its work
  }

在fastRemove方法中,可以看到第2行把modCount变量的值加一,但在ArrayList返回的迭代器会做迭代器内部的修改次数检查:


final void checkForComodification() {
    if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }

而foreach写法是对实际的Iterable、hasNext、next方法的简写,因为上面的remove(Object)方法修改了modCount的值,所以才会报出并发修改异常。


阿里开发手册也明确说明禁止使用foreach删除、增加List元素。


迭代器Iterator删除元素


    Iterator<String> iterator = list.iterator();
   while(iterator.hasNext()){
       if(iterator.next().length()==4){
           iterator.remove();
      }
  }
   System.out.println(list);


[AA, BBB, EEE]



这种方式可以正常的循环及删除。但要注意的是,使用iterator的remove方法,而不是List的remove方法,如果用list的remove方法同样会报上面提到的ConcurrentModificationException错误。


总结


无论什么场景,都不要对List使用for循环的同时,删除List集合元素,要使用迭代器删除元素。


作者:程序员子龙
来源:juejin.cn/post/7299384698883620918
收起阅读 »

iOS 仿花小猪首页滑动效果

iOS
一. 背景 首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示: 二. 分析 从花小猪首页交互我们可以分析出如下信息: 首页卡片分为三段式,底部、中间、顶部。 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡...
继续阅读 »

一. 背景


首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示:



二. 分析


从花小猪首页交互我们可以分析出如下信息:




  • 首页卡片分为三段式,底部、中间、顶部。




  • 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡片滚动视图依然可以滚动。




  • 当首页卡片在中间,可以先外部视图整体往上或者往下滑动,往下滑动到底部后,禁止滑动,滑动到顶部,内部视图卡片头部悬浮,内部滚动视图可以滚动。




  • 当首页卡片在顶部,可以拖动卡片外部视图整体下滑,也可以通过内部视图向下滚动,滚动到跟内部头部底部持平,变成整体一起向下滑动。而当内部滚动视图向上滚动,内部卡片头部悬浮固定。




  • 首页卡片滑动过程中,如果停在中间位置,依据卡片停止位置,距离底部、中间、顶部位置远近,向距离近的一端,直接移动到相应位置,比如移动到中间和顶部位置之间,如果距离顶部近,则直接移动到顶部。




  • 当首页卡片在底部,上滑速度很快超过一定值,就直接到顶部。同样在顶部下滑也一样。




  • 当首页卡片在顶部,内部滚动视图快速下滑,下滑到跟卡片头部分开,产生弹簧效果,不直接一起下滑,但其他部分如果慢慢滑动,下滑到跟卡片头部即将分开时,变成整体一起下滑。




三. 实现


理清了首页卡片的滑动交互细节之后,我们开始设计对应类和相关职责。



从上面结构图我们可以看出,主要分为三部分




  • 卡片外层容器externalScrollView,限定为UIScrollView类型。




  • 卡片内头部insideHeaderView,限定为UIView类型。




  • 卡片内滚动视图insideTableView,由于滚动视图所以insideTableView一定是UIScrollView类型,为了复用,这里我们限定为UITableView




这里其实我们不关心头部视图insideHeaderView,因为内部头部视图insideHeaderView和内部滚动视图insideTableView之间的关系是固定,就是内部滚动视图insideTableView一直在头部视图 insideHeaderView下面。


同样我们也不关心滚动视图insideTableView里面的内容,我们需要处理的就是卡片外层容器externalScrollView和内部滚动视图insideTableView之间交互关系。


因为所有这种类型交互处理逻辑是一致的,因此我们抽出FJFScrollDragHelper类。



  • 首先我们来认识下滚动辅助类FJFScrollDragHelper相关属性


    /// scrollView 显示高度
public var scrollViewHeight: CGFloat = kScreenH
/// 限制的高度(超过这个高度可以滚动)
public var kScrollLimitHeight: CGFloat = kScreenH * 0.51
/// 滑动初始速度(大于该速度直接滑动到顶部或底部)
public var slideInitSpeedLimit: CGFloat = 3500.0
/// 当前 滚动 视图 位置
public var curScrollViewPositionType: FJFScrollViewPositionType = .middle
/// 最高 展示 高度
public var topShowHeight: CGFloat = 0
/// 中间 展示 高度
public var middleShowHeight: CGFloat = 0
/// 最低 展示 高度
public var lowestShowHeight: CGFloat = 0
/// 当前 滚动 视图 类型
private var currentScrollType: FJFCurrentScrollViewType = .externalView
/// 外部 滚动 view
public weak var externalScrollView: UIScrollView?
/// 内部 滚动 view
public weak var insideScrollView: UIScrollView?
/// 拖动 scrollView 回调
public var panScrollViewBlock: (() -> Void)?
/// 移动到顶部
public var goToTopPosiionBlock: (() -> Void)?
/// 移动到 底部 默认位置
public var goToLowestPosiionBlock: (() -> Void)?
/// 移动到 中间 默认位置
public var goToMiddlePosiionBlock: (() -> Void)?

我们看到FJFScrollDragHelper内部弱引用了外部滚动视图externalScrollView和内部滚动视图insideScrollView




  1. 关联对象,并给外部externalScrollView添加滑动手势




/// 添加 滑动 手势 到 外部滚动视图
public func addPanGestureRecognizer(externalScrollView: UIScrollView){
let panRecoginer = UIPanGestureRecognizer(target: self, action: #selector(panScrollViewHandle(pan:)))
externalScrollView.addGestureRecognizer(panRecoginer)
self.externalScrollView = externalScrollView
}



  1. 处理滑动手势




// MARK: - Actions
/// tableView 手势
@objc
private func panScrollViewHandle(pan: UIPanGestureRecognizer) {
/// 当前 滚动 内部视图 不响应拖动手势
if self.currentScrollType == .insideView {
return
}
guard let contentScrollView = self.externalScrollView else {
return
}
let translationPoint = pan.translation(in: contentScrollView.superview)

// contentScrollView.top 视图距离顶部的距离
contentScrollView.y += translationPoint.y
/// contentScrollView 移动到顶部
let distanceToTopH = self.getTopPositionToTopDistance()
if contentScrollView.y < distanceToTopH {
contentScrollView.y = distanceToTopH
self.curScrollViewPositionType = .top
self.currentScrollType = .all
}
/// 视图在底部时距离顶部的距离
let distanceToBottomH = self.getBottomPositionToTopDistance()
if contentScrollView.y > distanceToBottomH {
contentScrollView.y = distanceToBottomH
self.curScrollViewPositionType = .bottom
self.currentScrollType = .externalView
}
/// 拖动 回调 用来 更新 遮罩
self.panScrollViewBlock?()
// 在滑动手势结束时判断滑动视图距离顶部的距离是否超过了屏幕的一半,如果超过了一半就往下滑到底部
// 如果小于一半就往上滑到顶部
if pan.state == .ended || pan.state == .cancelled {

// 处理手势滑动时,根据滑动速度快速响应上下位置
let velocity = pan.velocity(in: contentScrollView)
let largeSpeed = self.slideInitSpeedLimit
/// 超过 最大 力度
if velocity.y < -largeSpeed {
gotoTheTopPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y < 0, velocity.y > -largeSpeed {
if self.curScrollViewPositionType == .bottom {
gotoMiddlePosition()
} else {
gotoTheTopPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > largeSpeed {
gotoLowestPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > 0, velocity.y < largeSpeed {
if self.curScrollViewPositionType == .top {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
}
let scrollViewDistanceToTop = kScreenH - contentScrollView.top
let topAndMiddleMeanValue = (self.topShowHeight + self.middleShowHeight) / 2.0
let middleAndBottomMeanValue = (self.middleShowHeight + self.lowestShowHeight) / 2.0

if scrollViewDistanceToTop >= topAndMiddleMeanValue {
gotoTheTopPosition()
} else if scrollViewDistanceToTop < topAndMiddleMeanValue,
scrollViewDistanceToTop > middleAndBottomMeanValue {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
}

处理滑动手势需要当前视图滚动类型currentScrollType和卡片当前所处的位置curScrollViewPositionType来分别进行判断。


/// 当前 滚动 视图 类型
public enum FJFCurrentScrollViewType {
case externalView /// 外部 视图
case insideView /// 内部 视图
case all /// 内部外部都可以响应
}

/// 当前 滚动 视图 位置 属性
public enum FJFScrollViewPositionType {
case top /// 顶部
case middle /// 中间
case bottom /// 底部
}

如下是对应的判断逻辑:


暂时无法在飞书文档外展示此内容


A. 在底部


 /// 回到 底部 位置
private func gotoLowestPosition() {
self.curScrollViewPositionType = .bottom
self.goToLowestPosiionBlock?()
}

private func gotoLowestPosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
}

只能滚动外部视图,内部滚动视图偏移量是0.


B. 在中间


/// 回到 中间 位置
private func gotoMiddlePosition() {
self.curScrollViewPositionType = .middle
self.goToMiddlePosiionBlock?()
}

private func gotoMiddlePosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
}

只能滚动外部视图,内部滚动视图偏移量是0.


C. 在顶部



  • 开始滚动判断:


    /// 更新 当前 滚动类型 当开始拖动 (当在顶部,开始滑动时候,判断当前滑动的对象是内部滚动视图,还是外部滚动视图)
public func updateCurrentScrollTypeWhenBeginDragging(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
}
}


  • 滚动过程中判断


/// 更新 滚动 类型 当滚动的时候,并返回是否立即停止滚动
public func isNeedToStopScrollAndUpdateScrollType(scrollView: UIScrollView) -> Bool {
if scrollView == self.insideScrollView {
/// 当前滚动的是外部视图
if self.currentScrollType == .externalView {
self.insideScrollView?.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
return true
}
if self.curScrollViewPositionType == .top {
if self.currentScrollType == .all { /// 在顶部的时候,外部和内部视图都可以滑动,判断当内部滚动视图视图的位置,如果滚动到底部了,则变为外部滚动视图跟着滑动,内部滚动视图不动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
} else if scrollView.isDecelerating == false,
self.currentScrollType == .insideView { /// 在顶部的时候,当内部滚动视图,慢慢滑动到底部,变成整个外部滚动视图跟着滑动下来,内部滚动视图不再滑动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
}
}
}
}
return false
}


  • 滚动结束判断


/// 当在顶部,滚动停止时候 更新 当前 滚动类型 ,如果当前内部滚动视图,已经滚动到最底部,
/// 则只能滚动最外层滚动视图,如果内部滚动视图没有滚动到最底部,则外部和内部视图都可以滚动
public func updateCurrentScrollTypeWhenScrollEnd(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .all
}
}
}

以上就是具体滚动判断相关处理逻辑,对应实现效果如下。



作者:果哥爸
来源:juejin.cn/post/7299731897626345481
收起阅读 »

读完一个人的朝圣,我觉得每一个人身上都有男主的影子

莫琳:”你会去很久吗?“ 哈罗德:”到街尾就回来“     一个童年缺少母爱,父爱,中年丧子,一生都在低头做事,不敢正视他人,可以在一个小酒厂工作四十年,却不求升职加薪,与人无争,与人无交的退休老头,跋涉六百多公里,穿越整个大不列颠,拿着一封信去拯救患了癌症的...
继续阅读 »

莫琳:”你会去很久吗?“


哈罗德:”到街尾就回来“


    一个童年缺少母爱,父爱,中年丧子,一生都在低头做事,不敢正视他人,可以在一个小酒厂工作四十年,却不求升职加薪,与人无争,与人无交的退休老头,跋涉六百多公里,穿越整个大不列颠,拿着一封信去拯救患了癌症的老友,听起来应该是无聊透了。远远没有,帅气潇洒,带领古巴人民与美国死磕几十年,躲过n = 700次暗杀,成功睡到美国中情局女特工,并令其爱上自己,传闻睡过35000个女性的菲德尔*卡斯特罗的故事令人感到有种有趣有料。的确,哈罗德是一个平凡以至于差劲糊涂的人,但远没有那么简单,哈罗德、哈罗德,作者是借哈罗德写我们的父母,我们的孩子,我们的朋友,我们亲爱的自己,我们每个人身上可能都有哈的影子,如今这个影子开始征程,或叫朝圣,我更倾向于叫“做梦”。因为这真的像是哈罗德的一场梦。


  这里没有艺术,没有历史,儿女情长,伟大,浪漫。


  这里只有回忆,哈罗德 距离终点很近很远,近的是,一生真的很短,远的是,回忆又是那么漫长。


”没有爱的生活不叫生活“


    哈罗德有一个酒鬼父亲,从不关心他的儿子,在十六岁生日那天,将哈赶出了家门,而他的母亲,早在很久以前已经抛弃了这个家庭去周游世界。在父母那里哈罗德没有得到一丝爱,好像还不如很多人养的宠物的到的关爱多,没有得到爱的哈罗德好像也吝啬于给予爱,路上他在回忆道:“他欠过去一点点慷慨”,但他本人是“喜欢和人交往的,希望明白她们之所爱,之所失”。哈不是一个自私心眼坏的人,(和生活中大多人一样,心眼不坏,好多父母也说,自己孩子“心眼不坏”,这个词有参考价值吗?)他只是不会表达,这种表达好像也不仅仅只是 羞于对儿子说:“爸爸爱你”,也不是:“儿子,你真棒,爸爸为你自豪”(他的儿子没有过任何亲昵的经历)。更不是对爱人说一句:”亲爱的“,好像这个人本身:”没有多少爱“,就好像爱是水,爱是食物,是力气,我们能看到摸到能展示给别人看,”看我有的是力气,我有的是爱“,就像没有力气的男人,哈罗德是一个没有爱的男人。哈罗德倒不是冷漠,也不是自私,在我看来,哈罗德一个60岁的老头,其实是个孩子,一个没有被给予爱与鼓励多的孩子,一个没有受到爱的给予的孩子,是不会轻易去爱别人,爱这个社会的。他们没有爱人的能力,更很难让人感受到来自他内心的热情与爱(友爱,情爱,亲情……)。


  哈罗德是一个“没有妈的孩子”,上学时,哈总是被小伙伴们这样嘲笑。它是一个极其脆弱敏感的孩子,他不敢交朋友,他不敢直视别人,甚至整个屋子的人看他一眼,都会脸红到脖子,他对儿子很少给予父爱,对妻子关怀,理解不够,这些都源于童年,缺失的亲情。


    一个人的成长的表现就是爱越来越多的人,事。即使受到伤害。


  (我不是鼓吹爱与自由可以战胜一切,不是让别人去滥情,只想说,帮帮我们身边的人,每个人,哪怕一个微笑,这也是脆弱的哈罗德坚持走到终点的原因之一)


”婚姻“


  莫琳,哈的爱人,总爱重复哈的话,要不就是”我不这么认为“,两人因为儿子的悲剧分道扬镳,只是名义上的夫妻,在哈走后,莫琳经历了一系列的情绪波动,先是惊讶,抱怨,而后焦虑不安,后是平静的回忆,直到对哈的日夜思念,莫琳因为哈罗德对儿子的不作为,而抱怨,恨着哈罗德,二十年之久,莫琳本身是一个普通的女性,无论作为母亲,妻子,都算是合格的,那什么让两人冷漠二十年,让婚姻只剩下房子一个躯壳,是哈罗德的性格缺陷?两人沟通不够?是残酷的现实?…………,


  哈的徒步之旅让他们重新走到了一起,值得一提的是,两人对于初见时的回忆时如此的不同。(男人女人是两个物种)


  莫琳的回忆:"他微弯下腰,嘴唇贴近她的耳朵,伸手拨开她的一绺头发,才开口说话。这大胆的举动让她感到一股强烈的电流顺着脖子传上来,甚至今日想起,肌肤下仍能感受到那一份悸动。他说了什么?无论说了什么,都肯定是极其有趣的内容,因为两人都笑得歇斯底里,还尴尬地打起嗝来。她想起他转身走向酒吧取水时衣角扬起的样子,想起自己乖乖地站在原地等他。那时好像只有当哈罗德在附近,世界才有光。那两个跳得、笑得如此畅快的年轻人如今去了哪里?"


  哈罗德的回忆:“哈罗德在跳舞,突然发现隔着一整个舞池的莫琳在看着他。他还记得那一刻疯狂地挥舞四肢的感觉,仿佛要在这个美丽女孩的见证下甩掉过去的一切。他鼓起勇气,越跳越起劲,双腿踢向空中,双手像滑溜溜的海鳗扭动。他停下来仔细观察,她还在看着他,这次她碰到他的目光,忽然笑了。她笑得那样乐不可支,抖着肩膀,秀发拂过脸庞。他生平第一次不由自主地穿过舞池,去触碰一个完全的陌生人。天鹅绒一样的秀发下,是苍白而柔软的肌肤。她没有回避。“


  维系婚姻的到底是什么?A爱情,B浪漫的回忆,C未来的期盼,D,子女 E,两个人的时候感觉孤独,一个人时更孤独(莫琳回忆道)F,安于现状G,人丑又穷离不起(老婆太厉害,不敢提)


……敲黑板!!!这道题超纲,考试不考……


信仰与理智


  读完之后,我问过自己,哈罗德为什么要徒步去远在千里的贝里克?支撑他的是什么?


  他在经历一路的跋涉到底吃了多少苦?值不值?关键是他是个沉闷、死气沉沉没有活力的哈罗德。做出这个选择不可思议,作者提到将这归为“期待一种比不言自明的现实更大,更疯狂也更美好的可能”。在我看来,哈罗德身上有很多缺陷,他自己给自己贴了太多标签,沉默逆来顺受,软弱,羞涩……,但人不是一个个标签构成的,通过一个个的标签并不能完全解释一个人的行为,事实上,作者没有解释也不用解释,哈为什么做了这个决定,因为包括这件事在内的很多事都是有一种奇思妙想甚至吓到自己,自己也不知从哪冒出来的想法开始,首先,在那一时一刻,理想梦想狂想疯想轮奸了理智,从此孕育了伟大,自己被惊讶、震撼、感动,从而迈出了一步,两步,就像疑问自行车就两个轮子为什么可以冲的那么快,疑问它为什么不倒一样,没有人告诉你为什么?而你同样可以骑上去,跑的比风还快,还自由。你做到了,哈罗德的决定也做的那么简单与自然。


  做决定容易,简单的决定,简单的计划不一定成功,仅仅一双帆布鞋(属于海洋,却行走于陆地),一路上,哈曾羞于解释自己的计划,但一路的陌生人,即使有再多的不理解,不可思议,也都给予了支持,或物质上,或精神上,他们都是哈罗德走下去的动力,哈罗德感谢他们,正是这些动力让他尝试去冲破囚禁它一生的牢笼,束缚,释放了年轻时候,转瞬即逝的野性,冒险与激情,但是哈罗德毕竟只是哈罗德,标签解释不了全部,却注定哈罗德在那么一个阶段,否定自我,否定这次徒步的意义。


  人生就像一个缓冲区,就像数据库buffer,操作系统buffer,各种buffer一样,缓冲区存在的意义,在于解决通信双方处理信息的速度不同的问题,人生的buffer,怎么讲?得与失,的是写入buffer,失  是移除buffer。


  朋友有失有得,太多照顾不过来,就要失去热度低得,太少,你会感到孤独,又会去交新朋友,


从人生来讲,不只是朋友,童年,青年,中年,老年,童年的我们是空的buffer,接受各种各样的信息,我们每天都在快速的成长,不曾感到失去,直到青年,我们开始困惑,感伤,为了长大,活下去,为了责任,不得不得到很多不想得到的,失去很多宝贵的,(大话西游的主题,成长,得与失)。到了中年,可能渐渐的习惯了得失,不再多愁善感,这是我们可能是很多人心中的英雄和榜样,只有我们自己知道,我是个什么样的混蛋,到了老年,得到的可能仅仅是白发,失去的与日俱增,缓冲区越来越小,我们没有了憧憬,只剩下回忆,我们已经不是年轻的感伤,更多是一种超然的释怀。因为除了生命,任何都不属于我。


哈罗德在救赎曾经的哈罗德


  哈罗德呢?他老了,他在徒步,他在救赎自己,他在朝圣,但他并不是英雄,他正在失去很多,跟普通人不同的是,他早就失去了很多,甚至很多不曾得到,父爱,母爱,朋友,儿子,爱人,这些失去放在有这么一个性格缺陷的老头身上,有点残忍,但上帝在决定你命运的时候,才不会管你是不是很可怜了,更不会征询你的意见,就这样 ,哈罗德在失去唯一的陪伴——小狗后(一条小狗,它的名字就叫“小狗”,哈罗德就这么叫),崩溃了,没有了时间概念,没有了距离,以前用英里丈量到终点的距离,现在用回忆,在一幅幅回忆前 ,哈罗德真的要放弃了,莫琳在哈走的日子里也在回忆,回忆年轻,回忆自己对哈罗德过分要求,与缺失的理解,在哈崩溃时,莫琳恢复了以前的温情与鼓励,哈罗德蹒跚的走到了终点,老朋友,结束了,但她们的婚姻好像重新开始。


后记:


在我观察,这本小说,有爱需要表达,婚姻,信仰,三个主题,这是一本鸡汤小说,出自英国作家蕾秋乔伊斯,这是第二本我非常喜欢的女作家写的小说,以前以为女作家只会写给女孩的鸡崽文学,什么女孩你必须优秀…………,XXX,或者儿女情长,纯粹YY的爱情小说,现在证明,我错了,女作家也可以写出能体现对社会对历史对人性的深刻理解的作品(《穆斯林的葬礼》),他们对人心理的细腻把握,同样让人感到有趣,这本小说里,我并没有对哈罗德,旅途的见闻,中间媒体炒作,投机分子借机包装,哈罗德与荃妮的友谊做过多感想,因为我觉得这只是路上的故事,大树的枝叶,任何一部小说都要有插曲丰满主人公的形象,但主题从头贯穿到结尾,其中婚姻的主题很明显,但是我经历有限,不能提出特别的见解,望请见谅。


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

耗时七天,我写完了自己的第一个小程序

web
一入红尘深似海。 自2016年参加工作时申请了第一张信用卡,至今已有七年矣。在这七年之中,自己信用卡的数量由1张逐渐增加到12张,后又慢慢减少到如今的5张。七年的卡海浮沉让我从初入社会的一无所有,到如今的负债累累。 当然,这篇文章并不是来记录自己七年的负债之旅...
继续阅读 »

一入红尘深似海。


自2016年参加工作时申请了第一张信用卡,至今已有七年矣。在这七年之中,自己信用卡的数量由1张逐渐增加到12张,后又慢慢减少到如今的5张。七年的卡海浮沉让我从初入社会的一无所有,到如今的负债累累。


当然,这篇文章并不是来记录自己七年的负债之旅,而是在经历了多年“钱都去哪儿了?”的内心呼唤后的心灵觉醒:还是要有个账本记账啊!


我的需求并不复杂:



1、可以快速的知道如今自己卡的总额度是多少,还需要还的欠款是多少(清楚负债情况)


2、可以快速知道每张卡的可用额度是多少,账单日是哪天(便于刷卡时明确知道该刷哪张卡,不至于出现今天刚刷了卡,明天就出了账单要还的现象)


3、可以知道每个月刷卡的总手续费是多少(清楚损益,明白每个月的损耗)


4、记录收支(了解每一分钱都去了哪里)



基于以上四个简单的需求,在尝试现在市面上十几款记账软件后,我惊奇的发现:竟然没有一款合适的软件可以满足我的需求!


于是,我做了一个【XXXX】的决定:自己来写一个工具吧!


然后,就诞生了我发布的第一个小程序:了账


小程序简介


了(liao)账是一款简洁的记账小程序。了账中的了字,是明了之意,清楚明白自己的账目,亦是了结之意,祝愿各位卡友早日上岸。


了账的页面相对简洁,有【未登录】【已登录】两种状态展示,如下图所示:


【未登录】


截屏2023-10-23 08.19.09.png


【已登录】


截屏2023-10-23 08.27.30.png


了账只有账户账单两个tab页,分别用来展示当前账户信息和查看收支记录。


账户页面展示了用户(目前只有作者本人😄)较为关心的几个数据:【当前额度】、【可用额度】、【现金额度】、【信用卡总览】、【当前账户】。


账单页面除了查看每一笔收支记录外,在顶部也展示了当月总出账、总入账信息。


截屏2023-10-29 14.53.05.png


新增账户


用户可通过【新增】按键创建账户,在账户页面,顶部账户信息会随之动态改变。如下图所示:


截屏2023-10-29 15.05.44.png


在新增页面,用户可点击账户类型修改新增账户的类型,目前【了账】共包含【信用卡】、【储蓄卡】、【支付宝】、【微信】、【其他】共五类账户。除信用卡外,其余四类账户额度统一归类为【现金额度】。


信用卡除了【固定额度】之外有时会给一部分【临时额度】,因此,在新增账户页面,除了【固定额度】之外,添加了【当前额度】字段。【当前额度】是包含【固定额度】和【临时额度】的账户总额度。


新增收支


当用户创建过账户后,就可以点击【账户】页面右下角【记一笔】浮块创建收支记录,并在【账单】页面查看。相应的,账户页面所展示的【账户信息】也会随之动态改变。如下图所示:


截屏2023-10-29 15.24.00.png


在记录收支时,不同的账户类型可选的账单类型也不相同。如:信用卡账户下可选择的账单类型为【日常支出】、【个人刷卡】、【账单还款】,储蓄卡账户下可选择的账单类型为【日常支出】、【日常收入】、【转账支出】、【转账收入】,支付宝账户微信账户其他账户则多出【提现】类型可供选择。如下图所示:


截屏2023-10-29 15.41.54.png


当账单类型为【日常支出】时,则须选择支出类型。目前共有【食】、【行】、【衣】、【住】、【娱乐】、【其他支出】六类支出可供选择。如下图所示:


截屏2023-10-29 15.35.20.png


信用卡账户账单类型为【个人刷卡】,以及支付宝账户微信账户其他账户账单类型为【提现】时,则需填写【收款金额】。收款账户为除【信用卡账户】外的其他账户,收款金额为除去手续费之外的实际到账金额。如下图所示:


截屏2023-10-29 15.53.45.png


账单的编辑、删除和账户的编辑、删除


用户可通过左滑对当前账户及当前收支进行编辑、删除。当收支被删除后,账户信息将会回退该笔收支。当账户被删除后,该账户下的所有收支将不可被编辑、删除。如下图所示:


截屏2023-11-11 12.27.15.png


账户详情和账单详情


点击每个账户和账单,可进入详情页,查看详情信息。如下图所示:


截屏2023-11-11 14.25.39.png


写在最后


账本只是工具,最主要的还是要诸位卡友调整好心态,量入为出。祝愿各位早日上岸!!!


写代码用了7天,备案发布将近一个月!!!最后上线认证居然还收了30块巨款!!!至今仍未明白:经历了实名注册小程序号,实名IPC备案后,最后上线认证的意义在哪里?难道只为承袭小马哥一贯的氪金传统?


作者:凡铁
来源:juejin.cn/post/7299733832413069347
收起阅读 »

整理了七八年的笔记后,感觉很累,好像并不值得......

🔊整理了七八年的笔记(包括收藏别人的),感觉很累,回顾后好像并不值得那么做,于是对收藏文章,做笔记,学知识有了一些不一样的感悟,于是写下此文,警示自己。 📚一、收藏怪 收藏不等于吸收,收藏的文章不消化,还是别人的,收藏不等于学会了知识点,警惕陷阱。 ...
继续阅读 »

🔊整理了七八年的笔记(包括收藏别人的),感觉很累,回顾后好像并不值得那么做,于是对收藏文章,做笔记,学知识有了一些不一样的感悟,于是写下此文,警示自己。



📚一、收藏怪




  1. 收藏不等于吸收,收藏的文章不消化,还是别人的,收藏不等于学会了知识点,警惕陷阱。




  2. 重复的事情不要做第二遍,当初偷懒没理解收藏的文章,到头来还得从头开始理解;但却做了一些重复的事情。生命其实很宝贵。




  3. 收藏文章要精选,收藏的文章过多会增加未来的负担; 再看是消耗未来的时间,未来的时间一定比现在的时间更加宝贵,请慎重地做文章收藏。好的文章点个赞,不想错过知识就及时总结消化。




去阅读了那些以前收藏的文章,当初没有懂的,现在也可能没有懂;现在懂的也并不是因为收藏了才懂的。




📒二、断舍离


那些似懂非懂的知识,要学会断舍离。收藏的文章,记录的笔记;并不是越多越好,数量可能带来质量的下降,可能充斥重复,甚至废话。



  1. 做减法,该丢掉就丢掉。 这是生活经验;也是哲学道理。

  2. 似懂非懂的知识,多了只会让自己难受。通透才能轻松。什么都想要,什么也达不到最好,反而容易让自己撑着。

  3. 少即是多,抓重点。什么都想学,什么也学不好。知识也是有二八定律的。

  4. 不要废话,保持简单,一句话能说清楚的事情,就不要两句话。

  5. 知识是会给增加烦恼、负担的。别让知识给自己制造无形的麻烦、也不给自己未来添堵。

  6. 允许自己有知识不会,也别想着什么都会;更别想着什么都学。




📑三、兴趣



  1. 不喜欢就不要学,讨厌的知识容易遗忘。学习知识也要取悦自己。

  2. 学习自己感兴趣的,这样才能深耕,深耕那些不喜欢的知识,会让自己痛苦。

  3. 学习是需要时间成本的,而且很大,对自己也要投其所好。




📜四、偷懒



  1. 记录再多不理解的笔记、知识,不及时消化,还是不会理解的,有用吗?这样建议别做。

  2. 摒弃那种笔记做越多越充实的自我欺骗行为。要做一个偷懒的人




✒️五、总结


最近整理大量以前的学习笔记,以及各种技术收藏文档;发现了很多无用的,累赘的;不懂的还是不懂;似懂非懂的还是似懂非懂。而做笔记的过程却花费了太多时间。 把事情寄托于未来,成本更高。思维上要学会偷懒。别用笔记上的勤奋来掩饰思维上的懒惰。


🔊注:有一些偏激的观点,但本文仅代表当下自己的想法。


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

移动端APP版本治理

1、背景 在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。 只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。 而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中...
继续阅读 »

1、背景


在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。


只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。


而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中间还有一个更新升级的空档期,多数公司在这里都是一个“三不管”地带,而这个空档期,我称之为版本交付的最后一公里



2、价值



2.1、业务侧


总有人会挑战“有什么业务价值?”对吧,那就先从业务价值来看。


尽管有些app的有些业务是动态发布的,但也一定会有些功能是依赖跟版的,也就是说,你没办法让所有用户都用上你的新功能,那对于产运团队来说,业务指标就还有提升的空间。


举两个例子:



  1. 饿了么免单活动需要8.+版本以上的app用户才能参与,现在参与用户占比80%,治理一波后,免单用户参与占比提升到90%,对业务来说,免单数没变,但是订单量却是有实实在在的提升的。

  2. 再来一个,酷狗音乐8.+的app用户才可以使用扫码登录,app低版本治理之后,扫码登录的用户占比势必也会提升,那相应的,登录成功率也可以提升,登录流程耗时也会缩短,这都是实实在在的指标提升。



虚拟数据,不具备真实参考性。



2.2、技术侧


说完业务看技术,在技术侧也可以分为三个维度来看:



  1. 稳定性,老版本的crash、anr之类的问题在新版本大概率是修复了的,疑难杂症可能久一点;

  2. 性能优化,比如启动、包大小、内存,可以预见是比老版本表现更好的,个别指标一两个版本可能会有微量劣化,但是一直开倒车的公司早晚会倒闭;

  3. 安全合规,不管是老的app版本还是老的服务接口,都可能会存在安全问题,那么黑产就可能抓住这个漏洞从而对我们服务的稳定性造成隐患,甚至产生资损;


2.3、其他方面


除了上面提到的业务指标和用户体验之外,还有没有?


有没有想过,老版本的用户升上来之后,那些兼容老版本的接口、系统服务等,是不是可以下线了,除了减少人力维护成本之外,还能减少机器成本啊,这也都是实打实的经费支出。


对于项目本身来说,也可以去掉一些无用代码,减少项目复杂度,提升健壮性、可维护性。


3、方案



3.1、升级交互


采用新的设计语言和新的交互方式。


3.1.1、弹窗样式


样式上要符合app整体的风格,信息展示明确,主次分明。


bad casegood case

3.1.2、操作表达


按钮的样式要凸显出来,并放在常规易操作的位置上。


bad casegood case

3.1.3、提醒链路


从一级菜单到二级页面的更新提醒链路,并保持统一。



3.1.4、进度感知


下载进度一定要可查看并准确,如果点了按钮之后什么提示都没有,用户会进入一个很迷茫的状态,体验很差。



3.2、提醒策略


我们需要针对不同的用户下发不同的提醒策略,这种更细致的划分,不光是为了稳定性和目标的达成,也是为了更好的用户体验,毕竟反复提醒容易引起用户的反感。


3.2.1、提醒时机


提醒时机其实是有讲究的,原则上是不能阻塞用户的行为。


特别是有强制行为的情况,比如强更,肯定不能在app启动就无脑拉起弹窗。



bad case:双十一那天,用户正争分夺秒准备下单呢,结果你让人升级,这不扯淡的吗。



时机的考虑上有两个维度:



  1. 平台:峰时谷时;

  2. 用户:闲时忙时;


3.2.2、逻辑引擎


为什么需要逻辑引擎呢?逻辑引擎的好处是跨端,双端逻辑可以保持一致,也可以动态下发。




可以使用接口平替,约定好协议即可。



3.2.3、软强更


强制更新虽然有可以预见的好效果,但是也伴随着投诉风险,要做好风险管控。


而在2023-02-27日,工信部更是发布了《关于进一步提升移动互联网应用服务能力》的通知,其中第四条更是明确表示“窗口关闭用户可选”,所以强更弹窗并不是我们的最佳选择。



虽然强更不可取,但是我们还可以提高用户操作的费力度,在取消按钮上增加倒计时,再根据低版本用户的分层,来配置不同的倒计时梯度,比如5s、10s、20s这样。


用户一样可以选择取消,但是却要等待一会,所以称之为软强更



3.2.4、策略字段



  • 标题

  • 内容

  • 最新版本号

  • 取消倒计时时长

  • 是否提醒

  • 是否强更

  • 双端最低支持的系统版本

  • 最大提醒次数

  • 未更新最大版本间隔

  • 等等


3.3、提示文案


升级文案属于是ROI很高的了,只需要总结一下新版本带来了哪些新功能、有哪些提升,然后配置一下就好了,但是对用户来说,却是实打实的信息冲击,他们可以明确的感知到新版本中的更新,对升级意愿会有非常大的提升。



虽然说roi很高,但是也要花点心思,特别是大的团队,需要多方配合。


首先是需求要有精简的价值点,然后运营同学整合所有的需求价值点,根据优先级,出一套面向用户的提醒话术,也就是提示的升级文案了。



3.4、更新渠道


iOS用户一般在App Store更新应用,但是对于Android来说,厂商比较多,对应的渠道也多,还有一些三方的,这些碎片化的渠道自然就把用户人群给分流了,为了让每一个用户都有渠道可以更新到最新版本,那就需要在渠道的运营上下点功夫了,尽可能的多覆盖。



除了拓宽更新渠道之外,在应用市场的介绍也要及时更新。


还有一点细节是,优化应用市场的搜索关键词。


最后,别忘了自有渠道-官网。


3.5、触达投放


如果我们做了上面这么多,还是有老版本的用户不愿意升级怎么办?我们还有哪些方式可以告诉他需要升级?


触达投放是一个很好的方式,就像游戏里的公告、全局喇叭一样,但是像发短信这种需要预算的方式一般都是放在最后使用的,尽可能的控制成本。




避免过度打扰,做好人群细分,控制好成本。



3.6、其他方案


类型介绍效果
更新引导更新升级操作的新手引导😀
操作手册描述整个升级流程,可以放在帮助页面,也可以放在联系客服的智能推荐🙂
营销策略升级给会员体验、优惠券之类的😀
内卷策略您获得了珍贵的内测机会,您使用的版本将领先同行98%😆
选择策略「88%的用户选择升级」,替用户做选择😆
心理策略「预计下载需要15秒」,给用户心理预期😀
接口拦截在使用某个功能或者登录的时候拦截,引导下载最新版本😆
自动下载设置里面加个开关,wifi环境下自动下载安装包😀
版本故事类似于微信,每个版本都有一个功能介绍的东西,点击跳转加上新手引导🙂

好的例子:


淘宝拼多多

4、长效治理


制定流程SOP,形成一个完整链路的组合拳打法🐶。


白话讲,就是每当一个新版本发布的时候,明确在每个时间段要做什么事,达成什么样的目标。


让更多需要人工干预的环节,都变成流程化,自动化。



5、最后


一张图总结APP版本治理依次递进的动作和策略。



作者:yechaoa
来源:juejin.cn/post/7299799127625170955
收起阅读 »

为什么稳定的大公司不向Flutter迁移?

迁移很难, 但从头开始很简单 从Flutter的测试版开始, 我就一直在关注它, 从那时起, 我就看到了Flutter在开发者, 社区和公司中的采用. 大多数新兴开发人员都曾多次讨论过这个问题: 为什么大公司不使用 Flutter? 因此, 这篇小文只是我...
继续阅读 »

迁移很难, 但从头开始很简单


从Flutter的测试版开始, 我就一直在关注它, 从那时起, 我就看到了Flutter在开发者, 社区和公司中的采用. 大多数新兴开发人员都曾多次讨论过这个问题:



为什么大公司不使用 Flutter?



因此, 这篇小文只是我对这个问题的个人观察和回答.


转向新技术既困难又复杂, 而从最新技术重新开始则很容易. 这(据我观察)也是为什么稳定的大公司不会将其长期使用的应用程序迁移到新技术的最大原因之一, 除非它能带来惊人的利润.


你会发现大多数初创公司都在使用 Flutter, 这是因为 90% 以上的跨平台应用程序新创意都可以在Flutter中以经济高效的方式轻松实现. Flutter中的一切都非常快速, 令人惊叹, 而且具有我们在过去几年的Flutter之旅中听说过的所有令人惊叹的优点.


那么问题来了:


既然Flutter如此令人惊叹, 高性价比, 单一代码库, 更轻松的单一团队管理, 令人愉悦的开发者体验, 等等等等; 那么为什么大公司不将他们的应用程序迁移到Flutter呢? 从头开始迁移或重写, 拥有单一团队, 单一代码, 这不就是天堂么?



不, 没那么简单.



问题所在: 业务vs技术热情


Flutter 令人惊叹, 你最终可以说服开发人员在公司中使用Flutter构建应用程序. 问题在于公司的运营业务. 企业希望Flutter能立即为业务做出贡献. 他们不希望等待自己的团队完全重写应用程序, 然后将其付诸实践以繁荣业务.


但正如我前面所说, 对于技术团队来说, 重写是最理想的. 因此, 这是一个可以由公司利益相关者共同思考的问题. 公司内部需要在分析领域, 业务类型, 团队文化等所有因素后, 找到一个中间立场.


无论如何, 让我们来看看这两种情况的结果如何.


从头开始重写: 技术方面


一家大公司拥有庞大的产品, 这些产品已融入流程和领域, 工作完美无瑕, 为企业完成了工作.


从技术上讲, 对首席技术官来说, 最好的办法是在Flutter上从头开始重写应用程序, 并将其完成. 但是, 如果他们决定这样做, 就必须雇佣一个全新的Flutter开发团队, 向他们解释当前产品/领域的所有情况, 并让他们重写应用程序. 这看起来很容易, 其实不然. 当前代码库中有很多内部知识必须传授给新团队, 这样他们才能为应用程序构建完全相同的体验/UI/UX/流程.



为什么构建完全相同的东西如此重要?



这是因为用户总有一天会第一次收到Flutter构建的应用程序更新, 这对于一个拥有成千上万用户的应用程序来说是非常危险的.


其次, 新功能正在当前应用的基础上构建, 重写后的应用可能无法赶上当前应用. 但这是一个商业决策(是停止新开发并先进行重写, 还是继续在当前应用程序中添加功能, 无论如何都要权衡利弊).


每家公司在领域, 文化, 人员, 领导力, 思维过程, 智囊团等方面都是独一无二的. 内部可能存在数以百计的挑战, 只有进入系统后才能了解. 你不可能对每家科技公司都提出一个单一的看法.


从头开始重写: 业务方面


公司在采用新事物时, 有一个非常重要的想法:



在重写的过程中, 业务不仅不应受到影响, 而且还应保持增长.



这意味着你不能在运行中的应用程序的功能和开发上妥协. 在重写版本中, 运行正常的程序不应出现错误. 为确保这一点, 需要进行严格的原子级测试, 以确保用户从一开始就掌握的功能不会出现任何问题(我们谈论的是大公司, 这意味着应用程序已运行多年). 我们可以进行单元/集成/用户界面测试, 但当它关系到业务, 金钱和用户时, 没有人会愿意冒这个险.


简而言之, 大多数稳定的公司不会决定从头开始用Flutter重写他们稳定的应用程序. 如果是基于项目的公司, 他们可能会使用Flutter启动新赢得的客户项目.


迁移(业务上友好, 技术上却不友好)


公司决定迁移到Flutter的另一种方式是逐屏迁移到 Flutter, 并使用与本地端(Talabat 的应用程序)通信的方法渠道. 对于技术团队来说, 这可能是一场噩梦, 但对于业务的持续运行, 以及让Flutter部分从一开始就为业务做出贡献来说, 这是最可行的方法(在重写过程中, 应用程序的Flutter部分除非上线到生产, 否则对业务没有任何用处).


作为一名读者和Flutter爱好者, 你可能会认为逐屏迁移非常了不起, 但实际上, 当你在一个每天有成千上万用户使用的生产应用程序中工作时, 这真的非常复杂. 这就像开颅手术.


总结一下


根据我的观察, 对于一家以产品为基础的公司来说, 决定将自己多年的移动开发技术栈转换为新的技术栈是非常困难的. 因此, 如果大公司真的决定转换, 这个决定本身确实值得称赞, 勇气可嘉, 也很有激励作用.


如果业务非常重要(如 Talabat, Foodpanda, 或涉及日常大量使用, 支付, 安全, 多供应商系统等的用户关键型应用程序), 那么从业务角度来看, 最理想的做法是以混合方式慢慢迁移应用程序. 同样, 这也不一定对所有人都可行, 重写可能更好. 这完全取决于公司和业务的结构以及决策的力度.


对于以项目为基础的公司来说, 使用Flutter启动新项目是最理想的选择(因为他们拥有热情洋溢, 不断壮大的团队, 并致力于融入新技术). 当他们使用Flutter构建新项目时, 如果交付速度比以前更快, 效率更高, 他们就会自动扩大业务.


对于开发人员和技术团队来说, 任何新技术都是令人惊叹的, 但如果你是一位在结构合理, 稳定的公司工作的工程师, 你也应该了解公司的业务视角, 从而理解他们对转用新技术的看法.


如果你是一名高级工程师/资深工程师, 你应该用他们更容易理解的语言向业务部门传达你的热情. 对于推介 Flutter, 可以是更少的时间, 更少的成本, 更少的努力, 更快的交付, 一个团队, 一个代码库, 更少的公关审查等(如果你是Flutter人员, 你已经知道了所有这些).



业务部门在做决定时必须考虑多个方面, 因此作为工程师, 要告诉他们一些能让他们更容易做出决定的事情.



以上是我的个人观点和看法, 如果你有不同的看法或经验, 请随时在评论中与我分享, 很乐意参与该问题的讨论.


Stay GOLD!


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

阿里云又崩了,这次送什么

双十一的烟味还没散,昨天下午至晚间(11月12日),阿里云又崩了,而且语雀又没逃过去,从出现故障到完全恢复长达3个半小时,这次又是怎么回事呢? 这次受影响最大的是OSS服务,也就是阿里云的对象存储服务,对外提供的是图片、视频,以及各种文件的读写服务,可千万别小...
继续阅读 »

双十一的烟味还没散,昨天下午至晚间(11月12日),阿里云又崩了,而且语雀又没逃过去,从出现故障到完全恢复长达3个半小时,这次又是怎么回事呢?


这次受影响最大的是OSS服务,也就是阿里云的对象存储服务,对外提供的是图片、视频,以及各种文件的读写服务,可千万别小看这个服务,我们平常聊天发图片、发文件,网站上展示的图片、视频,以及各种js、css、字体等文件可能都是放在OSS服务中的。


从网友的反馈来看,仅阿里系受影响的产品就包括:阿里云OSS、语雀、钉钉、阿里云盘、淘宝、闲鱼等,应该还有些用户相对少的服务没有爆出来,毕竟阿里系产品众多,对象存储在每个产品中都是不可或缺的。



使用阿里云OSS服务的外部公司产品估计就更多了,只是大家量小体微,没有掀起什么狂风巨浪,我这里也多多少少受了一点影响,上传文件出错。



对于这次故障阿里云发了一个公告,文字不少,大家看看是否清楚明白。



根源是某个底层管控服务组件出现了问题,不过没说这个组件是干什么的,起到什么作用。再看受影响的产品还是挺多的,队列消息服务都被波及了。我猜测可能是底层存储的某个控制程序又出问题了,而且是个广泛部署的服务,可能是鉴权,否则不会影响这么广泛,恢复这么缓慢。


话说同样是阿里系的语雀前段时间刚崩了七个多小时,这么快阿里云又崩了,还恰逢双十一,再往前翻,去年阿里云香港机房还挂了两天,这不免让人对阿里的技术心生疑虑,加之社会对阿里的管理文化颇多吐槽,如此下去,阿里在人们的心中可能就要堕落下去了,可以说有向悬崖边滑落的风险。


频繁的出现问题,可能不仅仅是技术上的问题了,管理制度上可能也存在一些风险,相信阿里的王博士和周大佬也想得到,这里就不瞎指挥了。



不知道阿里云这次怎么给大家交代,看看网友们的期待吧。



不过我们使用阿里云的产品,以前多多少少都出现过一些问题,前几年产品经理会主动给些补偿,不过最近几年,影响不大的话,用户不要求,也少见阿里云主动补偿。对于此事,后续我会继续关注。


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

30岁之前透支,30岁之后还债。

前言 看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。 今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。 愉悦二字说来容易,但各位都一样,奔波于现实,劳累于...
继续阅读 »

前言


看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。


今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。


愉悦二字说来容易,但各位都一样,奔波于现实,劳累于生活,岂是三言两语就能改变的。


病来如山倒


我又病了,有些意外和突然的,令我措手不及。


一天早上我起来,脖子有些酸,就伸手揉揉捏捏,忽然发现脖颈左侧有一个肿块,仔细拿捏,发现竟然是在里面,而且硬邦邦的,伴有轻微的疼痛感。


当时早上对着镜子拍下来的肿块,我还保留了照片。


1.jpg


立马便一身冷汗冒出,我从未经历过这样的事情,去年身体毕竟出过问题,两相叠加之下,内心更是难以描述。


因为是周一,怀着忐忑的心情去上班了,接下来一直都有些神经兮兮,觉得自己身体出了大问题。


之前我有文章讲过自己去年其实已经检查出血脂的问题,停更半年之久,调养了一番,才真正感觉到身体有所恢复,根据我发文的日期可见一二。


恢复更新的这段时间,报复式地写作和分享,一度不知不觉地排到榜单第二,今天登录看了一下,居然还在月榜前三没下来,也是意外。


话说回来,人一旦身体冒出点病痛,整个心情都显得低沉萎靡,很快就能在方方面面反应出来。


我是硬着头皮上班的,抽空网上查了下好让自己有个心理准备。



百度一搜便是绝症,这是很多年前就知道的,但病急乱投医果然是人之本性,我毅然决然还是搜了。


然后,各种甲状腺之类的就来了,再搜,淋巴瘤也来了,再搜,好家伙,直接恶性肿瘤十有八九了。



面对未知而产生的接近绝望的心情,想必不少人有类似经验。


比如我,下意识先想到的竟然不是我是不是要完蛋了,而是想到自己是家中独子,父母年迈身体有恙,妻子操劳,孩子尚小,家中主要经济来源也是我。


我一旦倒下,实在不敢想,往深了一想各种负面因子都蜂拥而来。


我不知道有多少人和我的性格相似,就是身体出了这种未知的问题,一面觉得应该去医院看看,一面又怕折腾来去最后拿到最不可接受的结果,可能不知道反而能活久一点,大概就是这种心情了。


是的,我大体是个胆子还算大的人,也猛然间抗拒去医院了。


不去医院的结果,就是你每天都在意这个肿块,每天都要摸摸它是不是变小了,是不是消失了,每天都小心呵护着它,甚至还想对它说说话倾诉一下,像是自己偷养的小情人一样。


只盼着某天睡觉醒来,用手一摸,哈哈没有了这样。


我就是差不多一个月都这样惶惶不可终日地度过,直到这周六才被妻子赶去医院做了检查。


透支和还债



30岁之前透支,30岁之后还债。



说来好笑,摸到肿块的第二天吧,还有朋友私信找我合作,换做平时,我肯定欣然接受,并开始设计文稿。


但身体有问题,一切都索然无味了,再次真切地体会到这种被现实打碎一切欲望的撕裂感。


2.png


为什么我30岁之后身体慢慢开始出现各种问题,这两年我有静下心来思考过。


到底还是30岁之前透支太多了,30岁之后你依然养成30岁之前的生活习惯,无异于自杀行为。



我把身体比作一根橡皮筋,它大概只能扯那么长,我长期将它扯那么那么长,我以为它没事,直到有一次我将它扯那么那么那么长,砰的一声它就断了。


我们都无法知道自己的这根橡皮筋到底能扯多长,只要它没断,我们都觉得它还能扯很长,代价就是,只需断一次,你再也无法重来了。



30岁之前,我努力学习各种知识,熬夜那是家常便饭,睡一觉便生龙活虎。


我就像以前上学的三好学生一样,在学校我扎扎实实,放学了我还进补习班,补习班回来了我还上网学知识。


回头想想,真特么离谱啊,我上学都没这样,走上社会了竟然付出了之前在学校几倍的努力。


早知如此,我好好上学读书最后进入一个更优质的圈子,不就少走很多弯路了吗,但是谁又会听当年的老师和父母一番肺腑之言呢。


埋怨过去没有什么意义,只能偶尔借着都市小说幻想一下带着记忆重生回校园的自己。


细数下来,我30岁之前熬过的夜比我加的班还多,我不是天天加班,但好像真的天天熬夜。


可我身体一点问题都没有,我觉得自己不是那种被命运抛弃的人,内心一直这么侥幸,你是不是也和我一样呢。


30岁之后,该来的还是来了,32岁那年,我有一次咳嗽入院,反复高烧,退了又发烧,医生一度以为是新冠,或结核,或白血病什么的,后来全部检查了都不是,发现就是普通的肺部感染。


每天两瓶抗病毒的点滴,大概半个月才逐渐恢复,人都瘦脱相了,这是我人生头一次住院,躺在病床上像废人一样。


等到33岁也就是去年,偶然头晕了一次,那种眩晕,天旋地转,犯恶心,怎么站怎么坐怎么躺都不行,真正要死的感觉。


后面我一度以为是年纪轻轻得了高血压,结果查了下是血脂的问题,还不算严重,但继续下去很可能会变成一些心脑血管疾病。


我难以置信,这可都是老年病啊,我一个30几岁的程序员说来就来了?


调养半年多,肉眼可见身体有好转,我又开始没忍住熬夜了,想做自己的课题,想分享更多的东西,这些都要花时间,而且包括一些其他领域的内容,想得太多,自然花的时间就多。


一不小心就连续熬了一个多月,平均每晚都是2点左右躺下,有时中午还不午休,刷手机找素材。


终于,脖子上起了肿块,让我整个人都蒙圈了,觉得一切努力都是在玩弄自己,忽然间什么都没意思了。


我尽量把这种感受描述出来,希望你们能看明白,真切体会一二。


为什么30岁之后我一熬夜就有问题出现,说白了,30岁之前透支了已经,一来是身体负荷达到临界,二来养成了多年的坏习惯,一时想改还改不过来。



30岁之前真别玩弄自己的身体了xdm,橡皮筋断了就真断了,接不上了,接上了也没以前的弹性了。



健康取决于自律和心情



对于程序员来说,健康取决于两点:自律和心情。



30岁之前,请学会自律,学习时间自律,生活作息自律,一日三餐自律,养成这样的习惯,30岁之后的你会受益匪浅。


自律真的很难,我就是一个很难做到的人,我有倔强地适应过,却又悲哀地失败了。


就像你是一个歇斯底里的人,忽然让你温文尔雅,你又能坚持多久呢。


我用很多鸡汤说服过自己,对于已经30几岁的我来说,也只能维持一段时间。


想看的多,想玩的多,想学的也多,时间是真不够啊,真想向天再借五百年。


我应该算是幸运的那一类,至少我这般透支身体,我还活着,也没用余生去直面绝望。



我用这两年的身体故障给自己上了重要的一课,人死如灯灭。



如果能重来,我一定会学习时间规划,我一定会把每天的时间安排的好好的。


我一定会保证一日三餐不落下,少吃外卖,多吃水果蔬菜。


我一定会保证每晚充足的睡眠,早睡早起,绝不熬夜。


我一定会每天下班和放假抽出一些时间运动和锻炼。


我不是说给自己听的,因为我已经透支了。


我是说给在看文章的你们听的,还年轻点的,还没透支的,请用我的现在当做你可能更坏的未来,早点醒悟,为时不晚。


自律很难,但不自律可能等死,这个选择一点也不难。



工作压力大,作为程序员是避免不了的,所以我以前有劝过大家,薪水的重要性只占一半,你应该追寻一份薪水尚可,但压力一定在承受范围内的工作,这是我认为在国内对于程序员来说相对友好的途径。



我进入IT行业目前为止的整个生涯中,学习阶段听到过传智播客张孝祥老师的猝死,工作阶段听说过附近的4396游戏公司里面30多岁程序员猝死,今年又听到了左耳朵耗子先生的离世。


我想着,那一天,离我和你还有多远。


心情真的很重要,至少能快速反应在身体上。


当我这周六被妻子劝说去检查的时候,我内心一直是紧张的,妻子没去,就在家陪着孩子,跟我说你自己去吧,如果有坏消息就别回复了,等回来再说,如果没什么事那就发个微信。


我想我理解她的意思了,点了点头就骑车去了医院。


医院真不是什么好地方,我就是给医院干活的,我全身上下都讨厌这里。


最煎熬的时间是做彩超前的一个多小时,因为人太多,我得排队,盯着大屏上的号序,我脑子里想了很多事情,甚至连最坏的打算都想好了。


人就很奇怪,越是接近黑暗,越是能回忆起非常多的往事,连高中打篮球挥洒汗水的模样和搞笑的投篮姿势都能想起来。


喊到我的时候,我心跳了一下,然后麻木地进去了,躺下的时候,医生拿着仪器对着我的脖子扫描,此时的我是近一个月以来第一次内心平静,当真好奇怪的感觉。


随着医生一句:没什么事,就一个淋巴结。


犹如审判一般,我感觉一下无罪释放了。


当时听到这句话简直犹如天籁,这会儿想起来还感觉毛孔都在欢快地愉悦。


我问她不是什么肿瘤或甲状腺吧,她说不是,就一个正常的淋巴结,可能是炎症导致了增生,这种一般3个多月至半年才会完全消掉。


这是当时拍的结果


3.jpg


拿给主任医师看了之后,对方也说一点事没有,只是告诫我别再熬夜了。


我不知道人生还会给我几次机会,但我从20几岁到30几岁,都没有重视过这个问题,也没有认真思考过。


直到最近,我才发现,活着真好。


当晚是睡得最踏实的一晚,一点梦都没做,中途也没醒,一觉到天亮。


更离谱的是,早上我摸了一下脖子,竟然真的小了点,这才短短一天,说了都没人信。


我头一次相信,心情真的会影响身体,你心情好了,身体的器官和血液仿佛都欢腾了起来。


如何保持一个好心情,原来这般重要,我拿自己的身体给大家做实验了,有用!



希望大家每天在自律的基础上保持好心情,不负年华,不负自己。



总结


xdm,好好活着,快乐活着。


作者:程序员济癫
来源:juejin.cn/post/7300564263344128051
收起阅读 »

DDD落地之架构分层

一.前言 DDD系列Demo被好多读者催更。肝了一周,参考了众多资料,与众多DDD领域的大佬进行了结构与理念的沟通后,终于完成了改良版的代码层次结构。 本文将给大家展开讲一讲 为什么我们要使用DDD? 到底什么样的系统适配DDD? DDD的代码怎么做,为什么...
继续阅读 »

一.前言


DDD系列Demo被好多读者催更。肝了一周,参考了众多资料,与众多DDD领域的大佬进行了结构与理念的沟通后,终于完成了改良版的代码层次结构。


本文将给大家展开讲一讲



  • 为什么我们要使用DDD?

  • 到底什么样的系统适配DDD?

  • DDD的代码怎么做,为什么要这么做?


你可以直接阅读本文,但我建议先阅读一文带你落地DDD,如果你对DDD已经有过了解与认知,请直接阅读。


干货直接上,点此查看demo代码,配合代码阅读本文,体验更深


DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

二.为什么我们要使用DDD


虽然我在第一篇DDD的系列文:一文带你落地DDD中已经做过介绍我们使用DDD的理由。但是对于业务架构不太熟悉的同学还是无法get到DDD的优势是什么。



作为程序员嘛,我还是比较提倡大家多思考,多扎实自己的基础知识的。面试突击文虽香,但是,面试毕竟像是考试,更多时候我们还是需要在一家公司里面去工作。别人升职加薪,你怨声载道,最后跳槽加小几千没有意义嘛。



image.png


言归正传,我相信基本上99%的java开发读者,不管你是计科专业出身还是跨专业,初学spring或者springboot的时候,接触到的代码分层都是MVC。


这说明了MVC有它自身独有的优势:



  • 开发人员可以只关注整个结构中的其中某一层;

  • 可以很容易的用新的实现来替换原有层次的实现;

  • 可以降低之间的依赖;

  • 有利于标准化;

  • 利于各逻辑的复用。


但是真实情况是这样吗?随着你系统功能迭代,业务逻辑越来越复杂之后。MVC三层中,V层作为数据载体,C层作为逻辑路由都是很薄的一层,大量的代码都堆积在了M层(模型层)。一个service的类,动辄几百上千行,大的甚至几万行,逻辑嵌套复杂,主业务逻辑不清晰。service做的稍微轻量化一点的,代码就像是胶水,把数据库执行逻辑与控制返回给前端的逻辑胶在一起,主次不清晰。


一看你的工程,类啊,代码量啊都不少,你甚至不知道如何入手去修改“屎山”一样的代码。


归根到底的原因是什么?


image.png


service承载了它这个年纪不该承受的业务逻辑。


举个例子: 你负责了一个项目的从0到1的搭建,后面业务越来越好,招了新的研发进来。新的研发跟你一起开发,service层逻辑方法类似有不完全相同,为了偷懒,拷贝了你的代码,改了一小段逻辑。这时候基本上你的代码量已经是乘以2了。同理再来一个人,你的代码量可能乘了4。然而作为数据载体的POJO繁多,里面空空如也,你想把逻辑放进去,却发现无从入手。POJO的贫血模型陷入了恶性循环。


那么DDD为什么可以去解决以上的问题呢?


DDD核心思想是什么呢?解耦!让业务不是像炒大锅饭一样混在一起,而是一道道工序复杂的美食,都有他们自己独立的做法。


DDD的价值观里面,任何业务都是某个业务领域模型的职责体现。A领域只会去做A领域的事情,A领域想去修改B领域,需要找中介(防腐层)去对B领域完成操作。我想完成一个很长的业务逻辑动作,在划分好业务边界之后,交给业务服务的编排者(应用服务)去组织业务模型(聚合)完成逻辑。


这样,每个服务(领域)只会做自己业务边界内的事情,最小细粒度的去定义需求的实现。原先空空的贫血模型摇身一变变成了充血模型。原理冗长的service里面类似到处set,get值这种与业务逻辑无关的数据载体包装代码,都会被去除,进到应用服务层,你的代码就是你的业务逻辑。逻辑清晰,可维护性高!


三.到底什么样的系统适配DDD


看完上文对于DDD的分析之后是不是觉得MVC一对比简直就是垃圾。但是你回过头来想想,DDD其实在10几年前就已经被提出来了,但为什么是近几年才开始逐渐进入大众的视野?


相信没有看过我之前DDD的文章的同学看了我上面的分析大概也能感觉的到,DDD这个系统不像MVC结构那么简单,分层肯定更加复杂。


因此不是适配DDD的系统是什么呢?


中小规模的系统,本身业务体量小,功能单一,选择mvc架构无疑是最好的。


项目化交付的系统,研发周期短,一天到晚按照甲方的需求定制功能。


相反的,适配DDD的系统是什么呢?


中大规模系统,产品化模式,业务可持续迭代,可预见的业务逻辑复杂性的系统。


总而言之就是:


你还不了解DDD或者你们系统功能简单,就选择MVC.


你不知道选用什么技术架构做开发,业务探索阶段,选用MVC.


其他时候酌情考虑上DDD。


四.DDD的代码怎么做,为什么要这么做


4.1.经典分层


image-20210913185730992.png
在用户界面层和业务逻辑层中间加了应用层(Application Layer) , 业务逻辑层改为领域层, 数据访问层改为基础设施层(Infrastructure Layer) , 突破之前数据库访问的限制。 固有的思维中,依赖是自顶向下传递的,用户界面依赖应用层,应用层依赖领域层和基础设施层,越往下的层,与业务越远,并更加通用;出于重用的考虑,通用的功能会剥离成框架或者平台,而在低层次(基础设施层)会调用、依赖这些框架,也就导致了业务对象(领域层)依赖外部平台或框架。


4.2.依赖倒置分层


image-20210913190943631.png


为了突破这种违背本身业务领域的依赖,将基础设施往上提,当领域服务与基础设置有交集时,定义一个接口(灰度接口),让基础设施去实现对应的接口。接口本身是介于应用服务与领域服务之间的,为了纯净化领域层而存在。


Image.png


这么做的好处就是,从分包逻辑来看,上层依赖下层,底层业务域不依赖任何一方,领域独立。


4.3.DDD分层请求调用链


未命名文件.png


4.3.1.增删改


1.用户交互层发起请求


2.应用服务层编排业务逻辑【仅做方法编排,不处理任何逻辑】


3.编排逻辑如果依赖三方rpc,则定义adapter,方式三方服务字段影响到本服务。


4.编排逻辑如果依赖其他领域服务,应用服务,可直接调用,无需转化。但是与当前框架不相符合的,例如发送短信这种,最好还是走一下适配器,运营商换了,依赖的应用服务没准都不同了。


5.聚合根本身无法处理的业务在领域层处理,依赖倒置原则,建立一层interfaces层(灰度防腐层),放置领域层与基础设置的耦合。


6.逻辑处理结束,调用仓储聚合方法。


4.3.2.查询


CQRS模型,与增删改不同的应用服务,是查询应用服务。不必遵守DDD分层规则(不会对数据做修改)。简单逻辑甚至可以直接由controller层调用仓储层返回数据。


五.总结


其实DDD在分层上从始至终一致在贯穿的一个逻辑就是,解耦。如果真的极端推崇者,每一层,每一步都会增加一个适配器。我觉得这个对于研发来说实在太痛苦了,还是要在架构与实际研发上做一个中和。


六.特别鸣谢


lilpilot


image.png


作者:柏炎
来源:juejin.cn/post/7007382308667785253
收起阅读 »

DDD落地之仓储

一.前言 hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。 昨天媳妇儿生病了在医院,她挂点滴的时候,我也没闲下来,抓紧时间做出了DDD的...
继续阅读 »

一.前言


hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。


昨天媳妇儿生病了在医院,她挂点滴的时候,我也没闲下来,抓紧时间做出了DDD的第一版demo,就冲这点,


大家点个关注,点个赞,不过分吧。


image.png


这个项目我会持续维护,针对读者提出的issue与相关功能点的增加,我都会持续的补充。


查看demo


DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

本文将给大家介绍的同样是DDD中的一个比较好理解与落地的知识点-仓储



本系列为MVC框架迁移至DDD,考虑到国内各大公司内还是以mybatis作为主流进行业务开发。因此,demo中的迁移与本文的相关实例均以mybatis进行演示。至于应用仓储选型是mybatis还是jpa,文中会进行分析,请各位仔细阅读本文。


二.仓储


2.1.仓储是什么


原著《领域驱动设计:软件核心复杂性应对之道》 中对仓储的有关解释:



为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的Aggregate提供Repository。让客户始终聚焦于型,而将所有对象存储和访问操作交给Repository来完成。



上文通俗的讲,当领域模型一旦建立之后,你不应该关心领域模型的存取方式。仓储就相当于一个功能强大的仓库,你告诉他唯一标识:例如订单id,它就能把所有你想要数据按照设置的领域模型一口气组装返回给你。存储时也一样,你把整块订单数据给他,至于它怎么拆分,放到什么存储介质【DB,Redis,ES等等】,这都不是你业务应该关心的事。你完全信任它能帮助你完成数据管理工作。


2.2.为什么要用仓储


先说贫血模型的缺点:



有小伙伴之前提出过不知道贫血模型的定义,这里做一下解释。贫血模型:PO,DTO,VO这种常见的业务POJO,都是数据java里面的数据载体,内部没有任何的业务逻辑。所有业务逻辑都被定义在各种service里面,service做了各种模型之间的各种逻辑处理,臃肿且逻辑不清晰。充血模型:建立领域模型形成聚合根,在聚合根即表示业务,在聚合内部定义当前领域内的业务处理方法与逻辑。将散落的逻辑进行收紧。




  1. 无法保护模型对象的完整性和一致性: 因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。

  2. 对象操作的可发现性极差: 单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数?

  3. 代码逻辑重复: 比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。

  4. 代码的健壮性差: 比如一个数据模型的变化可能导致从上到下的所有代码的变更。

  5. 强依赖底层实现: 业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。


image.png


虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的99%的代码都是基于贫血模型,为什么呢?



  1. 数据库思维: 从有了数据库的那一天起,开发人员的思考方式就逐渐从写业务逻辑转变为了写数据库逻辑,也就是我们经常说的在写CRUD代码

  2. 贫血模型“简单”: 贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情

  3. 脚本思维: 很多常见的代码都属于脚本胶水代码,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。


但是可能最核心的原因在于,实际上我们在日常开发中,混淆了两个概念:



  • 数据模型(Data Model): 指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型。

  • 业务模型/领域模型(Domain Model): 指业务逻辑中,相关联的数据该如何联动。


所以,解决这个问题的根本方案,就是要在代码里区分Data Model和Domain Model,具体的规范会在后文详细描述。在真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。


能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值。


image.png


三.落地


3.1.落地概念图


1.png


DTO Assembler: 在Application层 【应用服务层】EntityDTO的转化器有一个标准的名称叫DTO Assembler 【汇编器】



DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。



Data Converter: 在Infrastructure层 【基础设施层】EntityDO的转化器没有一个标准名称,但是为了区分Data Mapper,我们叫这种转化器Data Converter。这里要注意Data Mapper通常情况下指的是DAO,比如Mybatis的Mapper。


3.2.Repository规范


首先聚合仓储之间是一一对应的关系。仓储只是一种持久化的手段,不应该包含任何业务操作。




  1. 接口名称不应该使用底层实现的语法


    定义仓储接口,接口中有save类似的方法,与面向集合的仓储的不同点:面向集合的仓储只有在新增时调用add即可,面向持久化的无论是新增还是修改都要调用save




  2. 出参入参不应该使用底层数据格式:


    需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO 。更近一步,Repository 接口实际上应该存在于Domain层,根本看不到 DO 的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。




  3. 应该避免所谓的“通用”Repository模式


    很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类




  4. 不要在仓储里面编写业务逻辑


    首先要清楚的是,仓储是存在基础设施层的,并不会去依赖上层的应用服务,领域服务等。




图片1.png


仓储内部仅能依赖mapper,es,redis这种存储介质包装框架的工具类。save动作,仅对传入的聚合根进行解析放入不同的存储介质,你想放入redis,数据库还是es,由converter来完成聚合根的转换解析。同样,从不同的存储介质中查询得到的数据,交给converter来组装。




  1. 不要在仓储内控制事务


    你的仓储用于管理的是单个聚合,事务的控制应该取决于业务逻辑的完成情况,而不是数据存储与更新情况。




3.3.CQRS仓储


2222.png
回顾一下这张图,可以发现增删改数据模型走了DDD模型。而查询则从应用服务层直接穿透到了基础设施层。


这就是CQRS模型,从数据角度来看,增删改数据非幂等操作,任何一个动作都能对数据进行改动,称为危险行为。而查询,不会因为你查询次数的改变,而去修改到数据,称为安全行为。而往往功能迭代过程中,数据修改的逻辑还是复杂的,因此建模也都是针对于增删改数据而言的。


那么查询数据有什么原则吗?




  1. 构建独立仓储


    查询的仓储与DDD中的仓储应该是两个方法,互相独立。DDD中的仓储方法严格意义上只有三个:save,delete,byId,内部没有业务逻辑,仅对数据做拆分组合。查询仓储方法可以根据用户需求,研发需求来自定义仓储返回的数据结构,不限制返回的数据结构为聚合,可以是限界范围内的任意自定义结构。




  2. 不要越权


    不要再查询仓储内做太多的sql逻辑,数据查询组装交给assember。




  3. 利用好assember


    类似于首页,一个接口可能返回的数据来源于不同的领域,甚至有可能不是自己本身业务服务内部的。


    这种复杂的结果集,交给assember来完成最终结果集的组装与返回。结构足够简单的情况下,用户交互层【controller,mq,rpc】甚至可以直接查询仓储的结果进行返回。


    当然还有很多其他博文中会说,如果查询结果足够简单,甚至可以直接在controller层调用mapper查询结果返回。除非你是一个固定的字典服务或者规则表,否则哪怕业务再简单,你的业务也会迭代,后续查询模型变化了,dao层里面的查询逻辑就外溢到用户交互层,显然得不偿失。




3.4.ORM框架选型


目前主流使用的orm框架就是mybatis与jpa。国内使用mybatis多,国外使用jpa多。两者框架上的比较本文不做展开,不清楚两个框架实现差异的,可以自行百度。


那么我们如果做DDD建模的话到底选择哪一种orm框架更好呢?


mybatis是一个半自动框架(当然现在有mybatis-plus的存在,mybatis也可以说是跻身到全自动框架里面了),国内使用它作为orm框架是主流。为什么它是主流,因为它足够简单,设计完表结构之后,映射好字段就可以进行开发了,业务逻辑可以用胶水一个个粘起来。而且在架构支持上,mybatis不支持实体嵌套实体,这个在领域模型建模结束后的应用上就优于mybatis。


当然我们今天讨论的是架构,任何时候,技术选型不是决定我们技术架构的关键性因素


jpa天生就具备做DDD的优势。但是这并不意味着mybatis就做不了DDD了,我们完全可以将领域模型的定义与orm框架的应用分离,单独定义converter去实现领域模型与数据模型之间的转换,demo中我也是这么给大家演示的。


image.png




当然,如果是新系统或者迁移时间足够多,我还是推荐使用JPA的,红红火火恍恍惚惚~


image.png


四.demo演示


需求描述,用户领域有四个业务场景



  1. 新增用户

  2. 修改用户

  3. 删除用户

  4. 用户数据在列表页分页展示



核心实现演示,不贴全部代码,完整demo可从文章开头的github仓库获取



4.1.领域模型


/**
* 用户聚合根
*
*
@author baiyan
*/

@Getter
@NoArgsConstructor
public class User extends BaseUuidEntity implements AggregateRoot {

  /**
    * 用户名
    */

  private String userName;

  /**
    * 用户真实名称
    */

  private String realName;

  /**
    * 用户手机号
    */

  private String phone;

  /**
    * 用户密码
    */

  private String password;

  /**
    * 用户地址
    */

  private Address address;

  /**
    * 用户单位
    */

  private Unit unit;

  /**
    * 角色
    */

  private List roles;

  /**
    * 新建用户
    *
    *
@param command 新建用户指令
    */

  public User(CreateUserCommand command){
      this.userName = command.getUserName();
      this.realName = command.getRealName();
      this.phone = command.getPhone();
      this.password = command.getPassword();
      this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
      this.relativeRoleByRoleId(command.getRoles());
  }

  /**
    * 修改用户
    *
    *
@param command 修改用户指令
    */

  public User(UpdateUserCommand command){
      this.setId(command.getUserId());
      this.userName = command.getUserName();
      this.realName = command.getRealName();
      this.phone = command.getPhone();
      this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
      this.relativeRoleByRoleId(command.getRoles());
  }

  /**
    * 组装聚合
    *
    *
@param userPO
    *
@param roles
    */

  public User(UserPO userPO, List roles){
      this.setId(userPO.getId());
      this.setDeleted(userPO.getDeleted());
      this.setGmtCreate(userPO.getGmtCreate());
      this.setGmtModified(userPO.getGmtModified());
      this.userName = userPO.getUserName();
      this.realName = userPO.getRealName();
      this.phone = userPO.getPhone();
      this.password = userPO.getPassword();
      this.setAddress(userPO.getProvince(),userPO.getCity(),userPO.getCounty());
      this.relativeRoleByRolePO(roles);
      this.setUnit(userPO.getUnitId(),userPO.getUnitName());
  }

  /**
    * 根据角色id设置角色信息
    *
    *
@param roleIds 角色id
    */

  public void relativeRoleByRoleId(List<Long> roleIds){
      this.roles = roleIds.stream()
              .map(roleId->new Role(roleId,null,null))
              .collect(Collectors.toList());
  }

  /**
    * 设置角色信息
    *
    *
@param roles
    */

  public void relativeRoleByRolePO(List roles){
      if(CollUtil.isEmpty(roles)){
          return;
      }
      this.roles = roles.stream()
              .map(e->new Role(e.getId(),e.getCode(),e.getName()))
              .collect(Collectors.toList());
  }

  /**
    * 设置用户地址信息
    *
    *
@param province 省
    *
@param city 市
    *
@param county 区
    */

  public void setAddress(String province,String city,String county){
      this.address = new Address(province,city,county);
  }

  /**
    * 设置用户单位信息
    *
    *
@param unitId
    *
@param unitName
    */

  public void setUnit(Long unitId,String unitName){
      this.unit = new Unit(unitId,unitName);
  }

}

4.2.DDD仓储实现


/**
*
* 用户领域仓储
*
* @author baiyan
*/

@Repository
public class UserRepositoryImpl implements UserRepository {

  @Autowired
  private UserMapper userMapper;

  @Autowired
  private RoleMapper roleMapper;

  @Autowired
  private UserRoleMapper userRoleMapper;

  @Override
  public void delete(Long id){
      userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRolePO::getUserId,id));
      userMapper.deleteById(id);
  }

  @Override
  public User byId(Long id){
      UserPO user = userMapper.selectById(id);
      if(Objects.isNull(user)){
          return null;
      }
      List userRoles = userRoleMapper.selectList(Wrappers.lambdaQuery()
              .eq(UserRolePO::getUserId, id).select(UserRolePO::getRoleId));
      List roleIds = CollUtil.isEmpty(userRoles) ? new ArrayList<>() : userRoles.stream()
              .map(UserRolePO::getRoleId)
              .collect(Collectors.toList());
      List roles = roleMapper.selectBatchIds(roleIds);
      return UserConverter.deserialize(user,roles);
  }


  @Override
  public User save(User user){
      UserPO userPo = UserConverter.serializeUser(user);
      if(Objects.isNull(user.getId())){
          userMapper.insert(userPo);
          user.setId(userPo.getId());
      }else {
          userMapper.updateById(userPo);
          userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRolePO::getUserId,user.getId()));
      }
      List userRolePos = UserConverter.serializeRole(user);
      userRolePos.forEach(userRoleMapper::insert);
      return this.byId(user.getId());
  }

}

4.3.查询仓储


/**
*
* 用户信息查询仓储
*
*
@author baiyan
*/

@Repository
public class UserQueryRepositoryImpl implements UserQueryRepository {

  @Autowired
  private UserMapper userMapper;

  @Override
  public Page<UserPageDTO> userPage(KeywordQuery query){
      Page<UserPO> userPos = userMapper.userPage(query);
      return UserConverter.serializeUserPage(userPos);
  }

}

五.mybatis迁移方案


以OrderDO与OrderDAO的业务场景为例



  1. 生成Order实体类,初期字段可以和OrderDO保持一致

  2. 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成

  3. 写单元测试,确保Order和OrderDO之间的转化100%正确

  4. 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性

  5. 将原有代码里使用了OrderDO的地方改为Order

  6. 将原有代码里使用了OrderDAO的地方都改为用OrderRepository

  7. 通过单测确保业务逻辑的一致性。


六.总结



  1. 数据模型与领域模型需要正确区分,仓储是它们互相转换的抽象实现。

  2. 仓储对业务层屏蔽实现,即领域层不需要关注领域对象如何持久化。

  3. 仓储是一个契约,而不是数据访问层。它明确表明聚合所必需的数据操作。

  4. 仓储用于管理单个聚合,它不应该控制事务。

  5. ORM框架选型在迁移过程中不可决定性因此,可以嫁接转换器,但是还是优先推荐JPA。

  6. 查询仓储可以突破DDD边界,用户交互层可以直接进行查询。


七.特别鸣谢


lilpilot


image.png


作者:柏炎
来源:juejin.cn/post/7006595886646034463
收起阅读 »

DDD落地之事件驱动模型

一.前言 hello,everyone。一日不见,如隔24小时。 周末的时候写了一文带你落地DDD,发现大家对于新的领域与知识都挺感兴趣的。后面将会出几篇DDD系列文章给大家介绍mvc迁移DDD实际要做的一些步骤。 DDD系列博客一文带你落地DDDDDD落地...
继续阅读 »

一.前言


hello,everyone。一日不见,如隔24小时。


image.png


周末的时候写了一文带你落地DDD,发现大家对于新的领域与知识都挺感兴趣的。后面将会出几篇DDD系列文章给大家介绍mvc迁移DDD实际要做的一些步骤。


DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

DDD的理念中有一个是贯穿始终的,业务边界与解耦。我最开始不了解DDD的时候,我就觉得事件驱动模型能够非常好的解耦系统功能。当然,这个是我比较菜,在接触DDD之后才开始对事件驱动模型做深度应用与了解。其实无论是在spring的框架中还是在日常MVC代码的编写过程中,巧用事件驱动模型都能很好的提高代码的可维护性。


image.png


因此,本文将对DDD中使用事件驱动模型建立与踩坑做一个系统性的介绍。从应用层面出发,帮助大家更好的去进行架构迁移。



我的第一本掘金小册《深入浅出DDD》已经在掘金上线,欢迎大家试读~



DDD的微信群我也已经建好了,由于文章内不能放二维码,大家可以加我微信baiyan_lou,备注DDD交流,我拉你进群,欢迎交流共同进步。


二.事件驱动模型


2.1.为什么需要事件驱动模型


一个框架,一门技术,使用之前首先要清楚,什么样的业务场景需要使用这个东西。为什么要用跟怎么样把他用好更加重要。


假设我们现在有一个比较庞大的单体服务的订单系统,有下面一个业务需求:创建订单后,需要下发优惠券,给用户增长积分


先看一下,大多数同学在单体服务内的写法。【假设订单,优惠券,积分均为独立service】


//在orderService内部定义一个放下
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command){
 //创建订单
 Long orderId = this.doCreate(command);
 //发送优惠券
 couponService.sendCoupon(command,orderId);
 //增长积分
 integralService.increase(command.getUserId,orderId);
}

image.png


上面这样的代码在线上运行会不会有问题?不会!


image.png


那为什么要改呢?


原因是,业务需求在不断迭代的过程中,与当前业务非强相关的主流程业务,随时都有可能被替换或者升级。


双11大促,用户下单的同时需要给每个用户赠送几个小礼品,那你又要写一个函数了,拼接在主方法的后面。双11结束,这段要代码要被注释。有一年大促,赠送的东西改变,代码又要加回来。。。。


来来回回的,订单逻辑变得又臭又长,注释的代码逻辑很多还不好阅读与理解。


image.png


如果用了事件驱动模型,那么当第一步创建订单成功之后,发布一个创建订单成功的领域事件。优惠券服务,积分服务,赠送礼品等等监听这个事件,对监听到的事件作出相应的处理。


事件驱动模型代码


//在orderService内部定义一个放下
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command){
//创建订单
Long orderId = this.doCreate(command);
publish(orderCreateEvent);
}

//各个需要监听的服务
public void handlerEvent(OrderCreateEvent event){
//逻辑处理
}

image.png


代码解耦,高度符合开闭原则


2.2.事件驱动模型选型


2.2.1.JDK中时间驱动机制


JDK为我们提供的事件驱动(EventListener、EventObject)、观察者模式(Observer)。


JDK不仅提供了Observable类、Observer接口支持观察者模式,而且也提供了EventObjectEventListener接口来支持事件监听模式。


观察者(Observer)相当于事件监听者(监听器) ,被观察者(Observable)相当于事件源和事件,执行逻辑时通知observer即可触发oberver的update,同时可传被观察者和参数。简化了事件-监听模式的实现


// 观察者,实现此接口即可
public interface Observer {

/**
* 当被观察的对象发生变化时候,这个方法会被调用
* Observable o:被观察的对象
* Object arg:传入的参数
**/

 void update(Observable o, Object arg);
}

// 它是一个Class
public class Observable {

 // 是否变化,决定了后面是否调用update方法
 private boolean changed = false;
 
 // 用来存放所有`观察自己的对象`的引用,以便逐个调用update方法
 // 需要注意的是:1.8的jdk源码为Vector(线程安全的),有版本的源码是ArrayList的集合实现;
 private Vector obs;

 public Observable() {
 obs = new Vector<>();
}

public synchronized void addObserver(Observer o); //添加一个观察者 注意调用的是addElement方法, 添加到末尾   所以执行时是倒序执行的
public synchronized void deleteObserver(Observer o);
public synchronized void deleteObservers(); //删除所有的观察者

// 循环调用所有的观察者的update方法
public void notifyObservers();
public void notifyObservers(Object arg);
 public synchronized int countObservers() {
 return obs.size();
}

 // 修改changed的值
 protected synchronized void setChanged() {
changed = true;
}
 
 protected synchronized void clearChanged() {
changed = false;
}
 
 public synchronized boolean hasChanged() {
return changed;
}
}

内部观察者队列啥的都交给Observable去处理了, 并且,它是线程安全的。但是这种方式其实使用起来并不是那么的方便,没有一个消息总线,需要自己单独去维护观察者与被观察者。对于业务系统而言,还需要自己单独去维护每一个观察者的添加。


2.2.2.spring中的事件驱动机制


spring在4.2之后提供了@EventListener注解,让我们更便捷的使用监听。


了解过spring启动流程的同学都知道,Spring容器刷新的时候会发布ContextRefreshedEvent事件,因此若我们需要监听此事件,直接写个监听类即可。


@Slf4j
@Component
public class ApplicationRefreshedEventListener implements ApplicationListener {

  @Override
  public void onApplicationEvent(ContextRefreshedEvent event) {
      //解析这个事件,做你想做的事,嘿嘿
  }
}

同样的我们也可以自己来定义一个事件,通过ApplicationEventPublisher发送。


/**
* 领域事件基类
*
*
@author baiyan
*
@date 2021/09/07
*/

@Getter
@Setter
@NoArgsConstructor
public abstract class BaseDomainEvent<T> implements Serializable {

  private static final long serialVersionUID = 1465328245048581896L;

  /**
    * 领域事件id
    */

  private String demandId;

  /**
    * 发生时间
    */

  private LocalDateTime occurredOn;

  /**
    * 领域事件数据
    */

  private T data;

  public BaseDomainEvent(String demandId, T data) {
      this.demandId = demandId;
      this.data = data;
      this.occurredOn = LocalDateTime.now();
  }

}

定义统一的业务总线发送事件


/**
* 领域事件发布接口
*
*
@author baiyan
*
@date 2021/09/07
*/

public interface DomainEventPublisher {

  /**
    * 发布事件
    *
    *
@param event 领域事件
    */

  void publishEvent(BaseDomainEvent event);

}

/**
* 领域事件发布实现类
*
* @author baiyan
* @date 2021/09/07
*/

@Component
@Slf4j
public class DomainEventPublisherImpl implements DomainEventPublisher {

  @Autowired
  private ApplicationEventPublisher applicationEventPublisher;

  @Override
  public void publishEvent(BaseDomainEvent event) {
      log.debug("发布事件,event:{}", event.toString());
      applicationEventPublisher.publishEvent(event);
  }

}

监听事件


@Component
@Slf4j
public class UserEventHandler {

  @EventListener
  public void handleEvent(DomainEvent event) {
      //doSomething
  }

}

芜湖,起飞~


image.png


相比较与JDK提供的观察者模型的事件驱动,spring提供的方式就是yyds。


2.3.事件驱动之事务管理


平时我们在完成某些数据的入库后,发布了一个事件。后续我们进行操作记录在es的记载,但是这时es可能集群响应超时了,操作记录入库失败报错。但是从业务逻辑上来看,操作记录的入库失败,不应该影响到主流程的逻辑执行,需要事务独立。亦或是,如果主流程执行出错了,那么我们需要触发一个事件,发送钉钉消息到群里进行线上业务监控,需要在主方法逻辑中抛出异常再调用此事件。这时,我们如果使用的是@EventListener,上述业务场景的实现就是比较麻烦的逻辑了。


为了解决上述问题,Spring为我们提供了两种方式:


(1)@TransactionalEventListener注解。


(2) 事务同步管理器TransactionSynchronizationManager


本文针对@TransactionalEventListener进行一下解析。


我们可以从命名上直接看出,它就是个EventListener,在Spring4.2+,有一种叫做@TransactionEventListener的方式,能够实现在控制事务的同时,完成对对事件的处理。


//被@EventListener标注,表示它能够监听事件
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EventListener
public @interface TransactionalEventListener {

//表示当前事件跟随消息发送方事务的出发时机,默认为消息发送方事务提交之后才进行处理。
  TransactionPhase phase() default TransactionPhase.AFTER_COMMIT;

  //true时不论发送方是否存在事务均出发当前事件处理逻辑
  boolean fallbackExecution() default false;

  //监听的事件具体类型,还是建议指定一下,避免监听到子类的一些情况出现
  @AliasFor(annotation = EventListener.class, attribute = "classes")
  Class[] value() default {};

  //指向@EventListener对应的值
  @AliasFor(annotation = EventListener.class, attribute = "classes")
  Class[] classes() default {};

  //指向@EventListener对应的值
  String condition() default "";

}

public enum TransactionPhase {
  // 指定目标方法在事务commit之前执行
  BEFORE_COMMIT,

  // 指定目标方法在事务commit之后执行
  AFTER_COMMIT,

  // 指定目标方法在事务rollback之后执行
  AFTER_ROLLBACK,

  // 指定目标方法在事务完成时执行,这里的完成是指无论事务是成功提交还是事务回滚了
  AFTER_COMPLETION
}

我们知道,Spring的事件监听机制(发布订阅模型)实际上并不是异步的(默认情况下),而是同步的来将代码进行解耦。而@TransactionEventListener仍是通过这种方式,但是加入了回调的方式来解决,这样就能够在事务进行Commited,Rollback…等时候才去进行Event的处理,来达到事务同步的目的。


三.实践及踩坑


针对是事件驱动模型里面的@TransactionEventListener@EventListener假设两个业务场景。


新增用户,关联角色,增加关联角色赋权操作记录。


1.统一事务:上述三个操作事务一体,无论哪个发生异常,数据统一回滚。


2独立事务:上述三个操作事务独立,事件一旦发布,后续发生任意异常均不影响。


3.1.统一事务


用户新增


@Service
@Slf4j
public class UserServiceImpl implements UserService {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Transactional(rollbackFor = Exception.class)
  public void createUser(){
      //省略非关键代码
      save(user);
      domainEventPublisher.publishEvent(userEvent);
  }
}

用户角色关联


@Component
@Slf4j
public class UserEventHandler {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Autowired
  UserRoleService userRoleService;

  @EventListener
  public void handleEvent(UserEvent event) {
      log.info("接受到用户新增事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleService.save(userRole);
      domainEventPublisher.publishEvent(userRoleEvent);
  }

}

用户角色操作记录


@Component
@Slf4j
public class UserRoleEventHandler {

  @Autowired
  UserRoleRecordService userRoleRecordService;

  @EventListener
  public void handleEvent(UserRoleEvent event) {
      log.info("接受到userRole事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleRecordService.save(record);
  }

}

以上即为同一事务下的一个逻辑,任意方法内抛出异常,所有数据的插入逻辑都会回滚。


image.png


给出一下结论,@EventListener标注的方法是被加入在当前事务的执行逻辑里面的,与主方法事务一体。


踩坑1:


严格意义上来说这里不算是把主逻辑从业务中拆分出来了,还是在同步的事务中,当然这个也是有适配场景的,大家为了代码简洁性与函数级逻辑清晰可以这么做。但是这样做其实不是那么DDD,DDD中应用服务的一个方法即为一个用例,里面贯穿了主流程的逻辑,既然是当前系统内强一致性的业务,那就应该在一个应用服务中体现。当然这个是属于业务边界的。举例的场景来看,用户与赋权显然不是强一致性的操作,赋权失败,不应该影响我新增用户,所以这个场景下做DDD改造,不建议使用统一事务。


踩坑2:


listener里面的执行逻辑可能比较耗时,需要做异步化处理,在UserEventHandler方法上标注@Async,那么这里与主逻辑的方法事务就隔离开了,监听器内的事务开始独立,将不会影响到userService内的事务。例如其他代码不变的情况下用户角色服务代码修改如下


@Component
@Slf4j
public class UserEventHandler {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Autowired
  UserRoleService userRoleService;

  @EventListener
  @Async
  public void handleEvent(UserEvent event) {
      log.info("接受到用户新增事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleService.save(userRole);
      domainEventPublisher.publishEvent(userRoleEvent);
      throw new RuntimeException("制造一下异常");
  }

}

发现,用户新增了,用户角色关联关系新增了,但是操作记录没有新增。第一个结果好理解,第二个结果就奇怪了把,事件监听里面抛了异常,但是居然数据保存成功了。


这里其实是因为UserEventHandlerhandleEvent方法外层为嵌套@TransactionaluserRoleService.save操作结束,事务就提交了,后续的抛异常也不影响。为了保持事务一致,在方法上加一个@Transactional即可。


3.2.独立事务


@EventListener作为驱动加载业务分散代码管理还挺好的。但是在DDD层面,事务数据被杂糅在一起,除了问题一层层找也麻烦,而且数据捆绑较多,还是比较建议使用@TransactionalEventListene


用户新增


@Service
@Slf4j
public class UserServiceImpl implements UserService {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Transactional(rollbackFor = Exception.class)
  public void createUser(){
      //省略非关键代码
      save(user);
      domainEventPublisher.publishEvent(userEvent);
  }
}

用户角色关联


@Component
@Slf4j
public class UserEventHandler {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Autowired
  UserRoleService userRoleService;

  @TransactionalEventListener
  public void handleEvent(UserEvent event) {
      log.info("接受到用户新增事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleService.save(userRole);
      domainEventPublisher.publishEvent(userRoleEvent);
  }

}

用户角色操作记录


@Component
@Slf4j
public class UserRoleEventHandler {

  @Autowired
  UserRoleRecordService userRoleRecordService;

  @TransactionalEventListener
  public void handleEvent(UserRoleEvent event) {
      log.info("接受到userRole事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleRecordService.save(record);
  }

}

一样的代码,把注解从@EventListener更换为@TransactionalEventListener。执行之后发现了一个神奇的问题,用户角色操作记录数据没有入库!!!


image.png


捋一捋逻辑看看,换了个注解,就出现这个问题了,比较一下·两个注解的区别。 @TransactionalEventListener事务独立,且默认注解phase参数值为TransactionPhase.AFTER_COMMIT,即为主逻辑方法事务提交后在执行。而我们知道spring中事务的提交关键代码在AbstractPlatformTransactionManager.commitTransactionAfterReturning


protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
  if (txInfo != null && txInfo.getTransactionStatus() != null) {
    if (logger.isTraceEnabled()) {
        logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
    }
    //断点处
    txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
  }
}

配置文件中添加以下配置


logging:
level:
  org:
    mybatis: debug

在上述代码的地方打上断点,再次执行逻辑。


发现,第一次userService保存数据进入此断点,然后进入到userRoleService.save逻辑,此处不进入断点,后续的操作记录的事件处理方法也没有进入。


在来看一下日志


- 2021-09-07 19:54:38.166, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
- 2021-09-07 19:54:38.166, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:38.167, DEBUG, [,,], [http-nio-8088-exec-6], o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@1832a0d9] will be managed by Spring
- 2021-09-07 19:54:38.184, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.430, INFO, [,,], [http-nio-8088-exec-6], com.examp.event.demo.UserEventHandler - 接受到用户新增事件:com.examp.event.demo.UserEvent@385db2f9
- 2021-09-07 19:54:53.602, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
- 2021-09-07 19:54:53.602, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818] was not registered for synchronization because synchronization is not active
- 2021-09-07 19:54:53.603, DEBUG, [,,], [http-nio-8088-exec-6], o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@1832a0d9] will be managed by Spring
- 2021-09-07 19:54:53.622, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818]

注意看接受到用户新增事件之后的日志,SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818] was not registered for synchronization because synchronization is not active说明当前事件是无事务执行的逻辑。再回过头去看一下@TransactionalEventListener,默认配置是在事务提交后才进行事件执行的,但是这里事务都没有,自然也就不会触发事件了。


看图捋一下代码逻辑


image-20210907200823192.png


那怎么解决上面的问题呢?


其实这个东西还是比较简单的:


1.可以对监听此事件的逻辑无脑标注@TransactionalEventListener(fallbackExecution = true),无论事件发送方是否有事务都会触发事件。


2.在第二个发布事件的上面标注一个@Transactional(propagation = Propagation.REQUIRES_NEW),切记不可直接标注@Transactional,这样因为userService上事务已经提交,而@Transactional默认事务传播机制为Propagation.REQUIRED,如果当前没有事务,就新建一个事务,如果已经存在一个事务,加入到这个事务中。


userService中的事务还存在,只是已经被提交,无法再加入,也就是会导致操作记录仍旧无法被插入。


将配置修改为


logging:
level:
  org: debug

可以看到日志


- 2021-09-07 20:26:29.900, DEBUG, [,,], [http-nio-8088-exec-2], o.s.j.d.DataSourceTransactionManager - Cannot register Spring after-completion synchronization with existing transaction - processing Spring after-completion callbacks immediately, with outcome status 'unknown'

四.DDD中的事件驱动应用


理清楚spring中事件驱动模型之后,我们所要做的就是开始解耦业务逻辑。


通过事件风暴理清楚业务用例,设计完成聚合根【ps:其实我觉得设计聚合根是最难的,业务边界是需要团队成员达成共识的地方,不是研发说了算的】,划分好业务领域边界,将原先杂糅在service里面的各个逻辑根据聚合根进行:



  1. 对于聚合的每次命令操作,都至少一个领域事 件发布出去,表示操作的执行结果

  2. 每一个领域事件都将被保存到事件存储中

  3. 从资源库获取聚合时,将根据发生在聚合上的 事件来重建聚合,事件的重放顺序与其产生顺序相同

  4. 聚合快照:将聚合的某一事件发生时的状态快 照序列化存储下来。


五.总结


本文着重介绍了事件驱动模型的概念与应用,并对实际可能出现的业务逻辑做了分析与避坑。最后对于DDD中如何进行以上事件驱动模型进行了分析。


当然我觉得到这里大家应该对事件模型有了一个清晰的认知了,但是对于DDD中应用还是有些模糊。千言万语汇成一句话:与聚合核心逻辑有关的,走应用服务编排,与核心逻辑无关的,走事件驱动模型,采用独立事务模式。至于数据一致性,就根据大家自己相关的业务来决定了,方法与踩坑都告诉了大家了。


你我都是架构师!!!


image.png


六.引用及参考


@TransactionalEventListener的使用和实现原理


【小家Spring】从Spring中的(ApplicationEvent)事件驱动机制出发,聊聊【观察者模式】【监听者模式】【发布订阅模式】【消息队列MQ】【EventSourcing】...


image.png


作者:柏炎
来源:juejin.cn/post/7005175434555949092
收起阅读 »

一文带你落地DDD

一.前言 hello,everyone,好久不见。最近几周部门有个大版本发布,一直没有抽出时间来写博。由于版本不断迭代,功能越做越复杂,系统的维护与功能迭代越来越困难。前段领导找我说,能不能在架构上动手做做文章,将架构迁移到DDD。哈哈哈哈,当时我听到这个话的...
继续阅读 »

一.前言


hello,everyone,好久不见。最近几周部门有个大版本发布,一直没有抽出时间来写博。由于版本不断迭代,功能越做越复杂,系统的维护与功能迭代越来越困难。前段领导找我说,能不能在架构上动手做做文章,将架构迁移到DDD。哈哈哈哈,当时我听到这个话的时候瞬间来了精神。说实话,从去年开始从大厂的一些朋友那里接触到DDD,自己平时也会时不时的阅读相关的文章与开源项目,但是一直没有机会在实际的工作中实施。正好借着这次机会可以开始实践一下。


image.png


本文由于本文的重点为MVC三层架构如何迁移DDD,因此将先对DDD做一个简要的概念介绍(细化的领域概念不做过多展开),然后对于MVC三层架构迁移至DDD作出迁移方案建议。如有不对之处,欢迎指出,共同进步。


本文尤其感谢一下lilpilot在DDD落地方案上给出的宝贵建议。


image.png


DDD系列博客



  1. 一文带你落地DDD

  2. DDD落地之事件驱动模型

  3. DDD落地之仓储

  4. DDD落地之架构分层

二.DDD是什么


2.1.DDD简介


相信了解过DDD的同学都听过网上那种官方的介绍:




  • Domain Drive Design(领域驱动设计)




  • 六边形架构模型




  • 领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具




  • ....


    说的都多多少少抽象点了,听君一席话,如听一席话,哈哈哈




在我看来常规在MVC三层架构中,我们进行功能开发的之前,拿到需求,解读需求。往往最先做的一步就是先设计表结构,在逐层设计上层dao,service,controller。对于产品或者用户的需求都做了一层自我理解的转化。


众所周知,人才是系统最大的bug。


image.png


image-20210904135645004.png


用户需求在被提出之后经过这么多层的转化后,特别是研发需求在数据库结构这一层转化后,将业务以主观臆断行为进行了转化。一旦业务边界划分模糊,考虑不全。大量的逻辑补充堆积到了代码层实现,变得越来越难维护,到处是if/else,传说中***一样代码。


image-20210904140321557.png


DDD所要做的就是



  • 消除信息不对称

  • 常规MVC三层架构中自底向上的设计方式做一个反转,以业务为主导,自顶向下的进行业务领域划分

  • 将大的业务需求进行拆分,分而治之



说到这里大家可能还是有点模糊DDD与常见的mvc架构的区别。这里以电商订单场景为例。假如我们现在要做一个电商订单下单的需求。涉及到用户选定商品,下订单,支付订单,对用户下单时的订单发货。


MVC架构里面,我们常见的做法是在分析好业务需求之后,就开始设计表结构了,订单表,支付表,商品表等等。然后编写业务逻辑。这是第一个版本的需求,功能迭代饿了,订单支付后我可以取消,下单的商品我们退换货,是不是又需要进行加表,紧跟着对于的实现逻辑也进行修改。功能不断迭代,代码就不断的层层往上叠。


DDD架构里面,我们先进行划分业务边界。这里面核心是订单。那么订单就是这个业务领域里面的聚合逻辑体现。支付,商品信息,地址等等都是围绕着订单而且。订单本身的属性决定之后,类似于地址只是一个属性的体现。当你将订单的领域模型构建好之后,后续的逻辑边界与仓储设计也就随之而来了。



2.2.为什么要用DDD



  • 面向对象设计,数据行为绑定,告别贫血模型

  • 降低复杂度,分而治之

  • 优先考虑领域模型,而不是切割数据和行为

  • 准确传达业务规则,业务优先

  • 代码即设计

  • 它通过边界划分将复杂业务领域简单化,帮我们设计出清晰的领域和应用边界,可以很容易地实现业务和技术统一的架构演进

  • 领域知识共享,提升协助效率

  • 增加可维护性和可读性,延长软件生命周期

  • 中台化的基石


2.3.DDD术语介绍


战略设计:限界上下文、通用语言,子域


战术设计:聚合、实体、值对象、资源库、领域服务、领域事件、模块


1595145053316-e3f10592-4b88-479e-b9b7-5f1ba43cadcb.jpeg


2.3.1.限界上下文与通用语言


限界上下文是一个显式的语义和语境上的边界,领域模型便存在于边界之内。边界内,通用语言中的所有术语和词组都有特定的含义。


通用语言就是能够简单、清晰、准确描述业务涵义和规则的语言。


把限界上下文拆解开看。限界就是领域的边界,而上下文则是语义环境。 通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流。


域是问题空间,限界上下文是解决空间


2.3.2.上下文组织和集成模式


防腐层(Anticorruption Layer):简称ACL,在集成两个上下文,如果两边都状态良好,可以引入防腐层来作为两边的翻译,并且可以隔离两边的领域模型。


image-20210904143337032.png


2.3.3.实体


DDD中要求实体是唯一的且可持续变化的。意思是说在实体的生命周期内,无论其如何变化,其仍旧是同一个实体。唯一性由唯一的身份标识来决定的。可变性也正反映了实体本身的状态和行为。


实体 = 唯一身份标识 + 可变性【状态 + 行为】


2.3.4.值对象


当你只关心某个对象的属性时,该对象便可作为一个值对象。 我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。


值对象=将一个值用对象的方式进行表述,来表达一个具体的固定不变的概念。


2.3.5.聚合


聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。


我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。


2.3.6.聚合根


聚合的根实体,最具代表性的实体


2.3.7.领域服务


当一些逻辑不属于某个实体时,可以把这些逻辑单独拿出来放到领域服务中 理想的情况是没有领域服务,如果领域服务使用不恰当慢慢又演化回了以前逻辑都在service层的局面。


可以使用领域服务的情况:



  • 执行一个显著的业务操作

  • 对领域对象进行转换

  • 以多个领域对象作为输入参数进行计算,结果产生一个值对象


2.3.8.应用服务


应用服务是用来表达用例和用户故事的主要手段。


应用层通过应用服务接口来暴露系统的全部功能。 在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。


应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。


应用层作为展现层与领域层的桥梁。展现层使用VO(视图模型)进行界面展示,与应用层通过DTO(数据传输对象)进行数据交互,从而达到展现层与DO(领域对象)解耦的目的。


2.3.9.工厂


职责是创建完整的聚合



  • 工厂方法

  • 工厂类


领域模型中的工厂



  • 将创建复杂对象和聚合的职责分配给一个单独的对象,它并不承担领域模型中的职责,但是领域设计的一部份

  • 对于聚合来说,我们应该一次性的创建整个聚合,并且确保它的不变条件得到满足

  • 工厂只承担创建模型的工作,不具有其它领域行为

  • 一个含有工厂方法的聚合根的主要职责是完成它的聚合行为

  • 在聚合上使用工厂方法能更好的表达通用语言,这是使用构造函数所不能表达的


聚合根中的工厂方法



  • 聚合根中的工厂方法表现出了领域概念

  • 工厂方法可以提供守卫措施


领域服务中的工厂



  • 在集成限界上下文时,领域服务作为工厂

  • 领域服务的接口放在领域模型内,实现放在基础设施层


2.3.10.资源库【仓储】


是聚合的管理,仓储介于领域模型和数据模型之间,主要用于聚合的持久化和检索。它隔离了领域模型和数据模型,以便我们关注于领域模型而不需要考虑如何进行持久化。


我们将暂时不使用的领域对象从内存中持久化存储到磁盘中。当日后需要再次使用这个领域对象时,根据 key 值到数据库查找到这条记录,然后将其恢复成领域对象,应用程序就可以继续使用它了,这就是领域对象持久化存储的设计思想


2.3.11.事件模型


领域事件是一个领域模型中极其重要的部分,用来表示领域中发生的事件。忽略不相关的领域活动,同时明确领域专家要跟踪或希望被通知的事情,或与其他模型对象中的状态更改相关联


领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。



比如下订单后,给用户增长积分与赠送优惠券的需求。如果使用瀑布流的方式写代码。一个个逻辑调用,那么不同用户,赠送的东西不同,逻辑就会变得又臭又长。这里的比较好的方式是,用户下订单成功后,发布领域事件,积分聚合与优惠券聚合监听订单发布的领域事件进行处理。



2.4.DDD架构总览


2.4.1.架构图


严格分层架构:某层只能与直接位于的下层发生耦合。


松散分层架构:允许上层与任意下层发生耦合


依赖倒置原则


高层模块不应该依赖于底层模块,两者都应该依赖于抽象


抽象不应该依赖于实现细节,实现细节应该依赖于接口


简单的说就是面向接口编程。


按照DIP的原则,领域层就可以不再依赖于基础设施层,基础设施层通过注入持久化的实现就完成了对领域层的解耦,采用依赖注入原则的新分层架构模型就变成如下所示:


image-20210904145125083.png


从上往下


第一层为用户交互层,web请求,rpc请求,mq消息等外部输入均被视为外部输入的请求,可能修改到内部的业务数据。


第二层为业务应用层,与MVC中的service不同的不是,service中存储着大量业务逻辑。但在应用服务的实现中(以功能点为维度),它负责编排、转发、校验等。


第三层为领域层,聚合根是里面最高话事人。核心逻辑均在聚合根中体现【充血模型】,如果当前聚合根不能处理当前逻辑,需要其他聚合根的配合时,则在聚合根的外部包一层领域服务去实现逻辑。当然,理想的情况是不存在领域服务的。


第四层为基础设施层,为其他层提供技术实现支持


相信这里大家还看见了应用服务层直接调用仓储层的一条线,这条线是什么意思呢?


领域模型的建立是为了控制对于数据的增删改的业务边界,至于数据查询,不同的报表,不同的页面需要展示的数据聚合不具备强业务领域,因此常见的会使用CQRS方式进行查询逻辑的处理。


2.4.2.六边形架构(端口与适配器)


对于每一种外界类型,都有一个适配器与之对应。外界接口通过应用层api与内部进行交互。


对于右侧的端口与适配器,我们可以把资源库看成持久化的适配器。


image-20210904150651866.png


2.4.3.命令和查询职责分离--CQRS



  • 一个对象的一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据,声明为void。

  • 一个对象的一个方法如果返回了数据,该方法便是一个查询(Query),不应该通过直接或者间接的手段修改对象状态。

  • 聚合只有Command方法,没有Query方法。

  • 资源库只有add/save/fromId方法。

  • 领域模型一分为二,命令模型(写模型)和查询模型(读模型)。

  • 客户端和查询处理器 客户端:web浏览器、桌面应用等 查询处理器:一个只知道如何向数据库执行基本查询的简单组件,查询处理器不复杂,可以返回DTO或其它序列化的结果集,根据系统状态自定

  • 查询模型:一种非规范化的数据模型,并不反映领域行为,只用于数据显示

  • 客户端和命令处理器 聚合就是命令模型 命令模型拥有设计良好的契约和行为,将命令匹配到相应的契约是很直接的事情

  • 事件订阅器更新查询模型

  • 处理具有最终一致性的查询模型


2.4.4.事件驱动架构


落地指导与实践:DDD落地之事件驱动模型




  • 事件驱动架构可以融入六边型架构,融合的比较好,也可以融入传统分层架构




  • 管道和过滤器




  • 长时处理过程



    1. 主动拉取状态检查:定时器和完成事件之间存在竞态条件可能造成失败

    2. 被动检查,收到事件后检查状态记录是否超时。问题:如果因为某种原因,一直收不到事件就一直不过期




  • 事件源



    1. 对于聚合的每次命令操作,都至少一个领域事 件发布出去,表示操作的执行结果

    2. 每一个领域事件都将被保存到事件存储中

    3. 从资源库获取聚合时,将根据发生在聚合上的 事件来重建聚合,事件的重放顺序与其产生顺序相同

    4. 聚合快照:将聚合的某一事件发生时的状态快 照序列化存储下来。以减少重放事件时的耗时




三.落地分享


3.1.事件风暴


EventStorming则是一套Workshop(可以理解成一个类似于头脑风暴的工作坊)方法。DDD出现要比EventStorming早了10多年,而EventStorming的设计虽然参考了DDD的部分内容,但是并不是只为了DDD而设计的,是一套独立的通过协作基于事件还原系统全貌,从而快速分析复杂业务领域,完成领域建模的方法。


image-20210904152542121.png


针对老系统内的业务逻辑,根据以上方式进行业务逻辑聚合的划分


例如电商场景下购车流程进行事件风暴


image-20210904152737731.png


3.2.场景识别


事件风暴结束明确业务聚合后,进行场景识别与层级划分


image-20210904153035722.png


3.3.包模块划分


图片2.png


3.4.迁移说明


3.4.1.仓储层


在我们日常的代码中,使用Repository模式是一个很简单,但是又能得到很多收益的事情。最大的收益就是可以彻底和底层实现解耦,让上层业务可以快速自发展。


以目前逆向模型举例,现有



  • OrderDO

  • OrderDAO


可以通过以下几个步骤逐渐的实现Repository模式:



  1. 生成Order实体类,初期字段可以和OrderDO保持一致

  2. 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成

  3. 写单元测试,确保Order和OrderDO之间的转化100%正确

  4. 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性

  5. 将原有代码里使用了OrderDO的地方改为Order

  6. 将原有代码里使用了OrderDAO的地方都改为用OrderRepository

  7. 通过单测确保业务逻辑的一致性。


有一点要注意,目前我们用mybatis,dao操作都是含有业务含义的,正常的repository不应该有这种方法,目前repository中的含有业务含义的方法只是兼容方案,最终态都要干掉的。


极端DDD推崇者要求在repository中只存在save与byId两个聚合方法。这个当然需要根据实际业务场景来决定,但是还是建议仅保存这两个方法,其他业务需求查询聚合的方法单独开一个queryRepository实现不同数据的查询聚合与页面数据展示。保证数据的增删改入口唯一


3.4.2. 隔离三方依赖-adapter


思想和repository是一致的,以调用payApi为例:



  1. 在domain新建adapter包

  2. 新建PayAdapter接口

  3. 在infrastructure中定义adapter的实现,转换内部模型和外部模型,调用pay接口,返回内部模型dto

  4. 将原先业务中调用rpc的地方改成adapter

  5. 单测对比rpc和adapter,保证正确性


3.4.3. 抽离技术组件


同样是符合六边形架构的思想,把mqProducer,JsonUtil等技术组件,在domain定义接口,在infrastructure写实现,替换步骤和adapter类似。


3.4.4. 业务流程模块化-application


如果是用能力链的项目,能力链的service就可以是application。如果原先service中的业务逻辑混杂,甚至连参数组装都是在service中体现的。那么需要把逻辑归到聚合根中,当前聚合根无法完全包裹的,防止在领域模型中体现。在应用服务层中为能力链的体现。


3.4.5. CQRS参数显式化


能力链项目,定义command,query包,通过能力链来体现Command,Query,包括继承CommandService、QueryService,Po继承CommandPo,QueryPo


非能力链项目,在application定义command,query包,参数和类名要体现CQRS。


3.4.6. 战略设计-domain


重新设计聚合和实体,可能和现有模型有差异,如果模型差距不大,直接将能力点内的逻辑,迁移到实体中,


将原来调用repository的含业务含义的方法,换成save,同时删除含业务含义的方法,这个时候可以考虑用jpa替换mybatis,这里就看各个子域的选择了,如果用jpa的话 dao层可以干掉。至此,原biz里的大多数类已迁移完成。


四.迁移过程中可能存在的疑问


迁移过程中一定会存在或多或少不清楚的地方,这里我分享一下我在迁移的过程中遇到的问题。


image.png


1.领域服务与应用服务的实际应用场景区别


应用服务:可以理解为是各种方法的编排,不会处理任务业务逻辑,比如订单数修改,导致价格变动,这个逻辑体现在聚合根中,应用服务只负责调用。


领域服务:聚合根本身无法完全处理这个逻辑,例如支付这个步骤,订单聚合不可能支付,所以在订单聚合上架一层领域服务,在领域服务中实现支付逻辑。应用服务调用领域服务。


2.聚合根定义的业务边界是什么?


不以表结构数据进行业务逻辑的划分,一个业务体为一块业务。比如一个订单涉及商品,收货地址,发货地址,个人信息等等。以实体与值对象的方式在聚合内进行定义。


3.一个command修改一个聚合时,会关联修改到别的关联表,这个关联表算不算聚合


关联表不算聚合,算值对象


4.应用服务层如果调用rpc是否必须使用adapter


是的,必须使用,屏蔽外部依赖对于当前业务逻辑的影响。设想一下,你现在需要调用rpc接口,返回的字段有100,你要取其中50个字段。隔了一段时间,调用方改了接口逻辑的返回,数据被包含在实体内。而你调用这个接口的地方特别多,改动就很大了。但是如果有了适配器这一层,你只要定义本身业务需要的数据结构,剩下的业务不需要考虑,完全新人适配器可以将你想要的数据从rpc中加载到。


5.聚合根内部逻辑无法单独处理时,放到领域服务内的话,是否可以调用其他聚合根的领域服务或者应用服务,加入业务强绑定形式,聚合根内部如果需要调用service服务或者仓储时如何做。


可以这么做,但是逻辑要保证尽量内聚。


6.事件通知模式,比如是强绑定形式的,是否还是此种方式,还是与本聚合根无关的逻辑均走事件通知


强依赖形式的走逻辑编排,比如订单依赖支付结果进行聚合修改则走应用服务编排。订单支付后发送优惠券,积分等弱耦合方式走事件通知模式。


7.聚合根,PO,DTO,VO的限界


po是数据库表结构的一一对应。


dto是数据载体,贫血模型,仅对数据进行装载。


vo为dto结构不符合前端展示要求时的包装。


聚合根为一个或者多个po的聚合数据,当然不仅仅是po的组合,还有可能是值对象数据,充血模型,内聚核心业务逻辑处理。


8.查询逻辑单独开设一个repository,还是可以在聚合根的仓储中,划分的依据是什么


单独开设一个仓储。聚合根的仓储应该查询结果与save的参数均为聚合根,但是业务查询可能多样,展示给前端的数据也不一定都是聚合根的字段组成,并且查询不会对数据库造成不可逆的后果,因此单独开设查询逻辑处理,走CQRS模式。


9.返回的结果数据为多个接口组成,是否在应用服务层直接组合


不可以,需要定义一个assember类,单独对外部依赖的各种数据进行处理。


10.save方法做完delete,insert,update所有方法吗?


delete方法单独处理,可以增加一个delete方法,insert与update方法理论上是需要保持统一方法的。


11.查询逻辑如果涉及到修改聚合根怎么处理


简单查询逻辑直接走仓储,复杂逻辑走应用服务,在应用服务中进行聚合根数据修改。


12.逻辑处理的service放置在何处


如果为此种逻辑仅为某个聚合使用,则放置在对应的领域服务中,如果逻辑处理会被多个聚合使用,则将其单独定义一个service,作为一个工具类。


五.总结


本文对DDD做了一个不算深入的概念,架构的介绍。后对现在仍旧还是被最多使用的MVC三层架构迁移至DDD方案做了一个介绍,最后对可能碰到的一些细节疑问点做了问答。


当然不是所有的业务服务都合适做DDD架构,DDD合适产品化,可持续迭代,业务逻辑足够复杂的业务系统,中小规模的系统与团队还是不建议使用的,毕竟相比较与MVC架构,成本很大。


demo演示:DDD-demo


关于MVC分层的微服务架构博主在之前的文章中也给出过一些设计规范,感兴趣的大家可以去看看:


1.看完这篇,你就是架构师


2.求求你,别写祖传代码了


image.png


六.更多DDD学习资料


博客资料:


ThoughtWork DDD系列


张逸 DDD系列


欧创新 DDD系列


代码示例:


阿里COLA



github.com/citerus/ddd…




github.com/YaoLin1/ddd…




github.com/ddd-by-exam…




github.com/Sayi/ddd-ca…



七.特别鸣谢


lilpilot


image.png


作者:柏炎
来源:juejin.cn/post/7004002483601145863
收起阅读 »

Spring 缓存注解这样用,太香了!

作者最近在开发公司项目时使用到 Redis 缓存,并在翻看前人代码时,看到了一种关于 @Cacheable 注解的自定义缓存有效期的解决方案,感觉比较实用,因此作者自己拓展完善了一番后分享给各位。 Spring 缓存常规配置 Spring Cache 框架给我...
继续阅读 »

作者最近在开发公司项目时使用到 Redis 缓存,并在翻看前人代码时,看到了一种关于 @Cacheable 注解的自定义缓存有效期的解决方案,感觉比较实用,因此作者自己拓展完善了一番后分享给各位。


Spring 缓存常规配置


Spring Cache 框架给我们提供了 @Cacheable 注解用于缓存方法返回内容。但是 @Cacheable 注解不能定义缓存有效期。这样的话在一些需要自定义缓存有效期的场景就不太实用。


按照 Spring Cache 框架给我们提供的 RedisCacheManager 实现,只能在全局设置缓存有效期。这里给大家看一个常规的 CacheConfig 缓存配置类,代码如下,


@EnableCaching
@Configuration
public class CacheConfig extends CachingConfigurerSupport {
...

private RedisSerializer keySerializer() {
return new StringRedisSerializer();
}

private RedisSerializer valueSerializer() {
return new GenericFastJsonRedisSerializer();
}

public static final String CACHE_PREFIX = "crowd:";

@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
// 配置序列化(解决乱码的问题)
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
//设置key为String
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
//设置value为自动转Json的Object
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
.computePrefixWith(name -> CACHE_PREFIX + name + ":")
.entryTtl(Duration.ofSeconds(600));
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(Objects.requireNonNull(redisConnectionFactory));
return new RedisCacheManager(redisCacheWriter, config);
}
}

这里面简单对 RedisCacheConfiguration 缓存配置做一下说明:



  1. serializeKeysWith():设置 Redis 的 key 的序列化规则。

  2. erializeValuesWith():设置 Redis 的 value 的序列化规则。

  3. computePrefixWith():计算 Redis 的 key 前缀。

  4. entryTtl():全局设置 @Cacheable 注解缓存的有效期。


那么使用如上配置生成的 Redis 缓存 key 名称是什么样得嘞?这里用开源项目 crowd-adminConfigServiceImpl 类下 getValueByKey(String key) 方法举例,


@Cacheable(value = "configCache", key = "#root.methodName + '_' + #root.args[0]")
@Override
public String getValueByKey(String key) {
QueryWrapper wrapper = new QueryWrapper<>();
wrapper.eq("configKey", key);
Config config = getOne(wrapper);
if (config == null) {
return null;
}
return config.getConfigValue();
}

执行此方法后,Redis 中缓存 key 名称如下,



crowd:configCache:getValueByKey_sys.name




ttl 过期时间是 287,跟我们全局设置的 300 秒基本是一致的。此时假如我们想把 getValueByKey 方法的缓存有效期单独设置为 600 秒,那我们该如何操作嘞?


@Cacheable 注解默认是没有提供有关缓存有效期设置的。想要单独修改 getValueByKey 方法的缓存有效期只能修改全局的缓存有效期。那么有没有别的方法能够为 getValueByKey 方法单独设置缓存有效期嘞?当然是有的,大家请往下看。


自定义 MyRedisCacheManager 缓存


其实我们可以通过自定义 MyRedisCacheManager 类继承 Spring Cache 提供的 RedisCacheManager 类后,重写 createRedisCache(String name, RedisCacheConfiguration cacheConfig) 方法来完成自定义缓存有效期的功能,代码如下,


public class MyRedisCacheManager extends RedisCacheManager {
public MyRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
super(cacheWriter, defaultCacheConfiguration);
}

@Override
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
String[] array = StringUtils.split(name, "#");
name = array[0];
// 解析 @Cacheable 注解的 value 属性用以单独设置有效期
if (array.length > 1) {
long ttl = Long.parseLong(array[1]);
cacheConfig = cacheConfig.entryTtl(Duration.ofSeconds(ttl));
}
return super.createRedisCache(name, cacheConfig);
}
}


MyRedisCacheManager 类逻辑如下,



  1. 继承 Spring Cache 提供的 RedisCacheManager 类。

  2. 重写 createRedisCache(String name, RedisCacheConfiguration cacheConfig) 方法。

  3. 解析 name 参数,根据 # 字符串进行分割,获取缓存 key 名称以及缓存有效期。

  4. 重新设置缓存 key 名称以及缓存有效期。

  5. 调用父类的 createRedisCache(name, cacheConfig) 方法来完成缓存写入。


接着我们修改下 CacheConfig 类的 cacheManager 方法用以使用 MyRedisCacheManager 类。代码如下,


@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
return new MyRedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory), defaultCacheConfig());
}

private RedisCacheConfiguration defaultCacheConfig() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
.computePrefixWith(name -> CACHE_PREFIX + name + ":")
.entryTtl(Duration.ofSeconds(600));
}

最后在使用 @Cacheable 注解时,在原有 value 属性的 configCache 值后添加 #600,单独标识缓存有效期。代码如下,


@Cacheable(value = "configCache#600", key = "#root.methodName + '_' + #root.args[0]")
@Override
public String getValueByKey(String key) {
...
}

看下 getValueByKey 方法生成的 Redis 缓存 key 有效期是多久。如下,



OK,看到是 590 秒有效期后,我们就大功告成了。到这里我们就完成了对 @Cacheable 注解的自定义缓存有效期功能开发。


作者:waynaqua
来源:juejin.cn/post/7299353390764179506
收起阅读 »

《程序员的自我修养》读后感

书中内容涉及哪些方面? 新人如何学习编程,如何进入这一行,对职业人的建议 进来以后职场中的一些经验,这一职业经常面临的问题 全栈工程师的相关问题,面试招人环节 除了技术你应该怎样提升自己,如何建立自己的品牌 如何教育自己的孩子编程,作者的自学设计经验分...
继续阅读 »

书中内容涉及哪些方面


新人如何学习编程,如何进入这一行,对职业人的建议

进来以后职场中的一些经验,这一职业经常面临的问题

全栈工程师的相关问题,面试招人环节

除了技术你应该怎样提升自己,如何建立自己的品牌

如何教育自己的孩子编程,作者的自学设计经验分享

程序员的未来和作者的美好希冀......

可以看出来作者写了很多,对程序员各个阶段都有思考和分享。看起来都是些老生常谈的建议,也有些都是职场的潜规则。但是还是得说出来,摆到明面上,这样大家才能学习、思考、践行。这也是这本书的意义所在。


我也说说自己的一点想法,一方面是对书的内容的消化吸收、一方面也是有自己的感悟,及时记录下来。


1. 保证健康的体魄


身体是革命的本钱,最近看《去有风的地方》,主角的闺蜜第一集就死了,为什么呢?没有照顾好自己的身体,胃不行,太可惜了。前些年 github 的996 项目还有越来越多的猝死现象也让大家对行业内司空见惯的加班潜规则加以抵制,都说明大家对自己的身体越来越重视了。说到底还是得(心里)坚持锻炼。


锻炼在现实中是怎么实施的呢?


很多人说,哎呀,我办了健身卡可是都没去过几次,太浪费了,就在家锻炼吧。


退而求其次,买了哑铃甚至跑步机,用上几次一直吃灰。


再或者,买了瑜伽垫,开通健身会员,跟着练上一个月,舒展筋骨,体能恢复什么的。坚持打卡,一旦哪天没打,完了,开始断断续续直至把各类健身会员订阅关掉。


说下楼跑步,冬天嫌冷夏天怕热.......


没错,这就是我,甚至遛狗都是骑着电动车让狗跑。但是,我是真的想让身体越来越好。所做的这些努力,哪怕放弃的次数再多,我也会继续下去,比起因为放弃过就不努力,我这样算不算越挫越勇呢?到现在,我已经不怕放弃了,只会遗憾没有开始。


2.读书、旅行与思考不能停


精神与身体都需要锻炼,什么软技能、代码重构、艺术审美,你不会真指望一本书能让你学会这些吧?


《自我修养》书里说的那些道理,更多的像是旅游景点的指路牌,告诉你方向,你想往哪个方向走,你就读哪方面的书充实自己,而不是把路牌拍张照站原地不动。多读书,现在这年头,你就是读狗血的网络小说,说不定哪天还能看到同名影视剧上映。


旅行嘛,去年被辞职以后我出去旅游了两周,回来身心俱疲,开始思考旅行的意义。看惯了全国统一的网红景点,我悟了。旅行也得带点脑子去,我想从这个城市看到什么?历史、不一样的人文、自然,总要有点不同吧,要是冲着网红景点的噱头去,那指定被坑。酒吧、小吃,哪个地方没有。旅行,就是要看不一样的东西。


关于思考,之前的我,对技术思考最多的时候就是在找工作的时候。那确实是每天都很充实,被面试官各种问题轰炸,工作中呢,差不多的管理系统,差不多的业务,差不多的逻辑,有复杂的也不会超过两天,超过了就把需求打回去,或者找别人一起想,最后说一声:实现不了。现在的我,几乎每天都在思考,一个是做的业务之前没做过,还有个是观念转变,面向面试上班,主动思考。


展开说说面向面试上班,既然面试问的最多也难回答的是:你遇到的问题有哪些,怎么处理的。那我上班就开始整理问题,碰到难题就思考,解决了就复盘。没问题我想问题,(1)想之前工作的几家后台系统的难题是什么,没有?那你搞的不是核心,难题都让别人解决了或者引入一个包搞定了。你要说实在没有,这个系统就我一个人写的,那可以想下通常这种系统比较难实现的是哪部分业务,比如权限、审核、排班等等,然后记录下解决方案(2)由点及面,css遇到了一个问题,比如移动端适配问题,单位是用vw呢还是rem呢,布局是flex还是百分比呢,搜结果的过程中顺便可以看看解决方案都有哪些,对照下优缺点和适用场景,最终选择最佳方案。


3. 认真工作、诚信做人


书里有这样一段话:


那些优秀的程序员有时看上去很懒,他们会在上班时间做那些与工作毫无关系的事情,比如,在纸上随意地乱涂乱画,长时间坐在那里发呆,甚至玩手机。但一旦他们进入编程状态,你又会发现他们变得像打字员般,指尖飞舞,瞬间完成他们的工作。 —第三章第一节第5小节 很懒却又很高效


这就是我们现在说的“摸鱼”,我之前一直觉得摸鱼的时候有种羞愧感,同事路过我会马上放下手机,假装在敲代码。真正意识到这是一个正常的行为,我也就心态上轻松了。除了无所事事的时候,有一堆工作还摸鱼的情况,我内心是这样的:**,太难了,等会再看吧,然后拿起手机。刷了一会,想到活还没干完,果断放下手机,开始敲代码或者接着想。越是在忙的时候,这样的情形重复次数越多。很显然,这是给大脑休息的机会,摸鱼基本上不用动脑子,只需要眼睛浏览,因为有这样的机会,接下来才能更高效的完成任务。


特别忙的时候,手机拿起放下的速度也越来越快,这个时候会比较着急,想上厕所也憋着,憋不住了上厕所一路上也在想逻辑,从第三视角去审视自己这个行为又觉得很有意思,不知道在心理学上是怎么解释的。说这一点,不是为了摆烂,而是认真的工作,这样无愧于内心,也肯定是有产出的。在读《人人都是产品经理》的时候,也有一段话很应景:


任何情况下 我们都要做好手头的事情。 确保就算这事儿对公司来说又黄了,我也要通过做事有所收获。


那面试你拿的出手的自然也就是这些收获。


诚信做人,这一点毋庸置疑,书中说的职场经验、软技能、建立自己的品牌啦,这些都能和认真工作、诚信做人挂的上钩。工作认真完成了,你的个人品牌效应自然能提升,职场中大家对你也会尊重。打个比方,初入公司,你活干不下来,是个人都想骂你两句。


差不多了,就写这么多感想吧。


作者:宅神king
来源:juejin.cn/post/7190294025214099517
收起阅读 »

别把执着用错地方,两种思维的碰撞

概要   今天通过一个项目问题解决过程来分享,两种不同的思维方式在解决项目问题时的碰撞,中间引起的带入问题,请忽略不计。 背景   先说说前提,本身的技术路子比较野,啥东西都能摸两把,基本的思路和习惯都是以结果优先,技术作为手段,但这并不代表我没有标准化的基准...
继续阅读 »

概要


  今天通过一个项目问题解决过程来分享,两种不同的思维方式在解决项目问题时的碰撞,中间引起的带入问题,请忽略不计。


背景


  先说说前提,本身的技术路子比较野,啥东西都能摸两把,基本的思路和习惯都是以结果优先,技术作为手段,但这并不代表我没有标准化的基准和要求,只不过这个次序和容忍度有不同的理解、而我作为程序猿中的代码收容所,杂货收割机,可能是绝大部分标榜 “技术控、理论控、优雅控” 最不屑的。


  文档、PPT、事务性、管理性、运维、交付、架构、甚至是商务、咨询、售前支撑都有涉及,这两年又对工控、数采非标互联网进一步在学习,说这些不是为了标榜啥,只是想说明我对技术的态度,是只要感觉缺的就会去补充学,早先,从一直排斥文档,认为这项工作是 “不务正业”,到后来慢慢发现实际是有点儿恐惧写文档,天天被批判,再到后来反感的PPT和"跟傻子讲技术",心态上变化,看到差距,尊重别人的成就,就会自然而然的追赶,直到无所畏惧。


前因


  Java属于可能我最先了解和尝试的编程语言,但怎么说呢,在当时的环境里面,C++、C# 是我选择的结果,期间也涉及了其他乱七八糟的语言,也都是副业需要,涉及到就捡起来用,可能内心觉得Java比较 “简单”,直到工作里面不得不顶着运维和维护,说实话,没啥太大障碍,真正接受生态,也就近两年,心态的变化,感觉看到了不一样的新大陆,当然主要是各位开拓者的不断努力,不断的思维碰撞和实践努力,而我本身也不能说是专职做这块,只是有些块别人解决有问题,思维碰撞一下,以下就说说关于问题解决的剖析。


过程


   同事A属于入行就做Java,也有个8年多工作经验,技术能力,执行力都不错,就是这个行业通病,沟通有所欠缺,这个只是一个点的分享,只是从思维上去谈问题,(我本身是个大白菜),还是规则引擎的点,之前有过分享,整体是由我设计的方案,之前有分享,聊聊规则引擎的调研及实现全过程,因工期等其他影响,我把人员能力问题也考虑在内,在正式应用交互过程中,关于Mqtt处理相应速度问题和数据重复入库的问题得以复现,同事A有不少的参与度,因为某些原因(我不得不负起主责,前后端整体调整),这是问题的大背景


复现排查



  • 尝试1:这个问题同事A经过问题复现,初步确认是多线程循环引起的问题,解决的办法很粗暴,判断如果循环超过1000次就终止线程,导致的结果就是性能损耗和任务丢失,(这种情况在采集上是可以容忍的,数据是不间断的),就此就准备收手。我认为这个种解决办法是最终没办法下的妥协处置,而且问题也没分析定位清楚,做进一步尝试,此处是应该要执着的地方,

  • 尝试2:于是A经过一顿跟踪,用了各种线程跟测工具和理论检索,通过方法锁尝试去解决该问题,造成的第二种后果是结果正确了,但及时性达不到,根本发挥不了性能。进一步也发现了消息密集的情况下会产生问题,中间也进行过任务队列的分组处置,(此项尝试基本属于A的各种线程理论一顿分析,进行尝试,得不出来啥有效结果,各种理论分析)

  • 尝试3: 我经过不停得添加代码,定位异常,定位到了异常代码块,属于递归循环调用侧发生得异常,之前线程循环调用得问题也基本定位到,是因为父子线程得优先级,加上规则本身深度遍历及结果等待造成得问题,也就是Future得get等待引发得,此时,要验证该问题,把规则内部得递归任务添加调整为同步,(只涉及到一句代码),循环得问题得以解决,处理速度也跟得上,(线程放置到了外侧处理消息(消息接收本身也是多线程)),但又引起了新得问题,入库得数据发生重复和不正常了(如果问题回溯,其实很快能发现,当然有点儿马后炮了)

  • 尝试4:此时,同事A又尝试想放弃,认为在入库侧判断是否重复插入即可,并把别人得关于多线程入库重复得解决常规办法得内容做了共享,我给得理由是:“再往前尝试以下,感觉马上就能找到真相,正是学技术得好时候,不要此时妥协”,很显示A在口头上答应,行动上已经放弃了,我就消息重复产生得地方又进行了各种锁尝试,也基本定位到了产生消息重复的地方,加上方法锁,结果和执行均验证没啥问题。

  • 补上最后一药: 最后的相同broker相同主题下多个规则执行问题,其实不是啥大问题,很容易就解决了,两种方案,但实在是实践浪费的太久,我尝试了复杂的理想办法,交付中的问题都得以解决。


回溯


   其实这个问题在回溯过程来看,在尝试4的时候,应该马上就能反应过来去解决的,但实际可能是周期和脑子都有点儿混乱,还是分析了执行结果和操作才做了验证,实在是我已经不敢太过相信线程理论,还是实践出真知,所以,这个过程中,我对Java的语法半吊子,但并不妨碍我去参与分析解决问题,而同事A,虽然知道线程的理论和各种语法,各个线程分析结果,但依然妨碍了他去解决此项问题,之前有听过软件的思维最重要,经验有时候会成为负担,可能在你们看来,这个问题其实有充足的理论基础,一定很容易解决定位这个问题,但怎么说呢,记得上计算机理论的时候,老师说过一句,编程就是抽象化具体事务到编程语言的过程、


  在其后的过程中,好像绝大多数都是把问题分解成实现,排查分析问题的过程,我理解就是把具体的事务反推到程序上,很可惜这个过程,被归集到个人的能力或者习惯上,毕竟,对过程负责,是常态,项目的面试过程也就是说清过程,至于是谁解决的问题,并没有那么重要,这造成了两种分化,掌握全貌的失去了动手能力,对结果负责,不对过程负责,掌握单一的,失去了对结果负责的能力,掌握着对过程负责,新的纷争和各种千奇百怪的妥协结果也就是值得接受的。


分析


   当你在执着各种编码规范,各种优雅简洁之道的同时,是否也能思考一下,我们所专注的语法,面试理论,就具体问题而言,真就是理论精通了之后,不会成为 “赵括”,指点江山,飞扬文字。


  而这种分化性,我已遇到了不下3种典型,要不点了吹牛逼的和撕逼的技能点、要不就点了技术理论的技能点,要不就去点了文档管理技能点,当然,只是个现象,躺平是常态,没啥压力和责任,谁愿意变成受虐狂,但有没有可能,也有那么一丝,对这个职业和技术的热爱? 可能又会变成鸡汤和躺着说话不腰疼,至于外在因素、想法和处境的不同,咱姑且就图一乐,从容面对吧、总之是,事不临身,我不愁,事临身,愁也没用,那能不能,稍微有一些,哪怕只有一丝希望的掌控呢,要说起怨怼,我有一肚子,但总不可能变成情绪发泄机吧,对结果有预期,但不放弃努力的过程,也许这是我此刻能够坚持做的。


PS


  在这里申明一下,同事A的各方面能力我很认可,此处也只是精力有限下的小分享(有个小背景是,我们基本上背着2.5个以上的事在解决此项问题)、所以请各位不要基于我个人的认知有指摘。


作者:沈二到不行
来源:juejin.cn/post/7292290711734681651
收起阅读 »

从源码角度解读Java Set接口底层实现原理

  咦咦咦,各位小可爱,我是你们的好伙伴——bug菌,今天又来给大家普及Java SE相关知识点了,别躲起来啊,听我讲干货还不快点赞,赞多了我就有动力讲得更嗨啦!所以呀,养成先点赞后阅读的好习惯,别被干货淹没了哦~ 环境说明:Windows 10 + Int...
继续阅读 »

  咦咦咦,各位小可爱,我是你们的好伙伴——bug菌,今天又来给大家普及Java SE相关知识点了,别躲起来啊,听我讲干货还不快点赞,赞多了我就有动力讲得更嗨啦!所以呀,养成先点赞后阅读的好习惯,别被干货淹没了哦~


在这里插入图片描述


环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8

前言


  Set是Java集合框架中的一个接口,它继承了Collection接口,并添加了一些独有的方法。Set可看做是没有重复元素的Collection,它的实现类包括HashSet、TreeSet等。本文将从源码的角度来解读Set接口的底层实现原理。


摘要


  本文将对Java Set接口进行详细的解读,包括Set的概述、源代码解析、应用场景案例、优缺点分析、类代码方法介绍和测试用例等方面。


Set接口


概述


  Set是一个不允许重复元素的集合。它继承了Collection接口,最基本的操作包括添加元素、检查元素是否存在、删除元素等。Set接口的实现类包括HashSet、TreeSet等。HashSet是基于哈希表的实现,TreeSet是基于红黑树的实现。


源代码解析


Set


  Set接口是Java集合框架中的一种接口,它表示一组无序且不重复的元素。Set接口继承自Collection接口,因此它具有Collection接口的所有方法,但是在Set接口中,添加重复元素是不允许的。Set接口有两个主要的实现类:HashSet和TreeSet。其中,HashSet基于哈希表实现,对于非Null元素具有O(1)的插入和查找时间复杂度;而TreeSet基于红黑树实现,对于有序集合的操作具有良好的性能表现。在使用Set接口时,可以通过迭代器遍历元素,也可以使用foreach语句遍历元素。


  如下是部分源码截图:


在这里插入图片描述


HashSet


  HashSet基于哈希表实现,它使用了一个称为“hash表”的数组来存储元素。当我们向HashSet中添加元素时,首先会对元素进行哈希,并通过哈希值来确定元素在数组中的位置。如果该位置已经有元素了,就会通过equals方法来判断是否重复,如果重复则不添加,如果不重复则添加到该位置。当然,由于哈希表中可能会存在多个元素都哈希到同一个位置的情况,因此这些元素会被存储在同一个位置上,形成一个链表。在查找元素时,先通过哈希值定位到链表的头部,然后在链表中进行搜索,直到找到匹配的元素或到达链表的末尾。


public class HashSet extends AbstractSet implements Set, Cloneable, java.io.Serializable {
static final long serialVersionUID = -5024744406713321676L;
private transient HashMap map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

public HashSet() {
map = new HashMap<>();
}

public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

public boolean contains(Object o) {
return map.containsKey(o);
}

public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
}

  这是一个实现了 HashSet 数据结构的 Java 类。HashSet 继承了 AbstractSet 类,同时实现了 Set 接口和 Cloneable 和 Serializable 接口。


  HashSet 内部使用 HashMap 来存储元素,其中元素作为 key ,一个 static final Object 作为 value,即:


private transient HashMap map;
private static final Object PRESENT = new Object();

  在构造器中,HashSet 实例化了一个空的 HashMap 对象:


public HashSet() {
map = new HashMap<>();
}

  向 HashSet 中添加元素时,HashSet 调用 HashMap 的 put() 方法。当新元素没有在 HashMap 中存在时,put() 方法返回 null ,此时 HashSet 返回 true,表示添加成功。如果元素已经存在于 HashMap(即已经在 HashSet 中),那么 put() 方法返回已经存在的 Object,此时 HashSet 返回 false,表示添加失败。


public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

  判断 HashSet 是否包含指定元素时,HashSet 调用 HashMap 的 containsKey() 方法。如果 HashMap 中包含该元素,则 HashSet 返回 true,否则返回 false。


public boolean contains(Object o) {
return map.containsKey(o);
}

  从 HashSet 中移除指定元素时,HashSet 调用 HashMap 的 remove() 方法。如果该元素存在于 HashMap 中(即在 HashSet 中),则返回 true,否则返回 false。


public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}

  如下是部分源码截图:


在这里插入图片描述


TreeSet


  TreeSet基于红黑树实现,它是一种自平衡的二叉查找树。每个节点都有一个额外的颜色属性,只能是红色或黑色。红黑树的基本操作包括插入、删除和查找。当我们向TreeSet中添加元素时,它会根据元素的大小来将元素添加到树中的合适位置。对于每个节点,其左子树的所有元素都比该节点的元素小,右子树的所有元素都比该节点的元素大。在删除时,如果要删除的节点有两个子节点,会先在右子树中找到最小元素,然后将该节点的元素替换为最小元素。删除最小元素就是从根节点开始,一直找到最左侧的节点即可。


public class TreeSet extends AbstractSet implements NavigableSet, Cloneable, java.io.Serializable {
private transient NavigableMap m;
private static final Object PRESENT = new Object();

public TreeSet() {
this(new TreeMap());
}

public TreeSet(NavigableMap m) {
this.m = m;
}

public boolean add(E e) {
return m.put(e, PRESENT)==null;
}

public boolean contains(Object o) {
return m.containsKey(o);
}

public boolean remove(Object o) {
return m.remove(o)==PRESENT;
}
}

  这段代码定义了一个泛型类TreeSet,继承了AbstractSet类,并实现了接口NavigableSet,Cloneablejava.io.Serializable


  类中的m变量是一个NavigableMap类型的成员变量,TreeSet内部实际上是通过使用TreeMap实现的。


  类中还定义了PRESENT静态常量,用于表示在TreeSet中已经存在的元素。


  TreeSet类中的add方法实现了向集合中添加元素的功能,使用了NavigableMap中的put方法,如果添加的元素在集合中不存在,则返回null,否则返回PRESENT


  contains方法判断集合中是否包含某个元素。使用了NavigableMap中的containsKey方法。


  remove方法实现删除某个元素的功能,使用NavigableMap中的remove方法,如果删除成功,则返回PRESENT


  如下是部分源码截图:


在这里插入图片描述


应用场景案例


  Set的一个常见应用场景就是去重。由于Set中不允许存在重复元素,因此我们可以利用Set来去除列表中的重复元素,代码如下:


代码演示


List list = new ArrayList<>(Arrays.asList(1, 2, 3, 2, 1));
Set set = new HashSet<>(list);
list = new ArrayList<>(set);
System.out.println(list); // output: [1, 2, 3]

代码分析


  根据如上代码,在此我给大家进行深入详细的解读一下测试代码,以便于更多的同学能够理解并加深印象。


  该代码创建了一个包含重复元素的整型列表list,并使用list初始化了一个整型哈希集合set。然后,通过将set转换回一个新的ArrayList对象,生成一个不带重复元素的整型列表list。最后,输出list的元素。 因此,代码输出应该是[1, 2, 3]。


优缺点分析


优点



  1. Set接口的实现类可以高效地检查元素是否存在;

  2. Set接口的实现类不允许存在重复元素,可以用来进行去重操作;

  3. HashSet的添加、删除、查找操作时间复杂度为O(1);TreeSet的添加、删除、查找操作时间复杂度为O(logN)。


缺点



  1. 如果需要有序存储元素,那么需要使用TreeSet,但是由于TreeSet是基于红黑树实现的,因此占用内存空间较大;

  2. HashSet在哈希冲突的情况下,会导致链表长度增加,从而影响查找效率;

  3. HashSet在遍历元素时,元素的顺序不能保证。


类代码方法介绍


HashSet



  1. add(E e):向集合中添加元素;

  2. clear():清空集合中所有元素;

  3. contains(Object o):判断集合中是否存在指定的元素;

  4. isEmpty():判断集合是否为空;

  5. iterator():返回一个用于遍历集合的迭代器;

  6. remove(Object o):从集合中移除指定的元素;

  7. size():返回集合中元素的数量。


TreeSet



  1. add(E e):向集合中添加元素;

  2. ceiling(E e):返回集合中大于等于指定元素的最小元素;

  3. clear():清空集合中所有元素;

  4. contains(Object o):判断集合中是否存在指定的元素;

  5. descendingIterator():返回一个逆序遍历集合的迭代器;

  6. first():返回集合中的第一个元素;

  7. headSet(E toElement, boolean inclusive):返回集合中小于指定元素的子集;

  8. isEmpty():判断集合是否为空;

  9. iterator():返回一个用于遍历集合的迭代器;

  10. last():返回集合中的最后一个元素;

  11. remove(Object o):从集合中移除指定的元素;

  12. size():返回集合中元素的数量;

  13. subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive):返回集合中大于等于fromElement且小于等于toElement的子集;

  14. tailSet(E fromElement, boolean inclusive):返回集合中大于等于指定元素的子集。


测试用例


下面是一些测试用例,展示了Set接口的一些基本操作:


package com.demo.javase.day61;

import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;

/**
*
@Author bug菌
*
@Date 2023-11-06 10:33
*/

public class SetTest {

public static void main(String[] args) {

// 创建一个 HashSet 对象
Set set = new HashSet();

// 向集合中添加元素
set.add("Java");
set.add("C++");
set.add("Python");

// 打印出集合中的元素个数
System.out.println("集合中的元素个数为:" + set.size());

// 判断集合是否为空
System.out.println("集合是否为空:" + set.isEmpty());

// 判断集合中是否包含某个元素
System.out.println("集合中是否包含 Python:" + set.contains("Python"));

// 从集合中移除某个元素
set.remove("C++");
System.out.println("从集合中移除元素后,集合中的元素个数为:" + set.size());

// 使用迭代器遍历集合中的元素
Iterator iterator = set.iterator();
System.out.println("遍历集合中的元素:");
while (iterator.hasNext()) {
String element = iterator.next();
System.out.println(element);
}

// 清空集合中的所有元素
set.clear();
System.out.println("清空集合中的元素后,集合中的元素个数为:" + set.size());
}
}

该测试用例使用了HashSet作为实现Set接口的具体类,并测试了以下基本操作:



  1. 向集合中添加元素

  2. 打印出集合中的元素个数

  3. 判断集合是否为空

  4. 判断集合中是否包含某个元素

  5. 从集合中移除某个元素

  6. 使用迭代器遍历集合中的元素

  7. 清空集合中的所有元素


测试结果


  根据如上测试用例,本地测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加更多的测试数据或测试方法,进行熟练学习以此加深理解。


当运行该测试用例后,我们将得到以下输出结果:


集合中的元素个数为:3
集合是否为空:false
集合中是否包含 Python:true
从集合中移除元素后,集合中的元素个数为:2
遍历集合中的元素:
Java
Python
清空集合中的元素后,集合中的元素个数为:0

具体执行截图如下:


在这里插入图片描述


测试代码分析


  根据如上测试用例,在此我给大家进行深入详细的解读一下测试代码,以便于更多的同学能够理解并加深印象。


这段代码演示了如何使用Java中的Set接口和HashSet类。具体来说,代码实现了:


1.创建一个HashSet对象。


2.向集合中添加元素。


3.打印出集合中的元素个数。


4.判断集合是否为空。


5.判断集合中是否包含某个元素。


6.从集合中移除某个元素。


7.使用迭代器遍历集合中的元素。


8.清空集合中的所有元素。


  从这段代码可以看出,Set接口和HashSet类可以帮助我们快速地实现集合的添加、删除、查找等操作,并且还支持迭代器遍历集合中的所有元素。


作者:bug菌
来源:juejin.cn/post/7298969233546067968
收起阅读 »

BigDecimal二三事

概述 作为JAVA程序员,应该或多或少跟BigDecimal打过交道。JAVA在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。 精度丢失 先从1个问题说起,看如下代码 System.out.println(...
继续阅读 »

image.png


概述


作为JAVA程序员,应该或多或少跟BigDecimal打过交道。JAVA在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。


精度丢失


先从1个问题说起,看如下代码


System.out.println(0.1 + 0.2);

最后打印出的结果是0.30000000000000004,而不是预期的0.3。

有经验的开发同学应该一下子看出来这就是因为double丢失精度导致。更深层次的原因,是因为我们的计算机底层是二进制的,只有0和1,对于整数来说,从低到高的每1位代表了1、2、4、8、16...这样的2的正次数幂,只要位数足够,每个整数都可以分解成这样的2的正次数幂组合,例如7D=111B13D=1101B。但是到了小数这里,就会发现2的负次数幂值是0.5、0.25、0.125、0.0625这样的值,但是并不是每个小数都可以分解成这样的2的负次数幂组合,例如你无法精确凑出0.1。所以,double的0.1其实并不是精确的0.1,只是通过几个2的负次数幂值凑的近似的0.1,所以会出现前面0.1 + 0.2 = 0.30000000000000004这样的结果。


适用场景


双精度浮点型变量double可以处理16位有效数,但是某些场景下,即使已经做到了16位有效位的数还是不够,比如涉及金额计算,差一点就会导致账目不平。


常用方法


加减乘除


既然BigDecimal主要用于数值计算,那么最基础的方法就是加减乘除。BigDecimal没有对应的数值类的基本数据类型,所以不能直接使用+-*/这样的符号来进行计算,而要使用BigDecimal内部的方法。


public BigDecimal add(BigDecimal augend)
public BigDecimal subtract(BigDecimal subtrahend)
public BigDecimal multiply(BigDecimal multiplicand)
public BigDecimal divide(BigDecimal divisor)

需要注意的是,BigDecimal是不可变的,所以,addsubtractmultiplydivide方法都是有返回值的,返回值是一个新的BigDecimal对象,原来的BigDecimal值并没有变。


设置精度和舍入策略


可以通过setScale方法来设置精度和舍入策略。


public BigDecimal setScale(int newScale, RoundingMode roundingMode)

第1个参数newScale代表精度,即小数点后位数;第2个参数roundingMode代表舍入策略,RoundingMode是一个枚举,用来替代原来在BigDecimal定义的常量,原来在BigDecimal定义的常量已经标记为Deprecated。在RoundingMode类中也通过1个valueOf方法来给出映射关系


/**
* Returns the {@code RoundingMode} object corresponding to a
* legacy integer rounding mode constant in {@link BigDecimal}.
*
* @param rm legacy integer rounding mode to convert
* @return {@code RoundingMode} corresponding to the given integer.
* @throws IllegalArgumentException integer is out of range
*/

public static RoundingMode valueOf(int rm) {
return switch (rm) {
case BigDecimal.ROUND_UP -> UP;
case BigDecimal.ROUND_DOWN -> DOWN;
case BigDecimal.ROUND_CEILING -> CEILING;
case BigDecimal.ROUND_FLOOR -> FLOOR;
case BigDecimal.ROUND_HALF_UP -> HALF_UP;
case BigDecimal.ROUND_HALF_DOWN -> HALF_DOWN;
case BigDecimal.ROUND_HALF_EVEN -> HALF_EVEN;
case BigDecimal.ROUND_UNNECESSARY -> UNNECESSARY;
default -> throw new IllegalArgumentException("argument out of range");
};
}

我们逐一看一下每个值的含义



  • UP

    直接进位,例如下面代码结果是3.15


BigDecimal pi = BigDecimal.valueOf(3.141);
System.out.println(pi.setScale(2, RoundingMode.UP));


  • DOWN

    直接舍去,例如下面代码结果是3.1415


BigDecimal pi = BigDecimal.valueOf(3.14159);
System.out.println(pi.setScale(4, RoundingMode.DOWN));


  • CEILING

    如果是正数,相当于UP;如果是负数,相当于DOWN。

  • FLOOR

    如果是正数,相当于DOWN;如果是负数,相当于UP。

  • HALF_UP

    就是我们正常理解的四舍五入,实际上应该也是最常用的。
    下面的代码结果是3.14


BigDecimal pi = BigDecimal.valueOf(3.14159);
System.out.println(pi.setScale(2, RoundingMode.HALF_UP));

下面的代码结果是3.142


BigDecimal pi = BigDecimal.valueOf(3.14159);
System.out.println(pi.setScale(3, RoundingMode.HALF_UP));


  • HALF_DOWN

    与四舍五入类似,这种是五舍六入。我们对于HALF_UP和HALF_DOWN可以理解成对于5的处理不同,UP遇到5是进位处理,DOWN遇到5是舍去处理,

  • HALF_EVEN

    如果舍弃部分左边的数字为偶数,相当于HALF_DOWN;如果舍弃部分左边的数字为奇数,相当于HALF_UP

  • UNNECESSARY

    非必要舍入。如果除去小数的后导0后,位数小于等于scale,那么就是去除scale位数后面的后导0;位数大于scale,抛出ArithmeticException。

    下面代码结果是3.14


BigDecimal pi = BigDecimal.valueOf(3.1400);
System.out.println(pi.setScale(2, RoundingMode.UNNECESSARY));

下面代码抛出ArithmeticException


BigDecimal pi = BigDecimal.valueOf(3.1400);
System.out.println(pi.setScale(1, RoundingMode.UNNECESSARY));

常见问题


创建BigDecimal对象


先看下面代码


BigDecimal a = new BigDecimal(0.1);
System.out.println(a);

实际输出的结果是0.1000000000000000055511151231257827021181583404541015625。其实这跟我们开篇引出的精度丢失是同一个问题,这里构造方法中的参数0.1是double类型,本身无法精确表示0.1,虽然BigDecimal并不会导致精度丢失,但是在更加上游的源头,double类型的0.1已经丢失了精度,这里用一个已经丢失精度的0.1来创建不会丢失精度的BigDecimal,精度还是会丢失。类似于使用2K的清晰度重新录制了一遍原始只有360P的视频,清晰度也不会优于原始的360P。

所以,我们应该尽量避免使用double来创建BigDecimal,确实源头是double的,我们可以使用valueOf方法,这个方法会先调用Double.toString(val)来转成String,这样就不会产生精度丢失,下面的代码结果就是0.1


BigDecimal a = BigDecimal.valueOf(0.1);
System.out.println(a);

顺便说一下,BigDecimal还内置了ZEROONETEN这样的常量可以直接使用。


toString


这个问题比较隐蔽,在数据比较小的时候不会遇到,但是看如下代码


BigDecimal a = BigDecimal.valueOf(987654321987654321.123456789123456789);
System.out.println(a);

最后实际输出的结果是9.8765432198765427E+17。原因是System.out.println会自动调用BigDecimal的toString方法,而这个方法会在必要时使用科学计数法,如果不想使用科学计数法,可以使用BigDecimal的toPlainString方法。另外提一下,BigDecimal还提供了一个toEngineeringString方法,这个方法也会使用科学技术法,不一样的是,这里面的10都是3、6、9这样的幂,对应我们在查看大数的时候,很多都是每3位会增加1个逗号。


comparTo 和 equals


这个问题出现的不多,有经验的开发同学在比较数值的时候,会自然而然使用comparTo方法。这里说一下BigDecimal的equals方法除了比较数值之外,还会比较scale精度,不同精度不会equles。

例如下面代码分别会返回0false


BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.10");
System.out.println(a.compareTo(b));
System.out.println(a.equals(b));

不能除尽时ArithmeticException异常


上面提到的加减乘除的4个方法中,除法会比较特殊,因为可能出现除不尽的情况,这时如果没有设置精度,就会抛出ArithmeticException,因为这个是否能除尽是跟具体数值相关的,这会导致偶现的bug,更加难以排查。

例如下面代码就会抛出ArithmeticException异常


BigDecimal a = new BigDecimal(1);
BigDecimal b = new BigDecimal(3);
System.out.println(a.divide(b));

应对的方法是,在除法运算时,注意设置结果的精度和舍入模式,下面的代码就能正常输出结果0.33


BigDecimal a = new BigDecimal(1);
BigDecimal b = new BigDecimal(3);
System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));

总结


BigDecimal主要用于double因为精度丢失而不满足的某些特殊业务场景,例如会计金额计算。在可以忍受略微不精确的场景还是使用内部提供的addsubtractmultiplydivide方法来进行基础的加减乘除运算,运算后会返回新的对象,原始的对象并不会改变。在使用BigDecimal的过程中,要注意创建对象、toString、比较数值、不能除尽时需要设置精度等问题。



作者:podongfeng
来源:juejin.cn/post/7195489874422513701
收起阅读 »

看明白两个案例,秒懂事件循环

web
事件循环的任务队列包括宏任务和微任务 执行顺序就是:同步代码 -> 第一轮微任务 -> 第一轮宏任务 -> 第二轮微任务 ->... 宏任务有:setTimeout, setInterval, setImmediate, I/O, UI...
继续阅读 »

事件循环的任务队列包括宏任务微任务


执行顺序就是:同步代码 -> 第一轮微任务 -> 第一轮宏任务 -> 第二轮微任务 ->...


宏任务有:setTimeout, setInterval, setImmediate, I/O, UI rendering。


微任务有:process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)


两大原则:



  1. setTimeout和setInterval同源,且均优先于setImmediate执行

  2. nextTick队列会比Promie.then方法里面的代码先执行


简单案例


setTimeout(function() {
console.log('timeout1'); // 5-第一轮宏任务
})

new Promise(function(resolve) {
console.log('promise1'); // 1-同步代码
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log('promise2'); // 2-同步代码
}).then(function() {
console.log('then1'); // 4-第一轮微任务
})

console.log('global1'); // 3-同步代码


/*
promise1
promise2
global1
then1
timeout1
*/


综合案例


console.log('golb1'); // 1-同步代码

setTimeout(function() {
console.log('timeout1'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('timeout1_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('timeout1_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('timeout1_then') // 3.4-第二轮微任务
})
})

setImmediate(function() {
console.log('immediate1'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('immediate1_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('immediate1_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('immediate1_then') // 3.4-第二轮微任务
})
})

process.nextTick(function() {
console.log('glob1_nextTick');// 2.1-第一轮微任务
})
new Promise(function(resolve) {
console.log('glob1_promise');// 1-同步代码
resolve();
}).then(function() {
console.log('glob1_then') // 2.2-第一轮微任务
})

setTimeout(function() {
console.log('timeout2'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('timeout2_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('timeout2_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('timeout2_then') // 3.4-第二轮微任务
})
})

process.nextTick(function() {
console.log('glob2_nextTick');// 2.1-第一轮微任务
})
new Promise(function(resolve) {
console.log('glob2_promise');// 1-同步代码
resolve();
}).then(function() {
console.log('glob2_then')// 2.2-第一轮微任务
})

setImmediate(function() {
console.log('immediate2'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('immediate2_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('immediate2_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('immediate2_then') // 3.4-第二轮微任务
})
})

/*
(1-同步代码)
golb1
glob1_promise
glob2_promise
(2-第一轮微任务)
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
(3-第一轮宏任务)
(setTimeout)
timeout1
timeout1_promise
timeout1_nextTick
timeout1_then
timeout2
timeout2_promise
timeout2_nextTick
timeout2_then
(setImmediate)
immediate1
immediate1_promise
immediate1_nextTick
immediate1_then
immediate2
immediate2_promise
immediate2_nextTick
immediate2_then
*/


注:在Node 11前,Node的事件循环会与浏览器存在差异,以上面案例中的两个setTimeout为例:


//在Node 11前
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
timeout2_nextTick
timeout1_then
timeout2_then
// 在Node11后和浏览器
timeout1
timeout1_promise
timeout1_nextTick
timeout1_then
timeout2
timeout2_promise
timeout2_nextTick
timeout2_then

即在同一类任务分发器(如:多个setTimeout),在Node 11前,会先执行所有的nextTick,再到Promise.then;而在Node11后和浏览器,都是依次执行每个setTimeout,在同一个setTimeout里面先执行所有nextTick,再到Promise.then。


Refs:


mp.weixin.qq.com/s/m3a6vjp8-…


作者:星辰_Stars
来源:juejin.cn/post/7298325881731219496
收起阅读 »

面试题:小男孩毕业之初次面试

web
前言 看到身边的同学渐渐的都有了一些面试之后,我逐渐感到了焦虑,甚至都有些对自己感到不自信,之后在上周三的上午,终于时来运转,先是梭翱打电话来,之后就是美云和琻瑢那边的简历初筛通过,通知我面试,之后按照自己的回忆写下了一些感悟与题目,希望对你们有所帮助。 浙江...
继续阅读 »

前言


看到身边的同学渐渐的都有了一些面试之后,我逐渐感到了焦虑,甚至都有些对自己感到不自信,之后在上周三的上午,终于时来运转,先是梭翱打电话来,之后就是美云和琻瑢那边的简历初筛通过,通知我面试,之后按照自己的回忆写下了一些感悟与题目,希望对你们有所帮助。


浙江杭州(实习 130-160/天)



这是我的第一场面试,面试官问的都是vue的问题。这场面试全程懵逼下来的,因为我前面基本都在准备js和css方面,vue方面也就瞄了几眼,结果就是和面试官疯狂的扯。面试完之后反思,在自我介绍中一定要讲清楚自己使用了是vue2还是vue3,不熟悉或者面试前没准备好的知识点一定不要讲出来,全程懵下来血的教训。然后也是电话面试,所以在听面试官老师的问题方面可能有点费力。在看面试题的时候,不要死记硬背,可以根据自己熟悉的语句自己表达出来就行。



1. 说一下vue2和vue3生命周期的实现和它们的不同点?


每个Vue实例在创建时都会经过一系列的初始化过程,vue的生命周期钩子,就是说在达到某一阶段或条件时去触发的函数,目的就是为了完成一些动作或者事件


Vue2的生命周期函数




  • create阶段:vue实例被创建


    beforeCreate: 创建前,此时data和methods中的数据都还没有初始化


    created: 创建完毕,data中有值,未挂载




  • mount阶段: vue实例被挂载到真实DOM节点


    beforeMount:可以发起服务端请求,取数据


    mounted: 此时可以操作DOM




  • update阶段:当vue实例里面的data数据变化时,触发组件的重新渲染


    beforeUpdate:更新前


    updated:更新后




  • destroy阶段:vue实例被销毁


    beforeDestroy:实例被销毁前,此时可以手动销毁一些方法


    destroyed:销毁后




上述生命周期钩子函数中,beforeCreate和created钩子函数在组件创建时只会执行一次,而beforeMount、mounted、beforeUpdate和updated钩子函数则会在组件的数据发生变化时多次执行。在组件销毁时,beforeDestroy和destroyed钩子函数也只会执行一次。


Vue3的生命周期函数




  • setup() : 开始创建组件之前,在 beforeCreate 和 created 之前执行,创建的是 data 和 method




  • mount阶段: vue实例被挂载到真实DOM节点


    onBeforeMount() : 组件挂载到节点上之前执行的函数;


    onMounted() : 组件挂载完成后执行的函数;




  • update阶段:当vue实例里面的data数据变化时,触发组件的重新渲染


    onBeforeUpdate(): 组件更新之前执行的函数;


    onUpdated(): 组件更新完成之后执行的函数;




  • unmount阶段:vue实例被销毁


    onBeforeUnmount(): 组件卸载之前执行的函数;


    onUnmounted(): 组件卸载完成后执行的函数;




在Vue3中,beforeDestroy钩子函数被废弃,取而代之的是onUnmounted钩子函数。与Vue2不同,onUnmounted钩子函数在组件卸载之后调用,而不是在组件销毁之前调用。此外,Vue3还新增了一个onErrorCaptured钩子函数,用于处理子孙组件抛出的错误。


不同


1. vue3和vue2的生命周期函数名称


在Vue2中,我们熟悉的生命周期函数有:beforeCreate、created、beforeMountmounted、beforeUpdate、updated、 beforeDestroy、destroyed。而在Vue3中,这些函数名称被进行了重命名,变成了:beforeCreate->setup,created->setup,beforeMount->onBeforeMount,mounted->onMounted,beforeUpdate->onBeforeUpdate,updated->onUpdated,beforeUnmount ->onBeforeUnmount,unmounted ->onUnmounted。


重命名的原因是为了更好地反映生命周期的不同阶段,方便开发者进行理解和使用。


常用生命周期对比如下表所示。


vue2vue3
beforeCreate使用 setup()
created使用 setup()
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeDestroyonBeforeUnmount
destroyedonUnmounted

2. 新增和废弃生命周期函数


Vue3为我们提供了一些新的生命周期函数,这些函数可以帮助我们更好地管理组件,Vue3废弃了beforeDestroy钩子函数,并且新增了生命周期函数。这些新的生命周期函数分别是:


onRenderTracked:当渲染跟踪或依赖项跟踪时被调用。


onRenderTriggered:当渲染时触发其他渲染时,或者当在当前渲染中延迟调度的作业时被调用。


onErrorCaptured:当子组件抛出未处理的错误时被调用。这些新的生命周期函数可以帮助我们更好地调试、优化组件,提升应用的性能。


3. 使用hook函数代替生命周期函数


Vue3引入了新的API——Composition API,通过这个API可以使用hook函数来代替生命周期函数。 Composition API可以让我们更好地管理代码逻辑,将不同的功能划分为不同的小函数,便于维护和复用。hook函数在组件中的调用顺序与生命周期函数类似,但是更加灵活,可以根据需要进行组合和抽离。


4.v-if和v-for的优先级不同


vue2生命周期执行过程


生命周期.png


vue3生命周期执行过程


image.png


2. Vue2和Vue3数据更新时有什么不一样?


Proxy 替代 Object.defineProperty:在Vue2中,使用Object.defineProperty来拦截数据的变化,但是该方法存在一些缺陷,比如不能监听新增的属性和数组变化等。Vue3中使用了ES6中的Proxy来拦截数据的变化,能够完全监听数据变化,并且能够监听新增的属性。


批量更新:Vue2中,在数据变化时,会立即触发虚拟DOM的重渲染,如果在一个事件循环中连续修改多个数据,可能会造成性能问题。而Vue3中,使用了更高效的批量更新策略,会在下一个事件循环中统一处理数据变化,提高了性能。


更快的响应式系统:Vue3中使用了更快的响应式系统,能够更快地追踪依赖关系,并在数据变化时更快地更新视图。此外,Vue3还对Reactivity API进行了优化,使得开发者能够更灵活地使用响应式数据。


Composition API:Vue3中引入了Composition API,可以更好地组织代码逻辑,也可以更好地处理数据更新。通过使用setup函数和ref、reactive等函数,能够更方便地对数据进行监听和修改。


3. 为什么vue中更改对象和数组时,有时候页面没有进行更新




  1. 对象或数组未在初始时声明为响应式:在Vue中,只有在初始时声明为响应式的对象和数组才能进行监听和更新。如果在初始时没有声明为响应式,那么更改对象或数组时,Vue无法检测到变化,从而无法进行更新。




  2. 直接更改对象或数组的属性或元素:在Vue中,如果直接更改对象或数组的属性或元素,Vue无法检测到变化。因此,应该使用Vue提供的响应式方法来更改对象或数组的属性或元素,例如Vue.setVue.$set方法。




  3. 变异方法不会触发更新:Vue会对一些常用的数组变异方法进行封装,使其成为响应式的,例如pushpopshiftunshiftsplicesortreverse方法。但是,如果使用不在这个列表中的变异方法来更改数组,Vue就无法检测到变化。因此,应该尽可能使用Vue封装过的变异方法。




  4. 异步更新:在Vue中,更新是异步的。当数据发生变化时,Vue会将更新推迟到下一个事件循环中。因此,如果在一个事件循环中进行多次数据更改,Vue只会进行一次更新。如果需要在一次事件循环中进行多次数据更改,请使用Vue.nextTick方法。




总之,为了确保Vue可以正确地监听和更新对象和数组,应该在初始时将它们声明为响应式,避免直接更改对象或数组的属性或元素,尽可能使用Vue提供的响应式方法,避免使用不在Vue封装列表中的变异方法,以及注意异步更新的特性。


4. 你在项目里面是怎么使用vuex/pinia?


在我的项目中我使用的是pinia


首先,先通过npm安装pinia


npm install pinia

其次,在根组件app.vue中创建Pinia实例并将其注册为应用程序的插件


import { createPinia } from 'pinia'
const pinia = createPinia()
createApp(App).use(pinia).mount('#app')

接着,在src目录下创建一个store文件夹中的index.js,而在使用Pinia时,通过引入Pinia中的defineStore来定义一个store实例,类似于Vuex的store。然后我定义了不同的子仓库并暴露(export),来存储对应不同的页面所需的数据与操作,之后再返回(return)数据和操作。而在组件中使用Pinia时,需要通过引入,useStore辅助函数获取store实例,并将状态、操作和获取器映射到组件中,以便使用。


import { defineStore } from "pinia";
import { reactive } from "vue";

export const useUserStore = defineStore('user', () => {
const state = reactive({gridList:[]})
const loadUser = async () => {}
return {
state,
loadUser
}
})

import { useUserStore } from "@/store/user";

const userStore = useUserStore();
const gridList = computed(() => userStore.state.gridList);

上海(实习 100-150/天)



该面试是通过视频面试,面试的时候题目相对比较简单,都是一些基础的问题,这也就给了我极大的自信



1. JS的Event Loop你能给我介绍下吗?


因为JS是单线程的语言,为了防止一个函数执行时间过长阻塞后面的代码,所以就需要Event Loop这个事件环的运行机制。


当执行一段有同步又有异步的代码时,会先将同步任务压入执行栈中,然后把异步任务放入异步队列中等待执行,微任务放到微任务队列,宏任务放到宏任务队列,依次执行。执行完同步任务之后,Event Loop会先把微任务队列执行清空,微任务队列清空后,进入宏任务队列,取宏任务队列的第一个项任务进行执行,执行完之后,查看微任务队列是否有任务,有的话,清空微任务队列。然后再执行宏任务队列,反复微任务宏任务队列,直到所有队列任务执行完毕。


PS: 答完了基本的答案之后,最好可以往下继续延申,不要让面试成为一问一答,这样你的面试就会变的比较丰满,让面试官不至于太枯燥,直到面试官让你停为止。



异步队列又分为宏任务队列和微任务队列,因为宏任务队列的执行时间较长,所以微任务队列要优先于宏任务队列先执行。


微任务队列的代表就是,Promise.thenMutationObserver,宏任务的话就是setImmediate setTimeout setInterval



2. 渲染页面的重绘回流你能给我讲一下吗?




  • 重排/回流(Reflow):当DOM元素发生了规格大小,位置,增删改的操作时,浏览器需要重新计算元素的几何属性,重新生成布局,重新排列元素。




  • 重绘(Repaint): 当一个DOM元素的外观样式发生改变,但没有改变布局,重新把DOM元素的样式渲染到页面的过程。





重排和重绘它们会破坏用户体验,并且让UI展示非常迟缓,而在两者无法避免的情况下,重排的性能影响更大,所以一般选择代价更小的重绘。


『重绘』不一定会出现『重排』,『重排』必然会出现『重绘』。



上海(实习 200-210/天)



这场面试很正常,自我感觉含金量也比较高,通过视频面试能够知道,面试官老师人也长得挺帅的,说话和蔼,讲真,人真的挺好的。不过自己还会犯傻,走进思维误区,没有理解清面试官老师的问题,所以在面试中如果没听清楚问题,千万一定要再问一下面试官。



1. 响应式开发你了解吗?响应式是如何实现的呢?


响应式开发是一种设计和开发网站或应用程序的方法,使其能够在不同设备上以适应性和灵活性的方式呈现。它可以确保网站或应用程序在各种屏幕尺寸、浏览器和设备上都能提供良好的用户体验。


响应式开发的实现基于使用CSS媒体查询、弹性布局和流体网格等技术。以下是一些主要的实现方法:




  1. CSS媒体查询:使用CSS媒体查询可以检测设备的屏幕尺寸、分辨率和方向等特性,并根据这些特性应用不同的样式规则。通过定义不同的CSS样式,可以使网页在不同的设备上以不同的方式呈现。




  2. 弹性布局:即(display:flex),使用弹性布局(flexbox)可以创建灵活的布局结构,使内容能够根据屏幕尺寸进行自动调整。弹性布局使得元素的大小、位置和间距能够根据可用空间进行自适应。




  3. 网格布局:即(display:grid),使用流体网格(fluid grid)可以创建基于相对单位(如百分比)的网格系统,使页面的布局能够根据屏幕大小进行缩放和调整。这样可以确保内容在不同屏幕尺寸上均匀分布和对齐。




2. 媒体查询这个你了解吗?


我在使用less预编译样式中使用过媒体查询(这里提一嘴自己使用过less或者其他的预编译),媒体查询使用@media规则来定义,其语法如下:


@media mediatype and|not|only (media feature) {
/* CSS样式规则 */
}

其中,mediatype指定了媒体类型,如screen表示屏幕媒体、print表示打印媒体等。andnotonly是逻辑运算符,用于组合多个条件。media feature表示设备的特性,如width表示屏幕宽度、orientation表示屏幕方向等。


下面是一些常用的媒体特性:



  • width:屏幕宽度。

  • height:屏幕高度。

  • device-width:设备屏幕宽度。

  • device-height:设备屏幕高度。

  • orientation:屏幕方向(横向或纵向)。

  • aspect-ratio:屏幕宽高比。

  • color:设备的颜色位深。

  • resolution:屏幕分辨率。


通过结合不同的媒体特性和条件,可以根据设备的不同特性来应用不同的CSS样式。例如,可以使用媒体查询来定义在屏幕宽度小于某个阈值时应用的样式,或者根据屏幕方向调整布局等。


以下是一个示例,演示如何使用媒体查询在屏幕宽度小于600px时应用特定的样式:


@media screen and (max-width: 600px) {
/* 在屏幕宽度小于600px时应用的样式 */
body {
font-size: 14px;
}
/* 其他样式规则 */
}

这样,当浏览器窗口宽度小于600px时,body元素的字体大小将被设置为14px。


3. CSS的伪元素你知道是什么东西吗?


伪元素是CSS中的一种特殊选择器,用于向选中的元素的特定部分添加样式,而不需要在HTML结构中添加额外的元素。伪元素使用双冒号::作为标识符,用于区分伪类(pseudo-class)和伪元素。(在旧版本的CSS中,单冒号:也被用作伪元素的标识符,但在CSS3规范中,建议使用双冒号以区分伪类和伪元素。)


以下是一些常用的CSS伪元素:



  1. ::before:在选中元素的内容之前插入一个生成的内容。

  2. ::after:在选中元素的内容之后插入一个生成的内容。


这些伪元素可以与CSS的属性和样式一起使用,例如contentcolorbackground等,以为选中的元素的特定部分添加样式。


以下是一个示例,演示如何使用伪元素为元素的内容之前插入一个生成的内容并应用样式:


p::before {
content: "前缀:";
font-weight: bold;
color: blue;
}

在上述示例中,::before伪元素被应用于<p>元素,它在该段落的内容之前插入了一个生成的文本"前缀:",并为该生成的文本应用了加粗字体和蓝色的颜色。


4. 介绍一下HTML5的特有的标签?



  1. 语义化标签



  • <article>:用于表示独立的、完整的文章内容。

  • <section>:用于表示页面或应用程序中的一个区域,可以包含一个标题。

  • <header>:用于表示页面或应用程序的标题,通常包含logo和导航。

  • <footer>:用于表示页面或应用程序的页脚部分,通常包含版权信息、联系方式等。

  • <nav>:用于表示导航链接的集合,通常包含一组指向其他页面的链接。

  • <aside>:用于表示页面或应用程序的旁边栏,通常包含相关的信息、广告、链接等。



  1. <video>:用于嵌入视频文件,可以使用<source>标签指定多个视频文件,以便在不同的浏览器和设备上播放。

  2. <audio>:用于嵌入音频文件,可以使用<source>标签指定多个音频文件,以便在不同的浏览器和设备上播放。

  3. <canvas>:用于创建绘图区域,可以使用JavaScript在上面绘制图形、动画等。

  4. <progress>:用于显示进度条,表示任务完成的进度。


5. 你如果要做一个搜索引擎比较友好的页面,应该是要做到些什么东西呢?




  1. 使用语义化的HTML标记:使用适当的HTML标签来正确表示页面的结构,如使用<header><nav><article>等。




  2. 使用有意义的标题:使用恰当的标题标签(<h1><h2>等)来突出页面的主题和内容。




  3. 提供关键词和描述:在HTML文档中,可以通过<meta>标签来定义各种属性,比如页面的描述和关键字。


    keywords:向搜索引擎说明你的网页的关键词


     `<meta name="keyword" content="前端,面试,小厂">`

    description:告诉搜索引擎你的站点的主要内容


    <meta name="description" content="页面描述,包含关键字和吸引人的内容">



  4. 使用合适的图像标签:为图片使用适当的alt属性,描述图片内容,方便搜索引擎理解图像。




  5. 使用服务端渲染(SSR)的框架,比如vue中的Nuxtreact中的Next,即在服务端生成完整的 HTML 页面,并将其发送给浏览器。这使得搜索引擎可以更好地理解和索引页面的内容,因为它们可以直接看到渲染后的页面。




6. 介绍一下flex的布局吧?


## 阮一峰老师有一个博客,专门讲解一个flex布局,你可以讲一下flex布局吗?


7. 后端和前端的一些交互,你了解是什么东西?


后端和前端之间的交互通常通过前后端分离的架构来实现,其中前端负责展示界面和用户交互,后端负责处理数据和逻辑操作。


以下是一些常见的后端和前端交互的方式和技术:




  1. RESTful API:使用基于HTTP的RESTful API,前端可以向后端发送请求并获取数据。后端提供API接口,通过GETPOSTPUTDELETE等HTTP方法来处理前端请求,并返回相应的数据。前端可以使用Ajax、Fetch API或axios等工具来发送请求和处理响应。




  2. 数据传输格式前后端交互时需要使用一致的数据传输格式。常见的数据格式包括JSON(JavaScript Object Notation)和XML(eXtensible Markup Language)。前端可以发送数据请求给后端,后端将数据以指定的格式进行封装和返回给前端。




  3. 然后我还使用过nodejs中的koa洋葱模型简单搭建过一个MVC结构的服务器。




8. 那你有遇到过跨域问题吗?实际解决方法?


我分别说了




  • JSONP:在DOM文档中,使用<script>标签,但却缺点只能发 GET 请求并且容易受到XSS跨站脚本攻击




  • CORS:通过在服务器配置响应头,Access-Control-Allow-xxx字段来设置访问的白名单、可允许访问的方式等




  • postMessage




  • html原生的websocket




  • 代理 白嫖即食:构建工具的proxy代理配置区别(解决跨域)




讲了这些东西之后,面试官就让我说一下实际解决方法,像jsonp,postMeassage都不是正常的


然后我就把整个CORS跨域的过程给讲了一遍,包含了浏览器的跨域拦截



首先,浏览器进行了一个跨域请求,向服务器发送了一个预检(options)请求,服务器会在响应头部中设置Access-Control-Allow-Origin和Access-Control-Allow-Methods等配置,告知浏览器是否允许跨域请求。如果该页面满足服务器设置的白名单和可允许访问的方式,那么服务器就允许跨域访问,浏览器就会接受响应,进行真实的跨域请求,否则就会报错。



面试基本必问问题


1. 你有什么想问我的吗?(问到这里一场面试结束了)




  1. 公司团队使用的技术栈有哪些?




  2. 如果我面试通过后,公司是否有人带,主要做些什么




  3. 公司团队提交代码的工具有什么要求吗?




  4. 把之前没答上来的问题可以再问一遍(让面试官感到你很好学)




2. 你写项目的时候碰到过印象里比较深刻的一些bug或困难,你怎么解决的?


其实这部分可以从侧面分析这个问题,问你遇到的bug可能一时半会儿不知道怎么回答,但如果问你是如何实现项目中的某个功能,这时候就好回答了,只需要转换回答成没有这个功能代码会出现什么问题。所以面试官不是问你有什么bug,而是你在项目中有哪些亮点。



前端中常见的一些bug



  1. JavaScript 错误:在应用程序中使用的 JavaScript 代码可能包含语法错误或逻辑错误,这些错误会导致应用程序在执行时出现问题,从而导致性能问题。

  2. DOM 操作错误:通过 JavaScript 操作文档对象模型 (DOM) 可以更新应用程序中的 HTML 元素。但是,如果 DOM 操作不正确或在操作过程中执行了太多的操作,可能会导致性能问题。

  3. 页面重绘:当用户与页面交互时,浏览器会执行重新绘制和重排操作。如果页面包含太多的重绘操作或页面重排操作,则可能导致性能问题。

  4. 图像和资源加载:在加载图像和其他资源时,如果没有正确管理缓存或使用适当的图像格式,则可能导致性能问题。

  5. 前端框架错误:使用前端框架时,可能会出现错误或不良的编码实践,这些问题可能会导致性能问题。



axios响应拦截


遇到bug:我是使用mockjs来模拟后端的接口,当时我在设置端口的返回值时,我返回数据有一个状态码以及把json数据中export出来的detail数据添加到data这个需要返回的数据(代码如下),这导致我在获取接口里的数据时需要多.data(引用一层data),当时我没意识,结果一直获取不到数据。


解决办法:通过使用axios进行请求和响应,并在响应的时候设置一个拦截,对响应进行一番处理之后就可以直接拿到接口返回的值,而不会导致接口返回的值不会有太多的嵌套了。


Mock.mock(/\/detail/, 'get', () => {
return {
code: 0, // 返回状态码
data: detail // 返回数据
}
})

import axios from "axios";
// 响应拦截器
axios.interceptors.response.use((res) => {
return res.data
})

图片和组件的懒加载


遇到的bug:我做的项目使用了很多的组件页面和大量的图片,导致在加载页面时耗时比较久,以及在页面的切换时很多暂时不需要的页面组件一次性全部加载了,导致整个项目的性能非常差。


解决办法


图片懒加载:在App.vue中引入VueLazy并且使用app.use启用它,然后把图片中的src改成v-lazy


<img :src="xxx.png">

改成


<img v-lazy="xxx.png">

页面组件懒加载:在router配置中的component,把直接在代码一开始引入组件页面,改成箭头函数式引入。


    import Home from '@/views/Home/Home.vue' 
{
path: '/',
component: Home
},

改成


    {
path: '/',
component: () => import('@/views/Home/Home.vue')
},

搜索界面节流


遇到的bug:在搜索界面的时候,当我一直点击搜索时,它会频繁的进行请求,造成了不必要的性能损耗。


解决办法:使用loadash库中的节流API,进行对触发搜索事件进行节流,防止用户进行频繁的搜索请求导致性能损耗。



import _ from 'lodash'

const value = ref(null)

const ajax1 = () => {
console.log('开始搜索,搜索内容为' + value.value)
}

let debounceAjax1 = _.debounce(ajax1, 1000)

const onSearch = () => {
if (!value.value) {
showToast('搜索内容为空,请输入内容')
return
}
debounceAjax1()
}

404页面


遇到的bug:当输入url中没有在路由配置中配置过的路径时,页面它会出现空白,并且浏览器发出警告,如果我这个项目上线的话,可能会造成用户的体验不友好和搜索引擎不友好。


解决办法:在路由配置中再配置一个404页面的路径,这样就能使用户不管怎么输入不合规的url后,都会提示用户输错了网址。


    {
path: '/404',
name: 'NotFound',
component: () => import('@/views/NotFound/Index.vue')
},
// 所有未定义路由,全部重定向到404页
{
path: '/:pathMatch(.*)',
redirect: '/404'
}

结语


面试,说到底,迈开第一步其实是最重要的,别想那么多,要抱着反正有那么多家公司,我没必要非要去你这一家的心态去面试,把面试官当作一个久久未联系过的老朋友,突然有一天碰到了聊起天。面试完之后一定及时的整理复盘,不断地让自己变得更加牢固。


作者:吃腻的奶油
来源:juejin.cn/post/7233307834456375353
收起阅读 »

听说你会架构设计?来,弄一个群聊系统

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。 1. 引言 前些天所在部门出去团建,于是公司行政和 HR 拉了一个微信群,发布一些跟团和集合信息。 当我正在查看途径路线和团建行程时,忽然一条带着...
继续阅读 »

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。


1. 引言


前些天所在部门出去团建,于是公司行政和 HR 拉了一个微信群,发布一些跟团和集合信息。


当我正在查看途径路线和团建行程时,忽然一条带着喜意的消息扑面而来,消息上赫然带着八个大字:恭喜发财,大吉大利



抢红包!!原来是公司领导在群里发了个红包,于是引得群员哄抢,气氛其乐融融。



毕竟,团不团建无所谓,不上班就很快乐;抢多抢少无所谓,有钱进就很开心。



打工人果然是最容易满足的生物!


我看着群里嬉戏打闹的聊天,心中陷入了沉思:微信这个集齐了陌生人聊天、文件分享和抢红包功能的群聊设计确实有点意思,如果在面试或者工作中让我们设计一个群聊系统,需要从哪些方面来考虑呢?


群聊系统设计


面试官:微信作为 10 亿用户级别的全民 App,有用过吧?


我:(内心 OS,说没用过你也不会相信啊~)当然,亲爱的面试官,我经常使用微信来接收工作消息和文件,并且经常在上面处理工作内容。


面试官:(内心 OS:这小伙子工作意识很强嘛,加分!)OK,微信的群聊功能是微信里面核心的一个能力,它可以将数百个好友或陌生人放进一个群空间,如果让你设计一个用户量为 10 亿用户的群聊系统,你会怎么设计呢?


2. 系统需求


2.1 系统特点与功能需求


我:首先群聊功能是社交应用的核心能力之一,它允许用户创建自己的社交圈子,与家人、朋友或共同兴趣爱好者进行友好地交流。


以下是群聊系统常见的几个功能:





  • 创建群聊:用户可以创建新的聊天群组,邀请其他好友用户加入或与陌生人面对面建群。




  • 群组管理:群主和管理员能够管理群成员,设置规则和权限。




  • 消息发送和接收:允许群成员发送文本、图片、音频、视频等多种类型的消息,并推送给所有群成员。




  • 实时通信:消息应该能够快速传递,确保实时互动。




  • 抢红包:用户在群聊中发送任意个数和金额的红包,群成员可以抢到随机金额的红包。




2.2 非功能需求


除了功能需要,当我们面对 10 亿微信用户每天都可能使用建群功能的情景时,还需要处理大规模的用户并发。


这就引出了系统的非功能需求,包括:



  • 高并发:系统需要支持大量用户同时创建和使用群组,以确保无延迟的用户体验。

  • 高性能:快速消息传递、即时响应,是数字社交的关键。

  • 海量存储:系统必须可扩展,以容纳用户生成的海量消息文本、图片及音视频数据。


面试官:嗯,不错,那你可以简要概述一下这几个常用的功能吗?


3. 核心组件


我:好的,我们首先做系统的概要设计,这里涉及到群聊系统的核心组件和基本业务的概要说明。


3.1 核心组件


群聊系统中,会涉及到如下核心组件和协议。




  • 客户端:接收手机或 PC 端微信群聊的消息,并实时传输给后台服务器;

  • Websocket传输协议:支持客户端和后台服务端的实时交互,开销低,实时性高,常用于微信、QQ 等 IM 系统通信系统;

  • 长连接集群:与客户端进行 Websocket 长连接的系统集群,并将消息通过中间件转发到应用服务器;

  • 消息处理服务器集群:提供实时消息的处理能力,包括数据存储、查询、与数据库交互等;




  • 消息推送服务器集群:这是信息的中转站,负责将消息传递给正确的群组成员;




  • 数据库服务器集群:用于存储用户文本数据、图片的缩略图、音视频元数据等;




  • 分布式文件存储集群:存储用户图片、音视频等文件数据。




3.2 业务概要说明


在业务概要说明里,我们关注用户的交互方式和数据存储......


面试官:稍等一下,群聊系统的好友建群功能比较简单,拉好友列表存数据就可以了!你用过面对面建群吧,可以简要说一下如何设计面对面建群功能吗?


我:(内心 OS,还好之前在吃饭时用过面对面建群结账,不然就G了),好的,群聊系统除了拉好友建群外,还支持面对面建群的能力。


4. 面对面建群


用户发起面对面建群后,系统支持输入一个 4 位数的随机码,周围的用户输入同一个随机码便可加入同一个群聊,面对面建群功能通常涉及数据表设计和核心业务交互流程如下。


4.1 数据库表设计




  1. User 表:存储用户信息,包括用户 ID、昵称、头像等。




  2. Gr0up 表:存储群组信息,包括群 ID、群名称、创建者 ID、群成员个数等。




  3. Gr0upMember 表:关联用户和群组,包括用户 ID 和群 ID。




  4. RandomCode 表:存储面对面建群的随机码和关联的群 ID。




4.2 核心业务交互流程



用户 A 在手机端应用中发起面对面建群,并输入一个随机码,校验通过后,等待周围(50 米之内)的用户加入。此时,系统将用户信息以 HashMap 的方式存入缓存中,并设置过期时间为 3min


{随机码,用户列表[用户A(ID、名称、头像)]}

用户 B 在另一个手机端发起面对面建群,输入指定的随机码,如果该用户周围有这样的随机码,则进入同一个群聊等待页面,并可以看到其它群员的头像和昵称信息


此时,系统除了根据随机码获取所有用户信息,也会实时更新缓存里的用户信息。



成员A进群


当第一个用户点击进入该群时,就可以加入群聊,系统将生成的随机码保存在 RandomCode 表中,并关联到新创建的群 ID,更新群成员的个数。


然后,系统将用户信息和新生成的群聊信息存储在 Gr0up、Gr0upMember 表中,并实时更新群成员个数。


成员B加入


然后,B 用户带着随机码加入群聊时,手机客户端向服务器后端发送请求,验证随机码是否有效。后台服务检查随机码是否存在于缓存中,如果存在,则校验通过。


然后,根据 Gr0up 中的成员个数,来判断当前群成员是否满员(目前普通用户创建的群聊人数最多为 500 人)。


如果验证通过,后台将用户 B 添加到群成员表 Gr0upMember 中,并返回成功响应。


面试官:如果有多个用户同时加入,MySQL 数据库如何保证群成员不会超过最大值呢?


我:有两种方式可以解决。一个是通过 MySQL 的事务,将获取 Gr0up 群成员数和插入 Gr0upMember 表操作放在同一个事务里,但是这样可能带来锁表的问题,性能较差。


另一种方式是采用 Redis 的原子性命令incr 来记录群聊的个数,其中 key 为群聊ID,value 为当前群成员个数。


当新增群员时,首先将该群聊的人数通过 incr 命令加一,然后获取群成员个数。如果群员个数大于最大值,则减一后返回群成员已满的提示。


使用 Redis 的好处是可以快速响应,并且可以利用 Redis 的原子特性避免并发问题,在电商系统中也常常使用类似的策略来防止超卖问题


位置算法


同时,在面对面建群的过程中相当重要的能力是标识用户的区域,比如 50 米以内。这个可以用到 Redis 的 GeoHash 算法,来获取一个范围内的所有用户信息


由于篇幅有限,这里不展开赘述,想了解更多位置算法相关的细节,可以看我之前的文章:听说你会架构设计?来,弄一个公交&地铁乘车系统。


面试官:嗯不错,那你再讲一下群聊系统里的消息发送和接收吧!


5. 消息发送与接收


我:当某个成员在微信群里发言,系统需要处理消息的分发、通知其他成员、以及确保消息的显示


在群聊系统中保存和展示用户的图片、视频或音频数据时,通常需要将元数据和文件分开存储。


其中元数据存储在 MySQL 集群,文件数据存储在分布式对象存储集群中。


5.1 交互流程


消息发送和接收的时序图如下所示:





  1. 用户A在群中发送一条带有图片、视频或音频的消息。




  2. 移动客户端应用将消息内容和媒体文件上传到服务器后端。




  3. 服务器后端接收到消息和媒体文件后,将消息内容存储到 Message 表中,同时将媒体文件存储到分布式文件存储集群中。在 Message 表里,不仅记录了媒体文件的 MediaID,以便关联消息和媒体;还记录了缩略图、视频封面图等等




  4. 服务器后端会向所有群成员广播这条消息。移动客户端应用接收到消息后,会根据消息类型(文本、图片、视频、音频)加载对应的展示方式。




  5. 当用户点击查看图片、视频或音频缩略图时,客户端应用会根据 MediaID 到对象存储集群中获取对应的媒体文件路径,并将其展示给用户。




5.2 消息存储和展示


除了上述建群功能中提到的用户表和群组表以外,存储元数据还需要以下表结构:



  1. Message表: 用于存储消息,每个消息都有一个唯一的 MessageID,消息类型(文本、图片、视频、音频),消息内容(文字、图片缩略图、视频封面图等),发送者 UserID、接收群 Gr0upID、发送时间等字段。

  2. Media表: 存储用户上传的图片、视频、音频等媒体数据。每个媒体文件都有一个唯一的 MediaID,文件路径、上传者 UserID、上传时间等字段。

  3. MessageState表: 用于存储用户消息状态,包括 MessageID、用户 ID、是否已读等。在消息推送时,通过这张表计算未读数,统一推送给用户,并在离线用户的手机上展示一个小数字代表消息未读数。


面试官:我们时常看到群聊有 n 个未读消息,这个是怎么设计的呢?


我:MessageState 表记录了用户的未读消息数,想要获取用户的消息未读数时,只需要客户端调用一下接口查询即可获取,这个接口将每个群的未读个数加起来,统一返回给客户端,然后借助手机的 SDK 推送功能加载到用户手机上。


面试官:就这么简单吗,可以优化一下不?


我:(内心 OS,性能确实很差,就等着你问呢)是的,我们需要优化一下,首先 MySQL 查询 select count 类型的语句时,都会触发全表扫描,所以每次加载消息未读数都很慢。


为了查询性能考虑,我们可以将用户的消息数量存入 Redis,并实时记录一个未读数值。并且,当未读数大于 99 时,就将未读数值置为 100 且不再增加。


当推送用户消息时,只要未读数为 100,就将推送消息数设置为 99+,以此来提升存储的性能和交互的效率。


面试官:嗯,目前几乎所有的消息推送功能都是这么设计的。那你再说一下 10 亿用户的群聊系统应该如何在高并发,海量数据下保证高性能高可用吧!


我:我想到了几个点,比如采用集群部署、消息队列、多线程、缓存等。


集群部署:可扩展


在群聊系统中,我们用到了分布式可扩展的思想,无论是长连接服务、消息推送服务,还是数据库以及分布式文件存储服务,都是集群部署。


一方面防止单体故障,另一方面可以根据业务来进行弹性伸缩,提升了系统的高可用性。


消息队列:异步、削峰


在消息推送时,由于消息量和用户量很多,所以我们将消息放到消息队列(比如 Kafka)中异步进行消费和推送,来进行流量削峰,防止数据太多将服务打崩。


多线程


在消息写入和消费时,可以多线程操作,一方面节省了硬件开销,不至于部署太多机器。另一方面提升了效率,毕竟多个流水线工作肯定比单打独斗更快。


其它优化


缓存前面已经说到了,除了建群时记录 code,加群时记录群成员数,我们还可以缓存群聊里最近一段时间的消息,防止每个用户都去 DB 拉取一遍数据,这提升了消息查阅的效率。


除此之外,为了节省成本,可以记录流量的高峰时间段,根据时间段来定时扩缩节点(当然,这只是为了成本考虑,在实际业务中这点开销不算什么大问题)。


6. 小结


后续


面试官:嗯不错,实际上的架构中也没有节省这些资源,而是把重心放在了用户体验上。(看了看表)OK,那今天的面试就到这,你有什么想问的吗?


我:(内心 OS,有点慌,但是不能表现出来)由于时间有限,之前对系统高并发、高性能的设计,以及对海量数据的处理浅尝辄止,这在系统设计的面试中占比如何?


面试官:整体想得比较全,但是还不够细节。当然,也可能是时间不充分的原因,已经还不错了!


我:(内心 OS,借你吉言)再想问一下,如果我把这些写出来,会有读者给我点赞、分享、加入在看吗?


面试官:……


结语


群聊系统是社交应用的核心功能之一,每个社交产品几乎都有着群聊系统的身影:包括但不限于 QQ、微信、抖音、小红书等。


上述介绍的技术细节可能只是群聊系统的冰山一角,像常见的抢红包、群内音视频通话这些核心功能也充斥着大量的技术难点。


但正是有了这些功能,才让我们使用的 App 变得更加有趣。而这,可能也是技术和架构的魅力所在吧~



由于篇幅有限,本文到这就结束了。后续可能会根据阅读量、在看数的多寡,判断是否继续更新抢红包、群内音视频通话等核心功能,感兴趣的小伙伴可以关注一下。


作者:xin猿意码
来源:juejin.cn/post/7298985311771656244
收起阅读 »

5年编程之心得体会

关键词:代码、编程、业务、技术、数据、面试、成长、开发、逻辑、能力 一、心得 说说我自己理解的编程:编程就是要教会只会0和1的电脑去解决现实生活中各种复杂的问题,电脑只有与或非三种逻辑,只有顺序循环分支三种控制结构。我一直觉得编程某种意义上是一门“手艺”,...
继续阅读 »

关键词:代码、编程、业务、技术、数据、面试、成长、开发、逻辑、能力


一、心得




  1. 说说我自己理解的编程:编程就是要教会只会0和1的电脑去解决现实生活中各种复杂的问题,电脑只有与或非三种逻辑,只有顺序循环分支三种控制结构。我一直觉得编程某种意义上是一门“手艺”,因为优雅而高效的代码,就如同完美的工艺品一样让人赏心悦目,最主要、最容易被我们直观感受到的问题就是:烂代码实在是太多了。后来,在亲历了许多个令人不悦的项目之后,我才慢慢看清楚:即便两个人实现同一个功能,最终效果看上去也一模一样,但代码质量却可能有着云泥之别,好代码就像好文章,语言精练、层次分明,让人读了还想读;烂代码则像糊成一团的意大利面条,处处充斥着相似的逻辑,模块间的关系错综复杂,多看一眼都令人觉得眼睛会受伤




  2. 越简洁的代码,越清晰的逻辑,就越不容易出错。而且在实际工作中不是用代码量来评价一个程序员的工作强度和等级,高端的同学总是用最简短精妙的代码来解决问题。代码变得越来越简洁,代码看起来更加结构化和规范化、扁平结构比嵌套结构更好




  3. 在团队合作中,你的代码不只有你在维护,降低别人的阅读/理解代码逻辑的成本是一种良好的品德




  4. 简单的代码,只会用到最基本的语法糖,复杂的高级特性,会有更多的依赖(如语言的版本)




  5. 一个公司如果数据库从来不出问题,那一定是因为没有业务量或者流量




  6. 所有技术的选型和设计,都有它的应用场景,除去那些让人开心的案例,剩下的毫无疑问就是坑;如何尽可能地避开这些坑,如何在出现问题的时候可以用最快的速度去修复,这些都是至关重要的因素




  7. 任何项目在早期,整个数据基本处于裸奔状态,没有做任何的权限校验与审计,用户可以对数据为所欲为,这个阶段主要考虑效率优先,随着业务的发展,数据安全的重要性愈发突显,大数据权限系统则会应运而生




  8. 现实中大部分程序员都属于是斐波那契程序员




  9. 关于代码意见:我的看法是,一个处理代码行数超过四五十行,就可以考虑缩减抽离了,为什么要这么做,其实很简单:出于可维护性,一个业务再复杂,离不开一个主干逻辑(也可能是多个)和 N 个子逻辑,你不能把臃肿的子逻辑代码放在同一个处理代码内部,这样太影响可读性,影响可读性的后果就是提高了维护成本
    7c90c17b3544b18518addf56f0bf9e29.jpg




  10. 首先从成长的角度来看,追求代码质量是一个优秀程序员对自己的要求,我想任何一门工艺、手艺,从业者想要把他做的更好,这是一个非常自然的目标,我们既然靠写代码谋生,就应该对代码有追求,对代码有自己的审美和判断,代码质量真的只是一个底线,在这条底线之上,才有可能谈稳定,谈伸缩,谈性能,谈架构,优雅与否则是区分顶级程序员与一般程序员的终极指标所在,能用不是代码的标准,能被维护才是代码的标准




  11. 程序员大厂面试三板斧:八股文、算法、项目经验




  12. 切记切记:不要用战术上的勤奋来掩盖战略上的懒惰




  13. 写代码是要有感觉的:感觉到了思如泉涌、键盘啪啪作响,一个需求很快就做完了,搬砖速度飞起




  14. 我们是一群与画家有着极大的相似性的猿/媛,是在创造,而不是完成某个任务,会在求解问题过程中产生精神愉悦或享受,我们崇尚分享、开放、民主、计算机的自由使用和进步




  15. 真正工作中都是写写业务代码,哪有那么多深度的技术问题;其实我觉得写代码最重要是逻辑思维够强,代码的规范工整,思路清晰,化繁为简,否则你说你写了多复杂的海量并发处理,多有深度,但是代码乱得一团糟,别人没法维护,再牛逼有何用




  16. 软件开发的任务应该是思考,思考手头的问题,设计出一个完美的解决方案,然后再把这个方案转变成可供用户使用的软件




  17. 阅读他人的代码是一种很棒的学习方式。正如一位作家所说,“阅读其他人的作品是让你成为一个更好的作家的最好方式”,这同样适用于代码




  18. 优秀程序员绝不只有技术:


    (1)问题解决能力


    (2)业务理解能力


    (3)沟通能力


    (4)产品思维


    (5)管理能力


    (6)分享表达能力


    ...




二、总结


锁屏.jpg
最后再开个玩笑哈哈:所以在感慨,在这个行业可能确实需要像前任这样的码农,挖坑,填坑,坑更多了,再填......生生不息,这样行业才能长久生存,没有 bug 可以修复,没有屎山可以铲,我们真的就失业了,如果每个程序员写的文档详细,逻辑清晰,注释清楚,拿什么让老板离不开你,靠什么威胁老板给你高工资,所以我现在的处境用一句话形容:全凭同行衬托


作者:纯之风
来源:juejin.cn/post/7273025562141327396
收起阅读 »

我给项目加了性能守卫插件,同事叫我晚上别睡的太死

web
引言 给组内的项目都在CICD流程上更新上了性能守卫插件,效果也还不错,同事还疯狂夸奖我 接下里进入我们的此次的主题吧 由于我组主要是负责的是H5移动端项目,老板比较关注性能方面的指标,比如首页打开速度,所以一般会关注FP,FCP等等指标,所以一般项目写完...
继续阅读 »



引言


给组内的项目都在CICD流程上更新上了性能守卫插件,效果也还不错,同事还疯狂夸奖我


WX20230708-170807@2x.png


接下里进入我们的此次的主题吧



由于我组主要是负责的是H5移动端项目,老板比较关注性能方面的指标,比如首页打开速度,所以一般会关注FP,FCP等等指标,所以一般项目写完以后都会用lighthouse查看,或者接入性能监控系统采集指标.



WX20230708-141706@2x.png


但是会出现两个问题,如果采用第一种方式,使用lighthouse查看性能指标,这个得依赖开发自身的积极性,他要是开发完就Merge上线,你也不知道具体指标怎么样。如果采用第二种方式,那么同样是发布到线上才能查看。最好的方式就是能强制要求开发在还没发布的时候使用lighthouse查看一下,那么在什么阶段做这个策略呢。聪明的同学可能想到,能不能在CICD构建阶段加上策略。其实是可以的,谷歌也想到了这个场景,提供性能守卫这个lighthouse ci插件


性能守卫



性能守卫是一种系统或工具,用于监控和管理应用程序或系统的性能。它旨在确保应用程序在各种负载和使用情况下能够提供稳定和良好的性能。



Lighthouse是一个开源的自动化工具,提供了四种使用方式:




  • Chrome DevTools




  • Chrome插件




  • Node CLI




  • Node模块




image.png


其架构实现图是这样的,有兴趣的同学可以深入了解一下


这里我们我们借助Lighthouse Node模块继承到CICD流程中,这样我们就能在构建阶段知道我们的页面具体性能,如果指标不合格,那么就不给合并MR


剖析lighthouse-ci实现


lighthouse-ci实现机制很简单,核心实现步骤如上图,差异就是lighthouse-ci实现了自己的server端,保持导出的性能指标数据,由于公司一般对这类数据敏感,所以我们一般只需要导出对应的数据指标JSON,上传到我们自己的平台就行了。


image.png


接下里,我们就来看看lighthouse-ci实现步骤:





    1. 启动浏览器实例:CLI通过Puppeteer启动一个Chrome实例。


    const browser = await puppeteer.launch();




    1. 创建新的浏览器标签页:接着,CLI创建一个新的标签页(或称为"页面")。


    const page = await browser.newPage();




    1. 导航到目标URL:CLI命令浏览器加载指定的URL。


    await page.goto('https://example.com');




    1. 收集数据:在加载页面的同时,CLI使用各种Chrome提供的API收集数据,包括网络请求数据、JavaScript执行时间、页面渲染时间等。





    1. 运行审计:数据收集完成后,CLI将这些数据传递给Lighthouse核心,该核心运行一系列预定义的审计。





    1. 生成和返回报告:最后,审计结果被用来生成一个JSON或HTML格式的报告。


    const report = await lighthouse(url, opts, config).then(results => {
    return results.report;
    });




    1. 关闭浏览器实例:报告生成后,CLI关闭Chrome实例。


    await browser.close();



// 伪代码
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const {URL} = require('url');

async function run() {
// 使用 puppeteer 连接到 Chrome 浏览器
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});

// 新建一个页面
const page = await browser.newPage();

// 在这里,你可以执行任何Puppeteer代码,例如:
// await page.goto('https://example.com');
// await page.click('button');

const url = 'https://example.com';

// 使用 Lighthouse 进行审查
const {lhr} = await lighthouse(url, {
port: new URL(browser.wsEndpoint()).port,
output: 'json',
logLevel: 'info',
});

console.log(`Lighthouse score: ${lhr.categories.performance.score * 100}`);

await browser.close();
}

run();

导出的HTML文件


image.png


导出的JSON数据


image.png


实现一个性能守卫插件


在实现一个性能守卫插件,我们需要考虑以下因数:





    1. 易用性和灵活性:插件应该易于配置和使用,以便它可以适应各种不同的CI/CD环境和应用场景。它也应该能够适应各种不同的性能指标和阈值。





    1. 稳定性和可靠性:插件需要可靠和稳定,因为它将影响整个构建流程。任何失败或错误都可能导致构建失败,所以需要有强大的错误处理和恢复能力。





    1. 性能:插件本身的性能也很重要,因为它将直接影响构建的速度和效率。它应该尽可能地快速和高效。





    1. 可维护性和扩展性:插件应该设计得易于维护和扩展,以便随着应用和需求的变化进行适当的修改和更新。





    1. 报告和通知:插件应该能够提供清晰和有用的报告,以便开发人员可以快速理解和处理任何性能问题。它也应该有一个通知系统,当性能指标低于预定阈值时,能够通知相关人员。





    1. 集成:插件应该能够轻松集成到现有的CI/CD流程中,同时还应该支持各种流行的CI/CD工具和平台。





    1. 安全性:如果插件需要访问或处理敏感数据,如用户凭证,那么必须考虑安全性。应使用最佳的安全实践来保护数据,如使用环境变量来存储敏感数据。




image.png


// 伪代码
//perfci插件
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const { port } = new URL(browser.wsEndpoint());

async function runAudit(url) {
const browser = await puppeteer.launch();
const { lhr } = await lighthouse(url, {
port,
output: 'json',
logLevel: 'info',
});
await browser.close();

// 在这里定义你的性能预期
const performanceScore = lhr.categories.performance.score;
if (performanceScore < 0.9) { // 如果性能得分低于0.9,脚本将抛出错误
throw new Error(`Performance score of ${performanceScore} is below the threshold of 0.9`);
}
}

runAudit('https://example.com').catch(console.error);


使用


name: CI
on: [push]
jobs:
lighthouseci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm install && npm install -g @lhci/cli@0.11.x
- run: npm run build
- run: perfci autorun


性能审计


const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const nodemailer = require('nodemailer');

// 配置邮件发送器
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your-email@gmail.com',
pass: 'your-password',
},
});

// 定义一个函数用于执行Lighthouse审计并处理结果
async function runAudit(url) {
// 通过Puppeteer启动Chrome
const browser = await puppeteer.launch({ headless: true });
const { port } = new URL(browser.wsEndpoint());

// 使用Lighthouse进行性能审计
const { lhr } = await lighthouse(url, { port });

// 检查性能得分是否低于阈值
if (lhr.categories.performance.score < 0.9) {
// 如果性能低于阈值,发送警告邮件
let mailOptions = {
from: 'your-email@gmail.com',
to: 'admin@example.com',
subject: '网站性能低于阈值',
text: `Lighthouse得分:${lhr.categories.performance.score}`,
};

transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

await browser.close();
}

// 使用函数
runAudit('https://example.com');


接下来,我们分步骤大概介绍下几个核心实现


数据告警


// 伪代码
const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const nodemailer = require('nodemailer');

// 配置邮件发送器
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your-email@gmail.com',
pass: 'your-password',
},
});

// 定义一个函数用于执行Lighthouse审计并处理结果
async function runAudit(url) {
// 通过Puppeteer启动Chrome
const browser = await puppeteer.launch({ headless: true });
const { port } = new URL(browser.wsEndpoint());

// 使用Lighthouse进行性能审计
const { lhr } = await lighthouse(url, { port });

// 检查性能得分是否低于阈值
if (lhr.categories.performance.score < 0.9) {
// 如果性能低于阈值,发送警告邮件
let mailOptions = {
from: 'your-email@gmail.com',
to: 'admin@example.com',
subject: '网站性能低于阈值',
text: `Lighthouse得分:${lhr.categories.performance.score}`,
};

transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

await browser.close();
}

// 使用函数
runAudit('https://example.com');

处理设备、网络等不稳定情况


// 伪代码

// 网络抖动
const { lhr } = await lighthouse(url, {
port,
emulatedFormFactor: 'desktop',
throttling: {
rttMs: 150,
throughputKbps: 1638.4,
cpuSlowdownMultiplier: 4,
requestLatencyMs: 0,
downloadThroughputKbps: 0,
uploadThroughputKbps: 0,
},
});


// 设备
const { lhr } = await lighthouse(url, {
port,
emulatedFormFactor: 'desktop', // 这里可以设定为 'mobile' 'desktop'
});


用户登录态问题



也可以让后端同学专门提供一条内网访问的登录态接口环境,仅用于测试环境



const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const fs = require('fs');
const axios = require('axios');
const { promisify } = require('util');
const { port } = new URL(browser.wsEndpoint());

// promisify fs.writeFile for easier use
const writeFile = promisify(fs.writeFile);

async function runAudit(url, options = { port }) {
// 使用Puppeteer启动Chrome
const browser = await puppeteer.launch();
const page = await browser.newPage();

// 访问登录页面
await page.goto('https://example.com/login');

// 输入用户名和密码
await page.type('#username', 'example_username');
await page.type('#password', 'example_password');

// 提交登录表单
await Promise.all([
page.waitForNavigation(), // 等待页面跳转
page.click('#login-button'), // 点击登录按钮
]);

// 运行Lighthouse
const { lhr } = await lighthouse(url, options);

// 保存审计结果到JSON文件
const resultJson = JSON.stringify(lhr);
await writeFile('lighthouse.json', resultJson);

// 上传JSON文件到服务器
const formData = new FormData();
formData.append('file', fs.createReadStream('lighthouse.json'));

// 上传文件到你的服务器
const res = await axios.post('https://your-server.com/upload', formData, {
headers: formData.getHeaders()
});

console.log('File uploaded successfully');

await browser.close();
}

// 运行函数
runAudit('https://example.com');

总结


性能插件插件还有很多需要考虑的情况,所以,不懂还是来私信问我吧,我同事要请我吃饭去了,不写了。


作者:linwu
来源:juejin.cn/post/7253331974051823675
收起阅读 »

前端小工搬砖一周年了

入职一周年啦!对过去这一年的工作和生活都做个总结吧,经历的时候总觉得前方迷雾重重,回首看去每个脚印都清清楚楚。 工作 我还记得入职第一周的时候,交给我的第一个任务是修改几个页面的文案。工作确实不难,只是项目比较老了,整体结构比较复杂。开始的一些任务都是修修补补...
继续阅读 »

入职一周年啦!对过去这一年的工作和生活都做个总结吧,经历的时候总觉得前方迷雾重重,回首看去每个脚印都清清楚楚。


工作


我还记得入职第一周的时候,交给我的第一个任务是修改几个页面的文案。工作确实不难,只是项目比较老了,整体结构比较复杂。开始的一些任务都是修修补补,只要找对了地方,写个几行代码就可以了。


后面我也慢慢独立做一些新功能了。当亲朋好友问起我的工作时,我还能说出来我做了xxx。整体来说,我的工作都不算难,有些也只是比较麻烦复杂。现在的我就是个搬砖小工,告诉我需要做什么,然后我把它做完。


接下来我比较感兴趣的一个方向是提升开发效率。梳理日常的工作流程,通过一些工具或工程化的手段,来提供更好的开发体验。


学习


专业知识的学习上,感觉没有学习很多。平时的工作内容都不算太专业,也就是个基础的开发。虽然写的代码不少了,但也基本上就是使用一些语法和组件,不涉及太多原理上的东西。


前半年基本上都是学了一些跟开发密切相关的基础知识,后半年开始进行体系化的学习,每天早上到公司后学一会。在极客上学了一些课程,目前在看《vue.js的设计与实现》,一边看一边敲代码,快看完了,对于vue的原理确实更清楚了,平时写代码也更清晰了。


只是要具体说说学了点什么的话,又啥也想不起来了。还是要多写学习总结啊,写了会对所学的东西有个更深刻的理解。定个目标好了,以后一周写一篇学习总结。


生活


生活还是过得比较满意的。一开始找房就比较顺利,住了一年了,除了疫情期间邻居装修,整体还是比较满意的。


这一年也基本坚持下来了跑步的习惯,虽然也就养生跑了。除了疫情期间,每周会去跑个步。身体状态也都还不错。


今年对做甜点非常感兴趣。做了各种奶油蛋糕和慕斯蛋糕。天气热了之后,开始做糖水,杨枝甘露、各种水果奶昔还有烧仙草等等,还做了我非常喜欢的香草冰淇淋。


读书


读书想单独列出来,感觉能反映一个时期的思想状态。入职前半年看书时长40个小时,算是看书比较少的时段了。入职一个月后,我就开始看《工作的意义》和《生命的意义》了,还看了一些重生小说,半本《原则:应对变化中的世界秩序》,看完了《群体的疯狂》、《置身事内》和《梁永安:阅读、游历和爱情》。


上半年看的书比较杂乱,整个人也是迷茫和焦虑,看的基本都是一些热门书。


入职下半年就感觉好多了,看书74个小时。看完了《论生命之短暂》、《撒哈拉的故事》、《结构性改革》、《始于极限》、《庄子》、《道德经》、《孟子》、《传习录》等。看的都是一些我感兴趣的书,偏哲学的书。


下半年整个人也不那么焦虑了,虽然裁员的消息还是满天飞,但我也不担心了。看前方还是迷雾重重,只是我不再紧盯着前方,我看着脚下每一步。


总结


工作上算是中规中矩。生活上是丰富多彩,体验了许多之前没玩过、没做过的,但是我最爱的依然是跑步。最满意的是心态上的变化,从初入职场的迷茫焦虑,到逐渐平淡释然。只是一直困惑我的问题是方向,我始终没有找到人生的方向,一直以来也没有什么追求。不过也慢慢释然了,好像也没必要非要有目标。但行好事,莫问前程。


作者:叶之
来源:juejin.cn/post/7257122036597063735
收起阅读 »

聊聊深色模式(Dark Mode)

web
什么是深色模式 深色模式(Dark Mode),或者叫暗色模式,黑夜模式,是和日常使用的浅色(亮色)模式(Light Mode)相对应的一种UI主题。 深色模式最早来源于人机交互领域的研究和实践,从2018年左右开始,Apple推出了iOS 13,其中包含了系...
继续阅读 »

什么是深色模式


深色模式(Dark Mode),或者叫暗色模式,黑夜模式,是和日常使用的浅色(亮色)模式(Light Mode)相对应的一种UI主题。


深色模式最早来源于人机交互领域的研究和实践,从2018年左右开始,Apple推出了iOS 13,其中包含了系统级别的深色模式,可以将整个系统的界面切换为暗色调。


Google也在Android 10中推出了类似的深色模式功能,使深色模式得到了更广泛的应用和推广。


iOS官网的深色模式示例


iOS官网的深色模式示例


它不是简单的把背景变为黑色,文字变为白色,而是一整套的配色主题,这种模式相比浅色模式更加柔和,可以减少亮度对用户眼睛造成的刺激和疲劳。


随着越来越多的应用开始支持深色模式,作为开发也理应多了解下深色模式。


首先,怎么打开深色模式


在说怎么实现之前,先来说说我们要怎么打开深色模式,一般来说只需要在系统调节亮度的地方就可以调节深色模式,具体我们可以看各个系统的官方网站即可:
如何打开深色模式



但是在开发调试调试时,不断切换深色模式可能比较麻烦,这时浏览器就提供了一种模拟系统深色模式的方法,可以让当前的Web页面临时变为深色模式,以Chrome为例:
浏览器模拟深色/浅色模式



  1. 打开Chrome DevTools

  2. Command+Shift+P

  3. 输入dark或者light

  4. 打开深色或者浅色模式打开深色模式打开浅色模式


不过要注意的是,浏览器DevTools里开启深色模式,在关闭开发者工具后就会失效。


自动适配 - 声明页面支持深色模式


其实,在支持深色模式的浏览器中,有一套默认的深色模式,只需要我们在应用中声明,即可自动适配深色模式,声明有两种方式:


1. 添加color-schememeta标签


在HTML的head标签中增加color-schememeta标签,如下所示:


<!--
The page supports both dark and light color schemes,
and the page author prefers light.
-->

<meta name="color-scheme" content="light dark">

通过上述声明,告诉浏览器这个页面支持深色模式和浅色模式,并且页面更倾向于浅色模式。在声明了这个之后,当系统切换到深色模式时,浏览器将会把我们的页面自动切换到默认的深色模式配色,如下所示:
左边浅色,右边是浏览器自动适配的深色


左边浅色,右边是浏览器自动适配的深色


2. 在CSS里添加color-scheme属性


/*
The page supports both dark and light color schemes,
and the page author prefers light.
*/

:root {
color-scheme: light dark;
}

通过上面在:root元素上添加color-scheme属性,值为light dark,可以实现和meta标签一样的效果,同时这个属性不只可用于:root级别,也可用于单个元素级别,比meta标签更灵活。


但是提供color-schemeCSS属性需要首先下载CSS(如果通过<link rel="stylesheet">引用)并进行解析,使用meta可以更快地使用所需配色方案呈现页面背景。两者各有优劣吧。


自定义适配


1. 自动适配的问题


在上面说了我们可以通过一些标签或者CSS属性声明,来自动适配深色模式,但是从自动适配的结果来看,适配的并不理想:
左边浅色,右边是浏览器自动适配的深色


左边浅色,右边是浏览器自动适配的深色




  • 首先是默认的黑色字体,到深色模式下变成了纯白色#FFFFFF,和黑色背景(虽然说不是纯黑)对比起来很扎眼,在一些设计相关的文章[1][2]里提到,深色模式下避免使用纯黑和纯白,否则更容易使人眼睛👁疲劳,同时容易在页面滚动时出现拖影:


    滚动时出现拖影,图片来源「即刻」




滚动时出现拖影,图片来源「即刻」




  • 自动适配只能适配没有指定颜色和背景色的内容,比如上面的1、2、3级文字还有背景,没有显式设置colorbackground-color


    对于设置了颜色和背景色(这种现象在开发中很常见吧)的内容,就无法自动适配,比如上面的7个色块的背景色,写死了颜色,但是色块上的文字没有设置颜色。最终在深色渲染下渲染出的效果就是,色块背景色没变,但是色块上的文字变成了白色,导致一些文字很难看清。




所以,最好还是自定义适配逻辑,除了解决上面的问题,还可以加一下其他的东西,比如加一些深浅色模式变化时的过渡动画等。


2. 如何自定义适配


自定义适配有两种方式,CSS媒体查询和通过JS监听主题模式


1). CSS媒体查询


prefers-color-scheme - CSS:层叠样式表 | MDN
我们可以通过在CSS中设置媒体查询@media (prefers-color-scheme: dark),来设置深色模式下的自定义颜色。比如:


.textLevel1 {
color: #404040;
margin-bottom: 0;
}
.textLevel2 {
color: #808080;
margin-bottom: 0;
}
.textLevel3 {
color: #bfbfbf;
margin-bottom: 0;
}

@media (prefers-color-scheme: dark) {
.textLevel1 {
color: #FFFFFF;
opacity: 0.9;
}
.textLevel2 {
color: #FFFFFF;
opacity: 0.6;
}
.textLevel3 {
color: #FFFFFF;
opacity: 0.3;
}
}

通过媒体查询设置元素在深色模式下的1、2、3级文字的颜色,在浅色模式下设置不同的颜色,在深色模式下,增加不透明度:


截屏2023-03-12 下午6.04.18.png左边的是自动适配的浅色深色,右边是自定义适配的浅色深色


左边的是自动适配的浅色深色,右边是自定义适配的浅色深色


对于prefers-color-scheme的兼容性也不必担心,主流浏览器基本都支持了:


截屏2023-03-12 下午6.10.07.png


2). JS监听主题颜色


Window.matchMedia() - Web API 接口参考 | MDN


通过CSS媒体查询适配深色模式已经很方便了,完全不需要修改JS代码逻辑,那为什么还需要JS监听主题颜色呢?


因为通过CSS媒体查询虽然方便,但是只能跟随系统的主题颜色变化,假设用户想要类似于下面的自定义主题颜色,不跟随系统变化,或者业务上想做一些什么高级的操作,那么CSS媒体查询就无能为力了。


7b368843-dd42-4b4b-bc73-6d445de78923.gif


在JS中,可以通过window.matchMedia('(prefers-color-scheme: dark)').match来判断当前的颜色主题:


const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

if (darkModeMediaQuery.matches) {
// dark mode
} else {
// light mode
}

darkModeMediaQuery.addEventListener('change', (event: MediaQueryListEvent) => {
if (event.matches) {
// dark mode
} else {
// light mode
}
});

如果使用的是React,还可以写一个自定义hooks,来监听当前的颜色主题:


export enum EColorScheme {
LIGHT = 'light',
DARK = 'dark',
}

export const useColorScheme = (): EColorScheme => {
const [scheme, setScheme] = useState(
darkModeMediaQuery.matches ? EColorScheme.DARK : EColorScheme.LIGHT
);

useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setScheme(event.matches ? EColorScheme.DARK : EColorScheme.LIGHT);
};
darkModeMediaQuery.addEventListener('change', listener);
return () => {
darkModeMediaQuery.removeEventListener('change', listener);
};
}, []);

return scheme;
};

一些题外话


我写完这个hooks之后,有一次偶然用Chat GPT让它写一段监听深色模式的代码,写成hooks,最后生成的代码和这个几乎一模一样,Chat GPT还是有点东西的:


import { useState, useEffect } from 'react';

type ThemeType = 'light' | 'dark';

function useTheme(): ThemeType {
const [theme, setTheme] = useState<ThemeType>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
);

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

const handleChange = () => setTheme(mediaQuery.matches ? 'dark' : 'light');

mediaQuery.addEventListener('change', handleChange);

return () => mediaQuery.removeEventListener('change', handleChange);
}, []);

return theme;
}

export default useTheme;

window.matchMedia的兼容性也挺好的:


截屏2023-03-12 下午7.03.48.png
通过JS监听颜色主题变化之后,那可玩性就很多了,我们可以通过下面这些方式来适配深色模式:




  • 动态添加类名覆盖样式


    通过判断深色模式来添加一个深色模式的类名,覆盖浅色模式样式:


    <div
    className={classnames(
    style.wrapper,
    scheme === EColorScheme.DARK && style.darkModeWrapper
    )}
    >
    {/* some code here */}
    </div>



  • 对于深色模式直接引用不同的CSS资源文件




  • 用一些第三方的库,比如postcss-darkmode




回到上面话题,通过JS可以监听到系统的颜色主题,那怎么实现用户主动选择颜色主题,不随系统的改变呢?其实也很简单,可以在本地store中设置一个颜色主题的值,用户设置了就优先选用store里的,没有设置就跟随系统,以上面的hooks为例:


export const useColorScheme = (): EColorScheme => {
// 从 store 中取出用户手动设置的主题
const manualScheme = useSelector(selectManualColorScheme);
const [scheme, setScheme] = useState(
darkModeMediaQuery.matches ? EColorScheme.DARK : EColorScheme.LIGHT
);

useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setScheme(event.matches ? EColorScheme.DARK : EColorScheme.LIGHT);
};
darkModeMediaQuery.addEventListener('change', listener);
return () => {
darkModeMediaQuery.removeEventListener('change', listener);
};
}, []);

// 优先取用户手动设置的主题
return manualScheme || scheme;
};

React Native中的适配


上面说的都是在浏览器里对深色模式的适配,那在React Native里面要怎么适配深色模式呢?


1. 大于等于0.62的版本


Appearance · React Native


在React Native 0.62版本中,引入了Appearance模块,通过这个模块:


type ColorSchemeName = 'light' | 'dark' | null | undefined;

export namespace Appearance {
type AppearancePreferences = {
colorScheme: ColorSchemeName;
};

type AppearanceListener = (preferences: AppearancePreferences) => void;

/**
* Note: Although color scheme is available immediately, it may change at any
* time. Any rendering logic or styles that depend on this should try to call
* this function on every render, rather than caching the value (for example,
* using inline styles rather than setting a value in a `StyleSheet`).
*
* Example: `const colorScheme = Appearance.getColorScheme();`
*/

export function getColorScheme(): ColorSchemeName;

/**
* Add an event handler that is fired when appearance preferences change.
*/

export function addChangeListener(listener: AppearanceListener): EventSubscription;

/**
* Remove an event handler.
*/

export function removeChangeListener(listener: AppearanceListener): EventSubscription;
}

/**
* A new useColorScheme hook is provided as the preferred way of accessing
* the user's preferred color scheme (aka Dark Mode).
*/

export function useColorScheme(): ColorSchemeName;

通过Appearance模块,可以获得当前的系统颜色主题:


const colorScheme = Appearance.getColorScheme();
if (colorScheme === 'dark') {
// dark mode
} else {
// light mode
}

Appearance.addChangeListener((prefer: Appearance.AppearancePreferences) => {
if (prefer.colorScheme === 'dark') {
// dark mode
} else {
// light mode
}
});

同时也提供了一个上面我们自己实现的hooks,useColorScheme


const colorScheme = useColorScheme();

一些坑




  1. Appearance这个接口在Chrome调试模式下,会不生效,永远返回light


    Appearance.getColorScheme() always returns ‘light’




  2. Appearance想要生效,还需要Native做一些配置


    React Native 0.62.2 Appearance return wrong color scheme



    Also make sure you do not have UIUserInterfaceStyle set in your Info.plist. I had it set to 'light' so Appearance.getColorScheme() was always returning 'light'.





2. 小于0.62的版本


对于0.62之前的版本,由于RN没有提供官方接口,需要通过第三方的库react-native-dark-mode来实现:
GitHub - codemotionapps/react-native-dark-mode: Detect dark mode in React Native


它的实现原理感兴趣的可以看下:



react-native-dark-mode 实现原理(这段实现原理其实也是问Chat GPT得到的答案😂)


react-native-dark-mode库的实现原理比较简单,它主要是利用了原生平台的接口来检测当前系统是否处于深色模式。在iOS平台上,它使用了UIUserInterfaceStyle接口来获取当前系统的界面风格,然后判断是否为暗黑模式。在Android平台上,它使用了UiModeManager接口来获取当前系统的 UI 模式,然后判断是否为夜间模式。


具体来说,react-native-dark-mode在React Native项目中提供了一个名为useDarkMode的 React Hooks,用于获取当前系统是否处于深色模式。当使用这个Hooks时,它会首先检测当前平台是否支持暗黑模式,如果支持,就直接调用原生平台的接口获取当前系统的界面风格或UI模式,并将结果返回给调用方。如果不支持,就返回一个默认值(比如浅色模式)。


需要注意的是,由于react-native-dark-mode是一个纯JS库,它无法直接调用原生平台的接口。所以它在Native端编写了一个名为DarkMode的模块,在JS层通过NativeModules.DarkMode来调用。



  • 在iOS上,DarkMode模块会通过RCT_EXPORT_MODULE()宏将自己暴露给RN的JS层。同时,它还会使用RCT_EXPORT_METHOD()宏将检测系统界面风格的方法暴露给JS层,使得在JS中可以直接调用该方法。

  • 在Android上,DarkMode模块同样会通过@ReactModule注解将自己暴露给JS层。然后,它会创建一个名为DarkModeModule的Java类,并在该类中实现检测系统UI模式的方法。最后,它会使用@ReactMethod注解将该方法暴露给JS层,使得在JS中可以直接调用该方法。



参考链接



作者:酥风
来源:juejin.cn/post/7298997940019085366
收起阅读 »

回顾我这三年,都是泡沫

昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。 刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup… 虽然没见过面,不知道他长什么...
继续阅读 »

朋友圈


昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。


刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup…


虽然没见过面,不知道他长什么样,在我脑海里,他就是两样放着光,对技术充满好奇心、自我驱动力很强小伙子。


我就知道他能成,因为我多少也是这样子的,尽管我现在有些倦怠。


后来,随着工作越来越忙,博客也停更了,我们便很少联系了。


不过,后面我招人,尤其是校招生或者初级开发,我都是按照他这个范本来的。我也时常跟别人提起,我认识北京这样一个小伙子。


也有可能我们这边庙太小了,这样的小伙伴屈指可数。


平台和好奇心一样重要


大部分人智商条件不会有太多的差距,尤其是程序员这个群体,而好奇心可以让你比别人多迈出一步,经过长时间的积累就会拉开很大的差距。


而平台可以让你保持专注,与优秀的人共事,获得更多专业的经验和知识、财富,建立自己的竞争壁垒。








回到正题。


我觉得是时候阶段性地总结和回望回顾我过去这三年,却发现大部分都是泡沫。跨端、业务、质量管理、低代码、领域驱动设计... 本文话题可能会比较杂




2020 年七月,口罩第二年。我选择了跳槽,加入了一家创业公司




跨端开发的泡沫


2020 年,微信小程序已经成为国内重要的流量入口,事实也证明,我们过去几年交付的 C 端项目几乎上都是小程序。更严谨的说,应该是微信小程序,尽管很多巨头都推出了自己的小程序平台,基本上都是陪跑的。




Taro 2.x


进来后接手的第一个项目是原生小程序迁移到 Taro。


那时候,我们的愿景是“一码多端”,期望一套程序能够跑在微信小程序、支付宝小程序等小程序平台、H5、甚至是原生 App。


那时候 Taro 还是 2.x 版本,即通过语法静态编译成各端小程序的源码。


我们迁移花了不少的时间,尽管 Taro 官方提供了自动转换的工具,但是输出的结果是不可靠的,我们仍需要进行全量的回归测试,工作量非常大。 期间我也写了一个自动化代码迁移 CLI 来处理和 Lint 各种自动迁移后的不规范代码。




重构迁移只是前戏。难的让开发者写好 Taro,更难的是写出跨端的 Taro 代码。




我总结过,为什么 Taro(2.x) 这么难用:



  • 很多初级开发者不熟悉 React。在此之前技术栈基本是 Vue

  • 熟悉 React 的却不熟悉 Taro 的各种约束。

  • 即使 Taro 宣称一码多端,你还是需要了解对应平台/端的知识。 即使是小程序端,不同平台的小程序能力和行为都有较大的区别。而 Taro 本身在跨端上并没有提供较好的约束,本身 Bug 也比较多。

  • 如果你有跨端需求,你需要熟知各端的短板,以进行权衡和取舍。强调多端的一致和统一会增加很多复杂度, 对代码的健壮性也是一个比较大的考验。

  • 我们还背着历史包袱。臃肿、不规范、难以维护、全靠猜的代码。




在跨端上,外行人眼里‘一码多端’就是写好一端,其他端不用改就可以直接运行起来,那有那么简单的事情?


每个端都有自己的长板和短板:


短板效应


我们从拆分两个维度来看各端的能力:


维度




放在一个基线上看:


对比


跨端代码写不好,我们不能把锅扔给框架,它仅仅提供了一种通用的解决方案,很多事情还是得我们自己去做。




实际上要开发跨平台的程序,最好的开发路径就是对齐最短的板,这样迁移到其他端就会从而很多,当然代价就是开发者负担会很重:


路径


为了让开发者更好的掌握 Taro, 我编写了详细的 Wiki, 阐述了 React 的各种 trickTaro 如何阉割了 ReactTaro 的原理、开发调试、跨端开发应该遵循的各种规范






Taro 3.0


我们的 Taro 项目在 2020 年底正式在生产使用,而 Taro 3.0 在 2020 年 / 7 月就正式发布了,在次年 5 月,我们决定进行升级。


技术的发展就是这么快,不到 5 个月时间,Taro 2.x 就成为了技术债。


Taro 2.x 官方基本停止了新功能的更新、bug 也不修了,最后我们不得不 Fork Taro 仓库,发布在私有 npm 镜像库中。




Taro 2.x 就是带着镣铐跳舞,实在是太痛苦,我写了一篇文档来历数了它的各种‘罪行’:



  • 2.x 太多条条框框,学习成本高

  • 这是一个假的 React

  • 编译慢

  • 调试也太反人类







Taro 3.x 使用的是动态化的架构,有很多优势:


3.x 架构 和数据流


3.x 架构 和数据流



  • 动态化的架构。给未来远程动态渲染、低代码渲染、使用不同的前端框架(支持 Vue 开发)带来了可能

  • 不同端视图渲染方式差异更小,更通用,跨端兼容性更好。

  • 2.x 有非常多的条条框框,需要遵循非常多的规范才能写出兼容多端的代码。3.x 使用标准 React 进行开发,有更好的开发体验、更低的学习成本、更灵活的代码组织。

  • 可以复用 Web 开发生态。




使用类似架构的还有 Remax、Alita、Kbone, 我之前写过一篇文章实现的细节 自己写个 React 渲染器: 以 Remax 为例(用 React 写小程序)




而 Taro 不过是新增了一个中间层:BOM/DOM,这使得 Taro 不再直接耦合 React, 可以使用任意一种视图框架开发,可以使用 Vue、preact、甚至是 jQuery, 让 Web 生态的复用成为可能。




升级 3.x 我同样通过编写自动化升级脚本的形式来进行,这里记录了整个迁移的过程。








重构了再重构


我在 2B or not 2B: 多业态下的前端大泥球 讲述过我们面临的困境。


21 年底,随着后端开启全面的 DDD 重构(推翻现有的业务,重新梳理,在 DDD 的指导下重新设计和开发),我们也对 C 端进行了大规模的重构,企图摆脱历史债务,提高后续项目的交付效率




C 端架构


上图是重构后的结果,具体过程限于篇幅就不展开了:





  • 基础库:我们将所有业务无关的代码重新进行了设计和包装。

    • 组件库:符合 UI 规范的组件库,我们在这里也进行了一些平台差异的抹平

    • api: Taro API 的二次封装,抹平一些平台差异

    • utils: 工具函数库

    • rich-html、echart:富文本、图表封装

    • router:路由导航库,类型安全、支持路由拦截、支持命名导航、简化导航方法…




  • 模块化:我们升级到 Taro 3.x 之后,代码的组织不再受限于分包和小程序的约束。我们将本来单体的小程序进行了模块的拆分,即 monorepo 化。按照业务的边界和职责拆分各种 SDK

  • 方案:一些长期积累开发痛点解决方案,比如解决分包问题的静态资源提取方案、解决页面分享的跳板页方案。

  • 规范和指导实现。指导如何开发 SDK、编写跨平台/易扩展的应用等等




巨头逐鹿的小程序平台,基本上是微信小程序一家独大


跨端框架,淘汰下来,站稳脚跟的也只有 taro 和 uniapp


时至今日,我们吹嘘许久的“一码多端”实际上并没有实现;








大而全 2B 业务的泡沫


其实比一码多端更离谱的事情是“一码多业态”。


所谓一码多业态指的是一套代码适配多个行业,我在 2B or not 2B: 多业态下的前端大泥球 中已经进行了深入的探讨。


这是我过去三年经历的最大的泡沫,又称屎山历险记。不要过度追求复用,永远不要企图做一个大而全的 2B 产品






低代码的泡沫


2021 年,低代码正火,受到的资本市场的热捧。


广义的低代码就是一个大箩筐,什么都可以往里装,比如商城装修、海报绘制、智能表格、AI 生成代码、可视化搭建、审核流程编排…


很多人都在蹭热点,只要能粘上一点边的,都会包装自己是低代码,包括我们。在对外宣称我们有低代码的时候,我们并没有实际的产品。现在 AI 热潮类似,多少声称自己有大模型的企业是在裸泳呢?




我们是 2B 赛道,前期项目交付是靠人去堆的,效率低、成本高,软件的复利几乎不存在。


低代码之风吹起,我们也期望它能破解我们面临的外包难题(我们自己都在质疑这种软件交付方式和外包到底有什么区别)。


也有可能是为了追逐资本热潮,我们也规划做自己的 PaaS、aPaaS、iPaaS… 各种 “aaS”(不是 ass)。


但是我们都没做成,规划和折腾了几个月,后面不了了之,请来的大神也送回去了。




在我看来,我们那时候可能是钱多的慌。但并没有做低代码的相关条件,缺少必要的技术积累和资源。就算缩小范围,做垂直领域的低代码,我们对领域的认知和积累还是非常匮乏。




在这期间, 我做了很多调研,也单枪匹马撸了个 “前端可视化搭建平台”:


低代码平台


由于各种原因, 这个项目停止了开发。如今社区上也有若干个优秀的开源替代物,比如阿里的低代码引擎、网易云的 Tango、华为云的 TinyEngine。如果当年坚持开发下去,说不定今天也小有成就了。




不管经过这次的折腾,我越坚信,低代码目前还不具备取代专业编程的能力。我在《前端如何破解 CRUD 的循环》也阐述过相关的观点。


大型项目的规模之大、复杂度之深、迭代的周期之长,使用低代码无疑是搬石头砸自己的脚。简单预想一下后期的重构和升级就知道了。




低代码的位置


低代码是无代码和专业编码之间的中间形态,但这个中间点并不好把握。比如,如果倾向专业编码,抽象级别很低,虽然变得更加灵活,但是却丧失了易用性,最终还是会变成专业开发者的玩具。


找对场景,它就是一把利器。不要期望它能 100% 覆盖专业编码,降低预期,覆盖 10%?20%?再到 30%? 已经是一个不错的成就。


低代码真正可以提效不仅在于它的形式(可视化),更在于它的生态。以前端界面搭建为例,背后开箱即用的组件、素材、模板、应用,才是它的快捷之道。


在我看来,低代码实际上并不是一个新技术,近年来火爆,更像是为了迎合资本的炒作而稍微具象化的概念。


而今天,真正的’降本增效‘的大刀砍下来,又有多少’降本增效‘的低代码活下来了呢?








质量管理的泡沫


2021 年四月,我开始优化前端开发质量管理,设计的开发流程如下:


流程


开发环境:



  • 即时反馈:通过 IDE 或者构建程序即时对问题进行反馈。

  • 入库前检查:这里可以对变动的源代码进行统一格式化,代码规范检查、单元测试。如果检查失败则无法提交。


集成环境:



  • 服务端检查:聪明的开发者可能绕过开发环境本地检查,在集成环境我们可以利用 Gerrit + Jenkins 来执行检查。如果验证失败,该提交会被拒绝入库。

  • CodeReview:CodeReview 是最后一道防线,主要用于验证机器无法检验的设计问题。

  • 自动化部署:只有服务端检查和 CodeReview 都通过才能提交到仓库

    • 测试环境:即时部署,关闭安全检查、开启调试方便诊断问题

    • 生产环境:授权部署




生产环境:


前端应用在客户端中运行,我们通常需要通过各种手段来监控和上报应用的状态,以便更快地定位和解决客户问题。






原则一:我认为“自动化才是秩序”:


文档通常都会被束之高阁,因此单靠文档很难形成约束力。尤其在迭代频繁、人员构造不稳定的情况。规范自动化、配合有效的管理才是行之有效的解决办法。



  • 规范自动化。能够交给机器去执行的,都应该交给机器去处理, 最大程度降低开发者的心智负担、犯错率。可以分为以下几个方面:

    • 语言层面:类型检查,比如 Typescript。严格的 Typescript 可以让开发者少犯很多错误。智能提示对开发效率也有很大提升。

    • 风格层面:统一的代码格式化风格。例如 Prettier

    • 规范层面:一些代码规范、最佳实践、反模式。可以遵循社区的流行规范, 例如 JavaScript Standard

    • 架构层面:项目的组织、设计、关联、流程。可以通过脚手架、规范文档、自定义 ESLint 规则。



  • 管理和文化: 机器还是有局限性,更深层次的检查还是需要人工进行。比如单元测试、CodeReview。这往往需要管理来驱动、团队文化来支撑。这是我们后面需要走的路。






原则二:不要造轮子


我们不打算造轮子,建立自己的代码规范。社区上有很多流行的方案,它们是集体智慧的结晶,也最能体现行业的最佳实践:


社区规范


没必要自己去定义规则,因为最终它都会被废弃,我们根本没有那么多精力去维护。






实现


企业通知 Code Review


企业通知 Code Review






我们这套代码质量管理体系,主要基于以下技术来实现:



  • Jenkins: 运行代码检查、构建、通知等任务

  • Gerrit:以 Commit 为粒度的 CodeReview 工具

  • wkfe-standard: 我们自己实现渐进式代码检查 CLI






如果你想了解这方面的细节,可以查看以下文档:





我推崇的自动化就是秩序目的就是让机器来取代人对代码进行检查。然而它只是仅仅保证底线。


人工 CodeReview 的重要性不能被忽略,毕竟很多事情机器是做不了的。


为了推行 CodeReview,我们曾自上而下推行了 CCC(简洁代码认证) 运动,开发者可以提交代码让专家团队来 Code Review,一共三轮,全部通过可以获得证书,该证书可以成为绩效和晋升的加分项;除此之外还有代码规范考试…


然而,这场运动仅仅持续了几个月,随着公司组织架构的优化、这些事情就不再被重视。


不管是多么完善的规范、工作流,人才是最重要的一环,到最后其实是人的管理






DDD / 中台的泡沫


近年来,后端微服务、中台化等概念火热,DDD 也随之而起。


DDD 搜索趋势


上图的 DDD Google 趋势图,一定程度可以反映国内 DDD 热度的现实情况:



  • 在 14 年左右,微服务的概念开始被各方关注,我们可以看到这年 DDD 的搜索热度有明显的上升趋势

  • 2015 年,马某带领阿里巴巴集团的高管,去芬兰的赫尔辛基对一家名叫 supercell 的游戏公司进行商务拜访,中台之风随着而起,接下来的一两年里,DDD 的搜索热度达到了顶峰。

  • 2021 ~ 2022 年,口罩期间,很多公司业务几乎停摆,这是一个’内修‘的好时机。很多公司在这个阶段进行了业务的 DDD 重构,比较典型的代表是去哪儿业务瘦身 42%+效率提升 50% :去哪儿网业务重构 DDD 落地实践)。




上文提到,我们在 2021 年底也进行了一次轰轰烈烈的 DDD 重构战役,完全推翻现有的项目,重新梳理业务、重新设计、重新编码。


重构需要投入了大量的资源,基本公司 1 / 3 的研发资源都在里面了,这还不包括前期 DDD 的各种预研和培训成本。


在现在看来,这些举措都是非常激进的。而价值呢?现在还不’好说‘(很难量化)






DDD 落地难


其实既然开始了 DDD 重构, 就说明我们已经知道 ’怎么做 DDD‘ 了,在重构之前,我们已经有了接近一年的各种学习和铺垫,且在部分中台项目进行了实践。


但我至今还是觉得 DDD 很难落地,且不说它有较高的学习成本,就算是已落地的项目我们都很难保证它的连续性(坚持并贯彻初衷、规范、流程),烂尾的概率比较高。


为了降低开发者对 DDD 的上手门槛,我们也进行了一些探索。






低代码 + DDD?


可视化领域建模


可视化领域建模


2022 下半年,我们开始了 ’DDD 可视化建模‘ 的探索之路,如上图所示。


这个平台的核心理念和方法论来源于我们过去几年对 DDD 的实践经验,涵盖了需求资料的管理、产品愿景的说明、统一语言、业务流程图、领域模型/查询模型/领域服务的绘制(基于 CQRS),数据建模(ER)、对象结构映射(Mapper)等多种功能,覆盖了 DDD 的整个研发流程。


同时它也是一个知识管理平台,我们希望在这里聚合业务开发所需要的各种知识,包括原始需求资料、统一语言、领域知识、领域建模的结果。让项目的二开、新团队成员可以更快地入手。


最终,建模的结果通过“代码生成器”生成代码,真正实现领域驱动设计,而设计驱动编码。


很快我们会完全开源这套工具,可以关注我的后续文章。






DDD 泡沫


即使我们有’低代码‘工具 + 代码自动生成的加持,实现了领域驱动设计、设计驱动编码,结果依旧是虎头蛇尾,阻止不了 DDD 泡沫的破裂。




我也思考了很多原因,为什么我们没有’成功‘?





  • DDD 难?学习曲线高

  • 参与的人数少,DDD 受限在后端开发圈子里面,其他角色很少参与进来,违背了 DDD 的初衷

  • 重术而轻道。DDD 涵括了战略设计和战术设计,如果战略设计是’道‘、战术设计就是’术‘,大部分开发者仅仅着眼于术,具体来说他们更关注编码,思维并没有转变,传统数据建模思维根深蒂固

  • 中台的倒台,热潮的退去


扩展阅读:







一些零碎的事


过去三年还做了不少事情,限于篇幅,就不展开了:







过去三年经历时间轴:



  • 2020 年 7 月,换了公司,开始接手真正迁移中的 Taro 项目

  • 2020 年 10 月,Taro 2.x 小程序正式上线

  • 2020 年 10 月 ~ 11 月 优化代码质量管理体系,引入开发规范、Gerrit Code Review 流程

  • 2020 年 12 月 ~ 2021 年 4 月,业务开发

  • 2021 年 1 月 博客停更

  • 2021 年 5 月 Taro 3.x 升级

  • 2021 年 7 月 ~ 10 月 前端低代码平台开发

  • 2021 年 11 月 ~ 2022 年 5 月, DDD 大规模重构,C 端项目重构、国际化改造

  • 2022 年 6 月 ~ 2022 年 11 月,B 端技术升级,涉及容器化改造、微前端升级、组件库开发等

  • 2022 年 12 月~ 2023 年 4 月,可视化 DDD 开发平台开发

  • 2023 年 5 月 ~ 至今。业务开发,重新开始博客更新








总结


贝尔实验室


我们都有美好的愿望


重构了又重构,技术的债务还是高城不下


推翻了再推翻,我们竟然是为了‘复用’?


降本增效的大刀砍来


泡沫破碎,回归到了现实


潮水退去,剩下一些裸泳的人


我又走到了人生的十字路口,继续苟着,还是换个方向?


作者:荒山
来源:juejin.cn/post/7289718324857880633
收起阅读 »

如何设计一个网盘系统的架构

1. 概述 现代生活中已经离不开网盘,比如百度网盘。在使用网盘的过程中,有没有想过它是如何工作的?在本文中,我们将讨论如何设计像百度网盘这样的系统的基础架构。 2. 系统需求 2.1. 功能性需求 用户能够上传照片/文件。 用户能够创建/删除目录。 用户能够...
继续阅读 »

1. 概述


现代生活中已经离不开网盘,比如百度网盘。在使用网盘的过程中,有没有想过它是如何工作的?在本文中,我们将讨论如何设计像百度网盘这样的系统的基础架构。


2. 系统需求


2.1. 功能性需求



  1. 用户能够上传照片/文件。

  2. 用户能够创建/删除目录。

  3. 用户能够下载文件。

  4. 用户能够共享上传的文件。

  5. 能够在所有的用户设备之间同步数据。

  6. 即使网络不可用,用户也能上传文件/照片,只是存储在离线文件中,当网络可用时,离线文件将同步到在线存储。


2.2 非功能性需求




  1. 可用性: 指系统可用于处理用户请求的时间百分比。我们通常将可用性称为5个9、4个9。5个9意味着 99.999% 的可用性,4 个9意味着 99.99% 的可用性等。




  2. 持久性: 即使系统发生故障,用户上传的数据也应永久存储在数据库中。系统应确保用户上传的文件应永久存储在服务器上,而不会丢失任何数据。




  3. 可靠性: 指系统对于相同输入给出预期的输出。




  4. 可扩展性: 随着用户数量的不断增加,系统应该能处理不断增加的流量。




  5. ACID: 原子性、一致性、隔离性和持久性。所有的文件操作都应该遵循这些属性。



    1. 原子性:对文件执行的任何操作都应该是完整的或不完整的,不应该是部分完整的。即如果用户上传文件,操作的最终状态应该是文件已 100% 上传或根本没有上传。

    2. 一致性: 保证操作完成之前和之后的数据是相同的。

    3. 隔离性:意味着同时运行的2个操作应该是独立的,并且不会影响彼此的数据。

    4. 持久性:参考第二点关于持久性的解释。




3. 容量估算


假设我们有 5 亿总用户,其中 1 亿是每日活跃用户。


那么,每分钟的活跃用户数:


1亿 / (24小时 * 60分钟)= 0.07万

再假设下高峰期每分钟有 100 万活跃用户,平均每个用户上传 5 个文件,则每分钟将有 500 万次上传。


如果1次上传平均100KB的文件,则1分钟上传的总文件大小为:


100KB * 5 = 500TB

4. API设计


4.1 上传文件


POST: /uploadFile
Request {
filename: string,
createdOnInUTC: long,
createdBy: string,
updatedOnInUTC: long,
updatedBy: string
}

Response: {
fileId: string,
downloadUrl: string
}

上传文件分为2步:



  1. 上传文件元数据

  2. 上传文件


4.2 下载文件


GET: /file/{fileId}
Response: {
fileId: string,
downloadUrl: string
}

通过返回的downloadURL进行文件下载。


4.3 删除文件


DELETE: /file/{fileId}

4.4 获取文件列表


GET: /folders/{folderId}?startIndex={startIndex}&limit={limit}

Response: {
folderId: string,
fileList: [
{
fileId: string,
filename: string,
thumbnail_img: string,
lastModifiedDateInUTC: string
creationDateInUTC: string
}
]
}

由于文件数量可能会很大,这里采用分页返回的方式。


5. 关键点的设计思考



  1. 文件存储: 我们希望系统具有高可用性和耐用性来存储用户上传的内容。为此,我们可以使用对象存储的系统作为文件存储,可选的有AWS的S3、阿里云的对象存储等。我们采用S3。

  2. 存储用户数据及其上传元数据: 为了存储用户数据及其文件元数据,我们可以使用关系型数据库和非关系型数据库结合的方式,关系型数据库采用MySQL, 非关系型数据库采用MongoDB。

  3. 离线存储: 当用户的设备离线时,用户完成的所有更新都将存储在其本地设备存储中,一旦用户上线,设备会将更新同步到云端。

  4. 上传文件: 用户上传的文件大小可能很大,为了将文件从任何设备上传到服务器而不出现任何失败,我们必须将其分段上传。目前常见的对象存储中都支持分段上传。

  5. 下载/共享文件: 通过分享文件的URL来实现共享和下载。如果文件存储是S3的话,也可以使用预签名的URL来实现此功能。



默认情况下,所有 S3 对象都是私有的,只有对象所有者有权访问它们。但是,对象所有者可以通过创建预签名 URL 与其他人共享对象。预签名 URL 使用安全凭证授予下载对象的限时权限。URL 可以在浏览器中输入或由程序使用来下载对象。





  1. 设备之间同步: 当用户在其中一台设备上进行更改时,当用户登录其他设备时,这些更改应同步在其他设备上。有两种方法可以做到这一点。



    1. 一旦用户从一台设备更新,其他设备也应该更新。

    2. 当用户登录时更新其他设备进行更新。


    我们采用第二种方法,因为即使用户不使用其他设备,它也可以防止对其他设备进行不必要的更新。如果用户在两个不同的设备上在线怎么办?那么在这种情况下我们可以使用长轮询。用户当前在线的设备将长时间轮询后端服务器并等待任何更新。因此,当用户在一台设备上进行更新时,另一台设备也会收到更新。




6. 数据库设计


用户表


userId: string
username: string
emailId: string
creationDateInUtc: long

文件源数据表


fileId: string
userId: string
filename: string
fileLocation: string
creationDateInUtc: long
updationDateInUtc: long

7. 架构设计





  1. File MetaData Service: 该服务负责添加/更新/删除用户上传文件的元数据。客户端设备将与此服务通信以获取文件/文件夹的元数据。




  2. File Upload Service: 该服务负责将文件上传到 S3 存储桶。用户的设备将以块的形式将文件流式传输到此服务,一旦所有块都上传到 S3 存储桶,上传就会完成。




  3. Synchronization Service: 同步服务,两种情况需要同步。



    1. 当用户在其设备上打开应用程序时,在这种情况下,我们将从同步服务同步用户的该设备与用户当前查看的目录的最新快照。

    2. 当用户从一个先后登录两个不同设备时,我们需要同步用户的第一个设备的数据,故而我们使用长轮询来轮询该目录/文件在服务器上的最新更改内容。




  4. S3 存储桶: 我们使用 S3 存储桶来存储用户文件/文件夹。根据用户 ID 创建文件夹,每个用户的文件/文件夹可以存储在该用户的文件夹中。




  5. Cache: 使用缓存来减少元数据检索的延迟,当客户端请求文件/文件夹的元数据时,它将首先查找缓存,如果在缓存中找不到,那么它将查找数据库。




  6. 负载均衡 我们希望我们的服务能够扩展到数百万用户,为此我们需要水平扩展我们的服务。我们将使用负载均衡器将流量分配到不同的主机。这里我们采用Nginx做负载均衡。




  7. UserDevices: 用户可以使用移动设备、台式机、平板电脑等多种设备来访问驱动器。我们需要保证所有用户设备的数据都是相同的,并且不能存在数据差异。




8. 总结


本文讨论了如何设计一个网盘系统的架构,综合功能性需求和非功能性需求,设计了API、数据库和服务架构。但是没有讨论权限设计和数据安全的部分,也欢迎大家补充改进。


作者:郭煌
来源:juejin.cn/post/7299353265098850313
收起阅读 »