注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

3 个技巧,让你像技术专家一样解决编码问题

「我应该如何提高解决问题的能力?尽管我掌握了 JavaScript,却无法解决实际问题或理解复杂的 JavaScript 代码。」 经常有年轻的开发者朋友问我类似的问题。对开发者来说,解决问题非常重要。编写优秀的代码是一门创造性的艺术,而要成为出色的开发者,就...
继续阅读 »

「我应该如何提高解决问题的能力?尽管我掌握了 JavaScript,却无法解决实际问题或理解复杂的 JavaScript 代码。」


经常有年轻的开发者朋友问我类似的问题。对开发者来说,解决问题非常重要。编写优秀的代码是一门创造性的艺术,而要成为出色的开发者,就必须成为富有创造力的能够解决问题的人。



我发现新手程序员犯的最大的错误是专注于学习语法,而不是学习如何解决问题。—— V. Anton Spraul



尽管我观察到,解决问题的技能需要时间和经验的积累,但我坚信掌握它并不需要很多年;只要勇敢地直面问题,就会有所提高。我曾与许多初级开发人员一起工作,年轻人们也比他们的老伙计更善于解决问题。


本文将详细讲解三个技巧,让开发者可以像技术专家一样解决问题。重头戏开始之前,我们先来看看技术专家和技术小白在解决问题方面有哪些不同。


专家思维 vs 新手思维



大多数人会回避或胡乱处理问题。优秀的思考者和领导者则会主动寻找问题,他们也拥有能够更好地解决问题的方法。—— Michael Simmons



Robert J. Sternberg 教授根据美国心理学家 Herbert A. Simon、Robert Glaser 和 Micheline Chi 等人的研究,揭示了技术专家解决问题比技术小白更有效的秘密。



Arnaud Chevallier 在 Work forward in solving problems, not backward 一文中犀利写道,「逆向工作法是一种从假设出发的方法。如果想要提高利润率,逆向工作法会指引你去寻找增加收入的办法,因为增加收入可以带来更高的利润率。那减少成本呢?难道在确定最终的解决方案之前,我们不应该先全面地了解各种可以实现目标的办法吗?」


可以看到,技术专家通常会花大量的时间寻找、明确和定义问题,并且使用正向工作法解决问题,同时密切关注问题解决的过程。下面就跟大家分享,技术专家们常用的问题解决技巧。


三个专家级的问题解决技巧


01 问题十二连 The 12 What Elses


提问题听上去没什么难度,但要找到正确的、缺失的问题并准确地描述出来却不容易。Lenedra J. Carroll 介绍的「问题十二连 The 12 What Elses」可以有效帮助我们摆脱这个苦恼。


在头脑风暴时,先提出一个问题,并生成 12 个答案;然后选取其中一个答案转化为下一个问题,再生成 12 个答案。不断重复此过程,直到获得一个明确的解决方案。


通过连续地提问,我们会得到一个「问题回答地图」,它对假设的测试和结构化解决复杂问题很有帮助。


使用「问题回答地图」测试假设


提问和追问是如何将我们往正确方向上引导的?下面两个例子可能会给你答案。




02 根本原因分析法 Root Cause Analysis


我们经常在多次解决失败后,才发现问题的情况跟预期有所不同,所以在开始解决问题之前,就要先了解其根本原因是什么。


只有消除错误的选择,才能更好地定义问题并找到有效的解决方案。根本原因分析法有助于避免在错误的方向上浪费时间和精力。


根本原因分析的几种方法


根本原因分析法的示例


当需要修复 Bug 时,开发者可以使用以下任意方式,进行根本原因分析:



  • 确定问题在哪个环境出现,并尝试在相同和不同的环境中重现它,以掌握更好的理解。

  • 如果与 Web 性能有关,可以分析捆绑文件。

  • 进行单元测试和集成测试。

  • 进行日志文件分析。

  • 进行交互式调试。


03 使用多元思维 Spectrum Thinking


二元思维认为事情的状态是非黑即白的,只有互相对立的两种可能。有些时候它是正确的,但其他时候,它可能是一种错误的简化。


二元思维


与二元思维对应的是多元思维,也可以称作频谱思维(Spectrum Thinking)。它会考虑更多选择、更多替代方案和可能性,比如「两者共存」「介于两者之间」「其他的可能性」或「二者皆否」等。


频谱思维


通过培养多元思维,开发者可以有效提升创造力;你会惊讶地发现,修复 Bug、解决冲突、设计/执行客户需求的实现方案等居然会有这么多种解决方案和方式。


以展示信息详情为例,二元思维认为,信息详情要么通过弹窗展示,要么跳转到一个带返回箭头的新页面进行展示。




多元思维认为还有其他可能性,比如新增 Tab 页直接查看和更新信息,无需关闭当前列表页面。



多元思维还可能认为,可以提供一个支持三种布局的动态模板,让用户自主选择要用以上哪种方式。


二元思维和多元思维各有利弊,在实际工作中可以配合使用。


写在最后


解决问题能力是一个超出软件开发范畴的话题,它高度取决于我们的心态和态度。要想培养和提高解决(复杂)问题的能力,首先要对问题和挑战充满好奇心,而不是感到沮丧。


就像 Tim Hicks 说的那样,「问题就像赛车道上的弯道。处理得好,便可以在接下来的直道中状态满分;如果过弯太快,很可能会引发侧翻,影响后续赛程。」


(原文作者:Rakia Ben Sassi)




了解更多开发者提效、研发效能管理、前沿技术等消息,欢迎关注 LigaAI@稀土掘金


LigaAI 助力开发者扬帆远航,欢迎体验我们的产品,期待与你一路同行!


作者:LigaAI
来源:juejin.cn/post/7243592123803009083
收起阅读 »

看完还学不会正则,快来锤我!

web
前言 各位同仁在表单验证规则,或者在验证数据的时候,是不是经常都是xxx.xxxx,然后对数据处理来处理去,最后进行后续操作……,不仅费时费力,而且消耗精神,于是乎: ps(我):要不要用用正则,但是想不起来怎么写啊,百度搜一下吧,chatGpt搜一下,贴上去...
继续阅读 »

前言


各位同仁在表单验证规则,或者在验证数据的时候,是不是经常都是xxx.xxxx,然后对数据处理来处理去,最后进行后续操作……,不仅费时费力,而且消耗精神,于是乎:


ps(我):要不要用用正则,但是想不起来怎么写啊,百度搜一下吧,chatGpt搜一下,贴上去


我:欧哟?刚刚好啊,效果还怪好的嘞,哈哈哈,天助我也!


这还是好的情况,刚刚好符合,要是不好……


ps(我):服了,这正则不得行,chatGpt搜的错的,自己写吧又不会,找吧又没有……
我:乖乖写if else吧


又是啃哧啃哧,耗时耗力……可能大家伙都是差不多的哈,谁也别说谁(大佬除外)。本着多一事不如少一事的原则,直接学习一波!


js正则表达式


正则(正则表达式)是一种用于描述文本模式的工具,它通过使用特定的符号和语法规则来定义一个字符串的模式。正则表达式通常由各种字符和特殊元字符组成,用于进行字符串匹配、查找、替换和验证等操作。


使用正则表达式,可以执行以下操作:



  1. 模式匹配:正则表达式可以用于查找和匹配具有特定模式的字符串。通过定义一个模式,可以搜索和识别符合该模式的字符串。

  2. 字符串查找与替换:正则表达式可以用于在文本中进行字符串查找和替换。通过指定要查找或替换的模式,可以对目标字符串进行修改和处理。

  3. 数据验证:正则表达式可以用于验证用户输入或其他数据的格式和有效性。例如,可以使用正则表达式验证电子邮件地址、电话号码、日期等的格式是否符合预期。

  4. 文本提取:在文本处理中,可以使用正则表达式从大量文本数据中提取出所需的信息。例如,可以使用正则表达式从日志文件中提取特定的时间戳或关键字。

  5. 数据清洗与转换:使用正则表达式,可以进行文本数据的清洗和转换。可以根据模式匹配和替换规则,删除非法字符、规范化日期格式、提取关键信息等。


正则表达式提供了一种强大和灵活的文本处理工具,它被广泛应用于编程语言、文本编辑器、数据处理工具等各种软件中。虽然正则表达式的语法可能会显得复杂,但掌握它可以极大地提高对文本模式处理的能力。


应用


正则表达式在计算机科学和文本处理中具有广泛的应用。以下是一些常见的正则表达式应用:



  • 模式匹配:正则表达式可用于检测字符串是否与特定模式匹配。例如,可以使用正则表达式来验证电子邮件地址、检查电话号码的格式、识别日期等。

  • 字符串搜索与替换:正则表达式可以用于在文本中搜索特定的模式,并进行替换或提取。这对于在大量文本中进行批量操作非常有用,如查找和替换文本文件中的特定单词或短语。

  • 表单验证:在前端开发中,可以使用正则表达式验证用户输入的表单数据。例如,验证用户名是否只包含字母和数字、检查密码是否符合指定的复杂度要求等。

  • URL路由:许多Web框架使用正则表达式来解析URL路由和处理动态路由。它们通过正则表达式匹配URL字符串并将其映射到相应的处理程序或控制器。

  • 日志分析:使用正则表达式可以解析和提取日志文件中的有用信息。例如,可以使用正则表达式从服务器日志中提取IP地址、日期时间戳、错误消息等。

  • 数据清洗与转换:正则表达式可用于清洗和转换数据,如从多种格式的文本数据中提取特定字段、规范化日期格式、去除特殊字符等。

  • 编程工具与编辑器:许多编程工具和文本编辑器支持正则表达式搜索和替换功能。这使得开发人员能够更灵活地进行代码重构和批量修改操作。


创建正则


js 中内置了正则表达式对象 RegExp,我们要创建一个正则表达式,可以:



  • 第一种创建方式,接收patternmodifiers两个参数


// 构造函数RegExp
var regex = new RegExp(pattern, modifiers);
// 构造函数创建
var regex = new RegExp('xyz', 'i');
// 等价于 字面量创建
var regex = /xyz/i;

RegExp 构造函数接收两个参数,pattern 描述了表达式的模式,为字符串,modifiers 是正则表达式的修饰符,用于执行区分大小写和全局匹配。



  • 第二种创建方式,参数是一个正则表示式,这时会返回一个原有正则表达式的拷贝。


var regex = new RegExp(/xyz/i);
// 等价于
var regex = /xyz/i;

在构造函数创建时 pattern 是正则字符串,字面量创建时,pattern 是一个类似 /正则规则/ 表达式,是放在双斜杠里的。
modifiers有三个值,分别为


描述
i不区分大小写。在匹配时忽略大小写
g全局匹配。查找所有可能的匹配,而不仅仅是第一个匹配。
s单行匹配。启用单行模式,使点号(.)可以匹配包括换行符在内的任意字符。
u启用完整的 Unicode 匹配支持,会正确处理四个字节的 UTF-16 编码。
m多行匹配。启用多行模式,^ 和 $ 可以匹配行的开始和结束。

例如,使用 ig 修饰符创建一个忽略大小写和全局匹配的正则表达式:


javascript
var pattern = new RegExp("pattern", "ig");

或者使用字面量表示法也可以传递修饰符:


var pattern = /pattern/ig;

正则对象的方法


正则表达式对象通常提供一组方法,用于在字符串上执行不同的操作。以下是一些常见的正则表达式对象的方法:




  1. test(string): 检测指定字符串是否与正则表达式匹配。返回一个布尔值,表示是否找到匹配项。一般开发当中,我们使用这种方法较多。


    const regex = /apple/;
    console.log(regex.test("I love apples")); // true
    console.log(regex.test("I prefer oranges")); // false



  2. exec(string): 在给定字符串中搜索匹配项,并返回一个数组或 null。数组包含有关匹配项的详细信息,如匹配的子字符串、捕获组等。


    const regex = /\d+/;
    console.log(regex.exec("I have 100 apples")); // ["100"]
    console.log(regex.exec("There are no numbers here")); // null



  3. match(regexp): 在字符串中查找与正则表达式匹配的内容,并返回一个数组或 null。类似于 exec() 方法,但是 match() 是在字符串上调用,而不是在正则表达式上调用。


    const string = "I have 100 apples";
    const regex = /\d+/;
    console.log(string.match(regex)); // ["100"]



  4. search(string): 在字符串中搜索与正则表达式匹配的内容,并返回匹配项的索引。如果没有找到匹配项,则返回 -1。


    const string = "I prefer oranges";
    const regex = /oranges/;
    console.log(string.search(regex)); // 8



  5. replace(regexp, replacement): 替换字符串中与正则表达式匹配的部分。可以将匹配项替换为指定的字符串或使用函数进行替换。


    const string = "I like cats and dogs";
    const regex = /cats/;
    const replacement = "birds";
    const newString = string.replace(regex, replacement);
    console.log(newString); // "I like birds and dogs"



  6. split(regexp): 将字符串分割为由正则表达式匹配的子字符串组成的数组。正则表达式定义了分隔符。


    const string = "apple,banana,orange";
    const regex = /,/;
    const parts = string.split(regex);
    console.log(parts); // ["apple", "banana", "orange"]



正则规则


分为基本字符匹配;元字符匹配,如\w;锚点匹配指定匹配发生的位置, 如^ 表示匹配行的开头;量词和限定符, 如*; 分组和捕获();零宽断言:正向肯定断言 (?=...):匹配满足断言条件的位置,但不会消耗字符;


接下来一一进行介绍。


基本字符匹配


匹配字面量字符/ /


如果想在javaScript当中直接匹配java,可以直接在我们的字面量当中写入想要匹配的值,即java直接进行匹配。


正则: /java/


可以匹配的不能匹配的
javascriptJavascript
javajaava

字符组[ ]


如果不仅仅想要匹配java还想要匹配Java,那光光/java/是不够的。这时候还需要用到我们的字符组。


正则:/[Jj]ava/


可以匹配的不能匹配的
javascriptjaava
Javascriptjvav

[]匹配规则当中,目标字符可以匹配中括号里面的任意一个字符即可,转为javaScript语言就是 ||的意思。观察两个目标字符串,java与Java的区别也仅仅是首字母不同,那么只需要兼容开头的大小字母即可。


拓展

若是想匹配java Java JAva,正则需要如何编写?通过观察各个字符当中的差别,即前两个字母的可能性都可能为大小写,便得出前两个位置的匹配使用字符组即可。


正则:/[J][Aa]va/


字符组区间 -


如果说只想匹配前缀为123,后面是二十六个字母当中任何一个的字符怎么办?


这简单,刚刚学完字符组,我直接一手/123[a,b,c,d....]/把二十六个字母全部列一遍,话虽如此,但大可不必!


此处若是可选匹配字母过多的话,可直接使用字符组区间连接


正则: /123[a-zA-Z]/


可以匹配的不能匹配的
123a123
123B12345

同时还可以匹配多个数字,比如我只想匹配[3-9]的数字,那么也可以使用连接符


正则123[3-9][a-zA-Z]


可以匹配的不能匹配的
1233a123a9
1236B123B

字符组取反:[^]


有的时候你可能也不想匹配某些字符,比如只晕小写字母,那么这个时候你可以对你所要匹配的字符组进行取反,那就匹配不到了。


正则:/[^a-z]/


可以匹配的不能匹配的
1233ABCDEabcde
12345678adasd
123adasdadasd


注意: 此处需要全部为小写字母test匹配结果才是false,若字符包含其他的字符,test的匹配结果仍然为true。



const pattern = /[^a-z]/ // 表示的意思为所有字符都不是小写
const string = '123adasd' // 此处还有数字
pattern.test(string) // true

元字符匹配


日常开发当中,元字符单独使用的情况并不多,更多的是跟随后续的量词一块使用,最终形成限定字符格式的正则。


单点 .


. 是一个特殊的元字符,可以用于匹配除了换行符 \n(或其他行终止符,如 \r\n)之外的任意单个字符。


正则:/./


可以匹配的不能匹配的
1\n(换行)
a\r(回车)

数字 \d


\d 可以匹配任意一个数字字符,包括 0 到 9 的数字。


字符 \w


用于匹配字母字符、数字和下划线。


具体来说,\w 匹配以下字符:



  • 小写字母(a-z)

  • 大写字母(A-Z)

  • 数字(0-9)

  • 下划线(_)


空白符 \s


用于匹配空白字符



  • 空格符(Space)

  • 制表符(\t)

  • 换行符(\n)

  • 回车符(\r)

  • 垂直制表符(\v)

  • 换页符(\f)



注意:如果说想要匹配正则当中的匹配规则符号,例如只想匹配单点字符.,则需要使用反斜杠进行转义,即/\./ 任何匹配正则当中具有意义的字符都需要进行转义。



量词


量词用于指定模式重复出现的次数。允许你匹配一定数量的字符或子模式,是正则当中见怪不怪的玩意。与上述字符相互搭配,能获得意想不到的结果。


量词 {}


用于匹配前面的字符或子表达式指定的精确的重复次数。


比如需要匹配重复多个字符,如需要匹配出现两次a的字符串。


正则:/a{2}/


可以匹配的不能匹配的
aaabab
aabbabb

但是我只知道会出现a字符,可能是两到三个呢?这个时候就可以使用区间来表示,囊括出现的次数。


正则:/a{2,3}/


可以匹配的不能匹配的
aaabbbb
aabbabb
aaababab

如果只知道出现一次,但是不清楚具体有几次,便直接可以不写右区间,表示至少出现n次,比如下面的正则就表示至少出现3次a


正则:/a{3,}/


可以匹配的不能匹配的
aaabbbb
baaaaaabb

量词 +


用于匹配前面的字符或子表达式至少一次或多次出现。
实际上,+的表现形式,还可以用{1,}来表示


正则: /a+/ 等价于 /a{1,}/


可以匹配的不能匹配的
abbbb
aabbb

量词 *


用于匹配前面的字符或子表达式出现0次或多次出现。实际上,*的表现形式,也可以用{0,}来表示


正则: /a*/ 等价于 /a{0,}/


可以匹配的不能匹配的
abbbb
aabbb

量词 ?


用于匹配前面的字符或子表达式零次或一次。实际上,*的表现形式,也可以用{0,1}来表示


正则: /a?b/ 等价于 /a{0,1}b/


可以匹配的不能匹配的
babcde
bad


正则表达式的贪婪匹配和非贪婪匹配是用来描述匹配模式时的两种不同行为。
贪婪匹配是指正则表达式尽可能地匹配更长的文本片段。它会尽量多地消耗输入字符串,并尝试匹配满足整个正则表达式模式的最长可能结果,是默认的行为,
反之,非贪婪匹配(也称为懒惰匹配或最小匹配) 则是指正则表达式尽可能地匹配更短的文本片段。它会尽量少地消耗输入字符串,并尝试匹配满足整个正则表达式模式的最短可能结果。
通常非贪婪匹配通过在正则字符串后面加?号来表示。



示例
正则表达式 /a+/,它表示匹配一个或多个连续出现的字符 "a"。


对于字符串 "aaa",贪婪匹配将尽量匹配更长的连续的 "a" 字符串,在这种情况下会匹配整个字符串 "aaa"。


使用非贪婪匹配需要在量词后面添加 ?。正则表达式 /a+?/ 表示非贪婪匹配,将匹配一个或多个连续出现的字符 "a",但只尽量匹配最短的结果。非贪婪匹配将尽量匹配最短的连续的 "a" 字符串。在这个例子中,非贪婪匹配会匹配第一个 "a" 字符,因为它是最短的满足正则表达式模式的子串。


锚点匹配


锚点是正则表达式中的特殊字符,用于匹配字符串的位置而不是具体的字符,可用于指定匹配发生的位置,常用的锚点有^$\b


^ 起始位置


表示匹配行的开头。下面正则表示匹配以a为开头的字符


正则:^a


可以匹配的不能匹配的
ada
abbc

$ 结束位置


表示匹配行的结尾。下面正则表示匹配以a为结尾的字符


正则:a$


可以匹配的不能匹配的
aab
dabc

\b 边界


表示匹配单词边界。下面正则表示匹配独立的单词


正则:/\bapple\b/


可以匹配的不能匹配的
I love applepineapple
applepinapple

\b还有很多其他的应用,比如



  • \b\w+\b:匹配一个或多个连续的单词字符,可以用来分割句子为单词数组。

  • \b\d{4}\b:匹配仅包含4位数字的字符串


在转义\b的时候需要使用\\b


分组和捕获:


分组 ()


括号 ( ):用于将一组模式作为单个单元进行匹配,并将其视为一个分组。


比如,我要匹配以jstsjava后缀的文件
正则:/.*\.(js|ts|java)/


可以匹配的不能匹配的
index.js1.png
1.ts2.jpg
calss.java3.mp3

再比如 正则:/(ab){1,}/,可以匹配一个或出现多个连续的ab,利用分组实现的


可以匹配的不能匹配的
abaa
ababba

捕获组


通过圆括号捕获分组内的内容,可以在后续操作中进行引用。


可能这比较难理解,我们举例说明,比如,我们有1-82-2这种类型的数据,我们可以使用正则的分组将两边的数据包裹,并使用exec进行捕获。分组符号的数据就是把这些想要捕获的数据标记出来。


如果我们想要 () 的分组能力,但是又不想捕获数据,可以使用 (?:) 表达式。可以提高正则表达式的性能和简洁性。


image.png


零宽断言



  1. 正向肯定预查(?=...):表示在当前位置后面,如果满足括号内的表达式,则继续匹配成功。

  2. 正向否定预查(?!...):表示在当前位置后面,如果不满足括号内的表达式,则继续匹配成功。

  3. 反向肯定预查(?<=...):表示在当前位置前面,如果满足括号内的表达式,则继续匹配成功。

  4. 反向否定预查(?<!...):表示在当前位置前面,如果不满足括号内的表达式,则继续匹配成功。



  • /(?=\d)\w+/ 匹配由数字紧随其后的单词字符。
    | 可以匹配的 | 不能匹配的 |
    | --- | --- |
    | 1 | w |
    | 1w | ww |


为什么这里能匹配1呢?1首先同样属于字符,其次还是数字,在断言的时候,不消耗字符,符合数字随其后的规则(本身)



  • /(?<!\d)\w+/ 匹配没有数字紧随在前面的单词字符。(js不支持)



js并不支持反向预查,只支持正向预查。这是因为正向预查在匹配时,可以当前位置后面的内容进行断言判断,如果不符合预期,则无法继续匹配成功。这种类型的预查可以通过回溯来实现。


然而,反向否定预查需要从当前位置回溯到前面的位置进行条件判断,这就使得正则引擎需要逆序地扫描前面的内容,增加了匹配的复杂度。因此,实现反向否定预查的算法相对更为复杂,并且可能导致性能下降。


反向否定预查在某些特定情况下可以被其他模式替代,比如使用捕获组结合后续的处理代码来达到类似的效果。



正则表达式大全



  1. 邮箱验证


/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(.\w{2,3})+$/

^\w+匹配以字符开头,([.-]?\w+)* 部分出现两次,品牌包含一个或多个由-或点.连接的部分,(.\w{2,3})+匹配域名




  1. URL 验证:包括 HTTP 和 HTTPS 协议。


/^(https?://)?[\w-]+(.[\w-]+)+[/#?]?.*$/


  1. 身-份-证号码验证:验证中国大陆身-份-证号码的有效性。


低配:
/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/

高配:
身-份-证号匹配
/^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[1-2]\d|3[0-1])\d{3}[0-9Xx]$/


  • ^[1-9]\d{5}:匹配 6 位行政区划代码、

  • (19|20)\d{2}:年份,匹配以 19 或 20 开头的四位数字、

  • (0[1-9]|1[0-2]):月份,取值范围为 01 到 12、

  • (0[1-9]|[1-2]\d|3[0-1]):日期,取值范围为 01 到 31、

  • \d{3}:顺序码,任意三位数字、

  • [0-9Xx]:校验码,可以是数字或字母 X 或 x、



  1. 数字验证:用于验证一个字符串是否只由数字组成。


`/^\d+$/`


  1. 字母验证:用于验证一个字符串是否只由字母组成。


`/^[a-zA-Z]+$/`


  1. 小数验证:匹配的数字可包含小数点,此处转义了小数点,


/^\d+(\.\d+)?$/


  1. 整数验证(包括负数):用于验证一个字符串是否为整数,可以包含正负号。


`/^[-+]?\d+$/`


  1. IP 地址验证: 用于验证 IPv4 地址的有效性。


/^((25[0-5]|2[0-4]\d|[01]?\d\d?).){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/


  1. 手机号码验证:


低配版本,仅表示11位数字


```
^\d{11}$ 低配版本,11位数字
```

高配版本


```
/^(13[0-9]|14[5|7]|15[0|1|2|3|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$/

如果不想这么复杂,可以写为
/^1[3-9]d{9}$/


还能匹配*特殊符号的,但是会失去匹配11位数功能
/^1[3-9]\d{1}(?:\*{1,})*\d+$/
```

如果确定符号个数,可改为/^1[3-9]\d{1}((?:\*{4})|\d{4})\d{4}$/,就能匹配固定11位数的号码



  • 可以匹配152702365242

  • 可以匹配152****65242


10.密码复杂度要求




  • 8位任意密码


    /^.{8,}$/



  • 包括至少8个字符,包含大写字母、小写字母和数字


    /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/



?=为正向断言,判断条件是否符合.*\d,即任意字符但是需要出现一个数字,其余类似


这个正则表达式用于强制密码应至少包含一个数字(?=.*\d)、一个小写字母(?=.*[a-z])和一个大写字母(?=.*[A-Z]),并且长度至少为8个字符.{8,}



  • 包括至少8个字符,包含大写字母、小写字母和数字,包括特殊字符
    /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*?]).{8,}$/



11.以8结尾,且位数在6位以内的数字


/^\d{0,5}8$/


  1. 时间匹配,匹配时分,年月日的匹配建议还是按照Date的API,正则在匹配闰年的二月份时候无法匹配


/^(?:[01]\d|2[0-3]):(?:[0-5]\d)$/


  • 可以匹配09:10 12:12 23:01 23:59


/^(?:(?!0000)[0-9]{4}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$/



  • (?!0000) 表示后面不能跟着四个0,即年份不能为0000。

  • [0-9]{4} 表示匹配四个数字,即年份的格式为四位数字。

  • - 表示匹配“-”字符。

  • (?:…) 表示非捕获型分组,用于提高正则表达式的效率。

  • (?:0[1-9]|1[0-2]) 表示匹配01-12月份,其中0[1-9]表示01-09月份,1[0-2]表示10-12月份。

  • (?:0[1-9]|1[0-9]|2[0-8]) 表示匹配01-28日,其中0[1-9]表示01-09日,1[0-9]表示10-19日,2[0-8]表示20-28日。

  • (?:0[13-9]|1[0-2])-(?:29|30) 表示匹配01、03、05、07、08、10、12月份的29或30日。

  • (?:0[13578]|1[02])-31 表示匹配01、03、05、07、08、10、12月份的31日。

  • (?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29 表示匹配闰年的2月29日,其中[0-9]{2}表示匹配两位数字的年份,(?:0[48]|[2468][048]|[13579][26])表示匹配闰年的年份,即能被4整除但不能被100整除,或者能被400整除。

  • $ 表示匹配字符串的结束位置。



  1. 用户名:4-10位的用户名,包含下划线、连接符


/^[a-zA-Z0-9_-]{4,10}$/

总结


以上就是目前能想到的常用的正则,大家如果也有或者说常用的正则,也可以在评论区反馈,谢谢各位!


作者:原野风殇
来源:juejin.cn/post/7299376141451411490
收起阅读 »

如何正确遍历删除List中的元素

删除List中元素这个场景很场景,很多人可能直接在循环中直接去删除元素,这样做对吗?我们就来聊聊。 for循环索引删除 删除长度为4的字符串元素。    List<String> list = new ArrayList<String>...
继续阅读 »

删除List中元素这个场景很场景,很多人可能直接在循环中直接去删除元素,这样做对吗?我们就来聊聊。


for循环索引删除


删除长度为4的字符串元素。


    List<String> list = new ArrayList<String>();
   list.add("AA");
   list.add("BBB");
   list.add("CCCC");
   list.add("DDDD");
   list.add("EEE");

   for (int i = 0; i < list.size(); i++) {
       if (list.get(i).length() == 4) {
           list.remove(i);
      }
  }
   System.out.println(list);
}

实际上输出结果:


[AA, BBB, DDDD, EEE]

DDDD 竟然没有删掉!


原因是:删除某个元素后,list的大小size发生了变化,而list的索引也在变化,索引为i的元素删除后,后边元素的索引自动向前补位,即原来索引为i+1的元素,变为了索引为i的元素,但是下一次循环取的索引是i+1,此时你以为取到的是原来索引为i+1的元素,其实取到是原来索引为i+2的元素,所以会导致你在遍历的时候漏掉某些元素。


比如当你删除第1个元素后,继续根据索引访问第2个元素时,因为删除的关系后面的元素都往前移动了一位,所以实际访问的是第3个元素。不会报出异常,只会出现漏删的情况。


foreach循环删除元素


for (String s : list) {
       if (s.length() == 4) {
           list.remove(s);

      }
  }
   System.out.println(list);

如果没有break,会报错:



java.util.ConcurrentModificationException at java.util.ArrayListItr.checkForComodification(ArrayList.java:911)atjava.util.ArrayListItr.checkForComodification(ArrayList.java:911) at java.util.ArrayListItr.next(ArrayList.java:861) at com.demo.ApplicationTest.testDel(ApplicationTest.java:64) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)



报ConcurrentModificationException错误的原因:


看一下JDK源码中ArrayList的remove源码是怎么实现的:


public boolean remove(Object o) {
       if (o == null) {
           for (int index = 0; index < size; index++)
               if (elementData[index] == null) {
                   fastRemove(index);
                   return true;
              }
      } else {
           for (int index = 0; index < size; index++)
               if (o.equals(elementData[index])) {
                   fastRemove(index);
                   return true;
              }
      }
       return false;
  }

一般情况下程序会最终调用fastRemove方法:


private void fastRemove(int index) {
       modCount++;
       int numMoved = size - index - 1;
       if (numMoved > 0)
           System.arraycopy(elementData, index+1, elementData, index,
                            numMoved);
       elementData[--size] = null; // clear to let GC do its work
  }

在fastRemove方法中,可以看到第2行把modCount变量的值加一,但在ArrayList返回的迭代器会做迭代器内部的修改次数检查:


final void checkForComodification() {
    if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }

而foreach写法是对实际的Iterable、hasNext、next方法的简写,因为上面的remove(Object)方法修改了modCount的值,所以才会报出并发修改异常。


阿里开发手册也明确说明禁止使用foreach删除、增加List元素。


迭代器Iterator删除元素


    Iterator<String> iterator = list.iterator();
   while(iterator.hasNext()){
       if(iterator.next().length()==4){
           iterator.remove();
      }
  }
   System.out.println(list);


[AA, BBB, EEE]



这种方式可以正常的循环及删除。但要注意的是,使用iterator的remove方法,而不是List的remove方法,如果用list的remove方法同样会报上面提到的ConcurrentModificationException错误。


总结


无论什么场景,都不要对List使用for循环的同时,删除List集合元素,要使用迭代器删除元素。


作者:程序员子龙
来源:juejin.cn/post/7299384698883620918
收起阅读 »

iOS 仿花小猪首页滑动效果

iOS
一. 背景 首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示: 二. 分析 从花小猪首页交互我们可以分析出如下信息: 首页卡片分为三段式,底部、中间、顶部。 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡...
继续阅读 »

一. 背景


首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示:



二. 分析


从花小猪首页交互我们可以分析出如下信息:




  • 首页卡片分为三段式,底部、中间、顶部。




  • 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡片滚动视图依然可以滚动。




  • 当首页卡片在中间,可以先外部视图整体往上或者往下滑动,往下滑动到底部后,禁止滑动,滑动到顶部,内部视图卡片头部悬浮,内部滚动视图可以滚动。




  • 当首页卡片在顶部,可以拖动卡片外部视图整体下滑,也可以通过内部视图向下滚动,滚动到跟内部头部底部持平,变成整体一起向下滑动。而当内部滚动视图向上滚动,内部卡片头部悬浮固定。




  • 首页卡片滑动过程中,如果停在中间位置,依据卡片停止位置,距离底部、中间、顶部位置远近,向距离近的一端,直接移动到相应位置,比如移动到中间和顶部位置之间,如果距离顶部近,则直接移动到顶部。




  • 当首页卡片在底部,上滑速度很快超过一定值,就直接到顶部。同样在顶部下滑也一样。




  • 当首页卡片在顶部,内部滚动视图快速下滑,下滑到跟卡片头部分开,产生弹簧效果,不直接一起下滑,但其他部分如果慢慢滑动,下滑到跟卡片头部即将分开时,变成整体一起下滑。




三. 实现


理清了首页卡片的滑动交互细节之后,我们开始设计对应类和相关职责。



从上面结构图我们可以看出,主要分为三部分




  • 卡片外层容器externalScrollView,限定为UIScrollView类型。




  • 卡片内头部insideHeaderView,限定为UIView类型。




  • 卡片内滚动视图insideTableView,由于滚动视图所以insideTableView一定是UIScrollView类型,为了复用,这里我们限定为UITableView




这里其实我们不关心头部视图insideHeaderView,因为内部头部视图insideHeaderView和内部滚动视图insideTableView之间的关系是固定,就是内部滚动视图insideTableView一直在头部视图 insideHeaderView下面。


同样我们也不关心滚动视图insideTableView里面的内容,我们需要处理的就是卡片外层容器externalScrollView和内部滚动视图insideTableView之间交互关系。


因为所有这种类型交互处理逻辑是一致的,因此我们抽出FJFScrollDragHelper类。



  • 首先我们来认识下滚动辅助类FJFScrollDragHelper相关属性


    /// scrollView 显示高度
public var scrollViewHeight: CGFloat = kScreenH
/// 限制的高度(超过这个高度可以滚动)
public var kScrollLimitHeight: CGFloat = kScreenH * 0.51
/// 滑动初始速度(大于该速度直接滑动到顶部或底部)
public var slideInitSpeedLimit: CGFloat = 3500.0
/// 当前 滚动 视图 位置
public var curScrollViewPositionType: FJFScrollViewPositionType = .middle
/// 最高 展示 高度
public var topShowHeight: CGFloat = 0
/// 中间 展示 高度
public var middleShowHeight: CGFloat = 0
/// 最低 展示 高度
public var lowestShowHeight: CGFloat = 0
/// 当前 滚动 视图 类型
private var currentScrollType: FJFCurrentScrollViewType = .externalView
/// 外部 滚动 view
public weak var externalScrollView: UIScrollView?
/// 内部 滚动 view
public weak var insideScrollView: UIScrollView?
/// 拖动 scrollView 回调
public var panScrollViewBlock: (() -> Void)?
/// 移动到顶部
public var goToTopPosiionBlock: (() -> Void)?
/// 移动到 底部 默认位置
public var goToLowestPosiionBlock: (() -> Void)?
/// 移动到 中间 默认位置
public var goToMiddlePosiionBlock: (() -> Void)?

我们看到FJFScrollDragHelper内部弱引用了外部滚动视图externalScrollView和内部滚动视图insideScrollView




  1. 关联对象,并给外部externalScrollView添加滑动手势




