博文太長了, 還是先說下概要:
- 框架"輕量"與否可以從兩方面來看待: 1) 框架本身的體量 - 例如小 jar 無依賴的苗條框架; 2) 用戶使用框架是否獲得各種便利而無阻隔("隔" 的含義參考王國維先生的人間詞話)
- 單單"輕量"二字不足以說明框架的特性和使用方式
- 一定要說"輕量", 老碼農傾向與第二種 - 用戶使用框架是否獲得各種便利而無阻隔
- 為了"輕量"而刻意使框架苗條化有時候不足取.
tl;dr 以下博客正文
1. Java Web 服務框架的前世今生
時光回到 2000 年中, 老碼農坐在天津河川大廈 7 樓接手平生第一個 Web 服務項目, 采用的是最新(當年)的 Java Servlet 技術, 倒騰着精致(自認為)的結構來處理 HttpServletResponse 輸出 (幽怨地看向 N 年之后 Beetl, 卻看不到 Rythm 的模樣); 半年后第二個 web 項目開坑的時候, 從 apache jarkata 中挖出了一個名叫 Velocity 的模板, 果斷放棄自己的輸出框架; 再一年半之后的第三個項目(代號 kasino), 不說了, 直接上一整套 Apache Turbine 服務框架 (估計現在大部分人都沒有聽說過這個框架, 不過單單從這個框架項目衍生出的 Velocity 和大名鼎鼎的 Maven 可知 Turbine 的分量). 這里不得不提到另一個同時期的 Web 服務框架, 其盛(?)名卻一直延續至今: Struts; 出於偶然的因素, 老碼農看到 Struts/JSP 這套路線的時候已經上了 Turbine/Velocity 的船, 與這套路線也就漸行漸遠了.
用過 Turbine 之后老碼農對 Web 服務框架的概念逐漸開始建立起來, 然而 MVC 模式和 Web 服務框架之間的關系之后后來 Spring 出了 SpringMVC 之后才更加清晰地定義下來並為業界接受. 遺憾地是 2003 年之后老碼農轉向嵌入式系統和 Java web 服務這條線暫時分道揚鑣, 錯過了這些年這個領域許多的精彩. 2009 年老碼農重回 Web 開發, 先和 CakePHP 搏斗三個月, 發現自己實在不是宇宙第一語言的對手, 決定還是回去找原配 Java. 當時 SSH 在 Java Web 服務框架之中已經如日中天, 但老碼農並沒有直接投懷入抱(當然更沒有想過去踩 EJB 的深坑), 還是矜持地決定再研究研究其他框架. 這以矜持就讓老碼農碰上了真愛 PlayFramework. 和今天的 Play2 不同, 當年的 Play 給人一種驚艷的感覺, 最激動人心的幾個特性:
-
開發模式熱加載 - 修改錯誤之后直接在瀏覽器上點 reload 就 okay - 無需重啟服務! 無需重啟服務! 無需重啟服務!
-
開發模式發生錯誤直接在 Web 頁面高亮顯示錯誤代碼行 - 沒有用過這個特性的開發估計很難有直觀的概念, 這里是一個 play 的出錯頁面, 大家可以體會一下:
3.無狀態模型 - Session 不在服務端, 做橫向擴展毫無違和感
讀到這里, 有的看官可能會說這幾個特貌似和代碼沒有啥關系啊. 這里其實是老碼農想強調的地方, 框架並非僅僅關乎代碼, 而是關乎整個開發過程. 設想一下如果每次修改無需等待 10 秒或者更長的時間讓服務重啟, 整個開發會節省多少小時; 同理, 如果出錯之后無需到后台花幾十秒到幾分鍾去 Log 中定位錯誤, 又能夠為整個項目節省多少小時. 老碼農認為 Play1 帶來的這些特性對生產力的提高是有革命性的影響.
時光飛逝如電, 轉眼進入二十世紀第二個十年. 如果說二十世紀的第一個十年奠定了 Java Web 服務框架的基礎, 那第二個十年就是 Java Web 服務框架的井噴. TechEmpower 最近一期的 Java Micro/全棧 Web 服務框架 有三十種之多. 而國內的 JFinal, Nutz, RedKale, tio-mvc 等各種框架也是諸雄並起, 整個行業一遍欣欣向榮(春秋戰國)景象.
2. "輕量" 的定(歧)義
曾幾何時, "輕量" 二字浮出水面. 溯其根源, 大致和當年的 EJB 有關. 話說老碼農一直是 Java 的忠實擁泵, 然而划線止於 J2EE, 其帶來的 EJB 各種概念與操作都讓老碼農感到十分別扭. 后續有人大膽離經叛道, 聲明 J2EE 太重, 業界需要一個更輕的框架, 於是乎 SpringFramework 應運而生, 可以說 "輕量" 這個定語早先原是修飾 Spring 的. 然而花開花落去, 10 年光陰在 IT 上也算得上滄海桑田, 昔日翩翩少年郎 Spring 如今也成了中年油膩大叔, 貌似和 "輕量" 早說再見, 而反被貼上了 "重量" 的標簽. "以彼之道還施彼身", 當下眾多框架(或其他工具軟件)似乎都深諳姑蘇慕容的絕技, 紛紛成為新一代的 "輕量" 產品, 仿佛如不加 "輕量" 二字就不得人歡喜. 各位看官如不相信且請點擊此處, 亦或有其他更為高明表達方式, 諸如 "Tiny", "無依賴", "極簡", "極速" 乃至 "Xxx走天涯" 等等, 以烘突"輕量"這個主題. 然則何為"輕量", 老碼農原以為很清楚的概念, 反思二三, 卻發現自己對此的理解竟然並不清晰. 於是決定寫下此篇博文記述下自己對此的思考
"輕量" 者, 重在於"輕", 無依賴且小 jar 包必然是輕了. 下一個問題是為何要 "輕", 或者說 "輕" 給開發/維護帶來何種好處. 如果說輕量定義限制於 "依賴少且小 jar 包" (下面且稱之為苗條框架), 可以立刻獲得的好處大致有:
- 易於上手
- 易於調試
- 降低甚至消除因為依賴過多引發版本沖突的可能
- 如果需要, 便於開發擴展功能, 甚至直接魔改核心代碼
然而世界上並沒有永久免費的面包, 也沒有銀彈, 就上面的"輕量"深入思考下去, 也會有不一樣的發現. 逐條分解一下:
- 易於上手 - 對新手當然再好不過, 然而新手只是一個階段, 而且是程序員生涯中比重很小的一個階段. 易於上手當然很重要, 但作為壓倒一切的目標來設計框架就沒有太大必要了. 從另一方面來講, 易於上手也不僅僅是苗條框架的專利, 設計簡潔的 API, 提供足夠的文檔和示例項目都可以讓框架變得更加易於上手.
- 易於調試 - 平心而論, 這一點至關重要, 因為這是每個開發人員時刻面臨的問題. 設想一下, 每次進入應用代碼都有超過 30 個堆棧, 途中還要歷經 N 個循環, 每次都有 20 個迭代; 從應用代碼返回之后也如此, 這樣的日常大概會讓開發人員郁悶到死. 然而是否只有苗條框架才能做到易於調試呢? 此處大大有思考余地, 元芳你怎么看?
- 降低甚至消除因為依賴過多引發版本沖突的可能 - 少依賴自然也就不太可能有版本沖突的可能; 可是為了減少版本沖突的可能就不要依賴了, 怎么看都有一種因噎廢食的感覺呢? 況且 Maven 體系的出現不就是為了管理依賴版本的復雜度嗎?
- 如果需要, 便於開發擴展功能, 甚至直接魔改核心代碼 - 很好的理由, 奈何只有一次性價值. 魔改核心代碼之后要追趕原項目的新版本就需要不停地做 catch-up, 除非大家自此處分道揚鑣. 至於開發擴展功能, 也並非少依賴小 jar 包的專利, 只要文檔好, 生態大, 自然擴展項目滾滾而來, 君不見 Spring 的擴展部隊比滅霸的還要厲害么
由此看來單純以苗條論英雄也有失之偏頗的地方. 而依賴這個概念原本衍生自"重用", 完全地否定"依賴"也就是在一定程度上否定了"重用". 老碼農對此表示不能贊同.下面就 "輕量" 這個概念繼續思考
3. 我對"輕量"的理解
老碼農感覺輕量不應該是對框架本身代碼量和依賴的衡量, 更為確切的講, 用戶(開發)玩起來的感覺才是定位框架輕量與否的指示. 鋼鐵俠的盔甲自重必然是很大的, 玩起來卻是輕量得很, 幾乎可以隨風起舞; 當然嫦娥仙子的天衣想來也必然輕量, 和鋼鐵俠的盔甲相比卻有各自有妙處. 由此可見, 用輕量來描述框架其實並不能確切地表達各自優點特性.
3.1 初始化項目的輕量
若是框架依賴眾多, 啟動一個空項目需要四處尋求依賴包 (貌似 maven 之前的世界差不多都是如此), 必然感覺不會輕量. 即便有了 maven, 然則 pom.xml 文件一寫就是洋洋數百行代碼, 也會感覺重重的. 當 pom 文件能用 10 來行乃至 50 行以內寫出來, 一頁紙可觀全貌, 幾遍依賴再多, 也不會覺得很重. 按此標准來看, 傳統的 SpringMVC 一定重於 SpringBoot + Starter 的項目. 當然苗條框架在這里也必然輕量, 無論是采用當今的 maven 方式, 還是拿一個 jar 包放進 lib 目錄的石器時代模式都不會很重.
3.2 代碼中的輕量
這里面的內容就太多了, 只能勉強挑揀幾個講述一二.
3.2.1 框架的表達力與代碼量
當框架有足夠的表達力的時候, 應用的代碼必定可以以少克多, 且不影響閱讀 (非常重要!). 舉個 Spring 注解改進的例子:
// 以前的表達 @RequestMapping(value = "/path", method = "POST") public void doJob() {...} // 新的表達 @PostMapping("/path") public void doJob() {...}
毫無疑問新的表達減少了手腕疲勞綜合症發生概率 5%, 且提高了代碼可讀性 5%. 由此可見框架表達力在提高生產力, 延長碼農使用壽命方面有非常重要的作用. 這是我們希望看到的輕量.
3.2.2 框架對於上下文環境的應變能力
一個 HTTP 請求附帶了大量的上下文信息, 比如 Accept
頭就是用來告訴服務端, 客戶端需要何種響應. 對於下面的代碼:
@GetAction("/employees") public Iterable<Employee> list() { return employeeDao.findAll(); }
如果請求的 Accept=application/json
框架能自動序列化 Iterable<Employee>
為 JSON 數組, 而當 Accept=text/csv
框架能自動生成 csv 下載文件, ... 這樣的框架必然減少了開發處理各種輸出格式的負擔, 少了很多相關代碼, 這也是我們希望看到的輕量
3.2.3 框架對於計算環境的適配能力
實例化一個控制器是否應該單例, 還是每個請求都需要新的控制器實例, 這需要回答對計算環境的要求. 簡單地說如果控制器實例沒有自己的狀態, 就應該采用單例, 例如:
@UrlContext("employees") public class EmployeeService { @Inject private EmployeeDao employeeDao; @GetAction public Iterable<Employee> list() {return employeeDao.findAll();} @PostAction public Employee create(Employee employee) {return employeeDao.save(employee);} ... }
上面的控制器 EmployeeService
只有一個字段 employeeDao
, 倘若該字段是無狀態的, 那 EmployeeService
也應該是無狀態的, 因此框架會自動采用單例來獲得控制器實例.
下面是例子則是不同的情況:
@UrlContext("my") public class MyProfileService { @LoginUser public User me; @GetAction public User getMyProfile() {return me;} }
這個控制器有一個狀態字段 me
, 該字段在每次請求進來的時候通過 token(或者 cookie) 綁定到當前登錄用戶, 因此每次處理新的請求必須初始化新的 MyProfileService
實例.
在上面的示例代碼中我們並沒有看到應用使用特別的手段 (比如加上 @Singleton 注解等) 來通知框架應該如何初始化控制器實例, 這是框架自動適配當前計算環境的能力; 這種能力可以讓開發人員寫出更加輕量的代碼.
3.2.4 框架對於應用參數類型的識別和處理能力
這一點對於 Web 服務框架尤其重要, 在請求端提供的數據是沒有類型的 (即便是 JSON encoded 的數據也只有有限數據類型), 而服務端的 Java 對象比然是有自己的數據類型, 因此自動將請求參數按照既定規則映射到 Java 數據可以節省應用大量的開發時間. 例如下面的請求處理方法:
@PostAction("employees") public Employee create(Employee employee) {return employeeDao.save(employee);}
能自動處理請求參數到 Java 參數 Employee 的綁定
基於 form 的請求 (ContentType=application/x-www-form-urlencoded
)
employee[firstName]=三
employee[lastName]=張
employee[email]=zhang3@comp.com
...
基於 JSON 的請求 (ContentType=application/json
)
{
"firstName": "三", "lastName": "張", "email": "zhang3@comp.com", ... }
能基於 Content-Type
頭自動實現對請求參數到 Java 聲明參數的綁定能大大減少應用的代碼量, 從而帶來開發人員喜聞樂見的"輕量".
3.3 對開發支持的輕量
這一點在上面 Playframework 介紹的時候曾經提到過. 老碼農認為和代碼輕量相比, 框架對開發支持的輕量同樣重要.
3.3.1 開發模式與產品模式
將框架運行時分為開發模式與產品模式是 PlayFramework 最先引入 Java Web 服務框架的. 這個區分可以讓框架作者愜意地引入開發時的支持而無需擔心對運行時性能或者安全的影響. 以下描述都基於開發模式討論
3.3.2 熱加載
框架監控文件系統的變化, 並在需要時重新加載更新后的源代碼或者配置文件, 讓開發人員只需要在瀏覽器上點擊 F5 重新加載頁面即可觀察到代碼更改帶來的變化; 整個過程在幾百毫秒到幾秒之內發生. 這一點帶來的生產力提高優勢太大了. 老碼農自己曾經在 SpringMVC 上開發項目, 每次重啟服務大概需要 10 秒左右, 時間雖然不是很長, 但整個開發反饋環因此暫停帶來的不快實在是很難忍受. 當開發框架有了熱加載支持之后開發的方式都發生了一些變化. 而帶來的身心愉悅就不多說了. 開發時熱加載可以讓開發感受到喜歡的輕量.
3.3.3 開發時錯誤提示頁面
開發過程中錯誤難免, 倘若框架能提供一些方便讓應用開發迅速定位錯誤點, 也能帶來輕量的感覺:
當路由找不到時:
當程序編譯錯誤時:
當程序運行時出錯時:
當模板頁面出錯時:
3.4 API 文檔的輕量
前后端分離漸成主流的形勢下, API 文檔愈發重要. 相應的工具 (如 Swagger) 也應運而生. 然而 Swagger 需要應用加入額外注解, 這是讓人感到稍微重滯的地方:
@ApiOperation(value = "View a list of available products", response = Iterable.class) @RequestMapping(value = "/list", method= RequestMethod.GET,produces = "application/json") public Iterable list(Model model){ Iterable productList = productService.listAllProducts(); return productList; }
倘若框架直接從 JavaDoc 中生成文檔則感覺又要輕量一點, 例如:
/** * Create a bookmark. * * Normal operation * * * It shall add a bookmark successfully with URL and brief description provided and respond with 201 and new bookmark ID. * * Exceptional cases * * * It shall respond 401 if a guest user (user that not logged in) submit request to add bookmark * * It shall respond 400 with error message "URL expected" when a logged in user submit request to add bookmark without URL provided * * It shall respond 400 with error message "description expected" when a logged in user submit request to add bookmark without description provided. * * Refer: [github issue](https://github.com/act-gallery/bookmark/issues/3) * * @param bookmark an new bookmark posted * @return ID of the new bookmark */ @PostAction @PropertySpec("id") public Bookmark create(@Valid Bookmark bookmark) { AAA.requirePermission(AppPermission.PERM_CREATE_BOOKMARK); bookmark.owner = me.email; return bookmarkDao.save(bookmark); }
生成 API 文檔如下:
此處框架直接將 JavaDoc 的內容格式化為 API 文檔描述, 同時生成請求 JSON 示例以及返回數據示例, 應用開發除了在方法的 JavaDoc 上寫清楚描述之外並沒有做任何額外工作; 而前端已經可以獲得非常清晰的 API 文檔. 這也是對開發大大有益的文檔的輕量
3.5 測試的輕量
Web 服務框架的測試麻煩開發皆知. 就一個簡單的 HelloWorld 程序, 其測試代碼大致可能為:
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) public class HttpRequestTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Test public void testGreetingService() throws Exception { assertThat(this.restTemplate.getForObject("http://localhost:" + port + "/", String.class)).contains("Hello World"); } }
這樣的測試對於開發來講實在是有點重. 實際上完全可以采用輕量得多的方式來表達相同的意思:
Scenario(Hello Service):
interactions:
- request:
get: / response: json: result: Hello World
運行測試當然也應該輕量:
命令行(CICD 環境)下運行:
或者在開發時調試運行:
自動測試之所以難, 難在寫測試用例的麻煩. 如果框架能夠以一種簡單的方式讓開發寫測試用例, 且支持易行的方式來運行測試用例, 這種輕量化將讓自動測試不再成為開發人員的阻抗, 而是一種動力.
3.6 部署的輕量
傳統基於 Servlet 的部署並不是一個很舒適的過程. 老碼農理解的部署輕量可以是:
直接打包 -> scp 上傳 -> 運行遠端腳本暫停服務並解包重啟服務. 這個過程應該可以在 Jenkins 里面簡單配置完成.
或者可以稍微前衛一點, 直接打個 docker 包?
4. 總結
老碼農最近對 Java web 服務端框架中的 "輕量" 做了一點自己的分析與思考, 在本文中分享出來. 希望能夠為各位 Java web 端玩家帶來一點不同的意見, 歡迎大家在評論中就這方面發表自己的看法, 只要有道理, 贊同與反對都是好評論.