spring|Spring 启动慢(提速利器 SpringFu 来了)

spring|Spring 启动慢(提速利器 SpringFu 来了)
文章图片

函数是应用在Serverless世界里的一种极轻量形态,每个函数通常专注提供单一功能的服务。它们相互串联,井然有序,时而成群结队的出现,在完成既定任务后又很快的消失,此起彼伏,辉映成趣,将云的灵巧与飘逸发挥得淋漓尽致。
与此同时,扎根在中后台辛勤耕耘了十多年的Spring,早已是一位心宽体阔、中年发福了的明星大叔。倘若将他邀请到函数的舞台上,那就成了绝佳的娱乐新闻题材。其实Spring本尊也确曾出演过一部收视率不太高的“古装情景喜剧”《Spring Cloud Function 1.0》,尽管使出了反应式(Reactive)架构、云平台集成等等经典绝技,无奈臃肿的身材难以跟上轻快的舞步,被Nodejs和Python等晚辈小生调戏得窘态频现。于是,Spring立下宏志,即刻减肥,不瘦30斤,不回江湖。如今,Spring终于功成归来,不仅习得新技能Functional Bean,还为社区带来了两位新伙伴:SpringInit和SpringFu。
根据非官方实验数据,将SpringMVC应用改造为SpringFu结构,启动时间大约能缩短50%。这点提速当然不算瞩目,然而SpringFu的关键价值在于,它使Spring框架能够摆脱JVM动态特性束缚,从而适配AOT编译模式。倘若搭配上GraalVM编译器,应用启动速度就能直线下降到原先的大约1%,彻底甩掉“起跑永远慢半拍”的帽子。
一 Spring 5.0的创新 关于SpringFu的故事,还得从Spring 5.0版本说起。
纵观Spring的演化过程,Bean对象的定义方式是一条脉络清晰的主线。早期“SSH时代”的Spring框架只能使用xml文件定义Bean,写一个Java项目,一半是xml文件。即便如此,Spring 1.0依然带领着这支“尖括号加代码”大军击溃了更加笨拙繁琐的EJB框架。平定天下以后,Spring内部开始了自我进化,Spring 2.0推出了基于注解的Bean定义机制,逐步替换过去的xml文件。最终在SpringBoot项目的强力助攻下,注解全面取代xml,完成大国一统。眼看太平盛世刚刚到来,可就在最近几年里,Spring社区又刮起了一股更年轻的“去注解”风潮。
这股风潮的兴起与云原生以及GraalVM项目蓬勃发展有着不可忽视的联系。了解过AOT编译的读者应该知道,反射、动态代理等运行时特性都是妨碍Java代码进行AOT编译的首要因素,然而基于注解的Bean扫描过程恰恰大量依赖了这些Java动态特性。这就相当于说,曾经代表着先进生产力的Spring注解,现在正在成为制约Spring继续演进的束脚石。
事已至此,Spring自个也不含糊。“注解不要也罢,直接暴露接口”,2017年底,正赶在Spring 5.0新品发布会的节骨眼上,Spring当机立断的拿出了布局未来的一张牌:Functional Bean。
发展才是硬道理。
这项肩负重任的Functional Bean究竟是何神技呢?不瞒您说,其实关键的修改就是加了一个成员方法。
对于Spring系统而言,IoC容器是其中最核心的部分,在代码上就是BeanFactory和各种ApplicationContext。这当中,最功绩显赫的要数能从xml文件装载Bean的ClassPathXmlApplicationContext,和能从代码上下文扫描Bean的
AnnotationConfigApplicationContext,它俩的光辉事迹早已在网上被广泛传诵,无需多述。比较有意思的地方在于,若从继承关系来看,这两位老爷子顶多也就算得上是远房亲戚,虽然都继承自ApplicationContext,在中间隔却了好几代血缘。而与Functional Bean相关的“改进”发生在AnnotationConfigApplicationContext的父类,也就是GenericApplicationContext类型里。
spring|Spring 启动慢(提速利器 SpringFu 来了)
文章图片

