阿里妹導讀:Spring啓動慢的問題一直爲廣大開發者所詬病,而Spring社區新開源的項目SpringFu終於改變了這一現狀。本文以SpringMVC的幾種典型註解爲例,通過代碼示例對比SpringFu和SpringMVC的差異,並通過源碼解讀,分析SpringFu背後的原理。

文末福利:雲開發體驗——Linux指令入門。

函數是應用在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的歷史上,GenericApplicationContext並不是一位經常拋頭露面的IoC容器。據文檔記載,一直到Spring 5.0之前,GenericApplicationContext的適用場景都是配合其他DefinitionReader對象完成非標準來源的Bean註冊,比如從properties文件加載Bean定義。它有一個registerBeanDefinition()方法,但實際的代碼實現卻只是將傳入的Bean定義直接轉交給代管的beanFactory對象。

就是這麼個低調的小副手,在Spring 5.0裏忽然被賦予瞭如同AnnotationConfigApplicationContext一樣獨立加載和操控Bean的權力,就像78歲高齡的前副總統被提名大選,雖是情理之中,卻也有些意料之外。新增加的方法叫做registerBean(),共有6種重載,其中前5種都是最後1種的參數簡化版本,因此本質上就是一個方法,其完整定義如下:

<T> void registerBean(String beanName, Class<T> beanClass, Supplier<T> 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<GenericApplicationContext>接口,然後通過其提供的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 classApplication { 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方法註解來表示。

@ConfigurationpublicclassMyConfiguration{@Beanpublic Foo foo(){returnnew Foo(); }@Beanpublic Bar bar(Foo foo){returnnew Bar(foo); }}

在SpringFu裏對應的是ConfigurationDsl類型的beans方法,該方法接收一個Consumer<BeanDefinitionDsl>接口對象作爲參數,按照慣例,Consumer接口的實現通常採用Lambda方法來定義,因而在代碼中不會顯式的見到BeanDefinitionDsl的身影。

ConfigurationDsl config = beans(def -> def .bean(Foo.class) .bean(Bar.class) // 隱含使用構造函數注入其他Bean)

在實際的代碼裏,一般也極少出現單獨的ConfigurationDsl,它總是以Consumer<ConfigurationDsl>的形式出現,並且隱藏在Lambda方法裏面被傳遞給需要它的對象。

像SpringMVC中使用@Component、@Service等註解的地方,在SpringFu裏的處理方法與前例是相同的,只需將原類型的註解去掉,然後通過beans()方法來註冊。例如下面這兩個定義:

@ComponentpublicclassXxComponent{// ...}@ServicepublicclassYyService{// ...}

在SpringFu裏大致是這個樣子:

publicclassXxComponent{// ...}publicclassYyService{// ...}beans(def -> def .bean(XxComponent.class) .bean(YyService.class))

稍有特殊的是@Controller註解(以及@RestController註解),由於它是業務請求的入口,與API的路由息息相關,因此在SpringFu中有專門的DSL類型與之對應,比如WebMvcServerDsl和WebFluxServerDsl,它們提供諸如port()、router()等方法來定義與HTTP監聽相關的屬性。例如這兩個SpringMVC的接口:

@RestController@RequestMapping("/api/demo")publicclassMyController{@Autowiredprivate MyService myService;@GetMapping("/")public List<Data> findAll() {return myService.findAll(); }@GetMapping("/{id}")public Data findOne(@PathVariableLong id) {return myService.findById(id); }}

在SpringFu裏,通常將處理請求的入口類命名爲Handler(而不是Controller,這種命名更符合函數定義的慣例),如果將上述代碼“直譯”爲SpringFu結構,大概會長這樣:

publicclassMyHandler{private MyService myService;public MyHandler(MyService myService) {this.myService = myService; }public List<Data> 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。

在所有類型中,孤零零的LoggingDsl是唯一沒有繼承AbstractDsl的漏網之魚。作爲SpringFu項目裏最簡單的DSL元素,去掉空行和註釋,LoggingDsl類型的有效代碼只有20行。不過,即便如此特立獨行,在LoggingDsl身上依然保留着一項與其他DSL元素相同的特徵:構造方法接收以自身類型爲模板的Consumer對象作爲參數。

LoggingDsl(Consumer<LoggingDsl> dsl) { dsl.accept(this);}

構造函數里只有一行dsl.accept(this),這行代碼在所有DSL元素裏都會出現。只是在繼承了AbstractDsl類型的DSL元素中,它是被放在實現initialize抽象方法的地方,而LoggingDsl類型沒有繼承過來的initialize方法,就直接將它擺在構造函數里了。“把自己傳遞給構造方法接收的Consumer對象”,關於DSL的這個神祕行爲,我們在後面講“啓動”的環節裏再來解釋。

回到ApplicationDsl上來,這個類型只是在ConfigurationDsl的基礎上,通過一個MessageSourceInitializer對象額外註冊了幾個Spring自用的Bean,主要功能都是直接繼承自ConfigurationDsl。再看ConfigurationDsl類型,這裏有幾個比較常用的方法:

configurationProperties(Class<T> clazz):註冊屬性配置類,相當於@ConfigurationProperties註解。

logging(Consumer<LoggingDsl> dsl) : 提供函數的輸出日誌配置。

beans(Consumer<BeanDefinitionDsl> dsl) :提供定義Bean的地方,相當於@Configuration註解。

enable(Consumer<ConfigurationDsl> 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那高挑的身影,因爲他早就抵達了這裏,迎接着大家的到來。

雲開發體驗

Linux指令入門——文件與權限

阿里雲開發者成長計劃來啦!基於真實的雲環境和業務場景,幫助開發者深入學習體驗雲上技術。本場景將提供一臺配置了Aliyun Linux 2的ECS實例(雲服務器),1小時帶你瞭解Linux系統中常用的文件目錄管理和文件權限管理命令。

相關文章