/// 添加 滑动 手势 到 外部滚动视图
public func addPanGestureRecognizer(externalScrollView: UIScrollView){
let panRecoginer = UIPanGestureRecognizer(target: self, action: #selector(panScrollViewHandle(pan:)))
externalScrollView.addGestureRecognizer(panRecoginer)
self.externalScrollView = externalScrollView
}



  1. 处理滑动手势




// MARK: - Actions
/// tableView 手势
@objc
private func panScrollViewHandle(pan: UIPanGestureRecognizer) {
/// 当前 滚动 内部视图 不响应拖动手势
if self.currentScrollType == .insideView {
return
}
guard let contentScrollView = self.externalScrollView else {
return
}
let translationPoint = pan.translation(in: contentScrollView.superview)

// contentScrollView.top 视图距离顶部的距离
contentScrollView.y += translationPoint.y
/// contentScrollView 移动到顶部
let distanceToTopH = self.getTopPositionToTopDistance()
if contentScrollView.y < distanceToTopH {
contentScrollView.y = distanceToTopH
self.curScrollViewPositionType = .top
self.currentScrollType = .all
}
/// 视图在底部时距离顶部的距离
let distanceToBottomH = self.getBottomPositionToTopDistance()
if contentScrollView.y > distanceToBottomH {
contentScrollView.y = distanceToBottomH
self.curScrollViewPositionType = .bottom
self.currentScrollType = .externalView
}
/// 拖动 回调 用来 更新 遮罩
self.panScrollViewBlock?()
// 在滑动手势结束时判断滑动视图距离顶部的距离是否超过了屏幕的一半,如果超过了一半就往下滑到底部
// 如果小于一半就往上滑到顶部
if pan.state == .ended || pan.state == .cancelled {

// 处理手势滑动时,根据滑动速度快速响应上下位置
let velocity = pan.velocity(in: contentScrollView)
let largeSpeed = self.slideInitSpeedLimit
/// 超过 最大 力度
if velocity.y < -largeSpeed {
gotoTheTopPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y < 0, velocity.y > -largeSpeed {
if self.curScrollViewPositionType == .bottom {
gotoMiddlePosition()
} else {
gotoTheTopPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > largeSpeed {
gotoLowestPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > 0, velocity.y < largeSpeed {
if self.curScrollViewPositionType == .top {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
}
let scrollViewDistanceToTop = kScreenH - contentScrollView.top
let topAndMiddleMeanValue = (self.topShowHeight + self.middleShowHeight) / 2.0
let middleAndBottomMeanValue = (self.middleShowHeight + self.lowestShowHeight) / 2.0

if scrollViewDistanceToTop >= topAndMiddleMeanValue {
gotoTheTopPosition()
} else if scrollViewDistanceToTop < topAndMiddleMeanValue,
scrollViewDistanceToTop > middleAndBottomMeanValue {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
}

处理滑动手势需要当前视图滚动类型currentScrollType和卡片当前所处的位置curScrollViewPositionType来分别进行判断。


/// 当前 滚动 视图 类型
public enum FJFCurrentScrollViewType {
case externalView /// 外部 视图
case insideView /// 内部 视图
case all /// 内部外部都可以响应
}

/// 当前 滚动 视图 位置 属性
public enum FJFScrollViewPositionType {
case top /// 顶部
case middle /// 中间
case bottom /// 底部
}

如下是对应的判断逻辑:


暂时无法在飞书文档外展示此内容


A. 在底部


 /// 回到 底部 位置
private func gotoLowestPosition() {
self.curScrollViewPositionType = .bottom
self.goToLowestPosiionBlock?()
}

private func gotoLowestPosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
}

只能滚动外部视图,内部滚动视图偏移量是0.


B. 在中间


/// 回到 中间 位置
private func gotoMiddlePosition() {
self.curScrollViewPositionType = .middle
self.goToMiddlePosiionBlock?()
}

private func gotoMiddlePosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
}

只能滚动外部视图,内部滚动视图偏移量是0.


C. 在顶部



  • 开始滚动判断:


    /// 更新 当前 滚动类型 当开始拖动 (当在顶部,开始滑动时候,判断当前滑动的对象是内部滚动视图,还是外部滚动视图)
public func updateCurrentScrollTypeWhenBeginDragging(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
}
}


  • 滚动过程中判断


/// 更新 滚动 类型 当滚动的时候,并返回是否立即停止滚动
public func isNeedToStopScrollAndUpdateScrollType(scrollView: UIScrollView) -> Bool {
if scrollView == self.insideScrollView {
/// 当前滚动的是外部视图
if self.currentScrollType == .externalView {
self.insideScrollView?.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
return true
}
if self.curScrollViewPositionType == .top {
if self.currentScrollType == .all { /// 在顶部的时候,外部和内部视图都可以滑动,判断当内部滚动视图视图的位置,如果滚动到底部了,则变为外部滚动视图跟着滑动,内部滚动视图不动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
} else if scrollView.isDecelerating == false,
self.currentScrollType == .insideView { /// 在顶部的时候,当内部滚动视图,慢慢滑动到底部,变成整个外部滚动视图跟着滑动下来,内部滚动视图不再滑动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
}
}
}
}
return false
}


  • 滚动结束判断


/// 当在顶部,滚动停止时候 更新 当前 滚动类型 ,如果当前内部滚动视图,已经滚动到最底部,
/// 则只能滚动最外层滚动视图,如果内部滚动视图没有滚动到最底部,则外部和内部视图都可以滚动
public func updateCurrentScrollTypeWhenScrollEnd(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .all
}
}
}

以上就是具体滚动判断相关处理逻辑,对应实现效果如下。



作者:果哥爸
来源:juejin.cn/post/7299731897626345481
收起阅读 »

读完一个人的朝圣,我觉得每一个人身上都有男主的影子

莫琳:”你会去很久吗?“ 哈罗德:”到街尾就回来“     一个童年缺少母爱,父爱,中年丧子,一生都在低头做事,不敢正视他人,可以在一个小酒厂工作四十年,却不求升职加薪,与人无争,与人无交的退休老头,跋涉六百多公里,穿越整个大不列颠,拿着一封信去拯救患了癌症的...
继续阅读 »

莫琳:”你会去很久吗?“


哈罗德:”到街尾就回来“


    一个童年缺少母爱,父爱,中年丧子,一生都在低头做事,不敢正视他人,可以在一个小酒厂工作四十年,却不求升职加薪,与人无争,与人无交的退休老头,跋涉六百多公里,穿越整个大不列颠,拿着一封信去拯救患了癌症的老友,听起来应该是无聊透了。远远没有,帅气潇洒,带领古巴人民与美国死磕几十年,躲过n = 700次暗杀,成功睡到美国中情局女特工,并令其爱上自己,传闻睡过35000个女性的菲德尔*卡斯特罗的故事令人感到有种有趣有料。的确,哈罗德是一个平凡以至于差劲糊涂的人,但远没有那么简单,哈罗德、哈罗德,作者是借哈罗德写我们的父母,我们的孩子,我们的朋友,我们亲爱的自己,我们每个人身上可能都有哈的影子,如今这个影子开始征程,或叫朝圣,我更倾向于叫“做梦”。因为这真的像是哈罗德的一场梦。


  这里没有艺术,没有历史,儿女情长,伟大,浪漫。


  这里只有回忆,哈罗德 距离终点很近很远,近的是,一生真的很短,远的是,回忆又是那么漫长。


”没有爱的生活不叫生活“


    哈罗德有一个酒鬼父亲,从不关心他的儿子,在十六岁生日那天,将哈赶出了家门,而他的母亲,早在很久以前已经抛弃了这个家庭去周游世界。在父母那里哈罗德没有得到一丝爱,好像还不如很多人养的宠物的到的关爱多,没有得到爱的哈罗德好像也吝啬于给予爱,路上他在回忆道:“他欠过去一点点慷慨”,但他本人是“喜欢和人交往的,希望明白她们之所爱,之所失”。哈不是一个自私心眼坏的人,(和生活中大多人一样,心眼不坏,好多父母也说,自己孩子“心眼不坏”,这个词有参考价值吗?)他只是不会表达,这种表达好像也不仅仅只是 羞于对儿子说:“爸爸爱你”,也不是:“儿子,你真棒,爸爸为你自豪”(他的儿子没有过任何亲昵的经历)。更不是对爱人说一句:”亲爱的“,好像这个人本身:”没有多少爱“,就好像爱是水,爱是食物,是力气,我们能看到摸到能展示给别人看,”看我有的是力气,我有的是爱“,就像没有力气的男人,哈罗德是一个没有爱的男人。哈罗德倒不是冷漠,也不是自私,在我看来,哈罗德一个60岁的老头,其实是个孩子,一个没有被给予爱与鼓励多的孩子,一个没有受到爱的给予的孩子,是不会轻易去爱别人,爱这个社会的。他们没有爱人的能力,更很难让人感受到来自他内心的热情与爱(友爱,情爱,亲情……)。


  哈罗德是一个“没有妈的孩子”,上学时,哈总是被小伙伴们这样嘲笑。它是一个极其脆弱敏感的孩子,他不敢交朋友,他不敢直视别人,甚至整个屋子的人看他一眼,都会脸红到脖子,他对儿子很少给予父爱,对妻子关怀,理解不够,这些都源于童年,缺失的亲情。


    一个人的成长的表现就是爱越来越多的人,事。即使受到伤害。


  (我不是鼓吹爱与自由可以战胜一切,不是让别人去滥情,只想说,帮帮我们身边的人,每个人,哪怕一个微笑,这也是脆弱的哈罗德坚持走到终点的原因之一)


”婚姻“


  莫琳,哈的爱人,总爱重复哈的话,要不就是”我不这么认为“,两人因为儿子的悲剧分道扬镳,只是名义上的夫妻,在哈走后,莫琳经历了一系列的情绪波动,先是惊讶,抱怨,而后焦虑不安,后是平静的回忆,直到对哈的日夜思念,莫琳因为哈罗德对儿子的不作为,而抱怨,恨着哈罗德,二十年之久,莫琳本身是一个普通的女性,无论作为母亲,妻子,都算是合格的,那什么让两人冷漠二十年,让婚姻只剩下房子一个躯壳,是哈罗德的性格缺陷?两人沟通不够?是残酷的现实?…………,


  哈的徒步之旅让他们重新走到了一起,值得一提的是,两人对于初见时的回忆时如此的不同。(男人女人是两个物种)


  莫琳的回忆:"他微弯下腰,嘴唇贴近她的耳朵,伸手拨开她的一绺头发,才开口说话。这大胆的举动让她感到一股强烈的电流顺着脖子传上来,甚至今日想起,肌肤下仍能感受到那一份悸动。他说了什么?无论说了什么,都肯定是极其有趣的内容,因为两人都笑得歇斯底里,还尴尬地打起嗝来。她想起他转身走向酒吧取水时衣角扬起的样子,想起自己乖乖地站在原地等他。那时好像只有当哈罗德在附近,世界才有光。那两个跳得、笑得如此畅快的年轻人如今去了哪里?"


  哈罗德的回忆:“哈罗德在跳舞,突然发现隔着一整个舞池的莫琳在看着他。他还记得那一刻疯狂地挥舞四肢的感觉,仿佛要在这个美丽女孩的见证下甩掉过去的一切。他鼓起勇气,越跳越起劲,双腿踢向空中,双手像滑溜溜的海鳗扭动。他停下来仔细观察,她还在看着他,这次她碰到他的目光,忽然笑了。她笑得那样乐不可支,抖着肩膀,秀发拂过脸庞。他生平第一次不由自主地穿过舞池,去触碰一个完全的陌生人。天鹅绒一样的秀发下,是苍白而柔软的肌肤。她没有回避。“


  维系婚姻的到底是什么?A爱情,B浪漫的回忆,C未来的期盼,D,子女 E,两个人的时候感觉孤独,一个人时更孤独(莫琳回忆道)F,安于现状G,人丑又穷离不起(老婆太厉害,不敢提)


……敲黑板!!!这道题超纲,考试不考……


信仰与理智


  读完之后,我问过自己,哈罗德为什么要徒步去远在千里的贝里克?支撑他的是什么?


  他在经历一路的跋涉到底吃了多少苦?值不值?关键是他是个沉闷、死气沉沉没有活力的哈罗德。做出这个选择不可思议,作者提到将这归为“期待一种比不言自明的现实更大,更疯狂也更美好的可能”。在我看来,哈罗德身上有很多缺陷,他自己给自己贴了太多标签,沉默逆来顺受,软弱,羞涩……,但人不是一个个标签构成的,通过一个个的标签并不能完全解释一个人的行为,事实上,作者没有解释也不用解释,哈为什么做了这个决定,因为包括这件事在内的很多事都是有一种奇思妙想甚至吓到自己,自己也不知从哪冒出来的想法开始,首先,在那一时一刻,理想梦想狂想疯想轮奸了理智,从此孕育了伟大,自己被惊讶、震撼、感动,从而迈出了一步,两步,就像疑问自行车就两个轮子为什么可以冲的那么快,疑问它为什么不倒一样,没有人告诉你为什么?而你同样可以骑上去,跑的比风还快,还自由。你做到了,哈罗德的决定也做的那么简单与自然。


  做决定容易,简单的决定,简单的计划不一定成功,仅仅一双帆布鞋(属于海洋,却行走于陆地),一路上,哈曾羞于解释自己的计划,但一路的陌生人,即使有再多的不理解,不可思议,也都给予了支持,或物质上,或精神上,他们都是哈罗德走下去的动力,哈罗德感谢他们,正是这些动力让他尝试去冲破囚禁它一生的牢笼,束缚,释放了年轻时候,转瞬即逝的野性,冒险与激情,但是哈罗德毕竟只是哈罗德,标签解释不了全部,却注定哈罗德在那么一个阶段,否定自我,否定这次徒步的意义。


  人生就像一个缓冲区,就像数据库buffer,操作系统buffer,各种buffer一样,缓冲区存在的意义,在于解决通信双方处理信息的速度不同的问题,人生的buffer,怎么讲?得与失,的是写入buffer,失  是移除buffer。


  朋友有失有得,太多照顾不过来,就要失去热度低得,太少,你会感到孤独,又会去交新朋友,


从人生来讲,不只是朋友,童年,青年,中年,老年,童年的我们是空的buffer,接受各种各样的信息,我们每天都在快速的成长,不曾感到失去,直到青年,我们开始困惑,感伤,为了长大,活下去,为了责任,不得不得到很多不想得到的,失去很多宝贵的,(大话西游的主题,成长,得与失)。到了中年,可能渐渐的习惯了得失,不再多愁善感,这是我们可能是很多人心中的英雄和榜样,只有我们自己知道,我是个什么样的混蛋,到了老年,得到的可能仅仅是白发,失去的与日俱增,缓冲区越来越小,我们没有了憧憬,只剩下回忆,我们已经不是年轻的感伤,更多是一种超然的释怀。因为除了生命,任何都不属于我。


哈罗德在救赎曾经的哈罗德


  哈罗德呢?他老了,他在徒步,他在救赎自己,他在朝圣,但他并不是英雄,他正在失去很多,跟普通人不同的是,他早就失去了很多,甚至很多不曾得到,父爱,母爱,朋友,儿子,爱人,这些失去放在有这么一个性格缺陷的老头身上,有点残忍,但上帝在决定你命运的时候,才不会管你是不是很可怜了,更不会征询你的意见,就这样 ,哈罗德在失去唯一的陪伴——小狗后(一条小狗,它的名字就叫“小狗”,哈罗德就这么叫),崩溃了,没有了时间概念,没有了距离,以前用英里丈量到终点的距离,现在用回忆,在一幅幅回忆前 ,哈罗德真的要放弃了,莫琳在哈走的日子里也在回忆,回忆年轻,回忆自己对哈罗德过分要求,与缺失的理解,在哈崩溃时,莫琳恢复了以前的温情与鼓励,哈罗德蹒跚的走到了终点,老朋友,结束了,但她们的婚姻好像重新开始。


后记:


在我观察,这本小说,有爱需要表达,婚姻,信仰,三个主题,这是一本鸡汤小说,出自英国作家蕾秋乔伊斯,这是第二本我非常喜欢的女作家写的小说,以前以为女作家只会写给女孩的鸡崽文学,什么女孩你必须优秀…………,XXX,或者儿女情长,纯粹YY的爱情小说,现在证明,我错了,女作家也可以写出能体现对社会对历史对人性的深刻理解的作品(《穆斯林的葬礼》),他们对人心理的细腻把握,同样让人感到有趣,这本小说里,我并没有对哈罗德,旅途的见闻,中间媒体炒作,投机分子借机包装,哈罗德与荃妮的友谊做过多感想,因为我觉得这只是路上的故事,大树的枝叶,任何一部小说都要有插曲丰满主人公的形象,但主题从头贯穿到结尾,其中婚姻的主题很明显,但是我经历有限,不能提出特别的见解,望请见谅。


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

耗时七天,我写完了自己的第一个小程序

web
一入红尘深似海。 自2016年参加工作时申请了第一张信用卡,至今已有七年矣。在这七年之中,自己信用卡的数量由1张逐渐增加到12张,后又慢慢减少到如今的5张。七年的卡海浮沉让我从初入社会的一无所有,到如今的负债累累。 当然,这篇文章并不是来记录自己七年的负债之旅...
继续阅读 »

一入红尘深似海。


自2016年参加工作时申请了第一张信用卡,至今已有七年矣。在这七年之中,自己信用卡的数量由1张逐渐增加到12张,后又慢慢减少到如今的5张。七年的卡海浮沉让我从初入社会的一无所有,到如今的负债累累。


当然,这篇文章并不是来记录自己七年的负债之旅,而是在经历了多年“钱都去哪儿了?”的内心呼唤后的心灵觉醒:还是要有个账本记账啊!


我的需求并不复杂:



1、可以快速的知道如今自己卡的总额度是多少,还需要还的欠款是多少(清楚负债情况)


2、可以快速知道每张卡的可用额度是多少,账单日是哪天(便于刷卡时明确知道该刷哪张卡,不至于出现今天刚刷了卡,明天就出了账单要还的现象)


3、可以知道每个月刷卡的总手续费是多少(清楚损益,明白每个月的损耗)


4、记录收支(了解每一分钱都去了哪里)



基于以上四个简单的需求,在尝试现在市面上十几款记账软件后,我惊奇的发现:竟然没有一款合适的软件可以满足我的需求!


于是,我做了一个【XXXX】的决定:自己来写一个工具吧!


然后,就诞生了我发布的第一个小程序:了账


小程序简介


了(liao)账是一款简洁的记账小程序。了账中的了字,是明了之意,清楚明白自己的账目,亦是了结之意,祝愿各位卡友早日上岸。


了账的页面相对简洁,有【未登录】【已登录】两种状态展示,如下图所示:


【未登录】


截屏2023-10-23 08.19.09.png


【已登录】


截屏2023-10-23 08.27.30.png


了账只有账户账单两个tab页,分别用来展示当前账户信息和查看收支记录。


账户页面展示了用户(目前只有作者本人😄)较为关心的几个数据:【当前额度】、【可用额度】、【现金额度】、【信用卡总览】、【当前账户】。


账单页面除了查看每一笔收支记录外,在顶部也展示了当月总出账、总入账信息。


截屏2023-10-29 14.53.05.png


新增账户


用户可通过【新增】按键创建账户,在账户页面,顶部账户信息会随之动态改变。如下图所示:


截屏2023-10-29 15.05.44.png


在新增页面,用户可点击账户类型修改新增账户的类型,目前【了账】共包含【信用卡】、【储蓄卡】、【支付宝】、【微信】、【其他】共五类账户。除信用卡外,其余四类账户额度统一归类为【现金额度】。


信用卡除了【固定额度】之外有时会给一部分【临时额度】,因此,在新增账户页面,除了【固定额度】之外,添加了【当前额度】字段。【当前额度】是包含【固定额度】和【临时额度】的账户总额度。


新增收支


当用户创建过账户后,就可以点击【账户】页面右下角【记一笔】浮块创建收支记录,并在【账单】页面查看。相应的,账户页面所展示的【账户信息】也会随之动态改变。如下图所示:


截屏2023-10-29 15.24.00.png


在记录收支时,不同的账户类型可选的账单类型也不相同。如:信用卡账户下可选择的账单类型为【日常支出】、【个人刷卡】、【账单还款】,储蓄卡账户下可选择的账单类型为【日常支出】、【日常收入】、【转账支出】、【转账收入】,支付宝账户微信账户其他账户则多出【提现】类型可供选择。如下图所示:


截屏2023-10-29 15.41.54.png


当账单类型为【日常支出】时,则须选择支出类型。目前共有【食】、【行】、【衣】、【住】、【娱乐】、【其他支出】六类支出可供选择。如下图所示:


截屏2023-10-29 15.35.20.png


信用卡账户账单类型为【个人刷卡】,以及支付宝账户微信账户其他账户账单类型为【提现】时,则需填写【收款金额】。收款账户为除【信用卡账户】外的其他账户,收款金额为除去手续费之外的实际到账金额。如下图所示:


截屏2023-10-29 15.53.45.png


账单的编辑、删除和账户的编辑、删除


用户可通过左滑对当前账户及当前收支进行编辑、删除。当收支被删除后,账户信息将会回退该笔收支。当账户被删除后,该账户下的所有收支将不可被编辑、删除。如下图所示:


截屏2023-11-11 12.27.15.png


账户详情和账单详情


点击每个账户和账单,可进入详情页,查看详情信息。如下图所示:


截屏2023-11-11 14.25.39.png


写在最后


账本只是工具,最主要的还是要诸位卡友调整好心态,量入为出。祝愿各位早日上岸!!!


写代码用了7天,备案发布将近一个月!!!最后上线认证居然还收了30块巨款!!!至今仍未明白:经历了实名注册小程序号,实名IPC备案后,最后上线认证的意义在哪里?难道只为承袭小马哥一贯的氪金传统?


作者:凡铁
来源:juejin.cn/post/7299733832413069347
收起阅读 »

整理了七八年的笔记后,感觉很累,好像并不值得......

🔊整理了七八年的笔记(包括收藏别人的),感觉很累,回顾后好像并不值得那么做,于是对收藏文章,做笔记,学知识有了一些不一样的感悟,于是写下此文,警示自己。 📚一、收藏怪 收藏不等于吸收,收藏的文章不消化,还是别人的,收藏不等于学会了知识点,警惕陷阱。 ...
继续阅读 »

🔊整理了七八年的笔记(包括收藏别人的),感觉很累,回顾后好像并不值得那么做,于是对收藏文章,做笔记,学知识有了一些不一样的感悟,于是写下此文,警示自己。



📚一、收藏怪




  1. 收藏不等于吸收,收藏的文章不消化,还是别人的,收藏不等于学会了知识点,警惕陷阱。




  2. 重复的事情不要做第二遍,当初偷懒没理解收藏的文章,到头来还得从头开始理解;但却做了一些重复的事情。生命其实很宝贵。




  3. 收藏文章要精选,收藏的文章过多会增加未来的负担; 再看是消耗未来的时间,未来的时间一定比现在的时间更加宝贵,请慎重地做文章收藏。好的文章点个赞,不想错过知识就及时总结消化。




去阅读了那些以前收藏的文章,当初没有懂的,现在也可能没有懂;现在懂的也并不是因为收藏了才懂的。




📒二、断舍离


那些似懂非懂的知识,要学会断舍离。收藏的文章,记录的笔记;并不是越多越好,数量可能带来质量的下降,可能充斥重复,甚至废话。



  1. 做减法,该丢掉就丢掉。 这是生活经验;也是哲学道理。

  2. 似懂非懂的知识,多了只会让自己难受。通透才能轻松。什么都想要,什么也达不到最好,反而容易让自己撑着。

  3. 少即是多,抓重点。什么都想学,什么也学不好。知识也是有二八定律的。

  4. 不要废话,保持简单,一句话能说清楚的事情,就不要两句话。

  5. 知识是会给增加烦恼、负担的。别让知识给自己制造无形的麻烦、也不给自己未来添堵。

  6. 允许自己有知识不会,也别想着什么都会;更别想着什么都学。




📑三、兴趣



  1. 不喜欢就不要学,讨厌的知识容易遗忘。学习知识也要取悦自己。

  2. 学习自己感兴趣的,这样才能深耕,深耕那些不喜欢的知识,会让自己痛苦。

  3. 学习是需要时间成本的,而且很大,对自己也要投其所好。




📜四、偷懒



  1. 记录再多不理解的笔记、知识,不及时消化,还是不会理解的,有用吗?这样建议别做。

  2. 摒弃那种笔记做越多越充实的自我欺骗行为。要做一个偷懒的人




✒️五、总结


最近整理大量以前的学习笔记,以及各种技术收藏文档;发现了很多无用的,累赘的;不懂的还是不懂;似懂非懂的还是似懂非懂。而做笔记的过程却花费了太多时间。 把事情寄托于未来,成本更高。思维上要学会偷懒。别用笔记上的勤奋来掩饰思维上的懒惰。


🔊注:有一些偏激的观点,但本文仅代表当下自己的想法。


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

移动端APP版本治理

1、背景 在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。 只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。 而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中...
继续阅读 »

1、背景


在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。


只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。


而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中间还有一个更新升级的空档期,多数公司在这里都是一个“三不管”地带,而这个空档期,我称之为版本交付的最后一公里



2、价值



2.1、业务侧


总有人会挑战“有什么业务价值?”对吧,那就先从业务价值来看。


尽管有些app的有些业务是动态发布的,但也一定会有些功能是依赖跟版的,也就是说,你没办法让所有用户都用上你的新功能,那对于产运团队来说,业务指标就还有提升的空间。


举两个例子:



  1. 饿了么免单活动需要8.+版本以上的app用户才能参与,现在参与用户占比80%,治理一波后,免单用户参与占比提升到90%,对业务来说,免单数没变,但是订单量却是有实实在在的提升的。

  2. 再来一个,酷狗音乐8.+的app用户才可以使用扫码登录,app低版本治理之后,扫码登录的用户占比势必也会提升,那相应的,登录成功率也可以提升,登录流程耗时也会缩短,这都是实实在在的指标提升。



虚拟数据,不具备真实参考性。



2.2、技术侧


说完业务看技术,在技术侧也可以分为三个维度来看:



  1. 稳定性,老版本的crash、anr之类的问题在新版本大概率是修复了的,疑难杂症可能久一点;

  2. 性能优化,比如启动、包大小、内存,可以预见是比老版本表现更好的,个别指标一两个版本可能会有微量劣化,但是一直开倒车的公司早晚会倒闭;

  3. 安全合规,不管是老的app版本还是老的服务接口,都可能会存在安全问题,那么黑产就可能抓住这个漏洞从而对我们服务的稳定性造成隐患,甚至产生资损;


2.3、其他方面


除了上面提到的业务指标和用户体验之外,还有没有?


有没有想过,老版本的用户升上来之后,那些兼容老版本的接口、系统服务等,是不是可以下线了,除了减少人力维护成本之外,还能减少机器成本啊,这也都是实打实的经费支出。


对于项目本身来说,也可以去掉一些无用代码,减少项目复杂度,提升健壮性、可维护性。


3、方案



3.1、升级交互


采用新的设计语言和新的交互方式。


3.1.1、弹窗样式


样式上要符合app整体的风格,信息展示明确,主次分明。


bad casegood case

3.1.2、操作表达


按钮的样式要凸显出来,并放在常规易操作的位置上。


bad casegood case

3.1.3、提醒链路


从一级菜单到二级页面的更新提醒链路,并保持统一。



3.1.4、进度感知


下载进度一定要可查看并准确,如果点了按钮之后什么提示都没有,用户会进入一个很迷茫的状态,体验很差。



3.2、提醒策略


我们需要针对不同的用户下发不同的提醒策略,这种更细致的划分,不光是为了稳定性和目标的达成,也是为了更好的用户体验,毕竟反复提醒容易引起用户的反感。


3.2.1、提醒时机


提醒时机其实是有讲究的,原则上是不能阻塞用户的行为。


特别是有强制行为的情况,比如强更,肯定不能在app启动就无脑拉起弹窗。



bad case:双十一那天,用户正争分夺秒准备下单呢,结果你让人升级,这不扯淡的吗。



时机的考虑上有两个维度:



  1. 平台:峰时谷时;

  2. 用户:闲时忙时;


3.2.2、逻辑引擎


为什么需要逻辑引擎呢?逻辑引擎的好处是跨端,双端逻辑可以保持一致,也可以动态下发。




可以使用接口平替,约定好协议即可。



3.2.3、软强更


强制更新虽然有可以预见的好效果,但是也伴随着投诉风险,要做好风险管控。


而在2023-02-27日,工信部更是发布了《关于进一步提升移动互联网应用服务能力》的通知,其中第四条更是明确表示“窗口关闭用户可选”,所以强更弹窗并不是我们的最佳选择。



虽然强更不可取,但是我们还可以提高用户操作的费力度,在取消按钮上增加倒计时,再根据低版本用户的分层,来配置不同的倒计时梯度,比如5s、10s、20s这样。


用户一样可以选择取消,但是却要等待一会,所以称之为软强更



3.2.4、策略字段



  • 标题

  • 内容

  • 最新版本号

  • 取消倒计时时长

  • 是否提醒

  • 是否强更

  • 双端最低支持的系统版本

  • 最大提醒次数

  • 未更新最大版本间隔

  • 等等


3.3、提示文案


升级文案属于是ROI很高的了,只需要总结一下新版本带来了哪些新功能、有哪些提升,然后配置一下就好了,但是对用户来说,却是实打实的信息冲击,他们可以明确的感知到新版本中的更新,对升级意愿会有非常大的提升。



虽然说roi很高,但是也要花点心思,特别是大的团队,需要多方配合。


首先是需求要有精简的价值点,然后运营同学整合所有的需求价值点,根据优先级,出一套面向用户的提醒话术,也就是提示的升级文案了。



3.4、更新渠道


iOS用户一般在App Store更新应用,但是对于Android来说,厂商比较多,对应的渠道也多,还有一些三方的,这些碎片化的渠道自然就把用户人群给分流了,为了让每一个用户都有渠道可以更新到最新版本,那就需要在渠道的运营上下点功夫了,尽可能的多覆盖。



除了拓宽更新渠道之外,在应用市场的介绍也要及时更新。


还有一点细节是,优化应用市场的搜索关键词。


最后,别忘了自有渠道-官网。


3.5、触达投放


如果我们做了上面这么多,还是有老版本的用户不愿意升级怎么办?我们还有哪些方式可以告诉他需要升级?


触达投放是一个很好的方式,就像游戏里的公告、全局喇叭一样,但是像发短信这种需要预算的方式一般都是放在最后使用的,尽可能的控制成本。




避免过度打扰,做好人群细分,控制好成本。



3.6、其他方案


类型介绍效果
更新引导更新升级操作的新手引导😀
操作手册描述整个升级流程,可以放在帮助页面,也可以放在联系客服的智能推荐🙂
营销策略升级给会员体验、优惠券之类的😀
内卷策略您获得了珍贵的内测机会,您使用的版本将领先同行98%😆
选择策略「88%的用户选择升级」,替用户做选择😆
心理策略「预计下载需要15秒」,给用户心理预期😀
接口拦截在使用某个功能或者登录的时候拦截,引导下载最新版本😆
自动下载设置里面加个开关,wifi环境下自动下载安装包😀
版本故事类似于微信,每个版本都有一个功能介绍的东西,点击跳转加上新手引导🙂

好的例子:


淘宝拼多多

4、长效治理


制定流程SOP,形成一个完整链路的组合拳打法🐶。


白话讲,就是每当一个新版本发布的时候,明确在每个时间段要做什么事,达成什么样的目标。


让更多需要人工干预的环节,都变成流程化,自动化。



5、最后


一张图总结APP版本治理依次递进的动作和策略。



作者:yechaoa
来源:juejin.cn/post/7299799127625170955
收起阅读 »

为什么稳定的大公司不向Flutter迁移?

迁移很难, 但从头开始很简单 从Flutter的测试版开始, 我就一直在关注它, 从那时起, 我就看到了Flutter在开发者, 社区和公司中的采用. 大多数新兴开发人员都曾多次讨论过这个问题: 为什么大公司不使用 Flutter? 因此, 这篇小文只是我...
继续阅读 »

迁移很难, 但从头开始很简单


从Flutter的测试版开始, 我就一直在关注它, 从那时起, 我就看到了Flutter在开发者, 社区和公司中的采用. 大多数新兴开发人员都曾多次讨论过这个问题:



为什么大公司不使用 Flutter?



因此, 这篇小文只是我对这个问题的个人观察和回答.


转向新技术既困难又复杂, 而从最新技术重新开始则很容易. 这(据我观察)也是为什么稳定的大公司不会将其长期使用的应用程序迁移到新技术的最大原因之一, 除非它能带来惊人的利润.


你会发现大多数初创公司都在使用 Flutter, 这是因为 90% 以上的跨平台应用程序新创意都可以在Flutter中以经济高效的方式轻松实现. Flutter中的一切都非常快速, 令人惊叹, 而且具有我们在过去几年的Flutter之旅中听说过的所有令人惊叹的优点.


那么问题来了:


既然Flutter如此令人惊叹, 高性价比, 单一代码库, 更轻松的单一团队管理, 令人愉悦的开发者体验, 等等等等; 那么为什么大公司不将他们的应用程序迁移到Flutter呢? 从头开始迁移或重写, 拥有单一团队, 单一代码, 这不就是天堂么?



不, 没那么简单.



问题所在: 业务vs技术热情


Flutter 令人惊叹, 你最终可以说服开发人员在公司中使用Flutter构建应用程序. 问题在于公司的运营业务. 企业希望Flutter能立即为业务做出贡献. 他们不希望等待自己的团队完全重写应用程序, 然后将其付诸实践以繁荣业务.


但正如我前面所说, 对于技术团队来说, 重写是最理想的. 因此, 这是一个可以由公司利益相关者共同思考的问题. 公司内部需要在分析领域, 业务类型, 团队文化等所有因素后, 找到一个中间立场.


无论如何, 让我们来看看这两种情况的结果如何.


从头开始重写: 技术方面


一家大公司拥有庞大的产品, 这些产品已融入流程和领域, 工作完美无瑕, 为企业完成了工作.


从技术上讲, 对首席技术官来说, 最好的办法是在Flutter上从头开始重写应用程序, 并将其完成. 但是, 如果他们决定这样做, 就必须雇佣一个全新的Flutter开发团队, 向他们解释当前产品/领域的所有情况, 并让他们重写应用程序. 这看起来很容易, 其实不然. 当前代码库中有很多内部知识必须传授给新团队, 这样他们才能为应用程序构建完全相同的体验/UI/UX/流程.



为什么构建完全相同的东西如此重要?



这是因为用户总有一天会第一次收到Flutter构建的应用程序更新, 这对于一个拥有成千上万用户的应用程序来说是非常危险的.


其次, 新功能正在当前应用的基础上构建, 重写后的应用可能无法赶上当前应用. 但这是一个商业决策(是停止新开发并先进行重写, 还是继续在当前应用程序中添加功能, 无论如何都要权衡利弊).


每家公司在领域, 文化, 人员, 领导力, 思维过程, 智囊团等方面都是独一无二的. 内部可能存在数以百计的挑战, 只有进入系统后才能了解. 你不可能对每家科技公司都提出一个单一的看法.


从头开始重写: 业务方面


公司在采用新事物时, 有一个非常重要的想法:



在重写的过程中, 业务不仅不应受到影响, 而且还应保持增长.



这意味着你不能在运行中的应用程序的功能和开发上妥协. 在重写版本中, 运行正常的程序不应出现错误. 为确保这一点, 需要进行严格的原子级测试, 以确保用户从一开始就掌握的功能不会出现任何问题(我们谈论的是大公司, 这意味着应用程序已运行多年). 我们可以进行单元/集成/用户界面测试, 但当它关系到业务, 金钱和用户时, 没有人会愿意冒这个险.


简而言之, 大多数稳定的公司不会决定从头开始用Flutter重写他们稳定的应用程序. 如果是基于项目的公司, 他们可能会使用Flutter启动新赢得的客户项目.


迁移(业务上友好, 技术上却不友好)


公司决定迁移到Flutter的另一种方式是逐屏迁移到 Flutter, 并使用与本地端(Talabat 的应用程序)通信的方法渠道. 对于技术团队来说, 这可能是一场噩梦, 但对于业务的持续运行, 以及让Flutter部分从一开始就为业务做出贡献来说, 这是最可行的方法(在重写过程中, 应用程序的Flutter部分除非上线到生产, 否则对业务没有任何用处).


作为一名读者和Flutter爱好者, 你可能会认为逐屏迁移非常了不起, 但实际上, 当你在一个每天有成千上万用户使用的生产应用程序中工作时, 这真的非常复杂. 这就像开颅手术.


总结一下


根据我的观察, 对于一家以产品为基础的公司来说, 决定将自己多年的移动开发技术栈转换为新的技术栈是非常困难的. 因此, 如果大公司真的决定转换, 这个决定本身确实值得称赞, 勇气可嘉, 也很有激励作用.


如果业务非常重要(如 Talabat, Foodpanda, 或涉及日常大量使用, 支付, 安全, 多供应商系统等的用户关键型应用程序), 那么从业务角度来看, 最理想的做法是以混合方式慢慢迁移应用程序. 同样, 这也不一定对所有人都可行, 重写可能更好. 这完全取决于公司和业务的结构以及决策的力度.


对于以项目为基础的公司来说, 使用Flutter启动新项目是最理想的选择(因为他们拥有热情洋溢, 不断壮大的团队, 并致力于融入新技术). 当他们使用Flutter构建新项目时, 如果交付速度比以前更快, 效率更高, 他们就会自动扩大业务.


对于开发人员和技术团队来说, 任何新技术都是令人惊叹的, 但如果你是一位在结构合理, 稳定的公司工作的工程师, 你也应该了解公司的业务视角, 从而理解他们对转用新技术的看法.


如果你是一名高级工程师/资深工程师, 你应该用他们更容易理解的语言向业务部门传达你的热情. 对于推介 Flutter, 可以是更少的时间, 更少的成本, 更少的努力, 更快的交付, 一个团队, 一个代码库, 更少的公关审查等(如果你是Flutter人员, 你已经知道了所有这些).



业务部门在做决定时必须考虑多个方面, 因此作为工程师, 要告诉他们一些能让他们更容易做出决定的事情.



以上是我的个人观点和看法, 如果你有不同的看法或经验, 请随时在评论中与我分享, 很乐意参与该问题的讨论.


Stay GOLD!


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

阿里云又崩了,这次送什么

双十一的烟味还没散,昨天下午至晚间(11月12日),阿里云又崩了,而且语雀又没逃过去,从出现故障到完全恢复长达3个半小时,这次又是怎么回事呢? 这次受影响最大的是OSS服务,也就是阿里云的对象存储服务,对外提供的是图片、视频,以及各种文件的读写服务,可千万别小...
继续阅读 »

双十一的烟味还没散,昨天下午至晚间(11月12日),阿里云又崩了,而且语雀又没逃过去,从出现故障到完全恢复长达3个半小时,这次又是怎么回事呢?


这次受影响最大的是OSS服务,也就是阿里云的对象存储服务,对外提供的是图片、视频,以及各种文件的读写服务,可千万别小看这个服务,我们平常聊天发图片、发文件,网站上展示的图片、视频,以及各种js、css、字体等文件可能都是放在OSS服务中的。


从网友的反馈来看,仅阿里系受影响的产品就包括:阿里云OSS、语雀、钉钉、阿里云盘、淘宝、闲鱼等,应该还有些用户相对少的服务没有爆出来,毕竟阿里系产品众多,对象存储在每个产品中都是不可或缺的。



使用阿里云OSS服务的外部公司产品估计就更多了,只是大家量小体微,没有掀起什么狂风巨浪,我这里也多多少少受了一点影响,上传文件出错。



对于这次故障阿里云发了一个公告,文字不少,大家看看是否清楚明白。



根源是某个底层管控服务组件出现了问题,不过没说这个组件是干什么的,起到什么作用。再看受影响的产品还是挺多的,队列消息服务都被波及了。我猜测可能是底层存储的某个控制程序又出问题了,而且是个广泛部署的服务,可能是鉴权,否则不会影响这么广泛,恢复这么缓慢。


话说同样是阿里系的语雀前段时间刚崩了七个多小时,这么快阿里云又崩了,还恰逢双十一,再往前翻,去年阿里云香港机房还挂了两天,这不免让人对阿里的技术心生疑虑,加之社会对阿里的管理文化颇多吐槽,如此下去,阿里在人们的心中可能就要堕落下去了,可以说有向悬崖边滑落的风险。


频繁的出现问题,可能不仅仅是技术上的问题了,管理制度上可能也存在一些风险,相信阿里的王博士和周大佬也想得到,这里就不瞎指挥了。



不知道阿里云这次怎么给大家交代,看看网友们的期待吧。



不过我们使用阿里云的产品,以前多多少少都出现过一些问题,前几年产品经理会主动给些补偿,不过最近几年,影响不大的话,用户不要求,也少见阿里云主动补偿。对于此事,后续我会继续关注。


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

30岁之前透支,30岁之后还债。

前言 看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。 今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。 愉悦二字说来容易,但各位都一样,奔波于现实,劳累于...
继续阅读 »

前言


看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。


今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。


愉悦二字说来容易,但各位都一样,奔波于现实,劳累于生活,岂是三言两语就能改变的。


病来如山倒


我又病了,有些意外和突然的,令我措手不及。


一天早上我起来,脖子有些酸,就伸手揉揉捏捏,忽然发现脖颈左侧有一个肿块,仔细拿捏,发现竟然是在里面,而且硬邦邦的,伴有轻微的疼痛感。


当时早上对着镜子拍下来的肿块,我还保留了照片。


1.jpg


立马便一身冷汗冒出,我从未经历过这样的事情,去年身体毕竟出过问题,两相叠加之下,内心更是难以描述。


因为是周一,怀着忐忑的心情去上班了,接下来一直都有些神经兮兮,觉得自己身体出了大问题。


之前我有文章讲过自己去年其实已经检查出血脂的问题,停更半年之久,调养了一番,才真正感觉到身体有所恢复,根据我发文的日期可见一二。


恢复更新的这段时间,报复式地写作和分享,一度不知不觉地排到榜单第二,今天登录看了一下,居然还在月榜前三没下来,也是意外。


话说回来,人一旦身体冒出点病痛,整个心情都显得低沉萎靡,很快就能在方方面面反应出来。


我是硬着头皮上班的,抽空网上查了下好让自己有个心理准备。



百度一搜便是绝症,这是很多年前就知道的,但病急乱投医果然是人之本性,我毅然决然还是搜了。


然后,各种甲状腺之类的就来了,再搜,淋巴瘤也来了,再搜,好家伙,直接恶性肿瘤十有八九了。



面对未知而产生的接近绝望的心情,想必不少人有类似经验。


比如我,下意识先想到的竟然不是我是不是要完蛋了,而是想到自己是家中独子,父母年迈身体有恙,妻子操劳,孩子尚小,家中主要经济来源也是我。


我一旦倒下,实在不敢想,往深了一想各种负面因子都蜂拥而来。


我不知道有多少人和我的性格相似,就是身体出了这种未知的问题,一面觉得应该去医院看看,一面又怕折腾来去最后拿到最不可接受的结果,可能不知道反而能活久一点,大概就是这种心情了。


是的,我大体是个胆子还算大的人,也猛然间抗拒去医院了。


不去医院的结果,就是你每天都在意这个肿块,每天都要摸摸它是不是变小了,是不是消失了,每天都小心呵护着它,甚至还想对它说说话倾诉一下,像是自己偷养的小情人一样。


只盼着某天睡觉醒来,用手一摸,哈哈没有了这样。


我就是差不多一个月都这样惶惶不可终日地度过,直到这周六才被妻子赶去医院做了检查。


透支和还债



30岁之前透支,30岁之后还债。



说来好笑,摸到肿块的第二天吧,还有朋友私信找我合作,换做平时,我肯定欣然接受,并开始设计文稿。


但身体有问题,一切都索然无味了,再次真切地体会到这种被现实打碎一切欲望的撕裂感。


2.png


为什么我30岁之后身体慢慢开始出现各种问题,这两年我有静下心来思考过。


到底还是30岁之前透支太多了,30岁之后你依然养成30岁之前的生活习惯,无异于自杀行为。



我把身体比作一根橡皮筋,它大概只能扯那么长,我长期将它扯那么那么长,我以为它没事,直到有一次我将它扯那么那么那么长,砰的一声它就断了。


我们都无法知道自己的这根橡皮筋到底能扯多长,只要它没断,我们都觉得它还能扯很长,代价就是,只需断一次,你再也无法重来了。



30岁之前,我努力学习各种知识,熬夜那是家常便饭,睡一觉便生龙活虎。


我就像以前上学的三好学生一样,在学校我扎扎实实,放学了我还进补习班,补习班回来了我还上网学知识。


回头想想,真特么离谱啊,我上学都没这样,走上社会了竟然付出了之前在学校几倍的努力。


早知如此,我好好上学读书最后进入一个更优质的圈子,不就少走很多弯路了吗,但是谁又会听当年的老师和父母一番肺腑之言呢。


埋怨过去没有什么意义,只能偶尔借着都市小说幻想一下带着记忆重生回校园的自己。


细数下来,我30岁之前熬过的夜比我加的班还多,我不是天天加班,但好像真的天天熬夜。


可我身体一点问题都没有,我觉得自己不是那种被命运抛弃的人,内心一直这么侥幸,你是不是也和我一样呢。


30岁之后,该来的还是来了,32岁那年,我有一次咳嗽入院,反复高烧,退了又发烧,医生一度以为是新冠,或结核,或白血病什么的,后来全部检查了都不是,发现就是普通的肺部感染。


每天两瓶抗病毒的点滴,大概半个月才逐渐恢复,人都瘦脱相了,这是我人生头一次住院,躺在病床上像废人一样。


等到33岁也就是去年,偶然头晕了一次,那种眩晕,天旋地转,犯恶心,怎么站怎么坐怎么躺都不行,真正要死的感觉。


后面我一度以为是年纪轻轻得了高血压,结果查了下是血脂的问题,还不算严重,但继续下去很可能会变成一些心脑血管疾病。


我难以置信,这可都是老年病啊,我一个30几岁的程序员说来就来了?


调养半年多,肉眼可见身体有好转,我又开始没忍住熬夜了,想做自己的课题,想分享更多的东西,这些都要花时间,而且包括一些其他领域的内容,想得太多,自然花的时间就多。


一不小心就连续熬了一个多月,平均每晚都是2点左右躺下,有时中午还不午休,刷手机找素材。


终于,脖子上起了肿块,让我整个人都蒙圈了,觉得一切努力都是在玩弄自己,忽然间什么都没意思了。


我尽量把这种感受描述出来,希望你们能看明白,真切体会一二。


为什么30岁之后我一熬夜就有问题出现,说白了,30岁之前透支了已经,一来是身体负荷达到临界,二来养成了多年的坏习惯,一时想改还改不过来。



30岁之前真别玩弄自己的身体了xdm,橡皮筋断了就真断了,接不上了,接上了也没以前的弹性了。



健康取决于自律和心情



对于程序员来说,健康取决于两点:自律和心情。



30岁之前,请学会自律,学习时间自律,生活作息自律,一日三餐自律,养成这样的习惯,30岁之后的你会受益匪浅。


自律真的很难,我就是一个很难做到的人,我有倔强地适应过,却又悲哀地失败了。


就像你是一个歇斯底里的人,忽然让你温文尔雅,你又能坚持多久呢。


我用很多鸡汤说服过自己,对于已经30几岁的我来说,也只能维持一段时间。


想看的多,想玩的多,想学的也多,时间是真不够啊,真想向天再借五百年。


我应该算是幸运的那一类,至少我这般透支身体,我还活着,也没用余生去直面绝望。



我用这两年的身体故障给自己上了重要的一课,人死如灯灭。



如果能重来,我一定会学习时间规划,我一定会把每天的时间安排的好好的。


我一定会保证一日三餐不落下,少吃外卖,多吃水果蔬菜。


我一定会保证每晚充足的睡眠,早睡早起,绝不熬夜。


我一定会每天下班和放假抽出一些时间运动和锻炼。


我不是说给自己听的,因为我已经透支了。


我是说给在看文章的你们听的,还年轻点的,还没透支的,请用我的现在当做你可能更坏的未来,早点醒悟,为时不晚。


自律很难,但不自律可能等死,这个选择一点也不难。



工作压力大,作为程序员是避免不了的,所以我以前有劝过大家,薪水的重要性只占一半,你应该追寻一份薪水尚可,但压力一定在承受范围内的工作,这是我认为在国内对于程序员来说相对友好的途径。



我进入IT行业目前为止的整个生涯中,学习阶段听到过传智播客张孝祥老师的猝死,工作阶段听说过附近的4396游戏公司里面30多岁程序员猝死,今年又听到了左耳朵耗子先生的离世。


我想着,那一天,离我和你还有多远。


心情真的很重要,至少能快速反应在身体上。


当我这周六被妻子劝说去检查的时候,我内心一直是紧张的,妻子没去,就在家陪着孩子,跟我说你自己去吧,如果有坏消息就别回复了,等回来再说,如果没什么事那就发个微信。


我想我理解她的意思了,点了点头就骑车去了医院。


医院真不是什么好地方,我就是给医院干活的,我全身上下都讨厌这里。


最煎熬的时间是做彩超前的一个多小时,因为人太多,我得排队,盯着大屏上的号序,我脑子里想了很多事情,甚至连最坏的打算都想好了。


人就很奇怪,越是接近黑暗,越是能回忆起非常多的往事,连高中打篮球挥洒汗水的模样和搞笑的投篮姿势都能想起来。


喊到我的时候,我心跳了一下,然后麻木地进去了,躺下的时候,医生拿着仪器对着我的脖子扫描,此时的我是近一个月以来第一次内心平静,当真好奇怪的感觉。


随着医生一句:没什么事,就一个淋巴结。


犹如审判一般,我感觉一下无罪释放了。


当时听到这句话简直犹如天籁,这会儿想起来还感觉毛孔都在欢快地愉悦。


我问她不是什么肿瘤或甲状腺吧,她说不是,就一个正常的淋巴结,可能是炎症导致了增生,这种一般3个多月至半年才会完全消掉。


这是当时拍的结果


3.jpg


拿给主任医师看了之后,对方也说一点事没有,只是告诫我别再熬夜了。


我不知道人生还会给我几次机会,但我从20几岁到30几岁,都没有重视过这个问题,也没有认真思考过。


直到最近,我才发现,活着真好。


当晚是睡得最踏实的一晚,一点梦都没做,中途也没醒,一觉到天亮。


更离谱的是,早上我摸了一下脖子,竟然真的小了点,这才短短一天,说了都没人信。


我头一次相信,心情真的会影响身体,你心情好了,身体的器官和血液仿佛都欢腾了起来。


如何保持一个好心情,原来这般重要,我拿自己的身体给大家做实验了,有用!



希望大家每天在自律的基础上保持好心情,不负年华,不负自己。



总结


xdm,好好活着,快乐活着。


作者:程序员济癫
来源:juejin.cn/post/7300564263344128051
收起阅读 »

DDD落地之架构分层

一.前言 DDD系列Demo被好多读者催更。肝了一周,参考了众多资料,与众多DDD领域的大佬进行了结构与理念的沟通后,终于完成了改良版的代码层次结构。 本文将给大家展开讲一讲 为什么我们要使用DDD? 到底什么样的系统适配DDD? DDD的代码怎么做,为什么...
继续阅读 »

一.前言


DDD系列Demo被好多读者催更。肝了一周,参考了众多资料,与众多DDD领域的大佬进行了结构与理念的沟通后,终于完成了改良版的代码层次结构。


本文将给大家展开讲一讲



  • 为什么我们要使用DDD?

  • 到底什么样的系统适配DDD?

  • DDD的代码怎么做,为什么要这么做?


你可以直接阅读本文,但我建议先阅读一文带你落地DDD,如果你对DDD已经有过了解与认知,请直接阅读。


干货直接上,点此查看demo代码,配合代码阅读本文,体验更深


DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

二.为什么我们要使用DDD


虽然我在第一篇DDD的系列文:一文带你落地DDD中已经做过介绍我们使用DDD的理由。但是对于业务架构不太熟悉的同学还是无法get到DDD的优势是什么。



作为程序员嘛,我还是比较提倡大家多思考,多扎实自己的基础知识的。面试突击文虽香,但是,面试毕竟像是考试,更多时候我们还是需要在一家公司里面去工作。别人升职加薪,你怨声载道,最后跳槽加小几千没有意义嘛。



image.png


言归正传,我相信基本上99%的java开发读者,不管你是计科专业出身还是跨专业,初学spring或者springboot的时候,接触到的代码分层都是MVC。


这说明了MVC有它自身独有的优势:



  • 开发人员可以只关注整个结构中的其中某一层;

  • 可以很容易的用新的实现来替换原有层次的实现;

  • 可以降低之间的依赖;

  • 有利于标准化;

  • 利于各逻辑的复用。


但是真实情况是这样吗?随着你系统功能迭代,业务逻辑越来越复杂之后。MVC三层中,V层作为数据载体,C层作为逻辑路由都是很薄的一层,大量的代码都堆积在了M层(模型层)。一个service的类,动辄几百上千行,大的甚至几万行,逻辑嵌套复杂,主业务逻辑不清晰。service做的稍微轻量化一点的,代码就像是胶水,把数据库执行逻辑与控制返回给前端的逻辑胶在一起,主次不清晰。


一看你的工程,类啊,代码量啊都不少,你甚至不知道如何入手去修改“屎山”一样的代码。


归根到底的原因是什么?


image.png


service承载了它这个年纪不该承受的业务逻辑。


举个例子: 你负责了一个项目的从0到1的搭建,后面业务越来越好,招了新的研发进来。新的研发跟你一起开发,service层逻辑方法类似有不完全相同,为了偷懒,拷贝了你的代码,改了一小段逻辑。这时候基本上你的代码量已经是乘以2了。同理再来一个人,你的代码量可能乘了4。然而作为数据载体的POJO繁多,里面空空如也,你想把逻辑放进去,却发现无从入手。POJO的贫血模型陷入了恶性循环。


那么DDD为什么可以去解决以上的问题呢?


DDD核心思想是什么呢?解耦!让业务不是像炒大锅饭一样混在一起,而是一道道工序复杂的美食,都有他们自己独立的做法。


DDD的价值观里面,任何业务都是某个业务领域模型的职责体现。A领域只会去做A领域的事情,A领域想去修改B领域,需要找中介(防腐层)去对B领域完成操作。我想完成一个很长的业务逻辑动作,在划分好业务边界之后,交给业务服务的编排者(应用服务)去组织业务模型(聚合)完成逻辑。


这样,每个服务(领域)只会做自己业务边界内的事情,最小细粒度的去定义需求的实现。原先空空的贫血模型摇身一变变成了充血模型。原理冗长的service里面类似到处set,get值这种与业务逻辑无关的数据载体包装代码,都会被去除,进到应用服务层,你的代码就是你的业务逻辑。逻辑清晰,可维护性高!


三.到底什么样的系统适配DDD


看完上文对于DDD的分析之后是不是觉得MVC一对比简直就是垃圾。但是你回过头来想想,DDD其实在10几年前就已经被提出来了,但为什么是近几年才开始逐渐进入大众的视野?


相信没有看过我之前DDD的文章的同学看了我上面的分析大概也能感觉的到,DDD这个系统不像MVC结构那么简单,分层肯定更加复杂。


因此不是适配DDD的系统是什么呢?


中小规模的系统,本身业务体量小,功能单一,选择mvc架构无疑是最好的。


项目化交付的系统,研发周期短,一天到晚按照甲方的需求定制功能。


相反的,适配DDD的系统是什么呢?


中大规模系统,产品化模式,业务可持续迭代,可预见的业务逻辑复杂性的系统。


总而言之就是:


你还不了解DDD或者你们系统功能简单,就选择MVC.


你不知道选用什么技术架构做开发,业务探索阶段,选用MVC.


其他时候酌情考虑上DDD。


四.DDD的代码怎么做,为什么要这么做


4.1.经典分层


image-20210913185730992.png
在用户界面层和业务逻辑层中间加了应用层(Application Layer) , 业务逻辑层改为领域层, 数据访问层改为基础设施层(Infrastructure Layer) , 突破之前数据库访问的限制。 固有的思维中,依赖是自顶向下传递的,用户界面依赖应用层,应用层依赖领域层和基础设施层,越往下的层,与业务越远,并更加通用;出于重用的考虑,通用的功能会剥离成框架或者平台,而在低层次(基础设施层)会调用、依赖这些框架,也就导致了业务对象(领域层)依赖外部平台或框架。


4.2.依赖倒置分层


image-20210913190943631.png


为了突破这种违背本身业务领域的依赖,将基础设施往上提,当领域服务与基础设置有交集时,定义一个接口(灰度接口),让基础设施去实现对应的接口。接口本身是介于应用服务与领域服务之间的,为了纯净化领域层而存在。


Image.png


这么做的好处就是,从分包逻辑来看,上层依赖下层,底层业务域不依赖任何一方,领域独立。


4.3.DDD分层请求调用链


未命名文件.png


4.3.1.增删改


1.用户交互层发起请求


2.应用服务层编排业务逻辑【仅做方法编排,不处理任何逻辑】


3.编排逻辑如果依赖三方rpc,则定义adapter,方式三方服务字段影响到本服务。


4.编排逻辑如果依赖其他领域服务,应用服务,可直接调用,无需转化。但是与当前框架不相符合的,例如发送短信这种,最好还是走一下适配器,运营商换了,依赖的应用服务没准都不同了。


5.聚合根本身无法处理的业务在领域层处理,依赖倒置原则,建立一层interfaces层(灰度防腐层),放置领域层与基础设置的耦合。


6.逻辑处理结束,调用仓储聚合方法。


4.3.2.查询


CQRS模型,与增删改不同的应用服务,是查询应用服务。不必遵守DDD分层规则(不会对数据做修改)。简单逻辑甚至可以直接由controller层调用仓储层返回数据。


五.总结


其实DDD在分层上从始至终一致在贯穿的一个逻辑就是,解耦。如果真的极端推崇者,每一层,每一步都会增加一个适配器。我觉得这个对于研发来说实在太痛苦了,还是要在架构与实际研发上做一个中和。


六.特别鸣谢


lilpilot


image.png


作者:柏炎
来源:juejin.cn/post/7007382308667785253
收起阅读 »

DDD落地之仓储

一.前言 hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。 昨天媳妇儿生病了在医院,她挂点滴的时候,我也没闲下来,抓紧时间做出了DDD的...
继续阅读 »

一.前言


hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。


昨天媳妇儿生病了在医院,她挂点滴的时候,我也没闲下来,抓紧时间做出了DDD的第一版demo,就冲这点,


大家点个关注,点个赞,不过分吧。


image.png


这个项目我会持续维护,针对读者提出的issue与相关功能点的增加,我都会持续的补充。


查看demo


DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

本文将给大家介绍的同样是DDD中的一个比较好理解与落地的知识点-仓储



本系列为MVC框架迁移至DDD,考虑到国内各大公司内还是以mybatis作为主流进行业务开发。因此,demo中的迁移与本文的相关实例均以mybatis进行演示。至于应用仓储选型是mybatis还是jpa,文中会进行分析,请各位仔细阅读本文。


二.仓储


2.1.仓储是什么


原著《领域驱动设计:软件核心复杂性应对之道》 中对仓储的有关解释:



为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的Aggregate提供Repository。让客户始终聚焦于型,而将所有对象存储和访问操作交给Repository来完成。



上文通俗的讲,当领域模型一旦建立之后,你不应该关心领域模型的存取方式。仓储就相当于一个功能强大的仓库,你告诉他唯一标识:例如订单id,它就能把所有你想要数据按照设置的领域模型一口气组装返回给你。存储时也一样,你把整块订单数据给他,至于它怎么拆分,放到什么存储介质【DB,Redis,ES等等】,这都不是你业务应该关心的事。你完全信任它能帮助你完成数据管理工作。


2.2.为什么要用仓储


先说贫血模型的缺点:



有小伙伴之前提出过不知道贫血模型的定义,这里做一下解释。贫血模型:PO,DTO,VO这种常见的业务POJO,都是数据java里面的数据载体,内部没有任何的业务逻辑。所有业务逻辑都被定义在各种service里面,service做了各种模型之间的各种逻辑处理,臃肿且逻辑不清晰。充血模型:建立领域模型形成聚合根,在聚合根即表示业务,在聚合内部定义当前领域内的业务处理方法与逻辑。将散落的逻辑进行收紧。




  1. 无法保护模型对象的完整性和一致性: 因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。

  2. 对象操作的可发现性极差: 单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数?

  3. 代码逻辑重复: 比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。

  4. 代码的健壮性差: 比如一个数据模型的变化可能导致从上到下的所有代码的变更。

  5. 强依赖底层实现: 业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。


image.png


虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的99%的代码都是基于贫血模型,为什么呢?



  1. 数据库思维: 从有了数据库的那一天起,开发人员的思考方式就逐渐从写业务逻辑转变为了写数据库逻辑,也就是我们经常说的在写CRUD代码

  2. 贫血模型“简单”: 贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情

  3. 脚本思维: 很多常见的代码都属于脚本胶水代码,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。


但是可能最核心的原因在于,实际上我们在日常开发中,混淆了两个概念:



  • 数据模型(Data Model): 指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型。

  • 业务模型/领域模型(Domain Model): 指业务逻辑中,相关联的数据该如何联动。


所以,解决这个问题的根本方案,就是要在代码里区分Data Model和Domain Model,具体的规范会在后文详细描述。在真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。


能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值。


image.png


三.落地


3.1.落地概念图


1.png


DTO Assembler: 在Application层 【应用服务层】EntityDTO的转化器有一个标准的名称叫DTO Assembler 【汇编器】



DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。



Data Converter: 在Infrastructure层 【基础设施层】EntityDO的转化器没有一个标准名称,但是为了区分Data Mapper,我们叫这种转化器Data Converter。这里要注意Data Mapper通常情况下指的是DAO,比如Mybatis的Mapper。


3.2.Repository规范


首先聚合仓储之间是一一对应的关系。仓储只是一种持久化的手段,不应该包含任何业务操作。




  1. 接口名称不应该使用底层实现的语法


    定义仓储接口,接口中有save类似的方法,与面向集合的仓储的不同点:面向集合的仓储只有在新增时调用add即可,面向持久化的无论是新增还是修改都要调用save




  2. 出参入参不应该使用底层数据格式:


    需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO 。更近一步,Repository 接口实际上应该存在于Domain层,根本看不到 DO 的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。




  3. 应该避免所谓的“通用”Repository模式


    很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类




  4. 不要在仓储里面编写业务逻辑


    首先要清楚的是,仓储是存在基础设施层的,并不会去依赖上层的应用服务,领域服务等。




图片1.png


仓储内部仅能依赖mapper,es,redis这种存储介质包装框架的工具类。save动作,仅对传入的聚合根进行解析放入不同的存储介质,你想放入redis,数据库还是es,由converter来完成聚合根的转换解析。同样,从不同的存储介质中查询得到的数据,交给converter来组装。




  1. 不要在仓储内控制事务


    你的仓储用于管理的是单个聚合,事务的控制应该取决于业务逻辑的完成情况,而不是数据存储与更新情况。




3.3.CQRS仓储


2222.png
回顾一下这张图,可以发现增删改数据模型走了DDD模型。而查询则从应用服务层直接穿透到了基础设施层。


这就是CQRS模型,从数据角度来看,增删改数据非幂等操作,任何一个动作都能对数据进行改动,称为危险行为。而查询,不会因为你查询次数的改变,而去修改到数据,称为安全行为。而往往功能迭代过程中,数据修改的逻辑还是复杂的,因此建模也都是针对于增删改数据而言的。


那么查询数据有什么原则吗?




  1. 构建独立仓储


    查询的仓储与DDD中的仓储应该是两个方法,互相独立。DDD中的仓储方法严格意义上只有三个:save,delete,byId,内部没有业务逻辑,仅对数据做拆分组合。查询仓储方法可以根据用户需求,研发需求来自定义仓储返回的数据结构,不限制返回的数据结构为聚合,可以是限界范围内的任意自定义结构。




  2. 不要越权


    不要再查询仓储内做太多的sql逻辑,数据查询组装交给assember。




  3. 利用好assember


    类似于首页,一个接口可能返回的数据来源于不同的领域,甚至有可能不是自己本身业务服务内部的。


    这种复杂的结果集,交给assember来完成最终结果集的组装与返回。结构足够简单的情况下,用户交互层【controller,mq,rpc】甚至可以直接查询仓储的结果进行返回。


    当然还有很多其他博文中会说,如果查询结果足够简单,甚至可以直接在controller层调用mapper查询结果返回。除非你是一个固定的字典服务或者规则表,否则哪怕业务再简单,你的业务也会迭代,后续查询模型变化了,dao层里面的查询逻辑就外溢到用户交互层,显然得不偿失。




3.4.ORM框架选型


目前主流使用的orm框架就是mybatis与jpa。国内使用mybatis多,国外使用jpa多。两者框架上的比较本文不做展开,不清楚两个框架实现差异的,可以自行百度。


那么我们如果做DDD建模的话到底选择哪一种orm框架更好呢?


mybatis是一个半自动框架(当然现在有mybatis-plus的存在,mybatis也可以说是跻身到全自动框架里面了),国内使用它作为orm框架是主流。为什么它是主流,因为它足够简单,设计完表结构之后,映射好字段就可以进行开发了,业务逻辑可以用胶水一个个粘起来。而且在架构支持上,mybatis不支持实体嵌套实体,这个在领域模型建模结束后的应用上就优于mybatis。


当然我们今天讨论的是架构,任何时候,技术选型不是决定我们技术架构的关键性因素


jpa天生就具备做DDD的优势。但是这并不意味着mybatis就做不了DDD了,我们完全可以将领域模型的定义与orm框架的应用分离,单独定义converter去实现领域模型与数据模型之间的转换,demo中我也是这么给大家演示的。


image.png




当然,如果是新系统或者迁移时间足够多,我还是推荐使用JPA的,红红火火恍恍惚惚~


image.png


四.demo演示


需求描述,用户领域有四个业务场景



  1. 新增用户

  2. 修改用户

  3. 删除用户

  4. 用户数据在列表页分页展示



核心实现演示,不贴全部代码,完整demo可从文章开头的github仓库获取



4.1.领域模型


/**
* 用户聚合根
*
*
@author baiyan
*/

@Getter
@NoArgsConstructor
public class User extends BaseUuidEntity implements AggregateRoot {

  /**
    * 用户名
    */

  private String userName;

  /**
    * 用户真实名称
    */

  private String realName;

  /**
    * 用户手机号
    */

  private String phone;

  /**
    * 用户密码
    */

  private String password;

  /**
    * 用户地址
    */

  private Address address;

  /**
    * 用户单位
    */

  private Unit unit;

  /**
    * 角色
    */

  private List roles;

  /**
    * 新建用户
    *
    *
@param command 新建用户指令
    */

  public User(CreateUserCommand command){
      this.userName = command.getUserName();
      this.realName = command.getRealName();
      this.phone = command.getPhone();
      this.password = command.getPassword();
      this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
      this.relativeRoleByRoleId(command.getRoles());
  }

  /**
    * 修改用户
    *
    *
@param command 修改用户指令
    */

  public User(UpdateUserCommand command){
      this.setId(command.getUserId());
      this.userName = command.getUserName();
      this.realName = command.getRealName();
      this.phone = command.getPhone();
      this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
      this.relativeRoleByRoleId(command.getRoles());
  }

  /**
    * 组装聚合
    *
    *
@param userPO
    *
@param roles
    */

  public User(UserPO userPO, List roles){
      this.setId(userPO.getId());
      this.setDeleted(userPO.getDeleted());
      this.setGmtCreate(userPO.getGmtCreate());
      this.setGmtModified(userPO.getGmtModified());
      this.userName = userPO.getUserName();
      this.realName = userPO.getRealName();
      this.phone = userPO.getPhone();
      this.password = userPO.getPassword();
      this.setAddress(userPO.getProvince(),userPO.getCity(),userPO.getCounty());
      this.relativeRoleByRolePO(roles);
      this.setUnit(userPO.getUnitId(),userPO.getUnitName());
  }

  /**
    * 根据角色id设置角色信息
    *
    *
@param roleIds 角色id
    */

  public void relativeRoleByRoleId(List<Long> roleIds){
      this.roles = roleIds.stream()
              .map(roleId->new Role(roleId,null,null))
              .collect(Collectors.toList());
  }

  /**
    * 设置角色信息
    *
    *
@param roles
    */

  public void relativeRoleByRolePO(List roles){
      if(CollUtil.isEmpty(roles)){
          return;
      }
      this.roles = roles.stream()
              .map(e->new Role(e.getId(),e.getCode(),e.getName()))
              .collect(Collectors.toList());
  }

  /**
    * 设置用户地址信息
    *
    *
@param province 省
    *
@param city 市
    *
@param county 区
    */

  public void setAddress(String province,String city,String county){
      this.address = new Address(province,city,county);
  }

  /**
    * 设置用户单位信息
    *
    *
@param unitId
    *
@param unitName
    */

  public void setUnit(Long unitId,String unitName){
      this.unit = new Unit(unitId,unitName);
  }

}

4.2.DDD仓储实现


/**
*
* 用户领域仓储
*
* @author baiyan
*/

@Repository
public class UserRepositoryImpl implements UserRepository {

  @Autowired
  private UserMapper userMapper;

  @Autowired
  private RoleMapper roleMapper;

  @Autowired
  private UserRoleMapper userRoleMapper;

  @Override
  public void delete(Long id){
      userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRolePO::getUserId,id));
      userMapper.deleteById(id);
  }

  @Override
  public User byId(Long id){
      UserPO user = userMapper.selectById(id);
      if(Objects.isNull(user)){
          return null;
      }
      List userRoles = userRoleMapper.selectList(Wrappers.lambdaQuery()
              .eq(UserRolePO::getUserId, id).select(UserRolePO::getRoleId));
      List roleIds = CollUtil.isEmpty(userRoles) ? new ArrayList<>() : userRoles.stream()
              .map(UserRolePO::getRoleId)
              .collect(Collectors.toList());
      List roles = roleMapper.selectBatchIds(roleIds);
      return UserConverter.deserialize(user,roles);
  }


  @Override
  public User save(User user){
      UserPO userPo = UserConverter.serializeUser(user);
      if(Objects.isNull(user.getId())){
          userMapper.insert(userPo);
          user.setId(userPo.getId());
      }else {
          userMapper.updateById(userPo);
          userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRolePO::getUserId,user.getId()));
      }
      List userRolePos = UserConverter.serializeRole(user);
      userRolePos.forEach(userRoleMapper::insert);
      return this.byId(user.getId());
  }

}

