今天给大家带来的分析案例是springboot集成的程序健康检测案例,首先是基于springboot1.5.13版本,其次主要分析的包如下图所示。
之所以要分析这块内容,其实还是由于工作上导致的,前段时间,运维想要让我们在程序种加入一个可以访问程序状态的路径,以便于运维检测程序,然后springboot也自带了这个功能,所以我就直接使用了,但是使用的过程种,发现了一个问题,如下图所示。
显示我的db的状态为unknown,这我就瞬间来精神了,凭啥我得db就是unknown状态,难道不配显示信息吗?当然这是玩笑之话,为啥显示未知状态,肯定还是由程序判断的结果,至于原因,我们接下来具体分析这一块内容,也会顺带分析到整个健康检测的一些核心机制功能实现点。
至于如何引入spring健康检查,在boot的情况下,下面俩张图估计大家都应该明白了。
怀着好奇心态的我,对引入jar没啥兴趣,但是我有点对这个配置感兴趣,我怀着试试的心态,直接把这个配置给删除了,然后重新访问了/health路径,如下图所示。
好家伙,还有这么一手,配置不配置依旧还会显示信息,但是显示的信息不一样,于是我们带着疑问进行分析去了。
首先我们分析下这个配置究竟是干嘛的,根据spring自带的配置提示,如下图所示
我们找到了配置的类在这里,所有在yml中配置的信息都会注入到这个类中。关于配置信息,我们先简单分析这到,后续会有关联点。
接下来我们分析/health这个路径,大家都知道,既然我能通过http访问这个/health,说明他在spring容器中肯定存在一个控制器,但是我们并没有自己去写这个控制器,由此猜测可能是spring自己注册的,这里就有点小麻烦了,如果我们自己写的话找起来还比较好找,因为直接使用idea搜索或者包都浏览一遍,但是spring自己注册的话,就不可控了,鬼知道他是怎么注册进去的,我们先试着使用idea全局搜索试一下:
我们发现了这个使用点,但是经过排查,发现并不是我们要找的。貌似这样我们又陷入了黑暗,感觉前途一片黑暗,spring源码分析之路宣告封闭,总不能把spring所有类都看一遍找找在哪注册了这个/health,估计看完头发都掉完了。各位莫着急,我这里教大家俩招,保证手到擒来:
第一种方式:
观看spring启动日志,
会有这么一行数据
2021-01-15 09:59:29 [main] [org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry:543] - Mapped "{[/health || /health.json],methods=[GET],produces=[application/vnd.spring-boot.actuator.v1+json || application/json]}" onto public java.lang.Object org.springframework.boot.actuate.endpoint.mvc.HealthMvcEndpoint.invoke(javax.servlet.http.HttpServletRequest,java.security.Principal)
到这里估计大家都看明白了,spring启动大多数情况下会默认输出所有控制器的映射信息,包括对应的handler,上面的信息告诉我们,这个/health对应的控制器为HealthMvcEndpoint,我们一会再去分析这个类。接下来看第二种方式。
第二种方式:
debug源码,这个需要对springmvc相关源码比较熟悉的人适用,大家都知道,我们spring有一个核心servlet就是dispatcherservlet,所有的映射控制器处理,都要经过他转发,因此我们直接去到这个类。
在第940行打上断点,然后使用postman或者浏览器发起请求,就会自动跳转到这个断点上,至于如何定位到doDispatcher以及这个mappedHandler,详细过程需要结合springmvc部份的源码以及梳理,这里就暂不深究了,有兴趣的小伙伴们可以私信或者留言告诉我,我抽时间可以安排一下,你懂得!
好了,俩种方式大致上已经告诉小伙伴们了,这里再说一点,貌似还有一种方式可以使用接口请求输出所有映射的详细信息,这里也不深究了,这俩种方式不仅仅限于本篇文章的用途,以后你们如果也想去寻找某个映射或者控制器,都可以使用这俩种方式。还是比较实用的。
接下来,我们重点去分析这个HealthMvcEndpoint类。
我们可以看到invoke方法上使用的是AcutorGetMapping,本质上还是属于RequestMapping的一种,因此这个invoke方法肯定是我们需要过一遍的,首先我们先简单分析
!getDelegate().isEnabled())
private static final String ENDPOINTS_ENABLED_PROPERTY = "endpoints.enabled";
我们并没有配置上面这个变量,他也没有默认的属性值在容器中,因此这个值肯定不存在的,所以在第73行判断条件为false,默认返回return true。所以不会走if里面的语句。
进入getHealth方法中,发现会调用getCurrentHealth,在这个方法中,sprng做了一个缓存机制,把得到的cachedHealth缓存了起来,并且有一个时间过期机制。第一次调用的时候这个cachedHealth肯定是null,因此我们需要分析getDelegate().invoke()方法,getDeleagte()方法属于超类中的方法,会返回一个泛型的delegate对象,我们简单看下超类的结构
有很多子类都实现了这个超类,分别提供不同的功能,我们这次研究的HealthMvcEndpoint就属于其中之一,并且这个超类中还有一个泛型变量delegate,这个也是在类实例化阶段需要填充的,这个我们稍后在分析,这个泛型变量具体类型基于子类的实现方式,我们看到子类HealthMvcEndpoint中明确了泛型为HealthEndpoint类型,接下来接续分析delegate.invoke()方法
可以看到healthIndicator是在构造函数中进行初始化的,老样子,继续走主流程,稍后在分析这块如何初始化的,这个类名是CompositeHealthIndicators,看类名就是综合健康检查的意思,一目了然。
继续分析health()方法
我们可以看到这个indicators是由一个map组成,value存的是所有spring集成的第三方中间件的健康检查的控件类,有redis,db,mail,config等等,然后for循环这个indicators,在healths中会放入各个中间件的一些健康信息,最后调用healthAggregator进行聚合处理。我们先简单看一个redis的健康检查控件
org.springframework.boot.actuate.health.RedisHealthIndicator
我们进入到这个类中
这个类比较简单,就一个核心方法doHealthCheck,了解spring源码的人都应该清楚,像这种方法名一看就是被调用的,而且绝大多数是在本类,但我们这个本类没有其他方法,而且这个方法是重写的,因此我们去超类中看看结构。
我们看到了超类中定义了一个抽象方法,并且在health()方法中进行调用,这个health方法就是上面CompositeHealthIndicators类中进行重写的health方法中进行for循环调用的地方。当调用到RedisHealthIndicator的health方法的时候,会默认调用超类的health方法,然后通过重写的方法调用到子类的doHealthCheck,这是典型的模板方法设计模式。其实设计模式也挺实用的,虽然我也不是很清楚每种场景的设计模式。
我们重点分析doHealthCheck,首先方法通过redisConnectionFactory获取一个redisconnection,如果获取到了则说明redis状态一且正常,且可以获取redis一些版本以及其他信息,这里大家看到connection做了类型判断,判断是集群模式还是单机模式,不同的类型走不同的处理逻辑,我们这边不研究这个了。这里需要注意的一个点是,我们并没看到down的处理逻辑,而且我们应该了解如果connection获取不到,肯定会报socket连接异常,但是这里异常虽然try了,并没有catch,因此异常肯定会往上层代码抛出,我们去看超类的处理
try {
doHealthCheck(builder);
}
catch (Exception ex) {
this.logger.warn("Health check failed", ex);
builder.down(ex);
}
一且都很清晰明了了,这里不但会打印日志,还会将这个中间件标记为down状态。
redis的其实并没有什么难度,当我准备分析db的时候,也觉得大致一样,但是真正分析的时候还是有点不同的,还是比较有趣的,我们带着db为啥是unknown的状态的疑问,接下来我们重点分析db的健康检查原理。
分析前我们先看上面一张图,我们会发现这个linkedhashmap中的db的value明显和别的不一样,上面我们已经分析过了redis的健康检查控件就是RedisHealthIndicator,但是这个db的却是CompositeHealthIndicator,再看下面这张图
org.springframework.boot.actuate.health.DataSourceHealthIndicator
明明是有db的专属控件的,为什么这里health方法中却不是呢,通过查看DataSourceHealthIndicator在哪被初始化,如下图所示进行研究
我们看上面这个很重要的类,org.springframework.boot.actuate.autoconfigure.HealthIndicatorAutoConfiguration
顾名思义,这个类是基本所有spring集成中间件健康检查的自动装配类。这时候有人会问我,你怎么找到这个类的呢,总不能一个个去看吧,其实很简单,我们把鼠标放在DataSourceHealthIndicator类名上,使用idea的find usages(alt+f7)就可以知道这个类在哪里被调用、使用、初始化等等。
我们还是继续分析这个HealthIndicatorAutoConfiguration,它由众多静态内部类组成,基本每个静态内部类都是一个中间件的健康检查装配类,我们看上面关于db的装配类,在重点分析第222行@bean注解的方法之前,我们先看下第193行这个db静态内部类的构造方法,因为这个有个属性的初始化跟后面的分析有关,构造方法中,它初始化了俩个属性如下:
this.dataSources = filterDataSources(dataSources.getIfAvailable());
this.metadataProviders = metadataProviders.getIfAvailable();
我们主要看第一个,dataSources的初始化,调用了filterDataSources方法,传参是
dataSources.getIfAvailable(),类型是ObjectProvider<Map<String, DataSource>> dataSources
关于这种ObjectProvider类型的参数,其实是spring独特的一个注入方式,我们这里不深究了,以后有机会在讲,这个变量的主要作用就是spring容器初始化这个构造函数的时候,会把容器中所有dataSource的对象注入到这个容器中,key就是dataSource的beanName,value就是dataSource。
因此我们看filterDataSources方法,正常程序中一般都会有dataSource的对象,所以第202行不会成立,继续往下看,第206,207行判断这个map的value是否是AbstractRoutingDataSource的子类,关于
org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
有的小伙伴可能不太了解,这个是spring多数据源的一个超类。如果你的项目中,需要使用多数据源,那么这个类你必然会用到,而我这个程序中也正好是使用了多数据源,因此当程序走到这一步的时候,不会走if分支,直接返回一个空的数据源map给类的私有属性。
说完了上面类的构造方法,我们继续看被@bean注解的dbHealthIndicator方法,接触过springboot的都知道,所有含有@bean的方法,只要被spring容器扫描到,那么在容器初始化阶段的时候,就会去解析这个bean注入到容器中,这个方法会调用createHealthIndicator方法,这个方法是来自于超类
这个类一共就俩个方法,并且是重载的,一个接受map参数返回HealthIndicator,一个接受泛型变量返回泛型对象。
第二个方法比较复杂,因为在dbHealthIndicator中调用的createHealthIndicator传的参数是一个map,所以我们这里只需看第一个,首先是if条件,上文我们也知道了,多数据源的dataSource是不会放入到map中的,因此在这里不会走if,直接new一个CompositeHealthIndicator,参数是HealthAggregator,且下面的for循环也没有任何意义,直接会跳过,返回这个composite,这样我们db的健康检查控件就初始化完毕了,不太像我们之前redis分析的那样,简单明了,这里貌似弯弯绕绕比较多,对于这个CompositeHealthIndicator,我们貌似有点眼熟,上文貌似是在HealthEndpoint中有这个,为啥db也是这个呢
我们再来看下这个类,其实这里spring相当于刷了一个小聪明,它复用了这个类,在无法正常初始化中间件的控件的时候,就像上面db的datasource为空的时候,他就默认初始化一个CompositeHealthIndicator,我们看这个health方法,和之前分析这个health不同,我们这里indicators为空,所以不会走for循环,所以我们看下第70行方法,传入的是一个空的healths,
这里aggregate方法里面处理了这个healths变量,37行因为空的map不会走,所以他会调用抽象方法,aggregateStatus方法,入参是一个空的集合
这个抽象类默认就一个子类,这个子类重写了aggregateStatus,首先因为入参是空集合,所以第一个for循环不会走,到if语句的时候,因为这个方法内部变量依旧是空集合,所以条件成立,返回状态为unknown状态。所以这就解释了为啥我程序中db状态为啥是unknown状态。到此为止我们梳理下整个的调用链路:
首先是HealthMvcEndpoint调用了invoke方法,而invoke最终调用了HealthEndpoint中的invoke方法,然后在invoke方法中调用healthIndicator变量进行for循环所有中间件健康检查控件类的health方法,如果能正常初始化的控件就会正常显示状态up或者down(如redis),如果不能正常初始化的则会默认赋值一个控件,如多数据源情况下的db控件,则绝大多数都会返回unknown状态。
文章到这里,开始的问题已经分析出了因果,我们在分析问题的时候还留下了几个其他疑问,分别如下:
1、ManagementServerProperties类的配置究竟有何作用?(为什么配置了就会显示更多信息,不配就只显示一个状态)
2、HealthMvcEndpoint类中delegate是如何初始化的?
3、HealthEndpoint中的healthIndicator是如何初始化的?
我们一个个来分析,首先第一个问题:
我们看到Health类中包含了status以及details,status就是状态,而details是各个中间件的详细信息,就像一开始文章所示的请求返回信息一样,包含redis的版本信息,磁盘信息等。上面getHealth方法中有个exposeHealthDetails方法,如果这个方法返回true则是返回详细信息,如果false,则看下面的构造只返回status。因此我们需要看下这个方法,
这个方法首先先判断this.secure,如果是false直接返回true,如果是true则走下面。/health要想返回详细信息这里一定得是false,我们看下这个secure是在那里初始化得。
首先是在构造函数中被初始化的,我们接下来看构造函数被谁调用
这里注意看构造函数的参数其中是由一个managementServerProperties.getSecurity().isEnabled()传入的,我们点进去看下
原来我们在yml中配置的属性,最终都会注入到这个类中,并且在HealthMvcEndpoint类初始化的时候一并传入过去,然后处理相关逻辑的时候会使用到这些属性。这就解释了我们第一个问题。
第二个问题,HealthMvcEndpoint类中delegate是如何初始化的?
我们看到healthMvcEndpoint构造函数中传入这个delegate,经过排查,发现在@bean方法中进行初始化的,
再通过上面这张图我们很清晰就能明白,这个delegate本身也会作为一个bean放入到容器中,然后作为构造参数注入到别的类中进行调用。因此第二个问题就分析完了。
第三个问题,HealthEndpoint中的healthIndicator是如何初始化的?
我们看上面图示,第56行new了一个默认的CompositeHealthIndicator,然后经过一个for循环处理,填充了一下healthIndicator的内部变量indicators,因此我们的重点是这个构造函数的healthIndicators变量。
可以看到这个构造函数参数是由this的一个变量传递的,经过上面的分析,这里不是空,所以肯定是有值的,
这个this.healthIndicators是由ObjectProvider类调用getIfAvailable方法得到来的,这个方法我们上面分析过,其实这是spring常用的一种注入方式,结果就是能够到所有的HealIndicator的bean的map对象,key为beanname,value为bean,这里也不深究了,如果有想了解这块知识的可以私信留言告诉我,我到时候整理讲解一下。
因此关于前面遗留的三个问题我也间接的回答完了,你们也可以自己去尝试分析一下,看一下是否如上所说。这一期的案例分析就说到这里了。
这篇文章还是让我花了不少时间去书写和思考的,如果有喜欢的小伙伴一定要点击收藏点赞哦,你们的赞扬是我继续的动力,哈哈,客套话了。想关于这篇文章讨论的可以在下方留言,我看到了随时会回复的。
写在文后:
关于下篇文章,我准备写一篇关于springboot初始化相关的文章,主要还是针对于问题而去分析的,我这里可以先抛出问题,留给各位去思考:
如果spring中有一个bean,我们自己也去定义了,为什么springboot会默认先初始化我们的类?
关于这个问题的讨论也可放在下方去留言。。。。。。。