注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

没用过微服务?别慌,丐版架构图,让你轻松拿捏面试官

大家好,我是哪吒。 很多人都说现在是云原生、大模型的时代,微服务已经过时了,但现实的是,很多人开发多年,都没有在实际的开发中用过微服务,更别提搭建微服务框架和技术选型了。 面试的时候都会问,怎么办? 今天分享一张微服务的丐版架构图,让你可以和面试官掰扯掰扯~ ...
继续阅读 »

大家好,我是哪吒。


很多人都说现在是云原生、大模型的时代,微服务已经过时了,但现实的是,很多人开发多年,都没有在实际的开发中用过微服务,更别提搭建微服务框架和技术选型了。


面试的时候都会问,怎么办?


今天分享一张微服务的丐版架构图,让你可以和面试官掰扯掰扯~


脑中有图,口若悬河,一套组合拳下来,面试官只能拍案叫好,大呼快哉,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
收起阅读 »

接口优化🚀68474ms->1329ms

小菜的一次接口优化:从68474ms到1329ms 前言 突然,有人大喊一声:小菜,你过来一下 小菜被吓得抖了一抖,连忙切出开发界面,看了一眼,原来是项目经理在喊 小菜屁颠屁颠的过去后 项目经理:小菜,有空你看看后台管理里的商品信息导出Excel功能,导出数据...
继续阅读 »

小菜的一次接口优化:从68474ms到1329ms


前言


突然,有人大喊一声:小菜,你过来一下


小菜被吓得抖了一抖,连忙切出开发界面,看了一眼,原来是项目经理在喊


小菜屁颠屁颠的过去后


项目经理:小菜,有空你看看后台管理里的商品信息导出Excel功能,导出数据只有几千条但是要等特别久


小菜:没问题,等我忙完手上的活就来看看怎么回事


分析与优化


小菜回到工位后,立马看了看后台管理系统的商品信息导出功能


该功能是通过导入规定格式的Excel(比如商品名称),然后导出这些商品的所有信息


小菜用对应的模板(大概数据量5千)使用此功能,大概等了1分钟多才导出结果


小菜:我可以先用arthas的trace监听这个接口,看看接口里哪些方法耗时,再具体进行分析


5ebf0da94e3d187b59b79e81cb66baa.png


使用arthas的trace命令监听端口后,发现总耗时70284ms,其中XXMessage耗时68s,导出Excel花费1.8s


小菜:那具体的业务处理应该在XXMessage里了,我先来看看


     public List<ExportVO> xxMessage(MultipartFile file, HttpServletRequest request, HttpServletResponse response) {
//导出结果
List<ExportVO> exportVOS = new ArrayList<>();
try {
//EasyExcel 读取模板数据
//使用AnalysisEventListener 在读取数据时加入导出结果,在读完后进行封装操作
EasyExcel.read(file.getInputStream(), Product.class, new AnalysisEventListener<Product>() {
private final List<Product> list = new ArrayList<>();

//解析完一行后如何处理
@Override
public void invoke(Product p, AnalysisContext analysisContext) {
doLine(p);
}

//解析完所有数据后如何处理
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
doAfter();
}
}).sheet().doRead();
} catch (IOException e) {
e.printStackTrace();
}
return exportVOS;
}

小菜:使用的EasyExcel读取,那真正的处理应该在实现的AnalysisEventListener中


小菜:让我先来看看每解析一行如何处理的


 //存储要处理的数据
List<Product> products = new ArrayList<>();

private void doLine(Product data) {
try {
//拿到所有使用ExcelProperty的字段
List<Field> fields = Arrays.stream(data.getClass().getDeclaredFields())
.filter(f -> f.isAnnotationPresent(ExcelProperty.class))
.collect(Collectors.toList());

//判断字段是否为空,为空则集合添加false不为空添加true
List<Boolean> lines = new ArrayList<>(fields.size());
for (Field field : fields) {
field.setAccessible(true);
Object value = field.get(data);
if (value == null) {
lines.add(Boolean.TRUE);
} else {
lines.add(Boolean.FALSE);
}
}

//ExcelProperty的所有字段不为空 就加入集合
if(lines.stream().allMatch(Boolean.TRUE::equals)){
products.add(data);
}
} catch (Exception e) {
log.error("parse data fail: " + e.getMessage());
}
}

(ExcelProperty注解用于标记表格中的列)


小菜拿出做算法题分析时间复杂度的思路


小菜:这里总共有三个循环分别是:获取使用ExcelProperty的字段、判断每个字段是否为空、allMatch匹配数组中所有元素为true


小菜:那么用时间复杂度表示就是O(3N),N为数据量,而这些集合的数据量则是使用ExcelProperty的字段,好像是固定的,并不会随着Excel表格中数据量的提升而提升,那么可以把它们看成常量,那最终时间复杂度就是常量级别O(1)


小菜:但是还用了反射会有些性能开销


小菜:咦?为啥要判断实体每个字段不为空才加入要处理的集合呢?


小菜:好像直接判断该商品名不为空就可以了吧?


小菜:用反射来实现通用性,难道这段代码是前辈复制的?


于是,小菜洋洋得意的将代码改成:


 //存储要处理的数据
List<Product> products = new ArrayList<>();

private void doLine(Product data) {
if (StringUtils.isNotEmpty(data.getProductName())) {
products.add(data);
}
}

为了担心自己改错,小菜还保留原始代码,方便回滚


再来看下解析完数据后的处理方法


小菜看着这一望无际一百多行没有注释、多层if嵌套的代码,整个人都呆了


大致观看了一遍后,小菜将shit mountain代码梳理成以下代码:


 //存储要处理的数据
List<Product> products = new ArrayList<>();

private void doLine(Product data) {
if (StringUtils.isNotEmpty(data.getProductName())) {
products.add(data);
}
}

private void doAfter() {
//要处理的数据为空直接返回
if (Empty.isEmpty(products)) {
return;
}

//循环处理数据
products.forEach(product->{
//根据商品名查询出商品列表 IO
List<Sku> skus = skuService.list(product.getProductName());
//查到商品数据为空跳过
if (Empty.isEmpty(skus)) {
continue;
}

//查询商品具体数据 IO

//查询分类、规格... IO

//封装实体 添加到导出列表
});
}

看到这里小菜一下就明白为什么接口这么慢了


小菜:好好好,你这样写代码是吧


小菜:不考虑查数据库的网络IO是吧,肯定是不想写联表SQL,偷懒直接用MP


小菜直接用一次联表查询替代这么多的查询,为了避免数据量太大,小菜设置每次处理的最大数据量,分多次处理


 private void doAfter() {
//要处理的数据为空直接返回
if (Empty.isEmpty(products)) {
return;
}

int batchSize = 520;
//将大集合拆分为多个小集合 分批次处理
List<List<Product>> lists = CollectionUtils.split(products,batchSize);

//循环处理数据
lists.forEach(products->{
//转换为商品名列表
List<String> productNames = products.stream().map(Product::getProductName).collect(Collectors.toList());
//联表查询 IO
List<SkuDetails> skus = skuService.list(productNames);
skus.forEach(skus->{
//封装实体 添加到导出列表
});
});
}

小菜优化完代码后,再用arthas监听一遍,发现这次只需要3s,速度提升近23倍


0dadfdaf5e5b37810bf2266d03a2250.png


最后


接口优化的方式有很多种,在优化前我们需要进行分析哪里需要优化


在平时的开发中,也要多考虑时间、空间复杂度,并不是什么场景下都要去避免关联多张表查询


循环查数据库会造成多次网络IO,等待时间会很久,需要降低网络IO的次数,这种场景就可以联表查询


如果担心查的数据太多,联表查询性能慢,可以考虑分析执行计划增加索引,又或者分批次进行处理


其他接口优化的方式还有很多种,比如数据库优化、缓存、异步....


缓存,可以使用本地缓存、分布式缓存、多级缓存,但引入缓存又会带来一致性问题,要分析业务场景使用适合使用缓存


异步,可以使用MQ去做异步,也可以使用多线程去做异步,各有各的特点


在一些业务场景中,不要为了使用某项技术而去使用


技术是用来服务业务的,使用技术前要考虑到当前项目采用该技术是否合适,就像找伴侣一样,强扭的瓜不甜


有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~


关注菜菜,分享更多干货,公众号:菜菜的后端私房菜


彩蛋


小菜装作忧愁的来到项目经理旁边


小菜:经理,这个接口对应的实现有些复杂,我估计下周忙完手上的事情就可以优化,你先帮我提个需求吧


项目经理:ok没问题,下周忙完就尽快优化吧


作者:菜菜的后端私房菜
来源:juejin.cn/post/7280007832702795795
收起阅读 »

几条SQL把服务器干崩了

大家好,我是冰河~~ 今天跟大家分享一个发生在今天凌晨的真实案例,这篇文章也是我事后临时写出来的,处理事情的过程有点无语,又有点气愤! 事件背景 事情的背景是这样的:一个朋友今年年初新开了一家公司,自己是公司的老板,不懂啥技术,主要负责公司的战略规划和经营管理...
继续阅读 »

大家好,我是冰河~~


今天跟大家分享一个发生在今天凌晨的真实案例,这篇文章也是我事后临时写出来的,处理事情的过程有点无语,又有点气愤!


事件背景


事情的背景是这样的:一个朋友今年年初新开了一家公司,自己是公司的老板,不懂啥技术,主要负责公司的战略规划和经营管理,但是他们公司的很多事情他都会过问。手下员工30多人,涵盖技术、产品、运营和推广,从成立之初,一直在做一款社交类的APP。平时,我们一直保持联系,我有时也会帮他们公司处理下技术问题。


事件经过


今天凌晨,我被电话铃声吵醒了,一看是这个朋友打来的,说是他们公司数据库服务器CPU被打满了,并且一直持续这个状态,他说拉个群,把他们后端Java同事拉进来一起沟通下,让我帮忙看看是什么问题,尽快处理下。说话语气很急,听的出来事态很严重,因为目前正在加大力度推广,周末使用人数也比较多,出现这种问题着实让人着急。


后面我加了那个朋友拉的微信群,开始了解服务器出现问题的具体情况,下面就是一些处理的经过了。


注:聊天内容已经获得授权公布。


2023-10-29-006.png


他们后端Java把运维发的监控截图发出来了,咱继续跟他沟通。


2023-10-29-007.png


为啥我说CPU占用高呢?大家看下他们运维发的图就知道了。


2023-10-29-001.png


CPU已经飙升到了400%了,数据库服务器基本已经卡死。拿到他给我发的SQL后,我跟他们老板要了一份他们的数据库表结构,在我电脑上执行了下查询计划。


2023-10-29-008.png


这不看不知道,一看吓一跳,一个C端频繁访问的接口SQL性能极差,Using temporary、Using filesort、Using join buffer、Block Nested Loop全出来了。


我把这个图发出去了,也结合他们团队的实际情况,给出了优化的目标建议:SQL中不要出现Using filesort、Block Nested Loop,尽量不要出现Using join buffer和Using temporary,把Using where尽量优化到Using Index级别。


2023-10-29-009.png


说是尽量不要出现Using join buffer和Using temporary,把Using where尽量优化到Using Index级别,就是怕他们做不到这点,优先把Using filesort、Block Nested Loop优化掉。 但是这货后面说的话实属把我震惊到了。


2023-10-29-封面.png


我看完他的回复,直接有点无语:卧槽,不超过500万rows效率很高?你这SQL 500万数据效果很高?更让我无语的是这货说MySQL一般一亿以上数据量开始优化,这特么不是完全扯淡吗?他说这话时,我大概就知道这货的水平了。。。


后面我就问他说的这些数据的依据是哪里来的。


2023-10-29-011.png


这货说是什么大数据高并发MySQL数据库压测出来的,稍微有过压测经验的应该都知道,压测一个很重要的前提就是要明确压测的环境,最起码要明确压测环境服务器的CPU核数和内存,直接来句MySQL一亿数据是大数据高并发MySQL数据库压测出来的结果,这还是MySQL官方的数据。。。。


不知道是不是因为群里有他们老板的缘故,这货后面还在狡辩。


2023-10-29-012.png


沟通到这里,我特么有种想打人的冲动,生产环境所有业务快被数据库拖死了,数据库服务器CPU被干爆了,监控到慢SQL,并且查看这些慢SQL的执行计划,性能非常低下,SQL里不管是select部分还是where部分大量使用了MySQL自带的函数,这不慢才怪啊。看这货处理问题的态度,要是我下面的人,早就让他卷铺盖走人了。


处理结果


后续我跟他们老板要了一个代码只读权限的账号,将代码拉取下来后,好家伙,到处都是这种SQL查询,要是一两处还好,把SQL修改并优化下,关联的业务逻辑调整下,再把功能测试下,接口压测下,没啥问题就可以发版上线了。


但是,如果到处都是这种SQL的话,那优化SQL就要花费一定的时间了,甚至是新发布的重大功能逻辑都要重写。最终,我跟他们老板说的是回滚版本吧,最新的功能还是先下线,把新功能的SQL、缓存、业务逻辑、接口都优化好,压测没问题后再重新上线。


2023-10-29-013.png


事后总结


无论什么时候,生产环境一旦出现致命问题,第一时间要优先恢复生产环境正常访问,随后再认真排查、定位和解决问题,毕竟生产环境一旦出现问题,每一秒流失的都是真金白银。


搭建技术团队一定要找靠谱的人,最起码团队的核心人员要靠谱,像我朋友团队的这个技术,在他的认知里,MySQL执行计划出现Using temporary、Using filesort、Using join buffer、Block Nested Loop,500W rows效率都很高,殊不知他们生产环境实际主表数据才10几条,要是真达到500W量级就别查询了,数据库直接就趴下了。还有这个MySQL一般一亿以上开始优化,这个依据我也不知道这货是从哪里看到的,并且还说了大数据高并发MySQL数据库压测出来的,这不纯属扯淡吗?


更离谱的是我事后悄悄问了他们老板,他的工作年限是多久,据说工作10多年了,是位80后。


顿时让我想到了一句话:人的认知有几个层次:不知道自己不知道,知道自己不知道,知道自己知道,不知道自己知道。


好了,今天就到这儿吧,我是冰河,我们下期见~~


作者:冰_河
来源:juejin.cn/post/7298635800631459892
收起阅读 »

Nginx与洗脚城技师那些有意思的小故事

FBI WARNING: 内容仅作为举例用途,本人不曾去过洗脚城,也不知道技师是什么 我们今天来讲讲Nginx的一些小常识吧。 什么是Nginx Nginx 是一款是由伟大且牛逼的俄罗斯的程序设计师 Igor Sysoev(伊戈尔 赛索耶夫) 在摸鱼玩耍时...
继续阅读 »

FBI WARNING:



内容仅作为举例用途,本人不曾去过洗脚城,也不知道技师是什么



我们今天来讲讲Nginx的一些小常识吧。


什么是Nginx


Nginx 是一款是由伟大且牛逼的俄罗斯的程序设计师 Igor Sysoev(伊戈尔 赛索耶夫) 在摸鱼玩耍时所开发高性能的 Web反向代理 服务器,也是一个 IMAP/POP3/SMTP 代理服务器。


什么是反向代理


说到反向代理之前,我们来看看什么是 正向代理(跑腿代购)。


正向代理运行在客户端的出口,获取客户端发送的请求,用自己的身份发送到服务器并获取返回数据,最终返回给客户端。


常见的正向代理,如网络爬虫的IP代理、VPN、游戏加速器......


image.png


那么,什么是反向代理呢?


反向代理服务器运行在服务端的入口,根据客户端请求的部分参数进行请求的分发与调度等。(那不就是洗脚城的前台吗?)


常见的反向代理场景:



  • 负载均衡

  • 动静分离    

  • 业务分离

  • ......


image.png


Nginx 有哪些作用?


1. Nginx的虚拟站点服务


Nginx可基于 端口号、域名(IP) 实现多站点服务,已实现前端访问80或443端口,通过上述不同条件访问不同的后端服务



一个洗脚城前台,后面有不知道数量的技师。



server
{
listen 80;
server_name http://www.hamm.cn;
index index.html;
root /home/www/portal_site;
}
server
{
listen 80;
listen 443 ssl http2;
# 证书相关配置
server_name api.hamm.cn;
index index.html;
root /home/www/api_service;
}
server
{
listen 80;
server_name static.hamm.cn;
index index.html;
root /home/www/static_service;
autoindex on;
}

image.png


2. Nginx反向代理实现负载均衡


通过反向代理指定上游多个负载点,配置不同的负载优先级以及调度策略来实现负载均衡。



按业务能力和服务质量,洗脚城前台往往会针对不同的技师分配不同的任务。



upstream api_slb {
server 192.168.123.78:8100 weight=5 max_fails=3 fail_timeout=5;
server 192.168.123.78:8200 weight=3 max_fails=3 fail_timeout=5;
server 192.168.123.79:8100 weight=2 max_fails=3 fail_timeout=5;
# 优先使用局域网测试服务器的服务 按权重进行负载


# 如果测试服务器不可用,可通过下面两台备用服务跑

server 192.168.123.77:8080 weight=2 backup;
# 同事张三 代码一般是最新 优先使用
server 192.168.123.151:8080 weight=1 backup;
# 同事李四 工位最近 做备机 方便沟通
}
server {
listen 80;
server_name api.hamm.cn;
location / {
proxy_pass http://api_slb;
}
}

image.png


3. Nginx业务分离或动静分离


使用Nginx解决一些特定的需求



  • 相同域名,不同路径访问不同资源

  • 不同域名,解决访问跨域等问题
    .......



技师根据不同的客户需求,分配不同业务能力的技师。



upstream api_service {
server 192.168.123.78:8100;
}
upstream web_frontend {
server 192.168.123.66:8010;
}
server {
listen 80;

# 使用统一域名 http://hamm.cn访问所有资源

server_name hamm.cn;

# 匹配 http://hamm.cn/api/****
# 到系统API服务器上
location /api/ {
proxy_pass http://api_service;
}

# 如果资源在本机 可使用Nginx自带的静态资源服务
location /static {
index index.html;
alias /home/www/static
}

# 匹配其他请求到前端服务器上
location / {
proxy_pass http://web_frontend
}
}

image.png


4. Nginx完成其他场景下的小需求




  • 跨域问题




server {
# API服务
listen 80;
server_name api.hamm.cn;

# 设置允许跨域的域名 支持通配符 如* 代表所有
add_header Access-Control-Allow-Origin hamm.cn;

# 跨域时允许提交的header数据
add_header Access-Control-Allow-Headers 'authorization','content-type';

# 允许跨域的请求方法
add_header Access-Control-Allow-Methods 'option,get,put,post';

# 还有很多配置项 自己百度吧:)
}
server {
# 前端
listen 80;
server_name hamm.cn;
}




  • 代理过滤





使用 sub_filter 模块将代理中一些数据进行过滤或替换:)



server {
...

location /api {
...
sub_filter_types application/json;
sub_filter_once off;

sub_filter '搜藏成功' '收藏成功';
}
}


技师服务的时候有点不耐烦,大喊了一声 “傻X客户”,洗脚城前台为了洗脚城的形象,给替换成了 “好帅的客户”





  • 自定义错误页




server
{
listen 80;
server_name api.hamm.cn;
error_page 502 503 404 403 500 /error.json;
}


技师让客户不高兴的时候,洗脚城每次都出来给大家唱首歌缓和下气氛。





  • 流量控制 请求黑名单IP 防盗链 请求拦截





技师提前预判是不是意向客户,或者专门找事的客户,提前处理好这些事情,不让技师烦恼。



server
{
listen 80;
server_name static.hamm.cn;
root /home/www/static_files/images;
location ~ .*\.(jpg|gif|png|bmp)$ {
# 如果是百度爬虫 让它一边去
if ($http_user_agent ~ 'Baiduspider') {
# rewrite * "/getout.jpg";
return 403;
}
# 图片 允许直接访问 如有跨域 只允许指定的域名访问
valid_referers none *.hamm.cn;
if ($invalid_referer) {
# 其他的请求 通通甩掉
return 403;
}
}
location /admin/ {
# 如果是上传到admin目录下的文件
allow 123.45.67.89;
# 只允许指定的IP可以访问
deny all;
# 其他人通通甩掉
}
}

image.png


总结一下


我真的没去过洗脚城。


作者:Hamm
来源:juejin.cn/post/7295995236886396939
收起阅读 »

面试官问我唯一ID如何生成

由于简历上,写了分库分表,还写了精通微服务和分布式系统,然后被面试官一顿蹂躏了这里面的技术细节;尤其给我印象最深的问题是,在分布式环境下,你们数据的唯一ID是如何去生成的? 当时条件反射,无脑的回答到,通过数据库自增方式生成的唯一ID;面试官听了后,叫我回家等...
继续阅读 »

由于简历上,写了分库分表,还写了精通微服务和分布式系统,然后被面试官一顿蹂躏了这里面的技术细节;尤其给我印象最深的问题是,在分布式环境下,你们数据的唯一ID是如何去生成的?


当时条件反射,无脑的回答到,通过数据库自增方式生成的唯一ID;面试官听了后,叫我回家等通知,然后就没有然后了。。。。。。。


目前业界有哪些唯一ID的生成方式了?


大概有5种。如下图


1698825367530.png


UUID模式


uuid全称是通用唯一识别码(Universally Unique Identifier)。底层是通过mac地址+时间+随机数进行生成的128位的二进制,转换为16进制字符串后,长度为32位。

uuid的生成非常简单, 如果用java代码生成,那只需要一行代码


public static void main(String[] args) {      
String uuid = UUID.randomUUID().toString().replaceAll("-","");
System.out.println(uuid); }

优点: uuid生成的ID 能满足唯一性;并且不依赖任何中间件;生成速度超快。


但是uuid的缺点也很明显: 生成的ID太长了,字符长度有32位

在业务系统里,一般ID是要进行存储到库里的,并且很有可能会作为常用的查询条件,比如存储到mysql,比int32的4字节或者int64的8字节来讲,太占空间;


第二个缺点:就是生成的ID是无序的,不是递增的;这样在把ID做为索引字段,在底层插入索引时,还会引起索引节点的分裂,影响插入速度。 所以用UUID生成的ID,对数据库来说很不友好


那么UUID是不是就没有业务使用场景了?

并不是的,像很多大厂的日志链路追踪的ID,就是基于UUID进行生成的。


redis 自增模式


大家都知道 在redis里有一个INCR命令,能从0到Long.maxValue-1进行单一连续的自增,最大19位;9位数字就是亿基本的数了,还剩10位,假设每天1千W的订单数据,即1千W个ID,能支持25亿年;距离地球毁灭时间还差点


redis id的自增代码也很简单,不详述。


优点:

redis 自增生成的ID 能满足唯一性也能满足单调自增性。做底层数据存储或者索引的时候,对数据库也很友好。生成速度还飞快,因为都是在内存中生成。


缺点:

redis 自增ID的缺点也很明显可能会丢数据
我们知道redis是一个kv内存数据库,虽提供了异步复制数据+哨兵模式 来保证服务的高可用+数据高可靠;但redis不保证不丢数据,即使从库变为主库,但也不保证之前的从库数据是最新的,毕竟是异步同步数据。


建议中大厂,或者能稳定运维redis和具有高可用方案+失败重试方案的厂子使用;小厂慎用吧。


数据库自增模式


在早些年,确实看到过依靠数据库自增的方式,进行唯一ID的生成。此种方式主要是依赖数据库的自增来实现;想要一个唯一的ID,那么往带有自增主键的表里插入一条数据吧,这样sql返回的时候,就会把自增ID返回来了


具体sql


INSERT INTO your_table (column1, column2) VALUES ('value1', 'value2'); SELECT LAST_INSERT_ID();

优点:
数据库自增生成的ID 能满足唯一性也基本能满足自增性


缺点:

生成速度较慢

受限于数据库的实现方式,,ID生成速度相较于前两者至少慢一个数量级,毕竟在生成的时候数据库有磁盘IO操作;高并发下,不建议使用此种方式。而且每次要获得一个ID,还需要往数据库里先插入一条记录。本来高并发下,数据库操作就是个瓶颈了,用了此种方式还加剧了数据库的负担。


数据库号段模式


数据库号段模式,可以看做是数据库自增模式的升级版,在数据库自增模式上进行了性能优化,解决了ID生成慢的问题。
思路入下图:


1698825866585.png


数据库自增模式,每次获取一个ID,都需要操作一次库,太慢了。
号段模式每次操作数据库是申请一个号段的范围。
比如操作一次数据库,申请1000到2000是这个应用的业务申请的号段;然后只能这个应用实例的业务能用;应用申请了号段后放到内存中,每次业务需要的时候,从内存里累加1返回,在java里也有现成的工具,比如AtomicLong包装下(省事还能解决并发问题);如果发现快不够了,还能异步提前从数据库中申请,放入内存中,这样就避免了业务需要唯一ID的时候,在去数据库申请,加快业务获取ID的速度。


优点:

能满足唯一性,也能满足自增性,性能也不差,美团开源的leaf使用此种模式,速度能到 5W/秒。


缺点:

对数据库是强依赖;但我想大多数业务系统基本都依赖数据库吧


个人认为此方案最适合小厂;性能不差,而且也不需要依赖太多的中间件;而且大厂里也不缺乏使用此方案身影,比如滴滴开源的Tinyid也是用的此模式


雪花算法模式


雪花算法(Snowflake)出自大名鼎鼎的twitter公司之手;是的就是那个被硅谷钢铁侠 马斯克收购的公司。
该算法开源后,深受国内大厂好评。并且像百度这种大厂,基于雪花算法思想,开发了自己开源的
UidGenerator 分布式ID生成器。


1698825928764.png


Snowflake生成的是Long类型的ID,我们知道一个Long类型占8个字节空间,1个字节又占8比特,也就是说一个Long类型会用88 64个比特。


Snowflake ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 数据中心(占5比特)+ 自增值(占12比特),总共64比特。



  • 第一个bit位(1bit):Java中long的最高位是符号位代表正负,正数是0,负数是1,一般生成ID都为正数,所以默认为0。

  • 时间戳部分(41bit):毫秒级的时间,不建议存当前时间戳,而是用(当前时间戳 - 固定开始时间戳)的差值,可以使产生的ID从更小的值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年 (如果你建设的IT系统要使用100年,慎用此法)

  • 工作机器id(10bit):也被叫做workId,这个可以灵活配置,机房或者机器号组合都可以。

  • 序列号部分(12bit),自增值支持同一毫秒内同一个节点可以生成4096个ID


优点:

* 雪花算法生成的ID,能保证唯一性;

* 随这时间齿轮的流转,也能满足自增性;有大厂的背书,生成速度也是超快;

* 对第三方中间件也是弱依赖


缺点:



  • 存在时钟回拨问题,所有机器时钟必须保存同步,否则会导致生成重复ID;但是也能解决

  • 生成的数字会随这时间的流逝增大,不会因为数据量的增大而增大,在某些业务场景不太适用

  • 需要维护机器ID的配置;或者依赖第三方中间件根据机器信息,生成机器ID


这么多的分布式唯一id生成方法,我该选择哪一种?


为了方便选择,列了个表。到底我的应用里,该选哪个方案了?还需结合自身业务,团队大小,技术实力,实现复杂度和维护难易度进行选择。


image.png


想偷懒,有现成的唯一ID生成工具吗?


有的,像比如美团开源的leaf 能支持数据库号段和雪花模式,传输门:github.com/Meituan-Dia…


百度的 UidGenerator 使用的是雪花模式,传输门:github.com/baidu/uid-g…


而滴滴的 Tinyid 使用的是号段模式,可以认为是基于美团leaf的开源版,传输门:github.com/didi/tinyid…


请注意:以上三个工具都是用java语言写的,用java语言的同学有福了,基本是开箱即用。


集成美团leaf 号段模式


1、从git上 github.com/Meituan-Dia… 下载 leaf源码


1698826132995.png


2、mvn clean install -DskipTests 编译打包工程


3、用工程里的script脚本目录下的sql 脚本,创建数据表


CREATE DATABASE leaf
CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '',
`max_id` bigint(20) NOT NULL DEFAULT '1',
`step` int(11) NOT NULL,
`description` varchar(256) DEFAULT NULL,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

insert int0 leaf_alloc(biz_tag, max_id, step, description) values('leaf-segment-test', 1, 2000, 'Test leaf Segment Mode Get Id')


4、在leaf-server工程里配置数据库连接信息


1698826171611.png


5、启动leaf-server工程,访问
浏览器访问 http://localhost:8080/api/segment/get/leaf-segment-test即可。


注意:这里的leaf-segment-test即时bizTag。如果想要换成其它业务标,往leaf_alloc插入一条数据即可。


不想用leaf的web模式;但又想用号段模式+本地api方式直接调用,是否可行?


可行的。只需要引入美团的leaf-core jar包即可。是的,这么好的工具,居然没有maven信息;不死心的我,去maven官方仓库搜了一把,还真发现了leaf-core的坐标。
如下:


<dependency>
<groupId>com.tencent.devops.leafgroupId>
<artifactId>leaf-coreartifactId>
<version>1.0.2-RELEASEversion>
dependency>

但是下载下来后,包名居然是腾讯的包名。反编译后,发现代码基本上没怎么变。如果想偷懒,可直接下载。


为了保险起间,我还是用了美团的leaf-core(只好做本地jar依赖)。
然后在代码里加入对ID生成器的初始化。代码如下:



@Service
public class LeafIdDBSegmentImpl implements InitializingBean {
IDGen idGen;
DruidDataSource dataSource;

@Override
public void afterPropertiesSet() throws Exception {
Properties properties = PropertyFactory.getProperties();

// Config dataSource
dataSource = new DruidDataSource();
dataSource.setUrl(properties.getProperty("leaf.jdbc.url"));
dataSource.setUsername(properties.getProperty("leaf.jdbc.username"));
dataSource.setPassword(properties.getProperty("leaf.jdbc.password"));
dataSource.init();

// Config Dao
IDAllocDao dao = new IDAllocDaoImpl(dataSource);

// Config ID Gen
idGen = new SegmentIDGenImpl();
((SegmentIDGenImpl) idGen).setDao(dao);
idGen.init();
}

public Long getId(String key){
Result result = idGen.get(key);
if (result.getStatus().equals(Status.EXCEPTION)) {
throw BizException.createBizException(BizErrorCode.LEAF_CREATE_ERROR_REQUIRED);
}
return result.getId();
}

}