4.3.查询仓储


/**
*
* 用户信息查询仓储
*
*
@author baiyan
*/

@Repository
public class UserQueryRepositoryImpl implements UserQueryRepository {

  @Autowired
  private UserMapper userMapper;

  @Override
  public Page<UserPageDTO> userPage(KeywordQuery query){
      Page<UserPO> userPos = userMapper.userPage(query);
      return UserConverter.serializeUserPage(userPos);
  }

}

五.mybatis迁移方案


以OrderDO与OrderDAO的业务场景为例



  1. 生成Order实体类,初期字段可以和OrderDO保持一致

  2. 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成

  3. 写单元测试,确保Order和OrderDO之间的转化100%正确

  4. 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性

  5. 将原有代码里使用了OrderDO的地方改为Order

  6. 将原有代码里使用了OrderDAO的地方都改为用OrderRepository

  7. 通过单测确保业务逻辑的一致性。


六.总结



  1. 数据模型与领域模型需要正确区分,仓储是它们互相转换的抽象实现。

  2. 仓储对业务层屏蔽实现,即领域层不需要关注领域对象如何持久化。

  3. 仓储是一个契约,而不是数据访问层。它明确表明聚合所必需的数据操作。

  4. 仓储用于管理单个聚合,它不应该控制事务。

  5. ORM框架选型在迁移过程中不可决定性因此,可以嫁接转换器,但是还是优先推荐JPA。

  6. 查询仓储可以突破DDD边界,用户交互层可以直接进行查询。


