注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

移动端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
收起阅读 »

00年菜鸡前端的面试经历分享

web
去年8月份入职的某大厂(外包)今年6月份被通知甲方即将转移去广州。我们外包人员产品和测试被外包公司安排了赔偿,但也赔的很少。前端和后端就是被安排其他甲方的面试,我碰巧很想去旅游,就直接自离了。 出去玩了一个月以后兜里的元子也基本见底了,虽说目前还没有车贷房贷但...
继续阅读 »

去年8月份入职的某大厂(外包)今年6月份被通知甲方即将转移去广州。我们外包人员产品和测试被外包公司安排了赔偿,但也赔的很少。前端和后端就是被安排其他甲方的面试,我碰巧很想去旅游,就直接自离了。


出去玩了一个月以后兜里的元子也基本见底了,虽说目前还没有车贷房贷但也要交房租也要吃饭,就又开始了找工作。不过令人没想到的是今年的行情能这么这么的差,以前每年都说今年环境差但每次我离职基本都能在两周内拿到满意的ofr,但今年算是找了将近两个月才找到个稍微稍微差不多点的(短期,三个月,而且薪资比上家低了3K,好在离家近,办公环境还算敞亮


(图片是面试路上拍的与文章内容没啥关系)


微信图片_20230817085740.jpg

简单记录,问的问题以及我的回答有的记不太清我就从简了。


第一家是一个研究所,面试我的不知道是个大哥还是大姐反正有点中性那种感觉(不过听声音应该是大哥),问了react中的useEffect,我说是用于修改以及监听数据变化,相当于react18之前的componentDidMount、componentDidUpdate和componentWillUnmount,传递的参数分别是要处理的逻辑函数以及数组。大文件上传,我说大文件上传主要的解决方案就是切片处理,和后端定义好key关键值,然后分割file分批通过接口上传文件以及参数后端拿到后再进行合并。第三个问了我性能优化,我说了几个大概方向:图片优化(大图片压缩、雪碧图)、代码优化(组件化减少复用、外部链接)、懒加载预加载、节流防抖。


然后问了我以前的工作亮点,这个问题 其实很多次被问到我也只是挑我觉得业务逻辑稍微难一点的东西说,实在没有个说出来让面试官眼前一亮的答案。


然后回家后hr联系我说给过,但只给到了12,我说我最低接受13,其实不是拉扯她我这次找工作本来给自己定的目标就是13-14,我是觉得这家离家比较近,但办公环境有些压抑,屋里人多 有点阴暗 我说能不能争取到13,hr说尝试一下,过了一会说最高12.5了我说那我再看看吧,其实也是因为心态问题,这是第一家我也只是试水的状态,他真的给到了13我可能也不是说一定就会去。


微信图片_20230801013245.jpg

第二家也是个自研,这家离家距离中规中矩,45分钟地铁。问的都是些基础面试题早就背的滚瓜烂熟那种,什么水平垂直居中 我说了三种 一种弹性盒、一种topleft50%然后margin各负一半、还有一种绝对定位相对定位。什么组件通信、路由传参,但这家吃亏在我没做过GIS和地图,所以结果是也没给过。第三家是个外包,其实我从不介意外包,因为我学历就不太顶,而且现在行情不好有的干就不错了。这家公司位置还挺好,在新街口附近,应该很有钱,问了vue中父子组件生命周期的执行顺序,我说父create-子create-子mount-父mount。然后他又追问我哪个先beforeCreate我说子先


然后问我 v-if和v-for的优先级以及vue2 和 vue3中他们的区别,其实应该是2中for大于if3中if大于for但我回答的时候说反了她还问我确定吗我说确定
然后和我说他们公司主要用的技术栈是react(我纳闷那你问我vue干啥玩楞)而且他们主要是用react native我寻思也行 做一些我没做过的东西也算开拓新领域了,但很遗憾也没给过


微信图片_20230817090227.jpg

第四家 就有意思了,贼拉远。怎么事儿呢? 上午十点半我刚自然醒迷瞪的我就看boss一看有个面试邀请乐呵的就接受了,然后一看是今天的我寻思那起来洗漱换衣服出发吧,结果一出门看路线才看到他娘的两个小时的路程,地铁转三趟,还要做十站公交,还要徒步1.5公里。我寻思这就算面试通过了以后也不好上下班呀,一天四个小时都在路上,我就打算取消了吧,但boss上即将面试的面试还不能取消,我跟hr说 hr说没事我们好多员工也在你那附近,过来吧。其实大概也能察觉到估计是让我过去填她人事kpi的,但我想着在家闲着也是闲着就当打发时间了,就去了。


确实是麻烦,这路程真的就算给我18k我都不想去,然后接我进去的是个花臂小哥,他花臂还挺帅的。我从家出发是十点,到那十二点都午休了,他们让我等到一点半我说我下午有事就联系了人事让面试官这会儿面一下子


问了我关于深浅拷贝 我说就是引用指针的区别,深拷贝就是重新注册一块空间声明变量,常用的方法有递归和json.parse再strfy但后者只能处理基本数据类型。问了我事件执行机制,我就大概往红任务微任务那方向回答的,然后让我手写了个递推和冒泡。就回了,吗的这面试就面了半个小时,来回路程四个半小时


出门十点,回家下午四点了(面完出来在地铁口吃了口饭)


面试官意思说我还可以,但回家以后我也没问hr后续,因为过了也不打算去,而且hr也没主动联系我


然后就搬了个家。。。


微信图片_20230817090228.jpg

最后一家面试(也就是现在入职这家)问了我跨域,我说跨域是出于浏览器的同源策略,当一个请求url的协议、域名、端口三者之间任意一个与当前页面url不同即为跨域。然后解决办法 第一个我说的jsonp解决,用script标签括住跨域的部分,第二个是本地代理。他又说线上环境你怎么办呢,我说线上的话那就cors解决,什么w3c标准啊跨源ajax啥的就都忽悠上了,其实正儿八经工作中我基本没用过cors和jsonp,基本全是本地代理


然后问了我数组的一些方法我就可增删改查合并分割这些的说了一些


微信图片_20230817115038.jpg

然后让我手写了一个promise和节流函数还有一个去重,讲实话就去重写出来的比较完整,promise和节流就写出来个大概思路


就让我进了


但就三个月,我想的仨月就仨月吧,干完也就十一月中旬了,再躺一个月过完元旦回家过年了


其实要不是因为刚和小伙伴签了一年的房子合同真有点打算去别的城市了


作者:牛油果好不好吃
来源:juejin.cn/post/7268011328940539939
收起阅读 »

你的代码不堪一击!太烂了!

web
前言 小王,你的页面白屏了,赶快修复一下。小王排查后发现是服务端传回来的数据格式不对导致,无数据时传回来不是 [] 而是 null, 从而导致 forEach 方法报错导致白屏,于是告诉测试,这是服务端的错误导致,要让服务端来修改,结果测试来了一句:“服务端返...
继续阅读 »

前言


小王,你的页面白屏了,赶快修复一下。小王排查后发现是服务端传回来的数据格式不对导致,无数据时传回来不是 [] 而是 null, 从而导致 forEach 方法报错导致白屏,于是告诉测试,这是服务端的错误导致,要让服务端来修改,结果测试来了一句:“服务端返回数据格式错误也不能白屏!!” “好吧,千错万错都是前端的错。” 小王抱怨着把白屏修复了。


刚过不久,老李喊道:“小王,你的组件又渲染不出来了。” 小王不耐烦地过来去看了一下,“你这个属性data 格式不对,要数组,你传个对象干嘛呢。”老李反驳: “ 就算 data 格式传错,也不应该整个组件渲染不出来,至少展示暂无数据吧!” “行,你说什么就是什么吧。” 小王又抱怨着把问题修复了。


类似场景,小王时不时都要经历一次,久而久之,大家都觉得小王的技术太菜了。小王听到后,倍感委屈:“这都是别人的错误,反倒成为我的错了!”


等到小王离职后,我去看了一下他的代码,的确够烂的,不堪一击!太烂了!下面来吐槽一下。


一、变量解构一解就报错


优化前


const App = (props) => {
const { data } = props;
const { name, age } = data
}

如果你觉得以上代码没问题,我只能说你对你变量的解构赋值掌握的不扎实。



解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于 undefinednull 无法转为对象,所以对它们进行解构赋值时都会报错。



所以当 dataundefinednull 时候,上述代码就会报错。


优化后


const App = (props) => {
const { data } = props;
const { name, age } = data || {};
}

二、不靠谱的默认值


估计有些同学,看到上小节的代码,感觉还可以再优化一下。


再优化一下


const App = (props = {}) => {
const { data = {} } = props;
const { name, age } = data ;
}

我看了摇摇头,只能说你对ES6默认值的掌握不扎实。



ES6 内部使用严格相等运算符(===)判断一个变量是否有值。所以,如果一个对象的属性值不严格等于 undefined ,默认值是不会生效的。



所以当 props.datanull,那么 const { name, age } = null 就会报错!


三、数组的方法只能用真数组调用


优化前:


const App = (props) => {
const { data } = props;
const nameList = (data || []).map(item => item.name);
}

那么问题来了,当 data123 , data || [] 的结果是 123123 作为一个 number 是没有 map 方法的,就会报错。


数组的方法只能用真数组调用,哪怕是类数组也不行。如何判断 data 是真数组,Array.isArray 是最靠谱的。


优化后:


const App = (props) => {
const { data } = props;
let nameList = [];
if (Array.isArray(data)) {
nameList = data.map(item => item.name);
}
}

四、数组中每项不一定都是对象


优化前:


const App = (props) => {
const { data } = props;
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => `我的名字是${item.name},今年${item.age}岁了`);
}
}

一旦 data 数组中某项值是 undefinednull,那么 item.name 必定报错,可能又白屏了。


优化后:


const App = (props) => {
const { data } = props;
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => `我的名字是${item?.name},今年${item?.age}岁了`);
}
}

? 可选链操作符,虽然好用,但也不能滥用。item?.name 会被编译成 item === null || item === void 0 ? void 0 : item.name,滥用会导致编辑后的代码大小增大。


二次优化后:


const App = (props) => {
const { data } = props;
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => {
const { name, age } = item || {};
return `我的名字是${name},今年${age}岁了`;
});
}
}

五、对象的方法谁能调用


优化前:


const App = (props) => {
const { data } = props;
const nameList = Object.keys(data);
}

只要变量能被转成对象,就可以使用对象的方法,但是 undefinednull 无法转换成对象。对其使用对象方法时就会报错。


优化后:


const App = (props) => {
const { data } = props;
const nameList = Object.keys(data || {});
}

二次优化后:


const _toString = Object.prototype.toString;
const isPlainObject = (obj) => {
return _toString.call(obj) === '[object Object]';
}
const App = (props) => {
const { data } = props;
const nameList = [];
if (isPlainObject(data)) {
nameList = Object.keys(data);
}
}

六、async/await 错误捕获


优化前:


import React, { useState } from 'react';

const App = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const res = await queryData();
setLoading(false);
}
}

如果 queryData() 执行报错,那是不是页面一直在转圈圈。


优化后:


import React, { useState } from 'react';

const App = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
try {
const res = await queryData();
setLoading(false);
} catch (error) {
setLoading(false);
}
}
}

如果使用 trycatch 来捕获 await 的错误感觉不太优雅,可以使用 await-to-js 来优雅地捕获。


二次优化后:


import React, { useState } from 'react';
import to from 'await-to-js';

const App = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const [err, res] = await to(queryData());
setLoading(false);
}
}

七、不是什么都能用来JSON.parse


优化前:


const App = (props) => {
const { data } = props;
const dataObj = JSON.parse(data);
}

JSON.parse() 方法将一个有效的 JSON 字符串转换为 JavaScript 对象。这里没必要去判断一个字符串是否为有效的 JSON 字符串。只要利用 trycatch 来捕获错误即可。


优化后:


const App = (props) => {
const { data } = props;
let dataObj = {};
try {
dataObj = JSON.parse(data);
} catch (error) {
console.error('data不是一个有效的JSON字符串')
}
}

八、被修改的引用类型数据


优化前:


const App = (props) => {
const { data } = props;
if (Array.isArray(data)) {
data.forEach(item => {
if (item) item.age = 12;
})
}
}

如果谁用 App 这个函数后,他会搞不懂为啥 dataage 的值为啥一直为 12,在他的代码中找不到任何修改 dataage 值的地方。只因为 data 是引用类型数据。在公共函数中为了防止处理引用类型数据时不小心修改了数据,建议先使用 lodash.clonedeep 克隆一下。


优化后:


import cloneDeep from 'lodash.clonedeep';

const App = (props) => {
const { data } = props;
const dataCopy = cloneDeep(data);
if (Array.isArray(dataCopy)) {
dataCopy.forEach(item => {
if (item) item.age = 12;
})
}
}

九、并发异步执行赋值操作


优化前:


const App = (props) => {
const { data } = props;
let urlList = [];
if (Array.isArray(data)) {
data.forEach(item => {
const { id = '' } = item || {};
getUrl(id).then(res => {
if (res) urlList.push(res);
});
});
console.log(urlList);
}
}

上述代码中 console.log(urlList) 是无法打印出 urlList 的最终结果。因为 getUrl 是异步函数,执行完才给 urlList 添加一个值,而 data.forEach 循环是同步执行的,当 data.forEach 执行完成后,getUrl 可能还没执行完成,从而会导致 console.log(urlList) 打印出来的 urlList 不是最终结果。


所以我们要使用队列形式让异步函数并发执行,再用 Promise.all 监听所有异步函数执行完毕后,再打印 urlList 的值。


优化后:


const App = async (props) => {
const { data } = props;
let urlList = [];
if (Array.isArray(data)) {
const jobs = data.map(async item => {
const { id = '' } = item || {};
const res = await getUrl(id);
if (res) urlList.push(res);
return res;
});
await Promise.all(jobs);
console.log(urlList);
}
}

十、过度防御


优化前:


const App = (props) => {
const { data } = props;
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => {
const { name, age } = item || {};
return `我的名字是${name},今年${age}岁了`;
});
}
const info = infoList?.join(',');
}

infoList 后面为什么要跟 ?,数组的 map 方法返回的一定是个数组。


优化后:


const App = (props) => {
const { data } = props;
let infoList = [];
if (Array.isArray(data)) {
infoList = data.map(item => {
const { name, age } = item || {};
return `我的名字是${name},今年${age}岁了`;
});
}
const info = infoList.join(',');
}

后续


以上对小王代码的吐槽,最后我只想说一下,以上的错误都是一些 JS 基础知识,跟任何框架没有任何关系。如果你工作了几年还是犯这些错误,真的可以考虑转行。


作者:红尘炼心
来源:juejin.cn/post/7259007674520158268
收起阅读 »

排查线上接口时间慢八个小时的心酸历程

项目上线时,突然发现时间与正常时间对不上,少了八个小时;但我丝毫不慌,这不就是个时区的问题吗,简单,但是这一次它给我深深的上了一课,一起来看整个排查过程吧; 开始排查 1、排查数据库 一般的时区问题都是数据库配置或数据链接参数的配置问题,于是我立马就定位到了问...
继续阅读 »

项目上线时,突然发现时间与正常时间对不上,少了八个小时;但我丝毫不慌,这不就是个时区的问题吗,简单,但是这一次它给我深深的上了一课,一起来看整个排查过程吧;


开始排查


1、排查数据库


一般的时区问题都是数据库配置或数据链接参数的配置问题,于是我立马就定位到了问题,应该是数据库的时区设置错了,于是我愉快的查看了数据库时区



命令:show variables like '%time_zone%';



image.png


1、system_time_zone:全局参数,系统时区,在MySQL启动时会检查当前系统的时区并根据系统时区设置全局参数system_time_zone的值。值为CST,与系统时间的时区一致。


2、ime_zone:全局参数,设置每个连接会话的时区,默认为SYSTEM,使用全局参数system_time_zone的值。


CST时间


CST时间:中央标准时间。
CST可以代表如下4个不同的时区:


● Central Standard Time (USA) UT-6:00,美国
● Central Standard Time (Australia) UT+9:30,澳大利亚
● China Standard Time UT+8:00,中国
● Cuba Standard Time UT-4:00,古巴


再次分析


很显然,这里与UTC时间无关,它只是时间标准。目前Mysql中的system_time_zone是CST,而CST可以代表4个不同的时区,那么,Mysql把它当做哪个时区进行处理了呢?


简单推算一下,中国时间是UT+8:00,美国是 UT-6:00,当传入中国时间,直接转换为美国时间(未考虑时区问题),时间便慢了14个小时。


既然知道了问题,那么解决方案也就有了。


解决方案


方案一:修改数据库时区


既然是Mysql理解错了CST指定的时区,那么就将其设置为正确的。


连接Mysql数据库,设置正确的时区:


set global time_zone = '+8:00';
set time_zone = '+8:00';
flush privileges;

再次执行show命令查看:show variables like '%time_zone%';


这里我选择方案2:修改数据库连接参数


## 原配置
serverTimezone=GMT%2B8
##修改 serverTimezone=Asia/Shanghai

url: jdbc:mysql://localhost:3306/aurora_admin?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=Asia/Shanghai&autoReconnect=true&rewriteBatchedStatements=true&allowMultiQueries=true



到这里,我想着问题肯定已经解决了;愉快的测了一手;
结果还是差八个小时?但是本地项目的时间是正常的啊,我开始有了不祥的预感;
于是我本地连上线上的数据库开始测试,发现时间是正常的。
到这里,就基本排查出不是MySQL的问题;


2、排查 Linux


我开始怀疑是不是 linux 系统的时区有问题;



查看硬件的时间:hwclock --show



image.png



查看系统的时间: date -R



image.png


发现Linux的时间四年没问题的,于是开始查服务器的时区配置
查看时区 TZ配置:echo $TZ
image.png


发现为空,于是查看系统配置;
查看系统配置命令:env
image.png


发现确实没有 TZ 的配置,现并未设置TZ变量,而是通过localtime指定时区;于是我修改 localtime的指定



先删除TZ环境变量:unset TZ




再执行: ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
命令etc目录下创建名称是localtime的快捷键指定到上海时区文件上;



修改完成后,果断重启项目测试,结果令人失望,时间依旧没有变化,于是尝试直接加上 TZ配置



命令:export TZ=Asia/Shanghai



再次查看:
image.png


可以看到 TZ配置已经设置成功,到这里我似乎看到了希望;于是再次测试,然后我人傻了,时间依旧少八个小时;
到这里,我实在是不知道还有什么是没排查的了,于是脑海里重新过一遍排查的过程,
1、本地连线上的数据库测试是正常的,所以数据库肯定是没问题的,
2、项目程序也没问题: @JsonFormat()并没有指定其他时区,字段类型也对的上;
3、Linux时间也没问题,时区也设置了;


3、排查Docker


我突然想到项目是通过Docker 来构建的,难道是Docker内部的时区有问题,这是唯一还没有排查的地方了,于是查看Dockerfile配置文件
image.png


很简单的配置呀,没理由出问题呀,为了保险起见;决定手动给他指定一个时区,最终的配置文件;
添加配置:


# 安装tzdata(如果 设置时区不生效使用)
# RUN apk add --no-cache tzdata
# 设置时区
ENV TZ="Asia/Shanghai"

image.png


重新构建项目运行,再次测试,时间正常了,


总结


整个排查过程虽然艰辛,但好在是解决了,我们在排查问题的时候,一定要胆大心细,多个地方考虑,很小伙伴可能想到是数据库的问题,但是发现修改配置后依然不行,可能会想是不是数据库版本问题呀,或者是不是我们项目哪儿写的有问题呀,把代码,配置看了一遍又一遍,虽然有这个可能,但是我们的思想就局限到这个地方了,就不敢去想,或者不愿去相信会是服务器问题,或其他的问题;我们应该培养这种发散的思想。


作者:钰紫薇
来源:juejin.cn/post/7221740907232657468
收起阅读 »

HashMap线程安全问题

JDK1.7的线程安全问题 JDK7版本的HashMap底层采用数组加链表的形式存储元素,假设需要存储的键值对通过计算发现存放的位置已经有元素了,那么HashMap就会用头插法将新节点插入到这个位置。 这一点我们可以从put方法去验证,它会根据key计算获得...
继续阅读 »

JDK1.7的线程安全问题


JDK7版本的HashMap底层采用数组加链表的形式存储元素,假设需要存储的键值对通过计算发现存放的位置已经有元素了,那么HashMap就会用头插法将新节点插入到这个位置。
JDK7HashMap头插法


这一点我们可以从put方法去验证,它会根据key计算获得元素的存放位置,如果位置为空则直接调用addEntry插入,如果不为空,则需要判断该位置的数组是否存在一样的key。如果存在key一致则覆盖并返回,若遍历当前索引的整个链表都不存在一致的key则通过头插法将元素添加至链表首部。


public V put(K key, V value) {
//判断是否是空表
if (table == EMPTY_TABLE) {
//初始化
inflateTable(threshold);
}
//判断是否是空值
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
//得到元素要存储的位置table[i],如果位置不为空则进行key比对,若一样则进行覆盖操作并返回,反之继续向后遍历,直到走到链表尽头为止
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//封装所需参数,准备添加
addEntry(hash, key, value, i);
return null;
}

addEntry方法是完成元素插入的具体实现,它会判断数组是否需要扩容,如果不需要扩容则直接调用createEntry,如果需要扩容,会将容量翻倍,然后调用createEntry通过头插法将元素插入。


void addEntry(int hash, K key, V value, int bucketIndex) {
//判断是否需要扩容
if ((size >= threshold) && (null != table[bucketIndex]))
//扩容
resize(2 * table.length);
//重新计算hash值
hash = (null != key) ? hash(key) : 0;
//计算所要插入的桶的索引值
bucketIndex = indexFor(hash, table.length);
}
//使用头插法将节点插入
createEntry(hash, key, value, bucketIndex);
}

那么当HashMap扩容的时候,它具体会如何实现呢?且看下文源码分析


void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;

if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}

//创建新的容器
Entry[] newTable = new Entry[newCapacity];
//将旧的容器的元素转移到新数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

可以看出它会根据newCapactity创建出一个新的容器newTable,然后将原数组的元素通过transfer方法转移到新的容器中。接下来我们看看transafer的源码:


void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
//记录要被转移到新数组的e节点的后继节点
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//计算e节点要存放的新位置i
int i = indexFor(e.hash, newCapacity);
//e的next指针指向i位置的节点
e.next = newTable[i];
//i位置的指针指向e
newTable[i] = e;
//e指向后继,进行下一次循环转移操作
e = next;
}
}
}


那么通过源码了解整体过程之后,接下来我们来聊聊今日主题,JDK1.7中HashMap在多线程中容易出现死循环,下面我们从这段代码分析死循环的情况。


public class HashMapDeadCycle {

public static void main(String[] args) {
HashMapThread thread0 = new HashMapThread();
HashMapThread thread1 = new HashMapThread();
HashMapThread thread2 = new HashMapThread();
HashMapThread thread3 = new HashMapThread();
HashMapThread thread4 = new HashMapThread();
thread0.start();
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}

class HashMapThread extends Thread {
private static final AtomicInteger ATOMIC_INTEGER = new AtomicInteger();
private static final Map<Integer, Integer> MAP = new HashMap<>();

private static final Integer SIZE = 1000_000;

@Override
public void run() {
while (ATOMIC_INTEGER.get() < SIZE) {
MAP.put(ATOMIC_INTEGER.get(), ATOMIC_INTEGER.get());
ATOMIC_INTEGER.incrementAndGet();
}
}
}

上诉代码就是开启多个线程不断地进行put操作,然后AtomicInteger和HashMap全局共享,运行几次后就会出现死循环。
运行过程中可能还会出现数组越界的情况
数组越界
当出现死循环后我们可以通过jpsjstack命令来分析死循环的情况。
JDK7HashMap死循环堆栈信息
在上图中我们从堆栈信息可以看到死循环是发生在HashMap的resize方法中,根源在transfer方法中。transfer方法在对table进行扩容到newTable后,需要将原来数据转移到newTable中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。我们不妨通过画图的方式来了解一下这个过程。
我们假设map的sieze=2,我们插入下一个元素到0索引位置时发现,0索引位置的元素个数已经等于2,此时触发扩容。
JDK7HashMap初始Map
于是创建了一个两倍大小的新数组


JDK7扩容


在迁移到新容器前,会使用e和next两个指针指向旧容器的元素
e和next指针
此时经过哈希计算,旧容器中索引0位置的元素存到新容器中的索引3上。e的next指向新容器中的索引i位置上,由于是第一次插入,newTable[i]实际上等于NULL。因为有next指向,所以当e指向的元素插入到新数组中时指向消失,next指向的元素不会被垃圾清除。JDK7HashMap头指针指向


此时新数组i索引位置的指针指向e,此时逻辑上e已经存在到新数组中。
  newTable[i] = e
此时e指向next,然后准备下一次的插入
 e = next
因为当前e没有后继节点,故而next指向null;此时当前e节点经过计算,位置也是在3索引,所以next域指向3索引头节点
在这里插入图片描述
此时新数组i索引位置的指针指向当前的e,完成迁移,此时循环发现next为null结束本次循环,继而迁移旧容器的其他索引位置的节点。


在这里插入图片描述
上诉就是单线程情况正常扩容的一个流程,但是在多线程情况下会出现什么呢,我们这里简化假设只有两个线程同时执行操作。
未resize前的数据结构如下:
在这里插入图片描述


我们假设线程A,执行到Entry<K,V> next = e.next;时线程被挂起,此时线程A的新容器和旧容器如下图所示:
在这里插入图片描述


线程A挂起后,此时线程B正常执行,并完成resize操作,结果如下:
在这里插入图片描述


此时切换到线程A,在线程A挂起时内存中值如下:e指向3,next指向7,此时结果如下:
在这里插入图片描述
接下来我们不妨按照代码逻辑继续往下看,首先e的next域指向头节点,此时3的next指针指向7,可以看到此时7和3构成了一个环,
在这里插入图片描述
我们接着往下看,执行 newTable[i] = e;代码,此时将3插入到7前面;
在这里插入图片描述


然后e指向next,而next为7,再次循环,此时e.next=3,而在上次循环中3.next=7,出现环形链表,构成一个死循环,最终导致CPU100。


在这里插入图片描述


JDK1.8的线程安全问题


JDK1.8中对HashMap进行了优化,发生hash碰撞时不再采用头插法,而是使用尾插法,因此不会出现环形链表的情况,但是JDK1.8就安全了吗?
我们来看看JDK1.8中的put操作代码,整体逻辑大概可以分为四个分支:



