暂时未有相关云产品技术能力~
在日常业务代码开发中,我们经常接触到AOP(面向切面编程),比如熟知的Spring AOP。我们经常用它来实现业务切面逻辑,比如登录校验,日志记录,性能监控,全局过滤器等。但Spring AOP有一个局限性,并不是所有的类都托管在 Spring 容器中,例如很多中间件代码、三方包代码和部分原生代码,都不能被Spring AOP代理到。如此一来,一旦你想实现的切面逻辑并不属于Spring的管辖范围,或者你想实现Spring之外的切面功能,就无从下手。对于Java后端应用,有没有一种更为通用的AOP方式?答案是有的,Java自身提供了JVM TI,Instrumentation等特性和接口,允许使用者以通过一系列原生API完成对JVM的复杂控制。自此衍生出了很多著名的框架,比如Btrace,Arthas等等,帮助开发者们实现更多更复杂的Java功能。JVM Sandbox也是其中的一员。当然,不同框架的设计目的和使命是不一样的,JVM-Sandbox的设计目的是实现一种在不重启、不侵入目标JVM应用情况下的AOP解决方案。举几个典型的JVM-Sandbox应用场景:流量回放:如何录制线上应用每次接口请求的入参和出参?改动应用代码固然可以,但成本太大,通过JVM-Sandbox,可以直接在不修改代码的情况下,直接抓取接口的出入参。安全漏洞热修复:假设某个三方包(例如出名的fastjson)又出现了漏洞,集团内那么多应用,一个个发布新版本修复,漏洞已经造成了大量破坏。通过JVM-Sandbox,直接修改替换有漏洞的代码,及时止损。接口故障模拟:想要模拟某个接口超时5s后返回false的情况,JVM-Sandbox很轻松就能实现。故障定位:像Arthas类似的功能。接口限流:动态对指定的接口做限流。日志打印...可以看到,借助JVM-Sandbox,你可以实现很多之前在传统切面实现中做不了的事,大大拓展了切面可操作的范围。本文围绕JVM SandBox展开,主要介绍如下内容:JVM SandBox的诞生背景JVM SandBox的架构设计代码实战:Spring Bean初始化耗时统计JVM SandBox的底层技术总结JVM Sandbox诞生背景JVM Sandbox诞生的技术背景在引言中已经赘述完毕,下面是作者开发该框架的一些业务背景,以下描述引用自文章[1]:JVM SandBox 是阿里开源的一款 JVM 平台非侵入式运行期 AOP 解决方案,本质上是一种 AOP 落地形式。那么可能有同学会问:已有成熟的 Spring AOP 解决方案,阿里巴巴为什么还要“重复造轮子”?这个问题要回到 JVM SandBox 诞生的背景中来回答。在 2016 年中,天猫双十一催动了阿里巴巴内部大量业务系统的改动,恰逢徐冬晨(阿里巴巴测试开发专家)所在的团队调整,测试资源保障严重不足,迫使他们必须考虑更精准、更便捷的老业务测试回归验证方案。开发团队面临的是新接手的老系统,老的业务代码架构难以满足可测性的要求,很多现有测试框架也无法应用到老的业务系统架构中,于是需要新的测试思路和测试框架。JVM Sandbox整体架构本章节不详细赘述JVM SandBox的架构设计,只讲其中几个重要的特性,详细的架构设计可以看开源代码的Wiki[2]。类隔离很多框架通过破坏双亲委派(我更愿意称之为直系亲属委派)来实现类隔离,JVM SandBox也不例外。它通过自定义的SandboxClassLoader破坏了双亲委派的约定,实现了几个隔离特性:和目标应用的类隔离:不用担心加载沙箱会引起原应用的类污染、冲突。模块之间类隔离:做到模块与模块之间、模块和沙箱之间、模块和应用之间互不干扰。图1 JVM Sandbox架构(引用自官方文档)无侵入AOP与事件驱动JVM-SANDBOX属于基于Instrumentation的动态编织类的AOP框架,通过精心构造了字节码增强逻辑,使得沙箱的模块能在不违反JDK约束情况下实现对目标应用方法的无侵入运行时AOP拦截。图2 JVM Sandbox编织代码示例(引用自官方文档)从上图中,可以看到一个方法的整个执行周期都被代码“加强”了,能够带来的好处就是你在使用JVM SandBox只需要对于方法的事件进行处理。主要是以下三种事件:// BEFORE try { /* * do something... */ // RETURN return; } catch (Throwable cause) { // THROWS }在沙箱的世界观中,任何一个Java方法的调用都可以分解为BEFORE、RETURN和THROWS三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。基于BEFORE、RETURN和THROWS三个环节事件分离,沙箱的模块可以完成很多类AOP的操作。可以感知和改变方法调用的入参可以感知和改变方法调用返回值和抛出的异常可以改变方法执行的流程 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回 (引用自官方文档)一切都是事件驱动的,这一点你可能很迷糊,但是别担心,请继续往下阅读,在下文的实战环节中,可以帮助你理解事件驱动的含义。代码实战示例代码版本:JVM-Sandbox 1.2.0在平常的业务开发中,我们会遇到很多启动时间非常久的应用,通常都是因为业务代码堆积,缺乏维护导致。通常我们会视而不见,忍一忍海阔天空。但是当积累到一定的程度,每次部署应用就成了开发者们的负担,常常需要等待几分钟甚至十几分钟。对于Spring应用来说,大部分启动时间都消耗在了Spring Bean的初始化上,所以我们来实现一个小工具,统计Spring Bean初始化的耗时,最后形成数据表,供开发者优化分析。在JVM SandBox中如何实现上面的工具?其实非常简单。先贴上思路的整体流程:图3 工具流程图首先新建Maven工程,在Maven依赖中引用JVM SandBox,官方推荐独立工程使用parent方式。<parent> <groupId>com.alibaba.jvm.sandbox</groupId> <artifactId>sandbox-module-starter</artifactId> <version>1.2.0</version> </parent>新建一个类作为一个JVM SandBox模块,如下图:图4 主入口代码实现使用@Infomation声明mode为AGENT模式,一共有两种模式Agent和Attach。Agent:随着JVM启动一起启动Attach:在已经运行的JVM进程中,动态的插入我们由于是监控JVM启动数据,所以需要AGENT模式。其次,继承com.alibaba.jvm.sandbox.api.Module和com.alibaba.jvm.sandbox.api.ModuleLifecycle。其中ModuleLifecycle包含了整个模块的生命周期回调函数。onLoad:模块加载,模块开始加载之前调用!模块加载是模块生命周期的开始,在模块生命中期中有且只会调用一次。 这里抛出异常将会是阻止模块被加载的唯一方式,如果模块判定加载失败,将会释放掉所有预申请的资源,模块也不会被沙箱所感知。onUnload:模块卸载,模块开始卸载之前调用!模块卸载是模块生命周期的结束,在模块生命中期中有且只会调用一次。 这里抛出异常将会是阻止模块被卸载的唯一方式,如果模块判定卸载失败,将不会造成任何资源的提前关闭与释放,模块将能继续正常工作。onActive:模块被激活后,模块所增强的类将会被激活,所有com.alibaba.jvm.sandbox.api.listener.EventListener将开始收到对应的事件。onFrozen:模块被冻结后,模块所持有的所有com.alibaba.jvm.sandbox.api.listener.EventListener将被静默,无法收到对应的事件。 需要注意的是,模块冻结后虽然不再收到相关事件,但沙箱给对应类织入的增强代码仍然还在。loadCompleted:模块加载完成,模块完成加载后调用!模块完成加载是在模块完成所有资源加载、分配之后的回调,在模块生命中期中有且只会调用一次。 这里抛出异常不会影响模块被加载成功的结果。模块加载完成之后,所有的基于模块的操作都可以在这个回调中进行。最常用的是loadCompleted,所以我们重写loadCompleted类,在里面开启我们的监控类SpringBeanStartMonitor线程。而SpringBeanStartMonitor的核心代码如下图:图5 过滤器代码实现使用Sandbox的doClassFilter过滤出匹配的类,这里我们是BeanFactory,接着使用doMethodFilter过滤出要监听的方法,这里是initializeBean。我们取initializeBean作为统计耗时的切入方法。为什么选择该方法,涉及到SpringBean的启动生命周期,不在本文赘述范围内。图6 initializeBean入口源码注释接着,我们使用JVM Sandbox提供的方法moduleEventWatcher.watch(springBeanFilter, springBeanInitListener, Event.Type.BEFORE, Event.Type.RETURN);将我们的springBeanInitListener监听器绑定到被观测的方法上,这样每次initializeBean被调用,都会走到我们的切面逻辑。监听器的主要逻辑如下:图7 监听逻辑代码实现代码有点长,不必细看,主要就是在原方法的BeforeEvent(进入前)和ReturnEvent(执行正常返回后)执行上述的切面逻辑,我这里便是使用了一个HashMap存储每个Bean的初始化开始和结束时间,最终统计出初始化耗时。最终,我们还需要一个方法来知道我们的原始Spring应用已经启动完毕,这样我们可以手动卸载我们的Sandbox模块,毕竟他已经完成了他的历史使命,不需要再依附在主进程上。我们通过一个简陋的办法,检查应用的HTTP端口是否会返回小于500的状态码,来判断Spring容器是否已经正确启动并对外提供服务。当然如果你的Spring没有使用Web框架,就不能用这个方法来判断启动完成,你也许可以通过Spring自己的生命周期钩子函数来实现,这里图方便,省略了更复杂的实现。整个SpringBean监听模块的开发就完成了,你可以感受到,你的开发和日常业务开发几乎没有区别,这就是JVM Sandbox封装带来的便利。下面是实际运行的效果:图7 工具生成的SpringBean耗时报表PS:该工具经过拓展,已经在集团内部已经孵化为 应用启动速度治理平台 ,采集了包括SpringBean、HSF、CPU堆栈等Java后端应用启动数据,帮助集团内部应用优化部署时间,大幅度提升应用优化效率。JVM Sandbox底层技术整个JVM Sandbox的入门使用基本上讲完了,上文提到了一些JVM技术名词,可能小伙伴们听过但不是特别了解。这里简单阐述几个重要的概念,理清楚这几个概念之间的关系,以便大家更好的理解JVM Sandbox底层的实现。JVMTIJVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 native 编程接口,JVMTI可以用来开发并监控虚拟机,可以查看JVM内部的状态,并控制JVM应用程序的执行。可实现的功能包括但不限于:调试、监控、线程分析、覆盖率分析工具等。很多Java监控、诊断工具都是基于这种形式来工作的。如果arthas、jinfo、brace等,虽然这些工具底层是JVM TI,但是它们还使用到了上层工具JavaAgent。JavaAgent和InstrumentationJavaagent是java命令的一个参数。参数 -javaagent 可以用于指定一个 jar 包。-agentlib:<libname>[=<选项>] # 加载本机代理库 <libname>, 例如 -agentlib:hprof,另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help-agentpath:<pathname>[=<选项>] # 按完整路径名加载本机代理库-javaagent:<jarpath>[=<选项>] # 加载 Java 编程语言代理, 请参阅 java.lang.instrument在上面的-javaagent参数中提到了参阅java.lang.instrument,这是在rt.jar 中定义的一个包,该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Javaagent。从名字上看,似乎是个 Java 代理之类的,而实际上,他的功能更像是一个Class 类型的转换器,他可以在运行时接受重新外部请求,对Class类型进行修改。Instrumentation的底层实现依赖于JVMTI。JVM 会优先加载 带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。Instrumentation支持的接口:public interface Instrumentation { //添加一个ClassFileTransformer //之后类加载时都会经过这个ClassFileTransformer转换 void addTransformer(ClassFileTransformer transformer, boolean canRetransform); void addTransformer(ClassFileTransformer transformer); //移除ClassFileTransformer boolean removeTransformer(ClassFileTransformer transformer); boolean isRetransformClassesSupported(); //将一些已经加载过的类重新拿出来经过注册好的ClassFileTransformer转换 //retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性 void retransformClasses(Class<?>... classes) throws UnmodifiableClassException; boolean isRedefineClassesSupported(); //重新定义某个类 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException; boolean isModifiableClass(Class<?> theClass); @SuppressWarnings("rawtypes") Class[] getAllLoadedClasses(); @SuppressWarnings("rawtypes") Class[] getInitiatedClasses(ClassLoader loader); long getObjectSize(Object objectToSize); void appendToBootstrapClassLoaderSearch(JarFile jarfile); void appendToSystemClassLoaderSearch(JarFile jarfile); boolean isNativeMethodPrefixSupported(); void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix); }Instrumentation的局限性:不能通过字节码文件和自定义的类名重新定义一个本来不存在的类增强类和老类必须遵循很多限制:比如新类和老类的父类必须相同;新类和老类实现的接口数也要相同,并且是相同的接口;新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;新类和老类新增或删除的方法必须是private static/final修饰的;再谈Attach和Agent上面的实战章节中已经提到了attach和agent两者的区别,这里再展开聊聊。在Instrumentation中,Agent模式是通过-javaagent:<jarpath>[=<选项>]从应用启动时候就插桩,随着应用一起启动。它要求指定的类中必须要有premain()方法,并且对premain方法的签名也有要求,签名必须满足以下两种格式:public static void premain(String agentArgs, Instrumentation inst) public static void premain(String agentArgs)一个java程序中-javaagent参数的个数是没有限制的,所以可以添加任意多个javaagent。所有的java agent会按照你定义的顺序执行,例如:java -javaagent:agent1.jar -javaagent:agent2.jar -jar MyProgram.jar上面介绍Agent模式的Instrumentation是在 JDK 1.5中提供的,在1.6中,提供了attach方式的Instrumentation,你需要的是agentmain方法,并且签名如下:public static void agentmain (String agentArgs, Instrumentation inst) public static void agentmain (String agentArgs)这两种方式各有不同用途,一般来说,Attach方式适合于动态的对代码进行功能修改,在排查问题的时候用的比较多。而Agent模式随着应用启动,所以经常用于提前实现一些增强功能,比如我上面实战中的启动观测,应用防火墙,限流策略等等。总结本文花了较短的篇幅重点介绍了JVM Sandbox的功能,实际用法,以及基础原理。它通过封装一些底层JVM控制的框架,使得对JVM层面的AOP开发变的异常简单,就像作者自己所说“JVM-SANDBOX还能帮助你做很多很多,取决于你的脑洞有多大了。”参考[1] JVM SandBox 的技术原理与应用分析 https://www.infoq.cn/article/tsy4lgjvsfweuxebw*gp[2] Github jvm-sandbox https://github.com/alibaba/jvm-sandbox/wiki[3] javaagent使用指南 https://www.cnblogs.com/rickiyang/p/11368932.html[4] Java JVMTI和Instrumention机制介绍 https://www.jianshu.com/p/eff047d4480a
前言接触到CDN的起因:网页打开的速度一直比较慢,经查证是我的网站有很多静态js大文件,通过浏览器读取这些js比较耗时间。最近有了一些空余精力, 好好优化一下加载速度。分析思路公用CDN加速公用js库其实首先想到的是把公共的js库使用公共CDN来加速,比如我的前端用到了echarts,js-cookie等。这些js最开始是放在我自己的服务器上的,我们可以让浏览器直接去读取公共CDN里存放的这些库。这里使用的是bootcss网站提供的公共CDN,在将这些公共库指向bootcss后,这些js文件加载速度到了100ms以内:https://cdn.bootcss.com/axios/0.18.0/axios.min.js https://cdn.bootcss.com/moment.js/2.22.2/moment.min.js接下来,我们还剩下一些自己的js文件,这些是前端打包后的js,不能用公用CDN来加速。这里举个例子,下图中的js达到了1.2m,每次在没有缓存的情况下加载这个Js,浏览器都需要5s以上的加载时间,新用户点击我的网站,都需要盯着空白页这么久,十分劝退,很多用户没等到网页渲染完毕就关了。如下图,极端情况下,会等到15s以上。这是完全不能接受的。Nginx启用Gzip接下来我想到的是将js文件大小压缩,毕竟主要是由于文件过大,才导致的传输缓慢。nginx作为我的反向代理,负责了我服务器对外的服务,我们可以启用nginx的gzip功能,对静态文件进行压缩,包括图片,js,css等。gzip on; gzip_min_length 1k; gzip_buffers 4 16k; #gzip_http_version 1.0; gzip_comp_level 2; gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png; gzip_vary off; gzip_disable "MSIE [1-6]\.";压缩后,js文件大小减少了很多,这个1.2m的文件,在浏览器端只需要加载500k的压缩js。然而,我使用的是1m带宽的云服务器,对于500k大小的js,有时候还是需要3-5s时间来加载,我们还需要想办法继续优化。对象存储OSS经过一阵查找,我找到了七牛云,七牛云免费提供10G的OSS存储空间,我的想法是将这个js文件上传上去,拿到文件的链接后,写在前端html中,从OSS读取该JS文件,从而达到加速的效果。然而,由于我全站开启了HTTPs,七牛云的OSS免费额度并不针对HTTPS请求,让我非常头疼,我的宗旨就是不花钱“白嫖”资源(滑稽)。于是,我找上了自己服务器所在的阿里云。阿里全站CDN加速阿里云的CDN介绍:将源站内容分发至最接近用户的节点,使用户可就近取得所需内容,提高用户访问的响应速度和成功率。解决因分布、带宽、服务器性能带来的访问延迟问题,适用于站点加速、点播、直播等场景。我使用流量计费方式,购买了100G的流量包:之后可以看到自己的流量包:在CDN控制台,添加上自己的域名,写上IP,使得加速的域名能够访问你的服务器。登记好域名后,阿里云提示我们去域名解析的地方添加一个CNAME解析:阿里云提供了CNAME指向的域名:我们将我们的A记录(指向服务器的记录)关闭,添加CNAME记录,指向阿里的CDN节点域名:然后访问我们的网站,结果如图,我又意识到,我没搞HTTPS,所有的请求由于强制走https,所以无法访问了。所以我们需要在阿里云的控制台对CDN添加https支持,也就需要导入你网站的https证书:由于我服务器就在阿里云,并且证书也是阿里云开的一年免费https,所以可以一键导入进来。导入完成后,再次访问网站,在第一次较慢的加载后,重新加载,这次只用了892ms,就加载完毕了该js文件:至此,新用户访问网站几乎可以在2s内显示出全部内容。PS:用了CDN后,会在阿里云的边缘节点缓存你的静态文件,所以读取的js文件大小是未压缩前的大小。总结以上就是我使用的一些优化方法,达到加速网站静态文件加载的目的。经过这些优化后,网站的加载速度从极端情况下的15s,变成了2s内。
RPC定义远程服务调用(Remote procedure call)的概念历史已久,1981年就已经被提出,最初的目的就是为了调用远程方法像调用本地方法一样简单,经历了四十多年的更新与迭代,RPC 的大体思路已经趋于稳定,如今百家争鸣的 RPC 协议和框架,诸如 Dubbo (阿里)、Thrift(FaceBook)、gRpc(Google)、brpc (百度)等都在不同侧重点去解决最初的目的,有的想极致完美,有的追求极致性能,有的偏向极致简单。RPC基本原理让我们回到 RPC 最初的目的,要想实现调用远程方法想调用本地方法一样简单,至少要解决如下问题:如何获取可用的远程服务器如何表示数据如何传递数据服务端如何确定并调用目标方法上述四点问题,都能与现在分布式系统火热的术语一一对应,如何获取可用的远程服务器(服务注册与发现)、如何表示数据(序列化与反序列化)、如何传递数据(网络通讯)、服务端如何确定并调用目标方法(调用方法映射)。笔者将通过一个简单 RPC 项目来解决这些问题。首先来看 RPC 的整体系统架构图:图中服务端启动时将自己的服务节点信息注册到注册中心,客户端调用远程方法时会订阅注册中心中的可用服务节点信息,拿到可用服务节点之后远程调用方法,当注册中心中的可用服务节点发生变化时会通知客户端,避免客户端继续调用已经失效的节点。那客户端是如何调用远程方法的呢,来看一下远程调用示意图:客户端模块代理所有远程方法的调用将目标服务、目标方法、调用目标方法的参数等必要信息序列化序列化之后的数据包进一步压缩,压缩后的数据包通过网络通信传输到目标服务节点服务节点将接受到的数据包进行解压解压后的数据包反序列化成目标服务、目标方法、目标方法的调用参数通过服务端代理调用目标方法获取结果,结果同样需要序列化、压缩然后回传给客户端通过以上描述,相信读者应该大体上了解了 RPC 是如何工作的,接下来看如何使用代码具体实现上述的流程。鉴于篇幅笔者会选择重要或者网络上介绍相对较少的模块来讲述。RPC实现细节1. 服务注册与发现作为一个入门项目,我们的系统选用 Zookeeper 作为注册中心, ZooKeeper 将数据保存在内存中,性能很高。在读多写少的场景中尤其适用,因为写操作会导致所有的服务器间同步状态。服务注册与发现是典型的读多写少的协调服务场景。Zookeeper 是一个典型的CP系统,在服务选举或者集群半数机器宕机时是不可用状态,相对于服务发现中主流的AP系统来说,可用性稍低,但是用于理解RPC的实现,也是绰绰有余。ZooKeeper节点介绍持久节点( PERSISENT ):一旦创建,除非主动调用删除操作,否则一直持久化存储。临时节点( EPHEMERAL ):与客户端会话绑定,客户端会话失效,这个客户端所创建的所有临时节点都会被删除除。节点顺序( SEQUENTIAL ):创建子节点时,如果设置SEQUENTIAL属性,则会自动在节点名后追加一个整形数字,上限是整形的最大值;同一目录下共享顺序,例如(/a0000000001,/b0000000002,/c0000000003,/test0000000004)。ZooKeeper服务注册在 ZooKeeper 根节点下根据服务名创建持久节点 /rpc/{serviceName}/service ,将该服务的所有服务节点使用临时节点创建在 /rpc/{serviceName}/service 目录下,代码如下(为方便展示,后续展示代码都做了删减):public void exportService(Service serviceResource) { String name = serviceResource.getName(); String uri = GSON.toJson(serviceResource); String servicePath = "rpc/" + name + "/service"; zkClient.createPersistent(servicePath, true); String uriPath = servicePath + "/" + uri; //创建一个新的临时节点,当该节点宕机会话失效时,该临时节点会被清理 zkClient.createEphemeral(uriPath); }注册效果如图,本地启动两个服务则 service 下有两个服务节点信息:存储的节点信息包括服务名,服务 IP:PORT ,序列化协议,压缩协议等。ZooKeeper服务发现客户端启动后,不会立即从注册中心获取可用服务节点,而是在调用远程方法时获取节点信息(懒加载),并放入本地缓存 MAP 中,供后续调用,当注册中心通知目录变化时清空服务所有节点缓存,代码如下:public List<Service> getServices(String name) { Map<String, List<Service>> SERVER_MAP = new ConcurrentHashMap<>(); String servicePath = "rpc/" + name + "/service"; List<String> children = zkClient.getChildren(servicePath); List<Service> serviceList = Optional.ofNullable(children).orElse(new ArrayList<>()).stream().map(str -> { String deCh = URLDecoder.decode(str, StandardCharsets.UTF_8.toString()); return gson.fromJson(deCh, Service.class); }).collect(Collectors.toList()); SERVER_MAP.put(name, serviceList); return serviceList; }public class ZkChildListenerImpl implements IZkChildListener { //监听子节点的删除和新增事件 @Override public void handleChildChange(String parentPath, List<String> childList) throws Exception { //有变动就清空服务所有节点缓存 String[] arr = parentPath.split("/"); SERVER_MAP.remove(arr[2]); } }PS:美团分布式 ID 生成系统Leaf就使用 Zookeeper 的顺序节点来注册 WorkerID ,临时节点保存节点 IP:PORT 信息。2. 客户端实现客户端调用本地方法一样调用远程方法的完美体验与 Java 动态代理的强大密不可分。DefaultRpcBaseProcessor 抽象类实现了 ApplicationListener , onApplicationEvent 方法在 Spring 项目启动完毕会收到时间通知,获取 ApplicationContext 上下文之后开始注入服务 injectService (依赖其他服务)或者启动服务 startServer (自身服务实现)。injectService 方法会遍历 ApplicationContext 上下文中的所有 Bean , Bean 中是否有属性使用了 InjectService 注解。有的话生成代理类,注入到 Bean 的属性中。代码如下:public abstract class DefaultRpcBaseProcessor implements ApplicationListener<ContextRefreshedEvent> { @Override public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) { //Spring启动完毕会收到Event if (Objects.isNull(contextRefreshedEvent.getApplicationContext().getParent())) { ApplicationContext applicationContext = contextRefreshedEvent.getApplicationContext(); //保存spring上下文 后续使用 Container.setSpringContext(applicationContext); startServer(applicationContext); injectService(applicationContext); } } private void injectService(ApplicationContext context) { String[] names = context.getBeanDefinitionNames(); for (String name : names) { Object bean = context.getBean(name); Class<?> clazz = bean.getClass(); //clazz = clazz.getSuperclass(); aop增强的类生成cglib类,需要Superclass才能获取定义的字段 Field[] declaredFields = clazz.getDeclaredFields(); //设置InjectService的代理类 for (Field field : declaredFields) { InjectService injectService = field.getAnnotation(InjectService.class); if (injectService == null) {continue; Class<?> fieldClass = field.getType(); Object object = context.getBean(name); field.set(object, clientProxyFactory.getProxy(fieldClass, injectService.group(), injectService.version())); ServerDiscoveryCache.SERVER_CLASS_NAMES.add(fieldClass.getName()); } } } protected abstract void startServer(ApplicationContext context); }调用 ClientProxyFactory 类的 getProxy ,根据服务接口、服务分组、服务版本、是否异步调用来创建该接口的代理类,对该接口的所有方法都会使用创建的代理类来调用。方法调用的实现细节都在 ClientInvocationHandler 中的 invoke 方法,主要内容是,获取服务节点信息,选择调用节点,构建 request 对象,最后调用网络模块发送请求。public class ClientProxyFactory { public <T> T getProxy(Class<T> clazz, String group, String version, boolean async) { return (T) objectCache.computeIfAbsent(clazz.getName() + group + version, clz -> Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz}, new ClientInvocationHandler(clazz, group, version, async))); } private class ClientInvocationHandler implements InvocationHandler { public ClientInvocationHandler(Class<?> clazz, String group, String version, boolean async) { } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //1. 获得服务信息 String serviceName = clazz.getName(); List<Service> serviceList = getServiceList(serviceName); Service service = loadBalance.selectOne(serviceList); //2. 构建request对象 RpcRequest rpcRequest = new RpcRequest(); rpcRequest.setServiceName(service.getName()); rpcRequest.setMethod(method.getName()); rpcRequest.setGroup(group); rpcRequest.setVersion(version); rpcRequest.setParameters(args); rpcRequest.setParametersTypes(method.getParameterTypes()); //3. 协议编组 RpcProtocolEnum messageProtocol = RpcProtocolEnum.getProtocol(service.getProtocol()); RpcCompressEnum compresser = RpcCompressEnum.getCompress(service.getCompress()); RpcResponse response = netClient.sendRequest(rpcRequest, service, messageProtocol, compresser); return response.getReturnValue(); } } }3. 网络传输客户端封装调用请求对象之后需要通过网络将调用信息发送到服务端,在发送请求对象之前还需要经历序列化、压缩两个阶段。序列化与反序列化序列化与反序列化的核心作用就是对象的保存与重建,方便客户端与服务端通过字节流传递对象,快速对接交互。序列化就是指把 Java 对象转换为字节序列的过程。反序列化就是指把字节序列恢复为 Java 对象的过程。Java序列化的方式有很多,诸如 JDK 自带的 Serializable 、 Protobuf 、 kryo 等,上述三种笔者自测性能最高的是 Kryo 、其次是 Protobuf 。Json 也不失为一种简单且高效的序列化方法,有很多大道至简的框架采用。序列化接口比较简单,读者可以自行查看实现代码。public interface MessageProtocol { byte[] marshallingRequest(RpcRequest request) throws Exception; RpcRequest unmarshallingRequest(byte[] data) throws Exception; byte[] marshallingResponse(RpcResponse response) throws Exception; RpcResponse unmarshallingResponse(byte[] data) throws Exception; }压缩与解压网络通信的成本很高,为了减小网络传输数据包的体积,将序列化之后的字节码压缩不失为一种很好的选择。Gzip 压缩算法比率在3到10倍左右,可以大大节省服务器的网络带宽,各种流行的 web 服务器也都支持 Gzip 压缩算法。Java 接入也比较容易,接入代码可以查看下方接口的实现。public interface Compresser { byte[] compress(byte[] bytes); byte[] decompress(byte[] bytes); }网络通信万事俱备只欠东风。将请求对象序列化成字节码,并且压缩体积之后,需要使用网络将字节码传输到服务器。常用网络传输协议有 HTTP 、 TCP 、 WebSocke t等。HTTP、WebSocket 是应用层协议,TCP 是传输层协议。有些追求简洁、易用的 RPC 框架也有选择 HTTP 协议的。TCP传输的高可靠性和极致性能是主流RPC框架选择的最主要原因。谈到 Java 生态的通信领域,Netty 的领衔地位短时间内无人能及。选用 Netty 作为网络通信模块, TCP 数据流的粘包、拆包不可避免。粘包、拆包问题TCP 传输协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。为了最大化传输效率。发送方可能将单个较小数据包合并发送,这种情况就需要接收方来拆包处理数据了。Netty 提供了3种类型的解码器来处理 TCP 粘包/拆包问题:定长消息解码器:FixedLengthFrameDecoder 。发送方和接收方规定一个固定的消息长度,不够用空格等字符补全,这样接收方每次从接受到的字节流中读取固定长度的字节即可,长度不够就保留本次接受的数据,再在下一个字节流中获取剩下数量的字节数据。分隔符解码器:LineBasedFrameDecoder 或 DelimiterBasedFrameDecoder。LineBasedFrameDecoder 是行分隔符解码器,分隔符为 \n 或 \r\n ;DelimiterBasedFrameDecoder 是自定义分隔符解码器,可以定义一个或多个分隔符。接收端在收到的字节流中查找分隔符,然后返回分隔符之前的数据,没找到就继续从下一个字节流中查找。数据长度解码器:LengthFieldBasedFrameDecoder。将发送的消息分为 header 和 body,header 存储消息的长度(字节数),body 是发送的消息的内容。同时发送方和接收方要协商好这个 header 的字节数,因为 int 能表示长度,long 也能表示长度。接收方首先从字节流中读取前n(header的字节数)个字节(header),然后根据长度读取等量的字节,不够就从下一个数据流中查找。不想使用内置的解码器也可自定义解码器,自定传输协议。网络通信这部分内容比较复杂,说来话长,代码易读,读者可先自行阅读代码。后续有机会细说此节内容。5. 服务端实现客户端通过网络传输将请求对象序列化、压缩之后的字节码传输到服务端之后,同样先通过解压、反序列化将字节码重建为请求对象。有了请求对象之后,就可以进行关键的方法调用环节了。public abstract class RequestBaseHandler { public RpcResponse handleRequest(RpcRequest request) throws Exception { //1. 查找目标服务代理对象 ServiceObject serviceObject = serverRegister.getServiceObject(request.getServiceName() + request.getGroup() + request.getVersion()); RpcResponse response = null; //2. 调用对应的方法 response = invoke(serviceObject, request); //响应客户端 return response; } //具体代理调用 public abstract RpcResponse invoke(ServiceObject serviceObject, RpcRequest request) throws Exception; }上述抽象类 RequestBaseHandler 是调用服务方法的抽象实现 handleRequest 通过请求对象的服务名、服务分组、服务版本在 serverRegister.getServiceObject 获取代理对象。然后调用 invoke 抽象方法来真正通过代理对象调用方法获得结果。服务的代理对象怎么产生的?如何通过代理对象调用方法?生成服务代理对象带着上述问题来看 DefaultRpcBaseProcessor 抽象类:public abstract class DefaultRpcBaseProcessor implements ApplicationListener<ContextRefreshedEvent> { @Override public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) { //Spring启动完毕会收到Event if (Objects.isNull(contextRefreshedEvent.getApplicationContext().getParent())) { ApplicationContext applicationContext = contextRefreshedEvent.getApplicationContext(); Container.setSpringContext(applicationContext); startServer(applicationContext); injectService(applicationContext); } } private void injectService(ApplicationContext context) {} protected abstract void startServer(ApplicationContext context); }DefaultRpcBaseProcessor 抽象类也有两个实现类 DefaultRpcReflectProcessor 和 DefaultRpcJavassistProcessor,来实现关键的生成代理对象的 startServer 方法。服务接口实现类的 Bean 作为代理对象public class DefaultRpcReflectProcessor extends DefaultRpcBaseProcessor { @Override protected void startServer(ApplicationContext context) { Map<String, Object> beans = context.getBeansWithAnnotation(RpcService.class); if (beans.size() > 0) { boolean startServerFlag = true; for (Object obj : beans.values()) { Class<?> clazz = obj.getClass(); Class<?>[] interfaces = clazz.getInterfaces(); /* 如果只实现了一个接口就用接口的className作为服务名 * 如果该类实现了多个接口,则使用注解里的value作为服务名 */ RpcService service = clazz.getAnnotation(RpcService.class); if (interfaces.length != 1) { String value = service.value(); ServiceObject so = new ServiceObject(value, Class.forName(value), obj, service.group(), service.version()); } else { Class<?> supperClass = interfaces[0]; ServiceObject so = new ServiceObject(supperClass.getName(), supperClass, obj, service.group(), service.version()); } serverRegister.register(so); } } } }DefaultRpcReflectProcessor 中获取到所有有 RpcService 注解的服务接口实现类 Bean,然后将该 Bean 作为服务代理对象注册到 serverRegister 中供上述的反射调用中使用。使用 Javassist 生成新的代理对象public class DefaultRpcJavassistProcessor extends DefaultRpcBaseProcessor { @Override protected void startServer(ApplicationContext context) { Map<String, Object> beans = context.getBeansWithAnnotation(RpcService.class); if (beans.size() > 0) { boolean startServerFlag = true; for (Map.Entry<String, Object> entry : beans.entrySet()) { String beanName = entry.getKey(); Object obj = entry.getValue(); Class<?> clazz = obj.getClass(); Class<?>[] interfaces = clazz.getInterfaces(); Method[] declaredMethods = clazz.getDeclaredMethods(); /* * 如果只实现了一个接口就用接口的className作为服务名 * 如果该类实现了多个接口,则使用注解里的value作为服务名 */ RpcService service = clazz.getAnnotation(RpcService.class); if (interfaces.length != 1) { String value = service.value(); //bean实现多个接口时,javassist代理类中生成的方法只按照注解指定的服务类来生成 declaredMethods = Class.forName(value).getDeclaredMethods(); Object proxy = ProxyFactory.makeProxy(value, beanName, declaredMethods); ServiceObject so = new ServiceObject(value, Class.forName(value), proxy, service.group(), service.version()); } else { Class<?> supperClass = interfaces[0]; Object proxy = ProxyFactory.makeProxy(supperClass.getName(), beanName, declaredMethods); ServiceObject so = new ServiceObject(supperClass.getName(), supperClass, proxy, service.group(), service.version()); } serverRegister.register(so); } } } }DefaultRpcJavassistProcessor 与 DefaultRpcReflectProcessor 的差异在于后者直接将服务实现类对象 Bean 作为服务代理对象,而前者通过 ProxyFactory.makeProxy(value, beanName, declaredMethods) 创建了新的代理对象,将新的代理对象注册到 serverRegister 中供后续调用调用中使用。该方法通过 Javassist 来生成代理类,代码冗长,建议阅读源码。我来通过下面的代码演示实现的代理类。首先我们的服务接口是:public interface HelloService { String hello(String name); }服务的实现类是:@RpcService public class HelloServiceImpl implements HelloService { @Override public String hello(String name) { return "a1"; } }那最终新生成的代理类是这样的:public class HelloService$proxy1649315143476 { private static cn.ppphuang.rpcspringstarter.service.HelloService serviceProxy = ((org.springframework.context.ApplicationContext)cn.ppphuang.rpcspringstarter.server.Container.getSpringContext()).getBean("helloServiceImpl"); public cn.ppphuang.rpcspringstarter.common.model.RpcResponse hello(cn.ppphuang.rpcspringstarter.common.model.RpcRequest request) throws java.lang.Exception { java.lang.Object[] params = request.getParameters(); if(params.length == 1 && (params[0] == null||params[0].getClass().getSimpleName().equalsIgnoreCase("String"))){ java.lang.String arg0 = null; arg0 = cn.ppphuang.rpcspringstarter.util.ConvertUtil.convertToString(params[0]); java.lang.String returnValue = serviceProxy.hello(arg0); return new cn.ppphuang.rpcspringstarter.common.model.RpcResponse(returnValue); } } public cn.ppphuang.rpcspringstarter.common.model.RpcResponse invoke(cn.ppphuang.rpcspringstarter.common.model.RpcRequest request) throws java.lang.Exception { String methodName = request.getMethod(); if(methodName.equalsIgnoreCase("hello")){ java.lang.Object returnValue = hello(request); return returnValue; } } }清理全限定类名后,代码如下:public class HelloService$proxy1649315143476 { private static HelloService serviceProxy = ((ApplicationContext)Container.getSpringContext()).getBean("helloServiceImpl"); public RpcResponse hello(RpcRequest request) throws Exception { Object[] params = request.getParameters(); if(params.length == 1 && (params[0] == null|| params[0].getClass().getSimpleName().equalsIgnoreCase("String"))){ String arg0 = ConvertUtil.convertToString(params[0]); String returnValue = serviceProxy.hello(arg0); return new RpcResponse(returnValue); } } public RpcResponse invoke(RpcRequest request) throws Exception { String methodName = request.getMethod(); if(methodName.equalsIgnoreCase("hello")){ Object returnValue = hello(request); return returnValue; } } }代理类 HelloService$proxy1649315143476 中有一个服务接口类型 HelloService 的静态属性 serviceProxy,值就是通过 ApplicationContext 上下文获取到的服务接口实现类 HelloServiceImpl 这个 Bean(SpringContext 已经被提前缓存到 Container 类中,读者可以自行查找代码了解)。public RpcResponse invoke(RpcRequest request) throws Exception 该方法判断调用的方法名是 hello 来调用代理类中的hello方法。public RpcResponse hello(RpcRequest request) throws Exception 该方法通过调用 serviceProxy.hello() 的方法获取结果。public interface InvokeProxy { /** * invoke调用服务接口 */ RpcResponse invoke(RpcRequest rpcRequest) throws Exception; }HelloService$proxy1649315143476 类实现 InvokeProxy 接口(ProxyFactory.makeProxy 代码中有体现)。InvokeProxy 接口只有一个 invoke 方法。到这里就能理解通过调用代理对象的 invoke 方法就能间接调用到服务接口实现类 HelloServiceImpl 的对应方法了。调用代理对象方法理清代理对象的生成之后,开始调用代理对象的方法。上文中写到的抽象类 RequestBaseHandler 有两个实现类 RequestJavassistHandler 和 RequestReflectHandler。Java 反射调用先看 RequestReflectHandler:public class RequestReflectHandler extends RequestBaseHandler { @Override public RpcResponse invoke(ServiceObject serviceObject, RpcRequest request) throws Exception { Method method = serviceObject.getClazz().getMethod(request.getMethod(), request.getParametersTypes()); Object value = method.invoke(serviceObject.getObj(), request.getParameters()); RpcResponse response = new RpcResponse(RpcStatusEnum.SUCCESS); response.setReturnValue(value); return response; } }Object value = method.invoke(serviceObject.getObj(), request.getParameters());这行代码都很熟悉,用 Java 框架中最常见的反射来调用代理类中的方法,大部分 RPC 框架也都是这么来实现的。通过 Javassists 生成的代理对象 invoke 方法调用接着看 RequestJavassistHandler:public class RequestJavassistHandler extends RequestBaseHandler { @Override public RpcResponse invoke(ServiceObject serviceObject, RpcRequest request) throws Exception { InvokeProxy invokeProxy = (InvokeProxy) serviceObject.getObj(); return invokeProxy.invoke(request); } }直接将代理对象转为 InvokeProxy,调用 InvokeProxy.invoke() 方法获得返回值,如果这里不能理解,回头再看一下使用 Javassist 生成新的代理对象这个小节吧。调用代理对象的方法获取到结果,仍要通过序列化、压缩后,将字节流数据包通过网络传输到客户端,客户端拿到响应的结果再解压,反序列化得到结果对象。Javassist介绍Javassist 是一个开源的分析、编辑和创建Java字节码的类库。是由东京工业大学的数学和计算机科学系的 Shigeru Chiba(千叶滋)所创建的。简单来说就是用源码级别的 api 去修改字节码。Duboo、MyBatis 也都使用了 Javassist。Duboo 作者也选择Javassist作为 Duboo 的代理工具,可以点击这里查看 Duboo 作者也选择 Javassist 的原因。Javassist 还能和谐(pojie)Java 编写的商业软件,例如抓包工具 Charles。代码在这里,供交流学习。在使用 Javassist 有踩到如下坑,供大家参考:Javassist 是运行时,没有 JDK 静态编译过程,JDK 的很多语法糖都是在静态编译过程中处理的,所以需要自行编码处理,例如自动拆装箱。int i = 1; Integer ii = i; //javassist 错误 JDK会自动装箱,javassist需要自行编码处理 int i = 1; Integer ii = new Integer(i); //javassist 正确自定义的类需要使用类的完全限定名,这也是为什么生成的代理类中类都是完全限定名。选择哪种代理方式可以通过配置文件 application.properties 修改 hp.rpc.server-proxy-type 的值来选择代理模式。性能测试,机器 Macbook Pro M1 8C 16G, 代码如下:@Autowired ClientProxyFactory clientProxyFactory; @Test void contextLoads() { long l1 = System.currentTimeMillis(); HelloService proxy = clientProxyFactory.getProxy(HelloService.class,"group3","version3"); for (int i = 0; i < 1000000; i++) { String ppphuang = proxy.hello("ppphuang"); } long l2 = System.currentTimeMillis(); long l3 = l2 - l1; System.out.println(l3); }测试结果(ms):请求次数反射调用1反射调用2反射调用3Javassist1Javassist2Javassist3100001303115911641126123510941000006110610360656259585461781000000544755189052329525605209952794测试结果差异并不大,Javassist 模式下只是稍快了一点点,几乎可以忽略不记。与Duboo作者博客6楼评论的测试结果一致。所以想简单通用性强用反射模式,也可以通过使用 Javassist 模式来学习更多知识,因为 Javassist 需要自己兼容很多特殊的状况,反射调用 JDK 已经帮你兼容完了。总结写到这里我们了解了 RPC 的基本原理、服务注册与发现、客户端代理、网络传输、重点介绍了服务端的两种代理模式,学习 Javassist 如何实现代理。还有很多东西没有重点讲述甚至没有提及,例如粘、拆包的处理、自定义数据包协议、Javassist 模式下如何实现方法重载、如何解决一个服务接口类有多个实现、如何解决一个实现类实现了多个服务接口、在 SpringBoot 中如何自动装载、如何开箱即用、怎么实现异步调用、怎么扩展序列化、压缩算法等等...有兴趣的读者可以在源码中寻找答案,或者寻找优化项,当然也可以寻找 bug 。附录项目地址:https://github.com/ppphuang/rpc-spring-starter测试DEMO:https://github.com/ppphuang/rpc-spring-starter-demo
前言说到底Spring StateMachine上手难度非常大,如果没有用来做重型状态机的需求,十分不推荐普通的小项目进行接入。最最重要的是,由于Spring StateMachine状态机实例不是无状态的,无法做到线程安全,所以代码要么需要使用锁同步,要么需要用Threadlocal,非常的痛苦和难用。 例如下面的Spring StateMachine代码就用了重量级锁保证线程安全,在高并发的互联网应用中,这种频繁的获取释放锁会造成严重的性能问题。private synchronized boolean sendEvent(Message<PurchaseOrderEvent> message, OrderEntity orderEntity) { boolean result = false; try { stateMachine.start(); // 尝试恢复状态机状态 persister.restore(stateMachine, orderEntity); // 执行事件 result = stateMachine.sendEvent(message); // 持久化状态机状态 persister.persist(stateMachine, (OrderEntity) message.getHeaders().get("purchaseOrder")); } catch (Exception e) { log.error("sendEvent error", e); } finally { stateMachine.stop(); } return result; }吃了一次亏后,我再一次在网上翻阅各种Java状态机的实现,有大的开源项目,也有小而美的个人实现。结果在COLA架构中发现了COLA还写了一套状态机实现。COLA的作者给我们提供了一个无状态的,轻量化的状态机,接入十分简单。并且由于无状态的特点,可以做到线程安全,支持电商的高并发场景。如果你确实需要在项目中引入状态机,此时此刻,我会推荐使用COLA状态机。COLA状态机介绍我精简一下DSL的主要含义:什么是DSL? DSL是一种工具,它的核心价值在于,它提供了一种手段,可以更加清晰地就系统某部分的意图进行沟通。比如正则表达式,/\d{3}-\d{3}-\d{4}/就是一个典型的DSL,解决的是字符串匹配这个特定领域的问题。文章的后半部分重点阐述了作者为什么要做COLA状态机?想必这也是读者比较好奇的问题。我帮大家精简一下原文的表述:首先,状态机的实现应该可以非常的轻量,最简单的状态机用一个Enum就能实现,基本是零成本。其次,使用状态机的DSL来表达状态的流转,语义会更加清晰,会增强代码的可读性和可维护性。开源状态机太复杂:就我们的项目而言(其实大部分项目都是如此)。我实在不需要那么多状态机的高级玩法:比如状态的嵌套(substate),状态的并行(parallel,fork,join)、子状态机等等。开源状态机性能差:这些开源的状态机都是有状态的(Stateful)的,因为有状态,状态机的实例就不是线程安全的,而我们的应用服务器是分布式多线程的,所以在每一次状态机在接受请求的时候,都不得不重新build一个新的状态机实例。所以COLA状态机设计的目标很明确,有两个核心理念:简洁的仅支持状态流转的状态机,不需要支持嵌套、并行等高级玩法。状态机本身需要是Stateless(无状态)的,这样一个Singleton Instance就能服务所有的状态流转请求了。COLA状态机的核心概念如下图所示,主要包括:State:状态 Event:事件,状态由事件触发,引起变化 Transition:流转,表示从一个状态到另一个状态 External Transition:外部流转,两个不同状态之间的流转 Internal Transition:内部流转,同一个状态之间的流转 Condition:条件,表示是否允许到达某个状态 Action:动作,到达某个状态之后,可以做什么 StateMachine:状态机COLA状态机原理这一小节,我们先讲几个COLA状态机最重要两个部分,一个是它使用的连贯接口,一个是状态机的注册和使用原理。如果你暂时对它的实现原理不感兴趣,可以直接跳过本小节,直接看后面的实战代码部分。下图展示了COLA状态机的源代码目录,可以看到非常的简洁。1. 连贯接口 Fluent InterfacesCOLA状态机的定义使用了连贯接口Fluent Interfaces,连贯接口的一个重要作用是,限定方法调用的顺序。比如,在构建状态机的时候,我们只有在调用了from方法后,才能调用to方法,Builder模式没有这个功能。下图中可以看到,我们在使用的时候是被严格限制的:StateMachineBuilder<States, Events, Context> builder = StateMachineBuilderFactory.create(); builder.externalTransition() .from(States.STATE1) .to(States.STATE2) .on(Events.EVENT1) .when(checkCondition()) .perform(doAction());这是如何实现的?其实是使用了Java接口来实现。2. 状态机注册和触发原理这里简单梳理一下状态机的注册和触发原理。用户执行如下代码来创建一个状态机,指定一个MACHINE_ID:StateMachine<States, Events, Context> stateMachine = builder.build(MACHINE_ID);COLA会将该状态机在StateMachineFactory类中,放入一个ConcurrentHashMap,以状态机名为key注册。static Map<String /* machineId */, StateMachine> stateMachineMap = new ConcurrentHashMap<>();注册好后,用户便可以使用状态机,通过类似下方的代码触发状态机的状态流转:stateMachine.fireEvent(StateMachineTest.States.STATE1, StateMachineTest.Events.EVENT1, new Context("1"));内部实现如下:首先判断COLA状态机整个组件是否初始化完成。通过routeTransition寻找是否有符合条件的状态流转。transition.transit执行状态流转。transition.transit方法中:检查本次流转是否符合condition,符合,则执行对应的action。COLA状态机实战一、状态流转使用示例从单一状态流转到另一个状态@Test public void testExternalNormal(){ StateMachineBuilder<States, Events, Context> builder = StateMachineBuilderFactory.create(); builder.externalTransition() .from(States.STATE1) .to(States.STATE2) .on(Events.EVENT1) .when(checkCondition()) .perform(doAction()); StateMachine<States, Events, Context> stateMachine = builder.build(MACHINE_ID); States target = stateMachine.fireEvent(States.STATE1, Events.EVENT1, new Context()); Assert.assertEquals(States.STATE2, target); } private Condition<Context> checkCondition() { return (ctx) -> {return true;}; } private Action<States, Events, Context> doAction() { return (from, to, event, ctx)->{ System.out.println(ctx.operator+" is operating "+ctx.entityId+" from:"+from+" to:"+to+" on:"+event); }; }可以看到,每次进行状态流转时,检查checkCondition(),当返回true,执行状态流转的操作doAction()。后面所有的checkCondition()和doAction()方法在下方就不再重复贴出了。从多个状态流传到新的状态@Test public void testExternalTransitionsNormal(){ StateMachineBuilder<States, Events, Context> builder = StateMachineBuilderFactory.create(); builder.externalTransitions() .fromAmong(States.STATE1, States.STATE2, States.STATE3) .to(States.STATE4) .on(Events.EVENT1) .when(checkCondition()) .perform(doAction()); StateMachine<States, Events, Context> stateMachine = builder.build(MACHINE_ID+"1"); States target = stateMachine.fireEvent(States.STATE2, Events.EVENT1, new Context()); Assert.assertEquals(States.STATE4, target); }状态内部触发流转@Test public void testInternalNormal(){ StateMachineBuilder<States, Events, Context> builder = StateMachineBuilderFactory.create(); builder.internalTransition() .within(States.STATE1) .on(Events.INTERNAL_EVENT) .when(checkCondition()) .perform(doAction()); StateMachine<States, Events, Context> stateMachine = builder.build(MACHINE_ID+"2"); stateMachine.fireEvent(States.STATE1, Events.EVENT1, new Context()); States target = stateMachine.fireEvent(States.STATE1, Events.INTERNAL_EVENT, new Context()); Assert.assertEquals(States.STATE1, target); }多线程测试并发测试@Test public void testMultiThread(){ buildStateMachine("testMultiThread"); for(int i=0 ; i<10 ; i++){ Thread thread = new Thread(()->{ StateMachine<States, Events, Context> stateMachine = StateMachineFactory.get("testMultiThread"); States target = stateMachine.fireEvent(States.STATE1, Events.EVENT1, new Context()); Assert.assertEquals(States.STATE2, target); }); thread.start(); } for(int i=0 ; i<10 ; i++) { Thread thread = new Thread(() -> { StateMachine<States, Events, Context> stateMachine = StateMachineFactory.get("testMultiThread"); States target = stateMachine.fireEvent(States.STATE1, Events.EVENT4, new Context()); Assert.assertEquals(States.STATE4, target); }); thread.start(); } for(int i=0 ; i<10 ; i++) { Thread thread = new Thread(() -> { StateMachine<States, Events, Context> stateMachine = StateMachineFactory.get("testMultiThread"); States target = stateMachine.fireEvent(States.STATE1, Events.EVENT3, new Context()); Assert.assertEquals(States.STATE3, target); }); thread.start(); } }由于COLA状态机时无状态的状态机,所以性能是很高的。相比起来,SpringStateMachine由于是有状态的,就需要使用者自行保证线程安全了。二、多分支状态流转示例/** * 测试选择分支,针对同一个事件:EVENT1 * if condition == "1", STATE1 --> STATE1 * if condition == "2" , STATE1 --> STATE2 * if condition == "3" , STATE1 --> STATE3 */ @Test public void testChoice(){ StateMachineBuilder<StateMachineTest.States, StateMachineTest.Events, Context> builder = StateMachineBuilderFactory.create(); builder.internalTransition() .within(StateMachineTest.States.STATE1) .on(StateMachineTest.Events.EVENT1) .when(checkCondition1()) .perform(doAction()); builder.externalTransition() .from(StateMachineTest.States.STATE1) .to(StateMachineTest.States.STATE2) .on(StateMachineTest.Events.EVENT1) .when(checkCondition2()) .perform(doAction()); builder.externalTransition() .from(StateMachineTest.States.STATE1) .to(StateMachineTest.States.STATE3) .on(StateMachineTest.Events.EVENT1) .when(checkCondition3()) .perform(doAction()); StateMachine<StateMachineTest.States, StateMachineTest.Events, Context> stateMachine = builder.build("ChoiceConditionMachine"); StateMachineTest.States target1 = stateMachine.fireEvent(StateMachineTest.States.STATE1, StateMachineTest.Events.EVENT1, new Context("1")); Assert.assertEquals(StateMachineTest.States.STATE1,target1); StateMachineTest.States target2 = stateMachine.fireEvent(StateMachineTest.States.STATE1, StateMachineTest.Events.EVENT1, new Context("2")); Assert.assertEquals(StateMachineTest.States.STATE2,target2); StateMachineTest.States target3 = stateMachine.fireEvent(StateMachineTest.States.STATE1, StateMachineTest.Events.EVENT1, new Context("3")); Assert.assertEquals(StateMachineTest.States.STATE3,target3); }可以看到,编写一个多分支的状态机也是非常简单明了的。三、通过状态机反向生成PlantUml图没想到吧,还能通过代码定义好的状态机反向生成plantUML图,实现状态机的可视化。(可以用图说话,和产品对比下状态实现的是否正确了。)四、特殊使用示例不满足状态流转条件时的处理@Test public void testConditionNotMeet(){ StateMachineBuilder<StateMachineTest.States, StateMachineTest.Events, StateMachineTest.Context> builder = StateMachineBuilderFactory.create(); builder.externalTransition() .from(StateMachineTest.States.STATE1) .to(StateMachineTest.States.STATE2) .on(StateMachineTest.Events.EVENT1) .when(checkConditionFalse()) .perform(doAction()); StateMachine<StateMachineTest.States, StateMachineTest.Events, StateMachineTest.Context> stateMachine = builder.build("NotMeetConditionMachine"); StateMachineTest.States target = stateMachine.fireEvent(StateMachineTest.States.STATE1, StateMachineTest.Events.EVENT1, new StateMachineTest.Context()); Assert.assertEquals(StateMachineTest.States.STATE1,target); }可以看到,当checkConditionFalse()执行时,永远不会满足状态流转的条件,则状态不会变化,会直接返回原来的STATE1。相关源码在这里:重复定义相同的状态流转@Test(expected = StateMachineException.class) public void testDuplicatedTransition(){ StateMachineBuilder<StateMachineTest.States, StateMachineTest.Events, StateMachineTest.Context> builder = StateMachineBuilderFactory.create(); builder.externalTransition() .from(StateMachineTest.States.STATE1) .to(StateMachineTest.States.STATE2) .on(StateMachineTest.Events.EVENT1) .when(checkCondition()) .perform(doAction()); builder.externalTransition() .from(StateMachineTest.States.STATE1) .to(StateMachineTest.States.STATE2) .on(StateMachineTest.Events.EVENT1) .when(checkCondition()) .perform(doAction()); }会在第二次builder执行到on(StateMachineTest.Events.EVENT1)函数时,抛出StateMachineException异常。抛出异常在on()的verify检查这里,如下:重复定义状态机@Test(expected = StateMachineException.class) public void testDuplicateMachine(){ StateMachineBuilder<StateMachineTest.States, StateMachineTest.Events, StateMachineTest.Context> builder = StateMachineBuilderFactory.create(); builder.externalTransition() .from(StateMachineTest.States.STATE1) .to(StateMachineTest.States.STATE2) .on(StateMachineTest.Events.EVENT1) .when(checkCondition()) .perform(doAction()); builder.build("DuplicatedMachine"); builder.build("DuplicatedMachine"); }会在第二次build同名状态机时抛出StateMachineException异常。抛出异常的源码在状态机的注册函数中,如下:结语为了不把篇幅拉得过长,在这里无法详细地横向对比几大主流状态机(Spring Statemachine,Squirrel statemachine等)和COLA的区别,不过基于笔者在Spring Statemachine踩过的深坑,目前来看,COLA状态机的简洁设计适合用在订单管理等小型状态机的维护,如果你想要在你的项目中接入状态机,又不需要嵌套、并行等高级玩法,那么COLA是个十分合适的选择。
前言本文快速回顾了计算机网络书本中常考的的知识点,用作面试复习,事半功倍。主要内容有:计算机网络体系结构,TCP与UDP,UDP/TCP实现DEMO代码基础计算机网络体系结构1. 五层协议应用层 :为特定应用程序提供数据传输服务,例如 HTTP、DNS 等。数据单位为报文。运输层 :提供的是进程间的通用数据传输服务。由于应用层协议很多,定义通用的运输层协议就可以支持不断增多的应用层协议。运输层包括两种协议:传输控制协议TCP,提供面向连接、可靠的数据传输服务,数据单位为报文段;TCP 主要提供完整性服务.用户数据报协议UDP,提供无连接、尽最大努力的数据传输服务,数据单位为用户数据报。UDP 主要提供及时性服务。网络层:为主机间提供数据传输服务,而运输层协议是为主机中的进程提供服务。网络层把运输层传递下来的报文段或者用户数据报封装成分组。数据链路层:网络层针对的还是主机之间的数据传输服务,而主机之间可以有很多链路,链路层协议就是为同一链路的节点提供服务。数据链路层把网络层传来的分组封装成帧。物理层 :考虑的是怎样在传输媒体上传输数据比特流,而不是指具体的传输媒体。物理层的作用是尽可能屏蔽传输媒体和通信手段的差异,使数据链路层感觉不到这些差异。数据报->分组->帧->比特流2. 七层协议其中表示层和会话层用途如下:表示层 :数据压缩、加密以及数据描述。这使得应用程序不必担心在各台主机中表示/存储的内部格式不同的问题。会话层 :建立及管理会话。五层协议没有表示层和会话层,而是将这些功能留给应用程序开发者处理。3. 数据在各层之间的传递过程在向下的过程中,需要添加下层协议所需要的首部或者尾部,而在向上的过程中不断拆开首部和尾部。路由器只有下面三层协议,因为路由器位于网络核心中,不需要为进程或者应用程序提供服务,因此也就不需要运输层和应用层。4. TCP/IP它只有四层,相当于五层协议中数据链路层和物理层合并为网络接口层。现在的 TCP/IP 体系结构不严格遵循 OSI 分层概念,应用层可能会直接使用 IP 层或者网络接口层TCP/IP 协议族是一种沙漏形状,中间小两边大,IP 协议在其中占用举足轻重的地位。物理层/数据链路层/网络层知识点偏通信理论的多一些,可以放在后面复习传输层TCP与UDP的特点用户数据报协议 UDP(User Datagram Protocol):无连接的,尽最大可能交付,没有拥塞控制,面向报文对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部,支持一对一、一对多、多对一和多对多的交互通信。传输控制协议 TCP(Transmission Control Protocol)是有连接的,提供可靠交付,有流量控制,拥塞控制,面向字节流把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块,每一条 TCP连接只能是点对点的(一对一)。总结(TCP和UDP的区别):1)TCP提供面向连接的传输;UDP提供无连接的传输2)TCP提供可靠的传输(有序,无差错,不丢失,不重复);UDP提供不可靠的传输。3)TCP面向字节流的传输,因此它能将信息分割成组,并在接收端将其重组;UDP是面向数据报的传输,没有分组开销。4)TCP提供拥塞控制和流量控制机制;UDP不提供拥塞控制和流量控制机制。5)TCP只能是点对点的(一对一)。UDP支持一对一、一对多、多对一和多对多的交互通信。首部格式UDP首部字段只有 8 个字节,包括源端口、目的端口、长度、检验和。12 字节的伪首部是为了计算检验和临时添加的。TCP序号 :用于对字节流进行编号,例如序号为 301,表示第一个字节的编号为 301,如果携带的数据长度为 100 字节,那么下一个报文段的序号应为 401。确认号 :期望收到的下一个报文段的序号。例如 B 正确收到 A 发送来的一个报文段,序号为 501,携带的数据长度为 200 字节,因此 B 期望下一个报文段的序号为 701,B 发送给 A 的确认报文段中确认号就为 701。数据偏移 :指的是数据部分距离报文段起始处的偏移量,实际上指的是首部的长度。确认 ACK :当 ACK=1 时确认号字段有效,否则无效。TCP 规定,在连接建立后所有传送的报文段都必须把 ACK 置 1。同步 SYN :在连接建立时用来同步序号。当 SYN=1,ACK=0 时表示这是一个连接请求报文段。若对方同意建立连接,则响应报文中 SYN=1,ACK=1。终止 FIN :用来释放一个连接,当 FIN=1 时,表示此报文段的发送方的数据已发送完毕,并要求释放连接。窗口 :窗口值作为接收方让发送方设置其发送窗口的依据。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。TCP拆包粘包如果客户端连续不断的向服务端发送数据包时,服务端接收的数据会出现两个数据包粘在一起的情况。分包机制一般有两个通用的解决方法:1,特殊字符控制2,在包头首都添加数据包的长度如果使用netty的话,就有专门的编码器和解码器解决拆包和粘包问题了。tips:UDP没有粘包问题,但是有丢包和乱序。不完整的包是不会有的,收到的都是完全正确的包。传送的数据单位协议是UDP报文或用户数据报,发送的时候既不合并,也不拆分。三次握手(1)第一步:源主机A的TCP向主机B发出连接请求报文段,其首部中的SYN(同步)标志位应置为1,表示想与目标主机B进行通信,**并发送一个同步序列号X(例:SEQ=100)进行同步,表明在后面传送数据时的第一个数据字节的序号是X+1(即101)**。SYN同步报文会指明客户端使用的端口以及TCP连接的初始序号。 (2)第二步:目标主机B的TCP收到连接请求报文段后,如同意,则发回确认。在确认报中应将ACK位和SYN位置1,表示客户端的请求被接受。确认号应为X+1(图中为101),同时也为自己选择一个序号Y。 (3)第三步:源主机A的TCP收到目标主机B的确认后要向目标主机B给出确认,其ACK置1,确认号为Y+1,而自己的序号为X+1。**TCP的标准规定,SYN置1的报文段要消耗掉一个序号。** &emsp;&emsp; 运行客户进程的源主机A的TCP通知上层应用进程,连接已经建立。当源主机A向目标主机B发送第一个数据报文段时,**其序号仍为X+1,因为前一个确认报文段并不消耗序号。** &emsp;&emsp; 当运行服务进程的目标主机B的TCP收到源主机A的确认后,也通知其上层应用进程,连接已经建立。至此建立了一个全双工的连接。 复制代码三次握手的原因如果采用的是三次握手,就算是那一次失效的报文传送过来了,服务端接受到了那条失效报文并且回复了确认报文,但是客户端不会再次发出确认。由于服务器收不到确认,就知道客户端并没有请求连接。如果使用的是两次握手建立连接,假设有这样一种场景,客户端发送了第一个请求连接并且没有丢失,只是因为在网络结点中滞留的时间太长了,由于TCP的客户端迟迟没有收到确认报文,以为服务器没有收到,此时重新向服务器发送这条报文,此后客户端和服务器经过两次握手完成连接,传输数据,然后关闭连接。此时此前滞留的那一次请求连接,网络通畅了到达了服务器,这个报文本该是失效的,但是,两次握手的机制将会让客户端和服务器再次建立连接,这将导致不必要的错误和资源的浪费。四次挥手客户端进程发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=u(等于前面已经传送过来的数据的最后一个字节的序号加1),此时,客户端进入FIN-WAIT-1(终止等待1)状态。 TCP规定,FIN报文段即使不携带数据,也要消耗一个序号。服务器收到连接释放报文,发出确认报文,ACK=1,ack=u+1,并且带上自己的序列号seq=v,此时,服务端就进入了CLOSE-WAIT(关闭等待)状态。TCP服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。客户端收到服务器的确认请求后,此时,客户端就进入FIN-WAIT-2(终止等待2)状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)。服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,FIN=1,ack=u+1,由于在半关闭状态,服务器很可能又发送了一些数据,假定此时的序列号为seq=w,此时,服务器就进入了LAST-ACK(最后确认)状态,等待客户端的确认。客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是seq=u+1,此时,客户端就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2∗MSL(最长报文段寿命)的时间后,当客户端撤销相应的TCB后,才进入CLOSED状态。服务器只要收到了客户端发出的确认,立即进入CLOSED状态。同样,撤销TCB后,就结束了这次的TCP连接。可以看到,服务器结束TCP连接的时间要比客户端早一些。等待 2MSL(最长报文段寿命) 的原因书中解释:TCP采用四次挥手关闭连接如图所示为什么建立连接协议是三次握手,而关闭连接却是四次握手呢?建立连接的时候, 服务器在LISTEN状态下,收到建立连接请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。而关闭连接时,服务器收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,而自己也未必全部数据都发送给对方了,所以己方可以立即关闭,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接,因此,己方ACK和FIN一般都会分开发送,从而导致多了一次。如果已经建立了连接,但是客户端突然出现故障了怎么办?TCP还设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75分钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。Time-wait状态好处和坏处好处上方所述两点坏处高并发下,端口都处在timewait很快就用完端口。解决方法:(阿里手册)调小 TCP 协议的 time_ wait 超时时间。net . ipv 4. tcp _ fin _ timeout = 30tcp_tw_reuse 这个参数作用是当新的连接进来的时候,可以复用处于TIME_WAIT的socket。默认值是0。tcp_tw_recycle和tcp_timestamps 默认TIME_WAIT的超时时间是2倍的MSL,但是MSL一般会设置的非常长。如果tcp_timestamps是关闭的,开启tcp_tw_recycle是没用的。但是一般情况下tcp_timestamps是默认开启的,所以直接开启就有用了。对于客户端作为客户端因为有端口65535问题,TIME_OUT过多直接影响处理能力,打开tw_reuse 即可解决,不建议同时打开tw_recycle,帮助不大。对于服务端打开tw_reuse无效线上环境 tw_recycle 最好不要打开服务器处于NAT 负载后,或者客户端处于NAT后(这是一定的事情,基本公司家庭网络都走NAT);公网服务打开就可能造成部分连接失败,内网的话到时可以视情况打开;像我所在公司对外服务都放在负载后面,负载会把timestamp 选项都给关闭,所以就算打开也不起作用。服务器TIME_WAIT 高怎么办不像客户端有端口限制,处理大量TIME_WAIT Linux已经优化很好了,每个处于TIME_WAIT 状态下连接内存消耗很少,而且也能通过tcp_max_tw_buckets = 262144 配置最大上限,现代机器一般也不缺这点内存。高并发服务器建议调小 TCP 协议的 time_wait 超时时间。240s调整至30s(阿里Java规约)TCP 滑动窗口窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {32, 33} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。TCP 可靠传输(超时重传)TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。一个报文段从发送再到接收到确认所经过的时间称为往返时间 RTT,加权平均往返时间 RTTs 计算如下:超时时间 RTO 应该略大于 RTTs,TCP 使用的超时时间计算如下:其中 RTTd 为偏差。TCP 流量控制流量控制是为了控制发送方发送速率,保证接收方来得及接收。接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。TCP 拥塞控制拥塞控制主要包含以下2个内容:(1)慢开始,拥塞避免(2)快重传,快恢复发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。为了便于讨论,做如下假设:接收方有足够大的接收缓存,因此不会发生流量控制;虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。1. 慢开始与拥塞避免发送的最初执行慢开始,令 cwnd=1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 ...注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。如果出现了超时,则令 ssthresh = cwnd/2,然后重新执行慢开始。2. 快重传与快恢复在接收方,要求每次接收到报文段都应该发送对已收到有序报文段的确认,例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。快重传在发送方,如果收到三个重复确认,那么可以确认下一个报文段丢失,例如收到三个 M2 ,则 M3 丢失。此时执行快重传,立即重传下一个报文段。快恢复在快重传情况下,只是丢失个别报文段,而不是网络拥塞,因此执行快恢复,令 ssthresh = cwnd/2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。TCPTCP连接的建立步骤客户端向服务器端发送连接请求后,就被动地等待服务器的响应。典型的TCP客户端要经过下面三步操作:1、创建一个Socket实例:构造函数向指定的远程主机和端口建立一个TCP连接;2.通过套接字的I/O流与服务端通信;3、使用Socket类的close方法关闭连接。服务端的工作是建立一个通信终端,并被动地等待客户端的连接。典型的TCP服务端执行如下两步操作:1、创建一个ServerSocket实例并指定本地端口,用来监听客户端在该端口发送的TCP连接请求;2、重复执行:1)调用ServerSocket的accept()方法以获取客户端连接,并通过其返回值创建一个Socket实例;2)为返回的Socket实例开启新的线程,并使用返回的Socket实例的I/O流与客户端通信;3)通信完成后,使用Socket类的close()方法关闭该客户端的套接字连接。DemoServerDemo.java/** * sinture.com Inc. * Copyright (c) 2016-2018 All Rights Reserved. */ package test.socketDemo.TCP; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; /** * @author yzd * @version Id: ServerDemo.java, v 0.1 2018年07月10日 12:36 yzd Exp $ */ public class ServerDemo { public static void main(String[] args) throws IOException { // 服务端在20006端口监听客户端请求的TCP连接 ServerSocket server = new ServerSocket(20000); Socket client = null; boolean f = true; while(f){ // 等待客户端的连接,如果没有获取连接 client = server.accept(); System.out.println("与客户端连接成功!"); // 为每个客户端连接开启一个线程 new Thread(new ServerThread(client)).start(); } } } 复制代码ServerThread.java/** * sinture.com Inc. * Copyright (c) 2016-2018 All Rights Reserved. */ package test.socketDemo.TCP; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintStream; import java.net.Socket; /** * @author yzd * @version Id: ServerThread.java, v 0.1 2018年07月10日 13:41 yzd Exp $ */ public class ServerThread implements Runnable { private Socket client = null; public ServerThread(Socket client){ this.client = client; } @Override public void run() { try{ //获取Socket的输出流,用来向客户端发送数据 PrintStream out = new PrintStream(client.getOutputStream()); //获取Socket的输入流,用来接收从客户端发送过来的数据 BufferedReader buf = new BufferedReader(new InputStreamReader(client.getInputStream())); boolean flag = true; while (flag){ //接收从客户端发送过来的数据 String str = buf.readLine(); if(str == null || "".equals(str)){ flag = false; }else { if("bye".equals(str)){ flag = false; }else{ //将接收到的字符串前面加上echo,发送到对应的客户端 out.println("echo:" + str); } } } out.close(); client.close(); } catch (IOException e) { e.printStackTrace(); } } } 复制代码ClientDemo.java/** * sinture.com Inc. * Copyright (c) 2016-2018 All Rights Reserved. */ package test.socketDemo.TCP; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintStream; import java.net.Socket; import java.net.SocketTimeoutException; /** * @author yzd * @version Id: ClientDemo.java, v 0.1 2018年07月10日 14:05 yzd Exp $ */ public class ClientDemo { public static void main(String[] args) throws IOException { //客户端请求与本机在20006端口建立TCP连接 Socket client = new Socket("127.0.0.1", 20000); client.setSoTimeout(10000); //获取键盘输入 BufferedReader input = new BufferedReader(new InputStreamReader(System.in)); //获取Socket的输出流,用来发送数据到服务端 PrintStream out = new PrintStream(client.getOutputStream()); //获取Socket的输入流,用来接收从服务端发送过来的数据 BufferedReader buf = new BufferedReader(new InputStreamReader(client.getInputStream())); boolean flag = true; while(flag){ System.out.print("输入信息:"); String str = input.readLine(); //发送数据到服务端 out.println(str); if("bye".equals(str)){ flag = false; }else{ try{ //从服务器端接收数据有个时间限制(系统自设,也可以自己设置),超过了这个时间,便会抛出该异常 String echo = buf.readLine(); System.out.println(echo); }catch(SocketTimeoutException e){ System.out.println("Time out, No response"); } } } input.close(); if(client != null){ //如果构造函数建立起了连接,则关闭套接字,如果没有建立起连接,自然不用关闭 client.close(); //只关闭socket,其关联的输入输出流也会被关闭 } } } 复制代码UDPUDP的通信建立的步骤客户端要经过下面三步操作:1、创建一个DatagramSocket实例,可以有选择地对本地地址和端口号进行设置,如果设置了端口号,则客户端会在该端口号上监听从服务器端发送来的数据;2、使用DatagramSocket实例的send()和receive()方法来发送和接收DatagramPacket实例,进行通信;3、通信完成后,调用DatagramSocket实例的close()方法来关闭该套接字。UDP服务端要经过下面三步操作:1、创建一个DatagramSocket实例,指定本地端口号,并可以有选择地指定本地地址,此时,服务器已经准备好从任何客户端接收数据报文;2、使用DatagramSocket实例的receive()方法接收一个DatagramPacket实例,当receive()方法返回时,数据报文就包含了客户端的地址,这样就知道了回复信息应该发送到什么地方;3、使用DatagramSocket实例的send()方法向服务器端返回DatagramPacket实例。Demo这里有一点需要注意:UDP程序在receive()方法处阻塞,直到收到一个数据报文或等待超时。由于UDP协议是不可靠协议,如果数据报在传输过程中发生丢失,那么程序将会一直阻塞在receive()方法处,这样客户端将永远都接收不到服务器端发送回来的数据,但是又没有任何提示。为了避免这个问题,我们在客户端使用DatagramSocket类的setSoTimeout()方法来制定receive()方法的最长阻塞时间,并指定重发数据报的次数,如果每次阻塞都超时,并且重发次数达到了设置的上限,则关闭客户端。ClientDemo.java/** * sinture.com Inc. * Copyright (c) 2016-2018 All Rights Reserved. */ package test.socketDemo.UDP; import java.io.IOException; import java.io.InterruptedIOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; /** * @author yzd * @version Id: ClientDemo.java, v 0.1 2018年07月10日 14:46 yzd Exp $ */ public class ClientDemo { public static final int TIMEOUT = 5000; public static final int MAXNUM = 5; public static void main(String[] args) throws IOException { String str_send = "Hello UDPServer"; byte[] buf = new byte[1024]; //客户端在9000端口监听接收到的数据 DatagramSocket ds = new DatagramSocket(9000); InetAddress loc = InetAddress.getLocalHost(); //定义用来发送数据的DatagramPacket实例 DatagramPacket dp_send= new DatagramPacket(str_send.getBytes(),str_send.length(),loc,3000); //定义用来接收数据的DatagramPacket实例 DatagramPacket dp_receive = new DatagramPacket(buf, 1024); //数据发向本地3000端口 ds.setSoTimeout(TIMEOUT); //设置接收数据时阻塞的最长时间 int tries = 0; //重发数据的次数 boolean receivedResponse = false; //是否接收到数据的标志位 //直到接收到数据,或者重发次数达到预定值,则退出循环 while(!receivedResponse && tries<MAXNUM){ //发送数据 ds.send(dp_send); System.out.println("Client send message succeed."); try{ //接收从服务端发送回来的数据 ds.receive(dp_receive); //如果接收到的数据不是来自目标地址,则抛出异常 if(!dp_receive.getAddress().equals(loc)){ throw new IOException("Received packet from an unknown source"); } //如果接收到数据。则将receivedResponse标志位改为true,从而退出循环 receivedResponse = true; }catch(InterruptedIOException e){ //如果接收数据时阻塞超时,重发并减少一次重发的次数 tries += 1; System.out.println("Time out," + (MAXNUM - tries) + " more tries..." ); } } if(receivedResponse){ System.out.println("client received data from server:"); String str_receive = new String(dp_receive.getData(),0,dp_receive.getLength()) + " from " + dp_receive.getAddress().getHostAddress() + ":" + dp_receive.getPort(); System.out.println(str_receive); //由于dp_receive在接收了数据之后,其内部消息长度值会变为实际接收的消息的字节数, //所以这里要将dp_receive的内部消息长度重新置为1024 dp_receive.setLength(1024); }else{ //如果重发MAXNUM次数据后,仍未获得服务器发送回来的数据,则打印如下信息 System.out.println("No response -- give up."); } ds.close(); } } 复制代码ServerDemo.java/** * sinture.com Inc. * Copyright (c) 2016-2018 All Rights Reserved. */ package test.socketDemo.UDP; import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; /** * @author yzd * @version Id: ServerDemo.java, v 0.1 2018年07月10日 15:12 yzd Exp $ */ public class ServerDemo { public static void main(String[] args) throws IOException { String str_send = "Hello UDPclient"; byte[] buf = new byte[1024]; //服务端在3000端口监听接收到的数据 DatagramSocket ds = new DatagramSocket(3000); //接收从客户端发送过来的数据 DatagramPacket dp_receive = new DatagramPacket(buf, 1024); System.out.println("Server is on,Waiting for client to send data......"); boolean f = true; while(f){ //服务器端接收来自客户端的数据 ds.receive(dp_receive); System.out.println("Server received data from client:"); String str_receive = new String(dp_receive.getData(),0,dp_receive.getLength()) + " from " + dp_receive.getAddress().getHostAddress() + ":" + dp_receive.getPort(); System.out.println(str_receive); //数据发动到客户端的3000端口 DatagramPacket dp_send= new DatagramPacket(str_send.getBytes(),str_send.length(),dp_receive.getAddress(),9000); ds.send(dp_send); System.out.println("Server send message succeed."); //由于dp_receive在接收了数据之后,其内部消息长度值会变为实际接收的消息的字节数, //所以这里要将dp_receive的内部消息长度重新置为1024 dp_receive.setLength(1024); } ds.close(); } } 复制代码应用层浏览器从输入URL地址到最终显示内容的过程DNS查找对应ip过程首先查找浏览器自身的DNS缓存,如果有这个域名映射且没过期(TTL)则直接向该IP发送HTTP请求,否则下一步查找本地操作系统hosts缓存,如果有且没过期,拿出来使用完成DNS解析,否则下一步查找本地DNS域名服务器,如果不可以由该服务器解析,则把请求发至根域名服务器,解析该域名是由谁来授权管理,返回顶级域名服务器的IP地址本地DNS服务器联系顶级域名服务器。顶级域名服务器如果无法解析,则找下一级DNS服务器,并把IP发给本地DNS服务器。以此类推,在DNS域名解析的过程中,使用UDP协议进行不可靠传输,不需要三次握手,传输需要的内容较少,使用UDP更快。HTTP请求过程建立TCP连接发送请求一旦建立了TCP连接,Web浏览器就会向Web服务器发送请求命令。例如:GET/sample/hello.jsp HTTP/1.1。发送请求头信息浏览器发送其请求命令之后,还要以头信息的形式向Web服务器发送一些别的信息,之后浏览器发送了一空白行来通知服务器,它已经结束了该头信息的发送。服务器应答客户机向服务器发出请求后,服务器会客户机回送应答, HTTP/1.1 200 OK ,应答的第一部分是协议的版本号和应答状态码。服务器发送应答头信息正如客户端会随同请求发送关于自身的信息一样,服务器也会随同应答向用户发送关于它自己的数据及被请求的文档。服务器向浏览器发送数据Web服务器向浏览器发送头信息后,它会发送一个空白行来表示头信息的发送到此为结束,接着,它就以Content-Type应答头信息所描述的格式发送用户所请求的实际数据。*7. Web服务器关闭TCP连接一般情况下,一旦Web服务器向浏览器发送了请求数据,它就要关闭TCP连接,然后如果浏览器或者服务器在其头信息加入了这行代码:Connection:keep-alive TCP连接在发送后将仍然保持打开状态,于是,浏览器可以继续通过相同的连接发送请求。保持连接节省了为每个请求建立新连接所需的时间,还节约了网络带宽。如果是第一次访问请求该网址浏览器发送HTTP请求,请求头包括:请求方法(Request Method)协议版本客户端信息(User-Agent)connect请求内容等host如果顺利访问:客户端返回200状态码返回信息包括:返回内容expires设置缓存过期时间contentType返回内容类型contentLengthstatusEtage该缓存的版本号contentEncodingDatecache-controlset-cookie设置本域名下浏览器的cookielastModified如果是第二次浏览器则发出http请求时带上cookie发送if-Modified-since(匹配前一次请求时返回的last-modified)if-None-match(匹配前一次请求时返回的Etag)如果资源没有被修改则返回304状态码。但是如果前一次请求浏览器设置expires,则浏览器首先会检查缓存中的资源,如果在设置的expires时间之内则不会再次发送请求。lastModified代表服务器最后修改时间,精确到秒。expires资源过期时间,精确到秒。Etag则代表资源的版本号,每次修改资源Etag就会变。不同资源的Etag不同。如果正确访问浏览器根据返回content-type,解析服务器返回的数据浏览器解析html文件时,每次遇到frame、img、link、javascript都会重新发送一个http请求javascript下载完后就会立即执行阻塞浏览器的渲染以及绘制。所以一般js链接放在最后,但是很多浏览器都会优先下载js文件和css文件,所以如果js没有对dom操作,尽量defer延迟加载js文件。css在文档头,防止因为css样式改变导致浏览器多次重绘或者回流,是页面闪动卡顿。js和css尽量使用外链形式,减少DOM结构的长度和复杂度,减少浏览器解析html文件的时间。dom节点尽量别深度嵌套,css少使用多层选择器。页面减少http请求的个数,多个图片使用图片dataURI编码或则图片精灵进行合并、css文件压缩合并、js文件压缩合并。配置localhost之后就不会走dns了本文参考github.com/CyC2018/CS-…github.com/linw7/Skill…www.jianshu.com/p/674fb7ec1…
子字符串匹配子字符串匹配算法的定义:文本长度:N模式字符串长度:M有效位移:s解决字符串匹配的算法有非常多,目前常用的有以下几种:暴力查找KMP 算法Boyer-Moore算法Rabin-Karp指纹字符串查找字符串匹配算法通常分为两个步骤:预处理(Preprocessing)和匹配(Matching)。所以算法的总运行时间为预处理和匹配的时间的总和。常用算法暴力查找朴素的字符串匹配算法又称为暴力匹配算法(Brute Force Algorithm),它的主要特点是:没有预处理阶段;滑动窗口总是后移 1 位;对模式中的字符的比较顺序不限定,可以从前到后,也可以从后到前;匹配阶段需要 O((n - m + 1)m) 的时间复杂度;需要 2n 次的字符比较;KMP 算法详细过程:从左到右匹配,直到匹配到第一个字符相等,如下图所示,然后继续匹配后面的字符。到了D,发现不对,这是如果暴力法,则直接将模式后移一位,重新匹配。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。在查找的一开始根据模式字符串,生成一张《部分匹配表》(Partial Match Table)移动位数 = 已匹配的字符数 - 对应的部分匹配值所以移动为数 = 6 - 2 =4这个《部分匹配表》如何生成?"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,- "A"的前缀和后缀都为空集,共有元素的长度为0; - "AB"的前缀为[A],后缀为[B],共有元素的长度为0; - "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0; - "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0; - "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1; - "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2; - "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。 复制代码Boyer-Moore几种常见的字符串匹配算法的性能比较:KMP算法并不是效率最高的算法,实际采用并不多。各种文本编辑器的"查找"功能(Ctrl+F),大多采用Boyer-Moore算法。详细过程:首先,"字符串"与"搜索词"头部对齐,从尾部开始比较。我们看到,"S"与"E"不匹配。这时,"S"就被称为"坏字符"(bad character),即不匹配的字符。我们还发现,"S"不包含在搜索词"EXAMPLE"之中,这意味着可以把搜索词直接移到"S"的后一位。依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在搜索词"EXAMPLE"之中。所以,将搜索词后移两位,两个"P"对齐。"坏字符规则":后移位数 = 坏字符的位置 - 搜索词中的上一次出现位置(如果"坏字符"不包含在搜索词之中,则上一次出现位置为 -1)上图中,比较的是P和E,出现在第6位(0开始),然后P上一次位置是4,所以6-4=2接着继续,一直比较到M:根据"坏字符规则",此时搜索词应该后移 2 - (-1)= 3 位。问题是,此时有没有更好的移法?比较前面一位,"MPLE"与"MPLE"匹配。我们把这种情况称为"好后缀"(good suffix),即所有尾部匹配的字符串。注意,"MPLE"、"PLE"、"LE"、"E"都是好后缀"好后缀规则":后移位数 = 好后缀的位置 - 搜索词中的上一次出现位置这个规则有三个注意点:(1)"好后缀"的位置以最后一个字符为准。假定"ABCDEF"的"EF"是好后缀,则它的位置以"F"为准,即5(从0开始计算)。(2)如果"好后缀"在搜索词中只出现一次,则它的上一次出现位置为 -1。比如,"EF"在"ABCDEF"之中只出现一次,则它的上一次出现位置为-1(即未出现)。(3)如果"好后缀"有多个,则除了最长的那个"好后缀",其他"好后缀"的上一次出现位置必须在头部。比如,假定"BABCDAB"的"好后缀"是"DAB"、"AB"、"B",请问这时"好后缀"的上一次出现位置是什么?回答是,此时采用的好后缀是"B",它的上一次出现位置是头部,即第0位。这个规则也可以这样表达:如果最长的那个"好后缀"只出现一次,则可以把搜索词改写成如下形式进行位置计算"(DA)BABCDAB",即虚拟加入最前面的"DA"。回到上文的这个例子。此时,所有的"好后缀"(MPLE、PLE、LE、E)之中,只有"E"在"EXAMPLE"还出现在头部,所以后移 6 - 0 = 6位。可以看到,"坏字符规则"只能移3位,"好后缀规则"可以移6位。所以,Boyer-Moore算法的基本思想是,每次后移这两个规则之中的较大值。Boyer–Moore 算法的精妙之处在于,其通过两种启示规则来计算后移位数,且其计算过程只与模式 P 有关,而与文本 T 无关。因此,在对模式 P 进行预处理时,可预先生成 "坏字符规则之向后位移表" 和 "好后缀规则之向后位移表",在具体匹配时仅需查表比较两者中最大的位移即可。Rabin-Karp首先计算模式字符串的散列函数, 如果找到一个和模式字符串散列值相同的子字符串, 那么继续验证两者是否匹配.这个过程等价于将模式保存在一个散列表中, 然后在文本中的所有子字符串查找. 但不需要为散列表预留任何空间, 因为它只有一个元素.基本思想长度为M的字符串对应着一个R进制的M位数, 为了用一张大小为Q的散列表来保存这种类型的键, 需要一个能够将R进制的M位数转化为一个0到Q-1之间的int值散列函数, 这里可以用除留取余法.举个例子, 需要在文本 3 1 4 1 5 9 2 6 5 3 5 8 9 7 9 3 查找模式 2 6 5 3 5, 这里R=10, 取Q=997, 则散列值为2 6 5 3 6 % 997 = 613 复制代码然后计算文本中所有长度为5的子字符串并寻找匹配3 1 4 1 5 % 997 = 508 1 4 1 5 9 % 997 = 201 ...... 2 6 5 3 6 % 997 = 613 (匹配) 复制代码计算散列函数在实际中,对于5位的数值, 只需要使用int就可以完成所有需要的计算, 但是当模式长度太大时, 我们使用Horner方法计算模式字符串的散列值2 % 997 = 22 6 % 997 = (2*10 + 6) % 997 = 262 6 5 % 997 = (26*10 + 5) % 997 = 2652 6 5 3 % 997 = (265*10 + 3) % 997 = 6592 6 5 3 5 % 997 = (659*10 + 5) % 997 = 613这里关键的一点就是在于不需要保存这些数的值, 只需保存它们除以Q之后的余数.取余操作的一个基本性质是如果每次算术操作之后都将结果除以Q并取余, 这等价于在完成所有算术操作之后再将最后的结果除以Q并取余.算法实现:构造函数为模式字符串计算了散列值patHash并在变量中保存了R^(M-1) mod Q的值, hashSearch()计算了文本前M个字母的散列值并和模式字符串的散列值比较, 如果没有匹配, 文本指针继续下移一位, 计算新的散列值再次比较,知道成功或结束.蒙特卡洛算法和拉斯维加斯算法区别:总结优点:暴力查找算法:实现简单且在一般情况下工作良好(Java的String类型的indexOf()方法就是采用暴力子字符串查找算法);Knuth-Morris-Pratt算法能够保证线性级别的性能且不需要在正文中回退;Boyer-Moore算法的性能一般情况下都是亚线性级别;Rabin-Karp算法是线性级别;缺点:暴力查找算法所需时间可能和NM成正比;Knuth-Morris-Pratt算法和Boyer-Moore算法需要额外的内存空间;Rabin-Karp算法内循环很长(若干次算术运算,其他算法都只需要比较字符);
前言Selenium很多难解决的问题,我们要首先想到从JS脚本出发,毕竟Selenium还是支持驱动浏览器运行JS脚本的。这篇文章的内容主要是Selenium日常开发中会遇到的坑,以Java代码为主,当然Python的小伙伴不用担心,这里所有的解决方案都是可以在Python中通用的。Selenium主要参考Selenium使用总结(Java版本):juejin.cn/post/684490…Selenium准备chromedriver各版本镜像:npm.taobao.org/mirrors/chr…chromedriver版本与chrome客户端对应支持关系:npm.taobao.org/mirrors/chr…最新版本截图:----------ChromeDriver v2.46 (2019-02-01)---------- Supports Chrome v71-73 Resolved issue 2728: Is Element Displayed command does not work correctly with v0 shadow DOM inserts [[Pri-1]] Resolved issue 755: /session/:sessionId/doubleclick only generates one set of mousedown/mouseup/click events [[Pri-2]] Resolved issue 2744: Execute Script returns wrong error code when JavaScript returns a cyclic data structure [[Pri-2]] Resolved issue 1529: OnResponse behavior can lead to port exhaustion [[Pri-2]] Resolved issue 2736: Close Window command should handle user prompts based on session capabilities [[Pri-2]] Resolved issue 1963: Sending keys to disabled element should throw Element Not interactable error [[Pri-2]] Resolved issue 2679: Timeout value handling is not spec compliant [[Pri-2]] Resolved issue 2002: Add Cookie is not spec compliant [[Pri-2]] Resolved issue 2749: Update Switch To Frame error checks to match latest W3C spec [[Pri-3]] Resolved issue 2716: Clearing Text Boxes [[Pri-3]] Resolved issue 2714: ConnectException: Failed to connect to localhost/0:0:0:0:0:0:0:1:15756. Could not start driver. [[Pri-3]] Resolved issue 2722: Execute Script does not correctly convert document.all into JSON format [[Pri-3]] Resolved issue 2681: ChromeDriver doesn't differentiate "no such element" and "stale element reference" [[Pri-3]] ----------ChromeDriver v2.45 (2018-12-10)---------- Supports Chrome v70-72 Resolved issue 1997: New Session is not spec compliant [[Pri-1]] Resolved issue 2685: Should Assert that the chrome version is compatible [[Pri-2]] Resolved issue 2677: Find Element command returns wrong error code when an invalid locator is used [[Pri-2]] Resolved issue 2676: Some ChromeDriver status codes are wrong [[Pri-2]] Resolved issue 2665: compile error in JS inside of WebViewImpl::DispatchTouchEventsForMouseEvents [[Pri-2]] Resolved issue 2658: Window size commands should handle user prompts [[Pri-2]] Resolved issue 2684: ChromeDriver doesn't start Chrome correctly with options.addArguments("user-data-dir=") [[Pri-3]] Resolved issue 2688: Status command is not spec compliant [[Pri-3]] Resolved issue 2654: Add support for strictFileInteractability [[Pri-]] 复制代码Selenium 滚动至元素滚动至元素参考:blog.csdn.net/sinat_28734…实现代码片段:// 获取元素 WebElement element = webDriver.findElement(By.cssSelector(elementsCss)); // 获取元素左上坐标值 Point elementPoint = element.getLocation(); int documentScrollTop = elementPoint.getY(); // 将页面根据元素滚动至合适位置 jsExecutor.executeScript("window.scrollTo(0," + documentScrollTop + ")"); 复制代码Selenium等待:显示,隐式参考:huilansame.github.io/huilansame.…强制等待sleep(3) # 强制等待3秒再执行下一步 复制代码隐性等待隐形等待是设置了一个最长等待时间,如果在规定时间内网页加载完成,则执行下一步,否则一直等到时间截止,然后执行下一步。注意这里有一个弊端,那就是程序会一直等待整个页面加载完成,也就是一般情况下你看到浏览器标签栏那个小圈不再转,才会执行下一步。# -*- coding: utf-8 -*- from selenium import webdriver driver = webdriver.Firefox() driver.implicitly_wait(30) # 隐性等待,最长等30秒 driver.get('https://huilansame.github.io') print driver.current_url driver.quit() 复制代码需要特别说明的是:隐性等待对整个driver的周期都起作用,所以只要设置一次即可,我曾看到有人把隐性等待当成了sleep在用,走哪儿都来一下…显性等待显性等待,WebDriverWait,配合该类的until()和until_not()方法,就能够根据判断条件而进行灵活地等待了。它主要的意思就是:程序每隔xx秒看一眼,如果条件成立了,则执行下一步,否则继续等待,直到超过设置的最长时间,然后抛出TimeoutException。# -*- coding: utf-8 -*- from selenium import webdriver from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By driver = webdriver.Firefox() driver.implicitly_wait(10) # 隐性等待和显性等待可以同时用,但要注意:等待的最长时间取两者之中的大者 driver.get('https://huilansame.github.io') locator = (By.LINK_TEXT, 'CSDN') try: WebDriverWait(driver, 20, 0.5).until(EC.presence_of_element_located(locator)) print driver.find_element_by_link_text('CSDN').get_attribute('href') finally: driver.close() 复制代码Selenium定位元素后偏差这是一个奇怪的问题,之所以会出现这个坐标偏差是因为windows系统下电脑设置的显示缩放比例造成的,location获取的坐标是按显示100%时得到的坐标,而截图所使用的坐标却是需要根据显示缩放比例缩放后对应的图片所确定的,因此就出现了偏差。解决这个问题有三种方法:1.修改电脑显示设置为100%。这是最简单的方法;2.缩放截取到的页面图片,即将截图的size缩放为宽和高都除以缩放比例后的大小;3.修改Image.crop的参数,将参数元组的四个值都乘以缩放比例。Selenium加载Flash看服务报告pc端截图重构内ChromeUtil.java如何使用网上方案:prefs.put("profile.default_content_setting_values.plugins", 1); prefs.put("profile.content_settings.plugin_whitelist.adobe-flash-player", 1); prefs.put("profile.content_settings.exceptions.plugins.*,*.per_resource.adobe-flash-player", 1); 复制代码经测试Chrome65+无法使用,无效。方法一基本思路:通过Selenium自动访问chrome单个网页的设置页,操作元素,始终允许加载flash。让Selenium自动选择下面的按钮这个操作的Demo代码:package util; import org.openqa.selenium.*; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.support.ui.Select; import java.io.File; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class ChromeUtil { /** * 格式化url进入该url设置页 * @param url * @return */ private static String _base_url(String url){ if (url.isEmpty()){ return url; } try { URL urls = new URL(url); return String.format("%s://%s",urls.getProtocol(),urls.getHost()); }catch (Exception e){ return url; } } /** * 元素选择 * @param driver * @param element * @return */ private static WebElement _shadow_root(WebDriver driver, WebElement element){ return (WebElement)((JavascriptExecutor) driver).executeScript("return arguments[0].shadowRoot", element); } /** * 允许网页的flash运行,chrome67版本可行,75版本提示升级flash * @param driver * @param url */ public static void allow_flash(WebDriver driver, String url) { url = _base_url(url); driver.get(String.format("chrome://settings/content/siteDetails?site=%s",url)); WebElement webele_settings = _shadow_root(driver,(((ChromeDriver)driver).findElementByTagName("settings-ui"))); WebElement webele_container = webele_settings.findElement(By.id("container")); WebElement webele_main = _shadow_root(driver,webele_container.findElement(By.id("main"))); WebElement showing_subpage = _shadow_root(driver,webele_main.findElement(By.className("showing-subpage"))); WebElement advancedPage = showing_subpage.findElement(By.id("advancedPage")); WebElement settings_privacy_page = _shadow_root(driver,advancedPage.findElement(By.tagName("settings-privacy-page"))); WebElement pages = settings_privacy_page.findElement(By.id("pages")); WebElement settings_subpage = pages.findElement(By.tagName("settings-subpage")); WebElement site_details = _shadow_root(driver,settings_subpage.findElement(By.tagName("site-details"))); WebElement plugins = _shadow_root(driver,site_details.findElement(By.id("plugins"))); WebElement permission = plugins.findElement(By.id("permission")); Select sel = new Select(permission); sel.selectByValue("allow"); } /** * @param args */ public static void main(String[] args) { System.setProperty("webdriver.chrome.driver", Constants.PATH_Dict.DRIVER_PATH.getValue()); WebDriver webDriver = null; try { // 初始化webDriver ChromeOptions options = new ChromeOptions(); // options.addArguments("--headless"); // 无头模式 // options.addArguments("--no-sandbox"); // Linux关闭沙盒模式 // options.addArguments("--disable-gpu"); // 禁用显卡 webDriver = new ChromeDriver(options); webDriver.manage().window().setSize(new Dimension(1300, 800)); String url = "https://shanghai.fang.anjuke.com/"; // 获取重定向后网址再打开Flash权限 webDriver.get(url); allow_flash(webDriver,webDriver.getCurrentUrl()); webDriver.get(url); Thread.sleep(1 * 60 * 1000); } catch(Exception e) { e.printStackTrace(); } finally { if(webDriver != null) { webDriver.quit(); } } } } 复制代码方法二在chrome设置里将所有网站加入flash白名单,但实测selenium会打开新的chrome,不读取通用设置,类似无痕窗口,有空再试试。总结全局flash加载的设置按钮在selenium不起作用使用pref加载也没有用禁止javascript禁止运行javascript还是可以通过pref的:HashMap<String, Object> chromePrefs = new HashMap<>(2); chromePrefs.put("profile.managed_default_content_settings.javascript", 2); options.setExperimentalOption("prefs", chromePrefs); 复制代码Selenium调整网页缩放大小运行jsdocument.body.style.zoom='0.5'
前言本文中搭建了一个简易的多人聊天室,使用了WebSocket的基础特性。本文内容摘要:初步理解WebSocket的前后端交互逻辑手把手使用 SpringBoot + WebSocket 搭建一个多人聊天室Demo代码源码及其解释前端展示页面正文新建工程我们新建一个SpringBoot2的项目工程,在默认依赖中,添加websocket依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> 复制代码WebSocket 配置我们先来设置websocket的配置,新建config文件夹,在里面新建类WebSocketConfigimport org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.*; @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); registry.enableSimpleBroker("/topic"); } } 复制代码代码解释:@EnableWebSocketMessageBroker用于启用我们的WebSocket服务器。我们实现了WebSocketMessageBrokerConfigurer接口,并实现了其中的方法。在第一种方法中,我们注册一个websocket端点,客户端将使用它连接到我们的websocket服务器。withSockJS()是用来为不支持websocket的浏览器启用后备选项,使用了SockJS。方法名中的STOMP是来自Spring框架STOMP实现。 STOMP代表简单文本导向的消息传递协议。它是一种消息传递协议,用于定义数据交换的格式和规则。为啥我们需要这个东西?因为WebSocket只是一种通信协议。它没有定义诸如以下内容:如何仅向订阅特定主题的用户发送消息,或者如何向特定用户发送消息。我们需要STOMP来实现这些功能。在configureMessageBroker方法中,我们配置一个消息代理,用于将消息从一个客户端路由到另一个客户端。第一行定义了以“/app”开头的消息应该路由到消息处理方法(之后会定义这个方法)。第二行定义了以“/topic”开头的消息应该路由到消息代理。消息代理向订阅特定主题的所有连接客户端广播消息。在上面的示例中,我们使用的是内存中的消息代理。之后也可以使用RabbitMQ或ActiveMQ等其他消息代理。创建 ChatMessage 实体ChatMessage用来在客户端和服务端中交互我们新建model文件夹,创建实体类ChatMessage。public class ChatMessage { private MessageType type; private String content; private String sender; public enum MessageType { CHAT, JOIN, LEAVE } public MessageType getType() { return type; } public void setType(MessageType type) { this.type = type; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getSender() { return sender; } public void setSender(String sender) { this.sender = sender; } } 复制代码实体中,有三个字段:type:消息类型content:消息内容sender:发送者类型有三种:CHAT: 消息JOIN:加入LEAVE:离开创建Controller来接收和发送消息创建controller文件夹,在controller文件夹添加类ChatControllerimport com.example.websocketdemo.model.ChatMessage; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Controller; @Controller public class ChatController { @MessageMapping("/chat.sendMessage") @SendTo("/topic/public") public ChatMessage sendMessage(@Payload ChatMessage chatMessage) { return chatMessage; } @MessageMapping("/chat.addUser") @SendTo("/topic/public") public ChatMessage addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) { // Add username in web socket session headerAccessor.getSessionAttributes().put("username", chatMessage.getSender()); return chatMessage; } } 复制代码代码解释:我们在websocket配置中,从目的地以/app开头的客户端发送的所有消息都将路由到这些使用@MessageMapping注释的消息处理方法。例如,具有目标/app/chat.sendMessage的消息将路由到sendMessage()方法,并且具有目标/app/chat.addUser的消息将路由到addUser()方法添加WebSocket事件监听完成了上述代码后,我们还需要对socket的连接和断连事件进行监听,这样我们才能广播用户进来和出去等操作。创建listener文件夹,新建WebSocketEventListener类import com.example.websocketdemo.model.ChatMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectedEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent; @Component public class WebSocketEventListener { private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class); @Autowired private SimpMessageSendingOperations messagingTemplate; @EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { logger.info("Received a new web socket connection"); } @EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String username = (String) headerAccessor.getSessionAttributes().get("username"); if(username != null) { logger.info("User Disconnected : " + username); ChatMessage chatMessage = new ChatMessage(); chatMessage.setType(ChatMessage.MessageType.LEAVE); chatMessage.setSender(username); messagingTemplate.convertAndSend("/topic/public", chatMessage); } } } 复制代码代码解释:我们已经在ChatController中定义的addUser()方法中广播了用户加入事件。因此,我们不需要在SessionConnected事件中执行任何操作。在SessionDisconnect事件中,编写代码用来从websocket会话中提取用户名,并向所有连接的客户端广播用户离开事件。创建前端聊天室页面我们在src/main/resources文件下创建前端文件,结构类似这样:static └── css └── main.css └── js └── main.js └── index.html 复制代码1. HTML文件 index.htmlHTML文件包含用于显示聊天消息的用户界面。 它包括sockjs和stomp 两个js库。SockJS是一个WebSocket客户端,它尝试使用本机WebSockets,并为不支持WebSocket的旧浏览器提供支持。 STOMP JS是javascript的stomp客户端。笔者在文件里使用了国内的CDN源<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0"> <title>Spring Boot WebSocket Chat Application</title> <link rel="stylesheet" href="/css/main.css" /> </head> <body> <noscript> <h2>Sorry! Your browser doesn't support Javascript</h2> </noscript> <div id="username-page"> <div class="username-page-container"> <h1 class="title">Type your username</h1> <form id="usernameForm" name="usernameForm"> <div class="form-group"> <input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control" /> </div> <div class="form-group"> <button type="submit" class="accent username-submit">Start Chatting</button> </div> </form> </div> </div> <div id="chat-page" class="hidden"> <div class="chat-container"> <div class="chat-header"> <h2>Spring WebSocket Chat Demo</h2> </div> <div class="connecting"> Connecting... </div> <ul id="messageArea"> </ul> <form id="messageForm" name="messageForm"> <div class="form-group"> <div class="input-group clearfix"> <input type="text" id="message" placeholder="Type a message..." autocomplete="off" class="form-control"/> <button type="submit" class="primary">Send</button> </div> </div> </form> </div> </div> <script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script> <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script> <script src="/js/main.js"></script> </body> </html> 复制代码2. JavaScript main.js添加连接到websocket端点以及发送和接收消息所需的javascript。'use strict'; var usernamePage = document.querySelector('#username-page'); var chatPage = document.querySelector('#chat-page'); var usernameForm = document.querySelector('#usernameForm'); var messageForm = document.querySelector('#messageForm'); var messageInput = document.querySelector('#message'); var messageArea = document.querySelector('#messageArea'); var connectingElement = document.querySelector('.connecting'); var stompClient = null; var username = null; var colors = [ '#2196F3', '#32c787', '#00BCD4', '#ff5652', '#ffc107', '#ff85af', '#FF9800', '#39bbb0' ]; function connect(event) { username = document.querySelector('#name').value.trim(); if(username) { usernamePage.classList.add('hidden'); chatPage.classList.remove('hidden'); var socket = new SockJS('/ws'); stompClient = Stomp.over(socket); stompClient.connect({}, onConnected, onError); } event.preventDefault(); } function onConnected() { // Subscribe to the Public Topic stompClient.subscribe('/topic/public', onMessageReceived); // Tell your username to the server stompClient.send("/app/chat.addUser", {}, JSON.stringify({sender: username, type: 'JOIN'}) ) connectingElement.classList.add('hidden'); } function onError(error) { connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!'; connectingElement.style.color = 'red'; } function sendMessage(event) { var messageContent = messageInput.value.trim(); if(messageContent && stompClient) { var chatMessage = { sender: username, content: messageInput.value, type: 'CHAT' }; stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage)); messageInput.value = ''; } event.preventDefault(); } function onMessageReceived(payload) { var message = JSON.parse(payload.body); var messageElement = document.createElement('li'); if(message.type === 'JOIN') { messageElement.classList.add('event-message'); message.content = message.sender + ' joined!'; } else if (message.type === 'LEAVE') { messageElement.classList.add('event-message'); message.content = message.sender + ' left!'; } else { messageElement.classList.add('chat-message'); var avatarElement = document.createElement('i'); var avatarText = document.createTextNode(message.sender[0]); avatarElement.appendChild(avatarText); avatarElement.style['background-color'] = getAvatarColor(message.sender); messageElement.appendChild(avatarElement); var usernameElement = document.createElement('span'); var usernameText = document.createTextNode(message.sender); usernameElement.appendChild(usernameText); messageElement.appendChild(usernameElement); } var textElement = document.createElement('p'); var messageText = document.createTextNode(message.content); textElement.appendChild(messageText); messageElement.appendChild(textElement); messageArea.appendChild(messageElement); messageArea.scrollTop = messageArea.scrollHeight; } function getAvatarColor(messageSender) { var hash = 0; for (var i = 0; i < messageSender.length; i++) { hash = 31 * hash + messageSender.charCodeAt(i); } var index = Math.abs(hash % colors.length); return colors[index]; } usernameForm.addEventListener('submit', connect, true) messageForm.addEventListener('submit', sendMessage, true) 复制代码代码解释:connect()函数使用SockJS和stomp客户端连接到我们在Spring Boot中配置的/ws端点。成功连接后,客户端订阅/topic/public,并通过向/app/chat.addUser目的地发送消息将该用户的名称告知服务器。stompClient.subscribe()函数采用一种回调方法,只要消息到达订阅主题,就会调用该方法。其它的代码用于在屏幕上显示和格式化消息。3. CSS main.css* { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } html,body { height: 100%; overflow: hidden; } body { margin: 0; padding: 0; font-weight: 400; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1rem; line-height: 1.58; color: #333; background-color: #f4f4f4; height: 100%; } body:before { height: 50%; width: 100%; position: absolute; top: 0; left: 0; background: #128ff2; content: ""; z-index: 0; } .clearfix:after { display: block; content: ""; clear: both; } .hidden { display: none; } .form-control { width: 100%; min-height: 38px; font-size: 15px; border: 1px solid #c8c8c8; } .form-group { margin-bottom: 15px; } input { padding-left: 10px; outline: none; } h1, h2, h3, h4, h5, h6 { margin-top: 20px; margin-bottom: 20px; } h1 { font-size: 1.7em; } a { color: #128ff2; } button { box-shadow: none; border: 1px solid transparent; font-size: 14px; outline: none; line-height: 100%; white-space: nowrap; vertical-align: middle; padding: 0.6rem 1rem; border-radius: 2px; transition: all 0.2s ease-in-out; cursor: pointer; min-height: 38px; } button.default { background-color: #e8e8e8; color: #333; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); } button.primary { background-color: #128ff2; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); color: #fff; } button.accent { background-color: #ff4743; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); color: #fff; } #username-page { text-align: center; } .username-page-container { background: #fff; box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); border-radius: 2px; width: 100%; max-width: 500px; display: inline-block; margin-top: 42px; vertical-align: middle; position: relative; padding: 35px 55px 35px; min-height: 250px; position: absolute; top: 50%; left: 0; right: 0; margin: 0 auto; margin-top: -160px; } .username-page-container .username-submit { margin-top: 10px; } #chat-page { position: relative; height: 100%; } .chat-container { max-width: 700px; margin-left: auto; margin-right: auto; background-color: #fff; box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); margin-top: 30px; height: calc(100% - 60px); max-height: 600px; position: relative; } #chat-page ul { list-style-type: none; background-color: #FFF; margin: 0; overflow: auto; overflow-y: scroll; padding: 0 20px 0px 20px; height: calc(100% - 150px); } #chat-page #messageForm { padding: 20px; } #chat-page ul li { line-height: 1.5rem; padding: 10px 20px; margin: 0; border-bottom: 1px solid #f4f4f4; } #chat-page ul li p { margin: 0; } #chat-page .event-message { width: 100%; text-align: center; clear: both; } #chat-page .event-message p { color: #777; font-size: 14px; word-wrap: break-word; } #chat-page .chat-message { padding-left: 68px; position: relative; } #chat-page .chat-message i { position: absolute; width: 42px; height: 42px; overflow: hidden; left: 10px; display: inline-block; vertical-align: middle; font-size: 18px; line-height: 42px; color: #fff; text-align: center; border-radius: 50%; font-style: normal; text-transform: uppercase; } #chat-page .chat-message span { color: #333; font-weight: 600; } #chat-page .chat-message p { color: #43464b; } #messageForm .input-group input { float: left; width: calc(100% - 85px); } #messageForm .input-group button { float: left; width: 80px; height: 38px; margin-left: 5px; } .chat-header { text-align: center; padding: 15px; border-bottom: 1px solid #ececec; } .chat-header h2 { margin: 0; font-weight: 500; } .connecting { padding-top: 5px; text-align: center; color: #777; position: absolute; top: 65px; width: 100%; } @media screen and (max-width: 730px) { .chat-container { margin-left: 10px; margin-right: 10px; margin-top: 10px; } } @media screen and (max-width: 480px) { .chat-container { height: calc(100% - 30px); } .username-page-container { width: auto; margin-left: 15px; margin-right: 15px; padding: 25px; } #chat-page ul { height: calc(100% - 120px); } #messageForm .input-group button { width: 65px; } #messageForm .input-group input { width: calc(100% - 70px); } .chat-header { padding: 10px; } .connecting { top: 60px; } .chat-header h2 { font-size: 1.1em; } } 复制代码整个项目结构如下:启动启动SpringBoot项目效果入下:补充:使用RabbitMQ代替内存作为消息代理添加依赖:<!-- RabbitMQ Starter Dependency --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <!-- Following additional dependency is required for Full Featured STOMP Broker Relay --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-reactor-netty</artifactId> </dependency> 复制代码然后将WebSocketConfig类中configureMessageBroker方法改为使用RabbitMq,完成!public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); // Use this for enabling a Full featured broker like RabbitMQ registry.enableStompBrokerRelay("/topic") .setRelayHost("localhost") .setRelayPort(61613) .setClientLogin("guest") .setClientPasscode("guest"); } 复制代码总结我们在本文中搭建了一个简易的多人聊天室,使用了WebSocket的特性。本文工程源代码:github.com/qqxx6661/sp…
前言本文内容摘要:为何要改造为分布式集群如何改造为分布式集群用户在聊天室集群如何发消息用户在聊天室集群如何接收消息补充知识点:STOMP 简介功能一:向聊天室集群中的全体用户发消息——Redis的订阅/发布功能二:集群集群用户上下线通知——Redis订阅发布功能三:集群用户信息维护——Redis集合WebSocket集群还有哪些可能性正文WebSocket集群/分布式改造:实现多人在线聊天室为何要改造为分布式集群分布式就是为了解决单点故障问题,想象一下,如果一个服务器承载了1000个大佬同时聊天,服务器突然挂了,1000个大佬瞬间全部掉线,大概明天你就被大佬们吊起来打了。当聊天室改为集群后,就算服务器A挂了,服务器B上聊天的大佬们还可以愉快的聊天,并且在前端还能通过代码,让连接A的大佬们快速重连至存活的服务器B,继续和大家愉快的聊天,岂不美哉!总结一下:实现了分布式WebSocket后,我们可以将流量负载均衡到不同的服务器上并提供一种通信机制让各个服务器能进行消息同步(不然用户A连上服务器A,用户B脸上服务器B,它们发消息的时候对方都没法收到)。如何改造为分布式集群当我们要实现分布式的时候,我们则需要在各个机器上共享这些信息,所以我们需要一个Publish/Subscribe的中间件。我们现在使用Redis作为我们的解决方案。1. 用户在聊天室集群如何发消息假设我们的聊天室集群有服务器A和B,用户Alice连接在A上,Bob连接在B上、Alice向聊天室的服务器A发送消息,A服务器必须要将收到的消息转发到Redis,才能保证聊天室集群的所有服务器(也就是A和B)能够拿到消息。否则,只有Alice在的服务器A能够读到消息,用户Bob在的服务器B并不能收到消息,A和B也就无法聊天了。2. 用户在聊天室集群如何接收消息说完了发送消息,那么如何保证Alice发的消息,其他所有人都能收到呢,前面我们知道了Alice发送的消息已经被传到了Redis的频道,那么所有服务器都必须订阅这个Redis频道,然后把这个频道的消息转发到自己的用户那里,这样自己服务器所管辖的用户就能收到消息。补充知识点:STOMP 简介上期我们搭建了个websocket聊天室demo,并且使用了STOMP协议,但是我并没有介绍到底什么是STOMP协议,同学们会有疑惑,这里对于STOMP有很好地总结:当直接使用WebSocket时(或SockJS)就很类似于使用TCP套接字来编写Web应用。因为没有高层级的线路协议(wire protocol),因此就需要我们定义应用之间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。就像HTTP在TCP套接字之上添加了请求-响应模型层一样,STOMP在WebSocket之上提供了一个基于帧的线路格式(frame-based wire format)层,用来定义消息的语义。与HTTP请求和响应类似,STOMP帧由命令、一个或多个头信息以及负载所组成。例如,如下就是发送数据的一个STOMP帧:>>> SEND transaction:tx-0 destination:/app/marco content-length:20 {"message":"Marco!"} 复制代码功能一:向聊天室集群中的全体用户发消息——Redis的订阅/发布1. 添加Redis依赖pom<!-- redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> 复制代码2. application.properties新增redis配置当然首先要确保你安装了Redis,windows下安装redis比较麻烦,你可以搜索redis-for-windows下载安装。# redis 连接配置 spring.redis.database=0 spring.redis.host=127.0.0.1 spring.redis.password= spring.redis.port=6379 spring.redis.ssl=false # 空闲连接最大数 spring.redis.jedis.pool.max-idle=10 # 获取连接最大等待时间(s) spring.redis.jedis.pool.max-wait=60000 复制代码3. 在application.properties添加频道名定义# Redis定义 redis.channel.msgToAll = websocket.msgToAll 复制代码4. 新建redis/RedisListenerBeanpackage cn.monitor4all.springbootwebsocketdemo.redis; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.stereotype.Component; import java.net.Inet4Address; import java.net.InetAddress; /** * Redis订阅频道属性类 * @author yangzhendong01 */ @Component public class RedisListenerBean { private static final Logger LOGGER = LoggerFactory.getLogger(RedisListenerBean.class); @Value("${server.port}") private String serverPort; @Value("${redis.channel.msgToAll}") private String msgToAll; /** * redis消息监听器容器 * 可以添加多个监听不同话题的redis监听器,只需要把消息监听器和相应的消息订阅处理器绑定,该消息监听器 * 通过反射技术调用消息订阅处理器的相关方法进行一些业务处理 * @param connectionFactory * @param listenerAdapter * @return */ @Bean RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 监听msgToAll container.addMessageListener(listenerAdapter, new PatternTopic(msgToAll)); LOGGER.info("Subscribed Redis channel: " + msgToAll); return container; } } 复制代码5. 聊天室集群:发消息改造我们单机聊天室的发送消息Controller是这样的:@MessageMapping("/chat.sendMessage") @SendTo("/topic/public") public ChatMessage sendMessage(@Payload ChatMessage chatMessage) { return chatMessage; 复制代码我们前端发给我们消息后,直接给/topic/public转发这个消息,让其他用户收到。在集群中,我们需要把消息转发给Redis,并且不转发给前端,而是让服务端监听Redis消息,在进行消息发送。将Controller改为:@Value("${redis.channel.msgToAll}") private String msgToAll; @Autowired private RedisTemplate<String, String> redisTemplate; @MessageMapping("/chat.sendMessage") public void sendMessage(@Payload ChatMessage chatMessage) { try { redisTemplate.convertAndSend(msgToAll, JsonUtil.parseObjToJson(chatMessage)); } catch (Exception e) { LOGGER.error(e.getMessage(), e); } } 复制代码你会发现我们在代码中使用了JsonUtil将实体类ChatMessage转为了Json发送给了Redis,这个Json工具类需要使用到FaskJson依赖:pom添加FastJson依赖<!-- json --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.58</version> </dependency> 复制代码添加Json解析工具类JsonUtil,提供对象转Json,Json转对象的能力package cn.monitor4all.springbootwebsocketdemo.util; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * JSON 转换 */ public final class JsonUtil { private static final Logger LOGGER = LoggerFactory.getLogger(JsonUtil.class); /** * 把Java对象转换成json字符串 * * @param object 待转化为JSON字符串的Java对象 * @return json 串 or null */ public static String parseObjToJson(Object object) { String string = null; try { string = JSONObject.toJSONString(object); } catch (Exception e) { LOGGER.error(e.getMessage()); } return string; } /** * 将Json字符串信息转换成对应的Java对象 * * @param json json字符串对象 * @param c 对应的类型 */ public static <T> T parseJsonToObj(String json, Class<T> c) { try { JSONObject jsonObject = JSON.parseObject(json); return JSON.toJavaObject(jsonObject, c); } catch (Exception e) { LOGGER.error(e.getMessage()); } return null; } } 复制代码6. 聊天室集群:接收消息改造单机的聊天室,我们接收消息是通过Controller直接把消息转发到所有人的频道上,这样就能在所有人的聊天框显示。在集群中,我们需要服务器把消息从Redis中拿出来,并且推送到自己管的用户那边,我们在Service层实现消息的推送。在处理消息之后发送消息:正如前面看到的那样,使用 @MessageMapping 或者 @SubscribeMapping 注解可以处理客户端发送过来的消息,并选择方法是否有返回值。 如果 @MessageMapping注解的控制器方法有返回值的话,返回值会被发送到消息代理,只不过会添加上"/topic"前缀。可以使用@SendTo 重写消息目的地; 如果 @SubscribeMapping注解的控制器方法有返回值的话,返回值会直接发送到客户端,不经过代理。如果加上@SendTo 注解的话,则要经过消息代理。在应用的任意地方发送消息:spring-websocket 定义了一个 SimpMessageSendingOperations 接口(或者使用SimpMessagingTemplate ),可以实现自由的向任意目的地发送消息,并且订阅此目的地的所有用户都能收到消息。我们在service实现发送,需要使用上述第二种方法。新建类service/ChatService:package cn.monitor4all.springbootwebsocketdemo.service; import cn.monitor4all.springbootwebsocketdemo.model.ChatMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; @Service public class ChatService { private static final Logger LOGGER = LoggerFactory.getLogger(ChatService.class); @Autowired private SimpMessageSendingOperations simpMessageSendingOperations; public void sendMsg(@Payload ChatMessage chatMessage) { LOGGER.info("Send msg by simpMessageSendingOperations:" + chatMessage.toString()); simpMessageSendingOperations.convertAndSend("/topic/public", chatMessage); } } 复制代码我们在哪里调用这个service呢,我们需要在监听到消息后调用,所以我们就要有下面的Redis监听消息处理专用类新建类redis/RedisListenerHandle:package cn.monitor4all.springbootwebsocketdemo.redis; import cn.monitor4all.springbootwebsocketdemo.model.ChatMessage; import cn.monitor4all.springbootwebsocketdemo.service.ChatService; import cn.monitor4all.springbootwebsocketdemo.util.JsonUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.stereotype.Component; /** * Redis订阅频道处理类 * @author yangzhendong01 */ @Component public class RedisListenerHandle extends MessageListenerAdapter { private static final Logger LOGGER = LoggerFactory.getLogger(RedisListenerHandle.class); @Value("${redis.channel.msgToAll}") private String msgToAll; @Value("${server.port}") private String serverPort; @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private ChatService chatService; /** * 收到监听消息 * @param message * @param bytes */ @Override public void onMessage(Message message, byte[] bytes) { byte[] body = message.getBody(); byte[] channel = message.getChannel(); String rawMsg; String topic; try { rawMsg = redisTemplate.getStringSerializer().deserialize(body); topic = redisTemplate.getStringSerializer().deserialize(channel); LOGGER.info("Received raw message from topic:" + topic + ", raw message content:" + rawMsg); } catch (Exception e) { LOGGER.error(e.getMessage(), e); return; } if (msgToAll.equals(topic)) { LOGGER.info("Send message to all users:" + rawMsg); ChatMessage chatMessage = JsonUtil.parseJsonToObj(rawMsg, ChatMessage.class); // 发送消息给所有在线Cid chatService.sendMsg(chatMessage); } else { LOGGER.warn("No further operation with this topic!"); } } } 复制代码7. 看看效果这样,我们的改造就基本完成了!我们看一下效果我们将服务器运行在8080上,然后打开localhost:8080,起名Alice进入聊天室随后,我们在application.properties中将端口server.port=8081再次运行程序(别忘了开启IDEA的“允许启动多个并行服务”设置,不然会覆盖掉你的8080服务,如下图),在8081启动一个聊天室,起名Bob进入聊天室。如下两图,我们已经可以在不同端口的两个聊天室,互相聊天了!(注意看url)在互相发送消息是,我们还可以使用命令行监听下Redis的频道websocket.msgToAll,可以看到双方传送的消息。如下图:我们还可以打开Chrome的F12控制台,查看前端的控制台发送消息的log,如下图:大功告成了吗?功能实现了,但是并不完美!你会发现,Bob的加入并没有提醒Bob进入了聊天室(在单机版是有的),这是因为我们在“加入聊天室”的代码还没有修改,在加入时,只有Bob的服务器B里的其他用户知道Bob加入了聊天室。我们还能再进一步!功能二/功能三:集群用户上下线通知,集群用户信息存储我们需要弥补上面的不足,将用户上线下线的广播发送到所有服务器上。此外,我还希望以后能够查询集群中所有的在线用户,我们在redis中添加一个set,来保存用户名,这样就可以随时得到在线用户的数量和名称。1. 在application.properties添加频道名定义# Redis定义 redis.channel.userStatus = websocket.userStatus redis.set.onlineUsers = websocket.onlineUsers 复制代码我们增加两个定义第一个是新增redis频道websocket.userStatus用来广播用户上下线消息第二个是redis的set,用来保存在线用户信息2. 在RedisListenerBean添加新频道监听container.addMessageListener(listenerAdapter, new PatternTopic(userStatus)); 复制代码3. 在ChatService中添加public void alertUserStatus(@Payload ChatMessage chatMessage) { LOGGER.info("Alert user online by simpMessageSendingOperations:" + chatMessage.toString()); simpMessageSendingOperations.convertAndSend("/topic/public", chatMessage); } 复制代码在service中我们向本服务器的用户广播消息,用户上线或者下线的消息都通过这里传达。4. 修改ChatController中的addUser方法@MessageMapping("/chat.addUser") public void addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) { LOGGER.info("User added in Chatroom:" + chatMessage.getSender()); try { headerAccessor.getSessionAttributes().put("username", chatMessage.getSender()); redisTemplate.opsForSet().add(onlineUsers, chatMessage.getSender()); redisTemplate.convertAndSend(userStatus, JsonUtil.parseObjToJson(chatMessage)); } catch (Exception e) { LOGGER.error(e.getMessage(), e); } } 复制代码我们修改了addUser方法,在这里往redis中广播用户上线的消息,并把用户名username写入redis的set中(websocket.onlineUsers)5. 修改WebSocketEventListener中的handleWebSocketDisconnectListener方法@EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String username = (String) headerAccessor.getSessionAttributes().get("username"); if(username != null) { LOGGER.info("User Disconnected : " + username); ChatMessage chatMessage = new ChatMessage(); chatMessage.setType(ChatMessage.MessageType.LEAVE); chatMessage.setSender(username); try { redisTemplate.opsForSet().remove(onlineUsers, username); redisTemplate.convertAndSend(userStatus, JsonUtil.parseObjToJson(chatMessage)); } catch (Exception e) { LOGGER.error(e.getMessage(), e); } } } 复制代码在用户关闭网页时,websocket会调用该方法,我们在这里需要把用户从redis的在线用户set里删除,并且向集群发送广播,说明该用户退出聊天室。6. 修改Redis监听类RedisListenerHandleelse if (userStatus.equals(topic)) { ChatMessage chatMessage = JsonUtil.parseJsonToObj(rawMsg, ChatMessage.class); if (chatMessage != null) { chatService.alertUserStatus(chatMessage); } 复制代码在监听类中我们接受了来自userStatus频道的消息,并调用service7. 看看效果此外,我们还可以在Reids中查询到用户信息:WebSocket集群还有哪些可能性有了这两篇文章的基础, 我们当然还能实现以下的功能:某用户A单独私信给某用户B,或者私信给某用户群(用户B和C)系统提供外部调用接口,给指定用户/用户群发送消息,实现消息推送系统提供外部接口,实时获取用户数据(人数/用户信息)参考文献深入浅出Websocket(二)分布式Websocket集群juejin.cn/post/684490…Spring消息之STOMP:www.cnblogs.com/jmcui/p/899…总结我们在本文中把单机版的聊天室改为了分布式聊天室,大大提高了聊天室可用性。本文工程源代码:单机版:github.com/qqxx6661/sp…集群版:github.com/qqxx6661/sp…
前言在本文中,我将介绍如何使用WebSocket向实时多人答题对战游戏提供服务端,并详细介绍通接口的设计。本文内容摘要:在线游戏常用的通讯方案如何使用WebSocket实现游戏对战实时通信游戏步骤的画面演示和对应的WebSocket接口设计正文WebSocket实现在线多人游戏——对战答题在线游戏常用的通讯方案参考:blog.csdn.net/honey199396…HTTP优点:协议较成熟,应用广泛、基于TCP/IP,拥有TCP优点、研发成本很低,开发快速、开源软件较多,nginx,apache,tomact等缺点:无状态无连接、只有PULL模式,不支持PUSH、数据报文较大特性:基于TCP/IP应用层协议、无状态,无连接、支持C/S模式、适用于文本传输TCP优点:可靠性 、全双工协议、开源支持多、应用较广泛、面向连接、研发成本低、报文内容不限制(IP层自动分包,重传,不大于1452bytes)缺点:操作系统:较耗内存,支持连接数有限、设计:协议较复杂,自定义应用层协议、网络:网络差情况下延迟较高、传输:效率低于UDP协议特性:面向连接、可靠性、全双工协议、基于IP层、OSI参考模型位于传输层、适用于二进制传输WebScoket优点:协议较成熟、基于TCP/IP,拥有TCP优点、数据报文较小,包头非常小、面向连接,有状态协议、开源较多,开发较快缺点:特性:有状态,面向连接、数据报头较小、适用于WEB3.0,以及其他即时联网通讯UDP优点:操作系统:并发高,内存消耗较低、传输:效率高,网络延迟低、传输模型简单,研发成本低缺点:协议不可靠、单向协议、开源支持少、报文内容有限,不能大于1464bytes、设计:协议设计较复杂、网络:网络差,而且丢数据报文特性:无连接,不可靠,基于IP协议层,OSI参考模型位于传输层,最大努力交付,适用于二进制传输总结对于弱联网类游戏,必须消除类的,卡牌类的,可以直接HTTP协议,考虑安全的话直接HTTPS,或者对内容体做对称加密;对于实时性,交互性要求较高,可以优先选择Websocket,其次TCP协议;对于实时性要求极高,且可达性要求一般可以选择UDP协议;局域网对战类,赛车类,直接来UDP协议吧;WebSocket实现双人在线游戏实时通信我们采用websocket作为我们的通信方案,主要是因为我们希望对战双方能够实时显示对方的得分。本小节详细介绍了我们在线问答对战游戏中,具体的websocket通讯方式定义。本问答游戏规则如下:用户打开h5页面后,输入自己的昵称,发送给服务端,服务端将用户昵称保存到hashmap,并记录用户状态(空闲,游戏中),接着用户进入大厅。大厅中用户可以互相选择,一旦某用户选择了另一位用户,将触发开始游戏,双方进入答题模式。答题的两位用户各回答10题,每题答对为10分,共100分,左上角页面显示自己的分数,右上角显示对方分数,实时通过websocket接收对方分数。10题结束,双方等待对方总分,最后判断输赢,显示结果界面。所以我们需要设计三个WebSocket协议:用户创建昵称,进入玩家大厅用户选择对手,双方进入游戏对战过程实时显示双方分数接下来详细介绍这三种WebSocket接口用户创建昵称,进入玩家大厅打开界面,进入游戏:我们使用了HashMap存储用户状态,private Map<String, StatusEnum> userToStatus = new HashMap<>(); 复制代码用户状态分为空闲和游戏中:public enum StatusEnum { IDLE, IN_GAME } 复制代码WebSocket接口设计如下:WebSocket接口代码如下:@MessageMapping("/game.add_user") @SendTo("/topic/game") public MessageReply addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) throws JsonProcessingException { MessageReply message = new MessageReply(); String sender = chatMessage.getSender(); ChatMessage result = new ChatMessage(); result.setType(MessageTypeEnum.ADD_USER); result.setReceiver(Collections.singletonList(sender)); if (userToStatus.containsKey(sender)) { message.setCode(201); message.setStatus("该用户名已存在"); message.setChatMessage(result); log.warn("addUser[" + sender + "]: " + message.toString()); } else { result.setContent(mapper.writeValueAsString(userToStatus.keySet().stream().filter(k -> userToStatus.get(k).equals(StatusEnum.IDLE)).toArray())); message.setCode(200); message.setStatus("成功"); message.setChatMessage(result); userToStatus.put(sender, StatusEnum.IDLE); headerAccessor.getSessionAttributes().put("username",sender); log.warn("addUser[" + sender + "]: " + message.toString()); } return message; } 复制代码用户选择对手,双方进入游戏在大厅中选择玩家,随后会进入对战:我们使用了HashMap存储了正在对战的用户,给双方配对。private Map<String, String> userToPlay = new HashMap<>(); 复制代码WebSocket接口设计如下:WebSocket接口代码如下:@MessageMapping("/game.choose_user") @SendTo("/topic/game") public MessageReply chooseUser(@Payload ChatMessage chatMessage) throws JsonProcessingException { MessageReply message = new MessageReply(); String receiver = chatMessage.getContent(); String sender = chatMessage.getSender(); ChatMessage result = new ChatMessage(); result.setType(MessageTypeEnum.CHOOSE_USER); if (userToStatus.containsKey(receiver) && userToStatus.get(receiver).equals(StatusEnum.IDLE)) { List<QuestionRelayDTO> list=new ArrayList<>(); questionService.getQuestions(limit).forEach(item->{ QuestionRelayDTO relayDTO=new QuestionRelayDTO(); relayDTO.setTopic_id(item.getId()); relayDTO.setTopic_name(item.getQuestion()); List<Answer> answers=new ArrayList<>(); answers.add(new Answer(1,item.getId(),item.getOptionA(),item.getResult()==1?1:0)); answers.add(new Answer(2,item.getId(),item.getOptionB(),item.getResult()==2?1:0)); answers.add(new Answer(3,item.getId(),item.getOptionC(),item.getResult()==3?1:0)); answers.add(new Answer(4,item.getId(),item.getOptionD(),item.getResult()==4?1:0)); relayDTO.setTopic_answer(answers); list.add(relayDTO); }); result.setContent(mapper.writeValueAsString(list)); result.setReceiver(Arrays.asList(sender, receiver)); message.setCode(200); message.setStatus("匹配成功"); message.setChatMessage(result); userToStatus.put(receiver, StatusEnum.IN_GAME); userToStatus.put(sender, StatusEnum.IN_GAME); userToPlay.put(receiver,sender); userToPlay.put(sender,receiver); log.warn("chooseUser[" + sender + "," + receiver + "]: " + message.toString()); } else { result.setContent(mapper.writeValueAsString(userToStatus.keySet().stream().filter(k -> userToStatus.get(k).equals(StatusEnum.IDLE)).toArray())); result.setReceiver(Collections.singletonList(sender)); message.setCode(202); message.setStatus("该用户不存在或已在游戏中"); message.setChatMessage(result); log.warn("chooseUser[" + sender + "]: " + message.toString()); } return message; } 复制代码对战过程实时显示双方分数对战过程中的演示图:左边显示我方分数,右边显示对方分数WebSocket接口设计如下:WebSocket接口代码如下:@MessageMapping("/game.do_exam") @SendTo("/topic/game") public MessageReply doExam(@Payload ChatMessage chatMessage) throws JsonProcessingException { MessageReply message = new MessageReply(); String sender = chatMessage.getSender(); String receiver = userToPlay.get(sender); ChatMessage result = new ChatMessage(); result.setType(MessageTypeEnum.DO_EXAM); log.warn("userToStatus:" + mapper.writeValueAsString(userToStatus)); if (userToStatus.containsKey(receiver) && userToStatus.get(receiver).equals(StatusEnum.IN_GAME)) { result.setContent(chatMessage.getContent()); result.setSender(sender); result.setReceiver(Collections.singletonList(receiver)); message.setCode(200); message.setStatus("成功"); message.setChatMessage(result); log.warn("doExam[" + receiver + "]: " + message.toString()); }else{ result.setReceiver(Collections.singletonList(sender)); message.setCode(203); message.setStatus("该用户不存在或已退出游戏"); message.setChatMessage(result); log.warn("doExam[" + sender + "]: " + message.toString()); } return message; } 复制代码进一步这个只是个两天赶出来的Demo,当然里成品还有非常大的差距。这里有几个需要继续解决的事情:实现自动匹配/排行榜WebSocket通讯优化:在某些地方使用点对点通讯,而非全部使用广播通讯。我们可以使用convertAndSendToUser()方法,按照名字就可以判断出来,convertAndSendToUser()方法能够让我们给特定用户发送消息。spring webscoket能识别带”/user”的订阅路径并做出处理,例如,如果浏览器客户端,订阅了’/user/topic/greetings’这条路径,stompClient.subscribe('/user/topic/greetings', function(data) { //... }); 复制代码就会被spring websocket利用UserDestinationMessageHandler进行转化成”/topic/greetings-usererbgz2rq”,”usererbgz2rq”中,user是关键字,erbgz2rq是sessionid,这样子就把用户和订阅路径唯一的匹配起来了参考文献点对点通讯:blog.csdn.net/yingxiake/a…总结我们在本文中实现了在线多人对战游戏的服务端WebSocket接口设计,进一步巩固了对WebSocket的基础和应用范围的理解。
salt介绍SaltStack是一个服务器基础架构集中化管理平台,具备配置管理、远程执行、监控等功能,基于Python语言实现,结合轻量级消息队列(ZeroMQ)与Python第三方模块(Pyzmq、PyCrypto、Pyjinjia2、python-msgpack和PyYAML等)构建。SaltStack 采用 C/S模式,server端就是salt的master,client端就是minion,minion与master之间通过ZeroMQ消息队列通信。master监听4505和4506端口,4505对应的是ZMQ的PUB system,用来发送消息,4506对应的是REP system是来接受消息的。命令执行步骤具体步骤如下Salt stack的Master与Minion之间通过ZeroMq进行消息传递,使用了ZeroMq的发布-订阅模式,连接方式包括tcp,ipcsalt命令,将cmd.run ls命令从salt.client.LocalClient.cmd_cli发布到master,获取一个Jodid,根据jobid获取命令执行结果。master接收到命令后,将要执行的命令发送给客户端minion。minion从消息总线上接收到要处理的命令,交给minion._handle_aes处理minion._handle_aes发起一个本地线程调用cmdmod执行ls命令。线程执行完ls后,调用minion._return_pub方法,将执行结果通过消息总线返回给mastermaster接收到客户端返回的结果,调用master._handle_aes方法,将结果写的文件中salt.client.LocalClient.cmd_cli通过轮询获取Job执行结果,将结果输出到终端。saltstack安装测试环境CentOS6.5master:172.20.22.46 (主机名:hadoop1) # 之前配置Spark改的名字slave:172.20.22.159 (主机名:hadoop2)分为以下几步关闭selinux/etc/selinux/config,把SELINUX=enforcing改为SELINUX=disabledmaster安装salt-minion, salt-masterGoogleslave安装salt-minionGoogle配置salt-master参考#指定master,冒号后有一个空格 master: 192.168.2.22 user: root #-------以下为可选-------------- # salt运行的用户,影响到salt的执行权限 user: root #s alt的运行线程,开的线程越多一般处理的速度越快,但一般不要超过CPU的个数 worker_threads: 10 # master的管理端口 publish_port : 4505 # master跟minion的通讯端口,用于文件服务,认证,接受返回结果等 ret_port : 4506 # 如果这个master运行的salt-syndic连接到了一个更高层级的master,那么这个参数需要配置成连接到的这个高层级master的监听端口 syndic_master_port : 4506 # 指定pid文件位置 pidfile: /var/run/salt-master.pid # saltstack 可以控制的文件系统的开始位置 root_dir: / # 日志文件地址 log_file: /var/log/salt_master.log # 分组设置 nodegroups: group_all: '*' # salt state执行时候的根目录 file_roots: base: - /srv/salt/ # 设置pillar 的根目录 pillar_roots: base: - /srv/pillar 复制代码本文主要改动file_roots: base: - /srv/salt/base dev: - /srv/salt/dev test: - /srv/salt/test prod: - /srv/salt/prod 复制代码配置salt-minion参考#指定master,冒号后有一个空格 master: 192.168.2.22 id: minion-01 user: root #-------以下为可选-------------- # minion的识别ID,可以是IP,域名,或是可以通过DNS解析的字符串 id: 192.168.0.100 # salt运行的用户权限 user: root # master的识别ID,可以是IP,域名,或是可以通过DNS解析的字符串 master : 192.168.0.100 # master通讯端口 master_port: 4506 # 备份模式,minion是本地备份,当进行文件管理时的文件备份模式 backup_mode: minion # 执行salt-call时候的输出方式 output: nested # minion等待master接受认证的时间 acceptance_wait_time: 10 # 失败重连次数,0表示无限次,非零会不断尝试到设置值后停止尝试 acceptance_wait_time_max: 0 # 重新认证延迟时间,可以避免因为master的key改变导致minion需要重新认证的syn风暴 random_reauth_delay: 60 # 日志文件位置 log_file: /var/logs/salt_minion.log # 文件路径基本位置 file_roots: base: - /etc/salt/minion/file # pillar基本位置 pillar_roots: base: - /data/salt/minion/pillar 复制代码本文主要改动master : 172.20.22.46 grains: roles: - nginx env: - test myname: - yzd 复制代码启动/重启saltsudo service salt-master start/restartsudo service salt-minion start/restartmaster认证minion的keyGoogle检查安装是否完成[root@hadoop1 Desktop]# salt-run manage.status down: up: - hadoop1 - hadoop2 [root@hadoop1 Desktop]# salt '*' grains.item os myname hadoop1: ---------- myname: os: CentOS hadoop2: ---------- myname: # 自定义的grains - yzd os: CentOS 复制代码部署WordPress环境要求摘自官网 We recommend servers running version 7.2 or greater of PHP and MySQL version 5.6 OR MariaDB version 10.0 or greater. We also recommend either Apache or Nginx as the most robust options for running WordPress, but neither is required.最后的完整文件树[root@hadoop1 base]# tree . ├── mysql │ ├── conf.sls │ ├── files │ │ ├── conf.sh │ │ ├── my.cnf │ │ ├── mysql-5.5.60.tar.gz │ │ ├── mysql-5.6.40.tar.gz │ │ ├── mysqld │ │ └── mysqllns.sh │ ├── init.sls │ └── install.sls ├── top.sls ├── web │ ├── apache.sls │ ├── ap.sls │ └── files │ ├── apache-conf.d │ │ ├── mod_dnssd.conf │ │ ├── php.conf │ │ ├── README │ │ └── welcome.conf │ ├── httpd.conf │ └── php.ini └── wordpress ├── files │ ├── wordpress-latest.tar.gz │ └── wordpress-yang.tar.gz └── wp-install.sls 7 directories, 21 files 复制代码安装Apache和Php流程安装apache和php依赖将主机的apache和php配置文件覆盖从机文件运行apache服务web/ap.sls:lamp-install: pkg.installed: - pkgs: - httpd - php - php-pdo - php-mysql - php-gd apache-config: file.managed: - name: /etc/httpd/conf/httpd.conf #服务实际使用的文件路径 - source: salt://web/files/httpd.conf #salt的源文件用于分发到minion上面 路径是base目录下面的web 这里也支持http和ftp方式 - user: root - group: root - mode: 644 - require: - pkg: lamp-install php-config: file.managed: - name: /etc/php.ini - source: salt://web/files/php.ini - user: root - group: root - mode: 644 #使用watch在apache配置文件发送变化时,重新加载apache配置 lamp-service: service.running: - name: httpd - enable: True - reload: True #如果不加reload 默认会重启服务 - watch: #增加 - file: apache-config #监控上面的apache-config ID 所以说 一个ID在一个状态只能出现一次 apache-conf: file.recurse: - name: /etc/httpd/conf.d - source: salt://web/files/apache-conf.d 复制代码运行结果:[root@hadoop1 base]# salt 'hadoop2' state.sls web.ap hadoop2: ---------- ID: lamp-install Function: pkg.installed Result: True Comment: 4 targeted packages were installed/updated. The following packages were already installed: httpd Started: 09:14:41.225378 Duration: 64220.577 ms Changes: ---------- libXpm: ---------- new: 3.5.10-2.el6 old: php: ---------- new: 5.3.3-49.el6 old: php-cli: ---------- new: 5.3.3-49.el6 old: php-common: ---------- new: 5.3.3-49.el6 old: php-gd: ---------- new: 5.3.3-49.el6 old: php-mysql: ---------- new: 5.3.3-49.el6 old: php-pdo: ---------- new: 5.3.3-49.el6 old: ---------- ID: apache-config Function: file.managed Name: /etc/httpd/conf/httpd.conf Result: True Comment: File /etc/httpd/conf/httpd.conf is in the correct state Started: 09:15:45.451376 Duration: 14.631 ms Changes: ---------- ID: php-config Function: file.managed Name: /etc/php.ini Result: True Comment: File /etc/php.ini is in the correct state Started: 09:15:45.466131 Duration: 4.601 ms Changes: ---------- ID: lamp-service Function: service.running Name: httpd Result: True Comment: Service httpd has been enabled, and is running Started: 09:15:45.477085 Duration: 477.583 ms Changes: ---------- httpd: True ---------- ID: apache-conf Function: file.recurse Name: /etc/httpd/conf.d Result: True Comment: Recursively updated /etc/httpd/conf.d Started: 09:15:45.954926 Duration: 149.052 ms Changes: ---------- /etc/httpd/conf.d/welcome.conf: ---------- diff: --- +++ @@ -9,3 +9,4 @@ ErrorDocument 403 /error/noindex.html </LocationMatch> Summary ------------ Succeeded: 5 (changed=3) Failed: 0 ------------ Total states run: 5 复制代码安装Mysql5.6流程传输mysql源码包至从机解压源码包安装编译所需依赖源码安装mysql将主机my.cnf覆盖从机文件运行conf.sh,使用scripts/mysql_install_db建立数据库运行mysqllns.sh创建所需要的软连接将主机mysqld覆盖从机文件运行mysqldinit.slsinclude: - mysql.install - mysql.conf 复制代码install.sls#install source mysql mysql_source: file.managed: - name: /home/mysql-5.6.40.tar.gz - unless: test -e /home/mysql-5.6.40.tar.gz - source: salt://mysql/files/mysql-5.6.40.tar.gz #tar source mysql extract_mysql: cmd.run: - cwd: /home - names: - tar xf mysql-5.6.40.tar.gz - unless: test -d /home/mysql-5.6.40 - require: - file: mysql_source #useradd for mysql mysql_user: user.present: - name: mysql - uid: 1024 - createhome: False - gid_from_name: True - shell: /sbin/nologin #mysql pkg.install mysql_pkg: pkg.installed: - pkgs: - gcc - gcc-c++ - autoconf - automake - openssl - openssl-devel - zlib - zlib-devel - ncurses-devel - libtool-ltdl-devel - cmake #mysql source install mysql_commpile: cmd.run: - cwd: /home/mysql-5.6.40 - names: - chown root:root /home/mysql-5.6.40 -R - cmake -DCMAKE_INSTALL_PREFIX=/usr/local/mysql -DMYSQL_DATADIR=/usr/local/mysql/data -DDEFAULT_CHARSET=utf8 -DDEFAULT_COLLATTON=utf8_cuicode_ci -DWITH_READLINE=1 -DWITH_SSL=system -DWITH_EMBEDDED_SERVER=1 -DENABLED_LOCAL_INFILE=1 -DDEFAULT_COLLATION=utf8_general_ci -DWITH_MYISAM_STORAGE_ENGINE=1 -DWITH_INNOBASE_STORAGE_ENGINE=1 -DWITH_DEBUG=0 - make - make install - require: - cmd: extract_mysql - pkg: mysql_pkg - unless: test -d /usr/local/mysql 复制代码conf.slsinclude: - mysql.install # mysql for config mysql_cnf: file.managed: - name: /etc/my.cnf - user: root - mode: 755 - source: salt://mysql/files/my.cnf # mysql init salt://mysql/files/conf.sh: cmd.script: - env: - BATCH: 'yes' - require: - cmd: mysql_commpile - pkg: mysql_pkg # mysql lnk salt://mysql/files/mysqllns.sh: cmd.script: - env: - BATCH: 'yes' - require: - cmd: mysql_commpile - pkg: mysql_pkg # mysql server mysql_service: file.managed: - name: /etc/init.d/mysqld - user: root - mode: 755 - source: salt://mysql/files/mysqld cmd.run: - names: - /sbin/chkconfig --add mysqld - /sbin/chkconfig --level 35 mysqld on - unless: /sbin/chkconfig --list mysqld service.running: - name: mysqld - enable: True - reload: True 复制代码conf.sh:#!/bin/bash /usr/local/mysql/scripts/mysql_install_db --user=mysql --basedir=/usr/local/mysql/ --datadir=/usr/local/mysql/data/ 复制代码mysqllns.sh#!/bin/bash ln -sv /usr/local/mysql/bin/mysql /usr/bin ln -sv /usr/local/mysql/bin/mysqladmin /usr/bin/ ln -sv /usr/local/mysql/bin/mysqldump /usr/bin/ #mysql competence /bin/chown -R mysql.mysql /usr/local/mysql/ && /bin/chown -R mysql.mysql /usr/local/mysql/data/ #for wordpress mkdir /var/lib/mysql ln -sv /usr/local/mysql/data/mysql.sock /var/lib/mysql/ 复制代码mysqld#!/bin/sh # chkconfig: 2345 10 50 # description: mysqld basedir=/usr/local/mysql/ datadir=/usr/local/mysql/data/ 复制代码注意: 需要添加下面两行来保证chkconfig设置成功#!/bin/sh # chkconfig: 2345 10 50 复制代码运行结果由于首次运行编译返回日志过长,为了美观的结果,运行了两次,所以软连接脚本显示失败(文件已存在)[root@hadoop1 base]# salt 'hadoop2' state.sls mysql.init hadoop2: ---------- ID: mysql_source Function: file.managed Name: /home/mysql-5.6.40.tar.gz Result: True Comment: unless execution succeeded Started: 10:09:23.030270 Duration: 888.041 ms Changes: ---------- ID: extract_mysql Function: cmd.run Name: tar xf mysql-5.6.40.tar.gz Result: True Comment: unless execution succeeded Started: 10:09:23.919593 Duration: 7.14 ms Changes: ---------- ID: mysql_user Function: user.present Name: mysql Result: True Comment: User mysql is present and up to date Started: 10:09:23.927631 Duration: 2.501 ms Changes: ---------- ID: mysql_pkg Function: pkg.installed Result: True Comment: All specified packages are already installed. Started: 10:09:23.931484 Duration: 2006.185 ms Changes: ---------- ID: mysql_commpile Function: cmd.run Name: cmake -DCMAKE_INSTALL_PREFIX=/usr/local/mysql -DMYSQL_DATADIR=/usr/local/mysql/data -DDEFAULT_CHARSET=utf8 -DDEFAULT_COLLATTON=utf8_cuicode_ci -DWITH_READLINE=1 -DWITH_SSL=system -DWITH_EMBEDDED_SERVER=1 -DENABLED_LOCAL_INFILE=1 -DDEFAULT_COLLATION=utf8_general_ci -DWITH_MYISAM_STORAGE_ENGINE=1 -DWITH_INNOBASE_STORAGE_ENGINE=1 -DWITH_DEBUG=0 Result: True Comment: unless execution succeeded Started: 10:09:25.939461 Duration: 8.829 ms Changes: ---------- ID: mysql_commpile Function: cmd.run Name: make Result: True Comment: unless execution succeeded Started: 10:09:25.948913 Duration: 5.983 ms Changes: ---------- ID: mysql_commpile Function: cmd.run Name: make install Result: True Comment: unless execution succeeded Started: 10:09:25.955397 Duration: 6.567 ms Changes: ---------- ID: mysql_cnf Function: file.managed Name: /etc/my.cnf Result: True Comment: File /etc/my.cnf is in the correct state Started: 10:09:25.962408 Duration: 4.894 ms Changes: ---------- ID: salt://mysql/files/conf.sh Function: cmd.script Result: True Comment: Command 'salt://mysql/files/conf.sh' run Started: 10:09:25.967900 Duration: 200740.417 ms Changes: ---------- pid: 28643 retcode: 0 stderr: 2018-07-24 10:09:26 0 [Warning] TIMESTAMP with implicit DEFAULT value is deprecated. Please use --explicit_defaults_for_timestamp server option (see documentation for more details). 2018-07-24 10:09:26 0 [Note] Ignoring --secure-file-priv value as server is running with --bootstrap. 2018-07-24 10:09:26 0 [Note] /usr/local/mysql//bin/mysqld (mysqld 5.6.40) starting as process 28647 ... 2018-07-24 10:11:06 0 [Warning] TIMESTAMP with implicit DEFAULT value is deprecated. Please use --explicit_defaults_for_timestamp server option (see documentation for more details). 2018-07-24 10:11:06 0 [Note] Ignoring --secure-file-priv value as server is running with --bootstrap. 2018-07-24 10:11:06 0 [Note] /usr/local/mysql//bin/mysqld (mysqld 5.6.40) starting as process 28744 ... stdout: Installing MySQL system tables...OK Filling help tables...OK To start mysqld at boot time you have to copy support-files/mysql.server to the right place for your system PLEASE REMEMBER TO SET A PASSWORD FOR THE MySQL root USER ! To do so, start the server, then issue the following commands: /usr/local/mysql//bin/mysqladmin -u root password 'new-password' /usr/local/mysql//bin/mysqladmin -u root -h 192.168.253.62 password 'new-password' Alternatively you can run: /usr/local/mysql//bin/mysql_secure_installation which will also give you the option of removing the test databases and anonymous user created by default. This is strongly recommended for production servers. See the manual for more instructions. You can start the MySQL daemon with: cd . ; /usr/local/mysql//bin/mysqld_safe & You can test the MySQL daemon with mysql-test-run.pl cd mysql-test ; perl mysql-test-run.pl Please report any problems at http://bugs.mysql.com/ The latest information about MySQL is available on the web at http://www.mysql.com Support MySQL by buying support/licenses at http://shop.mysql.com WARNING: Found existing config file /usr/local/mysql//my.cnf on the system. Because this file might be in use, it was not replaced, but was used in bootstrap (unless you used --defaults-file) and when you later start the server. The new default config file was created as /usr/local/mysql//my-new.cnf, please compare it with your file and take the changes you need. WARNING: Default config file /etc/my.cnf exists on the system This file will be read by default by the MySQL server If you do not want to use this, either remove it, or use the --defaults-file argument to mysqld_safe when starting the server ---------- ID: salt://mysql/files/mysqllns.sh Function: cmd.script Result: False Comment: Command 'salt://mysql/files/mysqllns.sh' run Started: 10:12:46.710451 Duration: 262.12 ms Changes: ---------- pid: 28810 retcode: 1 stderr: ln: creating symbolic link `/usr/bin/mysql': File exists ln: creating symbolic link `/usr/bin/mysqladmin': File exists ln: creating symbolic link `/usr/bin/mysqldump': File exists mkdir: cannot create directory `/var/lib/mysql': File exists ln: creating symbolic link `/var/lib/mysql/mysql.sock': File exists stdout: ---------- ID: mysql_service Function: file.managed Name: /etc/init.d/mysqld Result: True Comment: File /etc/init.d/mysqld is in the correct state Started: 10:12:46.972872 Duration: 4.936 ms Changes: ---------- ID: mysql_service Function: cmd.run Name: /sbin/chkconfig --add mysqld Result: True Comment: unless execution succeeded Started: 10:12:46.977924 Duration: 10.248 ms Changes: ---------- ID: mysql_service Function: cmd.run Name: /sbin/chkconfig --level 35 mysqld on Result: True Comment: unless execution succeeded Started: 10:12:46.988419 Duration: 6.787 ms Changes: ---------- ID: mysql_service Function: service.running Name: mysql Result: True Comment: Service mysql has been enabled, and is in the desired state Started: 10:12:46.995438 Duration: 236.708 ms Changes: ---------- mysql: True Summary ------------- Succeeded: 13 (changed=3) Failed: 1 ------------- Total states run: 14 复制代码安装wordpress流程将wordpress解压至/var/www/html修改wp-config.php并发送至从机在数据库新建名为wordpress数据库wp-install.sls# copy tar.gz wordpress_source: file.managed: - name: /home/wordpress-yang.tar.gz - unless: test -e /home/wordpress-yang.tar.gz - source: salt://wordpress/files/wordpress-yang.tar.gz #tar source extract_wordpress: cmd.run: - cwd: /home - names: - tar xf wordpress-yang.tar.gz - chown root:root /home/wordpress -R - unless: test -d /home/wordpress - require: - file: wordpress_source # move to www move_wordpress: cmd.run: - cwd: /home - names: - cp -rf wordpress/* /var/www/html/ - mysql -uroot -e "create database IF NOT EXISTS wordpress" - require: - cmd: extract_wordpress 复制代码运行结果[root@hadoop1 base]# salt 'hadoop2' state.sls wordpress.wp-install hadoop2: ---------- ID: wordpress_source Function: file.managed Name: /home/wordpress-yang.tar.gz Result: True Comment: unless execution succeeded Started: 10:22:32.580145 Duration: 1199.729 ms Changes: ---------- ID: extract_wordpress Function: cmd.run Name: tar xf wordpress-yang.tar.gz Result: True Comment: unless execution succeeded Started: 10:22:33.780510 Duration: 9.208 ms Changes: ---------- ID: extract_wordpress Function: cmd.run Name: chown root:root /home/wordpress -R Result: True Comment: unless execution succeeded Started: 10:22:33.790282 Duration: 6.722 ms Changes: ---------- ID: move_wordpress Function: cmd.run Name: cp -rf wordpress/* /var/www/html/ Result: True Comment: Command "cp -rf wordpress/* /var/www/html/" run Started: 10:22:33.797953 Duration: 2065.049 ms Changes: ---------- pid: 29030 retcode: 0 stderr: stdout: ---------- ID: move_wordpress Function: cmd.run Name: mysql -uroot -e "create database IF NOT EXISTS wordpress" Result: True Comment: Command "mysql -uroot -e "create database IF NOT EXISTS wordpress"" run Started: 10:22:35.863419 Duration: 53.151 ms Changes: ---------- pid: 29033 retcode: 0 stderr: stdout: Summary ------------ Succeeded: 5 (changed=2) Failed: 0 ------------ Total states run: 5 复制代码总结成功通过master的saltstack为slave安装lamp环境,并将openstack部署成功。遇到的问题整理无法运行state.sls报错the function "state.highstate" is running as PID xxxx kill掉slave的进程php admin不显示网页重启apache sudo service httpd restart参考https://www.jianshu.com/p/624b9cf51c64 https://blog.csdn.net/chengyuqiang/article/details/78119322 https://www.linuxidc.com/Linux/2017-12/149615.html http://www.cnblogs.com/xiewenming/p/7674806.html
前言欢迎来到菜鸟SpringCloud实战入门系列(SpringCloudForNoob),该系列通过层层递进的实战视角,来一步步学习和理解SpringCloud。本系列适合有一定Java以及SpringBoot基础的同学阅读。配置中心客户端主动刷新机制 + 配置中心服务化和高可用改造客户端Refresh:客户端主动获取配置信息经过上一章节配置好Spring Cloud Config后,客户端(config-client模块)能够获得从服务端(config-server模块)传来的配置文件信息。文末写出了一个问题,客户端并不能获取更新后的配置信息,想要刷新信息,必须重启config-client模块,这显然不切实际。实验:验证客户端无法更新下面做一个实验,启动客户端和服务端,随后更新dev配置文件,新加了(new):随后push到远程仓库,我们再次直接访问服务端的 http://localhost:8769/spring-cloud-config-dev.properties :发现更新成了新的配置文件。之后访问客户端:发现依然是老的配置文件信息,客户端只在启动时获取了当时的配置文件信息。开启更新机制我们只需要在config-server模块中进行改动。实现Refresh机制需要添加依赖spring-boot-starter-actuator,这个依赖在我们的root模块中就已经添加,在config-server模块就不需要重复添加了。如果你在root父模块没有添加,那么就需要加上。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> 复制代码对于springboot 1.5.X 以上版本,需要在配置文件中关闭安全认证。management.security.enabled=false 复制代码对于springboot 2,上述配置不起作用,需要修改server端配置文件,将端口暴露:management: endpoints: web: exposure: include: "*" 复制代码还要将客户端端口暴露:management: endpoints: web: exposure: include: refresh 复制代码测试:我们开启服务端和客户端,先测试下未更新前获取的配置信息:随后我们修改配置文件并push:然后以post请求访问 curl -v -X POST "http://localhost:8002/actuator/refresh" :得到了:如果在不变更的情况下,再次发送POST请求:使用Webhook监听配置更新WebHook是当某个事件发生时,通过发送http post请求的方式来通知信息接收方。Webhook来监测你在Github.com上的各种事件,最常见的莫过于push事件。如果你设置了一个监测push事件的Webhook,那么每当你的这个项目有了任何提交,这个Webhook都会被触发,这时Github就会发送一个HTTP POST请求到你配置好的地址。如此一来,你就可以通过这种方式去自动完成一些重复性工作,比如,你可以用Webhook来自动触发一些持续集成(CI)工具的运作,比如Travis CI;又或者是通过 Webhook 去部署你的线上服务器。下图就是github上面的webhook配置。这种机制适用于只有少数微服务的情况,在大量未服务的情况下,这种机制就显得捉襟见肘。消息总线机制如果项目少配置少的情况可以通过/refresh来手动刷新配置,如果项目比较复杂的情况呢这种肯定是行不通的,Spring Cloud Bus消息总线可以解决配置修改的真正的动态刷新。我们放在下一章进行学习。配置中心服务化和高可用改造目前我们的两个子模块config-server和config-client是相互耦合的,client需要输入server的地址来调用它,这样的调用违反了低耦合原则(低耦合:就是A模块与B模块存在依赖关系,那么当B发生改变时,A模块仍然可以正常工作,那么就认为A与B是低耦合的。)现在我们就是用之前学习的Eureka来对配置中心进行改造。服务端改造改造集中在两方面,一个是在注册中心注册,一个是开启多个服务端达到高可用的目的。添加依赖(由于eureka的依赖在我们的父模块已经添加,所以对于config-server模块我们不需要改动):<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-config-server</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-eureka</artifactId> </dependency> </dependencies> 复制代码配置文件新增注册配置:server: port: 8769 spring: application: name: spring-cloud-config-server cloud: config: server: git: uri: https://xxxxxxxxxxx.git # 配置git仓库的地址 search-paths: config-repo # git仓库地址下的相对地址,可以配置多个,用,分割。 username: xxxxxx # git仓库的账号 password: xxxxx # git仓库的密码 # 客户端调用需要 management: endpoints: web: exposure: include: "*" eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ 复制代码启动类添加@EnableDiscoveryClient:@SpringBootApplication @EnableConfigServer @EnableDiscoveryClient public class ConfigServerApplication { public static void main(String[] args) { SpringApplication.run(ConfigServerApplication.class, args); } } 复制代码客户端改造依赖修改:同服务端相同,我们不需要修改,父模块将注册中心等都已经引入(见第一章)import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; @SpringBootApplication @EnableDiscoveryClient public class ConfigClientApplication { public static void main(String[] args) { SpringApplication.run(ConfigClientApplication.class, args); } } 复制代码配置文件yml修改:在前面我们给config-client子模块配置了两个yml文件,一个是传统application.yml一个是bootstrap.yml,bootstrap.yml的启动优先于application.yml我们修改bootstrap.yml,添加注册中心配置,并将config的配置加上:spring.cloud.config.discovery.enabled :开启Config服务发现支持spring.cloud.config.discovery.serviceId :指定server端的name,也就是server端spring.application.name的值删除spring.cloud.config.urispring: cloud: config: name: spring-cloud-config profile: dev label: master discovery: enabled: true service-id: spring-cloud-config-server eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/ 复制代码随后我们启动三个模块:Eureka子模块config-serverconfig-client查看Eureka状态 http://localhost:8761/ :为了达成高可用,我们将config-server的端口号再修改为8770,启动一个新的config-server,这样就有两个config-server同时为我们服务。调用客户端接口:本章代码github.com/qqxx6661/sp…参考www.ityouknow.com/springcloud…blog.csdn.net/youanyyou/a…www.ityouknow.com/springcloud…
前言本文介绍了如何整合搜索引擎elasticsearch与springboot,对外提供数据查询接口。开发环境组件介绍:elasticsearch:搜索引擎,用于存储待搜索数据logstash:用于将mysql中的商品数据同步到搜索引擎中elasticsearch-head(可选):elasticsearch可视化工具kibana(可选):elasticsearch可视化工具本文测试环境:springboot:1.5.16elasticsearch:2.3.5(springboot1.5仅支持2.x的es)logstash:6.5.4开发步骤使用Docker部署elasticsearchdocker下一键启动es,可根据需要的版本号对语句做修改sudo docker run -it --rm --name elasticsearch -d -p 9200:9200 -p 9300:9300 elasticsearch:2.3.5 复制代码注意到该命令:--rm参数:容器终止后销毁-d:后台进程-p 9200:9200 -p 9300:9300:开放了9200端口和9300端口得到如图:此时打开网页localhost:9200即可查看状态,显示类似为:{ "name" : "Ant-Man", "cluster_name" : "elasticsearch", "version" : { "number" : "2.3.5", "build_hash" : "90f439ff60a3c0f497f91663701e64ccd01edbb4", "build_timestamp" : "2016-07-27T10:36:52Z", "build_snapshot" : false, "lucene_version" : "5.5.0" }, "tagline" : "You Know, for Search" } 复制代码注意:docker的es默认对0.0.0.0公网开放下载并使用logstash并导入数据schedule => "* * * * *"默认为每分钟同步一次input { jdbc { jdbc_connection_string => "jdbc:mysql://localhost:3306/pm_backend" jdbc_user => "root" jdbc_password => "xxxxxxxxxx" jdbc_driver_library => "xxxxxxxx/mysql-connector-java-5.1.6.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_paging_enabled => "true" jdbc_page_size => "5000" statement=> "select * from pm_jd_item" schedule => "* * * * *" type => "pm_jd_item" } } output { elasticsearch { hosts => "localhost:9200" index => "pm_backend" document_type => "%{type}" document_id => "%{id}" } stdout { codec => json_lines } } 复制代码在logstash目录下执行命令,完成数据的导入:bin/logstash -f jdbc.conf 复制代码得到如图:同步完成后,使用elasticsearch-head查看(或者用kibana,请随意):整合进springboot添加pom.xml<!-- 搜索引擎:elastic-search--> <dependency> <groupId>org.elasticsearch</groupId> <artifactId>elasticsearch</artifactId> <version>2.4.6</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-elasticsearch</artifactId> </dependency> 复制代码修改application.properties# elasticsearch spring.data.elasticsearch.cluster-name=elasticsearch #节点地址,多个节点用逗号隔开 spring.data.elasticsearch.cluster-nodes=127.0.0.1:9300 #spring.data.elasticsearch.local=false spring.data.elasticsearch.repositories.enable=true 复制代码在需要进行搜索的实体类上添加@Document、@Id、@Field等标注,本例为JdItem.java@Document(indexName = "pm_backend", type = "pm_jd_item") public class JdItem implements Serializable { @Id private Integer id; @Field(type = FieldType.Long) private Long itemId; @Field(type = FieldType.Long) private Long categoryId; @Field(type = FieldType.String) private String name; 复制代码添加JdItemRepository继承ElasticsearchRepositorypublic interface JdItemRepository extends ElasticsearchRepository<JdItem, Integer>{ } 复制代码编写JdItemController中的查询接口findJdItemByName代码截取自个人项目京东价格监控,仅供参考!/** * 根据商品名在pm_jd_item中搜索商品 * @param itemName * @param startRow * @param pageSize * @return */ @ApiOperation(value="查询商品", notes="查询商品") @RequestMapping(value = "/findJdItemByName", method = {RequestMethod.GET}) public ResponseData<List<JdItem>> findJdItemByName( @ApiParam("用户输入的商品名") @RequestParam(value = "itemName") String itemName, @ApiParam("页码索引(默认为0)") @RequestParam(value = "startRow", required = false, defaultValue = "0") int startRow, @ApiParam("每页的商品数量(默认为10)") @RequestParam(value = "pageSize", required = false, defaultValue = "10") int pageSize ){ ResponseData<List<JdItem>> responseData = new ResponseData<>(); try { FunctionScoreQueryBuilder functionScoreQueryBuilder = QueryBuilders.functionScoreQuery().add(QueryBuilders.matchPhraseQuery("name", itemName), ScoreFunctionBuilders.weightFactorFunction(100)).scoreMode("sum").setMinScore(10); Pageable pageable = new PageRequest(startRow, pageSize); SearchQuery searchQuery = new NativeSearchQueryBuilder().withPageable(pageable).withQuery(functionScoreQueryBuilder).build(); Page<JdItem> jdItems = jdItemRepository.search(searchQuery); // Page分页getTotalPages()返回了应有的页数,临时放在errorMsg传给前端 responseData.jsonFill(1, String.valueOf(jdItems.getTotalPages()), jdItems.getContent()); } catch (Exception e) { e.printStackTrace(); responseData.jsonFill(2, e.getMessage(), null); } return responseData; } } 复制代码运行springboot调用findJdItemByName接口,得到:参考Docker安装ES & Kibana:www.jianshu.com/p/fdfead5ac…Elasticsearch之使用Logstash导入Mysql数据:blog.codecp.org/2018/04/16/…
前言由于业务需求,需要同时在SpringBoot中配置两套数据源(连接两个数据库),要求能做到service层在调用各数据库表的mapper时能够自动切换数据源,也就是mapper自动访问正确的数据库。本文内容:在Springboot+Mybatis项目的基础上,学习多数据源的快速配置避免网上某些配置数据源文章的深坑正文多数据源配置实战(整合MyBatis)SpringBoot版本:2.0.6.RELEASE项目结构图(原谅我保护隐私代码):排除SpringBoot的自动配置类DataSourceAutoConfiguration首先要在@SpringBootApplication排除该类,因为它会读取application.properties文件的spring.datasource.*属性并自动配置单数据源@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) 复制代码在application.properties中配置多数据源连接信息你需要连接多少个数据库源,就配置几个,名字可以自由命名代替db1,db2# database db.conn.str = useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useLocalSessionState=true&tinyInt1isBit=false spring.datasource.db1.jdbc-url=jdbc:mysql://xxxx1:xxxx/xxxxx1?${db.conn.str} spring.datasource.db1.username=xxxxx spring.datasource.db1.password=xxxxx spring.datasource.db1.driver-class-name=com.mysql.jdbc.Driver spring.datasource.db2.jdbc-url=jdbc:mysql://xxxxx2:xxxx/xxxxx2?${db.conn.str} spring.datasource.db2.username=xxxxx spring.datasource.db2.password=xxxxx spring.datasource.db2.driver-class-name=com.mysql.jdbc.Driver 复制代码注意:这里请一定将spring.datasource.db1.url改为spring.datasource.db1.jdbc-url手动创建数据库配置类由于我们禁掉了自动数据源配置,因些下一步就需要手动将这些数据源创建出来,创建DataSourceConfig类@Configuration public class DataSourceConfig { @Bean(name = "db1") @ConfigurationProperties(prefix = "spring.datasource.db1") public DataSource businessDbDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "db2") @ConfigurationProperties(prefix = "spring.datasource.db2") public DataSource newhomeDbDataSource() { return DataSourceBuilder.create().build(); } } 复制代码分别配置不同数据源的mybatis的SqlSessionFactory这样做可以让我们的不同包名底下的mapper自动使用不同的数据源创建Db1Config:/** * @author yangzhendong01 */ @Configuration @MapperScan(basePackages = {"com.xxxxx.webApi.mapper.db1"}, sqlSessionFactoryRef = "sqlSessionFactoryDb1") public class Db1Config { @Autowired @Qualifier("db1") private DataSource dataSourceDb1; @Bean public SqlSessionFactory sqlSessionFactoryDb1() throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSourceDb1); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/db1/*.xml")); return factoryBean.getObject(); } @Bean public SqlSessionTemplate sqlSessionTemplateDb1() throws Exception { return new SqlSessionTemplate(sqlSessionFactoryDb1()); } } 复制代码创建Db2Config:/** * @author yangzhendong01 */ @Configuration @MapperScan(basePackages = {"com.xxxxx.webApi.mapper.db2"}, sqlSessionFactoryRef = "sqlSessionFactoryDb2") public class Db2Config { @Autowired @Qualifier("db2") private DataSource dataSourceDb2; @Bean public SqlSessionFactory sqlSessionFactoryDb2() throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSourceDb2); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/db2/*.xml")); return factoryBean.getObject(); } @Bean public SqlSessionTemplate sqlSessionTemplateDb2() throws Exception { return new SqlSessionTemplate(sqlSessionFactoryDb2()); } } 复制代码注意:此步一定要添加mapper.xml文件扫描路径,否则报错Invalid bound statement (not found)factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/xxxxxx/*.xml")); 复制代码完成这些配置后,假设我们有2个Mapper mapper.db1.xxxMapper和mapper.db2.xxxMapper,我们在程序的任何位置使用前者时会自动连接db1库,后者连接db2库。参考文献主要参考:blog.csdn.net/neosmith/ar…其他参考:blog.didispace.com/springbootm…总结本文在一个Springboot+Mybatis项目的基础上,学习多数据源的快速配置。
前言当你兴冲冲地开始运行自己的Java项目时,你是否遇到过如下问题:程序在稳定运行了,可是实现的功能点了没反应。为了修复Bug而上线的新版本,上线后发现Bug依然在,却想不通哪里有问题?想到可能出现问题的地方,却发现那里没打日志,没法在运行中看到问题,只能加了日志输出重新打包——部署——上线程序功能正常了,可是为啥响应时间这么慢,在哪里出现了问题?程序不但稳定运行,而且功能完美,但跑了几天或者几周过后,发现响应速度变慢了,是不是内存泄漏了?以前,你碰到这些问题,解决的办法大多是,修改代码,重新上线。但是在大公司里,上线的流程是非常繁琐的,如果为了多加一行日志而重新发布版本,无疑是非常折腾人的。好了,前言已经超过字数了,哈哈,在本篇文章里,你能够了解:Arthas使用实例:帮助你快速让你上手,拯救你的低效率Debug使用Arthas解决具体问题:看一下Arthas帮我拯救了多少时间相似工具:看看线上Debug还有没有别的工具可以使用原理浅谈:莫在浮沙筑高阁!你需要大概了解下Arthas的原理线上Debug神器Arthas快速启动快速启动它,你只需要两行命令:wget https://alibaba.github.io/arthas/arthas-boot.jar java -jar arthas-boot.jar 复制代码随后,在界面出现的进程中,选择你的程序序号,比如1这样你就进入了arthas的控制台基本使用Arthas有如下功能:1. 首先是我认为的“上帝视角”指令:Dashboard当前系统的实时数据面板,按 ctrl+c 退出。当运行在Ali-tomcat时,会显示当前tomcat的实时信息,如HTTP请求的qps, rt, 错误数, 线程池信息等等。通过这些,你可以对于整个程序进程有个直观的数据监控。2. 类加载问题相关指令SC:查看JVM已加载的类信息通过SC我们可以看到我们这个类的详细信息,包括是从哪个jar包读取的,他是不是接口/枚举类等,甚至包括他是从哪个类加载器加载的。上图中代码:[arthas@37]$ sc -d *MathGame class-info demo.MathGame code-source /home/scrapbook/tutorial/arthas-demo.jar name demo.MathGame isInterface false isAnnotation false isEnum false isAnonymousClass false isArray false isLocalClass false isMemberClass false isPrimitive false isSynthetic false simple-name MathGame modifier public annotation interfaces super-class +-java.lang.Object class-loader +-sun.misc.Launcher$AppClassLoader@70dea4e +-sun.misc.Launcher$ExtClassLoader@69260973 classLoaderHash 70dea4e 复制代码SC也可以查看已加载的类,帮助你看是否有没有纳入进来的类,尤其是在Spring中,可以判断的你的依赖有没有正确的进来。上图中代码:# 查看JVM已加载的类信息 [arthas@37]$ sc javax.servlet.Filter com.example.demo.arthas.AdminFilterConfig$AdminFilter javax.servlet.Filter org.apache.tomcat.websocket.server.WsFilter org.springframework.boot.web.filter.OrderedCharacterEncodingFilter org.springframework.boot.web.filter.OrderedHiddenHttpMethodFilter org.springframework.boot.web.filter.OrderedHttpPutFormContentFilter org.springframework.boot.web.filter.OrderedRequestContextFilter org.springframework.web.filter.CharacterEncodingFilter org.springframework.web.filter.GenericFilterBean org.springframework.web.filter.HiddenHttpMethodFilter org.springframework.web.filter.HttpPutFormContentFilter org.springframework.web.filter.OncePerRequestFilter org.springframework.web.filter.RequestContextFilter org.springframework.web.servlet.resource.ResourceUrlEncodingFilter Affect(row-cnt:14) cost in 11 ms. # 查看已加载类的方法信息 [arthas@37]$ sm java.math.RoundingMode java.math.RoundingMode <init>(Ljava/lang/String;II)V java.math.RoundingMode values()[Ljava/math/RoundingMode; java.math.RoundingMode valueOf(I)Ljava/math/RoundingMode; java.math.RoundingMode valueOf(Ljava/lang/String;)Ljava/math/RoundingMode; Affect(row-cnt:4) cost in 6 ms. 复制代码jad:反编译某个类,或者反编译某个类的某个方法上图中代码:# 反编译只显示源码 jad --source-only com.Arthas # 反编译某个类的某个方法 jad --source-only com.Arthas mysql [arthas@37]$ jad demo.MathGame ClassLoader: +-sun.misc.Launcher$AppClassLoader@70dea4e +-sun.misc.Launcher$ExtClassLoader@69260973 Location: /home/scrapbook/tutorial/arthas-demo.jar /* * Decompiled with CFR. */ package demo; import java.io.PrintStream; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.TimeUnit; public class MathGame { private static Random random = new Random(); public int illegalArgumentCount = 0; public List<Integer> primeFactors(int number) { if (number < 2) { ++this.illegalArgumentCount; throw new IllegalArgumentException("number is: " + number + ", need >= 2"); } ArrayList<Integer> result = new ArrayList<Integer>(); int i = 2; while (i <= number) { if (number % i == 0) { result.add(i); number /= i; i = 2; continue; } ++i; } return result; } public static void main(String[] args) throws InterruptedException { MathGame game = new MathGame(); do { game.run(); TimeUnit.SECONDS.sleep(1L); } while (true); } public void run() throws InterruptedException { try { int number = random.nextInt() / 10000; List<Integer> primeFactors = this.primeFactors(number); MathGame.print(number, primeFactors); } catch (Exception e) { System.out.println(String.format("illegalArgumentCount:%3d, ", this.illegalArgumentCount) + e.getMessage()); } } public static void print(int number, List<Integer> primeFactors) { StringBuffer sb = new StringBuffer(number + "="); for (int factor : primeFactors) { sb.append(factor).append('*'); } if (sb.charAt(sb.length() - 1) == '*') { sb.deleteCharAt(sb.length() - 1); } System.out.println(sb); } } Affect(row-cnt:1) cost in 760 ms. 复制代码3. 方法运行相关指令watch:方法执行的数据观测你可以通过watch指令,来监控某个类,监控后,运行下你的功能,复现下场景,arthas会提供给你具体的出参和入参,帮助你排查故障trace:输出方法调用路径,并输出耗时这个指令对于优化代码非常的有用,可以看出具体每个方法执行的时间,如果是for循环等重复语句,还能看出n次循环中的最大耗时,最小耗时,和平均耗时,完美!tt:官方名为时空隧道这是我调试用的最多的指令,在你对某方法开启tt后,会记录下每一次的调用(你需要设置最大监控次数),然后你可以在任何时候会看这里面的调用,包括出参,入参,运行耗时,是否异常等。非常强大。4. 线程调试相关指令thread相关命令:thread -n:排列出 CPU 使用率 Top N 的线程。thread -b:排查阻塞的线程我们代码有时候设计的不好,会引发死锁的问题,卡住整个线程执行,使用这个指令可以轻松的找到问题线程,以及问题的执行语句。强大的ognl表达式众所周知,一般来说,表达式都是调试工具里最强的指令,哈哈。在Arthas中你可以利用ognl表达式语言做很多事,比如执行某个方法,获取某个信息,甚至进行修改。[arthas@19856]$ ognl '@com.Arthas@hashSet' @HashSet[ @String[count1], @String[count2], @String[count29], @String[count28], @String[count0], @String[count27], @String[count5], @String[count26], @String[count6], @String[count25], @String[count3], @String[count24], [arthas@19856]$ ognl '@com.Arthas@hashSet.add("test")' @Boolean[true] [arthas@19856]$ # 查看添加的字符 [arthas@19856]$ ognl '@com.Arthas@hashSet' | grep test @String[test], [arthas@19856]$ 复制代码甚至你可以动态更换日志输出级别$ ognl '@com.lz.test@LOGGER.logger.privateConfig' @PrivateConfig[ loggerConfig=@LoggerConfig[root], loggerConfigLevel=@Level[INFO], intLevel=@Integer[400], ] $ ognl '@com.lz.test@LOGGER.logger.setLevel(@org.apache.logging.log4j.Level@ERROR)' null $ ognl '@com.lz.test@LOGGER.logger.privateConfig' @PrivateConfig[ loggerConfig=@LoggerConfig[root], loggerConfigLevel=@Level[ERROR], intLevel=@Integer[200], ] 复制代码使用Arthas解决具体问题1. 响应时间异常问题工作中遇到一个优化问题,系统中一个导出表格的功能,响应时间长达2分钟,虽然给内部使用,但也不能这么夸张,用trace跟踪下方法,发现是其中的手机号加解密函数占用了非常大的时间,几千个手机号,进行了解密后加密的精彩操作,最终导致了两分钟的返回时间。2. 某功能Bug导致服务器返回500首先通过trace看异常报错的方法,之后通过tt排查方法,发现入参进来后,居然走错了方法(因为多态),走到了返回null的方法中,所以导致了NPE空指针错误。补充相似工具BTrace一是个历史比较久的工具,观察下来Arthas其实和他的理念蛮相似的,相信Arthas也参考过Btrace,作为一个学习样例来开发Arthas。详细的优劣势看图:原理浅谈分为三个部分:启动arthas服务端代码分析arthas客户端代码分析启动使用了阿里开源的组件cli,对参数进行了解析com.taobao.arthas.boot.Bootstrap在传入参数中没有pid,则会调用本地jps命令,列出java进程进入主逻辑,会在用户目录下建立.arthas目录,同时下载arthas-core和arthas-agent等lib文件,最后启动客户端和服务端通过反射的方式来启动字符客户端服务端——前置准备看服务端启动命令可以知道 从 arthas-core.jar开始启动,arthas-core的pom.xml文件里面指定了mainClass为com.taobao.arthas.core.Arthas,使得程序启动的时候从该类的main方法开始运行。首先解析入参,生成com.taobao.arthas.core.config.Configure类,包含了相关配置信息使用jdk-tools里面的VirtualMachine.loadAgent,其中第一个参数为agent路径, 第二个参数向jar包中的agentmain()方法传递参数(此处为agent-core.jar包路径和config序列化之后的字符串),加载arthas-agent.jar包运行arthas-agent.jar包,指定了Agent-Class为com.taobao.arthas.agent.AgentBootstrap上图中代码:public class Arthas { private Arthas(String[] args) throws Exception { attachAgent(parse(args)); } private Configure parse(String[] args) { // 省略非关键代码,解析启动参数作为配置,并填充到configure对象里面 return configure; } private void attachAgent(Configure configure) throws Exception { // 省略非关键代码,attach到目标进程 virtualMachine = VirtualMachine.attach("" + configure.getJavaPid()); virtualMachine.loadAgent(configure.getArthasAgent(), configure.getArthasCore() + ";" + configure.toString()); } public static void main(String[] args) { new Arthas(args); } } 复制代码服务端——监听客户端请求如果是exit,logout,quit,jobs,fg,bg,kill等直接执行。如果是其他的命令,则创建Job,并运行。创建Job时,会根据具体客户端传递的命令,找到对应的Command,并包装成Process, Process再被包装成Job。运行Job时,反向先调用Process,再找到对应的Command,最终调用Command的process处理请求。服务端——Command处理流程不需要使用字节码增强的命令其中JVM相关的使用 java.lang.management 提供的管理接口,来查看具体的运行时数据。比较简单,就不介绍了。需要使用字节码增强的命令字节码增加的命令统一继承EnhancerCommand类,process方法里面调用enhance方法进行增强。调用Enhancer类enhance方法,该方法内部调用inst.addTransformer方法添加自定义的ClassFileTransformer,这边是Enhancer类。Enhancer类使用AdviceWeaver(继承ClassVisitor),用来修改类的字节码。重写了visitMethod方法,在该方法里面修改类指定的方法。visitMethod方法里面使用了AdviceAdapter(继承了MethodVisitor类),在onMethodEnter方法, onMethodExit方法中,把Spy类对应的方法(ON_BEFORE_METHOD, ON_RETURN_METHOD, ON_THROWS_METHOD等)编织到目标类的方法对应的位置。在前面Spy初始化的时候可以看到,这几个方法其实指向的是AdviceWeaver类的methodOnBegin, methodOnReturnEnd等。在这些方法里面都会根据adviceId查找对应的AdviceListener,并调用AdviceListener的对应的方法,比如before,afterReturning, afterThrowing。客户端客户端代码在arthas-client模块里面,入口类是com.taobao.arthas.client.TelnetConsole。主要使用apache commons-net jar进行telnet连接,关键的代码有下面几步:构造TelnetClient对象,并初始化构造ConsoleReader对象,并初始化调用IOUtil.readWrite(telnet.getInputStream(), telnet.getOutputStream(), System.in, consoleReader.getOutput())处理各个流,一共有四个流:telnet.getInputStream()telnet.getOutputStream()System.inconsoleReader.getOutput()请求时:从本地System.in读取,发送到 telnet.getOutputStream(),即发送给远程服务端。 响应时:从telnet.getInputStream()读取远程服务端发送过来的响应,并传递给 consoleReader.getOutput(),即在本地控制台输出。关于源码,深入下去还有很多东西需要生啃,我也没有消化得很好,大家可以继续阅读详细资料。总结Arthas是一个线上Debug神器,小白也可以轻松上手。参考文献开源地址:github.com/alibaba/art…官方文档:alibaba.github.io/arthas其他参考:Hollis:Arthas - Java 线上问题定位处理的终极利器www.cnblogs.com/LittleHann/…juejin.cn/post/684490…tech.dianwoda.com/2018/12/20/…www.jianshu.com/p/0771646f3…github.com/alibaba/art…blog.csdn.net/qq_27376871…github.com/alibaba/art…
前言主要内容有:该模式的介绍,包括:引子、意图(大白话解释)类图、时序图(理论规范)该模式的代码示例:熟悉该模式的代码长什么样子该模式的优缺点:模式不是万金油,不可以滥用模式该模式的实际使用案例:了解它在哪些重要的源码中被使用创建型——单例模式引子《HEAD FIRST设计模式》中“单例模式”又称为“单件模式”对于系统中的某些类来说,只有一个实例很重要。比如大家熟悉的Spring框架中,Controller和Service都默认是单例模式。如果用生活中的例子举例,一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。如何保证一个类只有一个实例并且这个实例易于被访问呢?答:定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象。一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供一个访问该实例的方法。这就是单例模式的模式动机。意图确保一个类只有一个实例,并提供该实例的全局访问点。单例模式的要点有三个:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。使用一个私有构造函数、一个私有静态变量以及一个公有静态函数来实现。私有构造函数保证了不能通过构造函数来创建对象实例,只能通过公有静态函数返回唯一的私有静态变量。类图如果看不懂UML类图,可以先粗略浏览下该图,想深入了解的话,可以继续谷歌,深入学习:单例模式的类图:时序图时序图(Sequence Diagram)是显示对象之间交互的图,这些对象是按时间顺序排列的。时序图中显示的是参与交互的对象及其对象之间消息交互的顺序。我们可以大致浏览下时序图,如果感兴趣的小伙伴可以去深究一下:实现单例模式有非常多的实现方式,这里我们从最差的实现方式逐渐过渡到优雅的实现方式(剑指offer的方式),包括:懒汉式-线程不安全饿汉式-线程安全懒汉式-线程安全懒汉式(延迟实例化)—— 线程安全/双重校验 (重要,牢记)静态内部类实现枚举实现 (重要,牢记)1. 懒汉式-线程不安全以下实现中,私有静态变量 uniqueInstance 被延迟实例化,这样做的好处是,如果没有用到该类,那么就不会实例化 uniqueInstance,从而节约资源。这个实现在多线程环境下是不安全的,如果多个线程能够同时进入 if (uniqueInstance == null) ,并且此时 uniqueInstance 为 null,那么会有多个线程执行 uniqueInstance = new Singleton(); 语句,这将导致实例化多次 uniqueInstance。public class Singleton { private static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } } 复制代码2. 饿汉式-线程安全如此一来,只会实例化一次,作为静态变量private static Singleton uniqueInstance = new Singleton(); 复制代码3. 懒汉式(延迟实例化)—— 线程安全只需要对 getUniqueInstance() 方法加锁,那么在一个时间点只能有一个线程能够进入该方法,从而避免了实例化多次 uniqueInstance。但是当一个线程进入该方法之后,其它试图进入该方法的线程都必须等待,即使 uniqueInstance 已经被实例化了。这会让线程阻塞时间过长,因此该方法有性能问题,不推荐使用。public static synchronized Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } 复制代码4. 懒汉式(延迟实例化)—— 线程安全/双重校验一.私有化构造函数二.声明静态单例对象三.构造单例对象之前要加锁(lock一个静态的object对象)或者方法上加synchronized。四.需要两次检测单例实例是否已经被构造,分别在锁之前和锁之后使用lock(obj)public class Singleton { private Singleton() {} //关键点0:构造函数是私有的 private volatile static Singleton single; //关键点1:声明单例对象是静态的 private static object obj= new object(); public static Singleton GetInstance() //通过静态方法来构造对象 { if (single == null) //关键点2:判断单例对象是否已经被构造 { lock(obj) //关键点3:加线程锁 { if(single == null) //关键点4:二次判断单例是否已经被构造 { single = new Singleton(); } } } return single; } } 复制代码使用synchronized (Singleton.class)public class Singleton { private Singleton() {} private volatile static Singleton uniqueInstance; public static Singleton getUniqueInstance() { if (uniqueInstance == null) { synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } } 复制代码面试时可能的提问0.为何要检测两次?答:如果两个线程同时执行 if 语句,那么两个线程就会同时进入 if 语句块内。虽然在if语句块内有加锁操作,但是两个线程都会执行 uniqueInstance = new Singleton(); 这条语句,只是先后的问题,也就是说会进行两次实例化,从而产生了两个实例。因此必须使用双重校验锁,也就是需要使用两个 if 语句。1.构造函数能否公有化?答:不行,单例类的构造函数必须私有化,单例类不能被实例化,单例实例只能静态调用。2.lock住的对象为什么要是object对象,可以是int吗?答:不行,锁住的必须是个引用类型。如果锁值类型,每个不同的线程在声明的时候值类型变量的地址都不一样,那么上个线程锁住的东西下个线程进来会认为根本没锁。3.uniqueInstance 采用 volatile 关键字修饰uniqueInstance = new Singleton(); 这段代码其实是分为三步执行。分配内存空间 初始化对象 将 uniqueInstance 指向分配的内存地址 复制代码但是由于 JVM 具有指令重排的特性,有可能执行顺序变为了 1-->3-->2public class Singleton { private volatile static Singleton uniqueInstance; private Singleton(){} public static Singleton getInstance(){ if(uniqueInstance == null){ // B线程检测到uniqueInstance不为空 synchronized(Singleton.class){ if(uniqueInstance == null){ uniqueInstance = new Singleton(); // A线程被指令重排了,刚好先赋值了;但还没执行完构造函数。 } } } return uniqueInstance;// 后面B线程执行时将引发:对象尚未初始化错误。 } } 复制代码所以B线程检测到不为null后,直接出去调用该单例,而A还没有运行完构造函数,导致该单例还没创建完毕,B调用会报错!所以必须用volatile防止JVM重排指令5. 静态内部类实现当 Singleton 类加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 getUniqueInstance() 方法从而触发 SingletonHolder.INSTANCE 时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例。这种方式不仅具有延迟初始化的好处,而且由虚拟机提供了对线程安全的支持。public class Singleton { private Singleton() {} private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getUniqueInstance() { return SingletonHolder.INSTANCE; } } 复制代码6. 枚举实现这是单例模式的最佳实践,它实现简单,并且在面对复杂的序列化或者反射攻击的时候,能够防止实例化多次。public enum Singleton { INSTANCE; private String objName; public String getObjName() { return objName; } public void setObjName(String objName) { this.objName = objName; } public static void main(String[] args) { // 单例测试 Singleton firstSingleton = Singleton.INSTANCE; firstSingleton.setObjName("firstName"); System.out.println(firstSingleton.getObjName()); Singleton secondSingleton = Singleton.INSTANCE; secondSingleton.setObjName("secondName"); System.out.println(firstSingleton.getObjName()); System.out.println(secondSingleton.getObjName()); // 反射获取实例测试 try { Singleton[] enumConstants = Singleton.class.getEnumConstants(); for (Singleton enumConstant : enumConstants) { System.out.println(enumConstant.getObjName()); } } catch (Exception e) { e.printStackTrace(); } } } 复制代码为什么枚举是单例模式的最好方式?考虑以下单例模式的实现,该 Singleton 在每次序列化的时候都会创建一个新的实例,为了保证只创建一个实例,必须声明所有字段都是 transient,并且提供一个 readResolve() 方法。public class Singleton implements Serializable { private static Singleton uniqueInstance; private Singleton() { } public static synchronized Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } } 复制代码如果不使用枚举来实现单例模式,会出现反射攻击,因为通过反射的setAccessible() 方法可以将私有构造函数的访问级别设置为 public,然后调用构造函数从而实例化对象。枚举实现是由 JVM 保证只会实例化一次,因此不会出现上述的反射攻击。从上面的讨论可以看出,解决序列化和反射攻击很麻烦,而枚举实现不会出现这两种问题,所以说枚举实现单例模式是最佳实践。使用场景举例Logger类,全局唯一,保证你能在每个类里调用为一个Logger输出日志Spring:Spring里很多类都是单例的,也是你理解单例最合适的地方,比如Controller和Service类,默认都是单例的。数据库连接池对象:你从代码的任何地方都需要拿到连接池里的资源。参考blog.jobbole.com/109449/github.com/CyC2018/CS-…《HEAD FIRST 设计模式》《剑指offer》
前言主要内容有:该模式的介绍,包括:引子、意图(大白话解释)类图、时序图(理论规范)该模式的代码示例:熟悉该模式的代码长什么样子该模式的优缺点:模式不是万金油,不可以滥用模式该模式的实际使用案例:了解它在哪些重要的源码中被使用创建型——简单工厂/工厂模式/抽象工厂引子工厂模式是一个非常重要的创建型模式,但是工厂模式又分为好多种,并且网上文章很多,很多对工厂模式的定义都不是很明确,甚至还互相冲突,本文希望通过放在一起串讲的形式,力求能够用最简洁的语言理清工厂模式。先看一个工厂模式的定义:“Define an interface for creating an object, but let subclasses decide which class to instantiate. Factory Method lets a class defer instantiation to subclasses.”(在基类中定义创建对象的一个接口,让子类决定实例化哪个类。工厂方法让一个类的实例化延迟到子类中进行。)使用了工厂模式,我们可以将对象的创建和使用分离。用来防止用来实例化一个类的数据和代码在多个类中到处都是。工厂模式最主要的形式是以下三种:简单/静态工厂(Simple Factory)工厂方法(Factory Method)抽象工厂(Abstract Factory)意图1. 简单/静态工厂(Simple Factory)先来看简单工厂模式,它指的是,在创建一个对象时不向客户暴露内部细节,并提供一个创建对象的通用接口。在简单工厂模式中,可以根据参数的不同返回不同类的实例。2. 工厂方法(Factory Method)工厂方法又可以称为:工厂模式虚拟构造器(Virtual Constructor)模式多态工厂(Polymorphic Factory)模式工厂模式通过工厂子类来确定究竟应该实例化哪一个具体产品类。不再设计一个工厂类来统一负责所有产品的创建,而是将具体产品的创建过程交给专门的工厂子类去完成。这一特点无疑使得工厂方法模式具有超越简单工厂模式的优越性,更加符合“开闭原则”。3. 抽象工厂(Abstract Factory)在了解抽象工厂之前,我们先要了解下什么是产品等级结构和产品族产品族 :在抽象工厂模式中,产品族是指由同一个工厂生产的,位于不同产品等级结构中的一组产品,如海尔电器工厂生产的海尔电视机、海尔电冰箱,海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中。产品等级结构 :产品等级结构即产品的继承结构,如一个抽象类是电视机,其子类有海尔电视机、海信电视机、TCL电视机,则抽象电视机与具体品牌的电视机之间构成了一个产品等级结构,抽象电视机是父类,而具体品牌的电视机是其子类。工厂方法模式针对的是一个产品等级结构,而抽象工厂模式则需要面对多个产品等级结构,一个工厂等级结构可以负责多个不同产品等级结构中的产品对象的创建 。抽象工厂模式是所有形式的工厂模式中最为抽象和最具一般性的一种形态。如果看到这里还是对抽象工厂理解不够,不要着急,下方的代码示例会给你加深理解。类图如果看不懂UML类图,可以先粗略浏览下该图,想深入了解的话,可以继续谷歌,深入学习:1. 简单/静态工厂(Simple Factory)简单工厂模式包含如下角色:Factory:工厂角色 负责实现创建所有实例的内部逻辑Product:抽象产品角色 是所创建的所有对象的父类,负责描述所有实例所共有的公共接口ConcreteProduct:具体产品角色 是创建目标,所有创建的对象都充当这个角色的某个具体类的实例。2. 工厂方法(Factory Method)(相比简单工厂,将工厂变为了抽象工厂和具体工厂)Factory:抽象工厂,担任这个角色的是工厂方法模式的核心,任何在模式中创建对象的工厂类必须实现这个接口。在实际的系统中,这个角色也常常使用抽象类实现。ConcreteFactory:具体工厂,担任这个角色的是实现了抽象工厂接口的具体Java类。具体工厂角色含有与业务密切相关的逻辑,并且受到使用者的调用以创建具体产品对象。Product:抽象产品,工厂方法模式所创建的对象的超类,也就是所有产品类的共同父类或共同拥有的接口。在实际的系统中,这个角色也常常使用抽象类实现。ConcreteProduct:具体产品,这个角色实现了抽象产品(Product)所声明的接口,工厂方法模式所创建的每一个对象都是某个具体产品的实例。3. 抽象工厂(Abstract Factory)抽象工厂模式包含如下角色:AbstractFactory:抽象工厂ConcreteFactory:具体工厂AbstractProduct:抽象产品ConcreteProduct:具体产品你会发现工厂模式和抽象工厂的角色是相同的。时序图时序图(Sequence Diagram)是显示对象之间交互的图,这些对象是按时间顺序排列的。时序图中显示的是参与交互的对象及其对象之间消息交互的顺序。我们可以大致浏览下时序图,如果感兴趣的小伙伴可以去深究一下:1. 简单/静态工厂(Simple Factory)2. 工厂方法(Factory Method)3. 抽象工厂(Abstract Factory)代码样例给出的代码中,每个类都以角色来区分1. 简单/静态工厂(Simple Factory)工厂——LoginManagerpublic class LoginManager { public static Login factory(String type){ if(type.equals("password")){ return new PasswordLogin(); }else if(type.equals("passcode")){ return new DomainLogin(); }else{ /** * 这里抛出一个自定义异常会更恰当 */ throw new RuntimeException("没有找到登录类型"); } } } 复制代码抽象产品——Login接口public interface Login { //登录验证 public boolean verify(String name , String password); } 复制代码具体产品——PasswordLoginpublic class PasswordLogin implements Login { @Override public boolean verify(String name, String password) { // TODO Auto-generated method stub /** * 业务逻辑 */ return true; } } 复制代码客户端调用public class Test { public static void main(String[] args) { String loginType = "password"; String name = "name"; String password = "password"; Login login = LoginManager.factory(loginType); boolean bool = login.verify(name, password); if (bool) { /** * 业务逻辑 */ } else { /** * 业务逻辑 */ } } } 复制代码假如不使用上面的简单工厂模式则验证登录Servlet代码如下,可以看到代码耦合度很高:public class Test { public static void main(String[] args) { // TODO Auto-generated method stub String loginType = "password"; String name = "name"; String password = "password"; //处理口令认证 if(loginType.equals("password")){ PasswordLogin passwordLogin = new PasswordLogin(); boolean bool = passwordLogin.verify(name, password); if (bool) { /** * 业务逻辑 */ } else { /** * 业务逻辑 */ } } //处理域认证 else if(loginType.equals("passcode")){ DomainLogin domainLogin = new DomainLogin(); boolean bool = domainLogin.verify(name, password); if (bool) { /** * 业务逻辑 */ } else { /** * 业务逻辑 */ } }else{ /** * 业务逻辑 */ } } } 复制代码2. 工厂方法(Factory Method)(相比简单工厂,将工厂变为了抽象工厂和具体工厂)抽象的产品接口——ILight:具备开关两种功能的产品public interface ILight { void TurnOn(); void TurnOff(); } 复制代码具体的产品类——BulbLightpublic class TubeLight implements ILight { public void TurnOn() { Console.WriteLine("TubeLight turns on."); } public void TurnOff() { Console.WriteLine("TubeLight turns off."); } } 复制代码抽象的工厂类——ICreatorpublic interface ICreator { ILight CreateLight(); } 复制代码具体的工厂类——BulbCreatorpublic class BulbCreator implements ICreator { public ILight CreateLight() { return new BulbLight(); } } 复制代码客户端调用static void Main(string[] args) { //先给我来个灯泡 ICreator creator = new BulbCreator(); ILight light = creator.CreateLight(); light.TurnOn(); light.TurnOff(); //再来个灯管看看 creator = new TubeCreator(); light = creator.CreateLight(); light.TurnOn(); light.TurnOff(); } 复制代码3. 抽象工厂(Abstract Factory)抽象产品: 苹果系列public interface Apple { void AppleStyle(); } 复制代码抽象产品: 三星系列public interface Sumsung { void BangziStyle(); } 复制代码具体产品:iphonepublic class iphone implements Apple { public void AppleStyle() { Console.WriteLine("Apple's style: iPhone!"); } } 复制代码具体产品:ipadpublic class ipad implements Apple { public void AppleStyle() { Console.WriteLine("Apple's style: iPad!"); } } 复制代码具体产品:note2public class note2 implements Sumsung { public void BangziStyle() { Console.WriteLine("Bangzi's style : Note2!"); } } 复制代码抽象工厂public interface Factory { Apple createAppleProduct(); Sumsung createSumsungProduct(); } 复制代码手机工厂public class Factory_Phone implements Factory { public Apple createAppleProduct() { return new iphone(); } public Sumsung createSumsungProduct() { return new note2(); } } 复制代码pad工厂public class Factory_Pad implements Factory { public Apple createAppleProduct() { return new ipad(); } public Sumsung createSumsungProduct() { return new Tabs(); } } 复制代码客户端调用public static void Main(string[] args) { //采购商要一台iPad和一台Tab Factory factory = new Factory_Pad(); Apple apple = factory.createAppleProduct(); apple.AppleStyle(); Sumsung sumsung = factory.createSumsungProduct(); sumsung.BangziStyle(); //采购商又要一台iPhone和一台Note2 factory = new Factory_Phone(); apple = factory.createAppleProduct(); apple.AppleStyle(); sumsung = factory.createSumsungProduct(); sumsung.BangziStyle(); } 复制代码static void Main(string[] args) { //先给我来个灯泡 ICreator creator = new BulbCreator(); ILight light = creator.CreateLight(); light.TurnOn(); light.TurnOff(); //再来个灯管看看 creator = new TubeCreator(); light = creator.CreateLight(); light.TurnOn(); light.TurnOff(); } 复制代码用意图里面的话再次总结一下:工厂方法模式针对的是一个产品等级结构,而抽象工厂模式则需要面对多个产品等级结构,一个工厂等级结构可以负责多个不同产品等级结构中的产品对象的创建 。优缺点1. 简单/静态工厂(Simple Factory)优点构造容易,逻辑简单。缺点简单工厂模式中的if else判断非常多,完全是Hard Code,如果有一个新产品要加进来,就要同时添加一个新产品类,并且必须修改工厂类,再加入一个 else if 分支才可以, 这样就违背了 “开放-关闭原则“中的对修改关闭的准则了。一个工厂类中集合了所有的类的实例创建逻辑,违反了高内聚的责任分配原则,将全部的创建逻辑都集中到了一个工厂类当中,所有的业务逻辑都在这个工厂类中实现。什么时候它不能工作了,整个系统都会受到影响。因此一般只在很简单的情况下应用,比如当工厂类负责创建的对象比较少时。简单工厂模式由于使用了静态工厂方法,造成工厂角色无法形成基于继承的等级结构。2. 工厂方法(Factory Method)优点在工厂方法模式中,工厂方法用来创建客户所需要的产品,同时还向客户隐藏了哪种具体产品类将被实例化这一细节,用户只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体产品类的类名。工厂方法模式之所以又被称为多态工厂模式,是因为所有的具体工厂类都具有同一抽象父类。使用工厂方法模式的另一个优点是在系统中加入新产品时,无须修改抽象工厂和抽象产品提供的接口,无须修改客户端,也无须修改其他的具体工厂和具体产品,而只要添加一个具体工厂和具体产品就可以了。这样,系统的可扩展性也就变得非常好,完全符合“开闭原则”,这点比简单工厂模式更优秀。缺点在添加新产品时,需要编写新的具体产品类,而且还要提供与之对应的具体工厂类,系统中类的个数将成对增加,在一定程度上增加了系统的复杂度,有更多的类需要编译和运行,会给系统带来一些额外的开销。由于考虑到系统的可扩展性,需要引入抽象层,在客户端代码中均使用抽象层进行定义,增加了系统的抽象性和理解难度,且在实现时可能需要用到DOM、反射等技术,增加了系统的实现难度。3. 抽象工厂(Abstract Factory)优点应用抽象工厂模式可以实现高内聚低耦合的设计目的,因此抽象工厂模式得到了广泛的应用。增加新的具体工厂和产品族很方便,因为一个具体的工厂实现代表的是一个产品族,无须修改已有系统,符合“开闭原则”。缺点开闭原则的倾斜性(增加新的工厂和产品族容易,增加新的产品等级结构麻烦)使用场景举例1. 简单/静态工厂(Simple Factory)工厂类负责创建的对象比较少:由于创建的对象较少,不会造成工厂方法中的业务逻辑太过复杂。Java JDK中使用JDK类库中广泛使用了简单工厂模式,如工具类java.text.DateFormat,它用于格式化一个本地日期或者时间。public final static DateFormat getDateInstance(); public final static DateFormat getDateInstance(int style); public final static DateFormat getDateInstance(int style,Locale locale); 复制代码Java加密技术 获取不同加密算法的密钥生成器:KeyGenerator keyGen=KeyGenerator.getInstance("DESede"); 复制代码创建密码器:Cipher cp = Cipher.getInstance("DESede"); 复制代码2. 工厂方法(Factory Method)Java JDK中使用JDBC中的工厂方法:Connection conn=DriverManager.getConnection("jdbc:microsoft:sqlserver://localhost:1433; DatabaseName=DB;user=sa;password="); Statement statement=conn.createStatement(); ResultSet rs=statement.executeQuery("select * from UserInfo"); 复制代码java.util.Calendarjava.util.ResourceBundlejava.text.NumberFormatjava.nio.charset.Charsetjava.net.URLStreamHandlerFactoryjava.util.EnumSetjavax.xml.bind.JAXBContext3. 抽象工厂(Abstract Factory)在以下情况下可以使用抽象工厂模式:一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是重要的。系统中有多于一个的产品族,而每次只使用其中某一产品族。(与工厂方法模式的区别)属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。系统提供一个产品类的库,所有的产品以同样的接口出现,从而使客户端不依赖于具体实现。Java JDK中使用javax.xml.parsers.DocumentBuilderFactoryjavax.xml.transform.TransformerFactoryjavax.xml.xpath.XPathFactory总结抽象工厂模式中我们可以定义实现不止一个接口,一个工厂也可以生成不止一个产品类,抽象工厂模式较好的实现了“开放-封闭”原则,是三个模式中较为抽象,并具一般性的模式。但是归根结底,工厂模式还是一定程度上增加了代码复杂度,有没有一种办法,不需要创建工厂,也能解决代码以后的扩展性问题呢?参考《HEAD FIRST 设计模式》www.jianshu.com/p/d1b6731c1…www.jianshu.com/p/1cf9859e0…www.jianshu.com/p/d6622f3e7…design-patterns.readthedocs.io/zh_CN/lates…
前言今天文章的主题是如何使用Mysql内置的Binlog日志对误删的数据进行恢复,读完本文,你能够了解到:MySQL的binlog日志是什么?通常是用来干什么的?模拟一次误删数据的操作,并且使用binlog日志恢复误删的数据。正文Binlog介绍binlog是记录所有数据库表结构变更(例如CREATE、ALTER TABLE…)以及表数据修改(INSERT、UPDATE、DELETE…)的二进制日志。 binlog不会记录SELECT和SHOW这类操作,因为这类操作对数据本身并没有修改,但你可以通过查询通用日志来查看MySQL执行过的所有语句。看了上面binlog的定义,大家也应该能大致推理出binlog的三大用途:恢复数据:今天要说的重点数据库复制:主从数据库是通过将binlog传给从库,从库有两个线程,一个I/O线程,一个SQL线程,I/O线程读取主库传过来的binlog内容并写入到relay log,SQL线程从relay log里面读取内容,写入从库的数据库。审计:用户可以通过二进制日志中的信息来进行审计,判断是否有对数据库进行注入攻击。所以说,想要能够恢复数据,首先,你得打开Mysql的binlog,在平常你自己安装的单机Mysql中,默认情况下不会开启。下面就一步步地实践下如何开启你服务器上的Binlog日志。在MySQL中开启Binlog首先进入数据库控制台,运行指令:mysql> show variables like'log_bin%'; +---------------------------------+-------+ | Variable_name | Value | +---------------------------------+-------+ | log_bin | OFF | | log_bin_basename | | | log_bin_index | | | log_bin_trust_function_creators | OFF | | log_bin_use_v1_row_events | OFF | +---------------------------------+-------+ 5 rows in set (0.00 sec) 复制代码可以看到我们的binlog是关闭的,都是OFF。接下来我们需要修改Mysql配置文件,执行命令:sudo vi /etc/mysql/mysql.conf.d/mysqld.cnf 复制代码在文件末尾添加:log-bin=/var/lib/mysql/mysql-bin 复制代码保存文件,重启mysql服务:sudo service mysql restart 复制代码重启完成后,查看下mysql的状态:systemctl status mysql.service 复制代码这时,如果你的mysql版本在5.7或更高版本,就会报错:Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.190791Z 0 [Warning] Changed limits: max_open_files: 1024 (requested 5000) Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.190839Z 0 [Warning] Changed limits: table_open_cache: 431 (requested 2000) Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.359713Z 0 [Warning] TIMESTAMP with implicit DEFAULT value is deprecated. Please use --explicit_defaults_for_timestamp server option (se Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.361395Z 0 [Note] /usr/sbin/mysqld (mysqld 5.7.28-0ubuntu0.16.04.2-log) starting as process 5930 ... Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.363017Z 0 [ERROR] You have enabled the binary log, but you haven't provided the mandatory server-id. Please refer to the proper server Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.363747Z 0 [ERROR] Aborting Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.363922Z 0 [Note] Binlog end Jan 06 15:49:58 VM-0-11-ubuntu mysqld[5930]: 2020-01-06T07:49:58.364108Z 0 [Note] /usr/sbin/mysqld: Shutdown complete Jan 06 15:49:58 VM-0-11-ubuntu systemd[1]: mysql.service: Main process exited, code=exited, status=1/FAILURE 复制代码You have enabled the binary log, but you haven't provided the mandatory server-id. Please refer to the proper server之前我们的配置,对于5.7以下版本应该是可以的。但对于高版本,我们需要指定server-id。如果你不是分布式的部署Mysql,这个server-id随机给个数字就可以。server-id=123454 复制代码模拟删除数据并恢复首先新建数据库mytest,新建一张表table1,结构见下方SQL代码CREATE DATABASE `test` ; USE `test`; DROP TABLE IF EXISTS `table1`; CREATE TABLE `table2` ( `id` int(11) DEFAULT NULL, `name` varchar(20) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 复制代码插入两条数据,分别是 (1,'A'),(2,'B')INSERT INTO `table1` VALUES (1,'A'),(2,'B'); 复制代码我们看一下binlog日志的状态,使用show master statusmysql> show master status -> ; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000001 | 690 | | | | +------------------+----------+--------------+------------------+-------------------+ 1 row in set 复制代码binlog日志特征:每当我们重启MySQL一次,会自动生成一个binlog文件,当然,我们也可以手动的来刷新binlog文件,通过 flush logs,同样会新创建一个binlog文件。实际上当服务器在重启时,也会调用flush logs操作。上图代码中可以看到,现在我们正在使用 mysql-bin.0000001 ,并且这个文件现在正在记录到690行。然后,使用flush logs来主动刷新一次binlogmysql> flush logs; Query OK, 0 rows affected mysql> show master status -> ; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000002 | 154 | | | | +------------------+----------+--------------+------------------+-------------------+ 1 row in set 复制代码可以看到,现在日志文件在 mysql-bin.000002 文件中,位置为154。也就是我们主动刷新了一次binlog,生成了新的000002,而000001则已经归档了,不会再写入新的日志进去了。接下来我们在插入两条数据insert into table1 values (3,'C'); insert into table1 values (4,'D'); 复制代码mysql> select * from table1; +----+----+ | id |name| +----+----+ | 1 | A | | 2 | B | | 3 | C | | 4 | D | +----+----+ 复制代码这时候我们已经有了四条数据,我们再次flush logs,把mysql-bin.000002日志存档,开启新的mysql-bin.000003日志,这样,每次我们插入的数据彼此独立。实际情况下,binlog会比较复杂,这里也是做了简化,为了理解更方便。mysql> flush logs; Query OK, 0 rows affected mysql> show master status; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000003 | 154 | | | | +------------------+----------+--------------+------------------+-------------------+ 1 row in set 复制代码然后我们删除id为4的数据(4,D),并且再次刷新binlog,如此一来,binlog.000003里面只有一条删除操作。mysql> delete from table1 where id = 4; Query OK, 1 row affected mysql> show master status; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000003 | 423 | | | | +------------------+----------+--------------+------------------+-------------------+ 1 row in set mysql> flush logs; Query OK, 0 rows affected mysql> show master status; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000004 | 154 | | | | +------------------+----------+--------------+------------------+-------------------+ 1 row in set 复制代码让我们来好好观察下mysql-bin.00002和mysql-bin00003两个binlog,使用命令:show binlog events in 'mysql-bin.000003'mysql> show binlog events in 'mysql-bin.000003'; +------------------+-----+----------------+-----------+-------------+--------------------------------------------------------+ | Log_name | Pos | Event_type | Server_id | End_log_pos | Info | +------------------+-----+----------------+-----------+-------------+--------------------------------------------------------+ | mysql-bin.000003 | 4 | Format_desc | 123456 | 123 | Server ver: 5.7.28-0ubuntu0.16.04.2-log, Binlog ver: 4 | | mysql-bin.000003 | 123 | Previous_gtids | 123456 | 154 | | | mysql-bin.000003 | 154 | Anonymous_Gtid | 123456 | 219 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' | | mysql-bin.000003 | 219 | Query | 123456 | 293 | BEGIN | | mysql-bin.000003 | 293 | Table_map | 123456 | 343 | table_id: 108 (test.table1) | | mysql-bin.000003 | 343 | Delete_rows | 123456 | 392 | table_id: 108 flags: STMT_END_F | | mysql-bin.000003 | 392 | Xid | 123456 | 423 | COMMIT /* xid=39 */ | +------------------+-----+----------------+-----------+-------------+--------------------------------------------------------+ 7 rows in set mysql> show binlog events in 'mysql-bin.000002'; +------------------+-----+----------------+-----------+-------------+--------------------------------------------------------+ | Log_name | Pos | Event_type | Server_id | End_log_pos | Info | +------------------+-----+----------------+-----------+-------------+--------------------------------------------------------+ | mysql-bin.000002 | 4 | Format_desc | 123456 | 123 | Server ver: 5.7.28-0ubuntu0.16.04.2-log, Binlog ver: 4 | | mysql-bin.000002 | 123 | Previous_gtids | 123456 | 154 | | | mysql-bin.000002 | 154 | Anonymous_Gtid | 123456 | 219 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' | | mysql-bin.000002 | 219 | Query | 123456 | 293 | BEGIN | | mysql-bin.000002 | 293 | Table_map | 123456 | 343 | table_id: 108 (test.table1) | | mysql-bin.000002 | 343 | Write_rows | 123456 | 390 | table_id: 108 flags: STMT_END_F | | mysql-bin.000002 | 390 | Xid | 123456 | 421 | COMMIT /* xid=34 */ | | mysql-bin.000002 | 421 | Anonymous_Gtid | 123456 | 486 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' | | mysql-bin.000002 | 486 | Query | 123456 | 560 | BEGIN | | mysql-bin.000002 | 560 | Table_map | 123456 | 610 | table_id: 108 (test.table1) | | mysql-bin.000002 | 610 | Write_rows | 123456 | 659 | table_id: 108 flags: STMT_END_F | | mysql-bin.000002 | 659 | Xid | 123456 | 690 | COMMIT /* xid=35 */ | | mysql-bin.000002 | 690 | Rotate | 123456 | 737 | mysql-bin.000003;pos=4 | +------------------+-----+----------------+-----------+-------------+--------------------------------------------------------+ 13 rows in set 复制代码虽然有很多看似复杂的指令,但是还是不难看出,在02里,有两条写操作,03里有一条删除操作。一条插入操作的完整日志是这样:| mysql-bin.000002 | 154 | Anonymous_Gtid | 123456 | 219 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' | | mysql-bin.000002 | 219 | Query | 123456 | 293 | BEGIN | | mysql-bin.000002 | 293 | Table_map | 123456 | 343 | table_id: 108 (test.table1) | | mysql-bin.000002 | 343 | Write_rows | 123456 | 390 | table_id: 108 flags: STMT_END_F | | mysql-bin.000002 | 390 | Xid | 123456 | 421 | COMMIT /* xid=34 */ | 复制代码我们的目的是恢复误删的数据,其实就是将binlog.000002日志的两条插入记录重演一遍,而不需要取理会binlog.000003的操作(因为删除是一个误操作)所以现在能理解为什么我们频繁刷新binlog了吧,当然,在实际的线上环境中,我们肯定需要将binlog导出后,仔细筛选出误操作,并将其排除,之后再运行binlog。在本文中,我们只做一个恢复两条插入语句的操作,执行语句:sudo mysqlbinlog /var/lib/mysql/mysql-bin.000002 --start-position 154 --stop-position 690 | mysql -uroot -p mytest 复制代码注意:这里填写的路径/var/lib/mysql/mysql-bin.000002需要具体到你的binlog目录,网上大部分文章只写到mysql-bin.000002,如果你不在目录里,mysqlbinlog命令并不会自动定位binlog所在路径。参数描述:--start-datetime:从二进制日志中读取指定等于时间戳或者晚于本地计算机的时间 --stop-datetime:从二进制日志中读取指定小于时间戳或者等于本地计算机的时间 取值和上述一样 --start-position:从二进制日志中读取指定position 事件位置作为开始。 --stop-position:从二进制日志中读取指定position 事件位置作为事件截至 复制代码执行成功后,再次查看表table1,可以看到两条新的id=3和4的数据被插入了进来。恢复成功了。mysql> select * from table1; +----+----+ | id |name| +----+----+ | 1 | A | | 2 | B | | 3 | C | | 3 | C | | 4 | D | +----+----+ 6 rows in set 复制代码延伸思考Binlog在什么情况下无法恢复数据?结语删库跑路不用怕,其他开发运维都等着恢复你的数据呢,多好的练手机会是不是。当然,看完binlog日志恢复数据的原理,希望大家以后在定期备份数据库的脚本里,也能够加上刷新binlog日志的命令,这样一旦某天丢失数据,可以将当天binlog数据单独拿出来还原,做到清晰可辨,也加快恢复效率。参考www.cnblogs.com/rjzheng/p/9…blog.csdn.net/king_kgh/ar…www.jianshu.com/p/564fcc2b5…blog.csdn.net/king_kgh/ar…
前言大家好,好久不发文章了。(快一个月了- -)最近有很多学习的新知识想和大家分享,但无奈最近项目蛮忙的,很多文章写了一半搁置在了笔记里,待以后慢慢补充发布。本文主要是通过实际代码讲解,帮助你一步步搭建一个简易的秒杀系统。从而快速的了解秒杀系统的主要难点,并且迅速上手实际项目。我对秒杀系统文章的规划:从零开始打造简易秒杀系统:乐观锁防止超卖从零开始打造简易秒杀系统:令牌桶限流从零开始打造简易秒杀系统:Redis 缓存从零开始打造简易秒杀系统:消息队列异步处理订单...秒杀系统秒杀系统介绍秒杀系统相信网上已经介绍了很多了,我也不想黏贴很多定义过来了。废话少说,秒杀系统主要应用在商品抢购的场景,比如:电商抢购限量商品卖周董演唱会的门票火车票抢座...秒杀系统抽象来说就是以下几个步骤:用户选定商品下单校验库存扣库存创建用户订单用户支付等后续步骤...听起来就是个用户买商品的流程而已嘛,确实,所以我们为啥要说他是个专门的系统呢。为什么要做所谓的“系统”如果你的项目流量非常小,完全不用担心有并发的购买请求,那么做这样一个系统意义不大。但如果你的系统要像12306那样,接受高并发访问和下单的考验,那么你就需要一套完整的流程保护措施,来保证你系统在用户流量高峰期不会被搞挂了。(就像12306刚开始网络售票那几年一样)这些措施有什么呢:严格防止超卖:库存100件你卖了120件,等着辞职吧防止黑产:防止不怀好意的人群通过各种技术手段把你本该下发给群众的利益全收入了囊中。保证用户体验:高并发下,别网页打不开了,支付不成功了,购物车进不去了,地址改不了了。这个问题非常之大,涉及到各种技术,也不是一下子就能讲完的,甚至根本就没法讲完。我们先从“防止超卖”开始吧毕竟,你网页可以卡住,最多是大家没参与到活动,上网口吐芬芳,骂你一波。但是你要是卖多了,本该拿到商品的用户可就不乐意了,轻则投诉你,重则找漏洞起诉赔偿。让你吃不了兜着走。不能再说下去了,我这篇文章可是打着实战文章的名头,为什么我老是要讲废话啊啊啊啊啊啊。上代码。说好的做“简易”的秒杀系统,所以我们只用最简单的SpringBoot项目建立“简易”的数据库表结构一开始我们先来张最最最简易的结构表,参考了crossoverjie的秒杀系统文章。等未来我们需要解决更多的系统问题,再扩展表结构。一张库存表stock,一张订单表stock_order-- ---------------------------- -- Table structure for stock -- ---------------------------- DROP TABLE IF EXISTS `stock`; CREATE TABLE `stock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称', `count` int(11) NOT NULL COMMENT '库存', `sale` int(11) NOT NULL COMMENT '已售', `version` int(11) NOT NULL COMMENT '乐观锁,版本号', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Table structure for stock_order -- ---------------------------- DROP TABLE IF EXISTS `stock_order`; CREATE TABLE `stock_order` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `sid` int(11) NOT NULL COMMENT '库存ID', `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 复制代码通过HTTP接口发起一次购买请求代码中我们采用最传统的Spring MVC+Mybaits的结构结构如下图:Controller层代码提供一个HTTP接口: 参数为商品的Id@RequestMapping("/createWrongOrder/{sid}") @ResponseBody public String createWrongOrder(@PathVariable int sid) { LOGGER.info("购买物品编号sid=[{}]", sid); int id = 0; try { id = orderService.createWrongOrder(sid); LOGGER.info("创建订单id: [{}]", id); } catch (Exception e) { LOGGER.error("Exception", e); } return String.valueOf(id); } 复制代码Service层代码@Override public int createWrongOrder(int sid) throws Exception { //校验库存 Stock stock = checkStock(sid); //扣库存 saleStock(stock); //创建订单 int id = createOrder(stock); return id; } private Stock checkStock(int sid) { Stock stock = stockService.getStockById(sid); if (stock.getSale().equals(stock.getCount())) { throw new RuntimeException("库存不足"); } return stock; } private int saleStock(Stock stock) { stock.setSale(stock.getSale() + 1); return stockService.updateStockById(stock); } private int createOrder(Stock stock) { StockOrder order = new StockOrder(); order.setSid(stock.getId()); order.setName(stock.getName()); int id = orderMapper.insertSelective(order); return id; } 复制代码发起并发购买请求我们通过JMeter(jmeter.apache.org/) 这个并发请求工具来模拟大量用户同时请求购买接口的场景。注意:POSTMAN并不支持并发请求,其请求是顺序的,而JMeter是多线程请求。希望以后PostMan能够支持吧,毕竟JMeter还在倔强的用Java UI框架。毕竟是亲儿子呢。我们在表里添加一个Iphone,库存100。(请忽略订单表里的数据,开始前我清空了)在JMeter里启动1000个线程,无延迟同时访问接口。模拟1000个人,抢购100个产品的场景。点击启动:你猜会卖出多少个呢,先想一想。。。答案是:卖出了14个,库存减少了14个,但是每个请求Spring都处理了,创建了1000个订单。我这里该夸Spring强大的并发处理能力,还是该骂MySQL已经是个成熟的数据库,却都不会自己锁库存?避免超卖问题:更新商品库存的版本号为了解决上面的超卖问题,我们当然可以在Service层给更新表添加一个事务,这样每个线程更新请求的时候都会先去锁表的这一行(悲观锁),更新完库存后再释放锁。可这样就太慢了,1000个线程可等不及。我们需要乐观锁。一个最简单的办法就是,给每个商品库存一个版本号version字段我们修改代码:Controller层/** * 乐观锁更新库存 * @param sid * @return */ @RequestMapping("/createOptimisticOrder/{sid}") @ResponseBody public String createOptimisticOrder(@PathVariable int sid) { int id; try { id = orderService.createOptimisticOrder(sid); LOGGER.info("购买成功,剩余库存为: [{}]", id); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } return String.format("购买成功,剩余库存为:%d", id); } 复制代码Service层@Override public int createOptimisticOrder(int sid) throws Exception { //校验库存 Stock stock = checkStock(sid); //乐观锁更新库存 saleStockOptimistic(stock); //创建订单 int id = createOrder(stock); return stock.getCount() - (stock.getSale()+1); } private void saleStockOptimistic(Stock stock) { LOGGER.info("查询数据库,尝试更新库存"); int count = stockService.updateStockByOptimistic(stock); if (count == 0){ throw new RuntimeException("并发更新库存失败,version不匹配") ; } } 复制代码Mapper<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock"> update stock <set> sale = sale + 1, version = version + 1, </set> WHERE id = #{id,jdbcType=INTEGER} AND version = #{version,jdbcType=INTEGER} </update> 复制代码我们在实际减库存的SQL操作中,首先判断version是否是我们查询库存时候的version,如果是,扣减库存,成功抢购。如果发现version变了,则不更新数据库,返回抢购失败。发起并发购买请求这次,我们能成功吗?再次打开JMeter,把库存恢复为100,清空订单表,发起1000次请求。这次的结果是:卖出去了39个,version更新为了39,同时创建了39个订单。我们没有超卖,可喜可贺。由于并发访问的原因,很多线程更新库存失败了,所以在我们这种设计下,1000个人真要是同时发起购买,只有39个幸运儿能够买到东西,但是我们防止了超卖。OK,今天先到这里,之后我们继续一步步完善这个简易的秒杀系统,它总有从树苗变成大树的那一天!参考cloud.tencent.com/developer/a…juejin.cn/post/684490…crossoverjie.top/%2F2018%2F0…
前言本文是秒杀系统的第二篇,通过实际代码讲解,帮助你快速的了解秒杀系统的关键点,上手实际项目。本篇主要讲解接口限流措施,接口限流其实定义也非常广,接口限流本身也是系统安全防护的一种措施,暂时列举这几种容易理解的:令牌桶限流单用户访问频率限流抢购接口隐藏此外,前文发出后很多同学对于乐观锁在高并发时无法卖出全部商品提出了“严正抗议”,所以还是在本篇中补充讲解下乐观锁与悲观锁。正文接口限流在面临高并发的请购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力。尤其是对于下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。所以秒杀系统会尽量选择独立于公司其他后端系统之外进行单独部署,以免秒杀业务崩溃影响到其他系统。除了独立部署秒杀业务之外,我们能够做的就是尽量让后台系统稳定优雅的处理大量请求。接口限流实战:令牌桶限流算法令牌桶限流算法网上已经有了很多介绍,我摘抄一篇介绍过来:令牌桶算法最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。令牌桶算法与漏桶算法漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。令牌桶算法不能与另外一种常见算法漏桶算法相混淆。这两种算法的主要区别在于:漏桶算法能够强行限制数据的传输速率,而令牌桶算法在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在令牌桶算法中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,因此它适合于具有突发特性的流量。使用Guava的RateLimiter实现令牌桶限流接口Guava是Google开源的Java工具类,里面包罗万象,也提供了限流工具类RateLimiter,该类里面实现了令牌桶算法。我们拿出源码,在之前讲过的乐观锁抢购接口上增加该令牌桶限流代码:OrderController:@Controller public class OrderController { private static final Logger LOGGER = LoggerFactory.getLogger(OrderController.class); @Autowired private StockService stockService; @Autowired private OrderService orderService; //每秒放行10个请求 RateLimiter rateLimiter = RateLimiter.create(10); @RequestMapping("/createWrongOrder/{sid}") @ResponseBody public String createWrongOrder(@PathVariable int sid) { int id = 0; try { id = orderService.createWrongOrder(sid); LOGGER.info("创建订单id: [{}]", id); } catch (Exception e) { LOGGER.error("Exception", e); } return String.valueOf(id); } /** * 乐观锁更新库存 + 令牌桶限流 * @param sid * @return */ @RequestMapping("/createOptimisticOrder/{sid}") @ResponseBody public String createOptimisticOrder(@PathVariable int sid) { // 阻塞式获取令牌 //LOGGER.info("等待时间" + rateLimiter.acquire()); // 非阻塞式获取令牌 if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) { LOGGER.warn("你被限流了,真不幸,直接返回失败"); return "购买失败,库存不足"; } int id; try { id = orderService.createOptimisticOrder(sid); LOGGER.info("购买成功,剩余库存为: [{}]", id); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } return String.format("购买成功,剩余库存为:%d", id); } } 复制代码代码中,RateLimiter rateLimiter = RateLimiter.create(10);这里初始化了令牌桶类,每秒放行10个请求。在接口中,可以看到有两种使用方法:阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,就在这里阻塞住,等待令牌的发放。非阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,会尝试等待设置好的时间(这里写了1000ms),其会自动判断在1000ms后,这个请求能不能拿到令牌,如果不能拿到,直接返回抢购失败。如果timeout设置为0,则等于阻塞时获取令牌。我们使用JMeter设置200个线程,来同时抢购数据库里库存100个的iphone。(数据库结构和JMeter使用请查看从零开始搭建简易秒杀系统(一):防止超卖)我们将请求响应结果为“你被限流了,真不幸,直接返回失败”的请求单独断言出来:我们使用rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS),非阻塞式的令牌桶算法,来看看购买结果:可以看到,绿色的请求代表被令牌桶拦截掉的请求,红色的则是购买成功下单的请求。通过JMeter的请求汇总报告,可以得知,在这种情况下请求能够没被限流的比率在15%左右。可以看到,200个请求中没有被限流的请求里,由于乐观锁的原因,会出现一些并发更新数据库失败的问题,导致商品没有被卖出。这也是上一篇小伙伴问的最多的问题。所以我想再谈一谈乐观锁与悲观锁。再谈锁之前,我们再试一试令牌桶算法的阻塞式使用,我们将代码换成rateLimiter.acquire();,然后将数据库恢复成100个库存,订单表清零。开始请求:这次的结果非常有意思,先放几张结果图(按顺序截图的),爱思考的同学们可以先推测下我接下来想说啥。总结:首先,所有请求进入了处理流程,但是被限流成每秒处理10个请求。在刚开始的请求里,令牌桶里一下子被取了10个令牌,所以出现了第二张图中的,乐观锁并发更新失败,然而在后面的请求中,由于令牌一旦生成就被拿走,所以请求进来的很均匀,没有再出现并发更新库存的情况。这也符合“令牌桶”的定义,可以应对突发请求(只是由于乐观锁,所以购买冲突了)。而非“漏桶”的永远恒定的请求限制。200个请求,在乐观锁的情况下,卖出了全部100个商品,如果没有该限流,而请求又过于集中的话,会卖不出去几个。就像第一篇文章中的那种情况一样。Guava中RateLimiter实现原理令牌桶的实现原理,本文中不再班门弄斧了,还是以实战为主。毕竟Guava是只提供了令牌桶的一种实现,实际项目中肯定还要根据需求来使用或者自己实现。再谈防止超卖讲完了令牌桶限流算法,我们再回头思考超卖的问题,在海量请求的场景下,如果像第一篇文章那样的使用乐观锁,会导致大量的请求返回抢购失败,用户体验极差。然而使用悲观锁,比如数据库事务,则可以让数据库一个个处理库存数修改,修改成功后再迎接下一个请求,所以在不同情况下,应该根据实际情况使用悲观锁和乐观锁。悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。两种锁各有优缺点,不能单纯的定义哪个好于哪个。乐观锁比较适合数据修改比较少,读取比较频繁的场景,即使出现了少量的冲突,这样也省去了大量的锁的开销,故而提高了系统的吞吐量。但是如果经常发生冲突(写数据比较多的情况下),上层应用不不断的retry,这样反而降低了性能,对于这种情况使用悲观锁就更合适。实现不需要版本号字段的乐观锁上一篇文章中,我的乐观锁建立在更新数据库版本号上,这里贴出一种不用额外字段的乐观锁SQL语句。<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock"> update stock <set> sale = sale + 1, </set> WHERE id = #{id,jdbcType=INTEGER} AND sale = #{sale,jdbcType=INTEGER} </update> 复制代码实现悲观锁我们为了在高流量下,能够更好更快的卖出商品,我们实现一个悲观锁(事务for update更新库存)。看看悲观锁的结果如何。在Controller中,增加一个悲观锁卖商品接口:/** * 事务for update更新库存 * @param sid * @return */ @RequestMapping("/createPessimisticOrder/{sid}") @ResponseBody public String createPessimisticOrder(@PathVariable int sid) { int id; try { id = orderService.createPessimisticOrder(sid); LOGGER.info("购买成功,剩余库存为: [{}]", id); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } return String.format("购买成功,剩余库存为:%d", id); } 复制代码在Service中,给该卖商品流程加上事务:@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) @Override public int createPessimisticOrder(int sid){ //校验库存(悲观锁for update) Stock stock = checkStockForUpdate(sid); //更新库存 saleStock(stock); //创建订单 int id = createOrder(stock); return stock.getCount() - (stock.getSale()); } /** * 检查库存 ForUpdate * @param sid * @return */ private Stock checkStockForUpdate(int sid) { Stock stock = stockService.getStockByIdForUpdate(sid); if (stock.getSale().equals(stock.getCount())) { throw new RuntimeException("库存不足"); } return stock; } /** * 更新库存 * @param stock */ private void saleStock(Stock stock) { stock.setSale(stock.getSale() + 1); stockService.updateStockById(stock); } /** * 创建订单 * @param stock * @return */ private int createOrder(Stock stock) { StockOrder order = new StockOrder(); order.setSid(stock.getId()); order.setName(stock.getName()); int id = orderMapper.insertSelective(order); return id; } 复制代码这里使用Spring的事务,@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED),如果遇到回滚,则返回Exception,并且事务传播使用PROPAGATION_REQUIRED–支持当前事务,如果当前没有事务,就新建一个事务,关于Spring事务传播机制可以自行查阅资料,以后也想出一个总结文章。我们依然设置100个商品,清空订单表,开始用JMeter更改请求的接口/createPessimisticOrder/1,发起200个请求:查看结果,可以看到,HMeter给出的汇总报告中,200个请求,100个返回了抢购成功,100个返回了抢购失败。并且商品卖给了前100个进来的请求,十分的有序。所以,悲观锁在大量请求的请求下,有着更好的卖出成功率。但是需要注意的是,如果请求量巨大,悲观锁会导致后面的请求进行了长时间的阻塞等待,用户就必须在页面等待,很像是“假死”,可以通过配合令牌桶限流,或者是给用户显著的等待提示来优化。悲观锁真的锁住库存了吗?最后一个问题,我想证明下我的事务真的在执行for update后锁住了商品库存,不让其他线程修改库存。我们在idea中打断点,让代码运行到for update执行完成后。然后再mysql命令行中,执行 update stock set count = 50 where id = 1;试图偷偷修改库存,再回车之后,你会发现命令行阻塞了,没有返回任何消息,显然他在等待行锁的释放。接下里,你手动继续运行程序,把该事务执行完。在事务执行完成的瞬间,命令行中成功完成了修改,说明锁已经被线程释放,其他的线程能够成功修改库存了。证明事务的行锁是有效的!总结下一篇,将会继续讲解接口限流(单用户限流 + 抢购接口隐藏)。现在有点累,休息休息。参考cloud.tencent.com/developer/a…juejin.cn/post/684490…crossoverjie.top/%2F2018%2F0…www.jianshu.com/p/5d4fe4b2a…segmentfault.com/a/119000001…zhenganwen.top/posts/30bb5…
前言本文是秒杀系统的第三篇,通过实际代码讲解,帮助你了解秒杀系统设计的关键点,上手实际项目。本篇主要讲解秒杀系统中,关于抢购(下单)接口相关的单用户防刷措施,主要说两块内容:抢购接口隐藏单用户限制频率(单位时间内限制访问次数)当然,这两个措施放在任何系统中都有用,严格来说并不是秒杀系统独特的设计,所以今天的内容也会比较的通用。此外,我做了一张流程图,描述了目前我们实现的秒杀接口下单流程:正文抢购接口隐藏对于稍微懂点电脑的,又会动歪脑筋的人来说,点击F12打开浏览器的控制台,就能在点击抢购按钮后,获取我们抢购接口的链接。(手机APP等其他客户端可以抓包来拿到)一旦坏蛋拿到了抢购的链接,只要稍微写点爬虫代码,模拟一个抢购请求,就可以不通过点击下单按钮,直接在代码中请求我们的接口,完成下单。所以就有了成千上万的薅羊毛军团,写一些脚本抢购各种秒杀商品。他们只需要在抢购时刻的000毫秒,开始不间断发起大量请求,觉得比大家在APP上点抢购按钮要快,毕竟人的速度又极限,更别说APP说不定还要经过几层前端验证才会真正发出请求。所以我们需要将抢购接口进行隐藏,抢购接口隐藏(接口加盐)的具体做法:每次点击秒杀按钮,先从服务器获取一个秒杀验证值(接口内判断是否到秒杀时间)。Redis以缓存用户ID和商品ID为Key,秒杀地址为Value缓存验证值用户请求秒杀商品的时候,要带上秒杀验证值进行校验。大家先停下来仔细想想,通过这样的办法,能够防住通过脚本刷接口的人吗?能,也不能。可以防住的是直接请求接口的人,但是只要坏蛋们把脚本写复杂一点,先去请求一个验证值,再立刻请求抢购,也是能够抢购成功的。不过坏蛋们请求验证值接口,也需要在抢购时间开始后,才能请求接口拿到验证值,然后才能申请抢购接口。理论上来说在访问接口的时间上受到了限制,并且我们还能通过在验证值接口增加更复杂的逻辑,让获取验证值的接口并不快速返回验证值,进一步拉平普通用户和坏蛋们的下单时刻。所以接口加盐还是有用的!下面我们就实现一种简单的加盐接口代码,抛砖引玉。代码逻辑实现代码还是使用之前的项目,我们在其上面增加两个接口:获取验证值接口携带验证值下单接口由于之前我们只有两个表,一个stock表放库存商品,一个stockOrder订单表,放订购成功的记录。但是这次涉及到了用户,所以我们新增用户表,并且添加一个用户张三。并且在订单表中,不仅要记录商品id,同时要写入用户id。整个SQL结构如下,讲究一个简洁,暂时不加入别的多余字段:-- ---------------------------- -- Table structure for stock -- ---------------------------- DROP TABLE IF EXISTS `stock`; CREATE TABLE `stock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称', `count` int(11) NOT NULL COMMENT '库存', `sale` int(11) NOT NULL COMMENT '已售', `version` int(11) NOT NULL COMMENT '乐观锁,版本号', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of stock -- ---------------------------- INSERT INTO `stock` VALUES ('1', 'iphone', '50', '0', '0'); INSERT INTO `stock` VALUES ('2', 'mac', '10', '0', '0'); -- ---------------------------- -- Table structure for stock_order -- ---------------------------- DROP TABLE IF EXISTS `stock_order`; CREATE TABLE `stock_order` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `sid` int(11) NOT NULL COMMENT '库存ID', `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称', `user_id` int(11) NOT NULL DEFAULT '0', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ---------------------------- -- Records of stock_order -- ---------------------------- -- ---------------------------- -- Table structure for user -- ---------------------------- DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of user -- ---------------------------- INSERT INTO `user` VALUES ('1', '张三'); 复制代码获取验证值接口该接口要求传用户id和商品id,返回验证值,并且该验证值Controller中添加方法:/** * 获取验证值 * @return */ @RequestMapping(value = "/getVerifyHash", method = {RequestMethod.GET}) @ResponseBody public String getVerifyHash(@RequestParam(value = "sid") Integer sid, @RequestParam(value = "userId") Integer userId) { String hash; try { hash = userService.getVerifyHash(sid, userId); } catch (Exception e) { LOGGER.error("获取验证hash失败,原因:[{}]", e.getMessage()); return "获取验证hash失败"; } return String.format("请求抢购验证hash值为:%s", hash); } 复制代码UserService中添加方法:@Override public String getVerifyHash(Integer sid, Integer userId) throws Exception { // 验证是否在抢购时间内 LOGGER.info("请自行验证是否在抢购时间内"); // 检查用户合法性 User user = userMapper.selectByPrimaryKey(userId.longValue()); if (user == null) { throw new Exception("用户不存在"); } LOGGER.info("用户信息:[{}]", user.toString()); // 检查商品合法性 Stock stock = stockService.getStockById(sid); if (stock == null) { throw new Exception("商品不存在"); } LOGGER.info("商品信息:[{}]", stock.toString()); // 生成hash String verify = SALT + sid + userId; String verifyHash = DigestUtils.md5DigestAsHex(verify.getBytes()); // 将hash和用户商品信息存入redis String hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId; stringRedisTemplate.opsForValue().set(hashKey, verifyHash, 3600, TimeUnit.SECONDS); LOGGER.info("Redis写入:[{}] [{}]", hashKey, verifyHash); return verifyHash; } 复制代码一个Cache常量枚举类CacheKey:package cn.monitor4all.miaoshadao.utils; public enum CacheKey { HASH_KEY("miaosha_hash"), LIMIT_KEY("miaosha_limit"); private String key; private CacheKey(String key) { this.key = key; } public String getKey() { return key; } } 复制代码代码解释:可以看到在Service中,我们拿到用户id和商品id后,会检查商品和用户信息是否在表中存在,并且会验证现在的时间(我这里为了简化,只是写了一行LOGGER,大家可以根据需求自行实现)。在这样的条件过滤下,才会给出hash值。并且将Hash值写入了Redis中,缓存3600秒(1小时),如果用户拿到这个hash值一小时内没下单,则需要重新获取hash值。下面又到了动小脑筋的时间了,想一下,这个hash值,如果每次都按照商品+用户的信息来md5,是不是不太安全呢。毕竟用户id并不一定是用户不知道的(就比如我这种用自增id存储的,肯定不安全),而商品id,万一也泄露了出去,那么坏蛋们如果再知到我们是简单的md5,那直接就把hash算出来了!在代码里,我给hash值加了个前缀,也就是一个salt(盐),相当于给这个固定的字符串撒了一把盐,这个盐是HASH_KEY("miaosha_hash"),写死在了代码里。这样黑产只要不猜到这个盐,就没办法算出来hash值。这也只是一种例子,实际中,你可以把盐放在其他地方, 并且不断变化,或者结合时间戳,这样就算自己的程序员也没法知道hash值的原本字符串是什么了。携带验证值下单接口用户在前台拿到了验证值后,点击下单按钮,前端携带着特征值,即可进行下单操作。Controller中添加方法:/** * 要求验证的抢购接口 * @param sid * @return */ @RequestMapping(value = "/createOrderWithVerifiedUrl", method = {RequestMethod.GET}) @ResponseBody public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid, @RequestParam(value = "userId") Integer userId, @RequestParam(value = "verifyHash") String verifyHash) { int stockLeft; try { stockLeft = orderService.createVerifiedOrder(sid, userId, verifyHash); LOGGER.info("购买成功,剩余库存为: [{}]", stockLeft); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return e.getMessage(); } return String.format("购买成功,剩余库存为:%d", stockLeft); } 复制代码OrderService中添加方法:@Override public int createVerifiedOrder(Integer sid, Integer userId, String verifyHash) throws Exception { // 验证是否在抢购时间内 LOGGER.info("请自行验证是否在抢购时间内,假设此处验证成功"); // 验证hash值合法性 String hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId; String verifyHashInRedis = stringRedisTemplate.opsForValue().get(hashKey); if (!verifyHash.equals(verifyHashInRedis)) { throw new Exception("hash值与Redis中不符合"); } LOGGER.info("验证hash值合法性成功"); // 检查用户合法性 User user = userMapper.selectByPrimaryKey(userId.longValue()); if (user == null) { throw new Exception("用户不存在"); } LOGGER.info("用户信息验证成功:[{}]", user.toString()); // 检查商品合法性 Stock stock = stockService.getStockById(sid); if (stock == null) { throw new Exception("商品不存在"); } LOGGER.info("商品信息验证成功:[{}]", stock.toString()); //乐观锁更新库存 saleStockOptimistic(stock); LOGGER.info("乐观锁更新库存成功"); //创建订单 createOrderWithUserInfo(stock, userId); LOGGER.info("创建订单成功"); return stock.getCount() - (stock.getSale()+1); } 复制代码代码解释:可以看到service中,我们需要验证了:商品信息用户信息时间库存如此,我们便完成了一个拥有验证的下单接口。试验一下接口我们先让用户1,法外狂徒张三登场,发起请求:http://localhost:8080/getVerifyHash?sid=1&userId=1 复制代码得到结果:控制台输出:别急着下单,我们看一下redis里有没有存储好key:木偶问题,接下来,张三可以去请求下单了!http://localhost:8080/createOrderWithVerifiedUrl?sid=1&userId=1&verifyHash=d4ff4c458da98f69b880dd79c8a30bcf 复制代码得到输出结果:单用户限制频率假设我们做好了接口隐藏,但是像我上面说的,总有无聊的人会写一个复杂的脚本,先请求hash值,再立刻请求购买,如果你的app下单按钮做的很差,大家都要开抢后0.5秒才能请求成功,那可能会让脚本依然能够在大家前面抢购成功。我们需要在做一个额外的措施,来限制单个用户的抢购频率。其实很简单的就能想到用redis给每个用户做访问统计,甚至是带上商品id,对单个商品做访问统计,这都是可行的。我们先实现一个对用户的访问频率限制,我们在用户申请下单时,检查用户的访问次数,超过访问次数,则不让他下单!使用Redis/Memcached我们使用外部缓存来解决问题,这样即便是分布式的秒杀系统,请求被随意分流的情况下,也能做到精准的控制每个用户的访问次数。Controller中添加方法:/** * 要求验证的抢购接口 + 单用户限制访问频率 * @param sid * @return */ @RequestMapping(value = "/createOrderWithVerifiedUrlAndLimit", method = {RequestMethod.GET}) @ResponseBody public String createOrderWithVerifiedUrlAndLimit(@RequestParam(value = "sid") Integer sid, @RequestParam(value = "userId") Integer userId, @RequestParam(value = "verifyHash") String verifyHash) { int stockLeft; try { int count = userService.addUserCount(userId); LOGGER.info("用户截至该次的访问次数为: [{}]", count); boolean isBanned = userService.getUserIsBanned(userId); if (isBanned) { return "购买失败,超过频率限制"; } stockLeft = orderService.createVerifiedOrder(sid, userId, verifyHash); LOGGER.info("购买成功,剩余库存为: [{}]", stockLeft); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return e.getMessage(); } return String.format("购买成功,剩余库存为:%d", stockLeft); } 复制代码UserService中增加两个方法:addUserCount:每当访问订单接口,则增加一次访问次数,写入RedisgetUserIsBanned:从Redis读出该用户的访问次数,超过10次则不让购买了!不能让张三做法外狂徒。@Override public int addUserCount(Integer userId) throws Exception { String limitKey = CacheKey.LIMIT_KEY.getKey() + "_" + userId; String limitNum = stringRedisTemplate.opsForValue().get(limitKey); int limit = -1; if (limitNum == null) { stringRedisTemplate.opsForValue().set(limitKey, "0", 3600, TimeUnit.SECONDS); } else { limit = Integer.parseInt(limitNum) + 1; stringRedisTemplate.opsForValue().set(limitKey, String.valueOf(limit), 3600, TimeUnit.SECONDS); } return limit; } @Override public boolean getUserIsBanned(Integer userId) { String limitKey = CacheKey.LIMIT_KEY.getKey() + "_" + userId; String limitNum = stringRedisTemplate.opsForValue().get(limitKey); if (limitNum == null) { LOGGER.error("该用户没有访问申请验证值记录,疑似异常"); return true; } return Integer.parseInt(limitNum) > ALLOW_COUNT; } 复制代码试一试接口使用前文用的JMeter做并发访问接口30次,可以看到下单了10次后,不让再购买了:大功告成了。能否不用Redis/Memcached实现用户访问频率统计且慢,如果你说你不愿意用redis,有什么办法能够实现访问频率统计吗,有呀,如果你放弃分布式的部署服务,那么你可以在内存中存储访问次数,比如:Google Guava的内存缓存状态模式不知道大家的设计模式复习的怎么样了,如果没有复习到状态模式,可以先去看看状态模式的定义。状态模式很适合实现这种访问次数限制场景。我的博客和公众号(后端技术漫谈)里,写了个《设计模式自习室》系列,详细介绍了每种设计模式,大家有兴趣可可以看看。【设计模式自习室】开篇:为什么要有设计模式?这里我就不实现了,毕竟咱们还是分布式秒杀服务为主,不过引用一个博客的例子,大家感受下状态模式的实际应用:www.cnblogs.com/java-my-lif…考虑一个在线投票系统的应用,要实现控制同一个用户只能投一票,如果一个用户反复投票,而且投票次数超过5次,则判定为恶意刷票,要取消该用户投票的资格,当然同时也要取消他所投的票;如果一个用户的投票次数超过8次,将进入黑名单,禁止再登录和使用系统。public class VoteManager { //持有状体处理对象 private VoteState state = null; //记录用户投票的结果,Map<String,String>对应Map<用户名称,投票的选项> private Map<String,String> mapVote = new HashMap<String,String>(); //记录用户投票次数,Map<String,Integer>对应Map<用户名称,投票的次数> private Map<String,Integer> mapVoteCount = new HashMap<String,Integer>(); /** * 获取用户投票结果的Map */ public Map<String, String> getMapVote() { return mapVote; } /** * 投票 * @param user 投票人 * @param voteItem 投票的选项 */ public void vote(String user,String voteItem){ //1.为该用户增加投票次数 //从记录中取出该用户已有的投票次数 Integer oldVoteCount = mapVoteCount.get(user); if(oldVoteCount == null){ oldVoteCount = 0; } oldVoteCount += 1; mapVoteCount.put(user, oldVoteCount); //2.判断该用户的投票类型,就相当于判断对应的状态 //到底是正常投票、重复投票、恶意投票还是上黑名单的状态 if(oldVoteCount == 1){ state = new NormalVoteState(); } else if(oldVoteCount > 1 && oldVoteCount < 5){ state = new RepeatVoteState(); } else if(oldVoteCount >= 5 && oldVoteCount <8){ state = new SpiteVoteState(); } else if(oldVoteCount > 8){ state = new BlackVoteState(); } //然后转调状态对象来进行相应的操作 state.vote(user, voteItem, this); } } 复制代码public class Client { public static void main(String[] args) { VoteManager vm = new VoteManager(); for(int i=0;i<9;i++){ vm.vote("u1","A"); } } } 复制代码结果:总结最后,感谢大家的喜爱。希望大家多多支持我。参考cloud.tencent.com/developer/a…juejin.cn/post/684490…zhenganwen.top/posts/30bb5…www.cnblogs.com/java-my-lif…
前言本文我们来聊聊秒杀系统中的订单异步处理。本篇文章主要内容为何我们需要对下订单采用异步处理简单的订单异步处理实现非异步与异步下单接口的性能对比一个用户抢购体验更好的实现方式项目源码再也不用担心看完文章不会代码实现啦:https://github.com/qqxx6661/miaosha❝整个项目源码仓库使用了Maven + Springboot进行编写,并且上传了SQL文件,支持SpringBoot一键启动,方便大家调试。❞我努力将整个仓库的代码尽量做到整洁和可复用,在代码中我尽量做好每个方法的文档,并且尽量最小化方法的功能,比如下面这样:public interface StockService { /** * 查询库存:通过缓存查询库存 * 缓存命中:返回库存 * 缓存未命中:查询数据库写入缓存并返回 * @param id * @return */ Integer getStockCount(int id); /** * 获取剩余库存:查数据库 * @param id * @return */ int getStockCountByDB(int id); /** * 获取剩余库存: 查缓存 * @param id * @return */ Integer getStockCountByCache(int id); /** * 将库存插入缓存 * @param id * @return */ void setStockCountCache(int id, int count); /** * 删除库存缓存 * @param id */ void delStockCountCache(int id); /** * 根据库存 ID 查询数据库库存信息 * @param id * @return */ Stock getStockById(int id); /** * 根据库存 ID 查询数据库库存信息(悲观锁) * @param id * @return */ Stock getStockByIdForUpdate(int id); /** * 更新数据库库存信息 * @param stock * return */ int updateStockById(Stock stock); /** * 更新数据库库存信息(乐观锁) * @param stock * @return */ public int updateStockByOptimistic(Stock stock); } 复制代码正文简单的订单异步处理实现介绍前面几篇文章,我们从「限流角度,缓存角度」来优化了用户下单的速度,减少了服务器和数据库的压力。这些处理对于一个秒杀系统都是非常重要的,并且效果立竿见影,那还有什么操作也能有立竿见影的效果呢?答案是对于下单的异步处理。在秒杀系统用户进行抢购的过程中,由于在同一时间会有大量请求涌入服务器,如果每个请求都立即访问数据库进行扣减库存+写入订单的操作,对数据库的压力是巨大的。如何减轻数据库的压力呢,「我们将每一条秒杀的请求存入消息队列(例如RabbitMQ)中,放入消息队列后,给用户返回类似“抢购请求发送成功”的结果。而在消息队列中,我们将收到的下订单请求一个个的写入数据库中」,比起多线程同步修改数据库的操作,大大缓解了数据库的连接压力,最主要的好处就表现在数据库连接的减少:同步方式:大量请求快速占满数据库框架开启的数据库连接池,同时修改数据库,导致数据库读写性能骤减。异步方式:一条条消息以顺序的方式写入数据库,连接数几乎不变(当然,也取决于消息队列消费者的数量)。「这种实现可以理解为是一中流量削峰:让数据库按照他的处理能力,从消息队列中拿取消息进行处理。」结合之前的四篇秒杀系统文章,这样整个流程图我们就实现了:代码实现我们在源码仓库里,新增一个controller对外接口:/** * 下单接口:异步处理订单 * @param sid * @return */ @RequestMapping(value = "/createUserOrderWithMq", method = {RequestMethod.GET}) @ResponseBody public String createUserOrderWithMq(@RequestParam(value = "sid") Integer sid, @RequestParam(value = "userId") Integer userId) { try { // 检查缓存中该用户是否已经下单过 Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId); if (hasOrder != null && hasOrder) { LOGGER.info("该用户已经抢购过"); return "你已经抢购过了,不要太贪心....."; } // 没有下单过,检查缓存中商品是否还有库存 LOGGER.info("没有抢购过,检查缓存中商品是否还有库存"); Integer count = stockService.getStockCount(sid); if (count == 0) { return "秒杀请求失败,库存不足....."; } // 有库存,则将用户id和商品id封装为消息体传给消息队列处理 // 注意这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证 LOGGER.info("有库存:[{}]", count); JSONObject jsonObject = new JSONObject(); jsonObject.put("sid", sid); jsonObject.put("userId", userId); sendToOrderQueue(jsonObject.toJSONString()); return "秒杀请求提交成功"; } catch (Exception e) { LOGGER.error("下单接口:异步处理订单异常:", e); return "秒杀请求失败,服务器正忙....."; } } 复制代码createUserOrderWithMq接口整体流程如下:检查缓存中该用户是否已经下单过:在消息队列下单成功后写入redis一条用户id和商品id绑定的数据没有下单过,检查缓存中商品是否还有库存缓存中如果有库存,则将用户id和商品id封装为消息体「传给消息队列处理」注意:这里的「有库存和已经下单」都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证,「作为兜底逻辑」消息队列是如何接收消息的呢?我们新建一个消息队列,采用第四篇文中使用过的RabbitMQ,我再稍微贴一下整个创建RabbitMQ的流程把:pom.xml新增RabbitMq的依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> 复制代码写一个RabbitMqConfig:@Configuration public class RabbitMqConfig { @Bean public Queue orderQueue() { return new Queue("orderQueue"); } } 复制代码添加一个消费者:@Component @RabbitListener(queues = "orderQueue") public class OrderMqReceiver { private static final Logger LOGGER = LoggerFactory.getLogger(OrderMqReceiver.class); @Autowired private StockService stockService; @Autowired private OrderService orderService; @RabbitHandler public void process(String message) { LOGGER.info("OrderMqReceiver收到消息开始用户下单流程: " + message); JSONObject jsonObject = JSONObject.parseObject(message); try { orderService.createOrderByMq(jsonObject.getInteger("sid"),jsonObject.getInteger("userId")); } catch (Exception e) { LOGGER.error("消息处理异常:", e); } } } 复制代码真正的下单的操作,在service中完成,我们在orderService中新建createOrderByMq方法:@Override public void createOrderByMq(Integer sid, Integer userId) throws Exception { Stock stock; //校验库存(不要学我在trycatch中做逻辑处理,这样是不优雅的。这里这样处理是为了兼容之前的秒杀系统文章) try { stock = checkStock(sid); } catch (Exception e) { LOGGER.info("库存不足!"); return; } //乐观锁更新库存 boolean updateStock = saleStockOptimistic(stock); if (!updateStock) { LOGGER.warn("扣减库存失败,库存已经为0"); return; } LOGGER.info("扣减库存成功,剩余库存:[{}]", stock.getCount() - stock.getSale() - 1); stockService.delStockCountCache(sid); LOGGER.info("删除库存缓存"); //创建订单 LOGGER.info("写入订单至数据库"); createOrderWithUserInfoInDB(stock, userId); LOGGER.info("写入订单至缓存供查询"); createOrderWithUserInfoInCache(stock, userId); LOGGER.info("下单完成"); } 复制代码真正的下单的操作流程为:校验数据库库存乐观锁更新库存(其他之前讲到的锁也可以啦)写入订单至数据库「写入订单和用户信息至缓存供查询」:写入后,在外层接口便可以通过判断redis中是否存在用户和商品的抢购信息,来直接给用户返回“你已经抢购过”的消息。「我是如何在redis中记录商品和用户的关系的呢,我使用了set集合,key是商品id,而value则是用户id的集合,当然这样有一些不合理之处:」这种结构默认了一个用户只能抢购一次这个商品使用set集合,在用户过多后,每次检查需要遍历set,用户过多有性能问题大家知道需要做这种操作就好,具体如何在生产环境的redis中存储这种关系,大家可以深入优化下。@Override public Boolean checkUserOrderInfoInCache(Integer sid, Integer userId) throws Exception { String key = CacheKey.USER_HAS_ORDER.getKey() + "_" + sid; LOGGER.info("检查用户Id:[{}] 是否抢购过商品Id:[{}] 检查Key:[{}]", userId, sid, key); return stringRedisTemplate.opsForSet().isMember(key, userId.toString()); } 复制代码非异步与异步下单接口的性能对比接下来就是喜闻乐见的「非正规」性能测试环节,我们来对异步处理和非异步处理做一个性能对比。首先,为了测试方便,我把用户购买限制先取消掉,不然我用Jmeter还要来模拟多个用户id,太麻烦了,不是我们的重点。我们把上面的controller接口这一部分注释掉:// 检查缓存中该用户是否已经下单过 Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId); if (hasOrder != null && hasOrder) { LOGGER.info("该用户已经抢购过"); return "你已经抢购过了,不要太贪心....."; } 复制代码这样我们可以用JMeter模拟抢购的情况了。「我们先玩票大的!」 在我这个1c4g1m带宽的云数据库上,「设置商品数量5000个,同时并发访问10000次」。服务器先跑起来,访问接口是http://localhost:8080/createUserOrderWithMq?sid=1&userId=1启动!10000个线程并发,直接把我的1M带宽小水管云数据库打穿了!对不起对不起,打扰了,我们还是老实一点,不要对这么低配置的数据库有不切实际的幻想。我们改成1000个线程并发,商品库存为500个,「使用常规的非异步下单接口」:对比1000个线程并发,「使用异步订单接口」:「可以看到,非异步的情况下,吞吐量是37个请求/秒,而异步情况下,我们的接只是做了两个事情,检查缓存中库存+发消息给消息队列,所以吞吐量为600个请求/秒。」在发送完请求后,消息队列中立刻开始处理消息:我截图了在500个库存刚刚好消耗完的时候的日志,可以看到,一旦库存没有了,消息队列就完成不了扣减库存的操作,就不会将订单写入数据库,也不会向缓存中记录用户已经购买了该商品的消息。更加优雅的实现那么问题来了,我们实现了上面的异步处理后,用户那边得到的结果是怎么样的呢?用户点击了提交订单,收到了消息:您的订单已经提交成功。然后用户啥也没看见,也没有订单号,用户开始慌了,点到了自己的个人中心——已付款。发现居然没有订单!(因为可能还在队列中处理)这样的话,用户可能马上就要开始投诉了!太不人性化了,我们不能只为了开发方便,舍弃了用户体验!所以我们要改进一下,如何改进呢?其实很简单:让前端在提交订单后,显示一个“排队中”,「就像我们在小米官网抢小米手机那样」同时,前端不断请求 检查用户和商品是否已经有订单 的接口,如果得到订单已经处理完成的消息,页面跳转抢购成功。「是不是很小米(滑稽.jpg),暴露了我是miboy的事实」实现起来,我们只要在后端加一个独立的接口:/** * 检查缓存中用户是否已经生成订单 * @param sid * @return */ @RequestMapping(value = "/checkOrderByUserIdInCache", method = {RequestMethod.GET}) @ResponseBody public String checkOrderByUserIdInCache(@RequestParam(value = "sid") Integer sid, @RequestParam(value = "userId") Integer userId) { // 检查缓存中该用户是否已经下单过 try { Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId); if (hasOrder != null && hasOrder) { return "恭喜您,已经抢购成功!"; } } catch (Exception e) { LOGGER.error("检查订单异常:", e); } return "很抱歉,你的订单尚未生成,继续排队吧您嘞。"; } 复制代码我们来试验一下,首先我们请求两次下单的接口,大家用postman或者浏览器就好:http://localhost:8080/createUserOrderWithMq?sid=1&userId=1可以看到,第一次请求,下单成功了,第二次请求,则会返回已经抢购过。因为这时候redis已经写入了该用户下过订单的数据:127.0.0.1:6379> smembers miaosha_v1_user_has_order_1 (empty list or set) 127.0.0.1:6379> smembers miaosha_v1_user_has_order_1 1) "1" 复制代码我们为了模拟消息队列处理茫茫多请求的行为,我们在下单的service方法中,让线程休息10秒:@Override public void createOrderByMq(Integer sid, Integer userId) throws Exception { // 模拟多个用户同时抢购,导致消息队列排队等候10秒 Thread.sleep(10000); //完成下面的下单流程(省略) } 复制代码然后我们清除订单信息,开始下单:http://localhost:8080/createUserOrderWithMq?sid=1&userId=1第一次请求,返回信息如上图。紧接着前端显示排队中的时候,请求检查是否已经生成订单的接口,接口返回”继续排队“:一直刷刷刷接口,10秒之后,接口返回”恭喜您,抢购成功“,如下图:整个流程就走完了。结束语这篇文章介绍了如何在保证用户体验的情况下完成订单异步处理的流程。内容其实不多,深度没有前一篇那么难理解。参考https://www.cnblogs.com/xiangkejin/p/9351463.htmlhttps://www.cnblogs.com/xiangkejin/p/9351501.html
前言最近在线上环境遇到了一次SQL慢查询引发的数据库故障,影响线上业务。经过排查后,确定原因是SQL在执行时,MySQL优化器选择了错误的索引(不应该说是“错误”,而是选择了实际执行耗时更长的索引)。在排查过程中,查阅了许多资料,也学习了下MySQL优化器选择索引的基本准则,在本文中进行解决问题思路的分享。在这次事故中也能充分看出深入了解MySQL运行原理的重要性,这是遇到问题时能否独立解决问题的关键。 本文的主要内容:故障描述问题原因排查MySQL索引选择原理解决方案思考与总结正文故障描述在7月24日11点线上某数据库突然收到大量告警,慢查询数超标,并且引发了连接数暴增,导致数据库响应缓慢,影响业务。看图表慢查询在高峰达到了每分钟14w次,在平时正常情况下慢查询数仅在两位数以下,如下图:赶紧查看慢SQL记录,发现都是同一类语句导致的慢查询(隐私数据例如表名,我已经隐去):select * from sample_table where 1 = 1 and (city_id = 565) and (type = 13) order by id desc limit 0, 1 复制代码看起来语句很简单,没什么特别的。但是每个执行的查询时间达到了惊人的44s。简直耸人听闻,这已经不是“慢”能形容的了...接下来查看表数据信息,如下图:可以看到表数据量较大,预估行数在83683240,也就是8000w左右,千万数据量的表。大致情况就是这样,下面进入排查问题的环节。问题原因排查首先当然要怀疑会不会该语句没走索引,查看建表DML中的索引:KEY `idx_1` (`city_id`,`type`,`rank`), KEY `idx_log_dt_city_id_rank` (`log_dt`,`city_id`,`rank`), KEY `idx_city_id_type` (`city_id`,`type`) 复制代码请忽略idx_1和idx_city_id_type两个索引的重复,这都是历史遗留问题了。可以看到是有idx_city_id_type和idx_1索引的,我们的查询条件是city_id和type,这两个索引都是能走到的。但是,我们的查询条件真的只要考虑city_id和type吗?既然有索引,接下来就该看该语句实际有没有走到索引了,MySQL提供了Explain可以分析SQL语句。Explain 用来分析 SELECT 查询语句。Explain比较重要的字段有:select_type : 查询类型,有简单查询、联合查询、子查询等key : 使用的索引rows : 预计需要扫描的行数我们使用Explain分析该语句:select * from sample_table where city_id = 565 and type = 13 order by id desc limit 0,1 复制代码得到结果:可以看出,虽然possiblekey有我们的索引,但是最后走了主键索引。而表是千万级别,并且该查询条件最后实际是返回的空数据,也就是MySQL在主键索引上实际检索时间很长,导致了慢查询。我们可以使用force index(idx_city_id_type)让该语句选择我们设置的联合索引:select * from sample_table force index(idx_city_id_type) where ( ( (1 = 1) and (city_id = 565) ) and (type = 13) ) order by id desc limit 0, 1 复制代码这次明显执行的飞快,分析语句:实际执行时间0.00175714s,走了联合索引后,不再是慢查询了。问题找到了,总结下来就是:MySQL优化器认为在limit 1的情况下,走主键索引能够更快的找到那一条数据,并且如果走联合索引需要扫描索引后进行排序,而主键索引天生有序,所以优化器综合考虑,走了主键索引。实际上,MySQL遍历了8000w条数据也没找到那个天选之人(符合条件的数据),所以浪费了很多时间。MySQL索引选择原理优化器索引选择的准则MySQL一条语句的执行流程大致如下图,而查询优化器则是选择索引的地方:引用参考文献一段解释:首先要知道,选择索引是MySQL优化器的工作。而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的CPU资源越少。当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。总结下来,优化器选择有许多考虑的因素:扫描行数、是否使用临时表、是否排序等等我们回头看刚才的两个explain截图:走了主键索引的查询语句,rows预估行数1833,而强制走联合索引行数是45640,并且Extra信息中,显示需要Using filesort进行额外的排序。所以在不加强制索引的情况下,优化器选择了主键索引,因为它觉得主键索引扫描行数少,而且不需要额外的排序操作,主键索引天生有序。rows是怎么预估出来的同学们就要问了,为什么rows只有1833,明明实际扫描了整个主键索引啊,行数远远不止几千行。实际上explain的rows是MySQL预估的行数,是根据查询条件、索引和limit综合考虑出来的预估行数。MySQL是怎样得到索引的基数的呢?这里,我给你简单介绍一下MySQL采样统计的方法。 为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。 采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。 而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过1/M的时候,会自动触发重新做一次索引统计。 在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb_stats_persistent的值来选择: 设置为on的时候,表示统计信息会持久化存储。这时,默认的N是20,M是10。 设置为off的时候,表示统计信息只存储在内存中。这时,默认的N是8,M是16。 由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。 复制代码我们可以使用analyze table t 命令,可以用来重新统计索引信息。但是这条命令生产环境需要联系DBA,所以我就不做实验了,大家可以自行实验。索引要考虑 order by 的字段为什么这么说?因为如果我这个表中的索引是city_id,type和id的联合索引,那优化器就会走这个联合索引,因为索引已经做好了排序。更改limit大小能解决问题?把limit数量调大会影响预估行数rows,进而影响优化器索引的选择吗?答案是会。我们执行limit 10select * from sample_table where city_id = 565 and type = 13 order by id desc limit 0,10 复制代码图中rows变为了18211,增长了10倍。如果使用limit 100,会发生什么?优化器选择了联合索引。初步估计是rows还会翻倍,所以优化器放弃了主键索引。宁愿用联合索引后排序,也不愿意用主键索引了。为何突然出现异常慢查询问:这个查询语句已经在线上稳定运行了非常长的时间,为何这次突然出现了慢查询?答:以前的语句查询条件返回结果都不为空,limit1很快就能找到那条数据,返回结果。而这次代码中查询条件实际结果为空,导致了扫描了全部的主键索引。解决方案知道了MySQL为何选择这个索引的原因后,我们就可以根据上面的思路来列举出解决办法了。主要有两个大方向:强制指定索引干涉优化器选择强制选择索引:force index就像上面我最开始的操作那样,我们直接使用force index,让语句走我们想要走的索引。select * from sample_table force index(idx_city_id_type) where ( ( (1 = 1) and (city_id = 565) ) and (type = 13) ) order by id desc limit 0, 1 复制代码这样做的优点是见效快,问题马上就能解决。缺点也很明显:高耦合,这种语句写在代码里,会变得难以维护,如果索引名变化了,或者没有这个索引了,代码就要反复修改。属于硬编码。很多代码用框架封装了SQL,force index()并不容易加进去。我们换一种办法,我们去引导优化器选择联合索引。干涉优化器选择:增大limit通过增大limit,我们可以让预估扫描行数快速增加,比如改成下面的limit 0, 1000SELECT * FROM sample_table where city_id = 565 and type = 13 order by id desc LIMIT 0,1000 复制代码这样就会走上联合索引,然后排序,但是这样强行增长limit,其实总有种面向黑盒调参的感觉。干涉优化器选择:增加包含order by id字段的联合索引我们这句慢查询使用的是order by id,但是我们却没有在联合索引中加入id字段,导致了优化器认为联合索引后还要排序,干脆就不太想走这个联合索引了。我们可以新建city_id,type和id的联合索引,来解决这个问题。这样也有一定的弊端,比如我这个表到了8000w数据,建立索引非常耗时,而且通常索引就有3.4个g,如果无限制的用索引解决问题,可能会带来新的问题。表中的索引不宜过多。干涉优化器选择:写成子查询还有什么办法?我们可以用子查询,在子查询里先走city_id和type的联合索引,得到结果集后在limit1选出第一条。但是子查询使用有风险,一版DBA也不建议使用子查询,会建议大家在代码逻辑中完成复杂的查询。当然我们这句并不复杂啦~Select * From sample_table Where id in (Select id From `newhome_db`.`af_hot_price_region` where (city_id = 565 and type = 13)) limit 0, 1 复制代码还有很多解决办法...SQL优化是个很大的工程,我们还有非常多的办法能够解决这句慢查询问题,这里就不一一展开了。留给大家做为思考题了。总结本文带大家回顾了一次MySQL优化器选错索引导致的线上慢查询事故,可以看出MySQL优化器对于索引的选择并不单单依靠某一个标准,而是一个综合选择的结果。我自己也对这方面了解不深入,还需要多多学习,争取能够好好的做一个索引选择的总结(挖坑)。不说了,拿起巨厚的《高性能MySQL》,开始...最后做个文章总结:该慢查询语句中使用order by id导致优化器在主键索引和city_id和type的联合索引中有所取舍,最终导致选择了更慢的索引。可以通过强制指定索引,建立包含id的联合索引,增大limit等方式解决问题。平时开发时,尤其是对于特大数据量的表,要注意SQL语句的规范和索引的建立,避免事故的发生。参考《高性能MySQL》MySQL优化器 limit影响的case:www.cnblogs.com/xpchild/p/3…mysql中走与不走索引的情况汇集(待全量实验):www.cnblogs.com/gxyandwmm/p…MySQL ORDER BY主键id加LIMIT限制走错索引:www.jianshu.com/p/caf5818ec…【业务学习】关于MySQL order by limit 走错索引的探讨:segmentfault.com/a/119000002…MySQL为什么有时候会选错索引?:www.cnblogs.com/a-phper/p/1…
注意:本仓库灵感来源于美团技术博客 ,若您需要寻找的是美团仓库,可以跳转这里本文目录:什么是操作日志?Java中常见的操作日志实现方式实战:通过注解实现操作日志的记录什么是操作日志?定义:操作日志主要是指对某个对象进行新增操作或者修改操作后记录下这个新增或者修改,操作日志要求可读性比较强,因为它主要是给用户看的,比如订单的物流信息,用户需要知道在什么时间发生了什么事情。再比如,客服对工单的处理记录信息。以我们系统内部使用的一个CRM系统举例,里面每个联系人的资料都会有操作历史:这些数据就是操作系统日志,这些数据通常会以结构化数据的形式存储在数据库中,对于开发来说,这种日志的代码逻辑通常是非常规律,比如读取变化前和变化后的数据,获取当前操作人和操作时间等等。常见的操作日志实现方式本小节文中使用的部分定义描述和示例来源于美团原文,请知悉。在小型项目中,这种日志记录的操作通常会以提供一个接口或整个日志记录Service来实现。那么放到多人共同开发的项目中,除了封装一个方法,还有什么更好的办法来统一实现操作日志的记录?下面就要讨论下在Java中,常见的操作日志实现方式。当你需要给一个大型系统从头到尾加上操作日志,那么除了上述的手动处理方式,也有很多种整体设计方案:1. 使用Canal监听数据库记录操作日志Canal应运而生,它通过伪装成数据库的从库,读取主库发来的binlog,用来实现数据库增量订阅和消费业务需求。这个方式有点是和业务逻辑完全分离,缺点也很大,需要使用到MySQL的Binlog,向DBA申请就有点困难。如果涉及到修改第三方接口,那么就无法监听别人的数据库了。所以调用RPC接口时,就需要额外的在业务代码中增加记录代码,破坏了“和业务逻辑完全分离”这个基本原则,局限性大。2. 通过日志文件的方式记录log.info("订单已经创建,订单编号:{}", orderNo) log.info("修改了订单的配送地址:从“{}”修改到“{}”, "金灿灿小区", "银盏盏小区") 复制代码这种方式,需要手动的设定好操作日志和其他日志的区别,比如给操作日志单独的Logger。并且,对于操作人的记录,需要在函数中额外的写入请求的上下文中。3. 通过 LogUtil 的方式记录日志LogUtil.log(orderNo, "订单创建", "小明") LogUtil.log(orderNo, "订单创建,订单号"+"NO.11089999", "小明") String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”" LogUtil.log(orderNo, String.format(tempalte, "小明", "金灿灿小区", "银盏盏小区"), "小明") 复制代码这种方式会导致业务的逻辑比较繁杂,最后导致 LogUtils.logRecord() 方法的调用存在于很多业务的代码中,而且类似 getLogContent() 这样的方法也散落在各个业务类中,对于代码的可读性和可维护性来说是一个灾难。4. 方法注解实现操作日志@OperationLog(bizType = "bizType", bizId = "#request.orderId", pipeline = DataPipelineEnum.QUEUE) public Response<BaseResult> function(Request request) { // 方法执行逻辑 } 复制代码我们可以在注解的操作日志上记录固定文案,这样业务逻辑和业务代码可以做到解耦,让我们的业务代码变得纯净起来。实战:通过注解实现操作日志的记录我给自己的这个项目,或者说依赖包起名为log-record-starter,一方面遵循springboot-starter命名规范,一方面也表明项目的用处,记录日志。开启项目之前,先问问自己Q:你这个依赖包,又是一个冗余的造轮子吧?市面上这种东西是不是已经够多了?A:本着有现成轮子绝不造轮子的原则,我在Github和其他网站进行了一系列的相关搜索,Github有几个类似的实现项目,不过都以个人实现为主,没有一个具有一定影响力的成熟项目。基于我在自己的业务项目中拥有实际的场景需求,并且目前还没有满足我需求的现成可接入依赖,我才开始这个依赖包的代码编写。Q:我用了你这个依赖包,是不是很复杂?之后你不维护了的话,是不是坑我们这些吃螃蟹的?A:依赖包的维护问题一直是一个大问题,本着最小依赖,尽量可扩展的原则。本库特点如下:使用SpringBoot Starter,接入只需要简单引入一个依赖。通过Spring Spel表达式拿到参数,对你的业务逻辑没有侵入性。默认使用RabbitMq传递日志消息,日志操作解耦。之后会引入其他数据源,例如Kafka等(毕竟还要给三歪的项目用)。好了,这就是我想说在前面的话。下面就是该项目的使用介绍和应用场景介绍。Log-record-starter 一句话介绍本项目支持用户使用注解的方式从方法中获取操作日志,并推送到指定数据源只需要简单的加上一个@OperationLog便可以将方法的参数,返回结果甚至是异常堆栈通过消息队列发送出去,统一处理。@OperationLog(bizType = "bizType", bizId = "#request.orderId", pipeline = DataPipelineEnum.QUEUE) public Response<BaseResult> function(Request request) { // 方法执行逻辑 } 复制代码使用方法只需要简单的三步:第一步: SpringBoot项目中引入依赖<dependency> <groupId>cn.monitor4all</groupId> <artifactId>log-record-starter</artifactId> <version>1.0.0</version> </dependency> 复制代码这里先打断一下,由于Maven公共仓库,是全球唯一托管的,个人开发的项目要提交上去,需要复杂的审核流程,我搞了一会没搞定,就先将包传到了Github Package上(实际就是Github的私有Maven库),所以大家引入依赖后,是不会直接拉到包的,需要配置下你的Maven settings.xml文件。配置很简单,两步,一步是去Github登录,到自己的Settings中,申请一个token,拿到一串字符串。第二步,找到你的settings.xml文件,添加上:activeProfiles> <activeProfile>github</activeProfile> </activeProfiles> <profiles> <profile> <id>github</id> <repositories> <repository> <id>central</id> <url>https://repo1.maven.org/maven2</url> </repository> <repository> <id>github</id> <url>https://maven.pkg.github.com/OWNER/REPOSITORY</url> <snapshots> <enabled>true</enabled> </snapshots> </repository> </repositories> </profile> </profiles> <servers> <server> <id>github</id> <username>这里填写你的Github用户名</username> <password>这里填写你刚才申请的token</password> </server> </servers> 复制代码重启下你的IDEA,能看到下面这个,应该你的settings.xml生效了。第二步: 在Spring配置文件中添加RabbitMq数据源配置在自己公司里由于阿里封装了自己的MQ叫做MetaQ,并没有对外开源,所以这里先接入了RabbitMQ,也算是比较通用,图个方便。未来会接其他数据源。RabbitMq的安装在这里不展开了,实在是不想把篇幅拉得太大,大家可以自行谷歌下,比如“Docker安装RabbitMq”类似的文章,几分钟就可以设置安装好。log-record.rabbitmq.host=localhost log-record.rabbitmq.port=5672 log-record.rabbitmq.username=admin log-record.rabbitmq.password=xxxxxxxx log-record.rabbitmq.queue-name=logrecord log-record.rabbitmq.routing-key= log-record.rabbitmq.exchange-name=logrecord 复制代码第三步: 在你自己的项目中,在需要记录日志的方法上,添加注解。@OperationLog(bizType = "bizType", bizId = "#request.orderId", pipeline = DataPipelineEnum.QUEUE) public Response<BaseResult> function(Request request) { // 方法执行逻辑 } 复制代码(必填)bizType:业务类型(必填)bizId:唯一业务ID(支持SpEL表达式)(必填)pipeline:数据管道,目前只有QUEUE一个数据管道,后续可考虑接入更多数据源(非必填)msg:需要传递的其他数据(支持SpEL表达式)(非必填)tag:自定义标签代码工作原理由于采用的是SpringBoot Starter方式,所以只要你是用的是SpringBoot,会自动扫描到依赖包中的类,并自动通过Spring进行配置和管理。该注解通过在切面中解析SpEL参数,将数据发往数据源。目前仅支持RabbitMq,发送的消息体如下:方法处理正常发送消息体:[LogDTO(logId=3771ff1e-e5ff-4251-a534-31dab5b666b3, bizId=str, bizType=testType1, exception=null, operateDate=Sat Nov 06 20:08:54 CST 2021, success=true, msg={"testList":["1","2","3"],"testStr":"str"}, tag=operation)] 复制代码方法处理异常发送消息体:[LogDTO(logId=d162b2db-2346-4144-8cd4-aea900e4682b, bizId=str, bizType=testType1, exception=testError, operateDate=Sat Nov 06 20:09:24 CST 2021, success=false, msg={"testList":["1","2","3"],"testStr":"str"}, tag=operation)] 复制代码LogDTO是定义的消息结构:logId:生成的UUID bizId:注解中传递的bizId bizType:注解中传递的bizType exception:若方法执行失败,写入执行的异常信息 operateDate:操作执行的当前时间 success:方式是否执行成功 msg:注解中传递的tag tag:注解中传递的tag 复制代码我还加上了重复注解的支持,可以在一个方法上同时加多个@OperationLog,下图是最终使用效果,可以看到,有几个@OperationLog,就能同时发送多条日志:应用场景以下罗列了一些实际的应用场景,包括我业务中实际使用,并且已经上线使用的场景。一、特定操作记录日志:如文章最上面一张CRM系统的图描述的那样,在用户进行了编辑操作后,拿到用户操作的数据,执行日志写入。二、特定操作触发通知:由于我的业务是接手了好几个仓库,并且这几个仓库的操作串成了一条完成链路,我需要在链路的某个节点触发给用户的提醒,如果写硬编码也可以实现,但是远不如在方法上使用注解发送消息来得方便。例如下方在下单方法调用后发送消息。三、特定操作更新数据表:我的业务中,几个系统互相吞吐数据,订单的一部分数据存留在外部系统里,我们最终目标想要将其中一个系统替代掉,所以需要拦截他们的数据,恰好几个系统是使用LINK作为网关的,我们将数据请求拦截一层,并将拦截的方法使用该二方库进行全部参数的发送,将数据同步写入我们自己的数据库中,实现”双写“。四、跨多应用数据聚合操作:和”三“类似,在多个应用中,如果需要做行为相同的业务逻辑,完全可以在各个系统中将数据发送到同一个消息队列中,再进行统一处理。总结本文带大家了解了操作日志在Java中的几种实现方式,并且初步介绍了自己的实现代码,在之后的文章里,我会把实现的细节,包括如何部署到Maven仓库等一一和大家唠唠。参考https://tech.meituan.com/2021/09/16/operational-logbook.html
前言二叉树遍历是非常经典的算法题,也是二叉树的一道基础算法题。但是在平常的笔试面试中,其出现的频率其实并不是特别的高,我推测是这种题目相对来说比较基础,算是一个基础知识点。比如剑指offer中出现的后序遍历题目,是给出一个数字序列,让你判断是不是平衡二叉树后序遍历序列,这样出题的难度比直接让你写后序遍历难很多。但是,二叉树遍历容易吗?在递归方法下,前中后序遍历都是一个思路,理解起来也比较容易。但是只是用迭代的话,二叉树遍历其实是有难度的!,这也是为什么LeetCode会在这三题题目的下方写出进阶: 递归算法很简单,你可以通过迭代算法完成吗?这句话了。本文的主要内容如下:题目定义:上篇:二叉树前序、中序、后序遍历下篇:层序遍历、其他遍历相关题型解题思路:递归 + 迭代+ 莫里斯Morris遍历解题代码:Java + Python注1:本文中的解题思路会尽量的全面,但是解题方法千变万化,有很多奇技淫巧我不会去介绍,大家有兴趣可以自行扩展学习。注2:本文中的代码会尽量简单,易懂,并不会去追求极致的写法(比如:在一行内完成,使用各种非正式的库等)。正文二叉树的定义最多有两棵子树的树被称为二叉树二叉树下还有很多种特殊的二叉树,下方简单介绍几种常用的。满二叉树二叉树中所有非叶子结点的度都是2,且叶子结点都在同一层次上完全二叉树(可以不满)如果一个二叉树与满二叉树前m个节点的结构相同,这样的二叉树被称为完全二叉树。也就是说,如果把满二叉树从右至左、从下往上删除一些节点,剩余的结构就构成完全二叉树。二叉搜索树二叉查找树(BinarySearch Tree,也叫二叉搜索树,或称二叉排序树Binary Sort Tree)或者是一棵空树,或者是具有下列性质的二叉树:若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;它的左、右子树也分别为二叉排序树二叉树前中后序遍历遍历方法前序遍历:根结点 ---> 左子树 ---> 右子树中序遍历:左子树---> 根结点 ---> 右子树后序遍历:左子树 ---> 右子树 ---> 根结点题目介绍前序遍历LeetCode题目地址:leetcode-cn.com/problems/bi…输入: [1,null,2,3] 1 \ 2 / 3 输出: [1,2,3] 复制代码中序遍历LeetCode题目地址:leetcode-cn.com/problems/bi…输入: [1,null,2,3] 1 \ 2 / 3 输出: [1,3,2] 复制代码后序遍历LeetCode题目地址:leetcode-cn.com/problems/bi…输入: [1,null,2,3] 1 \ 2 / 3 输出: [3,2,1] 复制代码解题思路详解与代码实现二叉树的前中后序遍历,主要就是两种思路,一种是递归,一种是迭代。如果看到这里还没有感觉,不用担心,先直接往下看,第一个代码(前序遍历的递归思路)会帮助你提升感觉。递归思路递归思路是最容易理解的思路,并且前中后序遍历都相同。比如前序遍历,在递归的函数里,先往结果数组里加入根节点,然后加入根节点的左节点,然后加入根节点的右节点。最后所有递归的函数运行完毕,结果集就已经完成了。中序和后序的思路相同,就不再赘述了。前序遍历Java:class Solution { public List<Integer> preorderTraversal(TreeNode root) { List<Integer> result = new ArrayList<>(); if (root == null) { return result; } preorder(root, result); return result; } private static List<Integer> preorder(TreeNode root, List<Integer> result) { if (root != null) { result.add(root.val); preorder(root.left, result); preorder(root.right, result); } return result; } } 复制代码Python:class Solution(object): def _preorderTraversal(self, root, result): if root: result.append(root.val) self._preorderTraversal(root.left, result) self._preorderTraversal(root.right, result) def preorderTraversal(self, root): """ :type root: TreeNode :rtype: List[int] """ if root == None: return [] result = [] self._preorderTraversal(root, result) return result 复制代码中序遍历Java:class Solution { public List<Integer> inorderTraversal(TreeNode root) { List<Integer> result = new ArrayList<>(); if (root == null) { return result; } result = inorder(root, result); return result; } private static List<Integer> inorder(TreeNode root, List<Integer> result) { if (root != null) { inorder(root.left, result); result.add(root.val); inorder(root.right, result); } return result; } } 复制代码Python:class Solution(object): def generate(self, root, result): if root: self.inorder(root.left, list) result.append(root.val) self.inorder(root.right, list) def inorderTraversal(self, root): """ :type root: TreeNode :rtype: List[int] """ if not root: return [] result = [] self.generate(root, result) return result 复制代码后序遍历Java:class Solution { public List<Integer> postorderTraversal(TreeNode root) { List<Integer> result = new ArrayList<>(); if (root == null) { return result; } result = postorder(root, result); return result; } private static List<Integer> postorder(TreeNode root, List<Integer> result) { if (root != null) { postorder(root.left, result); postorder(root.right, result); result.add(root.val); } return result; } } 复制代码Python:class Solution(object): def _postorderTraversal(self, root, result): if root: self._postorderTraversal(root.left, result) self._postorderTraversal(root.right, result) result.append(root.val) def postorderTraversal(self, root): """ :type root: TreeNode :rtype: List[int] """ if root == None: return [] result = [] self._postorderTraversal(root, result) return result 复制代码迭代思路前序遍历我们需要一个栈来完成遍历。1.根节点入栈 2.取出节点,值加入结果,然后先加右,后加左。 3.重复2 复制代码这样就得到了 根节点——左子树——右子树 的遍历结果集。Java:来自官方题解LinkedList<TreeNode> stack = new LinkedList<>(); LinkedList<Integer> output = new LinkedList<>(); if (root == null) { return output; } stack.add(root); while (!stack.isEmpty()) { TreeNode node = stack.pollLast(); output.add(node.val); if (node.right != null) { stack.add(node.right); } if (node.left != null) { stack.add(node.left); } } return output; } 复制代码Python:class Solution(object): def preorderTraversal(self, root): """ :type root: TreeNode :rtype: List[int] """ ret = [] stack = [root] while stack: node = stack.pop() if node: ret.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return ret 复制代码中序遍历还是使用一个栈来解决问题。步骤如下:&emsp;1 &emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;/&emsp; \ &emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp; 2&emsp;&emsp; 3 &emsp;&emsp;&emsp;&emsp;&emsp;&emsp; / \&emsp; / \ &emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp; 4 5 6 7 复制代码我们将根节点1入栈,如果有左孩子,依次入栈,那么入栈顺序为:1,2,4。由于4的左子树为空,停止入栈,此时栈为{1,2,4}。此时将4出栈,并遍历4,由于4也没有右孩子,那么根据中序遍历的规则,我们显然应该继续遍历4的父亲2,情况是这样。所以我们继续将2出栈并遍历2,2存在右孩子,将5入栈,此时栈为{1,5}。5没有孩子,则将5出栈并遍历5,这也符合中序遍历的规则。此时栈为{1}。1有右孩子,则将1出栈并遍历1,然后将右孩子3入栈,并继续以上三个步骤即可。栈的变化过程:{1}->{1,2}->{1,2,4}->{1,2}->{1}->{1,5}->{1}->{}->{3}->{3,6}->{3}->{}->{7}->{}。总结:从根节点遍历,先放入所有有左孩子的节点直到没有,然后出栈,出栈的时候就将出栈的数字放入结果集,然后看其有没有右孩子,有的话右孩子入栈。Java:官方题解public class Solution { public List <Integer> inorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<>(); Stack<TreeNode> stack = new Stack<>(); TreeNode curr = root; while (curr != null || !stack.isEmpty()) { while (curr != null) { stack.push(curr); curr = curr.left; } curr = stack.pop(); res.add(curr.val); curr = curr.right; } return res; } } 复制代码Python:class Solution: # @param root, a tree node # @return a list of integers def inorderTraversal(self, root): result = [] stack = [] while root or stack: if root: stack.append(root) root = root.left else: root = stack.pop() result.append(root.val) root = root.right return result 复制代码后序遍历将数组输出为右子树-左子树-根节点。最后,再将数组倒序输出,形成后序遍历。这样代码并不用很繁琐,也能做完迭代。是不是似曾相识,没错,和前序遍历的迭代几乎一样,仅仅是先放右节点再放左节点变成了先放左节点再放右节点,然后倒序输出。Java:class Solution { public List<Integer> postorderTraversal(TreeNode root) { LinkedList<TreeNode> stack = new LinkedList<>(); LinkedList<Integer> output = new LinkedList<>(); if (root == null) { return output; } stack.add(root); while (!stack.isEmpty()) { TreeNode node = stack.pollLast(); output.addFirst(node.val); if (node.left != null) { stack.add(node.left); } if (node.right != null) { stack.add(node.right); } } return output; } } 复制代码Python:class Solution(object): def postorderTraversal(self, root): """ :type root: TreeNode :rtype: List[int] """ if root is None: return [] ret = [] stack = [root] while stack: node = stack.pop() if node: ret.append(node.val) if node.left: stack.append(node.left) if node.right: stack.append(node.right) return ret[::-1] 复制代码所以迭代方式,前后序是非常类似的,中序遍历可能需要单独理解下。莫里斯遍历二叉树常规的遍历方法是用递归来实现的,这种方法一般都需要O(n)的空间复杂度和O(n)的时间复杂度。而Morris方法实现的是O(1)的空间复杂度和O(n)的时间复杂度。我们知道,遍历二叉树时,最大的难点在于,遍历到子节点的时候怎样重新返回到父节点(假设节点中没有指向父节点的p指针),由于不能用栈作为辅助空间。(不然就是普通迭代方法)。为了解决这个问题,Morris方法用到了线索二叉树(threaded binary tree)的概念。在Morris方法中不需要为每个节点额外分配指针指向其前驱(predecessor)和后继节点(successor),只需要利用叶子节点中的左右空指针指向某种顺序遍历下的前驱节点或后继节点就可以了。中序遍历Step 1: 将当前节点current初始化为根节点 Step 2: While current不为空, 若current没有左子节点 a. 将current添加到输出 b. 进入右子树,亦即, current = current.right 否则 a. 在current的左子树中,令current成为最右侧节点的右子节点 b. 进入左子树,亦即,current = current.left 复制代码1 / \ 2 3 / \ / 4 5 6 复制代码首先,1 是根节点,所以将 current 初始化为 1。1 有左子节点 2,current 的左子树是2 / \ 4 5 复制代码在此左子树中最右侧的节点是 5,于是将 current(1) 作为 5 的右子节点。令 current = cuurent.left (current = 2)。 现在二叉树的形状为:2 / \ 4 5 \ 1 \ 3 / 6 复制代码对于 current(2),其左子节点为4,我们可以继续上述过程4 \ 2 \ 5 \ 1 \ 3 / 6 复制代码Java:class Solution { public List < Integer > inorderTraversal(TreeNode root) { List < Integer > res = new ArrayList < > (); TreeNode curr = root; TreeNode pre; while (curr != null) { if (curr.left == null) { res.add(curr.val); curr = curr.right; // move to next right node } else { // has a left subtree pre = curr.left; while (pre.right != null) { // find rightmost pre = pre.right; } pre.right = curr; // put cur after the pre node TreeNode temp = curr; // store cur node curr = curr.left; // move cur to the top of the new tree temp.left = null; // original cur left be null, avoid infinite loops } } return res; } } 复制代码前序遍历理解了中序遍历,前序和后序遍历相对来说也就更容易理解了。所以前序和后序贴了思路,代码我也没自己写后submit,在这里就不放了。算法的思路是从当前节点向下访问先序遍历的前驱节点,每个前驱节点都恰好被访问两次。首先从当前节点开始,向左孩子走一步然后沿着右孩子一直向下访问,直到到达一个叶子节点(当前节点的中序遍历前驱节点),所以我们更新输出并建立一条伪边 predecessor.right = root 更新这个前驱的下一个点。如果我们第二次访问到前驱节点,由于已经指向了当前节点,我们移除伪边并移动到下一个顶点。后序遍历后续遍历稍显复杂,需要建立一个临时节点dump,令其左孩子是root。并且还需要一个子过程,就是倒序输出某两个节点之间路径上的各个节点。步骤:当前节点设置为临时节点dump。如果当前节点的左孩子为空,则将其右孩子作为当前节点。如果当前节点的左孩子不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点。a) 如果前驱节点的右孩子为空,将它的右孩子设置为当前节点。当前节点更新为当前节点的左孩子。b) 如果前驱节点的右孩子为当前节点,将它的右孩子重新设为空。倒序输出从当前节点的左孩子到该前驱节点这条路径上的所有节点。当前节点更新为当前节点的右孩子。重复以上1、2直到当前节点为空。参考leetcode-cn.com/problems/bi…www.cnblogs.com/AnnieKim/ar…blog.csdn.net/softwarex4/…
前言本文是秒杀系统的第四篇,我们来讨论秒杀系统中「缓存热点数据」的问题,进一步延伸到数据库和缓存的双写一致性问题,并且给出了实现代码。前文回顾和文章规划零基础上手秒杀系统(一):防止超卖零基础上手秒杀系统(二):令牌桶限流 + 再谈超卖零基础上手秒杀系统(三):抢购接口隐藏 + 单用户限制频率零基础上手秒杀系统(四):缓存数据(数据库与缓存一致性实战)(本篇)零基础上手秒杀系统:消息队列异步处理订单...本篇文章主要内容缓存热点数据为何要使用缓存哪类数据适合缓存缓存的利与弊缓存和数据库双写一致性不使用更新缓存而是删除缓存先删除缓存,还是先操作数据库?我一定要数据库和缓存数据一致怎么办实战:先删除缓存,再更新数据库实战:先更新数据库,再删缓存实战:删除缓存重试机制实战:删除缓存重试机制实战:读取binlog异步删除缓存项目源码在这里妈妈再也不用担心我看完文章不会写代码实现啦:https://github.com/qqxx6661/miaosha正文缓存热点数据在秒杀实际的业务中,一定有很多需要做缓存的场景,比如售卖的商品,包括名称,详情等。访问量很大的数据,可以算是“热点”数据了,尤其是一些读取量远大于写入量的数据,更应该被缓存,而不应该让请求打到数据库上。为何要使用缓存缓存是为了追求“快”而存在的。我们用代码举一个例子。拿出我之前三篇文章的项目代码来,在其中增加两个查询库存的接口getStockByDB和getStockByCache,分别表示从数据库和缓存查询某商品的库存量。随后我们用JMeter进行并发请求测试。/** * 查询库存:通过数据库查询库存 * @param sid * @return */ @RequestMapping("/getStockByDB/{sid}") @ResponseBody public String getStockByDB(@PathVariable int sid) { int count; try { count = stockService.getStockCountByDB(sid); } catch (Exception e) { LOGGER.error("查询库存失败:[{}]", e.getMessage()); return "查询库存失败"; } LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count); return String.format("商品Id: %d 剩余库存为:%d", sid, count); } /** 复制代码 • 查询库存:通过缓存查询库存 • 缓存命中:返回库存 • 缓存未命中:查询数据库写入缓存并返回 • @param sid • @return */ @RequestMapping("/getStockByCache/{sid}") @ResponseBody public String getStockByCache(@PathVariable int sid) { Integer count; try { count = stockService.getStockCountByCache(sid); if (count == null) { count = stockService.getStockCountByDB(sid); LOGGER.info("缓存未命中,查询数据库,并写入缓存"); stockService.setStockCountToCache(sid, count); } } catch (Exception e) { LOGGER.error("查询库存失败:[{}]", e.getMessage()); return "查询库存失败"; } LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count); return String.format("商品Id: %d 剩余库存为:%d", sid, count); }在设置为10000个并发请求的情况下,运行JMeter,结果首先出现了大量的报错,10000个请求中98%的请求都直接失败了。打开日志,报错如下:原来是SpringBoot内置的Tomcat最大并发数搞的鬼,其默认值为200,对于10000的并发,单机服务实在是力不从心。当然,你可以修改这里的并发数设置,但是你的小机器仍然可能会扛不住。将其修改为如下配置后,我的小机器才在通过缓存拿库存的情况下,保证了10000个并发的100%返回请求:server.tomcat.max-threads=10000 server.tomcat.max-connections=10000 复制代码不使用缓存的情况下,吞吐量为668个请求每秒,并且有5%的请求由于服务压力实在太大,没有返回库存数据:使用缓存的情况下,吞吐量为2177个请求每秒:在这种“不严谨”的对比下,有缓存对于一台单机,性能提升了3倍多,如果在多台机器,更多并发的情况下,由于数据库有了更大的压力,缓存的性能优势应该会更加明显。测完了这个小实验,我看了眼我挂着Mysql的小水管腾讯云服务器,生怕他被这么高流量搞挂。这种突发的流量,指不定会被检测为异常攻击流量呢~我用的是腾讯云服务器1C4G2M,活动买的,很便宜。哪类数据适合缓存缓存量大但又不常变化的数据,比如详情,评论等。对于那些经常变化的数据,其实并不适合缓存,一方面会增加系统的复杂性(缓存的更新,缓存脏数据),另一方面也给系统带来一定的不稳定性(缓存系统的维护)。「但一些极端情况下,你需要将一些会变动的数据进行缓存,比如想要页面显示准实时的库存数,或者其他一些特殊业务场景。这时候你需要保证缓存不能(一直)有脏数据,这就需要再深入讨论一下。」缓存的利与弊我们到底该不该上缓存的,这其实也是个trade-off的问题。上缓存的优点:能够缩短服务的响应时间,给用户带来更好的体验。能够增大系统的吞吐量,依然能够提升用户体验。减轻数据库的压力,防止高峰期数据库被压垮,导致整个线上服务BOOM!上了缓存,也会引入很多额外的问题:缓存有多种选型,是内存缓存,memcached还是redis,你是否都熟悉,如果不熟悉,无疑增加了维护的难度(本来是个纯洁的数据库系统)。缓存系统也要考虑分布式,比如redis的分布式缓存还会有很多坑,无疑增加了系统的复杂性。在特殊场景下,如果对缓存的准确性有非常高的要求,就必须考虑「缓存和数据库的一致性问题」。缓存和数据库双写一致性说了这么多缓存的必要性,那么使用缓存是不是就是一个很简单的事情了呢,我之前也一直是这么觉得的,直到遇到了需要缓存与数据库保持强一致的场景,才知道让数据库数据和缓存数据保持一致性是一门很高深的学问。从远古的硬件缓存,操作系统缓存开始,缓存就是一门独特的学问。这个问题也被业界探讨了非常久,争论至今。我翻阅了很多资料,发现其实这是一个权衡的问题。值得好好讲讲。以下的讨论会引入几方观点,我会跟着观点来写代码验证所提到的问题。不使用更新缓存而是删除缓存「大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。」《分布式之数据库和缓存双写一致性方案解析》孤独烟:❝「原因一:线程安全角度」同时有请求A和请求B进行更新操作,那么会出现(1)线程A更新了数据库(2)线程B更新了数据库(3)线程B更新了缓存(4)线程A更新了缓存这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。「原因二:业务场景角度」有如下两点:(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。❞「其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。」先删除缓存,还是先操作数据库?「那么问题就来了,我们是先删除缓存,然后再更新数据库,还是先更新数据库,再删缓存呢?」❝对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。❞沈剑老师说的没有问题,不过「没完全考虑好并发请求时的数据脏读问题」,让我们再来看看孤独烟老师《分布式之数据库和缓存双写一致性方案解析》:❝「先删缓存,再更新数据库」该方案会导致请求数据不一致同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:(1)请求A进行写操作,删除缓存(2)请求B查询发现缓存不存在(3)请求B去数据库查询得到旧值(4)请求B将旧值写入缓存(5)请求A将新值写入数据库上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。❞「所以先删缓存,再更新数据库并不是一劳永逸的解决方案,再看看先更新数据库,再删缓存」❝「先更新数据库,再删缓存」这种情况不存在并发问题么?不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生(1)缓存刚好失效(2)请求A查询数据库,得一个旧值(3)请求B将新值写入数据库(4)请求B删除缓存(5)请求A将查到的旧值写入缓存ok,如果发生上述情况,确实是会发生脏数据。然而,发生这种情况的概率又有多少呢?发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,「数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。」❞「先更新数据库,再删缓存」依然会有问题,不过,问题出现的可能性会因为上面说的原因,变得比较低!所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请「先更新数据库,再删缓存!」我一定要数据库和缓存数据一致怎么办那么,如果我tm非要保证绝对一致性怎么办,先给出结论:「没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP。」所以,我们得委曲求全,可以去做到BASE理论中说的「最终一致性」。❝最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性❞大佬们给出了到达最终一致性的解决思路,主要是针对上面两种双写策略(先删缓存,再更新数据库/先更新数据库,再删缓存)导致的脏数据问题,进行相应的处理,来保证最终一致性。延时双删问:先删除缓存,再更新数据库中避免脏数据?答案:采用延时双删策略。上文我们提到,在先删除缓存,再更新数据库的情况下,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。「那么延时双删怎么解决这个问题呢?」❝(1)先淘汰缓存(2)再写数据库(这两步和原来一样)(3)休眠1秒,再次淘汰缓存这么做,可以将1秒内所造成的缓存脏数据,再次删除。❞「那么,这个1秒怎么确定的,具体该休眠多久呢?」❝针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。❞「如果你用了mysql的读写分离架构怎么办?」❝ok,在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。(1)请求A进行写操作,删除缓存(2)请求A将数据写入数据库了,(3)请求B查询缓存发现,缓存没有值(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值(5)请求B将旧值写入缓存(6)数据库完成主从同步,从库变为新值上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。❞「采用这种同步淘汰策略,吞吐量降低怎么办?」❝ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。❞「所以在先删除缓存,再更新数据库的情况下」,可以使用延时双删的策略,来保证脏数据只会存活一段时间,就会被准确的数据覆盖。「在先更新数据库,再删缓存的情况下」,缓存出现脏数据的情况虽然可能性极小,但也会出现。我们依然可以用延时双删策略,在请求A对缓存写入了脏的旧值之后,再次删除缓存。来保证去掉脏缓存。删缓存失败了怎么办:重试机制看似问题都已经解决了,但其实,还有一个问题没有考虑到,那就是删除缓存的操作,失败了怎么办?比如延时双删的时候,第二次缓存删除失败了,那不还是没有清除脏数据吗?「解决方案就是再加上一个重试机制,保证删除缓存成功。」参考孤独烟老师给的方案图:「方案一:」❝流程如下所示(1)更新数据库数据;(2)缓存因为种种问题删除失败(3)将需要删除的key发送至消息队列(4)自己消费消息,获得需要删除的key(5)继续重试删除操作,直到成功然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。❞方案二:❝流程如下图所示:(1)更新数据库数据(2)数据库会将操作信息写入binlog日志当中(3)订阅程序提取出所需要的数据以及key(4)另起一段非业务代码,获得该信息(5)尝试删除缓存操作,发现删除失败(6)将这些信息发送至消息队列(7)重新从消息队列中获得该数据,重试操作。❞「而读取binlog的中间件,可以采用阿里开源的canal」好了,到这里我们已经把缓存双写一致性的思路彻底梳理了一遍,下面就是我对这几种思路徒手写的实战代码,方便有需要的朋友参考。实战:先删除缓存,再更新数据库终于到了实战,我们在秒杀项目的代码上增加接口:先删除缓存,再更新数据库OrderController中新增:/** * 下单接口:先删除缓存,再更新数据库 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV1/{sid}") @ResponseBody public String createOrderWithCacheV1(@PathVariable int sid) { int count = 0; try { // 删除库存缓存 stockService.delStockCountCache(sid); // 完成扣库存下单事务 orderService.createPessimisticOrder(sid); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 复制代码stockService中新增:@Override public void delStockCountCache(int id) { String hashKey = CacheKey.STOCK_COUNT.getKey() + "_" + id; stringRedisTemplate.delete(hashKey); LOGGER.info("删除商品id:[{}] 缓存", id); } 复制代码其他涉及的代码都在之前三篇文章中有介绍,并且可以直接去Github拿到项目源码,就不在这里重复贴了。实战:先更新数据库,再删缓存如果是先更新数据库,再删缓存,那么代码只是在业务顺序上颠倒了一下,这里就只贴OrderController中新增:/** * 下单接口:先更新数据库,再删缓存 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV2/{sid}") @ResponseBody public String createOrderWithCacheV2(@PathVariable int sid) { int count = 0; try { // 完成扣库存下单事务 orderService.createPessimisticOrder(sid); // 删除库存缓存 stockService.delStockCountCache(sid); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 复制代码实战:缓存延时双删如何做延时双删呢,最好的方法是开设一个线程池,在线程中删除key,而不是使用Thread.sleep进行等待,这样会阻塞用户的请求。更新前先删除缓存,然后更新数据,再延时删除缓存。OrderController中新增接口:// 延时时间:预估读数据库数据业务逻辑的耗时,用来做缓存再删除 private static final int DELAY_MILLSECONDS = 1000; /** 复制代码 • 下单接口:先删除缓存,再更新数据库,缓存延时双删 • @param sid • @return */ @RequestMapping("/createOrderWithCacheV3/{sid}") @ResponseBody public String createOrderWithCacheV3(@PathVariable int sid) { int count; try { // 删除库存缓存 stockService.delStockCountCache(sid); // 完成扣库存下单事务 count = orderService.createPessimisticOrder(sid); // 延时指定时间后再次删除缓存 cachedThreadPool.execute(new delCacheByThread(sid)); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); }OrderController中新增线程池:// 延时双删线程池 private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>()); /** 复制代码 • 缓存再删除线程 */ private class delCacheByThread implements Runnable { private int sid; public delCacheByThread(int sid) { this.sid = sid; } public void run() { try { LOGGER.info("异步执行缓存再删除,商品id:[{}], 首先休眠:[{}] 毫秒", sid, DELAY_MILLSECONDS); Thread.sleep(DELAY_MILLSECONDS); stockService.delStockCountCache(sid); LOGGER.info("再次删除商品id:[{}] 缓存", sid); } catch (Exception e) { LOGGER.error("delCacheByThread执行出错", e); } } }来试验一下,请求接口createOrderWithCacheV3:日志中,做到了两次删除:实战:删除缓存重试机制上文提到了,要解决删除失败的问题,需要用到消息队列,进行删除操作的重试。这里我们为了达到效果,接入了RabbitMq,并且需要在接口中写发送消息,并且需要消费者常驻来消费消息。Spring整合RabbitMq还是比较简单的,我把简单的整合代码也贴出来。pom.xml新增RabbitMq的依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> 复制代码写一个RabbitMqConfig:@Configuration public class RabbitMqConfig { @Bean public Queue delCacheQueue() { return new Queue("delCache"); } 复制代码 复制代码}添加一个消费者:@Component @RabbitListener(queues = "delCache") public class DelCacheReceiver { private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class); @Autowired private StockService stockService; @RabbitHandler public void process(String message) { LOGGER.info("DelCacheReceiver收到消息: " + message); LOGGER.info("DelCacheReceiver开始删除缓存: " + message); stockService.delStockCountCache(Integer.parseInt(message)); } 复制代码 复制代码}OrderController中新增接口:/** * 下单接口:先更新数据库,再删缓存,删除缓存重试机制 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV4/{sid}") @ResponseBody public String createOrderWithCacheV4(@PathVariable int sid) { int count; try { // 完成扣库存下单事务 count = orderService.createPessimisticOrder(sid); // 删除库存缓存 stockService.delStockCountCache(sid); // 延时指定时间后再次删除缓存 // cachedThreadPool.execute(new delCacheByThread(sid)); // 假设上述再次删除缓存没成功,通知消息队列进行删除缓存 sendDelCache(String.valueOf(sid)); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); 复制代码 复制代码}访问createOrderWithCacheV4:可以看到,我们先完成了下单,然后删除了缓存,并且假设延迟删除缓存失败了,发送给消息队列重试的消息,消息队列收到消息后再去删除缓存。实战:读取binlog异步删除缓存我们需要用到阿里开源的canal来读取binlog进行缓存的异步删除。不过很蛋疼的是,这次文章的工作量实在有点太大了,连续写代码和整理文字身体有点吃不消了,不知道你们有没有学累。扩展阅读更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching小结引用陈浩《缓存更新的套路》最后的总结语作为小结:❝分布式系统里要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP,BASE理论。异构数据库本来就没办法强一致,「只是尽可能减少时间窗口,达到最终一致性」。还有别忘了设置过期时间,这是个兜底方案❞结束语本文总结了秒杀系统中关于缓存数据的思考和实现,并探讨了缓存数据库双写一致性问题。「可以总结为如下几点:」对于读多写少的数据,请使用缓存。为了保持一致性,会导致系统吞吐量的下降。为了保持一致性,会导致业务代码逻辑复杂。缓存做不到绝对一致性,但可以做到最终一致性。对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。作者水平有限,写文章过程中难免出现错误和疏漏,请理性讨论与指正。❝希望大家多多支持我的公主号:后端技术漫谈❞参考https://cloud.tencent.com/developer/article/1574827https://www.jianshu.com/p/2936a5c65e6bhttps://www.cnblogs.com/rjzheng/p/9041659.htmlhttps://www.cnblogs.com/codeon/p/8287563.htmlhttps://www.jianshu.com/p/0275ecca2438https://www.jianshu.com/p/dc1e5091a0d8https://coolshell.cn/articles/17416.html
前言记录一次线上JVM堆外内存泄漏问题的排查过程与思路,其中夹带一些JVM内存分配机制以及常用的JVM问题排查指令和工具分享,希望对大家有所帮助。在整个排查过程中,我也走了不少弯路,但是在文章中我仍然会把完整的思路和想法写出来,当做一次经验教训,给后人参考,文章最后也总结了下内存泄漏问题快速排查的几个原则。本文的主要内容:故障描述和排查过程故障原因和解决方案分析JVM堆内内存和堆外内存分配原理常用的进程内存泄漏排查指令和工具介绍和使用故障描述8月12日中午午休时间,我们商业服务收到告警,服务进程占用容器的物理内存(16G)超过了80%的阈值,并且还在不断上升。监控系统调出图表查看:像是Java进程发生了内存泄漏,而我们堆内存的限制是4G,这种大于4G快要吃满内存应该是JVM堆外内存泄漏。确认了下当时服务进程的启动配置:-Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:PermSize=256m -XX:MaxPermSize=512m -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80 复制代码虽然当天没有上线新代码,但是当天上午我们正在使用消息队列推送历史数据的修复脚本,该任务会大量调用我们服务其中的某一个接口,所以初步怀疑和该接口有关。下图是该调用接口当天的访问量变化:可以看到案发当时调用量相比正常情况(每分钟200+次)提高了很多(每分钟5000+次)。我们暂时让脚本停止发送消息,该接口调用量下降到每分钟200+次,容器内存不再以极高斜率上升,一切似乎恢复了正常。接下来排查这个接口是不是发生了内存泄漏。排查过程首先我们先回顾下Java进程的内存分配,方便我们下面排查思路的阐述。以我们线上使用的JDK1.8版本为例。JVM内存分配网上有许多总结,我就不再进行二次创作。JVM内存区域的划分为两块:堆区和非堆区。堆区:就是我们熟知的新生代老年代。非堆区:非堆区如图中所示,有元数据区和直接内存。这里需要额外注意的是:永久代(JDK8的原生去)存放JVM运行时使用的类,永久代的对象在full GC时进行垃圾收集。复习完了JVM的内存分配,让我们回到故障上来。堆内存分析虽说一开始就基本确认与堆内存无关,因为泄露的内存占用超过了堆内存限制4G,但是我们为了保险起见先看下堆内存有什么线索。我们观察了新生代和老年代内存占用曲线以及回收次数统计,和往常一样没有大问题,我们接着在事故现场的容器上dump了一份JVM堆内存的日志。堆内存Dump堆内存快照dump命令:jmap -dump:live,format=b,file=xxxx.hprof pid 复制代码画外音:你也可以使用jmap -histo:live pid直接查看堆内存存活的对象。导出后,将Dump文件下载回本地,然后可以使用Eclipse的MAT(Memory Analyzer)或者JDK自带的JVisualVM打开日志文件。使用MAT打开文件如图所示:可以看到堆内存中,有一些nio有关的大对象,比如正在接收消息队列消息的nioChannel,还有nio.HeapByteBuffer,但是数量不多,不能作为判断的依据,先放着观察下。下一步,我开始浏览该接口代码,接口内部主要逻辑是调用集团的WCS客户端,将数据库表中数据查表后写入WCS,没有其他额外逻辑发觉没有什么特殊逻辑后,我开始怀疑WCS客户端封装是否存在内存泄漏,这样怀疑的理由是,WCS客户端底层是由SCF客户端封装的,作为RPC框架,其底层通讯传输协议有可能会申请直接内存。是不是我的代码出发了WCS客户端的Bug,导致不断地申请直接内存的调用,最终吃满内存。我联系上了WCS的值班人,将我们遇到的问题和他们描述了一下,他们回复我们,会在他们本地执行下写入操作的压测,看看能不能复现我们的问题。既然等待他们的反馈还需要时间,我们就准备先自己琢磨下原因。我将怀疑的目光停留在了直接内存上,怀疑是由于接口调用量过大,客户端对nio使用不当,导致使用ByteBuffer申请过多的直接内存。画外音:最终的结果证明,这一个先入为主的思路导致排查过程走了弯路。在问题的排查过程中,用合理的猜测来缩小排查范围是可以的,但最好先把每种可能性都列清楚,在发现自己深入某个可能性无果时,要及时回头仔细审视其他可能性。沙箱环境复现为了能还原当时的故障场景,我在沙箱环境申请了一台压测机器,来确保和线上环境一致。首先我们先模拟内存溢出的情况(大量调用接口):我们让脚本继续推送数据,调用我们的接口,我们持续观察内存占用。当开始调用后,内存便开始持续增长,并且看起来没有被限制住(没有因为限制触发Full GC)。接着我们来模拟下平时正常调用量的情况(正常量调用接口):我们将该接口平时正常的调用量(比较小,且每10分钟进行一次批量调用)切到该压测机器上,得到了下图这样的老生代内存和物理内存趋势:问题来了:为何内存会不断往上走吃满内存呢?当时猜测是由于JVM进程并没有对于直接内存大小进行限制(-XX:MaxDirectMemorySize),所以堆外内存不断上涨,并不会触发FullGC操作。上图能够得出两个结论:在内存泄露的接口调用量很大的时候,如果恰好堆内老生代等其他情况一直不满足FullGC条件,就一直不会FullGC,直接内存一路上涨。而在平时低调用量的情况下, 内存泄漏的比较慢,FullGC总会到来,回收掉泄露的那部分,这也是平时没有出问题,正常运行了很久的原因。由于上面提到,我们进程的启动参数中并没有限制直接内存,于是我们将-XX:MaxDirectMemorySize配置加上,再次在沙箱环境进行了测验。结果发现,进程占用的物理内存依然会不断上涨,超出了我们设置的限制,“看上去”配置似乎没起作用。这让我很讶异,难道JVM对内存的限制出现了问题?到了这里,能够看出我排查过程中思路执着于直接内存的泄露,一去不复返了。直接内存分析为了更进一步的调查清楚直接内存里有什么,我开始对直接内存下手。由于直接内存并不能像堆内存一样,很容易的看出所有占用的对象,我们需要一些命令来对直接内存进行排查,我有用了几种办法,来查看直接内存里到底出现了什么问题。查看进程内存信息 pmappmap - report memory map of a process(查看进程的内存映像信息)pmap命令用于报告进程的内存映射关系,是Linux调试及运维一个很好的工具。pmap -x pid 如果需要排序 | sort -n -k3** 复制代码执行后我得到了下面的输出,删减输出如下:.. 00007fa2d4000000 8660 8660 8660 rw--- [ anon ] 00007fa65f12a000 8664 8664 8664 rw--- [ anon ] 00007fa610000000 9840 9832 9832 rw--- [ anon ] 00007fa5f75ff000 10244 10244 10244 rw--- [ anon ] 00007fa6005fe000 59400 10276 10276 rw--- [ anon ] 00007fa3f8000000 10468 10468 10468 rw--- [ anon ] 00007fa60c000000 10480 10480 10480 rw--- [ anon ] 00007fa614000000 10724 10696 10696 rw--- [ anon ] 00007fa6e1c59000 13048 11228 0 r-x-- libjvm.so 00007fa604000000 12140 12016 12016 rw--- [ anon ] 00007fa654000000 13316 13096 13096 rw--- [ anon ] 00007fa618000000 16888 16748 16748 rw--- [ anon ] 00007fa624000000 37504 18756 18756 rw--- [ anon ] 00007fa62c000000 53220 22368 22368 rw--- [ anon ] 00007fa630000000 25128 23648 23648 rw--- [ anon ] 00007fa63c000000 28044 24300 24300 rw--- [ anon ] 00007fa61c000000 42376 27348 27348 rw--- [ anon ] 00007fa628000000 29692 27388 27388 rw--- [ anon ] 00007fa640000000 28016 28016 28016 rw--- [ anon ] 00007fa620000000 28228 28216 28216 rw--- [ anon ] 00007fa634000000 36096 30024 30024 rw--- [ anon ] 00007fa638000000 65516 40128 40128 rw--- [ anon ] 00007fa478000000 46280 46240 46240 rw--- [ anon ] 0000000000f7e000 47980 47856 47856 rw--- [ anon ] 00007fa67ccf0000 52288 51264 51264 rw--- [ anon ] 00007fa6dc000000 65512 63264 63264 rw--- [ anon ] 00007fa6cd000000 71296 68916 68916 rwx-- [ anon ] 00000006c0000000 4359360 2735484 2735484 rw--- [ anon ] 复制代码可以看出,最下面一行是堆内存的映射,占用4G,其他上面有非常多小的内存占用,不过通过这些信息我们依然看不出问题。堆外内存跟踪 NativeMemoryTrackingNative Memory Tracking (NMT) 是Hotspot VM用来分析VM内部内存使用情况的一个功能。我们可以利用jcmd(jdk自带)这个工具来访问NMT的数据。NMT必须先通过VM启动参数中打开,不过要注意的是,打开NMT会带来5%-10%的性能损耗。-XX:NativeMemoryTracking=[off | summary | detail] # off: 默认关闭 # summary: 只统计各个分类的内存使用情况. # detail: Collect memory usage by individual call sites. 复制代码然后运行进程,可以使用下面的命令查看直接内存:jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown] [scale= KB | MB | GB] # summary: 分类内存使用情况. # detail: 详细内存使用情况,除了summary信息之外还包含了虚拟内存使用情况。 # baseline: 创建内存使用快照,方便和后面做对比 # summary.diff: 和上一次baseline的summary对比 # detail.diff: 和上一次baseline的detail对比 # shutdown: 关闭NMT 复制代码我们使用:jcmd pid VM.native_memory detail scale=MB > temp.txt 复制代码得到如图结果:上图中给我们的信息,都不能很明显的看出问题,至少我当时依然不能通过这几次信息看出问题。排查似乎陷入了僵局。山重水复疑无路在排查陷入停滞的时候,我们得到了来自WCS和SCF方面的回复,两方都确定了他们的封装没有内存泄漏的存在,WCS方面没有使用直接内存,而SCF虽然作为底层RPC协议,但是也不会遗留这么明显的内存bug,否则应该线上有很多反馈。查看JVM内存信息 jmap此时,找不到问题的我再次新开了一个沙箱容器,运行服务进程,然后运行jmap命令,看一看JVM内存的实际配置:jmap -heap pid 复制代码得到结果:Attaching to process ID 1474, please wait... Debugger attached successfully. Server compiler detected. JVM version is 25.66-b17 using parallel threads in the new generation. using thread-local object allocation. Concurrent Mark-Sweep GC Heap Configuration: MinHeapFreeRatio = 40 MaxHeapFreeRatio = 70 MaxHeapSize = 4294967296 (4096.0MB) NewSize = 2147483648 (2048.0MB) MaxNewSize = 2147483648 (2048.0MB) OldSize = 2147483648 (2048.0MB) NewRatio = 2 SurvivorRatio = 8 MetaspaceSize = 21807104 (20.796875MB) CompressedClassSpaceSize = 1073741824 (1024.0MB) MaxMetaspaceSize = 17592186044415 MB G1HeapRegionSize = 0 (0.0MB) Heap Usage: New Generation (Eden + 1 Survivor Space): capacity = 1932787712 (1843.25MB) used = 1698208480 (1619.5378112792969MB) free = 234579232 (223.71218872070312MB) 87.86316621615607% used Eden Space: capacity = 1718091776 (1638.5MB) used = 1690833680 (1612.504653930664MB) free = 27258096 (25.995346069335938MB) 98.41346682518548% used From Space: capacity = 214695936 (204.75MB) used = 7374800 (7.0331573486328125MB) free = 207321136 (197.7168426513672MB) 3.4349974840697497% used To Space: capacity = 214695936 (204.75MB) used = 0 (0.0MB) free = 214695936 (204.75MB) 0.0% used concurrent mark-sweep generation: capacity = 2147483648 (2048.0MB) used = 322602776 (307.6579818725586MB) free = 1824880872 (1740.3420181274414MB) 15.022362396121025% used 29425 interned Strings occupying 3202824 bytes 复制代码输出的信息中,看得出老年代和新生代都蛮正常的,元空间也只占用了20M,直接内存看起来也是2g...嗯?为什么MaxMetaspaceSize = 17592186044415 MB?看起来就和没限制一样。再仔细看看我们的启动参数:-Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:PermSize=256m -XX:MaxPermSize=512m -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80 复制代码配置的是-XX:PermSize=256m -XX:MaxPermSize=512m,也就是永久代的内存空间。而1.8后,Hotspot虚拟机已经移除了永久代,使用了元空间代替。 由于我们线上使用的是JDK1.8,所以我们对于元空间的最大容量根本就没有做限制,-XX:PermSize=256m -XX:MaxPermSize=512m 这两个参数对于1.8就是过期的参数。下面的图描述了从1.7到1.8,永久代的变更:那会不会是元空间内存泄露了呢?我选择了在本地进行测试,方便更改参数,也方便使用JVisualVM工具直观的看出内存变化。使用JVisualVM观察进程运行首先限制住元空间,使用参数-XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=128m,然后在本地循环调用出问题的接口。得到如图:可以看出,在元空间耗尽时,系统出发了Full GC,元空间内存得到回收,并且卸载了很多类。然后我们将元空间限制去掉,也就是使用之前出问题的参数:-Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80 -XX:MaxDirectMemorySize=2g -XX:+UnlockDiagnosticVMOptions 复制代码得到如图:可以看出,元空间在不断上涨,并且已装入的类随着调用量的增加也在不断上涨,呈现正相关趋势。柳暗花明又一村问题一下子明朗了起来,随着每次接口的调用,极有可能是某个类都在不断的被创建,占用了元空间的内存。观察JVM类加载情况 -verbose在调试程序时,有时需要查看程序加载的类、内存回收情况、调用的本地接口等。这时候就需要-verbose命令。在myeclipse可以通过右键设置(如下),也可以在命令行输入java -verbose来查看。-verbose:class 查看类加载情况 -verbose:gc 查看虚拟机中内存回收情况 -verbose:jni 查看本地方法调用的情况 复制代码我们在本地环境,添加启动参数-verbose:class循环调用接口。可以看到生成了无数com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto:[Loaded com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto from file:/C:/Users/yangzhendong01/.m2/repository/com/alibaba/fastjson/1.2.71/fastjson-1.2.71.jar] [Loaded com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto from file:/C:/Users/yangzhendong01/.m2/repository/com/alibaba/fastjson/1.2.71/fastjson-1.2.71.jar] [Loaded com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto from file:/C:/Users/yangzhendong01/.m2/repository/com/alibaba/fastjson/1.2.71/fastjson-1.2.71.jar] [Loaded com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto from file:/C:/Users/yangzhendong01/.m2/repository/com/alibaba/fastjson/1.2.71/fastjson-1.2.71.jar] [Loaded com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto from file:/C:/Users/yangzhendong01/.m2/repository/com/alibaba/fastjson/1.2.71/fastjson-1.2.71.jar] [Loaded com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto from file:/C:/Users/yangzhendong01/.m2/repository/com/alibaba/fastjson/1.2.71/fastjson-1.2.71.jar] 复制代码当调用了很多次,积攒了一定的类时,我们手动执行Full GC,进行类加载器的回收,我们发现大量的fastjson相关类被回收。如果在回收前,使用jmap查看类加载情况,同样也可以发现大量的fastjson相关类:jmap -clstats 7984 复制代码这下有了方向,这次仔细排查代码,查看代码逻辑里哪里用到了fastjson,发现了如下代码:/** * 返回Json字符串.驼峰转_ * @param bean 实体类. */ public static String buildData(Object bean) { try { SerializeConfig CONFIG = new SerializeConfig(); CONFIG.propertyNamingStrategy = PropertyNamingStrategy.SnakeCase; return jsonString = JSON.toJSONString(bean, CONFIG); } catch (Exception e) { return null; } } 复制代码问题根因我们在调用wcs前将驼峰字段的实体类序列化成下划线字段,**这需要使用fastjson的SerializeConfig,而我们在静态方法中对其进行了实例化。SerializeConfig创建时默认会创建一个ASM代理类用来实现对目标对象的序列化。也就是上面被频繁创建的类com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto,如果我们复用SerializeConfig,fastjson会去寻找已经创建的代理类,从而复用。但是如果new SerializeConfig(),则找不到原来生成的代理类,就会一直去生成新的WlkCustomerDto代理类。下面两张图时问题定位的源码:我们将SerializeConfig作为类的静态变量,问题得到了解决。private static final SerializeConfig CONFIG = new SerializeConfig(); static { CONFIG.propertyNamingStrategy = PropertyNamingStrategy.SnakeCase; } 复制代码fastjson SerializeConfig 做了什么SerializeConfig介绍:SerializeConfig的主要功能是配置并记录每种Java类型对应的序列化类(ObjectSerializer接口的实现类),比如Boolean.class使用BooleanCodec(看命名就知道该类将序列化和反序列化实现写到一起了)作为序列化实现类,float[].class使用FloatArraySerializer作为序列化实现类。这些序列化实现类,有的是FastJSON中默认实现的(比如Java基本类),有的是通过ASM框架生成的(比如用户自定义类),有的甚至是用户自定义的序列化类(比如Date类型框架默认实现是转为毫秒,应用需要转为秒)。当然,这就涉及到是使用ASM生成序列化类还是使用JavaBean的序列化类类序列化的问题,这里判断根据就是是否Android环境(环境变量"java.vm.name"为"dalvik"或"lemur"就是Android环境),但判断不仅这里一处,后续还有更具体的判断。理论上来说,每个SerializeConfig实例若序列化相同的类,都会找到之前生成的该类的代理类,来进行序列化。们的服务在每次接口被调用时,都实例化一个ParseConfig对象来配置Fastjson反序列的设置,而未禁用ASM代理的情况下,由于每次调用ParseConfig都是一个新的实例,因此永远也检查不到已经创建的代理类,所以Fastjson便不断的创建新的代理类,并加载到metaspace中,最终导致metaspace不断扩张,将机器的内存耗尽。升级JDK1.8才会出现问题导致问题发生的原因还是值得重视。为什么在升级之前不会出现这个问题?这就要分析jdk1.8和1.7自带的hotspot虚拟机的差异了。从jdk1.8开始,自带的hostspot虚拟机取消了过去的永久区,而新增了metaspace区,从功能上看,metaspace可以认为和永久区类似,其最主要的功用也是存放类元数据,但实际的机制则有较大的不同。首先,metaspace默认的最大值是整个机器的物理内存大小,所以metaspace不断扩张会导致java程序侵占系统可用内存,最终系统没有可用的内存;而永久区则有固定的默认大小,不会扩张到整个机器的可用内存。当分配的内存耗尽时,两者均会触发full gc,但不同的是永久区在full gc时,以堆内存回收时类似的机制去回收永久区中的类元数据(Class对象),只要是根引用无法到达的对象就可以回收掉,而metaspace判断类元数据是否可以回收,是根据加载这些类元数据的Classloader是否可以回收来判断的,只要Classloader不能回收,通过其加载的类元数据就不会被回收。这也就解释了我们这两个服务为什么在升级到1.8之后才出现问题,因为在之前的jdk版本中,虽然每次调用fastjson都创建了很多代理类,在永久区中加载类很多代理类的Class实例,但这些Class实例都是在方法调用是创建的,调用完成之后就不可达了,因此永久区内存满了触发full gc时,都会被回收掉。而使用1.8时,因为这些代理类都是通过主线程的Classloader加载的,这个Classloader在程序运行的过程中永远也不会被回收,因此通过其加载的这些代理类也永远不会被回收,这就导致metaspace不断扩张,最终耗尽机器的内存了。这个问题并不局限于fastjson,只要是需要通过程序加载创建类的地方,就有可能出现这种问题。尤其是在框架中,往往大量采用类似ASM、javassist等工具进行字节码增强,而根据上面的分析,在jdk1.8之前,因为大多数情况下动态加载的Class都能够在full gc时得到回收,因此不容易出现问题,也因此很多框架、工具包并没有针对这个问题做一些处理,一旦升级到1.8之后,这些问题就可能会暴露出来。总结问题解决了,接下来复盘下整个排查问题的流程,整个流程暴露了我很多问题,最主要的就是对于JVM不同版本的内存分配还不够熟悉,导致了对于老生代和元空间判断失误,走了很多弯路,在直接内存中排查了很久,浪费了很多时间。其次,排查需要的一是仔细,二是全面,,最好将所有可能性先行整理好,不然很容易陷入自己设定好的排查范围内,走进死胡同不出来。最后,总结一下这次的问题带来的收获:JDK1.8开始,自带的hostspot虚拟机取消了过去的永久区,而新增了metaspace区,从功能上看,metaspace可以认为和永久区类似,其最主要的功用也是存放类元数据,但实际的机制则有较大的不同。对于JVM里面的内存需要在启动时进行限制,包括我们熟悉的堆内存,也要包括直接内存和元生区,这是保证线上服务正常运行最后的兜底。使用类库,请多注意代码的写法,尽量不要出现明显的内存泄漏。对于使用了ASM等字节码增强工具的类库,在使用他们时请多加小心(尤其是JDK1.8以后)。参考观察程序运行时类加载的过程blog.csdn.net/tenderhearted/article/details/39642275Metaspace整体介绍(永久代被替换原因、元空间特点、元空间内存查看分析方法)www.cnblogs.com/duanxz/p/35…java内存占用异常问题常见排查流程(含堆外内存异常)my.oschina.net/haitaohu/bl…JVM源码分析之堆外内存完全解读lovestblog.cn/blog/2015/0…JVM 类的卸载www.cnblogs.com/caoxb/p/127…fastjson在jdk1.8上面开启asmgithub.com/alibaba/fas…fastjson:PropertyNamingStrategy_cngithub.com/alibaba/fas…警惕动态代理导致的Metaspace内存泄漏问题blog.csdn.net/xyghehehehe…
前言本篇文章是我之前系列文章中的一篇,主要讨论了我们在平时的开发过程中,各大系统中都要用到的缓存数据的问题,进一步延伸到数据库和缓存的双写一致性问题,并且给出了所有方案的实现代码方便大家参考。本篇文章主要内容数据缓存为何要使用缓存哪类数据适合缓存缓存的利与弊如何保证缓存和数据库一致性不更新缓存,而是删除缓存先操作缓存,还是先操作数据库非要保证数据库和缓存数据强一致该怎么办缓存和数据库一致性实战实战:先删除缓存,再更新数据库实战:先更新数据库,再删缓存实战:缓存延时双删实战:删除缓存重试机制实战:读取binlog异步删除缓存数据缓存在我们实际的业务场景中,一定有很多需要做数据缓存的场景,比如售卖商品的页面,包括了许多并发访问量很大的数据,它们可以称作是是“热点”数据,这些数据有一个特点,就是更新频率低,读取频率高,这些数据应该尽量被缓存,从而减少请求打到数据库上的机会,减轻数据库的压力。为何要使用缓存缓存是为了追求“快”而存在的。我们用代码举一个例子。我在自己的Demo代码仓库中增加了两个查询库存的接口getStockByDB和getStockByCache,分别表示从数据库和缓存查询某商品的库存量。随后我们用JMeter进行并发请求测试。这是两个接口的代码:/** * 查询库存:通过数据库查询库存 * @param sid * @return */ @RequestMapping("/getStockByDB/{sid}") @ResponseBody public String getStockByDB(@PathVariable int sid) { int count; try { count = stockService.getStockCountByDB(sid); } catch (Exception e) { LOGGER.error("查询库存失败:[{}]", e.getMessage()); return "查询库存失败"; } LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count); return String.format("商品Id: %d 剩余库存为:%d", sid, count); } /** * 查询库存:通过缓存查询库存 * 缓存命中:返回库存 * 缓存未命中:查询数据库写入缓存并返回 * @param sid * @return */ @RequestMapping("/getStockByCache/{sid}") @ResponseBody public String getStockByCache(@PathVariable int sid) { Integer count; try { count = stockService.getStockCountByCache(sid); if (count == null) { count = stockService.getStockCountByDB(sid); LOGGER.info("缓存未命中,查询数据库,并写入缓存"); stockService.setStockCountToCache(sid, count); } } catch (Exception e) { LOGGER.error("查询库存失败:[{}]", e.getMessage()); return "查询库存失败"; } LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count); return String.format("商品Id: %d 剩余库存为:%d", sid, count); } 复制代码首先设置为10000个并发请求的情况下,运行JMeter,结果首先出现了大量的报错,10000个请求中98%的请求都直接失败了。让人很慌张~打开日志,报错如下:SpringBoot内置的Tomcat最大并发数搞的鬼,其默认值为200,对于10000的并发,单机服务实在是力不从心。当然,你可以修改这里的并发数设置,但是你的小机器仍然可能会扛不住。将其修改为如下配置后,我的小机器才在通过缓存拿库存的情况下,保证了10000个并发的100%返回请求:server.tomcat.max-threads=10000 server.tomcat.max-connections=10000 复制代码可以看到,不使用缓存的情况下,吞吐量为668个请求每秒:使用缓存的情况下,吞吐量为2177个请求每秒:在这种“十分不严谨”的对比下,有缓存对于一台单机,性能提升了3倍多,如果在多台机器,更多并发的情况下,由于数据库有了更大的压力,缓存的性能优势应该会更加明显。哪类数据适合缓存缓存量大但又不常变化的数据,比如详情,评论等。对于那些经常变化的数据,其实并不适合缓存,一方面会增加系统的复杂性(缓存的更新,缓存脏数据),另一方面也给系统带来一定的不稳定性(缓存系统的维护)。但一些极端情况下,你需要将一些会变动的数据进行缓存,比如想要页面显示准实时的库存数,或者其他一些特殊业务场景。这时候你需要保证缓存不能(一直)有脏数据,这就需要再深入讨论一下。缓存的利与弊我们到底该不该上缓存的,这其实也是个trade-off(权衡)的问题。上缓存的优点:能够缩短服务的响应时间,给用户带来更好的体验。能够增大系统的吞吐量,依然能够提升用户体验。减轻数据库的压力,防止高峰期数据库被压垮,导致整个线上服务BOOM!上了缓存,也会引入很多额外的问题:缓存有多种选型,是内存缓存,memcached还是redis,你是否都熟悉,如果不熟悉,无疑增加了维护的难度(本来是个纯洁的数据库系统)。缓存系统也要考虑分布式,比如redis的分布式缓存还会有很多坑,无疑增加了系统的复杂性。在特殊场景下,如果对缓存的准确性有非常高的要求,就必须考虑缓存和数据库的一致性问题。本文想要重点讨论的,就是缓存和数据库的一致性问题,各位看官且往下看。如何保证缓存和数据库一致性说了这么多缓存的必要性,那么使用缓存是不是就是一个很简单的事情了呢,我之前也一直是这么觉得的,直到遇到了需要缓存与数据库保持强一致的场景,才知道让数据库数据和缓存数据保持一致性是一门很高深的学问。从远古的硬件缓存,操作系统缓存开始,缓存就是一门独特的学问。这个问题也被业界探讨了非常久,争论至今。我翻阅了很多资料,发现其实这是一个权衡的问题。值得好好讲讲。以下的讨论会引入几方观点,我会跟着观点来写代码验证所提到的问题。不更新缓存,而是删除缓存大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。观点引用:《分布式之数据库和缓存双写一致性方案解析》孤独烟原因一:线程安全角度同时有请求A和请求B进行更新操作,那么会出现(1)线程A更新了数据库(2)线程B更新了数据库(3)线程B更新了缓存(4)线程A更新了缓存这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。原因二:业务场景角度有如下两点:(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。先操作缓存,还是先操作数据库那么问题就来了,我们是先删除缓存,然后再更新数据库,还是先更新数据库,再删缓存呢?先来看看大佬们怎么说。《【58沈剑架构系列】缓存架构设计细节二三事》58沈剑:对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。沈剑老师说的没有问题,不过没完全考虑好并发请求时的数据脏读问题,让我们再来看看孤独烟老师《分布式之数据库和缓存双写一致性方案解析》:先删缓存,再更新数据库该方案会导致请求数据不一致同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:(1)请求A进行写操作,删除缓存(2)请求B查询发现缓存不存在(3)请求B去数据库查询得到旧值(4)请求B将旧值写入缓存(5)请求A将新值写入数据库上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。所以先删缓存,再更新数据库并不是一劳永逸的解决方案,再看看先更新数据库,再删缓存这种方案怎么样?先更新数据库,再删缓存这种情况不存在并发问题么?不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生(1)缓存刚好失效(2)请求A查询数据库,得一个旧值(3)请求B将新值写入数据库(4)请求B删除缓存(5)请求A将查到的旧值写入缓存ok,如果发生上述情况,确实是会发生脏数据。然而,发生这种情况的概率又有多少呢?发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。先更新数据库,再删缓存依然会有问题,不过,问题出现的可能性会因为上面说的原因,变得比较低!所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请先更新数据库,再删缓存!我非要数据库和缓存数据强一致怎么办那么,如果我非要保证绝对一致性怎么办,先给出结论:没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP。所以,我们得委曲求全,可以去做到BASE理论中说的最终一致性。最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性大佬们给出了到达最终一致性的解决思路,主要是针对上面两种双写策略(先删缓存,再更新数据库/先更新数据库,再删缓存)导致的脏数据问题,进行相应的处理,来保证最终一致性。缓存延时双删问:先删除缓存,再更新数据库中避免脏数据?答案:采用延时双删策略。上文我们提到,在先删除缓存,再更新数据库的情况下,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。那么延时双删怎么解决这个问题呢?(1)先淘汰缓存(2)再写数据库(这两步和原来一样)(3)休眠1秒,再次淘汰缓存这么做,可以将1秒内所造成的缓存脏数据,再次删除。那么,这个1秒怎么确定的,具体该休眠多久呢?针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。如果你用了mysql的读写分离架构怎么办?ok,在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。(1)请求A进行写操作,删除缓存(2)请求A将数据写入数据库了,(3)请求B查询缓存发现,缓存没有值(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值(5)请求B将旧值写入缓存(6)数据库完成主从同步,从库变为新值上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。采用这种同步淘汰策略,吞吐量降低怎么办?ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。所以在先删除缓存,再更新数据库的情况下,可以使用延时双删的策略,来保证脏数据只会存活一段时间,就会被准确的数据覆盖。在先更新数据库,再删缓存的情况下,缓存出现脏数据的情况虽然可能性极小,但也会出现。我们依然可以用延时双删策略,在请求A对缓存写入了脏的旧值之后,再次删除缓存。来保证去掉脏缓存。删缓存失败了怎么办:重试机制看似问题都已经解决了,但其实,还有一个问题没有考虑到,那就是删除缓存的操作,失败了怎么办?比如延时双删的时候,第二次缓存删除失败了,那不还是没有清除脏数据吗?解决方案就是再加上一个重试机制,保证删除缓存成功。参考孤独烟老师给的方案图:方案一:流程如下所示(1)更新数据库数据;(2)缓存因为种种问题删除失败(3)将需要删除的key发送至消息队列(4)自己消费消息,获得需要删除的key(5)继续重试删除操作,直到成功然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。方案二:流程如下图所示:(1)更新数据库数据(2)数据库会将操作信息写入binlog日志当中(3)订阅程序提取出所需要的数据以及key(4)另起一段非业务代码,获得该信息(5)尝试删除缓存操作,发现删除失败(6)将这些信息发送至消息队列(7)重新从消息队列中获得该数据,重试操作。缓存和数据库一致性实战实战:先删除缓存,再更新数据库终于到了实战,我们在秒杀项目的代码上增加接口:先删除缓存,再更新数据库OrderController中新增:/** * 下单接口:先删除缓存,再更新数据库 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV1/{sid}") @ResponseBody public String createOrderWithCacheV1(@PathVariable int sid) { int count = 0; try { // 删除库存缓存 stockService.delStockCountCache(sid); // 完成扣库存下单事务 orderService.createPessimisticOrder(sid); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 复制代码stockService中新增:@Override public void delStockCountCache(int id) { String hashKey = CacheKey.STOCK_COUNT.getKey() + "_" + id; stringRedisTemplate.delete(hashKey); LOGGER.info("删除商品id:[{}] 缓存", id); } 复制代码其他涉及的代码都在之前三篇文章中有介绍,并且可以直接去Github拿到项目源码,就不在这里重复贴了。实战:先更新数据库,再删缓存如果是先更新数据库,再删缓存,那么代码只是在业务顺序上颠倒了一下,这里就只贴OrderController中新增:/** * 下单接口:先更新数据库,再删缓存 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV2/{sid}") @ResponseBody public String createOrderWithCacheV2(@PathVariable int sid) { int count = 0; try { // 完成扣库存下单事务 orderService.createPessimisticOrder(sid); // 删除库存缓存 stockService.delStockCountCache(sid); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 复制代码实战:缓存延时双删如何做延时双删呢,最好的方法是开设一个线程池,在线程中删除key,而不是使用Thread.sleep进行等待,这样会阻塞用户的请求。更新前先删除缓存,然后更新数据,再延时删除缓存。OrderController中新增接口:// 延时时间:预估读数据库数据业务逻辑的耗时,用来做缓存再删除 private static final int DELAY_MILLSECONDS = 1000; /** * 下单接口:先删除缓存,再更新数据库,缓存延时双删 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV3/{sid}") @ResponseBody public String createOrderWithCacheV3(@PathVariable int sid) { int count; try { // 删除库存缓存 stockService.delStockCountCache(sid); // 完成扣库存下单事务 count = orderService.createPessimisticOrder(sid); // 延时指定时间后再次删除缓存 cachedThreadPool.execute(new delCacheByThread(sid)); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 复制代码OrderController中新增线程池:// 延时双删线程池 private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>()); /** * 缓存再删除线程 */ private class delCacheByThread implements Runnable { private int sid; public delCacheByThread(int sid) { this.sid = sid; } public void run() { try { LOGGER.info("异步执行缓存再删除,商品id:[{}], 首先休眠:[{}] 毫秒", sid, DELAY_MILLSECONDS); Thread.sleep(DELAY_MILLSECONDS); stockService.delStockCountCache(sid); LOGGER.info("再次删除商品id:[{}] 缓存", sid); } catch (Exception e) { LOGGER.error("delCacheByThread执行出错", e); } } } 复制代码来试验一下,请求接口createOrderWithCacheV3:日志中,做到了两次删除:实战:删除缓存重试机制上文提到了,要解决删除失败的问题,需要用到消息队列,进行删除操作的重试。这里我们为了达到效果,接入了RabbitMq,并且需要在接口中写发送消息,并且需要消费者常驻来消费消息。Spring整合RabbitMq还是比较简单的,我把简单的整合代码也贴出来。pom.xml新增RabbitMq的依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> 复制代码写一个RabbitMqConfig:@Configuration public class RabbitMqConfig { @Bean public Queue delCacheQueue() { return new Queue("delCache"); } } 复制代码添加一个消费者:@Component @RabbitListener(queues = "delCache") public class DelCacheReceiver { private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class); @Autowired private StockService stockService; @RabbitHandler public void process(String message) { LOGGER.info("DelCacheReceiver收到消息: " + message); LOGGER.info("DelCacheReceiver开始删除缓存: " + message); stockService.delStockCountCache(Integer.parseInt(message)); } } 复制代码OrderController中新增接口:/** * 下单接口:先更新数据库,再删缓存,删除缓存重试机制 * @param sid * @return */ @RequestMapping("/createOrderWithCacheV4/{sid}") @ResponseBody public String createOrderWithCacheV4(@PathVariable int sid) { int count; try { // 完成扣库存下单事务 count = orderService.createPessimisticOrder(sid); // 删除库存缓存 stockService.delStockCountCache(sid); // 延时指定时间后再次删除缓存 // cachedThreadPool.execute(new delCacheByThread(sid)); // 假设上述再次删除缓存没成功,通知消息队列进行删除缓存 sendDelCache(String.valueOf(sid)); } catch (Exception e) { LOGGER.error("购买失败:[{}]", e.getMessage()); return "购买失败,库存不足"; } LOGGER.info("购买成功,剩余库存为: [{}]", count); return String.format("购买成功,剩余库存为:%d", count); } 复制代码访问createOrderWithCacheV4:可以看到,我们先完成了下单,然后删除了缓存,并且假设延迟删除缓存失败了,发送给消息队列重试的消息,消息队列收到消息后再去删除缓存。实战:读取binlog异步删除缓存我们需要用到阿里开源的canal来读取binlog进行缓存的异步删除。扩展阅读更新缓存的的Design Pattern有四种:Cache asideRead throughWrite throughWrite behind caching,这里有陈皓的总结文章可以进行学习。coolshell.cn/articles/17…小结引用陈浩《缓存更新的套路》最后的总结语作为小结:分布式系统里要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP,BASE理论。异构数据库本来就没办法强一致,只是尽可能减少时间窗口,达到最终一致性。还有别忘了设置过期时间,这是个兜底方案结束语本文总结并探讨了缓存数据库双写一致性问题。文章内容大致可以总结为如下几点:对于读多写少的数据,请使用缓存。为了保持数据库和缓存的一致性,会导致系统吞吐量的下降。为了保持数据库和缓存的一致性,会导致业务代码逻辑复杂。缓存做不到绝对一致性,但可以做到最终一致性。对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。参考cloud.tencent.com/developer/a…www.jianshu.com/p/2936a5c65…www.cnblogs.com/rjzheng/p/9…www.cnblogs.com/codeon/p/82…www.jianshu.com/p/0275ecca2…www.jianshu.com/p/dc1e5091a…coolshell.cn/articles/17…
前言公司这两个月启动了全新的项目,项目排期满满当当,不过该学习还是要学习。这不,给公司搭项目的时候,踩到了一个Spring AOP的坑。本文内容重点:问题描述Spring AOP执行顺序探究顺序错误的真相代码验证结论问题描述公司新项目需要搭建一个新的前后分离HTTP服务,我选择了目前比较熟悉的SpringBoot Web来快速搭建一个可用的系统。鲁迅说过,不要随便升级已经稳定使用的版本。我偏不信这个邪,仗着自己用了这么久Spring,怎么能不冲呢。不说了,直接引入了最新的SprinBoot 2.3.4.RELEASE版本,开始给项目搭架子。起初,大多数的组件引入都一切顺利,本以为就要大功告成了,没想到在搭建日志切面时栽了跟头。作为一个接口服务,为了方便查询接口调用情况和定位问题,一般都会将请求日志打印出来,而Spring的AOP作为切面支持,完美的切合了日志记录的需求。之前的项目中,运行正确的切面日志记录效果如下图:可以看到图内的一次方法调用,会输出请求url,出入参,以及请求IP等等,之前为了好看,还加入了分割线。我把这个实现类放入新项目中,执行出来却是这样的:我揉了揉眼睛,仔细看了看复制过来的老代码,精简版如下:/** * 在切点之前织入 * @param joinPoint * @throws Throwable */ @Before("webLog()") public void doBefore(JoinPoint joinPoint) throws Throwable { // 开始打印请求日志 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); // 初始化traceId initTraceId(request); // 打印请求相关参数 LOGGER.info("========================================== Start =========================================="); // 打印请求 url LOGGER.info("URL : {}", request.getRequestURL().toString()); // 打印 Http method LOGGER.info("HTTP Method : {}", request.getMethod()); // 打印调用 controller 的全路径以及执行方法 LOGGER.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName()); // 打印请求的 IP LOGGER.info("IP : {}", IPAddressUtil.getIpAdrress(request)); // 打印请求入参 LOGGER.info("Request Args : {}", joinPoint.getArgs()); } /** * 在切点之后织入 * @throws Throwable */ @After("webLog()") public void doAfter() throws Throwable { LOGGER.info("=========================================== End ==========================================="); } /** * 环绕 * @param proceedingJoinPoint * @return * @throws Throwable */ @Around("webLog()") public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { long startTime = System.currentTimeMillis(); Object result = proceedingJoinPoint.proceed(); // 打印出参 LOGGER.info("Response Args : {}", result); // 执行耗时 LOGGER.info("Time-Consuming : {} ms", System.currentTimeMillis() - startTime); return result; } 复制代码代码感觉完全没有问题,难道新版本的SpringBoot出Bug了。显然,成熟的框架不会在这种大方向上犯错误,那会不会是新版本的SpringBoot把@After和@Around的顺序反过来了?其实事情也没有那么简单。Spring AOP执行顺序我们先来回顾下Spring AOP执行顺序。我们在网上查找关于SpringAop执行顺序的的资料,大多数时候,你会查到如下的答案:正常情况异常情况多个切面的情况所以@Around理应在@After之前,但是在SprinBoot 2.3.4.RELEASE版本中,@Around切切实实执行在了@After之后。当我尝试切换回2.2.5.RELEASE版本后,执行顺序又回到了@Around-->@After探究顺序错误的真相既然知道了是SpringBoot版本升级导致的问题(或者说顺序变化),那么就要来看看究竟是哪个库对AOP执行的顺序进行了变动,毕竟,SpringBoot只是“形”,真正的内核在Spring。我们打开pom.xml文件,使用插件查看spring-aop的版本,发现SpringBoot 2.3.4.RELEASE 版本使用的AOP是spring-aop-5.2.9.RELEASE。而2.2.5.RELEASE对应的是spring-aop-5.2.4.RELEASE于是我去官网搜索文档,不得不说Spring由于过于庞大,官网的文档已经到了冗杂的地步,不过最终还是找到了:docs.spring.io/spring-fram…As of Spring Framework 5.2.7, advice methods defined in the same @Aspect class that need to run at the same join point are assigned precedence based on their advice type in the following order, from highest to lowest precedence: @Around, @Before, @After, @AfterReturning, @AfterThrowing.我粗浅的翻译一下重点:从Spring5.2.7开始,在相同@Aspect类中,通知方法将根据其类型按照从高到低的优先级进行执行:@Around,@Before ,@After,@AfterReturning,@AfterThrowing。这样看其实对比不明显,我们再回到老版本,也就是2.2.5.RELEASE对应的spring-aop-5.2.4.RELEASE,当时的文档是这么写的:What happens when multiple pieces of advice all want to run at the same join point? Spring AOP follows the same precedence rules as AspectJ to determine the order of advice execution. The highest precedence advice runs first "on the way in" (so, given two pieces of before advice, the one with highest precedence runs first). "On the way out" from a join point, the highest precedence advice runs last (so, given two pieces of after advice, the one with the highest precedence will run second).简单翻译:在相同@Aspect类中Spring AOP遵循与AspectJ相同的优先级规则来确定advice执行的顺序。再挖深一点,那么AspectJ的优先级规则是什么样的?我找了AspectJ的文档:www.eclipse.org/aspectj/doc…At a particular join point, advice is ordered by precedence.A piece of around advice controls whether advice of lower precedence will run by calling proceed. The call to proceed will run the advice with next precedence, or the computation under the join point if there is no further advice.A piece of before advice can prevent advice of lower precedence from running by throwing an exception. If it returns normally, however, then the advice of the next precedence, or the computation under the join pint if there is no further advice, will run.Running after returning advice will run the advice of next precedence, or the computation under the join point if there is no further advice. Then, if that computation returned normally, the body of the advice will run.Running after throwing advice will run the advice of next precedence, or the computation under the join point if there is no further advice. Then, if that computation threw an exception of an appropriate type, the body of the advice will run.Running after advice will run the advice of next precedence, or the computation under the join point if there is no further advice. Then the body of the advice will run.大伙又要说了,哎呀太长不看!简短地说,Aspectj的规则就是上面我们能够在网上查阅到的顺序图展示的那样,依旧是老的顺序。代码验证我把业务逻辑从代码中删除,只验证下这几个advice的执行顺序:package com.bj58.xfbusiness.cloudstore.system.aop; import com.bj58.xfbusiness.cloudstore.utils.IPAddressUtil; import com.bj58.xfbusiness.cloudstore.utils.TraceIdUtil; import org.apache.commons.lang3.StringUtils; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; /** * 日志切面 */ @Aspect @Component public class WebLogAspect { private final static Logger LOGGER = LoggerFactory.getLogger(WebLogAspect.class); /** 以 controller 包下定义的所有请求为切入点 */ @Pointcut("execution(public * com.xx.xxx.xxx.controller..*.*(..))") public void webLog() {} /** * 在切点之前织入 * @param joinPoint * @throws Throwable */ @Before("webLog()") public void doBefore(JoinPoint joinPoint) throws Throwable { LOGGER.info("-------------doBefore-------------"); } @AfterReturning("webLog()") public void afterReturning() { LOGGER.info("-------------afterReturning-------------"); } @AfterThrowing("webLog()") public void afterThrowing() { LOGGER.info("-------------afterThrowing-------------"); } /** * 在切点之后织入 * @throws Throwable */ @After("webLog()") public void doAfter() throws Throwable { LOGGER.info("-------------doAfter-------------"); } /** * 环绕 * @param proceedingJoinPoint * @return * @throws Throwable */ @Around("webLog()") public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { long startTime = System.currentTimeMillis(); LOGGER.info("-------------doAround before proceed-------------"); Object result = proceedingJoinPoint.proceed(); LOGGER.info("-------------doAround after proceed-------------"); return result; } 复制代码我们将版本改为2.2.5.RELEASE,结果如图:我们将版本改为2.3.4.RELEASE,结果如图:结论经过上面的资料文档查阅,我能给出的结论是:从Spring5.2.7开始,Spring AOP不再严格按照AspectJ定义的规则来执行advice,而是根据其类型按照从高到低的优先级进行执行:@Around,@Before ,@After,@AfterReturning,@AfterThrowing。参考www.cnblogs.com/dennyLee202…segmentfault.com/a/119000001…
前言大多数情况下,我们会在打印日志时定义日志的LOGGER级别,用来控制输出的信息范围。一方面,过多的输出会影响查看日志的效率,另一方面,过少的日志让问题定位变得困难。但当线上出现问题时,线上容器通常定义在info级别,发生一些疑难问题时,光靠info级别的日志很难定位问题。一个典型的场景:在一些需要打印MySQL语句的场景,如果你正在使用MyBatis框架,由于MyBaits中SQL语句是DEBUG级别的信息,通常在线上容器就没法看到。一个丑陋的解决办法就是在沙箱/预发环境,将log4j.xml中的info改为debug:<Root level="info"> <AppenderRef ref="detail"/> <AppenderRef level="error" ref="error"/> </Root> 复制代码然后重新打包部署,再发起请求来调试代码。甚至在一些无法模拟请求的场景下,还需要将修改灰度至线上环境,大量的debug信息会对线上服务造成实质性的影响。效果演示:本文内容重点:Arthas工具简介本地测试:实时修改LOGGER级别线上实战:实时打印MyBatis SQL语句总结Arthas工具简介Arthas是阿里开源的Java诊断工具,它的功能可以大致参考下图:它运行的原理是通过字节码生成工具(ASM字节码增强),将代理逻辑编织到原来的类里,实现对应的监控调试等功能。本地测试:实时修改LOGGER级别安装arthas网络安装在接通外网的环境下,可以使用快速网络安装,会从阿里的源拉去全量包。curl -O https://arthas.aliyun.com/arthas-boot.jar java -jar arthas-boot.jar 复制代码全量安装如果本地外网环境不通,比如某些容器内是不允许外网访问的,那么可以使用预先下载好的全量安装包,然后解压后运行包内的jar,使用命令:java -jar arthas-boot.jar 复制代码启动arthas我在本地启动arthas,效果如下图:全局Logger信息使用命令:logger 复制代码可以看到所有logger的信息,包括其中每个appenders。使用如下命令,修改名称为ROOT的logger的日志级别至debug级别:logger --name ROOT --level debug 复制代码可以看到多出了debug级别的输出。指定类名的logger信息在有多个logger的情况下,可以查找指定名称的loggerlogger -n ROOT 复制代码指定classloader的logger信息如果需要改变指定类的输出级别,先要定位到该类的classLoader,然后修改该clasLoader的logger。使用sc命令查看你需要改变的类信息:sc -d cn.monitor4all.miaoshaweb.DynamicLoggerTest | grep classLoaderHash 复制代码随后可以通过classLoader找到其对应的logger:logger -c 18b4aac2 复制代码然后就可以调整对应的logger日志级别:logger -c 18b4aac2 --name ROOT --level debug 复制代码使用 ongl 命令此外,Arthas还支持使用ognl来修改日志级别。但是这种方法对log4j不友好,修改会报错。并且就算支持的logback/slf4j,也需要复杂的形如ognl -c @org.slf4j.LoggerFactory@getLogger("root").setLevel()的命令才能修改,并不是一个很好的办法。线上实战:实时打印MyBatis SQL语句容器内启动arthas我的线上容器,是没有外网访问权限的(这种情况蛮常见的),我将全量包解压在容器内运行:打印DEBUG级别的SQL日志下图是没有DEBUG信息的一条请求日志,可以看到只有入参出参的拦截器信息(INFO级别):使用logger --name ROOT --level debug,将SQL语句输出出来:毕竟,很多时候线上的bug是不小心拼错SQL导致。总结文章简单总结了使用Arthas来动态调整日志级别的使用方法。在线上环境,能够有效的提升排查问题的效率。当然Arthas能做的还远不止于此,更多有趣并且实用的功能等待大家的发掘。参考jueee.github.io/2020/08/202…arthas.aliyun.com/doc/logger.…juejin.cn/post/684490…
前言本篇文章是我这一个多月来帮助组内废弃fastjson框架的总结,我们将大部分Java仓库从fastjson迁移至了Gson。这么做的主要的原因是公司受够了fastjson频繁的安全漏洞问题,每一次出现漏洞都要推一次全公司的fastjson强制版本升级,很令公司头疼。文章的前半部分,我会简单分析各种json解析框架的优劣,并给出企业级项目迁移json框架的几种解决方案。在文章的后半部分,我会结合这一个月的经验,总结下Gson的使用问题,以及fastjson迁移到Gson踩过的深坑。文章目录:为何要放弃fastjson?fastjson替代方案三种json框架的特点性能对比最终选择方案替换依赖时的注意事项谨慎,谨慎,再谨慎做好开发团队和测试团队的沟通做好回归/接口测试考虑迁移前后的性能差异使用Gson替换fastjsonJson反序列化范型处理List/Map写入驼峰与下划线转换迁移常见问题踩坑Date序列化方式不同SpringBoot异常Swagger异常@Mapping JsonObject作为入参异常为何要放弃fastjson?究其原因,是fastjson漏洞频发,导致了公司内部需要频繁的督促各业务线升级fastjson版本,来防止安全问题。fastjson在2020年频繁暴露安全漏洞,此漏洞可以绕过autoType开关来实现反序列化远程代码执行并获取服务器访问权限。从2019年7月份发布的v1.2.59一直到2020年6月份发布的 v1.2.71 ,每个版本的升级中都有关于AutoType的升级,涉及13个正式版本。fastjson中与AutoType相关的版本历史:1.2.59发布,增强AutoType打开时的安全性 fastjson 1.2.60发布,增加了AutoType黑名单,修复拒绝服务安全问题 fastjson 1.2.61发布,增加AutoType安全黑名单 fastjson 1.2.62发布,增加AutoType黑名单、增强日期反序列化和JSONPath fastjson 1.2.66发布,Bug修复安全加固,并且做安全加固,补充了AutoType黑名单 fastjson 1.2.67发布,Bug修复安全加固,补充了AutoType黑名单 fastjson 1.2.68发布,支持GEOJSON,补充了AutoType黑名单 1.2.69发布,修复新发现高危AutoType开关绕过安全漏洞,补充了AutoType黑名单 1.2.70发布,提升兼容性,补充了AutoType黑名单 1.2.71发布,补充安全黑名单,无新增利用,预防性补充 复制代码相比之下,其他的json框架,如Gson和Jackson,漏洞数量少很多,高危漏洞也比较少,这是公司想要替换框架的主要原因。fastjson替代方案本文主要讨论Gson替换fastjson框架的实战问题,所以在这里不展开详细讨论各种json框架的优劣,只给出结论。经过评估,主要有Jackson和Gson两种json框架放入考虑范围内,与fastjson进行对比。三种json框架的特点FastJson速度快fastjson相对其他JSON库的特点是快,从2011年fastjson发布1.1.x版本之后,其性能从未被其他Java实现的JSON库超越。使用广泛fastjson在阿里巴巴大规模使用,在数万台服务器上部署,fastjson在业界被广泛接受。在2012年被开源中国评选为最受欢迎的国产开源软件之一。测试完备fastjson有非常多的testcase,在1.2.11版本中,testcase超过3321个。每次发布都会进行回归测试,保证质量稳定。使用简单fastjson的API十分简洁。Jackson容易使用 - jackson API提供了一个高层次外观,以简化常用的用例。无需创建映射 - API提供了默认的映射大部分对象序列化。性能高 - 快速,低内存占用,适合大型对象图表或系统。干净的JSON - jackson创建一个干净和紧凑的JSON结果,这是让人很容易阅读。不依赖 - 库不需要任何其他的库,除了JDK。Gson提供一种机制,使得将Java对象转换为JSON或相反如使用toString()以及构造器(工厂方法)一样简单。允许预先存在的不可变的对象转换为JSON或与之相反。允许自定义对象的表现形式支持任意复杂的对象输出轻量易读的JSON性能对比1.序列化单对象性能Fastjson > Jackson > Gson,其中Fastjson和Jackson性能差距很小,Gson性能较差2.序列化大对象性能Jackson> Fastjson > Gson ,序列化大Json对象时Jackson> Gson > Fastjson,Jackson序列化大数据时性能优势明显3.反序列化单对象性能 Fastjson > Jackson > Gson , 性能差距较小4.反序列化大对象性能 Fastjson > Jackson > Gson , 性能差距较很小最终选择方案Jackson适用于高性能场景,Gson适用于高安全性场景对于新项目仓库,不再使用fastjson。对于存量系统,考虑到Json更换成本,由以下几种方案可选:项目未使用autoType功能,建议直接切换为非fastjson,如果切换成本较大,可以考虑继续使用fastjson,关闭safemode。业务使用了autoType功能,建议推进废弃fastjson。替换依赖注意事项企业项目或者说大型项目的特点:代码结构复杂,团队多人维护。承担重要线上业务,一旦出现严重bug会导致重大事故。如果是老项目,可能缺少文档,不能随意修改,牵一发而动全身。项目有很多开发分支,不断在迭代上线。所以对于大型项目,想要做到将底层的fastjson迁移到gson是一件复杂且痛苦的事情,其实对于其他依赖的替换,也都一样。我总结了如下几个在替换项目依赖过程中要特别重视的问题。谨慎,谨慎,再谨慎再怎么谨慎都不为过,如果你要更改的项目是非常重要的业务,那么一旦犯下错误,代价是非常大的。并且,对于业务方和产品团队来说,没有新的功能上线,但是系统却炸了,是一件“无法忍受”的事情。尽管你可能觉得很委屈,因为只有你或者你的团队知道,虽然业务看上去没变化,但是代码底层已经发生了翻天覆地的变化。所以,谨慎点!做好开发团队和测试团队的沟通在依赖替换的过程中,需要做好项目的规划,比如分模块替换,严格细分排期。把前期规划做好,开发和测试才能有条不紊的进行工作。开发之间,需要提前沟通好开发注意事项,比如依赖版本问题,防止由多个开发同时修改代码,最后发现使用的版本不同,接口用法都不同这种很尴尬,并且要花额外时间处理的事情。而对于测试,更要事先沟通好。一般来说,测试不会太在意这种对于业务没有变化的技术项目,因为既不是优化速度,也不是新功能。但其实迁移涉及到了底层,很容易就出现BUG。要让测试团队了解更换项目依赖,是需要大量的测试时间投入的,成本不亚于新功能,让他们尽量重视起来。做好回归/接口测试上面说到测试团队需要投入大量工时,这些工时主要都用在项目功能的整体回归上,也就是回归测试。当然,不只是业务回归测试,如果有条件的话,要做接口回归测试。如果公司有接口管理平台,那么可以极大提高这种项目测试的效率。打个比方,在一个模块修改完成后,在测试环境(或者沙箱环境),部署一个线上版本,部署一个修改后的版本,直接将接口返回数据进行对比。考虑迁移前后的性能差异正如上面描述的Gson和Fastjson性能对比,替换框架需要注意框架之间的性能差异,尤其是对于流量业务,也就是高并发项目,响应时间如果发生很大的变化会引起上下游的注意,导致一些额外的后果。使用Gson替换Fastjson这里总结了两种json框架常用的方法,贴出详细的代码示例,帮助大家快速的上手Gson,无缝切换!Json反序列化String jsonCase = "[{\"id\":10001,\"date\":1609316794600,\"name\":\"小明\"},{\"id\":10002,\"date\":1609316794600,\"name\":\"小李\"}]"; // fastjson JSONArray jsonArray = JSON.parseArray(jsonCase); System.out.println(jsonArray); System.out.println(jsonArray.getJSONObject(0).getString("name")); System.out.println(jsonArray.getJSONObject(1).getString("name")); // 输出: // [{"date":1609316794600,"name":"小明","id":10001},{"date":1609316794600,"name":"小李","id":10002}] // 小明 // 小李 // Gson JsonArray jsonArrayGson = gson.fromJson(jsonCase, JsonArray.class); System.out.println(jsonArrayGson); System.out.println(jsonArrayGson.get(0).getAsJsonObject().get("name").getAsString()); System.out.println(jsonArrayGson.get(1).getAsJsonObject().get("name").getAsString()); // 输出: // [{"id":10001,"date":1609316794600,"name":"小明"},{"id":10002,"date":1609316794600,"name":"小李"}] // 小明 // 小李 复制代码看得出,两者区别主要在get各种类型上,Gson调用方法有所改变,但是变化不大。那么,来看下空对象反序列化会不会出现异常:String jsonObjectEmptyCase = "{}"; // fastjson JSONObject jsonObjectEmpty = JSON.parseObject(jsonObjectEmptyCase); System.out.println(jsonObjectEmpty); System.out.println(jsonObjectEmpty.size()); // 输出: // {} // 0 // Gson JsonObject jsonObjectGsonEmpty = gson.fromJson(jsonObjectEmptyCase, JsonObject.class); System.out.println(jsonObjectGsonEmpty); System.out.println(jsonObjectGsonEmpty.size()); // 输出: // {} // 0 复制代码看看空数组呢,毕竟[]感觉比{}更加容易出错。String jsonArrayEmptyCase = "[]"; // fastjson JSONArray jsonArrayEmpty = JSON.parseArray(jsonArrayEmptyCase); System.out.println(jsonArrayEmpty); System.out.println(jsonArrayEmpty.size()); // 输出: // [] // 0 // Gson JsonArray jsonArrayGsonEmpty = gson.fromJson(jsonArrayEmptyCase, JsonArray.class); System.out.println(jsonArrayGsonEmpty); System.out.println(jsonArrayGsonEmpty.size()); // 输出: // [] // 0 复制代码两个框架也都没有问题,完美解析。范型处理解析泛型是一个非常常用的功能,我们项目中大部分fastjson代码就是在解析json和Java Bean。// 实体类 User user = new User(); user.setId(1L); user.setUserName("马云"); // fastjson List<User> userListResultFastjson = JSONArray.parseArray(JSON.toJSONString(userList), User.class); List<User> userListResultFastjson2 = JSON.parseObject(JSON.toJSONString(userList), new TypeReference<List<User>>(){}); System.out.println(userListResultFastjson); System.out.println("userListResultFastjson2" + userListResultFastjson2); // 输出: // userListResultFastjson[User [Hash = 483422889, id=1, userName=马云], null] // userListResultFastjson2[User [Hash = 488970385, id=1, userName=马云], null] // Gson List<User> userListResultTrue = gson.fromJson(gson.toJson(userList), new TypeToken<List<User>>(){}.getType()); System.out.println("userListResultGson" + userListResultGson); // 输出: // userListResultGson[User [Hash = 1435804085, id=1, userName=马云], null] 复制代码可以看出,Gson也能支持泛型。List/Map写入这一点fastjson和Gson有区别,Gson不支持直接将List写入value,而fastjson支持。所以Gson只能将List解析后,写入value中,详见如下代码:// 实体类 User user = new User(); user.setId(1L); user.setUserName("马云"); // fastjson JSONObject jsonObject1 = new JSONObject(); jsonObject1.put("user", user); jsonObject1.put("userList", userList); System.out.println(jsonObject1); // 输出: // {"userList":[{"id":1,"userName":"马云"},null],"user":{"id":1,"userName":"马云"}} // Gson JsonObject jsonObject = new JsonObject(); jsonObject.add("user", gson.toJsonTree(user)); System.out.println(jsonObject); // 输出: // {"user":{"id":1,"userName":"马云"},"userList":[{"id":1,"userName":"马云"},null]} 复制代码如此一来,Gson看起来就没有fastjson方便,因为放入List是以gson.toJsonTree(user)的形式放入的。这样就不能先入对象,在后面修改该对象了。驼峰与下划线转换驼峰转换下划线依靠的是修改Gson的序列化模式,修改为LOWER_CASE_WITH_UNDERSCORESGsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); Gson gsonUnderScore = gsonBuilder.create(); System.out.println(gsonUnderScore.toJson(user)); // 输出: // {"id":1,"user_name":"马云"} 复制代码常见问题排雷下面整理了我们在公司项目迁移Gson过程中,踩过的坑,这些坑现在写起来感觉没什么技术含量。但是这才是我写这篇文章的初衷,帮助大家把这些很难发现的坑避开。这些问题有的是在测试进行回归测试的时候发现的,有的是在自测的时候发现的,有的是在上线后发现的,比如Swagger挂了这种不会去测到的问题。Date序列化方式不同不知道大家想过一个问题没有,如果你的项目里有缓存系统,使用fastjson写入的缓存,在你切换Gson后,需要用Gson解析出来。所以就一定要保证两个框架解析逻辑是相同的,但是,显然这个愿望是美好的。在测试过程中,发现了Date类型,在两个框架里解析是不同的方式。fastjson:Date直接解析为UnixGson:直接序列化为标准格式Date导致了Gson在反序列化这个json的时候,直接报错,无法转换为Date。解决方案:新建一个专门用于解析Date类型的类:import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import java.io.IOException; import java.util.Date; public class MyDateTypeAdapter extends TypeAdapter<Date> { @Override public void write(JsonWriter out, Date value) throws IOException { if (value == null) { out.nullValue(); } else { out.value(value.getTime()); } } @Override public Date read(JsonReader in) throws IOException { if (in != null) { return new Date(in.nextLong()); } else { return null; } } } 复制代码接着,在创建Gson时,把他放入作为Date的专用处理类:Gson gson = new GsonBuilder().registerTypeAdapter(Date.class,new MyDateTypeAdapter()).create(); 复制代码这样就可以让Gson将Date处理为Unix。当然,这只是为了兼容老的缓存,如果你觉得你的仓库没有这方面的顾虑,可以忽略这个问题。SpringBoot异常切换到Gson后,使用SpringBoot搭建的Web项目的接口直接请求不了了。报错类似:org.springframework.http.converter.HttpMessageNotWritableException 复制代码因为SpringBoot默认的Mapper是Jackson解析,我们切换为了Gson作为返回对象后,Jackson解析不了了。解决方案:application.properties里面添加:#Preferred JSON mapper to use for HTTP message conversion spring.mvc.converters.preferred-json-mapper=gson 复制代码Swagger异常这个问题和上面的SpringBoot异常类似,是因为在SpringBoot中引入了Gson,导致 swagger 无法解析 json。GsonSwaggerConfig.java@Configuration public class GsonSwaggerConfig { //设置swagger支持gson @Bean public IGsonHttpMessageConverter IGsonHttpMessageConverter() { return new IGsonHttpMessageConverter(); } } 复制代码IGsonHttpMessageConverter.javapublic class IGsonHttpMessageConverter extends GsonHttpMessageConverter { public IGsonHttpMessageConverter() { //自定义Gson适配器 super.setGson(new GsonBuilder() .registerTypeAdapter(Json.class, new SpringfoxJsonToGsonAdapter()) .serializeNulls()//空值也参与序列化 .create()); } } 复制代码SpringfoxJsonToGsonAdapter.javapublic class SpringfoxJsonToGsonAdapter implements JsonSerializer<Json> { @Override public JsonElement serialize(Json json, Type type, JsonSerializationContext jsonSerializationContext) { return new JsonParser().parse(json.value()); } } 复制代码@Mapping JsonObject作为入参异常有时候,我们会在入参使用类似:public ResponseResult<String> submitAudit(@RequestBody JsonObject jsonObject) {} 复制代码如果使用这种代码,其实就是使用Gson来解析json字符串。但是这种写法的风险是很高的,平常请大家尽量避免使用JsonObject直接接受参数。在Gson中,JsonObject若是有数字字段,会统一序列化为double,也就是会把count = 0这种序列化成count = 0.0。为何会有这种情况?简单的来说就是Gson在将json解析为Object类型时,会默认将数字类型使用double转换。如果Json对应的是Object类型,最终会解析为Map类型;其中Object类型跟Json中具体的值有关,比如双引号的""值翻译为STRING。我们可以看下数值类型(NUMBER)全部转换为了Double类型,所以就有了我们之前的问题,整型数据被翻译为了Double类型,比如30变为了30.0。可以看下Gson的ObjectTypeAdaptor类,它继承了Gson的TypeAdaptor抽象类:解决方案:第一个方案:把入参用实体类接收,不要使用JsonObject第二个方案:与上面的解决Date类型问题类似,自己定义一个Adaptor,来接受数字,并且处理。这种想法我觉得可行但是难度较大,可能会影响到别的类型的解析,需要在设计适配器的时候格外注意。总结这篇文章主要是为了那些需要将项目迁移到Gson框架的同学们准备的。一般来说,个人小项目,是不需要费这么大精力去做迁移,所以这篇文章可能目标人群比较狭窄。但文章中也提到了不少通用问题的解决思路,比如怎么评估迁移框架的必要性。其中需要考虑到框架兼容性,两者性能差异,迁移耗费的工时等很多问题。希望文章对你有所帮助。参考《如何从Fastjson迁移到Gson》juejin.cn/post/684490…《FastJson迁移至Jackson》此文作者自己封装了工具类来完成迁移mxcall.github.io/posts/%E5%B…《你真的会用Gson吗?Gson使用指南》www.jianshu.com/p/e74019622…json性能对比github.com/zysrxx/json…fastjson官方文档github.com/alibaba/fas…易百教程www.yiibai.com/jackson
前言Github之前更新了一个Action功能(应该是很久以前了),可以实现很多自动化操作。用来替代用户自己设置的自动化脚本(比如:钩子+Jenkins)。由于平时根本不会有需求用到它,毕竟平时都在用公司的CI/CD流程,所以一直没有机会玩Action。借着春节放假,就自己写个小Demo体验一下。本文通过实现一个提交代码后自动执行Junit单元测试并输出测试报告的自动化流程小Demo,来快速上手Github Action。Github Action 是什么?Github Action官方文档中对自身的定义:在 GitHub Actions 的仓库中自动化、自定义和执行软件开发工作流程。 您可以发现、创建和共享操作以执行您喜欢的任何作业(包括 CI/CD),并将操作合并到完全自定义的工作流程中。用人话说,就是你可以给你的代码仓库部署一系列自动化脚本,在你进行了提交/合并分支等操作后,自动执行脚本。阮一峰Github Action指南中的介绍:大家知道,持续集成由很多操作组成,比如抓取代码、运行测试、登录远程服务器,发布到第三方服务等等。GitHub 把这些操作就称为 actions。很多操作在不同项目里面是类似的,完全可以共享。GitHub 注意到了这一点,想出了一个很妙的点子,允许开发者把每个操作写成独立的脚本文件,存放到代码仓库,使得其他开发者可以引用。如果你需要某个 action,不必自己写复杂的脚本,直接引用他人写好的 action 即可,整个持续集成过程,就变成了一个 actions 的组合。这就是 GitHub Actions 最特别的地方。GitHub Actions 有一些自己的术语:workflow (工作流程):持续集成一次运行的过程,就是一个 workflow。job (任务):一个 workflow 由一个或多个 jobs 构成,含义是一次持续集成的运行,可以完成多个任务。step(步骤):每个 job 由多个 step 构成,一步步完成。action (动作):每个 step 可以依次执行一个或多个命令(action)。看这些介绍和定义,其实比较枯燥,我们直接来看代码实现,在代码中来理解这些定义和指令。快速上手给仓库创建新文件夹.github/workflow首先,用你自己的任意GitHub仓库,在仓库内添加文件夹.github/workflow 或者.github/workflows:一个库可以有多个 workflow 文件。GitHub 只要发现.github/workflows目录里面有.yml文件,就会自动运行该文件。撰写你的workflow一个yml脚本便是Action的核心了,我们新建一个blank.yml,内容如下:我在代码里做了一些注释,帮助大家理解每个指令的含义。整个脚本大致的流程如下:指定在push或者pull request时触发脚本执行拉取ubuntu最新版的镜像缓存Maven依赖目录,避免每次都下载全量依赖包,加快执行速度安装Java8指定pom.xml文件路径,随后用Maven编译项目运行Junit单元测试给项目撰写单元测试代码ok,写完脚本,我们需要来编写一些测试代码,让Junit有事可做。我使用了自己的一个仓库,上面有完整的action脚本和测试类代码,供参考:github.com/qqxx6661/aw…这是一个Maven仓库,我们在test文件夹内加入测试代码。上面的测试代码测试的是下面的一个静态方法:提交代码,触发Github Action执行将代码commit并push后,点开你的仓库主页,点击Action标签:可以看到已经有了执行信息。接着看下我们的Action到底有没有执行,点开Action标签,已经发现了Junit:可以进行脚本代码的在线编辑:点进本次commit执行的记录,可以看到,action顺利完成了几个步骤:点开Maven的构建日志,可以看到我们第一次跑action,所有的依赖还是即时下载的:单元测试运行的日志输出正常:为了试验Maven的依赖包是否能够使用到缓存,我们再写几个单元测试,然后commit:可以看到,新的action日志里直接开始了编译,不再需要下载全量的包:单元测试页成功执行:至此,我们的简易入门教程便结束了。还有很多功能等待探索当然,这还只是Action的冰山一角,其能做的事情远不止于此:编译打包代码自动上传至公有云/App容器单元测试/代码覆盖率测试/文档同步/发布版本等着你们的探索。参考docs.github.com/cn/actions/…www.ruanyifeng.com/blog/2019/0…
什么是微服务网关SpringCloud Gateway是Spring全家桶中一个比较新的项目,Spring社区是这么介绍它的:该项目借助Spring WebFlux的能力,打造了一个API网关。旨在提供一种简单而有效的方法来作为API服务的路由,并为它们提供各种增强功能,例如:安全性,监控和可伸缩性。而在真实的业务领域,我们经常用SpringCloud Gateway来做微服务网关,如果你不理解微服务网关和传统网关的区别,可以阅读此篇文章 Service Mesh和API Gateway关系深度探讨 来了解两者的定位区别。以我粗浅的理解,传统的API网关,往往是独立于各个后端服务,请求先打到独立的网关层,再打到服务集群。而微服务网关,将流量从南北走向改为东西走向(见下图),微服务网关和后端服务是在同一个容器中的,所以也有个别名,叫做Gateway Sidecar。为啥叫Sidecar,这个词应该怎么理解呢,吃鸡里的三蹦子见过没:摩托车是你的后端服务,而旁边挂着的额外座椅就是微服务网关,他是依附于后端服务的(一般是指两个进程在同一个容器中),是不是生动形象了一些。由于本人才疏学浅,对于微服务相关概念理解上难免会有偏差。就不在此详细讲述原理性的文字了。文章目录让我们造一个网关把引入pom依赖编写yml文件接口转义问题获取请求体(Request Body)踩坑实战获取客户端真实IP尾缀匹配总结源代码完整项目源代码已经收录到我的Github:github.com/qqxx6661/sp…让我们造一个网关把引入pom依赖我使用了spring-boot 2.2.5.RELEASE作为parent依赖:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.5.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> 复制代码在dependencyManagement中,我们需要指定sringcloud的版本,以便保证我们能够引入我们想要的SpringCloud Gateway版本,所以需要用到dependencyManagement:<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR8</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> 复制代码最后,是在dependency中引入spring-cloud-starter-gateway:<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> 复制代码如此一来,我们便引入了2.2.5.RELEASE版本的网关:此外,请检查一下你的依赖中是否含有spring-boot-starter-web,如果有,请干掉它。因为我们的SpringCloud Gateway是一个netty+webflux实现的web服务器,和Springboot Web本身就是冲突的。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> 复制代码做到这里,实际上你的项目就已经可以启动了,运行SpringcloudGatewayApplication,得到结果如图:编写yml文件SpringBoot的核心概念是约定优先于配置,在以前初学Spring时,一直不理解这句话的意思,在使用SpringCloud Gateway时,更加深入的理解了这句话。在默认情况下,你不需要任何的配置,就能够运行起来最基本的网关。针对你之后特定的需求,再去追加配置。而SpringCloud Gateway更强大的一点就是内置了非常多的默认功能实现,你需要的大部分功能,比如在请求中添加一个header,添加一个参数,都只需要在yml中引入相应的内置过滤器即可。可以说,yml是整个SpringCloud Gateway的灵魂。一个网关最基本的功能,就是配置路由,在这方面,SpringCloud Gateway支持非常多方式。比如:通过时间匹配通过 Cookie 匹配通过 Header 属性匹配通过 Host 匹配通过请求方式匹配通过请求路径匹配通过请求参数匹配通过请求 ip 地址进行匹配这些在官网教程中,都有详细的介绍,就算你百度下,也会有很多民间翻译的入门教程,我就不再赘述了,我只用一个请求路径做一个简单的例子。在公司的项目中,由于有新老两套后台服务,我们使用不同的uri路径进行区分。老服务路径为:url/api/xxxxxx,服务端口号为8001新服务路径为:url/api/v2/xxxxx,服务端口号为8002那么可以直接在yml里面配置:logging: level: org.springframework.cloud.gateway: DEBUG reactor.netty.http.client: DEBUG spring: cloud: gateway: default-filters: - AddRequestHeader=gateway-env, springcloud-gateway routes: - id: "server_v2" uri: "http://127.0.0.1:8002" predicates: - Path=/api/v2/** - id: "server_v1" uri: "http://127.0.0.1:8001" predicates: - Path=/api/** 复制代码上面的代码解释如下:logging:由于文章需要,我们打开gateway和netty的Debug模式,可以看清楚请求进来后执行的流程,方便后续说明。default-filters:我们可以方便的使用default-filters,在请求中加入一个自定义的header,我们加入一个KV为gateway-env:springcloud-gateway,来注明我们这个请求经过了此网关。这样做的好处是后续服务端也能够看到。routes:路由是网关的重点,相信读者们看代码也能理解,我配置了两个路由,一个是server_v1的老服务,一个是server_v2的新服务。**请注意,一个请求满足多个路由的谓词条件时,请求只会被首个成功匹配的路由转发。**由于我们老服务的路由是/xx,所以需要将老服务放在后面,优先匹配词缀/v2的新服务,不满足的再匹配到/xx。来看一下http://localhost:8080/api/xxxxx的结果:来看一下http://localhost:8080/api/v2/xxxxx的结果:可以看到两个请求被正确的路由了。由于我们真正并没有开启后端服务,所以最后一句error请忽略。接口转义问题在公司实际的项目中,我在搭建好网关后,遇到了一个接口转义问题,相信很多读者可能也会碰到,所以在这里我们最好是防患于未然,优先处理下。问题是这样的,很多老项目在url上并没有进行转义,导致会出现如下接口请求,http://xxxxxxxxx/api/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"这样请求过来,网关会报错:java.lang.IllegalArgumentException: Invalid character '=' for QUERY_PARAM in "http://pic1.ajkimg.com/display/anjuke/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"在不修改服务代码逻辑的前提下,网关其实已经可以解决这件事情,解决办法就是升级到2.1.1.RELEASE以上的版本。The issue was fixed in version spring-cloud-gateway 2.1.1.RELEASE.所以我们一开始就是用了高版本2.2.5.RELEASE,避免了这个问题,如果小伙伴发现之前使用的版本低于 2.1.1.RELEASE,请升级。获取请求体(Request Body)在网关的使用中,有时候会需要拿到请求body里面的数据,比如验证签名,body可能需要参与签名校验。但是SpringCloud Gateway由于底层采用了webflux,其请求是流式响应的,即 Reactor 编程,要读取 Request Body 中的请求参数就没那么容易了。网上谷歌了很久,很多解决方案要么是彻底过时,要么是版本不兼容,好在最后参考了这篇文章,终于有了思路:www.jianshu.com/p/db3b15aec…首先我们需要将body从请求中拿出来,由于是流式处理,Request的Body是只能读取一次的,如果直接通过在Filter中读取,会导致后面的服务无法读取数据。SpringCloud Gateway 内部提供了一个断言工厂类ReadBodyPredicateFactory,这个类实现了读取Request的Body内容并放入缓存,我们可以通过从缓存中获取body内容来实现我们的目的。首先新建一个CustomReadBodyRoutePredicateFactory类,这里只贴出关键代码,完整代码请看可运行的Github仓库:@Component public class CustomReadBodyRoutePredicateFactory extends AbstractRoutePredicateFactory<CustomReadBodyRoutePredicateFactory.Config> { protected static final Log log = LogFactory.getLog(CustomReadBodyRoutePredicateFactory.class); private List<HttpMessageReader<?>> messageReaders; @Value("${spring.codec.max-in-memory-size}") private DataSize maxInMemory; public CustomReadBodyRoutePredicateFactory() { super(Config.class); this.messageReaders = HandlerStrategies.withDefaults().messageReaders(); } public CustomReadBodyRoutePredicateFactory(List<HttpMessageReader<?>> messageReaders) { super(Config.class); this.messageReaders = messageReaders; } @PostConstruct private void overrideMsgReaders() { this.messageReaders = HandlerStrategies.builder().codecs((c) -> c.defaultCodecs().maxInMemorySize((int) maxInMemory.toBytes())).build().messageReaders(); } @Override public AsyncPredicate<ServerWebExchange> applyAsync(Config config) { return new AsyncPredicate<ServerWebExchange>() { @Override public Publisher<Boolean> apply(ServerWebExchange exchange) { Class inClass = config.getInClass(); Object cachedBody = exchange.getAttribute("cachedRequestBodyObject"); if (cachedBody != null) { try { boolean test = config.predicate.test(cachedBody); exchange.getAttributes().put("read_body_predicate_test_attribute", test); return Mono.just(test); } catch (ClassCastException var6) { if (CustomReadBodyRoutePredicateFactory.log.isDebugEnabled()) { CustomReadBodyRoutePredicateFactory.log.debug("Predicate test failed because class in predicate does not match the cached body object", var6); } return Mono.just(false); } } else { return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) -> { return ServerRequest.create(exchange.mutate().request(serverHttpRequest).build(), CustomReadBodyRoutePredicateFactory.this.messageReaders).bodyToMono(inClass).doOnNext((objectValue) -> { exchange.getAttributes().put("cachedRequestBodyObject", objectValue); }).map((objectValue) -> { return config.getPredicate().test(objectValue); }).thenReturn(true); }); } } @Override public String toString() { return String.format("ReadBody: %s", config.getInClass()); } }; } @Override public Predicate<ServerWebExchange> apply(Config config) { throw new UnsupportedOperationException("ReadBodyPredicateFactory is only async."); } } 复制代码代码主要作用:在有body的请求到来时,将body读取出来放到内存缓存中。若没有body,则不作任何操作。这样我们便可以在拦截器里使用exchange.getAttribute("cachedRequestBodyObject")得到body体。对了,我们还没有演示一个filter是如何写的,在这里就先写一个完整的demofilter。让我们新建类DemoGatewayFilterFactory:@Component public class DemoGatewayFilterFactory extends AbstractGatewayFilterFactory<DemoGatewayFilterFactory.Config> { private static final String CACHE_REQUEST_BODY_OBJECT_KEY = "cachedRequestBodyObject"; public DemoGatewayFilterFactory() { super(Config.class); log.info("Loaded GatewayFilterFactory [DemoFilter]"); } @Override public List<String> shortcutFieldOrder() { return Collections.singletonList("enabled"); } @Override public GatewayFilter apply(DemoGatewayFilterFactory.Config config) { return (exchange, chain) -> { if (!config.isEnabled()) { return chain.filter(exchange); } log.info("-----DemoGatewayFilterFactory start-----"); ServerHttpRequest request = exchange.getRequest(); log.info("RemoteAddress: [{}]", request.getRemoteAddress()); log.info("Path: [{}]", request.getURI().getPath()); log.info("Method: [{}]", request.getMethod()); log.info("Body: [{}]", (String) exchange.getAttribute(CACHE_REQUEST_BODY_OBJECT_KEY)); log.info("-----DemoGatewayFilterFactory end-----"); return chain.filter(exchange); }; } public static class Config { private boolean enabled; public Config() {} public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } } } 复制代码这个filter里,我们拿到了新鲜的请求,并且打印出了他的path,method,body等。我们发送一个post请求,body就写一个“我是body”,运行网关,得到结果:是不是非常清晰明了!你以为这就结束了吗?这里有两个非常大的坑。1. body为空时处理上面贴出的CustomReadBodyRoutePredicateFactory类其实已经是我修复过的代码,里面有一行.thenReturn(true)是需要加上的。这才能保证当body为空时,不会报出异常。至于为啥一开始写的有问题,显然因为我偷懒了,直接copy网上的代码了,哈哈哈哈哈。2. body大小超过了buffer的最大限制这个情况是在公司项目上线后才发现的,我们的请求里body有时候会比较大,但是网关会有默认大小限制。所以上线后发现了频繁的报错:org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144谷歌后,找到了解决方案,需要在配置中增加了如下配置spring: codec: max-in-memory-size: 5MB 复制代码把buffer大小改到了5M。你以为这就又双叕结束了,太天真了,你会发现可能没有生效。问题的根源在这里:我们在spring配置了上面的参数,但是我们自定义的拦截器是会初始化ServerRequest,这个DefaultServerRequest中的HttpMessageReader会使用默认的262144所以我们在此处需要从Spring中取出CodecConfigurer, 并将里面的Reader传给serverRequest。详细的debug过程可以看这篇参考文献:theclouds.io/tag/spring-…OK,找到问题后,就可以修改我们的代码,在CustomReadBodyRoutePredicateFactory里,增加:@Value("${spring.codec.max-in-memory-size}") private DataSize maxInMemory; @PostConstruct private void overrideMsgReaders() { this.messageReaders = HandlerStrategies.builder().codecs((c) -> c.defaultCodecs().maxInMemorySize((int) maxInMemory.toBytes())).build().messageReaders(); } 复制代码这样每次就会使用我们的5MB来作为最大缓存限制了。依然提醒一下,完整的代码可以请看可运行的Github仓库讲到这里,入门实战就差不多了,你的网关已经可以上线使用了,你要做的就是加上你需要的业务功能,比如日志,延签,统计等。踩坑实战获取客户端真实IP很多时候,我们的后端服务会去通过host拿到用户的真实IP,但是通过外层反向代理nginx的转发,很可能就需要从header里拿X-Forward-XXX类似这样的参数,才能拿到真实IP。在我们加入了微服务网关后,这个复杂的链路中又增加了一环。这不,如果你不做任何设置,由于你的网关和后端服务在同一个容器中,你的后端服务很有可能就会拿到localhost:8080(你的网关端口)这样的IP。这时候,你需要在yml里配置PreserveHostHeader,这是SpringCloud Gateway自带的实现:filters: - PreserveHostHeader # 防止host被修改为localhost 复制代码字面意思,就是将Host的Header保留起来,透传给后端服务。filter里面的源码贴出来给大家:public GatewayFilter apply(Object config) { return new GatewayFilter() { public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { exchange.getAttributes().put(ServerWebExchangeUtils.PRESERVE_HOST_HEADER_ATTRIBUTE, true); return chain.filter(exchange); } public String toString() { return GatewayToStringStyler.filterToStringCreator(PreserveHostHeaderGatewayFilterFactory.this).toString(); } }; } 复制代码尾缀匹配公司的项目中,老的后端仓库api都以.json结尾(/api/xxxxxx.json),这就催生了一个需求,当我们对老接口进行了重构后,希望其打到我们的新服务,我们就要将.json这个尾缀切除。可以在filters里设置:filters: - RewritePath=(?<segment>/?.*).json, $\{segment} # 重构接口抹去.json尾缀 复制代码这样就可以实现打到后端的接口去除了.json后缀。总结本文带领读者一步步完成了一个微服务网关的搭建,并且将许多可能隐藏的坑进行了解决。最后的成品项目在笔者公司已经上线运行,并且增加了签名验证,日志记录等业务,每天承担百万级别的请求,是经过实战验证过的项目。最后再发一次项目源码仓库:github.com/qqxx6661/sp…参考cloud.tencent.com/developer/a…juejin.cn/post/684490…segmentfault.com/a/119000001…cloud.spring.io/spring-clou…www.cnblogs.com/savorboard/…www.servicemesher.com/blog/servic…www.cnblogs.com/hyf-huangyo…www.codercto.com/a/52970.htm…github.com/spring-clou…blog.csdn.net/zhangzhen02…
本文的主要内容:Serverless概念解释3分钟部署一个网站10分钟开发一个在线RSS阅读小站腾讯Serverless Web Function的优缺点分析首先放一个我部署好的RSS在线阅读器页面:只要在url传入需要解析的RSS订阅地址,比如xxxx/rss?rssurl=blog.csdn.net/qqxx6661/rs… ,就可以解析出该RSS,并渲染成你想要的博客样式。整个实现代码除了html模板,只需要4行代码。Serverless概念Serverless是一个怎样的运行原理呢?简单的解释下:腾讯云云函数是腾讯云提供的 Serverless 执行环境。您只需编写简单的、目的单一的云函数即可将它与您的腾讯云基础设施及其他云服务产生的事件关联。当然了,Serverless不是表示没有服务器,而表示当您在使用 Serverless 时,您无需关心底层资源,也无需登录服务器和优化服务器,只需关注最核心的代码片段,即可跳过复杂的、繁琐的基本工作。核心的代码片段完全由事件或者请求触发,平台根据请求自动平行调整服务资源。Serverless 拥有近乎无限的扩容能力,空闲时,不运行任何资源。代码运行无状态,可以轻易实现快速迭代、极速部署。它的大致执行流程如下图:所以,Serverless其实本质上是云服务上帮你整合了云资源,你只需要编写最核心的代码,比如一个请求过来如何处理和返回对应的数据。其他的服务器部署相关的事情,都交给云服务商。这样带来的最核心的好处是节省了大量资源,只有你的网站有人访问时,才会计算资源消耗的价格,大幅度降低了成本。可能你只是想搭建一个博客,以前可能需要买一年的服务器,起码一年开销几百元。在Serverless下,如果你的博客访问量并不是很高,可能一年只要花费十几块钱。3分钟部署一个网站我们打开Serverless创建函数服务页面:console.cloud.tencent.com/scf/list-cr…选择Python3 Web函数模板:接着可以设置一些基本信息,这里我啥也没修改,直接点击完成。大概等待30秒,一个Serverless服务就创建完成了。接下来,我们就被跳转到了代码编辑页面。由于我们在上面的选项里选了Python3 Web开发,所以代码默认有了一个最基本的Flask框架模板。(Flask是什么?是Python的一个Web开发框架,就像Java下面的SpringBoot一样,也能很轻松地开发一个Web服务。官方文档:flask.palletsprojects.com/en/2.0.x/)模板代码已经有了路径(“/”)最基本的返回值,我们把他改几个字,然后点击左下角部署按钮。花费了十几秒部署完成后,点击测试,我们就能看到返回的Body了,如下图。这时候你访问页面,也返回了同样的结果。一个最最基础的Web服务器就OK了,不需要买域名和HTTPS证书,不需要SSH登录服务器,不需要手动编译代码,🐂🍺。10分钟开发一个在线RSS阅读小站上面是牛刀小试,接下来稍微整一个复杂一点点的例子。**我一直觉得RSS阅读是一个虽然过时,但是其精神一直在传承的阅读方式。**比如微信的订阅号,可以说其本质也是一种RSS阅读,通过关注订阅,并获得更新文章的推送,来定制化你的阅读内容。所以我想做一个RSS解析器,通过传入RSS的网址(很多网站还保留有这个网址,比如CSDN,比如阮一峰的博客等),能够渲染出该RSS链接里所有的文章,并展示在网页上。后面,还可以深度的修改页面的展示,做出一个微信订阅号网页版,也不是不可能,哈哈哈。OK,花里胡哨的就不扯了,先开始实现一个最简单的事情,把RSS链接渲染出来。我们使用官方的Flask模板,这个模板的Flask环境相对更加完整,方便开发。我们首先需要一个RSS解析的框架,在Python中,有一个feedparser的框架,能够解析RSS url。使用pip3 install feedparser安装feedparser:接着我们在app.py加入代码:import feedparser @app.route('/rss') def rss(): feed = feedparser.parse(request.args.get('rssurl')) return render_template('rss.html', entries=feed.entries) 复制代码在这里,我进行了一波本地调试,看一下feed这个参数,拿到了什么:可以看到,解析后的rss链接,被feedparser框架解析为了一个数组,每个entry是一个文章的标题,作者,链接等。根据上面的参数名,我们再加入一个rss/html,放在templates文件夹中,里面写好了我稍微美化过的html页面,并且将每篇文章循环插入html中,这里用到了flask自带的渲染模板jinja2:<html> <head> <title>RSS阅读博客</title> <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.0.2/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.0.2/js/bootstrap.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.slim.min.js"></script> <div class="container-fluid"> <div class="row-fluid"> <div class="span12"> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <div class="container-fluid"> <a class="navbar-brand" href="#">RSS解析器</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarSupportedContent"> <ul class="navbar-nav me-auto mb-2 mb-lg-0"> <li class="nav-item"> <a class="nav-link active" aria-current="page" href="#">首页</a> </li> </ul> <form class="d-flex"> <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"> <button class="btn btn-outline-success" type="submit">Search</button> </form> </div> </div> </nav> <div class="accordion" id="accordionExample"> {% for entry in entries %} <div class="accordion-item"> <h2 class="accordion-header" id="heading{{ loop.index }}"> <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ loop.index }}" aria-expanded="false" aria-controls="collapse{{ loop.index }}"> {{ entry.title }} | {{ entry.published }} </button> </h2> <div id="collapse{{ loop.index }}" class="accordion-collapse collapse" aria-labelledby="heading{{ loop.index }}" data-bs-parent="#accordionExample"> <div class="accordion-body"> {{ entry.summary | safe }} </div> </div> </div> {% endfor %} </div> </div> </div> </div> </body> </html> 复制代码两个文件修改完,直接点击部署然后访问腾讯云给我们分配的域名:service-ehshqmzv-1252138314.cd.apigw.tencentcs.com/release/rss…可以看到,完美解析出了我CSDN博客的RSS流,并且可以一个个打开。动图见文章最上方。Web Function的优缺点分析可以看出来,Serverless开发一个网站和传统网站开发区别非常大,它撇去了传统开发中那些冗杂但是又不经常会去修改的配置和流程,让开发者专注于业务逻辑的开发。但是这种开发方式真的完美吗?我思考了一下它的优势和不足。**最明显的优势便是它简化了开发的步骤,省去了很多部署的无聊工作量。**但是,它的简化是有代价的,简化是会牺牲很大一部分灵活性和可定制性的。简化能够好用的前提是,云服务商把这些事情做到了,并且做好了。如果你的网站需要大量复杂的逻辑,并且需要优化网关的配置,那么很多事情在Serverless下,至少在目前提供的Serverless下,还是做不到的。上面说到的弊端,其实会带来一个很大的问题,就是沉没成本,当你花了很多时间在Serverless上,却发现有一些小的要求或者定制化,没法实现,这时你是去翻文档,提工单,还是选择老老实实买个虚机,自己手动重新部署呢。当然,上述观点是一个开发者的视角来看的。作为普通消费者,可能很多时候只需要部署一个静态网站,用来放一个博客,或者说推广下自己的公司和产品。那么Serverlss大概率是符合要求的。除了上面的主要矛盾外,还有一点是我想提出的,就是目前在线代码编辑页面的调试功能有点太弱了。在最开始的Python3 Web模板中,在线的依赖库貌似缺失了新版本的feedparser和flask,导致我在本地调试能够运行的代码放到Serverless上各种不成功,但是错误信息却很难找。导致我不得不在VS Code的终端里,一个手动开flask服务,一个去curl请求,才能看到报错信息。当然,这个可能是我自己走得歪路,但是在页面上,很难一眼看出来Debug窗口在哪里。在用户体验上,还有很多事情可以做。
总体流程网上有很多文章可以查到,主要是以下几步:在sonatype提交发布工单(Issue)配置gpg秘钥配置pom.xml和setting.xmlmvn clean deploy你肯定要问了,sonatype和公共的Maven仓库是什么关系?为什么需要在sonatype进行操作呢?Maven中央仓库并不支持直接发布jar包。我们需要将jar包发布到一些指定的第三方Maven仓库,然后该仓库再将jar包同步到Maven中央仓库。其中,最”简单”的方式是通过Sonatype OSSRH仓库来发布jar包。接下来,我会介绍如何将jar包发布到Sonatype OSSRH。此外,还要重申的一点:网络上的教程都是有时效性的,包括本文也是(本文写于2021年12月)。所以最好的方式,是按照官网的文档去做,遇到问题再配合网上的教程解决,因为官网的文档永远是最新并且最优的解决方案,直接照着博客教程去做有可能会走很多弯路。官网文档地址:(这是你最应该看的文档没有之一)central.sonatype.org/publish/pub…好了,让我们一步步跟着上面的官方文档来操作。在sonatype提交工单第一步,首先你需要在sonatype网站注册账号:issues.sonatype.org/secure/Sign…创建好后登录,点击页面上方的新建,来提交一个新的issue。下图是我创建时候填写的内容,大家可以参考。主要是几个地方要注意:问题名称,只要大概表达清楚意思即可groupId要写准确Porject URL填写Github仓库地址SCM url需要在Github仓库地址后带git后缀提交后,我本来以为是人工审核,其实是全自动机器人自动回复你。它要求你证明你对groupId的网址有 所有权,比如我填写的是cn.monitor4all,那么我就要在monitor4all.cn的网站上,添加一个TXT解析,指向这个Issue(值写为OSSRH-xxxxx).如果你是的groupId填写的是com.github.xxx,则不需要做上述的步骤。所以如果自己没有域名,或者嫌麻烦的,直接用com.github.xxx即可。由于我的网站域名是自己买的,并且是腾讯云解析的,所以我去腾讯云添加了一条TXT解析值写为OSSRH-75759。(我的工单地址就是issues.sonatype.org/browse/OSSR…稍等几分钟,sonatype就检测到了你的域名所有权。配置gpg秘钥第二部,你需要设置gpg秘钥,官网gps秘钥签名教程:central.sonatype.org/publish/pub…你肯定会好奇什么是GPG,GPG是一种RSA算法的实现。1991年,程序员Phil Zimmermann为了避开政府监视,开发了加密软件PGP。这个软件非常好用,迅速流传开来,成了许多程序员的必备工具。但是,它是商业软件,不能自由使用。所以,自由软件基金会决定,开发一个PGP的替代品,取名为GnuPG。这就是GPG的由来。sonatype既然允许你上传到公有仓库,肯定要对你这个“人”,进行鉴权。防止其他恶意的人上传Jar包。我们去官网下载GunPGwww.gnupg.org/download/有各种系统的版本可以下载,我下载了MacOS版。装好后,我们打开ssh,输入命令:➜ ~ gpg --generate-key 复制代码紧接着跟着操作就能生成秘钥,以下是我的生成结果,打了码:gpg (GnuPG/MacGPG2) 2.2.32; Copyright (C) 2021 Free Software Foundation, Inc. This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. 注意:使用 “gpg --full-generate-key” 以获得一个全功能的密钥生成对话框。 GnuPG 需要构建用户标识以辨认您的密钥。 真实姓名: xxxxxxx 电子邮件地址: xxxxxxx@foxmail.com 您选定了此用户标识: “xxxxxxxx <xxxxx@foxmail.com>” 更改姓名(N)、注释(C)、电子邮件地址(E)或确定(O)/退出(Q)? u 更改姓名(N)、注释(C)、电子邮件地址(E)或确定(O)/退出(Q)? o 我们需要生成大量的随机字节。在质数生成期间做些其他操作(敲打键盘 、移动鼠标、读写硬盘之类的)将会是一个不错的主意;这会让随机数 发生器有更好的机会获得足够的熵。 我们需要生成大量的随机字节。在质数生成期间做些其他操作(敲打键盘 、移动鼠标、读写硬盘之类的)将会是一个不错的主意;这会让随机数 发生器有更好的机会获得足够的熵。 gpg: 密钥 54EC3C8FA3A5B50F 被标记为绝对信任 gpg: 目录‘/Users/xxxxxxxxx/.gnupg/openpgp-revocs.d’已创建 gpg: 吊销证书已被存储为‘/Users/xxxxxxxxx/.gnupg/openpgp-revocs.d/xxxxxxxxxxxxxxxxxxxxxxxxxxxx.rev’ 公钥和私钥已经生成并被签名。 pub rsa3072 2021-12-06 [SC] [有效至:2023-12-06] 8BDxxxxxxxxxxxxxxxxxxxxxxxxxxxxB50F uid xxxxxxxx <xxxxxxxx@foxmail.com> sub rsa3072 2021-12-06 [E] [有效至:2023-12-06] 复制代码秘钥生成好后,需要你把公钥上传到公共服务器供sonatype验证。官网教程里有三个地址可以用:keyserver.ubuntu.comkeys.openpgp.orgpgp.mit.edu我按照官网给的办法上传:➜ ~ gpg --keyserver pgp.mit.edu:11371 --send-keys 8BD96B0EA18E5162B94EA7F754EC3C8FA3A5B50F gpg: 正在发送密钥 54EC3C8FA3A5B50F 到 pgp.mit.edu:11371 gpg: 发送至公钥服务器失败:文件结尾 gpg: 发送至公钥服务器失败:文件结尾 ➜ ~ gpg --keyserver keyserver.ubuntu.com --send-keys 8BD96B0EA18E5162B94EA7F754EC3C8FA3A5B50F gpg: 正在发送密钥 54EC3C8FA3A5B50F 到 hkp://keyserver.ubuntu.com gpg: 发送至公钥服务器失败:Network is unreachable gpg: 发送至公钥服务器失败:Network is unreachable ➜ ~ gpg --keyserver pgp.mit.edu:11371 --send-keys 54EC3C8FA3A5B50F gpg: 正在发送密钥 54EC3C8FA3A5B50F 到 pgp.mit.edu:11371 gpg: 发送至公钥服务器失败:文件结尾 gpg: 发送至公钥服务器失败:文件结尾 复制代码但是,报错了,反复尝试了各种网上教程,依然报错。我以为是我网络的问题,或者是那几个服务器也失效了,反正一遍遍的排查,折腾了一个晚上。就当快要崩溃时,我无意中发现gunpg还是有应用可以打开的,也就是有GUI界面,于是我进去看了看,然后在我的秘钥点击右键,有上传的服务器的选项:这样居然传成功了,真是大无语啊兄弟们。配置pom.xml和setting.xml第三步,你需要按照官网的教程,来配置你的pom.xml和setting.xml文件。大家看到这里已经很累了,我就不按照官网教程那样一步步演示了,直接给你们总结下成功需要添加的配置。首先是setting.xml。你需要添加一个profile:<profiles> <profile> <id>ossrh</id> <activation> <activeByDefault>true</activeByDefault> </activation> <properties> <gpg.executable>gpg2</gpg.executable> <gpg.passphrase>yzdbwj1993</gpg.passphrase> </properties> </profile> </profiles> 复制代码还需要一个server,里面需要填写你的ossrh账号密码:<servers> <server> <id>ossrh</id> <username>你上面注册的账号</username> <password>你上面注册的密码</password> </server> </servers> </settings> 复制代码setting.xml配置好了,接下来是你项目的pom.xml。首先,你需要申明很多plugin,以及一个snapshotRepository,我把所有的都贴在了这里。<distributionManagement> <!-- 申明打包到Maven公有仓库 --> <snapshotRepository> <id>ossrh</id> <url>https://s01.oss.sonatype.org/content/repositories/snapshots</url> </snapshotRepository> </distributionManagement> <build> <plugins> <plugin> <groupId>org.sonatype.plugins</groupId> <artifactId>nexus-staging-maven-plugin</artifactId> <version>1.6.7</version> <extensions>true</extensions> <configuration> <serverId>ossrh</serverId> <nexusUrl>https://s01.oss.sonatype.org/</nexusUrl> <autoReleaseAfterClose>true</autoReleaseAfterClose> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-source-plugin</artifactId> <version>2.2.1</version> <executions> <execution> <id>attach-sources</id> <goals> <goal>jar-no-fork</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-javadoc-plugin</artifactId> <version>2.9.1</version> <configuration> <javadocExecutable>${java.home}/../bin/javadoc</javadocExecutable> </configuration> <executions> <execution> <id>attach-javadocs</id> <goals> <goal>jar</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-gpg-plugin</artifactId> <version>1.5</version> <executions> <execution> <id>sign-artifacts</id> <phase>verify</phase> <goals> <goal>sign</goal> </goals> </execution> </executions> </plugin> </plugins> </build> 复制代码注意,在maven-javadoc-plugin这个插件添加后,最好像上面一样,加上一个官方教程没有提到的:<configuration> <javadocExecutable>${java.home}/../bin/javadoc</javadocExecutable> </configuration> 复制代码否则你可能会编译失败,提示找不到你的JAVA_HOME环境变量,就像下图这样:MavenReportException: Error while creating archive: Unable to find javadoc command: The environment variable JAVA_HOME is not correctly set.之后,你还可以添加上你的一些个人信息:<licenses> <license> <name>The Apache Software License, Version 2.0</name> <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url> <distribution>actable</distribution> </license> </licenses> <developers> <developer> <name>xxxxxxxxx Yang</name> <email>xxxxxxxxx@foxmail.com</email> <organization>xxxxxxxxxx</organization> </developer> </developers> <scm> <tag>master</tag> <url>git@github.com:qqxx6661/logRecord.git</url> <connection>git@github.com:qqxx6661/logRecord.git</connection> <developerConnection>git@github.com:qqxx6661/logRecord.git</developerConnection> </scm> 复制代码正式打包发布最后,一切都准备好了,你可以进行最神圣的一个指令mvn clean deploy 复制代码紧接着就上传成功了你的工单这时候也会更新一条信息:Central sync is activated for cn.monitor4all. After you successfully release, your component will be available to the public on Central repo1.maven.org/maven2/, typically within 30 minutes, though updates to search.maven.org can take up to four hours.你的Jar包会在30分钟左右能够被拉取。下图就是成功的截图拉。
前言在前公司有将fastjson迁移至Gson的需求,当时组内由我牵头,花了将近一个月时间,将大部分Java仓库从fastjson迁移至了Gson。这么做的主要的原因是当时正值FastJson AutoType漏洞频发,连续几个版本升级,每一次出现漏洞都要推一次全公司的fastjson强制版本升级,很令公司头疼,故推动全集团最迁移。文章的前半部分,我会简单分析各种json解析框架的优劣,并给出公司大型项目迁移json框架的几种解决方案。在文章的后半部分,我会结合这一个月的经验,总结下Gson的使用问题,以及fastjson迁移到Gson踩过的深坑。文章目录:fastjson替代方案三种json框架的特点性能对比最终选择方案替换依赖时的注意事项谨慎,谨慎,再谨慎做好开发团队和测试团队的沟通做好回归/接口测试考虑迁移前后的性能差异使用Gson替换fastjsonJson反序列化范型处理List/Map写入驼峰与下划线转换迁移常见问题踩坑Date序列化方式不同SpringBoot异常Swagger异常@Mapping JsonObject作为入参异常注意:是否使用fastjson是近年来一个争议性很大的话题,本文无意讨论框架选型的对错,只关注迁移这件事中遇到的问题进行反思和思考。大家如果有想发表的看法,可以在评论区理性讨论。fastjson替代方案fastjson在2020年频繁暴露安全漏洞,此漏洞可以绕过autoType开关来实现反序列化远程代码执行并获取服务器访问权限。从2019年7月份发布的v1.2.59一直到2020年6月份发布的 v1.2.71 ,每个版本的升级中都有关于AutoType的升级,涉及13个正式版本。fastjson中与AutoType相关的版本历史:1.2.59发布,增强AutoType打开时的安全性 fastjson 1.2.60发布,增加了AutoType黑名单,修复拒绝服务安全问题 fastjson 1.2.61发布,增加AutoType安全黑名单 fastjson 1.2.62发布,增加AutoType黑名单、增强日期反序列化和JSONPath fastjson 1.2.66发布,Bug修复安全加固,并且做安全加固,补充了AutoType黑名单 fastjson 1.2.67发布,Bug修复安全加固,补充了AutoType黑名单 fastjson 1.2.68发布,支持GEOJSON,补充了AutoType黑名单 1.2.69发布,修复新发现高危AutoType开关绕过安全漏洞,补充了AutoType黑名单 1.2.70发布,提升兼容性,补充了AutoType黑名单 1.2.71发布,补充安全黑名单,无新增利用,预防性补充本文主要讨论Gson替换fastjson框架的实战问题,所以在这里不展开详细讨论各种json框架的优劣,只给出结论。经过评估,主要有Jackson和Gson两种json框架放入考虑范围内,与fastjson进行对比。三种json框架的特点FastJson速度快fastjson相对其他JSON库的特点是快,从2011年fastjson发布1.1.x版本之后,其性能从未被其他Java实现的JSON库超越。使用广泛fastjson在阿里巴巴大规模使用,在数万台服务器上部署,fastjson在业界被广泛接受。在2012年被开源中国评选为最受欢迎的国产开源软件之一。测试完备fastjson有非常多的testcase,在1.2.11版本中,testcase超过3321个。每次发布都会进行回归测试,保证质量稳定。使用简单fastjson的API十分简洁。Jackson容易使用 - jackson API提供了一个高层次外观,以简化常用的用例。无需创建映射 - API提供了默认的映射大部分对象序列化。性能高 - 快速,低内存占用,适合大型对象图表或系统。干净的JSON - jackson创建一个干净和紧凑的JSON结果,这是让人很容易阅读。不依赖 - 库不需要任何其他的库,除了JDK。Gson提供一种机制,使得将Java对象转换为JSON或相反如使用toString()以及构造器(工厂方法)一样简单。允许预先存在的不可变的对象转换为JSON或与之相反。允许自定义对象的表现形式支持任意复杂的对象输出轻量易读的JSON性能对比同事撰写的性能对比源码:https://github.com/zysrxx/json-comparison本文不详细讨论性能的差异,毕竟这其中涉及了很多各个框架的实现思路和优化,所以只给出结论:1.序列化单对象性能Fastjson > Jackson > Gson,其中Fastjson和Jackson性能差距很小,Gson性能较差2.序列化大对象性能Jackson> Fastjson > Gson ,序列化大Json对象时Jackson> Gson > Fastjson,Jackson序列化大数据时性能优势明显3.反序列化单对象性能 Fastjson > Jackson > Gson , 性能差距较小4.反序列化大对象性能 Fastjson > Jackson > Gson , 性能差距较很小最终选择方案Jackson适用于高性能场景,Gson适用于高安全性场景对于新项目仓库,不再使用fastjson。对于存量系统,考虑到Json更换成本,由以下几种方案可选:项目未使用autoType功能,建议直接切换为非fastjson,如果切换成本较大,可以考虑继续使用fastjson,关闭safemode。业务使用了autoType功能,建议推进迁移fastjson到别的框架。替换依赖注意事项企业项目或者说大型项目的特点:代码结构复杂,团队多人维护。承担重要线上业务,一旦出现严重bug会导致重大事故。如果是老项目,可能缺少文档,不能随意修改,牵一发而动全身。项目有很多开发分支,不断在迭代上线。所以对于大型项目,想要做到将底层的fastjson迁移到gson是一件复杂且痛苦的事情,其实对于其他依赖的替换,也都一样。我总结了如下几个在替换项目依赖过程中要特别重视的问题。谨慎,谨慎,再谨慎再怎么谨慎都不为过,如果你要更改的项目是非常重要的业务,那么一旦犯下错误,代价是非常大的。并且,对于业务方和产品团队来说,没有新的功能上线,但是系统却炸了,是一件“无法忍受”的事情。尽管你可能觉得很委屈,因为只有你或者你的团队知道,虽然业务看上去没变化,但是代码底层已经发生了翻天覆地的变化。所以,谨慎点!做好开发团队和测试团队的沟通在依赖替换的过程中,需要做好项目的规划,比如分模块替换,严格细分排期。把前期规划做好,开发和测试才能有条不紊的进行工作。开发之间,需要提前沟通好开发注意事项,比如依赖版本问题,防止由多个开发同时修改代码,最后发现使用的版本不同,接口用法都不同这种很尴尬,并且要花额外时间处理的事情。而对于测试,更要事先沟通好。一般来说,测试不会太在意这种对于业务没有变化的技术项目,因为既不是优化速度,也不是新功能。但其实迁移涉及到了底层,很容易就出现BUG。要让测试团队了解更换项目依赖,是需要大量的测试时间投入的,成本不亚于新功能,让他们尽量重视起来。做好回归/接口测试上面说到测试团队需要投入大量工时,这些工时主要都用在项目功能的整体回归上,也就是回归测试。当然,不只是业务回归测试,如果有条件的话,要做接口回归测试。如果公司有接口管理平台,那么可以极大提高这种项目测试的效率。打个比方,在一个模块修改完成后,在测试环境(或者沙箱环境),部署一个线上版本,部署一个修改后的版本,直接将接口返回数据进行对比。一般来说是Json对比,网上也有很多的Json对比工具:https://www.sojson.com/考虑迁移前后的性能差异正如上面描述的Gson和Fastjson性能对比,替换框架需要注意框架之间的性能差异,尤其是对于流量业务,也就是高并发项目,响应时间如果发生很大的变化会引起上下游的注意,导致一些额外的后果。使用Gson替换Fastjson这里总结了两种json框架常用的方法,贴出详细的代码示例,帮助大家快速的上手Gson,无缝切换!Json反序列化String jsonCase = "[{\"id\":10001,\"date\":1609316794600,\"name\":\"小明\"},{\"id\":10002,\"date\":1609316794600,\"name\":\"小李\"}]"; // fastjson JSONArray jsonArray = JSON.parseArray(jsonCase); System.out.println(jsonArray); System.out.println(jsonArray.getJSONObject(0).getString("name")); System.out.println(jsonArray.getJSONObject(1).getString("name")); // 输出: // [{"date":1609316794600,"name":"小明","id":10001},{"date":1609316794600,"name":"小李","id":10002}] // 小明 // 小李 // Gson JsonArray jsonArrayGson = gson.fromJson(jsonCase, JsonArray.class); System.out.println(jsonArrayGson); System.out.println(jsonArrayGson.get(0).getAsJsonObject().get("name").getAsString()); System.out.println(jsonArrayGson.get(1).getAsJsonObject().get("name").getAsString()); // 输出: // [{"id":10001,"date":1609316794600,"name":"小明"},{"id":10002,"date":1609316794600,"name":"小李"}] // 小明 // 小李看得出,两者区别主要在get各种类型上,Gson调用方法有所改变,但是变化不大。那么,来看下空对象反序列化会不会出现异常:String jsonObjectEmptyCase = "{}"; // fastjson JSONObject jsonObjectEmpty = JSON.parseObject(jsonObjectEmptyCase); System.out.println(jsonObjectEmpty); System.out.println(jsonObjectEmpty.size()); // 输出: // {} // 0 // Gson JsonObject jsonObjectGsonEmpty = gson.fromJson(jsonObjectEmptyCase, JsonObject.class); System.out.println(jsonObjectGsonEmpty); System.out.println(jsonObjectGsonEmpty.size()); // 输出: // {} // 0没有异常,开心。看看空数组呢,毕竟[]感觉比{}更加容易出错。String jsonArrayEmptyCase = "[]"; // fastjson JSONArray jsonArrayEmpty = JSON.parseArray(jsonArrayEmptyCase); System.out.println(jsonArrayEmpty); System.out.println(jsonArrayEmpty.size()); // 输出: // [] // 0 // Gson JsonArray jsonArrayGsonEmpty = gson.fromJson(jsonArrayEmptyCase, JsonArray.class); System.out.println(jsonArrayGsonEmpty); System.out.println(jsonArrayGsonEmpty.size()); // 输出: // [] // 0两个框架也都没有问题,完美解析。范型处理解析泛型是一个非常常用的功能,我们项目中大部分fastjson代码就是在解析json和Java Bean。// 实体类 User user = new User(); user.setId(1L); user.setUserName("马云"); // fastjson List<User> userListResultFastjson = JSONArray.parseArray(JSON.toJSONString(userList), User.class); List<User> userListResultFastjson2 = JSON.parseObject(JSON.toJSONString(userList), new TypeReference<List<User>>(){}); System.out.println(userListResultFastjson); System.out.println("userListResultFastjson2" + userListResultFastjson2); // 输出: // userListResultFastjson[User [Hash = 483422889, id=1, userName=马云], null] // userListResultFastjson2[User [Hash = 488970385, id=1, userName=马云], null] // Gson List<User> userListResultTrue = gson.fromJson(gson.toJson(userList), new TypeToken<List<User>>(){}.getType()); System.out.println("userListResultGson" + userListResultGson); // 输出: // userListResultGson[User [Hash = 1435804085, id=1, userName=马云], null]可以看出,Gson也能支持泛型。List/Map写入这一点fastjson和Gson有区别,Gson不支持直接将List写入value,而fastjson支持。所以Gson只能将List解析后,写入value中,详见如下代码:// 实体类 User user = new User(); user.setId(1L); user.setUserName("马云"); // fastjson JSONObject jsonObject1 = new JSONObject(); jsonObject1.put("user", user); jsonObject1.put("userList", userList); System.out.println(jsonObject1); // 输出: // {"userList":[{"id":1,"userName":"马云"},null],"user":{"id":1,"userName":"马云"}} // Gson JsonObject jsonObject = new JsonObject(); jsonObject.add("user", gson.toJsonTree(user)); System.out.println(jsonObject); // 输出: // {"user":{"id":1,"userName":"马云"},"userList":[{"id":1,"userName":"马云"},null]}如此一来,Gson看起来就没有fastjson方便,因为放入List是以gson.toJsonTree(user)的形式放入的。这样就不能先入对象,在后面修改该对象了。(有些同学比较习惯先放入对象,再修改对象,这样的代码就得改动)驼峰与下划线转换驼峰转换下划线依靠的是修改Gson的序列化模式,修改为LOWER_CASE_WITH_UNDERSCORESGsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); Gson gsonUnderScore = gsonBuilder.create(); System.out.println(gsonUnderScore.toJson(user)); // 输出: // {"id":1,"user_name":"马云"}常见问题排雷下面整理了我们在公司项目迁移Gson过程中,踩过的坑,这些坑现在写起来感觉没什么技术含量。但是这才是我写这篇文章的初衷,帮助大家把这些很难发现的坑避开。这些问题有的是在测试进行回归测试的时候发现的,有的是在自测的时候发现的,有的是在上线后发现的,比如Swagger挂了这种不会去测到的问题。Date序列化方式不同不知道大家想过一个问题没有,如果你的项目里有缓存系统,使用fastjson写入的缓存,在你切换Gson后,需要用Gson解析出来。所以就一定要保证两个框架解析逻辑是相同的,但是,显然这个愿望是美好的。在测试过程中,发现了Date类型,在两个框架里解析是不同的方式。fastjson:Date直接解析为UnixGson:直接序列化为标准格式Date导致了Gson在反序列化这个json的时候,直接报错,无法转换为Date。解决方案:新建一个专门用于解析Date类型的类:import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import java.io.IOException; import java.util.Date; public class MyDateTypeAdapter extends TypeAdapter<Date> { @Override public void write(JsonWriter out, Date value) throws IOException { if (value == null) { out.nullValue(); } else { out.value(value.getTime()); } } @Override public Date read(JsonReader in) throws IOException { if (in != null) { return new Date(in.nextLong()); } else { return null; } } }接着,在创建Gson时,把他放入作为Date的专用处理类:Gson gson = new GsonBuilder().registerTypeAdapter(Date.class,new MyDateTypeAdapter()).create();这样就可以让Gson将Date处理为Unix。当然,这只是为了兼容老的缓存,如果你觉得你的仓库没有这方面的顾虑,可以忽略这个问题。SpringBoot异常切换到Gson后,使用SpringBoot搭建的Web项目的接口直接请求不了了。报错类似:org.springframework.http.converter.HttpMessageNotWritableException因为SpringBoot默认的Mapper是Jackson解析,我们切换为了Gson作为返回对象后,Jackson解析不了了。解决方案:application.properties里面添加:#Preferred JSON mapper to use for HTTP message conversion spring.mvc.converters.preferred-json-mapper=gsonSwagger异常这个问题和上面的SpringBoot异常类似,是因为在SpringBoot中引入了Gson,导致 swagger 无法解析 json。采用类似下文的解决方案(添加Gson适配器):http://yuyublog.top/2018/09/03/springboot%E5%BC%95%E5%85%A5swagger/GsonSwaggerConfig.java@Configuration public class GsonSwaggerConfig { //设置swagger支持gson @Bean public IGsonHttpMessageConverter IGsonHttpMessageConverter() { return new IGsonHttpMessageConverter(); } }IGsonHttpMessageConverter.javapublic class IGsonHttpMessageConverter extends GsonHttpMessageConverter { public IGsonHttpMessageConverter() { //自定义Gson适配器 super.setGson(new GsonBuilder() .registerTypeAdapter(Json.class, new SpringfoxJsonToGsonAdapter()) .serializeNulls()//空值也参与序列化 .create()); } }SpringfoxJsonToGsonAdapter.javapublic class SpringfoxJsonToGsonAdapter implements JsonSerializer<Json> { @Override public JsonElement serialize(Json json, Type type, JsonSerializationContext jsonSerializationContext) { return new JsonParser().parse(json.value()); } }@Mapping JsonObject作为入参异常有时候,我们会在入参使用类似:public ResponseResult<String> submitAudit(@RequestBody JsonObject jsonObject) {}如果使用这种代码,其实就是使用Gson来解析json字符串。但是这种写法的风险是很高的,平常请大家尽量避免使用JsonObject直接接受参数。在Gson中,JsonObject若是有数字字段,会统一序列化为double,也就是会把count = 0这种序列化成count = 0.0。为何会有这种情况?简单的来说就是Gson在将json解析为Object类型时,会默认将数字类型使用double转换。如果Json对应的是Object类型,最终会解析为Map<String, Object>类型;其中Object类型跟Json中具体的值有关,比如双引号的""值翻译为STRING。我们可以看下数值类型(NUMBER)全部转换为了Double类型,所以就有了我们之前的问题,整型数据被翻译为了Double类型,比如30变为了30.0。可以看下Gson的ObjectTypeAdaptor类,它继承了Gson的TypeAdaptor抽象类:具体的源码分析和原理阐述,大家可以看这篇拓展阅读:https://www.jianshu.com/p/eafce9689e7d解决方案:第一个方案:把入参用实体类接收,不要使用JsonObject第二个方案:与上面的解决Date类型问题类似,自己定义一个Adaptor,来接受数字,并且处理。这种想法我觉得可行但是难度较大,可能会影响到别的类型的解析,需要在设计适配器的时候格外注意。总结这篇文章主要是为了那些需要将项目迁移到Gson框架的同学们准备的。一般来说,个人小项目,是不需要费这么大精力去做迁移,所以这篇文章可能目标人群比较狭窄。但文章中也提到了不少通用问题的解决思路,比如怎么评估迁移框架的必要性。其中需要考虑到框架兼容性,两者性能差异,迁移耗费的工时等很多问题。希望文章对你有所帮助。参考《如何从Fastjson迁移到Gson》https://juejin.im/post/6844904089281626120《FastJson迁移至Jackson》此文作者自己封装了工具类来完成迁移https://mxcall.github.io/posts/%E5%B7%A5%E4%BD%9C/%E7%A8%8B%E5%BA%8F%E5%91%98/javaSE/FastJson%E8%BF%81%E7%A7%BB%E8%87%B3Jackson/《你真的会用Gson吗?Gson使用指南》https://www.jianshu.com/p/e740196225a4json性能对比https://github.com/zysrxx/json-comparison/tree/master/src/main/java/json/comparisonfastjson官方文档https://github.com/alibaba/fastjson/wiki易百教程https://www.yiibai.com/jackson
2023年01月
2022年05月