七.特别鸣谢


lilpilot


image.png


作者:柏炎
来源:juejin.cn/post/7006595886646034463
收起阅读 »

DDD落地之事件驱动模型

一.前言 hello,everyone。一日不见,如隔24小时。 周末的时候写了一文带你落地DDD,发现大家对于新的领域与知识都挺感兴趣的。后面将会出几篇DDD系列文章给大家介绍mvc迁移DDD实际要做的一些步骤。 DDD系列博客一文带你落地DDDDDD落地...
继续阅读 »

一.前言


hello,everyone。一日不见,如隔24小时。


image.png


周末的时候写了一文带你落地DDD,发现大家对于新的领域与知识都挺感兴趣的。后面将会出几篇DDD系列文章给大家介绍mvc迁移DDD实际要做的一些步骤。


DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

DDD的理念中有一个是贯穿始终的,业务边界与解耦。我最开始不了解DDD的时候,我就觉得事件驱动模型能够非常好的解耦系统功能。当然,这个是我比较菜,在接触DDD之后才开始对事件驱动模型做深度应用与了解。其实无论是在spring的框架中还是在日常MVC代码的编写过程中,巧用事件驱动模型都能很好的提高代码的可维护性。


image.png


因此,本文将对DDD中使用事件驱动模型建立与踩坑做一个系统性的介绍。从应用层面出发,帮助大家更好的去进行架构迁移。



我的第一本掘金小册《深入浅出DDD》已经在掘金上线,欢迎大家试读~



DDD的微信群我也已经建好了,由于文章内不能放二维码,大家可以加我微信baiyan_lou,备注DDD交流,我拉你进群,欢迎交流共同进步。


二.事件驱动模型


2.1.为什么需要事件驱动模型


一个框架,一门技术,使用之前首先要清楚,什么样的业务场景需要使用这个东西。为什么要用跟怎么样把他用好更加重要。


假设我们现在有一个比较庞大的单体服务的订单系统,有下面一个业务需求:创建订单后,需要下发优惠券,给用户增长积分


先看一下,大多数同学在单体服务内的写法。【假设订单,优惠券,积分均为独立service】


//在orderService内部定义一个放下
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command){
 //创建订单
 Long orderId = this.doCreate(command);
 //发送优惠券
 couponService.sendCoupon(command,orderId);
 //增长积分
 integralService.increase(command.getUserId,orderId);
}

image.png


上面这样的代码在线上运行会不会有问题?不会!


image.png


那为什么要改呢?


原因是,业务需求在不断迭代的过程中,与当前业务非强相关的主流程业务,随时都有可能被替换或者升级。


双11大促,用户下单的同时需要给每个用户赠送几个小礼品,那你又要写一个函数了,拼接在主方法的后面。双11结束,这段要代码要被注释。有一年大促,赠送的东西改变,代码又要加回来。。。。


来来回回的,订单逻辑变得又臭又长,注释的代码逻辑很多还不好阅读与理解。


image.png


如果用了事件驱动模型,那么当第一步创建订单成功之后,发布一个创建订单成功的领域事件。优惠券服务,积分服务,赠送礼品等等监听这个事件,对监听到的事件作出相应的处理。


事件驱动模型代码


//在orderService内部定义一个放下
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command){
//创建订单
Long orderId = this.doCreate(command);
publish(orderCreateEvent);
}

//各个需要监听的服务
public void handlerEvent(OrderCreateEvent event){
//逻辑处理
}

image.png


代码解耦,高度符合开闭原则


2.2.事件驱动模型选型


2.2.1.JDK中时间驱动机制


JDK为我们提供的事件驱动(EventListener、EventObject)、观察者模式(Observer)。


JDK不仅提供了Observable类、Observer接口支持观察者模式,而且也提供了EventObjectEventListener接口来支持事件监听模式。


观察者(Observer)相当于事件监听者(监听器) ,被观察者(Observable)相当于事件源和事件,执行逻辑时通知observer即可触发oberver的update,同时可传被观察者和参数。简化了事件-监听模式的实现


// 观察者,实现此接口即可
public interface Observer {

/**
* 当被观察的对象发生变化时候,这个方法会被调用
* Observable o:被观察的对象
* Object arg:传入的参数
**/

 void update(Observable o, Object arg);
}

// 它是一个Class
public class Observable {

 // 是否变化,决定了后面是否调用update方法
 private boolean changed = false;
 
 // 用来存放所有`观察自己的对象`的引用,以便逐个调用update方法
 // 需要注意的是:1.8的jdk源码为Vector(线程安全的),有版本的源码是ArrayList的集合实现;
 private Vector obs;

 public Observable() {
 obs = new Vector<>();
}

public synchronized void addObserver(Observer o); //添加一个观察者 注意调用的是addElement方法, 添加到末尾   所以执行时是倒序执行的
public synchronized void deleteObserver(Observer o);
public synchronized void deleteObservers(); //删除所有的观察者

// 循环调用所有的观察者的update方法
public void notifyObservers();
public void notifyObservers(Object arg);
 public synchronized int countObservers() {
 return obs.size();
}

 // 修改changed的值
 protected synchronized void setChanged() {
changed = true;
}
 
 protected synchronized void clearChanged() {
changed = false;
}
 
 public synchronized boolean hasChanged() {
return changed;
}
}

内部观察者队列啥的都交给Observable去处理了, 并且,它是线程安全的。但是这种方式其实使用起来并不是那么的方便,没有一个消息总线,需要自己单独去维护观察者与被观察者。对于业务系统而言,还需要自己单独去维护每一个观察者的添加。


2.2.2.spring中的事件驱动机制


spring在4.2之后提供了@EventListener注解,让我们更便捷的使用监听。


了解过spring启动流程的同学都知道,Spring容器刷新的时候会发布ContextRefreshedEvent事件,因此若我们需要监听此事件,直接写个监听类即可。


@Slf4j
@Component
public class ApplicationRefreshedEventListener implements ApplicationListener {

  @Override
  public void onApplicationEvent(ContextRefreshedEvent event) {
      //解析这个事件,做你想做的事,嘿嘿
  }
}

同样的我们也可以自己来定义一个事件,通过ApplicationEventPublisher发送。


/**
* 领域事件基类
*
*
@author baiyan
*
@date 2021/09/07
*/

@Getter
@Setter
@NoArgsConstructor
public abstract class BaseDomainEvent<T> implements Serializable {

  private static final long serialVersionUID = 1465328245048581896L;

  /**
    * 领域事件id
    */

  private String demandId;

  /**
    * 发生时间
    */

  private LocalDateTime occurredOn;

  /**
    * 领域事件数据
    */

  private T data;

  public BaseDomainEvent(String demandId, T data) {
      this.demandId = demandId;
      this.data = data;
      this.occurredOn = LocalDateTime.now();
  }

}

定义统一的业务总线发送事件


/**
* 领域事件发布接口
*
*
@author baiyan
*
@date 2021/09/07
*/

public interface DomainEventPublisher {

  /**
    * 发布事件
    *
    *
@param event 领域事件
    */

  void publishEvent(BaseDomainEvent event);

}

/**
* 领域事件发布实现类
*
* @author baiyan
* @date 2021/09/07
*/

@Component
@Slf4j
public class DomainEventPublisherImpl implements DomainEventPublisher {

  @Autowired
  private ApplicationEventPublisher applicationEventPublisher;

  @Override
  public void publishEvent(BaseDomainEvent event) {
      log.debug("发布事件,event:{}", event.toString());
      applicationEventPublisher.publishEvent(event);
  }

}

监听事件


@Component
@Slf4j
public class UserEventHandler {

  @EventListener
  public void handleEvent(DomainEvent event) {
      //doSomething
  }

}

芜湖,起飞~


image.png


相比较与JDK提供的观察者模型的事件驱动,spring提供的方式就是yyds。


2.3.事件驱动之事务管理


平时我们在完成某些数据的入库后,发布了一个事件。后续我们进行操作记录在es的记载,但是这时es可能集群响应超时了,操作记录入库失败报错。但是从业务逻辑上来看,操作记录的入库失败,不应该影响到主流程的逻辑执行,需要事务独立。亦或是,如果主流程执行出错了,那么我们需要触发一个事件,发送钉钉消息到群里进行线上业务监控,需要在主方法逻辑中抛出异常再调用此事件。这时,我们如果使用的是@EventListener,上述业务场景的实现就是比较麻烦的逻辑了。


为了解决上述问题,Spring为我们提供了两种方式:


(1)@TransactionalEventListener注解。


(2) 事务同步管理器TransactionSynchronizationManager


本文针对@TransactionalEventListener进行一下解析。


我们可以从命名上直接看出,它就是个EventListener,在Spring4.2+,有一种叫做@TransactionEventListener的方式,能够实现在控制事务的同时,完成对对事件的处理。


//被@EventListener标注,表示它能够监听事件
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EventListener
public @interface TransactionalEventListener {

//表示当前事件跟随消息发送方事务的出发时机,默认为消息发送方事务提交之后才进行处理。
  TransactionPhase phase() default TransactionPhase.AFTER_COMMIT;

  //true时不论发送方是否存在事务均出发当前事件处理逻辑
  boolean fallbackExecution() default false;

  //监听的事件具体类型,还是建议指定一下,避免监听到子类的一些情况出现
  @AliasFor(annotation = EventListener.class, attribute = "classes")
  Class[] value() default {};

  //指向@EventListener对应的值
  @AliasFor(annotation = EventListener.class, attribute = "classes")
  Class[] classes() default {};

  //指向@EventListener对应的值
  String condition() default "";

}

public enum TransactionPhase {
  // 指定目标方法在事务commit之前执行
  BEFORE_COMMIT,

  // 指定目标方法在事务commit之后执行
  AFTER_COMMIT,

  // 指定目标方法在事务rollback之后执行
  AFTER_ROLLBACK,

  // 指定目标方法在事务完成时执行,这里的完成是指无论事务是成功提交还是事务回滚了
  AFTER_COMPLETION
}

我们知道,Spring的事件监听机制(发布订阅模型)实际上并不是异步的(默认情况下),而是同步的来将代码进行解耦。而@TransactionEventListener仍是通过这种方式,但是加入了回调的方式来解决,这样就能够在事务进行Commited,Rollback…等时候才去进行Event的处理,来达到事务同步的目的。


三.实践及踩坑


针对是事件驱动模型里面的@TransactionEventListener@EventListener假设两个业务场景。


新增用户,关联角色,增加关联角色赋权操作记录。


1.统一事务:上述三个操作事务一体,无论哪个发生异常,数据统一回滚。


2独立事务:上述三个操作事务独立,事件一旦发布,后续发生任意异常均不影响。


3.1.统一事务


用户新增


@Service
@Slf4j
public class UserServiceImpl implements UserService {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Transactional(rollbackFor = Exception.class)
  public void createUser(){
      //省略非关键代码
      save(user);
      domainEventPublisher.publishEvent(userEvent);
  }
}

用户角色关联


@Component
@Slf4j
public class UserEventHandler {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Autowired
  UserRoleService userRoleService;

  @EventListener
  public void handleEvent(UserEvent event) {
      log.info("接受到用户新增事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleService.save(userRole);
      domainEventPublisher.publishEvent(userRoleEvent);
  }

}

用户角色操作记录


@Component
@Slf4j
public class UserRoleEventHandler {

  @Autowired
  UserRoleRecordService userRoleRecordService;

  @EventListener
  public void handleEvent(UserRoleEvent event) {
      log.info("接受到userRole事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleRecordService.save(record);
  }

}

以上即为同一事务下的一个逻辑,任意方法内抛出异常,所有数据的插入逻辑都会回滚。


image.png


给出一下结论,@EventListener标注的方法是被加入在当前事务的执行逻辑里面的,与主方法事务一体。


踩坑1:


严格意义上来说这里不算是把主逻辑从业务中拆分出来了,还是在同步的事务中,当然这个也是有适配场景的,大家为了代码简洁性与函数级逻辑清晰可以这么做。但是这样做其实不是那么DDD,DDD中应用服务的一个方法即为一个用例,里面贯穿了主流程的逻辑,既然是当前系统内强一致性的业务,那就应该在一个应用服务中体现。当然这个是属于业务边界的。举例的场景来看,用户与赋权显然不是强一致性的操作,赋权失败,不应该影响我新增用户,所以这个场景下做DDD改造,不建议使用统一事务。


踩坑2:


listener里面的执行逻辑可能比较耗时,需要做异步化处理,在UserEventHandler方法上标注@Async,那么这里与主逻辑的方法事务就隔离开了,监听器内的事务开始独立,将不会影响到userService内的事务。例如其他代码不变的情况下用户角色服务代码修改如下


@Component
@Slf4j
public class UserEventHandler {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Autowired
  UserRoleService userRoleService;

  @EventListener
  @Async
  public void handleEvent(UserEvent event) {
      log.info("接受到用户新增事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleService.save(userRole);
      domainEventPublisher.publishEvent(userRoleEvent);
      throw new RuntimeException("制造一下异常");
  }

}

发现,用户新增了,用户角色关联关系新增了,但是操作记录没有新增。第一个结果好理解,第二个结果就奇怪了把,事件监听里面抛了异常,但是居然数据保存成功了。


这里其实是因为UserEventHandlerhandleEvent方法外层为嵌套@TransactionaluserRoleService.save操作结束,事务就提交了,后续的抛异常也不影响。为了保持事务一致,在方法上加一个@Transactional即可。


3.2.独立事务


@EventListener作为驱动加载业务分散代码管理还挺好的。但是在DDD层面,事务数据被杂糅在一起,除了问题一层层找也麻烦,而且数据捆绑较多,还是比较建议使用@TransactionalEventListene


用户新增


@Service
@Slf4j
public class UserServiceImpl implements UserService {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Transactional(rollbackFor = Exception.class)
  public void createUser(){
      //省略非关键代码
      save(user);
      domainEventPublisher.publishEvent(userEvent);
  }
}

用户角色关联


@Component
@Slf4j
public class UserEventHandler {

  @Autowired
  DomainEventPublisher domainEventPublisher;

  @Autowired
  UserRoleService userRoleService;

  @TransactionalEventListener
  public void handleEvent(UserEvent event) {
      log.info("接受到用户新增事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleService.save(userRole);
      domainEventPublisher.publishEvent(userRoleEvent);
  }

}

用户角色操作记录


@Component
@Slf4j
public class UserRoleEventHandler {

  @Autowired
  UserRoleRecordService userRoleRecordService;

  @TransactionalEventListener
  public void handleEvent(UserRoleEvent event) {
      log.info("接受到userRole事件:"+event.toString());
      //省略部分数据组装与解析逻辑
      userRoleRecordService.save(record);
  }

}

一样的代码,把注解从@EventListener更换为@TransactionalEventListener。执行之后发现了一个神奇的问题,用户角色操作记录数据没有入库!!!


image.png


捋一捋逻辑看看,换了个注解,就出现这个问题了,比较一下·两个注解的区别。 @TransactionalEventListener事务独立,且默认注解phase参数值为TransactionPhase.AFTER_COMMIT,即为主逻辑方法事务提交后在执行。而我们知道spring中事务的提交关键代码在AbstractPlatformTransactionManager.commitTransactionAfterReturning


protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
  if (txInfo != null && txInfo.getTransactionStatus() != null) {
    if (logger.isTraceEnabled()) {
        logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
    }
    //断点处
    txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
  }
}

配置文件中添加以下配置


logging:
level:
  org:
    mybatis: debug

在上述代码的地方打上断点,再次执行逻辑。


发现,第一次userService保存数据进入此断点,然后进入到userRoleService.save逻辑,此处不进入断点,后续的操作记录的事件处理方法也没有进入。


在来看一下日志


- 2021-09-07 19:54:38.166, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
- 2021-09-07 19:54:38.166, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:38.167, DEBUG, [,,], [http-nio-8088-exec-6], o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@1832a0d9] will be managed by Spring
- 2021-09-07 19:54:38.184, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 2021-09-07 19:54:51.430, INFO, [,,], [http-nio-8088-exec-6], com.examp.event.demo.UserEventHandler - 接受到用户新增事件:com.examp.event.demo.UserEvent@385db2f9
- 2021-09-07 19:54:53.602, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
- 2021-09-07 19:54:53.602, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818] was not registered for synchronization because synchronization is not active
- 2021-09-07 19:54:53.603, DEBUG, [,,], [http-nio-8088-exec-6], o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@1832a0d9] will be managed by Spring
- 2021-09-07 19:54:53.622, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818]

注意看接受到用户新增事件之后的日志,SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818] was not registered for synchronization because synchronization is not active说明当前事件是无事务执行的逻辑。再回过头去看一下@TransactionalEventListener,默认配置是在事务提交后才进行事件执行的,但是这里事务都没有,自然也就不会触发事件了。


看图捋一下代码逻辑


image-20210907200823192.png


那怎么解决上面的问题呢?


其实这个东西还是比较简单的:


1.可以对监听此事件的逻辑无脑标注@TransactionalEventListener(fallbackExecution = true),无论事件发送方是否有事务都会触发事件。


2.在第二个发布事件的上面标注一个@Transactional(propagation = Propagation.REQUIRES_NEW),切记不可直接标注@Transactional,这样因为userService上事务已经提交,而@Transactional默认事务传播机制为Propagation.REQUIRED,如果当前没有事务,就新建一个事务,如果已经存在一个事务,加入到这个事务中。


userService中的事务还存在,只是已经被提交,无法再加入,也就是会导致操作记录仍旧无法被插入。


将配置修改为


logging:
level:
  org: debug

可以看到日志


- 2021-09-07 20:26:29.900, DEBUG, [,,], [http-nio-8088-exec-2], o.s.j.d.DataSourceTransactionManager - Cannot register Spring after-completion synchronization with existing transaction - processing Spring after-completion callbacks immediately, with outcome status 'unknown'

四.DDD中的事件驱动应用


理清楚spring中事件驱动模型之后,我们所要做的就是开始解耦业务逻辑。


通过事件风暴理清楚业务用例,设计完成聚合根【ps:其实我觉得设计聚合根是最难的,业务边界是需要团队成员达成共识的地方,不是研发说了算的】,划分好业务领域边界,将原先杂糅在service里面的各个逻辑根据聚合根进行:



  1. 对于聚合的每次命令操作,都至少一个领域事 件发布出去,表示操作的执行结果

  2. 每一个领域事件都将被保存到事件存储中

  3. 从资源库获取聚合时,将根据发生在聚合上的 事件来重建聚合,事件的重放顺序与其产生顺序相同

  4. 聚合快照:将聚合的某一事件发生时的状态快 照序列化存储下来。


五.总结


本文着重介绍了事件驱动模型的概念与应用,并对实际可能出现的业务逻辑做了分析与避坑。最后对于DDD中如何进行以上事件驱动模型进行了分析。


当然我觉得到这里大家应该对事件模型有了一个清晰的认知了,但是对于DDD中应用还是有些模糊。千言万语汇成一句话:与聚合核心逻辑有关的,走应用服务编排,与核心逻辑无关的,走事件驱动模型,采用独立事务模式。至于数据一致性,就根据大家自己相关的业务来决定了,方法与踩坑都告诉了大家了。


你我都是架构师!!!


image.png


六.引用及参考


@TransactionalEventListener的使用和实现原理


【小家Spring】从Spring中的(ApplicationEvent)事件驱动机制出发,聊聊【观察者模式】【监听者模式】【发布订阅模式】【消息队列MQ】【EventSourcing】...


image.png


作者:柏炎
来源:juejin.cn/post/7005175434555949092
收起阅读 »

一文带你落地DDD

一.前言 hello,everyone,好久不见。最近几周部门有个大版本发布,一直没有抽出时间来写博。由于版本不断迭代,功能越做越复杂,系统的维护与功能迭代越来越困难。前段领导找我说,能不能在架构上动手做做文章,将架构迁移到DDD。哈哈哈哈,当时我听到这个话的...
继续阅读 »

一.前言


hello,everyone,好久不见。最近几周部门有个大版本发布,一直没有抽出时间来写博。由于版本不断迭代,功能越做越复杂,系统的维护与功能迭代越来越困难。前段领导找我说,能不能在架构上动手做做文章,将架构迁移到DDD。哈哈哈哈,当时我听到这个话的时候瞬间来了精神。说实话,从去年开始从大厂的一些朋友那里接触到DDD,自己平时也会时不时的阅读相关的文章与开源项目,但是一直没有机会在实际的工作中实施。正好借着这次机会可以开始实践一下。


image.png


本文由于本文的重点为MVC三层架构如何迁移DDD,因此将先对DDD做一个简要的概念介绍(细化的领域概念不做过多展开),然后对于MVC三层架构迁移至DDD作出迁移方案建议。如有不对之处,欢迎指出,共同进步。


本文尤其感谢一下lilpilot在DDD落地方案上给出的宝贵建议。


image.png


DDD系列博客



  1. 一文带你落地DDD

  2. DDD落地之事件驱动模型

  3. DDD落地之仓储

  4. DDD落地之架构分层

二.DDD是什么


2.1.DDD简介


相信了解过DDD的同学都听过网上那种官方的介绍:




  • Domain Drive Design(领域驱动设计)




  • 六边形架构模型




  • 领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具




  • ....


    说的都多多少少抽象点了,听君一席话,如听一席话,哈哈哈




在我看来常规在MVC三层架构中,我们进行功能开发的之前,拿到需求,解读需求。往往最先做的一步就是先设计表结构,在逐层设计上层dao,service,controller。对于产品或者用户的需求都做了一层自我理解的转化。


众所周知,人才是系统最大的bug。


image.png


image-20210904135645004.png


用户需求在被提出之后经过这么多层的转化后,特别是研发需求在数据库结构这一层转化后,将业务以主观臆断行为进行了转化。一旦业务边界划分模糊,考虑不全。大量的逻辑补充堆积到了代码层实现,变得越来越难维护,到处是if/else,传说中***一样代码。


image-20210904140321557.png


DDD所要做的就是



  • 消除信息不对称

  • 常规MVC三层架构中自底向上的设计方式做一个反转,以业务为主导,自顶向下的进行业务领域划分

  • 将大的业务需求进行拆分,分而治之



说到这里大家可能还是有点模糊DDD与常见的mvc架构的区别。这里以电商订单场景为例。假如我们现在要做一个电商订单下单的需求。涉及到用户选定商品,下订单,支付订单,对用户下单时的订单发货。


MVC架构里面,我们常见的做法是在分析好业务需求之后,就开始设计表结构了,订单表,支付表,商品表等等。然后编写业务逻辑。这是第一个版本的需求,功能迭代饿了,订单支付后我可以取消,下单的商品我们退换货,是不是又需要进行加表,紧跟着对于的实现逻辑也进行修改。功能不断迭代,代码就不断的层层往上叠。


DDD架构里面,我们先进行划分业务边界。这里面核心是订单。那么订单就是这个业务领域里面的聚合逻辑体现。支付,商品信息,地址等等都是围绕着订单而且。订单本身的属性决定之后,类似于地址只是一个属性的体现。当你将订单的领域模型构建好之后,后续的逻辑边界与仓储设计也就随之而来了。



2.2.为什么要用DDD



  • 面向对象设计,数据行为绑定,告别贫血模型

  • 降低复杂度,分而治之

  • 优先考虑领域模型,而不是切割数据和行为

  • 准确传达业务规则,业务优先

  • 代码即设计

  • 它通过边界划分将复杂业务领域简单化,帮我们设计出清晰的领域和应用边界,可以很容易地实现业务和技术统一的架构演进

  • 领域知识共享,提升协助效率

  • 增加可维护性和可读性,延长软件生命周期

  • 中台化的基石


2.3.DDD术语介绍


战略设计:限界上下文、通用语言,子域


战术设计:聚合、实体、值对象、资源库、领域服务、领域事件、模块


1595145053316-e3f10592-4b88-479e-b9b7-5f1ba43cadcb.jpeg


2.3.1.限界上下文与通用语言


限界上下文是一个显式的语义和语境上的边界,领域模型便存在于边界之内。边界内,通用语言中的所有术语和词组都有特定的含义。


通用语言就是能够简单、清晰、准确描述业务涵义和规则的语言。


把限界上下文拆解开看。限界就是领域的边界,而上下文则是语义环境。 通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流。


域是问题空间,限界上下文是解决空间


2.3.2.上下文组织和集成模式


防腐层(Anticorruption Layer):简称ACL,在集成两个上下文,如果两边都状态良好,可以引入防腐层来作为两边的翻译,并且可以隔离两边的领域模型。


image-20210904143337032.png


2.3.3.实体


DDD中要求实体是唯一的且可持续变化的。意思是说在实体的生命周期内,无论其如何变化,其仍旧是同一个实体。唯一性由唯一的身份标识来决定的。可变性也正反映了实体本身的状态和行为。


实体 = 唯一身份标识 + 可变性【状态 + 行为】


2.3.4.值对象


当你只关心某个对象的属性时,该对象便可作为一个值对象。 我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。


值对象=将一个值用对象的方式进行表述,来表达一个具体的固定不变的概念。


2.3.5.聚合


聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。


我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。


2.3.6.聚合根


聚合的根实体,最具代表性的实体


2.3.7.领域服务


当一些逻辑不属于某个实体时,可以把这些逻辑单独拿出来放到领域服务中 理想的情况是没有领域服务,如果领域服务使用不恰当慢慢又演化回了以前逻辑都在service层的局面。


可以使用领域服务的情况:



  • 执行一个显著的业务操作

  • 对领域对象进行转换

  • 以多个领域对象作为输入参数进行计算,结果产生一个值对象


2.3.8.应用服务


应用服务是用来表达用例和用户故事的主要手段。


应用层通过应用服务接口来暴露系统的全部功能。 在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。


应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。


应用层作为展现层与领域层的桥梁。展现层使用VO(视图模型)进行界面展示,与应用层通过DTO(数据传输对象)进行数据交互,从而达到展现层与DO(领域对象)解耦的目的。


2.3.9.工厂


职责是创建完整的聚合



  • 工厂方法

  • 工厂类


领域模型中的工厂



  • 将创建复杂对象和聚合的职责分配给一个单独的对象,它并不承担领域模型中的职责,但是领域设计的一部份

  • 对于聚合来说,我们应该一次性的创建整个聚合,并且确保它的不变条件得到满足

  • 工厂只承担创建模型的工作,不具有其它领域行为

  • 一个含有工厂方法的聚合根的主要职责是完成它的聚合行为

  • 在聚合上使用工厂方法能更好的表达通用语言,这是使用构造函数所不能表达的


聚合根中的工厂方法



  • 聚合根中的工厂方法表现出了领域概念

  • 工厂方法可以提供守卫措施


领域服务中的工厂



  • 在集成限界上下文时,领域服务作为工厂

  • 领域服务的接口放在领域模型内,实现放在基础设施层


2.3.10.资源库【仓储】


是聚合的管理,仓储介于领域模型和数据模型之间,主要用于聚合的持久化和检索。它隔离了领域模型和数据模型,以便我们关注于领域模型而不需要考虑如何进行持久化。


我们将暂时不使用的领域对象从内存中持久化存储到磁盘中。当日后需要再次使用这个领域对象时,根据 key 值到数据库查找到这条记录,然后将其恢复成领域对象,应用程序就可以继续使用它了,这就是领域对象持久化存储的设计思想


2.3.11.事件模型


领域事件是一个领域模型中极其重要的部分,用来表示领域中发生的事件。忽略不相关的领域活动,同时明确领域专家要跟踪或希望被通知的事情,或与其他模型对象中的状态更改相关联


领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。



比如下订单后,给用户增长积分与赠送优惠券的需求。如果使用瀑布流的方式写代码。一个个逻辑调用,那么不同用户,赠送的东西不同,逻辑就会变得又臭又长。这里的比较好的方式是,用户下订单成功后,发布领域事件,积分聚合与优惠券聚合监听订单发布的领域事件进行处理。



2.4.DDD架构总览


2.4.1.架构图


严格分层架构:某层只能与直接位于的下层发生耦合。


松散分层架构:允许上层与任意下层发生耦合


依赖倒置原则


高层模块不应该依赖于底层模块,两者都应该依赖于抽象


抽象不应该依赖于实现细节,实现细节应该依赖于接口


简单的说就是面向接口编程。


按照DIP的原则,领域层就可以不再依赖于基础设施层,基础设施层通过注入持久化的实现就完成了对领域层的解耦,采用依赖注入原则的新分层架构模型就变成如下所示:


image-20210904145125083.png


从上往下


第一层为用户交互层,web请求,rpc请求,mq消息等外部输入均被视为外部输入的请求,可能修改到内部的业务数据。


第二层为业务应用层,与MVC中的service不同的不是,service中存储着大量业务逻辑。但在应用服务的实现中(以功能点为维度),它负责编排、转发、校验等。


第三层为领域层,聚合根是里面最高话事人。核心逻辑均在聚合根中体现【充血模型】,如果当前聚合根不能处理当前逻辑,需要其他聚合根的配合时,则在聚合根的外部包一层领域服务去实现逻辑。当然,理想的情况是不存在领域服务的。


第四层为基础设施层,为其他层提供技术实现支持


相信这里大家还看见了应用服务层直接调用仓储层的一条线,这条线是什么意思呢?


领域模型的建立是为了控制对于数据的增删改的业务边界,至于数据查询,不同的报表,不同的页面需要展示的数据聚合不具备强业务领域,因此常见的会使用CQRS方式进行查询逻辑的处理。


2.4.2.六边形架构(端口与适配器)


对于每一种外界类型,都有一个适配器与之对应。外界接口通过应用层api与内部进行交互。


对于右侧的端口与适配器,我们可以把资源库看成持久化的适配器。


image-20210904150651866.png


2.4.3.命令和查询职责分离--CQRS



  • 一个对象的一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据,声明为void。

  • 一个对象的一个方法如果返回了数据,该方法便是一个查询(Query),不应该通过直接或者间接的手段修改对象状态。

  • 聚合只有Command方法,没有Query方法。

  • 资源库只有add/save/fromId方法。

  • 领域模型一分为二,命令模型(写模型)和查询模型(读模型)。

  • 客户端和查询处理器 客户端:web浏览器、桌面应用等 查询处理器:一个只知道如何向数据库执行基本查询的简单组件,查询处理器不复杂,可以返回DTO或其它序列化的结果集,根据系统状态自定

  • 查询模型:一种非规范化的数据模型,并不反映领域行为,只用于数据显示

  • 客户端和命令处理器 聚合就是命令模型 命令模型拥有设计良好的契约和行为,将命令匹配到相应的契约是很直接的事情

  • 事件订阅器更新查询模型

  • 处理具有最终一致性的查询模型


2.4.4.事件驱动架构


落地指导与实践:DDD落地之事件驱动模型




  • 事件驱动架构可以融入六边型架构,融合的比较好,也可以融入传统分层架构




  • 管道和过滤器




  • 长时处理过程



    1. 主动拉取状态检查:定时器和完成事件之间存在竞态条件可能造成失败

    2. 被动检查,收到事件后检查状态记录是否超时。问题:如果因为某种原因,一直收不到事件就一直不过期




  • 事件源



    1. 对于聚合的每次命令操作,都至少一个领域事 件发布出去,表示操作的执行结果

    2. 每一个领域事件都将被保存到事件存储中

    3. 从资源库获取聚合时,将根据发生在聚合上的 事件来重建聚合,事件的重放顺序与其产生顺序相同

    4. 聚合快照:将聚合的某一事件发生时的状态快 照序列化存储下来。以减少重放事件时的耗时




三.落地分享


3.1.事件风暴


EventStorming则是一套Workshop(可以理解成一个类似于头脑风暴的工作坊)方法。DDD出现要比EventStorming早了10多年,而EventStorming的设计虽然参考了DDD的部分内容,但是并不是只为了DDD而设计的,是一套独立的通过协作基于事件还原系统全貌,从而快速分析复杂业务领域,完成领域建模的方法。


image-20210904152542121.png


针对老系统内的业务逻辑,根据以上方式进行业务逻辑聚合的划分


例如电商场景下购车流程进行事件风暴


image-20210904152737731.png


3.2.场景识别


事件风暴结束明确业务聚合后,进行场景识别与层级划分


image-20210904153035722.png


3.3.包模块划分


图片2.png


3.4.迁移说明


3.4.1.仓储层


在我们日常的代码中,使用Repository模式是一个很简单,但是又能得到很多收益的事情。最大的收益就是可以彻底和底层实现解耦,让上层业务可以快速自发展。


以目前逆向模型举例,现有



  • OrderDO

  • OrderDAO


可以通过以下几个步骤逐渐的实现Repository模式:



  1. 生成Order实体类,初期字段可以和OrderDO保持一致

  2. 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成

  3. 写单元测试,确保Order和OrderDO之间的转化100%正确

  4. 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性

  5. 将原有代码里使用了OrderDO的地方改为Order

  6. 将原有代码里使用了OrderDAO的地方都改为用OrderRepository

  7. 通过单测确保业务逻辑的一致性。


有一点要注意,目前我们用mybatis,dao操作都是含有业务含义的,正常的repository不应该有这种方法,目前repository中的含有业务含义的方法只是兼容方案,最终态都要干掉的。


极端DDD推崇者要求在repository中只存在save与byId两个聚合方法。这个当然需要根据实际业务场景来决定,但是还是建议仅保存这两个方法,其他业务需求查询聚合的方法单独开一个queryRepository实现不同数据的查询聚合与页面数据展示。保证数据的增删改入口唯一


3.4.2. 隔离三方依赖-adapter


思想和repository是一致的,以调用payApi为例:



  1. 在domain新建adapter包

  2. 新建PayAdapter接口

  3. 在infrastructure中定义adapter的实现,转换内部模型和外部模型,调用pay接口,返回内部模型dto

  4. 将原先业务中调用rpc的地方改成adapter

  5. 单测对比rpc和adapter,保证正确性


3.4.3. 抽离技术组件


同样是符合六边形架构的思想,把mqProducer,JsonUtil等技术组件,在domain定义接口,在infrastructure写实现,替换步骤和adapter类似。


3.4.4. 业务流程模块化-application


如果是用能力链的项目,能力链的service就可以是application。如果原先service中的业务逻辑混杂,甚至连参数组装都是在service中体现的。那么需要把逻辑归到聚合根中,当前聚合根无法完全包裹的,防止在领域模型中体现。在应用服务层中为能力链的体现。


3.4.5. CQRS参数显式化


能力链项目,定义command,query包,通过能力链来体现Command,Query,包括继承CommandService、QueryService,Po继承CommandPo,QueryPo


非能力链项目,在application定义command,query包,参数和类名要体现CQRS。


3.4.6. 战略设计-domain


重新设计聚合和实体,可能和现有模型有差异,如果模型差距不大,直接将能力点内的逻辑,迁移到实体中,


将原来调用repository的含业务含义的方法,换成save,同时删除含业务含义的方法,这个时候可以考虑用jpa替换mybatis,这里就看各个子域的选择了,如果用jpa的话 dao层可以干掉。至此,原biz里的大多数类已迁移完成。


四.迁移过程中可能存在的疑问


迁移过程中一定会存在或多或少不清楚的地方,这里我分享一下我在迁移的过程中遇到的问题。


image.png


1.领域服务与应用服务的实际应用场景区别


应用服务:可以理解为是各种方法的编排,不会处理任务业务逻辑,比如订单数修改,导致价格变动,这个逻辑体现在聚合根中,应用服务只负责调用。


领域服务:聚合根本身无法完全处理这个逻辑,例如支付这个步骤,订单聚合不可能支付,所以在订单聚合上架一层领域服务,在领域服务中实现支付逻辑。应用服务调用领域服务。