该代码主要做了几件事:

1、在该单列类,实例化后,会进行 数据库源的初始化工作,并且自行实例化IDAllocDao;当然你可以把工程里已有的数据源赋给IDAllocDaoImpl

2、获取ID时,调用底层idGen.get获取

3、业务代码使用时 用 LeafIdDBSegmentImpl.get方法 传入数据库里事先配置好的bizTag即可。


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

一文告诉你,如何实现 IP 属地功能

细心的朋友们可能已经发现了,先在抖音、知乎、快手、小红书等这些平台已经上线了“网络用户显示 IP 的功能”,境外用户显示的是国家,国内的用户显示的省份,而且此项显示无法关闭,归属地强制显示。 作为网友,我们可能只是看看戏,但是作为一个努力学习的码农,我们肯定...
继续阅读 »

细心的朋友们可能已经发现了,先在抖音、知乎、快手、小红书等这些平台已经上线了“网络用户显示 IP 的功能”,境外用户显示的是国家,国内的用户显示的省份,而且此项显示无法关闭,归属地强制显示。



作为网友,我们可能只是看看戏,但是作为一个努力学习的码农,我们肯定要来看一下这个功能是怎么实现的,今天这篇文章,就用几分钟的时间来讲述一下这个功能是怎么实现的。



获取用户 IP 地址


HttpServletRequest 获取 IP




首先我们来看一下,在 Java 中,是如何获取到 IP 属地的,主要有以下两步:



  1. 通过 HttpServletRequest 对象,获取用户的 「IP」 地址

  2. 通过 IP 地址,获取对应的省份、城市]


首先,我们这里写一个工具类用于获取 IP 地址,因为用户的每次 Request 请求都会携带请求的 IP 地址放到请求头中,所以我们可以通过截取请求中的 IP 来获取 IP 地址;


/**
* 网络工具类
*
* @author <a href="https://github.com/liyupi">程序员鱼皮</a>
* @from <a href="https://yupi.icu">编程导航知识星球</a>
*/
public class NetUtils {

/**
* 获取客户端 IP 地址
*
* @param request
* @return
*/
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
if (ip.equals("127.0.0.1")) {
// 根据网卡取本机配置的 IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (Exception e) {
e.printStackTrace();
}
if (inet != null) {
ip = inet.getHostAddress();
}
}
}
// 多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ip != null && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
}
// 本机访问
if ("localhost".equalsIgnoreCase(ip) || "127.0.0.1".equalsIgnoreCase(ip) || "0:0:0:0:0:0:0:1".equalsIgnoreCase(ip)){
// 根据网卡取本机配置的IP
InetAddress inet;
try {
inet = InetAddress.getLocalHost();
ip = inet.getHostAddress();
} catch (UnknownHostException e) {
e.printStackTrace();
}
}
// 如果查找不到 IP,可以返回 127.0.0.1,可以做一定的处理,但是这里不考虑
// if (ip == null) {
// return "127.0.0.1";
// }
return ip;
}

/**
* 获取mac地址
*/
public static String getMacAddress() throws Exception {
// 取mac地址
byte[] macAddressBytes = NetworkInterface.getByInetAddress(InetAddress.getLocalHost()).getHardwareAddress();
// 下面代码是把mac地址拼装成String
StringBuilder sb = new StringBuilder();
for (int i = 0; i < macAddressBytes.length; i++) {
if (i != 0) {
sb.append("-");
}
// mac[i] & 0xFF 是为了把byte转化为正整数
String s = Integer.toHexString(macAddressBytes[i] & 0xFF);
sb.append(s.length() == 1 ? 0 + s : s);
}
return sb.toString().trim().toUpperCase();
}
}

获取用户的 IP 地址属地


淘宝库获取用户 IP 地址属地




通过这个方法,就可以重请求头中获取到用户的 IP 地址了,然后接下来就是 IP 地址归属地省份、城市的获取了,这里可以用很多 IP 地址查询的库进行查询,这里用一个库来测试一下。


淘宝 IP 地址库:ip.taobao.com/



不过淘宝的 IP 地址查询库已经在 2022 年 3 月 31 日下线了,这里我们就不能使用它了,只能另辟蹊径了。



这里我们截取一段之前淘宝货期 IP 地址的源码,然后一起来看一下。



这里可以看到,在日志文件中,出现了大量的 the request over max qps for user 问题。


虽然这个方法已经寄了,但是我们求知的道路可以停吗?肯定不可以啊,这里我们就来整一个新的获取 IP 地址属地的方法,也就是我们今天文章的主角:Ip2region。


Ip2region 介绍




这个是在之前的一篇文章看到的,他是一个 Gthub 的开源项目,即 Ip2region 开源项目


地址如下:github.com/lionsoul201…


这个开源库目前已经更新到了 V2 的版本,现在的它是一个强大的离线IP地址定位库和IP定位数据管理框架,其达到了微秒级别的查询效率,还提供了众多主流编程语言的 xdb 数据生成和查询客户端实现,可以说是非常得好用👍👍👍👍,今天这篇文章我们主要针对其 V2 版本进行讲解,如果想要查询 1.0 版本的内容的话,可以去 Github 上面进行查看。


Ip2region 详解


高达 99.9 % 的查询准确率




数据聚合了一些知名 ip 到地名查询提供商的数据,这些是他们官方的的准确率,经测试着实比经典的纯真 IP 定位准确一些。ip2region 的数据聚合自以下服务商的开放 API 或者数据(升级程序每秒请求次数 2 到 4 次),比例如下:





  1. 80%, 淘宝 IP 地址库, ip.taobao.com/%5C




  2. ≈10%, GeoIP, geoip.com/%5C

  3. ≈2%, 纯真 IP 库, http://www.cz88.net/%5C


Ip2region V2.0 特性




1、IP 数据管理框架


xdb 支持亿级别的 IP 数据段行数,默认的 region 信息都固定了格式:国家|区域|省份|城市|ISP,缺省的地域信息默认是0。 region 信息支持完全自定义,例如:你可以在 region 中追加特定业务需求的数据,例如:GPS信息/国际统一地域信息编码/邮编等。也就是你完全可以使用 ip2region 来管理你自己的 IP 定位数据。


2、数据去重和压缩


xdb 格式生成程序会自动去重和压缩部分数据,默认的全部 IP 数据,生成的 ip2region.xdb 数据库是 11MiB,随着数据的详细度增加数据库的大小也慢慢增大。


3、极速查询响应


即使是完全基于 xdb 文件的查询,单次查询响应时间在十微秒级别,可通过如下两种方式开启内存加速查询:



  1. vIndex 索引缓存 :使用固定的 512KiB 的内存空间缓存 vector index 数据,减少一次 IO 磁盘操作,保持平均查询效率稳定在10-20微秒之间。

  2. xdb 整个文件缓存:将整个 xdb 文件全部加载到内存,内存占用等同于 xdb 文件大小,无磁盘 IO 操作,保持微秒级别的查询效率。


多语言以及查询客户端的支持


已经客户端有:Java、C#、php、C、Python、Node.js、PHP 拓展(PHP 5 和 PHP 7)等,主要如下:


binding描述开发状态binary查询耗时b-tree查询耗时memory查询耗时
cANSC c binding已完成0.0x毫秒0.0x毫秒0.00x毫秒
c#c# binding已完成0.x毫秒0.x毫秒0.1x毫秒
Golanggolang binding已完成0.x毫秒0.x毫秒0.1x毫秒
Javajava binding已完成0.x毫秒0.x毫秒0.1x毫秒
Lualua实现 binding已完成0.x毫秒0.x毫秒0.x毫秒
Lua_clua的c扩展已完成0.0x毫秒0.0x毫秒0.00x毫秒
nginxnginx的c扩展已完成0.0x毫秒0.0x毫秒0.00x毫秒
nodejsnodejs已完成0.x毫秒0.x毫秒0.1x毫秒
phpphp实现 binding已完成0.x毫秒0.1x毫秒0.1x毫秒
php5_extphp5的c扩展已完成0.0x毫秒0.0x毫秒0.00x毫秒
php7_extphp7的c扩展已完成0.0毫秒0.0x毫秒0.00x毫秒
pythonpython bindng已完成0.x毫秒0.x毫秒0.x毫秒
rustrust binding已完成0.x毫秒0.x毫秒0.x毫秒

Ip2region xdb Java 查询客户端实现




这里简单展示一下 Java 的实现,这里使用开发中常用的 Maven 实现的方式:


1. 引入 Maven 仓库


由于项目使用Spring 的方式构建,这里可以选择使用引入 Spring 的 starter 的方式进行


<dependency>
<groupId>com.github.hiwepy</groupId>
<artifactId>ip2region-spring-boot-starter</artifactId>
<version>2.0.1.RELEASE</version>
</dependency>

<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>2.7.0</version>
</dependency>

在引入 Maven 依赖之后,我们这里引入几种实现的方式:


2. 实现方式 1:【基于文件查询】


import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
public static void main(String[] args) {
// 1、创建 searcher 对象
String dbPath = "ip2region.xdb file path";
Searcher searcher = null;
try {
searcher = Searcher.newWithFileOnly(dbPath);
} catch (IOException e) {
System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
return;
}

// 2、查询
try {
String ip = "1.2.3.4";
long sTime = System.nanoTime();
String region = searcher.search(ip);
long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
} catch (Exception e) {
System.out.printf("failed to search(%s): %s\n", ip, e);
}

// 3、备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
}
}

3. 实现方式 2:【缓存VectorIndex索引】


我们可以提前从 xdb 文件中加载出来 VectorIndex 数据,然后全局缓存,每次创建 Searcher 对象的时候使用全局的 VectorIndex 缓存可以减少一次固定的 IO 操作,从而加速查询,减少 IO 压力。


import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
public static void main(String[] args) {
String dbPath = "ip2region.xdb file path";

// 1、从 dbPath 中预先加载 VectorIndex 缓存,并且把这个得到的数据作为全局变量,后续反复使用。
byte[] vIndex;
try {
vIndex = Searcher.loadVectorIndexFromFile(dbPath);
} catch (Exception e) {
System.out.printf("failed to load vector index from `%s`: %s\n", dbPath, e);
return;
}

// 2、使用全局的 vIndex 创建带 VectorIndex 缓存的查询对象。
Searcher searcher;
try {
searcher = Searcher.newWithVectorIndex(dbPath, vIndex);
} catch (Exception e) {
System.out.printf("failed to create vectorIndex cached searcher with `%s`: %s\n", dbPath, e);
return;
}

// 3、查询
try {
String ip = "1.2.3.4";
long sTime = System.nanoTime();
String region = searcher.search(ip);
long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
} catch (Exception e) {
System.out.printf("failed to search(%s): %s\n", ip, e);
}

// 备注:每个线程需要单独创建一个独立的 Searcher 对象,但是都共享全局的制度 vIndex 缓存。
}
}

4. 实现方式 3:「缓存整个 xdb 数据」


我们也可以预先加载整个 ip2region.xdb 的数据到内存,然后基于这个数据创建查询对象来实现完全基于文件的查询,类似之前的 memory search。


import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
public static void main(String[] args) {
String dbPath = "ip2region.xdb file path";

// 1、从 dbPath 加载整个 xdb 到内存。
byte[] cBuff;
try {
cBuff = Searcher.loadContentFromFile(dbPath);
} catch (Exception e) {
System.out.printf("failed to load content from `%s`: %s\n", dbPath, e);
return;
}

// 2、使用上述的 cBuff 创建一个完全基于内存的查询对象。
Searcher searcher;
try {
searcher = Searcher.newWithBuffer(cBuff);
} catch (Exception e) {
System.out.printf("failed to create content cached searcher: %s\n", e);
return;
}

// 3、查询
try {
String ip = "1.2.3.4";
long sTime = System.nanoTime();
String region = searcher.search(ip);
long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
} catch (Exception e) {
System.out.printf("failed to search(%s): %s\n", ip, e);
}

// 备注:并发使用,用整个 xdb 数据缓存创建的查询对象可以安全的用于并发,也就是你可以把这个 searcher 对象做成全局对象去跨线程访问。
}
}

5. 编译测试程序


通过 maven 来编译测试程序。


# cd 到 java binding 的根目录
cd binding/java/
mvn compile package

然后会在当前目录的 target 目录下得到一个 ip2region-{version}.jar 的打包文件。


6. 查询测试



  • 可以通过 java -jar ip2region-{version}.jar search 命令来测试查询:


➜  java git:(v2.0_xdb) ✗ java -jar target/ip2region-2.6.0.jar search
java -jar ip2region-{version}.jar search [command options]
options:
--db string ip2region binary xdb file path
--cache-policy string cache policy: file/vectorIndex/content


  • 例如:使用默认的 data/ip2region.xdb 文件进行查询测试:


➜  java git:(v2.0_xdb) ✗ java -jar target/ip2region-2.6.0.jar search --db=../../data/ip2region.xdb
ip2region xdb searcher test program, cachePolicy: vectorIndex
type 'quit' to exit
ip2region>> 1.2.3.4
{region: 美国|0|华盛顿|0|谷歌, ioCount: 7, took: 82 μs}
ip2region>>

输入 ip 即可进行查询测试,也可以分别设置 cache-policy 为 file/vectorIndex/content 来测试三种不同缓存实现的查询效果。


bench 测试


可以通过 java -jar ip2region-{version}.jar bench 命令来进行 bench 测试,一方面确保 xdb 文件没有错误,一方面可以评估查询性能:


➜  java git:(v2.0_xdb) ✗ java -jar target/ip2region-2.6.0.jar bench
java -jar ip2region-{version}.jar bench [command options]
options:
--db string ip2region binary xdb file path
--src string source ip text file path
--cache-policy string cache policy: file/vectorIndex/content

例如:通过默认的 data/ip2region.xdb 和 data/ip.merge.txt 文件进行 bench 测试:


➜  java git:(v2.0_xdb) ✗ java -jar target/ip2region-2.6.0.jar bench --db=../../data/ip2region.xdb --src=../../data/ip.merge.txt
Bench finished, {cachePolicy: vectorIndex, total: 3417955, took: 8s, cost: 2 μs/op}

可以通过分别设置 cache-policy 为 file/vectorIndex/content 来测试三种不同缓存实现的效果。 @Note: 注意 bench 使用的 src 文件要是生成对应 xdb 文件相同的源文件。


作者:夭要7夜宵
来源:juejin.cn/post/7295576148364296231
收起阅读 »

为什么堂堂微信数据库表名、字段名起的如此随意?

1.微信数据库解密 微信数据库在在哪个文件夹 EnMicroMsg.db的父文件加密规则是md5("mm" + uin)这样就可以准确的获取到db文件的位置. uin的获取:/data/data/com.tencent.mm/shared_prefs/au...
继续阅读 »

1.微信数据库解密




  • 微信数据库在在哪个文件夹


    EnMicroMsg.db的父文件加密规则是md5("mm" + uin)这样就可以准确的获取到db文件的位置.


    uin的获取:/data/data/com.tencent.mm/shared_prefs/auth_info_key_prefs.xml`里面有个uinz字段,直接获取value值,示例如下图所示:


    image-20210526135110550




  • 解密微信数据库:目前只涉及两个数据库EnMicroMsg.db(微信数据涉数据库)和WxFileIndex.db(文件索引数据库)


    解密的密码:md5(手机imei+uin)的32位字符串取前7位,如果imei拿不到就用1234567890ABCDEF代替




2. 好友相关信息


微信的好友相关数据涉及三张表:rcontact,bizinfo,img_flag




  • rcontact表存放了该账号涉及的所有账号(微信账号,群账号)的基本信息(eg:微信昵称,备注,原微信号,改之后的微信号,全拼等等)。如下图所示:


    111




  • bizinfo表存放的是该账号的好友微信号,群账号,这里好友包括已经通过的和添加没通过的,如下所示:


    22222




  • img_flag表存放该账号所有涉及的微信(好友,同属一个群不是好友,添加的陌生人)的头像地址,数据如下图所示:


    3333


    总结:rcontact表是一张基础表,存放所有的账号基本信息,bizinfo存放是该账号的好友信息或者群组信息,img_flag存放了微信账号对应的头像信息,以下场景有:




    • 获取微信好友信息,查询sql如下:


      select r.username, r.alias, r.conRemark, r.nickname, r.pyInitial, r.quanPin,r.encryptUserName, i.reserved2 from rcontact r INNER JOIN img_flag i  on r.username = i.username where r.type&2=2  and r.username not like '%@chatroom' and i.lastupdatetime > 0



    • 获取添加未通过的好友信息,此时有两种情况:1)添加同属一个群的好友。2)添加陌生人。比如说通过微信号,扫码什么。这两种情况在数据库的表现形式是不一样的,添加同属一个群的,在bizinfo表会插入一条username为添加好友的微信号记录,而如果是添加陌生人,则username是一个以@stranger结尾的key,对应的数据如下图所示:


      4444


      注意:这里如果通过微信号,扫码添加的陌生人,其username是一长串的以@stranger结尾的key,同 时pyInitial,qunPin两个字段存的并不是这个陌生人的微信号


      查询sql如下:


      SELECT r.username, r.alias, r.conRemark, r.nickname, r.pyInitial, r.quanPin, r.encryptUserName, i.reserved2 FROM rcontact r INNER JOIN bizinfo b ON r.username = b.username INNER JOIN img_flag i ON r.username = i.username 
      WHERE r.type <> 33 and r.type & 2 <> 2 AND r.username <> '当前微信号' AND r.username NOT LIKE '%@chatroom' AND b.updateTime > 0



    • 获取同属一个群但不是好友的基本信息:


      查询sql如下:


      SELECT DISTINCT r.username, r.alias, r.conRemark, r.nickname, r.pyInitial, r.quanPin, i.reserved2 FROM rcontact r  INNER JOIN img_flag i ON r.username = i.username 
      WHERE r.username not in(select username from bizinfo) and i.lastupdatetime >0

    3.微信群组


    微信群组信息表为chatroom,存放着一些基本信息,数据如下图所示:


    555


    注意:微信群组一开始建立显示群昵称是所有好友微信昵称加起来的一个字符串,即displayname字段,但是如果修改了群昵称之后,显示的是修改之后的,这时候需要根据根据群账号chatroomname去rcontact表做关联查询,根据rcontact表的username等于群账号查询出一条记录,此时这条记录的字段nickname即修改后的群昵称,查询sql如下:


    select c.chatroomname, c.memberlist, c.displayname, c.roomowner, c.selfDisplayName, r.nickname from chatroom c inner join rcontact r on r.username = c.chatroomname where c.modifytime > 0


    目前的微信群组的头像在img_flag表没有存储,暂时找不到资源所在


    4.微信聊天数据


    微信的聊天记录是保存在message表中的,数据示例如下图:


    666


    msgSvrId:唯一标示一条聊天记录的id,可以作为更新聊天记录数据的条件


    createTime:发送消息的时间


    talker:如果是群账号,说明这条消息是群会话消息,发送人在content内容前面(发送人微信号:发送内容);如果是好友微信号,说明这条消息是好友会话消息


    isSend:发送或者接收标志。0:接收 1:发送


    type:消息类型 1:文本内容(包括小表情) 3:图片 34:语音 43:视频 47:大表情 49:文件


    436207665:微信红包 419430449:微信转账


    图片,视频,语音, 文件 根据msgId去索引库WxFileIndex的表WxFileIndex2查询




    • 图片查询sql:


      select * from WxFileIndex2 where msgId in(msgIds) and msgType=3 and msgSubType20



    • 语音查询sql:


      select * from WxFileIndex2 where msgId in(msgIds) and msgType=34



    • 视频查询sql:


      select * from WxFileIndex2 where msgId in(msgIds) and msgType=43 and msgSubType=1



    • 文件查询sql:


      select * from WxFileIndex2 where msgId in(msgIds) and msgType=49 and msgSubType = 34



    • 大表情查询sql:根据groupId去找到对应的包名,md5即表情的文件名


      select e.md5, e.groupid, m.msgSvrId from emojiinfo e INNER JOIN message m on e.md5=m.imgpath where m.type=47



    5.总结


    以上分析师基于Android系统端的微信,且微信数据的撤销删除仍需要研究,待补充,未完待续.......




作者:shepherd111
来源:juejin.cn/post/7295160228879122458
收起阅读 »

适合小公司的自动化部署脚本

背景(偷懒) 在小小的公司里面,挖呀挖呀挖。快挖不动了,一件事重复个5次,还在人肉手工,身体和心理就开始不舒服了,并且违背了个人的座右铭:“偷懒”是人类进步的第一推动力。 每次想要去测试环境验证个新功能,又或者被测试无情的催促着部署新版本后;都需要本地打那个2...
继续阅读 »

背景(偷懒)


在小小的公司里面,挖呀挖呀挖。快挖不动了,一件事重复个5次,还在人肉手工,身体和心理就开始不舒服了,并且违背了个人的座右铭:“偷懒”是人类进步的第一推动力


每次想要去测试环境验证个新功能,又或者被测试无情的催促着部署新版本后;都需要本地打那个200多M的jar包;以龟速般的每秒几十KB网络,通过ftp上传到服务器;用烂熟透的jps命令查找到进程,kill后,重启服务。


是的,我想偷懒,想从已陷入到手工部署的沼泽地里走出来。如何救赎?


自我救赎之路


我的诉求很简单,想要一款“一键CI/CD的工具”,然后可以继续偷懒。为了省事,我做了以下工作


找了一款停止服务的脚本,并做了小小的优化


首推 陈皮大哥的停服脚本(我在里面加了个sleep 5);脚本见下文。只需要修改 APP_MAINCLASS的变量“XXX-1.0.0.jar”替换为自己jar的名字即可,其它不用动


该脚本主要是通过jps + jar的名字获得进程号,进行kill。( 脚本很简单,注释也很详细,就不展开了,感兴趣可以阅读下,不到5分钟,写过代码的你能看懂的)


把以下脚本保存为stop.sh


#!/bin/bash
# 主类
APP_MAINCLASS="XXX-1.0.0.jar"
# 进程ID
psid=0
# 记录尝试次数
num=0
# 获取进程ID,如果进程不存在则返回0,
# 当然你也可以在启动进程的时候将进程ID写到一个文件中,
# 然后使用的使用读取这个文件即可获取到进程ID
getpid() {
javaps=`jps -l | grep $APP_MAINCLASS`
if [ -n "$javaps" ]; then
psid=`echo $javaps | awk '{print $1}'`
else
psid=0
fi
}
stop() {
getpid
num=`expr $num + 1`
if [ $psid -ne 0 ]; then
# 重试次数小于3次则继续尝试停止服务
if [ "$num" -le 3 ];then
echo "attempt to kill... num:$num"
kill $psid
sleep 5
else
# 重试次数大于3次,则强制停止
echo "force kill..."
kill -9 $psid
fi
# 检查上述命令执行是否成功
if [ $? -eq 0 ]; then
echo "Shutdown success..."
else
echo "Shutdown failed..."
fi
# 重新获取进程ID,如果还存在则重试停止
getpid
if [ $psid -ne 0 ]; then
echo "getpid... num:$psid"
stop
fi
else
echo "App is not running"
fi
}
stop

编写2行的shell 启动脚本


修改脚本中的XXX-1.0.0.jar为你自己的jar名称即可。保存脚本内容为start.sh。jvm参数可自行修改


basepath=$(cd `dirname $0`; pwd)
nohup java -server -Xmx2g -Xms2g -Xmn1024m -XX:PermSize=128m -Xss256k -XX:+DisableExplicitGC -XX:+UseParNewGC -XX:-UseAdaptiveSizePolicy -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:LargePageSizeInBytes=128m -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -Xloggc:logs/gc.log -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:HeapDumpPath=logs/dump.hprof -XX:ParallelGCThreads=4 -jar $basepath/XXX-1.0.0.jar &>nohup.log &

复用之前jenkins,自己写部署脚本


脚本一定要放到 Post Steps里


1689757456174.png


9行脚本,主要干了几件事:



  • 备份正在运行的jar包;(万一有啥情况,还可以快速回滚)

  • 把jenkins上打好的包,复制到目标服务上

  • 执行停服脚本

  • 执行启动服务脚本


脚本见下文:


ssh -Tq $IP << EOF 
source /etc/profile
#进入应用部署目录
cd /data/app/test
##备份时间戳
DATE=`date +%Y-%m-%d_%H-%M-%S`
#删除备份jar包
rm -rf /data/app/test/xxx-1.0.0.jar.bak*
#备份历史jar包
mv /data/app/test/xxx-1.0.0.jar /data/app/test/xxx-1.0.0.jar.bak$DATE
#从jenkins上拉取最新jar包
scp root@$jenkisIP:/data/jenkins/workspace/test/target/XXX-1.0.0.jar /data/app/test
# 执行停止应用脚本
sh /data/app/test/stop.sh
#执行重启脚本
sh /data/app/test/start.sh
exit
EOF

注:



  • $IP 是部署服务器ip,$jenkisIP 是jenkins所在的服务器ip。 在部署前请设置jenkins服务器和部署服务器之间ssh免密登录

  • /data/app/test 是部署jar包存放路径

  • stop.sh 是上文的停止脚本

  • start.sh 是上文的启动脚本


总结


如果不想把时间浪费在本地打包,忍受不了上传jar包的龟速网络,人肉停服和启动服务。请尝试下这款自动部署化脚本。小小的投入,带来大大的回报。


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


参考资料:

xie.infoq.cn/article/52c…
Linux ----如何使用 kill 命令优雅停止 Java 服务

blog.csdn.net/m0_46897923… --ssh免密登录


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

如何优雅的判断一个对象是否为空?

我们在刚开始学习Java的时候,遇到过最多的异常肯定是臭名昭著的空指针异常(NullPointerException),可以说它陪伴了我们整个初学阶段。字符串、对象、集合等等一不留神就容易出现空指针异常! 那么如何优雅的判断一个对象是否为空并且减少空指针异常呢...
继续阅读 »

我们在刚开始学习Java的时候,遇到过最多的异常肯定是臭名昭著的空指针异常(NullPointerException),可以说它陪伴了我们整个初学阶段。字符串、对象、集合等等一不留神就容易出现空指针异常!


那么如何优雅的判断一个对象是否为空并且减少空指针异常呢?


今天来介绍一个容器类——Optional


Optional介绍


Optional是一个容器类,它只有两种情况:



  • 要么包含一个非空对象

  • 要么为空


它有助于编写更健壮的代码,以处理可能为空的值,而不必担心空指针异常!


Optional用法


Optional的创建


Optional有以下两种常见的创建方法:



  • Optional.of(T value):创建一个包含非空值的Optional,如果value为null,则抛出NullPointerException

  • Optional.ofNullable(T value):创建一个Optional,允许value为null


判断Optional容器中是否包含对象


isPresent(): 返回一个布尔值,如果Optional容器中包含一个非空对象则返回true,否则返回false


获取Optional容器的对象



  • get(): 如果Optional包含非空值,返回该值;否则抛出NoSuchElementException

  • orElse(T other): 如果Optional为空,返回指定的默认值other

  • orElseGet(Supplier<? extends T> other): 如果Optional为空,使用提供的Supplier函数生成默认值

  • orElseThrow(Supplier<? extends X> exceptionSupplier): 如果Optional为空,抛出由提供的Supplier函数生成的异常


过滤


filter(Predicate<? super T> predicate): 如果Optional包含非空值且满足predicate条件,则返回当前Optional,否则返回一个空Optional。


映射



  • map(Function<? super T, ? extends U> mapper): 如果Optional包含非空值,应用mapper函数并返回新的Optional。

  • flatMap(Function<? super T, Optional> mapper): 类似于map,但允许mapper函数返回Optional。


Optional示例


假如我们有一个User类,可以使用Optional来处理可能为空的User对象。User类结构如下:


public class User {
private String name;

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

public String getName() {
return name;
}
}

示例:创建Optional



Optional userOptional = Optional.ofNullable(new User("张三"));



示例:判断Optional是否包含对象


if (userOptional.isPresent()) {
System.out.println("用户存在:" + userOptional.get().getName());
} else {
System.out.println("用户不存在");
}

示例:获取Optional容器的对象


User user = userOptional.orElse(new User("李四"));
System.out.println("User: " + user.getName());

示例:过滤


Optional<User> filteredUserOptional = userOptional.filter(u -> u.getName().startsWith("张"));
if (filteredUserOptional.isPresent()) {
System.out.println("结果:" + filteredUserOptional.get().getName());
} else {
System.out.println("未找到对应用户");
}

示例:映射


Optional<String> userNameOptional = userOptional.map(User::getName);
userNameOptional.ifPresent(name -> System.out.println("用户名为: " + name));

使用场景总结



  • 当你从某个方法返回一个值,但该值可能为空,而调用者需要明确知道值是否存在。

  • 在处理方法参数时,你可以用Optional来表示某个参数可以为空,以提醒调用者可能会传入null。

  • 避免繁琐的null检查和条件语句,使代码更简洁和可读!



更多文章干货,推荐公众号【程序员老J】



作者:程序员老J
来源:juejin.cn/post/7298142364194979852
收起阅读 »

强无敌!一个项目涵盖SpringBoot集成各种场景

大家好,我是 Java陈序员。我们都知道,作为 Java 后端开发肯定绕不开 Spring,而 SpringBoot 的横空出世更是帮助我们开发者可以快速迭代一个项目! SpringBoot 之所以强大,是因为支持自动化配置,可以快速装配组件,如持久化框架缓存...
继续阅读 »

大家好,我是 Java陈序员。我们都知道,作为 Java 后端开发肯定绕不开 Spring,而 SpringBoot 的横空出世更是帮助我们开发者可以快速迭代一个项目!


SpringBoot 之所以强大,是因为支持自动化配置,可以快速装配组件,如持久化框架缓存消息队列日志等等。


今天给大家介绍一个 SpringBoot 集成各种场景的项目,可以用来学习,也可以开箱即用无需重复造轮子


项目简介


spring boot demo 是一个用来深度学习并实战 spring boot 的项目,目前总共包含 66 个集成 demo,已经完成 55 个。


目前已经集成功能:



  • actuator:监控

  • admin:可视化监控

  • logback:日志

  • aopLog:通过 AOP 记录 Web 请求日志

  • 统一异常处理:json 级别和页面级别

  • freemarker:模板引擎

  • thymeleaf:模板引擎

  • Beetl:模板引擎

  • Enjoy:模板引擎

  • JdbcTemplate:通用 JDBC 操作数据库

  • JPA:强大的 ORM 框架

  • Mybatis:强大的 ORM 框架

  • 通用 Mapper:快速操作 Mybatis

  • PageHelper:通用的 Mybatis 分页插件

  • Mybatis-plus:快速操作 Mybatis

  • BeetlSQL:强大的 ORM 框架

  • upload:本地文件上传和七牛云文件上传

  • Redis:缓存

  • ehcache:缓存

  • email:发送各种类型邮件

  • task:基础定时任务

  • quartz:动态管理定时任务

  • xxl-job:分布式定时任务

  • swaggerAPI 接口管理测试

  • security:基于 RBAC` 的动态权限认证

  • SpringSessionSession 共享

  • Zookeeper:结合 AOP 实现分布式锁

  • RabbitMQ:消息队列

  • Kafka:消息队列

  • websocket:服务端推送监控服务器运行信息

  • socket.io:聊天室

  • ureport2:中国式报表

  • 打包成 War 文件

  • 集成 ElasticSearch:基本操作和高级查询

  • Async:异步任务

  • 集成Dubbo:采用官方的starter

  • MongoDB:文档数据库

  • neo4j:图数据库

  • Docker:容器化

  • JPA 多数据源

  • Mybatis 多数据源

  • 代码生成器

  • GrayLog:日志收集

  • JustAuth:第三方登录

  • LDAP:增删改查

  • 动态添加/切换数据源

  • 单机限流:AOP + Guava RateLimiter

  • 分布式限流:AOP + Redis + Lua

  • ElasticSearch 7.x:使用官方 Rest High Level Client

  • HTTPS

  • Flyway:数据库初始化

  • UReport2:中国式复杂报表