在Spring的历史上,GenericApplicationContext并不是一位经常抛头露面的IoC容器。据文档记载,一直到Spring 5.0之前,GenericApplicationContext的适用场景都是配合其他DefinitionReader对象完成非标准来源的Bean注册,比如从properties文件加载Bean定义。它有一个registerBeanDefinition()方法,但实际的代码实现却只是将传入的Bean定义直接转交给代管的beanFactory对象。
就是这么个低调的小副手,在Spring 5.0里忽然被赋予了如同AnnotationConfigApplicationContext一样独立加载和操控Bean的权力,就像78岁高龄的前副总统被提名大选,虽是情理之中,却也有些意料之外。新增加的方法叫做registerBean(),共有6种重载,其中前5种都是最后1种的参数简化版本,因此本质上就是一个方法,其完整定义如下:

void registerBean(String beanName, Class beanClass, Supplier supplier, BeanDefinitionCustomizer... customizers)

一共4个参数:
  • beanName:给Bean取的名字
  • beanClass:注册Bean的类型
  • supplier:关键参数,用于生成Bean对象
  • customizers:可选参数,对生成的Bean对象进行配置
其中supplier和customizers参数所属类型都是只有一个方法的接口,在Java 8以上版本里,这种接口参数可以直接传入Lambda函数作为匿名类来使用。例如下面这个Bean定义:
context.registerBean("myService", MyService.class, () -> new MyService(),// supplier参数,定义Bean对象的创建方法 (bean) -> bean.setLazyInit(true)// customizers参数,配置Bean );

用Lambda函数定义Bean,没有注解,没有反射,没有动态特性,这就是Functional Bean。
由于AnnotationConfigApplicationContext本身也是一种GenericApplicationContext,因此在代码里访问它一点也不困难。比如继承ApplicationContextInitializer接口,然后通过其提供的initialize()回调方法参数拿到GenericApplicationContext对象。或者在Spring容器里的任意地方用@Autowired注解直接拿到全局GenericApplicationContext对象等等。然而对于早已习惯了“Spring == 各种注解”的现有开发者来说,相比写@Component、@Configuration等等注解,要自己获取应用容器,再调用registerBean()方法来注册Bean,适应成本实在有点高。即使在最适用的Serverless函数场景下,愿意折腾的开发者早就投奔了Nodejs阵营,不愿折腾的开发者继续Spring注解将就用,这种“丑陋”的Bean注册方式即便官方博客多次宣传,在发布过后的近一年里依然几乎无人问津。
眼看不温不火的Spring Cloud Function 2.0同样难以扛起推广Functional Bean的大旗,Spring此时亟待一位像当年SpringBoot那样席卷全球的网红节目来反转自己在Serverless战线上一度低迷的票房。为此,一个崭新的项目,SpringFu出现在了大家的视线里。
二 SpringFu vs SpringMVC SpringFu项目的发起人Deleuze先生来自法国,已经为Pivotal公司的Spring团队效力超过6年,我猜他大概是位中国迷。根据项目作者的阐述,Fu有三种含义,首先是Functional的前两个字母缩写,其次是来源于单词Kong-Fu(功夫),最后则是谐音中文的“赋”(函数声明式的定义写起来像是有节奏韵律的诗歌)。
这款项目的设计也正如其名所述,将Spring Bean的定义过程编制得如同有行云流水般的顺滑。来看个例子:
public class Application { public static void main (String[] args) { JafuApplication jafu = webApplication(app -> app.beans(def -> def .bean(DemoHandler.class) .bean(DemoService.class)) .enable(webMvc(server -> server .port(server.profiles().contains("test") ? 8181 : 8080) .router(router -> { DemoHandler handler = server.ref(DemoHandler.class); router.GET("/", handler::hello) .GET("/api", handler::json); }).converters(converter -> converter .string() .jackson())))); jafu.run(args); } }

相比我们印象中Spring项目里东一个@Service西一个@Controller的松散型结构,基于SpringFu编写的代码非常紧凑,信息密度极高。事实上SpringFu的大部分核心能力依然直接来自于Spring的各个子项目,但它与SpringMVC项目用各种注解区分不同Bean的方式完全不同,SpringFu让用户显式的调用不同的注册接口来将所需的不同Bean对象注册到Spring上下文容器,整个机制完全不依赖反射和其他Java动态特性。因此只要用户自己没有故意使用Java动态语法,采用SpringFu编写的程序就能天然支持GraalVM的AOT编译,生成启动速度极快的二进制文件。其效果要比提供大量运行时信息给GraalVM编译器更简洁而显著,这也是SpringFu能带来近百倍提速的主要原因。
为了更直观的感受SpringFu这种声明式代码的独特Feeling,下面以SpringMVC的几种典型注解为线索,对比一下二者的差异。
首先是普通的Bean定义,在SpringMVC通过@Configuration类注解和@Bean方法注解来表示。
@Configuration public class MyConfiguration {@Bean public Foo foo() { return new Foo(); }@Bean public Bar bar(Foo foo) { return new Bar(foo); } }

在SpringFu里对应的是ConfigurationDsl类型的beans方法,该方法接收一个Consumer接口对象作为参数,按照惯例,Consumer接口的实现通常采用Lambda方法来定义,因而在代码中不会显式的见到BeanDefinitionDsl的身影。
ConfigurationDsl config = beans(def -> def .bean(Foo.class) .bean(Bar.class) // 隐含使用构造函数注入其他Bean )

在实际的代码里,一般也极少出现单独的ConfigurationDsl,它总是以Consumer的形式出现,并且隐藏在Lambda方法里面被传递给需要它的对象。
像SpringMVC中使用@Component、@Service等注解的地方,在SpringFu里的处理方法与前例是相同的,只需将原类型的注解去掉,然后通过beans()方法来注册。例如下面这两个定义:
@Component public class XxComponent { // ... }@Service public class YyService { // ... }

在SpringFu里大致是这个样子:
public class XxComponent { // ... }public class YyService { // ... }beans(def -> def .bean(XxComponent.class) .bean(YyService.class) )

稍有特殊的是@Controller注解(以及@RestController注解),由于它是业务请求的入口,与API的路由息息相关,因此在SpringFu中有专门的DSL类型与之对应,比如WebMvcServerDsl和WebFluxServerDsl,它们提供诸如port()、router()等方法来定义与HTTP监听相关的属性。例如这两个SpringMVC的接口:
@RestController @RequestMapping("/api/demo") public class MyController {@Autowired private MyService myService; @GetMapping("/") public List findAll() { return myService.findAll(); }@GetMapping("/{id}") public Data findOne(@PathVariable Long id) { return myService.findById(id); } }

在SpringFu里,通常将处理请求的入口类命名为Handler(而不是Controller,这种命名更符合函数定义的惯例),如果将上述代码“直译”为SpringFu结构,大概会长这样:
public class MyHandler {private MyService myService; public MyHandler(MyService myService) { this.myService = myService; }public List findAll() { return myService.findAll(); }public Data findOne(ServerRequest request) { val id = request.pathVariable("id"); return myService.findById(id); } }router(r -> { MyHandler handler = server.ref(MyHandler.class); r.GET("/", handler::findAll) .GET("/{id}", handler::findOne); }

实际更符合惯例的SpringFu写法则是将MyHandler类型本身也匿名掉,这样上述代码可以进一步精简成:
router(r -> { MyService myService = server.ref(MyService.class); r.GET("/", myService::findAll) .GET("/{id}", request -> { return myService.findById(request.pathVariable("id")); }); }

这种流畅的声明式代码对于天生小巧的Serverless函数十分契合,尤其搭配上同样简洁的Kotlin语言时,往往仅需一个文件就能完成许多中等复杂程度函数的定义。
不过从SpringFu的业务范围来看,它的目标并非替代SpringMVC。在Spring布局的Functional Bean蓝图里,SpringFu更像是一支精锐的突击小分队,专攻以Serverless函数为典型的新型轻应用场景。对于Spring框架的传统优势领域,中后台大型服务而言,把所有Bean集中定义在一个地方毕竟过于理想。在通往Serverless的道路上,其实SpringFu并不孤单,它还有一个姐妹项目叫SpringInit,该项目立足于通过编译期代码增强,将用户编写的SpringMVC注解偷偷换成Functional Bean的方式注册,从而实现大型JVM服务的Serverless适配。而这个同样异想天开的项目作者正是大名鼎鼎的SpringBoot和SpringCloud项目创始人Dave Syer。由于篇幅所限,本文不对SpringInit项目再做展开,有兴趣的同学可移步Github围观。
三 源码解读 在设计之初,SpringFu就是奔着DSL(特定领域语言)的思路去的,项目分为JaFu(Java-Fu)和KoFu(Kotlin-Fu)两个部分,分别对应了符合Java和Kotlin语言语法的新型高效Spring服务编写方式。由于开发人手不足,其中JaFu子项目在0.1.0版本后曾暂停过一段时间,代码也从仓库里移除了,后来社区呼声强烈,在0.3.0版本里又再次复出。两者的本质原理大差不差,以下的源码分析以JaFu的最新发布版本v0.4.3作为参考。
项目结构十分简洁,一共3个模块:
autoconfigure-adapter:公共的ApplicationContextInitializer对象
jafu:SpringFu的Java DSL实现
kofu:SpringFu的Kotlin DSL实现
其中autoconfigure-adapter模块被jafu和kofu共用,它实现了许多ApplicationContextInitializer对象,这些对象用于在SpringBoot初始化过程中通过Functional Bean机制预注册某些系统的Bean。这当中,有些是为了从而加速服务的启动过程,比如ServletWebServerInitializer中注册的TomcatServletWebServerFactoryCustomizer,在这里直接注册会比让SpringBoot自己去扫描快许多;有些是为了改变服务行为,比如在JacksonJsonConverterInitializer会注册一个名称为mappingJackson2HttpMessageConverter的Bean,它会影响json对象通过HTTP接口返回时的序列化方式。总之这个模块属于针对函数场景的Spring定制优化,涉及很多Spring内部细节,我们点到为止。
在jafu模块下的源文件并不多,放在顶层目录最显眼位置的JaFu.java是用户程序的发动机,提供了进入SpringFu世界的三种入口方法application()、webApplication()和reactiveWebApplication()。它们负责创建出Spring的ApplicationContext容器,然后将其包装成一个匿名的JafuApplication对象返回,在之后在各种DSL里传递的context成员都是这个容器的引用。
几种入口的区别在于创建的ApplicationContext容器类型,application()生成的是原始的GenericApplicationContext类型容器(能够提供Functional Bean所需的registerBean()方法的最基础容器类型),而webApplication()和reactiveWebApplication()生成的是功能更丰富的ServletWebServerApplicationContext和ReactiveWebServerApplicationContext容器,它俩都来自SpringBoot项目,从这里已经可以看出,SpringFu麻雀虽小,却是站在巨人肩膀上起飞的。
在入口方法上还有一个关键细节,是创建JafuApplication对象时候要接收一个ApplicationDsl类型的参数。这个ApplicationDsl是SpringFu整套DSL机制的总指挥舱,里面琳琅满目的装载着其他所有DSL元素。
至此,SpringFu的外观轮廓已经出来了。任何SpringFu程序的最外层代码都可以概括成下面这种三部曲模式:
application(// 构造,也可以是webApplication()或reactiveWebApplication() ApplicationDsl// 配置 ).run()// 启动

第一步“构造”完成,接下来是内容最丰富的一个部分:“配置”。
从继承关系来看,ApplicationDsl是一种ConfigurationDsl,而ConfigurationDsl以及其他各种DSL元素都来自AbstractDsl。
spring|Spring 启动慢(提速利器 SpringFu 来了)
文章图片

在所有类型中,孤零零的LoggingDsl是唯一没有继承AbstractDsl的漏网之鱼。作为SpringFu项目里最简单的DSL元素,去掉空行和注释,LoggingDsl类型的有效代码只有20行。不过,即便如此特立独行,在LoggingDsl身上依然保留着一项与其他DSL元素相同的特征:构造方法接收以自身类型为模板的Consumer对象作为参数。
LoggingDsl(Consumer dsl) { dsl.accept(this); }

构造函数里只有一行dsl.accept(this),这行代码在所有DSL元素里都会出现。只是在继承了AbstractDsl类型的DSL元素中,它是被放在实现initialize抽象方法的地方,而LoggingDsl类型没有继承过来的initialize方法,就直接将它摆在构造函数里了。“把自己传递给构造方法接收的Consumer对象”,关于DSL的这个神秘行为,我们在后面讲“启动”的环节里再来解释。
回到ApplicationDsl上来,这个类型只是在ConfigurationDsl的基础上,通过一个MessageSourceInitializer对象额外注册了几个Spring自用的Bean,主要功能都是直接继承自ConfigurationDsl。再看ConfigurationDsl类型,这里有几个比较常用的方法:
  • configurationProperties(Class clazz):注册属性配置类,相当于@ConfigurationProperties注解。
  • logging(Consumer dsl) : 提供函数的输出日志配置。
  • beans(Consumer dsl) :提供定义Bean的地方,相当于@Configuration注解。
  • enable(Consumer configuration) → 为其他DSL提供扩展能力的万能配置入口,比如增加Web监听。
为了符合流式声明结构的要求,这些方法都返回ConfigurationDsl类型,并且使用使用return this让下一个配置可以串联起来。然后就可以写出像下面这样的配置代码:
conf -> conf .beans(...) .logging(...) .enable(...);

beans()方法接收的是一个消费BeanDefinitionDsl的匿名函数,这个DSL提供bean()方法,其效果类似SpringMVC程序里的@Bean注解,但在内部会通过GenericApplicationContext的registerBean()方法直接注册Bean到IoC容器,没有反射和扫描的过程。logging()方法接收一个消费LoggingDsl的匿名函数,后者提供level()方法,可以动态调整任意包路径的输出日志级别。enable()方法需要结合其他DSL元素一起使用,其用途非常广泛,比如开头示例里的webMvc()方法会返回一个WebMvcServerDsl对象,可以配置HTTP监听、路由等属性并通过该DSL对象自动注册相关的Bean到Spring上下文。
在整个ApplicationDsl对象定义完以后,就进入到最后的一个环节“启动”了。前面提到过,application()接收一个ApplicationDsl对象,会返回一个JafuApplication对象。接下来就要调用这个返回对象里的点火器方法run()。
SpringFu的DSL是声明式的,开发者通过ApplicationDsl对象定义的所有配置信息,此时都还藏在ApplicationDsl自己的肚子里,真正的Spring容器里面依然空空如也。在JafuApplication的run()方法里,SpringFu创建出由SpringBoot框架封装的SpringApplication应用对象,并将传入的ApplicationDsl对象指定为该应用对象的initializer,然后调用应用对象的run()。这之后就进入了SpringBoot的剧本,SpringApplication会完成Spring运行所需的所有前序工作,然后调用所有initializer对象的initialize()方法,包括此前传入的ApplicationDsl对象。
在ApplicationDsl的initialize()方法里,首先通过super.initialize(context)调用祖父类型AbstractDsl的initialize()方法,将传入的context容器引用保留下来。然后执行dsl.accept(this)进入构造时传入的回调方法。在接下来的beans()和enable()方法里,又会显式的调用子级DSL元素的initialize()方法,从而将这个初始化过程一级一级的迭代下去。就像是层层嵌套的递归调用,直到所有子级元素都构造完毕。至此,初始化过程结束,程序返回到SpringBoot的启动流程。
从源码不难得出结论,SpringFu程序的本质就是规避了JVM动态特性的SpringBoot程序。在卸掉过去spring-boot-starter-web和spring-boot-starter-webflux沉重的外壳之后,换上了一身轻便的战袍。
看似离经叛道,实则一脉相承。
四 总结 SpringFu的到来是Spring面向Serverless时代的一次主动出击,颠覆自己,重获新生。为了让Spring在Serverless函数的舞台上也能轻盈起舞,SpringFu选择了一条前人从未走过的路,将Spring不符合AOT编译的东西统统去掉,做极简主义的减法。
事实证明,没有负担的SpringFu能够跑得更快、飞得更高。
随着云原生渐渐渗入到开发者日常的方方面面,相信在前往Serverless的旅途上,我们终将再次遇见Spring那高挑的身影,因为他早就抵达了这里,迎接着大家的到来。

原文链接
【spring|Spring 启动慢(提速利器 SpringFu 来了)】本文为阿里云原创内容,未经允许不得转载。

    推荐阅读