  1. 如果没有hash碰撞,则直接插入元素

  2. 如果算出来索引位置有值且转成红黑树则调用插入红黑树节点的方法完成插入

  3. 如果算出来索引位置有值且转为链表则遍历链表将节点插入到末端。

  4. 如果key已存在则覆盖原有的value


final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict)
{
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果没有hash碰撞,则直接插入元素
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果算出来索引位置有值且转成红黑树则调用插入红黑树节点的方法完成插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果算出来索引位置有值且是链表则遍历链表,将节点追加到末端
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果key已存在则覆盖原有的value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}


其中这段代码不难看出有点问题


		//如果没有hash碰撞,则直接插入元素
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

假设有两个线程A、B都在进行put操作,并且算出来的插入下标一致,当线程A执行完上面这段代码时时间片耗尽被挂起,此线程B抢到时间片完成插入元素,然后线程A重新获得时间片继续往下执行代码,直接插入,这就会导致线程B插入的数据被覆盖,从而线程不安全。接下来我们画图加深理解。
1.线程A执行到 if ((p = tab[i = (n - 1) & hash]) == null),判断到索引2为空后被挂起。
在这里插入图片描述
2.线程B判断到索引也是2且为空后执行完代码直接插入。
在这里插入图片描述
3.线程1被唤醒执行后续逻辑,这就会导致线程2的key被覆盖。
在这里插入图片描述
那么下面我们从这段代码验证一下键值对覆盖问题,创建一个size为2的map,然后设置两个线程A、B往map同一个索引位置插入数据。


public class DeadCycle {
private static final HashMap<String, String> MAP = new HashMap<>(2, 1.5f);
public static void main(String[] args) throws InterruptedException {

CountDownLatch countDownLatch = new CountDownLatch(2);

new Thread(() -> {
MAP.put("3", "zayton");
countDownLatch.countDown();
}, "t1").start();

new Thread(() -> {
MAP.put("5", "squid");
countDownLatch.countDown();
}, "t2").start();

countDownLatch.await();

System.out.println(MAP.get("3"));
System.out.println(MAP.get("5"));

}
}

在put方法中的if ((p = tab[i = (n - 1) & hash]) == null)处打上断点,然后调试模式设置为thread,设置条件


"t1".equals(Thread.currentThread().getName())||"t2".equals(Thread.currentThread().getName()) 

在这里插入图片描述
然后启动程序,当t1完成判断,准备创建节点时将线程切换成t2。
在这里插入图片描述
可以看到t2将(5,squid)键值对准备放入数组中,然后我们放行代码。
在这里插入图片描述
此时线程自动切换成t1,t1再上面已经完成判断认为当前索引位置的数组为null,所有在这里可以看到t2插入的键值对被覆盖成了(3,zayton)
在这里插入图片描述
此时放行代码,然后可以看出map.get("5")为null,即验证了hashMap在多线程情况下会出现索引覆盖问题。
在这里插入图片描述


参考文献


面试官:HashMap 为什么线程不安全?
大厂常问的HashMap线程安全问题,看这一篇就够了!


作者:zayton_squid
来源:juejin.cn/post/7299354838928539688
收起阅读 »

90%的程序员在编写登录接口时犯了这个致命错误!

在众多程序猿中,存在一个令人头痛的问题:为什么90%的人编写的登录接口都存在安全风险?这个问题很值得探讨。或许是因为这些开发者过于自信,认为自己的代码无懈可击,或者是因为他们缺乏安全意识,未意识到安全问题的重要性。然而,这种做法是非常危险的,因为一个不安全的登...
继续阅读 »

在众多程序猿中,存在一个令人头痛的问题:为什么90%的人编写的登录接口都存在安全风险?这个问题很值得探讨。或许是因为这些开发者过于自信,认为自己的代码无懈可击,或者是因为他们缺乏安全意识,未意识到安全问题的重要性。然而,这种做法是非常危险的,因为一个不安全的登录接口可能会威胁用户的安全和隐私。那么,为什么在编写登录接口时总容易出现安全漏洞呢?很可能是因为这些程序猿过于注重代码的功能性和实现细节,却忽视了安全问题的重要性,或者对安全措施缺乏足够的了解。这种情况下,他们很少考虑登录接口可能存在的安全问题,或者没有意识到潜在的严重后果。在这种情况下,网络安全员就显得非常重要了。他们是保护登录接口安全的专家,可以帮助程序员识别潜在的安全风险并提出有效的解决方案,从而提高整个系统的安全性。网络安全员需要考虑哪些安全风险呢?


首先,存在SQL注入攻击漏洞的风险。如果程序猿没有对输入参数进行处理,就可能被利用生成有害的SQL语句来攻击网站。其次,不添加盐值的密码处理是另一个安全风险。如果程序员未将随机盐值结合在密码中,可能会因"彩虹表"破解而带来风险。这种情况下,攻击者可以对密码进行逆向破解,并进一步攻击其他网站。第三,存在页面跨站点攻击(XSS)漏洞的风险。如果程序员未对输入的HTML文本进行过滤,就可能受到XSS攻击。这种情况下,攻击者可以注入有害代码到HTML页面中,在用户浏览页面时盗取用户信息。最后,缺乏防止暴力破解的保护措施也是一个常见的安全问题。如果程序员未对登录失败次数进行限制,就会存在暴力破解风险,攻击者可以尝试多次猜测用户名和密码进行登录,进而对密码进行暴力破解。总而言之,对于程序猿来说,编写一个安全的登录接口非常重要。如果你还没有特别的网络安全背景,也不用担心,本文将为你针对以上几种安全风险列出相对应的措施,帮助你提高系统的安全性,保障用户安全。


一、对于SQL注入攻击的安全风险:


解决方案:使用预定义的SQL语句,避免直接使用用户输入的值。另外,为了增加数据安全性,可以使用参数化查询语句,而非拼接SQL语句的方式。角色和设定:程序猿需要与数据库管理员共同开发或执行安全方案,并规定非法字符与关键字的过滤编码方法。


PreparedStatement statement = connection.prepareStatement("SELECT * FROM table_name WHERE column_name = ?");
statement.setString(1, userInput);
ResultSet resultSet = statement.executeQuery();

二、对于不添加盐值的密码处理安全风险:


解决方案:使用密码哈希和盐值加密技术进行密码处理,以避免针对单一密钥的攻击和彩虹表攻击。


角色和设定:程序猿需要与安全管理员共同开发或实现密码处理方案,并规定随机盐值的生成方法。


public String getHashedPassword(String password, String salt) {
   String saltedPassword = password + salt;
   MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
   byte[] hash = messageDigest.digest(saltedPassword.getBytes(StandardCharsets.UTF_8));
   return new String(Base64.getEncoder().encode(hash));
}

三、对于页面跨站点攻击(XSS)风险:


解决方案:过滤用户提供的所有输入,特别是HTML文本,以防止恶意脚本被注入。可以使用现有的工具库,如JSoup、OWASP ESAPI等,来过滤并转义HTML文本和特殊字符。


角色和设定:程序猿需要与Web管理员共同配置和使用预定义的规则,以及相应的过滤编码方法。


String unsafeHtml = "<script>alert("Hello, world!");</script>";
String safeHtml = Jsoup.clean(unsafeHtml, Whitelist.basic());

四、对于缺乏防止暴力破解的保护措施风险:


解决方案:使用失败次数限制、暂停时间间隔等措施,防止暴力破解攻击。另外,可以使用多因素身份验证来增强登录安全性。


角色和设定:Java开发人员需要与安全管理员共同开发或实现防止暴力破解的策略,并规定每个账户的限制尝试次数。


public class LoginService {
   private Map<String, Integer> failedAttempts = new HashMap<>();
   private final int MAX_ATTEMPTS = 3;

   public boolean validateCredentials(String username, String password) {
       // 根据数据库验证凭据
  }

   public boolean isAccountLocked(String username) {
       Integer attempts = failedAttempts.get(username);
       if (attempts != null && attempts >= MAX_ATTEMPTS) {
           return true;
      }
       return false;
  }

   public boolean processLogin(String username, String password) {
       if (isAccountLocked(username)) {
           throw new AccountLockedException("Account is locked due to too many failed attempts.");
      }
       boolean result = validateCredentials(username, password);
       if (!result) {
           if (!failedAttempts.containsKey(username)) {
               failedAttempts.put(username, 1);
          } else {
               failedAttempts.put(username, failedAttempts.get(username) + 1);
          }
      } else {
           // 重置失败尝试计数器
           failedAttempts.remove(username);
      }
       return result;
  }
}

程序猿需要注重安全问题,并积极与网络安全员合作,以确保登录接口的安全性和系统可靠性。同时,他们需要不断加强自己的技能和知识,以便适应不断变化的安全需求和挑战。


除了以上列举的安全风险和解决方案,还有一些其他的安全措施也需要注意。比如,程序猿需要确保应用程序和系统组件的版本都是最新的,以修复已知的漏洞并增强安全性。另外,程序猿还需要对敏感信息进行正确的管理和保护,如用户密码、个人身份信息等。在这种情况下,可以使用加密技术、访问控制等手段来保护敏感信息的安全。例如,在应用程序中使用HTTPS加密协议来保护数据传输过程中的安全性。此外,Java开发人员还需要定期进行代码安全审查、漏洞扫描等工作,以及建立相应的应急响应计划,以应对可能出现的安全漏洞和攻击行为。总之,Java开发人员需要以安全为先的理念,全面提高安全性意识和技能水平,与网络安全员密切合作和交流,从而保障系统和用户的安全。


网络安全是一个不断发展和变化的领域,Java开发人员需要不断跟进新的安全趋势和技术,以保证系统和应用程序的安全性。以下是一些建议,可供Java开发人员参考:



  1. 学习网络安全基础知识,包括常见的安全漏洞、攻击技术、安全协议等。



  1. 使用安全的编码实践,如输入验证、错误处理、数据加密、访问控制等,以提高代码的健壮性和安全性。3. 阅读应用程序和系统组件的文档、安全更新和漏洞报告,及时修复已知的漏洞和安全问题。

  2. 定期进行代码安全审查和漏洞扫描,及时发现和修复潜在的安全漏洞,防止恶意攻击。

  3. 使用最新的开发工具和库,以快速构建和部署安全性可靠的应用程序和系统组件。

  4. 将安全性融入软件开发生命周期中,包括需求分析、设计、开发、测试和部署等方面。7. 与网络安全专家紧密合作,了解系统和应用程序的安全性状态,及时采取必要的措施,防止安全漏洞和攻击。总之,Java开发人员需要具备一定的安全意识和技能,以建立安全性可靠的应用程序和系统组件,从而保护用户的隐私和数据安全,促进信息化建设的可持续发展。


此外,Java开发人员还需要关注一些特定的安全问题,如:1. 跨站脚本攻击(XSS):XSS是一种常见的Web攻击方式,攻击者通过注入恶意脚本,窃取用户的敏感信息或欺骗用户执行某些恶意操作。Java开发人员应该使用输入验证和输出编码等技术,过滤用户的输入和输出,避免XSS攻击。2. SQL注入攻击:SQL注入攻击是一种网络攻击方式,攻击者通过注入SQL语句,窃取或破坏数据库中的数据。Java开发人员应该避免使用拼接SQL语句的方式来操作数据库,而是应该使用参数化查询或ORM框架等技术,避免SQL注入攻击。3. 不安全的密钥管理:Java应用程序中使用的加密技术通常需要密钥来保护敏感信息,例如SSL证书、对称加密密钥等。Java开发人员需要正确管理密钥和证书,避免泄漏和被攻击者恶意利用。4. 未授权的访问:Java应用程序中可能包含一些敏感的资源、函数或API,需要进行授权才能访问。Java开发人员应该使用访问控制等技术,限制未经授权的访问,从而保护系统和应用程序的安全性。总之,Java开发人员需要不断提高自己的安全意识和技能,了解新的安全趋势和技术,避免常见的安全漏洞和攻击,保护应用程序和系统组件的安全可靠性。


作者:谁是大流氓
来源:juejin.cn/post/7221808657531486265
收起阅读 »

Redis 性能刺客,大key

在使用 Redis 的过程中,如果未能及时发现并处理 Big keys(下文称为“大Key”),可能会导致服务性能下降、用户体验变差,甚至引发大面积故障。 本文将介绍大Key产生的原因、其可能引发的问题及如何快速找出大Key并将其优化的方案。 一、大Key的...
继续阅读 »

在使用 Redis 的过程中,如果未能及时发现并处理 Big keys(下文称为“大Key”),可能会导致服务性能下降、用户体验变差,甚至引发大面积故障。


本文将介绍大Key产生的原因、其可能引发的问题及如何快速找出大Key并将其优化的方案。



一、大Key的定义


Redis中,大Key是指占用了较多内存空间的键值对。大Key的定义实际是相对的,通常以Key的大小和Key中成员的数量来综合判定,例如:


graph LR
A(大Key)
B(Key本身的数据量过大)
C(Key中的成员数过多)
D(Key中成员的数据量过大)
E(一个String类型的Key 它的值为5MB)
F(一个ZSET类型的Key 它的成员数量为1W个)
G(一个Hash类型的Key 成员数量虽然只有1K个但这些成员的Value总大小为100MB)

A --> B --> E
A --> C --> F
A --> D --> G

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px
style G fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px


注意:上述例子中的具体数值仅供参考,在实际业务中,您需要根据Redis的实际业务场景进行综合判断。



二、大Key引发的问题


当Redis中存在大量的大键时,可能会对性能和内存使用产生负面影响,影响内容包括




  • 客户端执行命令的时长变慢。




  • Redis内存达到maxmemory参数定义的上限引发操作阻塞或重要的Key被逐出,甚至引发内存溢出(Out Of Memory)。




  • 集群架构下,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。




  • 对大Key执行读请求,会使Redis实例的带宽使用率被占满,导致自身服务变慢,同时易波及相关的服务。




  • 对大Key执行删除操作,易造成主库较长时间的阻塞,进而可能引发同步中断或主从切换。




上面的这些点总结起来可以分为三个方面:


graph LR
A(大Key引发的问题)
B(内存占用)
C(网络传输延迟)
D(持久化和复制延迟)

A ---> B
A ---> C
A ---> D

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px

三、大Key产生的原因


未正确使用Redis、业务规划不足、无效数据的堆积、访问量突增等都会产生大Key,如:




  • 在不适用的场景下使用Redis,易造成Keyvalue过大,如使用String类型的Key存放大体积二进制文件型数据;




  • 业务上线前规划设计不足,没有对Key中的成员进行合理的拆分,造成个别Key中的成员数量过多;




  • 未定期清理无效数据,造成如HASH类型Key中的成员持续不断地增加;




  • 使用LIST类型Key的业务消费侧发生代码故障,造成对应Key的成员只增不减。




上面的这些点总结起来可以分为五个方面:


graph LR
A(大Key产生的原因)
B(存储大量数据)
C(缓存过期策略错误)
D(冗余数据)
E(序列化格式选择不当)
F(数据结构选择不当)

A ---> B
A ---> C
A ---> D
A ---> E
A ---> F

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px

四、如何快速找出大Key


要快速找出Redis中的大键,可以使用Redis的命令和工具进行扫描和分析。以下是一些方法:




  • 使用Redis命令扫描键Redis提供了SCAN命令,可以用于迭代遍历所有键。您可以使用该命令结合适当的模式匹配来扫描键,并在扫描过程中获取键的大小(使用MEMORY USAGE命令)。通过比较键的大小,您可以找出占用较多内存的大键。




  • 使用Redis内存分析工具:有一些第三方工具可以帮助您分析Redis实例中的内存使用情况,并找出大键。其中一种常用的工具是Redis的官方工具Redis Memory Analyzer (RMA)。您可以使用该工具生成Redis实例的内存快照,然后分析快照中的键和它们的大小,以找出大键。




  • 使用Redis命令和Lua脚本组合:您可以编写Lua脚本,结合Redis的命令和Lua的逻辑来扫描和分析键。通过编写适当的脚本,您可以扫描键并获取它们的大小,然后筛选出大键。





现在大部分都是使用的云Redis,其本身一般也提供了多种方案帮助我们轻松找出大Key,具体可以参考一下响应云Redis的官网使用文档。



五、大Key的优化方案


大Key会给我们的系统带来性能瓶颈,所以肯定是要进行优化的,那么下面来介绍一下大Key都可以怎么优化。


5.1 对大Key进行拆分


例如将含有数万成员的一个HASH Key拆分为多个HASH Key,并确保每个Key的成员数量在合理范围。在Redis集群架构中,拆分大Key能对数据分片间的内存平衡起到显著作用。


5.2 对大Key进行清理


将不适用Redis能力的数据存至其它存储,并在Redis中删除此类数据。



注意




  • Redis 4.0及之后版本:可以通过UNLINK命令安全地删除大Key甚至特大Key,该命令能够以非阻塞的方式,逐步地清理传入的Key。




  • Redis 4.0之前的版本:建议先通过SCAN命令读取部分数据,然后进行删除,避免一次性删除大量key导致Redis阻塞。





5.3 对过期数据进行定期清理


堆积大量过期数据会造成大Key的产生,例如在HASH数据类型中以增量的形式不断写入大量数据而忽略了数据的时效性。可以通过定时任务的方式对失效数据进行清理。



注意:在清理HASH数据时,建议通过HSCAN命令配合HDEL命令对失效数据进行清理,避免清理大量数据造成Redis阻塞。



5.4 特别说明



如果你用的是云Redis服务,要注意云Redis本身带有的大key的优化方案



六、总结


本文介绍了大KeyRedis中的定义以及可能引发的问题。介绍了快速找出大Key的方法以及对于大Key的优化方案。通过合理的优化方案,可以提升Redis的性能和用户体验。



希望本文对您有所帮助。如果有任何错误或建议,请随时指正和提出。


同时,如果您觉得这篇文章有价值,请考虑点赞和收藏。这将激励我进一步改进和创作更多有用的内容。


感谢您的支持和理解!



作者:竹子爱揍功夫熊猫
来源:juejin.cn/post/7298989375370166298
收起阅读 »

坏了,CSS真被他们玩出花来了

web
前言 事情是这样子的,本人由于摸鱼过多被临时抽调去支援公司的一个官网开发,其中有个任务是改造侧边栏导航,我心想着很简单嘛,两下搞完继续我的摸鱼大业😁.然后ui就丢给了一个网站让我照着这个做(龟龟现成的都有,这也太好了),网站在这里,让我们来看一下简单的交互. ...
继续阅读 »

前言


事情是这样子的,本人由于摸鱼过多被临时抽调去支援公司的一个官网开发,其中有个任务是改造侧边栏导航,我心想着很简单嘛,两下搞完继续我的摸鱼大业😁.然后ui就丢给了一个网站让我照着这个做(龟龟现成的都有,这也太好了),网站在这里,让我们来看一下简单的交互.


iShot_2023-10-30_11.07.56.gif


看了一下大致就是滚动到特定位置后把导航栏固定,hover的时候显示导航列表,这不是小菜一碟.


滚动到特定位置固定导航


主要逻辑就是下面这段,简单来说就是监听滚动条位置,当滚动到特定高度时改变导航按钮的定位方式,
css部分是用的@emotion/styled,文档可以看一下这里.


const [fixed, setFixed] = useState(false);
useEffect(() => {
const root = document.querySelector('#root');
if (!root) return;
const fn = () => {
if (root.scrollTop > 451) {
setFixed(true);
} else {
setFixed(false);
}
};
root.addEventListener('scroll', fn);
return () => {
root.removeEventListener('scroll', fn);
};
}, []);

const StyledFixed = styled.div<{ fixed?: boolean }>`
width: 0;
position: ${(props) => (props.fixed ? 'fixed' : 'absolute')};
top: ${(props) => (props.fixed ? '80px' : '531px')};
left: 80px;
z-index: 9;
@media (max-width: 1800px) {
left: 12px;
}
`;

效果如下


iShot_2023-10-30_11.24.58.gif


hover显示导航列表


然后就是鼠标移入的时候显示导航列表了,我心想这还不简单,几行css就搞定了(简单描述下就是把导航列表放在按钮里面,给按钮加上hover效果),但是当我研究了一下腾讯网站的代码,我发现事情好像没那么简单.


image.png


导航按钮和列表是平级的,这样的话鼠标移上去列表显示,但列表显示的同时hover效果也没有了,列表就又隐藏了,就会导致闪烁的效果,gif图展示不够明显.


iShot_2023-10-30_11.49.33.gif

现在问题来了,如果是平级元素,那如何控制hover显示呢,答案就在他们的父元素身上,从下面2张图上可以看出,导航按钮以及他的父元素都加上了hover样式


image.png


image.png


默认情况下父元素宽度为0,防止误触发列表展示,hover状态下设置width:auto
image.png


实现效果


到这里,我已经完全清楚了实现原理,完整的代码在下面


const LeftNav = observer(() => {
const { selectedKeys, openKeys } = menuStore;
const [fixed, setFixed] = useState(false);
useEffect(() => {
const root = document.querySelector('#root');
if (!root) return;
const fn = () => {
if (root.scrollTop > 451) {
setFixed(true);
} else {
setFixed(false);
}
};
root.addEventListener('scroll', fn);
return () => {
root.removeEventListener('scroll', fn);
};
}, []);
const onMenuClick = ({ key }: { key: string }) => {
menuStore.selectedKeys = [key];
if (key) {
const dom = document.querySelector(`#${key}`);
menuStore.scrollingKey = key;
dom?.scrollIntoView({
behavior: 'smooth'
});
}
};

const onOpenChange = (keys: string[]) => {
menuStore.openKeys = keys.filter((i) => i !== openKeys[0]);
};

return (
<StyledFixed fixed={fixed}>
<div className='left-nav-btn'>
<RightOutlinedIcon />
</div>
<div className='left-nav-list'>
<StyledMenuWrap>
<Menu selectedKeys={selectedKeys} openKeys={openKeys} mode='inline' items={MENU} onClick={onMenuClick} onOpenChange={onOpenChange} />
</StyledMenuWrap>
</div>
</StyledFixed>

);
});

const StyledFixed = styled.div<{ fixed?: boolean }>`
width: 0;
position: ${(props) => (props.fixed ? 'fixed' : 'absolute')};
top: ${(props) => (props.fixed ? '80px' : '531px')};
left: 80px;
z-index: 9;
&:hover {
width: auto;
.left-nav-btn {
display: none;
}
.left-nav-list {
transform: none;
visibility: visible;
}
}
@media (max-width: 1800px) {
left: 12px;
}
.left-nav-btn {
position: absolute;
top: 80px;
width: 40px;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
padding: 12px;
border-radius: 100px;
border: 1px solid #fff;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0px 4px 30px 0px rgba(12, 25, 68, 0.05);

cursor: pointer;

&::before {
content: '页面导航';
background: linear-gradient(139deg, #c468ef 5.3%, #2670ff 90.91%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}

&:hover {
display: none;
& + .left-nav-list {
transform: none;
visibility: visible;
}
}
@media (min-width: 1799px) {
display: none;
}
}
.left-nav-list {
position: relative;
width: 172px;
z-index: 1;
transition: all 0.3s ease-in;
@media (max-width: 1800px) {
transform: translateX(-200px);
visibility: hidden;
}
}
`
;

最终的实现效果如下
iShot_2023-10-30_14.39.20.gif


至于腾讯网站中的滚动到对应模块高亮菜单的实现可以看看 IntersectionObserver 这个api,好了本次的分享就到此为止了,感谢各位大佬的阅读与点赞😁,你可以说我菜,因为我是真的菜.


作者:hahayq
来源:juejin.cn/post/7295343805020487690
收起阅读 »

10年老程序员告诉你,高级程序员应具备怎样的职业素质

回头望一望过往,从13年毕业至今,用手指数一数刚好能数过来,创过业,上过班,也搞过个人项目。 能力栈也从java、php、前端、产品经理、UI和平面设计之间反复横跳,反复折腾,🤣创过业的苦逼人应该都懂,后来主攻了前端,已经做了5年高级前端了。 最近自己行色匆匆...
继续阅读 »

回头望一望过往,从13年毕业至今,用手指数一数刚好能数过来,创过业,上过班,也搞过个人项目。


能力栈也从java、php、前端、产品经理、UI和平面设计之间反复横跳,反复折腾,🤣创过业的苦逼人应该都懂,后来主攻了前端,已经做了5年高级前端了。


最近自己行色匆匆地整理了一些编程方面的思考,聊聊自己对高级程序员的想法,当作是给自己这些年的一些总结吧。通过6个方面聊聊除了技术水平外,高级程序员应该具备什么样的职业能力。