2.聚合根定义的业务边界是什么?


不以表结构数据进行业务逻辑的划分,一个业务体为一块业务。比如一个订单涉及商品,收货地址,发货地址,个人信息等等。以实体与值对象的方式在聚合内进行定义。


3.一个command修改一个聚合时,会关联修改到别的关联表,这个关联表算不算聚合


关联表不算聚合,算值对象


4.应用服务层如果调用rpc是否必须使用adapter


是的,必须使用,屏蔽外部依赖对于当前业务逻辑的影响。设想一下,你现在需要调用rpc接口,返回的字段有100,你要取其中50个字段。隔了一段时间,调用方改了接口逻辑的返回,数据被包含在实体内。而你调用这个接口的地方特别多,改动就很大了。但是如果有了适配器这一层,你只要定义本身业务需要的数据结构,剩下的业务不需要考虑,完全新人适配器可以将你想要的数据从rpc中加载到。


5.聚合根内部逻辑无法单独处理时,放到领域服务内的话,是否可以调用其他聚合根的领域服务或者应用服务,加入业务强绑定形式,聚合根内部如果需要调用service服务或者仓储时如何做。


可以这么做,但是逻辑要保证尽量内聚。


6.事件通知模式,比如是强绑定形式的,是否还是此种方式,还是与本聚合根无关的逻辑均走事件通知


强依赖形式的走逻辑编排,比如订单依赖支付结果进行聚合修改则走应用服务编排。订单支付后发送优惠券,积分等弱耦合方式走事件通知模式。


7.聚合根,PO,DTO,VO的限界


po是数据库表结构的一一对应。


dto是数据载体,贫血模型,仅对数据进行装载。


vo为dto结构不符合前端展示要求时的包装。


聚合根为一个或者多个po的聚合数据,当然不仅仅是po的组合,还有可能是值对象数据,充血模型,内聚核心业务逻辑处理。


8.查询逻辑单独开设一个repository,还是可以在聚合根的仓储中,划分的依据是什么


单独开设一个仓储。聚合根的仓储应该查询结果与save的参数均为聚合根,但是业务查询可能多样,展示给前端的数据也不一定都是聚合根的字段组成,并且查询不会对数据库造成不可逆的后果,因此单独开设查询逻辑处理,走CQRS模式。


9.返回的结果数据为多个接口组成,是否在应用服务层直接组合


不可以,需要定义一个assember类,单独对外部依赖的各种数据进行处理。


10.save方法做完delete,insert,update所有方法吗?


delete方法单独处理,可以增加一个delete方法,insert与update方法理论上是需要保持统一方法的。


11.查询逻辑如果涉及到修改聚合根怎么处理


简单查询逻辑直接走仓储,复杂逻辑走应用服务,在应用服务中进行聚合根数据修改。


12.逻辑处理的service放置在何处


如果为此种逻辑仅为某个聚合使用,则放置在对应的领域服务中,如果逻辑处理会被多个聚合使用,则将其单独定义一个service,作为一个工具类。


五.总结


本文对DDD做了一个不算深入的概念,架构的介绍。后对现在仍旧还是被最多使用的MVC三层架构迁移至DDD方案做了一个介绍,最后对可能碰到的一些细节疑问点做了问答。


当然不是所有的业务服务都合适做DDD架构,DDD合适产品化,可持续迭代,业务逻辑足够复杂的业务系统,中小规模的系统与团队还是不建议使用的,毕竟相比较与MVC架构,成本很大。


demo演示:DDD-demo


关于MVC分层的微服务架构博主在之前的文章中也给出过一些设计规范,感兴趣的大家可以去看看:


1.看完这篇,你就是架构师


2.求求你,别写祖传代码了


image.png


六.更多DDD学习资料


博客资料:


ThoughtWork DDD系列


张逸 DDD系列


欧创新 DDD系列


代码示例:


阿里COLA



github.com/citerus/ddd…




github.com/YaoLin1/ddd…




github.com/ddd-by-exam…




github.com/Sayi/ddd-ca…



七.特别鸣谢


lilpilot


image.png


作者:柏炎
来源:juejin.cn/post/7004002483601145863
收起阅读 »

Spring 缓存注解这样用,太香了!

作者最近在开发公司项目时使用到 Redis 缓存,并在翻看前人代码时,看到了一种关于 @Cacheable 注解的自定义缓存有效期的解决方案,感觉比较实用,因此作者自己拓展完善了一番后分享给各位。 Spring 缓存常规配置 Spring Cache 框架给我...
继续阅读 »

作者最近在开发公司项目时使用到 Redis 缓存,并在翻看前人代码时,看到了一种关于 @Cacheable 注解的自定义缓存有效期的解决方案,感觉比较实用,因此作者自己拓展完善了一番后分享给各位。


Spring 缓存常规配置


Spring Cache 框架给我们提供了 @Cacheable 注解用于缓存方法返回内容。但是 @Cacheable 注解不能定义缓存有效期。这样的话在一些需要自定义缓存有效期的场景就不太实用。


按照 Spring Cache 框架给我们提供的 RedisCacheManager 实现,只能在全局设置缓存有效期。这里给大家看一个常规的 CacheConfig 缓存配置类,代码如下,


@EnableCaching
@Configuration
public class CacheConfig extends CachingConfigurerSupport {
...

private RedisSerializer keySerializer() {
return new StringRedisSerializer();
}

private RedisSerializer valueSerializer() {
return new GenericFastJsonRedisSerializer();
}

public static final String CACHE_PREFIX = "crowd:";

@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
// 配置序列化(解决乱码的问题)
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
//设置key为String
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
//设置value为自动转Json的Object
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
.computePrefixWith(name -> CACHE_PREFIX + name + ":")
.entryTtl(Duration.ofSeconds(600));
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(Objects.requireNonNull(redisConnectionFactory));
return new RedisCacheManager(redisCacheWriter, config);
}
}

这里面简单对 RedisCacheConfiguration 缓存配置做一下说明:



  1. serializeKeysWith():设置 Redis 的 key 的序列化规则。

  2. erializeValuesWith():设置 Redis 的 value 的序列化规则。

  3. computePrefixWith():计算 Redis 的 key 前缀。

  4. entryTtl():全局设置 @Cacheable 注解缓存的有效期。


那么使用如上配置生成的 Redis 缓存 key 名称是什么样得嘞?这里用开源项目 crowd-adminConfigServiceImpl 类下 getValueByKey(String key) 方法举例,


@Cacheable(value = "configCache", key = "#root.methodName + '_' + #root.args[0]")
@Override
public String getValueByKey(String key) {
QueryWrapper wrapper = new QueryWrapper<>();
wrapper.eq("configKey", key);
Config config = getOne(wrapper);
if (config == null) {
return null;
}
return config.getConfigValue();
}

执行此方法后,Redis 中缓存 key 名称如下,



crowd:configCache:getValueByKey_sys.name




ttl 过期时间是 287,跟我们全局设置的 300 秒基本是一致的。此时假如我们想把 getValueByKey 方法的缓存有效期单独设置为 600 秒,那我们该如何操作嘞?


@Cacheable 注解默认是没有提供有关缓存有效期设置的。想要单独修改 getValueByKey 方法的缓存有效期只能修改全局的缓存有效期。那么有没有别的方法能够为 getValueByKey 方法单独设置缓存有效期嘞?当然是有的,大家请往下看。


自定义 MyRedisCacheManager 缓存


其实我们可以通过自定义 MyRedisCacheManager 类继承 Spring Cache 提供的 RedisCacheManager 类后,重写 createRedisCache(String name, RedisCacheConfiguration cacheConfig) 方法来完成自定义缓存有效期的功能,代码如下,


public class MyRedisCacheManager extends RedisCacheManager {
public MyRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
super(cacheWriter, defaultCacheConfiguration);
}

@Override
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
String[] array = StringUtils.split(name, "#");
name = array[0];
// 解析 @Cacheable 注解的 value 属性用以单独设置有效期
if (array.length > 1) {
long ttl = Long.parseLong(array[1]);
cacheConfig = cacheConfig.entryTtl(Duration.ofSeconds(ttl));
}
return super.createRedisCache(name, cacheConfig);
}
}


MyRedisCacheManager 类逻辑如下,



  1. 继承 Spring Cache 提供的 RedisCacheManager 类。

  2. 重写 createRedisCache(String name, RedisCacheConfiguration cacheConfig) 方法。

  3. 解析 name 参数,根据 # 字符串进行分割,获取缓存 key 名称以及缓存有效期。

  4. 重新设置缓存 key 名称以及缓存有效期。

  5. 调用父类的 createRedisCache(name, cacheConfig) 方法来完成缓存写入。


接着我们修改下 CacheConfig 类的 cacheManager 方法用以使用 MyRedisCacheManager 类。代码如下,


@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
return new MyRedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory), defaultCacheConfig());
}

private RedisCacheConfiguration defaultCacheConfig() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
.computePrefixWith(name -> CACHE_PREFIX + name + ":")
.entryTtl(Duration.ofSeconds(600));
}

最后在使用 @Cacheable 注解时,在原有 value 属性的 configCache 值后添加 #600,单独标识缓存有效期。代码如下,


@Cacheable(value = "configCache#600", key = "#root.methodName + '_' + #root.args[0]")
@Override
public String getValueByKey(String key) {
...
}

看下 getValueByKey 方法生成的 Redis 缓存 key 有效期是多久。如下,



OK,看到是 590 秒有效期后,我们就大功告成了。到这里我们就完成了对 @Cacheable 注解的自定义缓存有效期功能开发。


作者:waynaqua
来源:juejin.cn/post/7299353390764179506
收起阅读 »

《程序员的自我修养》读后感

书中内容涉及哪些方面? 新人如何学习编程,如何进入这一行,对职业人的建议 进来以后职场中的一些经验,这一职业经常面临的问题 全栈工程师的相关问题,面试招人环节 除了技术你应该怎样提升自己,如何建立自己的品牌 如何教育自己的孩子编程,作者的自学设计经验分...
继续阅读 »

书中内容涉及哪些方面


新人如何学习编程,如何进入这一行,对职业人的建议

进来以后职场中的一些经验,这一职业经常面临的问题

全栈工程师的相关问题,面试招人环节

除了技术你应该怎样提升自己,如何建立自己的品牌

如何教育自己的孩子编程,作者的自学设计经验分享

程序员的未来和作者的美好希冀......

可以看出来作者写了很多,对程序员各个阶段都有思考和分享。看起来都是些老生常谈的建议,也有些都是职场的潜规则。但是还是得说出来,摆到明面上,这样大家才能学习、思考、践行。这也是这本书的意义所在。


我也说说自己的一点想法,一方面是对书的内容的消化吸收、一方面也是有自己的感悟,及时记录下来。


1. 保证健康的体魄


身体是革命的本钱,最近看《去有风的地方》,主角的闺蜜第一集就死了,为什么呢?没有照顾好自己的身体,胃不行,太可惜了。前些年 github 的996 项目还有越来越多的猝死现象也让大家对行业内司空见惯的加班潜规则加以抵制,都说明大家对自己的身体越来越重视了。说到底还是得(心里)坚持锻炼。


锻炼在现实中是怎么实施的呢?


很多人说,哎呀,我办了健身卡可是都没去过几次,太浪费了,就在家锻炼吧。


退而求其次,买了哑铃甚至跑步机,用上几次一直吃灰。


再或者,买了瑜伽垫,开通健身会员,跟着练上一个月,舒展筋骨,体能恢复什么的。坚持打卡,一旦哪天没打,完了,开始断断续续直至把各类健身会员订阅关掉。


说下楼跑步,冬天嫌冷夏天怕热.......


没错,这就是我,甚至遛狗都是骑着电动车让狗跑。但是,我是真的想让身体越来越好。所做的这些努力,哪怕放弃的次数再多,我也会继续下去,比起因为放弃过就不努力,我这样算不算越挫越勇呢?到现在,我已经不怕放弃了,只会遗憾没有开始。


2.读书、旅行与思考不能停


精神与身体都需要锻炼,什么软技能、代码重构、艺术审美,你不会真指望一本书能让你学会这些吧?


《自我修养》书里说的那些道理,更多的像是旅游景点的指路牌,告诉你方向,你想往哪个方向走,你就读哪方面的书充实自己,而不是把路牌拍张照站原地不动。多读书,现在这年头,你就是读狗血的网络小说,说不定哪天还能看到同名影视剧上映。


旅行嘛,去年被辞职以后我出去旅游了两周,回来身心俱疲,开始思考旅行的意义。看惯了全国统一的网红景点,我悟了。旅行也得带点脑子去,我想从这个城市看到什么?历史、不一样的人文、自然,总要有点不同吧,要是冲着网红景点的噱头去,那指定被坑。酒吧、小吃,哪个地方没有。旅行,就是要看不一样的东西。


关于思考,之前的我,对技术思考最多的时候就是在找工作的时候。那确实是每天都很充实,被面试官各种问题轰炸,工作中呢,差不多的管理系统,差不多的业务,差不多的逻辑,有复杂的也不会超过两天,超过了就把需求打回去,或者找别人一起想,最后说一声:实现不了。现在的我,几乎每天都在思考,一个是做的业务之前没做过,还有个是观念转变,面向面试上班,主动思考。


展开说说面向面试上班,既然面试问的最多也难回答的是:你遇到的问题有哪些,怎么处理的。那我上班就开始整理问题,碰到难题就思考,解决了就复盘。没问题我想问题,(1)想之前工作的几家后台系统的难题是什么,没有?那你搞的不是核心,难题都让别人解决了或者引入一个包搞定了。你要说实在没有,这个系统就我一个人写的,那可以想下通常这种系统比较难实现的是哪部分业务,比如权限、审核、排班等等,然后记录下解决方案(2)由点及面,css遇到了一个问题,比如移动端适配问题,单位是用vw呢还是rem呢,布局是flex还是百分比呢,搜结果的过程中顺便可以看看解决方案都有哪些,对照下优缺点和适用场景,最终选择最佳方案。


3. 认真工作、诚信做人


书里有这样一段话:


那些优秀的程序员有时看上去很懒,他们会在上班时间做那些与工作毫无关系的事情,比如,在纸上随意地乱涂乱画,长时间坐在那里发呆,甚至玩手机。但一旦他们进入编程状态,你又会发现他们变得像打字员般,指尖飞舞,瞬间完成他们的工作。 —第三章第一节第5小节 很懒却又很高效


这就是我们现在说的“摸鱼”,我之前一直觉得摸鱼的时候有种羞愧感,同事路过我会马上放下手机,假装在敲代码。真正意识到这是一个正常的行为,我也就心态上轻松了。除了无所事事的时候,有一堆工作还摸鱼的情况,我内心是这样的:**,太难了,等会再看吧,然后拿起手机。刷了一会,想到活还没干完,果断放下手机,开始敲代码或者接着想。越是在忙的时候,这样的情形重复次数越多。很显然,这是给大脑休息的机会,摸鱼基本上不用动脑子,只需要眼睛浏览,因为有这样的机会,接下来才能更高效的完成任务。


特别忙的时候,手机拿起放下的速度也越来越快,这个时候会比较着急,想上厕所也憋着,憋不住了上厕所一路上也在想逻辑,从第三视角去审视自己这个行为又觉得很有意思,不知道在心理学上是怎么解释的。说这一点,不是为了摆烂,而是认真的工作,这样无愧于内心,也肯定是有产出的。在读《人人都是产品经理》的时候,也有一段话很应景:


任何情况下 我们都要做好手头的事情。 确保就算这事儿对公司来说又黄了,我也要通过做事有所收获。


那面试你拿的出手的自然也就是这些收获。


诚信做人,这一点毋庸置疑,书中说的职场经验、软技能、建立自己的品牌啦,这些都能和认真工作、诚信做人挂的上钩。工作认真完成了,你的个人品牌效应自然能提升,职场中大家对你也会尊重。打个比方,初入公司,你活干不下来,是个人都想骂你两句。


差不多了,就写这么多感想吧。


作者:宅神king
来源:juejin.cn/post/7190294025214099517
收起阅读 »

别把执着用错地方,两种思维的碰撞

概要   今天通过一个项目问题解决过程来分享,两种不同的思维方式在解决项目问题时的碰撞,中间引起的带入问题,请忽略不计。 背景   先说说前提,本身的技术路子比较野,啥东西都能摸两把,基本的思路和习惯都是以结果优先,技术作为手段,但这并不代表我没有标准化的基准...
继续阅读 »

概要


  今天通过一个项目问题解决过程来分享,两种不同的思维方式在解决项目问题时的碰撞,中间引起的带入问题,请忽略不计。


背景


  先说说前提,本身的技术路子比较野,啥东西都能摸两把,基本的思路和习惯都是以结果优先,技术作为手段,但这并不代表我没有标准化的基准和要求,只不过这个次序和容忍度有不同的理解、而我作为程序猿中的代码收容所,杂货收割机,可能是绝大部分标榜 “技术控、理论控、优雅控” 最不屑的。


  文档、PPT、事务性、管理性、运维、交付、架构、甚至是商务、咨询、售前支撑都有涉及,这两年又对工控、数采非标互联网进一步在学习,说这些不是为了标榜啥,只是想说明我对技术的态度,是只要感觉缺的就会去补充学,早先,从一直排斥文档,认为这项工作是 “不务正业”,到后来慢慢发现实际是有点儿恐惧写文档,天天被批判,再到后来反感的PPT和"跟傻子讲技术",心态上变化,看到差距,尊重别人的成就,就会自然而然的追赶,直到无所畏惧。


前因


  Java属于可能我最先了解和尝试的编程语言,但怎么说呢,在当时的环境里面,C++、C# 是我选择的结果,期间也涉及了其他乱七八糟的语言,也都是副业需要,涉及到就捡起来用,可能内心觉得Java比较 “简单”,直到工作里面不得不顶着运维和维护,说实话,没啥太大障碍,真正接受生态,也就近两年,心态的变化,感觉看到了不一样的新大陆,当然主要是各位开拓者的不断努力,不断的思维碰撞和实践努力,而我本身也不能说是专职做这块,只是有些块别人解决有问题,思维碰撞一下,以下就说说关于问题解决的剖析。


过程


   同事A属于入行就做Java,也有个8年多工作经验,技术能力,执行力都不错,就是这个行业通病,沟通有所欠缺,这个只是一个点的分享,只是从思维上去谈问题,(我本身是个大白菜),还是规则引擎的点,之前有过分享,整体是由我设计的方案,之前有分享,聊聊规则引擎的调研及实现全过程,因工期等其他影响,我把人员能力问题也考虑在内,在正式应用交互过程中,关于Mqtt处理相应速度问题和数据重复入库的问题得以复现,同事A有不少的参与度,因为某些原因(我不得不负起主责,前后端整体调整),这是问题的大背景


复现排查



  • 尝试1:这个问题同事A经过问题复现,初步确认是多线程循环引起的问题,解决的办法很粗暴,判断如果循环超过1000次就终止线程,导致的结果就是性能损耗和任务丢失,(这种情况在采集上是可以容忍的,数据是不间断的),就此就准备收手。我认为这个种解决办法是最终没办法下的妥协处置,而且问题也没分析定位清楚,做进一步尝试,此处是应该要执着的地方,

  • 尝试2:于是A经过一顿跟踪,用了各种线程跟测工具和理论检索,通过方法锁尝试去解决该问题,造成的第二种后果是结果正确了,但及时性达不到,根本发挥不了性能。进一步也发现了消息密集的情况下会产生问题,中间也进行过任务队列的分组处置,(此项尝试基本属于A的各种线程理论一顿分析,进行尝试,得不出来啥有效结果,各种理论分析)

  • 尝试3: 我经过不停得添加代码,定位异常,定位到了异常代码块,属于递归循环调用侧发生得异常,之前线程循环调用得问题也基本定位到,是因为父子线程得优先级,加上规则本身深度遍历及结果等待造成得问题,也就是Future得get等待引发得,此时,要验证该问题,把规则内部得递归任务添加调整为同步,(只涉及到一句代码),循环得问题得以解决,处理速度也跟得上,(线程放置到了外侧处理消息(消息接收本身也是多线程)),但又引起了新得问题,入库得数据发生重复和不正常了(如果问题回溯,其实很快能发现,当然有点儿马后炮了)

  • 尝试4:此时,同事A又尝试想放弃,认为在入库侧判断是否重复插入即可,并把别人得关于多线程入库重复得解决常规办法得内容做了共享,我给得理由是:“再往前尝试以下,感觉马上就能找到真相,正是学技术得好时候,不要此时妥协”,很显示A在口头上答应,行动上已经放弃了,我就消息重复产生得地方又进行了各种锁尝试,也基本定位到了产生消息重复的地方,加上方法锁,结果和执行均验证没啥问题。

  • 补上最后一药: 最后的相同broker相同主题下多个规则执行问题,其实不是啥大问题,很容易就解决了,两种方案,但实在是实践浪费的太久,我尝试了复杂的理想办法,交付中的问题都得以解决。


回溯


   其实这个问题在回溯过程来看,在尝试4的时候,应该马上就能反应过来去解决的,但实际可能是周期和脑子都有点儿混乱,还是分析了执行结果和操作才做了验证,实在是我已经不敢太过相信线程理论,还是实践出真知,所以,这个过程中,我对Java的语法半吊子,但并不妨碍我去参与分析解决问题,而同事A,虽然知道线程的理论和各种语法,各个线程分析结果,但依然妨碍了他去解决此项问题,之前有听过软件的思维最重要,经验有时候会成为负担,可能在你们看来,这个问题其实有充足的理论基础,一定很容易解决定位这个问题,但怎么说呢,记得上计算机理论的时候,老师说过一句,编程就是抽象化具体事务到编程语言的过程、


  在其后的过程中,好像绝大多数都是把问题分解成实现,排查分析问题的过程,我理解就是把具体的事务反推到程序上,很可惜这个过程,被归集到个人的能力或者习惯上,毕竟,对过程负责,是常态,项目的面试过程也就是说清过程,至于是谁解决的问题,并没有那么重要,这造成了两种分化,掌握全貌的失去了动手能力,对结果负责,不对过程负责,掌握单一的,失去了对结果负责的能力,掌握着对过程负责,新的纷争和各种千奇百怪的妥协结果也就是值得接受的。


分析


   当你在执着各种编码规范,各种优雅简洁之道的同时,是否也能思考一下,我们所专注的语法,面试理论,就具体问题而言,真就是理论精通了之后,不会成为 “赵括”,指点江山,飞扬文字。


  而这种分化性,我已遇到了不下3种典型,要不点了吹牛逼的和撕逼的技能点、要不就点了技术理论的技能点,要不就去点了文档管理技能点,当然,只是个现象,躺平是常态,没啥压力和责任,谁愿意变成受虐狂,但有没有可能,也有那么一丝,对这个职业和技术的热爱? 可能又会变成鸡汤和躺着说话不腰疼,至于外在因素、想法和处境的不同,咱姑且就图一乐,从容面对吧、总之是,事不临身,我不愁,事临身,愁也没用,那能不能,稍微有一些,哪怕只有一丝希望的掌控呢,要说起怨怼,我有一肚子,但总不可能变成情绪发泄机吧,对结果有预期,但不放弃努力的过程,也许这是我此刻能够坚持做的。


PS


  在这里申明一下,同事A的各方面能力我很认可,此处也只是精力有限下的小分享(有个小背景是,我们基本上背着2.5个以上的事在解决此项问题)、所以请各位不要基于我个人的认知有指摘。


作者:沈二到不行
来源:juejin.cn/post/7292290711734681651
收起阅读 »

从源码角度解读Java Set接口底层实现原理

  咦咦咦,各位小可爱,我是你们的好伙伴——bug菌,今天又来给大家普及Java SE相关知识点了,别躲起来啊,听我讲干货还不快点赞,赞多了我就有动力讲得更嗨啦!所以呀,养成先点赞后阅读的好习惯,别被干货淹没了哦~ 环境说明:Windows 10 + Int...
继续阅读 »

  咦咦咦,各位小可爱,我是你们的好伙伴——bug菌,今天又来给大家普及Java SE相关知识点了,别躲起来啊,听我讲干货还不快点赞,赞多了我就有动力讲得更嗨啦!所以呀,养成先点赞后阅读的好习惯,别被干货淹没了哦~


在这里插入图片描述


环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8

前言


  Set是Java集合框架中的一个接口,它继承了Collection接口,并添加了一些独有的方法。Set可看做是没有重复元素的Collection,它的实现类包括HashSet、TreeSet等。本文将从源码的角度来解读Set接口的底层实现原理。


摘要


  本文将对Java Set接口进行详细的解读,包括Set的概述、源代码解析、应用场景案例、优缺点分析、类代码方法介绍和测试用例等方面。


Set接口


概述


  Set是一个不允许重复元素的集合。它继承了Collection接口,最基本的操作包括添加元素、检查元素是否存在、删除元素等。Set接口的实现类包括HashSet、TreeSet等。HashSet是基于哈希表的实现,TreeSet是基于红黑树的实现。


源代码解析


Set


  Set接口是Java集合框架中的一种接口,它表示一组无序且不重复的元素。Set接口继承自Collection接口,因此它具有Collection接口的所有方法,但是在Set接口中,添加重复元素是不允许的。Set接口有两个主要的实现类:HashSet和TreeSet。其中,HashSet基于哈希表实现,对于非Null元素具有O(1)的插入和查找时间复杂度;而TreeSet基于红黑树实现,对于有序集合的操作具有良好的性能表现。在使用Set接口时,可以通过迭代器遍历元素,也可以使用foreach语句遍历元素。


  如下是部分源码截图:


在这里插入图片描述


HashSet


  HashSet基于哈希表实现,它使用了一个称为“hash表”的数组来存储元素。当我们向HashSet中添加元素时,首先会对元素进行哈希,并通过哈希值来确定元素在数组中的位置。如果该位置已经有元素了,就会通过equals方法来判断是否重复,如果重复则不添加,如果不重复则添加到该位置。当然,由于哈希表中可能会存在多个元素都哈希到同一个位置的情况,因此这些元素会被存储在同一个位置上,形成一个链表。在查找元素时,先通过哈希值定位到链表的头部,然后在链表中进行搜索,直到找到匹配的元素或到达链表的末尾。


public class HashSet extends AbstractSet implements Set, Cloneable, java.io.Serializable {
static final long serialVersionUID = -5024744406713321676L;
private transient HashMap map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

public HashSet() {
map = new HashMap<>();
}

public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

public boolean contains(Object o) {
return map.containsKey(o);
}

public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
}

  这是一个实现了 HashSet 数据结构的 Java 类。HashSet 继承了 AbstractSet 类,同时实现了 Set 接口和 Cloneable 和 Serializable 接口。


  HashSet 内部使用 HashMap 来存储元素,其中元素作为 key ,一个 static final Object 作为 value,即:


private transient HashMap map;
private static final Object PRESENT = new Object();

  在构造器中,HashSet 实例化了一个空的 HashMap 对象:


public HashSet() {
map = new HashMap<>();
}

  向 HashSet 中添加元素时,HashSet 调用 HashMap 的 put() 方法。当新元素没有在 HashMap 中存在时,put() 方法返回 null ,此时 HashSet 返回 true,表示添加成功。如果元素已经存在于 HashMap(即已经在 HashSet 中),那么 put() 方法返回已经存在的 Object,此时 HashSet 返回 false,表示添加失败。


public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

  判断 HashSet 是否包含指定元素时,HashSet 调用 HashMap 的 containsKey() 方法。如果 HashMap 中包含该元素,则 HashSet 返回 true,否则返回 false。


public boolean contains(Object o) {
return map.containsKey(o);
}

  从 HashSet 中移除指定元素时,HashSet 调用 HashMap 的 remove() 方法。如果该元素存在于 HashMap 中(即在 HashSet 中),则返回 true,否则返回 false。


public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}

  如下是部分源码截图:


在这里插入图片描述


TreeSet


  TreeSet基于红黑树实现,它是一种自平衡的二叉查找树。每个节点都有一个额外的颜色属性,只能是红色或黑色。红黑树的基本操作包括插入、删除和查找。当我们向TreeSet中添加元素时,它会根据元素的大小来将元素添加到树中的合适位置。对于每个节点,其左子树的所有元素都比该节点的元素小,右子树的所有元素都比该节点的元素大。在删除时,如果要删除的节点有两个子节点,会先在右子树中找到最小元素,然后将该节点的元素替换为最小元素。删除最小元素就是从根节点开始,一直找到最左侧的节点即可。


public class TreeSet extends AbstractSet implements NavigableSet, Cloneable, java.io.Serializable {
private transient NavigableMap m;
private static final Object PRESENT = new Object();

public TreeSet() {
this(new TreeMap());
}

public TreeSet(NavigableMap m) {
this.m = m;
}

public boolean add(E e) {
return m.put(e, PRESENT)==null;
}

public boolean contains(Object o) {
return m.containsKey(o);
}

public boolean remove(Object o) {
return m.remove(o)==PRESENT;
}
}

  这段代码定义了一个泛型类TreeSet,继承了AbstractSet类,并实现了接口NavigableSet,Cloneablejava.io.Serializable


  类中的m变量是一个NavigableMap类型的成员变量,TreeSet内部实际上是通过使用TreeMap实现的。


  类中还定义了PRESENT静态常量,用于表示在TreeSet中已经存在的元素。


  TreeSet类中的add方法实现了向集合中添加元素的功能,使用了NavigableMap中的put方法,如果添加的元素在集合中不存在,则返回null,否则返回PRESENT


  contains方法判断集合中是否包含某个元素。使用了NavigableMap中的containsKey方法。


  remove方法实现删除某个元素的功能,使用NavigableMap中的remove方法,如果删除成功,则返回PRESENT


  如下是部分源码截图:


在这里插入图片描述


应用场景案例


  Set的一个常见应用场景就是去重。由于Set中不允许存在重复元素,因此我们可以利用Set来去除列表中的重复元素,代码如下:


代码演示


List list = new ArrayList<>(Arrays.asList(1, 2, 3, 2, 1));
Set set = new HashSet<>(list);
list = new ArrayList<>(set);
System.out.println(list); // output: [1, 2, 3]

代码分析


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


  该代码创建了一个包含重复元素的整型列表list,并使用list初始化了一个整型哈希集合set。然后,通过将set转换回一个新的ArrayList对象,生成一个不带重复元素的整型列表list。最后,输出list的元素。 因此,代码输出应该是[1, 2, 3]。


优缺点分析


优点



  1. Set接口的实现类可以高效地检查元素是否存在;

  2. Set接口的实现类不允许存在重复元素,可以用来进行去重操作;

  3. HashSet的添加、删除、查找操作时间复杂度为O(1);TreeSet的添加、删除、查找操作时间复杂度为O(logN)。


缺点



  1. 如果需要有序存储元素,那么需要使用TreeSet,但是由于TreeSet是基于红黑树实现的,因此占用内存空间较大;

  2. HashSet在哈希冲突的情况下,会导致链表长度增加,从而影响查找效率;

  3. HashSet在遍历元素时,元素的顺序不能保证。


类代码方法介绍


HashSet



  1. add(E e):向集合中添加元素;

  2. clear():清空集合中所有元素;

  3. contains(Object o):判断集合中是否存在指定的元素;

  4. isEmpty():判断集合是否为空;

  5. iterator():返回一个用于遍历集合的迭代器;

  6. remove(Object o):从集合中移除指定的元素;

  7. size():返回集合中元素的数量。


TreeSet



  1. add(E e):向集合中添加元素;

  2. ceiling(E e):返回集合中大于等于指定元素的最小元素;

  3. clear():清空集合中所有元素;

  4. contains(Object o):判断集合中是否存在指定的元素;

  5. descendingIterator():返回一个逆序遍历集合的迭代器;

  6. first():返回集合中的第一个元素;

  7. headSet(E toElement, boolean inclusive):返回集合中小于指定元素的子集;

  8. isEmpty():判断集合是否为空;

  9. iterator():返回一个用于遍历集合的迭代器;

  10. last():返回集合中的最后一个元素;

  11. remove(Object o):从集合中移除指定的元素;

  12. size():返回集合中元素的数量;

  13. subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive):返回集合中大于等于fromElement且小于等于toElement的子集;

  14. tailSet(E fromElement, boolean inclusive):返回集合中大于等于指定元素的子集。


测试用例


下面是一些测试用例,展示了Set接口的一些基本操作:


package com.demo.javase.day61;

import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;

/**
*
@Author bug菌
*
@Date 2023-11-06 10:33
*/

public class SetTest {

public static void main(String[] args) {

// 创建一个 HashSet 对象
Set set = new HashSet();

// 向集合中添加元素
set.add("Java");
set.add("C++");
set.add("Python");

// 打印出集合中的元素个数
System.out.println("集合中的元素个数为:" + set.size());

// 判断集合是否为空
System.out.println("集合是否为空:" + set.isEmpty());

// 判断集合中是否包含某个元素
System.out.println("集合中是否包含 Python:" + set.contains("Python"));

// 从集合中移除某个元素
set.remove("C++");
System.out.println("从集合中移除元素后,集合中的元素个数为:" + set.size());

// 使用迭代器遍历集合中的元素
Iterator iterator = set.iterator();
System.out.println("遍历集合中的元素:");
while (iterator.hasNext()) {
String element = iterator.next();
System.out.println(element);
}

// 清空集合中的所有元素
set.clear();
System.out.println("清空集合中的元素后,集合中的元素个数为:" + set.size());
}
}

该测试用例使用了HashSet作为实现Set接口的具体类,并测试了以下基本操作:



  1. 向集合中添加元素

  2. 打印出集合中的元素个数

  3. 判断集合是否为空

  4. 判断集合中是否包含某个元素

  5. 从集合中移除某个元素

  6. 使用迭代器遍历集合中的元素

  7. 清空集合中的所有元素


测试结果


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


当运行该测试用例后,我们将得到以下输出结果:


集合中的元素个数为:3
集合是否为空:false
集合中是否包含 Python:true
从集合中移除元素后,集合中的元素个数为:2
遍历集合中的元素:
Java
Python
清空集合中的元素后,集合中的元素个数为:0

具体执行截图如下:


在这里插入图片描述


测试代码分析


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


这段代码演示了如何使用Java中的Set接口和HashSet类。具体来说,代码实现了:


1.创建一个HashSet对象。


2.向集合中添加元素。


3.打印出集合中的元素个数。


4.判断集合是否为空。


5.判断集合中是否包含某个元素。


6.从集合中移除某个元素。


7.使用迭代器遍历集合中的元素。


8.清空集合中的所有元素。


  从这段代码可以看出,Set接口和HashSet类可以帮助我们快速地实现集合的添加、删除、查找等操作,并且还支持迭代器遍历集合中的所有元素。


作者:bug菌
来源:juejin.cn/post/7298969233546067968
收起阅读 »

BigDecimal二三事

