注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

软件设计中你考虑过重试了吗?

你好,我是刘牌! 人生做事情失败了,拍拍裤子,站起来再试试,那么为啥软件中请求失败了为何就放弃了,而不是不再试试呢! 前言 今天分享一下重试操作,我们知道网络是不可靠的,那么在进行网络请求时,难免会出现请求失败,连接失败等情况,为了保证软件的稳定性和良好的...
继续阅读 »

你好,我是刘牌!



人生做事情失败了,拍拍裤子,站起来再试试,那么为啥软件中请求失败了为何就放弃了,而不是不再试试呢!



前言


今天分享一下重试操作,我们知道网络是不可靠的,那么在进行网络请求时,难免会出现请求失败,连接失败等情况,为了保证软件的稳定性和良好的体验,很多时候我们不应该将程序内部出现的问题都抛出给用户,而是应该尽最大可能将软件内部不可抗拒的问题在程序内部处理掉,那么很多时候我们会采取重试操作。


背景和问题


程序产生网络故障或者其他一些故障是无法避免的,可能因为一些原因导致某些服务在短时间或者一段时间断连,可能是服务器负载过高而导致的,也可能是数据库导致故障从而影响服务,也可能是GC过于频繁而导致服务很不稳定等等,总之,导致服务不可用的因素很多很多。


对于程序的出错,如果不属于业务上的异常,不应该抛给用户,比如抛出“无法连接远程服务”,“服务器负载过高”,“数据库异常”这类异常给用户实际上没有任何意义,反而会影响用户用户体验,因为用户并不关心这些,他们也读不懂这些领域词汇,所以应该去避免这些问题。


解决方案


程序发生异常是无法避免的,我们只有采取一些补救措施,在最大程度上提高程序的稳定性和用户体验,对于程序产生的问题,有一些可能只是瞬时的,系统能够很快恢复,有一些需要一定的时间,而有一些需要介入人工,所以需要花费的时间更多,那么就需要根据不同的情况来处理,下面对其进行分类。


取消


当系统中的异常是暂时无法处理的,这时候就应该直接取消任务,因为如果不取消,而是让用户一直等待,那么就会导致用户的操作无法进行下一步,而是一直等待,用户体验就会变得很差,这时候应该给用户友好的提示,提醒他们稍后再进行办理,浪费别人的时间等于谋财害命。


重试


如果请求因为网络原因或者服务短暂的不可用,这种故障时间很短,很快就能恢复,比如一些服务是多实例部署,刚好请求到的那个服务出现网络故障而没能请求成功,如果我们直接返回异常,那么肯定不合适,因为其他服务实例还能提供服务,所以应该对请求进行重试,重试后可能请求到了其他正常的服务,即使请求到了之前的服务,那么可能它已经恢复好了,能够正常提供服务了,这里重试是没有时间间隔的,是不间断地请求,直到请求成功,这种适用于服务很够很快恢复的场景。


间隔重试


间隔重试就是不会一下进行重试,而是隔一个时间段再进行重试,比如一些服务因为过于繁忙导致负载过高而暂时对外提供服务,那么这时候如果不断发起重试,只会导致服务负载更高,我们应该隔个时间段再进行重试,让服务处理堆积的任务,等服务负载降下来再重试,这个时间间隔需要我们进行考量,按照合适的值去设置,比如1s,这完全根据实际场景去衡量。


上面对三种方案进行描述,我们只描述了重试,但是重试次数也是我们要去考量的一个值,如果一个服务20s才恢复,那么我们重试20秒肯定不太合适,不过也要看具体业务,面向客户的话肯定大多客户接受不了,这时候我们应该设置重试次数,比如重试了三次还不能成功,那么久取消任务,而不是一直重试下去。



重试次数也要根据实际情况来设置,如果一直重试,而服务一直无法恢复,那么也会消耗资源,并且用户导致用户请求一直在等待,用户体验不好,设置设置次数过少,那么可能会导致没有足够重试,从而导致浪费了一些重试次数,最后还没有成功,如下,第三次就重试成功,如果设置为两次,那么前两次没有成功就返回,用户还需重新再发起请求。



从上面可以看出,这些设置都没有黄金值,而是需要我们根据业务和不断地测试才能找出合适的值。


怎么重试,参数怎么管理


上面对重试进行一些理论的讲解,那么在实际场景中我们应该如果去做呢,首先要考虑我们的业务中是否真的有必要重试,如果没必要,那么就完全没必要去增加复杂度,如果需要,那么就需要进行良好的设计,保证其优雅和扩展性。


不同的业务有不同的重试逻辑,所以我们需要在不同的地方实现不同的逻辑,但是重试次数和重试时间间隔这些参数应该是需要可动态配置的,比如今天服务负载过高,那么时间间隔可以设置稍微长一点,次数可以设置多一点,然后负载较低的时候,参数可以设置小一点,这些配置信息可以写入配置中心中。


也有一些重试框架供我们使用,比如spring-retry,我们可以借助一些框架来管理我们的重试任务,更方便管理。


总结


以上对重试的一些介绍就完了,我们介绍了重试的场景,重试产生的背景,还有一些解决方案,还对重试的一些管理进行介绍,重试的方案很多,实现方式也有很多,它不是固定的技术,而是一种思想,也是我们在软件设计中应该考虑的一个点,它能提高软件的稳定性和用户体验,但是也需要我们进行考量。



今天的分享就到这里,感谢你的观看,我们下期见!



作者:刘牌
来源:juejin.cn/post/7238230111941689400
收起阅读 »

一体多面:哪有什么DO、BO、DTO,只不过是司空见惯的日常

1 分层疑问 无论DDD还是MVC模式构建项目,势必涉及到工程结构的分层,每一层由于定位不同,所以访问的对象也不同,那么对象在每一层传递时就会涉及到对象的转换,这时有人会产生以下疑问: 对象种类多,增加理解成本 对象之间转换,增加代码成本 编写代码时有时不同...
继续阅读 »

1 分层疑问


无论DDD还是MVC模式构建项目,势必涉及到工程结构的分层,每一层由于定位不同,所以访问的对象也不同,那么对象在每一层传递时就会涉及到对象的转换,这时有人会产生以下疑问:



  • 对象种类多,增加理解成本

  • 对象之间转换,增加代码成本

  • 编写代码时有时不同层对象几乎一样


即使有这样的疑问,我也认为分层是必要的,所以本文我们尝试回答上述疑问。




2 通用分层模型


两种通用模型是MVC和DDD,我之前在文章《DDD理论建模与实现全流程》也详细讨论DDD建模和落地全流程,本文只涉及对象的讨论,所以会对模型有所简化。




2.1 模型分类



  • 数据对象:DO(data object)

  • 业务对象:BO(business object)

  • 视图对象:VO(view object)

  • 数据传输对象:DTO(data transfer object)

  • 领域对象:DMO(domain object)

  • 聚合对象:AGG(aggregation)




2.2 MVC


MVC模型总体分为三层:



  • 持久层(persistence)

  • 业务层(business)

  • 表现层(presentation/client)


每一层使用对象:



  • 持久层

    • 输入对象:DO

    • 输出对象:DO



  • 业务层

    • 输入对象:BO

    • 输出对象:BO



  • 表现层

    • 输入对象:VO/DTO

    • 输出对象:VO/DTO






2.3 DDD


DDD模型总体分为四层:



  • 基础设施层(infrastructure)

  • 领域层(domain)

  • 应用层(application)

  • 外部访问层(presentation/client)


每一层使用对象:



  • 基础设施层

    • 输入对象:DO

    • 输出对象:DO



  • 领域层

    • 输入对象:DMO

    • 输出对象:DMO



  • 应用层

    • 输入对象:AGG

    • 输出对象:DTO



  • 外部访问层

    • 输入对象:VO/DTO

    • 输出对象:VO/DTO






3 生活实例


这些对象看起来比较复杂,理解成本很高,好像是为了分层硬造出来的概念。其实不然,这些对象在生活中司空见惯,只不过大家并没有觉察。我们设想有一家三口,小明、小明爸爸和小明妈妈,看看这些对象是怎么应用在生活中的。




3.1 MVC


3.1.1 数据对象(DO)


数据对象作用是直接持久化至数据库,是最本质的一种对象,这就像小明在卧室中穿着背心睡觉,这接近于人最本质的一种状态,小明此时是一种数据对象。




3.1.2 业务对象(BO)


小明起床走出卧室,这时小明就不仅仅是他自己了,他还多了很多身份,例如儿子、学生、足球队队员,不同身份输入和输出信息是不一样的。作为儿子要回应家长的要求,作为学生要回应老师的要求,作为足球队员要回应教练的要求。作为小明从数据对象,在不同的身份场景中变成了不同的业务对象。




3.1.3 视图对象/数据传输对象(VO/DTO)


小明吃完早饭准备去上学,但是嘴角粘上了饭粒,出门前要把饭粒擦掉。数据传输对象需要注意简洁性和安全新,最重要的是只携带必要信息,而不应该携带不必须要信息,所以此时小明变成了视图对象。




3.2 DDD


3.2.1 领域对象(DMO)


领域对象要做到领域完备,从本质上来说与业务对象相同,但是通常使用充血模型,业务对象通常使用贫血模型。




3.2.2 聚合对象(AGG)


学校要开家长会要求小明、小明妈妈和小明爸爸全部参加,其中小明负责大扫除,小明妈妈负责出黑板报,小明爸爸负责教小朋友们踢足球。此时学校和家长联系时是以家庭为单位的,家庭就是聚合对象。




4 一体多面


通过上述实例我们看到,即使是同一个自然人,在不同的场景下也有不同的身份,不同的身份对他的要求是不同的,输入和输出也是不同的。这就是一体多面。


同理对于同一个对象,即使其在数据库只有一条数据,但是由于场景的不同,输入和输出也是不同的,所以有了各种看似复杂的对象。我们再回看上面三个问题,可以尝试给出本文的答案:


对象种类多,增加理解成本:这是必须要付出的成本,小明不能嘴角挂着饭粒去上学


对象之间转换,增加代码成本:这是必须要付出的成本,不同角色切换时必须要付出切换成本,小明不能用回应足球队教练的输出回应老师或者老师,这是截然不同的角色


编写代码时有时不同层对象属性几乎一样:小明作为一个自然人,他自身固有特性也是相同的,所以表现在对象上是很多属性是一致的。但是不同的角色是有不同要求的,所以为了一些细微的差别,也是要新增对象的,这是必要的代价


作者:JAVA前线
来源:juejin.cn/post/7302740437529395211
收起阅读 »

Mysql升级后字符编码引起的血泪教训

描述 现在大部分企业所使用的MySQL数据库相信都已经从5.7升级到了8,性能也得到了大幅度的提升 MySQL 8.0对于数据管理带来了很多改变,使得MySQL成为一个更强大、更灵活和更易于使用的数据库管理系统。 MySQL 8.0提供了更好的JSON支持...
继续阅读 »

描述


现在大部分企业所使用的MySQL数据库相信都已经从5.7升级到了8,性能也得到了大幅度的提升


MySQL 8.0对于数据管理带来了很多改变,使得MySQL成为一个更强大、更灵活和更易于使用的数据库管理系统。




  1. MySQL 8.0提供了更好的JSON支持,包括更快的JSON函数和表达式,以及新的JSON数据类型和索引。




  2. MySQL 8.0引入了窗口函数,这些函数可以用来计算分析函数的结果并根据指定的排序规则进行分组。




  3. MySQL 8.0提供了更好的空间数据支持,包括新的空间数据类型和函数,例如ST_Distance_Sphere函数,它可以计算两个点之间的球面距离。




  4. MySQL 8.0提供了更好的安全性,包括更安全的默认配置、更严格的密码策略、更多的SSL/TLS选项等。




  5. MySQL 8.0提供了更好的性能,包括新的索引算法、更好的查询优化器、更好的并发控制等。




MySQL5.7


查看版本号

image.png


查看编码格式

image.png


从结果可以看出,MySQL8默认字符编码为utf8mb4


查看排序规则

image.png


从结果可以看出,MySQL8默认排序规则为utf8mb4_general_ci


总结

MySQL5.7 默认字符编码是utf8mb4,默认排序规则是utf8mb4_general_ci


MySQL8


查看版本号

image.png


查看编码格式

image.png


“character_set_client” 表示客户端字符集


“character_set_connection” 表示连接字符集


“character_set_server” 表示服务器字符集


从结果可以看出,MySQL8默认字符编码为utf8mb4


查看排序规则

image.png


从结果可以看出,MySQL8默认排序规则为utf8mb4_0900_ai_ci


总结

MySQL8 默认字符编码是utf8mb4,默认排序规则是utf8mb4_0900_ai_ci


utf8 与 utf8mb4 区别




  1. 存储字符范围不同:



    • utf8 编码最多能存储 3 个字节的 Unicode 字符,支持的 Unicode 范围较窄,无法存储一些辅助平面字符(如 emoji 表情)。

    • utf8mb4 编码最多能存储 4 个字节的 Unicode 字符,支持更广泛的 Unicode 范围,包括了 utf8 所不支持的一些特殊字符和 emoji 表情等。




  2. 存储空间不同:



    • utf8 编码时,字符长度可以是最多 3 个字节。

    • utf8mb4 编码时,字符长度可以是最多 4 个字节。




  3. 对于存储 Emoji 和特殊字符的支持:



    • utf8mb4 能够存储和处理来自辅助平面的字符,包括emoji表情,这些字符需要使用 4 个字节来编码。而 utf8 不支持这些字符。




utf8mb4_general_ci 与 utf8mb4_0900_ai_ci 区别




  1. utf8mb4_general_ci



    • 这是MySQL中较为通用的字符集和校对规则。

    • utf8mb4 是一种用于存储 Unicode 字符的编码方式,支持更广泛的字符范围,包括 emoji 等。

    • general_ci 是一种排序规则,对字符进行比较和排序时不区分大小写,对于大多数情况来说是足够通用的。




  2. utf8mb4_0900_ai_ci



    • 这是MySQL 8.0.0 版本后引入的校对规则。

    • 0900 表示MySQL 8.0.0 版本。

    • ai_ci 是指采用 accent-insensitive 方式,即对于一些有重音符号的字符,排序时会忽略重音的存在。




主要区别在于排序规则的不同。utf8mb4_0900_ai_ci 在排序时会对重音符号进行忽略,所以某些含有重音符号的字符在排序时可能会与 utf8mb4_general_ci 有所不同。


索引不生效问题


表结构

CREATE TABLE `user` (
`id` bigint NOT NULL COMMENT '主键',
`username` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '密码',
`store_id` bigint NOT NULL DEFAULT 0 COMMENT '门店id',
`is_delete` int NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`),
KEY `idx_store_id` (`store_id`)
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户表';


CREATE TABLE `user_role` (
`id` bigint NOT NULL COMMENT '主键',
`user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户id',
`role_id` bigint NOT NULL DEFAULT 0 COMMENT '角色id',
`is_delete` int NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户角色关系表';


查询

SELECT DISTINCT
t1.id,
t1.username
FROM
user t1
JOIN user_role t2 ON t2.user_id = t1.id
WHERE
t1.is_delete = 0
and t2.is_delete = 0
and t1.store_id = 2
AND t2.role_id NOT IN (9, 6)


执行计划

企业微信截图_c83704fd-f85a-4dc7-901f-00a9cf35857e.png


通过执行计划发现明明字段上加了索引,为什么索引没有生效


explain format = tree 命令

企业微信截图_e26332e8-cad7-42fc-bfb7-7c06fbadf26b.png


问题找到了


(convert(t2.user_id using utf8mb4) = t1.id))



在回头看看表结构

image.png


为什么会不一致呢?

mysql5.7 升级之前 两个表都是 CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci


mysql5.7 升级到 mysql8 后,user_role 更新过表结构


修改表排序规则


ALTER TABLE user CHARACTER COLLATE = utf8mb4_0900_ai_ci;



image.png


再次查看执行计划

企业微信截图_5a4e736a-a9b1-413a-b517-17e552d1b783.png


企业微信截图_a97f807a-8c3b-4a8e-ad2f-9ad47a6f398e.png


总结

开发一般都不太注意表结构的字符编码和排序规则,数据库升级一定要先统一字符编码和排序规则


查询的问题


由于先发布应用,后执行的脚步,没有通知测试所以没有生产验证,导致第二天一大早疯狂报警


image.png


一看就是两个表字段排序规则不一致导致的


只能修改表结构排序规则 快速解决


总结


升级MySQL是一个常见的操作,但在升级过程中可能会遇到各种问题。本文主要介绍排序规则不一致导致的问题,希望能对大家在升级MySQL时有所帮助。在进行任何升级操作之前,务必备份数据库,以防数据丢失。同时,建议定期对数据库进行性能优化,以提高系统的高可用。


作者:三火哥
来源:juejin.cn/post/7303349226066444288
收起阅读 »

Git 提交竟然还能这么用?

大家好,我是鱼皮。Git 是主流的代码版本控制系统,是团队协作开发中必不可少的工具。 这篇文章,主要是给大家分享 Git 的核心功能 提交(Commit)的作用,帮助大家更好地利用 Git 这一工具来提高自己的开发工作效率。 什么是 Git 提交? Git 提...
继续阅读 »

大家好,我是鱼皮。Git 是主流的代码版本控制系统,是团队协作开发中必不可少的工具。


这篇文章,主要是给大家分享 Git 的核心功能 提交(Commit)的作用,帮助大家更好地利用 Git 这一工具来提高自己的开发工作效率。


什么是 Git 提交?


Git 提交是指将你的代码保存到 Git 本地存储库,就像用 Word 写长篇论文时进行保存文件一样。每次 Git 提交时都会创建一个唯一的版本,除了记录本次新增或发生修改的代码外,还可以包含提交信息,来概括自己这次提交的改动内容。


如下图,就是一次 Git 提交:



Git 提交的作用


Git 提交有很多作用,我将它分为 基础用法其他妙用


基本作用


历史记录


Git 提交最基本的作用就是维护项目的历史记录。每次提交都会记录代码库的状态,包括文件的添加、修改和删除;还包括一些提交信息,比如提交时间、描述等。这使得我们可以通过查看所有的历史提交来追溯项目的开发进度和历程,了解每个提交中都发生了什么变化。


比如查看我们编程导航文档网站项目的提交记录,能看到我是怎么一步一步构建出这个文档网站的:



开源地址:github.com/liyupi/code…




在企业开发中,如果一个人写了 Bug,还死不承认,那么就可以搬出 Git 提交记录,每一行代码是谁提交的都能很快地查出来,谨防甩锅!


版本控制


另一个 Git 提交的基本作用是版本控制。每个提交都代表了代码库的一个版本,这意味着开发者可以随时切换代码版本进行开发,恢复旧版本的代码、或者撤销某次提交的代码改动。


推荐新手使用可视化工具而不是 Git 命令进行版本的切换和撤销提交,在不了解 Git 工作机制的情况下使用命令操作很容易出现问题。


如下图,在 JetBrains 系列开发工具中,右键某个提交,就可以切换版本或撤销提交了:



代码对比


你可以轻松地查看两个提交之间的所有代码更改,便于快速了解哪些部分发生了变化。这对于解决代码冲突、查找错误或审查代码非常有帮助。


在 JetBrains 系列开发工具中,只需要选中 2 个提交,然后点右键,选择 Compare Versions 就能实现代码对比了:



改动了哪些代码一目了然:



一般情况下,如果我们因为某次代码改动导致项目出现了新的 Bug。通过这种方式对比本次改动的所有代码,很快就能发现 Bug 出现的原因了。


其他妙用


除了基本作用外,Git 提交还有一些妙用~


记录信息


像上面提到的,Git 提交不仅能用于记录代码更改,我们还可以在提交信息中包含有关这次更改的重要信息。比如本次改动代码的介绍、代码更改的原因、相关的任务(需求单)或功能等。可以简单理解为给本次工作写总结和描述。


如果提交信息编写得非常清晰完善,那么项目的团队成员可以更容易地理解每个提交,甚至能做到 “提交即文档”,提高协作和项目维护效率。


正因如此,很多团队会定制自己的提交信息规范,比如之前我在鹅厂的时候,每次提交都建议带上需求单的地址,便于了解这次提交是为了完成什么需求。


这里给大家推荐一种很常用的提交信息规范 —— 约定式提交,每次提交信息都需要遵循以下的结构:



《约定式提交》文档:http://www.conventionalcommits.org/zh-hans/v1.…



<类型>[可选 范围]: <描述>

[可选 正文]

[可选 脚注]


当然,这种方式有利有弊,可能有同学会觉得 “我注释都懒得写,你还让我写提交信息?” 这取决于你们项目的规模和紧急程度等因素,反正团队内部保持一致就好。


像我在用 Git 开发个人项目时,也不是每次都写很详细的提交信息的。但是带 编程导航 的同学从 0 开发项目时,每场直播写的代码都会单独作为一次提交,如下图:



是不是很清晰呢?这样做的好处是,大家想获取某场直播对应的中间代码(而不是最终的成品代码)时,只需要点击某次提交记录就可以获取到了,很方便。



如果你的提交信息写得非常标准、统一结构,那么甚至还可以用程序自动读取所有的提交信息,生成日志、或者输出提交报告。


自动化构建部署


大厂研发流程中,一般都是使用 CI / CD(持续集成和持续部署)平台,以流水线的形式自动构建部署项目的。


Git 提交可以和 CI / CD 平台进行集成,比如自动监视代码库中的提交,并在每次提交后自动触发构建和部署任务。一个典型的使用场景是,每次代码开发完成后,先提交代码到测试分支,然后 CI / CD 平台监测到本次提交,并立即在测试环境中构建和部署,而不需要人工操作,从而提交效率。


GitHub Actions 和 GitHub Webhooks 都可以实现上述功能,感兴趣的同学可以尝试下。



GitHub Actions 文档教程:docs.github.com/zh/actions/…




检验项目真假


最后这个点就比较独特了,那就是面试官可以通过查看 Git 的提交记录来判断你的项目真假、是不是自己做的。


比如我收到一些同学的简历中,有的开源项目看起来感觉很厉害,但是点进仓库看了下提交记录,发现寥寥无几,甚至有的只有 1 次!像下图这样:



那么这个项目真的是他自己从 0 开始做的么?答案就显而易见了。


如果真的是你自己用心做的项目,提交记录绝对不止 1 次,而且面试官能够通过提交记录很清晰地了解到你的项目开发周期。


像我的 yuindex Web 终端项目一样,这才是比较真实、有说服力的:



其他人也能从你的提交记录中,感受到你对项目的用心程度。


讲到这里,是不是有些同学恍然大悟,知道为啥自己的项目明明开源了,但是没有收到面试邀请、或者被面试官觉得项目不真实了?


实践


以上就是本次分享,Git 提交的实践其实非常简单,我建议大家每次做新项目时,无论大小,都用 Git 来托管你的项目,并且每开发完一个功能或解决 Bug,都进行一次提交。等项目完成后回过头来看这些提交记录,都是自己宝贵的财富。


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

别把这些 Redis 操作写到生产环境

软件工程师在开发前要提前注意规避对 Redis 性能有影响的操作,避免走“先污染后治理”的老路。如下是整理出来6条会导致 Redis 性能下降的原因,尽量避免这些操作出现在生产环境中。 1. 大键和大值 存储大键或大值可能会消耗更多的内存,并且在 Redis ...
继续阅读 »

软件工程师在开发前要提前注意规避对 Redis 性能有影响的操作,避免走“先污染后治理”的老路。如下是整理出来6条会导致 Redis 性能下降的原因,尽量避免这些操作出现在生产环境中。


1. 大键和大值


存储大键或大值可能会消耗更多的内存,并且在 Redis 进行网络和磁盘 I/O 操作时可能会增加延迟。


创建一个大键和大值:


SET bigkey "a".repeat(5242880)  # 创建一个5MB的大值

2. 阻塞操作


某些 Redis 命令,如 BLPOPBRPOPBRPOPLPUSH,可能会阻塞 Redis 进程。同样,Lua 脚本执行时间过长也可能导致阻塞。


如下 BLPOP 操作会阻塞 Redis 直到有元素被推入列表或者超时:


BLPOP mylist 0  # 0表示无限期等待

3. 过期键的处理


如果有大量的键同时过期,Redis 的性能可能会受到影响,因为 Redis 需要在后台清理这些过期的键。


创建一个大量即将过期的键:


for i in range(100000):
EXPIRE key{i} 10 # 10秒后过期

4. 持久化


Redis 提供了两种持久化选项——RDB 和 AOF。RDB 是将当前进程数据生成快照保存的方式,而 AOF 是记录服务器收到的每一条写命令。频繁的持久化操作可能会增加磁盘 I/O 负载,从而影响性能。


启用 AOF 持久化并配置为每次有数据修改都立即写入磁盘(可能会影响性能):


CONFIG SET appendonly yes
CONFIG SET appendfsync always

5. 使用复杂度高的命令


KEYSSMEMBERSHGETALL 这样的命令可能需要扫描整个集合,当数据集大时,它们可能会导致 Redis 暂时停止处理其他请求。


KEYS 命令,它会扫描整个键空间:


KEYS *

6. 内存使用过高


如果 Redis 服务器的内存使用接近或达到了其最大值,性能可能会受到影响。此外,如果你的数据集大于可用内存,那么操作系统可能会开始进行分页,这会大大降低 Redis 的性能。


使用 INFO memory 命令可以查看 Redis 的内存使用情况:


INFO memory

作者:Light_Tree
来源:juejin.cn/post/7248286946573205565
收起阅读 »

为什么算法复杂度分析,是学算法最核心的一步

基本介绍 算法复杂度这个概念,是算法中比较重要的一个核心的点。假设你现在要去分辨一串代码写的好与坏,那么是不是就得需要有一个可以衡量的标准,而算法复杂度的分析,就是一把标准之尺,有了这把尺子,你就能分辨出那些写的糟糕的代码,同时你也知道了要怎样去优化这段代码。...
继续阅读 »

基本介绍


算法复杂度这个概念,是算法中比较重要的一个核心的点。假设你现在要去分辨一串代码写的好与坏,那么是不是就得需要有一个可以衡量的标准,而算法复杂度的分析,就是一把标准之尺,有了这把尺子,你就能分辨出那些写的糟糕的代码,同时你也知道了要怎样去优化这段代码。


而目前我们常用的分析法,也就是大O表示法


常见复杂度


我们看一下 下面的代码


   function fn(n) {
let m = 0
console.log(m)
for (let i = 0; i <= n; i = ++ ) {
m += i
m--
}
}

我们假设 一行代码的执行的消耗时间是 1run_time 那么以此推导上面代码执行的时间消耗是(3n + 2)run_time 那么用大O表示法就是O(3n + 2)。


ps:本文中中次的概念对应 每行代码 而不是整个代码片段


大O表示法,并不会具体分析出每行代码执行花费的时间,他是一个粗略的抽象的统计概念,主要是是表示的某段代码的,所消耗的(时间/空间)增长趋势


O表示是 总耗时 和总次数的一个比值,可以简单理解为 每一次代码执行所需要花费的耗时,也就是 总时间/总次数 = 每次执行需要消耗的平均时长。


那么刚刚的O(3n + 2) 其实就是 (3n + 2) * 每次代码需要消耗的平均时长,那么就可以得出一个公式 T(n) = O(代码执行的总次数)


其中 T(n) 表示的是 整段代码执行需要的总耗时


在大O表示法中,常数,低阶,系数,在表示的时候是可以直接忽略统计的的,那么最后实际表示的复杂度就是O(n) 了


我们再来看下面的代码


 function fn(n) {
let aa = 0
let bb = 0

for (let i = 0; i < n; ++i) {
aa += i
}

for (let i = 0; i < n; ++i) {
for (let k = 0; k < n; ++i) {
bb += k
}
}

}

前两行代码 很好看出来 就是个2,第一个for循环的消耗是 2n 第二个for循环 消耗是n的二次方那么实际用大O 表示就是 O(2 + 2n + n²) 最后表示的时候取3块代码中增长趋势最大的也就是O(n²)


O(logn) O(nlogn)


理解了上面分析的内容之后,这两个 O(logn), O(nlogn) 复杂度就很容易去学会了


 function fn(n) {
let m = 0
for (let i = 0; i < n; i *= 2) {
m++
}
}

我们来假设 n 是8 那么 2³ 就是8 那么也就是 2的x次方就是 n 那么用大O 表示法就是O(log2ⁿ)


function fn(n) {
let m = 0
for (let i = 0; i < n; i *= 3) {
m++
}
}

那么上面这段代码就很容易看出来是O(log3ⁿ) 了 我们忽略他的底数,都统一表示O(logn)


在这基础上O(nlogn) 就更好理解了,它表示的就是 一段 执行n遍的 logn复杂度的代码,我们把上面的代码稍稍修改一下


 function fn(n) {
for(let j =0;j<n;j++){
let m = 0
for (let i = 0; i < n; i *= 2) {
m++
}
}
}

空间复杂度


其实空间复杂度 和时间复杂度 计算方式是一模一样的,只不过是着重的点不一样,当你回了时间复杂度的计算,空间复杂对你来说就是张飞吃豆芽了


   function fn(n) {
let m = []
for (let i = 0; i <= n; i = ++ ) {
m.push(i)
}
}

这块代码我们关注他的空间使用 就知道 是O(n)了


案例分析


我们来举个前端中一个经典的复杂度优化的例子,react,vue 他们的diff算法。


要知道目前最好的 两棵树的完全比较,复杂度也还是O(n³) ,这对频繁触发更新的情况,是一个严重的瓶颈。 同样的问题也存在于 react中 useEffect 和useMemo 的dep 以及 memo 的props。


所以他们都将比较操作 只停留在了当前的一层,比如diff只比较 前后同一层级的节点变化,不同层级的变化比对在出发更新时做出决定,这样就可以始终把复杂度维持在O(n)



结语


其实你分析出来的复杂度不等于,代码真实的复杂度,不管是大O表示法也好 还是别的表示法也好,都是针对代码复杂度分析的一个抽象工具,比如有一段处理分页的代码的业务逻辑,你清清楚楚的知道,目前是不允许改变分页大小的,也就是每次调用最多传进来的只有10 条数据,但是代码写的复杂度是O(n²) 这时候其实是没有多大的影响的,但是假设你现在写了一个无线滚动的功能,每次加载还都需要对所有的数据做O(n²)的操作,那么这时候,你就需要去想想怎么做优化了


作者:烟花易冷人憔悴
来源:juejin.cn/post/7302644330883612672
收起阅读 »

风控规则引擎(一):Java 动态脚本

风控规则引擎(一):Java 动态脚本 日常场景 共享单车会根据微信分或者芝麻分来判断是否交押金 汽车租赁公司也会根据微信分或者芝麻分来判断是否交押金 在一些外卖 APP 都会提供根据你的信用等级来发放贷款产品 金融 APP 中会根据很复杂规则来判断用户是否...
继续阅读 »

风控规则引擎(一):Java 动态脚本