一、代码风格很大程度决定了你的态度


代码需要有自己一贯的风格,包括命名约定、逻辑代码习惯、不写无用坏代码、语句块风格(如lambda表达式中只有一句话时省略return)、模块或函数注释、逻辑复杂部分注释,且注释需包含联系方式和时间,这样可以让后来者在需要求助的时候找到当事人,这也是对事负责的态度。


同时编码风格的追求体现了一种编码的热爱、一种不随意妥协的态度,当然也有可能是强迫症或洁癖发作,看不得不美观的东西。


二、人人都应具备解决问题的能力


通过百度、google、或者寻求同事帮助等方式解决编码中遇到的问题也是一个程序员的基础能力,而更高阶自独立思考能力在这边可以为你带来很好的提升效果。这边的问题可能会分很多类:


1、功能性问题,如上传文件时怎么才能成功、如何生成一张图片并返回图片流等等,这类问题通常是查资料、问同事朋友解决;


2、技术性问题,如怎样通过一个字符串生成二维码图片(当然你也可以直接用别人的库,省的自己造轮子),这可能也是钻研精神的一种体现,毕竟会造手机的人比会用手机的人厉害得多的多。这样一直钻研的人,久而久之就可以解决各种各样的问题,了解各种底层原理,离大牛之路原来越近了,但此时的你,可能头发也就少了好几根;


3、结构性问题,以下三个问题是更高阶人员才会遇到的问题,如我应该如何组织代码结构,模块与模块之间又是怎样的一种关系,当然你也可以完全不考虑这个问题,然后随便找个地方保存你的代码,或者干脆就写到一个函数里就好了,对功能和性能完全没有影响,但这样做你的心真的不慌吗?没有人给你code review出来吗?


4、协作性问题,一个项目大部分时候毕竟是很多人一起开发维护的,如何能保持大家的共同认知很重要,谁都不希望在同一个项目里重复造轮子,谁也不希望看到有人把代码搞乱,或把自己的代码搞乱。这时你应该需要用各种协作规则、编码规范,并用文档的形式记录下来,简单举几个例子,第一个编码规范让团队内的编码风格保持一致,这个最好理解却也最容易被忽视的东西。第二分工明确职责明确,编码前做好充分沟通进行分工且明确结构方案和技术方案,执行时不要干涉他人写的代码,而通过提交问题的形式让当事人处理迭代,同时将沟通时分配给自己写的公共模块记录到准备好的模块文档中并通知其他成员,做到信息一致。执行起来光有这些自然不够,还需要不断地code review来调整未按规定执行的代码。


5、项目性问题,正常是项目经理或产品经理需要考虑的事,项目迭代如何按时交付,这就要求项目内需要建立一个可行的开发计划,很多人可能会觉得制定开发计划就是一个坑,每天把自己坑到晚上10点才下班,久而久之就成了公司压榨员工的现象了。做计划是一件很难的事,我认为要解决这个问题,特别需要注意以下三点,第一个需避免计划谬误,每个人都会把计划美化,觉得可以做很多事,但做起来确是另一幅景象,遭受了挫败感,所以具体的事情要减少到原计划的50%到70%,让自己切实可完成,让计划为你带来成就感,第二个是预留30%左右缓冲时间让计划避免突发事情的破坏,第三个是保证执100%完成,这是达成任务获得成就感,从而增强信心的环境,如果任务制定太高了,就每天再往下降一点,知道可以确保100%按时完成。


其他问题也就不再举例列出了。


最后总结一点就是,问题解决固然重要,但不要为了解决问题而解决问题,知其然不知其所以然,又或者干脆网上搜索到的代码复制粘贴过来试,能跑通就不管了,全然不知问题的根本原因所在。


更好的做法是要去理解问题本身,找到原因并在自身内化。


三、模块抽象能力很大程度区分了程序员的层次


这个也可以理解为我们常说的封装,但又不仅仅是抽出代码封装,而包含了更多的设计思维在里面。


我认为这是区分中高级程序员很重要的一点,模块抽象能力代表了代码的组织性、灵活性、可理解度以及可维护性。


每一种流行框架或库的出现都代表了作者在模块抽象部分的新思维,ViewModel、Promise、Spring的ioc、aop这些,都代表了作者在抽象上的理解,才孕育出我们现在的各种技术、社区等。那我们在实际项目中的模块抽象,同样可以让代码结构拥有上面所说的四个特征,让项目更加美观。


模块抽象能力可以在项目设计阶段,以及下面说到的微重构阶段进行,让代码始终可以保持高维护性的状态。


四、用写开源库的心态设计你的每个模块


可能我是个开源爱好者的缘故吧,虽然我也没上传几个代码库,也没什么时间来维护。


但是在设计项目模块的时候都会不自觉地想象如果我要把它开源出去,它应该会有什么功能,哪些功能是我们现在会用到的,在编写前就把它设计得更加灵活,这样设计的目的是为了让模块或组件更能够减少日后项目变化带来的重构成本,也许有些功能在后面使用时已经就具备了,再到后面愈加成熟后再开源出来,供大家使用也是一个快乐的分享,而这些东西也就成了你的门面,当然前提是有足够的时间来维护,而不能让那些使用的人困惑于bug和未满足的需求。


五、微重构能力可让你的项目持续保持高可维护性状态


好的产品经理对产品会有更好的把控力,会制定出稳定可行的路线图,这对于开发来说是一件很好的事,因为这样开发人员编码时就可以根据产品路线图预留好相关功能的接口,从而让代码在日后迭代更方便。


但大部分产品可能不是这样的,产品路线图并不总是制定好的,产品经理经常需要根据市场反馈才能制定出近期策略,或者上面所说的路线图突然改变了,这时候预留的代码可能也要作废了,久而久之项目在这样的环境下改来改去,坏代码越来越多,可维护性越来越差,统一重构成本又太大,到后面谁都不愿意接这块烫手山芋了。


谁都没有对一个项目从开始就完美设计以后不再修改的能力,微重构就要求你在平时迭代时对之前因为需求限制,或没有考虑到的散装代码、未抽象的代码进行抽取重构,让代码一直保持在高可维护性的状态。


虽然会在迭代时额外花费一小部分时间,但这是为了在日后节约更多的时间和精力,同时也让新来者更容易接受项目维护。


一个微重构的例子:原本一个前端项目只需要和一个ip的服务器交互,此时的ajax请求可能一个简单的函数封装就可以满足需求,随着项目迭代,这个项目需要和多台服务器交互,很多人觉得函数中加个ip参数就可以了,需求实现了改动也小,心里美滋滋。但这样却违背了模块抽象的原则,灵活性也大大减小,此时根据微重构的角度则应该封装成一个ajax类,在创建对象时都有其独特的特征参数一一对应每台服务器,这便有了受欢迎的Axios。


并不是所有需要复用的代码需要微重构。


它大致可分为以下三点:


1、可复用抽取,绝大多数开发人员都知道这块内容,对原本只有一个地方使用到的散装代码在某一天给的需求中也需要这块代码,此时就是抽取这块代码的时候了,这边所说的“这块代码”不仅仅是包括一个代码块,它还包括一个更细微的字符串或数字的常量、一个配置项等;


2、相关聚合,在平时项目中,我可以很肯定你一定会把视图文件放一个文件夹,把工具文件放一个文件夹,把相关controller、service、dao放到一个文件夹,但在代码中可能又是另一幅景象,你可能会在想要的调用接口的地方直接调用接口,也可能会在A文件和B文件写满了sql语句,这些都和上面的文件分类截然相反。为了便于维护和统一管理,我们就需要将相关代码聚合在一起,如序列化和反序列化、构造和解析、接口操作、数据库操作等等,这些都可以看作是一些相关操作,可将它们挂载到一个相关类上,可单独写一个文件导出方法,也可以写成一个类。


3、模块抽象,这就和上面第三、四点所说的一样了,利用抽象能力重构原本应该抽象却没有抽象的代码。


六、让你的好习惯辐射到更多人


也许你自己也有一套行之有效的编码或项目管理心法,无论如何都请不要独享,把它传播给更多需要的人吧,因为这至少在两个方面可以让你受益:


1、与你的同事分享可以增进团队融合度,谁都希望与自己价值观相近的人共事,但要遇到这样的同事很难,这样的事大多数只存在于理想中,所以如果你的团队相处很好,请好好珍惜吧。


大部分情况是核心价值观和能力匹配度可接受,并通过不断地磨合来相互适应,提高融合度,这时团队成员的分享、辩论等都可以持续提高团队融合度,当然,从相反面也可以过滤核心价值观不够匹配的成员。


2、与朋友或陌生人等非共事关系的人分享进行价值输出,可以把这种分享看作是一种服务或一个产品,通过产品或服务对外输出价值,既让这份价值产出了更多的收货,又可以提升自身品牌信誉度。


从辐射渠道上,你可以写成文章、做成分享PPT,让它在同事之间流动,让它在BBS上传播。


如果你时间精力充沛,你也可以做成视频,然后去qq群、微信群、知乎、segmentfault、简书等等各大社交网站去传播。


作者:古韵
来源:juejin.cn/post/7262146559831212090
收起阅读 »

面试官问我库里的数据和缓存如何保持一致了?

是的,我就是那个背着公司偷偷出去面试的小猪佩琪。上次被面试官蹂躏了一顿后,回去好好的恶补一下,然后准备继续战斗。我的誓言:地球不毁灭,宇宙不爆炸,那就越挫越勇的面试下去吧。 由于简历上的自我介绍和技术栈里,写了精通高并发和分布式系统架构,还很低调的写了熟悉re...
继续阅读 »

是的,我就是那个背着公司偷偷出去面试的小猪佩琪。上次被面试官蹂躏了一顿后,回去好好的恶补一下,然后准备继续战斗。我的誓言:地球不毁灭,宇宙不爆炸,那就越挫越勇的面试下去吧。


由于简历上的自我介绍和技术栈里,写了精通高并发和分布式系统架构,还很低调的写了熟悉redis(是的,没敢写精通),然后被敏锐的面试官,似乎抓到了简历上亮点;然后就是一阵疯狂的灵魂拷问redis的问题。面试官很低调的开口就问了我一个问题:你们数据库里的数据和缓存是如何保持一致的?


我不假思索,条件反射般的立刻回答到:在操作数据库成功后,立刻就操作缓存,以此来让他们保持一致。然后面试官就让我回去等通知了,然后就没有然后了。。。。。。


目前业界里有哪些方案,让数据库和缓存的数据保持一致了?


大概有以下四种


1699518595490.png


大厂模式(监听binlog+mq)


大厂模式主要是通过监听数据库的binlog(比如mysql binlog);通过binlog把数据库数据的更新操作日志(比如insert,update,delete),采集到后,通过MQ的方式,把数据同步给下游对应的消费者;下游消费者拿到数据的操作日志并拿到对应的业务数据后,再放入缓存。


大概流程图:


1699518624156.png


优点:

1、把操作缓存的代码逻辑,从正常的业务逻辑里解耦出来;业务代码更加清爽和简洁,两者互不干扰和影响,独立发展。用非人类的话说,减少对业务代码的侵入性。

2、曾经有幸在大厂里实践过此种方案,速度还贼快,虽然从库到缓存经过了类canel和mq中间件,但基本上耗时都是在毫秒级,99.9%都是10毫秒内能完成库里的数据和缓存数据同步(大厂的优势出来了)


缺点:

1、技术方案和架构,非常复杂

2、中间件的运维和维护,是个不小的工作量

3、由于引入了MQ需要解决引入MQ后带来的问题。比如数据乱序问题:同一条数据先发后至,后发先至的到达消费者后,从而引起的MQ乱序消费问题。但一般都能解决(比如通过redis lua+数据的时间戳比较方案,解决并发问题和数据乱序问题)


在大厂里,不缺类似canel这种伪装为数据库slave节点的自研中间件,并且大厂里也有足够的技术高手+物料,运维资源更是不缺;对小厂来说,慎用吧。


中小厂模式(定时+增量查询)


定时更新+增量查询:主要是利用库里行数据的更新时间字段+定时增量查询。

具体为:每次更新库里的行数据,记录当前行的更新时间;然后把更新时间做为一个索引字段(加快查询速度嘛)


定时任务:会每隔5秒钟(间隔时间可自定义);把库里最近更新5秒钟的数据查询出来;然后放入缓存,并记录本次查询结束时间。

整个查询过程和放入缓存的过程都是单线程执行;所以不会存在并发更新缓存问题。另外每次同步成功后,会记录同步成功时间;下次定时任务再执行时,会拿上次同步成功时间,做为本次查询开始时间条件;当前时间做为查询结束时间,以此达到增量查询的目标。

再加上查询条件里更新时间是个索引,性能也差不到哪里去。

即使偶尔的定时任务执行失败或者没有执行,也不会丢失数据,只要定时任务恢复了。


优点:

1、实现方案,和架构很简单。是的,比起大厂那套方案,简直不要太轻量。

2、也能把缓存逻辑和业务逻辑进行解耦

3、三方依赖也比较少。如果有条件可以上个分布式定时中间件比如 xxl-job,实在不行就用redis做个分布式锁也能用

缺点:

1、数据库里的数据和缓存中数据,会在极短时间内,存在不一致,但最终会是一致的。这个极短的时间,取决于定时调度间隔时间,一般在秒级。

2、如果是分库分表的业务,编写这个查询逻辑,估计会稍显复杂。


如果业务上不是要求毫秒级的及时性,也不是类似于价格这种非常敏感的数据,这种轻量级方案还真不错。无并发问题,也无数据乱序问题;秒级数据量超过几十万的增量数据并且还需要缓存的,怕是只有大厂才有的场景吧;怎么看此方案都非常适合中小公司。


小厂原始模式(缓存单删)


小厂原始模式,即业界俗称的 缓存删除模式。在更新数据前先删除缓存;然后在更新库,每次查询的时候发现缓存无数据,再从库里加载数据放入缓存。


图 缓存删除


1699518654807.png


图 缓存加载


1699518671638.png


此方案主要解决的是佩琪当时在面试回答方案中的弊端;为什么不更新数据时同步进行缓存的更新了?


主要是有些缓存数据,需要进行复杂的计算才能获得;而这些经过复杂计算的数据,并不一定是热点数据;所以采取缓存删除,当需要的时候在进行计算放入缓存中,节省系统开销和缓存中数据量(毕竟缓存容量有限,单价又不像磁盘那样低廉,公司有矿的请忽略这条建议)

另外一个原因:面对简单场景时,缓存删除成功,库更新失败;那么也没有关系,因为读缓存时,如果发现没有命中,会从库里再加载数据放入到缓存里。


优点:



  • 此种实现方案简单

  • 无需依赖三方中间件

  • 缓存中的数据基本能和库里的数据保持一致


缺点:



  • 缓存逻辑和正常业务逻辑耦合在一起

  • 在高并发的读流量下,还是会存在缓存和库里的数据不一致。见下图


图 缓存单删 数据不一致情况


1699518695739.png


time1下: T1线程执行缓存删除

time2下: T2线程查询缓存,发现没有,查库里数据,放入缓存中

time3下: T1线程更新库

time4下: 此时数据库数据是最新数据,而缓存中数据还是老版本数据


此方案非常适合业务初期,或者工期较紧的项目;读流量并发不高的情况下,属于万能型方案。


小厂优化模式(延迟双删)


延迟双删其实是为了解决缓存单删,在高并发读情况下,数据不一致的问题。具体过程为:
操作数据前,先删除缓存;接着操作DB;然后延迟一段时间,再删除缓存。


此种方案好是好,可是延迟一段时间是延迟多久了?延迟时间不够长,还是存在单删时,缓存和数据不一致的问题;延迟时间足够长又担心影响业务响应速度。实在是一个充满了玄学的延时时间


优点
1、技术架构上简单

2、不依赖三方中间件

3、操作速度上挺快的,直接操作DB和缓存


缺点
1、落地难度有点大,主要是延迟时间太不好确认了

2、缓存操作逻辑和业务逻辑进行了耦合


此种方案放那个厂子,估计都不太合适,脑壳痛。


方案这么多,我该选择那种方案了?


为了方便大家选择,列了个每种方案的对比图。请根据自身情况进行选择


1699518714343.png


佩琪你在那里BI了这么久,到底有没有现成的工具呀?


推荐款适合中小公司的缓存加载方案吧。基于Redisson,主要是利用
 MapLoader接口做实现;主要功能:发现缓存里没有,则从数据库加载;(其实自己封装个类似的应该也不难,想偷懒的可以试试)


MapLoader<String, String> mapLoader = new MapLoader<String, String>() {

@Override
public Iterable<String> loadAllKeys() {
List<String> list = new ArrayList<String>();
Statement statement = conn.createStatement();
try {
ResultSet result = statement.executeQuery("SELECT id FROM student");
while (result.next()) {
list.add(result.getString(1));
}
} finally {
statement.close();
}

return list;
}

@Override
public String load(String key) {
PreparedStatement preparedStatement = conn.prepareStatement("SELECT name FROM student where id = ?");
try {
preparedStatement.setString(1, key);
ResultSet result = preparedStatement.executeQuery();
if (result.next()) {
return result.getString(1);
}
return null;
} finally {
preparedStatement.close();
}
}
};

使用例子


MapOptions<K, V> options = MapOptions.<K, V>defaults()
.loader(mapLoader);

RMap<K, V> map = redisson.getMap("test", options);
// or
RMapCache<K, V> map = redisson.getMapCache("test", options);
// or with boost up to 45x times
RLocalCachedMap<K, V> map = redisson.getLocalCachedMap("test", options);
// or with boost up to 45x times
RLocalCachedMapCache<K, V> map = redisson.getLocalCachedMapCache("test", options);

总结


数据库和缓存数据保持一致的问题,本质上还是数据如何在多个系统间保持一致的问题。

能不能给我一颗银弹,然后彻底的解决它了?

对不起,没有。请结合自己实际环境,人力,物力,工期紧迫度,技术熟悉度,综合选择。

是的,当我的领导在问我技术方案,在来挑战我缓存和数据库保持一致时,我会把表格扔到他脸上,请选择一个吧,我来做你选择后的实现。


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


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

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

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


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


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



JSON 教程 | w3resource


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


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


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


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


它易于使用!



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

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

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

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

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

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


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


速度需求


🚀🚀🚀


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


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



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

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

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

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

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


JSON 会拖慢我们的应用吗?


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


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


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


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



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


JSON 为什么会变慢


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


1. 解析带来的开销


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


2. 序列化和反序列化


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


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



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




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



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


3. 字符串操作


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


4. 缺乏数据类型


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



5. 冗余


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



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



6. 不支持二进制


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


7. 深度嵌套


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


JSON的替代方案


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


1. Protocol Buffers(protobuf)


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



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


GitHub - vaishnav-mk/protobuf-example


2. MessagePack


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



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


3. BSON (二进制 JSON)


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



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


4. Apache Avro


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



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


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



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


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


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


开始: JSON 数据


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


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

JSON 总大小: ~ 139 字节


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


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



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


Protocol Buffers (protobuf):


syntax = "proto3";

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

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

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

Protocol Buffers 总大小: ~ 38 bytes


MessagePack:


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


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


a36a6964000000000a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d042100000005011241313302bdcccc3f0530112434353625029a99993f


MessagePack 总大小: ~34 字节


BSON (二进制 JSON):


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


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


3e0000001069640031000a4a6f686e20446f6502656d61696c006a6f686e646f65406578616d706c652e636f6d1000000022616765001f04370e4940

BSON 总大小: ~ 43 字节


Avro:


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


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


0e120a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d049a999940040a020b4108312e3525312e323538323539

Avro 总大小: ~ 32 字节



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


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


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


1. Avro:



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

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

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


2. MessagePack:



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

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

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


3. BSON:



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

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

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


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



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

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

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


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


优化 JSON 性能


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


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


1. 最小化数据大小:



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


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

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


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


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

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

2. 明智地使用数组:



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


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

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

3. 优化数字表示:



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


// Inefficient
{
"quantity": 1.0
}

// Efficient
{
"quantity": 1
}

4. 消除冗余:



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


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

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

5. 使用压缩:



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


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

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

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

6. 采用服务器端缓存:



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


7. 剖析与优化:



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



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



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


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


1. LinkedIn集成Protocol Buffers:


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


2. Uber的H3地理索引:



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

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

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


3. Slack的消息格式优化:



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

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

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


4. Auth0的Protocal Buffers实现:



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

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

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


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


总结一下


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


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



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

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

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

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


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


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


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

ARM汇编基础(一)----寄存器篇

AArch64寄存器Arm处理器提供通用寄存器和专用寄存器以及一些在特定模式下可用的额外寄存器。在 AArch64状态下,有以下寄存器是可用的:31个64位通用寄存器(X0-X30),通用寄存器的低32位可用W0-W30访问。4个栈指针寄存器:SP_EL0、S...
继续阅读 »

AArch64寄存器

Arm处理器提供通用寄存器和专用寄存器以及一些在特定模式下可用的额外寄存器。

在 AArch64状态下,有以下寄存器是可用的:
  • 31个64位通用寄存器(X0-X30),通用寄存器的低32位可用W0-W30访问。

  • 4个栈指针寄存器:SP_EL0、SP_EL1、SP_EL2、SP_EL3。

  • 3个异常链接寄存器:ELR_EL1、ELR_EL2、ELR_EL3。

  • 3个程序状态寄存器:SPSR_EL1、SPSR_EL2、SPSR_EL3。

  • 1个程序计数器。

除了状态寄存器SPSR_EL1、SPSR_EL2、SPSR_EL3是32bit,所有的寄存器均为64bit。

大多数的指令都可以操作32bit和64bit寄存器。寄存器宽度由寄存器标识符决定,W表示32bit,X表示64bit。Wn和Xn(0-30)是指同一个寄存器。当使用32位指令时,源寄存器的高32bit会被忽略,而目的寄存器的高32bit则会被置0。

没有W31或者X31寄存器,根据指令,寄存器31会被用作栈指针寄存器或者零寄存器。当用作栈指针寄存器时,可以用SP表示;当用作零寄存器时,用WZR和XZR分别表示32bit和64bit的零寄存器。

异常等级

Armv8架构定义了4个异常等级(EL0-EL3),EL3代表拥有最多执行特权的最高异常级别。当接受异常时,异常等级可以上升或者保持不变,当从异常处理中返回时,异常等级可以降低或者保持不变。

以下为常用的异常等级模式:

  • EL0

应用程序。

  • EL1

系统内核以及特殊函数。

  • EL2

虚拟机监视器(virtual machine monitor)。

  • EL3

Secure monitor.

当将异常带到更高的异常级别时,执行状态可以保持不变,或者从AArch32变到AArch64。

当返回到较低的异常级别时,执行状态可以保持不变,也可以从AArch64变更为AArch32。

执行状态的改变的唯一方式是从异常中获取或返回。在执行状态之间进行更改不可能与在AArch32状态下在A32和T32指令之间进行更改相同。

在powerup和reset上,处理器进入最高的实现异常级别。此异常级别的执行状态是实现的属性,可能由配置输入信号决定。

对于EL0以外的异常级别,执行状态由一个或多个控制寄存器配置位决定。这些位只能在更高的异常级别上设置。

对于EL0,执行状态被确定为异常返回EL0的一部分,由执行返回的异常级别控制。

LR寄存器

在AArch64状态下,当进行子函数调用时,LR寄存器保存返回地址。如果返回地址呗保存在栈上,LR寄存器也可以用作通用寄存器。LR寄存器对应的是寄存器30,与AArch32不同的是,LR寄存器和异常链接寄存器(ELRs)是不同的,因此,LR是未存储的。(ps:Unlike in AArch32 state, the LR is distinct from the Exception Link Registers (ELRs) and is therefore unbanked.【unbanked 确实不知道怎么翻译】)

异常链接寄存器有三个:ELR_EL1、ELR_EL2、ELR_EL3,与异常等级相对应。当发生异常时,目标异常级别的异常链接寄存器将存储异常处理完后要跳转到的返回地址。如果异常来自AArch32,ELR寄存器的高32bit全部置0。异常级别内的子函数调用用LR来存储子函数的返回地址。

例如,当异常等级从EL0变为EL1,返回地址将存储在ELR_EL1寄存器中。

在异常时,如果要启用用相同级别的中断,必须将ELR中的数据存储到栈中,因为在发生中断时ELR寄存器将被新的返回地址覆盖。

栈指针寄存器

在AArch64状态下,SP表示64位栈指针,SP_EL0是SP的别名。不要讲SP用作通用寄存器。

SP只能用作以下指令的操作寄存器:

  • 作为装载和存在的基本寄存器。在这种情况下,再添加任何偏移量之前,它必须是4字节对齐的,否则会发生堆栈对齐异常。

  • 作为算术指令的源寄存器或者目标寄存器,但是它不能被用作设置了条件标志的指令的目标寄存器。

  • 逻辑指令,例如为了使其地址对齐。

对于三个异常级别,都有一个单独的栈指针。在异常级别中,可以使用该异常级别下的专用栈指针,也可以使用与之相应的栈寄存器。可以使用SPSel寄存器来选择要在异常级别使用的栈指针。

栈指针的选择由附加到异常级别名称的字母t或h表示,例如EL0t或EL3h。t后缀表示异常级别使用SP_EL0, h后缀表示使用SP_ELx,其中x是当前异常级别号。EL0总是使用SP_EL0,所以不能有h后缀。

AArch64状态下预声明的核心寄存器名字

在AArch64状态中,预先声明的核心寄存器与AArch32状态中的不同。