概述 作为JAVA程序员,应该或多或少跟BigDecimal打过交道。JAVA在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。 精度丢失 先从1个问题说起,看如下代码 System.out.println(...
继续阅读 »

image.png


概述


作为JAVA程序员,应该或多或少跟BigDecimal打过交道。JAVA在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。


精度丢失


先从1个问题说起,看如下代码


System.out.println(0.1 + 0.2);

最后打印出的结果是0.30000000000000004,而不是预期的0.3。

有经验的开发同学应该一下子看出来这就是因为double丢失精度导致。更深层次的原因,是因为我们的计算机底层是二进制的,只有0和1,对于整数来说,从低到高的每1位代表了1、2、4、8、16...这样的2的正次数幂,只要位数足够,每个整数都可以分解成这样的2的正次数幂组合,例如7D=111B13D=1101B。但是到了小数这里,就会发现2的负次数幂值是0.5、0.25、0.125、0.0625这样的值,但是并不是每个小数都可以分解成这样的2的负次数幂组合,例如你无法精确凑出0.1。所以,double的0.1其实并不是精确的0.1,只是通过几个2的负次数幂值凑的近似的0.1,所以会出现前面0.1 + 0.2 = 0.30000000000000004这样的结果。


适用场景


双精度浮点型变量double可以处理16位有效数,但是某些场景下,即使已经做到了16位有效位的数还是不够,比如涉及金额计算,差一点就会导致账目不平。


常用方法


加减乘除


既然BigDecimal主要用于数值计算,那么最基础的方法就是加减乘除。BigDecimal没有对应的数值类的基本数据类型,所以不能直接使用+-*/这样的符号来进行计算,而要使用BigDecimal内部的方法。


public BigDecimal add(BigDecimal augend)
public BigDecimal subtract(BigDecimal subtrahend)
public BigDecimal multiply(BigDecimal multiplicand)
public BigDecimal divide(BigDecimal divisor)

需要注意的是,BigDecimal是不可变的,所以,addsubtractmultiplydivide方法都是有返回值的,返回值是一个新的BigDecimal对象,原来的BigDecimal值并没有变。


设置精度和舍入策略


可以通过setScale方法来设置精度和舍入策略。


public BigDecimal setScale(int newScale, RoundingMode roundingMode)

第1个参数newScale代表精度,即小数点后位数;第2个参数roundingMode代表舍入策略,RoundingMode是一个枚举,用来替代原来在BigDecimal定义的常量,原来在BigDecimal定义的常量已经标记为Deprecated。在RoundingMode类中也通过1个valueOf方法来给出映射关系


/**
* Returns the {@code RoundingMode} object corresponding to a
* legacy integer rounding mode constant in {@link BigDecimal}.
*
* @param rm legacy integer rounding mode to convert
* @return {@code RoundingMode} corresponding to the given integer.
* @throws IllegalArgumentException integer is out of range
*/

public static RoundingMode valueOf(int rm) {
return switch (rm) {
case BigDecimal.ROUND_UP -> UP;
case BigDecimal.ROUND_DOWN -> DOWN;
case BigDecimal.ROUND_CEILING -> CEILING;
case BigDecimal.ROUND_FLOOR -> FLOOR;
case BigDecimal.ROUND_HALF_UP -> HALF_UP;
case BigDecimal.ROUND_HALF_DOWN -> HALF_DOWN;
case BigDecimal.ROUND_HALF_EVEN -> HALF_EVEN;
case BigDecimal.ROUND_UNNECESSARY -> UNNECESSARY;
default -> throw new IllegalArgumentException("argument out of range");
};
}

我们逐一看一下每个值的含义



  • UP

    直接进位,例如下面代码结果是3.15


BigDecimal pi = BigDecimal.valueOf(3.141);
System.out.println(pi.setScale(2, RoundingMode.UP));


  • DOWN

    直接舍去,例如下面代码结果是3.1415


BigDecimal pi = BigDecimal.valueOf(3.14159);
System.out.println(pi.setScale(4, RoundingMode.DOWN));


  • CEILING

    如果是正数,相当于UP;如果是负数,相当于DOWN。

  • FLOOR

    如果是正数,相当于DOWN;如果是负数,相当于UP。

  • HALF_UP

    就是我们正常理解的四舍五入,实际上应该也是最常用的。
    下面的代码结果是3.14


BigDecimal pi = BigDecimal.valueOf(3.14159);
System.out.println(pi.setScale(2, RoundingMode.HALF_UP));

下面的代码结果是3.142


BigDecimal pi = BigDecimal.valueOf(3.14159);
System.out.println(pi.setScale(3, RoundingMode.HALF_UP));


  • HALF_DOWN

    与四舍五入类似,这种是五舍六入。我们对于HALF_UP和HALF_DOWN可以理解成对于5的处理不同,UP遇到5是进位处理,DOWN遇到5是舍去处理,

  • HALF_EVEN

    如果舍弃部分左边的数字为偶数,相当于HALF_DOWN;如果舍弃部分左边的数字为奇数,相当于HALF_UP

  • UNNECESSARY

    非必要舍入。如果除去小数的后导0后,位数小于等于scale,那么就是去除scale位数后面的后导0;位数大于scale,抛出ArithmeticException。

    下面代码结果是3.14


BigDecimal pi = BigDecimal.valueOf(3.1400);
System.out.println(pi.setScale(2, RoundingMode.UNNECESSARY));

下面代码抛出ArithmeticException


BigDecimal pi = BigDecimal.valueOf(3.1400);
System.out.println(pi.setScale(1, RoundingMode.UNNECESSARY));

常见问题


创建BigDecimal对象


先看下面代码


BigDecimal a = new BigDecimal(0.1);
System.out.println(a);

实际输出的结果是0.1000000000000000055511151231257827021181583404541015625。其实这跟我们开篇引出的精度丢失是同一个问题,这里构造方法中的参数0.1是double类型,本身无法精确表示0.1,虽然BigDecimal并不会导致精度丢失,但是在更加上游的源头,double类型的0.1已经丢失了精度,这里用一个已经丢失精度的0.1来创建不会丢失精度的BigDecimal,精度还是会丢失。类似于使用2K的清晰度重新录制了一遍原始只有360P的视频,清晰度也不会优于原始的360P。

所以,我们应该尽量避免使用double来创建BigDecimal,确实源头是double的,我们可以使用valueOf方法,这个方法会先调用Double.toString(val)来转成String,这样就不会产生精度丢失,下面的代码结果就是0.1


BigDecimal a = BigDecimal.valueOf(0.1);
System.out.println(a);

顺便说一下,BigDecimal还内置了ZEROONETEN这样的常量可以直接使用。


toString


这个问题比较隐蔽,在数据比较小的时候不会遇到,但是看如下代码


BigDecimal a = BigDecimal.valueOf(987654321987654321.123456789123456789);
System.out.println(a);

最后实际输出的结果是9.8765432198765427E+17。原因是System.out.println会自动调用BigDecimal的toString方法,而这个方法会在必要时使用科学计数法,如果不想使用科学计数法,可以使用BigDecimal的toPlainString方法。另外提一下,BigDecimal还提供了一个toEngineeringString方法,这个方法也会使用科学技术法,不一样的是,这里面的10都是3、6、9这样的幂,对应我们在查看大数的时候,很多都是每3位会增加1个逗号。


comparTo 和 equals


这个问题出现的不多,有经验的开发同学在比较数值的时候,会自然而然使用comparTo方法。这里说一下BigDecimal的equals方法除了比较数值之外,还会比较scale精度,不同精度不会equles。

例如下面代码分别会返回0false


BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.10");
System.out.println(a.compareTo(b));
System.out.println(a.equals(b));

不能除尽时ArithmeticException异常


上面提到的加减乘除的4个方法中,除法会比较特殊,因为可能出现除不尽的情况,这时如果没有设置精度,就会抛出ArithmeticException,因为这个是否能除尽是跟具体数值相关的,这会导致偶现的bug,更加难以排查。

例如下面代码就会抛出ArithmeticException异常


BigDecimal a = new BigDecimal(1);
BigDecimal b = new BigDecimal(3);
System.out.println(a.divide(b));

应对的方法是,在除法运算时,注意设置结果的精度和舍入模式,下面的代码就能正常输出结果0.33


BigDecimal a = new BigDecimal(1);
BigDecimal b = new BigDecimal(3);
System.out.println(a.divide(b, 2, RoundingMode.HALF_UP));

总结


BigDecimal主要用于double因为精度丢失而不满足的某些特殊业务场景,例如会计金额计算。在可以忍受略微不精确的场景还是使用内部提供的addsubtractmultiplydivide方法来进行基础的加减乘除运算,运算后会返回新的对象,原始的对象并不会改变。在使用BigDecimal的过程中,要注意创建对象、toString、比较数值、不能除尽时需要设置精度等问题。



作者:podongfeng
来源:juejin.cn/post/7195489874422513701
收起阅读 »

看明白两个案例,秒懂事件循环

web
事件循环的任务队列包括宏任务和微任务 执行顺序就是:同步代码 -> 第一轮微任务 -> 第一轮宏任务 -> 第二轮微任务 ->... 宏任务有:setTimeout, setInterval, setImmediate, I/O, UI...
继续阅读 »

事件循环的任务队列包括宏任务微任务


执行顺序就是:同步代码 -> 第一轮微任务 -> 第一轮宏任务 -> 第二轮微任务 ->...


宏任务有:setTimeout, setInterval, setImmediate, I/O, UI rendering。


微任务有:process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)


两大原则:



  1. setTimeout和setInterval同源,且均优先于setImmediate执行

  2. nextTick队列会比Promie.then方法里面的代码先执行


简单案例


setTimeout(function() {
console.log('timeout1'); // 5-第一轮宏任务
})

new Promise(function(resolve) {
console.log('promise1'); // 1-同步代码
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log('promise2'); // 2-同步代码
}).then(function() {
console.log('then1'); // 4-第一轮微任务
})

console.log('global1'); // 3-同步代码


/*
promise1
promise2
global1
then1
timeout1
*/


综合案例


console.log('golb1'); // 1-同步代码

setTimeout(function() {
console.log('timeout1'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('timeout1_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('timeout1_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('timeout1_then') // 3.4-第二轮微任务
})
})

setImmediate(function() {
console.log('immediate1'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('immediate1_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('immediate1_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('immediate1_then') // 3.4-第二轮微任务
})
})

process.nextTick(function() {
console.log('glob1_nextTick');// 2.1-第一轮微任务
})
new Promise(function(resolve) {
console.log('glob1_promise');// 1-同步代码
resolve();
}).then(function() {
console.log('glob1_then') // 2.2-第一轮微任务
})

setTimeout(function() {
console.log('timeout2'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('timeout2_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('timeout2_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('timeout2_then') // 3.4-第二轮微任务
})
})

process.nextTick(function() {
console.log('glob2_nextTick');// 2.1-第一轮微任务
})
new Promise(function(resolve) {
console.log('glob2_promise');// 1-同步代码
resolve();
}).then(function() {
console.log('glob2_then')// 2.2-第一轮微任务
})

setImmediate(function() {
console.log('immediate2'); // 3.1-第一轮宏任务
process.nextTick(function() {
console.log('immediate2_nextTick'); // 3.3-第二轮微任务
})
new Promise(function(resolve) {
console.log('immediate2_promise'); // 3.2-第一轮宏任务
resolve();
}).then(function() {
console.log('immediate2_then') // 3.4-第二轮微任务
})
})

/*
(1-同步代码)
golb1
glob1_promise
glob2_promise
(2-第一轮微任务)
glob1_nextTick
glob2_nextTick
glob1_then
glob2_then
(3-第一轮宏任务)
(setTimeout)
timeout1
timeout1_promise
timeout1_nextTick
timeout1_then
timeout2
timeout2_promise
timeout2_nextTick
timeout2_then
(setImmediate)
immediate1
immediate1_promise
immediate1_nextTick
immediate1_then
immediate2
immediate2_promise
immediate2_nextTick
immediate2_then
*/


注:在Node 11前,Node的事件循环会与浏览器存在差异,以上面案例中的两个setTimeout为例:


//在Node 11前
timeout1
timeout1_promise
timeout2
timeout2_promise
timeout1_nextTick
timeout2_nextTick
timeout1_then
timeout2_then
// 在Node11后和浏览器
timeout1
timeout1_promise
timeout1_nextTick
timeout1_then
timeout2
timeout2_promise
timeout2_nextTick
timeout2_then

即在同一类任务分发器(如:多个setTimeout),在Node 11前,会先执行所有的nextTick,再到Promise.then;而在Node11后和浏览器,都是依次执行每个setTimeout,在同一个setTimeout里面先执行所有nextTick,再到Promise.then。


Refs:


mp.weixin.qq.com/s/m3a6vjp8-…


作者:星辰_Stars
来源:juejin.cn/post/7298325881731219496
收起阅读 »

面试题:小男孩毕业之初次面试

web
前言 看到身边的同学渐渐的都有了一些面试之后,我逐渐感到了焦虑,甚至都有些对自己感到不自信,之后在上周三的上午,终于时来运转,先是梭翱打电话来,之后就是美云和琻瑢那边的简历初筛通过,通知我面试,之后按照自己的回忆写下了一些感悟与题目,希望对你们有所帮助。 浙江...
继续阅读 »

前言


看到身边的同学渐渐的都有了一些面试之后,我逐渐感到了焦虑,甚至都有些对自己感到不自信,之后在上周三的上午,终于时来运转,先是梭翱打电话来,之后就是美云和琻瑢那边的简历初筛通过,通知我面试,之后按照自己的回忆写下了一些感悟与题目,希望对你们有所帮助。


浙江杭州(实习 130-160/天)



这是我的第一场面试,面试官问的都是vue的问题。这场面试全程懵逼下来的,因为我前面基本都在准备js和css方面,vue方面也就瞄了几眼,结果就是和面试官疯狂的扯。面试完之后反思,在自我介绍中一定要讲清楚自己使用了是vue2还是vue3,不熟悉或者面试前没准备好的知识点一定不要讲出来,全程懵下来血的教训。然后也是电话面试,所以在听面试官老师的问题方面可能有点费力。在看面试题的时候,不要死记硬背,可以根据自己熟悉的语句自己表达出来就行。



1. 说一下vue2和vue3生命周期的实现和它们的不同点?


每个Vue实例在创建时都会经过一系列的初始化过程,vue的生命周期钩子,就是说在达到某一阶段或条件时去触发的函数,目的就是为了完成一些动作或者事件


Vue2的生命周期函数




  • create阶段:vue实例被创建


    beforeCreate: 创建前,此时data和methods中的数据都还没有初始化


    created: 创建完毕,data中有值,未挂载




  • mount阶段: vue实例被挂载到真实DOM节点


    beforeMount:可以发起服务端请求,取数据


    mounted: 此时可以操作DOM




  • update阶段:当vue实例里面的data数据变化时,触发组件的重新渲染


    beforeUpdate:更新前


    updated:更新后




  • destroy阶段:vue实例被销毁


    beforeDestroy:实例被销毁前,此时可以手动销毁一些方法


    destroyed:销毁后




上述生命周期钩子函数中,beforeCreate和created钩子函数在组件创建时只会执行一次,而beforeMount、mounted、beforeUpdate和updated钩子函数则会在组件的数据发生变化时多次执行。在组件销毁时,beforeDestroy和destroyed钩子函数也只会执行一次。


Vue3的生命周期函数




  • setup() : 开始创建组件之前,在 beforeCreate 和 created 之前执行,创建的是 data 和 method




  • mount阶段: vue实例被挂载到真实DOM节点


    onBeforeMount() : 组件挂载到节点上之前执行的函数;


    onMounted() : 组件挂载完成后执行的函数;




  • update阶段:当vue实例里面的data数据变化时,触发组件的重新渲染


    onBeforeUpdate(): 组件更新之前执行的函数;


    onUpdated(): 组件更新完成之后执行的函数;




  • unmount阶段:vue实例被销毁


    onBeforeUnmount(): 组件卸载之前执行的函数;


    onUnmounted(): 组件卸载完成后执行的函数;




在Vue3中,beforeDestroy钩子函数被废弃,取而代之的是onUnmounted钩子函数。与Vue2不同,onUnmounted钩子函数在组件卸载之后调用,而不是在组件销毁之前调用。此外,Vue3还新增了一个onErrorCaptured钩子函数,用于处理子孙组件抛出的错误。


不同


1. vue3和vue2的生命周期函数名称


在Vue2中,我们熟悉的生命周期函数有:beforeCreate、created、beforeMountmounted、beforeUpdate、updated、 beforeDestroy、destroyed。而在Vue3中,这些函数名称被进行了重命名,变成了:beforeCreate->setup,created->setup,beforeMount->onBeforeMount,mounted->onMounted,beforeUpdate->onBeforeUpdate,updated->onUpdated,beforeUnmount ->onBeforeUnmount,unmounted ->onUnmounted。


重命名的原因是为了更好地反映生命周期的不同阶段,方便开发者进行理解和使用。


常用生命周期对比如下表所示。


vue2vue3
beforeCreate使用 setup()
created使用 setup()
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeDestroyonBeforeUnmount
destroyedonUnmounted

2. 新增和废弃生命周期函数


Vue3为我们提供了一些新的生命周期函数,这些函数可以帮助我们更好地管理组件,Vue3废弃了beforeDestroy钩子函数,并且新增了生命周期函数。这些新的生命周期函数分别是:


onRenderTracked:当渲染跟踪或依赖项跟踪时被调用。


onRenderTriggered:当渲染时触发其他渲染时,或者当在当前渲染中延迟调度的作业时被调用。


onErrorCaptured:当子组件抛出未处理的错误时被调用。这些新的生命周期函数可以帮助我们更好地调试、优化组件,提升应用的性能。


3. 使用hook函数代替生命周期函数


Vue3引入了新的API——Composition API,通过这个API可以使用hook函数来代替生命周期函数。 Composition API可以让我们更好地管理代码逻辑,将不同的功能划分为不同的小函数,便于维护和复用。hook函数在组件中的调用顺序与生命周期函数类似,但是更加灵活,可以根据需要进行组合和抽离。


4.v-if和v-for的优先级不同


vue2生命周期执行过程


生命周期.png


vue3生命周期执行过程


image.png


2. Vue2和Vue3数据更新时有什么不一样?


Proxy 替代 Object.defineProperty:在Vue2中,使用Object.defineProperty来拦截数据的变化,但是该方法存在一些缺陷,比如不能监听新增的属性和数组变化等。Vue3中使用了ES6中的Proxy来拦截数据的变化,能够完全监听数据变化,并且能够监听新增的属性。


批量更新:Vue2中,在数据变化时,会立即触发虚拟DOM的重渲染,如果在一个事件循环中连续修改多个数据,可能会造成性能问题。而Vue3中,使用了更高效的批量更新策略,会在下一个事件循环中统一处理数据变化,提高了性能。


更快的响应式系统:Vue3中使用了更快的响应式系统,能够更快地追踪依赖关系,并在数据变化时更快地更新视图。此外,Vue3还对Reactivity API进行了优化,使得开发者能够更灵活地使用响应式数据。


Composition API:Vue3中引入了Composition API,可以更好地组织代码逻辑,也可以更好地处理数据更新。通过使用setup函数和ref、reactive等函数,能够更方便地对数据进行监听和修改。


3. 为什么vue中更改对象和数组时,有时候页面没有进行更新




  1. 对象或数组未在初始时声明为响应式:在Vue中,只有在初始时声明为响应式的对象和数组才能进行监听和更新。如果在初始时没有声明为响应式,那么更改对象或数组时,Vue无法检测到变化,从而无法进行更新。




  2. 直接更改对象或数组的属性或元素:在Vue中,如果直接更改对象或数组的属性或元素,Vue无法检测到变化。因此,应该使用Vue提供的响应式方法来更改对象或数组的属性或元素,例如Vue.setVue.$set方法。




  3. 变异方法不会触发更新:Vue会对一些常用的数组变异方法进行封装,使其成为响应式的,例如pushpopshiftunshiftsplicesortreverse方法。但是,如果使用不在这个列表中的变异方法来更改数组,Vue就无法检测到变化。因此,应该尽可能使用Vue封装过的变异方法。




  4. 异步更新:在Vue中,更新是异步的。当数据发生变化时,Vue会将更新推迟到下一个事件循环中。因此,如果在一个事件循环中进行多次数据更改,Vue只会进行一次更新。如果需要在一次事件循环中进行多次数据更改,请使用Vue.nextTick方法。




总之,为了确保Vue可以正确地监听和更新对象和数组,应该在初始时将它们声明为响应式,避免直接更改对象或数组的属性或元素,尽可能使用Vue提供的响应式方法,避免使用不在Vue封装列表中的变异方法,以及注意异步更新的特性。


4. 你在项目里面是怎么使用vuex/pinia?


在我的项目中我使用的是pinia


首先,先通过npm安装pinia


npm install pinia

其次,在根组件app.vue中创建Pinia实例并将其注册为应用程序的插件


import { createPinia } from 'pinia'
const pinia = createPinia()
createApp(App).use(pinia).mount('#app')

接着,在src目录下创建一个store文件夹中的index.js,而在使用Pinia时,通过引入Pinia中的defineStore来定义一个store实例,类似于Vuex的store。然后我定义了不同的子仓库并暴露(export),来存储对应不同的页面所需的数据与操作,之后再返回(return)数据和操作。而在组件中使用Pinia时,需要通过引入,useStore辅助函数获取store实例,并将状态、操作和获取器映射到组件中,以便使用。


import { defineStore } from "pinia";
import { reactive } from "vue";

export const useUserStore = defineStore('user', () => {
const state = reactive({gridList:[]})
const loadUser = async () => {}
return {
state,
loadUser
}
})

import { useUserStore } from "@/store/user";

const userStore = useUserStore();
const gridList = computed(() => userStore.state.gridList);

上海(实习 100-150/天)



该面试是通过视频面试,面试的时候题目相对比较简单,都是一些基础的问题,这也就给了我极大的自信



1. JS的Event Loop你能给我介绍下吗?


因为JS是单线程的语言,为了防止一个函数执行时间过长阻塞后面的代码,所以就需要Event Loop这个事件环的运行机制。


当执行一段有同步又有异步的代码时,会先将同步任务压入执行栈中,然后把异步任务放入异步队列中等待执行,微任务放到微任务队列,宏任务放到宏任务队列,依次执行。执行完同步任务之后,Event Loop会先把微任务队列执行清空,微任务队列清空后,进入宏任务队列,取宏任务队列的第一个项任务进行执行,执行完之后,查看微任务队列是否有任务,有的话,清空微任务队列。然后再执行宏任务队列,反复微任务宏任务队列,直到所有队列任务执行完毕。


PS: 答完了基本的答案之后,最好可以往下继续延申,不要让面试成为一问一答,这样你的面试就会变的比较丰满,让面试官不至于太枯燥,直到面试官让你停为止。



异步队列又分为宏任务队列和微任务队列,因为宏任务队列的执行时间较长,所以微任务队列要优先于宏任务队列先执行。


微任务队列的代表就是,Promise.thenMutationObserver,宏任务的话就是setImmediate setTimeout setInterval



2. 渲染页面的重绘回流你能给我讲一下吗?




  • 重排/回流(Reflow):当DOM元素发生了规格大小,位置,增删改的操作时,浏览器需要重新计算元素的几何属性,重新生成布局,重新排列元素。




  • 重绘(Repaint): 当一个DOM元素的外观样式发生改变,但没有改变布局,重新把DOM元素的样式渲染到页面的过程。





重排和重绘它们会破坏用户体验,并且让UI展示非常迟缓,而在两者无法避免的情况下,重排的性能影响更大,所以一般选择代价更小的重绘。


『重绘』不一定会出现『重排』,『重排』必然会出现『重绘』。



上海(实习 200-210/天)



这场面试很正常,自我感觉含金量也比较高,通过视频面试能够知道,面试官老师人也长得挺帅的,说话和蔼,讲真,人真的挺好的。不过自己还会犯傻,走进思维误区,没有理解清面试官老师的问题,所以在面试中如果没听清楚问题,千万一定要再问一下面试官。



1. 响应式开发你了解吗?响应式是如何实现的呢?


响应式开发是一种设计和开发网站或应用程序的方法,使其能够在不同设备上以适应性和灵活性的方式呈现。它可以确保网站或应用程序在各种屏幕尺寸、浏览器和设备上都能提供良好的用户体验。


响应式开发的实现基于使用CSS媒体查询、弹性布局和流体网格等技术。以下是一些主要的实现方法:




  1. CSS媒体查询:使用CSS媒体查询可以检测设备的屏幕尺寸、分辨率和方向等特性,并根据这些特性应用不同的样式规则。通过定义不同的CSS样式,可以使网页在不同的设备上以不同的方式呈现。




  2. 弹性布局:即(display:flex),使用弹性布局(flexbox)可以创建灵活的布局结构,使内容能够根据屏幕尺寸进行自动调整。弹性布局使得元素的大小、位置和间距能够根据可用空间进行自适应。




  3. 网格布局:即(display:grid),使用流体网格(fluid grid)可以创建基于相对单位(如百分比)的网格系统,使页面的布局能够根据屏幕大小进行缩放和调整。这样可以确保内容在不同屏幕尺寸上均匀分布和对齐。




2. 媒体查询这个你了解吗?


我在使用less预编译样式中使用过媒体查询(这里提一嘴自己使用过less或者其他的预编译),媒体查询使用@media规则来定义,其语法如下:


@media mediatype and|not|only (media feature) {
/* CSS样式规则 */
}

其中,mediatype指定了媒体类型,如screen表示屏幕媒体、print表示打印媒体等。andnotonly是逻辑运算符,用于组合多个条件。media feature表示设备的特性,如width表示屏幕宽度、orientation表示屏幕方向等。


下面是一些常用的媒体特性:



  • width:屏幕宽度。

  • height:屏幕高度。

  • device-width:设备屏幕宽度。

  • device-height:设备屏幕高度。

  • orientation:屏幕方向(横向或纵向)。

  • aspect-ratio:屏幕宽高比。

  • color:设备的颜色位深。

  • resolution:屏幕分辨率。


通过结合不同的媒体特性和条件,可以根据设备的不同特性来应用不同的CSS样式。例如,可以使用媒体查询来定义在屏幕宽度小于某个阈值时应用的样式,或者根据屏幕方向调整布局等。


以下是一个示例,演示如何使用媒体查询在屏幕宽度小于600px时应用特定的样式:


@media screen and (max-width: 600px) {
/* 在屏幕宽度小于600px时应用的样式 */
body {
font-size: 14px;
}
/* 其他样式规则 */
}

这样,当浏览器窗口宽度小于600px时,body元素的字体大小将被设置为14px。


3. CSS的伪元素你知道是什么东西吗?


伪元素是CSS中的一种特殊选择器,用于向选中的元素的特定部分添加样式,而不需要在HTML结构中添加额外的元素。伪元素使用双冒号::作为标识符,用于区分伪类(pseudo-class)和伪元素。(在旧版本的CSS中,单冒号:也被用作伪元素的标识符,但在CSS3规范中,建议使用双冒号以区分伪类和伪元素。)


以下是一些常用的CSS伪元素:



  1. ::before:在选中元素的内容之前插入一个生成的内容。

  2. ::after:在选中元素的内容之后插入一个生成的内容。


这些伪元素可以与CSS的属性和样式一起使用,例如contentcolorbackground等,以为选中的元素的特定部分添加样式。


以下是一个示例,演示如何使用伪元素为元素的内容之前插入一个生成的内容并应用样式:


p::before {
content: "前缀:";
font-weight: bold;
color: blue;
}

在上述示例中,::before伪元素被应用于<p>元素,它在该段落的内容之前插入了一个生成的文本"前缀:",并为该生成的文本应用了加粗字体和蓝色的颜色。


4. 介绍一下HTML5的特有的标签?



  1. 语义化标签



  • <article>:用于表示独立的、完整的文章内容。

  • <section>:用于表示页面或应用程序中的一个区域,可以包含一个标题。

  • <header>:用于表示页面或应用程序的标题,通常包含logo和导航。

  • <footer>:用于表示页面或应用程序的页脚部分,通常包含版权信息、联系方式等。

  • <nav>:用于表示导航链接的集合,通常包含一组指向其他页面的链接。

  • <aside>:用于表示页面或应用程序的旁边栏,通常包含相关的信息、广告、链接等。



  1. <video>:用于嵌入视频文件,可以使用<source>标签指定多个视频文件,以便在不同的浏览器和设备上播放。

  2. <audio>:用于嵌入音频文件,可以使用<source>标签指定多个音频文件,以便在不同的浏览器和设备上播放。

  3. <canvas>:用于创建绘图区域,可以使用JavaScript在上面绘制图形、动画等。

  4. <progress>:用于显示进度条,表示任务完成的进度。


5. 你如果要做一个搜索引擎比较友好的页面,应该是要做到些什么东西呢?




  1. 使用语义化的HTML标记:使用适当的HTML标签来正确表示页面的结构,如使用<header><nav><article>等。




  2. 使用有意义的标题:使用恰当的标题标签(<h1><h2>等)来突出页面的主题和内容。




  3. 提供关键词和描述:在HTML文档中,可以通过<meta>标签来定义各种属性,比如页面的描述和关键字。


    keywords:向搜索引擎说明你的网页的关键词


     `<meta name="keyword" content="前端,面试,小厂">`

    description:告诉搜索引擎你的站点的主要内容


    <meta name="description" content="页面描述,包含关键字和吸引人的内容">



  4. 使用合适的图像标签:为图片使用适当的alt属性,描述图片内容,方便搜索引擎理解图像。




  5. 使用服务端渲染(SSR)的框架,比如vue中的Nuxtreact中的Next,即在服务端生成完整的 HTML 页面,并将其发送给浏览器。这使得搜索引擎可以更好地理解和索引页面的内容,因为它们可以直接看到渲染后的页面。




6. 介绍一下flex的布局吧?


## 阮一峰老师有一个博客,专门讲解一个flex布局,你可以讲一下flex布局吗?


7. 后端和前端的一些交互,你了解是什么东西?


后端和前端之间的交互通常通过前后端分离的架构来实现,其中前端负责展示界面和用户交互,后端负责处理数据和逻辑操作。


以下是一些常见的后端和前端交互的方式和技术:




  1. RESTful API:使用基于HTTP的RESTful API,前端可以向后端发送请求并获取数据。后端提供API接口,通过GETPOSTPUTDELETE等HTTP方法来处理前端请求,并返回相应的数据。前端可以使用Ajax、Fetch API或axios等工具来发送请求和处理响应。




  2. 数据传输格式前后端交互时需要使用一致的数据传输格式。常见的数据格式包括JSON(JavaScript Object Notation)和XML(eXtensible Markup Language)。前端可以发送数据请求给后端,后端将数据以指定的格式进行封装和返回给前端。




  3. 然后我还使用过nodejs中的koa洋葱模型简单搭建过一个MVC结构的服务器。




8. 那你有遇到过跨域问题吗?实际解决方法?


我分别说了




  • JSONP:在DOM文档中,使用<script>标签,但却缺点只能发 GET 请求并且容易受到XSS跨站脚本攻击




  • CORS:通过在服务器配置响应头,Access-Control-Allow-xxx字段来设置访问的白名单、可允许访问的方式等




  • postMessage




  • html原生的websocket




  • 代理 白嫖即食:构建工具的proxy代理配置区别(解决跨域)




讲了这些东西之后,面试官就让我说一下实际解决方法,像jsonp,postMeassage都不是正常的


然后我就把整个CORS跨域的过程给讲了一遍,包含了浏览器的跨域拦截



首先,浏览器进行了一个跨域请求,向服务器发送了一个预检(options)请求,服务器会在响应头部中设置Access-Control-Allow-Origin和Access-Control-Allow-Methods等配置,告知浏览器是否允许跨域请求。如果该页面满足服务器设置的白名单和可允许访问的方式,那么服务器就允许跨域访问,浏览器就会接受响应,进行真实的跨域请求,否则就会报错。



面试基本必问问题


1. 你有什么想问我的吗?(问到这里一场面试结束了)




  1. 公司团队使用的技术栈有哪些?




  2. 如果我面试通过后,公司是否有人带,主要做些什么




  3. 公司团队提交代码的工具有什么要求吗?




  4. 把之前没答上来的问题可以再问一遍(让面试官感到你很好学)




2. 你写项目的时候碰到过印象里比较深刻的一些bug或困难,你怎么解决的?


其实这部分可以从侧面分析这个问题,问你遇到的bug可能一时半会儿不知道怎么回答,但如果问你是如何实现项目中的某个功能,这时候就好回答了,只需要转换回答成没有这个功能代码会出现什么问题。所以面试官不是问你有什么bug,而是你在项目中有哪些亮点。



前端中常见的一些bug



  1. JavaScript 错误:在应用程序中使用的 JavaScript 代码可能包含语法错误或逻辑错误,这些错误会导致应用程序在执行时出现问题,从而导致性能问题。

  2. DOM 操作错误:通过 JavaScript 操作文档对象模型 (DOM) 可以更新应用程序中的 HTML 元素。但是,如果 DOM 操作不正确或在操作过程中执行了太多的操作,可能会导致性能问题。

  3. 页面重绘:当用户与页面交互时,浏览器会执行重新绘制和重排操作。如果页面包含太多的重绘操作或页面重排操作,则可能导致性能问题。

  4. 图像和资源加载:在加载图像和其他资源时,如果没有正确管理缓存或使用适当的图像格式,则可能导致性能问题。

  5. 前端框架错误:使用前端框架时,可能会出现错误或不良的编码实践,这些问题可能会导致性能问题。



axios响应拦截


遇到bug:我是使用mockjs来模拟后端的接口,当时我在设置端口的返回值时,我返回数据有一个状态码以及把json数据中export出来的detail数据添加到data这个需要返回的数据(代码如下),这导致我在获取接口里的数据时需要多.data(引用一层data),当时我没意识,结果一直获取不到数据。


解决办法:通过使用axios进行请求和响应,并在响应的时候设置一个拦截,对响应进行一番处理之后就可以直接拿到接口返回的值,而不会导致接口返回的值不会有太多的嵌套了。


Mock.mock(/\/detail/, 'get', () => {
return {
code: 0, // 返回状态码
data: detail // 返回数据
}
})

import axios from "axios";
// 响应拦截器
axios.interceptors.response.use((res) => {
return res.data
})

图片和组件的懒加载


遇到的bug:我做的项目使用了很多的组件页面和大量的图片,导致在加载页面时耗时比较久,以及在页面的切换时很多暂时不需要的页面组件一次性全部加载了,导致整个项目的性能非常差。


解决办法


图片懒加载:在App.vue中引入VueLazy并且使用app.use启用它,然后把图片中的src改成v-lazy


<img :src="xxx.png">

改成


<img v-lazy="xxx.png">

页面组件懒加载:在router配置中的component,把直接在代码一开始引入组件页面,改成箭头函数式引入。


    import Home from '@/views/Home/Home.vue' 
{
path: '/',
component: Home
},

改成


    {
path: '/',
component: () => import('@/views/Home/Home.vue')
},

搜索界面节流


遇到的bug:在搜索界面的时候,当我一直点击搜索时,它会频繁的进行请求,造成了不必要的性能损耗。


解决办法:使用loadash库中的节流API,进行对触发搜索事件进行节流,防止用户进行频繁的搜索请求导致性能损耗。



import _ from 'lodash'

const value = ref(null)

const ajax1 = () => {
console.log('开始搜索,搜索内容为' + value.value)
}

let debounceAjax1 = _.debounce(ajax1, 1000)

const onSearch = () => {
if (!value.value) {
showToast('搜索内容为空,请输入内容')
return
}
debounceAjax1()
}

404页面


遇到的bug:当输入url中没有在路由配置中配置过的路径时,页面它会出现空白,并且浏览器发出警告,如果我这个项目上线的话,可能会造成用户的体验不友好和搜索引擎不友好。


解决办法:在路由配置中再配置一个404页面的路径,这样就能使用户不管怎么输入不合规的url后,都会提示用户输错了网址。


    {
path: '/404',
name: 'NotFound',
component: () => import('@/views/NotFound/Index.vue')
},
// 所有未定义路由,全部重定向到404页
{
path: '/:pathMatch(.*)',
redirect: '/404'
}

结语


面试,说到底,迈开第一步其实是最重要的,别想那么多,要抱着反正有那么多家公司,我没必要非要去你这一家的心态去面试,把面试官当作一个久久未联系过的老朋友,突然有一天碰到了聊起天。面试完之后一定及时的整理复盘,不断地让自己变得更加牢固。


作者:吃腻的奶油
来源:juejin.cn/post/7233307834456375353
收起阅读 »