日常场景



  1. 共享单车会根据微信分或者芝麻分来判断是否交押金

  2. 汽车租赁公司也会根据微信分或者芝麻分来判断是否交押金

  3. 在一些外卖 APP 都会提供根据你的信用等级来发放贷款产品

  4. 金融 APP 中会根据很复杂规则来判断用户是否有借款资格,以及贷款金额。


在简单的场景中,我们可以通过直接编写一些代码来解决需求,比如:


// 判断是否需要支付押金
return 芝麻分 > 650

这种方式代码简单,如果规则简单且不经常变化可以通过这种方式,在业务改变的时候,重新编写代码即可。


在金融场景中,往往会根据不同的产品,不同的时间,对接的银行等等多个维度来配置规则,单纯的直接编写代码无法满足业务需求,而且编写代码的方式对于运营人员来说无论实时性、可视化都很欠缺。


在这种情况往往会引入可视化的规则引擎,允许运营人员可以通过可视化配置的方式来实现一套规则配置,具有实时生效、可视化的效果。减少开发和运营的双重负担。


这篇主要介绍一下如何实现一个可视化的表达式的定义和执行。


表达式的定义


在上面说到的使用场景中,可以了解中至少需要支持布尔表达式。比如



  1. 芝麻分 > 650

  2. 居住地 不在 国外

  3. 年龄在 18 到 60 之间

  4. 名下无其他逾期借款


...


在上面的例子中,可以将一个表达式分为 3 个部分



  1. 规则参数 (ruleParam)

  2. 对应的操作 (operator)

  3. 对应操作的阈值 (args)


则可以将上面的布尔表达式表示为



  1. 芝麻分 > 650


{
"ruleParam": "芝麻分",
"operator": "大于",
"args": ["650"]
}


  1. 居住地 不在 国外


{
"ruleParam": "居住地",
"operator": "位于",
"args": ["国内"]
}


  1. 年龄在 18 到 60 之间


{
"ruleParam": "年龄",
"operator": "区间",
"args": ["18""60"]
}


  1. 名下无其他逾期借款


{
"ruleParam": "在途逾期数量",
"operator": "等于",
"args": ["0"]
}

表达式执行


上面的通过将表达式使用 json 格式定义出来,下面就是如何在运行中动态的解析这个 json 格式并执行。


有了 json 格式,可以通过以下方式来执行对应的表达式



  1. 因为表达式的结构已经定义好了,可以通过手写代码来判断所有的情况实现解释执行, 这种方案简单,但增加操作需要修改对应的解释的逻辑, 且性能低


/*
{
"ruleParam": "在途逾期数量",
"operator": "等于",
"args": ["0"]
}
*/

switch(operator) {
case "等于":
// 等于操作
break;
case "大于":
// 等于操作
break;
...
}



  1. 在第一次得到 json 字符串的时候,直接将其根据不同的情况生成对应的 java 代码,并动态编译成 Java Class,方便下一次执行,该方案依然需要处理各种情况,但因为在第一次编译成了 java 代码,性能和直接编写 java 代码一样




  2. 使用第三方库实现表达式的执行




使用第三方库实现动态表达式的执行


在 Java 中有很多表达式引擎,常见的有



  1. jexl3

  2. mvel

  3. spring-expression

  4. QLExpress

  5. groovy

  6. aviator

  7. ognl

  8. fel

  9. jsel


这里简单介绍一下 jexl3 和 aviator 的使用


jexl3 在 apache commons-jexl3 中,该表达式引擎比较符合人的书写习惯,其会判断操作的类型,并将参数转换成对应的类型比如 3 > 4 和 "3" > 4 这两个的执行结果是一样的


aviator 是一个高性能的 Java 的表达式类型,其要求确定参数的类型,比如上面的 "3" > 4 在 aviator 是无法执行的。


jexl3 更适合让运营手动编写的情况,能容忍一些错误情况;aviator 适合开发来使用,使用确定的类型参数来提供性能


jexl3 使用


加入依赖


<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-jexl3</artifactId>
<version>3.2.1</version>
</dependency>

// 创建一个带有缓存 jexl 表达式引擎,
JexlEngine JEXL = new JexlBuilder().cache(1000).strict(true).create();

// 根据表达式字符串来创建一个关于年龄的规则
JexlExpression ageExpression = JEXL.createExpression("age > 18 && age < 60");

// 获取需要的参数,java 代码太长了,简写一下
Map<String, Object> parameters parameters = {"age": 30}

// 执行一下
JexlContext jexlContext = new MapContext(parameters);

boolean result = (boolean) executeExpression.evaluate(jexlContext);

以上就会 jexl3 的简单使用


aviator


引入依赖


<dependency>
<groupId>com.googlecode.aviator</groupId>
<artifactId>aviator</artifactId>
<version>5.3.1</version>
</dependency>

Expression ageExpression = executeExpression = AviatorEvaluator.compile("age > 18 && age < 60");

// 获取需要的参数,java 代码太长了,简写一下
Map<String, Object> parameters parameters = {"age": 30}

boolean result = (boolean) ageExpression.execute(parameters);

注意 aviator 是强类型的,需要注意传入 age 的类型,如果 age 是字符串类型需要进行类型转换


性能测试


不同表达式引擎的性能测试


Benchmark                                         Mode  Cnt           Score           Error  Units
Empty thrpt 3 1265642062.921 ± 142133136.281 ops/s
Java thrpt 3 22225354.763 ± 12062844.831 ops/s
JavaClass thrpt 3 21878714.150 ± 2544279.558 ops/s
JavaDynamicClass thrpt 3 18911730.698 ± 30559558.758 ops/s
GroovyClass thrpt 3 10036761.622 ± 184778.709 ops/s
Aviator thrpt 3 2871064.474 ± 1292098.445 ops/s
Mvel thrpt 3 2400852.254 ± 12868.642 ops/s
JSEL thrpt 3 1570590.250 ± 24787.535 ops/s
Jexl thrpt 3 1121486.972 ± 76890.380 ops/s
OGNL thrpt 3 776457.762 ± 110618.929 ops/s
QLExpress thrpt 3 385962.847 ± 3031.776 ops/s
SpEL thrpt 3 245545.439 ± 11896.161 ops/s
Fel thrpt 3 21520.546 ± 16429.340 ops/s
GroovyScript thrpt 3 91.827 ± 106.860 ops/s

总结


这是写的规则引擎的第一篇,主要讲一下



  1. 如何讲一个布尔表达式转换为 json 格式的定义方便做可视化存储和后端校验

  2. 如何去执行一个 json 格式的表达式定义


在这里也提供了一些不同的表达式引擎和性能测试,如果感兴趣的可以去尝试一下。


下一篇主要讲一下在引擎里面规则参数、操作符是如何设计的,也讲一下可视化圆形的设计


作者:双鬼带单
来源:juejin.cn/post/7302805039450210313
收起阅读 »

技术大佬问我 订单消息重复消费了 怎么办?

技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了? 佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都被揉捏的像一个麻花了嘛 技术大佬 :哦,这次又是遇到什么难题了? 佩琪: 简历上的技术栈里,不是写了熟悉kafka中间件嘛,然后就被面试官炮轰了里面的细节 ...
继续阅读 »

技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了?


佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都被揉捏的像一个麻花了嘛


技术大佬 :哦,这次又是遇到什么难题了?


佩琪: 简历上的技术栈里,不是写了熟悉kafka中间件嘛,然后就被面试官炮轰了里面的细节


佩琪: 其中面试官给我印象深刻的一个问题是:你们的kafka消息里会有重复消息的情况吗?如果有,是怎么解决重复消息的了?


技术大佬 :哦,那你是怎么回答的了?


佩琪:我就是个crud boy,根本不知道啥是重复消息,所以就回答说,没有


技术大佬 :哦,真是个诚实的孩子;然后了?


佩琪:然后面试官就让我回家等通知了,然后就没有然后了。。。。


佩琪:对了大佬,只是简单的接个订单消息,为啥还会有重复的订单消息了?


技术大佬 :向上抬了抬眼睛,清了清嗓子,面露自信的微笑回答道:以我多年的经验,这里面大概有2个原因:一个是重复发送,一个是重复消费


写作 (6).png


佩琪哦哦,那重复发送是啥了?


技术大佬 :重试发送是从生产端保证消息不丢失,但不保证消息不会重发;业界还有个专业术语定义这个行为叫做"at least once"。在重试的时候,如果发送消息成功,在记录成功前程序崩了/或者因为网络问题,导致消息中间件存储了消息,但是调用方失败了,调用方为了保证消息不丢,会再次重发这个失败的消息。具体详情可参见上篇文章《kafka 消息“零丢失”的配方》


佩琪重复消费又是啥了?


技术大佬 :重复消费,是指消费端重复消费。举个例子吧, 比如我们一般在消费消息时,都建议处理完业务后,手工提交offset;但是在提交offset的时候,因为某些原因程序崩了。再次重启消费者应用后,会继续消费上次未提交的消息,像下面这段代码


  while(true){
consumer.poll(); // ①拉取消息
XXX // ② 业务处理;
consumer.commit(); //③提交消费位移
}

在第三步,提交位移的时候,程序突然崩了(jvm crash)或者网络闪断了;再次消费时,会拉取未提交的消息重复执行第二步的业务处理。


佩琪:哦哦,原来是这样。我就写了个消费者程序,咋这么多的“技术坑”在这里面。请问大佬,解决重复消费的底层逻辑是啥了?


技术大佬 : 两个字:幂等。 即相同的请求参数,请求1次,和请求100W次,得到的结果和对业务的影响是一样的。比如:同一个订单消息消费一次,然后进行积分累加;和同一个订单的消息重复消费10次,进行积分累加,最后效果是一样的,总积分不能变,不能变。 如果同一个订单消费了10次,积分也给累加了10次,这种就不叫幂等了。


佩琪:哦哦。那实现幂等的核心思想和通用做法又是什么了?


技术大佬 :其实也挺简单 存储+唯一key 。在进行业务处理时,查询下是否已处理过这个唯一key的消息;如果存在就不进行后续业务处理;如果不存在就继续后续业务的处理吧。


佩琪摸了摸头,唯一key是个啥了?


技术大佬 :唯一key是消息里业务数据的唯一标识; 比如对于某些业务场景是订单号;某些业务场景是订单号+订单状态;


佩琪存储又是指什么了?


技术大佬 :一般指的就是存储引擎了;比如业界常用的mysql,redis;或者不怎么常用的mongo,hbase等。


佩琪对了大佬,目前业界有哪些幂等解决方案?


技术大佬常用的大概有两种方式:强校验和弱校验


佩琪强校验是什么了?能具体说说吗?


技术大佬 :强校验其实是以数据库做为存储;唯一key的存储+业务逻辑的处理放入一个事务里;要么同时成功,要么同时失败。举个例子吧,比如接收到 用户订单支付消息后;根据订单号+状态,累加用户积分;先查询一把流水表,发现有这个订单的处理记录了,直接忽略对业务的处理;如果没有则进行业务的操作 ,然后把订单号和订单状态做为唯一key,插入流水表,最后做为一个整体的事务进行提交;


整体流程图如下:


写作 (4).png
待做


佩琪大佬好强。能具体说说你的这种方案有什么优缺点吗?


技术大佬 :给你一张图,你学习下?


优点缺点
在并发情况下,只会严格执行一次。数据库唯一性+事务回滚能保证业务只执行一次; 不会存在幂等校验穿透的问题处理速度较慢: 处理性能上和后续的redis方案比起来,慢一个数量级。毕竟有事务加持;另外插入唯一数据时极大可能读磁盘数据,进行唯一性校验
可提供查询流水功能:处理流水记录的持久化,在某些异常问题排查情况下,还能较为方便的提供查询记录历史数据需要额外进行清理:如果采用mysql进行存储,历史记录数据的清理,需要自己单独考虑和处理,但这应该不是个事儿
实现简单:实现难度还是较为简单的,一个注解能包裹住事务+回滚
适用资金类业务:非常适合涉及资金类业务的防重;毕竟涉及到钱,不把数据持久化和留痕,心理总是不踏实
架构上简约:架构上也简单,大多数业务系统都需要依赖数据库中间件吧。

佩琪: 果然是大佬,请收下我的打火机。


佩琪弱弱的问下,那弱校验是什么了?


技术大佬 :其实是用redis进行唯一key存储和防重。比如订单消息,订单号是一条数据的唯一标识吧。然后使用lua脚本设置该消息正在消费中;此时重复消息来,进行相同的设置,发现该订单号,已经被标识为正在处理中,那这条消息放入延时队列中,延时重试吧;如果发现已经消费成功,则直接返回,不执行业务了;业务执行完,设置该key执行成功。
大概过程是这样的
写作 (5).png


佩琪那用redis来进行防重,会存在什么问题吗?


技术大佬 : 可能会存在防重数据的丢失吧,最后带来防不了重。


佩琪redis为什么会丢防重数据了?


技术大佬 : 数据丢失一部分是因为redis自身的原因,因为它把数据放入了内存中;虽然提供了异步复制机制+高可用方案,但还是不能100%保证数据不丢失。


技术大佬 : 另外一个原因是,数据过期清理后,可能还有极低的概率处理相同的消息,此时就防不了重了。


佩琪那能不设置过期时间吗?


技术大佬 : 额,除非家里有矿。


技术大佬 : redis毕竟是用内存进行存储,存储容量比起硬盘来小很多,存储单位是G(硬盘线上存储单位是T开始),而且价格比起硬盘来又贵多个数量级;属于骄贵性存储;所以为了节约存储空间,一般都会设置一个较短的过期时间,进行数据的淘汰;而这个较短过期时间,是根据业务情况进行定义,有5分钟的,有10分钟的。


技术大佬 : 在说了,这些防重key是不太具备业务属性和高频率访问特性的,不属于热点数据,为啥还要一直放到缓存里了???


佩琪:果然是大佬,请再次收下我的打火机。用redis来做防重,缺点这么的多,那为什么还要用redis来进行防重了?


技术大佬 :你不觉得它的优点也很多吗。用它,主要是利用redis操作数据速度快,性能高;并且还能自动清理过期数据的特性,简直不要太方便;另外做防重的目标是啥了?还不是为了那些少数异常情况下产成的重复数据对其过滤;所以引入redis做防重,是为了大多数情况下,降低对业务性能的伤害;从而在性能和数据准确性中间取了个平衡。


技术大佬 : 建议对处理的及时性有一定要求,并且非资金类业务;比如消费下单消息,然后发送通知等业务使用吧。


技术大佬 :我知道你想要问什么了?我这里画了图,列了下优缺点,你拿去看看?


优点缺点
处理速度快因为数据有过期时间和redis自身特性;防重数据有丢失可能性,结果就是有不能防重的风险
无需自动清理唯一key记录实现上比起数据库,稍显复杂,需要写lua脚本;但学过编程的,相信我半天时间熟悉语法+写这个lua脚本应该是没问题的
消息一定能消费成功架构上稍显复杂,为了保证一定能消费成功,引入了延时队列

佩琪:嘿嘿大佬,我听说防重最好的是用布隆过滤器,占用空间小,速度很快,为啥不用布隆过滤器了?


技术大佬 :不使用redis布隆过滤器,主要是 redis 布隆过滤器特性会导致,某些消息会被漏掉。因为布隆过滤器底层逻辑是,校验一个key如果不存在,绝对不会存在;但是某个key如果存在,那么他是可能存在,又可能不存在。所以这会导致防重查询不准确,最终导致漏消息,这太不能接受了。


技术大佬 :还有个不算原因的原因,是redis 4.0之前的版本还都不支持布隆过滤器了。


佩琪大佬 redis我用过, redis 有个setnx,既能保证并发性,又能进行唯一key存储,你为啥不用了?


技术大佬 :不使用它,主要是redis的 setnx操作和后续的业务执行,不是一个事务单元;即可能setnx成功了,后续业务执行时进程崩溃了,然后在消息重试的时候,又发现setnx里有值了,最终会导致消费失败的消息重试时,会被过滤,造成消息丢失情况。所以才引入了redis lua+延时消息。在lua脚本里记录消费业务的执行状态,延时消息保证消息一定不会丢失。


佩琪我想偷个懒有现成的框架吗?


技术大佬 :有的。实现核心的幂等key的设置和校验lua脚本。



  1. lua代码如下:


local status = redis.call('get',KEYS[1]);
if status == nil //不存在,则redis放入唯一key和过期时间
then
redis.call('SETEX',KEYS[1],ARGV[1],1)
return "2" //设置成功
else //存在,返回处理状态
return status
end


  1. 消费者端的使用,伪代码如下


//调用lua脚本,获得处理状态
String key = null; //唯一id
int expiredTimeInSeconds = 10*60; //过期时间
String status = evalScript(key,expiredTimeInSeconds);

if(status.equals("2")){//设置成功,继续业务处理
//业务处理
}

if(status.equals("1")){ //已在处理中
//发送到延时队列吧
}

if(status.equals("3")){ //已处理成功
//什么都不做了
}


总结




  1. 生产端的重复发送和消费端的重复消费导致消息会重




  2. 解决消息重复消费的底层逻辑是幂等




  3. 实现幂等的核心思想是:唯一key+存储




  4. 有两种实现方式:基于数据库强校验和基于redis的弱校验。




感悟


太难了


为了保证上下游消息数据的完整性;引入了重试大法和手工提交offerSet等保证数据完整性解决手段;
可引入了这些解决手段后;又带来了数据重复的问题,数据重复的问题,是可以通过幂等来解决的。


太难了


作为应用层开发的crud boy的我,深深的叹了口气,开发的应用要在网络,主机,操作系统,中间件,开发人员写个bug等偶发性问题出现时,还需要保证上层应用数据的完整性和准确性。


此时佩琪头脑里突然闪现过一道灵光,业界有位大佬曾说过:“无论什么技术方案,都有好的一面,也有坏的一面。而且,每当引入一个新的技术方案解决一个已有的技术问题时,这个新的方案会带来更多的问题,问题就像一个生命体一样,它们会不断的繁殖和进化”。在消息防丢+防重的解决方案里,深感到这句话的哲理性。


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


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

支付宝二面:使用 try-catch 捕获异常会影响性能吗?大部分人都会答错!

文章来源:blog.csdn.net/bokerr/article/details/122655795# 前言不知道从何时起,传出了这么一句话:Java中使用try catch 会严重影响性能。然而,事实真的如此么?我们对try catch 应该畏之如猛虎么?...
继续阅读 »

文章来源:blog.csdn.net/bokerr/article/details/122655795


# 前言

不知道从何时起,传出了这么一句话:Java中使用try catch 会严重影响性能。

然而,事实真的如此么?我们对try catch 应该畏之如猛虎么?
# JVM 异常处理逻辑

Java 程序中显式抛出异常由athrow指令支持,除了通过 throw 主动抛出异常外,JVM规范中还规定了许多运行时异常会在检测到异常状况时自动抛出(效果等同athrow), 例如除数为0时就会自动抛出异常,以及大名鼎鼎的 NullPointerException 。

还需要注意的是,JVM 中 异常处理的catch语句不再由字节码指令来实现(很早之前通过 jsr和 ret指令来完成,它们在很早之前的版本里就被舍弃了),现在的JVM通过异常表(Exception table 方法体中能找到其内容)来完成 catch 语句;很多人说try catch 影响性能可能就是因为认识还停留于上古时代。