项目地址


https://github.com/xkcoding/spring-boot-demo

运行使用


开发环境



  • JDK 1.8 +

  • Maven 3.5 +

  • IntelliJ IDEA ULTIMATE 2018.2 + (注意:务必使用 IDEA 开发,同时保证安装 lombok 插件)

  • Mysql 5.7 + (尽量保证使用 5.7 版本以上,因为 5.7 版本加了一些新特性,同时不向下兼容。项目会尽量避免这种不兼容的地方,但还是建议尽量保证 5.7 版本以上)


代码导入



  1. 使用 git 克隆代码:


git clone https://github.com/xkcoding/spring-boot-demo.git

2. 使用 IDEA 打开 clone 下来的项目



  1. 找到各个 ModuleApplication 类就可以运行各个 demo



注意:



  1. 每个 demo 均有详细的 README,运行 demo 之前记得先看看

  2. 有些 demo 需要事先初始化数据库数据的



模块代码介绍


模块代码介绍


模块代码介绍


模块代码介绍


模块代码介绍


模块代码介绍


最后


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


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

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


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


大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!



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

和斯坦福博士写代码的一个月

近一个月,闭关开发了一个面向海外的项目,合作的对象是斯坦福和麻省理工的博士,很荣幸能够和这些全球顶尖大学毕业的大牛合作,今天,趁着周末总结下自己的一些感受。 1. 英语是硬伤 因为项目是面向海外,整个合作全程是英语,这就一览无遗地暴露出自己的英文短板,特别是口...
继续阅读 »

近一个月,闭关开发了一个面向海外的项目,合作的对象是斯坦福和麻省理工的博士,很荣幸能够和这些全球顶尖大学毕业的大牛合作,今天,趁着周末总结下自己的一些感受。


1. 英语是硬伤


因为项目是面向海外,整个合作全程是英语,这就一览无遗地暴露出自己的英文短板,特别是口语,在技术评审过程和讨论中,自己捉襟见肘的英文,只能不断的通过技术流程图和文字来弥补口语表达的不足。


最开始合作时比较羞涩,毕竟知道自己的英语有几斤几两,后面慢慢的放开,把自己的英语老本和盘托出,不过这也坚定了自己加强英文的决心,毕竟做程序开发,英语功底直接决定你理解英文资料的速度和程度。


2. 编程语言要多样


我自己最常用的是 java,但这次项目开发的主要语言是python,另外还涉及到C/C++, java, nodejs。尽管python,C/C++都有所了解,但是,要像 Java一样轻松驾驭去开发这么庞大的工程还是有点吃力。而在合作的过程中,发现他们语言的全能性,python,Java,C++,前端语言,他们都可以平滑切换,绝对的全栈工程师。


这个或许跟每个国家IT环境不一样有很大的关系,但是,作为 Java程序员,我个人还是比较建议再掌握一门动态语言,首选是python,像目前比较主流的数据分析、机器学习、科学计算、自动化和游戏开发等,Python绝对功不可没。另外,通过两种语言的对比,你能更好的看清每种语言的优点和不足,在日常开发中,或许就能借鉴另外一种语言的优点。


3. CR的重要性


CR,就是我们常说的Code Review(代码审查)。在国内的公司工作了这么多年,会做 CR的公司很少,包括一线大厂,能严格执行 CR的更是微乎其微,很多人说是因为业务太多,没有时间做CR,但是,究其原因是国内没有 CR的基因,技术管理者不推动,底层开发人员借着业务繁忙的理由,所以 CR就形同虚设。


下面给了几张项目CR的截图,因为涉及到商业隐私,所以部分信息被打码了:


图一:对于错误的实现,CR 会指正和给出正解。


图片


图二:展示了 CR 甚至细化到了Doc文档级别。


图片


图三:展示了 CR 过程中的探讨,给出自己意见的同时也征询组员更多的idea


图片


CR看似很费时,反反复复的修改和讨论,但是,它和技术方案的讨论不一样,CR针对的是技术方案已经成型的代码实现,所以这个过程更能体现出不同技术人员对同一个技术方案的的思考和实现方式,对于整个项目的质量有大大的提高,另外对于开发者之间的磨合是一个很好的润滑剂。


4. 包容


越优秀的人越懂得包容。这句话放到这个项目上最合适,一个是对于我英语水平的包容。一个是对我 Python 掌握程度的包容。通过上面部分 CR截图也能体现出来。有了这些包容,让我可以更快,更有信心的融入到项目开发。


5. 较真


对于代码实现,技术方案的较真,通过上面的 CR截图也可以体现,他们对于代码的较真可以小到一个Doc文档和标点符号,以及代码的格式化,对于代码性能可以通过大量的技术测试和对比,甚至这块代码不是他们负责的,这个或许就是我们说的技术洁癖和极客精神。


依然记得几年前的一个技术峰会上,某大牛说:中国的码农千千万万,如何让你的代码有亮点,那就是不断的抠细节,大到技术架构,小到标点符号,然后不断的重构,重构。


总结

  1. 通过这个项目,让我有幸见识了从 Google,AWS出来的优秀程序员的编码习惯。
  2. 学英语绝对不是崇洋媚外,它在一定程度上绝对了程序员的技术高度和深度,可以观察你身边优秀的技术人员,英语是不是都不差。
  3. 一定要向优秀的人靠近,和他们一起写代码,勇敢的让他们帮你CR,在交流和讨论中获取更多的葵花宝典;
  4. 程序员是一个终身学习的职业,一定要持续学习;


作者:猿java
来源:juejin.cn/post/7296111218721095715
收起阅读 »

你看现在的程序员,是怎么解bug的

大家好,我是伍六七。 干程序员的都会碰到各种各样的 bug,有的 bug 我们看一眼、或者多看几眼就能看出来问题。 但是,也有很多问题,我们看不出来,这个时候,我们不妨把我们的代码扔给 GPT,看看它能不能帮我们找到 bug,Fix the bug! 我们来看...
继续阅读 »

大家好,我是伍六七。


干程序员的都会碰到各种各样的 bug,有的 bug 我们看一眼、或者多看几眼就能看出来问题。


但是,也有很多问题,我们看不出来,这个时候,我们不妨把我们的代码扔给 GPT,看看它能不能帮我们找到 bug,Fix the bug!


我们来看个例子:


阿七来到这个公司之前,一直在传统 IT 公司工作,从来没用过什么分布式锁、多线程等高端技术。


这次,有一个业务场景,需要使用分布式锁解决问题。阿七按照自己的了解,写了一个分布式锁,结果死活有问题。


看了半天,头都挠凸了,就是不知道怎么回事,这个时候,他想到了大模型,他把问题扔给了 GPT。


prompt:



long currentTimeMillis = System.currentTimeMillis();
final String sequenceId = UUID.randomUUID().toString();
redisLockService.lock(username + currentTimeMillis, sequenceId, 2, TimeUnit.SECONDS);
---
请你分析上面的Java代码,如果有bug,请指出来,并给出正确写法



大模型返回:


这段代码的目的是使用 Redis 锁来对某个资源进行加锁操作。下面是对代码的分析:




  1. 获取当前时间的毫秒数,存储在 currentTimeMillis 变量中。




  2. 生成一个随机的 UUID 字符串,存储在 sequenceId 变量中。




  3. 调用 redisLockService.lock 方法,传入参数 username + currentTimeMillissequenceId2TimeUnit.SECONDS,对资源进行加锁操作。




如果存在 bug,可能是以下几个方面:




  1. username 变量的值是否正确,确保它是唯一的且能够标识资源的唯一性。




  2. redisLockService.lock 方法的实现是否正确,确保它能够正确地加锁并设置过期时间。




  3. 锁的释放是否在适当的时机进行,以避免资源被长时间锁定。




正确的写法可能如下所示:


long currentTimeMillis = System.currentTimeMillis();
final String sequenceId = UUID.randomUUID().toString();
String lockKey = username + currentTimeMillis;
boolean lockAcquired = redisLockService.lock(lockKey, sequenceId, 2, TimeUnit.SECONDS);
try {
if (lockAcquired) {
// 执行需要加锁的操作
} else {
// 锁未获取到,处理相应逻辑
}
} finally {
if (lockAcquired) {
redisLockService.unlock(lockKey, sequenceId);
}
}

在正确的写法中,我们首先获取锁,并在获取到锁的情况下执行需要加锁的操作。然后,在 finally 块中,我们释放锁,确保无论是否成功获取到锁,都能够正确释放资源。




一顿瞎扯,就是没找到最严重的那个 bug,我们继续问它。


prompt:



你没有找到这段代码的最严重的 bug,请你再深入分析分析,找到代码中的严重 bug



大模型返回:


非常抱歉,我之前的回答有误。这段代码存在一个严重的 bug,即在分布式环境下,由于锁的 key 值不唯一,可能会导致锁的误解锁或者锁的覆盖,从而导致并发问题。


正确的写法应该是使用 usernamesequenceId 作为锁的 key 值,以确保锁的唯一性。修改后的代码如下:


long currentTimeMillis = System.currentTimeMillis();
final String sequenceId = UUID.randomUUID().toString();
String lockKey = username + "_" + sequenceId;
redisLockService.lock(lockKey, sequenceId, 2, TimeUnit.SECONDS);
try {
// 执行需要加锁的操作
} finally {
redisLockService.unlock(lockKey, sequenceId);
}

在正确的写法中,我们使用 usernamesequenceId 作为锁的 key 值,以确保锁的唯一性。同时,在 finally 块中,我们释放锁,确保无论是否成功获取到锁,都能够正确释放资源。


这次,我们发现,它找到了代码中最严重的 bug,但是它给出的代码依然没有解决这个 bug,我们继续问它。


prompt;



lockKey 还是没有保证唯一性,并不能解决分布式锁资源不唯一的问题。请你深入思考,再给出正确的设置 lockKey 的代码.



大模型返回:


非常抱歉之前的回答仍然有误。你是正确的,lockKey 的生成方式仍然存在问题,无法保证在分布式环境下的唯一性。


为了确保锁的唯一性,可以使用 Redis 的分布式锁实现方式,例如使用 Redisson 或者 RedLock 等库来实现分布式锁。这些库提供了可靠的分布式锁实现,可以确保在分布式环境下的唯一性和正确性。


下面是使用 Redisson 实现分布式锁的示例代码:


long currentTimeMillis = System.currentTimeMillis();
final String lockKey = "lock:" + username; // 使用固定的前缀加上 username 作为锁的唯一标识
final String sequenceId = UUID.randomUUID().toString();
RLock lock = redissonClient.getLock(lockKey);
boolean lockAcquired = false;
try {
lockAcquired = lock.tryLock(2, TimeUnit.SECONDS);
if (lockAcquired) {
// 执行需要加锁的操作
} else {
// 锁未获取到,处理相应逻辑
}
} finally {
if (lockAcquired) {
lock.unlock();
}
}

在上述代码中,我们使用 Redisson 提供的 RLock 接口来获取分布式锁。通过调用 tryLock 方法来尝试获取锁,设置超时时间为 2 秒。如果成功获取到锁,则执行需要加锁的操作;否则,处理相应的逻辑。在 finally 块中,我们根据是否成功获取到锁来决定是否释放锁。


请确保你已经正确地引入了 Redisson 依赖,并且配置了正确的 Redis 连接信息。


这次,它成功的理解了我们的意思,并且修改了代码,解决了分布式锁资源 key 不唯一的问题。




关注我,送你全套我整理的 Java 岗位面试资料。这是我自己之前整理的面试题,靠着这份面试题,我从 30 人的小公司,进了 2000 人+的央企子公司,之后又进了互联网大厂。


作者:程序员伍六七
来源:juejin.cn/post/7296111218720981027
收起阅读 »

揭秘小米手机被疯狂吐槽的存储扩容技术

前段时间,在小米14的发布会上,雷布斯公布了名为“Xiaomi Ultra Space存储扩容”的技术,号称可以在512G的手机中再搞出来16G,256G的手机中再搞出8G。对于普通用户来说,能多得一些存储空间,无异是个很好的福利,不过也有网友说这是以损害存储...
继续阅读 »

image.png
前段时间,在小米14的发布会上,雷布斯公布了名为“Xiaomi Ultra Space存储扩容”的技术,号称可以在512G的手机中再搞出来16G,256G的手机中再搞出8G。对于普通用户来说,能多得一些存储空间,无异是个很好的福利,不过也有网友说这是以损害存储使用寿命为代价的,那么真相到底如何呢?这篇文章我就从技术角度来给大家详细分析下。


认识闪存


首先让我们来了解一些手机存储的基本知识。


手机存储使用的是闪存技术,其本质和U盘、固态硬盘都是一样的。


在闪存中读写的基本单位是页(Page),比页更大的概念是块(Block),一个块会包含很多页。


虽然读写的基本单位都是页,但是写实际操作的很可能是块,这是为什么呢?


这要从删除谈起,在闪存中删除数据时不会立即删除页上的数据,而只是给页打上一个空闲的标签。这是因为谁也不知道这个页什么时候会再写入数据,这样处理起来比较简单快速。


再看写操作,如果写入分配的页是因为删除而空闲的,数据并不能立即写入,根据闪存的特性,此时需要先把页上之前存储的数据擦除,然后才能写入;但是闪存中擦除操作的基本单位是块,此时就需要先把整个块中的有效数据读出来,然后再擦除块,最后再向块中写入修改后的整块数据;这整个操作称为“读-改-写”。当然如果写入分配的页是空白的,并不需要先进行擦除,此时直接写入就可以了。


预留空间


小米这次抠出来的存储空间来源于一个称为“预留空间”的区域,它的英文全称是Over Provisio,简称 OP。


那么“预留空间”是什么呢?我将通过5个方面来介绍它的用途,让大家近距离认识下。


提高写入速度


在上面介绍闪存的基本知识时,我们谈到闪存的写操作存在一种“读-改-写”的情况,因为额外的读和擦除操作,这种方法的耗时相比单纯的写入会增加不少,闪存使用的时间越长,空白的空间越少,这种操作越容易出现,闪存的读写性能下降的越快。


为了提升写入的性能,我们可以先将新数据写入到预留空间,此时上层系统就可以认为已经写入完成,然后我们在后台将预留空间中的新数据和原数据块中需要保留的数据合并到一个新的数据块中,这样就避免了频繁的读-修改-写操作,从而可以大大提高写入速度。


垃圾回收和整理


在上面介绍闪存的基本知识时,我们还谈到删除数据并不是立即清除空间,而是给数据页打一个标签,这样做的效率比较高。这样做就像我们标记了垃圾,但是并没有把它们运走,时间久了,这些垃圾会占用很多的空间。这些垃圾空间就像一个个的小碎片,所以有时也把这个问题称为碎片化问题。


虽然我们可以通过“读-改-写”操作来重新利用这些碎片空间,包括通过异步的“读-改-写”操作来提升上层应用的写入效率,但无疑还是存在写入的难度,实际写入之前还是要先进行擦除。


为了解决上述问题,聪明的设计师们又想到了新办方法:让存储器在后台自动检测、自动整理存储中的数据碎片,而不是等到写入数据时再进行整理。


考虑到闪存的读擦写特性,当需要移除数据块中部分碎片或者将不同数据碎片合并时,就得把需要保留的数据先放到一个临时空间中,以免数据出现丢失,待存储中的数据块准备好之后再重新写入,预留空间就可以用作这个临时空间。


磨损均衡


闪存中每个块的写入次数都是有限制的,超过这个限制,块就可能会变得不可靠,不能再被使用。这就是我们通常所说的闪存的磨损。


为了尽可能延长闪存的使用寿命,我们需要尽量均匀地使用所有的闪存块,确保每个块的使用频率大致相同。这就是磨损均衡的主要目标。


假设我们发现块A的使用频率过高,我们需要将它的数据移动到没怎么用过的块B去,以达到磨损均衡的目的。首先,我们需要读取块A中的数据,然后将这些数据暂时存储到预留空间。然后,我们擦除块A,将它标记为空闲。最后,我们从预留空间中取出数据,写入到块B。实际上,磨损均衡的策略比这更复杂,不仅仅是看使用频率,还需要考虑其他因素,比如块的寿命,数据的重要性等。


可以看到,预留空间在这个过程中起到了临时存储数据的作用。


不过你可能会问,为什么不直接将块A的数据复制到块B,而需要一个临时空间?


这是因为在实际操作中直接复制块A的数据到块B会带来一些问题和限制。


假如直接进行这种数据复制,那么在数据从块A复制到块B的过程中,块A和块B中都会存在一份相同的数据,如果有其他进程在这个过程中访问了这份数据,可能会产生数据一致性的问题。此外,如果移动过程中发生意外中断,如电源故障,可能会导致数据在块B中只复制了一部分,而块A中的数据还未被擦除,这样就可能导致数据丢失或者数据不一致的问题。


而如果我们使用预留空间,也就是引入一个第三方,就可以缓解这些问题。我们先将数据从块A复制到预留空间,然后擦除块A,最后再将预留空间中的数据写入到块B。在这个过程中,我们可以借助预留空间来实现一些原子性的机制,来保证数据不会丢失和数据的一致性。


错误校正


预留空间还可以用来存储错误校正码(ECC)。如果在读取数据时发现有错误,可以用错误校正码来修复这些错误,提高数据的可靠性。


很多同学可能也不了解这个错误校正码的来龙去脉,这里多说几句。


我们知道计算机中的数据最终都是二进制的0和1,0和1使用硬件比较好表达,比如我们使用高电压表示1,低电压表示0。但是硬件有时候会出错,本来写进去的是1,读出来的却是0。为了解决这个问题,设计师们就搞出来个错误校正码,这个校正码是使用某些算法基于要存储的数据算出来的,存储数据的时候把它一起保存起来。读取数据的时候再使用相同的算法进行计算,如果两个校正码对不上,就说明存储的数据出现错误了。然后ECC算法可以通过计算知道是哪一位出现了错误,改正它就可以恢复正确的数据了。


注意ECC能够修正的二进制位数有限,因为可以修复的位数越多,额外需要的存储空间也越大,具体能修复几位要考虑出现坏块的概率以及数据的重要性。


坏块管理


当闪存单元变为坏块时,预留空间可以提供新的闪存单元来替代坏块,此时读取对应数据时不再访问坏块,而是通过映射表转到预留空间中读取,从而保证数据的存储和读取不受影响,提高了固态硬盘的可靠性和耐用性。


综上所述,预留空间在提升固态硬盘性能,延长其使用寿命,提高数据的可靠性等方面发挥着重要的作用。


小米的优化


根据公开资料,小米将预留空间的占比从6.9%压缩到了约3%。


那么小米是怎么做到的呢?以下是官方说法:


小米在主机端也基于文件管理深度介入了 UFS 的资源管理,通过软件实现“数据非必要不写入(UFS)”,通过软件 + 固件实现“写入数据非必要不迁移”,减少写入量的同时也实现了更好的 wear-leveling 和 WAF


还有一张图:



优化解读


这里用了一些术语,文字也比较抽象,我这里解读下:


UFS(Universal Flash Storage)即通用闪存存储,可以理解为就是手机中的存储模块。


“数据非必要不写入(UFS)”也就是先把数据写入到缓冲区,然后等收到足够的数据之后(比如1页),再写入闪存单元,这样就可以减少闪存单元的擦写次数,自然就能延长闪存单元的使用寿命,推迟坏块的产生。这个缓冲区类似于计算机的内存,如果突然掉电可能会丢失一部分数据,但是对于手机来说,突然掉电这个情况发生的几率极低,所以小米在这里多缓存点数据对数据丢失的影响很小,不过还是需要注意缓冲空间有限,这个值也不能太大,具体多少小米应该经过大量测试之后做了评估。


“写入数据非必要不迁移” 没有细说怎么做的,大概率说的是优化磨损均衡、垃圾回收和整理策略,没事别瞎整理,整理的时候尽量少擦写,目的还是延长闪存单元的使用寿命。


“增加坏块预留” 小米可以根据用户的使用情况调整坏块预留区的大小,比如用户是个重度手机使用狂,他用1年相当于别人用4年,小米系统就会增加坏块预留区,以应对擦写次数增加带来的坏块几率增加。注意这个调整是在云端实现的,如果手机不联网,这个功能还用不上。


wear-leveling:就是上面提到的磨损均衡,小米优化了均衡算法,减少擦写。


WAF:写放大,Write Amplification Factor,缩写WAF。写放大就是上面提到的“读-改-写”操作引起的,因为擦除必须擦掉整个块的数据,所以上层系统只需要写一个页的情况下,底层存储可能要重写一个块,从页到块放大了写操作的数据量。因为闪存的寿命取决于擦除次数,所以写放大会影响到闪存的使用寿命。


概括来说就是,小米从存储的预留空间中抠出来一部分作为用户存储,不过预留空间的减小,意味着坏块管理、错误纠正等可以使用的空间变小,这些空间变小会减少存储的使用寿命,所以小米又通过各种算法延缓了手机存储的磨损速度,如此则对大家的使用没有什么影响,而用户又能多得一些存储空间。


小米的测试结果


对于大家担心小米手机存储的寿命问题,小米手机系统软件部总监张国全表示:“按照目前重度用户的模型来评估,在每天写入40GB数据的条件下, 256GB的扩容芯片依然可以保证超过10年, 512GB可以超过20年,请大家放心。”


同时一般固态硬盘往往都拥有5年的质保,而很多消费者往往会5年之内更换手机。因此按着这个寿命数据来看,普通消费者并不用太担心“扩容芯片”的寿命问题。所以如果你的手机用不了10年,可以不用担心这个问题。


当然更多的测试细节,小米并没有透漏,比如读写文件的大小等。不过按照小米的说法,存储的供应商也做了测试,没有什么问题。这个暂时只能相信小米是个负责任的企业,做好了完备的测试。




最后小米搞了这个技术,申请了专利,但是又把标准和技术方案贡献给了UFS协会,同时还要求存储芯片厂商设置了半年的保护期,也就是说技术可以分享给大家,但是请大家体谅下原创的辛苦,所以半年后其它手机厂商才能用上。


大家猜一下半年后其它手机厂商会跟进吗?


关注微/信/公/众/号:萤火架构,提升技术不迷路。


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

游戏服务器搭建过程中Maven多模块编译遇到的一些问题

游戏服务器有好几个项目,所以涉及到多个模块,这个开发中也是经常遇到的,但是因为之前一直没怎么关注,所以在开发的过程中的一些细节问题还是不懂,这次记录下,也分享下同样的问题给没试过的同学 1、多模块的创建 使用idea进行模块的创建,主要分为以下几步 1.1 父...
继续阅读 »

游戏服务器有好几个项目,所以涉及到多个模块,这个开发中也是经常遇到的,但是因为之前一直没怎么关注,所以在开发的过程中的一些细节问题还是不懂,这次记录下,也分享下同样的问题给没试过的同学


1、多模块的创建


使用idea进行模块的创建,主要分为以下几步


1.1 父模块的创建


直接创建一个Maven项目,在这个过程中选择了Spring web 和Lombok,也可以选择其他的包,到时候省的手写


image.png


1.2 删除垃圾文件


删除所有的文件,只留下pom.xml就行,因为父模块只是做一个模块和依赖管理的作用,因此不需要代码。


image.png


1.3 修改pom.xml


修改这个父模块的pom.xml文件,首先把节点、节点和全部删除:然后修改版本号为自己定义的(方便后续子模块指定父模块)


    <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.13</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>MultMoudle</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>MultMoudle</name>
<description>MultMoudle</description>
<packaging>pom</packaging>
</project>


1.4 创建子模块继承


继承父模块


image.png


可以看到父模块的pom中,已经有子模块的配置了。


2、子模块之间的互相引用


因为有一些接口文件需要在几个项目中共同使用,所以就要抽取相同的文件到common项目中,这个是非常合理的,在room和game中怎么引用呐,非常简单,和其他的jar包一样


    
<dependency>
<groupId>com.pdool</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>

使用坐标和版本进行引用,刷新引用就可以在引用中看到了


image.png


3、多个模块间版本的管理


多个模块中使用的依赖很有可能会有重复,但是怎么管理各个版本呐?


假设上述module-one和module-two都需要依赖fastjson2,我们之前会在两个模块的pom.xml中加入依赖,但是这样重复的配置看起来似乎不太优雅,如果需要升级要改2个地方,这个时候parent模块的管理功能就可以发挥了


3.1 dependencis


image.png


在上图中,在dependencis中加入了两个常用的工具库,在子模块中即使不加入也可以使用这个库了!因为子模块除了可以使用自己的依赖之外,还会向上查找父模块的依赖,也就是说,父模块的依赖是向下继承的,因此对于所有模块都要使用的依赖,我们可以写在父模块中。


所以,两个模块都依赖于Spring Web话,也可以将两个模块的Spring Web依赖移至父模块。


所以说父模块和子模块中,依赖也有着继承的关系!父模块的properties也是向下继承的。


3.2 dependencyManagement


dependencyManagement用于管理依赖的版本,我们在父模块的pom.xml加入这个标签:


image.png


dependencyManagement的注意事项:


dependencyManagement仅用于管理版本,而不会为自己以及子模块导入依赖,因此在dependencyManagement中声明依赖后,对应的子模块仍然需要在dependencies中加入依赖
在pom.xml中dependencyManagement和dependencies同级,并且dependencyManagement中也需要有一个dependencies
dependencyManagement不仅可以管理子模块的依赖版本,也可以管理自身的依赖版本
若不想让某个子模块使用父模块dependencyManagement的版本,那就在这个子模块的dependencies中声明对应版本
4、依赖执行
因为在项目中使用了mybatis-plus ,在common模块中定义了一些mapper,在room和game中需要使用,在项目中增加了componentscan ,但是依然在运行的时候报错,提示找不到common中的类,在编辑器中并不报错,这很奇怪,需要在依赖项目中增加一个标签。


这是由于Spring Boot打包的模式问题,我们打开被依赖模块module-two的pom.xml文件找到最下面节点中,在spring-boot-maven-plugin插件部分中加入下面配置:



exec
最终common中pom的定义



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

</build>

5、在Spring Boot项目中加载依赖项目的组件有几种常用的方法


在Spring Boot项目中加载依赖项目的组件有几种常用的方法:


5.1. 使用@ComponentScan注解:


在主应用程序类上使用@ComponentScan注解,指定要扫描的包路径。这将使Spring Boot扫描并加载依赖项目中带有@Component、@Service、@Repository等注解的组件。例如:


    
@SpringBootApplication
@ComponentScan(basePackages = "com.example.dependencyproject")
public class MyApplication {
// ...
}

2. 使用@Import注解:


在主应用程序类上使用@Import注解,引入依赖项目中的配置类或组件类。这将使Spring Boot加载这些配置类并注册其中的组件。例如:


     @SpringBootApplication
@Import(com.example.dependencyproject.MyConfiguration.class)
public class MyApplication {
// ...
}

3. 使用@Configuration注解和@Bean方法:


如果依赖项目中有@Configuration注解的配置类,可以在主应用程序类中使用@Bean方法来加载其中的组件。例如:


    
@SpringBootApplication
public class MyApplication {
// ...

@Bean
public MyComponent myComponent() {
return new MyComponent();
}
}

这样,MyComponent将作为一个Bean被加载到Spring应用程序上下文中。


根据你的具体情况和依赖项目的结构,你可以选择适合的方法来加载依赖项目的组件。请注意,为了能够加载依赖项目的组件,确保依赖项目已被正确地添加为项目的依赖项,并且在构建和部署过程中能够被正确地引用和访问。


6、@ComponentScan 扫描依赖项的包