听说你会架构设计?来,弄一个群聊系统

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。 1. 引言 前些天所在部门出去团建,于是公司行政和 HR 拉了一个微信群,发布一些跟团和集合信息。 当我正在查看途径路线和团建行程时,忽然一条带着...
继续阅读 »

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。


1. 引言


前些天所在部门出去团建,于是公司行政和 HR 拉了一个微信群,发布一些跟团和集合信息。


当我正在查看途径路线和团建行程时,忽然一条带着喜意的消息扑面而来,消息上赫然带着八个大字:恭喜发财,大吉大利



抢红包!!原来是公司领导在群里发了个红包,于是引得群员哄抢,气氛其乐融融。



毕竟,团不团建无所谓,不上班就很快乐;抢多抢少无所谓,有钱进就很开心。



打工人果然是最容易满足的生物!


我看着群里嬉戏打闹的聊天,心中陷入了沉思:微信这个集齐了陌生人聊天、文件分享和抢红包功能的群聊设计确实有点意思,如果在面试或者工作中让我们设计一个群聊系统,需要从哪些方面来考虑呢?


群聊系统设计


面试官:微信作为 10 亿用户级别的全民 App,有用过吧?


我:(内心 OS,说没用过你也不会相信啊~)当然,亲爱的面试官,我经常使用微信来接收工作消息和文件,并且经常在上面处理工作内容。


面试官:(内心 OS:这小伙子工作意识很强嘛,加分!)OK,微信的群聊功能是微信里面核心的一个能力,它可以将数百个好友或陌生人放进一个群空间,如果让你设计一个用户量为 10 亿用户的群聊系统,你会怎么设计呢?


2. 系统需求


2.1 系统特点与功能需求


我:首先群聊功能是社交应用的核心能力之一,它允许用户创建自己的社交圈子,与家人、朋友或共同兴趣爱好者进行友好地交流。


以下是群聊系统常见的几个功能:





  • 创建群聊:用户可以创建新的聊天群组,邀请其他好友用户加入或与陌生人面对面建群。




  • 群组管理:群主和管理员能够管理群成员,设置规则和权限。




  • 消息发送和接收:允许群成员发送文本、图片、音频、视频等多种类型的消息,并推送给所有群成员。




  • 实时通信:消息应该能够快速传递,确保实时互动。




  • 抢红包:用户在群聊中发送任意个数和金额的红包,群成员可以抢到随机金额的红包。




2.2 非功能需求


除了功能需要,当我们面对 10 亿微信用户每天都可能使用建群功能的情景时,还需要处理大规模的用户并发。


这就引出了系统的非功能需求,包括:



  • 高并发:系统需要支持大量用户同时创建和使用群组,以确保无延迟的用户体验。

  • 高性能:快速消息传递、即时响应,是数字社交的关键。

  • 海量存储:系统必须可扩展,以容纳用户生成的海量消息文本、图片及音视频数据。


面试官:嗯,不错,那你可以简要概述一下这几个常用的功能吗?


3. 核心组件


我:好的,我们首先做系统的概要设计,这里涉及到群聊系统的核心组件和基本业务的概要说明。


3.1 核心组件


群聊系统中,会涉及到如下核心组件和协议。




  • 客户端:接收手机或 PC 端微信群聊的消息,并实时传输给后台服务器;

  • Websocket传输协议:支持客户端和后台服务端的实时交互,开销低,实时性高,常用于微信、QQ 等 IM 系统通信系统;

  • 长连接集群:与客户端进行 Websocket 长连接的系统集群,并将消息通过中间件转发到应用服务器;

  • 消息处理服务器集群:提供实时消息的处理能力,包括数据存储、查询、与数据库交互等;




  • 消息推送服务器集群:这是信息的中转站,负责将消息传递给正确的群组成员;




  • 数据库服务器集群:用于存储用户文本数据、图片的缩略图、音视频元数据等;




  • 分布式文件存储集群:存储用户图片、音视频等文件数据。




3.2 业务概要说明


在业务概要说明里,我们关注用户的交互方式和数据存储......


面试官:稍等一下,群聊系统的好友建群功能比较简单,拉好友列表存数据就可以了!你用过面对面建群吧,可以简要说一下如何设计面对面建群功能吗?


我:(内心 OS,还好之前在吃饭时用过面对面建群结账,不然就G了),好的,群聊系统除了拉好友建群外,还支持面对面建群的能力。


4. 面对面建群


用户发起面对面建群后,系统支持输入一个 4 位数的随机码,周围的用户输入同一个随机码便可加入同一个群聊,面对面建群功能通常涉及数据表设计和核心业务交互流程如下。


4.1 数据库表设计




  1. User 表:存储用户信息,包括用户 ID、昵称、头像等。




  2. Gr0up 表:存储群组信息,包括群 ID、群名称、创建者 ID、群成员个数等。




  3. Gr0upMember 表:关联用户和群组,包括用户 ID 和群 ID。




  4. RandomCode 表:存储面对面建群的随机码和关联的群 ID。




4.2 核心业务交互流程



用户 A 在手机端应用中发起面对面建群,并输入一个随机码,校验通过后,等待周围(50 米之内)的用户加入。此时,系统将用户信息以 HashMap 的方式存入缓存中,并设置过期时间为 3min


{随机码,用户列表[用户A(ID、名称、头像)]}

用户 B 在另一个手机端发起面对面建群,输入指定的随机码,如果该用户周围有这样的随机码,则进入同一个群聊等待页面,并可以看到其它群员的头像和昵称信息


此时,系统除了根据随机码获取所有用户信息,也会实时更新缓存里的用户信息。



成员A进群


当第一个用户点击进入该群时,就可以加入群聊,系统将生成的随机码保存在 RandomCode 表中,并关联到新创建的群 ID,更新群成员的个数。


然后,系统将用户信息和新生成的群聊信息存储在 Gr0up、Gr0upMember 表中,并实时更新群成员个数。


成员B加入


然后,B 用户带着随机码加入群聊时,手机客户端向服务器后端发送请求,验证随机码是否有效。后台服务检查随机码是否存在于缓存中,如果存在,则校验通过。


然后,根据 Gr0up 中的成员个数,来判断当前群成员是否满员(目前普通用户创建的群聊人数最多为 500 人)。


如果验证通过,后台将用户 B 添加到群成员表 Gr0upMember 中,并返回成功响应。


面试官:如果有多个用户同时加入,MySQL 数据库如何保证群成员不会超过最大值呢?


我:有两种方式可以解决。一个是通过 MySQL 的事务,将获取 Gr0up 群成员数和插入 Gr0upMember 表操作放在同一个事务里,但是这样可能带来锁表的问题,性能较差。


另一种方式是采用 Redis 的原子性命令incr 来记录群聊的个数,其中 key 为群聊ID,value 为当前群成员个数。


当新增群员时,首先将该群聊的人数通过 incr 命令加一,然后获取群成员个数。如果群员个数大于最大值,则减一后返回群成员已满的提示。


使用 Redis 的好处是可以快速响应,并且可以利用 Redis 的原子特性避免并发问题,在电商系统中也常常使用类似的策略来防止超卖问题


位置算法


同时,在面对面建群的过程中相当重要的能力是标识用户的区域,比如 50 米以内。这个可以用到 Redis 的 GeoHash 算法,来获取一个范围内的所有用户信息


由于篇幅有限,这里不展开赘述,想了解更多位置算法相关的细节,可以看我之前的文章:听说你会架构设计?来,弄一个公交&地铁乘车系统。


面试官:嗯不错,那你再讲一下群聊系统里的消息发送和接收吧!


5. 消息发送与接收


我:当某个成员在微信群里发言,系统需要处理消息的分发、通知其他成员、以及确保消息的显示


在群聊系统中保存和展示用户的图片、视频或音频数据时,通常需要将元数据和文件分开存储。


其中元数据存储在 MySQL 集群,文件数据存储在分布式对象存储集群中。


5.1 交互流程


消息发送和接收的时序图如下所示:





  1. 用户A在群中发送一条带有图片、视频或音频的消息。




  2. 移动客户端应用将消息内容和媒体文件上传到服务器后端。




  3. 服务器后端接收到消息和媒体文件后,将消息内容存储到 Message 表中,同时将媒体文件存储到分布式文件存储集群中。在 Message 表里,不仅记录了媒体文件的 MediaID,以便关联消息和媒体;还记录了缩略图、视频封面图等等




  4. 服务器后端会向所有群成员广播这条消息。移动客户端应用接收到消息后,会根据消息类型(文本、图片、视频、音频)加载对应的展示方式。




  5. 当用户点击查看图片、视频或音频缩略图时,客户端应用会根据 MediaID 到对象存储集群中获取对应的媒体文件路径,并将其展示给用户。




5.2 消息存储和展示


除了上述建群功能中提到的用户表和群组表以外,存储元数据还需要以下表结构:



  1. Message表: 用于存储消息,每个消息都有一个唯一的 MessageID,消息类型(文本、图片、视频、音频),消息内容(文字、图片缩略图、视频封面图等),发送者 UserID、接收群 Gr0upID、发送时间等字段。

  2. Media表: 存储用户上传的图片、视频、音频等媒体数据。每个媒体文件都有一个唯一的 MediaID,文件路径、上传者 UserID、上传时间等字段。

  3. MessageState表: 用于存储用户消息状态,包括 MessageID、用户 ID、是否已读等。在消息推送时,通过这张表计算未读数,统一推送给用户,并在离线用户的手机上展示一个小数字代表消息未读数。


面试官:我们时常看到群聊有 n 个未读消息,这个是怎么设计的呢?


我:MessageState 表记录了用户的未读消息数,想要获取用户的消息未读数时,只需要客户端调用一下接口查询即可获取,这个接口将每个群的未读个数加起来,统一返回给客户端,然后借助手机的 SDK 推送功能加载到用户手机上。


面试官:就这么简单吗,可以优化一下不?


我:(内心 OS,性能确实很差,就等着你问呢)是的,我们需要优化一下,首先 MySQL 查询 select count 类型的语句时,都会触发全表扫描,所以每次加载消息未读数都很慢。


为了查询性能考虑,我们可以将用户的消息数量存入 Redis,并实时记录一个未读数值。并且,当未读数大于 99 时,就将未读数值置为 100 且不再增加。


当推送用户消息时,只要未读数为 100,就将推送消息数设置为 99+,以此来提升存储的性能和交互的效率。


面试官:嗯,目前几乎所有的消息推送功能都是这么设计的。那你再说一下 10 亿用户的群聊系统应该如何在高并发,海量数据下保证高性能高可用吧!


我:我想到了几个点,比如采用集群部署、消息队列、多线程、缓存等。


集群部署:可扩展


在群聊系统中,我们用到了分布式可扩展的思想,无论是长连接服务、消息推送服务,还是数据库以及分布式文件存储服务,都是集群部署。


一方面防止单体故障,另一方面可以根据业务来进行弹性伸缩,提升了系统的高可用性。


消息队列:异步、削峰


在消息推送时,由于消息量和用户量很多,所以我们将消息放到消息队列(比如 Kafka)中异步进行消费和推送,来进行流量削峰,防止数据太多将服务打崩。


多线程


在消息写入和消费时,可以多线程操作,一方面节省了硬件开销,不至于部署太多机器。另一方面提升了效率,毕竟多个流水线工作肯定比单打独斗更快。


其它优化


缓存前面已经说到了,除了建群时记录 code,加群时记录群成员数,我们还可以缓存群聊里最近一段时间的消息,防止每个用户都去 DB 拉取一遍数据,这提升了消息查阅的效率。


除此之外,为了节省成本,可以记录流量的高峰时间段,根据时间段来定时扩缩节点(当然,这只是为了成本考虑,在实际业务中这点开销不算什么大问题)。


6. 小结


后续


面试官:嗯不错,实际上的架构中也没有节省这些资源,而是把重心放在了用户体验上。(看了看表)OK,那今天的面试就到这,你有什么想问的吗?


我:(内心 OS,有点慌,但是不能表现出来)由于时间有限,之前对系统高并发、高性能的设计,以及对海量数据的处理浅尝辄止,这在系统设计的面试中占比如何?


面试官:整体想得比较全,但是还不够细节。当然,也可能是时间不充分的原因,已经还不错了!


我:(内心 OS,借你吉言)再想问一下,如果我把这些写出来,会有读者给我点赞、分享、加入在看吗?


面试官:……


结语


群聊系统是社交应用的核心功能之一,每个社交产品几乎都有着群聊系统的身影:包括但不限于 QQ、微信、抖音、小红书等。


上述介绍的技术细节可能只是群聊系统的冰山一角,像常见的抢红包、群内音视频通话这些核心功能也充斥着大量的技术难点。


但正是有了这些功能,才让我们使用的 App 变得更加有趣。而这,可能也是技术和架构的魅力所在吧~



由于篇幅有限,本文到这就结束了。后续可能会根据阅读量、在看数的多寡,判断是否继续更新抢红包、群内音视频通话等核心功能,感兴趣的小伙伴可以关注一下。


作者:xin猿意码
来源:juejin.cn/post/7298985311771656244
收起阅读 »

5年编程之心得体会

关键词:代码、编程、业务、技术、数据、面试、成长、开发、逻辑、能力 一、心得 说说我自己理解的编程:编程就是要教会只会0和1的电脑去解决现实生活中各种复杂的问题,电脑只有与或非三种逻辑,只有顺序循环分支三种控制结构。我一直觉得编程某种意义上是一门“手艺”,...
继续阅读 »

关键词:代码、编程、业务、技术、数据、面试、成长、开发、逻辑、能力


一、心得




  1. 说说我自己理解的编程:编程就是要教会只会0和1的电脑去解决现实生活中各种复杂的问题,电脑只有与或非三种逻辑,只有顺序循环分支三种控制结构。我一直觉得编程某种意义上是一门“手艺”,因为优雅而高效的代码,就如同完美的工艺品一样让人赏心悦目,最主要、最容易被我们直观感受到的问题就是:烂代码实在是太多了。后来,在亲历了许多个令人不悦的项目之后,我才慢慢看清楚:即便两个人实现同一个功能,最终效果看上去也一模一样,但代码质量却可能有着云泥之别,好代码就像好文章,语言精练、层次分明,让人读了还想读;烂代码则像糊成一团的意大利面条,处处充斥着相似的逻辑,模块间的关系错综复杂,多看一眼都令人觉得眼睛会受伤




  2. 越简洁的代码,越清晰的逻辑,就越不容易出错。而且在实际工作中不是用代码量来评价一个程序员的工作强度和等级,高端的同学总是用最简短精妙的代码来解决问题。代码变得越来越简洁,代码看起来更加结构化和规范化、扁平结构比嵌套结构更好




  3. 在团队合作中,你的代码不只有你在维护,降低别人的阅读/理解代码逻辑的成本是一种良好的品德




  4. 简单的代码,只会用到最基本的语法糖,复杂的高级特性,会有更多的依赖(如语言的版本)




  5. 一个公司如果数据库从来不出问题,那一定是因为没有业务量或者流量




  6. 所有技术的选型和设计,都有它的应用场景,除去那些让人开心的案例,剩下的毫无疑问就是坑;如何尽可能地避开这些坑,如何在出现问题的时候可以用最快的速度去修复,这些都是至关重要的因素




  7. 任何项目在早期,整个数据基本处于裸奔状态,没有做任何的权限校验与审计,用户可以对数据为所欲为,这个阶段主要考虑效率优先,随着业务的发展,数据安全的重要性愈发突显,大数据权限系统则会应运而生




  8. 现实中大部分程序员都属于是斐波那契程序员




  9. 关于代码意见:我的看法是,一个处理代码行数超过四五十行,就可以考虑缩减抽离了,为什么要这么做,其实很简单:出于可维护性,一个业务再复杂,离不开一个主干逻辑(也可能是多个)和 N 个子逻辑,你不能把臃肿的子逻辑代码放在同一个处理代码内部,这样太影响可读性,影响可读性的后果就是提高了维护成本
    7c90c17b3544b18518addf56f0bf9e29.jpg




  10. 首先从成长的角度来看,追求代码质量是一个优秀程序员对自己的要求,我想任何一门工艺、手艺,从业者想要把他做的更好,这是一个非常自然的目标,我们既然靠写代码谋生,就应该对代码有追求,对代码有自己的审美和判断,代码质量真的只是一个底线,在这条底线之上,才有可能谈稳定,谈伸缩,谈性能,谈架构,优雅与否则是区分顶级程序员与一般程序员的终极指标所在,能用不是代码的标准,能被维护才是代码的标准




  11. 程序员大厂面试三板斧:八股文、算法、项目经验




  12. 切记切记:不要用战术上的勤奋来掩盖战略上的懒惰




  13. 写代码是要有感觉的:感觉到了思如泉涌、键盘啪啪作响,一个需求很快就做完了,搬砖速度飞起




  14. 我们是一群与画家有着极大的相似性的猿/媛,是在创造,而不是完成某个任务,会在求解问题过程中产生精神愉悦或享受,我们崇尚分享、开放、民主、计算机的自由使用和进步




  15. 真正工作中都是写写业务代码,哪有那么多深度的技术问题;其实我觉得写代码最重要是逻辑思维够强,代码的规范工整,思路清晰,化繁为简,否则你说你写了多复杂的海量并发处理,多有深度,但是代码乱得一团糟,别人没法维护,再牛逼有何用




  16. 软件开发的任务应该是思考,思考手头的问题,设计出一个完美的解决方案,然后再把这个方案转变成可供用户使用的软件




  17. 阅读他人的代码是一种很棒的学习方式。正如一位作家所说,“阅读其他人的作品是让你成为一个更好的作家的最好方式”,这同样适用于代码




  18. 优秀程序员绝不只有技术:


    (1)问题解决能力


    (2)业务理解能力


    (3)沟通能力


    (4)产品思维


    (5)管理能力


    (6)分享表达能力


    ...




二、总结


锁屏.jpg
最后再开个玩笑哈哈:所以在感慨,在这个行业可能确实需要像前任这样的码农,挖坑,填坑,坑更多了,再填......生生不息,这样行业才能长久生存,没有 bug 可以修复,没有屎山可以铲,我们真的就失业了,如果每个程序员写的文档详细,逻辑清晰,注释清楚,拿什么让老板离不开你,靠什么威胁老板给你高工资,所以我现在的处境用一句话形容:全凭同行衬托


作者:纯之风
来源:juejin.cn/post/7273025562141327396
收起阅读 »

我给项目加了性能守卫插件,同事叫我晚上别睡的太死

web
引言 给组内的项目都在CICD流程上更新上了性能守卫插件,效果也还不错,同事还疯狂夸奖我 接下里进入我们的此次的主题吧 由于我组主要是负责的是H5移动端项目,老板比较关注性能方面的指标,比如首页打开速度,所以一般会关注FP,FCP等等指标,所以一般项目写完...
继续阅读 »



引言


给组内的项目都在CICD流程上更新上了性能守卫插件,效果也还不错,同事还疯狂夸奖我


WX20230708-170807@2x.png


接下里进入我们的此次的主题吧



由于我组主要是负责的是H5移动端项目,老板比较关注性能方面的指标,比如首页打开速度,所以一般会关注FP,FCP等等指标,所以一般项目写完以后都会用lighthouse查看,或者接入性能监控系统采集指标.



WX20230708-141706@2x.png


但是会出现两个问题,如果采用第一种方式,使用lighthouse查看性能指标,这个得依赖开发自身的积极性,他要是开发完就Merge上线,你也不知道具体指标怎么样。如果采用第二种方式,那么同样是发布到线上才能查看。最好的方式就是能强制要求开发在还没发布的时候使用lighthouse查看一下,那么在什么阶段做这个策略呢。聪明的同学可能想到,能不能在CICD构建阶段加上策略。其实是可以的,谷歌也想到了这个场景,提供性能守卫这个lighthouse ci插件


性能守卫



性能守卫是一种系统或工具,用于监控和管理应用程序或系统的性能。它旨在确保应用程序在各种负载和使用情况下能够提供稳定和良好的性能。



Lighthouse是一个开源的自动化工具,提供了四种使用方式:




  • Chrome DevTools




  • Chrome插件




  • Node CLI




  • Node模块




image.png


其架构实现图是这样的,有兴趣的同学可以深入了解一下


这里我们我们借助Lighthouse Node模块继承到CICD流程中,这样我们就能在构建阶段知道我们的页面具体性能,如果指标不合格,那么就不给合并MR


剖析lighthouse-ci实现


lighthouse-ci实现机制很简单,核心实现步骤如上图,差异就是lighthouse-ci实现了自己的server端,保持导出的性能指标数据,由于公司一般对这类数据敏感,所以我们一般只需要导出对应的数据指标JSON,上传到我们自己的平台就行了。


image.png


接下里,我们就来看看lighthouse-ci实现步骤:





    1. 启动浏览器实例:CLI通过Puppeteer启动一个Chrome实例。


    const browser = await puppeteer.launch();




    1. 创建新的浏览器标签页:接着,CLI创建一个新的标签页(或称为"页面")。


    const page = await browser.newPage();




    1. 导航到目标URL:CLI命令浏览器加载指定的URL。


    await page.goto('https://example.com');




    1. 收集数据:在加载页面的同时,CLI使用各种Chrome提供的API收集数据,包括网络请求数据、JavaScript执行时间、页面渲染时间等。





    1. 运行审计:数据收集完成后,CLI将这些数据传递给Lighthouse核心,该核心运行一系列预定义的审计。





    1. 生成和返回报告:最后,审计结果被用来生成一个JSON或HTML格式的报告。


    const report = await lighthouse(url, opts, config).then(results => {
    return results.report;
    });




    1. 关闭浏览器实例:报告生成后,CLI关闭Chrome实例。


    await browser.close();



// 伪代码
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const {URL} = require('url');

async function run() {
// 使用 puppeteer 连接到 Chrome 浏览器
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});

// 新建一个页面
const page = await browser.newPage();

// 在这里,你可以执行任何Puppeteer代码,例如:
// await page.goto('https://example.com');
// await page.click('button');

const url = 'https://example.com';

// 使用 Lighthouse 进行审查
const {lhr} = await lighthouse(url, {
port: new URL(browser.wsEndpoint()).port,
output: 'json',
logLevel: 'info',
});

console.log(`Lighthouse score: ${lhr.categories.performance.score * 100}`);

await browser.close();
}

run();

导出的HTML文件


image.png


导出的JSON数据


image.png


实现一个性能守卫插件


在实现一个性能守卫插件,我们需要考虑以下因数:





    1. 易用性和灵活性:插件应该易于配置和使用,以便它可以适应各种不同的CI/CD环境和应用场景。它也应该能够适应各种不同的性能指标和阈值。





    1. 稳定性和可靠性:插件需要可靠和稳定,因为它将影响整个构建流程。任何失败或错误都可能导致构建失败,所以需要有强大的错误处理和恢复能力。





    1. 性能:插件本身的性能也很重要,因为它将直接影响构建的速度和效率。它应该尽可能地快速和高效。





    1. 可维护性和扩展性:插件应该设计得易于维护和扩展,以便随着应用和需求的变化进行适当的修改和更新。





    1. 报告和通知:插件应该能够提供清晰和有用的报告,以便开发人员可以快速理解和处理任何性能问题。它也应该有一个通知系统,当性能指标低于预定阈值时,能够通知相关人员。





    1. 集成:插件应该能够轻松集成到现有的CI/CD流程中,同时还应该支持各种流行的CI/CD工具和平台。





    1. 安全性:如果插件需要访问或处理敏感数据,如用户凭证,那么必须考虑安全性。应使用最佳的安全实践来保护数据,如使用环境变量来存储敏感数据。




image.png


// 伪代码
//perfci插件
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const { port } = new URL(browser.wsEndpoint());

async function runAudit(url) {
const browser = await puppeteer.launch();
const { lhr } = await lighthouse(url, {
port,
output: 'json',
logLevel: 'info',
});
await browser.close();

// 在这里定义你的性能预期
const performanceScore = lhr.categories.performance.score;
if (performanceScore < 0.9) { // 如果性能得分低于0.9,脚本将抛出错误
throw new Error(`Performance score of ${performanceScore} is below the threshold of 0.9`);
}
}

runAudit('https://example.com').catch(console.error);


使用


name: CI
on: [push]
jobs:
lighthouseci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm install && npm install -g @lhci/cli@0.11.x
- run: npm run build
- run: perfci autorun


性能审计


const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const nodemailer = require('nodemailer');

// 配置邮件发送器
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your-email@gmail.com',
pass: 'your-password',
},
});

// 定义一个函数用于执行Lighthouse审计并处理结果
async function runAudit(url) {
// 通过Puppeteer启动Chrome
const browser = await puppeteer.launch({ headless: true });
const { port } = new URL(browser.wsEndpoint());

// 使用Lighthouse进行性能审计
const { lhr } = await lighthouse(url, { port });

// 检查性能得分是否低于阈值
if (lhr.categories.performance.score < 0.9) {
// 如果性能低于阈值,发送警告邮件
let mailOptions = {
from: 'your-email@gmail.com',
to: 'admin@example.com',
subject: '网站性能低于阈值',
text: `Lighthouse得分:${lhr.categories.performance.score}`,
};

transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

await browser.close();
}

// 使用函数
runAudit('https://example.com');


接下来,我们分步骤大概介绍下几个核心实现


数据告警


// 伪代码
const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const nodemailer = require('nodemailer');

// 配置邮件发送器
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your-email@gmail.com',
pass: 'your-password',
},
});

// 定义一个函数用于执行Lighthouse审计并处理结果
async function runAudit(url) {
// 通过Puppeteer启动Chrome
const browser = await puppeteer.launch({ headless: true });
const { port } = new URL(browser.wsEndpoint());

// 使用Lighthouse进行性能审计
const { lhr } = await lighthouse(url, { port });

// 检查性能得分是否低于阈值
if (lhr.categories.performance.score < 0.9) {
// 如果性能低于阈值,发送警告邮件
let mailOptions = {
from: 'your-email@gmail.com',
to: 'admin@example.com',
subject: '网站性能低于阈值',
text: `Lighthouse得分:${lhr.categories.performance.score}`,
};

transporter.sendMail(mailOptions, function(error, info){
if (error) {
console.log(error);
} else {
console.log('Email sent: ' + info.response);
}
});
}

await browser.close();
}

// 使用函数
runAudit('https://example.com');

处理设备、网络等不稳定情况


// 伪代码

// 网络抖动
const { lhr } = await lighthouse(url, {
port,
emulatedFormFactor: 'desktop',
throttling: {
rttMs: 150,
throughputKbps: 1638.4,
cpuSlowdownMultiplier: 4,
requestLatencyMs: 0,
downloadThroughputKbps: 0,
uploadThroughputKbps: 0,
},
});


// 设备
const { lhr } = await lighthouse(url, {
port,
emulatedFormFactor: 'desktop', // 这里可以设定为 'mobile' 'desktop'
});


用户登录态问题



也可以让后端同学专门提供一条内网访问的登录态接口环境,仅用于测试环境



const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const fs = require('fs');
const axios = require('axios');
const { promisify } = require('util');
const { port } = new URL(browser.wsEndpoint());

// promisify fs.writeFile for easier use
const writeFile = promisify(fs.writeFile);

async function runAudit(url, options = { port }) {
// 使用Puppeteer启动Chrome
const browser = await puppeteer.launch();
const page = await browser.newPage();

// 访问登录页面
await page.goto('https://example.com/login');

// 输入用户名和密码
await page.type('#username', 'example_username');
await page.type('#password', 'example_password');

// 提交登录表单
await Promise.all([
page.waitForNavigation(), // 等待页面跳转
page.click('#login-button'), // 点击登录按钮
]);

// 运行Lighthouse
const { lhr } = await lighthouse(url, options);

// 保存审计结果到JSON文件
const resultJson = JSON.stringify(lhr);
await writeFile('lighthouse.json', resultJson);

// 上传JSON文件到服务器
const formData = new FormData();
formData.append('file', fs.createReadStream('lighthouse.json'));

// 上传文件到你的服务器
const res = await axios.post('https://your-server.com/upload', formData, {
headers: formData.getHeaders()
});

console.log('File uploaded successfully');

await browser.close();
}

// 运行函数
runAudit('https://example.com');

总结


性能插件插件还有很多需要考虑的情况,所以,不懂还是来私信问我吧,我同事要请我吃饭去了,不写了。


作者:linwu
来源:juejin.cn/post/7253331974051823675
收起阅读 »

前端小工搬砖一周年了

入职一周年啦!对过去这一年的工作和生活都做个总结吧,经历的时候总觉得前方迷雾重重,回首看去每个脚印都清清楚楚。 工作 我还记得入职第一周的时候,交给我的第一个任务是修改几个页面的文案。工作确实不难,只是项目比较老了,整体结构比较复杂。开始的一些任务都是修修补补...
继续阅读 »

入职一周年啦!对过去这一年的工作和生活都做个总结吧,经历的时候总觉得前方迷雾重重,回首看去每个脚印都清清楚楚。


工作


我还记得入职第一周的时候,交给我的第一个任务是修改几个页面的文案。工作确实不难,只是项目比较老了,整体结构比较复杂。开始的一些任务都是修修补补,只要找对了地方,写个几行代码就可以了。


后面我也慢慢独立做一些新功能了。当亲朋好友问起我的工作时,我还能说出来我做了xxx。整体来说,我的工作都不算难,有些也只是比较麻烦复杂。现在的我就是个搬砖小工,告诉我需要做什么,然后我把它做完。


接下来我比较感兴趣的一个方向是提升开发效率。梳理日常的工作流程,通过一些工具或工程化的手段,来提供更好的开发体验。


学习


专业知识的学习上,感觉没有学习很多。平时的工作内容都不算太专业,也就是个基础的开发。虽然写的代码不少了,但也基本上就是使用一些语法和组件,不涉及太多原理上的东西。


前半年基本上都是学了一些跟开发密切相关的基础知识,后半年开始进行体系化的学习,每天早上到公司后学一会。在极客上学了一些课程,目前在看《vue.js的设计与实现》,一边看一边敲代码,快看完了,对于vue的原理确实更清楚了,平时写代码也更清晰了。


只是要具体说说学了点什么的话,又啥也想不起来了。还是要多写学习总结啊,写了会对所学的东西有个更深刻的理解。定个目标好了,以后一周写一篇学习总结。


生活


生活还是过得比较满意的。一开始找房就比较顺利,住了一年了,除了疫情期间邻居装修,整体还是比较满意的。


这一年也基本坚持下来了跑步的习惯,虽然也就养生跑了。除了疫情期间,每周会去跑个步。身体状态也都还不错。


今年对做甜点非常感兴趣。做了各种奶油蛋糕和慕斯蛋糕。天气热了之后,开始做糖水,杨枝甘露、各种水果奶昔还有烧仙草等等,还做了我非常喜欢的香草冰淇淋。


读书


读书想单独列出来,感觉能反映一个时期的思想状态。入职前半年看书时长40个小时,算是看书比较少的时段了。入职一个月后,我就开始看《工作的意义》和《生命的意义》了,还看了一些重生小说,半本《原则:应对变化中的世界秩序》,看完了《群体的疯狂》、《置身事内》和《梁永安:阅读、游历和爱情》。


上半年看的书比较杂乱,整个人也是迷茫和焦虑,看的基本都是一些热门书。


入职下半年就感觉好多了,看书74个小时。看完了《论生命之短暂》、《撒哈拉的故事》、《结构性改革》、《始于极限》、《庄子》、《道德经》、《孟子》、《传习录》等。看的都是一些我感兴趣的书,偏哲学的书。


下半年整个人也不那么焦虑了,虽然裁员的消息还是满天飞,但我也不担心了。看前方还是迷雾重重,只是我不再紧盯着前方,我看着脚下每一步。


总结


工作上算是中规中矩。生活上是丰富多彩,体验了许多之前没玩过、没做过的,但是我最爱的依然是跑步。最满意的是心态上的变化,从初入职场的迷茫焦虑,到逐渐平淡释然。只是一直困惑我的问题是方向,我始终没有找到人生的方向,一直以来也没有什么追求。不过也慢慢释然了,好像也没必要非要有目标。但行好事,莫问前程。


作者:叶之
来源:juejin.cn/post/7257122036597063735
收起阅读 »

聊聊深色模式(Dark Mode)

web
什么是深色模式 深色模式(Dark Mode),或者叫暗色模式,黑夜模式,是和日常使用的浅色(亮色)模式(Light Mode)相对应的一种UI主题。 深色模式最早来源于人机交互领域的研究和实践,从2018年左右开始,Apple推出了iOS 13,其中包含了系...
继续阅读 »

什么是深色模式


深色模式(Dark Mode),或者叫暗色模式,黑夜模式,是和日常使用的浅色(亮色)模式(Light Mode)相对应的一种UI主题。


深色模式最早来源于人机交互领域的研究和实践,从2018年左右开始,Apple推出了iOS 13,其中包含了系统级别的深色模式,可以将整个系统的界面切换为暗色调。


Google也在Android 10中推出了类似的深色模式功能,使深色模式得到了更广泛的应用和推广。


iOS官网的深色模式示例


iOS官网的深色模式示例


它不是简单的把背景变为黑色,文字变为白色,而是一整套的配色主题,这种模式相比浅色模式更加柔和,可以减少亮度对用户眼睛造成的刺激和疲劳。


随着越来越多的应用开始支持深色模式,作为开发也理应多了解下深色模式。


首先,怎么打开深色模式


在说怎么实现之前,先来说说我们要怎么打开深色模式,一般来说只需要在系统调节亮度的地方就可以调节深色模式,具体我们可以看各个系统的官方网站即可:
如何打开深色模式



但是在开发调试调试时,不断切换深色模式可能比较麻烦,这时浏览器就提供了一种模拟系统深色模式的方法,可以让当前的Web页面临时变为深色模式,以Chrome为例:
浏览器模拟深色/浅色模式



  1. 打开Chrome DevTools

  2. Command+Shift+P

  3. 输入dark或者light

  4. 打开深色或者浅色模式打开深色模式打开浅色模式


不过要注意的是,浏览器DevTools里开启深色模式,在关闭开发者工具后就会失效。


自动适配 - 声明页面支持深色模式


其实,在支持深色模式的浏览器中,有一套默认的深色模式,只需要我们在应用中声明,即可自动适配深色模式,声明有两种方式:


1. 添加color-schememeta标签


在HTML的head标签中增加color-schememeta标签,如下所示:


<!--
The page supports both dark and light color schemes,
and the page author prefers light.
-->

<meta name="color-scheme" content="light dark">

通过上述声明,告诉浏览器这个页面支持深色模式和浅色模式,并且页面更倾向于浅色模式。在声明了这个之后,当系统切换到深色模式时,浏览器将会把我们的页面自动切换到默认的深色模式配色,如下所示:
左边浅色,右边是浏览器自动适配的深色


左边浅色,右边是浏览器自动适配的深色


2. 在CSS里添加color-scheme属性


/*
The page supports both dark and light color schemes,
and the page author prefers light.
*/

:root {
color-scheme: light dark;
}

通过上面在:root元素上添加color-scheme属性,值为light dark,可以实现和meta标签一样的效果,同时这个属性不只可用于:root级别,也可用于单个元素级别,比meta标签更灵活。


但是提供color-schemeCSS属性需要首先下载CSS(如果通过<link rel="stylesheet">引用)并进行解析,使用meta可以更快地使用所需配色方案呈现页面背景。两者各有优劣吧。


自定义适配


1. 自动适配的问题


在上面说了我们可以通过一些标签或者CSS属性声明,来自动适配深色模式,但是从自动适配的结果来看,适配的并不理想:
左边浅色,右边是浏览器自动适配的深色


左边浅色,右边是浏览器自动适配的深色




  • 首先是默认的黑色字体,到深色模式下变成了纯白色#FFFFFF,和黑色背景(虽然说不是纯黑)对比起来很扎眼,在一些设计相关的文章[1][2]里提到,深色模式下避免使用纯黑和纯白,否则更容易使人眼睛👁疲劳,同时容易在页面滚动时出现拖影:


    滚动时出现拖影,图片来源「即刻」




