声明式编程
声明式编程可以提高程序整体的可读性(面向人、机器),包括不限于声明类型、声明依赖关系、声明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 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 create(@PathVariable("fooId") String fooId, Optional needBar) {
...
}
(Java 也在不断的改进,比如 JDK 8 加入的 Optional
类型,结合 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 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,提高了可读性。而依赖注入作为实例化组件的关注点是可以简单的实现一些面向切面的玩法,比如依赖关系图、函数调用跟踪等等。
egg-aop,Egg 下 AOP / IoC 插件
结语
代码是最好的文档,代码的可读性对后续可维护性是非常重要的,对人可读关系到后续维护的成本,而对机器可读关系到自动化的可能性。声明式编程更多的是去描述要什么/有什么而非怎么做,这在描述模块/系统间的关系的时候帮助很大,无论是自动化产出文档还是自动生成调用代码亦或是Mock对接等等,这都减少了重复劳动,而在大谈智能的时代,数据也代表了另一种可能性。