@ComponentScan 注解可以用于指定要扫描的包,它的作用不限于只扫描依赖项的包。@ComponentScan`可以扫描指定包及其子包下的组件,并将其加载到应用程序的上下文中。


当你在 Spring Boot 应用程序的主类上使用 @ComponentScan 注解时,它将扫描指定的包及其子包,并注册在这些包中使用了 @Component@Service@Repository@Controller 等注解的组件。


如果你指定的包路径包括了依赖项的包,那么它将扫描并加载依赖项中的组件。但是需要注意,@ComponentScan 不会限制只扫描依赖项的包,它将扫描所有指定的包路径下的组件。


举个例子,假设你的 Spring Boot 应用程序的主类上使用了以下的 @ComponentScan 注解:


    @SpringBootApplication
@ComponentScan(basePackages = {"com.example.myapp", "com.example.dependency"})
public class MyApplication {
// ...
}

在上述示例中,@ComponentScan 将扫描 com.example.myapp 包及其子包以及 com.example.dependency 包及其子包下的组件,并将它们加载到应用程序的上下文中。这样,你可以同时加载依赖项中的组件和应用程序本身的组件。


总之,@ComponentScan 注解不仅限于扫描依赖项的包,它可以扫描指定包及其子包下的所有组件,并将它们加载到应用程序的上下文中。


注:


如果你在 Spring Boot 应用程序的主类上使用了 @ComponentScan 注解,并指定了包路径,只有在指定的包路径下的本项目组件会被自动加载到应用程序的上下文中。


@ComponentScan 注解仅扫描指定的包及其子包下的组件,并将它们加载到应用程序的上下文中。如果本项目中的组件不在指定的包路径下,它们将不会被自动加载。


7、总结


不做不知道,手高眼低不行,必须得实践


作者:香菜菜
来源:juejin.cn/post/7297848688244441122
收起阅读 »

Coremail重磅发布2023年Q3企业邮箱安全性报告

10月25日,Coremail邮件安全联合北京中睿天下信息技术有限公司发布《2023年第三季度企业邮箱安全性研究报告》。2023年第三季度企业邮箱安全呈现出何种态势?作为邮箱管理员,我们又该如何做好防护?一、国内垃圾邮件激增,环比增长31.63%根据Corem...
继续阅读 »

10月25日,Coremail邮件安全联合北京中睿天下信息技术有限公司发布2023年第三季度企业邮箱安全性研究报告》。2023年第三季度企业邮箱安全呈现出何种态势?作为邮箱管理员,我们又该如何做好防护?

一、国内垃圾邮件激增,环比增长31.63%

根据Coremail邮件安全人工智能实验室(以下简称“AI实验室”)数据,2023 年Q3国内企业邮箱用户共收到近 亿封的垃圾邮件,环比增长 7.89%,同比去年同期增长 0.91%尤其是国内垃圾邮件激增,环比增长31.63%

经 AI 实验室分析,在 TOP100 接收列表中,教育领域收到的垃圾邮件高达 2.41 亿封,环比上涨13.8%,持续处于前列。

二、境内钓鱼邮件数量激增,首次超过境外

2023 年Q3,全国的企业邮箱用户共收到钓鱼邮件高达 8606.4 万封,同比激增 47.14%,环比也有 23.67%的上升。从总的钓鱼邮件数量来看,境内和境外的钓鱼邮件都呈现增长趋势。但在 2023 年第三季度,境内钓鱼邮件的数量显著增长,超过了境外钓鱼邮件的数量。

Coremail 邮件安全人工智能实验室发现黑产越来越多利用国内的云平台的监管漏洞发送钓鱼邮件,这对国内云服务提供商而言是巨大的挑战

三、Q3垃圾邮件呈现多元化趋势

2023 年 Q3 的垃圾邮件呈现出多元化的趋势,利用各种语言、主题和策略来达成发送垃圾邮件的目的,包括测试邮件、多语言内容、退税和通知等,数量巨大,层出不穷。

而钓鱼邮件常伪装为系统通知或补贴诈骗,这增加了账户被劫和数据泄露的风险。钓鱼邮件主题常利用紧迫性日常相关性模糊性专业性来吸引受害者,建议用户对此类钓鱼邮件保持高度警惕。

四、关键发现:基于邮件的高级威胁

1横向钓鱼攻击

横向钓鱼攻击直接利用了人们的信任关系,已经成为组织面临的重大威胁,而生成型 AI 为攻击者提供了更加强大的工具,使得这些攻击更加难以防范。

以下为 Coremail 在第三季度的横向钓鱼 (也称为内域钓鱼邮件)的检测和拦截数据分析解读:

① 嵌入式钓鱼 URL 的利用:高达 95%的横向钓鱼攻击使用嵌入钓鱼 URL 的邮件。

② 攻击频率:平均每月,约 25%的组织或企业会遭受一次横向钓鱼攻击。

③ 检测挑战:79%的横向钓鱼邮件需要动态分析嵌入的钓鱼URL,这增加了检测的复杂性和时间成本。

④ 更高的威胁等级:接收横向钓鱼邮件的人员的中招率上升了 200%。

2、商业电子邮件欺诈

商业电子邮件欺诈(BEC)涉及网络罪犯伪装成高管或受信任的供应商,以操纵员工转移资金或敏感信息。

针对商业电子邮件欺诈,以下为 Coremail 在第三季度的数据分析解读:

① 账号失陷与社交工程:高达 90%的 BEC 攻击与账户失陷同时发生,而 9%采用社交工程方法。

② 攻击方法:BEC 攻击主要侧重于直接诈骗钱财或信息。

③ 仿冒策略:85%的 BEC 攻击使用以下仿冒策略。

④ 邮件内容分析:70%的邮件为“银行信息变更请求”,15%为催促付款,12%为银行信息变更。

基于 AI 的新威胁

当然,BEC 攻击不仅仅是技术挑战,它更多的是一个人为问题。这类攻击强调了员工培训和安全意识的重要性,因为员工是这类攻击的第一道防线。同时,技术如双因素身份验证、邮件过滤防护和 AI 驱动的安全工具可以提供额外的防护。

五、新措施:监控,响应与安全意识

邮件作为企业沟通的主要方式,不幸地成为了许多网络威胁的首要入口。鉴于此,维护邮件安全不仅是技术问题,还涉及到组织的多个层面。以下分析了邮件安全厂商、邮箱管理员和用户在邮件安全中的作用以及他们分别在监控、响应和安全意识三个方面的关键角色。

1、组织安全的关键挑战

① 员工的安全意识

员工经常成为安全的最弱环节。安全意识方面的缺乏、不够严格的密码策略、轻率地点击可疑链接或不当地处理敏感信息,都可能导致严重的安全事件。

② 威胁响应流程

一个好的安全响应不仅要能有效地解决问题,还要迅速执行。然而,许多组织的反馈机制和响应矩阵的复杂性导致了繁琐的流程,最终导致效率低下和暴露更多风险。

2Coremail 针对性解决方案

① 利用 LLM 进行用户报告的预分类

为了应对迫在眉睫的网络威胁,Coremail 策略性地利用了大语言模型(LLM)即时预分类用户报告的电子邮件。通过 LLM 系统进行即时评估,安全团队可以迅速优先处理威胁,确保高风险邮件得到及时处理。这不仅极大地提高了威胁管理的效率,而且显著降低了由于延迟响应而可能出现的损害风险。

② 让用户成为安全架构的一部分

对于 Coremail 来说,用户不仅仅是被动的实体,而是安全生态系统中的主动参与者。用户是企业安全中的重要角色。通过培养用户主动报告潜在威胁的文化,不仅强化了安全防御,而且增强了用户的安全意识,从而减轻了管理负担。

如上图是“仿冒发信人,仿冒系统通知”的钓鱼漏判响应处理案例的流程。这个流程中,积极的用户参与、即时的邮件威胁响应以及管理员和邮件厂商的紧密合作,得以确保邮件系统的安全性和邮件威胁管理效率。

收起阅读 »

【Java集合】单列集合Collection常用方法详解,不容错过!

嗨~ 今天的你过得还好吗?路途漫漫终有一归幸与不幸都有尽头🌞在上篇文章中,我们简单介绍了下Java 集合家族中的成员,那么本篇文章,我们就来看看 Java在单列集合中,为我们提供的一些方法,以及单列集合的常用遍历玩法,一起来进入学...
继续阅读 »


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

路途漫漫终有一归

幸与不幸都有尽头

🌞

在上篇文章中,我们简单介绍了下Java 集合家族中的成员,那么本篇文章,我们就来看看 Java在单列集合中,为我们提供的一些方法,以及单列集合的常用遍历玩法,一起来进入学习吧。

在Java基础中我们也学过,在类实现接口后,该类就会将接口中的抽象方法继承过来,此时该类需要重写该抽象方法,完成具体的逻辑。


Collection 常用功能

Collection 是所有单列集合的父接口,因此在Collection 中定义了单列集合(List 和 Set)通用的一些方法,这些方法可用于操作所有的单列集合。

20190428183815681.png

1.1 方法如下:

image.png

打开api文档,我们可以看到Collection 在 java.util 下,我们通过练习来演示下这些方法的使用:


640 (28).gif


1.2方法演示

public class Demo1Collection {    

       public static void main(String[] args) {

           //创建集合对象

           //使用多态的形式 定义
           Collection<String> person = new ArrayList<>();
           //输出不是一个对象地址,所以说重写了toString 方法

           System.out.println(person);

   

   //        boolean add(Object o) 向集合中添加一个元素
   //        返回值是一个boolean值,一般可以不用接收
           person.add("科比");
           person.add("姚明");
           person.add("库里");

           person.add("麦迪");

           //添加完我们在输出一下这个集合

           System.out.println(person);


   //        boolean remove(Object o) 删除该集合中指定的元素

   //        返回 集合中存在元素,删除元素,返回true;集合中不存在,删除返回false

           boolean res1 = person.remove("科比");

           boolean res2 = person.remove("奥尼尔");

           System.out.println("res1=" +res1);

           System.out.println("res2=" +res2);

   //        boolean isEmpty() 判断该集合是否为空

           boolean empty = person.isEmpty();
           System.out.println("empty=" + empty);    
   //        boolean contains(Object o) 判断该集合中是否包含某个元素
           boolean contains = person.contains("麦迪");

           System.out.println("contains=" + contains);

   //        int size() 获取该集合元素个数

           int size = person.size();

           System.out.println("size = " + size);

   

   //        public Object[] toArray() 把集合总的元素,存储到数组中

           Object[] personArray = person.toArray();

           for (int i = 0; i < personArray.length; i++) {

               System.out.println("数组--" + personArray[i]);
           }    
   //        void clear() 删除该集合中的所有元素,但是集合还存在
           person.clear();
           System.out.println(person);    
   
           //通过多态的方式,如果我们把arrayList 换成HashSet,发现也能使用,这就是我们实现接口的好处
       }
   }

注意:有关Collection中的方法不止上面这些,其他方法可以自行查看API学习。


查询集合中的元素-Iterator 迭代器

2.1 Iterator 接口

在程序开发中,经常需要遍历集合中的所有元素,就是要看看里面所有的元素,那我们怎么办呢? 

20201216223250385.png

针对这种需求,JDK 专门提供了一个接口: java.util.Iterator。该接口也是 Java集合中的一员,但它与 Collection、Map 接口有所不同,Collection 接口 与 Map 接口 主要用于存储元素,而 Iterator 主要用于迭代访问(即 遍历) Collection中的元素,因为Iterator 对象也被称为迭代器。 


下面介绍一下迭代器的概念:

迭代即Collection集合元素的通用获取方式。在取元素之前先要判断集合中有没有元素,如果有,就把这个元素取出来,继续再判断,如果还有就再取出来。一直把集合中的所有元素全部取出。这种方式专业术语称为迭代。


通过文档,我们可以看到 Iterator 是一个接口,我们无法直接使用,而需要使用Iterator接口的实现类对象,通过Collection接口中的 Iterator()方法,就可以返回迭代器的实现类对象:

public Iterator iterator():  获取结合对应的迭代器,用来遍历集合中的元素。

通过API文档,我们可以看到 Collection 中 Itrerator 接口的常用方法如下:

  • public E next()  返回迭代的下一个元素

  • public boolean hasNext() 如果仍有元素可以迭代,则返回true

image.png

接下来我们通过案例学习,如何使用Iterator 迭代集合中的元素:

 /**
    *  Iterator 迭代器使用
    */

   public class Demo1Iterator {    
       public static void main(String[] args) {            /**
            * 使用步骤:
            * 1. 使用集合中的方法 iterator() 获取迭代器的实现类对象,使用Iterator接口接收 (使用接口接收返回值,这就是我们说的多态)
            * 2. 使用Iterator接口中的方法 hashNext() 判断有没有下一个元素
            * 3. 使用Iterator接口中的方法 next() 取出集合中的下一个元素
            *
            */

           Collection<String> ball = new ArrayList<>();
           ball.add("篮球");
           ball.add("足球");
           ball.add("排球");
           ball.add("乒乓球");    
           //我们来获取一个迭代器,多态
           Iterator<String> iterator = ball.iterator();    
           //判断
           boolean b = iterator.hasNext();
           System.out.println("是否有元素--" + b);            //取出
           String next = iterator.next();
           System.out.println("元素--" + next);    
           //判断
           boolean b1 = iterator.hasNext();
           System.out.println("是否有元素--" + b1);            //取出
           String next1 = iterator.next();
           System.out.println("元素--" + next1);    
           //判断
           boolean b2 = iterator.hasNext();
           System.out.println("是否有元素--" + b2);            //取出
           String next2 = iterator.next();
           System.out.println("元素--" + next2);    
           //判断
           boolean b3 = iterator.hasNext();
           System.out.println("是否有元素--" + b3);            //取出
           String next3 = iterator.next();
           System.out.println("元素--" + next3);    
           //判断
           boolean b4 = iterator.hasNext();
           System.out.println("是否有元素--" + b4);            //取出
   //        String next4 = iterator.next();
   //        System.out.println("元素--" + next4);
           //如果没有元素,在取的话,会报一个NoSuchElementException 的错误
   
   
           /**
            *
            * 代码优化 上面这些步骤是一个重复的过程,我们可以使用循环来优化,那我们选择哪种来呢
            * 我们说 知道元素个数,使用for
            * 不知道元素个数,使用while
            *
            * 那当前我们迭代器的个数,我们不知道,所以使用while循环,而我们的hasNext 就可以作为
            * while的条件来判断
            *
            */

           while (iterator.hasNext()) {
               String ballResult = iterator.next();
               System.out.println("--优化--" + ballResult);
           }
   
       }
   }

分析: 

在进行集合元素取出时,如果集合中已经没有元素了,还继续使用迭代器的next方法,将会发生java.util.NoSuchElementException 没有集合元素的错误。


640 (28).gif


2.2 迭代器的实现原理

我们在之前的案例中已经完成了Iterator 遍历集合的整个过程。当遍历集合时,首先通过调用集合的iterator() 方法获得迭代器对象,然后使用hasNext()方法判断集合中是否存在下一个元素,如果存在,则调用next()方法将元素取出,否则说明已经到达集合末尾,停止遍历元素。

编程学习,从云端源想开始,课程视频、在线书籍、在线编程、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

Itearator 迭代器对象在遍历集合时,内部采用指针的方式来跟踪集合中的元素,为了让初学者能更好的理解迭代器的工作原理,接下来通过一个图例来演示 Iterator 对象迭代元素的过程:

Description

在获取迭代器的实现类对象是,会把索引指向集合的-1位置,也就是在调用Iterator的next方法之前,迭代器的索引位于第一个元素之前,不指向任何元素。

  • 当第一次调用迭代器的next方法后,迭代器的索引会向后移动一位,指向第一个元素并将该元素返回,

  • 当再次调用next方法时,迭代器的索引会指向第二个元素并将该元素返回,

  • 以此类推,直到hasNext方法返回false,表示到达了集合的末尾,终止对元素的遍历。


查询集合中的元素-增强for

3.1  概念

增强for循环(也称为 for each 循环)是JDK5以后出来的一个高级for循环,专门用来遍历数组和集合的。 通过api文档,Collection 继承了一个Iterable 接口 ,而实现这个接口允许对象成为 “foreach” 语句目标,也就是所有的单列集合都可以使用增强for。 

它的内部原理其实是个Iterator 迭代器,只是用for循环的方式来简化迭代器的写法,所以在遍历的过程中,不能对集合中的元素进行增删改查操作。

格式:

for (元素类型 元素名 : 集合名或数组) {
       访问元素
   }

它用于遍历 Collection 和数组。通常只进行遍历元素,不在遍历的过程中对集合元素进行增删操作。


640 (28).gif


3.2 练习1:遍历数组

public class Demo2Foreach {    
       public static void main(String[] args) {    
           int[] array = {1,2,3,4,5};    
           for (int i : array) {
               System.out.println("--数组元素--" + i);    
               if (i == 2) {
                   i = 19;
               }
           }    
           //在增强for中修改 元素,并不能赋值
           System.out.println(Arrays.toString(array));
       }
   }

640 (28).gif


3.3 练习2:遍历集合

public class Demo3Foreach {    
       public static void main(String[] args) {
           Collection<String> ball = new ArrayList<>();
           ball.add("篮球");
           ball.add("足球");
           ball.add("排球");    
           for (String s : ball) {
               System.out.println("---" + s);
           }    
           //相对于Iterator遍历方式,增强for 简化了很多,所以优先使用该方式。
       }
   }

新for 循环必须有被遍历的目标。目标只能是Collection 或者是数组。仅仅作为遍历操作出现。


总结

本篇中主要介绍了单列集合接口Collection为我们提供的常用接口,也通过代码的方式带大家体会了一下。在后面的内容中为大家介绍了如何把单列集合中的内容查看出来(遍历),通过讲解一些底层的原理,让大家感受了一下迭代器的使用。


当然集合的遍历不仅仅限于这两种方式,例如java8为我们提供的流式遍历集合,希望大家下去后自己也能搜搜相关的遍历方式,尝试使用一下,ok,本文就到这里了。



收起阅读 »

以订单退款流程为例,聊聊如何优化策略模式

如果有人问你什么是策略模式?你可以尝试这样回答 策略模式是一种行为设计模式,它允许在运行时根据不同的情况选择不同的算法策略。这种模式将算法的定义与使用的代码分离开来,使得代码更加可读、可维护和可扩展。 在策略模式中,通常有一个抽象的策略接口,定义了一系列可以...
继续阅读 »

如果有人问你什么是策略模式?你可以尝试这样回答



策略模式是一种行为设计模式,它允许在运行时根据不同的情况选择不同的算法策略。这种模式将算法的定义与使用的代码分离开来,使得代码更加可读、可维护和可扩展。


在策略模式中,通常有一个抽象的策略接口,定义了一系列可以被不同策略实现类所实现的方法。在使用时,可以根据需要选择合适的具体策略类,并通过接口进行调用。



虽然解释了策略模式,但是面试官可能会认为你在吊书袋,完全没有自己的理解。我因为被领导卷到了,所以对策略模式有一些其他的理解,接下来我从业务中台实际遇到的问题出发,谈谈怎么被领导卷到,谈谈我对策略模式的优化和理解。


在业务中台,可以根据具体情况选择合适的策略类来执行相应的业务逻辑,从而满足不同业务方的要求。


由于各个业务方的业务逻辑存在差异性和相似性,编写中台代码时需要考虑这些差异性,并预留扩展点以供不同业务方根据自身需求提供策略类。在这种情况下,可以应用策略模式。


image.png


当业务方有特殊的业务逻辑时,只需要添加新的策略实现类即可,不需要修改现有的代码,方便了后续的维护和扩展。


然而在我们在应用策略模式时遇到了几个问题


遇到的实际问题


由于业务中台逻辑非常复杂,每个业务线的业务场景都很难保证完全一样,在代码实践中我们的系统出现了非常庞大复杂的策略类,将大量业务逻辑整合到同一个策略中,这导致系统能力复用非常困难。


举个例子,在退款校验逻辑中 业务场景 A,(售卖红包,类似于美团饿了么会员)。 需要判断如下校验逻辑,如果命中,则不允许退款!




  1. 订单是否在生效期,过期不允许退




  2. 订单是否在已使用,已使用不可退




  3. 订单如果是某类特殊商品订单,则不可退




  4. 如果超过当天最大退款次数,则不可退。




于是我们在编写代码时,就新定义 ConcreteStrategyA ,将以上 4 个业务逻辑放到策略类中。


过一段时间,又出现了业务场景 B(售卖红包,类似于美团饿了么会员)。它的校验逻辑和A 大同小异




  1. 订单是否在生效期,过期不允许退




  2. 订单售卖的红包完全使用不可退,部分使用可以退。




  3. 如果超过当天最大退款次数,则不可退。




业务场景B 相比A 而言,少校验了 “特殊商品订单不可退”的逻辑,同时增加了部分使用可以退的逻辑。


如何设计代码实现两种业务场景的校验逻辑呢? 这是非常具体的问题,如果是你如何实现呢?


完全独立的策略类


我们在最开始写代码时,分别独立实现了 ConcreteStrategyA、ConcreteStrategyB。两者的校验逻辑各自独立,没有代码复用。


此时的系统类图如下


image.png


这种实现方式是大量的代码拷贝,例如退款次数限制、生效期校验等代码都大量重复拷贝了。


后来我们发现了这个问题,将相关校验方法抽到父类中。


继承共同的父类


为了更好的复用校验策略,我们将校验生效期的方法、校验退款次数的方法抽取到共同的父级策略类。由具体的子策略类继承BaseStrategy 父策略类,在校验逻辑中,使用父级的校验方法。


如下图的类图所示
image.png


在相当长的一段时间里,我们认为这已经是最优的实现方式了。


但是被领导卷到了!


image.png


被卷到了


“新增业务场景时,为什么校验逻辑都是复用的原有能力,还需要新增扩展类,还需要开发代码呢?” 领导这样问道。


我尝试回答领导的问题:“开发这段代码,并不算太难,只需要增加一个扩展类就可以了!”


“你数数现在有多少个扩展类了?” 领导似乎有些生气,


我一看扩展类的数量,被吓到了,已经有了15个扩展类。这一刻真的非常尴尬,平时领导从不亲自看代码。估计是突然心血来潮。从表情来看,他好像很生气,估计是代码没看懂!


“我看到这部分代码时,想要查看具体的策略类,但Idea直接刷出了15个策略类……为什么会有这么多的扩展类呢?” 领导进一步补充道。


场面僵住了,我也没什么好办法……。当然领导向来的传统是:只提问题,不给解决办法! 我只能自己想解决办法!如何解决策略类过多、扩展类膨胀的问题呢?


image.png


策略类过多怎么办!


当业务场景非常多,业务逻辑非常复杂时,确实会出现非常多的策略类。但领导的问题是,现有业务场景有很多相似性,某些新增业务场景和原有业务场景类似,校验逻辑也是类似的,为什么还需要新增扩展类呢?


经过深思熟虑,让我发现问题所在!


策略类粒度太粗,导致系统复用难


目前我们的系统设计是每个业务场景都有单独的策略,这是大多数人认可的做法。不同的业务场景需要不同的策略。


然而,仔细思考一下我们会发现,不同业务场景的退款校验逻辑实际上是由一系列细分校验逻辑组合而成的,退款校验逻辑在不同的业务场景中是可以被重复使用的。


为什么不能将这些细分退款校验逻辑抽象为策略呢?例如,将过期不允许退款、已生效不可退款、超过最大退款次数不允许退款等校验逻辑抽象成为校验策略类。


各个业务场景组合多个校验策略,这样新增业务场景时,只需要组合多个校验策略即可。


如何组合校验策略


首先需要抽象校验策略接口: VerifyStrategy


classDiagram
class VerifyStrategy{
+void verify(VerifyContext context);
}

然后定义 VerifyScene 类


classDiagram
class VerifyScene{
+ Biz biz;
+ List<VerifyStrategy> strategies;
}

如何把对应具体策略类配置到VerifyScene中呢?本着 ”能不开发代码,就不要开发代码,优先使用配置!“的原则,我们选择使用Spring XML 文件配置。(在业务中台优先使用配置而非硬编码,否则这个问题不好回答。“业务方和中台都需要开发,为啥走你中台?”)


使用Spring XML 组合校验策略


在Spring XML文件中,可以声明VerifyScene类的Bean,初始化属性时,可以引用相关的校验策略。


 <bean name="Biz_A_Strategy" p:biz="A" class="com.XX.VerifyScene">
<property name="strategies">
<list>
<ref bean="checkPeriodVerifyStrategy"/> <!--校验是否未过期-->
<ref bean="checkUsageInfoVerifyStrategy"/> <!--校验使用情况-->
<ref bean="checkRefundTimeVerifyStrategy"/><!--校验退款次数-->
</list>
</property>
</bean>

当需要新增业务场景时,首先需要评估现有的校验策略是否满足需求,不满足则新增策略。最终在XML文档中增加 VerifyScene 校验场景,引用相关的策略类。


这样新增业务场景时,只要校验逻辑是复用的,就无需新增扩展类,也无需开发代码,只需要在XML中配置策略组合即可。


在XML文档中可以添加注释,说明当前业务场景每一个校验单元的业务逻辑。在某种程度上,这个XML文档就是所有业务的退款校验的业务文档。甚至无需再写文档说明每个业务场景的退款策略如何如何~


和领导汇报以后,领导很是满意。对业务方开始宣称,我们的中台系统支持零开发,配置化接入退款能力。


结束了吗?没有 ,我们后来想到更加优雅的方式。


使用Spring Configuration 和 Lamada


Spring 提供了@Bean注解注入Bean,我们可以使用Spring @Bean方式声明校验策略类


@Bean
public VerifyStrategy checkPeriodVerifyStrategy(){
return (context)->{
//校验生效期
};
}

通过以上方式,可以把checkPeriodVerifyStrategy 校验策略注入到Spring中,spring beanName就是方法名checkPeriodVerifyStrategy。


在Spring XML中可以使用 <ref bean="checkPeriodVerifyStrategy"/> 引用这个bean。


并且当点击XML中beanName时,可以直接跳转到 被@Bean修饰的checkPeriodVerifyStrategy方法。这样在梳理校验流程时,可以很方便地查看代码


点击这个BeanName,会跳转到对应的方法。(付费版Idea支持,社区版 Idea 不支持这个特性)
image.png


总结


总结几个问题




  1. 策略模式目的是:根据不同的业务场景选择不同的策略来执行相应的逻辑




  2. 策略模式一定要进行细化,通过组合多个细分策略模式为一个更大的策略,避免使用继承方案。




  3. 使用Spring XML 组合多个策略模式,可以避免开发。减少新增策略类




  4. 使用Spring Configuration @Bean 将策略类注入Spring 更加优雅




作者:他是程序员
来源:juejin.cn/post/7295010992122101801
收起阅读 »

从码农到工匠-怎么写好一个方法

感谢你阅读本文 今天我们来分享一下怎么怎么写好一个方法,我记得大四的时候彪哥推荐我去读《代码精进之路,从码农到工匠》这本书,那时候由于项目经验不多,所以对于书中很多东西没有感同身受,所以不以为然。 后面做的项目越来越多,从痛苦中不断思考,也看了一些优秀开源框架...
继续阅读 »

感谢你阅读本文


今天我们来分享一下怎么怎么写好一个方法,我记得大四的时候彪哥推荐我去读《代码精进之路,从码农到工匠》这本书,那时候由于项目经验不多,所以对于书中很多东西没有感同身受,所以不以为然。


后面做的项目越来越多,从痛苦中不断思考,也看了一些优秀开源框架的实现,逐渐对于开始理解书中的思想。


下文只列举了书中的的“方法封装”,“参数”和“方法短小”这三个,其他的没有列举,因为我觉得其实只要这三点我们执行到位,写出来的代码虽然谈不上优秀,但是维护性和可读性一定会大大提高。


封装的艺术


我们在学习面向对象编程时就知道面向对象的几大特征是抽象,继承,封装,多态,所以学会合理的封装是一件很重要的事情,这也是我们每个程序员应该有的意识。


判断封装


在方法中,总是难免会有很多判断,判断越多,程序就会变得越复杂,但是又不能不判断,我们可以使用设计模式来改善判断很多的方法,使用设计模式来优化判断,比如策略模式,但是其实它并没有消除判断,换句话说,判断是消除不了,我们能做的只能是让判断的代码可读,下面我们来看一段代码。


如下是一个判断,对文件小于1G,类型为txt,文件名中包含user_click_data或者super_user_click_data的文件可以进行处理


对于这个判断,虽然条件并不复杂,但是在代码中就显得“不清洁”,特别是随着系统的不断迭代,加入的人员不断增多,那么判断将会越来越多,如果开发人员加上清晰的注释,那么就比较容易读懂,但是并不是谁都有这样的习惯。


之前我遇到的代码中,有些一个方法中四五十个判断,而且判断还特别复杂,各种&&,||等一大堆,读起来十分费劲,对于这样的代码,遇到真的会让人抓狂。


如何解决呢?


封装判断就是一个好的方案,上面的判断代码我们就可以进行封装,如下。



上面我判断我们将其抽出来作为一个单独的方法,在加上可理解的方法名,那么别人在读你代码的时候就很容易读懂,也保证了代码的清洁,当然,方法命名是一件很难的事情,不过如果命名得不够准确,我们可以加上清晰的注释来解决。


虽然上面这段代码看似没啥改变,但是随着系统的不断复杂,就能体现出它的作用了,我经常看到别人在主流程的代码中加入很多复杂的判断,在我刚开始写代码的时候,也做过这种事,很多时候如果注释不清,命名不规范,上下文不清晰,那么读起来就很痛苦,而且整个方法看起来让人特别难受。


所以,一个看似很小的改动,却能在时间的作用下显得如此重要,保持这样一个习惯,不仅是对自己负责,也是对他人负责。


参数问题


参数问题也是我们应该去考虑的问题,当在一个方法中,如果参数特别多是很不友好的,我之前碰到过一个方法有十五六个参数,首先这么多个参数,在传递的时候,如果使用对IDEA使用比较熟悉,那么可以使用快捷键查看下一个参数是什么类型,如果使用不熟悉,那么就需要点进方法中去看,这是一个体力活。


加上参数越多,维护成本就越高,比如我需要加一个参数,但是这个参数并不是必须的,有些方法用到,有些不用,但是由于使用的是同一个方法,所以调用这个方法的地方都需要加上这个参数,调用的地方越多,成本就越大,如果是一个RPC接口,修改的成本更大。


复杂的参数封装成类。


对于复杂的参数,推荐封装成类然后进行传递,在添加或者减少字段的时候是在类中进行,没有对形参进行增加或者减少,对调用此方法的地方基本没任何影响,我们只需要在需要添加或者减少这个参数的地方进行操作,不需要的地方就保持不动。


方法尽量保持短小


方法短小更容易让人阅读和维护,当我去阅读别人的代码的时候,有些类有七八千行代码,一个方法有五六百行代码的时候,其实内心是奔溃的!


当然,在我刚开始写代码的时候,我也不懂,所以一个方法从来不去优化,也是一个方法一口气干到底,我记得当时给老师做一个项目,一个类我在Controller层直接写了几百行,Service层都不用,还有写Vue,也不会去使用组件和封装函数,所以一个Vue文件几千行,当需要修改的时候,十分麻烦,找一个方法和变量也特别费力。


记得上一个星期,我和前端同事联调,我看他去找一个方法找了足足三四分钟,因为项目十分庞大,加上设计有很大的缺陷,我看他们一个VUE文件可达到八九千行,所以才出现这个问题。


其实无论对于前端还是后端,Java还是golang等等,代码的短小都比较容易去阅读和维护,如果发现方法越写越大,那我们就要考虑去提取,去拆分了,《代码精进之路》作者推荐Java一个方法不要超过20行,不过也不是绝对的。


对于像Spring这种特别复杂的框架,因为不断升级和迭代,所以有些方法也变得特别长,代码也变得可读性不那个高,但是也不影响它是Java生态中使用最广泛,最强大的框架。


只不过对于我们普通人来说,因为水平肯定没有Spring团队的强,所以保证方法的可读性是十分重要的。


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


参考:《代码精进之路,从码农到工匠》weread.qq.com/web/reader/…?


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

工作六年,看到这样的代码,内心五味杂陈......

工作六年,看到这样的代码,内心五味杂陈...... 那天下午,看到了令我终生难忘的代码,那一刻破防了...... 🔊 本文记录那些年的 Java 代码轶事 ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起...... 📖一、历史背景 1.1 数据隔...
继续阅读 »

工作六年,看到这样的代码,内心五味杂陈......


那天下午,看到了令我终生难忘的代码,那一刻破防了......



🔊 本文记录那些年的 Java 代码轶事



ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起......


📖一、历史背景


1.1 数据隔离


预发,灰度,线上环境共用一个数据库。每一张表有一个 env 字段,环境不同值不同。特别说明: env 字段即环境字段。如下图所示:
image.png


1.2 隔离之前


🖌️插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;
有一天预发环境的操作影响到客户线上的数据。 为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。


当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。


1.3 隔离改造


其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。


每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:


SELECT XXX FROM tableName WHERE env = ${环境字段值} and ${condition}

1.4 隔离方案


最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!


image.png


具体方案:自定义 mybatis 拦截器进行统一处理。 通过这个方案可以解决以下几个问题:



  • 业务代码不用修改,包括 DO、Mapper、XML等。只修改 mybatis 拦截的逻辑。

  • 挨个添加补充字段,工程量很多,出错概率极高

  • 后续扩展容易


1.5 最终落地


在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件。真正实现改一处即可。 考虑历史数据过渡,将 env = ${当前环境}
修改成 en 'all')


SELECT xxx FROM ${tableName} WHERE env in (${当前环境},'all') AND ${其他条件}

具体实现逻辑如下图所示:


image.png



  1. 其中 env 字段是从 application.properties 配置获取,全局唯一,只要环境不同,env 值不同

  2. 借助 JSqlParser 开源工具,改写 sql 语句,修改重新填充、查询拼接条件即可。链接JSQLParser


思路:自定义拦截器,填充环境参数,修改 sql 语句,下面是部分代码示例:


@Intercepts(
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})}
)

@Component
public class EnvIsolationInterceptor implements Interceptor {
......
@Override
public Object intercept(Invocation invocation) throws Throwable {
......
if (SqlCommandType.INSERT == sqlCommandType) {
try {
// 重新 sql 执行语句,填充环境参数等
insertMethodProcess(invocation, boundSql);
} catch (Exception exception) {
log.error("parser insert sql exception, boundSql is:" + JSON.toJSONString(boundSql), exception);
throw exception;
}
}

return invocation.proceed();
}
}

一气呵成,完美上线。


image.png


📚二、发展演变


2.1 业务需求


随着业务发展,出现了以下需求:



  • 上下游合作,我们的 PRC 接口在匹配环境上与他们有差异,需要改造


SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')


  • 有一些环境的数据相互相共享,比如预发和灰度等

  • 开发人员的部分后面,希望在预发能纠正线上数据等


2.2 初步沟通


这个需求的落地交给了来了快两年的小鲜肉。 在开始做之前,他也问我该怎么做;我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......


image.png


(●ˇ∀ˇ●)年纪大了需要给年轻人机会。


2.3 勤劳能干


小鲜肉,没多久就实现了。不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。(不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧🤔)


2.4 具体实现


大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。填充颜色部分为小鲜肉的改造逻辑。


image.png


大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。


SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}

2.5 错误原因


经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。 简化举例: A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。具体如下:


image.png


2.6 五味杂陈


当我看到代码的一瞬间,彻底破防了......


image.png


queryProject 方法里面调用 findProjectWithOutEnv,
在两个方法中,都有填充处理 env 的代码。


2.7 遍地开花


然而,这三行代码,随处可见,在业务代码中遍地开花.......


image.png


// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();

// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());

//....... 业务代码 ....

// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);

image.png


改了个遍,很勤劳👍......


2.8 灵魂开问


image.png


难道真的就只能这么做吗,当然还有......



  • 开闭原则符合了吗

  • 改漏了应该办呢

  • 其他人遇到跳过的检查的场景也加这样的代码吗

  • 业务代码和功能代码分离了吗

  • 填充到应用上下文对象 user 合适吗

  • .......


大量魔法值,单行字符超500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......


内心涌动😥,我觉得要重构一下。


📒三、重构一下


3.1 困难之处


在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。 只能通过栈帧查询到调用链。


3.2 问题列表



  • 尽量不要修改已有方法,保证不影响原有逻辑;

  • 尽量不要在业务方法中修改功能代码;关注点分离;

  • 尽量最小改动,修改一处即可实现逻辑;

  • 改造后复用能力,而不是依葫芦画瓢地添加这种代码


3.3 实现分析



  1. 用独立的 ThreadLocal,不与当前用户信息上下文混合使用

  2. 注解+APO,通过注解参数解析,达到目标功能

  3. 对于方法之间的调用或者循环调用,要考虑优化


同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。


3.4 使用案例


改造后的使用案例如下,案例说明:project 表在预发环境校验跳过


@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})


@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response)
{
......
}

在使用的调用入口处添加注解。


3.5 具体实现



  1. 方法上标记注解, 注解参数定义规则

  2. 切面读取方法上面的注解规则,并传递到应用上下文

  3. 拦截器从应用上下文读取规则进行规则判断


image.png


注解代码


@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {

/**
* 是否跳过环境。 默认 true,不推荐设置 false
*
* @return
*/

boolean isKip() default true;

/**
* 赋值则判断规则,否则不判断
*
* @return
*/

String[] skipEnvList() default {};

/**
* 赋值则判断规则,否则不判断
*
* @return
*/

String[] skipTableList() default {};
}


3.6 不足之处



  1. 整个链路上的这个表操作都会跳过,颗粒度还是比较粗

  2. 注解只能在入口处使用,公共方法调用尽量避免


🤔那还要不要完善一下,还有什么没有考虑到的点呢? 拿起手机看到快12点的那一刻,我还是选择先回家了......


📝 四、总结思考


4.1 隔离总结


这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。


4.2 编码总结


同样的代码写两次就应该考虑重构了



  • 尽量修改一个地方,不要写这种边边角角的代码

  • 善用自定义注解,解决这种通用逻辑

  • 可以妥协,但是要有底线

  • ......


4.3 场景总结


简单梳理,自定义注解 + AOP 的场景


场景详细描述
分布式锁通过添加自定义注解,让调用方法实现分布式锁
合规参数校验结合 ognl 表达式,对特定的合规性入参校验校验
接口数据权限对不同的接口,做不一样的权限校验,以及不同的人员身份有不同的校验逻辑
路由策略通过不同的注解,转发到不同的 handler
......

自定义注解很灵活,应用场景广泛,可以多多挖掘。


4.4 反思总结



  • 如果一开始就做好技术方案或者直接使用不同的数据库是否可以解决这个问题

  • 是否可以拒绝那个所谓的需求

  • 先有设计再有编码


4.5 最后感想


image.png



在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感;身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪; 突然一想,这么做的意义又有多大呢?



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

一亿数据大表,我们是如何做分页的

本文是基于我们公司的情况做的大表分页方案简单记录,不一定适合所有业务场景,大家感兴趣的读一下,可以在评论区讨论,技术交流,文明用语。 最近在做一个功能,有一张大概一亿的数据需要做分页查询,所以存在深分页的问题,也就是越靠后的页访问起来就会越慢,所以需要对这个功...
继续阅读 »

本文是基于我们公司的情况做的大表分页方案简单记录,不一定适合所有业务场景,大家感兴趣的读一下,可以在评论区讨论,技术交流,文明用语。


最近在做一个功能,有一张大概一亿的数据需要做分页查询,所以存在深分页的问题,也就是越靠后的页访问起来就会越慢,所以需要对这个功能进行优化。


对于深分页问题,一般都是采用传递主键ID的做法来解决,也就是每次查询下一页数据时,把上一页数据中最大的ID传递给下一页,这样在查询某一页时就能利用主键索引来快速查询了。


但是由于历史原因,那张一亿数据的大表没有主键ID,-_-,所以这是需要解决的问题之一。


另外,就算能利用主键索引快速查询分页数据,但是毕竟是一亿数据,最终查询比较靠后的页数时,因为数据量大就算走索引最后还是会比较慢,所以就需要分表了。


我们先使用SQL脚本的方式把一亿数据迁移到分表,我们按照时间季度进行分表,比如2022q1、2022q2、2023q1这种方式来进行分表,这样每张分表的数据就不会太多了,大概1000万左右,并且在创建分表时顺便给分表添加主键ID字段,然后在迁移数据时需要按照原始大表的交易时间进行升序排序,保证ID的自增顺序与交易时间顺序是一致的。


不过这种方案存在两个问题,第一个问题就是查询分页时跨分表了怎么办,比如我查第10页数据时,一部分数据在2023q1,一部分数据在2023q2,首先在查询时本身就需要选择年份和季度,所以直接就确定了对应的是哪张分表,查询时根据年份和季度直接定位到分表,然后再进行分页查询,所以不会出现跨表,这个问题就自然而然解决了。


另外,每张表的主键ID要不要连续,比如2022q1的主键是1-1000000,那么2022q1的主键要不要是1000001-2000000,其实我个人觉得需要这么设计,因为两个分表的数据逻辑上其实一张表,所以主键最好不冲突,但是考虑到实现难度,暂时没有采取,而是每张分表的主键ID都是从1开始的,这种方案在目前看来是没有问题的,因为每次都只会从一张分表中查询数据,不会出现跨表的情况。


另外,在设计时也想到过能不能直接用交易时间做为主键来进行分表,但是想到交易时间的精度是秒,所以很有可能出现交易时间相同的记录,这样在做分页时可能会出现下一页数据和上一页数据有重复的,所以还是需要单独设计一个主键ID。


继续思考,假如这张一亿数据的大表要做分页,但是不根据年份和季度做查询来分页,而就是直接分页,那又该如何呢?


首先,肯定还是得利用分表和主键索引,我的思路是,先给原始表添加主键ID并生成自增ID,然后再按主键ID进行分表,分表记录数也可以控制在1000万左右,前端查询时仍然按ID来查分页就可以了,但是此时就存在跨表的问题,比如每页如果是20条,查第10页数据时,如果从分表一只查出了5条,那么后端接口判断5小于20,就尝试从分表二中继续查。


当然了,如果既有复杂的查询条件,又需要进行分页,那用关系型数据库就不太好做了,就可以考虑ES之类的了。


大家有什么更好的经验或思路,欢迎不吝赐教。


我是爱分享技术的大都督周瑜,欢迎关注我的公众号:Hoeller。公众号里有更多高质量干货系列文章和精品面试题。


记得点赞、分享哦!!!


作者:爱读源码的大都督
来源:juejin.cn/post/7294823722807017499
收起阅读 »

怎么办,代码发布完出问题了

作者:蒋静 前言 回滚是每个开发者必须熟悉的操作,它的重要性不言而喻,必要的时候我们可以通过回滚减少错误的代码对用户影响的时间。回滚的方式有很多种,方式有好也有坏,比如说使用 git 仓库回滚有可能会覆盖其他业务的代码,不稳定,构建产物的回滚最安全,便于优先...
继续阅读 »

作者:蒋静



前言


回滚是每个开发者必须熟悉的操作,它的重要性不言而喻,必要的时候我们可以通过回滚减少错误的代码对用户影响的时间。回滚的方式有很多种,方式有好也有坏,比如说使用 git 仓库回滚有可能会覆盖其他业务的代码,不稳定,构建产物的回滚最安全,便于优先解决线上问题。


构建部署之“痛”


我的几段公司的工作经历:



  1. 第一段经历,是在一个传统的公司,没有运维,要我们自己登录一个跳板机,把文件部署到服务器,非常麻烦。

  2. 第二段经历,是在一个初创公司,基建几乎没有,前端的规模也很小,发布就是打个 zip 包发给运维,运维去上线。但是久而久之,运维也就不耐烦了。

  3. 后来去了稍微大些的公司,构建、部署有一套比较完善的体系,在网页上点击按钮就可以了。


那么构建部署是如何实现的呢?下面我要来介绍古茗的部署和回滚代码机制。


发布分析


我们的最终目的是发布上线,我们发布的是什么呢?是一条分支,所以我们需要先创建一条分支(更加规范的步骤应该是:基于某个需求和某个应用去拉一条分支)。在分支上开发完我们就可以进行发布的操作啦!


这个时候我们就可以操作发布,我们填写需要的配置项后就可以点击发布按钮了。但是肯定不能让所有人随随便便就发布成功,所以我们要进行一些前置校验。比如说你有没有发布的权限、代码有没有冲突、是不是节假日或非发布窗口期、这个应用有没有正在被发布。。。等等的校验,总之就是确保代码是可以被你发布的。


然后我们的发布平台就会叫 Jenkins 拿着仓库信息、分支信息,以及其他等等的配置信息去仓库拉取代码了,拉到代码之后根据不同类型的应用进行区分,进行编译打包(这个过程不同应用之间是不同的),生成对应的产物。


1. 容器化发布


image.png



注:图中Wukong是我们自研DevOps平台



容器化发布发布的是镜像,镜像 id 代表了这次发布和这个镜像的关联关系。回滚的时候只需要找到这次发布对应的 id ,运维脚本根据这个 docker 的 id 找到 docker 镜像,直接部署这次 docker 镜像,做到回滚。由于发布的是 docker 的镜像,不仅可以保证产物是相同的,发布还很快。


容器化之前的发布:先找到对应的发布,根据这次发布找到对应的 tag,然后打包发布,但是这样只能保证业务代码是相同的,不能保证机器环境、打包机的环境、依赖的版本、打包的产物等等是一样的,并且需要的时间比容器化的方式慢得多。


2. oss发布


image.png


oss 发布和容器化发布流程的区别在于不用打包镜像而是将js、css等资源传到了 oss。通过 oss 发布的应用,只需要记住版本和 oss 上面资源路径的对应关系就可以了。


例如在我们这里的实现是:每次发布完成之后会记下有 hash 的 manifest 的地址,点击回滚后会根据发布 id 找到当次的产物,通过 oss 将 manifest 内容替换为有hash 的,从而就切换了访问的资源(html 的 manifest 地址不变,改变的是 manifest 文件的内容)。


3. 小程序


image.png
钉钉小程序的回滚就比较简单了,一般在我们点击回滚之后,内部会通过 http 接口调用小程序的 api 传递需要回滚的版本好后即回滚完成。或者你也可以选择手动到开发者后台的历史版本点回滚。
例如: open.dingtalk.com/document/or…


未来展望


有了完善的部署回滚机制,我们的产研团队才能有更好的交付体验。工作中的业务价值在我们整个交付内容占比应当是比较高的,而不应当把大量的时间花费在处理部署等流程上,让我们能够更快的去完成业务交付。


更好更稳定的回滚方式,能够让我们做到出现问题时快速恢复。这样才能保证一个较低的试错成本。


对于古茗来说,我认为一个很大的优势是,我们的规模不算很大,可以更好地做好研发流程对应的工具服务的统一,打通研发流程的各个流程,每个环节之间更好地进行串联,更好的助力业务发展。


作者:古茗前端团队
来源:juejin.cn/post/7295160228878106650
收起阅读 »

接手了一个外包开发的项目,我感觉我的头快要裂开了~

嗨,大家好,我是飘渺。 最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的Spring Cloud Alibaba微服务架构,并且是基于一个“大名鼎鼎”的微服务开源脚手架(附带着模块代码截图,相信很多同学一看就能认出来)。然而,...
继续阅读 »

嗨,大家好,我是飘渺。


最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的Spring Cloud Alibaba微服务架构,并且是基于一个“大名鼎鼎”的微服务开源脚手架(附带着模块代码截图,相信很多同学一看就能认出来)。然而,在这段时间里,我受到了来自"外包"和"微服务"这双重debuff的折磨。


image-20231016162237399


今天,我想和大家分享一下我在这几天中遇到的问题。希望这几个问题能引起大家的共鸣,以便在未来的微服务开发中避免再次陷入相似的困境。


1、服务模块拆分不合理


绝大部分网上的微服务开源框架都是基于后台管理进行模块拆分的。然而在实际业务开发中,应该以领域建模为基础来划分子服务。


目前的服务拆分方式往往是按照团队或功能来拆分,这种不合理的拆分方式导致了服务调用的混乱,同时增加了分布式事务的风险。


2、微服务拆分后数据库并没拆分


所有服务都共用同一个数据库,这在物理层面无法对数据进行隔离,也导致一些团队为了赶进度,直接读取其他服务的数据表。


这里不禁要问:如果不拆分数据库,那拆分微服务还有何意义?


3、功能复制,不是双倍快乐


在项目中存在一个基础设施模块,其中包括文件上传、数据字典、日志等基础功能。然而,文件上传功能居然在其他模块中重复实现了一遍。就像这样:


image-20231017185809403


4、到处都是无用组件堆彻


在项目的基础模块中,自定义了许多公共的Starter,并且这些组件在各个微服务中被全都引入。比如第三方登录组件、微信支付组件、不明所以的流程引擎组件、验证码组件等等……


image.png


拜托,我们已经有自己的SSO登录,不需要微信支付,还有自己的流程引擎。那些根本用不到的东西,干嘛要引入呢?


5、明显的错误没人解决


这个问题是由上面的问题所导致的,由于引入了一个根本不需要的消息中间件,项目运行时不断出现如下所示的连接异常。


image-20231013223714103


项目开发了这么久,出错了这么久,居然没有一个人去解决,真的让人不得不佩服他们的忍受力。


6、配置文件一团乱麻


你看到服务中这一堆配置文件,是不是心里咯噔了一下?


image-20231017190214587


或许有人会说:"没什么问题呀,按照不同环境划分不同的配置文件”。可是在微服务架构下,已经有了配置中心,为什么还要这么做呢?这不是画蛇添足吗?


7、乱用配置中心


项目一开始就明确要使用Apollo配置中心,一个微服务对应一个appid,appid一般与application.name一致。


但实际上,多个服务却使用了相同的appid,多个服务的配置文件还塞在了同一个appid下。


更让人费解的是,有些微服务又不使用配置中心。


8、Nacos注册中心混乱


由于项目有众多参与的团队,为了联调代码,开发人员在启动服务时不得不修改配置文件中Nacos的spring.cloud.nacos.discovery.group属性,同时需要启动所有相关服务。


这导致了两个问题:一是某个用户提交了自己的配置文件,导致其他人的服务注册到了别的group,影响他人的联调;二是Nacos注册中心会存在一大堆不同的Gr0up,查找服务变得相当麻烦。


其实要解决这个问题只需要重写一下网关的负载均衡策略,让流量调度到指定的服务即可。据我所知,他们使用的开源框架应该支持这个功能,只是他们不知道怎么使用。


9、接口协议混乱


使用的开源脚手架支持Dubbo协议和OpenFeign调用,然而在我们的项目中并不会使用Dubbo协议,微服务之间只使用OpenFeign进行调用。然而,在对外提供接口时,却暴露了一堆支持Dubbo协议的接口。


10、部署方式混乱


项目部署到Kubernetes云环境,一般来说,服务部署到云上的内部服务应该使用ClusterIP的方式进行部署,只有网关服务需要对外访问,网关可以通过NodePort或Ingress进行访问。


这样做可以避免其他人或服务绕过网关直接访问后端微服务。


然而,他们的部署方式是所有服务都开启了NodePort访问,然后在云主机上还要部署一套Nginx来反向代理网关服务的NodePort端口。


image-20231016162150035


结语


网络上涌现着众多微服务开源脚手架,它们吸引用户的方式是将各种功能一股脑地集成进去。然而,它们往往只是告诉你“如何集成”却忽略了“为什么要集成”。


尽管这些开源项目能够在学习微服务方面事半功倍,但在实际微服务项目中,我们不能盲目照搬,而应该根据项目的实际情况来有选择地裁剪或扩展功能。这样,我们才能更好地应对项目的需求,避免陷入不必要的复杂性,从而更加成功地实施微服务架构。


最后,这个开源项目你们认识吗?


image-20231017190633190



关注公众号 Java日知录 获取更多精彩文章



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

手把手教你压测

前言 身为后端程序员怎么也要会一点压力测试相关的技术吧, 不然无脑上线项目万一项目火了进来大量请求时出现程序执行缓慢, 宕机等情况你肯定稳稳背锅, 而且这个时候短时间内还没办法解决, 只能使用物理扩容CPU, 内存, 更换网络等几种方式来解决问题, 妥妥的为公...
继续阅读 »

前言


身为后端程序员怎么也要会一点压力测试相关的技术吧, 不然无脑上线项目万一项目火了进来大量请求时出现程序执行缓慢, 宕机等情况你肯定稳稳背锅, 而且这个时候短时间内还没办法解决, 只能使用物理扩容CPU, 内存, 更换网络等几种方式来解决问题, 妥妥的为公司增加支出好吧, 下一个被开的就是你


都是想跑路拿高薪的打工仔, 身上怎么可以背负污点, 赶紧学一手压力测试进行保命, 我先学为敬


本篇文章主打一个学完就会, 奥利给



文中出现软件的版本



  • JMeter: 5.5

  • ServerAgent: 2.2.3



性能调优对各个开发岗位的区别


各个岗位对性能调优的关键节点



  • 前端工程师:

    • 首屏时间: 初次访问项目等待加载时间

    • 白屏时间: 刷新页面到数据全部展示时间

    • 可交互时间

    • 完全加载时间



  • 后端工程师

    • RT: 响应时间

    • TRS: 每秒事务数

    • 并发数: 这应该不会解释了吧



  • 移动端工程师

    • 端到端相应时间

    • Crash率

    • 内存使用率

    • FPS





主要讲一下后端工程师(Java), 毕竟这是吃饭的家伙

对于后端工程师来说, 影响性能的地方主要有两个



  • 数据库读写, RPC, 网络IO, 代码逻辑复杂度, 缓存

  • JVM(Throughput)
    - JVM(Throughput)



影响性能的关键要素



  • 产品设计

    • 产品逻辑

    • 功能交互

    • 动态效果

    • 页面元素



  • 基础网络

  • 代码质量&架构

    • 架构不合理

    • 研发功底和经验不足

    • 没有性能意识: 只实现功能不注重代码性能, 当业务上量后系统出现连锁反应, 导致性能问题增加

    • 数据库: 慢查询, 过多查询, 索引使用不当, 数据库服务器瓶颈



  • 用户移动端环境

    • 设备类型&性能

    • 系统版本

    • 网络(WiFi, 2G, 3G, 4G, 5G)

    • 硬件及云服务(服务器硬件, CPU, 内存..)




1. 初步了解压力测试


1.1压力测试是什么


压力测试是针对特定系统或组件, 为要确定其稳定性而特意进行的严格测试. 会让系统在超过正常使用条件下运作, 然后再确认其结果
对系统不断施加压力, 来预估系统`负载能力`的一种测试

一般而言, 只有在系统基础功能测试验证完成, 系统趋于稳定的情况下, 才会进行压力测试

1.2压力测试的目的


当负载主键增加时, 观察系统各项性能指标的变化情况是否有异常
发现系统的性能短板, 进行针对性的性能优化
判断系统在**高并发情况下是否会报错**, 进行是否会挂掉
测试在系统某个方面达到瓶颈时, 粗略估计系统性能上限

1.3 压力测试的指标


指标含义
相应时间(RT)是指系统对请求作出响应的平均时间, 对于单用户的系统, 响应时间可以很好地度量系统的性能
吞吐量(Throughput)是指系统在单位时间内处理的数量, 每秒事务数TPS 也算是吞吐量的一种
资源利用率CPU占用率, 内存使用率, 系统负载, 网络IO
并发用户数是指系统可以同时承载的正常使用系统功能的用户的数量, 用户不同的使用模式会导致不同用户在单位时间发出不同数量的请求
错误率失败请求占比, 在测试时添加响应断言, 验证不通过即标记为错误, 若不添加, 响应码非200则为错误


评判系统性能, 主要考虑三个性能指标 RT, TPS, 资源利用率



image.png


上图充分的展示了响应时间, 吞吐量, 利用率和并发用户数之间的关系


随着并发用户的增加经过轻负载区, 达到最优并发数, 此时利用率高,吞吐量高, 响应时间短


但是如果用户数继续增加, 就会到达重负载区, 此时性能最大化, 但是当超过某一临界值(最大并发数)之后, 响应时间会急剧增加, 利用率平缓, 吞吐量急速下降


我们进行压测的目的主要就是测试出这个临界值的大小, 或者说, 我们系统当前能承受住的最大并发数


2. 压力测试工具 JMeter


老规矩, 先来一波软件介绍
JMeter是 Apache组织开发的基于 Java的开源压力测试工具, 具有体积小, 功能全, 使用方便等特点. 最初被设计用于 Web应用测试, 后来被扩展到其他测试领域.


常用压测工具:



  • Apache JMeter可视化的测试工具

  • LoadRunner 预测系统行为和性能的负载测试工具

  • Apache的 ab压力测试

  • nGrinder韩国研发的一款性能测试工具

  • PAS阿里测试工具


压测目标:



  • 负载上升各项指标是否正常

  • 发现性能短板

  • 高并发下系统是否稳定

  • 预估系统最大负载


2.1 安装 JMeter



写在前面, 需要 Java8环境, 没有的话需要去安装, 教程百度上一大堆



官网地址: jmeter.apache.org/


image.png


熟悉的download, 点他


image.png


开始下载(是真的慢)


image.png


解压之后进入 bin目录下, 双击 jmeter.bat, 就可以启动 JMeter了


image.png


上图可以看出, 在我们第一次打开界面时是英文的, 多少有点不友好, 接下来讲解一下怎么将语言更改为中文


2.2 设置 JMeter界面为中文


还是我们的 bin目录下, 有一个 jmeter.properties文件


image.png


双击打开, 搜索 language


image.png


去除 #号, 值更改为 zh_CN, 保存文件然后重启软件(双击jmeter.bat)


image.png


可以看到, 我们的 jmeter成功更改为了中文界面, 这对于我这种英语白痴来说是很舒服的


image.png


2.3 初步使用 JMeter


我们先随便创建一个测试用例, 就是简单测试, 同时讲解一下常用的参数


本次测试采用 20线程, 1秒启动时间, 循环100次, Get请求


2.3.1 创建线程组


image.png


image.png



  • 线程数: 虚拟的用户数, 一个用户占一个线程

  • Ramp-Up: 等待时间, 设置的虚拟用户(线程数)需要多长时间全部启动

  • 循环次数: 单个线程发送请求的次数

  • 调度器:

    • 持续时间: 该任务执行的时间

    • 启动延迟: 等待多少秒开始执行




2.3.2 创建 http请求


右键线程组-添加HTTP请求


image.png


这个中文讲解的很明白, 应该都看得懂的, 有疑问的评论区留言


image.png


2.3.3 结果树



结果树, 聚合报告, 图形结果只有新增, 解释在测试



线程组右键-添加-监听器-查看结果树


image.png


image.png


执行结果分析(启动之后显示界面)


image.png


列表列出了每一次的HTTP请求, 绿色的是成功, 红色的话就是失败



  • 取样器结果参数详解

    • Thread Name:线程组名称

    • Sample Start: 启动开始时间

    • Load time:加载时长

    • Latency:等待时长

    • Size in bytes:发送的数据总大小

    • Headers size in bytes:发送数据的其余部分大小

    • Sample Count:发送统计

    • Error Count:交互错误统计

    • Response code:返回码

    • Response message:返回信息

    • Response headers:返回的头部信息



  • 请求

    • 基本数据

    • 入参

    • 请求头



  • 相应数据

    • 响应码

    • 响应头




2.3.4 聚合报告


线程组右键-添加-监听器-聚合报告


image.png


执行结果分析(启动之后界面)


image.png


参数解释



  • 样本: 并发量

  • 平均值: 接口请求用时(单位毫秒)

  • 中位数: 请求用时中位数(单位毫秒), 例如2000请求以请求时间排序, 排名1000的用时时长

  • 90%百分位, 95%百分位, 99%百分位和中位数同理

  • 最小, 最大值: 请求用时最小和最大

  • 异常% : 请求中异常的百分比

  • 吞吐量: 单位时间内请求次数


2.3.5 图形结果


线程组右键-添加-监听器-图形结果


image.png


执行结果分析(启动之后显示界面)


image.png



  • 样本数目:总共发送到服务器的请求数。

  • 最新样本:代表时间的数字,是服务器响应最后一个请求的时间。

  • 吞吐量:服务器每分钟处理的请求数。

  • 平均值:总运行时间除以发送到服务器的请求数。

  • 中间值:有一半的服务器响应时间低于该值而另一半高于该值。

  • 偏离:表示服务器响应时间变化、离散程度测量值的大小。


2.3.6 断言


断言主要用来判断结果返回是否符合预期


线程组右键-添加-断言-响应断言


image.png


image.png


假设我们接口的返回状态码字段为code, 200为成功, 那么就可以在断言这里进行配置, 来判断请求是否成功


image.png


3. JMeter插件


3.1 插件安装


首先说明 JMeter是不支持插件的, 所以我们先要 JMeter的插件允许插件下载, 这句话多少有点拗口


网址: Install :: JMeter-Plugins.org


image.png


点击上图红框即可下载插件, 前面说过了 JMeter是 Java8开发的, 插件对应的也是一个 jar包


image.png


下好之后就可以放在 JMeter安装目录下的 lib/ext/ 下了, 具体下载页面也有说明


image.png


上述操作结束之后, 在选项里面就可以看到插件中心Plugins Manager


image.png


弹出以下界面, 点击 Available Plugins搜索我们需要的插件Basic GraphsAdditional Graphs, 勾选上, 然后安装


image.png


Basic Graphs主要显示显示平均响应时间,活动线程数,成功/失败交易数等


image.png


Additional Graphs主要显示吞吐量,连接时间,每秒的点击数等


image.png


在安装成功之后, 在监听器会相应的多出很多的 jc开头的, 这就代表安装成功了



我使用的是 5.5版本的, 之前版本安装之后好像要手动重启, 5.5安装完会自动重启



image.png


4. Linux硬件监控


在压测过程中, 我们需要实时了解服务器的CPU, 内存, 网络, 服务器负载等情况的变化, 这个时候我们就需要对我们的 Linux系统进行监控, 通常来讲, 我们查询 Linux系统的资源占用情况可以使用以下几种方法



  • 使用命令: top, iostat, iotop等

  • 使用 Linux远程连接工具 FinalShell等

  • 宝塔

  • JMeter压测工具 PerfMon


在 JMeter中, 如果需要监控服务器硬件, 那么我们还需要安装 PerfMon插件


image.png
PerfMon监控服务器硬件,如CPU,内存,硬盘读写速度等


进入下述地址开始下载监控包: github.com/undera/perf…


image.png


下载好之后我们可以直接解压放到服务器上, 会看到有两个startAgent文件, 分别是Windows系统和Linux系统的启动脚本


image.png


我们直接启动就可以了, 如果脚本启动连接不上的话可以考虑更改脚本内容


例: Linux系统脚本更改为以下内容


## 默认启动运行 startAgent.sh 脚本即可

## 服务启动默认4444端口,根本连接不上,因此自己创建一个部署脚本文件对此进行部署,且把端口修改为7879

nohup java -jar ./CMDRunner.jar --tool PerfMonAgent --udp-port 7879 --tcp-port 7879 > log.log 2>&1 &

## 赋予可执行权限

chmod 755 startAgent.sh

启动成功之后, 脚本同级路径下会多出 log.log的日志文件


image.png


然后我们就可以配置 JMeter了, 线程组-监听器-jp@gc - PerfMon Metrics Collector


image.png


image.png


我是在本地启动了ServerAgent.bat进行测试, 执行结果如下所示:


image.png



注: 文件必须配置, 不然没有图像



具体的配置指标信息建议看官方文档, 太多了....
github.com/undera/perf…


image.png


image.png


ServerAgent闪退问题


Windows系统配置好ServerAgent启动之后窗口闪退可能是 jre版本问题, 可以从下面的链接下载老版的 jre


http://www.aliyundrive.com/s/Yzw3DZ74w…


下载好之后, 建议安装目录设置在ServerAgent/jre


image.png


并更改startAgent.bat脚本, cd 到老版本 jre路径


image.png


作者:宁轩
来源:juejin.cn/post/7248511603883638844
收起阅读 »

一不小心真删库了

事情经过 就是今天看到web前端的页面因为某个字段是Null,就想给Prisma的某个字段加上一个默认值,就像这样: 感觉也比较常规,当我改完这个字段后,我想的就是直接跑prisma migrate然后去迁移数据库,然后自动把null全部填值了。 md,然后...
继续阅读 »

事情经过


就是今天看到web前端的页面因为某个字段是Null,就想给Prisma的某个字段加上一个默认值,就像这样:


image.png


感觉也比较常规,当我改完这个字段后,我想的就是直接跑prisma migrate然后去迁移数据库,然后自动把null全部填值了。


md,然后因为我觉得这个操作好像再正常不过了,所以我就没看prisma在说啥,直接一路y到底了。


image.png


然后我一看prisma的客户端,茫然->刷几下->看看链接->茫然->再刷几下->啊?->无语凝噎->亚历山大、血压升高、心跳加速。md我的数据那,我的表里怎么一条数据没了,几万条啊?????


所以这个事情就告诉了我们,在做一些操作的时候不要觉得烦就一路y到底,认真看看命令行的反馈,那tm红红的一段 All data will be lost.......


问题追溯


在问题追溯前我先简单介绍下Prisma是啥,以及db操作和migrate操作(就是如果完全不了解数据库Orm的已经可以直接跳过了)。


Prisma


简单的说就是Prisma通过自己创建的特定语言DSL,把自己DSL结构映射为表结构,然后可以通过一系列cli去生成client或者迁移洗数据推拉数据库之类的操作,去帮助开发者在 Node.jsTypeScript 项目中更轻松地处理数据库操作.


Prisma db


db操作pullpushseedexecute,这一系列命令用于将 Prisma schema 中的定义同步到数据库 schema 中。db push 会检查当前数据库并根据您的 Prisma schema 对其进行更改。这是一种直接将 Prisma schema 变更应用到数据库的方法,但不会创建或使用迁移文件。在使用 db类操作 时,不会记录更改的历史(也就是不会生成提交记录,记住这一点很重要)、而我理解迁移历史的意义就是在于回滚、备份、恢复和版本控制,因此适用于原型设计和开发过程中的快速迭代


它的工作流分解一下可以分为以下几步:



  1. 自检Prisma 工具检查当前连接的数据库,并收集有关现有表、列、索引和其他数据库对象的详细信息。

  2. 分析Prisma 分析收集到的数据库信息,并尝试推断当前数据库结构与 Prisma schema 之间的差异。结合推断出的信息,Prisma 将生成相应的数据库操作(例如创建、修改或删除表、列、索引等),执行后,当前已连接的数据库结构和 本地Prisma schema 应该保持一致。

  3. 生成触发器:默认情况下,在将更改应用到数据库架构后,Prisma 会触发生成器(例如,Prisma Client)。不需要手动调用 prisma generate


Prisma migrate


它帮助我们根据 Prisma schema 中的更改自动创建、处理和执行迁移,同时保持数据库 schema 与迁移历史记录同步。migrate dev 通过影子数据库(shadow database)来检测 schema 偏移和冲突,并在需要时提示我们进行数据库重置。也就是说它可以确保在团队中正确地记录和同步数据库更改。


它的工作流分解一下可以分为以下几步:



  1. 重播现有迁移历史:在影子数据库(shadow database)中重播现有迁移历史,以便检测 schema 偏移(编辑或删除的迁移文件,或者对数据库 schema 的手动更改)。

  2. 应用待执行的迁移:将待执行的迁移应用到影子数据库(例如,由同事创建的新迁移)。

  3. 从 Prisma schema 更改中生成新迁移:在运行 migrate dev 命令之前,从我们对 Prisma schema 所做的任何更改中生成新迁移。

  4. 将所有未应用的迁移应用到开发数据库并更新 _prisma_migrations:将所有未应用的迁移应用到开发数据库,并更新 _prisma_migrations 表以记录已执行的迁移。

  5. 生成触发器和迁移历史


Why?


刚刚提到了做db操作是不会生成提交记录的,那到底发生了什么呐,我旁边的老大哥给我复盘了一下,首先A同事对数据库进行了db push操作(因为他正在设计一张表),这时候数据库schema其实已经偏移了但这时候,我并不知道因为没有任何的留存,然后接下来我在没有进行db pull的情况下直接去做了migrate操作,其实在这个时候prisma通过生成的影子数据库已经检测到了有偏移了,但我并没有注意,直接y到底,导致直接进行了reset操作清空了数据。


反思



  • 在团队协作项目中优先使用数据库迁移工具(如 migrate dev)。

  • 在执行关键数据库操作之前,确保同步所有的更改并了解同事所做的修改。

  • 当迁移工具检测到偏移和冲突时,确保花时间了解问题并采取适当措施。

  • 这其实就是缺乏适当沟通和不遵循较好实践带来的后果。在执行关键操作前了解同事的更改和确认是非常重要的。遇到迁移工具检测到的偏移和冲突时,应确保彻底了解问题并采取适当措施解决,而不是忽略警告。


作者:溪饱鱼
来源:juejin.cn/post/7293765765126324259
收起阅读 »

Shell 命令奇淫技巧,就是有点短

1、在任意目录之间快速移动 你发现自己要在两个或更多目录之间频繁移动,一会切换到这里,一会切换到那里,来回跳转。这些目录之间隔得还挺远,反复输入冗长的路径让人疲惫不堪。 使用内建命令 pushd 和 popd 来管理目录栈,轻松地在目录之间切换。下面是一个简单...
继续阅读 »

1、在任意目录之间快速移动


你发现自己要在两个或更多目录之间频繁移动,一会切换到这里,一会切换到那里,来回跳转。这些目录之间隔得还挺远,反复输入冗长的路径让人疲惫不堪。


使用内建命令 pushd 和 popd 来管理目录栈,轻松地在目录之间切换。下面是一个简单的示例:


$ cd /tmp/tank
$ pwd
/tmp/tank

$
pushd /var/log/cups
/var/log/cups /tmp/tank

$
pwd
/var/log/cups

$
ls
access_log error_log page_log

$
popd
/tmp/tank

$
ls
empty full

$
pushd /var/log/cups
/var/log/cups /tmp/tank

$
pushd
/tmp/tank /var/log/cups

$
pushd
/var/log/cups /tmp/tank

$
pushd
/tmp/tank /var/log/cups

$
dirs
/tmp/tank /var/log/cups

栈是一种后进先出的结构,这两个命令也正是这么做的。如果对一个新目录使用 pushd,那么它会将前一个目录压入栈中。当使用 popd时,它会弹出栈顶保存的当前位置,切换到新的栈顶目录。使用这些命令更改位置时,会从左到右输出目录栈中的值,对应于栈中自顶向下的顺序。


如果使用 pushd 时没有指定目录,那么它会交换栈顶的两个目录的位置,这样就可以重复使用 pushd 命令来实现两者之间的切换。cd命令也能够达到相同效果。


如果不记得目录栈中都有哪些目录,可以使用内建命令 dirs 按照从左到右的顺序显示。加上 -v 选项后,显示形式更形象。


$ dirs -v
0 /opt/yongheng
1 /opt/yongheng/Shell
$

数字可用来调整栈内目录的位置。pushd +1 会将编号为 1 的目录置为栈顶(并切换到该目录)并将其他目录下压。


$  pushd +1
/opt/yongheng/Shell /opt/yongheng

$
dirs -v
0 /opt/yongheng/Shell
1 /opt/yongheng

要想看到类似于栈的目录列表,但又不希望出现编号,可以使用 -p选项。


# dirs -p                                                                                              /opt/yongheng/Shell                                                                                    /opt/yongheng     

2、重复上一个命令


你刚刚输入了一个又长又麻烦的命令,其中包含了冗长的路径名和一堆复杂的参数。现在需要重新执行该命令。难道还得再输入一次?


这个问题有两种解决方法。第一种方法只需要在提示符下输入两个惊叹号,然后 bash 就会显示并重复执行上一个命令。例如:


$  cd /opt/                                                                                            $ !!                                                                                                   cd /opt/  

另一种(更现代的)方法是使用箭头键。按上箭头键会回滚到执行过的上一个命令。如果找到了需要的命令,按下 Enter 键就可以(再次)执行该命令。


3、参数重用


重用上一个命令很简单,使用 !! 就行了,但你需要的未必总是整个命令。如何只重用最后一个参数呢?


用 !$ 指明上一个命令中的最后一个参数。!:1 表示第一个参数,!:2 表示第二个参数,以此类推。


多个命令使用相同的文件名为参数是司空见惯的事情。最常见的场景之一就是程序员编辑源代码文件,然后编译、再编辑,再编译……有了 !$,事情就方便多了。如下:


$ vi /some/long/path/name/you/only/type/once
...
$ gcc !$
gcc /some/long/path/name/you/only/type/once
...
$ vi !$
vi /some/long/path/name/you/only/type/once
...
$ gcc !$
gcc /some/long/path/name/you/only/type/once
...
$

明白其中的意思了吗?这不仅省去了大量的键盘输入,还避免了错误。如果编译时输错文件名,那编译的可就不是刚刚编辑好的源代码文件了。有了 !$,就可以始终得到刚刚用过的文件名。要是想重用的参数位于命令行内部,可以使用带编号的 !: 命令来获取。


4、安全第一,命令测试


一不小心就会输错字符。 (不信你瞧!)即便是简单的 bash 命令,由此带来的后果也非常严重:你会移动错或删错文件。如果再加上模式匹配,结果更让人心跳,因为模式中的输入错误会导致南辕北辙的结果。小心谨慎的用户会怎么做?


可以使用命令历史特性和键盘便捷方式来重复参数,无须从头输入,因此能够减少输入错误。如果要用到棘手的模式来匹配文件,先用echo 测试一下模式能否正常匹配,然后再用 !$ 进行实际操作。例如:


$ ls
ab1.txt ac1.txt jb1.txt wc3.txt

$
echo *1.txt
ab1.txt ac1.txt jb1.txt

$
echo [aj]?1.txt
ab1.txt ac1.txt jb1.txt

$
echo ?b1.txt
ab1.txt jb1.txt

$
rm !$
rm ?b1.txt
$

echo 是检查模式匹配结果的一种方法。一旦确信结果符合预期,就可以将模式用于实际命令。这里我们要删除有特定名称的文件,没人愿意在这种事上犯错。


作者:博学谷_狂野架构师
来源:juejin.cn/post/7187268796753641531
收起阅读 »

压缩炸弹,Java怎么防止

一、什么是压缩炸弹,会有什么危害 1.1 什么是压缩炸弹 压缩炸弹(ZIP):一个压缩包只有几十KB,但是解压缩后有几十GB,甚至可以去到几百TB,直接撑爆硬盘,或者是在解压过程中CPU飙到100%造成服务器宕机。虽然系统功能没有自动解压,但是假如开发人员在不...
继续阅读 »

一、什么是压缩炸弹,会有什么危害


1.1 什么是压缩炸弹


压缩炸弹(ZIP):一个压缩包只有几十KB,但是解压缩后有几十GB,甚至可以去到几百TB,直接撑爆硬盘,或者是在解压过程中CPU飙到100%造成服务器宕机。虽然系统功能没有自动解压,但是假如开发人员在不细心观察的情况下进行一键解压(不看压缩包里面的文件大小),可导致压缩炸弹爆炸。又或者压缩炸弹藏在比较深的目录下,不经意的解压缩,也可导致压缩炸弹爆炸。


以下是安全测试几种经典的压缩炸弹


graph LR
A(安全测试的经典压缩炸弹)
B(zip文件42KB)
C(zip文件10MB)
D(zip文件46MB)
E(解压后5.5G)
F(解压后281TB)
G(解压后4.5PB)

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:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style F fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style G fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px


压缩炸弹(也称为压缩文件炸弹、炸弹文件)是一种特殊的文件,它在解压缩时会迅速膨胀成极其庞大的文件,可能导致系统资源耗尽、崩溃或磁盘空间耗尽。


压缩炸弹的原理是利用文件压缩算法中的重复模式和递归压缩的特性。它通常是一个非常小的压缩文件,但在解压缩时会生成大量的重复数据,导致文件大小迅速增长。这种文件的设计意图是迫使系统进行大量的解压缩操作,以消耗系统资源或填满磁盘空间。


压缩炸弹可能对系统造成严重的影响,包括系统崩溃、资源耗尽、拒绝服务攻击等。因此,它被视为一种恶意的计算机攻击工具,常常被用于恶意目的或作为安全测试中的一种工具。



1.2 压缩炸弹会有什么危害


graph LR
A(压缩炸弹的危害)
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:#00FFFF,stroke:#00FFFF,stroke-width:2px

压缩炸弹可能对计算机系统造成以下具体的破坏:




  1. 资源耗尽:压缩炸弹在解压缩时会生成大量的重复数据,导致系统的CPU、内存和磁盘资源被迅速占用。这可能导致系统反应迟缓、无法响应用户的请求,甚至系统崩溃。




  2. 磁盘空间耗尽:由于压缩炸弹的膨胀特性,它可能在解压缩过程中填满磁盘空间。这会导致系统无法写入新的数据,造成磁盘空间耗尽,影响系统的正常运行。




  3. 系统崩溃:当一个压缩炸弹被解压缩时,系统可能由于资源耗尽或磁盘空间耗尽而崩溃。这可能导致系统重启或需要进行紧急修复,造成数据丢失或系统不可用的情况。




  4. 拒绝服务攻击:大规模的解压缩操作可能消耗大量系统资源,导致系统无法正常提供服务。这被恶意攻击者利用,用于进行拒绝服务攻击,使目标系统无法响应合法用户的请求。




  5. 数据丢失:在某些情况下,压缩炸弹可能会导致系统文件或数据的损坏或丢失。这可能发生在磁盘空间被耗尽时,写入操作无法成功完成的情况下。





重要提示:压缩炸弹可能对计算机系统造成不可逆的损害,请不要尝试创建、传播或使用压缩炸弹,以保护计算机和网络的安全。



二、怎么检测和处理压缩炸弹,Java怎么防止压缩炸弹


2.1 个人有没有方法可以检测压缩炸弹?


有一些方法可以识别和处理潜在的压缩炸弹,以防止对系统造成破坏。以下是一些常见的方法:


graph LR
A(个人检测压缩炸弹)
B(安全软件和防病毒工具)
C(文件大小限制)
D(文件类型过滤)

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:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style F fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style G fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px



  1. 安全软件和防病毒工具(推荐):使用最新的安全软件和防病毒工具可以帮助检测和阻止已知的压缩炸弹。这些工具通常具备压缩文件扫描功能,可以检查文件是否包含恶意的压缩炸弹。




  2. 文件大小限制:设置对文件大小的限制可以帮助防止解压缩过程中出现过大的文件。通过限制解压缩操作的最大文件大小,可以减少对系统资源和磁盘空间的过度消耗。




  3. 文件类型过滤:识别和过滤已知的压缩炸弹文件类型可以帮助阻止这些文件的传输和存储。通过检查文件扩展名或文件头信息,可以识别潜在的压缩炸弹,并阻止其传输或处理。




2.2 Java怎么防止压缩炸弹


在java中实际防止压缩炸弹的方法挺多的,可以采取以下措施来防止压缩炸弹:


graph LR
A(Java防止压缩炸弹)
B(解压缩算法的限制)
C(设置解压缩操作的资源限制)
D(使用安全的解压缩库)
E(文件类型验证和过滤)
F(异步解压缩操作)
G(安全策略和权限控制)

A ---> B
A ---> C
A ---> D
A ---> E
A ---> F
A ---> 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:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
style G fill:#00FFFF,stroke:#00FFFF,stroke-width:2px



  1. 解压缩算法的限制:限制解压缩算法的递归层数和重复模式的检测可以帮助防止解压缩过程无限膨胀。通过限制递归的深度和检测重复模式,可以及时中断解压缩操作并避免过度消耗资源。




  2. 设置解压缩操作的资源限制:使用Java的java.util.zipjava.util.jar等类进行解压缩时,可以设置解压缩操作的资源限制,例如限制解压缩的最大文件大小、最大递归深度等。通过限制资源的使用,可以减少对系统资源的过度消耗。




  3. 使用安全的解压缩库:确保使用的解压缩库是经过安全验证的,以避免存在已知的压缩炸弹漏洞。使用官方或经过广泛验证的库可以减少受到压缩炸弹攻击的风险。




  4. 文件类型验证和过滤:在解压缩之前,可以对文件进行类型验证和过滤,排除潜在的压缩炸弹文件。通过验证文件的类型、扩展名和文件头信息,可以识别并排除不安全的压缩文件。




  5. 异步解压缩操作:将解压缩操作放在异步线程中进行,以防止阻塞主线程和耗尽系统资源。这样可以保持应用程序的响应性,并减少对系统的影响。




  6. 安全策略和权限控制:实施严格的安全策略和权限控制,限制用户对系统资源和文件的访问和操作。确保只有受信任的用户或应用程序能够进行解压缩操作,以减少恶意使用压缩炸弹的可能性。




2.2.1 使用解压算法的限制来实现防止压缩炸弹


在前面我们说了Java防止压缩炸弹的一些策略,下面我将代码实现通过解压缩算法的限制来实现防止压缩炸弹。


先来看看我们实现的思路


graph TD
A(开始) --> B[创建 ZipFile 对象]
B --> C[打开要解压缩的 ZIP 文件]
C --> D[初始化 zipFileSize 变量为 0]
D --> E{是否有更多的条目}
E -- 是 --> F[获取 ZIP 文件的下一个条目]
F --> G[获取当前条目的未压缩大小]
G --> H[将解压大小累加到 zipFileSize 变量]
H --> I{zipFileSize 是否超过指定的大小}
I -- 是 --> J[调用 deleteDir方法删除已解压的文件夹]
J --> K[抛出 IllegalArgumentException 异常]
K --> L(结束)
I -- 否 --> M(保存解压文件) --> E
E -- 否 --> L

style A fill:#00FFFF,stroke:#00FFFF,stroke-width:2px
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:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
style G fill:#00FFFF,stroke:#00FFFF,stroke-width:2px
style H fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
style I fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
style J fill:#00FFFF,stroke:#00FFFF,stroke-width:2px
style K fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
style L fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style M fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px

实现流程说明如下:




  1. 首先,通过给定的 file 参数创建一个 ZipFile 对象,用于打开要解压缩的 ZIP 文件。




  2. zipFileSize 变量用于计算解压缩后的文件总大小。




  3. 使用 zipFile.entries() 方法获取 ZIP 文件中的所有条目,并通过 while 循环逐个处理每个条目。




  4. 对于每个条目,使用 entry.getSize() 获取条目的未压缩大小,并将其累加到 zipFileSize 变量中。




  5. 如果 zipFileSize 超过了给定的 size 参数,说明解压后的文件大小超过了限制,此时会调用 deleteDir() 方法删除已解压的文件夹,并抛出 IllegalArgumentException 异常,以防止压缩炸弹攻击。




  6. 创建一个 File 对象 unzipped,表示解压后的文件或目录在输出文件夹中的路径。




  7. 如果当前条目是一个目录,且 unzipped 不存在,则创建该目录。




  8. 如果当前条目不是一个目录,确保 unzipped 的父文件夹存在。




  9. 创建一个 FileOutputStream 对象 fos,用于将解压后的数据写入到 unzipped 文件中。




  10. 通过 zipFile.getInputStream(entry) 获取当前条目的输入流。




  11. 创建一个缓冲区 buffer,并使用循环从输入流中读取数据,并将其写入到 fos 中,直到读取完整个条目的数据。




  12. 最后,在 finally 块中关闭 fos 和 zipFile 对象,确保资源的释放。




实现代码工具类


import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

/**
* 文件炸弹工具类
*
* @author bamboo panda
* @version 1.0
* @date 2023/10
*/

public class FileBombUtil {

/**
* 限制文件大小 1M(限制单位:B)[1M=1024KB 1KB=1024B]
*/

public static final Long FILE_LIMIT_SIZE = 1024 * 1024 * 1L;

/**
* 文件超限提示
*/

public static final String FILE_LIMIT_SIZE_MSG = "The file size exceeds the limit";

/**
* 解压文件(带限制解压文件大小策略)
*
* @param file 压缩文件
* @param outputfolder 解压后的文件目录
* @param size 限制解压之后的文件大小(单位:B),示例 3M:1024 * 1024 * 3L (FileBombUtil.FILE_LIMIT_SIZE * 3)
* @throws Exception IllegalArgumentException 超限抛出的异常
* 注意:业务层必须抓取IllegalArgumentException异常,如果msg等于FILE_LIMIT_SIZE_MSG
* 要考虑后面的逻辑,比如告警
*/

public static void unzip(File file, File outputfolder, Long size) throws Exception {
ZipFile zipFile = new ZipFile(file);
FileOutputStream fos = null;
try {
Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
long zipFileSize = 0L;
ZipEntry entry;
while (zipEntries.hasMoreElements()) {
// 获取 ZIP 文件的下一个条目
entry = zipEntries.nextElement();
// 将解缩大小累加到 zipFileSize 变量
zipFileSize += entry.getSize();
// 判断解压文件累计大小是否超过指定的大小
if (zipFileSize > size) {
deleteDir(outputfolder);
throw new IllegalArgumentException(FILE_LIMIT_SIZE_MSG);
}
File unzipped = new File(outputfolder, entry.getName());
if (entry.isDirectory() && !unzipped.exists()) {
unzipped.mkdirs();
continue;
} else if (!unzipped.getParentFile().exists()) {
unzipped.getParentFile().mkdirs();
}

fos = new FileOutputStream(unzipped);
InputStream in = zipFile.getInputStream(entry);

byte[] buffer = new byte[4096];
int count;
while ((count = in.read(buffer, 0, buffer.length)) != -1) {
fos.write(buffer, 0, count);
}
}
} finally {
if (null != fos) {
fos.close();
}
if (null != zipFile) {
zipFile.close();
}
}

}

/**
* 递归删除目录文件
*
* @param dir 目录
*/

private static boolean deleteDir(File dir) {
if (dir.isDirectory()) {
String[] children = dir.list();
//递归删除目录中的子目录下
for (int i = 0; i < children.length; i++) {
boolean success = deleteDir(new File(dir, children[i]));
if (!success) {
return false;
}
}
}
// 目录此时为空,可以删除
return dir.delete();
}

}

测试类


import java.io.File;

/**
* 文件炸弹测试类
*
* @author bamboo panda
* @version 1.0
* @date 2023/10
*/

public class Test {

public static void main(String[] args) {
File bomb = new File("D:\temp\3\zbsm.zip");
File tempFile = new File("D:\temp\3\4");
try {
FileBombUtil.unzip(bomb, tempFile, FileBombUtil.FILE_LIMIT_SIZE * 60);
} catch (IllegalArgumentException e) {
if (FileBombUtil.FILE_LIMIT_SIZE_MSG.equalsIgnoreCase(e.getMessage())) {
FileBombUtil.deleteDir(tempFile);
System.out.println("原始文件太大");
} else {
System.out.println("错误的压缩文件格式");
}
} catch (Exception e) {
e.printStackTrace();
}
}

}

三、总结


文件炸弹是一种恶意的计算机程序或文件,旨在利用压缩算法和递归结构来创建一个巨大且无限增长的文件或文件集
合。它的目的是消耗目标系统的资源,如磁盘空间、内存和处理能力,导致系统崩溃或无法正常运行。文件炸弹可能是有意制造的攻击工具,用于拒绝服务(DoS)攻击或滥用资源的目的。


文件炸弹带来的危害极大,作为开发人员,我们必须深刻认识到文件炸弹的危害性,并始终保持高度警惕,以防止这种潜在漏洞给恐怖分子以可乘之机。


总而言之,我们作为开发人员,要深刻认识到文件炸弹的危害性,严防死守,不给恐怖分子任何可乘之机。通过使用安全工具、限制文件大小、及时更新软件、定期备份数据以及加强安全意识,我们可以有效地防止文件炸弹和其他恶意活动对系统造成损害。


在中国,网络安全和计算机犯罪问题受到相关法律法规的管理和监管。以下是一些中国关于网络安全和计算机犯罪方面的法律文献,其中也涉及到文件炸弹的相关规定:





  1. 《中华人民共和国刑法》- 该法律规定了各种计算机犯罪行为的法律责任,包括非法控制计算机信息系统、破坏计算机信息系统功能、非法获取计算机信息系统数据等行为,这些行为可能涉及到使用文件炸弹进行攻击。




  2. 《中华人民共和国网络安全法》- 该法律是中国的基本法律,旨在保障网络安全和维护国家网络空间主权。它规定了网络安全的基本要求和责任,包括禁止制作、传播软件病毒、恶意程序和文件炸弹等危害网络安全的行为。




  3. 《中华人民共和国计算机信息系统安全保护条例》- 这是一项行政法规,详细规定了计算机信息系统安全的保护措施和管理要求。其中包含了对恶意程序、计算机病毒和文件炸弹等威胁的防范要求。





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

学弟说他面试时被问到了HashMap,差点就遭老罪了

面试官:小伙子,了解HashMap吗? 学弟:哎呦,你干嘛~ 真的问这个呀.... 面试官:呦,练习时长两年半?待会答不上来,你可就遭老罪喽! 那行吧,那开始吧...唱跳rap篮球🏀...... 一、HashMap的底层结构 说一下你理解的HashM...
继续阅读 »

面试官:小伙子,了解HashMap吗?


学弟:哎呦,你干嘛~ 真的问这个呀....


面试官:呦,练习时长两年半?待会答不上来,你可就遭老罪喽!



在这里插入图片描述



那行吧,那开始吧...唱跳rap篮球🏀......



一、HashMap的底层结构



说一下你理解的HashMap底层?



hashMap是由数值和链表组合而成的数据结构,存储为key Value形式。


在java7中叫entry,数据形式为数组+链表。java8中叫Node,数据形式为数组+链表+红黑树(当链表长度大于8时转为红黑树)。


每一个节点都会保存自身的hash、key、value、以及next属性指向下一个节点。


在这里插入图片描述


二、为什么使用数组+链表数据结构



你刚提到了使用数组+链表,可以讲讲为什么使用这个结构吗?



HashMap内部使用数组来存储键值对,这个数组就是 HashMap 的主体。


在这里插入图片描述


在数组中存储的每个位置上,可能会有多个键值对,这些键值对通过链表的形式链接在一起。


在这里插入图片描述


使用数组+链表的数据结构是为了解决散列表中的键冲突问题。在散列表中,每个键都会被映射到一个桶中,但是不同的键可能会被映射到同一个桶中,这种情况被称为键冲突。


为了解决键冲突问题,HashMap 采用了链表的形式将所有映射到同一个桶中的键值对链接在一起,这样就可以通过遍历链表来查找指定键的值当链表长度过长时,查找效率就会下降,因此在链表长度超过一定阈值(8)后,HashMap会将链表转换为红黑树,以提高查找效率


同时,数组的优势在于支持通过下标快速访问元素,因此HashMap可以将每个桶的位置映射到数组的一个元素上,通过下标访问该元素即可访问到对应的链表或红黑树


我们都知道:数组的查询效率很高,添加和删除的效率低。链表的查询效率很低,添加和删除的效率高。


因此:使用数组加链表形式,不仅可以解决散列表中的键冲突问题,且数组的查询效率高、链表的添加和删除效率高。结合在一起,增删查效率都很高


请添加图片描述



嗯,确实不错。不愧是练习时长两年半的程序员.....



三、数组+链表+红黑树



你刚说数组+链表+红黑树,什么情况下会转化红黑树?什么情况下转数组呢?



链表中元素过多,会影响查找效率,当其个数达到8的时候转换为红黑树。红黑树是平衡二叉树,在查找性能方面比链表要高


当红黑树的节点数小于等于6时,红黑树转换为链表,是为了减少内存开销


需要注意的是:将链表转换为红黑树、红黑树转换为链表的操作会影响HashMap的性能,因此需要尽可能避免这种情况的发生。同时,当HashMap中的元素数量较小时,不会出现链表转换为红黑树的情况,因此使用HashMap时,可以考虑在元素数量较少的情况下使用HashMap,以提高性能。


在这里插入图片描述


四、头插法和尾插法



说一下什么是头插法,什么是尾插法?



哇,这不是为难我胖虎吗?啥是头插法?啥是尾插法?


在这里插入图片描述


4.1、头插法


顾名思义,头插法就是新增元素时,放在最前面嘛。


举个栗子🌰,楼主画了一个简单的框框。用来表示原有存储顺序依次为1、2、3的数组。
在这里插入图片描述


假设现在加入了一个4,如果使用头插法,就会变为4123。


在这里插入图片描述


4.2、尾插法


同样道理,尾插法就是新增元素时,放在最后面。


还是原有存储顺序依次为1、2、3的数组。
在这里插入图片描述
假设现在加入了一个4,如果使用尾插法,就会变为1234。


在这里插入图片描述



头插法为什么要调整为尾插法呢?



为什么头插法要调整为尾插法?这是个好问题!!!
请添加图片描述


java7中使用头插法,新来的值会取代原有的值,原有的值就顺推到链表中。在这种情况下,引用关系可能会乱掉,严重会造成死循环。java8使用尾插法,把元素放到最后,就不会出现这种情况。


五、HashMap如何运算存储索引



向一个hashMap中存入数据时,如何知道数据放在哪个位置呢?



当向一个hashMap中存入数据时,会先根据key的哈希值决定放在数组中哪个索引位置。



Hash公式:index = HashCode(Key) & (Length - 1)



如果数组中该索引位置是空的,直接将元素放入,如果该索引位置已经存在元素了,就根据equals方法判断下已有的元素是否和我们新放入的元素是同一个,如果返回true是同一个,则覆盖掉。不是同一元素则在原有元素下面使用链表进行存储


每个元素都有一个next属性指向下一个节点(数组+链表)


    /**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
.........
}

六、HashMap初始化、扩容



嗯,你知道HashMap默认初始化大小是多少吗?还有它的扩容?



HashMap默认初始化容量大小是16,最大容量为2的30次方,负载因子是0.75


在这里插入图片描述


扩容时,会把原有数组中的值取出再次hash到新的数组中(长度扩大以后,Hash的规则也随之改变),因此性能消耗也相对较大。


当HashMap中的元素数量超过负载因子(默认为 0.75)乘以数组长度时,就会触发扩容操作,将数组长度增加一倍,并重新计算每个元素在新数组中的位置。


七、hash碰撞是什么



你听说过hash碰撞吗?



hash碰撞就是不同的Key,经过同一散列算法之后得到的hashCode值相同。


hashCode不同,key一定不同。hashCode相同,key却不一定相同。


当两个key的hashCode()返回值不同时,它们对应哈希表索引也一定不同。不同的key对象,即使它们包含相同的属性、值或状态,它们的hashCode()返回值也是不相同的。


在这里插入图片描述


当两个key的hashCode()返回值相同时,它们可能对应同一个哈希表索引,但它们并不一定相等。在哈希表中,不同的key可能会产生相同的哈希值(哈希碰撞)。


因此,当 key的hashCode相同时,还需要比较key的相等性。需要调用key的equals() 方法来判断它们是否相等。只有当hashCode相等,且equals方法返回true时。才可以认为这两个key相等


八、如何解决hash碰撞



解决hash碰撞的方法有哪些呢?



在哈希表中,哈希碰撞可能会导致性能下降或者安全问题。


常见的解决方法有:


1、开放地址法:在发生哈希碰撞时,通过一定的算法在哈希表中寻找一个空闲的位置,并将元素插入该位置。


2、链式哈希表:在每个哈希表的元素位置上,存储一个链表,哈希碰撞时,将元素插入到相应的链表中。


3、再哈希法:如果一个哈希函数产生的哈希值发生了碰撞,就再次使用另一个哈希函数计算哈希值。


4、负载因子调整:通过调整哈希表的容量、负载因子等参数,可以减少哈希碰撞的发生。


九、HashMap为什么线程不安全



HashMap线程安全吗?为什么?



HashMap是非线程安全的。在多线程环境下,如果多个线程同时修改HashMap中的数据,就可能会导致数据的不一致性。


说白了就是没加锁。


在这里插入图片描述


当多个线程同时调用HashMap的put()方法,一旦他们计算出的hash值相同,就会发生冲突,导致数据被覆盖。


所以,对于多线程并发访问的情况,建议使用线程安全的Map实现


例如ConcurrentHashMap,或者使用Collections.synchronizedMap()方法将HashMap包装成一个线程安全的Map


十、HashMap、HashTable、ConcurrentHashMap的区别



最后一个问题:说一下HashMap、HashTable、ConcurrentHashMap的区别?



麻了! 真的麻了....救救孩子吧....


在这里插入图片描述


HashMap、HashTable、ConcurrentHashMap都是Java中常用的哈希表实现。


区别主要在以下几个方面:


1、线程安全性:HashTable是线程安全的,HashMap是非线程安全的,ConcurrentHashMap通过分段锁的方式保证了线程安全。


2、是否可为空:HashTable不允许value为空,ConcurrentHashMap不允许null值作为key或value,而HashMap则允许null作为key或value。


3、迭代器:HashTable的迭代器是通过Enumeration实现的,而HashMap和ConcurrentHashMap使用的是Iterator实现的。


4、扩容:HashTable在扩容时,将容量扩大一倍加一,而HashMap和ConcurrentHashMap的扩容机制是将容量扩大一倍。


5、初始容量:HashTable的初始容量为11,而HashMap和ConcurrentHashMap的初始容量为16。


6、性能:HashMap通常比HashTable性能更好,因为它没加锁。所以弊端就是线程不安全。但后者加了锁,是线程安全的,缺点就是消耗性能。ConcurrentHashMap在多线程并发访问时,比HashTable和HashMap性能更好,因为它使用了分段锁来保证线程安全


所以,不建议使用HashTable。至于选择HashMap还是ConcurrentHashMap取决于并发访问量的大小,若并发访问量不高,则选用HashMap。若并发访问量较大,则选用ConcurrentHashMap。



ok,那今天先到这里吧。练习时长两年半的程序员.....唱跳rap篮球🏀....差点就遭老罪喽~



还有,别忘记给那个练习时长两年半的三婶儿也点个赞哈~她唱跳rap篮球也还行......


在这里插入图片描述


作者:三婶儿
来源:juejin.cn/post/7209826725365137465
收起阅读 »

为啥一个 main 方法就能启动项目

在 Spring Boot 出现之前,我们要运行一个 Java Web 应用,首先需要有一个 Web 容器(例如 Tomcat 或 Jetty),然后将我们的 Web 应用打包后放到容器的相应目录下,最后再启动容器。 在 IDE 中也需要对 Web 容器进行一...
继续阅读 »

在 Spring Boot 出现之前,我们要运行一个 Java Web 应用,首先需要有一个 Web 容器(例如 Tomcat 或 Jetty),然后将我们的 Web 应用打包后放到容器的相应目录下,最后再启动容器。


在 IDE 中也需要对 Web 容器进行一些配置,才能够运行或者 Debug。而使用 Spring Boot 我们只需要像运行普通 JavaSE 程序一样,run 一下 main() 方法就可以启动一个 Web 应用了。这是怎么做到的呢?今天我们就一探究竟,分析一下 Spring Boot 的启动流程。


概览


回看我们写的第一个 Spring Boot 示例,我们发现,只需要下面几行代码我们就可以跑起一个 Web 服务器:


@SpringBootApplication
public class HelloApplication {
public static void main(String[] args) {
SpringApplication.run(HelloApplication.class, args);
}
}


去掉类的声明和方法定义这些样板代码,核心代码就只有一个 @SpringBootApplication 注解和 SpringApplication.run(HelloApplication.class, args) 了。而我们知道注解相当于是一种配置,那么这个 run() 方法必然就是 Spring Boot 的启动入口了。


接下来,我们沿着 run() 方法来顺藤摸瓜。进入 SpringApplication 类,来看看 run() 方法的具体实现:


public class SpringApplication {
......
public ConfigurableApplicationContext run(String... args) {
// 1 应用启动计时开始
StopWatch stopWatch = new StopWatch();
stopWatch.start();

// 2 声明上下文
DefaultBootstrapContext bootstrapContext = createBootstrapContext();
ConfigurableApplicationContext context = null;

// 3 设置 java.awt.headless 属性
configureHeadlessProperty();

// 4 启动监听器
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting(bootstrapContext, this.mainApplicationClass);
try {
// 5 初始化默认应用参数
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);

// 6 准备应用环境
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
configureIgnoreBeanInfo(environment);

// 7 打印 Banner(Spring Boot 的 LOGO)
Banner printedBanner = printBanner(environment);

// 8 创建上下文实例
context = createApplicationContext();
context.setApplicationStartup(this.applicationStartup);

// 9 构建上下文
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);

// 10 刷新上下文
refreshContext(context);

// 11 刷新上下文后处理
afterRefresh(context, applicationArguments);

// 12 应用启动计时结束
stopWatch.stop();
if (this.logStartupInfo) {
// 13 打印启动时间日志
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
}

// 14 发布上下文启动完成事件
listeners.started(context);

// 15 调用 runners
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
// 16 应用启动发生异常后的处理
handleRunFailure(context, ex, listeners);
throw new IllegalStateException(ex);
}

try {
// 17 发布上下文就绪事件
listeners.running(context);
}
catch (Throwable ex) {
handleRunFailure(context, ex, null);
throw new IllegalStateException(ex);
}
return context;
}
......
}


Spring Boot 启动时做的所有操作都这这个方法里面,当然在调用上面这个 run() 方法之前,还创建了一个 SpringApplication 的实例对象。因为上面这个 run() 方法并不是一个静态方法,所以需要一个对象实例才能被调用。


可以看到,方法的返回值类型为 ConfigurableApplicationContext,这是一个接口,我们真正得到的是 AnnotationConfigServletWebServerApplicationContext 的实例。通过类名我们可以知道,这是一个基于注解的 Servlet Web 应用上下文(我们知道上下文(context)是 Spring 中的核心概念)。


上面对于 run() 方法中的每一个步骤都做了简单的注释,接下来我们选择几个比较有代表性的来详细分析。


应用启动计时


在 Spring Boot 应用启动完成时,我们经常会看到类似下面内容的一条日志:


Started AopApplication in 2.732 seconds (JVM running for 3.734)

应用启动后,会将本次启动所花费的时间打印出来,让我们对于启动的速度有一个大致的了解,也方便我们对其进行优化。记录启动时间的工作是 run() 方法做的第一件事,在编号 1 的位置由 stopWatch.start() 开启时间统计,具体代码如下:


public void start(String taskName) throws IllegalStateException {
if (this.currentTaskName != null) {
throw new IllegalStateException("Can't start StopWatch: it's already running");
}
// 记录启动时间
this.currentTaskName = taskName;
this.startTimeNanos = System.nanoTime();
}


然后到了 run() 方法的基本任务完成的时候,由 stopWatch.stop()(编号 12 的位置)对启动时间做了一个计算,源码也很简单:


public void stop() throws IllegalStateException {
if (this.currentTaskName == null) {
throw new IllegalStateException("Can't stop StopWatch: it's not running");
}
// 计算启动时间
long lastTime = System.nanoTime() - this.startTimeNanos;
this.totalTimeNanos += lastTime;
......
}


最后,在 run() 中的编号 13 的位置将启动时间打印出来:


if (this.logStartupInfo) {
// 打印启动时间
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
}


打印 Banner


Spring Boot 每次启动是还会打印一个自己的 LOGO,如图:


在这里插入图片描述


这种做法很常见,像 Redis、Docker 等都会在启动的时候将自己的 LOGO 打印出来。Spring Boot 默认情况下会打印那个标志性的“树叶”和 “Spring” 的字样,下面带着当前的版本。


在 run() 中编号 7 的位置调用打印 Banner 的逻辑,最终由 SpringBootBanner 类的 printBanner() 完成。这个图案定义在一个常量数组中,代码如下:


class SpringBootBanner implements Banner {

private static final String[] BANNER = {
"",
" . ____ _ __ _ _",
" /\\\\ / ___'_ __ _ _(_)_ __ __ _ \\ \\ \\ \\",
"( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\",
" \\\\/ ___)| |_)| | | | | || (_| | ) ) ) )",
" ' |____| .__|_| |_|_| |_\\__, | / / / /",
" =========|_|==============|___/=/_/_/_/"
};
......

public void printBanner(Environment environment, Class sourceClass, PrintStream printStream) {
for (String line : BANNER) {
printStream.println(line);
}
......
}

}


手工格式化了一下 BANNER 的字符串,轮廓已经清晰可见了。真正打印的逻辑就是 printBanner() 方法里面的那个 for 循环。


记录启动时间和打印 Banner 代码都非常的简单,而且都有很明显的视觉反馈,可以清晰的看到结果。拿出来咱们做个热身,配合断点去 Debug 会有更加直观的感受,尤其是打印 Banner 的时候,可以看到整个内容被一行一行打印出来,让我想起了早些年用那些配置极低的电脑(还是 CRT 显示器)运行着 Win98,经常会看到屏幕内容一行一行加载显示。


创建上下文实例


下面我们来到 run() 方法中编号 8 的位置,这里调用了一个 createApplicationContext() 方法,该方法最终会调用 ApplicationContextFactory 接口的代码:


ApplicationContextFactory DEFAULT = (webApplicationType) -> {
try {
switch (webApplicationType) {
case SERVLET:
return new AnnotationConfigServletWebServerApplicationContext();
case REACTIVE:
return new AnnotationConfigReactiveWebServerApplicationContext();
default:
return new AnnotationConfigApplicationContext();
}
}
catch (Exception ex) {
throw new IllegalStateException("Unable create a default ApplicationContext instance, "
+ "you may need a custom ApplicationContextFactory", ex);
}
};


这个方法就是根据 SpringBootApplication 的 webApplicationType 属性的值,利用反射来创建不同类型的应用上下文(context)。而属性 webApplicationType 的值是在前面执行构造方法的时候由 WebApplicationType.deduceFromClasspath() 获得的。通过方法名很容易看出来,就是根据 classpath 中的类来推断当前的应用类型。


我们这里是一个普通的 Web 应用,所以最终返回的类型为 SERVLET。所以会返回一个 AnnotationConfigServletWebServerApplicationContext 实例。


构建容器上下文


接着我们来到 run() 方法编号 9 的 prepareContext() 方法。通过方法名,我们也能猜到它是为 context 做上台前的准备工作的。


private void prepareContext(DefaultBootstrapContext bootstrapContext, ConfigurableApplicationContext context,
ConfigurableEnvironment environment, SpringApplicationRunListeners listeners,
ApplicationArguments applicationArguments, Banner printedBanner)
{
......
// 加载资源
load(context, sources.toArray(new Object[0]));
listeners.contextLoaded(context);
}


在这个方法中,会做一些准备工作,包括初始化容器上下文、设置环境、加载资源等。


加载资源


上面的代码中,又调用了一个很关键的方法——load()。这个 load() 方法真正的作用是去调用 BeanDefinitionLoader 类的 load() 方法。源码如下:


class BeanDefinitionLoader {
......
void load() {
for (Object source : this.sources) {
load(source);
}
}

private void load(Object source) {
Assert.notNull(source, "Source must not be null");
if (source instanceof Class) {
load((Class) source);
return;
}
if (source instanceof Resource) {
load((Resource) source);
return;
}
if (source instanceof Package) {
load((Package) source);
return;
}
if (source instanceof CharSequence) {
load((CharSequence) source);
return;
}
throw new IllegalArgumentException("Invalid source type " + source.getClass());
}
......
}


可以看到,load() 方法在加载 Spring 中各种资源。其中我们最熟悉的就是 load((Class) source) 和 load((Package) source) 了。一个用来加载类,一个用来加载扫描的包。


load((Class) source) 中会通过调用 isComponent() 方法来判断资源是否为 Spring 容器管理的组件。 isComponent() 方法通过资源是否包含 @Component 注解(@Controller、@Service、@Repository 等都包含在内)来区分是否为 Spring 容器管理的组件。


而 load((Package) source) 方法则是用来加载 @ComponentScan 注解定义的包路径。


刷新上下文


run() 方法编号10 的 refreshContext() 方法是整个启动过程比较核心的地方。像我们熟悉的 BeanFactory 就是在这个阶段构建的,所有非懒加载的 Spring Bean(@Controller、@Service 等)也是在这个阶段被创建的,还有 Spring Boot 内嵌的 Web 容器要是在这个时候启动的。


跟踪源码你会发现内部调用的是 ConfigurableApplicationContext.refresh(),ConfigurableApplicationContext 是一个接口,真正实现这个方法的有三个类:AbstractApplicationContext、ReactiveWebServerApplicationContext 和 ServletWebServerApplicationContext。


AbstractApplicationContext 为后面两个的父类,两个子类的实现比较简单,主要是调用父类实现,比如 ServletWebServerApplicationContext 中的实现是这样的:


public final void refresh() throws BeansException, IllegalStateException {
try {
super.refresh();
}
catch (RuntimeException ex) {
WebServer webServer = this.webServer;
if (webServer != null) {
webServer.stop();
}
throw ex;
}
}


主要的逻辑都在 AbstractApplicationContext 中:


@Override
public void refresh() throws BeansException, IllegalStateException {
synchronized (this.startupShutdownMonitor) {
StartupStep contextRefresh = this.applicationStartup.start("spring.context.refresh");

// 1 准备将要刷新的上下文
prepareRefresh();

// 2 (告诉子类,如:ServletWebServerApplicationContext)刷新内部 bean 工厂
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

// 3 为上下文准备 bean 工厂
prepareBeanFactory(beanFactory);

try {
// 4 允许在子类中对 bean 工厂进行后处理
postProcessBeanFactory(beanFactory);

StartupStep beanPostProcess = this.applicationStartup.start("spring.context.beans.post-process");
// 5 调用注册为 bean 的工厂处理器
invokeBeanFactoryPostProcessors(beanFactory);

// 6 注册拦截器创建的 bean 处理器
registerBeanPostProcessors(beanFactory);
beanPostProcess.end();

// 7 初始化国际化相关资源
initMessageSource();

// 8 初始化事件广播器
initApplicationEventMulticaster();

// 9 为具体的上下文子类初始化特定的 bean
onRefresh();

// 10 注册监听器
registerListeners();

// 11 实例化所有非懒加载的单例 bean
finishBeanFactoryInitialization(beanFactory);

// 12 完成刷新发布相应的事件(Tomcat 就是在这里启动的)
finishRefresh();
}

catch (BeansException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Exception encountered during context initialization - " +
"cancelling refresh attempt: " + ex);
}

// 遇到异常销毁已经创建的单例 bean
destroyBeans();

// 充值 active 标识
cancelRefresh(ex);

// 将异常向上抛出
throw ex;
} finally {
// 重置公共缓存,结束刷新
resetCommonCaches();
contextRefresh.end();
}
}
}


简单说一下编号 9 处的 onRefresh() 方法,该方法父类未给出具体实现,需要子类自己实现,ServletWebServerApplicationContext 中的实现如下:


protected void onRefresh() {
super.onRefresh();
try {
createWebServer();
}
catch (Throwable ex) {
throw new ApplicationContextException("Unable to start web server", ex);
}
}

private void createWebServer() {
......
if (webServer == null && servletContext == null) {
......

// 根据配置获取一个 web server(Tomcat、Jetty 或 Undertow)
ServletWebServerFactory factory = getWebServerFactory();
this.webServer = factory.getWebServer(getSelfInitializer());
......
}
......
}


factory.getWebServer(getSelfInitializer()) 会根据项目配置得到一个 Web Server 实例,这里跟下一篇将要谈到的自动配置有点关系。


作者:刘水镜
来源:juejin.cn/post/7206749400172380219
收起阅读 »

他们在学校里不会教你的编程原则

前言 在大学的时候,学校一般只会教你你写编程语言,比如C、C++、JAVA等编程语言。但是当你离开大学进入这个行业开始工作时,才知道编程不只是知道编程语言、语法等,要想写好代码,必须还要了解一些编程原则才行。本文主要讨论KISS、DRY和SOLID这些常见的编...
继续阅读 »

前言


在大学的时候,学校一般只会教你你写编程语言,比如C、C++、JAVA等编程语言。但是当你离开大学进入这个行业开始工作时,才知道编程不只是知道编程语言、语法等,要想写好代码,必须还要了解一些编程原则才行。本文主要讨论KISSDRYSOLID这些常见的编程原则,而且你会发现随着工作时间越久,越能感受这些编程原则的精妙之处,历久弥香。


KISS原则



Keep It Simple, Stupid!



你是不是有过接手同事的代码感到十分头疼的经历,明明可以有更加简单、明白的写法,非要绕来绕去,看不明白?


其实,我们在写代码的时候应该要遵守KISS原则,核心思想就是尽量保持简单。代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。


我们写代码的的时候要站在别人的角度出发,就像马丁·福勒说的,我们写的代码不是给机器看的,而是给人看的。


“任何傻瓜都可以编写计算机可以理解的代码。优秀的程序员编写出人类可以理解的代码。” — 马丁·福勒


那么如何才能写出满足KISS原则的代码呢?


如何写出KISS原则的代码?


我们直接上例子,下面的校验IP是否合法的3种实现方式,大家觉得哪个最KISS?



  1. 写法一




  1. 写法二




  1. 写法三




  • 写法一代码量最少,正则表达式本身是比较复杂的,写出完全没有 bug 的正则表达本身就比较有挑战;另一方面,并不是每个程序员都精通正则表达式。对于不怎么懂正则表达式的同事来说,看懂并且维护这段正则表达式是比较困难的。这种实现方式会导致代码的可读性和可维护性变差,所以,从 KISS 原则的设计初衷上来讲,这种实现方式并不符合 KISS 原则。

  • 写法二使用了 StringUtils 类、Integer 类提供的一些现成的工具函数,来处理 IP地址字符串,逻辑清晰,可读性好。

  • 写法三不使用任何工具函数,而是通过逐一处理 IP 地址中的字符,来判断是否合法,容易出bug,不好理解。


所以说,符合KISS原则的代码并不是代码越少越好,还要考虑代码是否逻辑清晰、是否容易理解、是否够稳定。


总结以下如何写出KISS原则的代码:



  1. 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。

  2. 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出bug 的概率会更高,维护的成本也比较高。

  3. 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。

  4. 主观站在别人的角度上编写代码。你在编写代码的时候就要思考我这个同事看这段代码是不是很快就能够明白理解。


DRY原则



Don't Repeat Yourself



你是不是有过这样的经历,项目中很多重复逻辑的代码,然后修改一个地方,另外一个地方忘记修改,导致测试给你提了很多bug?


DRY原则,英文全称Don’t Repeat Yourself,直译过来就是不要重复你自己。这里的重复不仅仅是代码一模一样,还包括实现逻辑重复、功能语义重复、代码执行重复等。我们不要偷懒,有责任把这些存在重复的地方识别出来,然后优化它们。


如何写出DRY原则的代码呢?


我们直接上例子,代码重复的我就不讲了,很好理解,关于实现逻辑或者功能语义重复的我觉个例子。


还是上面校验IP的例子,团队中两个同事由于不知道就有了两种写法。



  • 同事A写法




  • 同事B写法



尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则。我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。不然哪天校验规则变了,很容易只改了其中一个,另外一个漏改,就会出现莫名其妙的bug


其他的比如逻辑重复的意思是虽然功能是不一致的,但是里面的逻辑都是一模一样的。举个例子,比如校验用户名和校验密码,虽然功能不一致,但是校验逻辑都是相似,判空、字符长度等等,这种情况我们就需要把相似的逻辑抽取到一个方法中,不然也是不符合DRY原则。


那么我们平时写代码注意些什么才是符合DRY原则呢?



  • 使用现成的轮子,不轻易造轮子


其实最关键的就是写代码带脑子,用到一个方法先看看有没有现成的,不要看看不看,就动手在那里造轮子。



  • 减少代码耦合


对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。



  • 满足单一职责原则


我们前面讲过,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。



  • 模块化


这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。



  • 业务与非业务逻辑分离


越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。



  • 通用代码下沉


从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。



  • 继承、多态、抽象、封装


在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。



  • 应用模板等设计模式


一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。


SOLID原则


SOLID原则不是一个单一的原则,而是对软件开发至关重要的 5 条原则,遵循这些原则有助于我们写出高内聚、低耦合、可扩展、可维护性好的代码。


S—单一职责原则



一个类应该有一个,而且只有一个改变它的理由。



单一职责原则在我看来是最容易理解也是最重要的一个原则。它的核心思想就是一个模块、类或者方法只做一件事,只有一个职责,千万不要越俎代庖。它可以带来下面的好处:



  • 可以让代码耦合度更低

  • 使代码更容易理解和维护

  • 使代码更易于测试和维护,使软件更易于实施,并有助于避免未来更改的意外副作用


举个例子,我们有两个类PersonAccount。 两者都负有存储其特定信息的单一责任。 如果要更改Person的状态,则无需修改类Account,反之亦然, 不要把账户的行为比如修改账户名changeAcctName写在Person类中。


    public class Person {
private Long personId;
private String firstName;
private String lastName;
private String age;
private List accounts;

// 错误做法
public void changeAcctName(Account account, String acctName) {
acccount.setAccountName(acctName);
// 更新到数据库
}
}

public class Account {
private Long guid;
private String accountNumber;
private String accountName;
private String status;
private String type;

}

所以大家在编写代码的时候,一定要停顿思考下这个段代码真的写在这里吗?另外很关键的一点是如果发现一个类或者一个方法十分庞大,那么很有可能已经违背单一职责原则了,后续维护可想而知十分痛苦。


O—开闭原则



软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。



对扩展开放,对修改关闭,什么意思?很简单,其实就是我们要尽量通过新增类实现功能,而不是修改原有的类或者逻辑。因为修改已有代码很有可能对已有功能引入bug。


让我们通过一个例子来理解这个原则,比如一个通知服务。


    public class NotificationService {
public void sendOTP(String medium) {
if (medium.equals("email")) {
//email 发送
} else if (medium.equals("mobile")) {
// 手机发送
}
}

现在需要新增微信的方式通知,你要怎么做呢? 是在加一个if else吗? 这样就不符合开闭原则了,我们看下开闭原则该怎么写。



  • 定义一个通知服务接口



public interface NotificationService {
public void sendOTP();
}


  • E-mail方式通知类EmailNotification



public class EmailNotification implements NotificationService{
public void sendOTP(){
// write Logic using JavaEmail api
}
}


  • 手机方式通知类MobileNotification



public class MobileNotification implements NotificationService{
public void sendOTP(){
// write Logic using Twilio SMS API
}
}


  • 同样可以添加微信通知服务的实现WechatNotification



public class WechatNotification implements NotificationService{
public void sendOTP(String medium){
// write Logic using wechat API
}
}

这样的方式就是遵循开闭原则的,你不用修改核心的业务逻辑,这样可能带来意向不到的后果,而是扩展实现方式,由调用方根据他们的实际情况调用。


是不是想到了设计模式中的策略模式,其实设计模式就是指导我们写出高内聚、低耦合的代码。


L—里氏替换原则



派生类或子类必须可替代其基类或父类



这个原则稍微有点难以理解,它的核心思想是每个子类或派生类都应该可以替代/等效于它们的基类或父类。这样有一个好处,就是无论子类是什么类型,客户端通过父类调用都不会产生意外的后果。


理解不了?那我我们通过一个例子来理解一下。


让我们考虑一下我有一个名为 SocialMedia 的抽象类,它支持所有社交媒体活动供用户娱乐,如下所示:


    package com.alvin.solid.lsp;

public abstract class SocialMedia {

public abstract void chatWithFriend();

public abstract void publishPost(Object post);

public abstract void sendPhotosAndVideos();

public abstract void groupVideoCall(String... users);
}

社交媒体可以有多个实现或可以有多个子类,如 FacebookWechatWeiboTwitter 等。


现在让我们假设 Facebook 想要使用这个特性或功能。


    package com.alvin.solid.lsp;

public class Wechat extends SocialMedia {

public void chatWithFriend() {
//logic
}

public void publishPost(Object post) {
//logic
}

public void sendPhotosAndVideos() {
//logic
}

public void groupVideoCall(String... users) {
//logic
}
}

我们都知道Facebook都提供了所有上述的功能,所以这里我们可以认为FacebookSocialMedia类的完全替代品,两者都可以无中断地替代。


现在让我们讨论 Weibo


    package com.alvin.solid.lsp;

public class Weibo extends SocialMedia {
public void chatWithFriend() {
//logic
}

public void publishPost(Object post) {
//logic
}

public void sendPhotosAndVideos() {
//logic
}

public void groupVideoCall(String... users) {
//不适用
}
}

我们都知道Weibo微博这个产品是没有群视频功能的,所以对于 groupVideoCall方法来说 Weibo 子类不能替代父类 SocialMedia。所以我们认为它是不符合里式替换原则。


如果强行这么做的话,会导致客户端用父类SocialMedia调用,但是实现类注入的可能是个Weibo的实现,调用groupVideoCall行为,产生意想不到的后果。


那有什么解决方案吗?


那就把功能拆开呗。


    public interface SocialMedia {   
public void chatWithFriend();
public void sendPhotosAndVideos()
}


public interface SocialPostAndMediaManager {
public void publishPost(Object post);
}



public interface VideoCallManager{
public void groupVideoCall(String... users);
}

现在,如果您观察到我们将特定功能隔离到单独的类以遵循LSP。


现在由实现类决定支持功能,根据他们所需的功能,他们可以使用各自的接口,例如 Weibo 不支持视频通话功能,因此 Weibo 实现可以设计成这样:


    public class Instagram implements SocialMedia,SocialPostAndMediaManager{
public void chatWithFriend(){
//logic
}
public void sendPhotosAndVideos(){
//logic
}
public void publishPost(Object post){
//logic
}
}

这样子就是符合里式替换原则LSP。


I—接口隔离原则



接口不应该强迫他们的客户依赖它不使用的方法。



大家可以看看自己的工程,是不是一个接口类中有很多很多的接口,每次调用API方法的时候IDE工具给你弹出一大堆,十分的"臃肿肥胖"。所以该原则的核心思想要将你的接口拆小,拆细,打破”胖接口“,不用强迫客户端实现他们不需要的接口。是不是和单一职责原则有点像?


例如,假设有一个名为 UPIPayment 的接口,如下所示


    public interface UPIPayments {

public void payMoney();

public void getScratchCard();

public void getCashBackAsCreditBalance();
}

现在让我们谈谈 UPIPayments 的一些实现,比如 Google PayAliPay


Google Pay 支持这些功能所以他可以直接实现这个 UPIPaymentsAliPay 不支持 getCashBackAsCreditBalance() 功能所以这里我们不应该强制客户端 AliPay 通过实现 UPIPayments 来覆盖这个方法。


我们需要根据客户需要分离接口,所以为了满足接口隔离原则,我们可以如下设计:



  • 创建一个单独的接口来处理现金返还。



public interface CashbackManager{
public void getCashBackAsCreditBalance();
}

现在我们可以从 UPIPayments 接口中删除getCashBackAsCreditBalanceAliPay也不需要实现getCashBackAsCreditBalance()这个它没有的方法了。


D—依赖倒置原则



高层模块不应该依赖低层模块,两者都应该依赖于抽象(接口)。抽象不应该依赖于细节(具体实现),细节应该取决于抽象。



这个原则我觉得也不是很好理解,所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。比如大家都知道的MVC模式,controller是调用service层接口这个抽象,而不是实现类。这也是我们经常说的要面向接口编程,而非细节或者具体实现,因为接口意味着契约,更加稳定。


我们通过一个例子加深一下理解。



  • 借记卡



public class DebitCard {
public void doTransaction(int amount){
System.out.println("tx done with DebitCard");
}
}


  • 信用卡



public class CreditCard{
public void doTransaction(int amount){
System.out.println("tx done with CreditCard");
}
}

现在用这两张卡你去购物中心购买了一些订单并决定使用信用卡支付


    public class ShoppingMall {
private DebitCard debitCard;
public ShoppingMall(DebitCard debitCard) {
this.debitCard = debitCard;
}
public void doPayment(Object order, int amount){
debitCard.doTransaction(amount);
}
public static void main(String[] args) {
DebitCard debitCard=new DebitCard();
ShoppingMall shoppingMall=new ShoppingMall(debitCard);
shoppingMall.doPayment("some order",5000);
}
}

上面的做法是一个错误的方式,因为 ShoppingMall 类与 DebitCard 紧密耦合。


现在你的借记卡余额不足,想使用信用卡,那么这是不可能的,因为 ShoppingMall 与借记卡紧密结合。


当然你也可以这样做,从构造函数中删除借记卡并注入信用卡。但这不是一个好的方式,它不符合依赖倒置原则。


那该如何正确设计呢?



  • 定义依赖的抽象接口BankCard



public interface BankCard {
public void doTransaction(int amount);
}


  • 现在 DebitCardCreditCard 都实现BankCard



public class CreditCard implements BankCard{
public void doTransaction(int amount){
System.out.println("tx done with CreditCard");
}
}


public class DebitCard implements BankCard {
public void doTransaction(int amount){
System.out.println("tx done with DebitCard");
}
}


  • 现在重新设计购物中心这个高级类,他也是去依赖这个抽象,而不是直接低级模块的实现类



public class ShoppingMall {
private BankCard bankCard;
public ShoppingMall(BankCard bankCard) {
this.bankCard = bankCard;
}
public void doPayment(Object order, int amount){
bankCard.doTransaction(amount);
}
public static void main(String[] args) {
BankCard bankCard=new CreditCard();
ShoppingMall shoppingMall1=new ShoppingMall(bankCard);
shoppingMall1.doPayment("do some order", 10000);
}
}

我们还可以拿 Tomcat这个 Servlet 容器作为例子来解释一下。


Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Sevlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet规范。


总结


本文总结了软件编程中的黄金原则,KISS原则,DRY原则,SOLID原则。这些原则不仅仅适用于编程,也可以指导我们在架构设计上。虽然其中有些原则很抽象,但是大家多多实践和思考,会体会到这些原则的精妙。


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

消息推送的实现方式

短轮询(Long Polling) 网络资源:短轮询会产生大量的网络请求,尤其是当客户端轮询间隔很短时。这可能会导致大量的网络开销。 服务器处理:对于每个轮询请求,服务器需要处理该请求并发送响应,即使没有新的数据。这会导致服务器频繁地处理请求,可能增加CPU...
继续阅读 »

短轮询(Long Polling)



  1. 网络资源:短轮询会产生大量的网络请求,尤其是当客户端轮询间隔很短时。这可能会导致大量的网络开销。

  2. 服务器处理:对于每个轮询请求,服务器需要处理该请求并发送响应,即使没有新的数据。这会导致服务器频繁地处理请求,可能增加CPU和内存的使用。

  3. 总结:如果更新频率很低,但客户端仍然频繁地发送请求,短轮询可能会造成资源浪费,因为大多数响应可能只是告知“无新数据”


长轮询(Long Polling)



  • 客户端发送请求到服务器,服务器如果没有准备好的数据,就保持连接开放,直到有数据可以发送。一旦数据被发送,客户端处理数据后再次发送新的请求,如此循环。

  • 长轮询通常用于实时或近实时的通知和更新,比如活动通知。



  1. 网络资源:相比短轮询,长轮询减少了无效的网络请求。服务器只在有新数据时才发送响应,从而减少了网络流量。

  2. 服务器处理:长轮询可能导致服务器需要维护更多的打开连接,因为它会为每个客户端请求保持一个打开的连接,直到有新数据或超时。这可能会增加服务器的内存使用,并可能达到服务器的并发连接限制。

  3. 总结:长轮询在某些场景下可以提供更高效的资源使用,尤其是当数据更新不频繁但需要快速传递给客户端时。但如果有大量的客户端同时进行长轮询,服务器可能需要处理大量的并发打开连接。


WebSocket:



  • WebSocket提供了一个全双工通信通道,使得服务器和客户端可以在任何时刻发送数据给对方。这是一个非常实时且高效的解决方案。适合实时聊天



  1. 网络资源:WebSocket 在建立连接后只需要一个握手过程,之后数据可以在此连接上双向传输,不需要为每条消息进行新的请求和响应。这极大地减少了网络开销。

  2. 服务器处理:一旦 WebSocket 连接被建立,它将保持打开状态,直到客户端或服务器决定关闭它。这意味着服务器必须维护所有活动的 WebSocket 连接,这可能会消耗内存和其他资源。

  3. 总结:WebSocket 在数据频繁更新并且需要实时传递给客户端的场景中非常有效。尽管需要维护持久连接,但由于减少了网络开销,通常更为高效。


服务器发送事件(Server-Sent Events, SSE) :



  • 服务器发送事件是一种使服务器能够发送新数据到客户端的简单方法。它比WebSocket简单,但只允许服务器向客户端发送数据。活动通知和提醒



  1. 网络资源:与 WebSocket 类似,SSE 也只需要一次握手来建立持久连接。一旦连接建立,服务器可以持续地向客户端推送消息。

  2. 服务器处理:SSE 需要维护持久连接以发送数据,但与 WebSocket 相比,SSE 只是单向的。这意味着服务器不需要处理从客户端发来的消息。

  3. 总结:SSE 是一种高效的技术,适用于只需要服务器向客户端推送数据的场景,例如实时消息通知。


HTTP/2 Server Push:




  • HTTP/2协议支持服务器推送,允许服务器在客户端需要之前预先发送数据。这可以减少延迟,但通常只用于发送关联的资源,如CSS或JavaScript文件,而不是用于通用的消息推送。




  • 主要用于提前发送关联资源如CSS、JavaScript文件,以减少加载时间,提高网页性能。




  • 可以减少网页加载时间,提高用户体验。




  • 不适用于通用的消息推送,且需要HTTP/2协议支持,实现可能需要特定的服务器配置。




MQTT协议


MQTT 全称(Message Queue Telemetry Transport):一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。


该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的MQ有点类似。


TCP协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于TCP/IP协议上,也就是说只要支持TCP/IP协议栈的地方,都可以使用MQTT协议。


为什么要用 MQTT协议?


MQTT协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP协议呢?



  • 首先HTTP协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合IOT应用程序。

  • HTTP是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。

  • 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP要实现这样的功能不但很困难,而且成本极高。


第三方推送服务:



  • 使用如Firebase Cloud Messaging (FCM), Apple Push Notification Service (APNs)等第三方推送服务来处理消息推送。


对比


WebSocket和Server-Sent Events提供了较低的延迟和较高的实时性,但可能需要更多的服务器资源。长轮询可能会有更高的延迟,并且可能不是最高效的解决方案。HTTP/2 Server Push和第三方推送服务可能更适合于不需要高度实时性的应用。消息队列和发布/订阅模型提供了一种解耦服务器和客户端的方式,但可能会增加系统的复杂性。


在选择实现方法时,需要考虑应用的具体需求,例如实时性的要求、服务器资源、网络条件以及开发和维护的复杂性。同时,也可以考虑将几种方法结合使用,以满足不同的需求。



  • 如果有大量的客户端并且数据更新不频繁,长轮询可能比短轮询更为有效,因为它减少了无效的网络请求。

  • 如果服务器有并发连接的限制或资源有限,大量的长轮询请求可能会耗尽资源,导致服务器不稳定。

  • 如果数据更新非常频繁,短轮询可能会比较合适,因为它可以更简单地处理频繁的请求。

  • WebSocket 通常在需要实时通信的应用中更为有效和资源高效。它减少了网络开销,并提供了持续的、低延迟的双向通信。

  • 短轮询长轮询 可能更适合不需要持续连接的场景或当 WebSocket 不可用或不适用时的备选方案。

  • WebSocket:提供双向通信,适用于需要实时双向交互的应用,如在线聊天。由于它是全双工的,可能需要更多的资源来处理双向的消息传输。

  • SSE:提供单向通信,适用于只需要服务器推送数据的应用,如股票行情更新。通常,SSE 比 WebSocket 更轻量,因为它只处理单向通信。

  • 短轮询:可能会产生大量网络开销,特别是在数据更新频繁的场景中。

  • 长轮询:减少了网络开销,但可能需要服务器维护大量的打开连接,直到有新数据或超时。


从资源消耗的角度看:



  • WebSocketSSE 都需要维护持久连接,但通常比短轮询和长轮询更高效,因为它们减少了网络开销。

  • SSE 可能比 WebSocket 更轻量,因为它是单向的。

  • 短轮询 可能是最耗资源的,尤其是在频繁请求且数据更新不频繁的场景中。

  • 长轮询 在某些情况下可能比短轮询更高效,但仍然不如 WebSocket 或 SSE。


作者:Pomelo_刘金
来源:juejin.cn/post/7291464815658172471
收起阅读 »

SQL为什么动不动就N百行以K计?

发明SQL的初衷之一显然是为了降低人们实施数据查询计算的难度。SQL中用了不少类英语的词汇和语法,这是希望非技术人员也能掌握。确实,简单的SQL可以当作英语阅读,即使没有程序设计经验的人也能运用。 然而,面对稍稍复杂的查询计算需求,SQL就会显得力不从心,经常...
继续阅读 »

发明SQL的初衷之一显然是为了降低人们实施数据查询计算的难度。SQL中用了不少类英语的词汇和语法,这是希望非技术人员也能掌握。确实,简单的SQL可以当作英语阅读,即使没有程序设计经验的人也能运用。


然而,面对稍稍复杂的查询计算需求,SQL就会显得力不从心,经常写出几百行有多层嵌套的语句。这种SQL,不要说非技术人员难以完成,即使对于专业程序员也不是件容易的事,常常成为很多软件企业应聘考试的重头戏。三行五行的SQL仅存在教科书和培训班,现实中用于报表查询的SQL通常是以“K”计的。


SQL困难的分析探讨


这是为什么呢?我们通过一个很简单的例子来考察SQL在计算方面的缺点。


设有一个由三个字段构成的销售业绩表(为了简化问题,省去日期信息):


sales_amount销售业绩表
sales销售员姓名,假定无重名
product销售的产品
amount该销售员在该产品上的销售额

现在我们想知道出空调和电视销售额都在前10名的销售员名单。


这个问题并不难,人们会很自然地设计出如下计算过程:


1. 按空调销售额排序,找出前10名;


2. 按电视销售额排序,找出前10名;


3. 对1、2的结果取交集,得到答案;


我们现在来用SQL做。


1. 找出空调销售额前10名,还算简单:


select top 10 sales from sales_amount where product='AC' order by amount desc

2. 找出电视销售额前10名。动作一样:


select top 10 sales from sales_amount where product='TV' order by amount desc

3. 求1、2的交集。这有点麻烦,SQL不支持步骤化,上两步的计算结果无法保存,只能再重抄一遍了:


select * from
( select top 10 sales from sales_amount where product='AC' order by amount desc )
intersect
( select top 10 sales from sales_amount where product='TV' order by amount desc )

一个只三步的简单计算用SQL要写成这样,而日常计算中多达十几步的比比皆是,这显然超出来许多人的可接受能力。


我们知道了SQL的第一个重要缺点:不支持步骤化。把复杂的计算分步可以在很大程度地降低问题的难度,反过来,把多步计算汇成一步则很大程度地提高了问题的难度。


可以想象,如果老师要求小学生做应用题时只能列一个算式完成,小朋友们会多么苦恼(当然,不乏一些聪明孩子搞得定)。


SQL查询不能分步,但用SQL写出的存储过程可以分步,那么用存储过程是否可以方便地解决这个问题呢?


暂先不管使用存储过程的技术环境有多麻烦和数据库的差异性造成的不兼容,我们只从理论上来看用分步SQL是否能让这个计算更简单捷些。


1. 计算空调销售额前10名。语句还是那样,但我们需要把结果存起来供第3步用,而SQL中只能用表存储集合数据,这样我们要建一个临时表:


create temporary table x1 as
select top 10 sales from sales_amount where product='AC' order by amount desc

2. 计算电视销售额前10名。类似地


create temporary table x2 as
select top 10 sales from sales_amount where product='TV' order by amount desc

3. 求交集,前面麻烦了,这步就简单些


select * from x1 intersect x2

分步后思路变清晰了,但临时表的使用仍然繁琐。在批量结构化数据计算中,作为中间结果的临时集合是相当普遍的,如果都建立临时表来存储,运算效率低,代码也不直观。


而且,SQL不允许某个字段取值是集合(即临时表),这样,有些计算即使容忍了繁琐也做不到。


如果我们把问题改为计算所有产品销售额都在前10名的销售员,试想一下应当如何计算,延用上述的思路很容易想到:


1. 将数据按产品分组,将每组排序,取出前10名;


2. 将所有的前10名取交集;


由于我们事先不知道会有多个产品,这样需要把分组结果也存储在一个临时表中,而这个表有个字段要存储对应的分组成员,这是SQL不支持的,办法就行不通了。


如果有窗口函数的支持,可以转换思路,按产品分组后,计算每个销售员在所有分组的前10名中出现的次数,若与产品总数相同,则表示该销售员在所有产品销售额中均在前10名内。


select sales
from ( select sales,
from ( select sales,
rank() over (partition by product order by amount desc ) ranking
from sales_amount)
where ranking <=10 )
group by sales
having count(*)=(select count(distinct product) from sales_amount)

这样的SQL,有多少人会写呢?


况且,窗口函数在有些数据库中还不支持。那么,就只能用存储过程写循环依次计算每个产品的前10名,与上一次结果做交集。这个过程比用高级语言编写程序并不简单多少,而且仍然要面对临时表的繁琐。


现在,我们知道了SQL的第二个重要缺点:集合化不彻底。虽然SQL有集合概念,但并未把集合作为一种基础数据类型提供,这使得大量集合运算在思维和书写时都需要绕路。


我们在上面的计算中使用了关键字top,事实上关系代数理论中没有这个东西(它可以被别的计算组合出来),这不是SQL的标准写法。


我们来看一下没有top时找前10名会有多困难?


大体思路是这样:找出比自己大的成员个数作为是名次,然后取出名次不超过10的成员,写出的SQL如下:


select sales
from ( select A.sales sales, A.product product,
(select count(*)+1 from sales_amount
where A.product=product AND A.amount<=amount) ranking
from sales_amount A )
where product='AC' AND ranking<=10


select sales
from ( select A.sales sales, A.product product, count(*)+1 ranking
from sales_amount A, sales_amount B
where A.sales=B.sales and A.product=B.product AND A.amount<=B.amount
group by A.sales,A.product )
where product='AC' AND ranking<=10

这样的SQL语句,专业程序员写出来也未必容易吧!而仅仅是计算了一个前10名。


退一步讲,即使有top,那也只是使取出前一部分轻松了。如果我们把问题改成取第6至10名,或者找比下一名销售额超过10%的销售员,困难仍然存在。


造成这个现象的原因就是SQL的第三个重要缺点:缺乏有序支持。SQL继承了数学上的无序集合,这直接导致与次序有关的计算相当困难,而可想而知,与次序有关的计算会有多么普遍(诸如比上月、比去年同期、前20%、排名等)。


SQL2003标准中增加的窗口函数提供了一些与次序有关的计算能力,这使得上述某些问题可以有较简单的解法,在一定程度上缓解SQL的这个问题。但窗口函数的使用经常伴随着子查询,而不能让用户直接使用次序访问集合成员,还是会有许多有序运算难以解决。


我们现在想关注一下上面计算出来的“好”销售员的性别比例,即男女各有多少。一般情况下,销售员的性别信息会记在花名册上而不是业绩表上,简化如下:


employee员工表
name员工姓名,假定无重名
gender员工性别

我们已经计算出“好”销售员的名单,比较自然的想法,是用名单到花名册时找出其性别,再计一下数。但在SQL中要跨表获得信息需要用表间连接,这样,接着最初的结果,SQL就会写成:


select employee.gender,count(*)
from employee,
( ( select top 10 sales from sales_amount where product='AC' order by amount desc )
intersect
( select top 10 sales from sales_amount where product='TV' order by amount desc ) ) A
where A.sales=employee.name
group by employee.gender

仅仅多了一个关联表就会导致如此繁琐,而现实中信息跨表存储的情况相当多,且经常有多层。比如销售员有所在部门,部门有经理,现在我们想知道“好”销售员归哪些经理管,那就要有三个表连接了,想把这个计算中的where和group写清楚实在不是个轻松的活儿了。


这就是我们要说的SQL的第四个重要困难:缺乏对象引用机制,关系代数中对象之间的关系完全靠相同的外键值来维持,这不仅在寻找时效率很低,而且无法将外键指向的记录成员直接当作本记录的属性对待,试想,上面的句子可否被写成这样:


select sales.gender,count(*)
from (…) // …是前面计算“好”销售员的SQL
group by sales.gender

显然,这个句子不仅更清晰,同时计算效率也会更高(没有连接计算)。


我们通过一个简单的例子分析了SQL的四个重要困难,这也是SQL难写或要写得很长的主要原因。基于一种计算体系解决业务问题的过程,也就是将业务问题的解法翻译成形式化计算语法的过程(类似小学生解应用题,将题目翻译成形式化的四则运算)。SQL的上述困难会造成问题解法翻译的极大障碍,极端情况就会发生这样一种怪现象:将问题解法形式化成计算语法的难度要远远大于解决问题本身


再打个程序员易于理解的比方,用SQL做数据计算,类似于用汇编语言完成四则运算。我们很容易写出3+5*7这样的算式,但如果用汇编语言(以X86为例),就要写成


    mov ax,3
mov bx,5
mul bx,7
add ax,bx

这样的代码无论书写还是阅读都远不如3+5*7了(要是碰到小数就更要命了)。虽然对于熟练的程序员也算不了太大的麻烦,但对于大多数人而言,这种写法还是过于晦涩难懂了,从这个意义上讲,FORTRAN确实是个伟大的发明。


为了理解方便,我们举的例子还是非常简单的任务。现实中的任务要远远比这些例子复杂,过程中会面临诸多大大小小的困难。这个问题多写几行,那个问题多写几行,一个稍复杂的任务写出几百行多层嵌套的SQL也就不奇怪了。而且这个几百行常常是一个语句,由于工程上的原因,SQL又很难调试,这又进一步加剧了复杂查询分析的难度。


更多例子


我们再举几个例子来分别说明这几个方面的问题。


为了让例子中的SQL尽量简捷,这里大量使用了窗口函数,故而采用了对窗口函数支持较好的ORACLE数据库语法,采用其它数据库的语法编写这些SQL一般将会更复杂。
这些问题本身应该也算不上很复杂,都是在日常数据分析中经常会出现的,但已经很难为SQL了。


计算不分步


把复杂的计算分步可以在很大程度地降低问题的难度,反过来,把多步计算汇成一步完成则会提高问题的复杂度。



任务1 销售部的人数,其中北京籍人数,再其中女员工人数?



销售部的人数


select count(*) from employee where department='sales'

其中北京籍的人数


select count(*) from employee where department='sales' and native_place='Beijing'

再其中的女员工人数


select count (*) from employee
where department='sales' and native_place='Beijing' and gender='female'

常规想法:选出销售部人员计数,再在其中找出其中北京籍人员计数,然后再递进地找出女员工计数。每次查询都基于上次已有的结果,不仅书写简单而且效率更高。


但是,SQL的计算不分步,回答下一个问题时无法引用前面的成果,只能把相应的查询条件再抄一遍。



任务2 每个部门挑选一对男女员工组成游戏小组



with A as
(select name, department,
row_number() over (partition by department order by 1) seq
from employee where gender=‘male’)
B as
(select name, department,
row_number() over(partition by department order by 1) seq
from employee where gender=‘female’)
select name, department from A
where department in ( select distinct department from B ) and seq=1
union all
select name, department from B
where department in (select distinct department from A ) and seq=1

计算不分步有时不仅造成书写麻烦和计算低效,甚至可能导致思路严重变形。


这个任务的直观想法:针对每个部门循环,如果该部门有男女员工则各取一名添进结果集中。但SQL不支持这种逐步完成结果集的写法(要用存储过程才能实现此方案),这时必须转变思路为:从每个部门中选出男员工,从每个部门选出女员工,对两个结果集分别选出部门出现在另一个结果集的成员,最后再做并集。


好在还有with子句和窗口函数,否则这个SQL语句简直无法看了。


集合无序


有序计算在批量数据计算中非常普遍(取前3名/第3名、比上期等),但SQL延用了数学上的无序集合概念,有序计算无法直接进行,只能调整思路变换方法。



任务3 公司中年龄居中的员工



select name, birthday
from (select name, birthday, row_number() over (order by birthday) ranking
from employee )
where ranking=(select floor((count(*)+1)/2) from employee)

中位数是个常见的计算,本来只要很简单地在排序后的集合中取出位置居中的成员。但SQL的无序集合机制不提供直接用位置访问成员的机制,必须人为造出一个序号字段,再用条件查询方法将其选出,导致必须采用子查询才能完成。



任务4 某支股票最长连续涨了多少交易日



select max (consecutive_day)
from (select count(*) (consecutive_day
from (select sum(rise_mark) over(order by trade_date) days_no_gain
from (select trade_date,
case when
closing_price>lag(closing_price) over(order by trade_date)
then 0 else 1 END rise_mark
from stock_price) )
group by days_no_gain)

无序的集合也会导致思路变形。


常规的计算连涨日数思路:设定一初始为0的临时变量记录连涨日期,然后和上一日比较,如果未涨则将其清0,涨了再加1,循环结束看该值出现的最大值。


使用SQL时无法描述此过程,需要转换思路,计算从初始日期到当日的累计不涨日数,不涨日数相同者即是连续上涨的交易日,针对其分组即可拆出连续上涨的区间,再求其最大计数。这句SQL读懂已经不易,写出来则更困难了。


集合化不彻底


毫无疑问,集合是批量数据计算的基础。SQL虽然有集合概念,但只限于描述简单的结果集,没有将集合作为一种基本的数据类型以扩大其应用范围。



任务5 公司中与其他人生日相同的员工



select * from employee
where to_char (birthday, ‘MMDD’) in
( select to_char(birthday, 'MMDD') from employee
group by to_char(birthday, 'MMDD')
having count(*)>1 )

分组的本意是将源集合分拆成的多个子集合,其返回值也应当是这些子集。但SQL无法表示这种“由集合构成的集合”,因而强迫进行下一步针对这些子集的汇总计算而形成常规的结果集。


但有时我们想得到的并非针对子集的汇总值而是子集本身。这时就必须从源集合中使用分组得到的条件再次查询,子查询又不可避免地出现。



任务6 找出各科成绩都在前10名的学生



select name
from (select name
from (select name,
rank() over(partition by subject order by score DESC) ranking
from score_table)
where ranking<=10)
group by name
having count(*)=(select count(distinct subject) from score_table)

用集合化的思路,针对科目分组后的子集进行排序和过滤选出各个科目的前10名,然后再将这些子集做交集即可完成任务。但SQL无法表达“集合的集合”,也没有针对不定数量集合的交运算,这时需要改变思路,利用窗口函数找出各科目前10名后再按学生分组找出出现次数等于科目数量的学生,造成理解困难。


缺乏对象引用


在SQL中,数据表之间的引用关系依靠同值外键来维系,无法将外键指向的记录直接用作本记录的属性,在查询时需要借助多表连接或子查询才能完成,不仅书写繁琐而且运算效率低下。



任务7 女经理的男员工们



用多表连接


select A.*
from employee A, department B, employee C
where A.department=B.department and B.manager=C.name and
A.gender='male' and C.gender='female'

用子查询


select * from employee
where gender='male' and department in
(select department from department
where manager in
(select name from employee where gender='female'
))

如果员工表中的部门字段是指向部门表中的记录,而部门表中的经理字段是指向员工表的记录,那么这个查询条件只要简单地写成这种直观高效的形式:


where gender='male' and department.manager.gender='female'

但在SQL中则只能使用多表连接或子查询,写出上面那两种明显晦涩的语句。



任务8 员工的首份工作公司



用多表连接


select name, company, first_company
from (select employee.name name, resume.company company,
row_number() over(partition by resume. name
order by resume.start_date) work_seq
from employee, resume where employee.name = resume.name)
where work_seq=1

用子查询


select name,
(select company from resume
where name=A.name and
start date=(select min(start_date) from resume
where name=A.name)) first_company
from employee A

没有对象引用机制和彻底集合化的SQL,也不能将子表作主表的属性(字段值)处理。针对子表的查询要么使用多表连接,增加语句的复杂度,还要将结果集用过滤或分组转成与主表记录一一对应的情况(连接后的记录与子表一一对应);要么采用子查询,每次临时计算出与主表记录相关的子表记录子集,增加整体计算量(子查询不能用with子句了)和书写繁琐度。


SPL的引入


问题说完,该说解决方案了。


其实在分析问题时也就一定程度地指明了解决方案,重新设计计算语言,克服掉SQL的这几个难点,问题也就解决了。


这就是发明SPL的初衷!


SPL是个开源的程序语言,其全名是Structured Process Language,和SQL只差一个词。目的在于更好的解决结构化数据的运算。SPL中强调了步骤化、支持有序集合和对象引用机制、从而得到彻底的集合化,这些都会大幅降低前面说的“解法翻译”难度。


这里的篇幅不合适详细介绍SPL了,我们只把上一节中的8个例子的SPL代码罗列出来感受一下:



任务1



AB
1=employee.select(department=="sales")=A1.len()
2=A1.select(native_place=="Beijing")=A2.len()
3=A2.select(gender=="female")=A3.len()

SPL可以保持记录集合用作中间变量,可逐步执行递进查询。



任务2



ABC
1for employee.group(department)=A1.group@1(gender)
2>if B1.len()>1=@|B1

有步骤和程序逻辑支持的SPL能很自然地逐步完成结果。



任务3



A
1=employee.sort(birthday)
2=A1((A1.len()+1)/2)

对于以有序集合为基础的SPL来说,按位置取值是个很简单的任务。



任务4



A
1=stock_price.sort(trade_date)
2=0
3=A1.max(A2=if(close_price>close_price[-1],A2+1,0))

SPL按自然的思路过程编写计算代码即可。



任务5



A
1=employee.group(month(birthday),day(birthday))
2=A1.select(~.len()>1).conj()

SPL可以保存分组结果集,继续处理就和常规集合一样。



任务6



A
1=score_table.group(subject)
2=A1.(~.rank(score).pselect@a(~<=10))
3=A1.(~(A2(#)).(name)).isect()

使用SPL只要按思路过程写出计算代码即可。



任务7



A
1=employee.select(gender=="male" && department.manager.gender=="female")

支持对象引用的SPL可以简单地将外键指向记录的字段当作自己的属性访问。



任务8



A
1=employee.new(name,resume.minp(start_date).company:first_company)

SPL支持将子表集合作为主表字段,就如同访问其它字段一样,子表无需重复计算。


SPL有直观的IDE,提供了方便的调试功能,可以单步跟踪代码,进一步降低代码的编写复杂度。


imagepng


对于应用程序中的计算,SPL提供了标准的JDBC驱动,可以像SQL一样集成到Java应用程序中:



Class.forName("com.esproc.jdbc.InternalDriver");
Connection conn =DriverManager.getConnection("jdbc:esproc:local://");
Statement st = connection.();
CallableStatement st = conn.prepareCall("{call xxxx(?,?)}");
st.setObject(1, 3000);
st.setObject(2, 5000);
ResultSet result=st.execute();
...


SPL资料



作者:苏三说技术
来源:juejin.cn/post/7189609501559881784
收起阅读 »