滚动时出现拖影,图片来源「即刻」




  • 自动适配只能适配没有指定颜色和背景色的内容,比如上面的1、2、3级文字还有背景,没有显式设置colorbackground-color


    对于设置了颜色和背景色(这种现象在开发中很常见吧)的内容,就无法自动适配,比如上面的7个色块的背景色,写死了颜色,但是色块上的文字没有设置颜色。最终在深色渲染下渲染出的效果就是,色块背景色没变,但是色块上的文字变成了白色,导致一些文字很难看清。




所以,最好还是自定义适配逻辑,除了解决上面的问题,还可以加一下其他的东西,比如加一些深浅色模式变化时的过渡动画等。


2. 如何自定义适配


自定义适配有两种方式,CSS媒体查询和通过JS监听主题模式


1). CSS媒体查询


prefers-color-scheme - CSS:层叠样式表 | MDN
我们可以通过在CSS中设置媒体查询@media (prefers-color-scheme: dark),来设置深色模式下的自定义颜色。比如:


.textLevel1 {
color: #404040;
margin-bottom: 0;
}
.textLevel2 {
color: #808080;
margin-bottom: 0;
}
.textLevel3 {
color: #bfbfbf;
margin-bottom: 0;
}

@media (prefers-color-scheme: dark) {
.textLevel1 {
color: #FFFFFF;
opacity: 0.9;
}
.textLevel2 {
color: #FFFFFF;
opacity: 0.6;
}
.textLevel3 {
color: #FFFFFF;
opacity: 0.3;
}
}

通过媒体查询设置元素在深色模式下的1、2、3级文字的颜色,在浅色模式下设置不同的颜色,在深色模式下,增加不透明度:


截屏2023-03-12 下午6.04.18.png左边的是自动适配的浅色深色,右边是自定义适配的浅色深色


左边的是自动适配的浅色深色,右边是自定义适配的浅色深色


对于prefers-color-scheme的兼容性也不必担心,主流浏览器基本都支持了:


截屏2023-03-12 下午6.10.07.png


2). JS监听主题颜色


Window.matchMedia() - Web API 接口参考 | MDN


通过CSS媒体查询适配深色模式已经很方便了,完全不需要修改JS代码逻辑,那为什么还需要JS监听主题颜色呢?


因为通过CSS媒体查询虽然方便,但是只能跟随系统的主题颜色变化,假设用户想要类似于下面的自定义主题颜色,不跟随系统变化,或者业务上想做一些什么高级的操作,那么CSS媒体查询就无能为力了。


7b368843-dd42-4b4b-bc73-6d445de78923.gif


在JS中,可以通过window.matchMedia('(prefers-color-scheme: dark)').match来判断当前的颜色主题:


const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

if (darkModeMediaQuery.matches) {
// dark mode
} else {
// light mode
}

darkModeMediaQuery.addEventListener('change', (event: MediaQueryListEvent) => {
if (event.matches) {
// dark mode
} else {
// light mode
}
});

如果使用的是React,还可以写一个自定义hooks,来监听当前的颜色主题:


export enum EColorScheme {
LIGHT = 'light',
DARK = 'dark',
}

export const useColorScheme = (): EColorScheme => {
const [scheme, setScheme] = useState(
darkModeMediaQuery.matches ? EColorScheme.DARK : EColorScheme.LIGHT
);

useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setScheme(event.matches ? EColorScheme.DARK : EColorScheme.LIGHT);
};
darkModeMediaQuery.addEventListener('change', listener);
return () => {
darkModeMediaQuery.removeEventListener('change', listener);
};
}, []);

return scheme;
};

一些题外话


我写完这个hooks之后,有一次偶然用Chat GPT让它写一段监听深色模式的代码,写成hooks,最后生成的代码和这个几乎一模一样,Chat GPT还是有点东西的:


import { useState, useEffect } from 'react';

type ThemeType = 'light' | 'dark';

function useTheme(): ThemeType {
const [theme, setTheme] = useState<ThemeType>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
);

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

const handleChange = () => setTheme(mediaQuery.matches ? 'dark' : 'light');

mediaQuery.addEventListener('change', handleChange);

return () => mediaQuery.removeEventListener('change', handleChange);
}, []);

return theme;
}

export default useTheme;

window.matchMedia的兼容性也挺好的:


截屏2023-03-12 下午7.03.48.png
通过JS监听颜色主题变化之后,那可玩性就很多了,我们可以通过下面这些方式来适配深色模式:




  • 动态添加类名覆盖样式


    通过判断深色模式来添加一个深色模式的类名,覆盖浅色模式样式:


    <div
    className={classnames(
    style.wrapper,
    scheme === EColorScheme.DARK && style.darkModeWrapper
    )}
    >
    {/* some code here */}
    </div>



  • 对于深色模式直接引用不同的CSS资源文件




  • 用一些第三方的库,比如postcss-darkmode




回到上面话题,通过JS可以监听到系统的颜色主题,那怎么实现用户主动选择颜色主题,不随系统的改变呢?其实也很简单,可以在本地store中设置一个颜色主题的值,用户设置了就优先选用store里的,没有设置就跟随系统,以上面的hooks为例:


export const useColorScheme = (): EColorScheme => {
// 从 store 中取出用户手动设置的主题
const manualScheme = useSelector(selectManualColorScheme);
const [scheme, setScheme] = useState(
darkModeMediaQuery.matches ? EColorScheme.DARK : EColorScheme.LIGHT
);

useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setScheme(event.matches ? EColorScheme.DARK : EColorScheme.LIGHT);
};
darkModeMediaQuery.addEventListener('change', listener);
return () => {
darkModeMediaQuery.removeEventListener('change', listener);
};
}, []);

// 优先取用户手动设置的主题
return manualScheme || scheme;
};

React Native中的适配


上面说的都是在浏览器里对深色模式的适配,那在React Native里面要怎么适配深色模式呢?


1. 大于等于0.62的版本


Appearance · React Native


在React Native 0.62版本中,引入了Appearance模块,通过这个模块:


type ColorSchemeName = 'light' | 'dark' | null | undefined;

export namespace Appearance {
type AppearancePreferences = {
colorScheme: ColorSchemeName;
};

type AppearanceListener = (preferences: AppearancePreferences) => void;

/**
* Note: Although color scheme is available immediately, it may change at any
* time. Any rendering logic or styles that depend on this should try to call
* this function on every render, rather than caching the value (for example,
* using inline styles rather than setting a value in a `StyleSheet`).
*
* Example: `const colorScheme = Appearance.getColorScheme();`
*/

export function getColorScheme(): ColorSchemeName;

/**
* Add an event handler that is fired when appearance preferences change.
*/

export function addChangeListener(listener: AppearanceListener): EventSubscription;

/**
* Remove an event handler.
*/

export function removeChangeListener(listener: AppearanceListener): EventSubscription;
}

/**
* A new useColorScheme hook is provided as the preferred way of accessing
* the user's preferred color scheme (aka Dark Mode).
*/

export function useColorScheme(): ColorSchemeName;

通过Appearance模块,可以获得当前的系统颜色主题:


const colorScheme = Appearance.getColorScheme();
if (colorScheme === 'dark') {
// dark mode
} else {
// light mode
}

Appearance.addChangeListener((prefer: Appearance.AppearancePreferences) => {
if (prefer.colorScheme === 'dark') {
// dark mode
} else {
// light mode
}
});

同时也提供了一个上面我们自己实现的hooks,useColorScheme


const colorScheme = useColorScheme();

一些坑




  1. Appearance这个接口在Chrome调试模式下,会不生效,永远返回light


    Appearance.getColorScheme() always returns ‘light’




  2. Appearance想要生效,还需要Native做一些配置


    React Native 0.62.2 Appearance return wrong color scheme



    Also make sure you do not have UIUserInterfaceStyle set in your Info.plist. I had it set to 'light' so Appearance.getColorScheme() was always returning 'light'.





2. 小于0.62的版本


对于0.62之前的版本,由于RN没有提供官方接口,需要通过第三方的库react-native-dark-mode来实现:
GitHub - codemotionapps/react-native-dark-mode: Detect dark mode in React Native


它的实现原理感兴趣的可以看下:



react-native-dark-mode 实现原理(这段实现原理其实也是问Chat GPT得到的答案😂)


react-native-dark-mode库的实现原理比较简单,它主要是利用了原生平台的接口来检测当前系统是否处于深色模式。在iOS平台上,它使用了UIUserInterfaceStyle接口来获取当前系统的界面风格,然后判断是否为暗黑模式。在Android平台上,它使用了UiModeManager接口来获取当前系统的 UI 模式,然后判断是否为夜间模式。


具体来说,react-native-dark-mode在React Native项目中提供了一个名为useDarkMode的 React Hooks,用于获取当前系统是否处于深色模式。当使用这个Hooks时,它会首先检测当前平台是否支持暗黑模式,如果支持,就直接调用原生平台的接口获取当前系统的界面风格或UI模式,并将结果返回给调用方。如果不支持,就返回一个默认值(比如浅色模式)。


需要注意的是,由于react-native-dark-mode是一个纯JS库,它无法直接调用原生平台的接口。所以它在Native端编写了一个名为DarkMode的模块,在JS层通过NativeModules.DarkMode来调用。



  • 在iOS上,DarkMode模块会通过RCT_EXPORT_MODULE()宏将自己暴露给RN的JS层。同时,它还会使用RCT_EXPORT_METHOD()宏将检测系统界面风格的方法暴露给JS层,使得在JS中可以直接调用该方法。

  • 在Android上,DarkMode模块同样会通过@ReactModule注解将自己暴露给JS层。然后,它会创建一个名为DarkModeModule的Java类,并在该类中实现检测系统UI模式的方法。最后,它会使用@ReactMethod注解将该方法暴露给JS层,使得在JS中可以直接调用该方法。



参考链接



作者:酥风
来源:juejin.cn/post/7298997940019085366
收起阅读 »

回顾我这三年,都是泡沫

昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。 刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup… 虽然没见过面,不知道他长什么...
继续阅读 »

朋友圈


昨天,一个在掘金认识的小伙伴,进入了美团专门做 IDE 的基建组,心底真是替他高兴,这本来就是他应得的。


刚认识的时候还是一个工作一年的小毛孩,整天逮着我问各种问题,模板引擎、Babel、Electron、Jest、Rollup…


虽然没见过面,不知道他长什么样,在我脑海里,他就是两样放着光,对技术充满好奇心、自我驱动力很强小伙子。


我就知道他能成,因为我多少也是这样子的,尽管我现在有些倦怠。


后来,随着工作越来越忙,博客也停更了,我们便很少联系了。


不过,后面我招人,尤其是校招生或者初级开发,我都是按照他这个范本来的。我也时常跟别人提起,我认识北京这样一个小伙子。


也有可能我们这边庙太小了,这样的小伙伴屈指可数。


平台和好奇心一样重要


大部分人智商条件不会有太多的差距,尤其是程序员这个群体,而好奇心可以让你比别人多迈出一步,经过长时间的积累就会拉开很大的差距。


而平台可以让你保持专注,与优秀的人共事,获得更多专业的经验和知识、财富,建立自己的竞争壁垒。








回到正题。


我觉得是时候阶段性地总结和回望回顾我过去这三年,却发现大部分都是泡沫。跨端、业务、质量管理、低代码、领域驱动设计... 本文话题可能会比较杂




2020 年七月,口罩第二年。我选择了跳槽,加入了一家创业公司




跨端开发的泡沫


2020 年,微信小程序已经成为国内重要的流量入口,事实也证明,我们过去几年交付的 C 端项目几乎上都是小程序。更严谨的说,应该是微信小程序,尽管很多巨头都推出了自己的小程序平台,基本上都是陪跑的。




Taro 2.x


进来后接手的第一个项目是原生小程序迁移到 Taro。


那时候,我们的愿景是“一码多端”,期望一套程序能够跑在微信小程序、支付宝小程序等小程序平台、H5、甚至是原生 App。


那时候 Taro 还是 2.x 版本,即通过语法静态编译成各端小程序的源码。


我们迁移花了不少的时间,尽管 Taro 官方提供了自动转换的工具,但是输出的结果是不可靠的,我们仍需要进行全量的回归测试,工作量非常大。 期间我也写了一个自动化代码迁移 CLI 来处理和 Lint 各种自动迁移后的不规范代码。




重构迁移只是前戏。难的让开发者写好 Taro,更难的是写出跨端的 Taro 代码。




我总结过,为什么 Taro(2.x) 这么难用:



  • 很多初级开发者不熟悉 React。在此之前技术栈基本是 Vue

  • 熟悉 React 的却不熟悉 Taro 的各种约束。

  • 即使 Taro 宣称一码多端,你还是需要了解对应平台/端的知识。 即使是小程序端,不同平台的小程序能力和行为都有较大的区别。而 Taro 本身在跨端上并没有提供较好的约束,本身 Bug 也比较多。

  • 如果你有跨端需求,你需要熟知各端的短板,以进行权衡和取舍。强调多端的一致和统一会增加很多复杂度, 对代码的健壮性也是一个比较大的考验。

  • 我们还背着历史包袱。臃肿、不规范、难以维护、全靠猜的代码。




在跨端上,外行人眼里‘一码多端’就是写好一端,其他端不用改就可以直接运行起来,那有那么简单的事情?


每个端都有自己的长板和短板:


短板效应


我们从拆分两个维度来看各端的能力:


维度




放在一个基线上看:


对比


跨端代码写不好,我们不能把锅扔给框架,它仅仅提供了一种通用的解决方案,很多事情还是得我们自己去做。




实际上要开发跨平台的程序,最好的开发路径就是对齐最短的板,这样迁移到其他端就会从而很多,当然代价就是开发者负担会很重:


路径


为了让开发者更好的掌握 Taro, 我编写了详细的 Wiki, 阐述了 React 的各种 trickTaro 如何阉割了 ReactTaro 的原理、开发调试、跨端开发应该遵循的各种规范






Taro 3.0


我们的 Taro 项目在 2020 年底正式在生产使用,而 Taro 3.0 在 2020 年 / 7 月就正式发布了,在次年 5 月,我们决定进行升级。


技术的发展就是这么快,不到 5 个月时间,Taro 2.x 就成为了技术债。


Taro 2.x 官方基本停止了新功能的更新、bug 也不修了,最后我们不得不 Fork Taro 仓库,发布在私有 npm 镜像库中。




Taro 2.x 就是带着镣铐跳舞,实在是太痛苦,我写了一篇文档来历数了它的各种‘罪行’:



  • 2.x 太多条条框框,学习成本高

  • 这是一个假的 React

  • 编译慢

  • 调试也太反人类







Taro 3.x 使用的是动态化的架构,有很多优势:


3.x 架构 和数据流


3.x 架构 和数据流



  • 动态化的架构。给未来远程动态渲染、低代码渲染、使用不同的前端框架(支持 Vue 开发)带来了可能

  • 不同端视图渲染方式差异更小,更通用,跨端兼容性更好。

  • 2.x 有非常多的条条框框,需要遵循非常多的规范才能写出兼容多端的代码。3.x 使用标准 React 进行开发,有更好的开发体验、更低的学习成本、更灵活的代码组织。

  • 可以复用 Web 开发生态。




使用类似架构的还有 Remax、Alita、Kbone, 我之前写过一篇文章实现的细节 自己写个 React 渲染器: 以 Remax 为例(用 React 写小程序)




而 Taro 不过是新增了一个中间层:BOM/DOM,这使得 Taro 不再直接耦合 React, 可以使用任意一种视图框架开发,可以使用 Vue、preact、甚至是 jQuery, 让 Web 生态的复用成为可能。




升级 3.x 我同样通过编写自动化升级脚本的形式来进行,这里记录了整个迁移的过程。








重构了再重构


我在 2B or not 2B: 多业态下的前端大泥球 讲述过我们面临的困境。


21 年底,随着后端开启全面的 DDD 重构(推翻现有的业务,重新梳理,在 DDD 的指导下重新设计和开发),我们也对 C 端进行了大规模的重构,企图摆脱历史债务,提高后续项目的交付效率




C 端架构


上图是重构后的结果,具体过程限于篇幅就不展开了:





  • 基础库:我们将所有业务无关的代码重新进行了设计和包装。

    • 组件库:符合 UI 规范的组件库,我们在这里也进行了一些平台差异的抹平

    • api: Taro API 的二次封装,抹平一些平台差异

    • utils: 工具函数库

    • rich-html、echart:富文本、图表封装

    • router:路由导航库,类型安全、支持路由拦截、支持命名导航、简化导航方法…




  • 模块化:我们升级到 Taro 3.x 之后,代码的组织不再受限于分包和小程序的约束。我们将本来单体的小程序进行了模块的拆分,即 monorepo 化。按照业务的边界和职责拆分各种 SDK

  • 方案:一些长期积累开发痛点解决方案,比如解决分包问题的静态资源提取方案、解决页面分享的跳板页方案。

  • 规范和指导实现。指导如何开发 SDK、编写跨平台/易扩展的应用等等




巨头逐鹿的小程序平台,基本上是微信小程序一家独大


跨端框架,淘汰下来,站稳脚跟的也只有 taro 和 uniapp


时至今日,我们吹嘘许久的“一码多端”实际上并没有实现;








大而全 2B 业务的泡沫


其实比一码多端更离谱的事情是“一码多业态”。


所谓一码多业态指的是一套代码适配多个行业,我在 2B or not 2B: 多业态下的前端大泥球 中已经进行了深入的探讨。


这是我过去三年经历的最大的泡沫,又称屎山历险记。不要过度追求复用,永远不要企图做一个大而全的 2B 产品






低代码的泡沫


2021 年,低代码正火,受到的资本市场的热捧。


广义的低代码就是一个大箩筐,什么都可以往里装,比如商城装修、海报绘制、智能表格、AI 生成代码、可视化搭建、审核流程编排…


很多人都在蹭热点,只要能粘上一点边的,都会包装自己是低代码,包括我们。在对外宣称我们有低代码的时候,我们并没有实际的产品。现在 AI 热潮类似,多少声称自己有大模型的企业是在裸泳呢?




我们是 2B 赛道,前期项目交付是靠人去堆的,效率低、成本高,软件的复利几乎不存在。


低代码之风吹起,我们也期望它能破解我们面临的外包难题(我们自己都在质疑这种软件交付方式和外包到底有什么区别)。


也有可能是为了追逐资本热潮,我们也规划做自己的 PaaS、aPaaS、iPaaS… 各种 “aaS”(不是 ass)。


但是我们都没做成,规划和折腾了几个月,后面不了了之,请来的大神也送回去了。




在我看来,我们那时候可能是钱多的慌。但并没有做低代码的相关条件,缺少必要的技术积累和资源。就算缩小范围,做垂直领域的低代码,我们对领域的认知和积累还是非常匮乏。




在这期间, 我做了很多调研,也单枪匹马撸了个 “前端可视化搭建平台”:


低代码平台


由于各种原因, 这个项目停止了开发。如今社区上也有若干个优秀的开源替代物,比如阿里的低代码引擎、网易云的 Tango、华为云的 TinyEngine。如果当年坚持开发下去,说不定今天也小有成就了。




不管经过这次的折腾,我越坚信,低代码目前还不具备取代专业编程的能力。我在《前端如何破解 CRUD 的循环》也阐述过相关的观点。


大型项目的规模之大、复杂度之深、迭代的周期之长,使用低代码无疑是搬石头砸自己的脚。简单预想一下后期的重构和升级就知道了。




低代码的位置


低代码是无代码和专业编码之间的中间形态,但这个中间点并不好把握。比如,如果倾向专业编码,抽象级别很低,虽然变得更加灵活,但是却丧失了易用性,最终还是会变成专业开发者的玩具。


找对场景,它就是一把利器。不要期望它能 100% 覆盖专业编码,降低预期,覆盖 10%?20%?再到 30%? 已经是一个不错的成就。


低代码真正可以提效不仅在于它的形式(可视化),更在于它的生态。以前端界面搭建为例,背后开箱即用的组件、素材、模板、应用,才是它的快捷之道。


在我看来,低代码实际上并不是一个新技术,近年来火爆,更像是为了迎合资本的炒作而稍微具象化的概念。


而今天,真正的’降本增效‘的大刀砍下来,又有多少’降本增效‘的低代码活下来了呢?








质量管理的泡沫


2021 年四月,我开始优化前端开发质量管理,设计的开发流程如下:


流程


开发环境:



  • 即时反馈:通过 IDE 或者构建程序即时对问题进行反馈。

  • 入库前检查:这里可以对变动的源代码进行统一格式化,代码规范检查、单元测试。如果检查失败则无法提交。


集成环境:



  • 服务端检查:聪明的开发者可能绕过开发环境本地检查,在集成环境我们可以利用 Gerrit + Jenkins 来执行检查。如果验证失败,该提交会被拒绝入库。

  • CodeReview:CodeReview 是最后一道防线,主要用于验证机器无法检验的设计问题。

  • 自动化部署:只有服务端检查和 CodeReview 都通过才能提交到仓库

    • 测试环境:即时部署,关闭安全检查、开启调试方便诊断问题

    • 生产环境:授权部署




生产环境:


前端应用在客户端中运行,我们通常需要通过各种手段来监控和上报应用的状态,以便更快地定位和解决客户问题。






原则一:我认为“自动化才是秩序”:


文档通常都会被束之高阁,因此单靠文档很难形成约束力。尤其在迭代频繁、人员构造不稳定的情况。规范自动化、配合有效的管理才是行之有效的解决办法。



  • 规范自动化。能够交给机器去执行的,都应该交给机器去处理, 最大程度降低开发者的心智负担、犯错率。可以分为以下几个方面:

    • 语言层面:类型检查,比如 Typescript。严格的 Typescript 可以让开发者少犯很多错误。智能提示对开发效率也有很大提升。

    • 风格层面:统一的代码格式化风格。例如 Prettier

    • 规范层面:一些代码规范、最佳实践、反模式。可以遵循社区的流行规范, 例如 JavaScript Standard

    • 架构层面:项目的组织、设计、关联、流程。可以通过脚手架、规范文档、自定义 ESLint 规则。



  • 管理和文化: 机器还是有局限性,更深层次的检查还是需要人工进行。比如单元测试、CodeReview。这往往需要管理来驱动、团队文化来支撑。这是我们后面需要走的路。






原则二:不要造轮子


我们不打算造轮子,建立自己的代码规范。社区上有很多流行的方案,它们是集体智慧的结晶,也最能体现行业的最佳实践:


社区规范


没必要自己去定义规则,因为最终它都会被废弃,我们根本没有那么多精力去维护。






实现


企业通知 Code Review


企业通知 Code Review






我们这套代码质量管理体系,主要基于以下技术来实现:



  • Jenkins: 运行代码检查、构建、通知等任务

  • Gerrit:以 Commit 为粒度的 CodeReview 工具

  • wkfe-standard: 我们自己实现渐进式代码检查 CLI






如果你想了解这方面的细节,可以查看以下文档:





我推崇的自动化就是秩序目的就是让机器来取代人对代码进行检查。然而它只是仅仅保证底线。


人工 CodeReview 的重要性不能被忽略,毕竟很多事情机器是做不了的。


为了推行 CodeReview,我们曾自上而下推行了 CCC(简洁代码认证) 运动,开发者可以提交代码让专家团队来 Code Review,一共三轮,全部通过可以获得证书,该证书可以成为绩效和晋升的加分项;除此之外还有代码规范考试…


然而,这场运动仅仅持续了几个月,随着公司组织架构的优化、这些事情就不再被重视。


不管是多么完善的规范、工作流,人才是最重要的一环,到最后其实是人的管理






DDD / 中台的泡沫


近年来,后端微服务、中台化等概念火热,DDD 也随之而起。


DDD 搜索趋势


上图的 DDD Google 趋势图,一定程度可以反映国内 DDD 热度的现实情况:



  • 在 14 年左右,微服务的概念开始被各方关注,我们可以看到这年 DDD 的搜索热度有明显的上升趋势

  • 2015 年,马某带领阿里巴巴集团的高管,去芬兰的赫尔辛基对一家名叫 supercell 的游戏公司进行商务拜访,中台之风随着而起,接下来的一两年里,DDD 的搜索热度达到了顶峰。

  • 2021 ~ 2022 年,口罩期间,很多公司业务几乎停摆,这是一个’内修‘的好时机。很多公司在这个阶段进行了业务的 DDD 重构,比较典型的代表是去哪儿业务瘦身 42%+效率提升 50% :去哪儿网业务重构 DDD 落地实践)。




上文提到,我们在 2021 年底也进行了一次轰轰烈烈的 DDD 重构战役,完全推翻现有的项目,重新梳理业务、重新设计、重新编码。


重构需要投入了大量的资源,基本公司 1 / 3 的研发资源都在里面了,这还不包括前期 DDD 的各种预研和培训成本。


在现在看来,这些举措都是非常激进的。而价值呢?现在还不’好说‘(很难量化)






DDD 落地难


其实既然开始了 DDD 重构, 就说明我们已经知道 ’怎么做 DDD‘ 了,在重构之前,我们已经有了接近一年的各种学习和铺垫,且在部分中台项目进行了实践。


但我至今还是觉得 DDD 很难落地,且不说它有较高的学习成本,就算是已落地的项目我们都很难保证它的连续性(坚持并贯彻初衷、规范、流程),烂尾的概率比较高。


为了降低开发者对 DDD 的上手门槛,我们也进行了一些探索。






低代码 + DDD?


可视化领域建模


可视化领域建模


2022 下半年,我们开始了 ’DDD 可视化建模‘ 的探索之路,如上图所示。


这个平台的核心理念和方法论来源于我们过去几年对 DDD 的实践经验,涵盖了需求资料的管理、产品愿景的说明、统一语言、业务流程图、领域模型/查询模型/领域服务的绘制(基于 CQRS),数据建模(ER)、对象结构映射(Mapper)等多种功能,覆盖了 DDD 的整个研发流程。


同时它也是一个知识管理平台,我们希望在这里聚合业务开发所需要的各种知识,包括原始需求资料、统一语言、领域知识、领域建模的结果。让项目的二开、新团队成员可以更快地入手。


最终,建模的结果通过“代码生成器”生成代码,真正实现领域驱动设计,而设计驱动编码。


很快我们会完全开源这套工具,可以关注我的后续文章。






DDD 泡沫


即使我们有’低代码‘工具 + 代码自动生成的加持,实现了领域驱动设计、设计驱动编码,结果依旧是虎头蛇尾,阻止不了 DDD 泡沫的破裂。




我也思考了很多原因,为什么我们没有’成功‘?





  • DDD 难?学习曲线高

  • 参与的人数少,DDD 受限在后端开发圈子里面,其他角色很少参与进来,违背了 DDD 的初衷

  • 重术而轻道。DDD 涵括了战略设计和战术设计,如果战略设计是’道‘、战术设计就是’术‘,大部分开发者仅仅着眼于术,具体来说他们更关注编码,思维并没有转变,传统数据建模思维根深蒂固

  • 中台的倒台,热潮的退去


扩展阅读:







一些零碎的事


过去三年还做了不少事情,限于篇幅,就不展开了:







过去三年经历时间轴:



  • 2020 年 7 月,换了公司,开始接手真正迁移中的 Taro 项目

  • 2020 年 10 月,Taro 2.x 小程序正式上线

  • 2020 年 10 月 ~ 11 月 优化代码质量管理体系,引入开发规范、Gerrit Code Review 流程

  • 2020 年 12 月 ~ 2021 年 4 月,业务开发

  • 2021 年 1 月 博客停更

  • 2021 年 5 月 Taro 3.x 升级

  • 2021 年 7 月 ~ 10 月 前端低代码平台开发

  • 2021 年 11 月 ~ 2022 年 5 月, DDD 大规模重构,C 端项目重构、国际化改造

  • 2022 年 6 月 ~ 2022 年 11 月,B 端技术升级,涉及容器化改造、微前端升级、组件库开发等

  • 2022 年 12 月~ 2023 年 4 月,可视化 DDD 开发平台开发

  • 2023 年 5 月 ~ 至今。业务开发,重新开始博客更新








总结


贝尔实验室


我们都有美好的愿望


重构了又重构,技术的债务还是高城不下


推翻了再推翻,我们竟然是为了‘复用’?


降本增效的大刀砍来


泡沫破碎,回归到了现实


潮水退去,剩下一些裸泳的人


我又走到了人生的十字路口,继续苟着,还是换个方向?


作者:荒山
来源:juejin.cn/post/7289718324857880633
收起阅读 »

如何设计一个网盘系统的架构

1. 概述 现代生活中已经离不开网盘,比如百度网盘。在使用网盘的过程中,有没有想过它是如何工作的?在本文中,我们将讨论如何设计像百度网盘这样的系统的基础架构。 2. 系统需求 2.1. 功能性需求 用户能够上传照片/文件。 用户能够创建/删除目录。 用户能够...
继续阅读 »

1. 概述


现代生活中已经离不开网盘,比如百度网盘。在使用网盘的过程中,有没有想过它是如何工作的?在本文中,我们将讨论如何设计像百度网盘这样的系统的基础架构。


2. 系统需求


2.1. 功能性需求



  1. 用户能够上传照片/文件。

  2. 用户能够创建/删除目录。

  3. 用户能够下载文件。

  4. 用户能够共享上传的文件。

  5. 能够在所有的用户设备之间同步数据。

  6. 即使网络不可用,用户也能上传文件/照片,只是存储在离线文件中,当网络可用时,离线文件将同步到在线存储。


2.2 非功能性需求




  1. 可用性: 指系统可用于处理用户请求的时间百分比。我们通常将可用性称为5个9、4个9。5个9意味着 99.999% 的可用性,4 个9意味着 99.99% 的可用性等。




  2. 持久性: 即使系统发生故障,用户上传的数据也应永久存储在数据库中。系统应确保用户上传的文件应永久存储在服务器上,而不会丢失任何数据。




  3. 可靠性: 指系统对于相同输入给出预期的输出。




  4. 可扩展性: 随着用户数量的不断增加,系统应该能处理不断增加的流量。




  5. ACID: 原子性、一致性、隔离性和持久性。所有的文件操作都应该遵循这些属性。



    1. 原子性:对文件执行的任何操作都应该是完整的或不完整的,不应该是部分完整的。即如果用户上传文件,操作的最终状态应该是文件已 100% 上传或根本没有上传。

    2. 一致性: 保证操作完成之前和之后的数据是相同的。

    3. 隔离性:意味着同时运行的2个操作应该是独立的,并且不会影响彼此的数据。

    4. 持久性:参考第二点关于持久性的解释。




3. 容量估算


假设我们有 5 亿总用户,其中 1 亿是每日活跃用户。


那么,每分钟的活跃用户数:


1亿 / (24小时 * 60分钟)= 0.07万

再假设下高峰期每分钟有 100 万活跃用户,平均每个用户上传 5 个文件,则每分钟将有 500 万次上传。


如果1次上传平均100KB的文件,则1分钟上传的总文件大小为:


100KB * 5 = 500TB

4. API设计


4.1 上传文件


POST: /uploadFile
Request {
filename: string,
createdOnInUTC: long,
createdBy: string,
updatedOnInUTC: long,
updatedBy: string
}

Response: {
fileId: string,
downloadUrl: string
}

上传文件分为2步:



  1. 上传文件元数据

  2. 上传文件


4.2 下载文件


GET: /file/{fileId}
Response: {
fileId: string,
downloadUrl: string
}

通过返回的downloadURL进行文件下载。


4.3 删除文件


DELETE: /file/{fileId}

4.4 获取文件列表


GET: /folders/{folderId}?startIndex={startIndex}&limit={limit}

Response: {
folderId: string,
fileList: [
{
fileId: string,
filename: string,
thumbnail_img: string,
lastModifiedDateInUTC: string
creationDateInUTC: string
}
]
}

由于文件数量可能会很大,这里采用分页返回的方式。


5. 关键点的设计思考



  1. 文件存储: 我们希望系统具有高可用性和耐用性来存储用户上传的内容。为此,我们可以使用对象存储的系统作为文件存储,可选的有AWS的S3、阿里云的对象存储等。我们采用S3。

  2. 存储用户数据及其上传元数据: 为了存储用户数据及其文件元数据,我们可以使用关系型数据库和非关系型数据库结合的方式,关系型数据库采用MySQL, 非关系型数据库采用MongoDB。

  3. 离线存储: 当用户的设备离线时,用户完成的所有更新都将存储在其本地设备存储中,一旦用户上线,设备会将更新同步到云端。

  4. 上传文件: 用户上传的文件大小可能很大,为了将文件从任何设备上传到服务器而不出现任何失败,我们必须将其分段上传。目前常见的对象存储中都支持分段上传。

  5. 下载/共享文件: 通过分享文件的URL来实现共享和下载。如果文件存储是S3的话,也可以使用预签名的URL来实现此功能。



默认情况下,所有 S3 对象都是私有的,只有对象所有者有权访问它们。但是,对象所有者可以通过创建预签名 URL 与其他人共享对象。预签名 URL 使用安全凭证授予下载对象的限时权限。URL 可以在浏览器中输入或由程序使用来下载对象。





  1. 设备之间同步: 当用户在其中一台设备上进行更改时,当用户登录其他设备时,这些更改应同步在其他设备上。有两种方法可以做到这一点。



    1. 一旦用户从一台设备更新,其他设备也应该更新。

    2. 当用户登录时更新其他设备进行更新。


    我们采用第二种方法,因为即使用户不使用其他设备,它也可以防止对其他设备进行不必要的更新。如果用户在两个不同的设备上在线怎么办?那么在这种情况下我们可以使用长轮询。用户当前在线的设备将长时间轮询后端服务器并等待任何更新。因此,当用户在一台设备上进行更新时,另一台设备也会收到更新。




6. 数据库设计


用户表


userId: string
username: string
emailId: string
creationDateInUtc: long

文件源数据表


fileId: string
userId: string
filename: string
fileLocation: string
creationDateInUtc: long
updationDateInUtc: long

7. 架构设计





  1. File MetaData Service: 该服务负责添加/更新/删除用户上传文件的元数据。客户端设备将与此服务通信以获取文件/文件夹的元数据。




  2. File Upload Service: 该服务负责将文件上传到 S3 存储桶。用户的设备将以块的形式将文件流式传输到此服务,一旦所有块都上传到 S3 存储桶,上传就会完成。




  3. Synchronization Service: 同步服务,两种情况需要同步。



    1. 当用户在其设备上打开应用程序时,在这种情况下,我们将从同步服务同步用户的该设备与用户当前查看的目录的最新快照。

    2. 当用户从一个先后登录两个不同设备时,我们需要同步用户的第一个设备的数据,故而我们使用长轮询来轮询该目录/文件在服务器上的最新更改内容。




  4. S3 存储桶: 我们使用 S3 存储桶来存储用户文件/文件夹。根据用户 ID 创建文件夹,每个用户的文件/文件夹可以存储在该用户的文件夹中。




  5. Cache: 使用缓存来减少元数据检索的延迟,当客户端请求文件/文件夹的元数据时,它将首先查找缓存,如果在缓存中找不到,那么它将查找数据库。




  6. 负载均衡 我们希望我们的服务能够扩展到数百万用户,为此我们需要水平扩展我们的服务。我们将使用负载均衡器将流量分配到不同的主机。这里我们采用Nginx做负载均衡。




  7. UserDevices: 用户可以使用移动设备、台式机、平板电脑等多种设备来访问驱动器。我们需要保证所有用户设备的数据都是相同的,并且不能存在数据差异。




8. 总结


本文讨论了如何设计一个网盘系统的架构,综合功能性需求和非功能性需求,设计了API、数据库和服务架构。但是没有讨论权限设计和数据安全的部分,也欢迎大家补充改进。


作者:郭煌
来源:juejin.cn/post/7299353265098850313
收起阅读 »

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