宣告式程式設計
宣告式程式設計可以提高程式整體的可讀性(面向人、機器),包括不限於宣告型別、宣告依賴關係、宣告API路徑/方法/引數等等。從面向機器的角度,宣告式的好處在於可以方便的提取這些元資訊進行二次加工。宣告式也是對系統整體的思考,找到關注點,劃分切面,提高重用性。從命令式到宣告式,是從要怎麼做,到需要什麼的轉變。
本文偏重於 Egg 中的實踐、改造,偏重於系統整體,在具體實現功能的時候,比如使用 forEach/map
替代 for
迴圈,使用 find/include
等替代 indexOf
之類的細節不做深入。
Controller
Controller 作為系統對外的介面,涉及到前後端互動,改變帶來的提升是最明顯的。
在 Java 體系裡,Spring MVC 提供了一些標準的註解來支援API定義,一種普通的寫法是:
@RequestMapping(value = "api/foo/{fooId}", method = RequestMethod.POST)
@ResponseBody
public 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")
@ResponseBody
public 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) // 假設許可權攔截註解
@ResponseBody
public 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.js
export class HomeController {
async getFoo() {
const { size, page } = this.ctx;
...
}
}
// app/router.js
export (app) => {
app.get(`/api/foo`, app.controller.home.getFoo);
}
改造後:
// app/controller/home.ts
export 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 中:
@Component
public 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,提高了可讀性。而依賴注入作為例項化元件的關注點是可以簡單的實現一些面向切面的玩法,比如依賴關係圖、函式呼叫跟蹤等等。
結語
程式碼是最好的文件,程式碼的可讀性對後續可維護性是非常重要的,對人可讀關係到後續維護的成本,而對機器可讀關係到自動化的可能性。宣告式程式設計更多的是去描述要什麼/有什麼而非怎麼做,這在描述模組/系統間的關係的時候幫助很大,無論是自動化產出文件還是自動生成呼叫程式碼亦或是Mock對接等等,這都減少了重複勞動,而在大談智慧的時代,資料也代表了另一種可能性。