下表显示AArch64状态下预声明的核心寄存器:

寄存器名称含义
W0-W3032-bit 通用寄存器。
X0-X3064-bit 通用寄存器。
WZR32-bit RAZ/WI寄存器,在32位指令下,寄存器31用作零寄存器时的名称。
XZR64-bit RAZ/WI寄存器,在64位指令下,寄存器31用作零寄存器时的名称。
WSP32-bit 栈指针,在32位指令下,寄存器31用作栈指针时的名称。
SP64-bit 栈指针,在64位指令下,寄存器31用作栈指针时的名称。
LR链接寄存器。和X30是同一个寄存器。

可以将寄存器名全部写成大写或小写。

请注意:

在AArch64状态下,PC寄存器不是一个通用寄存器,不能通过名称来访问他。

在AArch64下预声明的扩展寄存器

您可以将高级SIMD和浮点寄存器的名称写成大写或小写。

下表显示了AArch64状态下预先声明的扩展寄存器名:

寄存器名称含义
V0-V31128-bit矢量寄存器。
Q0-Q31128-bit标量寄存器。
D0-D3164-bit标量寄存器、双精度浮点寄存器。
S0-S3132-bit标量寄存器、单精度浮点寄存器。
H0-H3116-bit标量寄存器、半精度浮点寄存器。
B0-B318-bit标量寄存器。

AArch64状态下的PC寄存器

在AArch64状态下,PC寄存器存储的事当前执行的指令的地址。

它是由执行的指令的大小增加的,总是四个字节。

在AArch64状态下,PC寄存器不是一个通用寄存器,所以不能显式地访问它。以下类型的指令,可以隐式地读取它的值:

  • 计算PC相对地址的指令。

  • PC相关的加载指令。

  • 直接指向PC相关的标签。

  • 分支和链接指令,会将PC值存储在LR寄存器中。

唯一可以写入PC寄存器的指令类型:

  • 条件分支和无条件分支。

  • 异常产生和异常返回。

分支指令将目的地址加载到PC寄存器中。

在AArch64状态下的条件执行

在AArch64状态下,NZCV寄存器保存着N、Z、C、V标志位的值,处理器用这些标志位来决定是否执行条件指令。这些标志位被保存在NZCV寄存器的【31:28】位上。

条件标志位在任何异常等级下都可以使用MSR和MRS指令进行访问。

与A32相比,A64对条件的利用更少。例如在A64中:

  • 只有少数的指令可以set或test条件标志位。

  • 没有等效的T32 IT指令。

  • 唯一有条件执行的指令是条件分支指令B.cond,如果条件判定不成立(false),B.cond指令就像NOP指令一样。

在AArch64状态下的Q标志位

在AArch64状态下,不能对Q标识位进行读写,因为在A64中没有在通用寄存器上操作的饱和算术指令。(in A64 there are no saturating arithmetic instructions that operate on the general purpose registers.)

先进的SIMD饱和算法指令将浮点状态寄存器(FPSR)中的QC位设置为表示已经发生饱和。您可以通过Q助记符修饰符(例如SQADD)来识别这些指令。

流程状态

在AArch64状态下,没有CPSR寄存器。但是可以通过访问CPSR中不同的部分作为流程状态字段。

流程状态字段:

  • N、Z、C、V条件标识位(NZCV)。

  • 当前寄存器位宽(nRW)。

  • 栈指针选择位(SPSel)。

  • 禁止中断位(DAIF)。

  • 当前异常等级(EL)。

  • 单步处理位(SS)。

  • 非法异常返回状态位(IL)。

可以使用MSR指令写:

  • NZCV寄存器中的N、Z、C、V标识位。

  • DAIF寄存器中的禁止中断标识位。

  • 在异常等级为EL1或更高的情况下,SPSel寄存器的SP选择位。

可以使用MRS指令读:

  • NZCV寄存器中的N、Z、C、V标识位。

  • DAIF寄存器中的禁止中断标识位。

  • 在异常等级为EL1或更高的情况下,CEL寄存器的异常等级位。

  • 在异常等级为EL1或更高的情况下,SPSel寄存器的SP选择位。

当发生异常时,与当前异常级别关联的所有流程状态字段都存储在与目标异常级别关联的单个SPSR寄存器中。只能从SPSR访问SS、IL和nRW位。

AArch64下的SPSRs

保存的程序状态寄存器(SPSRs)是32位寄存器,当将异常带到使用AArch64状态的异常级别时,它存储当前异常级别的进程状态。这允许在处理异常之后恢复进程状态。

在AArch64状态下,每个异常等级都有自己的SPSR寄存器:

  • SPSR_EL1.

  • SPSR_EL2.

  • SPSR_EL3.

当发生异常时,当前异常等级的进程状态会被写入当前异常等级对应的SPSR寄存器中。当从一个异常中返回时,异常处理程序使用正在返回的异常级别的SPSR来恢复正在返回的异常级别的流程状态。

请注意

从异常返回时,首选返回地址将从与正在返回的异常级别关联的ELR恢复。

SPSRs存储了以下信息:

  • N、Z、C、V标识位。

  • D、A、I、F禁止中断位。

  • 寄存器位宽。

  • 执行模式。

  • IL和SS位。

收起阅读 »

越狱手机root密码重置

之前有入手过一台iphone6越狱机器,手机刚到那会儿,把玩了一番。之后就一直没动过了,今天突然心血来潮,想玩玩,结果发现,ssh登陆不上了,因为root密码不记得了。像咱们的qq或者什么的密码忘记了,正常思路,就是找回密码,即重置密码。所以同理。iphone...
继续阅读 »

之前有入手过一台iphone6越狱机器,手机刚到那会儿,把玩了一番。之后就一直没动过了,今天突然心血来潮,想玩玩,结果发现,ssh登陆不上了,因为root密码不记得了。
像咱们的qq或者什么的密码忘记了,正常思路,就是找回密码,即重置密码。所以同理。
iphone手机的账号和密码,一般是存储在/private/etc/master.passwd。


把这个文件导出到电脑桌面(一般越狱手机都是可以通过一些软件直接访问文件的,像ifunbox、pp助手、ifiles。这里我用的是pp助手),打开:



编辑该文件,把root后面的ZGrKPbggg0H8Q(这里不一定是这个,每个机器肯定都不同,只需记住是root:之后的13个字符即可。)改为/smx7MYTQIi2M


把文件名修改为master.passwd,再放回手机的private/etc/目录下。
再次ssh登陆越狱手机,输入alpine,即可。


收起阅读 »

一文带你玩转SQL中的DML(数据操作)语言:从概念到常见操作大解析!数据操作不再难!

嗨~ 今天的你过得还好吗?人生就是走自己路看自己的风景🌞- 2023.11.08 -前面我们介绍了SQL语句中数据定义语言(DDL)的概念以及它的常用语句,那么DML又是什么呢?二者有什么区别呢?本篇文章将为你讲述。一DML简介DML是指数据操作语言...
继续阅读 »


嗨~ 今天的你过得还好吗?

人生就是走自己路

看自己的风景

🌞

- 2023.11.08 -

前面我们介绍了SQL语句中数据定义语言(DDL)的概念以及它的常用语句,那么DML又是什么呢?二者有什么区别呢?本篇文章将为你讲述。




DML简介

DML是指数据操作语言,英文全称是Data Manipulation Language,用来对数据库中表的数据记录进行更新。


它创建的模式(表)使用数据操作语言来填充。DDL填充表的行,每行称为Tuple。使用DML,您可以插入,修改,删除和检索表中的信息。DML命令有助于管理存储在数据库中的数据。但是,DML命令不会自动提交,因此变化不是永久性的。所以,可以回滚操作


一些DML命令包括insert,update,delete和select。insert命令有助于将新记录或行存储到表中;update命令有助于修改表中的现有记录;delete命令允许从表中删除某个记录或一组记录;select命令允许从一个或多个表中检索特定记录。


关键字:

  • 插入 insert

  • 删除 delete

  • 更新 update



DDL 语句与DML 语句的主要区别

DDL 语句与DML 语句是SQL语言中的两种主要语句类别,它们在数据库操作中起到了不同的作用。


具体来说,DDL有助于更改数据库的结构。它主要用于定义或修改数据库的架构,包括创建、删除和修改表、数据类型、索引等。常用的DDL语句关键字包括CREATE、DROP和ALTER等。例如,可以使用CREATE TABLE语句来创建一个新表,使用ALTER TABLE语句来修改现有表的结构

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!大力推荐!


相反,DML有助于管理数据库中的数据它涵盖了SQL中处理数据的基本操作,包括查询、插入、更新和删除数据。DML语句通常用于从数据库中提取信息、修改现有数据或添加新数据。一些常用的DML语句关键字包括SELECT、INSERT、UPDATE和DELETE等。通过这些语句,用户可以对数据库中的数据进行各种操作,以满足其需求。


总之,DDL和DML分别代表了在数据库操作中的结构定义和管理数据的两个关键方面,它们共同构成了SQL语言的核心功能,使用户能够有效地创建和管理数据库。


DML 常见操作

前面我们已经了解了DML的概念和与DDL 语句的主要区别,下面我们来看看DML 常用语法有哪些。

首先,准备一张表:



1. 插入数据(INSERT)

1.1 语法:

INSERT INTO 表名[(字段1,字段2,...)] VALUES(字段的1值,字段2的值,...)

注:在语法定义上"[]"中的内容表示可写可不写

例:


注:

  • 数据库中字符串的字面量是使用单引号’'表达的

  • VALUES中指定的值要与指定的字段名个数,顺序,以及类型完全一致

  • 查看表中数据

SELECT * FROM person 


1.2 插入默认值

当插入数据时不指定某个字段,那么该字段插入默认值。若创建表时字段没有显示的指定默认值时,默认值插入NULL。

例:


注意事项:

  • age字段没有指定,因此插入默认值NULL;

  • 数据库中任何字段任何类型默认值都是NULL,当然可以在创建表时使用DEFAULT指定。



1.3 全列插入

当我们插入数据是不指定任何字段名时,就是全列插入。此时VALUES子句中需要为每个字段指定值。并且要求(个数,顺序,类型必须与表完全一致)

例:



2. 修改数据(UPDATE)

2.1 语法

UPDATE 表名 SET 字段1=新值1[,字段2=新值2,...][WHERE 过滤条件]

例:


注意:

UPDATE语句通常都要添加WHERE子句,用于添加要修改记录的条件,否则全表修改!

 


2.2 修改指定记录(添加WHERE子句)

例:


WHERE子句中常用的条件

=, <, <= ,> ,>= ,<>(不等于使用<>。!=不是所有数据库都支持)




2.3 将一个计算表达式的结果作为值使用



2.4 同时修改多个字段


 


3. 删除语句(DELETE)

3.1 语法:

DELETE FROM 表名 [WHERE 过滤条件]

注:DELETE语句通常都要添加WHERE子句,否则是清空表操作

例:




3.2 清空表操作

DELETE FROM person

练习:




总结

DML是一种用于操作数据库中数据的语言。它提供了对数据的增、删、改、查等基本操作,是进行数据库编程的重要工具之一。

在DML中,我们可以使用SELECT语句来查询数据库中的数据,使用INSERT语句来插入新的数据,使用UPDATE语句来更新已有的数据,以及使用DELETE语句来删除数据。这些操作可以通过简单的语法来实现,使得开发者能够更方便地对数据库中的数据进行处理。


除了基本操作外,DML还支持事务处理机制。事务可以确保一系列操作的完整性和一致性,如果其中任何一个步骤失败,则所有步骤都会被回滚,否则它们将被提交到数据库中。这种机制对于需要保证数据一致性的应用程序非常重要。

 


总的来说,DML是进行数据库编程不可或缺的一部分。通过熟练掌握DML的基本操作和事务处理机制,我们可以更高效地管理和操作数据库中的数据。同时,在实际应用中需要注意一些技巧和安全问题,如避免SQL注入攻击、优化查询语句等。


收起阅读 »

聊天气泡图片的动态拉伸、镜像与适配

前情提要 春节又到了,作为一款丰富的社交类应用,免不了要上线几款和新年主题相关的聊天气泡背景。这不,可爱的兔兔和财神爷等等都安排上了,可是Android的气泡图上线流程等我了解后着实感觉有些许复杂,对比隔壁的iOS真是被吊打,且听我从头到尾细细详解一遍。 创建...
继续阅读 »

前情提要


春节又到了,作为一款丰富的社交类应用,免不了要上线几款和新年主题相关的聊天气泡背景。这不,可爱的兔兔和财神爷等等都安排上了,可是Android的气泡图上线流程等我了解后着实感觉有些许复杂,对比隔壁的iOS真是被吊打,且听我从头到尾细细详解一遍。


创建.9.png格式的图片


新建项目.png
在开发上图所示的功能中,我们一般都会使用 .9.png 图片,那么一张普通png格式的图片怎么处理成 .9.png 格式呢,一起来简单回顾下。


在Android Studio中,对一张普通png图片右键,然后点击 “Create 9-Patch file...”,选择新图片保存的位置后,双击新图就会显示图片编辑器,图片左侧的黑色线段可以控制图片的竖向拉伸区域,上侧的黑色线段可以控制图片的横向拉伸区域,下侧和右侧的黑色线段则可以控制内容的填充区域,编辑后如下图所示:
Snipaste_2023-01-11_15-04-08.png


上图呢是居中拉伸的情况,但是如果中间有不可拉伸元素的话如何处理呢(一般情况下我们也不会有这样的聊天气泡,这里是拜托UI小姐姐专门修改图片做的示例),如下图所示,这时候拉伸的话左侧和上侧就需要使用两条(多条)线段来控制拉伸的区域了,从而避免中间的财神爷被拉伸:
Snipaste_2023-01-11_16-10-53.png


OK,.9.png格式图片的处理就是这样了。


从资源文件夹加载.9.png图片


比如加载drawable或者mipmap资源文件夹中的图片,这种加载方式的话很简单,直接给文字设置背景就可以了,刚刚处理过的小兔子图片放在drawable-xxhdpi文件夹下,命名为rabbit.9.png,示例代码如下所示:


textView.background = ContextCompat.getDrawable(this, R.drawable.rabbit)

从本地文件加载“.9.png”图片


如果我们将上述rabbit.9.png图片直接放到应用缓存文件夹中,然后通过bitmap进行加载,伪代码如下:


textView.text = "直接加载本地.9.png图片"
textView.background =
BitmapDrawable.createFromPath(cacheDir.absolutePath + File.separator + "rabbit.9.png")

则显示效果如下:
Screenshot_2023-01-11-17-13-54-60.jpg


可以看到,这样是达不到我们想要的效果的,整张图片被直接进行拉伸了,完全没有我们上文设计的拉伸效果。


其实要想达到上文设计的居中拉伸效果,我们需要使用aapt工具对.9.png图片再进行下处理(在Windows系统上aapt工具所在位置为:你SDK目录\build-tools\版本号\aapt.exe),Windows下的命令如下所示:


.\aapt.exe s -i .\rabbit.9.png -o rabbit9.png

将处理过后新生成的rabbit9.png图片放入到应用缓存文件夹中,然后通过bitmap直接进行加载,代码如下:


textView.text = "加载经aapt处理过的本地图片"
textView.background =
BitmapDrawable.createFromPath(cacheDir.absolutePath + File.separator + "rabbit9.png")

则显示效果正常,如下所示:
Screenshot_2023-01-11-17-32-33-91_24cef02ef5c5f1a3ed9b56e4c5956272.jpg
也就是说如果我们需要从本地或者assets文件夹中加载可拉伸图片的话,那么整个处理的流程就是:根据源rabit.png图片创建rabbit.9.png图片 -> 使用aapt处理生成新的rabbit9.png图片。


项目痛点


所以,以上就是目前项目中的痛点,每次增加一个聊天气泡背景,Android组都需要从UI小姐姐那里拿两张图片,一左一右,然后分别处理成 .9.png 图,然后还需要用aapt工具处理,然后再上传到服务器。后台还需要针对Android和iOS平台下发不同的图片,这也太复杂了。
所以我们的目标就是只需要一张通用的气泡背景图,直接上传服务器,移动端下载下来后,在本地做 拉伸、镜像、缩放等 功能的处理,那么一起来探索下吧。


进阶探索


我们来先对比看下iOS的处理方式,然后升级我们的项目。


iOS中的方式


只需要一个原始的png的图片即可,人家有专门的resizableImage函数来处理拉伸,大致的示例代码如下所示:


let image : UIImage = UIImage(named: "rabbit.png")
image.resizableImage(withCapInsets: .init(top: 20, left: 20, right:20, bottom:20))

注意:这里的withCapInsets参数的含义应该是等同与Android中的padding。padding的区域就是被保护不会拉伸的区域,而剩下的区域则会被拉伸来填充。
可以看到这里其实是有一定的约束规范的,UI小姐姐是按照此规范来进行气泡图的设计的,所以我们也可以遵循大致的约束,和iOS使用同一张气泡背景图片即可。


Android中的探索


那么在Android中有没有可能也直接通过代码来处理图片的拉伸呢?也可以有!!!


原理请参考《Android动态布局入门及NinePatchChunk解密》,各种思想的碰撞请参考《Create a NinePatch/NinePatchDrawable in runtime》。


站在前面巨人的肩膀上看,最终我们需要自定义创建的就是一个NinePatchDrawable对象,这样可以直接设置给TextView的background属性或者其他drawable属性。那么先来看下创建该对象所需的参数吧:


/**
* Create drawable from raw nine-patch data, setting initial target density
* based on the display metrics of the resources.
*/

public NinePatchDrawable(
Resources res,
Bitmap bitmap,
byte[] chunk,
Rect padding,
String srcName
)

主要就是其中的两个参数:



  • byte[] chunk:构造chunk数据,是构造可拉伸图片的数据结构

  • Rect padding:padding数据,同xml中的padding含义,不要被Rect所迷惑


构造chunk数据


这里构造数据可是有说法的,我们先以上文兔子图片的拉伸做示例,在该示例中,横向和竖向都分别有一条线段来控制拉伸,那么我们定义如下:
横向线段的起点位置的百分比为patchHorizontalStart,终点位置的百分比为patchHorizontalEnd;
竖向线段的起点位置的百分比为patchVerticalStart,终点位置的百分比为patchVerticalEnd;
width和height分别为传入进来的bitmap的宽度和高度,示例代码如下:


private fun buildChunk(): ByteArray {

// 横向和竖向都只有一条线段,一条线段有两个端点
val horizontalEndpointsSize = 2
val verticalEndpointsSize = 2

val NO_COLOR = 0x00000001
val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output

val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())

byteBuffer.put(1.toByte()) //was translated
byteBuffer.put(horizontalEndpointsSize.toByte()) //divisions x
byteBuffer.put(verticalEndpointsSize.toByte()) //divisions y
byteBuffer.put(COLOR_SIZE.toByte()) //color size

// skip
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// padding 设为0,即使设置了数据,padding依旧可能不生效
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// skip
byteBuffer.putInt(0)

// regions 控制横向拉伸的线段数据
val patchLeft = (width * patchHorizontalStart).toInt()
val patchRight = (width * patchHorizontalEnd).toInt()
byteBuffer.putInt(patchLeft)
byteBuffer.putInt(patchRight)

// regions 控制竖向拉伸的线段数据
val patchTop = (height * patchVerticalStart).toInt()
val patchBottom = (height * patchVerticalEnd).toInt()
byteBuffer.putInt(patchTop)
byteBuffer.putInt(patchBottom)

for (i in 0 until COLOR_SIZE) {
byteBuffer.putInt(NO_COLOR)
}

return byteBuffer.array()
}

OK,上面是横向竖向都有一条线段来控制图片拉伸的情况,再看上文财神爷图片的拉伸示例,就分别都是两条线段控制了,也有可能需要更多条线段来控制,所以我们需要稍微改造下我们的代码,首先定义一个PatchRegionBean的实体类,该类定义了一条线段的起点和终点(都是百分比):


data class PatchRegionBean(
val start: Float,
val end: Float
)

在类中定义横向和竖向竖向线段的列表,用来存储这些数据,然后改造buildChunk()方法如下:


private var patchRegionHorizontal = mutableListOf<PatchRegionBean>()
private var patchRegionVertical = mutableListOf<PatchRegionBean>()

private fun buildChunk(): ByteArray {

// 横向和竖向端点的数量 = 线段数量 * 2
val horizontalEndpointsSize = patchRegionHorizontal.size * 2
val verticalEndpointsSize = patchRegionVertical.size * 2

val NO_COLOR = 0x00000001
val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output

val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())

byteBuffer.put(1.toByte()) //was translated
byteBuffer.put(horizontalEndpointsSize.toByte()) //divisions x
byteBuffer.put(verticalEndpointsSize.toByte()) //divisions y
byteBuffer.put(COLOR_SIZE.toByte()) //color size

// skip
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// padding 设为0,即使设置了数据,padding依旧可能不生效
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)

// skip
byteBuffer.putInt(0)

// regions 控制横向拉伸的线段数据
patchRegionHorizontal.forEach {
byteBuffer.putInt((width * it.start).toInt())
byteBuffer.putInt((width * it.end).toInt())
}

// regions 控制竖向拉伸的线段数据
patchRegionVertical.forEach {
byteBuffer.putInt((height * it.start).toInt())
byteBuffer.putInt((height * it.end).toInt())
}

for (i in 0 until COLOR_SIZE) {
byteBuffer.putInt(NO_COLOR)
}

return byteBuffer.array()
}

构造padding数据


对比刚刚的chunk数据,padding就显得尤其简单了,注意这里传递来的值依旧是百分比,而且需要注意别和Rect的含义搞混了即可:


fun setPadding(
paddingLeft: Float,
paddingRight: Float,
paddingTop: Float,
paddingBottom: Float,
)
: NinePatchDrawableBuilder {
this.paddingLeft = paddingLeft
this.paddingRight = paddingRight
this.paddingTop = paddingTop
this.paddingBottom = paddingBottom
return this
}

/**
* 控制内容填充的区域
* (注意:这里的left,top,right,bottom同xml文件中的padding意思一致,只不过这里是百分比形式)
*/

private fun buildPadding(): Rect {
val rect = Rect()

rect.left = (width * paddingLeft).toInt()
rect.right = (width * paddingRight).toInt()

rect.top = (height * paddingTop).toInt()
rect.bottom = (height * paddingBottom).toInt()

return rect
}

镜像翻转功能


因为是聊天气泡背景,所以一般都会有左右两个位置的展示,而这俩文件一般情况下都是横向镜像显示的,在Android中好像也没有直接的图片镜像功能,但好在之前做海外项目LTR以及RTL时候了解到一个投机取巧的方式,通过设置scale属性为-1来实现。这里我们同样可以这么做,因为最终处理的都是bitmap图片,示例代码如下:


/**
* 构造bitmap信息
* 注意:需要判断是否需要做横向的镜像处理
*/

private fun buildBitmap(): Bitmap? {
return if (!horizontalMirror) {
bitmap
} else {
bitmap?.let {
val matrix = Matrix()
matrix.setScale(-1f, 1f)
val newBitmap = Bitmap.createBitmap(
it,
0, 0, it.width, it.height,
matrix, true
)
it.recycle()
newBitmap
}
}
}

如果需要镜像处理我们就通过设置Matrix的scaleX的属性为-1f,这就可以做到横向镜像的效果,竖向则保持不变,然后通过Bitmap类创建新的bitmap即可。
图像镜像反转的情况下,还需要注意的两点是:



  • chunk的数据中横向内容需要重新处理

  • padding的数据中横向内容需要重新处理


/**
* chunk数据的修改
*/

if (horizontalMirror) {
patchRegionHorizontal.forEach {
byteBuffer.putInt((width * (1f - it.end)).toInt())
byteBuffer.putInt((width * (1f - it.start)).toInt())
}
} else {
patchRegionHorizontal.forEach {
byteBuffer.putInt((width * it.start).toInt())
byteBuffer.putInt((width * it.end).toInt())
}
}

/**
* padding数据的修改
*/

if (horizontalMirror) {
rect.left = (width * paddingRight).toInt()
rect.right = (width * paddingLeft).toInt()
} else {
rect.left = (width * paddingLeft).toInt()
rect.right = (width * paddingRight).toInt()
}

屏幕的适配


屏幕适配的话其实就是利用Bitmap的density属性,如果UI给定的图是按照480dpi设计的,那么就设置为480dpi或者相近的dpi即可:


// 注意:是densityDpi的值,320、480、640等
bitmap.density = 480

简单封装


通过上述两步重要的过程我们已经知道如何构造所需的chunk和padding数据了,那么简单封装一个类来处理吧,加载的图片我们可以通过资源文件夹(drawable、mipmap),asstes文件夹,手机本地文件夹来获取,所以对上述三种类型都做下支持:


/**
* 设置资源文件夹中的图片
*/

fun setResourceData(
resources: Resources,
resId: Int,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
val bitmap: Bitmap? = try {
BitmapFactory.decodeResource(resources, resId)
} catch (e: Throwable) {
e.printStackTrace()
null
}

return setBitmapData(
bitmap = bitmap,
resources = resources,
horizontalMirror = horizontalMirror
)
}

/**
* 设置本地文件夹中的图片
*/

fun setFileData(
resources: Resources,
file: File,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
val bitmap: Bitmap? = try {
BitmapFactory.decodeFile(file.absolutePath)
} catch (e: Throwable) {
e.printStackTrace()
null
}

return setBitmapData(
bitmap = bitmap,
resources = resources,
horizontalMirror = horizontalMirror
)
}

/**
* 设置assets文件夹中的图片
*/