我们编写如下的类,add 方法中计算 ++x; 并捕获异常。
public class TestClass {    private static int len = 779;    public int add(int x){        try {            // 若运行时检测到 x = 0,那么 jvm会自动抛出异常,(可以理解成由jvm自己负责 athrow 指令调用)            x = 100/x;        } catch (Exception e) {            x = 100;        }        return x;    }}

使用javap 工具查看上述类的编译后的class文件
 # 编译 javac TestClass.java # 使用javap 查看 add 方法被编译后的机器指令 javap -verbose TestClass.class

忽略常量池等其他信息,下边贴出add 方法编译后的 机器指令集:
  public int add(int);    descriptor: (I)I    flags: ACC_PUBLIC    Code:      stack=2, locals=3, args_size=2         0: bipush        100   //  加载参数100         2: iload_1             //  将一个int型变量推至栈顶         3: idiv                //  相除         4: istore_1            //  除的结果值压入本地变量         5: goto          11    //  跳转到指令:11         8: astore_2            //  将引用类型值压入本地变量         9: bipush        100   //  将单字节常量推送栈顶<这里与数值100有关,可以尝试修改100后的编译结果:iconst、bipush、ldc>         10: istore_1            //  将int类型值压入本地变量        11: iload_1             //  int 型变量推栈顶        12: ireturn             //  返回      // 注意看 from  to 以及 targer,然后对照着去看上述指令      Exception table:         from    to  target type             0     5     8   Class java/lang/Exception      LineNumberTable:        line 6: 0        line 9: 5        line 7: 8        line 8: 9        line 10: 11      StackMapTable: number_of_entries = 2        frame_type = 72 /* same_locals_1_stack_item */          stack = [ class java/lang/Exception ]        frame_type = 2 /* same */


再来看 Exception table:


from=0, to=5。指令 0~5 对应的就是 try 语句包含的内容,而targer = 8 正好对应 catch 语句块内部操作。

个人理解,from 和 to 相当于划分区间,只要在这个区间内抛出了type 所对应的,“java/lang/Exception” 异常(主动athrow 或者 由jvm运行时检测到异常自动抛出),那么就跳转到target 所代表的第八行。

若执行过程中,没有异常,直接从第5条指令跳转到第11条指令后返回,由此可见未发生异常时,所谓的性能损耗几乎不存在;

如果硬是要说的话,用了try catch 编译后指令篇幅变长了;goto 语句跳转会耗费性能,当你写个数百行代码的方法的时候,编译出来成百上千条指令,这时候这句goto的带来的影响显得微乎其微。

如图所示为去掉try catch 后的指令篇幅,几乎等同上述指令的前五条。

综上所述:“Java中使用try catch 会严重影响性能” 是民间说法,它并不成立。如果不信,接着看下面的测试吧。

# 关于JVM的编译优化

其实写出测试用例并不是很难,这里我们需要重点考虑的是编译器的自动优化,是否会因此得到不同的测试结果?

本节会粗略的介绍一些jvm编译器相关的概念,讲它只为更精确的测试结果,通过它我们可以窥探 try catch 是否会影响JVM的编译优化。

前端编译与优化:我们最常见的前端编译器是 javac,它的优化更偏向于代码结构上的优化,它主要是为了提高程序员的编码效率,不怎么关注执行效率优化;例如,数据流和控制流分析、解语法糖等等。

后端编译与优化:后端编译包括 “即时编译[JIT]” 和 “提前编译[AOT]”,区别于前端编译器,它们最终作用体现于运行期,致力于优化从字节码生成本地机器码的过程(它们优化的是代码的执行效率)。

1. 分层编译

PS * JVM 自己根据宿主机决定自己的运行模式, “JVM 运行模式”;[客户端模式-Client、服务端模式-Server],它们代表的是两个不同的即时编译器,C1(Client Compiler) 和 C2 (Server Compiler)。

PS * 分层编译分为:“解释模式”、“编译模式”、“混合模式”;

解释模式下运行时,编译器不介入工作;

编译模式模式下运行,会使用即时编译器优化热点代码,有可选的即时编译器[C1 或 C2];

混合模式为:解释模式和编译模式搭配使用。

如图,我的环境里JVM 运行于 Server 模式,如果使用即时编译,那么就是使用的:C2 即时编译器。

2. 即时编译器

了解如下的几个 概念:

1. 解释模式

它不使用即时编译器进行后端优化

强制虚拟机运行于 “解释模式” -Xint

禁用后台编译 -XX:-BackgroundCompilation

2. 编译模式

即时编译器会在运行时,对生成的本地机器码进行优化,其中重点关照热点代码。
    # 强制虚拟机运行于 "编译模式"    -Xcomp    # 方法调用次数计数器阈值,它是基于计数器热点代码探测依据[Client模式=1500,Server模式=10000]    -XX:CompileThreshold=10    # 关闭方法调用次数热度衰减,使用方法调用计数的绝对值,它搭配上一配置项使用    -XX:-UseCounterDecay    # 除了热点方法,还有热点回边代码[循环],热点回边代码的阈值计算参考如下:    -XX:BackEdgeThreshold  = 方法计数器阈值[-XX:CompileThreshold] * OSR比率[-XX:OnStackReplacePercentage]    # OSR比率默认值:Client模式=933,Server模式=140    -XX:OnStackReplacePercentag=100

所谓 “即时”,它是在运行过程中发生的,所以它的缺点也也明显:在运行期间需要耗费资源去做性能分析,也不太适合在运行期间去大刀阔斧的去做一些耗费资源的重负载优化操作。

3. 提前编译器:jaotc

它是后端编译的另一个主角,它有两个发展路线,基于Graal [新时代的主角] 编译器开发,因为本文用的是 C2 编译器,所以只对它做一个了解;

第一条路线:与传统的C、C++编译做的事情类似,在程序运行之前就把程序代码编译成机器码;好处是够快,不占用运行时系统资源,缺点是"启动过程" 会很缓慢;

第二条路线:已知即时编译运行时做性能统计分析占用资源,那么,我们可以把其中一些耗费资源的编译工作,放到提前编译阶段来完成啊,最后在运行时即时编译器再去使用,那么可以大大节省即时编译的开销;这个分支可以把它看作是即时编译缓存;

遗憾的是它只支持 G1 或者 Parallel 垃圾收集器,且只存在JDK 9 以后的版本,暂不需要去关注它;JDK 9 以后的版本可以使用这个参数打印相关信息:[-XX:PrintAOT]。

# 关于测试的约束

执行用时统计

System.naoTime() 输出的是过了多少时间[微秒:10的负9次方秒],并不是完全精确的方法执行用时的合计,为了保证结果准确性,测试的运算次数将拉长到百万甚至千万次。

编译器优化的因素

上一节花了一定的篇幅介绍编译器优化,这里我要做的是:对比完全不使用任何编译优化,与使用即时编译时,try catch 对的性能影响。

通过指令禁用 JVM 的编译优化,让它以最原始的状态运行,然后看有无 try catch 的影响。

通过指令使用即时编译,尽量做到把后端优化拉满,看看 try catch 十有会影响到 jvm的编译优化。

关于指令重排序

目前尚未可知 try catch 的使用影响指令重排序;

我们这里的讨论有一个前提,当 try catch 的使用无法避免时,我们应该如何使用 try catch 以应对它可能存在的对指令重排序的影响。

指令重排序发生在多线程并发场景,这么做是为了更好的利用CPU资源,在单线程测试时不需要考虑。不论如何指令重排序,都会保证最终执行结果,与单线程下的执行结果相同;

虽然我们不去测试它,但是也可以进行一些推断,参考 volatile 关键字禁止指令重排序的做法:插入内存屏障;

假定 try catch 存在屏障,导致前后的代码分割;那么最少的try catch代表最少的分割。

所以,是不是会有这样的结论呢:我们把方法体内的 多个 try catch 合并为一个 try catch 是不是反而能减少屏障呢?这么做势必造成 try catch 的范围变大。

当然,上述关于指令重排序讨论内容都是基于个人的猜想,犹未可知 try catch 是否影响指令重排序;本文重点讨论的也只是单线程环境下的 try catch 使用影响性能。


# 测试代码

循环次数为100W ,循环内10次预算[给编译器优化预留优化的可能,这些指令可能被合并];

每个方法都会到达千万次浮点计算。

同样每个方法外层再循环跑多次,最后取其中的众数更有说服力。
public class ExecuteTryCatch {    // 100W     private static final int TIMES = 1000000;    private static final float STEP_NUM = 1f;    private static final float START_NUM = Float.MIN_VALUE;    public static void main(String[] args){        int times = 50;        ExecuteTryCatch executeTryCatch = new ExecuteTryCatch();        // 每个方法执行 50 次        while (--times >= 0){            System.out.println("times=".concat(String.valueOf(times)));            executeTryCatch.executeMillionsEveryTryWithFinally();            executeTryCatch.executeMillionsEveryTry();            executeTryCatch.executeMillionsOneTry();            executeTryCatch.executeMillionsNoneTry();            executeTryCatch.executeMillionsTestReOrder();        }    }    /**     * 千万次浮点运算不使用 try catch     * */    public void executeMillionsNoneTry(){        float num = START_NUM;        long start = System.nanoTime();        for (int i = 0; i < TIMES; ++i){            num = num + STEP_NUM + 1f;            num = num + STEP_NUM + 2f;            num = num + STEP_NUM + 3f;            num = num + STEP_NUM + 4f;            num = num + STEP_NUM + 5f;            num = num + STEP_NUM + 1f;            num = num + STEP_NUM + 2f;            num = num + STEP_NUM + 3f;            num = num + STEP_NUM + 4f;            num = num + STEP_NUM + 5f;        }        long nao = System.nanoTime() - start;        long million = nao / 1000000;        System.out.println("noneTry   sum:" + num + "  million:" + million + "  nao: " + nao);    }    /**     * 千万次浮点运算最外层使用 try catch     * */    public void executeMillionsOneTry(){        float num = START_NUM;        long start = System.nanoTime();        try {            for (int i = 0; i < TIMES; ++i){                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;            }        } catch (Exception e){        }        long nao = System.nanoTime() - start;        long million = nao / 1000000;        System.out.println("oneTry    sum:" + num + "  million:" + million + "  nao: " + nao);    }    /**     * 千万次浮点运算循环内使用 try catch     * */    public void executeMillionsEveryTry(){        float num = START_NUM;        long start = System.nanoTime();        for (int i = 0; i < TIMES; ++i){            try {                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;            } catch (Exception e) {            }        }        long nao = System.nanoTime() - start;        long million = nao / 1000000;        System.out.println("evertTry  sum:" + num + "  million:" + million + "  nao: " + nao);    }    /**     * 千万次浮点运算循环内使用 try catch,并使用 finally     * */    public void executeMillionsEveryTryWithFinally(){        float num = START_NUM;        long start = System.nanoTime();        for (int i = 0; i < TIMES; ++i){            try {                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;            } catch (Exception e) {            } finally {                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;            }        }        long nao = System.nanoTime() - start;        long million = nao / 1000000;        System.out.println("finalTry  sum:" + num + "  million:" + million + "  nao: " + nao);    }    /**     * 千万次浮点运算,循环内使用多个 try catch     * */    public void executeMillionsTestReOrder(){        float num = START_NUM;        long start = System.nanoTime();        for (int i = 0; i < TIMES; ++i){            try {                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;            } catch (Exception e) { }            try {                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;            } catch (Exception e){}            try {                num = num + STEP_NUM + 1f;                num = num + STEP_NUM + 2f;            } catch (Exception e) { }            try {                num = num + STEP_NUM + 3f;                num = num + STEP_NUM + 4f;                num = num + STEP_NUM + 5f;            } catch (Exception e) {}        }        long nao = System.nanoTime() - start;        long million = nao / 1000000;        System.out.println("orderTry  sum:" + num + "  million:" + million + "  nao: " + nao);    }}

# 解释模式下执行测试

设置如下JVM参数,禁用编译优化
  -Xint   -XX:-BackgroundCompilation

结合测试代码发现,即使百万次循环计算,每个循环内都使用了 try catch 也并没用对造成很大的影响。

唯一发现了一个问题,每个循环内都是使用 try catch 且使用多次。发现性能下降,千万次计算差值为:5~7 毫秒;4个 try 那么执行的指令最少4条goto ,前边阐述过,这里造成这个差异的主要原因是 goto 指令占比过大,放大了问题;当我们在几百行代码里使用少量try catch 时,goto所占比重就会很低,测试结果会更趋于合理。

# 编译模式测试

设置如下测试参数,执行10 次即为热点代码
-Xcomp -XX:CompileThreshold=10 -XX:-UseCounterDecay -XX:OnStackReplacePercentage=100 -XX:InterpreterProfilePercentage=33
执行结果如下图,难分胜负,波动只在微秒级别,执行速度也快了很多,编译效果拔群啊,甚至连 “解释模式” 运行时多个try catch 导致的,多个goto跳转带来的问题都给顺带优化了;由此也可以得到 try catch 并不会影响即时编译的结论。


我们可以再上升到亿级计算,依旧难分胜负,波动在毫秒级。

# 结论

try catch 不会造成巨大的性能影响,换句话说,我们平时写代码最优先考虑的是程序的健壮性,当然大佬们肯定都知道了怎么合理使用try catch了,但是对萌新来说,你如果不确定,那么你可以使用 try catch;

在未发生异常时,给代码外部包上 try catch,并不会造成影响。

举个栗子吧,我的代码中使用了:URLDecoder.decode,所以必须得捕获异常。
private int getThenAddNoJudge(JSONObject json, String key){        if (Objects.isNull(json))            throw new IllegalArgumentException("参数异常");        int num;        try {            // 不校验 key 是否未空值,直接调用 toString 每次触发空指针异常并被捕获            num = 100 + Integer.parseInt(URLDecoder.decode(json.get(key).toString(), "UTF-8"));        } catch (Exception e){            num = 100;        }        return num;    }    private int getThenAddWithJudge(JSONObject json, String key){        if (Objects.isNull(json))            throw new IllegalArgumentException("参数异常");        int num;        try {            // 校验 key 是否未空值            num = 100 + Integer.parseInt(URLDecoder.decode(Objects.toString(json.get(key), "0"), "UTF-8"));        } catch (Exception e){            num = 100;        }        return num;    }    public static void main(String[] args){        int times = 1000000;// 百万次        long nao1 = System.nanoTime();        ExecuteTryCatch executeTryCatch = new ExecuteTryCatch();        for (int i = 0; i < times; i++){            executeTryCatch.getThenAddWithJudge(new JSONObject(), "anyKey");        }        long end1 = System.nanoTime();        System.out.println("未抛出异常耗时:millions=" + (end1 - nao1) / 1000000 + "毫秒  nao=" + (end1 - nao1) + "微秒");        long nao2 = System.nanoTime();        for (int i = 0; i < times; i++){            executeTryCatch.getThenAddNoJudge(new JSONObject(), "anyKey");        }        long end2 = System.nanoTime();        System.out.println("每次必抛出异常:millions=" + (end2 - nao2) / 1000000 + "毫秒  nao=" + (end2 - nao2) + "微秒");    }
调用方法百万次,执行结果如下:


经过这个例子,我想你知道你该如何 编写你的代码了吧?可怕的不是 try catch 而是 搬砖业务不熟练啊。

收起阅读 »

新项目为什么决定用 JDK 17了

最近在调研 JDK 17,并且试着将之前的一个小项目升级了一下,在测试环境跑了一段时间。最终,决定了,新项目要采用 JDK 17 了。 JDK 1.8:“不是说好了,他发任他发,你用 Java 8 吗?” 不光是我呀,连 Spring Boot 都开始要拥护 ...
继续阅读 »

最近在调研 JDK 17,并且试着将之前的一个小项目升级了一下,在测试环境跑了一段时间。最终,决定了,新项目要采用 JDK 17 了。


JDK 1.8:“不是说好了,他发任他发,你用 Java 8 吗?”


不光是我呀,连 Spring Boot 都开始要拥护 JDK 17了,下面这一段是 Spring Boot 3.0 的更新日志。



Spring Boot 3.0 requires Java 17 as a minimum version. If you are currently using Java 8 or Java 11, you'll need to upgrade your JDK before you can develop Spring Boot 3.0 applications.



Spring Boot 3.0 需要 JDK 的最低版本就是 JDK 17,如果你想用 Spring Boot 开发应用,你需要将正在使用的 Java 8 或 Java 11升级到 Java 17。


选用 Java 17,概括起来主要有下面几个主要原因:


1、JDK 17 是 LTS (长期支持版),可以免费商用到 2029 年。而且将前面几个过渡版(JDK 9-JDK 16)去其糟粕,取其精华的版本;


2、JDK 17 性能提升不少,比如重写了底层 NIO,至少提升 10% 起步;


3、大多数第三方框架和库都已经支持,不会有什么大坑;


4、准备好了,来吧。


拿几个比较好玩儿的特性来说一下 JDK 17 对比 JDK 8 的改进。


密封类


密封类应用在接口或类上,对接口或类进行继承或实现的约束,约束哪些类型可以继承、实现。例如我们的项目中有个基础服务包,里面有一个父类,但是介于安全性考虑,值允许项目中的某些微服务模块继承使用,就可以用密封类了。


没有密封类之前呢,可以用 final关键字约束,但是这样一来,被修饰的类就变成完全封闭的状态了,所有类都没办法继承。


密封类用关键字 sealed修饰,并且在声明末尾用 permits表示要开放给哪些类型。


下面声明了一个叫做 SealedPlayer的密封类,然后用关键字 permits将集成权限开放给了 MarryPlayer类。


public sealed class SealedPlayer permits MarryPlayer {
public void play() {
System.out.println("玩儿吧");
}
}

之后 MarryPlayer 就可以继承 SealedPlayer了。


public non-sealed class MarryPlayer extends SealedPlayer{
@Override
public void play() {
System.out.println("不想玩儿了");
}
}

继承类也要加上密封限制。比如这个例子中是用的 non-sealed,表示不限制,任何类都可以继承,还可以是 sealed,或者 final


如果不是 permits 允许的类型,则没办法继承,比如下面这个,编译不过去,会给出提示 "java: 类不得扩展密封类:org.jdk17.SealedPlayer(因为它未列在其 'permits' 子句中)"


public non-sealed class TomPlayer extends SealedPlayer {

@Override
public void play() {

}
}

空指针异常


String s = null;
String s1 = s.toLowerCase();

JDK1.8 的版本下运行:


Exception in thread "main" java.lang.NullPointerException
at org.jdk8.App.main(App.java:10)

JDK17的版本(确切的说是14及以上版本)


Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toLowerCase()" because "s" is null
at org.jdk17.App.main(App.java:14)

出现异常的具体方法和原因都一目了然。如果你的一行代码中有多个方法、多个变量,可以快速定位问题所在,如果是 JDK1.8,有些情况下真的不太容易看出来。


yield关键字


public static int calc(int a,String operation){
var result = switch (operation) {
case "+" -> {
yield a + a;
}
case "*" -> {
yield a * a;
}
default -> a;
};
return result;
}

换行文本块


如果你用过 Python,一定知道Python 可以用 'hello world'"hello world"''' hello world '''""" hello world """ 四种方式表示一个字符串,其中后两种是可以直接支持换行的。


在 JDK 1.8 中,如果想声明一个字符串,如果字符串是带有格式的,比如回车、单引号、双引号,就只能用转义符号,例如下面这样的 JSON 字符串。


String json = "{\n" +
" \"name\": \"古时的风筝\",\n" +
" \"age\": 18\n" +
"}";

从 JDK 13开始,也像 Python 那样,支持三引号字符串了,所以再有上面的 JSON 字符串的时候,就可以直接这样声明了。


String json = """
{
"
name": "古时的风筝",
"
age": 18
}
"
"";

record记录类


类似于 Lombok 。


传统的Java应用程序通过创建一个类,通过该类的构造方法实例化类,并通过getter和setter方法访问成员变量或者设置成员变量的值。有了record关键字,你的代码会变得更加简洁。


之前声明一个实体类。


public class User {
private String name;

public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

使用 Record类之后,就像下面这样。


public record User(String name) {

}

调用的时候像下面这样


RecordUser recordUser = new RecordUser("古时的风筝");
System.out.println(recordUser.name());
System.out.println(recordUser.toString());

输出结果



Record 类更像是一个实体类,直接将构造方法加在类上,并且自动给字段加上了 getter 和 setter。如果一直在用 Lombok 或者觉得还是显式的写上 getter 和 setter 更清晰的话,完全可以不用它。


G1 垃圾收集器


JDK8可以启用G1作为垃圾收集器,JDK9到 JDK 17,G1 垃圾收集器是默认的垃圾收集器,G1是兼顾老年代和年轻代的收集器,并且其内存模型和其他垃圾收集器是不一样的。


G1垃圾收集器在大多数场景下,其性能都好于之前的垃圾收集器,比如CMS。


ZGC


从 JDk 15 开始正式启用 ZGC,并且在 JDK 16后对 ZGC 进行了增强,控制 stop the world 时间不超过10毫秒。但是默认的垃圾收集器仍然是 G1。


配置下面的参数来启用 ZGC 。


-XX:+UseZGC

可以用下面的方法查看当前所用的垃圾收集器


JDK 1.8 的方法


jmap -heap 8877

JDK 1.8以上的版本


jhsdb jmap --heap --pid 8877

例如下面的程序采用 ZGC 垃圾收集器。



其他一些小功能


1、支持 List.of()、Set.of()、Map.of()和Map.ofEntries()等工厂方法实例化对象;


2、Stream API 有一些改进,比如 .collect(Collectors.toList())可以直接写成 .toList()了,还增加了 Collectors.teeing(),这个挺好玩,有兴趣可以看一下;


3、HttpClient重写了,支持 HTTP2.0,不用再因为嫌弃 HttpClient 而使用第三方网络框架了,比如OKHTTP;


升级 JDK 和 IDEA


安装 JDK 17,这个其实不用说,只是推荐一个网站,这个网站可以下载各种系统、各种版本的 JDK 。地址是 adoptium.net/


还有,如果你想在 IDEA 上使用 JDK 17,可能要升级一下了,只有在 2021.02版本之后才支持 JDK 17。



作者:古时的风筝
来源:juejin.cn/post/7177550894316126269
收起阅读 »

女朋友要我讲解@Controller注解的原理,真是难为我了

背景 女朋友被公司裁员一个月了,和我一样作为后端工程师,最近一直在找工作,面试了很多家还是没有找到工作,面试官问@Controller的原理,她表示一脸懵,希望我能给她讲清楚。之前我也没有好好整理这块知识,这次借助这个机会把它彻底搞清楚。 我们知道Contr...
继续阅读 »

背景


女朋友被公司裁员一个月了,和我一样作为后端工程师,最近一直在找工作,面试了很多家还是没有找到工作,面试官问@Controller的原理,她表示一脸懵,希望我能给她讲清楚。之前我也没有好好整理这块知识,这次借助这个机会把它彻底搞清楚。
太难了.jpeg


我们知道Controller注解的类能够实现接收并处理Http请求,其实在我看Spring mvc模块的源码之前也和我女朋友目前的状态一样,很疑惑,Spring框架是底层是如何实现的,通过使用Controller注解就简单的完成了http请求的接收与处理。


image.png


有疑问就好啊,因为兴趣是最好的老师,如果有兴趣才有动力去弄懂这个技术点。


看过前面的文章的同学就会知道,学习Spring的所有组件,脑袋里要有一个思路,那就是解析组件和运用组件两个流程,这是Spring团队实现组件的统一套路,大家可以回忆一下是不是这么回事。


image.png


一、Spring解析Controller注解


首先我们看看Spring是如何解析Controller注解的,打开源码看看他长啥样??


@Target({ElementType.TYPE})
@Component
public @interface Controller {
String value() default "";
}

发现Controller注解打上了Component的注解,这样Spring做类扫描的时候,发现了@Controller标记的类也会当作Bean解析并注册到Spring容器。
我们可以看到Spring的类扫描器,第一个就注册了Component注解的扫描


//org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider
protected void registerDefaultFilters() {
this.includeFilters.add(new AnnotationTypeFilter(Component.class));
}

这样Spring容器启动完成之后,bean容器中就有了被Controller注解标记的bean实例了。
到这里只是单纯的把Controller标注的类实例化注册到Spring容器,和Http请求接收处理没半毛钱关系,那么他们是怎么关联起来的呢?


二、Spring解析Controller注解标注的类方法


这个时候Springmvc组件中的另外一个组件就闪亮登场了



RequestMappingHandlerMapping



RequestMappingHandlerMapping 看这个名就可以知道他的意思,请求映射处理映射器。
这里就是重点了,该类间接实现了InitializingBean方法,bean初始化后执行回调afterPropertiesSet方法,里面调用initHandlerMethods方法进行初始化handlermapping。



//类有没有加Controller的注解
protected boolean isHandler(Class<?> beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}

protected void initHandlerMethods() {
//所有的bean
String[] beanNames= applicationContext().getBeanNamesForType(Object.class);

for (String beanName : beanNames) {
Class<?> beanType = obtainApplicationContext().getType(beanName);
//有Controller注解的bean
if (beanType != null && isHandler(beanType)) {
detectHandlerMethods(beanName);
}
}
handlerMethodsInitialized(getHandlerMethods());
}

这里把标注了Controller注解的实例全部找到了,然后调用detectHandlerMethods方法,检测handler方法,也就是解析Controller标注类的方法。



private final Map<T, MappingRegistration<T>> registry = new HashMap<>();

protected void detectHandlerMethods(final Object handler) {
Class<?> handlerType = (handler instanceof String ?
obtainApplicationContext().getType((String) handler) : handler.getClass());

if (handlerType != null) {
final Class<?> userType = ClassUtils.getUserClass(handlerType);
//查找Controller的方法
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<T>) method -> getMappingForMethod(method, userType));

methods.forEach((method, mapping) -> {
//注册
this.registry.put(mapping,new MappingRegistration<>(mapping,method));

});
}


到这里为止,Spring将Controller标注的类和类方法已经解析完成。现在再来看RequestMappingHandlerMapping这个类的作用,他就是用来注册所有Controller类的方法。


三、Spring调用Controller注解标注的方法


接着还有一个重要的组件RequestMappingHandlerAdapter
它就是用来调用我们写的Controller方法,完成请求处理的流程。
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter


@Override
public boolean supports(Object handler) {
return handler instanceof HandlerMethod;
}

protected ModelAndView handleInternal(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod)
throws Exception {
//请求check
checkRequest(request);
//调用handler方法
mav = invokeHandlerMethod(request, response, handlerMethod);
//返回
return mav;
}

看到这里,就知道http请求是如何被处理的了,我们找到DispatcherServlet的doDispatch方法看看,确实是如此!!


四、DispatcherServlet调度Controller方法完成http请求


protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 从注册表查找handler
HandlerExecutionChain mappedHandler = getHandler(request);
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 底层调用Controller
ModelAndView m = ha.handle(processedRequest, response, mappedHandler.getHandler());
// 处理请求结果
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}

DispatcherServlet是Spring mvc的总入口,看到doDispatch方法后,全部都联系起来了。。。
最后我们看看http请求在Spring mvc中的流转流程。


image.png


第一次总结SpringMvc模块,理解不到位的麻烦各位大佬指正。


作者:服务端技术栈
来源:juejin.cn/post/7222186286564311095
收起阅读 »

当你穿越到道诡异仙的世界,如何利用密码学知识区分幻想和现实?

《道诡异仙》是一部流行的网络小说。 其中,剧情讲述了男主角李火旺穿越到诡异世界,但意识时不时会回到原来的现代社会中。两个世界时不时交错,男主角陷入到了混乱当中,一直在疑惑到底哪边世界是真实的,也因此发展出了精彩的故事。 那么,作为一个程序员,如果面临这样的处境...
继续阅读 »

《道诡异仙》是一部流行的网络小说。


其中,剧情讲述了男主角李火旺穿越到诡异世界,但意识时不时会回到原来的现代社会中。两个世界时不时交错,男主角陷入到了混乱当中,一直在疑惑到底哪边世界是真实的,也因此发展出了精彩的故事。


那么,作为一个程序员,如果面临这样的处境,有没有办法利用专业知识区分世界是否是真实的呢?


其实不论什么样的异世界,数学始终不变,我们可以利用密码学背后的数学原理,来检查一个世界是否是真实世界。


在剧情中,男主角李火旺一直怀疑他所处的“现代世界”是幻觉,那么,我们很容易想到,幻觉没办法伪造算力,只要我们构造一个需要一定算力的数学问题,再交给“现代世界”的女主角杨娜去找计算机计算就可以了。


但是考虑到书中"诡异世界"并没有关于计算的神通,其数学发展水平也有限,所以我们构造出的问题应该是难以计算,但是又易于检验的。这样的问题与密码学所需的数学原理非常相似,我们可以利用一个简单的事实:



计算两个大质数的乘积非常简单,但是把两个大质数的乘积质因数分解却非常困难。



所以我们可以设计这样一个方案:



  1. 首先教会"诡异世界”一侧的女主角白灵淼学会基本算术(只要到整数乘法就可以了)。接下来,指挥白灵淼生成两个大质数,并且把它们的乘积告诉男主。

  2. 待男主穿越回“现代世界”,把这个乘积告诉"现代世界"女主角杨娜,请她去找计算机计算它的质因数分解,之后再告诉男主。

  3. 男主回到诡异世界,检查"现代世界"给出的质因数分解结果是否正确,如果正确,那么"现代世界"必定是真实的。


那么,如何在基础算术之内,生成较大的质数呢?我们可以利用费马小定理:



如果p是一个质数,而整数a不是p的倍数,则a^(p-1) 除以p余1 。



实际上,取a为偶数,ap1×p+1a^{p-1} \times p+1在多数情况下都是质数。在不那么严格的情况下,我们完全可以把这些伪质数当作质数来使用。


针对验证世界是否存在算力的场景,我们只需要选择两个大约几十万的整数就可以了,比如:


12515+1=1036816717+1=32659312^{5-1} * 5+1 = 103681\\
6^{7-1} * 7+1 = 326593

如果怕踩到坑,可以拿一些小质数试验一下。


之后我们计算它们的乘积,得到了 3386148883333861488833


这些计算量稍微有点大,但是应该还在小白的能力范围内,最多花上一个小时,足够完成计算了。


注意,为了防止幻觉作弊,小白只告诉李火旺最终的乘积,不需要告诉李火旺两个质因数。


接下来,让我们的主角回到"现代世界",把3386148883333861488833交给"现代世界"女主角杨娜,要求她找计算机和程序员对33861488833做因式分解。


接下来杨娜大约要花一点钱,比如她找到了winter,因式分解的代码这样写:


let p = new Array(Math.ceil(Math.sqrt(33861488833))).fill(1)

p[0] = 0;
p[1] = 0;
for(let i = 2; i < p.length; i++) {
if(i === 0)
continue;
if(33861488833 % i === 0)
console.log(i);
for(let j = i * 2; j < p.length; j += i)
p[j] = 0;
}

//运行结果:103681

用计算机计算这个循环只需要几秒,但是如果是人肉计算,这个工作量几乎是不可完成的。


幻觉再怎么厉害,也不可能帮助李火旺超越数学,算出这个因式分解的结果。


如果在"现代世界"中,算出了正确的因式分解结果,因为李火旺本人并不知道质因数,所以可以确定不可能是李火旺的幻觉。


这样就可以验证"现代世界"的真实性了。


换句话说,即使"现代世界"是幻觉,那也是一个有巨大算力的幻觉系统,那么《道诡异仙》的故事可能就变成另一种风格了。


作者:winter
来源:juejin.cn/post/7250718023815528485
收起阅读 »

实现异步编程,这个工具类你得掌握!

前言 最近看公司代码,多线程编程用的比较多,其中有对CompletableFuture的使用,所以想写篇文章总结下 在日常的Java8项目开发中,CompletableFuture是很强大的并行开发工具,其语法贴近java8的语法风格,与stream一起使用也...
继续阅读 »

前言


最近看公司代码,多线程编程用的比较多,其中有对CompletableFuture的使用,所以想写篇文章总结下


在日常的Java8项目开发中,CompletableFuture是很强大的并行开发工具,其语法贴近java8的语法风格,与stream一起使用也能大大增加代码的简洁性


大家可以多应用到工作中,提升接口性能,优化代码!


觉得有收获,希望帮忙点赞,转发下哈,谢谢,谢谢


基本介绍


CompletableFuture是Java 8新增的一个类,用于异步编程,继承了Future和CompletionStage


这个Future主要具备对请求结果独立处理的功能,CompletionStage用于实现流式处理,实现异步请求的各个阶段组合或链式处理,因此completableFuture能实现整个异步调用接口的扁平化和流式处理,解决原有Future处理一系列链式异步请求时的复杂编码


图片


Future的局限性


1、Future 的结果在非阻塞的情况下,不能执行更进一步的操作


我们知道,使用Future时只能通过isDone()方法判断任务是否完成,或者通过get()方法阻塞线程等待结果返回,它不能非阻塞的情况下,执行更进一步的操作。


2、不能组合多个Future的结果


假设你有多个Future异步任务,你希望最快的任务执行完时,或者所有任务都执行完后,进行一些其他操作


3、多个Future不能组成链式调用


当异步任务之间有依赖关系时,Future不能将一个任务的结果传给另一个异步任务,多个Future无法创建链式的工作流。


4、没有异常处理


现在使用CompletableFuture能帮助我们完成上面的事情,让我们编写更强大、更优雅的异步程序


基本使用


创建异步任务


通常可以使用下面几个CompletableFuture的静态方法创建一个异步任务


public static CompletableFuture runAsync(Runnable runnable);              //创建无返回值的异步任务
public static CompletableFuture runAsync(Runnable runnable, Executor executor);     //无返回值,可指定线程池(默认使用ForkJoinPool.commonPool)
public static CompletableFuture supplyAsync(Supplier supplier);           //创建有返回值的异步任务
public static CompletableFuture supplyAsync(Supplier supplier, Executor executor); //有返回值,可指定线程池


使用示例:



Executor executor = Executors.newFixedThreadPool(10);
CompletableFuture future = CompletableFuture.runAsync(() -> {
   //do something
}, executor);
int poiId = 111;
CompletableFuture future = CompletableFuture.supplyAsync(() -> {
PoiDTO poi = poiService.loadById(poiId);
 return poi.getName();
});
// Block and get the result of the Future
String poiName = future.get();

使用回调方法


通过future.get()方法获取异步任务的结果,还是会阻塞的等待任务完成


CompletableFuture提供了几个回调方法,可以不阻塞主线程,在异步任务完成后自动执行回调方法中的代码


public CompletableFuture thenRun(Runnable runnable);            //无参数、无返回值
public CompletableFuture thenAccept(Consumersuper T> action);         //接受参数,无返回值
public CompletableFuture thenApply(Functionsuper T,? extends U> fn); //接受参数T,有返回值U


使用示例:



CompletableFuture future = CompletableFuture.supplyAsync(() -> "Hello")
                          .thenRun(() -> System.out.println("do other things. 比如异步打印日志或发送消息"));
//如果只想在一个CompletableFuture任务执行完后,进行一些后续的处理,不需要返回值,那么可以用thenRun回调方法来完成。
//如果主线程不依赖thenRun中的代码执行完成,也不需要使用get()方法阻塞主线程。
CompletableFuture future = CompletableFuture.supplyAsync(() -> "Hello")
                          .thenAccept((s) -> System.out.println(s + " world"));
//输出:Hello world
//回调方法希望使用异步任务的结果,并不需要返回值,那么可以使用thenAccept方法
CompletableFuture future = CompletableFuture.supplyAsync(() -> {
 PoiDTO poi = poiService.loadById(poiId);
 return poi.getMainCategory();
}).thenApply((s) -> isMainPoi(s));   // boolean isMainPoi(int poiId);

future.get();
//希望将异步任务的结果做进一步处理,并需要返回值,则使用thenApply方法。
//如果主线程要获取回调方法的返回,还是要用get()方法阻塞得到

组合两个异步任务


//thenCompose方法中的异步任务依赖调用该方法的异步任务
public CompletableFuture thenCompose(Functionsuper T, ? extends CompletionStage> fn);
//用于两个独立的异步任务都完成的时候
public CompletableFuture thenCombine(CompletionStage other,
                                             BiFunctionsuper
T,? super U,? extends V> fn);


使用示例:



CompletableFuture> poiFuture = CompletableFuture.supplyAsync(
() -> poiService.queryPoiIds(cityId, poiId)
);
//第二个任务是返回CompletableFuture的异步方法
CompletableFuture> getDeal(List poiIds){
 return CompletableFuture.supplyAsync(() ->  poiService.queryPoiIds(poiIds));
}
//thenCompose
CompletableFuture> resultFuture = poiFuture.thenCompose(poiIds -> getDeal(poiIds));
resultFuture.get();

thenCompose和thenApply的功能类似,两者区别在于thenCompose接受一个返回CompletableFuture的Function,当想从回调方法返回的CompletableFuture中直接获取结果U时,就用thenCompose


如果使用thenApply,返回结果resultFuture的类型是CompletableFuture>>,而不是CompletableFuture>


CompletableFuture future = CompletableFuture.supplyAsync(() -> "Hello")
.thenCombine(CompletableFuture.supplyAsync(() -> "world"), (s1, s2) -> s1 + s2);
//future.get()

组合多个CompletableFuture


当需要多个异步任务都完成时,再进行后续处理,可以使用allOf方法


CompletableFuture poiIDTOFuture = CompletableFuture
.supplyAsync(() -> poiService.loadPoi(poiId))
.thenAccept(poi -> {
   model.setModelTitle(poi.getShopName());
   //do more thing
});

CompletableFuture productFuture = CompletableFuture
.supplyAsync(() -> productService.findAllByPoiIdOrderByUpdateTimeDesc(poiId))
.thenAccept(list -> {
   model.setDefaultCount(list.size());
   model.setMoreDesc("more");
});
//future3等更多异步任务,这里就不一一写出来了

CompletableFuture.allOf(poiIDTOFuture, productFuture, future3, ...).join();  //allOf组合所有异步任务,并使用join获取结果

该方法挺适合C端的业务,比如通过poiId异步的从多个服务拿门店信息,然后组装成自己需要的模型,最后所有门店信息都填充完后返回


这里使用了join方法获取结果,它和get方法一样阻塞的等待任务完成


多个异步任务有任意一个完成时就返回结果,可以使用anyOf方法


CompletableFuture future1 = CompletableFuture.supplyAsync(() -> {
   try {
       TimeUnit.SECONDS.sleep(2);
  } catch (InterruptedException e) {
      throw new IllegalStateException(e);
  }
   return "Result of Future 1";
});

CompletableFuture future2 = CompletableFuture.supplyAsync(() -> {
   try {
       TimeUnit.SECONDS.sleep(1);
  } catch (InterruptedException e) {
      throw new IllegalStateException(e);
  }
   return "Result of Future 2";
});

CompletableFuture future3 = CompletableFuture.supplyAsync(() -> {
   try {
       TimeUnit.SECONDS.sleep(3);
  } catch (InterruptedException e) {
      throw new IllegalStateException(e);
     return "Result of Future 3";
});

CompletableFuture anyOfFuture = CompletableFuture.anyOf(future1, future2, future3);

System.out.println(anyOfFuture.get()); // Result of Future 2

异常处理


Integer age = -1;

CompletableFuture maturityFuture = CompletableFuture.supplyAsync(() -> {
 if(age < 0) {
   throw new IllegalArgumentException("Age can not be negative");
}
 if(age > 18) {
   return "Adult";
} else {
   return "Child";
}
}).exceptionally(ex -> {
 System.out.println("Oops! We have an exception - " + ex.getMessage());
 return "Unknown!";
}).thenAccept(s -> System.out.print(s));
//Unkown!

exceptionally方法可以处理异步任务的异常,在出现异常时,给异步任务链一个从错误中恢复的机会,可以在这里记录异常或返回一个默认值


使用handler方法也可以处理异常,并且无论是否发生异常它都会被调用


Integer age = -1;

CompletableFuture maturityFuture = CompletableFuture.supplyAsync(() -> {
   if(age < 0) {
       throw new IllegalArgumentException("Age can not be negative");
  }
   if(age > 18) {
       return "Adult";
  } else {
       return "Child";
  }
}).handle((res, ex) -> {
   if(ex != null) {
       System.out.println("Oops! We have an exception - " + ex.getMessage());
       return "Unknown!";
  }
   return res;
});

分片处理


分片和并行处理:分片借助stream实现,然后通过CompletableFuture实现并行执行,最后做数据聚合(其实也是stream的方法)


CompletableFuture并不提供单独的分片api,但可以借助stream的分片聚合功能实现


举个例子:


//请求商品数量过多时,做分批异步处理
List> skuBaseIdsList = ListUtils.partition(skuIdList, 10);//分片
//并行
List>> futureList = Lists.newArrayList();
for (List skuId : skuBaseIdsList) {
 CompletableFuture> tmpFuture = getSkuSales(skuId);
 futureList.add(tmpFuture);
}
//聚合
futureList.stream().map(CompletalbleFuture::join).collent(Collectors.toList());

举个例子


带大家领略下CompletableFuture异步编程的优势


这里我们用CompletableFuture实现水泡茶程序


首先还是需要先完成分工方案,在下面的程序中,我们分了3个任务:



  • 任务1负责洗水壶、烧开水

  • 任务2负责洗茶壶、洗茶杯和拿茶叶

  • 任务3负责泡茶。其中任务3要等待任务1和任务2都完成后才能开始


图片


下面是代码实现,你先略过runAsync()、supplyAsync()、thenCombine()这些不太熟悉的方法,从大局上看,你会发现:



  1. 无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;

  2. 语义更清晰,例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述任务3要等待任务1和任务2都完成后才能开始

  3. 代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的


//任务1:洗水壶->烧开水
CompletableFuture f1 =
 CompletableFuture.runAsync(()->{
 System.out.println("T1:洗水壶...");
 sleep(1, TimeUnit.SECONDS);

 System.out.println("T1:烧开水...");
 sleep(15, TimeUnit.SECONDS);
});
//任务2:洗茶壶->洗茶杯->拿茶叶
CompletableFuture f2 =
 CompletableFuture.supplyAsync(()->{
 System.out.println("T2:洗茶壶...");
 sleep(1, TimeUnit.SECONDS);

 System.out.println("T2:洗茶杯...");
 sleep(2, TimeUnit.SECONDS);

 System.out.println("T2:拿茶叶...");
 sleep(1, TimeUnit.SECONDS);
 return "龙井";
});
//任务3:任务1和任务2完成后执行:泡茶
CompletableFuture f3 =
 f1.thenCombine(f2, (__, tf)->{
   System.out.println("T1:拿到茶叶:" + tf);
   System.out.println("T1:泡茶...");
   return "上茶:" + tf;
});
//等待任务3执行结果
System.out.println(f3.join());

void sleep(int t, TimeUnit u) {
 try {
   u.sleep(t);
}catch(InterruptedException e){}
}
// 一次执行结果:
T1:洗水壶...
T2:洗茶壶...
T1:烧开水...
T2:洗茶杯...
T2:拿茶叶...
T1:拿到茶叶:龙井
T1:泡茶...
上茶:龙井

注意事项


1.CompletableFuture默认线程池是否满足使用


前面提到创建CompletableFuture异步任务的静态方法runAsync和supplyAsync等,可以指定使用的线程池,不指定则用CompletableFuture的默认线程池


private static final Executor asyncPool = useCommonPool ?
       ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();

可以看到,CompletableFuture默认线程池是调用ForkJoinPool的commonPool()方法创建,这个默认线程池的核心线程数量根据CPU核数而定,公式为Runtime.getRuntime().availableProcessors() - 1,以4核双槽CPU为例,核心线程数量就是4*2-1=7


这样的设置满足CPU密集型的应用,但对于业务都是IO密集型的应用来说,是有风险的,当qps较高时,线程数量可能就设的太少了,会导致线上故障


所以可以根据业务情况自定义线程池使用


2.get设置超时时间不能串行get,不然会导致接口延时线程数量*超时时间


作者:程序员清风
来源:juejin.cn/post/7301909438586683433
收起阅读 »

面试官问,如何在十亿级别用户中检查用户名是否存在?

前言 不知道大家有没有留意过,在使用一些app注册的时候,提示你用户名已经被占用了,需要更换一个,这是如何实现的呢?你可能想这不是很简单吗,去数据库里查一下有没有不就行了吗,那么假如用户数量很多,达到数亿级别呢,这又该如何是好? 数据库方案 第一种方案就是查...
继续阅读 »

前言


不知道大家有没有留意过,在使用一些app注册的时候,提示你用户名已经被占用了,需要更换一个,这是如何实现的呢?你可能想这不是很简单吗,去数据库里查一下有没有不就行了吗,那么假如用户数量很多,达到数亿级别呢,这又该如何是好?


数据库方案



第一种方案就是查数据库的方案,大家都能够想到,代码如下:


public class UsernameUniquenessChecker {
private static final String DB_URL = "jdbc:mysql://localhost:3306/your_database";
private static final String DB_USER = "your_username";
private static final String DB_PASSWORD = "your_password";

public static boolean isUsernameUnique(String username) {
try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) {
String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
int count = rs.getInt(1);
return count == 0; // If count is 0, username is unique
}
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return false; // In case of an error, consider the username as non-unique
}

public static void main(String[] args) {
String desiredUsername = "new_user";
boolean isUnique = isUsernameUnique(desiredUsername);
if (isUnique) {
System.out.println("Username '" + desiredUsername + "' is unique. Proceed with registration.");
} else {
System.out.println("Username '" + desiredUsername + "' is already in use. Choose a different one.");
}
}
}

这种方法会带来如下问题:



  1. 性能问题,延迟高 如果数据量很大,查询速度慢。另外,数据库查询涉及应用程序服务器和数据库服务器之间的网络通信。建立连接、发送查询和接收响应所需的时间也会导致延迟。

  2. 数据库负载过高。频繁执行 SELECT 查询来检查用户名唯一性,每个查询需要数据库资源,包括CPU和I/O。



  1. 可扩展性差。数据库对并发连接和资源有限制。如果注册率继续增长,数据库服务器可能难以处理数量增加的传入请求。垂直扩展数据库(向单个服务器添加更多资源)可能成本高昂并且可能有限制。


缓存方案


为了解决数据库调用用户名唯一性检查的性能问题,引入了高效的Redis缓存。



public class UsernameCache {

private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
private static final int CACHE_EXPIRATION_SECONDS = 3600;

private static JedisPool jedisPool;

// Initialize the Redis connection pool
static {
JedisPoolConfig poolConfig = new JedisPoolConfig();
jedisPool = new JedisPool(poolConfig, REDIS_HOST, REDIS_PORT);
}

// Method to check if a username is unique using the Redis cache
public static boolean isUsernameUnique(String username) {
try (Jedis jedis = jedisPool.getResource()) {
// Check if the username exists in the Redis cache
if (jedis.sismember("usernames", username)) {
return false; // Username is not unique
}
} catch (Exception e) {
e.printStackTrace();
// Handle exceptions or fallback to database query if Redis is unavailable
}
return true; // Username is unique (not found in cache)
}

// Method to add a username to the Redis cache
public static void addToCache(String username) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.sadd("usernames", username); // Add the username to the cache set
jedis.expire("usernames", CACHE_EXPIRATION_SECONDS); // Set expiration time for the cache
} catch (Exception e) {
e.printStackTrace();
// Handle exceptions if Redis cache update fails
}
}

// Cleanup and close the Redis connection pool
public static void close() {
jedisPool.close();
}
}

这个方案最大的问题就是内存占用过大,假如每个用户名需要大约 20 字节的内存。你想要存储10亿个用户名的话,就需要20G的内存。


总内存 = 每条记录的内存使用量 * 记录数 = 20 字节/记录 * 1,000,000,000 条记录 = 20,000,000,000 字节 = 20,000,000 KB = 20,000 MB = 20 GB


布隆过滤器方案


直接缓存判断内存占用过大,有没有什么更好的办法呢?布隆过滤器就是很好的一个选择。


那究竟什么布隆过滤器呢?


布隆过滤器Bloom Filter)是一种数据结构,用于快速检查一个元素是否存在于一个大型数据集中,通常用于在某些情况下快速过滤掉不可能存在的元素,以减少后续更昂贵的查询操作。布隆过滤器的主要优点是它可以提供快速的查找和插入操作,并且在内存占用方面非常高效。


具体的实现原理和数据结构如下图所示:



布隆过滤器的核心思想是使用一个位数组(bit array)和一组哈希函数。



  • 位数组(Bit Array) :布隆过滤器使用一个包含大量位的数组,通常初始化为全0。每个位可以存储两个值,通常是0或1。这些位被用来表示元素的存在或可能的存在。

  • 哈希函数(Hash Functions) :布隆过滤器使用多个哈希函数,每个哈希函数可以将输入元素映射到位数组的一个或多个位置。这些哈希函数必须是独立且具有均匀分布特性。


那么具体是怎么做的呢?



  • 添加元素:如上图所示,当将字符串“xuyang”,“alvin”插入布隆过滤器时,通过多个哈希函数将元素映射到位数组的多个位置,然后将这些位置的位设置为1。

  • 查询元素:当要检查一个元素是否存在于布隆过滤器中时,通过相同的哈希函数将元素映射到位数组的相应位置,然后检查这些位置的位是否都为1。如果有任何一个位为0,那么可以确定元素不存在于数据集中。但如果所有位都是1,元素可能存在于数据集中,但也可能是误判。


本身redis支持布隆过滤器的数据结构,我们用代码简单实现了解一下:


import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class BloomFilterExample {
public static void main(String[] args) {
JedisPoolConfig poolConfig = new JedisPoolConfig();
JedisPool jedisPool = new JedisPool(poolConfig, "localhost", 6379);

try (Jedis jedis = jedisPool.getResource()) {
// 创建一个名为 "usernameFilter" 的布隆过滤器,需要指定预计的元素数量和期望的误差率
jedis.bfCreate("usernameFilter", 10000000, 0.01);

// 将用户名添加到布隆过滤器
jedis.bfAdd("usernameFilter", "alvin");

// 检查用户名是否已经存在
boolean exists = jedis.bfExists("usernameFilter", "alvin");
System.out.println("Username exists: " + exists);
}
}
}

在上述示例中,我们首先创建一个名为 "usernameFilter" 的布隆过滤器,然后使用 bfAdd 将用户名添加到布隆过滤器中。最后,使用 bfExists 检查用户名是否已经存在。


优点:



  • 节约内存空间,相比使用哈希表等数据结构,布隆过滤器通常需要更少的内存空间,因为它不存储实际元素,而只存储元素的哈希值。如果以 0.001 误差率存储 10 亿条记录,只需要 1.67 GB 内存,对比原来的20G,大大的减少了。

  • 高效的查找, 布隆过滤器可以在常数时间内(O(1))快速查找一个元素是否存在于集合中,无需遍历整个集合。


缺点:



  • 误判率存在:布隆过滤器在判断元素是否存在时,有一定的误判率。这意味着在某些情况下,它可能会错误地报告元素存在,但不会错误地报告元素不存在。

  • 不能删除元素:布隆过滤器通常不支持从集合中删除元素,因为删除一个元素会影响其他元素的哈希值,增加了误判率。


总结


Redis 布隆过滤器的方案为大数据量下唯一性验证提供了一种基于内存的高效解决方案,它需要在内存消耗和错误率之间取得一个平衡点。当然布隆过滤器还有更多应用场景,比如防止缓存穿透、防止恶意访问等。


作者:JAVA旭阳
来源:juejin.cn/post/7293786247655129129
收起阅读 »

初学后端,如何做好表结构设计?

前言 最近有不少前端和测试转Go的朋友在私信我:如何做好表结构设计? 大家关心的问题阳哥必须整理出来,希望对大家有帮助。 先说结论 这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从...
继续阅读 »

前言


最近有不少前端和测试转Go的朋友在私信我:如何做好表结构设计?


大家关心的问题阳哥必须整理出来,希望对大家有帮助。


先说结论


这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从多方面考虑缓存问题。


收获最大的还是和大家的交流讨论,总结一下:



  1. 首先,一定要先搞清楚业务需求。比如我的例子中,如果不需要灵活设置,完全可以写到配置文件中,并不需要单独设计外键。主表中直接保存各种筛选标签名称(注意维护的问题,要考虑到数据一致性)

  2. 数据库表结构设计一定考虑数据量和并发量,我的例子中如果数据量小,可以适当做冗余设计,降低业务复杂度。


4个方面


设计数据库表结构需要考虑到以下4个方面:




  1. 数据库范式:通常情况下,我们希望表的数据符合某种范式,这可以保证数据的完整性和一致性。例如,第一范式要求表的每个属性都是原子性的,第二范式要求每个非主键属性完全依赖于主键,第三范式要求每个非主键属性不依赖于其他非主键属性。




  2. 实体关系模型(ER模型):我们需要先根据实际情况画出实体关系模型,然后再将其转化为数据库表结构。实体关系模型通常包括实体、属性、关系等要素,我们需要将它们转化为表的形式。




  3. 数据库性能:我们需要考虑到数据库的性能问题,包括表的大小、索引的使用、查询语句的优化等。




  4. 数据库安全:我们需要考虑到数据库的安全问题,包括表的权限、用户角色的设置等。




设计原则


在设计数据库表结构时,可以参考以下几个优雅的设计原则:




  1. 简单明了:表结构应该简单明了,避免过度复杂化。




  2. 一致性:表结构应该保持一致性,例如命名规范、数据类型等。




  3. 规范化:尽可能将表规范化,避免数据冗余和不一致性。




  4. 性能:表结构应该考虑到性能问题,例如使用适当的索引、避免全表扫描等。




  5. 安全:表结构应该考虑到安全问题,例如合理设置权限、避免SQL注入等。




  6. 扩展性:表结构应该具有一定的扩展性,例如预留字段、可扩展的关系等。




最后,需要提醒的是,优雅的数据库表结构需要在实践中不断迭代和优化,不断满足实际需求和新的挑战。



下面举个示例让大家更好的理解如何设计表结构,如何引入内存,有哪些优化思路:



问题描述



如上图所示,红框中的视频筛选标签,应该怎么设计数据库表结构?除了前台筛选,还想支持在管理后台灵活配置这些筛选标签。


这是一个很好的应用场景,大家可以先自己想一下。不要着急看我的方案。


需求分析



  1. 可以根据红框的标签筛选视频

  2. 其中综合标签比较特殊,和类型、地区、年份、演员等不一样



  • 综合是根据业务逻辑取值,并不需要入库

  • 类型、地区、年份、演员等需要入库



  1. 设计表结构时要考虑到:



  • 方便获取标签信息,方便把标签信息缓存处理

  • 方便根据标签筛选视频,方便我们写后续的业务逻辑


设计思路



  1. 综合标签可以写到配置文件中(或者写在前端),这些信息不需要灵活配置,所以不需要保存到数据库中

  2. 类型、地区、年份、演员都设计单独的表

  3. 视频表中设计标签表的外键,方便视频列表筛选取值

  4. 标签信息写入缓存,提高接口响应速度

  5. 类型、地区、年份、演员表也要支持对数据排序,方便后期管理维护


表结构设计


视频表


字段注释
id视频主键id
type_id类型表外键id
area_id地区表外键id
year_id年份外键id
actor_id演员外键id

其他和视频直接相关的字段(比如名称)我就省略不写了


类型表


字段注释
id类型主键id
name类型名称
sort排序字段

地区表


字段注释
id类型主键id
name类型名称
sort排序字段

年份表


字段注释
id类型主键id
name类型名称
sort排序字段

原以为年份字段不需要排序,要么是年份正序排列,要么是年份倒序排列,所以不需要sort字段。


仔细看了看需求,还有“10年代”还是需要灵活配置的呀~


演员表


字段注释
id类型主键id
name类型名称
sort排序字段

表结构设计完了,别忘了缓存


缓存策略


首先这些不会频繁更新的筛选条件建议使用缓存:




  1. 比较常用的就是redis缓存

  2. 再进阶一点,如果你使用docker,可以把这些配置信息写入docker容器所在物理机的内存中,而不用请求其他节点的redis,进一步降低网络传输带来的耗时损耗

  3. 筛选条件这类配置信息,客户端和服务端可以约定一个更新缓存的机制,客户端直接缓存配置信息,进一步提高性能


列表数据自动缓存


目前很多框架都是支持自动缓存处理的,比如goframe和go-zero


goframe


可以使用ORM链式操作-查询缓存


示例代码:


package main

import (
"time"

"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)

func main() {
var (
db = g.DB()
ctx = gctx.New()
)

// 开启调试模式,以便于记录所有执行的SQL
db.SetDebug(true)

// 写入测试数据
_, err := g.Model("user").Ctx(ctx).Data(g.Map{
"name": "xxx",
"site": "https://xxx.org",
}).Insert()

// 执行2次查询并将查询结果缓存1小时,并可执行缓存名称(可选)
for i := 0; i < 2; i++ {
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}

// 执行更新操作,并清理指定名称的查询缓存
_, err = g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: -1,
Name: "vip-user",
Force: false,
}).Data(gdb.Map{"name": "smith"}).Where("uid", 1).Update()
if err != nil {
g.Log().Fatal(ctx, err)
}

// 再次执行查询,启用查询缓存特性
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}

go-zero


DB缓存机制


go-zero缓存设计之持久层缓存


官方都做了详细的介绍,不作为本文的重点。


讨论


我的方案也在我的技术交流群里引起了大家的讨论,也和大家分享一下:


Q1 冗余设计和一致性问题



提问: 一个表里做了这么多外键,如果我要查各自的名称,势必要关联4张表,对于这种存在多外键关联的这种表,要不要做冗余呢(直接在主表里冗余各自的名称字段)?要是保证一致性的话,就势必会影响性能,如果做冗余的话,又无法保证一致性



回答:


你看文章的上下文应该知道,文章想解决的是视频列表筛选问题。


你提到的这个场景是在视频详情信息中,如果要展示这些外键的名称怎么设计更好。


我的建议是这样的:



  1. 根据需求可以做适当冗余,比如你的主表信息量不大,配置信息修改后同步修改冗余字段的成本并不高。

  2. 或者像我文章中写的不做冗余设计,但是会把外键信息缓存,业务查询从缓存中取值。

  3. 或者将视频详情的查询结果整体进行缓存


还是看具体需求,如果这些筛选信息不变化或者不需要手工管理,甚至不需要设计表,直接写死在代码的配置文件中也可以。进一步降低DB压力,提高性能。


Q2 why设计外键?



提问:为什么要设计外键关联?直接写到视频表中不就行了?这么设计的意义在哪里?



回答:



  1. 关键问题是想解决管理后台灵活配置

  2. 如果没有这个需求,我们可以直接把筛选条件以配置文件的方式写死在程序中,降低复杂度。

  3. 站在我的角度:这个功能的筛选条件变化并不会很大,所以很懂你的意思。也建议像我2.中的方案去做,去和产品经理拉扯喽~


总结


这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从多方面考虑缓存问题。


收获最大的还是和大家的交流讨论,总结一下:



  1. 首先,一定要先搞清楚业务需求。比如我的例子中,如果不需要灵活设置,完全可以写到配置文件中,并不需要单独设计外键。主表中直接保存各种筛选标签名称(注意维护的问题,要考虑到数据一致性)

  2. 数据库表结构设计一定考虑数据量和并发量,我的例子中如果数据量小,可以适当做冗余设计,降低业务复杂度



本文抛砖引玉,欢迎大家留言交流。



一起学习


欢迎和我一起讨论交流:可以在掘金私信我


也欢迎关注我的公众号: 程序员升职加薪之旅


也欢迎大家关注我的掘金,点赞、留言、转发。你的支持,是我更文的最大动力!



作者:王中阳Go
来源:juejin.cn/post/7212828749128876092
收起阅读 »

如何做大促压测

一.背景&目标 1.1 常见的压测场景 电商大促:一众各大厂的促销活动场景,如:淘宝率先推出的天猫双11,而后京东拉出的京东 618 .还是后续陆陆续续的一些年货节, 3.8 女神节等等.都属于一些常规的电商大促 票务抢购:常见的如承载咱们 8...
继续阅读 »

一.背景&目标


1.1 常见的压测场景




  • 电商大促:一众各大厂的促销活动场景,如:淘宝率先推出的天猫双11,而后京东拉出的京东 618 .还是后续陆陆续续的一些年货节, 3.8 女神节等等.都属于一些常规的电商大促




  • 票务抢购:常见的如承载咱们 80,90 青春回忆的 Jay 的演唱会,还有普罗大众都参与的 12306 全民狂欢抢票.




  • 单品秒杀:往年被小米抢购秒杀带起来的红米抢购,还有最近这几年各大电商准点的茅台抢购;过去这三年中抢过的口罩,酒精等.这都属于秒杀的范畴.




  • toB 私有化服务:这个场景相对特殊.但是随着咱们 toC 的业务饱和,很多软件服务商也开始做 toB 的业务. toB 的业务特点其中有一个相对比较特别的就是存在私有化部署的诉求.主要的一些目的也是基于一些数据安全,成本这些因素来考虑的.




如上是在工作过程接触到的一些场景,书不尽言.下面就针对这些场景做一个压测的的梳理.


1.2 目标


  稳是第一位的,不久前某猫厂云事故,以及刚出现的某雀文档事故,历历在目.从大了说,整个产品的公信力被质疑将是后续用户是否持续购买的最大障碍;往小了说咱们这些小兵严重就是直接被离职,直接决定房贷,车贷下个月能不能交上的事情.所以除了稳,我们没别的.


WX20231115-101734@2x.png


  那其实从实际场景来说,除了稳定性是我们要求的第一位.还有一个整体的成本也是常用来被考虑的.所以压测的目标就是在稳定性和成本中间尽可能做一个权衡.


  如上在这些场景中前三的这种场景优先都是以稳定性是第一位,特别是电商大促,涉及的流程和各模块繁杂.在具体实施的过程中尽可能的去保证稳定性,资源优先度可以先往后放一放.


  其中稳定性的部分.我理解有两个部分.首先是面对峰值流量的时候的稳定性,一个是整个系统全链路的系统业务流程的稳定性.如:整体的交易的黄金流程.保证从用户的商详,购物车,结算,订单,支付都能够完整的走下来,这是业务流程的稳定性.


  最后一个私有化的场景相对比较特殊,更多的是一个私域的流量场景,流量相比公域要少的多.这时候尽可能要去压榨机器的性能,在尽可能少的资源成本下去提供更多的流量支持.因为成本就直接面临了产品的竞争力.


二.流程


    将流程划分为三个阶段压测前的一些前置准备;压测进行过程中的主要是测试和研发的具体的配合操作,以及监控观测;压测后的一些结果沉淀以及复盘,优化,复压.


2.1 压测前


2.1.1 流量预估


    这个是压测前第一项工作也是非常重要的一项工作,直接决定了本次压测的一个目标,而目标的准确制定就决定了本次的压测的最终目的---保证大促的稳定的直接成功与否.所以这里的流量预估显得非常重要.一般来说的话常用的有这两种形式.




  • 流量同比规则粗估


    如: 2012年6月1日 42w(qps) , 2013年6月1日 24w(qps) .同比下滑 42% .在得到 2012年11月1日 49w(qps) .以此推算 2013年11月1日 49w*0.57=28w .这是一个大概的量,如果压测的话按照这个量上浮 20% .压测按照 28*1.2= 34(w).




  • GMV 原则预估




从业务侧拿到2013年11月1日 11.11dau 的预估的量. 比如: dau 相比 618 的增长 1.2 倍.从监控里得到 618 的查车的量 20w ,占比 40% .得到整体流量为 50w. 得到 11.11 整体的量 50w*1.2 得到整体双 11 的量为 60w . 如果压测的话按照这个量上浮 ** 20%** .压测按照 60*1.2=72(w)
.


2.1.2 限流对齐以及配置


  限流毋庸置疑都是需要配置的,防止系统在承载能力之外的流量冲击下直接崩溃,造成xue'peng


2.1.2.1 限流配置原则


在整个流量预估完成之后,各模块基本上可以基于所域系统服务在流量预估的数值来进行设置.来保证峰值以上的一些突发情况也能够在系统承受范围.


2.1.2.2 限流的配置



  • 单机维度


一般单机房维度设置限流有两个方面. cpu 维度和 qps 维度.



  • 机房维度


每个机房的压测流量不一样,如张北,中云信.需要根据机房来进行限流配置,因为一般场景下优先保障同机房调用.


2.1.2.3 机器配置



  • 单机核心配置


机器配置.16c32g 50G SAS硬盘. SAS [既有的机械硬盘升级]


export maxParameterCount="10000"
export acceptCount="1000"
export maxSpareThreads="750"
export maxThreads="1000"
export minSpareThreads="50"
export URIEncoding="UTF-8"
export JAVA_OPTS=" -Xms16384m -Xmx16384m -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m -XX:ConcGCThreads=4 -XX:ParallelGCThreads=16 -Djava.library.path=/usr/local/lib -server -Xmn4096m -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses -XX:+CMSClassUnloadingEnabled -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=75 -XX:+CMSScavengeBeforeRemark "


  • 集群机房资源配比及配置


2.1.2.4 监控配置


监控配置主要分两个方面.
本身系统的机器的物理监控.
主要的指标[ CPU 使用率, load 负载.内存使用率,磁盘使用率, TCP 重传,连通性.].示例如下:
在这里插入图片描述



  • 接口服务监控.主要指标.



调用次数(秒级,分钟级),平均响应时长,TP99,TP999,可用率.示例如下:
在这里插入图片描述


核心的监控面板:


1.自身系统依赖的服务接口监控面板.
2.常见上游/自身/下游error状态码监控面板.
3.自身系统核心接口监控面板


2.1.3 流量切割



  • 入口流量切割


  从域名到压测机器的流量,保证生产环境和压测环境进行流量切分



  • *DB *流量切割


  一般通过识别压测上下文指标的路由标,来判定是否需要重新切换数据源.这个技术很常见.常见的做法就是通过 AbstractRoutingDataSource 的重写来实现 determineCurrentLookupKey 方法来切换数据源.动态数据源切割.压测的数据源一般会重新 copy 一遍现有的数据库 schema 建立一个影子库,保证线上数据不受影响,有时候为了压测还需要进行一些线上数据的一些冲入,保证测试场景的完整进行.



  • MQ 流量切割


  主要是消费和发送都需要增加识别压测标来进行消息的发送和消息的消费.如:原有 topic .rd_product_add ,通过识别 isForceBot

标来增加 rd_product_add_shadow .



  • cache 流量切割


  方案基本同上.通过识别标来具体使用具体的 cacheClient 不同.



  • 其他的中间件具体改造


如: es,ck,blink 等.


   如上的流量切割后要进行小流量的试跑来保证改造的方案是可行的.防止出现压测过程的流量逃逸.影响线上真实的环境,污染生产数据等.


2.1.4 压测前的机器状态检查


   这一步主要是 check 机器指标异常的,主要指标有 CPU, 硬盘, 内存, 连通性.防止一些特别的机器造成压测一直压不上去.出现指标异常的机器进行流量摘除的处理或者重启能消除隐患也可以继续使用.


2.1.5 测试的数据&脚本准备



  • 数据准备


  这里的数据准备要充分的模拟生产的环境数据,例如:加车的数据多样性每个维度都要充分的添加到.常见的加车数量6-10.
常见的重要的生产数据模拟.用户数据,订单数据,产品数据,购物车数据.



  • 脚本
      要保证基本的用例case能通


2.2 压测中


2.2.1 单场景压测


特定的场景压测,比如商详.这种场景下的压测因为是单场景的,所以在压测过程中不能够按照打满的场景去操作.比如说:整体商详压测的目标机器 cpu 目标是 60% .单场景的时候可能要留一些 buffer 去给全链路的场景做一些预留.


2.2.2 全链路压测


2.2.3 故障演练


通过演练做到面对故障时的响应机制.目标:完成3分钟内发现,5分钟内应急处理.10分钟定位原因.
大致分为这几个方面.


2.2.3.1 系统及硬件


系统方面涉及: CPU ,硬盘, TCP 重传,内存,磁盘可用率.
JVM :频繁 GC ,高频 YGC .
应对预案:快速通过监控平台完成具体IP机器定位,通过IP摘除流量完成,机器流量下线.通知运维定位原因. JVM 相关 DUMP 响应日志进行分析.


2.2.3.1 中间件相关演练


  在服务中间件出现异常时系统能够正常提供服务,对应接口的指标能够满足目标要求.常见的中间件故障.
存储类: ES,DB,cache.
中间件: MQ
应对预案:中间件能够做到手动预案热备数据源切换,缓存中间件降级. MQ 停止消费等.


2.2.3.2 上下游服务异常演练


  通过观察上下游服务监控面板快速定位上下游接口超时.
应对预案:非核心链路接口,主动通过开关进行降级.核心链路接口快速联系上下游进行相关原因排查.


2.6 限流演练



  • 单机限流演练
      在日常qps 平均值的前提上浮一些,保证生产的正常流量能够进行正常访问而不会触发限流.

  • 集群演练


2.3 压测后



  • 压测后机器挂载流量回切

  • 压测复盘


2.3.1 压测优化



  • 代码优化

  • 资源扩缩容

  • 针对场景复压测


2.3.2 压测其他收官



  • 完成压测报告

  • 沉淀操作手册

  • 沉淀压测记录

  • 动态扩缩容规则确认,资源确认

  • 流量回切


   如果在整个压测过程中是使用的同样的生产环境,保证压测后机器及时归还线上.避免影响线上集群性能和用户体验.


三.压测中遇到的问题


3.1 硬件相关


   首先定位具体硬件 IP 地址,优先进行流量摘取.出现大面积故障时同时保留现场同时立即联系运维同学协助排查定位.


3.2 接口相关


   首先通过接口监控得到相关接口的tp99avg,观测到实际的接口耗时已经影响主接口的调用时,进行主动的开关降级做到不影响主接口和核心逻辑.


3.3 其他



  • tomcat 6 定期主动回收问题
    tomcat6.0.33为防止内存泄露周期性每 1 小时触发 1 次System.gc(),导致tp周期性波动。tomcat源码JreMemoryLeakPreventionListener fullgc触发位置:
    在这里插入图片描述
    修复方案:从fullgc平均耗时200ms左右来看,fullgc耗时引发接口超时导致图文详情h5超时风险较小。计划618后升级tomcat版本解决。


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

如何优雅地创建对象?

1. 写在前头 大家好,我是方圆,最近读完了《Effective Java 第三版》,准备把其中可供大家一起学习的点来分享出来。 这篇博客儿主要是关于建造者模式在创建对象时的应用,这已经成了我现在写代码的习惯,它在灵活性和代码整洁程度上,都让我十分满意。以下的...
继续阅读 »

1. 写在前头


大家好,我是方圆,最近读完了《Effective Java 第三版》,准备把其中可供大家一起学习的点来分享出来。


这篇博客儿主要是关于建造者模式在创建对象时的应用,这已经成了我现在写代码的习惯,它在灵活性和代码整洁程度上,都让我十分满意。以下的内容非常的长,也是我费尽心力去完成的一篇博客儿,从初次应用建造者模式,到发现Lombok方便的注解,最后深挖Lombok的源码,大家既可以简单的学会它的应用,也可以从源码的角度来弄清楚它为什么是这样儿,就看你有什么需求了!


那,我们开始吧!


2. Java Beans创建对象


先创建一个Student类做准备,包含如下五个字段,姓名,年龄,爱好,性别和介绍


public class Student {

private String name;

private Integer age;

private String hobby;

/**
* 性别 0-女 1-男
*/

private Integer sex;

/**
* 介绍
*/

private String describe;

}

2.1 最常见的创建对象方式



  • 直接new一个对象,之后逐个set它的值,比如我们现在需要一个芳龄23岁的男生叫小明


Student xm = new Student();
xm.setName("小明");
xm.setAge(23);
xm.setSex(1);


  • 四行代码看着好多,我现在想让代码好看一些,一行就把这个对象创建出来,那就,添加个构造函数呗


// Student中添加构造函数
public Student(String name, Integer age, Integer sex) {
this.name = name;
this.age = age;
this.sex = sex;
}

// 一行一个小明
Student xm2 = new Student("小明", 23, 1);

这下看着是舒心多了,一行代替了之前的四行代码



  • 又来新需求了,创建一个对象,只要年龄和姓名,不要性别了,如果还要使用一行代码的话,我们又需要维护一个构造方法


// Student中添加构造函数
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}

// 一行一个小明
Student xm3 = new Student("小明", 23);

两个构造方法,维护起来感觉还好...



  • 但是,需求接连不断,“再给我来一个只有名字的小明!”,“我还要一个有名字,有爱好的小明”,“我还要...”


有没有发现点儿什么,也就是说,只要创建包含不同字段的对象,都需要维护一个构造方法,五个字段最多维护“5 x 4 x 3 x 2 x 1...” 个构造方法,这才仅仅是五个字段,现在想想如果每打开一个实体类文件映入眼帘的是无数个构造方法,我就...


image.png


所以这个弊端很明显,Java Beans创建对象会让代码行数很多,一行set一个属性,不美观,而采用了构造方法创建对象之后,又要对构造方法进行维护,代码量大增,难道代码美观和少代码量不能兼得吗?


3. effective Java说:用建造者模式创建对象


我先直接把代码写好,再一点点给大家讲


public class Student {

private String name;

private Integer age;

private String hobby;

/**
* 性别 0-女 1-男
*/

private Integer sex;

/**
* 介绍
*/

private String describe;

// 注意这里添加了一个private的构造函数,建造者字段和实体字段一一对应赋值
private Student(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.hobby = builder.hobby;
this.sex = builder.sex;
this.describe = builder.describe;
}

// 静态方法创建建造者对象
public static Builder builder() {
return new Builder();
}

/**
* 采用建造者模式,每个字段都有一个设置字段的方法
* 且返回值为Builder,能进行链式编程
*/

public static class Builder {
private String name;
private Integer age;
private String hobby;
private Integer sex;
private String describe;

// 私有构造方法
private Builder() {
}

public Builder name(String val) {
this.name = val;
return this;
}

public Builder age(Integer val) {
this.age = val;
return this;
}

public Builder hobby(String val) {
this.hobby = val;
return this;
}

public Builder sex(Integer val) {
this.sex = val;
return this;
}

public Builder describe(String val) {
this.describe = val;
return this;
}

public Student build() {
return new Student(this);
}
}

}


  • 需要注意的点:




  1. 为Student添加了一个private的构造函数,参数值为Builder,建造者字段和实体字段在构造函数中一一对应赋值




  2. 建造者中对每个字段都添加一个方法,且返回值为建造者本身,这样才能进行链式编程




3.1 这下能自如应对对象创建


// 创建一个23岁的小明
Student xm4 = Student.builder().name("小明").age(23).build();
// 创建一个男23岁小明
Student xm5 = Student.builder().name("小明").age(23).sex(1).build();
// 创建一个喜欢写代码的小明
Student xm6 = Student.builder().name("小明").hobby("代码").build();
// ...

3.2 新添加字段怎么办?



  • 如果要新增一个国籍的字段,不光要在实体类中添加,还需要在建造者中添加对应的字段方法,而且还要更新实体类的构造方法


// 实体类和建造者中均新增字段
private String country;

// 建造者中添加对应方法
public Builder country(String val) {
this.country = val;
return this;
}

// 更新实体类的构造方法
private Student(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.hobby = builder.hobby;
this.sex = builder.sex;
this.describe = builder.describe;
// 新增赋值代码
this.country = builder.country;
}

完成如上工作就可以创建对象为country赋值了


Student xm7 = Student.builder().name("小明").country("中国").build();



  • 那,建造者模式的好处又有什么? 难道不是既有了JavaBeans创建对象的可读性避免了繁重的代码量吗?




  • 题外话: 在我刚使用如上建造者模式创建对象的时候,觉得分分钟能吊打Java Beans创建对象的代码,也乐此不疲的为我要使用的实体类进行维护,但是也正所谓“凡事都很难经得住时间的磨砺”,当发现了更好的方法后,我变懒了!




4. Lombok的@Builder注解


4.1 注解带来的代码整洁



  • 在类上注解标注@Builder注解,会自动生成建造者的代码,且和上述用法一致,而且不需要再为新增字段特意维护代码,也太香了吧...


@Data
@Builder
public class Student {
...
}


  • 所以可以直接标注@Builder注解使用建造者模式创建对象(使用方法和上文中3.1节一致)


4.2 你可能听说过@Accessors要比@Builder灵活



  • @Builder在创建对象时具有链式赋值的特点,但是在创建对象后,就不能链式赋值了,虽然toBuilder注解属性可以返回一个新的建造者,并复用对象的成员变量值,但是这并不是在原对象上进行修改,调用完build方法后,会返回一个新的对象


// 在@Builder注解中,指定属性toBuilder = true
@Builder(toBuilder = true)

// 在创建完成对象后使用toBuilder方法获取建造者,指定新的属性值创建对象
Student xm7 = Student.builder().name("小明").country("中国").build();

Student xm8 = xm7.toBuilder().age(23).build();


  • @Accessors注解可以在原对象上进行赋值,这里先解读一下@Accessors的源码,方便对下面的用法理解


/**
* @Accessors注解是不能单独使用的,单独标记不会产生任何作用
* 需要搭配@Data或者@Getter@Setter使用才能生效
*/

@Target({ElementType.TYPE, ElementType.FIELD})
@Retention(RetentionPolicy.SOURCE)
public @interface Accessors {
/**
* 这个属性默认是false,为false时,getter和setter方法会有get和set前缀
* 什么意思呢,比如字段name,在该属性为false生成的get和set方法为getName和setName
* 而当属性为true时,就没有没有get和set前缀,get方法和set方法都名为name,只不过set方法要有参数,是对name方法的重载
*/

boolean fluent() default false;

/**
* chain属性,显然从字面意思它能实现链式编程,默认属性false
* 为true时,setter方法的返回值是该对象,那么我们就能进行链式编程了
* 为false时,setter的返回值为void,就不能进行链式编程了
*
* 注意:特殊的一点是,当fluent属性为true时,该值在不指定的情况下也会为true
*/

boolean chain() default false;

/**
* 这个属性值当我们指定的时候,会将字段中已经匹配到的前缀进行'删除'后生成getter和setter方法
* 但是它也有生效条件:字段必须是驼峰式命名,且前几个小写字母与我们指定的前缀一致
*
* 举个例子:
* 我们有一个字段如下
* private String lastName
* 在我们不指定prefix时,生成的getter和setter方法为 getLastName 和 setLastName
* 当我们指定prefix为last时,那么生成的getter和setter方法 为 getName 和 setName
*/

String[] prefix() default {};
}


  • 下面我们来看看用法,它实在是很灵活


// 我们为Student类标记一个如下注解,方法不含get和set前缀,同时又支持链式编程
@Accessors(fluent = true, chain = true)

// 这里我们创建一个25岁的小明
Student xm9 = new Student().age(25).name("小明");
// do something

// 使用完之后,假设这里需要对25岁的小明的属性进行修改,可采用如下方法,之后重新复用这个对象即可
xm9.country("中国");


  • 这也实在太好用了吧!


4.3 既然把@Accessors的源码读了,@Builder的源码我也讲给你听吧


@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.SOURCE)
public @interface Builder {
// 指定创建建造者的方法名,默认为builder
String builderMethodName() default "builder";

// 指定创建对象的方法名,默认为build
String buildMethodName() default "build";

// 指定静态内部建造者类的名字,默认为 类名 + Builder,如StudentBuilder
String builderClassName() default "";

// 是否能重新从对象生成建造者,默认为false,上文中有使用样例
boolean toBuilder() default false;

// 建造者能够使用的范围,默认是PUBLIC
AccessLevel access() default AccessLevel.PUBLIC;

// 标注了该注解的字段必须指定默认初始化值
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.SOURCE)
public @interface Default {
}

// 这个注解的使用是要和 @Builder(toBuilder = true) 一同使用才可生效
// 在调用toBuilder方法时,会根据被标注该注解的字段或方法对字段进行赋值
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.SOURCE)
public @interface ObtainVia {
// 指定要获取值的字段
String field() default "";

// 指定要获取值的方法
String method() default "";

// 这个值在指定method才有效,为true时获取值的方法必须为静态的,且方法参数值为本类(参考下文代码)
boolean isStatic() default false;
}
}


  • 全网很少有人讲@ObtainVia注解,那我们就来说说,它到底有什么用,该怎么用



  1. 指定field赋值


// 在类中注解标记和新增字段如下
@Builder.ObtainVia(field = "hobbies")
private String hobby;

// 供hobby获取值使用
private String hobbies = "唱跳RAP";

// 测试调用toBuilder方法,检查hobby值,若为‘唱跳RAP’证明注解生效
System.out.println(new Student().toBuilder().build().getHobby());
结果:唱跳RAP

查看编译后的源码,可以发现赋值语句hobby(this.hobbies),原来它是如此生效的


public Student.StudentBuilder toBuilder() {
return (new Student.StudentBuilder()).name(this.name).lastNames(this.lastNames).age(this.age)
.hobby(this.hobbies).hobbies(this.hobbies)
.sex(this.sex).describe(this.describe).country(this.country);
}


  1. 指定非静态method赋值


// 在类中标注如下注解和创建如下方法
@Builder.ObtainVia(method = "describe")
private String describe;

// 非静态方法赋值
private String describe() {
return "小明的自我介绍";
}

// 测试调用toBuilder方法,检查describe值,若为‘小明的自我介绍’证明注解生效
System.out.println(new Student().toBuilder().build().getDescribe());
结果:小明的自我介绍

查看编译后的源码,发现会调用该方法


public Student.StudentBuilder toBuilder() {
// 这里会调用该方法进行赋值,在下面生成Builder时使用
String describe = this.describe();
return (new Student.StudentBuilder()).name(this.name).lastNames(this.lastNames).age(this.age).hobby(this.hobby).sex(this.sex)
.describe(describe)
.country(this.country);
}


  1. 指定静态method赋值


// 在类中标注如下注解和创建如下静态方法
@Builder.ObtainVia(method = "describe", isStatic = true)
private String describe;

// 静态方法赋值,需要指定本类类型参数
private static String describe(Student student) {
return "小明的自我介绍";
}

// 测试调用toBuilder方法,检查describe值,若为‘小明的自我介绍’证明注解生效
System.out.println(new Student().toBuilder().build().getDescribe());
结果:小明的自我介绍

查看编译后的源码


public Student.StudentBuilder toBuilder() {
// 这里调用静态方法赋值
String describe = describe(this);
return (new Student.StudentBuilder()).name(this.name).lastNames(this.lastNames).age(this.age).hobby(this.hobby).sex(this.sex).describe(describe).country(this.country);
}

5. 番外:@Builder,@Singular 夫妻双双把家还


5.1 @Singular简介


@Singular必须搭配@Builder使用,相辅相成,@Singular标记在集合容器字段上,在建造者中自动生成针对集合容器的添加单个值添加多个值清除其中值的方法,可进行标记的集合容器类型如下(参考官方文档) java.util.Iterable, Collection, List, Set, SortedSet, NavigableSet, Map, SortedMap, NavigableMap com.google.common.collect.ImmutableCollection, ImmutableList, ImmutableSet, ImmutableSortedSet, ImmutableMap, ImmutableBiMap, ImmutableSortedMap, ImmutableTable



  • 使用演示


// 在类中添加如下字段,并标注@Singular注解
@Singular
private List<String> subjects;

// 测试代码,调用单个添加和多个值添加的方法
Student xm11 = Student.builder()
.subject("Math").subject("Chinese")
.subjects(Arrays.asList("English", "History")).build();

// 查看添加结果
System.out.println(xm11.getSubjects().toString());
结果:[Math, Chinese, English, History]

// 调用clearSubjects清空方法,并查看结果
System.out.prinln(xm11.toBuilder().clearSubjects().build().getSubjects().toString());
结果:[]

5.2 @Singular源码解析


@Target({FIELD, PARAMETER})
@Retention(SOURCE)
public @interface Singular {
// 指定添加单个值的方法的方法名,不指定时会自动生成方法名,比例中为'subject'
String value() default "";

// 添加多个值是否忽略null,默认不忽略,添加null的列表时会抛出异常
// 为ture时,添加为null的列表不进行任何操作
boolean ignoreNullCollections() default false;
}


  • @Singular(ignoreNullCollections = false)编译后的代码


public Student.StudentBuilder subjects(Collection<? extends String> subjects) {
// 添加的列表为null,抛出异常
if (subjects == null) {
throw new NullPointerException("subjects cannot be null");
} else {
if (this.subjects == null) {
this.subjects = new ArrayList();
}

this.subjects.addAll(subjects);
return this;
}
}


  • @Singular(ignoreNullCollections = true)编译后的代码


public Student.StudentBuilder subjects(Collection<? extends String> subjects) {
// 为null时不进行任何操作
if (subjects != null) {
if (this.subjects == null) {
this.subjects = new ArrayList();
}

this.subjects.addAll(subjects);
}

return this;
}

5.3 @Singular在build方法中的细节



  • 创建完对象后,被标记为@Singular的列表能修改吗?我们试试


Student xm11 = Student.builder()
.subject("Math").subject("Chinese")
.subjects(Arrays.asList("English", "History")).build();

// 再添加一门Java课程
xm11.getSubjects().add("Java");

结果:抛出不支持操作的异常
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
at builder.TestBuilder.main(Student.java:177)


  • 为什么这样?我们看看源码中的build方法就知道了,build方法根据不同的列表大小走不同的初始化列表方法,返回的列表都是不能进行修改的


public Student build() {
List subjects;
switch(this.subjects == null ? 0 : this.subjects.size()) {
case 0:
// 列表大小为0时,创建一个空列表
subjects = Collections.emptyList();
break;
case 1:
// 列表大小为1时,创建一个不可修改的单元素列表
subjects = Collections.singletonList(this.subjects.get(0));
break;
default:
// 其他情况,创建一个不可修改的列表
subjects = Collections.unmodifiableList(new ArrayList(this.subjects));
}

// 下面进行忽略只看上边就好
String name$value = this.name$value;
if (!this.name$set) {
name$value = Student.$default$name();
}

return new Student(name$value, this.lastNames, this.age, this.hobby, this.sex, this.describe, this.country, subjects);
}

6. 写在最后


呼!终于写完了,做个总结吧(文末有博客对应的代码仓库)




  • @Accessors注解非常的轻便,我觉得它现在已经能cover我在业务开发中创建对象的需求了,代码可读性高,代码量又很少




  • @Builder注解它的功能相对来说更多一些,通过方法和字段来初始化建造者的值,搭配@Singular操作列表等,但是这些功能真正的在业务开发中的应用效果,还有待考量




巨人的肩膀



作者:方圆想当图灵
来源:juejin.cn/post/7246025362969722936
收起阅读 »

索引数据结构千千万 , 为什么B+Tree独领风骚

索引的由来 大数据时代谁掌握了数据就是掌握了流量,就是掌握的号召力。面对浩瀚的数据如何存储并非难事, 难点在于如何在大数据面前查询依旧快如闪电! 这时候索引就产生了,索引的产生主要还是借鉴于图书管理员书签的功能。在大数据面前 es 产生了,而我们今天要...
继续阅读 »

索引的由来




  • 大数据时代谁掌握了数据就是掌握了流量,就是掌握的号召力。面对浩瀚的数据如何存储并非难事, 难点在于如何在大数据面前查询依旧快如闪电!




  • 这时候索引就产生了,索引的产生主要还是借鉴于图书管理员书签的功能。在大数据面前 es 产生了,而我们今天要说的索引却不是它 而是目前中小项目中广泛使用的 mysql 数据库中的索引。




  • 本文主题着重介绍索引是什么?索引如何存储?为什么这么设计索引?常见的索引有哪些?最后我们在通过案列来分析如何命中索引以及索引失效的部分场景。




什么是索引



索引是创建在表上的,对数据库表中一列或多列的值进行排序的一种结构,可以提高查询的速度。




  • 索引是一种数据结构,以协助快速查询,更新数据库中的数据 。 mysql 的索引主要由 B+Tree 进行存储。在存储主题上又分为聚簇索引和非聚簇索引。


聚簇索引




  • 聚簇索引从字面上理解就是聚集在一起。所以凡事索引和数据存放在一起的我们就叫做聚簇索引。在mysqlINNODB 的主键索引就是采用的聚簇索引,因为在叶子节点负责存放数据,而非叶子节点负责存放索引。而除了主键索引外其他索引则是非聚簇索引,因为其他索引的叶子节点存储的是主键索引的地址指向。




非聚簇索引



  • MyISAM 引擎中就是非聚簇索引,我们通过它的文件结构也能够看出索引和数据是分开存放的。 非聚簇索引也会带来一些问题。诸如回表

  • INNODB 中非主键索引就是非聚簇索引,同时这种非主键索引也会带来一个问题就是二次索引也称回表。因为我们通过非主键索引是无法定位到最终数据的。大部分情况下我们是需要在根据主键索引进行第二次查找的。加入你有一个索引idx_name

    • select name from t where name=13 发生一次索引,不会回表查询

    • select * from t where name=13 发生两次索引,会发生回表



  • 上面第一个sql 不会发生回表是因为我门的sql 发生了索引覆盖,意思是idx_name 这颗树已经覆盖了我们查询的范围。


索引存储结构



  • 先说结论 mysql 中索引是通过 B+ Tree 进行存储的。但是在 mysql 中一开始是采取的 二叉树存储的。关于树形存储结构都是二叉树。那么我们是mysql 中不采用二叉树、红黑树呢?下面我们来分析下采用二叉树、红叉树分别会带来哪些问题。


二叉树



  • 二叉树是根据顺序在根据大小判断其存储的左右节点的。这就导致如果我们是按递增ID作为索引的话,最终就导致二叉树变成一颗偏向一边的树,换个角度看其实就是链表。


image-20221116191402773.png




  • 而针对一张表我们往往就是ID作为索引的居多。而ID采用自增策略的居多,所以如果索引采用的是二叉树的,毋庸置疑销量基本无提升,这也是为什么官方放弃 二叉树 作为索引存储的数据结构。




  • 而二叉树一共有如下几种极端情况




image-20221116203557935.png


平衡二叉树



  • 在开始红黑树之前,我们需要先了解下有种临界状态叫平衡二叉树。

  • 平衡二叉树又叫做Self-balancing binary search tree 。 平衡二叉树是二叉树的一种特例

  • 在二叉树中有一个定义平衡度(平衡因子)的概念。他的公式是左右高度的绝对值。

  • 当这个平衡度<=1的时候我们就称之为平衡二叉树

  • 在平衡二叉树中他的高度是最稳定的,换句话说平衡二叉树和其他二叉树相比能够在相同的节点情况下保证树的高度最低;这也是为什么mysql中索引的结构是一种平衡二叉树的升级版


image-20221116203517550.png


红黑树



红黑树实际上是一颗平衡二叉树;所以在构建的过程中他会发生自平衡



image-20221116194807714.png



  • 因为二叉树在极端的情况会变成一个链表,针对链表的问题红黑树的自平衡特性就完美的规避了二叉树的缺点。那么为什么最终索引也不是选择红黑树呢?

  • 仔细观察能够发现红黑树是一颗标准的二叉树。他所能容纳的最大节点数和他的高度正好成二的次方这个关系。也就是说假设红黑树的高度是h ,那么他能容纳最多的节点为 2^h。

  • 这样看来在数据量过大时,通过红黑树去构建貌似这颗二叉树高度就过去庞大了。高度也高给我们查询就带来更多次交互。要知道每个节点都是存储在硬盘中的,那么每一次的访问都会带来一次IO消耗。所以为了能够提高查询效率 mysql 最终还是没有选择红黑树。


①、每个节点要么红色要么黑色


②、根节点是黑色的


③、叶子节点是黑色的


④、红色节点的子节点一定是黑色的


⑤、从一个节点出发,到达任意一个叶子结点(NULL)路径上一定具有相同的黑色节点(保证了平衡度<=2)


image-20221116203407778.png


BTree



BTree的设计主要是针对磁盘获取其他存储的一种平衡树(不一定是二叉这里往往指的是多叉)



image-20221116203109328.png



  • B树非常适合读取和写入相对较大的数据块(如光盘)的存储系统。它通常用于数据库和文件系统。

  • 总结下BTree 具有如下特点:


①、至少是2阶,即至少有两个子节点
②、对于m阶BTree来说,非根节点所包含的关键词个数j需要满足 (m/2)-1<=j<=m-1
③、除叶子结点外,节点内关键词个数+1总是等于指针个数
④、所有叶子结点都在同一层
⑤、每个关键字保存实际磁盘数据


B+Tree



B+Tree 是BTree的一种变体。BTree节点里出了索引还会存储指针数据,而B+Tree仅存储索引值,这样同样空间节点能够存储更多的索引




  • B+Tree 因为压缩了数据存储空间,这样就能够在相同高度的BTree上存储更多的索引,这样更加提高索引定位销率。


image-20221116203306106.png


Hash表


①、hash索引无法进行范围查询,因为上述的hash结构是没有顺序的,hash索引只能实现等于、In等查询
②、hash值是针对元数据的一种散列运算。hash值得大小并不能反应元数据的大小。元数据a 、b对应的hash值有可能是3333、2222,而实际上上a<b . 所以我们无法通过hash值进行排序,从而hash索引无法进行排序
③、对于组合索引来说,在B+Tree中我们有最左匹配原则,但是在hash索引中是不支持的。因为组合索引整个映射成hash值,我们通过联合索引中部分值进行hash运算得带的值与hash索引中是没有关系的
④、hash索引在查询时是需要遍历整个hash表的。这点我们Java中的HashMap一样
⑤、hash索引在数据量少的情况下比BTree快。但是当hash冲突比较多的时候定位就会比B+Tree慢很多了。


image-20221116203746016.png


总结



  • 现在看来数据库运行的很牛逼,而且索引也很快,但这并不是一口吃成胖子的,了解了索引的底层数据结构后我们也能够了解 mysql 也是一步一步尝试过来的, 索引也是不断的优化而成的。说不定以后还会有其他结构产生,只能说每种数据结构都是最好的,前提是在特定的场景下。

  • 本专栏最后一篇我们将介绍下 mysql 的索引如何命中,以及那些场景导致索引失效。然后再着重介绍下高频面试题--回表&&索引下推



作者:zxhtom
来源:juejin.cn/post/7168268214713974798
收起阅读 »

说一个大家都知道的 Spring Boot 小细节!

小伙伴们知道,我们在创建 Spring Boot 项目的时候,默认都会有一个 parent,这个 parent 中帮我们定了项目的 JDK 版本、编码格式、依赖版本、插件版本等各种常见内容,有的小伙伴可能看过 parent 的源码,这个源码里边有这么一个配置:...
继续阅读 »

小伙伴们知道,我们在创建 Spring Boot 项目的时候,默认都会有一个 parent,这个 parent 中帮我们定了项目的 JDK 版本、编码格式、依赖版本、插件版本等各种常见内容,有的小伙伴可能看过 parent 的源码,这个源码里边有这么一个配置:


<resources>
<resource>
<directory>${basedir}/src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>**/application*.yml</include>
<include>**/application*.yaml</include>
<include>**/application*.properties</include>
</includes>
</resource>
<resource>
<directory>${basedir}/src/main/resources</directory>
<excludes>
<exclude>**/application*.yml</exclude>
<exclude>**/application*.yaml</exclude>
<exclude>**/application*.properties</exclude>
</excludes>
</resource>
</resources>

首先小伙伴们知道,这个配置文件的目的主要是为了描述在 maven 打包的时候要不要带上这几个配置文件,但是咋一看,又感觉上面这段配置似乎有点矛盾,松哥来和大家捋一捋就不觉得矛盾了:



  1. 先来看第一个 resource,directory 就是项目的 resources 目录,includes 中就是我们三种格式的配置文件,另外还有一个 filtering 属性为 true,这是啥意思呢?这其实是说我们在 maven 的 pom.xml 文件中定义的一些变量,可以在 includes 所列出的配置文件中进行引用,也就是说 includes 中列出来的文件,可以参与到项目的编译中。

  2. 第二个 resource,没有 filter,并且将这三个文件排除了,意思是项目在打包的过程中,除了这三类文件之外,其余文件直接拷贝到项目中,不会参与项目编译。


总结一下就是 resources 下的所有文件都会被打包到项目中,但是列出来的那三类,不仅会被打包进来,还会参与编译。


这下就清晰了,上面这段配置实际上并不矛盾。


那么在 properties 或者 yaml 中,该如何引用 maven 中的变量呢?


这块原本的写法是使用 $ 符号来引用,但是,我们在 properties 配置文件中,往往用 $ 符号来引用当前配置文件的另外一个 key,所以,我们在 Spring Boot 的 parent 中,还会看到下面这行配置:


<properties>
<java.version>17</java.version>
<resource.delimiter>@</resource.delimiter>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>

这里的 <resource.delimiter>@</resource.delimiter> 就表示将资源引用的符号改为 @ 符号。也就是在 yaml 或者 properties 文件中,如果我们想引用 pom.xml 中定义的变量,就可以通过 @ 符号来引用。


松哥举一个简单的例子,假设我想在项目的 yaml 文件中配置当前项目的 Java 版本,那么我就可以像下面这样写:


app:
java:
version: @java.version@

这里的 @java.version@ 就表示引用了 pom.xml 中定义的 java.version 变量。


现在我们对项目进行编译,编译之后再打开 application.yaml,内容如下:



可以看到,引用的变量已经被替换了。


按照 Spring Boot parent 中默认的配置,application*.yaml、application*.yml 以及 application*.properties 文件中可以引用 pom.xml 中定义的变量,其他文件则不可以。如果其他文件也想引用,就要额外配置一下。


例如,想让 txt 文件引用 pom.xml 中的变量,我们可以在 pom.xml 中做如下配置:


<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**/*.txt</include>
</includes>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

include 所有的 txt 文件,并且设置 filtering 为 true(不设置默认为 false),然后我们就可以在 resources 目录下的 txt 文件中引用 pom.xml 中的变量了,像下面这样:



编译之后,这个变量引用就会被替换成真正的值:



在 yaml 中引用 pom.xml 的配置,有一个非常经典的用法,就是多环境切换。


假设我们现在项目中有开发环境、测试环境以及生产环境,对应的配置文件分别是:



  • application-dev.yaml

  • application-test.yaml

  • application-prod.yaml


我们可以在 application.yaml 中指定具体使用哪个配置文件,像下面这样:


spring:
profiles:
active: dev

这个表示使用开发环境的配置文件。


但是有时候我们的环境信息是配置在 pom.xml 中的,例如 pom.xml 中包含如下内容:


<profiles>
<profile>
<id>dev</id>
<properties>
<package.environment>dev</package.environment>
</properties>
<!-- 是否默认 true表示默认-->
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<id>prod</id>
<properties>
<package.environment>prod</package.environment>
</properties>
</profile>
<profile>
<id>test</id>
<properties>
<package.environment>test</package.environment>
</properties>
</profile>
</profiles>

这里配置了三个环境,其中默认是 dev(activeByDefault)。那么我们在 application.yaml 中就可以使用 package.environment 来引用当前环境的名称,而不用硬编码。如下:


spring:
profiles:
active: @package.environment@

此时,我们通过 maven 命令对项目打包时,就可以指定当前环境的版本了,例如使用 test 环境,打包命令如下:


mvn package -Ptest

打包之后我们去看 application.yaml,就会发现里边的环境已经是 test 了。


如果你使用的是 IDEA,则也可以手动勾选环境之后点击打包按钮,如下:



可以先勾选上面的环境信息,再点击下面的打包。


好啦,一个小小知识点,因为有小伙伴在微信上问这个问题,就拿出来和大家分享下。


作者:江南一点雨
来源:juejin.cn/post/7226916546931949626
收起阅读 »

使用后端代码生成器,提高开发效率

如果你是一名后端开发者,那么大多数的工作一定是重复编写各种 CRUD(增删改查)代码。时间长了你会发现,这些工作不仅无趣,还会浪费你的很多时间,没有机会去做更有创造力和挑战的工作。 作为一名程序员,一定要学会偷懒!学会利用工具来解放人力。 其实现在有很多现成的...
继续阅读 »

如果你是一名后端开发者,那么大多数的工作一定是重复编写各种 CRUD(增删改查)代码。时间长了你会发现,这些工作不仅无趣,还会浪费你的很多时间,没有机会去做更有创造力和挑战的工作。


作为一名程序员,一定要学会偷懒!学会利用工具来解放人力。


其实现在有很多现成的代码生成器,可以帮助我们自动生成常用的增删改查代码,而不用自己重复编写,从而大幅提高开发效率,所以大家一定要掌握。


对应到 Java 后端开发,主流技术是 Spring Boot + Spring MVC + MyBatis 框架,使用这些技术来开发项目时,通常需要编写数据访问层 (DAO / Mapper) 和数据库表的 XML 映射代码、实体类、Service 业务逻辑代码、以及 Controller 接口代码。


本文就以使用 IDEA 开发工具中我认为非常好用的免费代码生成插件 MyBatisX 为例,带大家学习如何使用工具自动生成后端代码,节省时间和精力。


MyBatisX 自动生成代码教程


1、安装 MyBatisX 插件


首先,确保你已经安装了 IntelliJ IDEA 开发工具。


打开你的项目工程,然后进入 Settings 设置页搜索 MyBatisX 插件并安装,步骤如图:



2、配置数据库连接


MyBatisX 插件的核心功能是根据数据库表的结构来生成对应的实体类、数据访问层 Mapper、Service 等代码,所以在使用前,我们需要在 IDEA 中配置一个数据库连接。


先在 IDEA 右侧的 Database 中创建一个 MySQL 数据源配置:



然后根据自己的数据库信息填写配置,并测试能否连接成功:



连接成功后,就可以在 IDEA 中管理数据库了,不需要 Navicat 之类的第三方工具:



3、使用 MyBatisX 生成代码


右键要生成代码的数据表,进入 MyBatisX 生成器:



然后进入生成配置页面,可以根据你的需求来自定义代码生成规则:



上述配置中,我个人建议 base package (生成代码的包名和位置)尽量不要和已有的项目包名重叠,先把代码生成到一个完全不影响业务的位置,确认生成的代码没问题后,再移动代码会更保险一些。


进入下一步,填写更多的配置,可以选择生成代码的模板(一般是 MyBatis-Plus 模板),以及自定义实体类的生成规则(一般建议用 Lombok)。


以下是我常用的推荐配置:



改完配置后,直接点击生成即可,然后可以在包目录中看到生成的代码:



4、定制修改


通过以上方法,就已经能够完成基础增删改查代码的生成了,但一般情况下,我们得到生成的代码后,还要再根据自己的需求进行微调。


比如把主键 ID 的生成规则从自动递增改为雪花算法生成,防止数据 id 连续被别人轻松爬走:



最后你就可以使用现成的代码来操作数据库啦~


其他


如开头所说,现在的代码生成器非常多,比如 MyBatis Plus 框架也提供了灵活的代码生成器:



指路:baomidou.com/pages/98140…




再比如可以直接在浏览器使用的代码生成器,鱼皮自己也开发过并且开源了:



指路:sqlfather.yupi.icu/


开源:github.com/liyupi/sql-…




感兴趣的话,大家也可以尝试使用 FreeMarker 技术做一个属于自己的代码生成器。


实践


编程导航星球的用户中心项目使用了 MyBatisX 插件来生成代码,非常简单,大家一定要学会运用!


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

中介思想背后的三大技术意识,你具备了哪些?

不论在天上,在自然界,在精神中,不论在哪个地方,没有什么东西不是同时包含着直接性和间接性的。——黑格尔 代码既是一种艺术创作,也是一种工艺制造,其中也包含诸多中介。 以下是一个简单认识中介的例子: 中介活动:中介活动系指中介人居间帮助 委托方、委托方合作者(...
继续阅读 »

不论在天上,在自然界,在精神中,不论在哪个地方,没有什么东西不是同时包含着直接性和间接性的。——黑格尔



代码既是一种艺术创作,也是一种工艺制造,其中也包含诸多中介。


以下是一个简单认识中介的例子:


中介活动:中介活动系指中介人居间帮助

委托方、委托方合作者(下简称合作者)双方达成某项协议/契约/合同的活动。


嗯,读到这儿好像还能理解,以买房为例,大概就如下图所示:


中介活动图.jpg


但还得再加上这么一句话,但在中介过程中,牵涉到中介人与委托方(甲、乙方或双方)

签约、发布、寻找委托方合作者、协调甲、乙方签约、帮助完成签约和追索并获得报酬等活动。


中介活动图2.jpg


相比上一张图,这张图更能体现出中介具体做了哪些工作



  • 积极收集符合委托方条件的合作者;

  • 和合作者讨价还价;

  • ······


当然,中介并不是白干这些活,他最终也是需要中介费的,这也就是委托方的成本。


看完只想说,啊啊啊啊可恶要长脑子了,不过没有关系,在代码意识中,成本的耗费相比模块和架构意识少。




代码意识


言归正传,我们直入主题,看以下两个案例:




  • 有代码块 A、B、C... 想使用变量 “str”,我们往往会通过静态常数描述它,

    如:static String CONSTANT = "str"




  • 委托方:代码块 A、B、C...




  • 中介人:CONSTANT




  • 合作者:"str"




  • 有类 A、B、C... 想使用类 X,在 Spring 框架中,我们常常会通过注入的方式让 X 被其他类依赖.




  • 委托方:类 A、B、C...




  • 中介人:Spring 容器




  • 合作者:类 X




上述只是两个简单的中介行为案例,现实生活中委托方和合作者往往都会向中介人提供自己的需求,

而中介人则需要根据需求推荐委托方或合作者。


衍生出下一个案例:



  • 在一个方法中,入参为 code,而这个方法动作则需要根据不同的 code 执行不同的逻辑。

    如:


void performLogic(String code) {  

}

如果 code 的变化是固定的,例如像英文字母,无论如何都是 26 个,那我们穷举出来,其实也不耽误代码的扩张性,

只是过于冗长有点丑陋,但这种情况在实际中偏少。


void performLogic(String code) {  
if (code == "A") {

} else if(code == "B") {

} ... {

} else if (code == "Y") {

} else {

}
}

实际开发过程中,code 的变化往往是动态的,考虑维护成本和扩张性的功能,所以在方法performLogic()

显然不能因为 code 的动态变化而变化。


那可不可以找一个中介,让其提前知晓 code 对应的逻辑,每当 code 投来,我们让它把对应的逻辑给到委托者,

这样,委托者只需要关心自己在什么场景下传递什么 code,而不需要关心具体的逻辑怎么做。


这种情景 Map 结构再合适不过,因此可以这么写:


  
@Value // get
private final Map<String, Logic> knownLogics;

void performLogic(String code) {
Logic logic = knownLogics.getOrDefault(code, defaultLogic);
logic.performed();
}

那以上的身份可以确定如下:



  • 委托者:执行 performLogic() 的业务

  • 中介者:knownLogics

  • 合作者:对应的逻辑


中介活动也显然易见:



  1. 中介提前知晓委托者(上层业务)的需求(code)对应哪些合作者(逻辑)

  2. 委托者将需求给中介(knownLogics)

  3. 中介将对应的合作者告知委托者

  4. 委托者与合作者完成合作


中介者的身份有效地将委托者与合作者进行了解藕,彼此各尽其职。


相反,如果在此处采取 “字母”的做法,那么每当出现新的 code ,那么都需要在方法 performLogic() 中修改。


综上,使用中介思想可以让委托者和合作者在遵循单一职责和开闭原则的同时,还能保证委托者与合作者合作的代码不变,

是符合面向对象设计原则的。因此,使用中介思想可以促进开发人员理解面向对象设计原则和灵活使用设计模式,进而提高代码质量。




模块意识


以规则引擎在众安无界山理赔中心的应用为例:


在理赔业务中,主要有报案、立案、定损、理算和核赔五大流程,每一个流程进行至下一步时都需要进行规则校验,

例如有黑名单客户校验、反洗钱校验等校验规则。


如果将这些规则嵌套在每一步流程的代码中,那么一旦面对规则逻辑需要修改时,就不得不在原有代码上进行修改,

这导致规则与代码逻辑强耦合,并且每一次修改规则时,都需要重新编译代码。


我们可以通过中介意识将这个问题解决,我们将每个被校验的对象当作变量 x

在经过一个函数:


Fn(x1x2...,xn){0,1}F_n(x_1,x_2,..., x_n) \in
\begin{Bmatrix}
0, 1
\end{Bmatrix}

后得到通过或不通过。


理赔传参至函数.png


到这一步,我们也只是知道了委托方(业务代码)、委托方的需求(变量 X)及合作者(函数)


那中介是谁呢?没错,这个中介就是要新增的模块


言归正传,我们回到规则引擎在无界山理赔中心的应用中,那么我们可以确定以下身份:



  • 委托方:业务代码

  • 中介:规则引擎

  • 合作者:规则组(一簇规则;函数)


中介活动如下:



  1. 委托方(业务代码)提供需求(被校验的对象)给中介(规则引擎)

  2. 中介寻找合作者(规则组)

  3. 合作者按照委托方的需求签订协议(校验结果)


有了如上意识后,我们可以将规则引擎单独做一个模块去开发,然后使业务模块依赖,最后通过“创造(配置)”合作者(规则)。


这样,所有要使用到规则校验的业务代码都只需要通过规则引擎的入口,传递指定的需求和规则组 code 即可完成校验,如下图:


理赔传参至函数2.png


事实上,从模块意识开始,成本的问题就略有呈现了,例如:



  • 创建出中介;

  • 编写中介找到合作者逻辑;

  • 合作者创造出来,应该存储在何处?又如何管理?


通常来讲,都是存储到数据库,又通过接口调用进行增删改查,虽然与业务代码进行了解耦,但需要另取资源存储和管理,

那这样是否是拆东墙补西墙呢?


回答这个问题之前,反过来问一个问题,如果保持原来的做法,没有中介,那又会怎么样呢?因此这就成了对比,需要在权衡之下做选择。


显然,有了中介能够拆更少的东墙补更多的西墙。




架构意识



以前车马很慢,书信很远,一生只够爱一个人。



为了能让信封能够抵达心上人的手上,往往会将信封塞到信箱中,或是托信使帮忙托送,尔后忙于其他。


架构亦是如此,服务与服务之间难免存在沟通的情况,例如:



  1. 如果服务 A 需要且满足某接口,那么通常会让服务 A 寻找实现了该接口的服务;

  2. 如果服务 A 只是需要某服务的处理,并不关心处理的细节,那么通常会让 A 传递给信使,信使再告诉能帮助 A 的服务 X。


从中介思想的角度看上述两个案例,需要解决两个问题:



  1. 案例 1 中 A 是如何找到实现了该接口的服务?

  2. 案例 2 中的信使是谁?又如何找到他?


问题显而易见:



  1. 案例 1 中 A 肯定是通过中介才找到实现了该接口的服务;

  2. 案例 2 中 A 肯定也是通过中介才找到信使;

  3. 案例 2 中 信使自己本身也是一个中介人,负责存储 A 的需求和寻找帮助 A 的服务 X。


事实上,上述的案例就是现在的远程过程调用(Remote Procedure Call, 下简称 RPC)

和消息队列(Message Queue, 下简称 MQ)。


案例 1 (以 Dubbo 框架作为 RPC 框架为例)的身份确定如下:



  • 委托方:服务 A

  • 中介:注册中心

  • 合作者:实现了该接口的服务





案例 1 的中介活动如下:



  1. 委托方(服务 A)已知合作者(实现了该接口的服务)的要求(接口信息)

  2. 委托方按要求提供信息给中介

  3. 中介根据委托方提供的信息寻找合适(时间、天气等外部因素)的合作者

  4. 委托方得到合作者的答复(响应结果)


案例 2 的身份确定如下:



  • 委托方:服务 A

  • 中介 C:配置中心

  • 合作者 M 兼中介 M:消息队列

  • 合作者 X:帮助服务 A 的服务 X


生产者-消费者.jpg


案例 2 的中介活动如下:



  • 委托方(服务 A)从中介 C (配置中心)得知有中介 M (消息队列)可以帮助他

  • 委托方找到中介 M 拖信(消息体)

  • 中介 M 将信传给委托方指定的合作者 X (帮助服务 A 的服务)


中介意识在当前分布式架构中非常常用,除了 RPC 和 MQ 用于服务之间的交流之外,

还诞生了许多中介人用于不同的场景,例如:



  • 充当中介的事务协调者,用于分布式事务场景;

  • 充当中介的分布式锁,用于多服务对共享资源的访问;

  • 充当中介的负载均衡器,用于多服务时的负载均衡;

  • 充当中介的服务监控,用于监视多服务沟通链路;

  • ······


到这儿,作为造物主的你此时会发现,除了在完成功能的服务之外,令你头疼的不仅仅是功能逻辑,

还涉及到了如何让功能在各种场景下运行,因此你做了不少非原有功能的事情。


这就是成本,它不再是“多写几行代码”这种简单的成本,此时的它显然变得不可忽视。


那是不是就可以随意妄为呢?什么中介,这些成本才不想考虑。


那更有趣,如果我们的生活中没有像招标、房屋、拍卖和招聘这种中介,似乎好像也没啥影响,无疑是变得不方便。

但中介有没有可能是物,是思想呢?

比如一个人想让另一个人消失,是什么在约束他呢......


总结


艺术来源于生活,代码也如此,程序员也是一种艺术家,如今在现实生活中已有很多中介案例,只需要模仿或照搬。

特别地是,程序员有着与生俱来的逻辑感,他们热衷于为什么,于是在代码世界中将那些曾经的工匠精神复燃,

在那里,重现了将动力和转矩传递到所需处的齿轮、解决远距离沟通的电话乃至能量转换的发动机的发明。


参考文献


[1] Dubbo 官方文档


[2] 黑格尔. 逻辑学[M]. 北京: 商务印书馆, 1976.


作者:Masker
来源:juejin.cn/post/7300758264328683529
收起阅读 »

谈谈SSO单点登录的设计实现

谈谈SSO单点登录的设计实现 本篇将会讲讲单点登录的具体实现。 实现思路 其实单点登录在我们生活中很常见,比如学校的网站,有很多个系统,迎新系统,教务系统,网课系统。我们往往只需要登录一次就能在各个系统中被认定为登录状态。 这是怎么实现的?我们需要一个认证中心...
继续阅读 »

谈谈SSO单点登录的设计实现


本篇将会讲讲单点登录的具体实现。


实现思路


其实单点登录在我们生活中很常见,比如学校的网站,有很多个系统,迎新系统,教务系统,网课系统。我们往往只需要登录一次就能在各个系统中被认定为登录状态。


这是怎么实现的?我们需要一个认证中心,一如学校网站也有一个统一认证中心,也就是我们的SSO的Server端。在每个系统也就是Client端,我们只要判断已经在这个认证中心中登录,那我们就会被设置为登录状态。


再来就是最后一个问题了,我们判断在认证中心登录后,怎么在其他系统中也登录?


这个问题其实就是单点登录中最麻烦的问题了,也就是如何传播我们的登录状态。


我们可以分为两个情况Cookie共享传播状态,url参数传播状态。


Cookie共享传播状态


第一种情况:我们的认证中心和其他系统是在一个域名下的,认证中心为父域名(jwxt.com),其他系统是子域名(yx.jwxt.com),或者是同一IP不同端口的情况,我们的服务端通过cookie去判断是否登录。


在这种情况下我们只要在认证中心登录成功的时候设置Cookie,当然设置Cookie的时候也要注意设置好你的Cookie参数。


要注意设置的参数是dominpath。这两个参数值决定了Cookie的作用域。domin要设置为父域名(.jwxt.com)。当然还要注意一个SameSite参数,不能设置为。(如果为,你在baidu.com登录,在example.com网站如果你点击了 baidu.com/delete 链接,会带着你在baidu.com的Cookie访问。)


设置完Cookie,子域名的系统也有了Cookie,自然就会被服务端判断为登录状态。


简而言之,就是利用Cookie共享来实现登录状态的传播。


url参数传播状态


第二种我们的认证中心和其他系统不在一个域名下的,或者是不同IP的情况。


为了安全浏览器限制cookie跨域,也就是说第一种方法就不管用了。


这种情况可以通过传播参数来实现,也就是在认证中心登录后带着 登录凭证(token) 重定向到对应的Client页面,然后我们的前端就可以用js获取到url中的token进行存储(设置到Cookie或者localstorage等方式),之后我们的服务端只需要通过这个token就可以判断为登录状态了。


当然,为了安全我们往往不会直接传递凭证,而是传递一个校验码ticket,然后前端发送ticket到服务端校验ticket,校验成功,就进行登录,设置Cookie或者存储token。


流程


接下来我们梳理一下流程,一下Client为需要单点登录的系统,Server为统一认证中心。


Cookie共享传播状态



  1. 用户在Client1,如果没有登录,跳转到Server,判断在Server是否登录,如果判断没有登录,要求登录,登录成功后设置Cookie,跳转Client

  2. Client1登录成功


如果之后在Client2页面,由于共享Cookie,当然也是登录状态。


url参数传播状态



  1. 用户在Client1,判断没有登录,跳转到Server,判断在Server是否登录,如果没有登录,要求登录,登录成功后设置Cookie,带着ticket跳转Client。

  2. 到了Client1,前端通过参数获取到ticket,发送到服务端,服务端校验ticket获取登录id,设置Cookie进行登录。


之后在Client2页面



  1. 用户在Client2,判断没有登录,跳转到Server,判断在Server是否登录,这时候判断为登录,带着ticket(或者token)跳转Client。

  2. 到了Client2,前端通过参数获取到ticket,发送到服务端,服务端校验ticket获取登录id,设置Cookie进行登录。


如果不使用ticket校验就直接存储传播过来的登录凭证即可,当然如果你不存储到Cookie,记得在请求后端服务的时候带上token。


ticket校验


再说说ticket校验


ticket校验根据情况也可以分为两种,一种情况是Server和Client的后端共用的同一个Redis或者Redis集群,可以直接向Redis请求校验。如果后端用的Redis不同,可以发送http请求到Server端在Server端校验。


到此,单点登录就完成了。


当然在以上描述中的Cookie你也可以不使用,使用Cookie主要是方便,在请求后端时会自动发送。你只需要存储到localstorage/sessionstorage等地方,请求后端的时候记得get然后带上即可。


作者:秋玻
来源:juejin.cn/post/7297782151046266890
收起阅读 »

自定义注解实现服务动态开关

🧑‍💻🧑‍💻🧑‍💻Make things different and more efficient 接近凌晨了,今天的稿子还没来得及写,甚是焦虑,于是熬了一个夜也的给它写完。正如我的题目所说:《自定义注解实现服务动态开关》,接下来和shigen一起来揭秘吧。 ...
继续阅读 »

🧑‍💻🧑‍💻🧑‍💻Make things different and more efficient


接近凌晨了,今天的稿子还没来得及写,甚是焦虑,于是熬了一个夜也的给它写完。正如我的题目所说:《自定义注解实现服务动态开关》,接下来和shigen一起来揭秘吧。




前言


shigen实习的时候,遇到了业务场景:实现服务的动态开关,避免redis的内存被打爆了。 当时的第一感受就是这个用nacos配置一下不就可以了,nacos不就是有一个注解refreshScope,配置中心的配置文件更新了,服务动态的更新。当时实现是这样的:


在我的nacos上这样配置的:


 service:
  enable: true

那对应的java部分的代码就是这样的:


 class Service {
   @Value("service.enable")
   private boolean serviceEnable;
   
   public void method() {
     if (!serviceEnable) {
       return;
    }
     // 业务逻辑
  }
 }

貌似这样是可以的,因为我们只需要动态的观察数据的各项指标,遇到了快要打挂的情况,直接把布尔值换成false即可。




但是不优雅,我们来看看有什么不优雅的:



  1. 配置的动态刷新是有延迟的。nacos的延迟是依赖于网络的;

  2. 不亲民。万一哪个开发改坏了配置,服务就是彻底的玩坏了;而且,如果业务想做一个动态的配置,任何人都可以在系统上点击开关,类似于下边的操作:


服务开关操作


element-UI的动态开关


nacos配置的方式直接不可行了!


那给予以上的问题,相信部分的伙伴已经思考到了:那我把配置放在redis中呗,内存数据库,直接用外部接口控制数据。


很好,这种想法打开了今天的设计思路。我们先协一点伪代码:


 @getMapping(value="switch") 
 public Integer switch() {
     Integer status = redisTemplate.get("key");
     if (status == 1) {
       status = 0;
    } else {
       status = 1;
    }
     redisTemplate.set("key", status);
     return status;
 }
 
 
 @getMapping(value= "pay")
 public Result pay() {
   Integer status = redisTemplate.get("key");
   if (status ==0) {
     throw new Bizexception("服务不可用");
  } else {
     doSometing();
  }
 }

貌似超级完美了,但是想过没有,业务的侵入很大呢。而且,万一我的业务拓展了,别的地方也需要这样的配置,岂不是直接复制粘贴?那就到此为止吧。




我觉得任何业务的设计都是需要去思考的,一味的写代码,做着CRUD的各种操作,简直是等着被AI取代吧。


那接下来分享shigen的设计,带着大家从我的视角分析我的思考和设计点、关注点。


代码设计


注解设计


 @Target(ElementType.METHOD)
 @Retention(RetentionPolicy.RUNTIME)
 public @interface ServiceSwitch {
 
     String switchKey();
 
     String message() default "当前业务已关闭,请稍后再试!";
 
 }

我在设计的时候,考虑到了不同的业务模块和失败的信息,这些都可以抽取出来,在使用的时候,直接加上注解即可。具体的方法和拦截,我们采用spring的AOP来做。


常量类


 public class Constants {
 
     public static final String ON = "1";
     public static final String OFF = "0";
 
     public static class Service {
 
         public static final String ORDER = "service-order";
         public static final String PAY = "service-pay";
    }
 
 }

既然涉及到了业务模块和状态值,那配置一个常量类是再合适不过了。


业务代码


   @ServiceSwitch(switchKey = Constants.Service.PAY)
   public Result pay() {
       log.info("paying now");
       return Result.success();
  }

业务代码上,我们肯定喜欢这样的设计,直接加上一个注解标注我们想要控制的模块。


请注意,核心点来了,我们注解的AOP怎么设计?


AOP设计


老方式,我们先看一下代码:


 @Aspect
 @Component
 @Slf4j
 public class ServiceSwitchAOP {
 
     @Resource
     private RedisTemplate redisTemplate;
 
     /**
      * 定义切点,使用了@ServiceSwitch注解的类或方法都拦截 需要用注解的全路径
      */

     @Pointcut("@annotation(main.java.com.shigen.redis.annotation.ServiceSwitch)")
     public void pointcut() {
    }
 
     @Around("pointcut()")
     public Object around(ProceedingJoinPoint point) {
 
         // 获取被代理的方法的参数
         Object[] args = point.getArgs();
         // 获取被代理的对象
         Object target = point.getTarget();
         // 获取通知签名
         MethodSignature signature = (MethodSignature) point.getSignature();
 
         try {
 
             // 获取被代理的方法
             Method method = target.getClass().getMethod(signature.getName(), signature.getParameterTypes());
             // 获取方法上的注解
             ServiceSwitch annotation = method.getAnnotation(ServiceSwitch.class);
 
             // 核心业务逻辑
             if (annotation != null) {
 
                 String switchKey = annotation.switchKey();
                 String message = annotation.message();
                 /**
                  * 配置项: 可以存储在mysql、redis 数据字典
                  */

                 String configVal = redisTemplate.opsForValue().get(switchKey);
                 if (Constants.OFF.equals(configVal)) {
                     // 开关关闭,则返回提示。
                     return new Result(HttpStatus.FORBIDDEN.value(), message);
                }
            }
 
             // 放行
             return point.proceed(args);
        } catch (Throwable e) {
             throw new RuntimeException(e.getMessage(), e);
        }
    }
 }

拦截我的注解,实现一个切点,之后通知切面进行操作。在切面的操作上,我们读取注解的配置,然后从redis中拿取对应的服务状态。如果服务的状态是关闭的,直接返回我们自定义的异常类型;服务正常的话,继续进行操作。


接口测试


最后,我写了两个接口实现了服务的调用和服务模块状态值的切换。


 @RestController
 @RequestMapping(value = "serviceSwitch")
 public class ServiceSwitchTestController {
 
     @Resource
     private ServiceSwitchService serviceSwitchService;
 
     @GetMapping(value = "pay")
     public Result pay() {
         return serviceSwitchService.pay();
    }
 
     @GetMapping(value = "switch")
     public Result serviceSwitch(@RequestParam(value = "status", required = false) String status) {
         serviceSwitchService.switchService(status);
         return Result.success();
    }
 }

代码测试


测试服务正常


服务状态正常情况下的测试


此时,redis中服务的状态值是1,服务也可以正常的调用。


测试服务不正常


我们先调用接口,改变服务的状态:


调用接口,切换服务的状态


再次调用服务:


服务模块关闭


发现服务403错误,已经不能调用了。我们改变一下状态,服务又可以用了,这里就不做展示了。


作者:shigen01
来源:juejin.cn/post/7301193497247055908
收起阅读 »

写给想入门单元测试的你

✨这里是第七人格的博客✨小七,欢迎您的到来~✨ 🍅系列专栏:【架构思想】🍅 ✈️本篇内容: 写给想入门单元测试的你✈️ 🍱本篇收录完整代码地址:gitee.com/diqirenge/s…🍱 一、为什么要进行单元测试 首先我们来看一下标准的软件开发流程是什么样...
继续阅读 »

✨这里是第七人格的博客✨小七,欢迎您的到来~✨


🍅系列专栏:【架构思想】🍅


✈️本篇内容: 写给想入门单元测试的你✈️


🍱本篇收录完整代码地址:gitee.com/diqirenge/s…🍱


一、为什么要进行单元测试


首先我们来看一下标准的软件开发流程是什么样的


01_开发流程规范.png
从图中我们可以看到,单元测试作为开发流程中的重要一环,其实是保证代码健壮性的重要一环,但是因为各种各样的原因,在日常开发中,我们往往不重视这一步,不写或者写的不太规范。那为什么要进行单元测试呢?小七觉得有以下几点:



  • 便于后期重构。单元测试可以为代码的重构提供保障,只要重构代码之后单元测试全部运行通过,那么在很大程度上表示这次重构没有引入新的BUG,当然这是建立在完整、有效的单元测试覆盖率的基础上。

  • 优化设计。编写单元测试将使用户从调用者的角度观察、思考,特别是使用TDD驱动开发的开发方式,会让使用者把程序设计成易于调用和可测试,并且解除软件中的耦合。

  • 文档记录。单元测试就是一种无价的文档,它是展示函数或类如何使用的最佳文档,这份文档是可编译、可运行的、并且它保持最新,永远与代码同步。

  • 具有回归性。自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地地快速运行测试,而不是将代码部署到设备之后,然后再手动地覆盖各种执行路径,这样的行为效率低下,浪费时间。


不少同学,写单元测试,就是直接调用的接口方法,就跟跑swagger和postMan一样,这样只是对当前方法有无错误做了一个验证,无法构成单元测试网络。


比如下面这种代码


@Test
public void Test1(){
xxxService.doSomeThing();
}

接下来小七就和大家探讨一下如何写好一个简单的单元测试。


小七觉得写好一个单元测试应该要注意以下几点:


1、单元测试是主要是关注测试方法的逻辑,而不仅仅是结果。


2、需要测试的方法,不应该依赖于其他的方法,也就是说每一个单元各自独立。


3、无论执行多少次,其结果是一定的不变的,也就是单元测试需要有幂等性。


4、单元测试也应该迭代维护。


二、单元测试需要引用的jar包


针对springboot项目,咱们只需要引用他的starter即可


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.1.0.RELEASE</version>
</dependency>

下面贴出这个start包含的依赖


<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starters</artifactId>
<version>2.1.0.RELEASE</version>
</parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.1.0.RELEASE</version>
<name>Spring Boot Test Starter</name>
<description>Starter for testing Spring Boot applications with libraries including
JUnit, Hamcrest and Mockito</description>
<url>https://projects.spring.io/spring-boot/#/spring-boot-parent/spring-boot-starters/spring-boot-starter-test</url>
<organization>
<name>Pivotal Software, Inc.</name>
<url>https://spring.io</url>
</organization>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0</url>
</license>
</licenses>
<developers>
<developer>
<name>Pivotal</name>
<email>info@pivotal.io</email>
<organization>Pivotal Software, Inc.</organization>
<organizationUrl>http://www.spring.io</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/spring-projects/spring-boot.git/spring-boot-starters/spring-boot-starter-test</connection>
<developerConnection>scm:git:ssh://git@github.com/spring-projects/spring-boot.git/spring-boot-starters/spring-boot-starter-test</developerConnection>
<url>http://github.com/spring-projects/spring-boot/spring-boot-starters/spring-boot-starter-test</url>
</scm>
<issueManagement>
<system>Github</system>
<url>https://github.com/spring-projects/spring-boot/issues</url>
</issueManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>2.1.0.RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<version>2.1.0.RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<version>2.1.0.RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<version>2.4.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.11.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>2.23.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
<version>1.3</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-library</artifactId>
<version>1.3</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.skyscreamer</groupId>
<artifactId>jsonassert</artifactId>
<version>1.5.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.1.2.RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.1.2.RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.6.2</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

三、单元测试解析与技巧


1、单元测试类注解解析


下面是出现频率极高的注解:


/*
* 这个注解的作用是,在执行单元测试的时候,不是直接去执行里面的单元测试的方法
* 因为那些方法执行之前,是需要做一些准备工作的,它是需要先初始化一个spring容器的
* 所以得找这个SpringRunner这个类,来先准备好spring容器,再执行各个测试方法
*/

@RunWith(SpringRunner.class)
/*
* 这个注解的作用是,去寻找一个标注了@SpringBootApplication注解的一个类,也就是启动类
* 然后会执行这个启动类的main方法,就可以创建spring容器,给后面的单元测试提供完整的这个环境
*/

@SpringBootTest
/*
* 这个注解的作用是,可以让每个方法都是放在一个事务里面
* 让单元测试方法执行的这些增删改的操作,都是一次性的
*/

@Transactional
/*
* 这个注解的作用是,如果产生异常那么会回滚,保证数据库数据的纯净
* 默认就是true
*/

@Rollback(true)

2、常用断言


Junit所有的断言都包含在 Assert 类中。


void assertEquals(boolean expected, boolean actual)检查两个变量或者等式是否平衡
void assertTrue(boolean expected, boolean actual)检查条件为真
void assertFalse(boolean condition)检查条件为假
void assertNotNull(Object object)检查对象不为空
void assertNull(Object object)检查对象为空
void assertArrayEquals(expectedArray, resultArray)检查两个数组是否相等
void assertSame(expected, actual)查看两个对象的引用是否相等。类似于使用“==”比较两个对象
assertNotSame(unexpected, actual)查看两个对象的引用是否不相等。类似于使用“!=”比较两个对象
fail()让测试失败
static T verify(T mock, VerificationMode mode)验证调用次数,一般用于void方法

3、有返回值方法的测试


@Test
public void haveReturn() {
// 1、初始化数据
// 2、模拟行为
// 3、调用方法
// 4、断言
}

4、无返回值方法的测试


@Test
public void noReturn() {
// 1、初始化数据
// 2、模拟行为
// 3、调用方法
// 4、验证执行次数
}

四、单元测试小例


以常见的SpringMVC3层架构为例,咱们分别展示3层架构如何做简单的单元测试。业务场景为用户user的增删改查。


(1)dao层的单元测试


dao层一般是持久化层,也就是与数据库打交道的一层,单元测试尽量不要依赖外部,但是直到最后一层的时候,DAO层的时候,还是要依靠开发环境里的基础设施,来进行单元测试。


@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
@Rollback
public class UserMapperTest {

/**
* 持久层,不需要使用模拟对象
*/

@Autowired
private UserMapper userMapper;

/**
* 测试用例:查询所有用户信息
*/

@Test
public void testListUsers() {
// 初始化数据
initUser(20);
// 调用方法
List<User> resultUsers = userMapper.listUsers();
// 断言不为空
assertNotNull(resultUsers);
// 断言size大于0
Assert.assertThat(resultUsers.size(), is(greaterThanOrEqualTo(0)));
}

/**
* 测试用例:根据ID查询一个用户
*/

@Test
public void testGetUserById() {
// 初始化数据
User user = initUser(20);
Long userId = user.getId();
// 调用方法
User resultUser = userMapper.getUserById(userId);
// 断言对象相等
assertEquals(user.toString(), resultUser.toString());
}

/**
* 测试用例:新增用户
*/

@Test
public void testSaveUser() {
initUser(20);
}

/**
* 测试用例:修改用户
*/

@Test
public void testUpdateUser() {
// 初始化数据
Integer oldAge = 20;
Integer newAge = 21;
User user = initUser(oldAge);
user.setAge(newAge);
// 调用方法
Boolean updateResult = userMapper.updateUser(user);
// 断言是否为真
assertTrue(updateResult);
// 调用方法
User updatedUser = userMapper.getUserById(user.getId());
// 断言是否相等
assertEquals(newAge, updatedUser.getAge());
}

/**
* 测试用例:删除用户
*/

@Test
public void testRemoveUser() {
// 初始化数据
User user = initUser(20);
// 调用方法
Boolean removeResult = userMapper.removeUser(user.getId());
// 断言是否为真
assertTrue(removeResult);
}

private User initUser(int i) {
// 初始化数据
User user = new User();
user.setName("测试用户");
user.setAge(i);
// 调用方法
userMapper.saveUser(user);
// 断言id不为空
assertNotNull(user.getId());
return user;
}
}

(2)service层的单元测试


@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceImplTest {

@Autowired
private UserService userService;

/**
* 这个注解表名,该对象是个mock对象,他将替换掉你@Autowired标记的对象
*/

@MockBean
private UserMapper userMapper;

/**
* 测试用例:查询所有用户信息
*/

@Test
public void testListUsers() {
// 初始化数据
List<User> users = new ArrayList<>();

User user = initUser(1L);

users.add(user);
// mock行为
when(userMapper.listUsers()).thenReturn(users);
// 调用方法
List<User> resultUsers = userService.listUsers();
// 断言是否相等
assertEquals(users, resultUsers);
}

/**
* 测试用例:根据ID查询一个用户
*/

@Test
public void testGetUserById() {
// 初始化数据
Long userId = 1L;

User user = initUser(userId);
// mock行为
when(userMapper.getUserById(userId)).thenReturn(user);
// 调用方法
User resultUser = userService.getUserById(userId);
// 断言是否相等
assertEquals(user, resultUser);

}

/**
* 测试用例:新增用户
*/

@Test
public void testSaveUser() {
// 初始化数据
User user = initUser(1L);
// 默认的行为(这一行可以不写)
doNothing().when(userMapper).saveUser(any());
// 调用方法
userService.saveUser(user);
// 验证执行次数
verify(userMapper, times(1)).saveUser(user);

}

/**
* 测试用例:修改用户
*/

@Test
public void testUpdateUser() {
// 初始化数据
User user = initUser(1L);
// 模拟行为
when(userMapper.updateUser(user)).thenReturn(true);
// 调用方法
Boolean updateResult = userService.updateUser(user);
// 断言是否为真
assertTrue(updateResult);
}

/**
* 测试用例:删除用户
*/

@Test
public void testRemoveUser() {
Long userId = 1L;
// 模拟行为
when(userMapper.removeUser(userId)).thenReturn(true);
// 调用方法
Boolean removeResult = userService.removeUser(userId);
// 断言是否为真
assertTrue(removeResult);
}

private User initUser(Long userId) {
User user = new User();
user.setName("测试用户");
user.setAge(20);
user.setId(userId);
return user;
}

}

(3)controller层的单元测试


@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class UserControllerTest {

private MockMvc mockMvc;

@InjectMocks
private UserController userController;

@MockBean
private UserService userService;

/**
* 前置方法,一般执行初始化代码
*/

@Before
public void setup() {

MockitoAnnotations.initMocks(this);

this.mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
}

/**
* 测试用例:查询所有用户信息
*/

@Test
public void testListUsers() {
try {
List<User> users = new ArrayList<User>();

User user = new User();
user.setId(1L);
user.setName("测试用户");
user.setAge(20);

users.add(user);

when(userService.listUsers()).thenReturn(users);

mockMvc.perform(get("/user/"))
.andExpect(content().json(JSONArray.toJSONString(users)));
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 测试用例:根据ID查询一个用户
*/

@Test
public void testGetUserById() {
try {
Long userId = 1L;

User user = new User();
user.setId(userId);
user.setName("测试用户");
user.setAge(20);

when(userService.getUserById(userId)).thenReturn(user);

mockMvc.perform(get("/user/{id}", userId))
.andExpect(content().json(JSONObject.toJSONString(user)));
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 测试用例:新增用户
*/

@Test
public void testSaveUser() {
Long userId = 1L;

User user = new User();
user.setName("测试用户");
user.setAge(20);

when(userService.saveUser(user)).thenReturn(userId);

try {
mockMvc.perform(post("/user/").contentType("application/json").content(JSONObject.toJSONString(user)))
.andExpect(content().string("success"));
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 测试用例:修改用户
*/

@Test
public void testUpdateUser() {
Long userId = 1L;

User user = new User();
user.setId(userId);
user.setName("测试用户");
user.setAge(20);

when(userService.updateUser(user)).thenReturn(true);

try {
mockMvc.perform(put("/user/{id}", userId).contentType("application/json").content(JSONObject.toJSONString(user)))
.andExpect(content().string("success"));
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 测试用例:删除用户
*/

@Test
public void testRemoveUser() {
Long userId = 1L;

when(userService.removeUser(userId)).thenReturn(true);

try {
mockMvc.perform(delete("/user/{id}", userId))
.andExpect(content().string("success"));
} catch (Exception e) {
e.printStackTrace();
}
}

}


五、其他


1、小七认为不需要对私有方法进行单元测试。


2、dubbo的接口,在初始化的时候会被dubbo的类代理,和单测的mock是两个类,会导致mock失效,目前还没有找到好的解决方案。


3、单元测试覆盖率报告


(1)添加依赖


<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.2</version>
</dependency>


(2)添加插件


<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.2</version>
<executions>
<execution>
<id>pre-test</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>post-test</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>


(3)执行mvn test命令


报告生成位置


image.png


4、异常测试


本次分享主要是针对正向流程,异常情况未做处理。感兴趣的同学可以查看附录相关文档自己学习。


六、附录


1、user建表语句:


CREATE TABLE `user` (
`id` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
`name` VARCHAR(32) NOT NULL UNIQUE COMMENT '用户名',
`age` INT(3) NOT NULL COMMENT '年龄'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='user示例表';

2、文章小例源码地址:gitee.com/diqirenge/s…


3、mockito官网:site.mockito.org/


4、mockito中文文档:github.com/hehonghui/m…


作者:第七人格
来源:juejin.cn/post/7297608084306821132
收起阅读 »

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

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

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


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


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



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



为什么数据库会慢?


慢的本质:


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

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



  • 查找的时间复杂度

  • 数据总量

  • 高负载


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



  • 查找算法

  • 存储数据结构


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


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


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



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



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


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


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


八大方案总结



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


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


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


减少数据量


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


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



数据归档



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



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



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



中间表(结果表)



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


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



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

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


数据序列化存储




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


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


分库分表


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


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



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

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

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

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


拆分方式



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


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


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




路由方式



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


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


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


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



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


用空间换性能


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


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


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



分布式缓存



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


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



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



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



避免滥用缓存


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



避免缓存击穿


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


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


不是所有慢查询都适用


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


一主多从



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


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



选择合适的存储系统


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


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


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



CQRS


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



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


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


数据同步方式



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


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


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


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


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



替换(选择)存储系统


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


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


结束


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


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


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

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

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

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


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


怎么解决这个问题呢?


我想到了几种方法:


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


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


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



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


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


别笑,我认真的!


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


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


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


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


企微群聊机器人开发


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



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




设计 SDK 结构


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


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


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



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



企微文本消息格式


企微文本消息格式


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


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


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



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


开发 SDK


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


步骤如下:



  1. 获取 webhook

  2. 创建 SDK 项目

  3. 编写代码

  4. SDK 打包

  5. 调用 SDK


1、获取 webhook


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


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



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



2、创建 SDK 项目


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


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



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

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

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


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


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

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


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


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

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


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

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


3、编写配置类


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


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


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


示例代码如下:


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

    private String webhook;

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

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


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

4、编写消息类


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


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


public class Message {

    /**
     * 消息类型
     **/

    String msgtype;
}

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


@Data
public class TextMessage extends Message {

    /**
     * 消息内容
     */

    private String content;

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

    private List<String> mentionedList;

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

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

    private Boolean mentionAll = false;

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

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

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

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


5、编写消息发送类


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


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


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

@Slf4j
@Data
public class RtxRobotMessageSender {

    private final String webhook;
  
    public WebhookConfig webhookConfig;

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

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

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

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

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

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


6、SDK 打包


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


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



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




7、调用 SDK


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


引入代码如下:


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

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


wechatwork-bot:
  webhook: 你的webhook地址

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


@Resource
public RtxRobotMessageSender rtxRobotMessageSender;

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


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

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


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


示例代码如下:


@Component
public class WaterReminderTask {

    @Resource
    public RtxRobotMessageSender rtxRobotMessageSender;

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

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

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



最后


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


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


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

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

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

你好呀,我是歪歪。


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


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


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


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


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


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


Demo


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


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



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



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




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


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



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


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



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


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


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



上菜


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



这是提供者的代码:



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



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


没有!


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


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


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


合理,非常合理。


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


比如这样:



反应过来没有?


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


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


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



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



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


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


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



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


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



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




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


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



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


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



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


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


你怎么办?


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



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


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



tomcat.apache.org/tomcat-9.0-…




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



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


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


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



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


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


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


但是,你考虑过下游吗?


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



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


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


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


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


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


什么时候使用线程池呢?


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



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


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



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


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



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


这已经不是一个概念了。


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


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



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


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

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


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


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


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


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


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



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


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


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


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


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


荒腔走板



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


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


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


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


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


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


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


现在,不一样了。


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


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


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


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


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

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

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

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



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


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



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



前言


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


摘要


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


Java之弱引用


简介


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


源代码解析


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


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

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


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


  如下是部分源码截图:


在这里插入图片描述


应用场景案例


缓存


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


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

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

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


监听器


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


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

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

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

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

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


优缺点分析


优点



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

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

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


缺点



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

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


类代码方法介绍


WeakReference类


构造方法


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

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


get()方法


public T get();

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


ReferenceQueue类


构造方法


public ReferenceQueue();

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


poll()方法


public Reference<? extends T> poll();

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


测试用例


测试代码演示


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

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

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

public class WeakReferenceTest {

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

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

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

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

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

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

public String getId() {
return id;
}

public String getName() {
return name;
}
}

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

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

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

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

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

测试结果


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


在这里插入图片描述


测试代码分析


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


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


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


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


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


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


全文小结


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


总结


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


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


在使用弱引用时,我们可以使用WeakReference类来实现,并通过get()方法获取弱引用所包装的对象。同时,我们也可以使用ReferenceQueue类来关联弱引用,当目标对象被回收时,该队列会触发一个通知。


但是弱引用也有其缺点,例如对象可能被提前回收,这可能会导致某些操作失败,同时弱引用也需要额外的开销,会对程序的性能产生一定的影响。


因此,在使用弱引用时,我们需要根据具体场景具体分析,权衡其优缺点,选择合适的引用类型来进行内存管理。


... ...


文末


好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。


... ...


学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!


wished for you successed !!!




⭐️若喜欢我,就请关注我叭。


⭐️若对您有用,就请点赞叭。


⭐️若有疑问,就请评论留言告诉我叭。


作者:喵手
来源:juejin.cn/post/7299659033970851875
收起阅读 »

码农如何提高自己的品味

作者:京东科技 文涛 前言 软件研发工程师俗称程序员经常对业界外的人自谦作码农,一来给自己不菲的收入找个不错的说辞(像农民伯伯那样辛勤耕耘挣来的血汗钱),二来也是自嘲这个行业确实辛苦,辛苦得没时间捯饬,甚至没有驼背、脱发加持都说不过去。不过时间久了,行外人还真...
继续阅读 »

作者:京东科技 文涛


前言


软件研发工程师俗称程序员经常对业界外的人自谦作码农,一来给自己不菲的收入找个不错的说辞(像农民伯伯那样辛勤耕耘挣来的血汗钱),二来也是自嘲这个行业确实辛苦,辛苦得没时间捯饬,甚至没有驼背、脱发加持都说不过去。不过时间久了,行外人还真就相信了程序员就是一帮没品味,木讷的low货,大部分的文艺作品中也都是这么表现程序员的。可是我今天要说一下我的感受,编程是个艺术活,程序员是最聪明的一群人,我们的品味也可以像艺术家一样。


言归正转,你是不是以为我今天要教你穿搭?不不不,这依然是一篇技术文章,想学穿搭女士学陈舒婷(《狂飙》中的大嫂),男士找陈舒婷那样的女朋友就好了。笔者今天教你怎样有“品味”的写代码。



以下几点可提升“品味”


说明:以下是笔者的经验之谈具有部分主观性,不赞同的欢迎拍砖,要想体系化提升编码功底建议读《XX公司Java编码规范》、《Effective Java》、《代码整洁之道》。以下几点部分具有通用性,部分仅限于java语言,其它语言的同学绕过即可。


优雅防重


关于成体系的防重讲解,笔者之后打算写一篇文章介绍,今天只讲一种优雅的方式:


如果你的业务场景满足以下两个条件:


1 业务接口重复调用的概率不是很高


2 入参有明确业务主键如:订单ID,商品ID,文章ID,运单ID等


在这种场景下,非常适合乐观防重,思路就是代码处理不主动做防重,只在监测到重复提交后做相应处理。


如何监测到重复提交呢?MySQL唯一索引 + org.springframework.dao.DuplicateKeyException


代码如下:


public int createContent(ContentOverviewEntity contentEntity) {
try{
return contentOverviewRepository.createContent(contentEntity);
}catch (DuplicateKeyException dke){
log.warn("repeat content:{}",contentEntity.toString());
}
return 0;
}

用好lambda表达式


lambda表达式已经是一个老生常谈的话题了,笔者认为,初级程序员向中级进阶的必经之路就是攻克lambda表达式,lambda表达式和面向对象编程是两个编程理念,《架构整洁之道》里曾提到有三种编程范式,结构化编程(面向过程编程)、面向对象编程、函数式编程。初次接触lambda表达式肯定特别不适应,但如果熟悉以后你将打开一个编程方式的新思路。本文不讲lambda,只讲如下例子:


比如你想把一个二维表数据进行分组,可采用以下一行代码实现


List<ActionAggregation> actAggs = ....
Map<String, List<ActionAggregation>> collect =
actAggs.stream()
.collect(Collectors.groupingBy(ActionAggregation :: containWoNosStr,LinkedHashMap::new,Collectors.toList()));

用好卫语句


各个大场的JAVA编程规范里基本都有这条建议,但我见过的代码里,把它用好的不多,卫语句对提升代码的可维护性有着很大的作用,想像一下,在一个10层if 缩进的接口里找代码逻辑是一件多么痛苦的事情,有人说,哪有10层的缩进啊,别说,笔者还真的在一个微服务里的一个核心接口看到了这种代码,该接口被过多的人接手导致了这样的局面。系统接手人过多以后,代码腐化的速度超出你的想像。


下面举例说明:


没有用卫语句的代码,很多层缩进


if (title.equals(newTitle)){
if (...) {
if (...) {
if (...) {

}
}else{

}
}else{

}
}

使用了卫语句的代码,缩进很少


if (!title.equals(newTitle)) {
return xxx;
}
if (...) {
return xxx;
}else{
return yyy;
}
if (...) {
return zzz;
}

避免双重循环


简单说双重循环会将代码逻辑的时间复杂度扩大至O(n^2)


如果有按key匹配两个列表的场景建议使用以下方式:


1 将列表1 进行map化


2 循环列表2,从map中获取值


代码示例如下:


List<WorkOrderChain> allPre = ...
List<WorkOrderChain> chains = ...
Map<String, WorkOrderChain> preMap = allPre.stream().collect(Collectors.toMap(WorkOrderChain::getWoNext, item -> item,(v1, v2)->v1));
chains.forEach(item->{
WorkOrderChain preWo = preMap.get(item.getWoNo());
if (preWo!=null){
item.setIsHead(1);
}else{
item.setIsHead(0);
}
});

@see @link来设计RPC的API


程序员们还经常自嘲的几个词有:API工程师,中间件装配工等,既然咱平时写API写的比较多,那种就把它写到极致**@see @link**的作用是让使用方可以方便的链接到枚举类型的对象上,方便阅读


示例如下:


@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContentProcessDto implements Serializable {
/**
* 内容ID
*/

private String contentId;
/**
* @see com.jd.jr.community.common.enums.ContentTypeEnum
*/

private Integer contentType;
/**
* @see com.jd.jr.community.common.enums.ContentQualityGradeEnum
*/

private Integer qualityGrade;
}

日志打印避免只打整个参数


研发经常为了省事,直接将入参这样打印


log.info("operateRelationParam:{}", JSONObject.toJSONString(request));

该日志进了日志系统后,研发在搜索日志的时候,很难根据业务主键排查问题


如果改进成以下方式,便可方便的进行日志搜索


log.info("operateRelationParam,id:{},req:{}", request.getId(),JSONObject.toJSONString(request));

如上:只需要全词匹配“operateRelationParam,id:111”,即可找到业务主键111的业务日志。


用异常捕获替代方法参数传递


我们经常面对的一种情况是:从子方法中获取返回的值来标识程序接下来的走向,这种方式笔者认为不够优雅。


举例:以下代码paramCheck和deleteContent方法,返回了这两个方法的执行结果,调用方通过返回结果判断程序走向


public RpcResult<String> deleteContent(ContentOptDto contentOptDto) {
log.info("deleteContentParam:{}", contentOptDto.toString());
try{
RpcResult<?> paramCheckRet = this.paramCheck(contentOptDto);
if (paramCheckRet.isSgmFail()){
return RpcResult.getSgmFail("非法参数:"+paramCheckRet.getMsg());
}
ContentOverviewEntity contentEntity = DozerMapperUtil.map(contentOptDto,ContentOverviewEntity.class);
RpcResult<?> delRet = contentEventHandleAbility.deleteContent(contentEntity);
if (delRet.isSgmFail()){
return RpcResult.getSgmFail("业务处理异常:"+delRet.getMsg());
}
}catch (Exception e){
log.error("deleteContent exception:",e);
return RpcResult.getSgmFail("内部处理错误");
}
return RpcResult.getSgmSuccess();
}



我们可以通过自定义异常的方式解决:子方法抛出不同的异常,调用方catch不同异常以便进行不同逻辑的处理,这样调用方特别清爽,不必做返回结果判断


代码示例如下:


public RpcResult<String> deleteContent(ContentOptDto contentOptDto) {
log.info("deleteContentParam:{}", contentOptDto.toString());
try{
this.paramCheck(contentOptDto);
ContentOverviewEntity contentEntity = DozerMapperUtil.map(contentOptDto,ContentOverviewEntity.class);
contentEventHandleAbility.deleteContent(contentEntity);
}catch(IllegalStateException pe){
log.error("deleteContentParam error:"+pe.getMessage(),pe);
return RpcResult.getSgmFail("非法参数:"+pe.getMessage());
}catch(BusinessException be){
log.error("deleteContentBusiness error:"+be.getMessage(),be);
return RpcResult.getSgmFail("业务处理异常:"+be.getMessage());
}catch (Exception e){
log.error("deleteContent exception:",e);
return RpcResult.getSgmFail("内部处理错误");
}
return RpcResult.getSgmSuccess();
}

自定义SpringBoot的Banner


别再让你的Spring Boot启动banner千篇一律,spring 支持自定义banner,该技能对业务功能实现没任何卵用,但会给枯燥的编程生活添加一点乐趣。


以下是官方文档的说明: docs.spring.io/spring-boot…


另外你还需要ASCII艺术字生成工具: tools.kalvinbg.cn/txt/ascii


效果如下:


   _ _                   _                     _                 _       
(_|_)_ __ __ _ __| | ___ _ __ __ _ | |__ ___ ___ | |_ ___
| | | '_ \ / _` | / _` |/ _ \| '_ \ / _` | | '_ \ / _ \ / _ \| __/ __|
| | | | | | (_| | | (_| | (_) | | | | (_| | | |_) | (_) | (_) | |_\__ \
_/ |_|_| |_|\__, | \__,_|\___/|_| |_|\__, | |_.__/ \___/ \___/ \__|___/
|__/ |___/ |___/

多用Java语法糖


编程语言中java的语法是相对繁琐的,用过golang的或scala的人感觉特别明显。java提供了10多种语法糖,写代码常使用语法糖,给人一种 “这哥们java用得通透” 的感觉。


举例:try-with-resource语法,当一个外部资源的句柄对象实现了AutoCloseable接口,JDK7中便可以利用try-with-resource语法更优雅的关闭资源,消除板式代码。


try (FileInputStream inputStream = new FileInputStream(new File("test"))) {
System.out.println(inputStream.read());
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}

利用链式编程


链式编程,也叫级联式编程,调用对象的函数时返回一个this对象指向对象本身,达到链式效果,可以级联调用。链式编程的优点是:编程性强、可读性强、代码简洁。


举例:假如觉得官方提供的容器不够方便,可以自定义,代码如下,但更建议使用开源的经过验证的类库如guava包中的工具类


/**
链式map
*/

public class ChainMap<K,V> {
private Map<K,V> innerMap = new HashMap<>();
public V get(K key) {
return innerMap.get(key);
}

public ChainMap<K,V> chainPut(K key, V value) {
innerMap.put(key, value);
return this;
}

public static void main(String[] args) {
ChainMap<String,Object> chainMap = new ChainMap<>();
chainMap.chainPut("a","1")
.chainPut("b","2")
.chainPut("c","3");
}
}

未完,待续,欢迎评论区补充


作者:京东云开发者
来源:juejin.cn/post/7197604280705908793
收起阅读 »

DDD学习与感悟——总是觉得自己在CRUD怎么办?

一、DDD是什么? DDD全名叫做Dominos drives Design;领域驱动设计。再说的通俗一点就是:通过领域建模的方式来实现软件设计。 问题来了:什么是软件设计?为什么要进行软件设计? 软件开发最主要的目的就是:解决一个问题(业务)而产生的一个交付...
继续阅读 »

一、DDD是什么?


DDD全名叫做Dominos drives Design;领域驱动设计。再说的通俗一点就是:通过领域建模的方式来实现软件设计。


问题来了:什么是软件设计?为什么要进行软件设计?


软件开发最主要的目的就是:解决一个问题(业务)而产生的一个交付物(系统)。而软件设计旨在高效的实现复杂项目软件。也就是说软件设计是从业务到系统之间的桥梁。


而DDD则是在复杂业务场景下一种更高效更合理的软件设计思维方式和方法论。


二、以前的软件设计思维是什么?


绝大部分从事软件开发的人,不管是在学校还是刚开始工作,都是从ER图开始。即直接通过业务设计数据库模型和数据关联关系。这种思维根深蒂固的印在了这些人的头脑里(包括我自己)。因此在软件设计过程中习惯性的直接将业务转化为数据模型,面向数据开发。也就是我们所说的CRUD。我们有时候也会看到一些博客看到或者听到一些同事在说:这个业务有什么难的,不就是CRUD么?


不可否认的是,在软件生命周期初期,通过CRUD这种方式我们可以快速的实现业务规则,交付项目。然而一个系统的生命周期是很长的并且维护阶段的生命周期占绝大部分比例。
随着业务的发展,业务规则越来越复杂,通过CRUD这种粗暴方式,让工程代码越来越复杂,通常一个方法可能会出现几百甚至上千行代码,各种胶水代码和业务逻辑混合在一起,导致很难理解。



这种系统交接给另一个同学或者新进来的同学后,可能需要花费很长的时间才能理解这个方法,原因就是因为这种胶水代码淹没了业务核心规则。所以在现实场景中,我们经常会听到,上一个开发是SB,或者自嘲自己是在屎山上面继续堆屎。



三、DDD思想下的软件设计


DDD的思想是基于领域模型来实现软件设计。那么,什么是领域模型?领域模型怎么得来呢?


DDD思想,将软件的复杂程度提前到了设计阶段。基于DDD思想,我们的设计方式完全变了。


统一语言


首先,将业务方、领域专家以及相关的产研人员都聚拢在一起,共同探讨出业务场景和要解决的问题,统一语言。来确保所有人对于业务的理解都是一致的。



这里的统一语言不是指某种具体的技术语言,而是一种业务规则语言。所有人必须要能够理解这种统一语言。



战略设计


其次,我们根据待解决的问题空间,进行战略设计。所谓的战略设计就是根据问题空间在宏观层面识别出限界上下文。比如说一个电商业务,我们需要交付一个电商系统,根据电商业务的特点,需要划分出用户、商品、订单、仓储等限界上下文,每一个限界上下文都是一个独立的业务单元,具有完整的业务规则。


识别领域模型


然后,再分别针对上下文内的业务领域进行建模,得到领域模型。在DDD思想中,领域模型中通常包含实体、值对象、事件、领域服务等概念。我们可以通过“事件风暴”的方式来识别出这些概念。



注意,“事件风暴”和“头脑风暴”是有区别的。“头脑风暴”的主要目的是通过发散思维进行创新,而“事件风暴”是DDD中的概念,其主要目的是所有人一起根据统一语言和业务规则识别出事件。再根据事件识别出实体、值对象、领域服务、指令、业务流等领域模型中的概念。




所谓事件指的是已经发生了的事情。比如用户下了一个订单、用户取消了订单、用户支付了订单等



根据事件,我们可以识别出实体,比如上面这个例子中的订单实体,以及指令:取消、支付、下单等。


程序设计


识别出领域模型之后,我们就可以根据领域模型来指导我们进行程序设计了。这里的程序设计包括业务架构、数据架构、核心业务流程、系统架构、部署架构等。需要注意的是,在进行程序设计时,我们依然要遵循DDD中的设计规范。否则很容易走偏方向。


编写代码


有了完整的程序设计之后,我们就可以进行实际的工程搭建以及代码编写了。


这个阶段需要注意的是,我们需要遵循DDD思想中的架构设计和代码设计。实际上这个阶段也是非常困难的。因为基于DDD思想下的工程架构和我们传统的工程架构不一样。



基于DDD思想下,编码过程中我们经常会遇到的一个问题是:这个代码应该放在哪里合适。



工程结构


在DDD中,标准的工程结构分为4层。用户接口层、应用层、领域层和基础设施层。


截屏2023-06-22 18.00.33.png

DDD中,构建软件结构思维有六边形架构、CQRS架构等,它们是一种思想,是从逻辑层面对工程结构进行划分,而我们熟知的SOA架构以及微服务架构是从物理逻辑层面对工程结构进行划分,它们有着本质的区别,但是目标都是一样的:构建可维护、可扩展、可测试的软件系统。


代码编写


在DDD中,最为复杂的便是领域层,所有的业务逻辑和规则都在这里实现。因此我们经常会遇到一个问题就是代码应该放在哪里。


在具体落地过程中会遇到这些问题,解决这些问题没有银弹,因为不同的业务有不同的处理方式,这个时候我们需要与领域专家们讨论,得出大家都满意的处理方案。


代码重构


没有不变的业务。因此我们需要结合业务的发展而不断迭代更新我们的领域模型,通过重构的方式来挖掘隐形概念,再根据这些隐形概念去不断的调整我们的战略设计以及领域模型。使得整个软件系统的发展也是螺旋式迭代更新的过程。


通过以上的介绍,我们实现DDD的过程如下:


截屏2023-06-22 18.14.26.png


四、总结


通过对于DDD的理解,其实不难发现,程序员的工作重心变了,程序员其实不是在编写代码,而是在不断的摸索业务领域知识,尤其是复杂业务。


所以如果你总是觉得自己在CRUD,有可能不是你做的业务没价值,而是自己对于业务的理解还不够深;如果你总是沉迷于代码编写,可能你的发展空间就会受限了。


作者:浪漫先生
来源:juejin.cn/post/7299741943441457192
收起阅读 »

没用过微服务?别慌,丐版架构图,让你轻松拿捏面试官

大家好,我是哪吒。 很多人都说现在是云原生、大模型的时代,微服务已经过时了,但现实的是,很多人开发多年,都没有在实际的开发中用过微服务,更别提搭建微服务框架和技术选型了。 面试的时候都会问,怎么办? 今天分享一张微服务的丐版架构图,让你可以和面试官掰扯掰扯~ ...
继续阅读 »

大家好,我是哪吒。


很多人都说现在是云原生、大模型的时代,微服务已经过时了,但现实的是,很多人开发多年,都没有在实际的开发中用过微服务,更别提搭建微服务框架和技术选型了。


面试的时候都会问,怎么办?


今天分享一张微服务的丐版架构图,让你可以和面试官掰扯掰扯~


脑中有图,口若悬河,一套组合拳下来,面试官只能拍案叫好,大呼快哉,HR更是惊呼,我勒个乖乖,完全听不懂。


话不多说,直接上图。



由此可见,Spring Cloud微服务架构是由多个组件一起组成的,各个组件的交互流程如下。



  1. 浏览器通过查询DNS服务器,获取可用的服务实例的网络位置信息,从而实现服务的自动发现和动态更新;

  2. 通过CDN获取静态资源,提高访问速度,解决跨地域请求速度慢的问题;

  3. 通过LVS负载均衡器,实现负载均衡和网络协议;

  4. 通过Nginx反向代理服务器,将请求转发到gateway做路由转发和安全验证​;

  5. 访问注册中心和​配置中心Nacos,获取后端服务和配置项;

  6. 通过Sentinel进行限流;

  7. 通过Redis进行缓存服务、会话管理、分布式锁控制;

  8. 通过Elasticsearch进行全文搜索,存储日志,配合Kibana,对ES中的数据进行实时的可视化分析​。


一、域名系统DNS


在微服务中,域名系统DNS的作用主要是进行服务发现和负载均衡。



  1. 每个微服务实例在启动时,将自己的IP地址和端口号等信息注册到DNS服务器,浏览器通过查询DNS服务器,获取可用的服务实例的网络位置信息,从而实现服务的自动发现和动态更新。

  2. DNS服务器可以根据一定的策略,比如轮询、随机等,将请求分发到不同的负载均衡器LVS上,提高系统的并发处理能力和容错性。


二、LVS(Linux Virtual Server),Linux虚拟服务器


LVS是一个开源的负载均衡软件,基于Linux操作系统实现。它在Linux内核中实现负载均衡的功能,通过运行在用户空间的用户进程实现负载均衡的策略。



  1. LVS支持多种负载均衡算法,例如轮询、随机、加权轮询、加权随机等。

  2. LVS支持多种网络协议,例如TCP、HTTP、HTTPS,可以满足不同应用的需求。

  3. LVS具有高可用和可扩展性。它支持主从备份和冗余配置,当主服务器出现故障时,备份服务器可以自动接管负载,确保服务的连续性。此外,LVS还支持动态添加和删除服务器节点,方便管理员进行扩容和缩容的操作。


三、CDN静态资源


CDN静态资源图片、视频、JavaScript文件、CSS文件、静态HTML文件等。这些静态资源的特点是读请求量极大,对访问速度的要求很高,并占据了很高的宽带。如果处理不当,可能导致访问速度慢,宽带被占满,进而影响动态请求的处理。


CDN的作用是将这些静态资源分发到多个地理位置的机房的服务器上。让用户就近选择访问,提高访问速度,解决跨地域请求速度慢的问题。


四、Nginx反向代理服务器


1、Nginx的主要作用体现在以下几个方面:



  1. 反向代理,Nginx可以作为反向代理服务器,接收来自客户端的请求,然后将请求转发到后端的微服务实例。

  2. 负载均衡,Nginx可以根据配置,将请求分发到微服务不同的实例上,实现负载均衡。

  3. 服务路由,Nginx可以根据不同的路径规则,将请求路由到不同的微服务上。

  4. 静态资源服务,Nginx可以提供静态资源服务,如图片、视频、JavaScript文件、CSS文件、HTML静态文件等,减轻后端服务的压力,提高系统的响应速度和性能。


2、Nginx静态资源服务和CDN静态资源服务,如何选择?


在选择Nginx静态资源服务和CDN静态资源服务时,可以根据以下几个因素进行权衡和选择:



  1. 性能和速度:CDN静态资源服务通常具有更广泛的分布式节点和缓存机制,可以更快地响应用户的请求,并减少传输距离和网络拥塞。如果静态资源的加载速度和性能是首要考虑因素,CDN可能是更好的选择。

  2. 控制和自定义能力:Nginx静态资源服务提供更高的灵活性和控制能力,可以根据具体需求进行定制和配置。如果需要更精细的控制和自定义能力,或者在特定的网络环境下进行部署,Nginx可能更适合。

  3. 成本和预算:CDN静态资源服务通常需要支付额外的费用,而Nginx静态资源服务可以自行搭建和部署,成本相对较低。在考虑选择时,需要综合考虑成本和预算的因素。

  4. 内容分发和全球覆盖:如果静态资源需要分发到全球各地的用户,CDN静态资源服务的分布式节点可以更好地满足这个需求,提供更广泛的内容分发和全球覆盖。


选择Nginx静态资源服务还是CDN静态资源服务取决于具体的需求和场景。如果追求更好的性能和全球覆盖,可以选择CDN静态资源服务;如果更需要控制和自定义能力,且对性能要求不是特别高,可以选择Nginx静态资源服务。


五、Gateway网关


在微服务架构中,Gateway的作用如下:



  1. 统一入口:Gateway作为整个微服务架构的统一入口,所有的请求都会经过Gateway,这样做可以隐藏内部微服务的细节,降低后台服务受攻击的概率;

  2. 路由和转发:Gateway根据请求的路径、参数等信息,将请求路由到相应的微服务实例。这样可以让服务解耦,让各个微服务可以独立的开发、测试、部署;

  3. 安全和认证:Gateway通常集成了身份验证和权限验证的功能,确保只有经过验证的请求才能访问微服务。Gateway还具备防爬虫、限流、熔断的功能;

  4. 协议转换:由于微服务架构中可以使用不同的技术和协议,Gateway可以作为协议转换中心,实现不同协议之间的转换和兼容性;

  5. 日志和监控,Gateway可以记录所有的请求和响应日志,为后续的故障排查、性能分析、安全审计提供数据支持。Gateway还集成了监控和报警功能:实时反馈系统的运行状态;

  6. 服务聚合:在某些场景中,Gateway可以将来自多个微服务的数据进行聚合,然后一次性返回给客户端,减少客户端和微服务之间的交互次数,提高系统性能;


六、注册中心Nacos


在微服务架构中,Nacos的作用主要体现在注册中心、配置中心、服务健康检查等方面。



  1. 注册中心:Nacos支持基于DNS和RPC的服务发现,微服务可以将接口服务注册到Nacos中,客户端通过nacos查找和调用这些服务实例。

  2. 配置中心:Nacos提供了动态配置服务,可以动态的修改配置中心中的配置项,不需要重启后台服务,即可完成配置的修改和发布,提高了系统的灵活性和可维护性。

  3. 服务健康检查:Nacos提供了一系列的服务治理功能,比如服务健康检查、负载均衡、容错处理等。服务健康检查可以阻止向不健康的主机或服务实例发送请求,保证了服务的稳定性和可靠性。负载均衡可以根据一定的策略,将请求分发到不同的服务实例中,提高系统的并发处理能力和性能。


七、Redis缓存


1、在微服务架构中,Redis的作用主要体现在以下几个方面:



  1. 缓存服务:Redis可以作为高速缓存服务器,将常用的数据存储在内存中,提高数据访问速度和响应时间,减轻数据库的访问压力,并加速后台数据的查询;

  2. 会话管理:Redis可以存储会话信息,并实现分布式会话管理。这使会话信息可以在多个服务之间共享和访问,提供一致的用户体验;

  3. 分布式锁:Redis提供了分布式锁机制,可以确保微服务中多个节点对共享资源的访问的合理性和有序性,避免竞态条件和资源冲突;

  4. 消息队列:Redis支持发布订阅模式和消息队列模式,可以作为消息中间件使用。微服务之间可以通过Redis实现异步通信,实现解耦和高可用性;


2、竞态条件


竞态条件是指在同一个程序的多线程访问同一个资源的情况下,如果对资源的访问顺序敏感,就存在竞态条件。


竞态条件可能会导致执行结果出现各种问题,例如计算机死机、出现非法操作提示并结束程序、错误的读取旧的数据或错误的写入新数据。在串行的内存和存储访问能防止这种情况,当读写命令同时发生的时候,默认是先执行读操作的。


竞态条件也可能在网络中出现,当两个用户同时试图访问同一个可用信道的时候就会发生,系统同意访问之前没有计算机能得到信道被占用的提示。统计上说这种情况通常是发生在有相当长的延迟时间的网络里,比如使用地球同步卫星。


为了防止这种竞态条件发生,需要制定优先级列表,比如用户的用户名在字母表里排列靠前可以得到相对较高的优先级。黑客可以利用竞态条件这一弱点来赢得非法访问网络的权利。


竞态条件是由于多个线程或多个进程同时访问共享资源而引发的问题,它可能会导致不可预测的结果和不一致的状态。解决竞态条件的方法包括使用锁、同步机制、优先级列表等。


3、Redis会话管理如何实现?


Redis会话管理的一般实现步骤:



  1. 会话创建:当用户首次访问应用时,可以在Redis中创建一个新的会话,会话可以是一个具有唯一标识符的数据结构,例如哈希表或字符串;

  2. 会话信息存储:将会话信息关联到会话ID存储到Redis中,会话信息可以包括用户身份、登录状态、权限等。

  3. 会话过期时间设置:为会话设置过期时间,以确保会话在一定时间后自动失效。Redis提供了设置键值对过期时间的机制,可以通过EXPIRE命令为会话设置过期时间;

  4. 会话访问和更新:在每次用户访问应用时,通过会话ID获取相应的会话信息,并对其进行验证和更新。如果会话已过期,可以要求用户重新登录;

  5. 会话销毁:当用户主动退出或会话到期后,需要销毁会话,通过删除Redis中存储的会话信息即可。


八、Elasticsearch全文搜索引擎


在微服务架构中,Elasticsearch全文搜索引擎的应用主要体现在如下几个方面:



  1. 全文搜索引擎:ES是一个分布式的全文搜索引擎,它可以对海量的数据进行实时的全文搜索,返回与关键词相关的结果;

  2. 分布式存储:ES提供了分布式的实时文件存储功能,每个字段都可以被索引并可被搜索,这使得数据在ES中的存储和查询都非常高效;

  3. 数据分析:配合Kibana,对ES中的数据进行实时的可视化分析,为数据决策提供数据支持;

  4. 日志和监控:ES可以作为日志和监控数据的存储和分析平台。通过收集系统的日志信息,存入ES,可以实现实时的日志查询、分析、告警、展示;

  5. 扩展性:ES具有很好的扩展性,可以水平扩展到数百台服务器,处理PB级别的数据,使得ES能够应对海量数据的挑战。


九、感觉Redis和Elasticsearch很像?微服务中Redis和Elasticsearch的区别



  1. 数据存储和查询方式:Redis是一种基于键值对的存储系统,它提供高性能的读写操作,适用于存储结构简单、查询条件同样简单的应用场景。而Elasticsearch是一个分布式搜索和分析引擎,适用于全文搜索、数据分析等复杂场景,能够处理更复杂的查询需求;

  2. 数据结构与处理能力:Redis支持丰富的数据结构,如字符串、哈希、列表、集合等,并提供了原子性的操作,适用于实现缓存、消息队列、计数器等功能。而Elasticsearch则是基于倒排索引的数据结构,提供了强大的搜索和分析能力。但相对于Redis,Elasticsearch的写入效率较低;

  3. 实时性和一致性:Redis提供了很高的实时性,Redis将数据存储到内存中,能够很快的进行读写操作;而Elasticsearch是一个近实时的搜索平台,实时性不如Redis;

  4. 扩展性:Redis是通过增加Redis实例的形式实现扩展,对非常大的数据集可能要进行数据分片;而Elasticsearch具有水平扩展的能力,可以通过添加更多的节点来提高系统的处理能力,适用于大量数据的场景;



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

如何正确遍历删除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
收起阅读 »

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

从源码角度解读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
收起阅读 »

听说你会架构设计?来,弄一个群聊系统

大家好,我是小❤,一个漂泊江湖多年的 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
收起阅读 »

回顾我这三年,都是泡沫

昨天,一个在掘金认识的小伙伴,进入了美团专门做 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
收起阅读 »

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

项目上线时,突然发现时间与正常时间对不上,少了八个小时;但我丝毫不慌,这不就是个时区的问题吗,简单,但是这一次它给我深深的上了一课,一起来看整个排查过程吧; 开始排查 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
收起阅读 »

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

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

使用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
收起阅读 »

如何优雅的合并两个对象

提出需求 相信很多时候,你可能有这样的需求,比如A对象的属性1、属性2是你需要的,其余属性是空值,而B对象的属性3、属性4是你需要的,其余属性是空值,那么如何将A对象的属性与B对象的属性结合呢? 思路解析 要实现将两个对象的属性结合,需要用到反射技术,遍历每个...
继续阅读 »

提出需求


相信很多时候,你可能有这样的需求,比如A对象的属性1、属性2是你需要的,其余属性是空值,而B对象的属性3、属性4是你需要的,其余属性是空值,那么如何将A对象的属性与B对象的属性结合呢?


思路解析


要实现将两个对象的属性结合,需要用到反射技术,遍历每个对象的属性,将非null的属性复制给null的属性,最后实现合并属性的效果。


常用的复制对象属性的方法(传统方案)


使用Java Bean Utils,这是Apache Commons BeanUtils库中的一个工具类,使用此工具类,则需要自己去实现判断属性的业务逻辑。


这个工具类有两个复制属性的方法,copyProperties和copyProperty。


static voidcopyProperties(Object dest, Object orig)Copy property values from the origin bean to the destination bean for all cases where the property names are the same.
static voidcopyProperty(Object bean, String name, Object value)Copy the specified property value to the specified destination bean, performing any type conversion that is required.

copyProperties方法会将orig(第二个对象)的属性复制给dest(第二个对象),这种复制是完全复制,会完全覆盖dest的属性。


copyProperty方法将复制value给bean的特定属性。


hutool合并对象的方案


Hutool是一个Java工具包类库,对文件、流、加密解密、转码、正则、线程、XML等JDK方法进行封装,组成各种Util工具类。


hutool常常被广大spring程序员唾弃,抱怨其在项目中的稳定性,bug问题。但我认为,hutool是一个在不断迭代和维护的库,虽然是国内程序员开发的,但是其中很多功能仍然是简单好用的,不能够以偏概全的全面否定它。


回归正题,下面做详细的说明


pom导入合并对象所必须的hutool库


<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.8.16</version>
</dependency>

方法解释



  • cn.hutool.core.bean.BeanUtil


static <T> TcopyProperties(Object source, Class<T> tClass, String... ignoreProperties)按照Bean对象属性创建对应的Class对象,并忽略某些属性
static voidcopyProperties(Object source, Object target, boolean ignoreCase)复制Bean对象属性
static voidcopyProperties(Object source, Object target, CopyOptions copyOptions)复制Bean对象属性 限制类用于限制拷贝的属性,例如一个类我只想复制其父类的一些属性,就可以将editable设置为父类
static voidcopyProperties(Object source, Object target, String... ignoreProperties)复制Bean对象属性 限制类用于限制拷贝的属性,例如一个类我只想复制其父类的一些属性,就可以将editable设置为父类

hutool的复制对象方法copyProperties有4个重载类型,在apache common beanutils的基础上,增加了一些复制选项,这些选项是非常实用的,比如忽略大小写、忽略某些属性、某些特定的复制选项等。


copyProperties(Object source, Object target, CopyOptions copyOptions)


因为我实际用了copyProperties(Object source, Object target, CopyOptions copyOptions),重点讲解此方法的传入参数。


CopyOptions包括以下的一些属性或选项,可以根据你的项目需求来选择


限定符和类型字段和说明
protected TypeConverterconverter自定义类型转换器,默认使用全局万能转换器转换
protected Class<?>editable限制的类或接口,必须为目标对象的实现接口或父类,用于限制拷贝的属性,例如一个类我只想复制其父类的一些属性,就可以将editable设置为父类 如果目标对象是Map,源对象是Bean,则作用于源对象上
protected BiFunction<String,Object,Object>fieldValueEditor字段属性值编辑器,用于自定义属性值转换规则,例如null转""等
protected booleanignoreCase是否忽略字段大小写
protected booleanignoreError是否忽略字段注入错误
protected booleanignoreNullValue是否忽略空值,当源对象的值为null时,true: 忽略而不注入此值,false: 注入null
protected booleanoverride是否覆盖目标值,如果不覆盖,会先读取目标对象的值,非null则写,否则忽略。
protected booleantransientSupport是否支持transient关键字修饰和@Transient注解,如果支持,被修饰的字段或方法对应的字段将被忽略。

自己写的合并工具类


该工具类仅供参考,可以根据自己情况调整


public class MergeUtil {
private static final CopyOptions options = CopyOptions.create().setIgnoreNullValue(true).setOverride(false);
private static final CopyOptions optionsAllowOverride = CopyOptions.create().setIgnoreNullValue(true).setOverride(true);

//将sourceBean中的属性合并到tagetBean,忽略Null值,非Null值不允许覆盖
public static Object merge(Object sourceBean, Object targetBean) {
BeanUtil.copyProperties(sourceBean, targetBean, options);

return targetBean;
}

//将sourceBean中的属性合并到tagetBean,忽略Null值,非Null值允许覆盖
public static Object mergeAllowOverride(Object sourceBean, Object targetBean) {
BeanUtil.copyProperties(sourceBean, targetBean, optionsAllowOverride);

return targetBean;
}
}

作者:XiaoJi
来源:juejin.cn/post/7290475242274258978
收起阅读 »

记录一次EasyExcel的使用

1.这篇文章写什么? 在工作中碰到了这样一个需求,要从数据库里面读取一段时间内,每天某些时刻对应的温度数据,一天可能会设置有多个时间点,导出到excel中的模板需要支持多种格式的,比如一个月,一个季度,上半年,下半年等,并且需要支持灵活的配置导出的时间段范围。...
继续阅读 »

1.这篇文章写什么?


在工作中碰到了这样一个需求,要从数据库里面读取一段时间内,每天某些时刻对应的温度数据,一天可能会设置有多个时间点,导出到excel中的模板需要支持多种格式的,比如一个月,一个季度,上半年,下半年等,并且需要支持灵活的配置导出的时间段范围。超过温度的点需要用红色来进行标识


2.为什么写这篇文章


在工作中碰到了需要导出excel报表的需求,写这篇文章记录从需求到实现的过程,供后续参考和改进


3.如何实现?


要实现导出excel,首先就想到了easyexcel,它可以支持读、写、填充excel,针对现在这个需求,如果需要自定义时间的话可以通过配置字典,前端读取字典用dayjs来进行解析,把解析到的时间范围给到后端,后端再根据时间范围去导出数据,至于excel方面,想到了先用占位符在excel模板中预设好位置,然后用代码进行填充,主要的实现流程就大概有了


4.具体实现


根据上面的主要流程,下面就来进行每一步的细化,首先是前端


4.1 解析时间


通过字典来读取时间范围的代码,至于为什么不用后端来实现,之前也考虑过,不过因为无法确定具体的时间,就没用后端去实现,因为一个月不固定有多少天,不固定有多少周,如果用后端去实现的话可能会得不到准确的时间,反观前端的dayjs,就可以很简单的实现这个功能,通过解析dayjs代码来实现时间的获取,代码如下:


<template>
<div>
{{getDates()}}
</div>

</template>

<script lang="ts" setup>
import dayjs from "dayjs";
const getDates = () =>{
const str = "const startTime = dayjs().startOf('year').format('YYYY-MM-DD HH:mm:ss');const endTime = dayjs().endOf('year').format('YYYY-MM-DD HH:mm:ss');return [startTime, endTime]"
const timeFunc = new Function('dayjs', str);
const data = timeFunc(dayjs)
console.log(data[0])
console.log(data[1])
return timeFunc(dayjs)
}
</script>



用str来模拟从字段中读取到的时间范围代码,用dayjs解析出来,这样页面上就会直接打印出当前的时间:
[ "2023-01-01 00:00:00", "2023-12-31 23:59:59" ]


到此,前端解析时间的任务就完成了


4.2 导出excel


万丈高台起于垒土,要实现本次需求,首先要能导出excel,然后再考虑样式,最后再考虑现有可行方案的兼容性,能否兼容多种格式的模板。经过考察官网,发现了两种看起来比较可行和符合目前需求的导出方式:
首先把依赖导入先:


<dependency>  
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.2.1</version>
</dependency>

方式1: 定义一个模板,里面就搞几个占位符,每个占位符替换一个内容


image.png


直接上代码:


@Test
public void simpleFill() {
// 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
String templateFileName = "F:/excel/" + "simple.xlsx";
// 方案1 根据对象填充
String fileName = "F:/excel/" + System.currentTimeMillis() + ".xlsx";
// 这里会填充到第一个sheet, 然后文件流会自动关闭
Map<String, Object> map = new HashMap<>();
map.put("name", "张三");
map.put("number", 5.2);
map.put("month", 5);
EasyExcel.write(fileName).withTemplate(templateFileName)
.sheet().doFill(map);
}

看效果,替换成功了,能正常替换,这种方式能跑:
image.png


方式2 定义一个模板,里面搞几个占位符,以.开头的为循环的占位符


image.png
直接上代码:


    @Test
public void horizontalFill() {
String templateFileName = "F:/excel/" + "simpleHeng.xlsx";
String fileName = "F:/excel/" + System.currentTimeMillis() + "heng" + ".xlsx";
// 方案1
try (ExcelWriter excelWriter = EasyExcel.write(fileName).withTemplate(templateFileName)
.build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
excelWriter.write(data(),writeSheet);
}
}

private List<FillData> data() {
List<FillData> list = ListUtils.newArrayList();
for (int i = 0; i < 10; i++) {
FillData fillData = new FillData();
list.add(fillData);
fillData.setName("张三" + i);
fillData.setNumber(5.2);
fillData.setDate(new Date());
}
return list;
}

@Getter
@Setter
@EqualsAndHashCode
public class FillData {
private String name;
private double number;
private Date date;
}

看效果,替换成功(虽然哪里感觉不对劲):


image.png


现在基本的替换模板实现了,那么就进一步深入,给表格里面的内容添加颜色样式


经过网上搜索了一下,发现自定义样式需要写一个类去实现CellWriteHandler,从而自定义样式


@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {

@Override
public void afterCellDispose(CellWriteHandlerContext context) {
Cell cell = context.getCell();
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
CellStyle cellStyle = workbook.createCellStyle();
Font writeFont = workbook.createFont();
writeFont.setColor(IndexedColors.RED.getIndex());
writeFont.setBold(true);
cellStyle.setFont(writeFont);
cell.setCellStyle(cellStyle);
context.getFirstCellData().setWriteCellStyle(null);
}
}

然后在原有导出代码的基础上加个registerWriteHandler(new CustomCellWriteHandler())


EasyExcel.write(fileName).withTemplate(templateFileName)
.registerWriteHandler(new CustomCellWriteHandler())
.sheet().doFill(map);

然后就可以看到导出的效果了
image.png


再试试第二种方式,也就是循环写入的,加个样式:


WriteSheet writeSheet = EasyExcel.writerSheet().registerWriteHandler(new CustomCellWriteHandler()).build();

然后导出,发现这种方式的导出的excel未能成功设置颜色,后面也尝试了很多次,确定了这种方式没法带上样式,所以就放弃这种方式了
image.png


到这里就只剩下excel中每个位置的都用一个占位符来占位替换的方式了


4.3 实现思路


确定了可行的实现方式,现在就要在这种方式上思考如何实现兼容多种时间段,多种模板的导出。需求中每天可能会设置若干个点,也可能需要导出不同时间段的温度数据,例如月、季度、半年等,或者是其他更细的时间点,每次的模板可能也不大一样,例如下面的几种:



老王的模板



image.png



老张的模板



image.png



老李的模板



image.png


可以看出需要兼容不同的模板确实是个伤脑筋的事情,后面发现了个比较折中的办法:因为目前能实现excel上导出带样式的就只有替换占位符的方法,如何在不改代码的前提下适配多种模板呢,就直接把模板上每个格子都对应一个占位符,只要后端每次都是按照固定规则去生成数据的,那么只要设置好模板里每个格子的占位符就可以解决问题了。只要每次生成数据的顺序都是按照{t1}、{t2}、{t3}这样,依次类推下去,只要模板配置好占位符即可。例如每天一个监测点,那{t1}就对应第一天监测点的温度,{t2}就对应第二天监测点的温度,如果每天两个监测点,那么{t1}和{t2}就分别对应第一天的第一、第二个监测点的温度。这样就可以实现导出月、季度、半年的数据而不用改代码了,如果要导出某个时间段的,例如1月15日到2月15日的,这种时间段的话就使用大一级的模板,使用季度的模板即可兼容。


4.4 代码实现


有了上面的思路,大致实现流程就出来了


image.png


4.4.1 前端解析字符串,获取时间范围


这个上面有提及,这里就不再赘述,跳过


4.4.2 后端获取每天配置的时间点


由于配置的时间点是12、13、15这种,并不是具体的一个时间,因此需要结合时间范围来解析成具体的时间,首先需要获取时间段里面的每一天,然后再根据配置的时间点得到每个具体的时间点,获取到了时间点之后就可以去数据库中查询这个时间点对应的数据,模拟代码如下:
首先说明,这里引入了hutool来处理时间


<dependency>  
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>

public static void main(String[] args) {
DateTime startTime = DateUtil.parse("2023-07-01 00:00:00");
DateTime endTime = DateUtil.parse("2023-10-31 23:59:59");
List<Date> timeRangeDates = getTimeRangeDates(startTime, endTime);
List<String> times = Arrays.asList("12");
Map<DateTime, Float> resultMap = getTemperature(timeRangeDates, times);
}

/**
* 获取要导出数据的Map, key为时间,value为时间对应的值
*
* @param timeRangeDates 时间范围
* @param times 每天定义的时间点
* @return 导出的map
*/

private static Map<DateTime, Float> getTemperature(List<Date> timeRangeDates, List<String> times) {
Map<DateTime, Float> resultMap = new HashMap<>();
for (Date timeRangeDate : timeRangeDates) {
for (String time : times) {
// eg: 12 ==> 2023-11-07 12:00:00
String tempTime = DateUtil.formatDateTime(timeRangeDate).substring(0, 11) + time + ":00:00";
DateTime recordTime = DateUtil.parse(tempTime);
// 如果是将来的时间,就不执行查询,认为是没有数据的
if (recordTime.isAfter(new Date())) {
resultMap.put(recordTime, null);
continue;
}
// 模拟从数据库拿到的温度,10.5 °C
resultMap.put(recordTime, selectTemperature());
}
}
return resultMap;
}

/**
* 模拟从数据库查询数据,随机生成2到10之间的一个数字,保留一位小数
*
* @return 2到10之间的一个数字, 保留一位小数
*/

private static float selectTemperature() {
Random random = new Random();
float minValue = 2.0f;
float maxValue = 10.0f;
float range = maxValue - minValue;
float randomFloat = (random.nextFloat() * range) + minValue;
DecimalFormat df = new DecimalFormat("#.0");
String formattedFloat = df.format(randomFloat);
return Float.parseFloat(formattedFloat);
}

/**
* 获取起止时间内的每一天
*
* @param start 开始时间
* @param end 结束时间
* @return 每天
*/

private static List<Date> getTimeRangeDates(Date start, Date end) {
List<Date> dates = new ArrayList<>();
DateTime current = DateUtil.date(start);
DateTime endTime = DateUtil.date(end);
while (current.isBefore(endTime)) {
dates.add(current.toJdkDate());
current = current.offset(DateField.DAY_OF_MONTH, 1);
}
return dates;
}

经过上面一通操作,就可以得到了一个Map,里面的key是时间点,value是时间点对应的温度。
按道理来说,map已经构造好了,直接编写模板导出就完事了,就在这时,又碰到了一个问题,map上的{t1}、{t2}等占位符如何跟时间匹配的问题,模板上肯定是每个月都以最大的时间来定义,也就是31天,但并不是每个月都有31天,因此要在导出数据的时候把那些应该为空的占位符也计算出来,拿老王的模板来举例子:


今年是2023年,9月只有30天,那么模板上31号的占位符应该导出的时候就应该设置个空值,也就是在碰到{t93}的时候需要把值设置为空,然后再导出excel


image.png
完整代码如下:


package com.example.demo.test;

import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import com.alibaba.excel.EasyExcel;
import com.example.demo.handler.CustomCellWriteHandler;

import java.text.DecimalFormat;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

public class ExportTest {

public static void main(String[] args) {
DateTime startTime = DateUtil.parse("2023-07-01 00:00:00");
DateTime endTime = DateUtil.parse("2023-12-31 23:59:59");
List<Date> timeRangeDates = getTimeRangeDates(startTime, endTime);
List<String> times = Collections.singletonList("12");
Map<DateTime, Float> resultMap = getTemperature(timeRangeDates, times);
// 获取日期范围内的月份
List<Integer> distinctMonths = getDistinctMonths(timeRangeDates);
// 获取月份对应的天数
Map<Integer, Integer> daysInMonths = getDaysInMonths(timeRangeDates);
// 获取应该置空的下标
List<Integer> emptyIndexList = getEmptyIndexList(daysInMonths, times.size());
// 获取理论上一共有多少天,按照一个月31天算
int totalDaysCount = getTotalDaysCount(distinctMonths, timeRangeDates.size());
Map<String, Object> exportDataMap = new HashMap<>();
// i快 j慢
for (int i = 0, j = 0; i < totalDaysCount; i++) {
int currentDateIndex = j;
int currentCount = (i + 1) * times.size() - (times.size() - 1);
for (String time : times) {
// 本月不足31天的填充null
if (emptyIndexList.contains(currentCount)) {
exportDataMap.put("t" + currentCount++, null);
continue;
}
// 12 ==> 2023-10-25 12:00:00
String tempTime = DateUtil.formatDateTime(timeRangeDates.get(currentDateIndex))
.substring(0, 11) + time + ":00:00";
if (currentDateIndex == j) {
j++;
}
// 根据date查询数据
DateTime recordTime = DateUtil.parse(tempTime);
exportDataMap.put("t" + currentCount++, resultMap.get(recordTime));
}
}
// 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
String templateFileName = "F:/excel/" + "老王.xlsx";
// 方案1 根据对象填充
String fileName = "F:/excel/" + System.currentTimeMillis() + "老王.xlsx";
EasyExcel.write(fileName).withTemplate(templateFileName)
.registerWriteHandler(new CustomCellWriteHandler())
.sheet().doFill(exportDataMap);
}

private static int getTotalDaysCount(List<Integer> distinctMonths, int timeRangeSize) {
if (timeRangeSize <= 31) {
return Math.min(distinctMonths.size() * 31, timeRangeSize);
}
return distinctMonths.size() * 31;
}

private static List<Integer> getEmptyIndexList(Map<Integer, Integer> daysInMonths, int pointCount) {
List<Integer> list = new ArrayList<>();
AtomicInteger count = new AtomicInteger();
daysInMonths.forEach((key, value) -> {
// 本月的开始长度
int monthLength = count.get();
// 总本月虚拟长度
count.addAndGet(31 * pointCount);
// 本月实际长度
int realLength = value * pointCount;
// 本月开始下标
int startIndex = monthLength + realLength;
// 多出的下标
int extraCount = count.get() - realLength - monthLength;
// 记录需要存空值的占位符位置
for (int i = startIndex + 1; i <= startIndex + extraCount; i++) {
list.add(i);
}
});
return list;
}

/**
* 获取今年的某些月份的天数
*
* @param timeRangeDates 时间范围
* @return 该月份对应的年
*/

private static Map<Integer, Integer> getDaysInMonths(List<Date> timeRangeDates) {
List<Integer> distinctMonths = getDistinctMonths(timeRangeDates);
Date firstDate = timeRangeDates.get(0);
Calendar calendar = Calendar.getInstance();
calendar.setTime(firstDate);
int currentYear = calendar.get(Calendar.YEAR);
return distinctMonths.stream()
.collect(Collectors.toMap(
month -> month,
month -> YearMonth.of(currentYear, month).lengthOfMonth()
));
}

/**
* 获取时间范围内的所有月份、去重、转成String类型
*
* @param timeRangeDates 时间范围集合
* @return 月份集合
*/

public static List<Integer> getDistinctMonths(List<Date> timeRangeDates) {
return timeRangeDates.stream()
.map(date -> date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().getMonthValue())
.distinct()
.sorted()
.collect(Collectors.toList());
}

/**
* 获取要导出数据的Map, key为时间,value为时间对应的值
*
* @param timeRangeDates 时间范围
* @param times 每天定义的时间点
* @return 导出的map
*/

private static Map<DateTime, Float> getTemperature(List<Date> timeRangeDates, List<String> times) {
Map<DateTime, Float> resultMap = new HashMap<>();
for (Date timeRangeDate : timeRangeDates) {
for (String time : times) {
// eg: 12 ==> 2023-11-07 12:00:00
String tempTime = DateUtil.formatDateTime(timeRangeDate).substring(0, 11) + time + ":00:00";
DateTime recordTime = DateUtil.parse(tempTime);
// 如果是将来的时间,就不执行查询,认为是没有数据的
if (recordTime.isAfter(new Date())) {
resultMap.put(recordTime, null);
continue;
}
// 模拟从数据库拿到的温度,10.5 °C
resultMap.put(recordTime, selectTemperature());
}
}
return resultMap;
}

/**
* 模拟从数据库查询数据,随机生成2到10之间的一个数字,保留一位小数
*
* @return 2到10之间的一个数字, 保留一位小数
*/

private static float selectTemperature() {
Random random = new Random();
float minValue = 2.0f;
float maxValue = 10.0f;
float range = maxValue - minValue;
float randomFloat = (random.nextFloat() * range) + minValue;
DecimalFormat df = new DecimalFormat("#.0");
String formattedFloat = df.format(randomFloat);
return Float.parseFloat(formattedFloat);
}

/**
* 获取起止时间内的每一天
*
* @param start 开始时间
* @param end 结束时间
* @return 每天
*/

private static List<Date> getTimeRangeDates(Date start, Date end) {
List<Date> dates = new ArrayList<>();
DateTime current = DateUtil.date(start);
DateTime endTime = DateUtil.date(end);
while (current.isBefore(endTime)) {
dates.add(current.toJdkDate());
current = current.offset(DateField.DAY_OF_MONTH, 1);
}
return dates;
}
}

自定义设置样式


package com.example.demo.handler;

import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;

@Slf4j
public class CustomCellWriteHandler implements CellWriteHandler {

@Override
public void afterCellDispose(CellWriteHandlerContext context) {
Cell cell = context.getCell();
double numericCellValue;
try {
numericCellValue = cell.getNumericCellValue();
} catch (Exception e) {
log.error("解析到了非数值类型,直接返回");
return;
}
if (numericCellValue >= 2 && numericCellValue <= 8) {
return;
}
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
CellStyle cellStyle = workbook.createCellStyle();
Font writeFont = workbook.createFont();
writeFont.setColor(IndexedColors.RED.getIndex());
writeFont.setBold(true);
cellStyle.setFont(writeFont);
cell.setCellStyle(cellStyle);
context.getFirstCellData().setWriteCellStyle(null);
}
}

运行效果如下:
9月31日有占位符,但是9月只有30日,因此31日无数据,并且数据不在2和8范围之间的数据都可以用红色字体展示


image.png


至于返回数据给前端这里就省略了,毕竟文件都能生成了,其他的都是小问题了


5. 小结


本次的设计实现了支持不同模板,按照月(月初到月末)、季度、半年的数据进行导出,初步实现了功能,但是面对更复杂的场景,例如需要支持不同模板在某一段时间的导出功能暂时没法实现。不过也算是积累了一次使用easyexcel导出报表的经验,以此来记录本次开发的过程,为后续类似功能的开发提供借鉴


代码存放链接: gitee.com/szq2021/eas… (主要是怕找不着了,先存着)


作者:用户5024878678107
来源:juejin.cn/post/7298642789092474889
收起阅读 »