從零開始寫一個框架的詳細步驟
所謂定位就是回答幾個問題,我出於什麼目的要寫一個框架,我的這個框架是幹什麼的,有什麼特性適用於什麼場景,我的這個框架的使用者物件是誰,他們會怎麼使用,框架由誰維護將來怎麼發展等等。
如果你打算寫框架,那麼肯定心裡已經有一個初步的定位,比如它是一個快取框架、Web MVC框架、IOC框架、ORM/資料訪問框架、RPC框架或是一個用於Web開發的全棧式框架。是 否要重複造輪子?除非是練手專案,一般我們是有了解決不了問題的時候才會考慮不使用既有的成熟的框架而重複造輪子的,這個時候需要列出新框架主要希望解決 什麼問題。有關是否應該重複造輪子的話題討論了很多,我的建議是在把問題列清後進行簡單的研究看看是否可以通過擴充套件現有的框架來解決這個問題。一般而言大 部分成熟的框架都有一定的擴充套件和內部元件的替換能力,可以解決大部分技術問題,但在如下情況下我們可能不得不自己去寫一個框架,比如即使通過擴充套件也無法滿 足技術需求、安全原因、需要更高的生產力、需要讓框架和公司內部的流程更好地進行適配、開源的普適框架無法滿足效能需求、二次開發的成本高於重新開發的成 本等等。
主打輕量級?輕量級是很多人打算自己寫一個新框架的原因,但我們要明白,大部分專案在一開始的時候其實都是輕量級的,隨著框架 的使用者越來越多,它必定需要滿足各種奇怪的需求,在經過了無數次迭代之後,框架的主線流程就會多很多擴充套件點、檢測點,這樣框架勢必變得越來越重(從框架的 入口到框架的工作結束的方法呼叫層次越來越多,勢必框架也就越來越慢),如果你打算把框架定位於一個輕量級的框架的話,那麼在今後的迭代過程中需要進行一 些權衡,在心中有堅定的輕量級的理念的同時不斷做效能測試來確保框架的輕量,否則隨著時間的發展框架可能會越來越重進而偏離了開始的定位。
特性?如果你打算寫一個框架,並且只有輕量級這一個理由的話,你或許應該再為自己的框架想一些新特性,就像做一個產品一樣,如果找不出兩個以上的亮點,那麼這個產品不太可能成功,比如你的新框架可以是一個零配置的框架,可以是一個前端開發也能用的後端框架。
其它?一般來說框架是給程式設計師使用的,我們要考慮框架使用的頻度是怎麼樣的,這可能決定的框架的效能需求和穩定性需求。還有,需要考慮框架將來怎麼發展,是希望走開源路線還是商業路線。當然,這些問題也可以留到框架有一個大致的結構後再去考慮。
我們來為本文模擬一個場景,假設我們覺得現有的
調研
雖然到這裡你已經決定去寫一個框架了,但是在著手寫之前還是至少建議評估一下市面上的類似(成熟)框架。需要做的是通讀這些框架的文件以及閱讀一些原始碼,這麼做有幾個目的:
通過分析現有框架的功能,可以制定出一個新框架要實現的功能列表。通過分析現有框架的問題,總結出新框架需要避免的東西和改善的地方。
通過閱讀現有框架的原始碼,幫助自己理清框架的主線流程為總體設計做鋪墊(後面總體設計部分會更多談到)。
如果能充分理解現有的框架,那麼你就是站在巨人的肩膀上寫框架,否則很可能就是在井底造輪子。
新 開發一個框架的好處是沒有相容歷史版本的包袱,但是責任也同樣重大,因為如果對於一開始的定位或設計工作沒有做好的話,將來如果要對格局進行改變就會有巨 大的向前相容的包袱(除非你的框架沒有在任何正式專案中使用),相容意味著框架可能會越來越重,可能會越來越難看,閱讀至少一到兩個開源實現,做好充分的 調研工作可以使你避免犯大錯。
假設我們評估了一些主流框架後已經很明確,我們的MVC框架是一個Java平臺的、基於Servlet的輕量級的Web MVC框架,主要的理念是約定優於配置,高內聚大於低耦合,提供主流Web MVC框架的大部分功能,並且易用方面有所創新,新特性體包括:
起手零配置,總體上約定由於配置,即使需要擴充套件配置也支援通過程式碼和配置檔案兩種方式進行配置。除了Servlet之外不依賴其它類庫,支援通過外掛方式和諸如Spring等框架進行整合。
更優化的專案結構,不需要按照傳統的Java Web專案結構那樣來分離程式碼和WEB-INF,檢視可以和程式碼在一起,閱讀程式碼更便利。
攔截器和框架本身更緊密,提供Action、Controller和Global三個級別的"攔截器"(或者說過濾器)。
豐富的Action的返回值,返回的可以是檢視、可以是重定向、可以是檔案、可以是字串、可以是Json資料,可以是Javascript程式碼等等。
支援針對測試環境自動生成測試的檢視模型資料,以便前端和後端可以同時開發專案。
支援在開發的時候自動生成路由資訊、模型繫結、異常處理等配置的資訊頁面和除錯頁面,方便開發和除錯。
提供一套通用的控制元件模版,使得,並且支援多種模版引擎,比如Jsp、Velocity、Freemarker、Mustache等等。
嗯,看上去挺誘人的,這是一個不錯的開端,如果你要寫的框架自己都不覺得想用的話,那麼別人就更不會有興趣來嘗試使用你的框架了。
解決難點
之 所以把解決難點放在開搞之前是因為,如果實現這個框架的某些特性,甚至說實現這個框架的主流程有一些核心問題難以解決,那麼就要考慮對框架的特性進行調 整,甚至取消框架的開發計劃了。有的時候我們在用A平臺的時候發現一個很好用的框架,希望把這個框架移植到B平臺,這個想法是好的,但之所以在這以前這麼 多年沒有人這麼幹過是因為這個平臺的限制壓根不可能實現這樣的東西。比如我們要實現一個MVC框架,勢必需要依賴平臺提供的反射特性,如果你的語言平臺壓 根就沒有執行時反射這個功能,那麼這就是一個非常難以解決的難點。又比如我們在某個平臺實現一個類似於.NET平臺Linq2Sql的資料訪問框架,但如 果這個目標平臺的開發語言並不像C#那樣提供了型別推斷、匿名型別、Lambda表示式、擴充套件方法的話那麼由於語法的限制你寫出來的框架在使用的時候是無 法像.NET平臺Linq2Sql那樣優雅的,這就違背了實現框架的主要目的,實現新的框架也就變得意義不大了。
對於我們要實現的MVC框 架貌似不存在什麼根本性的無法解決的問題,畢竟在Java平臺已經有很多可以參考的例子了。如果框架的實現總體上沒什麼問題的話,就需要逐一評估框架的這 些新特性是否可以解決。建議對於每一個難點特性做一個原型專案來證明可行,以免在框架實現到一半的時候發現有無法解決的問題就比較尷尬了。
分析一下,貌似我們要實現的這8大特性只有第1點要研究一下,看看如何免配置通過讓程式碼方式讓我們的Web MVC框架可以和Servlet進行整合,如果無法實現的話,我們可能就需要把第1點特性從零配置改為一分鐘快速配置了。
開搞
首先需要給自己框架取一個名字,取名要考慮到易讀、易寫、易記,也需要儘量避免和市面上其它產品的名字重複,還有就是最好不要起一個侮辱其它同類框架的名字以免引起公憤。
如果將來打算把專案搞大的話,可以提前註冊一下專案的相關域名,畢竟現在域名也便宜,避免到時候專案名和域名差距很大,或專案的.com或.org域名對應了一個什麼不太和諧的網站這就尷尬了。
然後就是找一個地方來託管自己的程式碼,如果一開始不希望公開程式碼的話,最好除了本地原始碼倉庫還有一個異地的倉庫以免磁碟損壞導致抱憾終身,當然如果不怕出醜的話也可以在起步的時候就使用Github等網站來託管自己的程式碼。
總體設計
對 於總體設計我的建議是一開始不一定需要寫什麼設計文件畫什麼類圖,因為可能一開始的時候無法形成這麼具體的概念,我們可以直接從程式碼開始做第一步。框架的 使用者一般而言還是開發人員,拋開框架的內在的實現不說,框架的API設計的好壞取決於兩個方面。對於普通開發人員而言就是使用層面的API是否易於使 用,拿我們的MVC框架舉例來說:
最基本的,搭建一個HelloWorld專案,宣告一個Controller和Action,配置一個路由規則讓Get方法的請求可以解析到這個Action,可以輸出HelloWorld文字,怎麼實現?
如果要實現從Cookie以及表單中獲取相關資料繫結到Action的引數裡面,怎麼實現?
如果要配置一個Action在呼叫前需要判斷許可權,在呼叫後需要記錄日誌,怎麼實現?
我們這裡說的API,它不一定全都是方法呼叫的API,廣義上來說我們認為框架提供的接入層的使用都可以認為是API,所以上面的一些功能都可以認為是MVC框架的API。
框架除了提供基本的功能,還要提供一定程度的擴充套件功能,使得一些複雜的專案能夠在某些方面對框架進行增強以適應各種需求,比如:
我的Action是否可以返回圖片驗證碼?我的Action的引數繫結是否可以從Memcached中獲取資料?
如果出現異常,能否在開發的時候顯示具體的錯誤資訊,在正式環境顯示友好的錯誤頁面並且記錄錯誤資訊到資料庫?
一 般而言如果要實現這樣的功能就需要自己實現框架公開的一些類或介面,然後把自己的實現"註冊"到框架中,讓框架可以在某個時候去使用這些新的實現。這就需 要框架的設計者來考慮應該以怎麼樣的友好形式公開出去哪些內容,使得以後的擴充套件實現在自由度以及最少實現上的平衡,同時要兼顧外來的實現不破壞框架已有的 結構。
要想清楚這些不是一件容易的事情,所以在框架的設計階段完全可以使用從上到下的方式進行設計。也就是不去考慮框架怎麼實現,而是以一 個使用者的身份來寫一個框架的示例網站,API怎麼簡單怎麼舒服就怎麼設計,只從使用者的角度來考慮問題。對於相關用到的類,直接寫一個空的類(能用介面 的儘量用介面,你的目的只是通過編譯而不是能執行起來),讓程式可以通過編譯就可以了。你可以從框架的普通使用開始寫這樣一個示例網站,然後再寫各種擴充套件 應用,在此期間你可能會用到框架內部的20個類,這些類就是框架的接入類,在你的示例網站通過編譯的那剎那,其實你已經實現了框架的接入層的設計。
這裡值得一說的是API的設計蘊含了非常多的學問以及經驗,要在目標平臺設計一套合理易用的API首先需要對目標平臺足夠了解,每一個平臺都有一些約定俗成的規範,如果設計的API能符合這些規範那麼開發人員會更容易接受這個框架,此外還有一些建議:
之 所以我們把API的設計先行,而不是讓框架的設計先行是因為這樣我們更容易設計出好用的API,作為框架的實現者,我們往往會進行一些妥協,我們可能會為 了在框架內部DRY而設計出一套醜陋的API讓框架的使用者去做一些重複的工作;我們也可能會因為想讓框架變得更鬆耦合強迫框架的使用者去使用到框架的一 些內部API去初始化框架的元件。如果框架不是易用的,那麼框架的內部設計的再合理又有什麼意義?儘量少暴露一些框架內部的類名吧,對 於框架的使用者來說,你的框架對他一點都不熟悉,如果要上手你的框架需要學習一到兩個類尚可接受,如果要使用到十幾個類會頭暈腦脹的,即使你的框架有非常 多的功能以及配置,可以考慮提供一個入口類,比如建立一個ConfigCenter類作為入口,讓使用者可以僅僅探索這個類便可對框架進行所有的配置。
一 個好的框架是可以讓使用者少犯錯誤的,框架的設計者務必要考慮到,框架的使用者沒有這個業務來按照框架的最佳實踐來做,所以在設計API的時候,如果你希 望API的使用者一定要按照某個方式來做的話,可以考慮設定一個簡便的過載來載入預設的最合理的使用方式而不是要求使用者來為你的方法初始一些什麼依賴, 同時也可以在API內部做一些檢測,如果發現開發人員可能會犯錯進行一些提示或丟擲異常。好的框架無需過多的文件,它可以在開發人員用的時候告知它哪裡錯 了,最佳實踐是什麼,即便他們真的錯了也能以預設的更合理的方式來彌補這個錯誤。
建議所有的API都有一套統一的規範,比如入口都叫XXXCenter或XXXManager,而不是叫XXXCenter、YYYManager和 ZZZService。API往往需要進行迭代和改良的,在首個版本中把好名字用掉也不一定是一個好辦法,最好還是給自己的框架各種API的名字留一點餘 地,這樣以後萬一需要升級換代不至於太牽強。
下一步工作就是把專案中那些空的類按照功能進行劃分。目的很簡單,就是讓你的框架 的100個類或介面能夠按照功能進行拆分和歸類,這樣別人一開啟你的框架就可以馬上知道你的框架分為哪幾個主要部分,而不是在100個類中暈眩;還有因為 一旦在你的框架有使用者後你再要為API相關的那些類調整包就比困難了,即使你在建立框架的時候覺得我的框架就那麼十幾個類無需進行過多的分類,但是在將 來框架變大又發現當初設計的不合理,無法進行結構調整就會變得很痛苦。因此這個工作還是相當重要的,對於大多數框架來說,可以有幾種切蛋糕的方式:
分 層。我覺得框架和應用程式一樣,也需要進行分層。傳統的應用程式我們分為表現層、邏輯層和資料訪問層,類似的對於很多框架也可以進行橫向的層次劃分。要分 層的原因是我們的框架要處理的問題是基於多層抽象的,就像如果沒有OSI七層模型,要讓一個HTTP應用去直接處理網路訊號是不合理的也是不利於重用的。 舉一個例子,如果我們要寫一個基於Socket的RPC的框架,我們需要處理方法的代理以及序列化,以及序列化資料的傳輸,這完全是兩個層面的問題,前者 偏向於應用層,後者偏向於網路層,我們完全有理由把我們的框架分為兩個層面的專案(至少是兩個包),rpc.core和rpc.socket,前者不關心 網路實現來處理所有RPC的功能,後者不關心RPC來處理所有的Socket功能,在將來即使我們要淘汰我們的RPC的協議了,我們也可以重用 rpc.socket專案,因為它和RPC的實現沒有任何關係,它關注的只是socket層面的東西。橫切。剛才說的分層是橫向的分 割,橫切是縱向的分割(橫切是跨多個模組的意思,不是橫向來切的意思)。其實橫切關注點就是諸如日誌、配置、快取、AOP、IOC等通用的功能,對於這部 分功能,我們不應該把他們和真正的業務邏輯混淆在一起。對於應用類專案是這樣,對於框架類專案也是這樣,如果某一部分的程式碼量非常大,完全有理由為它分出 一個單獨的包。對於RPC專案,我們可能就會把客戶端和服務端通訊的訊息放在common包內,把配置的處理單獨放在config包內。
功能。也就是要實現一個框架主要解決的問題點,比如對於上面提到的RPC框架的core部分,可以想到的是我們主要解決是客戶端如何找到服務端,如何把進 行方法呼叫以及把方法的呼叫資訊傳給目標服務端,服務端如何接受到這樣的資訊根據配置在本地例項化物件呼叫方法後把結果返回客戶端三大問題,那麼我們可能 會把專案分為routing、client、server等幾個包。
如果是一個RPC框架,大概是這樣的結構:
對於我們的Web MVC框架,舉例如下:
我們可以有一個mvc.core專案,細分如下的包:common:公共的一元件,下面的各模組都會用到
config:配置模組,解決框架的配置問題
startup:啟動模組,解決框架和Servlet如何進行整合的問題
plugin:外掛模組,外掛機制的實現,提供IPlugin的抽象實現
routing:路由模組,解決請求路徑的解析問題,提供了IRoute的抽象實現和基本實現
controller:控制器模組,解決的是如何產生控制器
model:檢視模型模組,解決的是如何繫結方法的引數
action:action模組,解決的是如何呼叫方法以及方法返回的結果,提供了IActionResult的抽象實現和基本實現
view:檢視模組,解決的是各種檢視引擎和框架的適配
filter:過濾器模組,解決是執行Action,返回IActionResult前後的AOP功能,提供了IFilter的抽象實現以及基本實現
我們可以再建立一個mvc.extension專案,細分如下的包:
filters:一些IFilter的實現
results:一些IActionResult的實現
routes:一些IRoute的實現
plugins:一些IPlugin的實現
這裡我們以IXXX來描述一個抽象,可以是介面也可以是抽象類,在具體實現的時候根據需求再來確定。
這 種結構的劃分方式完全吻合上面說的切蛋糕方式,可以看到除了橫切部分和分層部分,作為一個Web MVC框架,它核心的元件就是routing、model、view、controller、action(當然,對於有些MVC框架它沒有route部 分,route部分是交由Web框架實現的)。
如果我們在這個時候還無法確定框架的模組劃分的話,問題也不大,我們可以在後續的搭建龍骨的步驟中隨著更多的類的建立,繼續理清和確定模組的劃分。
經過了設計的步驟,我們應該心裡對下面的問題有一個初步的規劃了:
我們的框架以什麼形式來提供如何優雅的API?我們的框架包含哪些模組,模組大概的作用是什麼?
搭建龍骨
在 經過了初步的設計之後,我們可以考慮為框架搭建一套龍骨,一套抽象的層次關係。也就是用抽象類、介面或空的類實現框架,可以通過編譯,讓框架撐起來,就像 造房子搭建房子的鋼筋混凝土結構(添磚加瓦是後面的事情,我們先要有一個結構)。對於開發應用程式來說,其實沒有什麼撐起來一說,因為應用程式中很多模組 都是並行的,它可能並沒有一個主結構,主流程,而對於框架來說,它往往是一個高度面向物件的,高度抽象的一套程式,搭建龍骨也就是搭建一套抽象層。這麼說 可能有點抽象,我們還是來想一下如果要做一個Web MVC框架,需要怎麼為上面說的幾個核心模組進行抽象(我們也來體會一下框架中一些類的命名,這裡我們為了更清晰,為所有介面都命名為IXXX,這點不太 符合Java的命名規範):
routing MVC的入口是路由每一個路由都是IRoute代表了不同的路由實現,它也提供一個getRouteResult()方法來返回RouteResult物件
我們實現一個框架自帶的DefaultRoute,使得路由支援配置,支援預設值,支援正則表示式,支援約束等等
我們需要有一個Routes類來管理所有的路由IRoute,提供一個findRoute()方法來返回RouteResult物件,自然我們這邊呼叫的就是IRoute的getRouteResult()方法,返回能匹配到的結果
RouteResult物件就是匹配的路由資訊,包含了路由解析後的所有資料
controller 路由下來是控制器
我們有IControllerFactory來建立Controller,提供createController()方法來返回IController
IController代表控制器,提供一個execute()方法來執行控制器
我們實現一個框架自帶的DefaultControllerFactory來以約定由於配置的方式根據約定規則以及路由資料RouteResult來找到IController並建立它
我 們為IController提供一個抽象實現,AbstractController,要求所有MVC框架的使用者建立的控制器需要繼承 AbstractController,在這個抽象實現中我們可以編寫一些便捷的API以便開發人員使用,比如view()方法、file()方法、 redirect()方法、json()方法、js()方法等等
action 找到了控制器後就是來找要執行的方法了
我們有IActionResult來代表Action返回的結果,提供一個execute()方法來執行這個結果
我們的框架需要實現一些自帶的IActionResult,比如ContentResult、ViewResult、FileResult、JsonResult、RedirectResult來對應AbstractController的一些便捷方法
再來定義一個IActionInvoker來執行Action,提供一個invokeAction()方法
我們需要實現一個DefaultActionInvoker以預設的方式進行方法的呼叫,也就是找到方法的一些IFilter按照一定的順序執行他們,最後使用反射進行方法的呼叫得到上面說的IActionResult並執行它的execute()方法
filter 我們的框架很重要的一點就是便捷的過濾器
剛才提到了IFilter,代表的是一個過濾器,我們提供IActionFilter對方法的執行前後進行過濾,提供IResultFilter對IActionResult執行前後進行過濾
我們的IActionInvoker怎麼找到需要執行的IFilter呢,我們需要定義一個IFilterProvider來提供過濾器,它提供一個getFilters()方法來提供所有的IFilter的例項
我 們的框架可以實現一些自帶的IFilterProvider,比如AnnotationFilterProvider通過掃描Action或 Controller上的註解來獲取需要執行的過濾器資訊;比如我們還可以實現GlobalFilterProvider,開發人員可以直接通過配置或代 碼方式告知框架應用於全域性的IFilter
既然我們實現了多個IFilterProvider,我們自然需要有一個類來管理這些IFilterProvider,我們實現一個FilterProviders類並提供getFilters()方法(這和我們的Routes類來管理IRoute是類似的,命名統一)
view 各種IActionResult中最特殊最複雜的就是ViewResult,我們需要有一個單獨的包來處理ViewResult的邏輯
我們需要有IViewEngine來代表一個模版引擎,提供一個getViewEngineResult()方法返回ViewEngineResult
ViewEngineResult包含檢視引擎尋找檢視的結果資訊,裡面包含IView和尋找的一些路徑等
IView自然代表的是一個檢視,提供render()方法(或者為了統一也可以叫做execute)來渲染檢視
我 們的框架可以實現常見的一些模版引擎,比如FreemarkerViewEngine、VelocityViewEngine 等,VelocityViewEngine返回的ViewEngineResult自然包含的是一個實現IView的VelocityView,不會返回 其它引擎的IView
同樣的,我們是不是需要一個ViewEngines來管理所有的IViewEngine呢,同樣也是實現findViewEngine()方法
common 這裡可以放一些專案中各個模組都要用到的一些東西
比 如各種context,context代表的是執行某個任務需要的環境資訊,這裡我們可以定義HttpContext、 ControllerContext、ActionContext和ViewContext,後者繼承前者,隨著MVC處理流程的進行,View執行時的 上下文相比Action執行時的上下文資訊肯定是多了檢視的資訊,其它同理,之所以把這個資訊放在common裡面而不是放在各個模組自己的包內是因為這 樣更清晰,可以一目瞭然各種物件的執行上下文有一個立體的概念
比如各種helper或utility
接下去就不再詳細闡述model、plugin等模組的內容了。
看到這裡,我們來總結一下,我們的MVC框架在組織結構上有著高度的統一:
如果xxx本身並無選擇策略,但xxx的建立過程也不是一個new這麼簡單的,可以由xxxFactory類來提供一個xxx如果我們需要用到很多個yyy,那麼我們會有各種yyyProvider(通過getyyy()方法)來提供這些yyy,並且我們需要有一個yyyProviders來管理這些yyyProvider
如果zzz的選擇是有策略性的,會按照需要選擇zzz1或zzzN,那麼我們可能會有一個zzzs來管理這些zzz並且(通過findzzz()方法)來提供合適的zzz
同 時我們框架的相關類的命名也是非常統一的,可以一眼看出這是實現、還是抽象類還是介面;是提供程式,是執行結果還是上下文。當然,在將來的程式碼實現過程中 很可能會把很多介面變為抽象類提供一些預設的實現,這並不會影響專案的主結構。我們會在模式篇對框架常用的一些高層設計模式做更多的介紹。
到了這裡,我們的專案裡已經有幾十個空的(抽象)類、介面了,其中也定義了各種方法可以把各個模組串起來(各種find()方法和execute()方法),可以說整個專案的龍骨已經建立起來了,這種感覺很好,因為我們心裡很有底,我們只需要在接下去的工作中做兩個事情:
實現各種DefaultXXX來走通主流程實現各種IyyyProvider和Izzz介面來完善支線流程
走通主線流程
所謂走通主線流程,就是讓這個框架可以以一個HelloWorld形式跑起來,這就需要把幾個核心類的核心方法使用最簡單的方式進行實現,還是拿我們的MVC框架來舉例子:
從startup開始,可能需要實現ServletContextListener來動態註冊我們框架的入口Servlet,暫且起名為DispatcherServlet吧,在這個類中我們需要走一下主線流程呼叫Routes.findRoute()獲得IRoute
呼叫IRoute.getRouteResult()來獲得RouteResult
使用拿到的RouteResult作為引數呼叫DefaultControllerFactory.createController()獲得IController(其實也是AbstractController)
呼叫IController.execute()
在 config中建立一個IConfig作為一種配置方式,我們實現一個DefaultConfig,把各種預設實現註冊到框架中去,也就是 DefaultRoute、DefaultControllerFactory、DefaultActionInvoker,然後把各種 IViewEngine加入ViewEngines
然後需要完成相關預設類的實現:
實現Routes.findRoute()
實現DefaultRoute.getRouteResult()
實現DefaultControllerFactory.createController()
實現AbstractController.execute()
實現DefaultActionInvoker.invokeAction()
實現ViewResult.execute()
實現ViewEngines.findViewEngine()
實現VelocityViewEngine.getViewEngineResult()
實現VelocityView.render()
在這一步,我們並不一定要去觸碰filter和model這部分的內容,我們的主線流程只是解析路由,獲得控制器,執行方法,找到檢視然後渲染檢視。過濾器和檢視模型的繫結屬於增強型的功能,屬於支線流程,不屬於主線流程。
雖 然在這裡我們說了一些MVC的實現,但本文的目的不在於教你實現一個MVC框架,所以不用深究每一個類的實現細節,這裡想說的是,在前面的龍骨搭建完後, 你會發現按照這個龍骨為它加一點肉上去實現主要的流程是順理成章的事情,毫無痛苦。在整個實現的過程中,你可以不斷完善common下的一些 context,把方法的呼叫引數封裝到上下文物件中去,不但看起來清楚且符合開閉原則。到這裡,我們應該可以跑起來在設計階段做的那個示例網站的 HelloWorld功能了。
在這裡還想說一點,有些人在實現框架的時候並沒有搭建龍骨的一步驟,直接以非OOP的方式實現了主線流程,這種方式有以下幾個缺點:
不容易做到SRP單一指責原則,你很容易把各種邏輯都集中寫在一起,比如大量的邏輯直接寫到了DispatcherServlet中,輔助一些Service或Helper,整個框架就肥瘦不勻,有些類特別龐大有些類特別小。
不容易做到OCP開閉原則,擴充套件起來不方便需要修改老的程式碼,我們期望的擴充套件是實現新的類然後讓框架感知,而不是直接修改框架的某些程式碼來增強功能。
很難實現DIP依賴倒置原則,即使你依賴的確實是IService但其實就沒意義,因為它只有一個實現,只是把他當作幫助類來用罷了。
實現各種支線流程
我們想一下,對於這個MVC框架有哪些沒有實現的支線流程?其實無需多思考,因為我們在搭建龍骨階段的設計已經給了我們明確的方向了,我們只需要把除了主線之外的那些龍骨上也填充一些實體即可,比如:
實現更多的IRoute,並註冊到Routes實現更多的IViewEngine,並註冊到ViewEngines
實現必要的IFilterProvider以及FilterProviders,把IFilterProvider註冊到FilterProviders
增強DefaultActionInvoker.invokeAction()方法,在合適的時候呼叫這些IFilter
實現更多的IActionResult,並且為AbstractController實現更多的便捷方法來返回這些IActionResult
……實現更多model模組的內容和plugin模組的內容
實現了這一步後,你會發現整個框架飽滿起來了,每一個包中不再是僅有的那些介面和預設實現,而且會有一種OOP的爽快感,爽快感來源於幾個方面:
面對介面程式設計抽象和多型的放心安心的爽快感為抽象類實現具體類享受到父類大量實現的滿足的爽快感
實現了大量的介面和抽象類後充實的爽快感
我們再來總結一下之前說的那些內容,實現一個框架的第一大步就是:
設計一套合理的介面為框架進行模組劃分
為框架搭建由抽象結構構成的骨架
在這個骨架的基礎上實現一個HelloWorld程式
為這個骨架的其它部分填充更多實現
經 過這樣的一些步驟後可以發現這個框架是很穩固的,很平衡的,很易於擴充套件的。其實到這裡很多人覺得框架已經完成了,有血有肉,其實個人覺得只能說開發工作實 現了差不多30%,後文會繼續說,畢竟直接把這樣一個血肉之軀拿出去對外有點嚇人,我們需要為它進行很多包裝和完善。
單元測試
在這之前我們寫的框架只能說是一個在最基本的情況下可以使用的框架,作為一個框架我們無法預測開發人員將來會怎麼使用它,所以我們需要做大量的工作來確保框架不但各種功能都是正確的,而且還是健壯的。寫應用系統的程式碼,大多數專案是不會去寫單元測試的,原因很多:
專案趕時間,連做一些輸入驗證都沒時間搞,哪裡有時間寫測試程式碼。專案對各項功能的質量要求不高,只要能在標準的操作流程下功能可用即可。
專案基本不會去改或是臨時專案,一旦測試通過之後就始終是這樣子了,沒有迭代。
……
對於框架,恰恰相反,沒有配套的單元測試的框架(也就是僅僅使用人工的方式進行測試,比如在main中呼叫一些方法觀察日誌或輸出,或者執行一下示例專案檢視各種功能是否正常,是非常可怕的)原因如下:
自動化程度高,迴歸需要的時間短,甚至可以整合到構建過程中進行,這是人工測試無法實現的。框架一定是有非常多的迭代和重構的, 每一次修改雖然只改了A功能,但是可能會影響到B和C功能,人工測試的話你可能只會驗證A是否正常,容易忽略B和C,使用單元測試的話只要所有功能都有覆蓋,那麼幾乎不可能遺漏因為修改導致的潛在問題,而且還能反饋出來因為修改導致的相容性問題。
之前說過,一旦框架開放出去,框架的使用者可能會以各種方式在各種環境來使用你的框架,環境不同會造成很多怪異的邊界輸入或非法輸入,需要使用單元測試對程式碼進行嚴格的邊界測試,以確保框架可以在嚴酷的環境下生存。
單元測試還能幫助我們改善設計,在寫單元測試的時候如果發現目的碼非常難以進行模擬難以構建有效的單元測試,那麼說明目的碼可能有強依賴或職責過於複雜,一個被單元測試高度覆蓋的框架往往是設計精良的,符合高內聚低耦合的框架。
如果框架的時間需求不是特別緊的話,單元測試的引入可以是走通主線流程的階段就引入,越早引入框架的成熟度可能就會越高,以後重構返工的機會會越小,框架的可靠性也肯定會大幅提高。之前我有寫過一個類庫專案,並沒有寫單元測試,在專案中使用了這個類庫一段時間也沒有出現任何問題,後來花了一點時間為類庫寫了單元測試,出乎我意料之外的是,我的類庫提供的所有API中有超過一半是無法通過單元測試的(原以為這是一個成熟的類庫,其實包含了數十個BUG),甚至其中有一個API是在我的專案中使用的。你可能會問,為什麼在使用這個API的時候沒有發生問題而在單元測試的時候發生問題了呢?原因之前提到過,我是框架的設計者,我在使用類庫提供的API的時候是知道使用的最佳實踐的,因此我在使用的時候為類庫進行了一個特別的設定,這個問題如果不是通過單元測試暴露的話,那麼其它人在使用這個類庫的時候基本都會遇到一個潛在的BUG。
示範專案
寫一個示例專案不僅僅是為了給別人參考,而且還能夠幫助自己去完善框架,對於示例專案,最好兼顧下面幾點:
是一個具有一定意義的網站或系統,而不是純粹為了演示特性而演示。這是因為,很多時候只有那些真正的業務邏輯才會暴露出問題,演示特性的時候我們總是有一些定勢思維會規避很多問題。或者可以提供兩個專案,一個純粹演示特性,一個是示例專案。覆蓋儘可能多的特性或使用難點,在專案的程式碼中提供一些註釋,很多開發人員不喜歡閱讀文件,反而喜歡看一下示例專案直接上手(模仿示例專案,或直接拿示例專案中的程式碼來修改)。
專案中的程式碼,特別是涉及到框架使用的程式碼一定要規範,原因上面也說了,作為框架的設計者你不會希望大家複製的程式碼粘帖的程式碼一團糟吧。
如果你的專案針對的不僅僅是Web專案,那麼示例專案最好提供Web和桌面兩個版本,一來你自己容易發現因為環境不同帶來的使用差異,二來可以給予不同型別專案不同的最佳實踐。
完善日誌和異常
一個好的框架不但需要設計精良,日誌和異常的處理是否到位也是非常重要的標準,這裡有一些反例:
日誌的各種級別的使用沒有統一的標準,甚至是永遠只使用某個級別的日誌。幾乎沒有任何的日誌,框架的執行完全是一個黑盒。
記錄的日誌多且沒有實際含義,只是除錯的時候用來觀察變數的內容。
異常型別只使用Exception,不使用更具體化的型別,沒有自定義型別。
異常的訊息文字只寫"錯誤"字樣,不寫清楚具體的問題所在。
永遠只是丟擲異常,讓異常上升到最外層,交給框架的使用者去處理。
用異常來控制程式碼流程,或本應該在方法未達到預期效果的時候使用異常卻使用返回值。
其實個人覺得,一個框架的主邏輯程式碼並不一定是最難的,最難的是對一些細節的處理,讓框架保持一套規範的統一的日誌和異常的使用反而對框架開發者來說是一個難點,下面是針對記錄日誌的一些建議:
1、首先要對框架使用的日誌級別有一個規範,比如定義:
DEBUG:用於觀察程式的執行流程,僅在除錯的時候開啟INFO:用於告知程式執行狀態或階段的變化,可以在測試環境開啟
WARNING:用於告知程式可以自己恢復的錯誤或異常,或不影響主線流程執行的錯誤或問題,可以在正式環境開啟
ERROR:用於告知程式無法恢復,主線流程中斷,需要開發或運維人員知曉干預的錯誤或異常,需要在正式環境開啟
2、按照上面的級別規範,在需要記錄日誌的地方記錄日誌,除了DEBUG級別的日誌其它日誌不能記錄過多,如果框架總是在執行的時候輸出幾十個WARNNING也容易讓使用者忽略真正的問題。
3、日誌記錄的訊息需要是明確的,最好包含一些上下文資訊,比如"無法在xxx下找到配置檔案xxx.config,框架將採用預設的配置",而不是"載入配置失敗!"
下面是一些針對使用異常的建議:
框架由於配置錯誤或使用錯誤或執行錯誤,不能完成API名字所表示的功能,考慮丟擲轉化後的異常,讓呼叫者知道發什麼了什麼情況,同時框架可以建立自己的錯誤處理機制對於可以預料的錯誤,並且錯誤型別可以列舉,考慮以返回值的形式告知呼叫者可以根據不同的結果來處理後續的邏輯
對於框架內部功能實現上遇到的呼叫者無能力解決的錯誤,如果錯誤可以重試或不影響返回,可以記錄警告或錯誤日誌
可以為每一個模組都陪伴自定義的異常型別,包含相關的上下文資訊(比如ViewException可以包含ViewContext),這樣出現異常可以很方便知曉是哪個模組出現問題並且可以得到出現異常時的環境資訊
如果異常跨了實現層次(比如從框架到應用),那麼最好進行一下包裝轉換(比如把檔案讀取失敗的提示改為載入配置檔案失敗的提示),否則上層人員是不知道怎麼處理這些內部問題的,內部問題需要由框架自己來處理
異常的日誌中可以記錄和當前操作密切相關的引數資訊,比如搜尋的路徑,檢視名等等,有關方法的資訊不用過多記錄,異常一般都帶有呼叫棧資訊
如果可能的話,出現異常的時候可以分析一下為什麼會出現這樣的問題,在異常資訊中給一些解決問題的建議或幫助連結方便使用者排查問題
異常處理從壞到好的層次是,出現了嚴重問題的時候:
使用者什麼都不知道,程式的完整性和邏輯得到破壞
使用者既不知道出現了什麼問題也不知道怎麼去解決
使用者能明確知道出現了什麼問題,但無法去解決
使用者不但知道發生了什麼,還能通過異常訊息的引導快速解決問題
完善配置
配置的部分可以留到框架寫的差不多了再去寫,因為這個時候已經可以想清楚哪些配置是:
需要公開出去給使用者配置的,並且配置會根據環境不同而不同需要公開出去給使用者來配置的,配置和部署環境無關
僅僅需要在框架內供框架開發人員來配置的
無需是一個配置,只要在程式碼中集中儲存這個設定即可
一般來說配置有幾種方式:
通過配置檔案來配置,比如XML檔案、JSON檔案或property檔案通過註解或特性(Annotation/Attribute)方式(對類、方法、引數)進行配置
通過程式碼方式進行配置(比如單獨的配置類,或實現配置類或呼叫框架的配置API)
很多框架提供了多種配置方式,比如Spring MVC同時支援上面三種方式的配置,個人覺得對配置,我們還是應該區別對待,而不是無腦把所有的配置項都同時以上面三種方式提供配置,我們要考慮高內聚和低耦合原則,對於Web框架來說,高內聚需要考慮的比低耦合更多,我的建議是對不同的配置項提供不同的配置方式:
如果配置專案是需要讓使用者來配置的,特別是和環境相關的,那麼最好使用配置方式來配置,比如開放的埠、記憶體、執行緒數配置,不過要注意:所有配置專案需要有預設值,如果找不到配置使用預設值,如果配置不合理使用預設值(你不會希望使用你框架的人把框架內部的執行緒池的min設定為999999,或定時器的間隔設定為0毫秒吧?)
框架啟動的時候檢測所有配置,如果不合理給予提示,大多人只會在啟動的時候看一下日誌,使用的時候根本就不管
不知道大家對於配置檔案的格式傾向於XML呢還是JSON呢還是鍵值對呢?
對於所有僅在開發時進行的配置,都儘量不要去使用配置檔案,並且讓配置儘量和它所配置的物件靠在一起:
如果是對框架整體性進行的設定擴充套件型別的配置,那就可以提供程式碼方式進行配置,比如我們要實現的MVC框架的各種IRoute、IViewEngine等,最好可以提供IConfig介面讓開發人員可以去實現介面,這樣他們可以知道有哪些東西可以配置,程式碼就是文件
如果是那種對模型、Action進行的配置,比如模型的驗證規則、Filter等一律採用註解的方式進行配置
有的人說使用配置檔案進行配置非常靈活,使用程式碼方式和註解方式來配置不靈活而且可能有侵入性。我覺得還是要權衡對待,我的建議是不要把太多框架內在的東西放在配置檔案中,增加使用者的難度(而且很多時候,大多數人只是複製配置為了完成配置而配置,並不是為了真正的靈活性而去使用配置檔案來配置你的框架,看看網上這麼所SSH配置檔案的抄來抄去就知道了)。
最後,我建議很多太內部的東西對於輕量級的應用型框架可以不去提供任何配置選項,只需要在某個常量檔案中定義即可,讓真正有需求進行二次開發的開發人員去修改,對於一個框架如果一下子暴露上百個"高階"配置項給使用者,他們會暈眩的。
提供狀態服務
所謂狀態服務就是反映框架內部運作狀態的服務,很多開源服務或系統(Nginx、Mongodb等)都提供了類似的模組和功能,作為框架的話我覺得也有必要提供一些內部資訊(主要是配置、資料統計以及內部資源狀態)出來,這樣使用你框架的人可以在開發的時候或線上運作的時候瞭解框架的運作狀態,我們舉兩個例子,對於一個我們之前提到的Web MVC框架來說,可以提供這些資訊:
路由配置檢視引擎配置
過濾器配置
對於一個Socket框架來說,有一些不同,Socket框架是有狀態的,其狀態服務提供的資訊除了當前生效的配置資訊之外,更多的是反映當前框架內部一些資源的狀態以及統計資料:
各種配置(池配置、佇列配置、叢集配置)Socket相關的統計資料(總開啟、總關閉、每秒收發資料、總收發資料、當前開啟等等)
各種池的當前狀態
各種佇列的當前狀態
狀態服務可以以下面幾種形式來提供:
程式碼方式,比如如果開發人員實現了IXXXStateAware介面的話,就可以為它的實現類來推送一些資訊,也可以直接在框架中設立一個StateCenter來公開框架所有的狀態資訊自動日誌方式,比如如果在配置中開啟了stateLoggingInterval=60s的選項,我們的框架就會自動一分鐘一次輸出日誌,顯示框架內部的狀態
介面方式,比如開放一個Restful的介面或額外監聽一個埠來提供狀態服務,方便使用者可以拿原始的資料和其它監控平臺進行整合
內部外部工具方式
比如我們可以直接為框架提供一個專門的頁面(/_route)來呈現路由的配置(甚至我們可以在這個頁面上讓開發人員可以直接輸入地址來測試路由的匹配情況,狀態服務不一定只能看),這樣在開發和測試的時候可以更方便除錯
我們也可以為框架提供一個專有工具來檢視框架的狀態資訊(當然,這個工具其實可能就是連線框架的某個網路服務來獲取資料),這樣即使框架在多個機器中使用,我們可能也只有一個監控工具即可
如果沒有狀態服務,那麼在執行的時候框架就是一個黑盒,反之如果狀態服務足夠詳細的話,可以方便我們排查一些功能或效能問題。不過要注意的一點是,狀體服務可能會降低框架的效能,我們可能需要對狀態服務也進行一次壓測,排除狀態服務中損耗效能的地方(有些資料的收集會意想不到得損耗效能)。
檢查執行緒安全
框架對多執行緒環境支援的是否好,是框架質量的一個重要的評估標準,往往可以看到甚至有一些成熟的框架也會有多執行緒問題。這裡涉及幾個方面:
1,你無法預料框架的使用者會怎麼樣去例項化和儲存你的API的入口類,如果你的入口類被用成為了一個單例,在併發呼叫的情況下會不會有單執行緒問題?
這是一個老話題,之前已經說過很多次,你在設計框架的時候心裡如果把一個類定位成了單例的類但卻沒有提供單例模式,你是無法要求使用者來幫你實現單例的。這其中涉及的不僅僅是多執行緒問題,可能還有效能問題。比如見過某分散式快取的客戶端的CacheClient在文件中要求使用者針對一個快取叢集保持一個CacheClient的單例(因為其中有了連線池),但是用的人還是每一次都例項化了一個CacheClient出來,幾小時後就會產生幾萬個半死的Socket導致網路奔潰。又見過某類庫的入口工廠的程式碼註釋中寫了要求使用的人把XXXFactory作為單例來使用(因為其中快取了大量資料),但是用的人就沒有注意到這個註釋,每一次都例項化了一個XXXFactory,造成GC的崩潰。所以我覺得作為框架的設計者開發人員,最好還是把框架的最佳實踐直接做到API中,使得使用者不可能出錯(之前說過一句話,再重複一次,好的框架不會讓使用的人犯錯)。你可能會說對於CacheClient的例子,不可能做成單例的,因為我的程式可能需要用到多個快取的叢集,換個思路,我們完全可以在封裝一層,通過一個CacheClientCreator之類的類來管理多個單例的CacheClient。即使在某些極端的情況下,你不能只提供一條路給使用者去走,也需要在框架內做一些檢測機制,及時提醒使用者 "我們發現您這樣使用了框架,這可能會產生問題,你本意是否打算那樣做呢?"
2,如果你的入口類本來就是單例的,那麼你是類中是否持有共享資源,你的API在併發的情況下被呼叫是否可以確保這些資源的執行緒安全?在解決多執行緒問題的時候往往有幾個難點:
百密難有一疏,你很難想到這段程式碼會有人這樣去併發呼叫。比如某init()方法,某config()方法,你總是假設使用者會呼叫並且僅呼叫一次,但事實不一定這樣,有的時候呼叫者自己也不清楚我的容器會呼叫我這段程式碼多少次。
好吧,解決多執行緒問題各種煩躁,那就對各種涉及到共享資源的方法全部加鎖。對方法進行粗獷(粒度)的鎖可能會導致效能急劇下降甚至是死鎖問題。
自以為使用了優雅的無鎖程式碼或併發容器但卻達不到目的。我們往往在大量使用了併發集合心中暗自竊喜解決了多執行緒問題的同時又達到了極佳的效能,但你以為這樣是解決了執行緒安全問題但其實根本就沒有,我們不能假設A和B都方法是執行緒安全的,但對A和B方法呼叫的整個程式碼段是執行緒安全的。
對於多執行緒問題,我沒有好的解決辦法,不過下面的幾條我覺得可以嘗試:
需要非常仔細的過一遍程式碼,把涉及到共享資源的地方,以及相關的方法和類列出來,不要去假設什麼,只要API暴露出去了則假設它可能被併發呼叫。共享資源不一定是靜態資源,哪怕資源是非靜態的,在併發環境下對相同物件的資源進行操作也可能產生問題。
一般而言對於公開的API,作為框架的設計者我們需要確保所有的靜態方法(或但單例類的例項方法)是執行緒安全的,對於例項方法我們可以不這麼做(因為效能原因),但是需要在註釋中明確提示使用者方法的非執行緒安全,如果需要併發呼叫請自行處理執行緒安全問題。
可以看看是否有可能讓這些資源(欄位)變為方法內的區域性變數,有的時候我們並不是真正的需要類持有一個欄位,只是因為多個方法要使用相同的東西,隨手一寫罷了。
對於使用頻率低的一些方法相關的一些資源沒有必要使用併發容器,直接採用粗狂的方式進行資源加鎖甚至是方法級別加鎖,先確保沒有執行緒安全,如果以後做壓測出現效能問題再來解決。
對於使用頻率高的一些方法相關的一些資源可以使用併發容器,但需要仔細思考一下程式碼是否會存線上程安全問題,必要的話為程式碼設計一些多執行緒環境的單元測試去驗證。
效能測試和優化
之前也提到過,你不會預測到你的專案會在怎麼樣的訪問量下使用,我們不希望框架和同類的框架相比有明顯的效能差距(如果你做的是一個ORM框架或RPC框架,這個工作就是必不可少的),所以在框架基本完成後我們需要做Benchmark:
確定幾個測試用例,儘量覆蓋主流程和一些重要擴充套件找幾個主流的同類型框架,實現相同的測試用例,實現到時候要單純一點,儘量不要再依賴其它外部框架
為這些框架和自己的框架,使用壓力測試工具在相同的環境和平臺來跑這些測試用例,使用圖表繪製在不同的壓力下的執行時間(以及記憶體和CPU等主要資源的消耗情況)
如果出現明顯的差距則用效能分析工具進行排查和優化,比如:
優化框架內的執行緒安全的實現方式
為框架內的程式碼做一些快取(快取反射得到的元資料等等)
減少呼叫層次
這些調整可能會打破原來的主線流程或讓程式碼變得難以理解,需要留下相關注釋
不斷重壓力測試和優化的過程,每次嘗試優化5%~20%的效能,雖然越到後來可能會越難,如果發現實在無法優化的話(效能分析工具顯示效能的分佈已經很均勻了),可以看一下其它框架對於這部分工作實現的程式碼邏輯
封裝和擴充套件