fun setAssetsData(
resources: Resources,
assetFilePath: String,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
var bitmap: Bitmap?

try {
val inputStream = resources.assets.open(assetFilePath)
bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
} catch (e: Throwable) {
e.printStackTrace()
bitmap = null
}

return setBitmapData(
bitmap = bitmap,
resources = resources,
horizontalMirror = horizontalMirror
)
}

/**
* 直接处理bitmap数据
*/

fun setBitmapData(
bitmap: Bitmap?,
resources: Resources,
horizontalMirror: Boolean = false
)
: NinePatchDrawableBuilder {
this.bitmap = bitmap
this.width = bitmap?.width ?: 0
this.height = bitmap?.height ?: 0

this.resources = resources
this.horizontalMirror = horizontalMirror
return this
}

横向和竖向的线段需要支持多段,所以分别使用两个列表来进行管理:


fun setPatchHorizontal(vararg patchRegion: PatchRegionBean): NinePatchDrawableBuilder {
patchRegion.forEach {
patchRegionHorizontal.add(it)
}
return this
}

fun setPatchVertical(vararg patchRegion: PatchRegionBean): NinePatchDrawableBuilder {
patchRegion.forEach {
patchRegionVertical.add(it)
}
return this
}

演示示例


我们使用一个5x5的25宫格图片来进行演示,这样我们可以很方便的看出来拉伸或者边距的设置到底有没有生效,将该图片放入资源文件夹中,页面上创建一个展示该图片用的ImageView,假设图片大小是200x200,然后创建一个TextView,通过我们自己的可拉伸功能设置文字的背景。


(注:演示所用的图片是请UI小哥哥帮忙处理的,听完说完我的需求后,UI小哥哥二话没说当着我的面直接出了十来种颜色风格的图片让我选,相当给力!!!)


一条线段控制的拉伸


示例代码如下:


textView.width = 800
textView.background = NinePatchDrawableBuilder()
.setResourceData(
resources = resources,
resId = R.drawable.sample_1,
horizontalMirror = false
)
.setPatchHorizontal(
PatchRegionBean(start = 0.4f, end = 0.6f),
)
.build()

显示效果如下:
Screenshot_2023-01-13-17-52-29-22_24cef02ef5c5f1a3ed9b56e4c5956272.jpg
可以看到竖向上没有拉伸,横向上图片 0.4-0.6 的区域全部被拉伸,然后填充了800的宽度。


两条线段控制的拉伸


接下来再看这段代码示例,这里我们横向上添加了两条线段,分别是从0.2-0.4,0.6-0.8:


textView.width = 800
textView.background = NinePatchDrawableBuilder()
.setResourceData(
resources = resources,
resId = R.drawable.sample_1,
horizontalMirror = false
)
.setPatchHorizontal(
PatchRegionBean(start = 0.2f, end = 0.4f),
PatchRegionBean(start = 0.6f, end = 0.8f),
)
.build()

显示效果如下:
Screenshot_2023-01-13-17-35-49-40_24cef02ef5c5f1a3ed9b56e4c5956272.jpg
可以看到横向上中间的(0.4-0.6)的部分没有被拉伸,(0.2-0.4)以及(0.6-0.8)的部分被分别拉伸,然后填充了800的宽度。


padding的示例


我们添加上文字,并且结合padding来进行演示下,这里先设置padding距离边界都为0.2的百分比,示例代码如下:


textView.background = NinePatchDrawableBuilder()
.setResourceData(
resources = resources,
resId = R.drawable.sample_2,
horizontalMirror = false
)
.setPatchHorizontal(
PatchRegionBean(start = 0.4f, end = 0.6f),
)
.setPatchVertical(
PatchRegionBean(start = 0.4f, end = 0.6f),
)
.setPadding(
paddingLeft = 0.2f,
paddingRight = 0.2f,
paddingTop = 0.2f,
paddingBottom = 0.2f
)
.build()

显示效果如下:
Screenshot_2023-01-13-18-05-27-82_24cef02ef5c5f1a3ed9b56e4c5956272.jpg


然后将padding的边距都改为0.4的百分比,显示效果如下:
Screenshot_2023-01-13-18-05-49-15_24cef02ef5c5f1a3ed9b56e4c5956272.jpg


屏幕适配的示例


上述的图片都是在480dpi下显示的,这里我们将densityDpi设置为960,按道理来说拉伸图展示会小一倍,如下图所示:


textView.background = NinePatchDrawableBuilder()
......
.setDensityDpi(densityDpi = 960)
.build()

Screenshot_2023-01-14-19-18-35-82_24cef02ef5c5f1a3ed9b56e4c5956272.jpg


效果一览


整个工具类实现完毕后,又简单写了两个页面通过设置各种参数来实时预览图片拉伸和镜像以及padding的情况,效果展示如下:
zonghe.png


整体的探索过程到此基本就结束了,效果是实现了,然而性能和兼容性还无法保证,接下来需要进一步做下测试才能上线。可能有大佬很早就接触过这些功能,如果能指点下,鄙人则不胜感激。


文中若有纰漏之处还请大家多多指教。


参考文章



  1. Android 点九图机制讲解及在聊天气泡中的应用

  2. Android动态布局入门及NinePatchChunk解密

  3. Android点九图总结以及在聊天气泡中的使用


作者:乐翁龙
来源:juejin.cn/post/7188708254346641465
收起阅读 »

使用JWT你应该要注意Token劫持安全问题

大家好,我是小趴菜,在工作中我们经常要做的一个就是登陆功能,然后获取这个用户的token,后续请求都会带上这个token来验证用户的请求。 问题背景 我们经常使用的JWT就是其中一种,如下 //生成Token public static String ge...
继续阅读 »

大家好,我是小趴菜,在工作中我们经常要做的一个就是登陆功能,然后获取这个用户的token,后续请求都会带上这个token来验证用户的请求。


问题背景


我们经常使用的JWT就是其中一种,如下


//生成Token  
public static String generateToken(Map<String, Object> payloads) {
Map<String, Object> map = new HashMap<>(2);
map.put("alg", "HS256");
map.put("typ", "JWT");
Date date = new Date(System.currentTimeMillis() + EXPIRE);
JWTCreator.Builder jwtBuilder = JWT
.create()
.withHeader(map)
.withExpiresAt(date);
for (Map.Entry<String, Object> entry : payloads.entrySet()) {
jwtBuilder.withClaim(entry.getKey(), entry.getValue().toString());
}
return jwtBuilder.sign(Algorithm.HMAC256(SECRET));
}

//校验Token
public static Map<String, Claim> verifyToken(String token) {
try{
JWTVerifier verifier = JWT
.require(Algorithm.HMAC256(SECRET))
.build();
DecodedJWT jwt = verifier.verify(token);
return jwt.getClaims();
}catch (Exception e){
throw new GlobalException(ResponseEnums.TOKEN_VERIFY_FAIL_ERROR);
}
}

我们会给每一个Token设置一个过期时间,前端拿到这个token以后,在之后的用户每一次请求都会带上这个Token进行校验,如果过期了或者Token格式不对,我们就不让请求通过,直接返回错误信息给前端


        //从请求头中拿到token key : token
String headerToken = request.getHeader(TokenConstant.TOKEN_HEADER);
if (StrUtil.isBlank(headerToken)) {
throw new GlobalException(ResponseEnums.TOKEN_IS_NULL_ERROR);
}

//解析token
Map<String, Claim> claimMap = JwtUtil.verifyToken(headerToken);
return true;
}

这看上去是一件很美好的事情,因为我们解决了用户请求校验的问题,但是这个Token是存储在前端的缓存中的。当我们点击退出登陆的时候,前端也只是把缓存的这个Token给清掉,这样用户后续的请求就没有这个Token,也就会让用户去重新登陆了。这看起来是没有问题的。


但是如果你这个Token还没有过期,这时候你的Token被其他人截取了,这时候,即使你退出登陆但是这个Token一样是可以通过校验的。所以其他人还是可以拿你这个Token去请求对应的接口。这时候就会有安全问题了。那有没有解决办法呢?


解决办法


其实是有的。我们可以把这个Token保存到Redis中。每次请求的时候,判断一下Redis中有没有这个Token,有就放行,没有就返回错误信息给前端。


image.png


当用户点击退出登陆的时候,把Redis的这个Token给删除掉就行了,这样后续即使用人截取了你这个Token,由于Redis没有,那么第一步返回fasle,就可以直接返回错误信息给前端,不会去执行这个请求


思考


既然我们使用了Redis那用JWT的意义在哪呢?我们Redis也可以设置过期时间,还可以给Token续期,很多JWT做不到的事Redis都可以做到。那为什么还要使用JWT呢?


作者:我是小趴菜
来源:juejin.cn/post/7298132141636403210
收起阅读 »

Android:监听滑动控件实现状态栏颜色切换

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。 今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图: 看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。



今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图:


1.gif


看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动到底部的时候背景色变为白色或者其他颜色的时候,状态栏颜色不跟随切换颜色有可能会显得难看至极。因此有了上图的效果,其实就是简单的实现了状态栏颜色切换的功能,效果看起来不至于那么尴尬。


首先,我们需要分析,其中需要用到哪些东西呢?



  • 沉浸式状态栏

  • 滑动组件监听


关于沉浸式状态栏,这里推荐使用immersionbar,一款非常不错的轮子。我们只需要将mannifests中主体配置为NoActionBar类型,再根据文档配置好状态栏颜色等属性即可快速得到沉浸式效果:


<style name="Theme.MyApplication" parent="Theme.AppCompat.Light.NoActionBar">

//基础设置
ImmersionBar.with(this)
.navigationBarColor(R.color.color_bg)
.statusBarDarkFont(true, 0.2f)
.autoStatusBarDarkModeEnable(true, 0.2f)//启用自动根据StatusBar颜色调整深色模式与亮式
.autoNavigationBarDarkModeEnable(true, 0.2f)//启用自动根据NavigationBar颜色调整深色式
.init()

//状态栏view
status_bar_view?.let {
ImmersionBar.setStatusBarView(this, it)
}

//xml中状态栏
<View
android:id="@+id/status_bar_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#b8bfff" />

关于滑动监听,我们都知道滑动控件有个监听滑动的方法OnScrollChangeListener,其中返回了Y轴滑动距离的参数。那么,我们可以根据这个参数进行对应的条件判断以达到动态修改状态栏的颜色。


scroll?.setOnScrollChangeListener { _, _, scrollY, _, _ ->
if (scrollY > linTop!!.height) {
if (!isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#ffffff")
)
isChange = true
}
} else {
if (isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#b8bfff")
)
isChange = false
}
}
}

这里判断滑动距离达到紫色视图末端时修改状态栏颜色。因为是在回调方法中,所以这里一旦滑动就在不停触发,所以给了一个私有属性进行不必要的操作,仅当状态改变时且滑动条件满足时才能修改状态栏。当然在这个方法内大家可以发挥自己的想象力做出更多的新花样来。


注意:



  • 滑动监听的这个方法只能在设备6.0及以上才能使用。

  • 需要初始化滑动控件的默认位置,xml中将焦点设置到其父容器中,防止滑动控件不再初始位置。


//初始化位置
scroll?.smoothScrollTo(0, 0)

//xml中设置父view焦点
android:focusable="true"
android:focusableInTouchMode="true"

好了,以上便是滑动控件中实现状态栏切换的简单实现,希望对大家有所帮助。


作者:似曾相识2022
来源:juejin.cn/post/7272229204870561850
收起阅读 »

货拉拉面试:全程八股!被问麻了

今天来看货拉拉 Java 技术岗的面试问题,废话不多说,先看问题。 一面问题 先让介绍项目,超卖问题项目是怎么实现的?有什么改进的想法? 线程池的核心参数? 在秒杀的过程中,比如只有 10 个名额,有 100 个人去抢,页面上需要做一些什么处理? Hash...
继续阅读 »

今天来看货拉拉 Java 技术岗的面试问题,废话不多说,先看问题。


一面问题




  1. 先让介绍项目,超卖问题项目是怎么实现的?有什么改进的想法?

  2. 线程池的核心参数?

  3. 在秒杀的过程中,比如只有 10 个名额,有 100 个人去抢,页面上需要做一些什么处理?

  4. HashSet 了解吗?

  5. HashMap 了解吗?从 0 个 put 20 个数据进去,整个过程是怎么样的?HashMap 扩容机制?是 put 12 个数据之前扩容还是之后扩容?什么时候装红黑树?为什么是 8 的时候转,为什么是 6 的时候退化回链表?

  6. ConcurrenHashMap 了解吗?用到哪些锁?

  7. CAS 原理了解吗?

  8. synchronized 有多少种锁?锁升级。

  9. MySQL 有哪些锁?

  10. 一条 SQL 执行的全流程?

  11. 地址输入 URL 到数据返回页面,整个流程?

  12. 域名服务器寻址?



二面问题




  1. 问了一下项目的锁,问怎么优化?

  2. 怎么进行项目部署的?

  3. 之前搭过最复杂的项目是什么?

  4. 你感觉这种架构有什么好处?为什么要进行微服务拆分?

  5. Nacos 用过吗?

  6. CAP 理论?Base 理论?

  7. MQ 用过吗?

  8. 有什么技术优势?



1.怎么解决超卖问题?


答:超卖问题是一个相对来说,比较经典且相对难处理的问题,解决它可以考虑从以下三方面入手:



  1. 前端初步限制:前端先做最基础的限制处理,只允许用户在一定时间内发送一次抢购请求。

  2. 后端限流:前端的限制只能针对部分普通用户,如果有恶意刷单程序,那么依靠前端是解决不了任何问题的,所以此时就需要后端做限流操作了,而后端的限流又分为以下手段:

    1. IP 限流:限制一个 IP 在一定时间内,只能发送一个请求。此技术实现要点:通过在 Spring Cloud Gateway 中编写自定义全局过滤器来实现 IP 限流。

    2. 接口限流:某个接口每秒只接受一定数量的请求。此技术实现要点:通过 Sentinel 的限流功能来实现。



  3. 排队处理:即时做了以上两步操作,仍然不能防止超卖问题的发生,此时需要使用分布式锁排队处理请求,才能真正的防止超卖问题的发生。此技术实现要点:

    1. 方案一:使用 Lua 脚本 + Redis 实现分布式锁。

    2. 方案二:使用 Redisson 实现分布式锁。





PS:关于这些技术实现细节,例如:Spring Cloud Gateway 全局自定义过滤器的实现、Sentinel 限流功能的实现、分布式锁 Redisson 的实现等,篇幅有限私信获取。



2.CAP 理论和 Base 理论?


CAP 理论


CAP 理论是分布式系统设计中的一个基本原则,它提供了一个思考和权衡一致性、可用性和分区容错性之间关系的框架。
CAP 理论的三个要素如下:



  1. 一致性(Consistency):在分布式系统中的多个副本或节点之间,保持数据的一致性。也就是说,如果有多个客户端并发地读取数据,在任何时间点上,它们都应该能够观察到相同的数据。

  2. 可用性(Availability):系统在任何时间点都能正常响应用户请求,即系统对外提供服务的能力。如果一个系统不能提供响应或响应时间过长,则认为系统不可用。

  3. 分区容忍性(Partition tolerance):指系统在遇到网络分区或节点失效的情况下,仍能够继续工作并保持数据的一致性和可用性。


CAP 理论指出,在分布式系统中,不能同时满足一致性、可用性和分区容错性这三个特性,只能是 CP 或者是 AP。



  • CP:强一致性和分区容错性设计。这样的系统要求保持数据的一致性,并能够容忍分区故障,但可用性较低,例如在分区故障期间无法提供服务。

  • AP:高可用性和分区容错性设计。这样的系统追求高可用性,而对一致性的要求较低。在分区故障期间,它可以继续提供服务,但数据可能会出现部分不一致。


CAP 无法全部满足的原因


CA 或 CAP 要求网络百分之百可以用,并且无延迟,否则在 C 一致性要求下,就必须要拒绝用户的请求,而拒绝了用户的请求就违背了 A 可用性,所以 CA 和 CAP 在分布式环境下是永无无法同时满足的,分布式系统要么是 CP 模式,要么是 AP 模式。


BASE 理论


BASE 理论是对分布式系统中数据的一致性和可用性进行权衡的原则,它是对 CAP 理论的一种补充。
BASE 是指:



  1. 基本可用性(Basically Available):系统保证在出现故障或异常情况下依然能够正常对外提供服务,尽管可能会有一定的性能损失或功能缺失。在分布式系统中,为了保证系统的可用性,有时会牺牲一致性。

  2. 软状态(Soft State):系统中的数据的状态并不是强一致的,而是柔性的。在分布式系统中,由于网络延迟、节点故障等因素,数据可能存在一段时间的不一致。

  3. 最终一致性(Eventually Consistent):系统会保证在一段时间内对数据的访问最终会达到一致的状态。即系统允许数据副本在一段时间内存在不一致的状态,但最终会在某个时间点达到一致。


BASE 理论强调系统的可用性和性能,尽可能保证系统持续提供服务,而不是追求强一致性。在实际应用中,为了降低分布式系统的复杂性和提高性能,可以采用一些方法来实现最终一致性,如版本管理、异步复制等技术手段。



PS:BASE 理论并不是对 CAP 理论的颠覆,而是对分布式系统在某些场景下的设计原则,在具体系统设计中,开发人员需要根据业务需求和场景来权衡和选择适当的一致性和可用性策略。



3.你有什么技术优势?


当面试官问你这个问题时,你可以从以下几个方面回答:



  1. 总结你掌握的技术点:首先,从你所应聘的职位和相关领域出发,总结并列出你的技术专长或专业专长。注意,你讲的这些技术点一定要向面试公司要求的技术点靠拢。

  2. 强调你的技术专长:在列举领域后,强调你在这些领域中的技术专长。你可以提及一些主要技术、框架等方面的技术专长。

  3. 举例说明:提供一些具体的项目案例或工作经验,展示你在技术领域上的实际应用能力。说明你如何使用所掌握的技术解决具体问题、优化系统性能或提升用户体验等。这样可以更加具体地说明你的技术优势,并证明你的技能在实践中是有价值的。

  4. 强调自己“软”优势:向面试官展示你的“软”优势,例如:喜欢专研技术(加上具体的例子,例如每天都有写代码提交到 GitHub)、积极学习和持续成长等能力。同时,强调你在团队合作中的贡献和沟通技巧等其他能力,这些也是技术优势的重要补充。



PS:其他常规的八股问题,可以在我的网站 http://www.javacn.site 找到答案,本文就不再赘述了,大家自己去看吧。



小结


货拉拉解决了日常生活中搬家难的痛点,也是属于某一个细分赛道的龙头企业了,公司不大,但算的上是比较知名的企业。他们公司的面试题并不难,以八股和项目中某个具体问题为主,只要好好准备,拿到他们公司的 Offer 还是比较简单的。


最后:祝大家都能拿到满意的 Offer。


作者:Java中文社群
来源:juejin.cn/post/7289333769236758569
收起阅读 »

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于 如何去判断它们什么时候出...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于



  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑


常见的做法


可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了


