聲明式編程
聲明式編程可以提高程式整體的可讀性(面向人、機器),包括不限於宣告類型、聲明依賴關係、聲明API路徑/方法/參數等等。從面向機器的角度,聲明式的好處在於可以方便的提取這些元資訊進行二次加工。聲明式也是對系統整體的思考,找到關注點,劃分切面,提高重用性。從命令式到聲明式,是從要怎麼做,到需要什麼的轉變。
本文偏重於 Egg 中的實踐、改造,偏重於系統整體,在具體實現功能的時候,比如使用 forEach/map 替代 for 迴圈,使用 find/include 等替代 indexOf 之類的細節不做深入。
Controller
Controller 作為系統對外的介面,涉及到前後端互動,改變帶來的提升是最明顯的。
在 Java 體系裡,Spring MVC 提供了一些標準的註解來支援API定義,一種普通的寫法是:
@RequestMapping(value = "api/foo/{fooId}", method = RequestMethod.POST)@ResponseBodypublic Result<Void> create(HttpServletRequest request) { Boolean xxxx = StringUtils.isBlank(request.getParameter("fooId")); if (無許可權) { ... } ...// 日誌記錄}
這種聲明式的寫法使我們可以很容易的看出這裡聲明了一個 POST 的API,而不需要去找其他商務邏輯。不過這裡也有一些問題,比如需要通讀代碼才能知道這個API的入參是 fooId,而當 Controller 的邏輯很複雜的時候呢?而許可權判斷之類的邏輯就更難看出了。
很顯然這種寫法對於看代碼的人來說是不友好的。這種寫法隱藏了參數資訊這個我們關注的東西,自然很難去統一的處理入參,像參數格式化、校正等邏輯只能和商務邏輯寫在一起。
而另一種寫法就是把參數聲明出來:
@RequestMapping(value = "api/foo/{fooId}", method = RequestMethod.POST, name = "建立foo")@ResponseBodypublic Result<Void> create(@PathVariable("fooId") String fooId, Optional<boolean> needBar) { ...}
(Java 也在不斷的改進,比如 JDK 8 加入的 Optional<T> 類型,結合 Spring 就可以用來標識參數為可選的)
這些都是在 Java/Spring 設計之內的東西,那剩下的比如許可權、日誌等需求呢?其實都是同理,這種系統上的關注點,可以通過劃分切面的方式把需求提取出來,寫成獨立的註解,而不是跟商務邏輯一起寫在方法內部,這樣可以使程式對人,對機器都更可讀。
抽象許可權切面:
/** * 建立foo * @param fooId * @return */@RequestMapping(value = "api/foo/{fooId}", method = RequestMethod.POST, name = '建立Foo')@Permission(Code = PM.CREATE_FOO) // 假設許可權攔截註解@ResponseBodypublic Result<Void> create(@PathVariable("fooId") String fooId) { ...}
面向機器
聲明式的優點不僅是對人更可讀,在用程式做分析的時候也更方便。比如在日常開發中,經常有需求是後端人員需要給前端人員提供API介面文檔等資訊,最常用的產生文檔的方式是寫完善的注釋,然後通過 javadoc 可以很容易的編寫詳細的文檔,配合 Doclet API 也可以自訂 Tag,實現自訂需求。
注釋是對代碼的一種補充,從代碼中可以提取的資訊越多,注釋中冗餘的資訊就可以越少,而聲明式可以降低提取的成本。
得益於 Java 的反射機制,可以容易的根據代碼提取介面的路由等資訊,還可以根據這些資訊直接產生前端調用的SDK進一步簡化前端調用成本。
*ASP.NET WebAPI 也有很好的實現,參見官方支援:Microsoft.AspNet.WebApi.HelpPage
Egg
有了 Java 的前車之鑒,那在 Egg 中是不是也可以做相應的最佳化呢?當然是可以的,在類型方面有著 TypeScript 的助攻,而對比 Java 的註解,JavaScript 裡的裝飾器也基本夠用。
改造前:
// app/controller/home.jsexport class HomeController { async getFoo() { const { size, page } = this.ctx; ... }}// app/router.jsexport (app) => { app.get('/api/foo', app.controller.home.getFoo);}
改造後:
// app/controller/home.tsexport class HomeController { @route('/api/foo', { name: '擷取Foo資料' }) async getFoo(size: number, page: number) { // js的話,去掉類型即可 ... }}
使用裝飾器的 API 可以實現跟 Java 類似的寫法,這種方式也同時規範了註冊路由的方式及資訊,以此來產生API文檔、前端SDK這類功能當然也是可以實現的,詳情:egg-controller 外掛程式
JavaScript 的實現的問題就在於缺少類型,畢竟代碼裡都沒寫嘛,對於簡單情境倒也足夠。當然,我們也可以使用 TypeScript 來提供類型資訊。
TypeScript
其實從 JavaScript 切換到 TypeScript 的成本很低,最簡單的方式就是將尾碼由 js 改成 ts,只在需要的地方寫上類型即可。而類型系統會帶來許多方便,編輯器智能提示,類型檢查等等。像 Controller 裡的API出入參類型,早晚都是要寫一遍的,無論是是代碼裡、注釋裡還是文檔裡,所以何不一併搞定呢?而且現在 Egg 官方也提供了針對 TypeScript 便捷的使用方式情節,可以嘗試一下。
反射/中繼資料
TypeScript 在這方面對比 Java/C# 還是要弱不少,只能支援比較基礎的中繼資料需求,而且由於 JavaScript 本身模組載入機制的原因,TypeScript 只能針對使用 decorators 的 Function、Class 添加中繼資料。比如泛型、複雜類型欄位等資訊都無法擷取。不過也有曲線的解法,TypeScript 提供了 Compiler API,可以在編譯時間添加外掛程式,而在編譯期,由於是針對 TypeScript 代碼,所以可以擷取到豐富的資訊,只是處理難度較大。
依賴注入
在其他組件層面也可以應用聲明式編程來提升可讀性,依賴注入就是一種典型的方式。
當我們拆分了兩個組件類,A 依賴 B 的時候,最簡單寫法:
class A { foo() {}}class B { bar() { const a = new A(); }}
可以看到 B 直接執行個體化了對象 A,而當有多個類依賴 A 的話呢?這種寫法會導致建立多個 A 的執行個體,而放到 Egg 的環境下,Service 是有可能需要 ctx 的,那麼就需要 const a = new A(this.ctx); 顯然是不可行的。
Egg 的解決方案是通過 loader 機制載入類,在 ctx 設定多個 getter ,統一管理執行個體,在首次訪問的時候初始化執行個體,在 Egg 項目中的寫法:
public class FooService extends Service { public foo() { this.ctx.service.barService.bar(); ... }}
為了實現執行個體的管理,所有組件都統一掛載到了 ctx 上,好處是不同組件的互訪問變得非常容易,不過為了實現互訪問,每個組件都強依賴了 ctx,通過 ctx 去尋找組件,大家應該也看出來了,這實際上在設計模式裡是服務定位器模式。在 TypeScript 下,類型定義會是問題,不過 Egg 做了輔助的工具,可以根據符合目錄規範的組件代碼產生對應的類型定義,通過 TypeScript 合并聲明的特性合并到 Egg 裡去。這也是當前性價比很高的方案。
這種方案的優點是互訪問方便,弊端是 ctx 上掛載了許多與 ctx 本身無關的組件,導致 ctx 的類型是分布定義的,比較複雜,而且隱藏了組件間的依賴關係,需要查看具體的商務邏輯才能知道組件間依賴關係。
那在 Java/C# 中是怎麼做的呢?在 Java/C# 中 AOP/IoC 基本都是各個架構的標配,比如 Spring 中:
@Componentpublic class FooService { @Autowired private BarService barService; public foo() { barService.bar(); ... }}
當然,在 Java 中一般都是聲明注入 IFooService 介面,然後實現一個 IFooServiceImpl,不過在前端基本上不會有人這麼幹,沒有這麼複雜的需求情境。所以依賴注入在前端來說能做的,最多是將依賴關係明確聲明,將與 ctx 無關的組件與 ctx 解耦。
Egg 中使用依賴注入改造如下:
public class FooService extends Service { // 如果不依賴 ctx 資料,也可以不繼承 // ts @lazyInject() barService: BarService; // js @lazyInject(BarService) barService; public foo() { this.barService.bar(); ... }}
換了寫法之後,可以直觀的看出 FooService 依賴了 BarService,並且不再通過 ctx 擷取 BarService,提高了可讀性。而依賴注入作為執行個體化組件的關注點是可以簡單的實現一些面向切面的玩法,比如依賴關係圖、函數調用跟蹤等等。
egg-aop,Egg 下 AOP / IoC 外掛程式
結語
代碼是最好的文檔,代碼的可讀性對後續可維護性是非常重要的,對人可讀關係到後續維護的成本,而對機器可讀關係到自動化的可能性。聲明式編程更多的是去描述要什麼/有什麼而非怎麼做,這在描述模組/系統間的關係的時候協助很大,無論是自動化產出文檔還是自動產生調用代碼亦或是Mock對接等等,這都減少了重複勞動,而在大談智能的時代,資料也代表了另一種可能性。