if(条件1){
//弹框1
}else if(条件2){
//弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了


if(条件1 && 条件2 && 条件3){
//弹框1
}else if(条件1 && (条件2 || 条件3)){
//弹框2
}else if(条件2 && 条件3){
//弹框3
}else if(....){
....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如



  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题


设计思路


能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


az1.png


定义任务


首先我们先简单定一个任务,以及需要执行的操作


interface SingleJob {
fun handle(): Boolean
fun launch(context: Context, callback: () -> Unit)
}


  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务


实现任务


定义一个TaskJobOne,让它去实现SingleJob


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return true
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了


任务链


首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务


object JobTaskManager {
val jobMap = linkedMapOf(
1 to TaskJobOne(),
2 to TaskJobTwo(),
3 to TaskJobThree()
)
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下



  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程


首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务


var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow


val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
if (job.handle()) {
job.launch(context) {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
} else {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链


MainScope().launch {
JobTaskManager.apply {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}

我们的任务链就完成了,看下效果


a1111.gif


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return false
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

a2222.gif


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了


依赖于外界因素


上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了



  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务
    鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态


const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103


  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行


接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据


interface SingleJob {
......
/**
* 获取执行状态
*/

fun status():Int

/**
* 设置执行状态
*/

fun setStatus(level:Int)

/**
* 设置数据
*/

fun setBundle(bundle: Bundle)
}

更改一下任务的实现


class TaskJobOne : SingleJob {
var flag = JOB_NOT_AVAILABLE
var data: Bundle? = null
override fun handle(): Boolean {
println("start handle job one")
return flag != JOB_CANCELED
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
val type = data?.getString("dialog_type")
AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
override fun setStatus(level: Int) {
if(flag != JOB_COMBINED_BY_NOTHING)
this.flag = level
}
override fun status(): Int = flag

override fun setBundle(bundle: Bundle) {
this.data = bundle
}
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据


fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
}

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据


class MainViewModel : ViewModel(){
val firstApi = flow {
kotlinx.coroutines.delay(1000)
emit("元宵节活动")
}
val secondApi = flow {
kotlinx.coroutines.delay(2000)
emit("端午节活动")
}
val thirdApi = flow {
kotlinx.coroutines.delay(3000)
emit("中秋节活动")
}
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下


val mainViewModel: MainViewModel by lazy {
ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
JobTaskManager.apply {
mainViewModel.firstApi
.zip(mainViewModel.secondApi) { a, b ->
setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", a)
})
setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", b)
})
}.zip(mainViewModel.thirdApi) { _, c ->
setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", c)
})
}.collect {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}
}

运行一下,效果如下


a3333.gif


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题


优化


首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态


/**
* 应用启动就执行任务链
*/

fun loadTask(context: Context) {
judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/

private fun judgeJob(context: Context, cur: Int) {
val job = jobMap[cur]
if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
MainScope().launch {
doJob(context, cur)
}
}
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行


private suspend fun doJob(context: Context, index: Int) {
if (index > jobMap.size) return
val singleJOb = jobMap[index]
callbackFlow {
if (singleJOb?.handle() == true) {
singleJOb.launch(context) {
trySend(index + 1)
}
} else {
trySend(index + 1)
}
awaitClose { }
}.collect {
curLevel = it
judgeJob(context,it)
}
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag


fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点


val thirdApi = flow {
kotlinx.coroutines.delay(5000)
emit("中秋节活动")
}

上层执行任务链的地方也改一下


MainScope().launch {
JobTaskManager.apply {
loadTask(this@MainActivity)
mainViewModel.firstApi.collect{
setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.secondApi.collect{
setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.thirdApi.collect{
setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
}
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


a4444.gif


总结


大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案


作者:Coffeeee
来源:juejin.cn/post/7195336320435601467
收起阅读 »

Android RecyclerView — 实现自动加载更多

在App中,使用列表来显示数据是十分常见的。使用列表来展示数据,最好不要一次加载太多的数据,特别是带图片时,页面渲染的时间会变长,常见的做法是进行分页加载。本文介绍一种无感实现自动加载更多的实现方式。 实现自动加载更多 自动加载更多这个功能,其实就是在滑动列表...
继续阅读 »

在App中,使用列表来显示数据是十分常见的。使用列表来展示数据,最好不要一次加载太多的数据,特别是带图片时,页面渲染的时间会变长,常见的做法是进行分页加载。本文介绍一种无感实现自动加载更多的实现方式。


实现自动加载更多


自动加载更多这个功能,其实就是在滑动列表的过程中加载分页数据,这样在加载完所有分页数据之前就可以不停地滑动列表。


计算刷新临界点


手动加载更多一般是当列表滑动到当前最后一个Item后,再向上拖动RecyclerView控件来触发。不难看出来,最后一个Item就是一般加载更多功能的临界点,当达到临界点之后,继续滑动就加载分页数据。对于自动加载更多这个功能来说,如果使用最后一个Item作为临界点,就无法做到在加载完所有分页数据之前不停地滑动列表。那么自动加载更多这个功能的临界点应该是什么呢?


RecyclerView在手机屏幕上一次可显示的Item数量是有限的,相当于对所有Item进行了分页。当倒数第二页Item的最后一个Item显示在屏幕上时,是一个不错的加载下一分页数据的时机。



  • 获取RecyclerView的可视Item数量


通过LayoutManagerfindLastVisibleItemPosition()findFirstVisibleItemPosition()方法,可以计算出可视Item数量。


private fun calculateVisibleItemCount() {
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 可视Item数量
val visibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
}
}


  • 计算临界点


通过LayoutManagergetItemCount()方法,可以获取Item的总量。Item总量减一再减去可视Item数量就是倒数第二页Item的最后一个Item的位置。然后通过LayoutManagerfindViewByPosition()方法来获取临界点Item控件,当Item未显示时,返回值为null


private fun calculateCriticalPoint() {
(binding.rvExampleDataContainerVertical.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 可视Item数量
val visibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
// 临界点位置
val criticalPointPosition = (linearLayoutManager.itemCount - 1) - visibleItemCount
// 获取临界点Item的控件,未显示时返回null。
val criticalPointItemView = linearLayoutManager.findViewByPosition(criticalPointPosition)
}
}

监听列表滑动


通过RecyclerViewaddOnScrollListener()方法,可以对RecyclerView添加滑动监听。在滑动监听中的回调里,可以对RecyclerView的滑动方向以及是否达到了临界点进行判断,当达到临界点时就可以加载下一页的分页数据。代码如下:


private fun checkLoadMore() {
binding.rvExampleDataContainerVertical.addOnScrollListener(object : RecyclerView.OnScrollListener() {

private var scrollToEnd = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 判断是拖动或者惯性滑动
if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
// 可视Item数量
val visibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
// 临界点位置
val criticalPointPosition = (linearLayoutManager.itemCount - 1) - visibleItemCount
// 获取临界点Item的控件,未显示时返回null。
val criticalPointItemView = linearLayoutManager.findViewByPosition(criticalPointPosition)
// 判断是向着列表尾部滚动,并且临界点已经显示,可以加载更多数据。
if (scrollToEnd && criticalPointItemView != null) {
// 加载更多数据
......
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
scrollToEnd = if (linearLayoutManager.orientation == LinearLayoutManager.VERTICAL) {
// 竖向列表判断向下滑动
dy > 0
} else {
// 横向列表判断向右滑动
dx > 0
}
}
}
})
}

完整演示代码



  • 适配器


class AutoLoadMoreExampleAdapter(private val vertical: Boolean = true) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

private val containerData = ArrayList<String>()

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RecyclerView.ViewHolder {
return if (vertical) {
AutoLoadMoreItemVerticalViewHolder(LayoutAutoLoadMoreExampleItemVerticalBinding.inflate(LayoutInflater.from(parent.context), parent, false))
} else {
AutoLoadMoreItemHorizontalViewHolder(LayoutAutoLoadMoreExampleItemHorizontalBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}

override fun getItemCount(): Int {
return containerData.size
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is AutoLoadMoreItemVerticalViewHolder -> {
holder.itemViewBinding.tvTextContent.text = containerData[position]
}

is AutoLoadMoreItemHorizontalViewHolder -> {
holder.itemViewBinding.tvTextContent.text = containerData[position]
}
}
}

fun setNewData(newData: ArrayList<String>) {
val currentItemCount = itemCount
if (currentItemCount != 0) {
containerData.clear()
notifyItemRangeRemoved(0, currentItemCount)
}
if (newData.isNotEmpty()) {
containerData.addAll(newData)
notifyItemRangeChanged(0, itemCount)
}
}

fun addData(newData: ArrayList<String>) {
val currentItemCount = itemCount
if (newData.isNotEmpty()) {
this.containerData.addAll(newData)
notifyItemRangeChanged(currentItemCount, itemCount)
}
}

class AutoLoadMoreItemVerticalViewHolder(val itemViewBinding: LayoutAutoLoadMoreExampleItemVerticalBinding) : RecyclerView.ViewHolder(itemViewBinding.root)

class AutoLoadMoreItemHorizontalViewHolder(val itemViewBinding: LayoutAutoLoadMoreExampleItemHorizontalBinding) : RecyclerView.ViewHolder(itemViewBinding.root)
}


  • 示例页面


class AutoLoadMoreExampleActivity : AppCompatActivity() {

private val prePageCount = 20

private var verticalRvVisibleItemCount = 0

private val verticalRvAdapter = AutoLoadMoreExampleAdapter()

private val verticalRvScrollListener = object : RecyclerView.OnScrollListener() {

private var scrollToBottom = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 判断是拖动或者惯性滑动
if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
if (verticalRvVisibleItemCount == 0) {
// 获取列表可视Item的数量
verticalRvVisibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
}
// 判断是向着列表尾部滚动,并且临界点已经显示,可以加载更多数据。
if (scrollToBottom && linearLayoutManager.findViewByPosition(linearLayoutManager.itemCount - 1 - verticalRvVisibleItemCount) != null) {
loadData()
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
// 判断列表是向列表尾部滚动
scrollToBottom = dy > 0
}
}

private var horizontalRvVisibleItemCount = 0

private val horizontalRvAdapter = AutoLoadMoreExampleAdapter(false)

private val horizontalRvScrollListener = object : RecyclerView.OnScrollListener() {

private var scrollToEnd = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
(recyclerView.layoutManager as? LinearLayoutManager)?.let { linearLayoutManager ->
// 判断是拖动或者惯性滑动
if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
if (horizontalRvVisibleItemCount == 0) {
// 获取列表可视Item的数量
horizontalRvVisibleItemCount = linearLayoutManager.findLastVisibleItemPosition() - linearLayoutManager.findFirstVisibleItemPosition()
}
// 判断是向着列表尾部滚动,并且临界点已经显示,可以加载更多数据。
if (scrollToEnd && linearLayoutManager.findViewByPosition(linearLayoutManager.itemCount - 1 - horizontalRvVisibleItemCount) != null) {
loadData()
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
// 判断列表是向列表尾部滚动
scrollToEnd = dx > 0
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = LayoutAutoLoadMoreExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.includeTitle.tvTitle.text = "AutoLoadMoreExample"

binding.rvExampleDataContainerVertical.adapter = verticalRvAdapter
binding.rvExampleDataContainerVertical.addOnScrollListener(verticalRvScrollListener)

binding.rvExampleDataContainerHorizontal.adapter = horizontalRvAdapter
binding.rvExampleDataContainerHorizontal.addOnScrollListener(horizontalRvScrollListener)

loadData()
}

fun loadData() {
val init = verticalRvAdapter.itemCount == 0
val start = verticalRvAdapter.itemCount
val end = verticalRvAdapter.itemCount + prePageCount

val testData = ArrayList<String>()
for (index in start until end) {
testData.add("item$index")
}
if (init) {
verticalRvAdapter.setNewData(testData)
horizontalRvAdapter.setNewData(testData)
} else {
verticalRvAdapter.addData(testData)
horizontalRvAdapter.addData(testData)
}
}
}

效果如图:


Screen_recording_202 -middle-original.gif

可以看见,分页设定为每页20条数据,列表可以在滑动中无感的实现加载更多。


示例Demo


演示代码已在示例Demo中添加。


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7294638699417288714
收起阅读 »

RecyclerView 低耦合单选、多选模块实现

前言 需求很简单也很常见,比如有一个数据列表RecyclerView,需要用户去点击选择一个或多个数据。 实现单选的时候往往简单下标记录了事,实现多选的时候就稍微复杂去处理集合和选中。随着项目选中需求增多,不同的地方有了不同的实现,难以维护。 因此本文设计和实...
继续阅读 »

前言


需求很简单也很常见,比如有一个数据列表RecyclerView,需要用户去点击选择一个或多个数据。


实现单选的时候往往简单下标记录了事,实现多选的时候就稍微复杂去处理集合和选中。随着项目选中需求增多,不同的地方有了不同的实现,难以维护。


因此本文设计和实现了简单的选择模块去解决此类需求。


本文实现的选择模块主要有以下特点:



  • 不需要改动Adapter,ViewHolder,Item,低耦合

  • 单选,可监听选择变化,手动设置选择位置,支持配置再次点击取消选择

  • 多选,支持全选,反选等

  • 支持数据变化后记录原选择


项目地址 BindingAdapter


效果


img1.jpg
img5.jpg
img4.jpg
import me.lwb.adapter.select.isItemSelected

class XxxActivity {
private val dataAdapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->
itemBinding.tips.text = item
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}

fun onCreate() {
val selectModule = dataAdapter.setupSingleSelectModule()//单选
val selectModule = dataAdapter.setupMultiSelectModule()//多选

selectModule.doOnSelectChange {

}
//...全选,反选等
}
}

原理


单选


单选的特点:



  1. 用户点击可以选中列表的一个元素 。

  2. 当选择另1个数据会自动取消当前已经选中的,也就是最多选中1个。

  3. 再次点击已经选中的元素取消选中(可配置)。


根据记录选中数据的不同,可以分为下标模式和标识模式,他们各有优缺点。


下标模式


通常情况我们都会这样实现。使用一个记录选中下标的变量selectIndex去标识当前选择,selectIndex=-1表示没有选中任何元素。


原理虽然简单,那么问题来了,变量selectIndex应该放在哪里呢? Adapter?Fragment?Activity?


往往许多人都会选择放在Adapter,觉得数据选中和数据放一起嘛。


实现是实现了,但是往往有更多问题:



  1. 给一个列表增加数据选择功能,需要改造Adapter,侵入性强。

  2. 我要给另外一个列表增加数据选择功能,需要再实现一遍,难复用。

  3. 去除数据选择功能,又需要再改动Adapter,耦合重。


总结起来其实这样实现是不符合单一职责的原则,selectIndex是数据选择功能的数据,Adapter是绑定UI数据的。放在一起改动一方就得牵扯到另外一方。


解决办法就是,单独抽离出选择模块,依赖于Adapter的接口而不是放在Adapter中实现。


得益于BindingAdapter提供的接口,我们首先通过doBeforeBindViewHolder 在绑定时添加Item点击事件的监听,然后切换selectIndex


我们将需要保存的选择数据和行为,单独放在一个模块:


class SingleSelectModule {
var selectIndex: Int
var enableUnselect: Boolean

init {
adapter.doBeforeBindViewHolder { holder, position ->
holder.itemView.setOnClickListener {
toogleSelect(position)
}
}
}

fun toggleSelect(selectedKey: Int) {
selectIndex = if (enableUnselect) {
if (isSelected(selectedKey)) {
INDEX_UNSELECTED //取消选择
} else {
selectedKey //切换选择
}
} else {
selectedKey //切换选择
}
}
//...
}


往往我们需要在onBindViewHolder时判断当前Item是否选中,从而对选中和未选中的Item显示不同的样式。


简单的实现的话可以保存SingleSelectModule引用,然后再onBindViewHolder中获取。


class XxActivity {
var selectModule: SingleSelectModule
val adapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->
val isItemSelected = selectModule.isSelected(pos)
itemBinding.tips.text = item
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}
}

但缺点就是,它又和SingleSelectModule产生了耦合,实际上我们只需要关心当前Item
是否选中即可,要是能给Item加个isItemSelected 属性就好了。


许多的选择方案确实是这么实现的,给Item 添加属性,或者使用Pair<Boolean,Item>去包装,这些方案又造成了一定的侵入性。
我们从另外一个角度,不从Item入手,而是从ViewHolder中去改造,比如这样:


class BindingViewHolder {
var isItemSelected: Boolean
}

ViewHolder加属性比Item更加通用,起码不用每个需要支持选择的列表都去改造Item


但是逻辑上需要注意:真正选中的是Item,而不是ViewHolder,因为ViewHolder
可能会在不同的时机绑定到不同的Item


所以实际上BindingViewHolder.isItemSelected起到一个桥接作用,
原本的onBindViewHolder内容,是通过val isItemSelected = selectModule.isSelected(pos)获取当前Item是否选中,然后再去使用isItemSelected


现在我们将变量加到ViewHolder后,就不用每次去定义变量了。


    val adapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->
this.isItemSelected = selectModule.isSelected(pos)
itemBinding.tips.text = item
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}

同时再把赋值isItemSelected = selectModule.isSelected(pos) 也放入到选择模块中


class SingleSelectModule {

init {
adapter.doBeforeBindViewHolder { holder, position ->
holder.isItemSelected = this.isSelected(pos)
holder.itemView.setOnClickListener {
toogleSelect(position)
}
}
}
}


doBeforeBindViewHolder 可以在监听Adapter的onBindViewHolder,并在其前面执行



最后这里就剩下一个问题了,给BindingViewHolder增加isItemSelected 不是又得改ViewHolder吗。还是造成了侵入性,
后续我们还得增加其他模块,总不能每增加一个模块就改一次ViewHolder吧。


那么如何动态的增加属性?


这里我们直接就想到了通过view.setTag/view.getTag(本质上是SparseArray)不就能实现动态添加属性吗,
同时利用上Kotlin的拓展属性,那么它就成了真的"拓展属性"了:


var BindingViewHolder<*>.isItemSelected: Boolean
set(value) {
itemView.setTag(R.id.binding_adapter_view_holder_tag_selected, value)
}
get() = itemView.getTag(R.id.binding_adapter_view_holder_tag_selected) == true

然后通过引入这个拓展属性import me.lwb.adapter.select.isItemSelected 就能直接在Adapter中访问了,
同理你可以添加任意个拓展属性,并通过doBeforeBindViewHolder来在它们被使用前赋值,这些都不需要改动Adapter或者ViewHolder


import me.lwb.adapter.select.isItemSelected
import me.lwb.adapter.select.isItemSelected2
import me.lwb.adapter.select.isItemSelected3

class XxActivity {
private val dataAdapter =
BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->
//使用isItemSelected isItemSelected2 isItemSelected3

itemBinding.tips.text = item++
itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
}

}

下标模式十分易用,只需一行代码即可setupSingleSelectModule,但是也有一定局限性,就是用户选中的数据是使用下标来记录的,


如果数据下标对应的数据是变化了,就往往不是我们预期的效果,比如[A,B,C,D],用户选择B,此时selectIndex=1,用户刷新数据变成了[D,C,B,A],这时由于selectIndex=1,虽然选择的都是第2个,但是数据变化了,就变成了选择了C


往往那么经常就只能清空选择了。


标识模式


下标模式适用于数据不变,或者变化后清空选中的情况。


标识模式就是记录数据的唯一标识,可以在数据变化后仍然选中对应的数据,一般Item都会有一个唯一Id可以用作标识。


实现和下标模式接近,但是需要实现获取标识的方法,并且判断选中是根据标识是否相同。


class SingleSelectModuleByKey<I : Any> internal constructor(
val adapter: MultiTypeBindingAdapter<I, *>,
val selector: I.() -> Any,
){

fun isSelected(selectedKey: I?): Boolean {
val select = selectedItem
return selectedKey != ITEM_UNSELECTED && select != ITEM_UNSELECTED && selectedKey.selector() == select.selector()
}
}

使用时指定Item的标识:


adapter.setupSingleSelectModuleByKey { it.id }

多选


多选也分为下标模式和标识模式,原理和单选类似


下标模式


存储选中状态从下标变成了下标集合


class MultiSelectModule<I : Any> internal constructor(
val adapter: MultiTypeBindingAdapter<I, *>,
) {
private val mutableSelectedIndexes: MutableSet<Int> = HashSet();
override fun isSelected(selectKey: Int): Boolean {
return selectedIndexes.contains(selectKey)
}
override fun selectItem(selectKey: Int, choose: Boolean) {
if (choose) {
mutableSelectedIndexes.add(selectKey)
} else {
mutableSelectedIndexes.remove(selectKey)
}
notifyItemsChanged()
}
//全选
override fun selectAll() {
mutableSelectedIndexes.clear()
//添加所有索引
for (i in 0 until adapter.itemCount) {
mutableSelectedIndexes.add(i)
}
notifyItemsChanged()
}

//反选
override fun invertSelected() {
val selectStates = BooleanArray(adapter.itemCount) { false }
mutableSelectedIndexes.forEach {
selectStates[it] = true
}
mutableSelectedIndexes.clear()
selectStates.forEachIndexed { index, select ->
if (!select) {
mutableSelectedIndexes.add(index)
}
}
notifyItemsChanged()
}
}


标识模式


存储选中状态从标识变成了标识集合


class SingleSelectModuleByKey<I : Any> internal constructor(
override val adapter: MultiTypeBindingAdapter<I, *>,
val selector: I.() -> Any,
) {
private val mutableSelectedItems: MutableMap<Any, IndexedValue<I>> = HashMap()
override fun isSelected(selectKey: I): Boolean {
return mutableSelectedItems.containsKey(selectKey.selector())
}
override fun selectItem(selectKey: I, choose: Boolean) {
val id = selectKey.selector()
if (choose) {
mutableSelectedItems[id] = IndexedValue(selectKey)
} else {
mutableSelectedItems.remove(id)
}
notifyItemsChanged()
}
//全选
override fun selectAll() {
mutableSelectedItems.clear()
mutableSelectedItems.putAll(adapter.data.mapIndexed { index, it ->
it.selector() to IndexedValue(it, index)
})
notifyItemsChanged()
}
//反选
override fun invertSelected() {
val other = adapter.data
.asSequence()
.filter { it !in mutableSelectedItems }
.mapIndexed { index, it -> it.selector() to IndexedValue(it, index) }
.toList()

mutableSelectedItems.clear()
mutableSelectedItems.putAll(other)

notifyItemsChanged()
}
}

使用上也是类似的


val selectModule = dataAdapter.setupMultiSelectModule()
val selectModule = dataAdapter.setupMultiSelectModuleByKey()

总结


本文实现了在RecyclerView中使用的独立的单选,多选模块,有下标模式标识模式基本能满足项目中的需求。
利用BindingAdapter提供的接口,使得添加选择模块几乎是拔插式的。
同时,由于RadioGr0upTabLayout更新数据麻烦,需要重写removeadd。因此许多情况下RecyclerView也可以代替RadioGr0upTabLayout使用


本文的实现和Demo均可在项目中找到。


项目地址 BindingAdapter


作者:丨小夕
来源:juejin.cn/post/7246657502842077245
收起阅读 »

Android 使用AIDL传输超大型文件

最近在写车载Android的第5篇视频教程「AIDL的实践与封装」时,遇到一个有意思的问题,能不能通过AIDL传输超过 1M 以上的文件? 我们先不细究,为什么要用AIDL传递大文件,单纯从技术的角度考虑能不能实现。众所周知,AIDL是一种基于Binder实现...
继续阅读 »

最近在写车载Android的第5篇视频教程「AIDL的实践与封装」时,遇到一个有意思的问题,能不能通过AIDL传输超过 1M 以上的文件?


我们先不细究,为什么要用AIDL传递大文件,单纯从技术的角度考虑能不能实现。众所周知,AIDL是一种基于Binder实现的跨进程调用方案,Binder 对传输数据大小有限制,传输超过 1M 的文件就会报 android.os.TransactionTooLargeException 异常。


如果文件相对比较小,还可以将文件分片,大不了多调用几次AIDL接口,但是当遇到大型文件或超大型文件时,这种方法就显得耗时又费力。好在,Android 系统提供了现成的解决方案,其中一种解决办法是,使用AIDL传递文件描述符ParcelFileDescriptor,来实现超大型文件的跨进程传输。


ParcelFileDescriptor


ParcelFileDescriptor 是一个实现了 Parcelable 接口的类,它封装了一个文件描述符 (FileDescriptor),可以通过 Binder 将它传递给其他进程,从而实现跨进程访问文件或网络套接字。ParcelFileDescriptor 也可以用来创建管道 (pipe),用于进程间的数据流传输。


ParcelFileDescriptor 的具体用法有以下几种:




  • 通过 ParcelFileDescriptor.createPipe() 方法创建一对 ParcelFileDescriptor 对象,分别用于读写管道中的数据,实现进程间的数据流传输。




  • 通过 ParcelFileDescriptor.fromSocket() 方法将一个网络套接字 (Socket)转换为一个 ParcelFileDescriptor 对象,然后通过 Binder 将它传递给其他进程,实现跨进程访问网络套接字。




  • 通过 ParcelFileDescriptor.open() 方法打开一个文件,并返回一个 ParcelFileDescriptor 对象,然后通过 Binder 将它传递给其他进程,实现跨进程访问文件。




  • 通过 ParcelFileDescriptor.close() 方法关闭一个 ParcelFileDescriptor 对象,释放其占用的资源。




ParcelFileDescriptor.createPipe()和ParcelFileDescriptor.open() 都可以实现,跨进程文件传输,接下来我们会分别演示。


实践



  • 第一步,定义AIDL接口


interface IOptions {
void transactFileDescriptor(in ParcelFileDescriptor pfd);
}


  • 第二步,在「传输方」使用ParcelFileDescriptor.open实现文件发送


private void transferData() {
try {
// file.iso 是要传输的文件,位于app的缓存目录下,约3.5GB
ParcelFileDescriptor fileDescriptor = ParcelFileDescriptor.open(new File(getCacheDir(), "file.iso"), ParcelFileDescriptor.MODE_READ_ONLY);
// 调用AIDL接口,将文件描述符的读端 传递给 接收方
options.transactFileDescriptor(fileDescriptor);
fileDescriptor.close();

} catch (Exception e) {
e.printStackTrace();
}
}


  • 或,在「传输方」使用ParcelFileDescriptor.createPipe实现文件发送


ParcelFileDescriptor.createPipe 方法会返回一个数组,数组中的第一个元素是管道的读端,第二个元素是管道的写端。


使用时,我们先将「读端-文件描述符」使用AIDL发给「接收端」,然后将文件流写入「写端」的管道即可。


    private void transferData() {
try {
/******** 下面的方法也可以实现文件传输,「接收端」不需要任何修改,原理是一样的 ********/
// createReliablePipe 创建一个管道,返回一个 ParcelFileDescriptor 数组,
// 数组中的第一个元素是管道的读端,
// 第二个元素是管道的写端
ParcelFileDescriptor[] pfds = ParcelFileDescriptor.createReliablePipe();
ParcelFileDescriptor pfdRead = pfds[0];
// 调用AIDL接口,将管道的读端传递给 接收端
options.transactFileDescriptor(pfdRead);
ParcelFileDescriptor pfdWrite = pfds[1];
// 将文件写入到管道中
byte[] buffer = new byte[1024];
int len;
try (
// file.iso 是要传输的文件,位于app的缓存目录下
FileInputStream inputStream = new FileInputStream(new File(getCacheDir(), "file.iso"));
ParcelFileDescriptor.AutoCloseOutputStream autoCloseOutputStream = new ParcelFileDescriptor.AutoCloseOutputStream(pfdWrite);
) {
while ((len = inputStream.read(buffer)) != -1) {
autoCloseOutputStream.write(buffer, 0, len);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}


注意,管道写入的文件流 总量限制在64KB,所以「接收方」要及时将文件从管道中读出,否则「传输方」的写入操作会一直阻塞。




  • 第三步,在「接收方」读取文件流并保存到本地


private final IOptions.Stub options = new IOptions.Stub() {
@Override
public void transactFileDescriptor(ParcelFileDescriptor pfd) {
Log.i(TAG, "transactFileDescriptor: " + Thread.currentThread().getName());
Log.i(TAG, "transactFileDescriptor: calling pid:" + Binder.getCallingPid() + " calling uid:" + Binder.getCallingUid());
File file = new File(getCacheDir(), "file.iso");
try (
ParcelFileDescriptor.AutoCloseInputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pfd);
) {
file.delete();
file.createNewFile();
FileOutputStream stream = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int len;
// 将inputStream中的数据写入到file中
while ((len = inputStream.read(buffer)) != -1) {
stream.write(buffer, 0, len);
}
stream.close();
pfd.close();
} catch (IOException e) {
e.printStackTrace();
}
}
};


  • 运行程序


在程序运行之前,需要将一个大型文件放置到client app的缓存目录下,用于测试。目录地址:data/data/com.example.client/cache。



注意:如果使用模拟器测试,模拟器的硬盘要预留 3.5GB * 2 的闲置空间。



将程序运行起来,可以发现,3.5GB 的 file.iso 顺利传输到了Server端。



大文件是可以传输了,那么使用这种方式会很耗费内存吗?我们继续在文件传输时,查看一下内存占用的情况,如下所示:



  • 传输方-Client,内存使用情况




  • 接收方-Server,内存使用情况



从Android Studio Profiler给出的内存取样数据可以看出,无论是传输方还是接收方的内存占用都非常的克制、平缓。


总结


在编写本文之前,我在掘金上还看到了另一篇文章:一道面试题:使用AIDL实现跨进程传输一个2M大小的文件 - 掘金


该文章与本文类似,都是使用AIDL向接收端传输ParcelFileDescriptor,不过该文中使用共享内存MemoryFile构造出ParcelFileDescriptor,MemoryFile的创建需要使用反射,对于使用MemoryFile映射超大型文件是否会导致内存占用过大的问题,我个人没有尝试,欢迎有兴趣的朋友进行实践。


总得来说 ParcelFileDescriptor 和 MemoryFile 的区别有以下几点:



  • ParcelFileDescriptor 是一个封装了文件描述符的类,可以通过 Binder 传递给其他进程,实现跨进程访问文件或网络套接字。MemoryFile 是一个封装了匿名共享内存的类,可以通过反射获取其文件描述符,然后通过 Binder 传递给其他进程,实现跨进程访问共享内存。

  • ParcelFileDescriptor 可以用来打开任意的文件或网络套接字,而 MemoryFile 只能用来创建固定大小的共享内存。

  • ParcelFileDescriptor 可以通过 ParcelFileDescriptor.createPipe() 方法创建一对 ParcelFileDescriptor 对象,分别用于读写管道中的数据,实现进程间的数据流传输。MemoryFile 没有这样的方法,但可以通过 MemoryFile.getInputStream() 和 MemoryFile.getOutputStream() 方法获取输入输出流,实现进程内的数据流传输。


在其他领域的应用方面,ParcelFileDescriptor 和 MemoryFile也有着性能上的差异,主要取决于两个方面:



  • 数据的大小和类型。


如果数据是大型的文件或网络套接字,那么使用 ParcelFileDescriptor 可能更合适,因为它可以直接传递文件描述符,而不需要复制数据。如果数据是小型的内存块,那么使用 MemoryFile 可能更合适,因为它可以直接映射到物理内存,而不需要打开文件或网络套接字。



  • 数据的访问方式。


如果数据是需要频繁读写的,那么使用 MemoryFile 可能更合适,因为它可以提供输入输出流,实现进程内的数据流传输。如果数据是只需要一次性读取的,那么使用 ParcelFileDescriptor 可能更合适,因为它可以通过 ParcelFileDescriptor.createPipe() 方法创建一对 ParcelFileDescriptor 对象,分别用于读写管道中的数据,实现进程间的数据流传输。


本文示例demo的地址:github.com/linxu-link/…


好了,以上就是本文的所有内容了,感谢你的阅读,希望对你有所帮助。


作者:林栩link
来源:juejin.cn/post/7218615271384088633
收起阅读 »

吵上热搜第一,华为和小鹏在争什么?

来源 | 伯虎财经(bohuFN) 作者 | 陈平安 大概没有人能想到,这场有关AEB技术的讨论会引起如此之大的风波,以至于不仅仅是行业内在热切讨论,甚至于直接冲上了微博热搜第一。 事情的起因源于小鹏汽车董事长和小鹏在一期采访里表达了对AEB技术的看法::“...
继续阅读 »


来源 | 伯虎财经(bohuFN)


作者 | 陈平安



大概没有人能想到,这场有关AEB技术的讨论会引起如此之大的风波,以至于不仅仅是行业内在热切讨论,甚至于直接冲上了微博热搜第一。


事情的起因源于小鹏汽车董事长和小鹏在一期采访里表达了对AEB技术的看法::“友商讲了AEB(汽车自动紧急制动系统),我认为99%是假的,它就是造假,那些宣传都不是公司官方发布的,全是来自小视频。我们的人也去问了,它的AEB根本不能开,路上误刹车的情况太多了。”


虽然这番言论并没有直接点出友商的品牌,但是AEB是华为智能汽车解决方案BU CEO余承东在在新款问界 M7 发布会上专门提到的竞争力之一:“华为 ADS2.0 系统的 AEB 能力进一步提高,前向刹停速度已经从上海车展期间的 60km/h 提升至 90km/h。”


很快余承东便在朋友圈给出回应,称“连AEB是什么,居然有车企的一把手还根本没有搞懂呢!”。



不仅仅是两家大佬,其他品牌的掌门人包括腾势销售事业部总经理赵长江、哪吒汽车CEO张勇也纷纷表达了自己的看法。


那么到底什么是AEB,为什么小鹏和问界会因为这个技术激烈讨论?


01 到底什么是AEB


到目前为止讨论已经走向尾声,而敲响结束钟声的,是昨晚车媒懂车帝的夜间 AEB 实测。


AEB是Automatic Emergency Braking的缩写,中文翻译是自动紧急制动。这项技术的好处是,它可以让车在符合一定条件的工况下,识别到碰撞危险并且接管车辆以紧急刹车,从而避免碰撞或减轻碰撞后果。


简单来说,就是能让你在面对碰撞来不及踩下制动踏板时,汽车也能自动制动,从而避免碰撞。


从实测的结果来看,搭载了问界ADS 2.0 系统的问界 M5 和阿维塔 11的表现要明显优于小鹏的两款车型——G6和G9。比如,在面对静态假车时,问界 M5 和阿维塔 11 都能在80km/h时完成刹停,小鹏的车型则是40km/h ,当面对静态两轮电动车时,问界 M5 和阿维塔 11则是能在 60km/h时刹车,小鹏 G6 则是30km/h。


事实上,在卖点宣传上,高速行驶时做到及时刹停是相当重要的一部分。不仅仅是问界新车M7,腾势的赵长江日前也发文表示,腾势现在可以做到60km/h的状态下启动AEB,以后的高阶智能驾驶版本可以实现80km/h及以上。


不过,AEB真的是自动刹停速度越高,就越好吗?


持相反观点的人还不少。何小鹏今早就在微博给出了回应。何小鹏的核心观点是:首先高速AEB存在安全隐患和体验的问题,当前要做的是优化智能辅助驾驶能力,降低误触率;其次在有人驾驶的主动安全策略方面,要更加谨慎。



事实上,AEB并不是新能源汽车独占的优势,而是现代汽车的一项基本配置。根据乘联会数据,截止今年年中,乘用车配置AEB功能占比为49.5%,其中新能源车配置该功能占比已达到55.8%。


一些汽车测试工程师表示,AEB建立在雷射雷达、无线电波雷达以及影像辨识系统上,工作原理是经过雷达测距、识别,根据距离和车速进行运算得出结果并相应。而影响其结果的因素有很多,比如系统的误判或者极端天气等。此前,业内发生多起的幽灵刹车也是产生于此。而这种误判在高速行驶的状态下,带来的安全风险会更大。


哪吒汽车CEO张勇也在微博表示,AEB场景的复杂性太高,“要做得好难度确实很高,需要极大里程和样本量的测试”。


因此能否在更高速的情况下完成刹停并不是衡量AEB好坏的标准,如何用更好的智能辅助驾驶去帮助驾驶员进行预先制动,躲避危险场景才是更好的选择。


02 争夺“智能驾驶”的标签


无论是华为加持下的问界,还是小鹏,都刚刚从生死边缘走回来。在刚刚过去的10月,小鹏交付新车20002台,环比增长31%,同比增长292%。而在整个三季度,小鹏的总交付量为40008辆汽车。


无独有偶的是,10月也是问界爆发的月份。10月份问界系列交付了12700辆汽车,其中新款问界M7贡献了绝大部分的10547辆。而在今年8月,问界的全系销量也仅为5018辆。不仅如此,根据问界的官方消息,新款问界M7上市50天累计大定突破80000辆。


如果要探究两家销量起死回生的秘诀,智能驾驶一定是绕不过的关键。无论是小鹏还是华为,都把自动驾驶作为一个重要的卖点,在宣传上,两家也一直在暗暗较劲。比如在发布ADS 2.0时,余承东用他一如既往的风格表示:“(智能驾驶)和新势力、特斯拉相比,我们遥遥领先”。


何小鹏则在接受采访时表示:“我们有的人负责吆喝,让大家关注起来。有些人负责干活,把这些东西干好,再把客户拿过来,我觉得这个配合是天作之合。”


在业内,这两位也确实是毫无争议的第一梯队。今年在智能驾驶能力的比拼上,“开城”成为了最主要的比较维度之一。何小鹏在今年的1024 小鹏汽车科技日上介绍,将于 11 月底在 25 个城市开放,紧接着在 12 月底扩大至 50 个城市。


余承东表示,华为高阶智能驾驶系统将在 2023 年 12 月于全国开放。


从市场的表现来看,智能驾驶也已经在事实上成为了消费者购车的一个重要影响因素。根据博主孙少军的分享,无论是小鹏还是问界,智驾的选装率都超过了六成。


在政策端,据HiEV报道,智能网联汽车准入试点通知最快将在11月发布,这或许意味着国家层面的自动驾驶L3标准即将到来。


各家车企也都在加大对于智能驾驶的投入。例如蔚来宣布10月起要把增强领航辅助NOP+的使用场景从高速进入城区,预计明年一季度,将累计开通城区领航路线里程20万公里;二季度,将累计开通城区领航路线里程40万公里。


理想则表示公司此前对智驾的重视度不够,应该提前半年全力投入,而不是今年年初才开始:“账上趴着 700 多亿的现金,要把它转化成持续的竞争力。”


在这个背景下,这两家头部企业需要迅速为自己和智能驾驶划上等号。就像比亚迪通过迅速吃下“油转电”市场完成市场认知从而滚起规模一样,在智驾全面提速的时代,即便是领先者华为和小鹏,也需要加一脚油门了。


*文章封面首图及配图,版权归版权所有人所有。若版权者认为其作品不宜供大家浏览或不应无偿使用,请及时联系我们,本平台将立即更正。


作者:用户5117553812944
来源:juejin.cn/post/7299016228579377202
收起阅读 »

如何优雅的合并两个对象

提出需求 相信很多时候,你可能有这样的需求,比如A对象的属性1、属性2是你需要的,其余属性是空值,而B对象的属性3、属性4是你需要的,其余属性是空值,那么如何将A对象的属性与B对象的属性结合呢? 思路解析 要实现将两个对象的属性结合,需要用到反射技术,遍历每个...
继续阅读 »

提出需求


相信很多时候,你可能有这样的需求,比如A对象的属性1、属性2是你需要的,其余属性是空值,而B对象的属性3、属性4是你需要的,其余属性是空值,那么如何将A对象的属性与B对象的属性结合呢?


思路解析


要实现将两个对象的属性结合,需要用到反射技术,遍历每个对象的属性,将非null的属性复制给null的属性,最后实现合并属性的效果。


常用的复制对象属性的方法(传统方案)


使用Java Bean Utils,这是Apache Commons BeanUtils库中的一个工具类,使用此工具类,则需要自己去实现判断属性的业务逻辑。


这个工具类有两个复制属性的方法,copyProperties和copyProperty。


static voidcopyProperties(Object dest, Object orig)Copy property values from the origin bean to the destination bean for all cases where the property names are the same.
static voidcopyProperty(Object bean, String name, Object value)Copy the specified property value to the specified destination bean, performing any type conversion that is required.

copyProperties方法会将orig(第二个对象)的属性复制给dest(第二个对象),这种复制是完全复制,会完全覆盖dest的属性。


copyProperty方法将复制value给bean的特定属性。


hutool合并对象的方案


Hutool是一个Java工具包类库,对文件、流、加密解密、转码、正则、线程、XML等JDK方法进行封装,组成各种Util工具类。


hutool常常被广大spring程序员唾弃,抱怨其在项目中的稳定性,bug问题。但我认为,hutool是一个在不断迭代和维护的库,虽然是国内程序员开发的,但是其中很多功能仍然是简单好用的,不能够以偏概全的全面否定它。


回归正题,下面做详细的说明


pom导入合并对象所必须的hutool库


<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.8.16</version>
</dependency>

方法解释



  • cn.hutool.core.bean.BeanUtil


static <T> TcopyProperties(Object source, Class<T> tClass, String... ignoreProperties)按照Bean对象属性创建对应的Class对象,并忽略某些属性
static voidcopyProperties(Object source, Object target, boolean ignoreCase)复制Bean对象属性
static voidcopyProperties(Object source, Object target, CopyOptions copyOptions)复制Bean对象属性 限制类用于限制拷贝的属性,例如一个类我只想复制其父类的一些属性,就可以将editable设置为父类
static voidcopyProperties(Object source, Object target, String... ignoreProperties)复制Bean对象属性 限制类用于限制拷贝的属性,例如一个类我只想复制其父类的一些属性,就可以将editable设置为父类

hutool的复制对象方法copyProperties有4个重载类型,在apache common beanutils的基础上,增加了一些复制选项,这些选项是非常实用的,比如忽略大小写、忽略某些属性、某些特定的复制选项等。


copyProperties(Object source, Object target, CopyOptions copyOptions)


因为我实际用了copyProperties(Object source, Object target, CopyOptions copyOptions),重点讲解此方法的传入参数。


CopyOptions包括以下的一些属性或选项,可以根据你的项目需求来选择


限定符和类型字段和说明
protected TypeConverterconverter自定义类型转换器,默认使用全局万能转换器转换
protected Class<?>editable限制的类或接口,必须为目标对象的实现接口或父类,用于限制拷贝的属性,例如一个类我只想复制其父类的一些属性,就可以将editable设置为父类 如果目标对象是Map,源对象是Bean,则作用于源对象上
protected BiFunction<String,Object,Object>fieldValueEditor字段属性值编辑器,用于自定义属性值转换规则,例如null转""等
protected booleanignoreCase是否忽略字段大小写
protected booleanignoreError是否忽略字段注入错误
protected booleanignoreNullValue是否忽略空值,当源对象的值为null时,true: 忽略而不注入此值,false: 注入null
protected booleanoverride是否覆盖目标值,如果不覆盖,会先读取目标对象的值,非null则写,否则忽略。
protected booleantransientSupport是否支持transient关键字修饰和@Transient注解,如果支持,被修饰的字段或方法对应的字段将被忽略。

自己写的合并工具类


该工具类仅供参考,可以根据自己情况调整


public class MergeUtil {
private static final CopyOptions options = CopyOptions.create().setIgnoreNullValue(true).setOverride(false);
private static final CopyOptions optionsAllowOverride = CopyOptions.create().setIgnoreNullValue(true).setOverride(true);

//将sourceBean中的属性合并到tagetBean,忽略Null值,非Null值不允许覆盖
public static Object merge(Object sourceBean, Object targetBean) {
BeanUtil.copyProperties(sourceBean, targetBean, options);

return targetBean;
}

//将sourceBean中的属性合并到tagetBean,忽略Null值,非Null值允许覆盖
public static Object mergeAllowOverride(Object sourceBean, Object targetBean) {
BeanUtil.copyProperties(sourceBean, targetBean, optionsAllowOverride);

return targetBean;
}
}

作者:XiaoJi
来源:juejin.cn/post/7290475242274258978
收起阅读 »

人需借以虚名而成事

前言 自从我看冯唐老师推荐的《资治通鉴》之后,对历史有那么一丢丢的兴趣,直到在短视频里面刷到《百家讲坛》里面几位老师的讲解片段,以及跟自己经历结合,瞬间满满的共鸣,是啊时代一直在变化,而里面的为人处事、社会规则凡此种种却从来没有太大变化,这就是我们需要去学习...
继续阅读 »

bcf7ea99ded21c1a1ac9a1d85b59724c.jpeg


前言




自从我看冯唐老师推荐的《资治通鉴》之后,对历史有那么一丢丢的兴趣,直到在短视频里面刷到《百家讲坛》里面几位老师的讲解片段,以及跟自己经历结合,瞬间满满的共鸣,是啊时代一直在变化,而里面的为人处事、社会规则凡此种种却从来没有太大变化,这就是我们需要去学习历史的原因。


所以有了以下感慨。



<<百家讲坛>> 不愧是经典,整本历史歪歪斜斜写着两字:权谋,谋事、谋人,加上不确定因素,天时地利人和,最终变成命运。
人的一生就像书中一行字轻描淡写,绘成一笔~



人需借以虚名而成事




这里解释一下,人需要倚靠外部的这些名号、头衔从而能有比较大的作为。为什么这么讲呢,听我细细道来~


人生需要四行


这是王立群老师讲的,人生要想有点成就需要四行,首先你自己行,你自身本身够硬,能够做好事,其次是有人说你行,从古代的推荐机制,比如说优秀的人推荐其他优秀的人才对不对,李白的大名远扬,唐玄宗在公主引荐之下见了皇帝,第三个说你行的人要行,推荐你的那个人本身能力要强,不然没有说服力,第四是身体行,很多机会是熬出来的,比如司马懿伺候了3位君主,兢兢业业,最终才能完成自己的大业。


何为借以虚名?


1、刘备的例子,他在比较年长的时候还无法实现自己的基业,寄人篱下,他登场的时候是怎样的呢,名号叫刘皇叔,翻开长长的祖谱,八辈子打不到杆哈哈哈,不过因为当时被曹操挟持,所以需要有汉族帮忙自己,所以顺理成章的提拔上来。


2、项羽出生在一个贵族家庭。他的父亲项梁是楚国将领,曾经在楚汉战争中和刘邦一起作战。而他的母亲也是楚国的贵族,是楚王的女儿。


相比之下,刘邦的出身则比较低微。他的祖父是一个农民,父亲是一个小县官。他自己也曾经当过一个普通的县尉,但因为功绩不够而被罢免。


尽管项羽出身高贵,但他的性格比较豁达,喜欢豪情壮志和享乐人生,行事大胆不拘小节。刘邦则更加谨慎稳重,注重政治规划和长远发展。


3、王立群老师讲述过他自身的故事,小学的时候成绩优秀得到保送,后面名额被学校给了别人,自己去了私立的学校,半工半读,到了大学还是遇到了同样的遭遇,后面就到比较好的大学教书,但是有一天校长跟他讲他的成绩还是出身问题需要去另一所低一点的学校教书,他当时就哭了,命运比较坎坷的。


4、工作中,我们常常会听到,某位领导自我介绍某某大厂经历,还有某某大学毕业,还有当你投一些大厂的核心部门的时候,都会对你学历进行考核。


所以我们从上面3个例子来看,人生确实正如王立群老师讲的,需要四行,当你自己不行的时候,即使有机会也会最终流失掉;当没有推荐你的时候,也会埋没在茫茫人海中,并不是说是金子总会发光,现实是伯乐更加重要;当说你行的人没有能力的时候也不行,名牌大学、大厂经历也是这样,通过一个有名声的物体从而表示你也可以,也很厉害哈哈;最后身体是本钱,这个就不用过多的讲解了。


读历史的目的,从前人的故事、经历中,学习到点什么,化为自身的经验、阅历。


我发现现在很多人都喜欢出去玩,说的开阔视野,扩展见识,在我看来读历史才是真正的拓宽自己的眼界,原来几百年前也有人跟我一样遇到同样的问题,也有那些胸怀大志的三国名将郁郁不得志。


历史是一本书籍,它可以很薄,几句话、几行字就概括了一个朝代,它也可以很厚,厚到整个朝代的背景,比如气候情况、经济情况、外交情况、内政情况,各个出名的人物事迹,那岂是几页纸能讲得完的,是多么的厚重。


如果人不能借以虚名怎么做事?


修炼两件事情




同样百家讲坛也给出了一个比较好的见解,一个人想要成事,需要两个明,一个是高明、一是精明。高明是你有足够的眼光去发现,发现机会也好,发现危机也好,往往人生的成就需要这种高瞻远瞩的;另一个精明,是指在做事上打磨,细心主动,王阳明讲的知行合一,里面很关键的一点就是事上练,方法论在实战中去验证总结。


心态上练




李连杰在采访中说了这么一个耐人寻味的故事,他发现不管是有钱的人还是没有钱的人都会生气,比如一些老板在为几百万损失破口大骂,也有人因为几万的丢失是否生气,也有人丢了几块钱很愤怒。他觉得人都会愤怒,只是那个导致他愤怒的级别不一样而已。


他早年给自己定目标,发现当达到目标之后,发现还是有人比自己厉害,所以即使达到目标也没有想象快乐;即使有钱人无法解决痛苦的问题,比如生老病死。


所以人生意义在哪里?


我认为如果你奔着成事的想法,多修炼上面两件事,如果没有发展机会,不如躺平,积累自己的能力,就像道德经里面的无为而为,而心态的修炼是人生终极修炼,因为生死才是人生最大的难关,人应该怎么脱离时间束缚,不受衰老影响。


恰恰历史给了我们启示,要有所作为,能够为人类历史上留下精神粮食,做出浓厚贡献,就像我们都没有见过孔子、老子吧,但是他们的精神至今都在影响着我们。


作者:大鸡腿同学
来源:juejin.cn/post/7252251628090490917
收起阅读 »

仿写el-upload组件,彻底搞懂文件上传

web
用了那么久的Upload组件,你知道是怎么实现的么,今天就来仿写一个饿了么el-upload vue组件,彻底搞懂前端的文件上传相关知识! 要实现的props 参数说明action必选参数,上传的地址headers设置上传的请求头部multiple是否支持多选...
继续阅读 »

用了那么久的Upload组件,你知道是怎么实现的么,今天就来仿写一个饿了么el-upload vue组件,彻底搞懂前端的文件上传相关知识!


要实现的props


参数说明
action必选参数,上传的地址
headers设置上传的请求头部
multiple是否支持多选文件
data上传时附带的额外参数
name上传的文件字段名
with-credentials支持发送 cookie 凭证信息
show-file-list是否显示已上传文件列表
drag是否启用拖拽上传
accept接受上传的文件类型
on-preview点击文件列表中已上传的文件时的钩子
on-remove文件列表移除文件时的钩子
on-success文件上传成功时的钩子
on-error文件上传失败时的钩子
on-progress文件上传时的钩子
on-change添加文件时被调用
before-upload上传文件之前的钩子,参数为上传的文件,若返回 false 或者返回 Promise 且被 reject,则停止上传。
before-remove删除文件之前的钩子,参数为上传的文件和文件列表,若返回 false 或者返回 Promise 且被 reject,则停止删除。
list-type文件列表的类型
auto-upload是否在选取文件后立即进行上传
file-list上传的文件列表, 例如: [{name: 'food.jpg', url: 'xxx.cdn.com/xxx.jpg'}]
limit最大允许上传个数
on-exceed文件超出个数限制时的钩子

参考:element.eleme.cn/#/zh-CN/com…


这里面有几个重要的点:



  1. input file 的美化

  2. 多选

  3. 拖拽


一个个实现


创建upload组件文件


src/components/upload/index.vue


<template></template>
<script setup>
  // 属性太多,把props单独放一个文件引入进来
  import property from './props'
  const props = defineProps(property)
</script>
<style></style>

./props.js


export default {
  action: {
    typeString
  },
  headers: {
    typeObject,
    default: {}
  },
  multiple: {
    typeBoolean,
    defaultfalse
  },
  data: {
    typeObject,
    default: {}
  },
  name: {
    typeString,
    default'file'
  },
  'with-credentials': {
    typeBoolean,
    defaultfalse
  },
  'show-file-list': {
    typeBoolean,
    defaulttrue,
  },
  drag: {
    typeBoolean,
    defaultfalse
  },
  accept: {
    typeString,
    default''
  },
  'list-type': {
    typeString,
    default'text' // text、picture、picture-card
  },
  'auto-upload': {
    typeBoolean,
    defaulttrue
  },
  'file-list': {
    typeArray,
    default: []
  },
  disabled: {
    typeBoolean,
    defaultfalse
  },
  limit: {
    typeNumber,
    defaultInfinity
  },
  'before-upload': {
    typeFunction,
    default() => {
      return true
    }
  },
  'before-remove': {
    typeFunction,
    default() => {
      return true
    }
  }

具体的编写upload组件代码


1. 文件上传按钮的样式


我们都知道,<input type="file">的默认样式是这样的: 很丑,并且无法改变其样式。


解决办法:可以把input隐藏,重新写个按钮点击来触发input的文件选择。


<template>
  <input 
     type="file" 
     id="file" 
     @change="handleChange"
  >

  <button 
     class="upload-btn" 
     @click="choose"
  >

    点击上传
  </button>
</template>
<script setup>
  // 触发选择文件
  const choose = () => {
    document.querySelector('#file').click()
  }
  // input选择文件回调
  const handleChange = (event) => {
    files = Array.from(event.target.files)
    console.log('[ files ] >', files)
  }
</script>
<style scoped>
  #file {
    display: none;
  }
  .upload-btn {
    border: none;
    background-color#07c160;
    color#fff;
    padding6px 10px;
    cursor: pointer;
  }
</style>

效果:



这样也是可以调起文件选择框,并触发input的onchange事件。



2. 多选


直接在input上加一个Booelan属性multiple,根据props中的值动态设置


顺便把accept属性也加上


<template>
  <input 
     type="file" 
     id="file" 
     :multiple="multiple"
     :accept="accept"
     @change="handleChange"
  >
</template>

3. 拖拽


准备一个接收拖拽文件的区域,props传drag=true就用拖拽,否则就使用input上传。


<template>
  <input 
    type="file" 
    id="file" 
    :multiple="multiple"
    :accept="accept"
    @change="handleChange"
  >
  <button 
     class="upload-btn" 
     v-if="!drag" 
     @click="choose"
  >
    点击上传
  </button>
  <div 
    v-else 
    class="drag-box" 
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="handleDrop"
    @click="choose"
    :class="{'dragging': isDragging}"
  >
    将文件拖到此处,或<span>点击上传</span>
  </div>
</template>

dragging用来拖拽鼠标进入时改变样式


<script setup>
  const isDragging = ref(false)
  // 拖放进入目标区域
  const handleDragOver = (event) => {
    event.preventDefault()
    isDragging.value = true
  }
  const handleDragLeave = (event) => {
    isDragging.value = false
  }
  let files = []
  // 拖拽放置
  const handleDrop = (event) => {
    event.preventDefault()
    isDragging.value = false
    files = Array.from(event.dataTransfer.files);
    console.log(files);
  }
</script>

.drag-box {
    width240px;
    height150px;
    line-height150px;
    text-align: center;
    border1px dashed #ddd;
    cursor: pointer;
    border-radius8px;
  }
  .drag-box:hover {
    border-color: cornflowerblue;
  }
  .drag-box.dragging {
    background-colorrgb(131161216, .2);
    border-color: cornflowerblue;
  }
  .drag-box span {
    color: cornflowerblue;
  }

跟使用input上传效果一样


4. 上传到服务器


并实现on-xxx钩子函数


  const emit = defineEmits()
  const fileList = ref([])
  let files = []

  // 拖拽放置
  const handleDrop = (event) => {
    event.preventDefault()
    isDragging.value = false
    files = Array.from(event.dataTransfer.files);
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  // input选择文件回调
  const handleChange = (event) => {
    files = Array.from(event.target.files)
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  const handleBeforeUpload = (files) => {
    if (files.length > props.limit - fileList.value.length) {
      console.error(`当前限制选择 ${props.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.value.length} 个文件`)
      emit('on-exceed', files, toRaw(fileList.value))
      return
    }
    // 可以把锁哥文件放到一个formData中一起上传,
    // 遍历文件一个个上传,这里一个个上传是为了实现钩子函数回调时返回对应的file对象。
    files.forEach(async file => {
      emit('on-change', file, files)
      if (!props.beforeUpload()) {
        return
      }
      if (props.autoUpload) {
        uploadRequest(file, files)
      }
    })
  }

  // 手动上传已选择的文件
  const submit = () => {
    files.forEach(async file => {
      uploadRequest(file, files)
    })
  }
  
  // 保存xhr对象,用于后面取消上传
  let xhrs = []
  const uploadRequest = async (file, files) => {
    let xhr = new XMLHttpRequest();
    // 调用open函数,指定请求类型与url地址。请求类型必须为POST
    xhr.open('POST', props.action);
    // 设置自定义请求头
    Object.keys(props.headers).forEach(k => {
      xhr.setRequestHeader(k, props.headers[k])
    })
    // 额外参数
    const formData = new FormData()
    formData.append('file', file);
    Object.keys(props.data).forEach(k => {
      formData.append(k, props.data[k]);
    })
    // 携带cookie
    xhr.withCredentials = props.withCredentials
    xhr.upload.onprogress = (e) => {
      emit('on-progress', e, file, files)
    }
    // 监听状态
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        const res = JSON.parse(xhr.response)
        const fileObj = {
          name: file.name,
          percentage: 100,
          raw: file,
          response: res,
          status: 'success',
          size: file.size,
          uid: file.uid,
        }
        fileList.value.push(fileObj)
        if (xhr.status === 200 || xhr.status === 201) {
          emit('on-success', res, fileObj, toRaw(fileList.value))
        } else {
          emit('on-error', res, fileObj, toRaw(fileList.value))
        }
      }
    }
    // 发起请求
    xhr.send(formData);
    xhrs.push({
      xhr,
      file
    })
  }

  const preview = (file) => {
    emit('on-preview', file)
  }

  const remove = (file, index) => {
    if (!props.beforeRemove()) {
      return
    }
    fileList.value.splice(index, 1)
    emit('on-remove', file, fileList.value)
  }

  // 取消上传
  const abort = (file) => {
    // 通过file对象找到对应的xhr对象,然后调用abort
    // xhr.abort()
  }

  defineExpose({
    abort,
    submit
  })

全部代码


<template>
  <input 
    type="file" 
    id="file" 
    :multiple="multiple"
    :accept="accept"
    @change="handleChange"
  >

  <button class="upload-btn" v-if="!drag" @click="choose">
    点击上传
  </button>
  <div 
    v-else 
    class="drag-box" 
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="handleDrop"
    @click="choose"
    :class="{'dragging': isDragging}"
  >

    将文件拖到此处,或<span>点击上传</span>
  </div>
  <template v-if="showFileList">
    <template v-if="listType === 'text'">
      <p class="file-item" v-for="(file, index) in fileList" :key="index" @click="preview(file)">
        <span>{{file.name}}</span>
        <span class="remove" @click.stop="remove(file, index)">×</span>
      </p>
    </template>
  </template>
</template>

<script setup>
  import { ref, toRaw, onMounted } from 'vue'
  import property from './props'
  const props = defineProps(property)
  const emit = defineEmits()

  const fileList = ref([])
  const isDragging = ref(false)

  // 触发选择文件
  const choose = () => {
    document.querySelector('#file').click()
  }

  // 拖放进入目标区域
  const handleDragOver = (event) => {
    event.preventDefault()
    isDragging.value = true
  }

  const handleDragLeave = (event) => {
    isDragging.value = false
  }

  let files = []

  // 拖拽放置
  const handleDrop = (event) => {
    event.preventDefault()
    isDragging.value = false
    files = Array.from(event.dataTransfer.files);
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  // input选择文件回调
  const handleChange = (event) => {
    files = Array.from(event.target.files)
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  const handleBeforeUpload = (files) => {
    if (files.length > props.limit - fileList.value.length) {
      console.error(`当前限制选择 ${props.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.value.length} 个文件`)
      emit('on-exceed', files, toRaw(fileList.value))
      return
    }
    files.forEach(async file => {
      emit('on-change', file, files)
      if (!props.beforeUpload()) {
        return
      }
      if (props.autoUpload) {
        uploadRequest(file, files)
      }
    })
  }

  // 手动上传已选择的文件
  const submit = () => {
    files.forEach(async file => {
      uploadRequest(file, files)
    })
  }

  let xhrs = []
  const uploadRequest = async (file, files) => {
    let xhr = new XMLHttpRequest();
    // 调用open函数,指定请求类型与url地址。请求类型必须为POST
    xhr.open('POST', props.action);
    // 设置自定义请求头
    Object.keys(props.headers).forEach(k => {
      xhr.setRequestHeader(k, props.headers[k])
    })
    // 额外参数
    const formData = new FormData()
    formData.append('file', file);
    Object.keys(props.data).forEach(k => {
      formData.append(k, props.data[k]);
    })
    // 携带cookie
    xhr.withCredentials = props.withCredentials
    xhr.upload.onprogress = (e) => {
      emit('on-progress', e, file, files)
    }
    // 监听状态
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        const res = JSON.parse(xhr.response)
        const fileObj = {
          name: file.name,
          percentage100,
          raw: file,
          response: res,
          status'success',
          size: file.size,
          uid: file.uid,
        }
        fileList.value.push(fileObj)
        if (xhr.status === 200 || xhr.status === 201) {
          emit('on-success', res, fileObj, toRaw(fileList.value))
        } else {
          emit('on-error', res, fileObj, toRaw(fileList.value))
        }
      }
    }
    // 发起请求
    xhr.send(formData);
    xhrs.push({
      xhr,
      file
    })
  }

  const preview = (file) => {
    emit('on-preview', file)
  }

  const remove = (file, index) => {
    if (!props.beforeRemove()) {
      return
    }
    fileList.value.splice(index, 1)
    emit('on-remove', file, fileList.value)
  }

  // 取消上传
  const abort = (file) => {
    // 通过file对象找到对应的xhr对象,然后调用abort
    // xhr.abort()
  }

  defineExpose({
    abort,
    submit
  })
</script>

<style scoped>
  #file {
    display: none;
  }
  .upload-btn {
    border: none;
    background-color#07c160;
    color#fff;
    padding6px 10px;
    cursor: pointer;
  }
  .drag-box {
    width240px;
    height150px;
    line-height150px;
    text-align: center;
    border1px dashed #ddd;
    cursor: pointer;
    border-radius8px;
  }
  .drag-box:hover {
    border-color: cornflowerblue;
  }
  .drag-box.dragging {
    background-colorrgb(131161216, .2);
    border-color: cornflowerblue;
  }
  .drag-box span {
    color: cornflowerblue;
  }
  .file-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top12px;
    padding0 8px;
    border-radius4px;
    cursor: pointer;
  }
  .file-item:hover {
    background-color#f5f5f5;
    color: cornflowerblue;
  }
  .file-item .remove {
    font-size20px;
  }
</style>

如何使用


<template>
<upload
ref="uploadRef"
action="http://localhost:3000/upload"
multiple
show-file-list
drag
auto-upload
upload-folder
:headers="headers"
:data="data"
:limit="3"
:before-upload="beforeUpload"
:before-remove="beforeRemove"
@on-change="handleChange"
@on-success="handleSuccess"
@on-error="handleError"
@on-preview="handlePreview"
@on-remove="handleRemove"
@on-exceed="handleExceed"
@on-progress="handleProgress"
>

</upload>
</template>

<script setup>
import { ref } from 'vue'
import upload from '@/components/upload/index.vue'

const uploadRef = ref(null)

const data = {
name: '张三',
age: 20
}

const headers = {
a: 111
}

const handleChange = (file, fileList) => {
// console.log('onChange', file, fileList)
}
const handleSuccess = (res, file, fileList) => {
console.log(res, file, fileList)
}
const handleError = (err, file, fileList) => {
console.log(err, file, fileList)
}
const handlePreview = (file) => {
console.log('handlePreview', file)
}
const handleRemove = (file, fileList) => {
console.log('handleRemove', file, fileList)
}
const handleExceed = (files, fileList) => {
console.log('文件个数超限', files, fileList)
}
const handleProgress = (e, file, fileList) => {
console.log('上传进度');
if (e.lengthComputable) {
const percentComplete = Math.ceil((e.loaded / e.total) * 100)
console.log('[ percentComplete ] >', percentComplete)
}
}
const beforeUpload = (file) => {
return true
}
const beforeRemove = (file, fileList) => {
return true
}
</script>

<style>

</style>


作者:xintianyou
来源:juejin.cn/post/7292302859964727346
收起阅读 »

前端如何直接上传文件夹

web
前面写了一篇仿写el-upload组件,彻底搞懂文件上传,实现了选择/拖拽文件上传,我们经常看到一些网站支持直接选择整个文件夹上传,例如:宝塔面板、cloudflare托管、对象存储网站等等需要模拟文件路径存储文件的场景。那是怎么实现的呢? 依然从两方面来说:...
继续阅读 »

前面写了一篇仿写el-upload组件,彻底搞懂文件上传,实现了选择/拖拽文件上传,我们经常看到一些网站支持直接选择整个文件夹上传,例如:宝塔面板、cloudflare托管、对象存储网站等等需要模拟文件路径存储文件的场景。那是怎么实现的呢?


依然从两方面来说:



  1. input选择文件夹

  2. 拖拽文件夹


input选择文件夹


在props.js中加一个属性,upload-folder是否支持上传文件夹


export default {
// 前面的省略了...
// 是否支持选择文件夹
'upload-folder': {
type: Boolean,
default: false
}
}

改一下input标签,依然是根据props的值动态判断是否支持上传文件夹。主要是webkitdirectory这个属性,由于不是一个标准属性,需要加浏览器前缀。


<input 
type="file"
id="file"
:multiple="multiple"
:accept="accept"
:webkitdirectory="uploadFolder"
:mozdirectory="uploadFolder"
:odirectory="uploadFolder"
@change="handleChange"
>


注意:支持选择文件夹时就只能选择文件夹,无法选择文件。


那么如何获取选择的文件夹呢?其实我们最终要上传的依然是文件,也就是file对象,文件夹也是一个特殊的文件。


依然是通过inputonchange事件回调拿到上传的event


或者直接获取input这个dom对象,然后拿到files属性,结果是一样的。


// input选择文件回调
const handleChange = (event) => {
console.log('[ files ] >', event.target.files)
const inputDom = document.querySelector('#file')
console.log('[ files ] >', inputDom.files)
}


可以看到,比选择单个文件时,多了一个webkitRelativePath属性,并且它是递归选择的文件夹,拿到这个文件夹及其子文件夹下所有的文件,我们可以通过这个属性拿到上传时文件所在的文件夹名称路径


拖拽文件夹


上篇文章讲过拖拽如何拿到文件,首先要准备一个用于拖拽放置的区域。
调用upload组件时,传入drag=true


<div 
class="drag-box"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>

将文件拖到此处,或<span>点击上传span>
div>

// 拖放进入目标区域
const handleDragOver = (event) => {
event.preventDefault()
}
// 拖拽放置
const handleDrop = (event) => {
event.preventDefault()
console.log('[ event ] >', event)
}

注意:和input上传不同,拖拽时,是可以同时拖拽文件和文件夹的。


因为可以同时拖拽文件和文件夹,我们就不能直接使用event.dataTransfer.files,如果刚好拖拽进来的是一个文件,那可以这么获取,如果是个文件夹呢?那就不行了。


同时拖拽一个文件和一个文件夹


这时候就要用到event.dataTransfer.items


// 拖拽放置
const handleDrop = (event) => {
event.preventDefault()
console.log(event.dataTransfer.items)
}

打印一下看看:

得到一个List类型的数据,里面是两个DataTransferItem,控制台无法直接查看它到底是个什么玩意儿。

看MDN,也看不出它具体是个啥。既然是List,遍历一下看看:


const handleDrop = (event) => {
event.preventDefault()
console.log(event.dataTransfer.items)
for (const item of event.dataTransfer.items) {
console.log('[ item ] >', item)
}
}


可以看到不管是文件还是文件夹,都被识别成了file,只不过图片是直接能识别出type为image/png


查看MDN,developer.mozilla.org/zh-CN/docs/…


点击查看itemPrototype,发现里面有个webkitGetAsEntry方法,执行它就能拿到item的具体信息。


看方法名,带了个webkit,但是这个方法除了Android Firefox浏览器以外都可以用。


for (const item of event.dataTransfer.items) {
const entry = item.webkitGetAsEntry()
console.log(entry)
}

依然拖动上面那个图片文件和一个文件夹:


可以看出,文件夹里面还有文件和文件夹,但是只显示了一个文件和一个文件夹,看来拖拽和input上传不一样,它不会自动的把里面所有的文件递归列出来。


通过isDirectory属性,就能区分是文件还是文件夹。除了这些基础属性以外,继续查看Prototype,可以看到还有一系列方法:


先看怎么拿到文件


entry是一个文件时,它有两个方法:createWriter()file(),查看MDN,developer.mozilla.org/en-US/docs/…

createWriter()已经废弃了,而且也不是我们今天要用的。

file()才是我们要找的。


这不就是我们熟悉的file对象吗,跟input上传拿到的一毛一样。


再看怎么拿到文件夹


查看MDN的Drop API webkitGetAsEntry()方法,developer.mozilla.org/zh-CN/docs/… 可得,如果是文件夹,可以通过createReader方法创建一个文件目录阅读器,然后通过readEntries方法,重新拿到每个item,这就是event.dataTransfer.items里面的每个item

我们写一下试试

依然是之前那个图片和文件夹

只打印出了跟目录下一级的一个文件和一个文件夹,那下面还有一个文件怎么办呢?


递归呀!


写一个递归读文件的方法。


const readFiles = async (item) => {
if (item.isDirectory) {
// 是一个文件夹
console.log('=======文件夹=======');
const directoryReader = item.createReader();
// readEntries是一个异步方法
const entries = await new Promise((resolve, reject) => {
directoryReader.readEntries(resolve, reject);
});

let files = [];
for (const entry of entries) {
const resultFiles = await readFiles(entry);
files = files.concat(resultFiles);
}
return files;
} else {
// 是一个文件
console.log('=======文件=======');
// file也是一个异步方法
const file = await new Promise((resolve, reject) => {
item.file(resolve, reject);
});
console.log('[ file ] >', file);
return [file];
}
}

handleDrop方法也要改一下


// 拖拽放置
const handleDrop = async (event) => {
event.preventDefault()
console.log(event.dataTransfer.items)

const files = [];
const promises = [];
for (const item of event.dataTransfer.items) {
const entry = item.webkitGetAsEntry();
console.log('[ entry ] >', entry);
promises.push(readFiles(entry));
}

const resultFilesArrays = await Promise.all(promises);
const allFiles = resultFilesArrays.flat();

console.log('[ All files ] >', allFiles);
}

再次拖拽上传看看

三个文件我们都拿到了。


总结


上传文件夹,还是直接使用input比较简单,使用它能直接拿到文件夹下所有的文件,以及每个文件在本地的路径,代码量也少很多。


拖拽的好处是文件和文件夹能一起上传。


作者:xintianyou
来源:juejin.cn/post/7292323606875553843
收起阅读 »

vue3 轮播图的实现

web
最近开发过程中,有一些轮播图的需求,虽然公司的组件库已经有swiper的组件,但是功能不全,很多效果实现不了,于是经过翻找swiper的官网,发现你想要的样式都有,下面来说一下swiper的简单使用。 想实现的效果 点击prev和next可实现图片的切换 安...
继续阅读 »

最近开发过程中,有一些轮播图的需求,虽然公司的组件库已经有swiper的组件,但是功能不全,很多效果实现不了,于是经过翻找swiper的官网,发现你想要的样式都有,下面来说一下swiper的简单使用。


想实现的效果


点击prev和next可实现图片的切换


image.png


安装


swiper的安装是比较简单的。

中文官网:http://www.swiper.com.cn/index.html

英文官网: swiperjs.com/


npm i swiper

使用


接下来就是swiper的使用了,swiper的使用非常简单。可查看官网例子

codesandbox.io/p/sandbox/2…


例子有归有,使用简单归简单,但是实现的样式和自己想要的差距还是很大,查了一波资料,现将代码放出,哈哈。

html


<swiper
:navigation="{
nextEl: '.swiper-button-next1',
prevEl: '.swiper-button-prev1'
}"

:modules="modules"
class="mySwiper"
:slides-per-view="3"
>

<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<swiper-slide>
<img src="图片地址" alt="" />
</swiper-slide>
<div class="swiper-button-prev-one" slot="button-prev">
<Button>
</Button>
</div>
<div class="swiper-button-next-one" slot="button-next">
<Button>
</Button>
</div>
</swiper>

通过navigation来控制轮播图的上一页,下一页,通过slides-per-view来控制每页显示几张图片。
js


import { Swiper, SwiperSlide, navigationPrev } from 'swiper/vue'
import 'swiper/css'
import 'swiper/css/navigation'
import { Navigation } from 'swiper/modules'
const modules = [Navigation]

js部分也是毕竟简单的,把该引入的文件引入即可。这样难道就实现效果了吗,当然不是,还需要改css样式


css(css部分部分采用tailwindcss编写)


.mySwiper {
@apply pb-2xl;
.swiper-button-prev-one {
@apply text-[#333] absolute text-[.875rem] left-0 bottom-0 cursor-pointer;
:deep(.m-button) {
@apply w-6 h-6;

span {
@apply text-sm #{!important};
}
}
}
.swiper-button-next-one {
@apply text-[#333] absolute text-[.875rem] left-[2.5rem] bottom-0 cursor-pointer;
:deep(.m-button) {
@apply w-6 h-6 bg-[#000] text-[#fff];
span {
@apply text-sm #{!important};
}
}
}
}

至此轮播图的效果就实现了,在做轮播图的需求时,需要仔细认真地查看文档,我是比较喜欢看英文文档,我觉得讲述比较全,大家学习的时候自行选择即可。


作者:zhouzhouya
来源:juejin.cn/post/7298907435061100595
收起阅读 »

使用 promise 重构 Android 异步代码

背景 业务当中写Android异步任务一直是一项挑战,以往的回调和线程管理方式比较复杂和繁琐,造成代码难以维护和阅读。在前端领域中JavaScript其实也面临同样的问题,Promise 就是它的比较主流的一种解法。 在尝试使用Promise之前我们也针对An...
继续阅读 »

背景


业务当中写Android异步任务一直是一项挑战,以往的回调和线程管理方式比较复杂和繁琐,造成代码难以维护和阅读。在前端领域中JavaScript其实也面临同样的问题,Promise 就是它的比较主流的一种解法。 在尝试使用Promise之前我们也针对Android现有的一些异步做了详细的对比。


文章思维导图


image.png


What:什么是Promise?


对于Android开发的同学,可能很多人不太熟悉Promise,它主要是前端的实践,所以先解析概念。
Promise 是 JavaScript 语言提供的一种标准化的异步管理方式,它的总体思想是,需要进行 io、等待或者其它异步操作的函数,不返回真实结果,而返回一个“承诺”,函数的调用方可以在合适的时机,选择等待这个承诺兑现(通过 Promise 的 then 方法的回调)。


最简单例子(JavaScript)


const promise = new Promise(function(resolve, reject) {
// ... some code

if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
}).then(function(value) {
console.log('resolved.');
}).catch(function(error) {
console.log('发生错误!', error);
});


实例化一个Promise对象,构造函数接受一个函数作为参数,该参数分别是resolvereject


resolve函数:将Promise 对象状态从pending 变成 resolved


reject函数:将Promise 对象状态从 pending 变成 rejected


then函数:回调 resolved状态的结果
catch函数:回调 rejected状态的结果



可以看到Promise的状态是非常简单且清晰的,这也让它在实现异步编程减少很多认知负担。


Why:为什么要考虑引入Promise


前面说的Promise 不就是 JavaScript 异步编程的一种思想吗,那这跟 Android 开发有什么关系? 虽然前端终端领域有所不同,但面临的问题其实是大同小异的,比如常见的异步回调导致回调地狱,逻辑处理不连贯等问题。
从事Android开发的同学应该对以下异步编程场景比较熟悉:



  • 单个网络请求

  • 多个网络请求竞速

  • 等待多个异步任务返回结果

  • 异步任务回调

  • 超时处理

  • 定时轮询


这里可以停顿思考一下,如果利用 Android常规的方式去实现以上场景,你会怎么做?你的脑子可能有以下解决方案:



  • 使用 Thread 创建

  • 使用 Thread + Looper + Handler

  • 使用 Android 原生 AsyncTask

  • 使用 HandlerThread

  • 使用 IntentService

  • 使用 线程池

  • 使用 RxJava 框架


以上方案都能在Android中实现异步任务处理,但或多或少存在一些问题和适用场景,我们详细剖析下各自的优缺点:


image.png


通过不同的异步实现方式的对比,可以发现每种实现方式都有适用场景,我们面对业务复杂度也是不一样的,每一种解决方案都是为了降低业务复杂度,用更低成本的方式来编码,但我们也知道代码写出来是给人看的,是需要持续迭代和维护,类似RxJava 这种框架于我们而言太复杂了,繁琐的操作符容易写出不易维护的代码,简单易理解应该是更好的追求,而不是炫技,所以我们才会探索用更轻量更简洁的编码方式来提升团队的代码一致性,就目前而言使用 Promise 来写代码将会有以下好处:



  • 解决回调地狱:Promise 可以把一层层嵌套的 callback 变成  .then().then()... ,从而使代码编写和阅读更直观

  • 易于处理错误:Promise 比 callback 在错误处理上更清晰直观

  • 非常容易编写多个异步操作的代码


How:怎么使用 Promise 重构业务代码?


这里由于我们的Java版本的Promise组件未开源,所以本部分只分析重构Case使用案例。


重构case1: 如何实现一个带超时的网络接口请求?


这是一段未重构前的获取付款码的异步代码:




可以看到以上代码存在以下问题:



  • 需要定义异步回调接口

  • 很多 if-else 判断,圈复杂度较高

  • 业务实现了一个超时类,为了不受网络库默认超时影响

  • 逻辑不够连贯,不易于维护


使用 Promise重构后:




可以看到有以下变化:



  • 消除了异步回调接口,链式调用让逻辑更连贯更清晰了

  • 通过 Promise 包装了网络请求调用,统一返回 Promise

  • 指定了 Promise 超时时间,无需额外实现繁琐的超时逻辑

  • 通过 validate 方法 替代 if - else 的判断,如果需要还可以定义校验规则

  • 统一处理异常错误,逻辑变得更加完备


重构case2:如何更优雅的实现长链接降级短链接?


重构前的做法:


代码存在以下问题:



  • 处理长链接请求超时,通过回调再处理降级逻辑

  • 使用Handler实现定时器轮询请求异步结果并处理回调

  • 处理各种逻辑判断,代码难以维护

  • 不易于模拟超时降级,代码可测试性差


使用Promise重构后:




第一个Promise处理长链接Push监听 ,设置5s超时,超时异常发生回调except方法,判断throwable 类型,如果为PromiseTimeoutException实例对象,则执行降级短链接。短链接是另外一个Promise,通过这种方式将逻辑都完全结果,代码不会割裂,逻辑更连贯。
短链接轮训查单逻辑使用Promise实现:




  • 最外层Promise,控制整体的超时,即不管轮询的结果如何,超过限定时间直接给定失败结果

  • Promise.delay(),这个比较细节,我们认定500ms轮询一定不会返回结果,则通过延迟的方式来减少一次轮询请求

  • Promise.retry(),真正重试的逻辑,限定了最多重试次数和延时逻辑,RetryStrategy定义的是重试的策略,延迟(delay)多少和满足怎样的条件(condition)才允许重试


这段代码把复杂的延时、条件判断、重试策略都通过Promise这个框架实现了,少了很多临时变量,代码量更少,逻辑更清晰。


重构case3:实现 iLink Push支付消息和短链接轮训查单竞速


后面针对降级策略重构成竞速模型,采用Promise.any很轻松得实现代码重构,代码如下图所示。



总结


本文提供一种异步编程的思路,借鉴了Promise思想来重构了Android的异步代码。通过Promise组件提供的多种并发模型能够更优雅的解决绝大部分的场景需求。


防踩坑指南


如果跟Activity或Fragment生命周期绑定,需要在生命周期结束时,取消掉promise的线程运行,否则可能会有内存泄露;这里可以采用AbortController来实现更优雅的中断 Promise。


并发模型



● 多任务并行请求


Promise.all():接受任意个Promise对象,并发执行异步任务。全部任务成功,有一个失败则视为整体失败。


Promise.allSettled(): 任务优先,所有任务必须执行完毕,永远不会进入失败状态。


Promise.any():接受任意个Promise对象,并发执行异步任务。等待其中一个成功即为成功,全部任务失败则进入错误状态,输出错误列表。


● 多任务竞速场景


Promise.race(): 接受任意个Promise对象,并发执行异步任务。时间是第一优先级,多个任务以最先返回的那个结果为准,此结果成功即为整体成功,失败则为整体失败。



扩展思考



  1. Promise 最佳实践




  1. 避免过长的链式调用:虽然Promise可以通过链式调用来避免回调地狱,但是如果Promise的链过长,代码的可读性和维护性也会变差。

  2. 及时针对Promise进行abort操作:Promise使用不当可能会造成内存泄露,比如未调用abort,页面取消未及时销毁proimse。

  3. 需要处理except异常回调,处理PromiseException.

  4. 可以使用validation来实现规则校验,减少if-else的规则判断




  1. Java Promise 组件实现原理




  1. 状态机实现(pending、fulfilled、rejected)

  2. 默认使用 ForkJoinPool 线程池,适合计算密集型任务。针对阻塞IO类型,可以使用内置ThreadPerTaskExecutor 简单线程池模型。




  1. Promise vs Kotlin协程



Promise 链式调用,代码清晰,上手成本较低;底层实现仍然是线程,通过线程池管理线程调度
Koitlin 协程,更轻量的线程,使用比较灵活,可以由开发者控制,比如挂起和恢复




  1. 可测试性的思考



根据 Promise 的特点,可以通过Mock状态(resolve、reject、outTime)来实现模拟成功,拒绝、超时;
实现思路:
● 自定义注解类辅助定位Hook点
● 使用ASM字节码对Promise 进行代码插桩



附录


Promise - JavaScript | MDN


Promises/A+


欢迎关注我的公众号,一起进步~


qrcode_for_gh_f3c52aa46d49_430 (1).jpg


作者:巫山老妖
来源:juejin.cn/post/7298955315621789730
收起阅读 »