开源 | 应用 Umajs 高效构建 Node.js Web 工程指南
导读
Umajs是一个简单易用、扩展灵活,基于TypeScript的Node.js Web框架。从 2018 年立项至今,Umajs团队持续的对框架打磨、迭代,在生产环境稳定运行近两年后,于2020年8月份开源。源码地址https://github.com/wuba/Umajs。
Uma 是 Ursa Major 的缩写,意为大熊座。北斗七星都是它的组成部分;正如同 Umajs 也是由不同的 package 所组合在一起。我们希望 Umajs 的每一部分,都是优秀的、闪耀的、经受的住各种大型项目检验的。
Umajs简介
参数装饰器:内置丰富的参数装饰器,同时也支持自定义参数装饰器;通过参数装饰器可以快速的提取、校验、转换、聚合用户输入为我们所需要的格式;
统一返回:统一返回是 Controller 的便捷返回。通过统一返回机制,我们可以快速的对返回结果进行修改;当然框架也支持普通的返回方式;
切面:运用切面机制可以显著提高代码的可复用性、降低业务逻辑之间的耦合度;Umajs 可以轻松的将中间件转换为切面方法以便于复用 Koa 社区丰富的中间件资源;
相信透过对框架的命名,你能感受得到开发团队对 Umajs 的期许:灵活、好用。而上述对框架优势的简单介绍也让我们对 Umajs 建立起一个初步的印象。接下来就让我们一起通过示例代码来了解这些核心优势是如何帮助开发者构建简单易用、扩展灵活的 Web 应用。
处理用户输入
开始之前我们先对 Controller 的功能做一个简单的定义:处理用户输入,返回处理结果给用户。Controller 是 Web 应用非常重要的组成部分,在大部分 Node.js Web 应用中——例如常见的 BFF(Backend for frontend)——尤其重要。
而 Umajs 内置了丰富的装饰器用于处理用户的输入。
1、装饰器
我们先简单认识一下装饰器。
假设你今天晚上和朋友约了去打篮球,那你肯定是要带上篮球鞋过去的;但如果晚上要和女朋友共进烛光晚餐,那最好还是带上一束鲜花对不对?
而无论是 AJ 还是玫瑰,相对于你本人来说都是身外之物,但又切切实实的能对你有帮助:心爱的篮球鞋使得你气势+1、恰到好处的红玫瑰使你魅力+1,而机械键盘和降噪耳机则让你在敲代码写文档时如虎添翼。
这就是装饰器的应用:在不改变原对象的情况下,为原对象添加额外的功能、加个 BUFF。
ES7 中引入了 @decorator 修饰器的提案,TypeScript 已经支持了这个提案。
在处理用户输入方面,Umajs 使用的是参数装饰器。
2、数据获取
举例来说有如下请求:
GET /users/10269?role=admin
上述请求对应的路由path是/users/:id,role=admin
是这个请求的 QueryString。
在Umajs中我们可以通过内置的参数装饰器@Param获取路由参数,通过@Query获取 QueryString 上的 value,代码示例如下:
// controller/index.controller.ts
@Path('/users/:id')
getUser(@Param('id') uid: string, @Query('role') role: string) {
// 查询过程略
return Result.json({ uid, role });
}
参数装饰器
@Param('id') uid: string
的参数id代表待获取参数的 key,返回值uid代表待获取参数的 value,返回值可以直接在方法里使用。
同样的,对于POST请求,框架提供了@Body装饰器来快捷获取 body 数据。
通过框架内置的这些参数装饰器获取请求的参数称的上是轻巧快捷了。除此之外,框架还提供了自定义参数装饰器的功能以便于针对不同的业务场景做定制化处理。接下来我们看一下如何实现自定义参数装饰器:
// decorator/MyQuery.ts
import { createArgDecorator, IContext } from '@umajs/core';
export const MyQuery = createArgDecorator(
(ctx: IContext, key: string) => ctx.query[key],
);
通过示例代码我们可以看到,使用createArgDecorator能够很轻松的创建自定义参数装饰器:它接收一个函数作为参数,这个函数有context和key两个参数,key作为一个可选参数代表我们想在自定义参数装饰器中获取的参数字段,这样我们就能在context
上获取相应的数据。示例代码实现了一个简易的@Query参数装饰器,实际应用中我们能做的功能不止于此。
3、数据检查与转换
// decorator/AgeCheck.ts
export const AgeCheck = createArgDecorator(
(ctx: IContext, ageKey: string) => {
let age = ctx.query[ageKey];
if (age === undefined) {
return Result.json({
code: 0,
msg: '请加上 age 参数',
});
}
age = +age;
if (Number.isNaN(age) || age < 0 || age > 120) {
return Result.json({
code: 0,
msg: '请传入正确的 age 参数',
});
}
return age;
}
);
在这段示例中我们对输入的年龄数据进行了提取与检查,如果没有传递年龄参数、或者传递的年龄参数不符合预期,则返回相应的提示信息给用户。推而广之,我们在实际应用中可以对任何需要检查的字段使用自定义参数装饰器进行校验,而这些校验逻辑是可以轻松复用的。
在实际应用中我们还有如下场景,前端传递的是 yyyy-MM-dd 格式的日期数据,而数据库或者第三方服务需要的是时间戳格式,那么我们也可以在自定义参数装饰器中对其进行转换:
// decorator/ToTimestamp.ts
export const ToTimestamp = createArgDecorator(
(ctx: IContext, dateKey: string) => {
const dateStr = ctx.query[dateKey];
// 转换 yyyyMMdd 为时间戳
return dateStrToTimestamp(dateStr);
},
);
有了以上两个参数装饰器:GET /age?age=22&date=2020-10-10这个请求我们就可以在 Controller 的方法中使用它们来进行数据检查与转换,而 Controller 方法则专注于具体业务逻辑的处理,从而实现关注点分离:
@Path('/age')
age(@AgeCheck('age') age: number, @DateCheck('date') date: string) {
return Result.send(`date is ${date}, age is ${age}`);
}
实际上除了@Param、@Query外,Umajs 还通过扩展包@umajs/arg-decorator提供了丰富的常用参数装饰器,详见文档:内置参数装饰器参考文档。
4、数据聚合
通过以上几个示例,相信大家对于 Umajs 参数装饰器的便捷之处有了一定的认识。然而实际开发中我们还需要面对一些更复杂的场景。举例来说,第三方接口所需的 DTO(Data Transfer Object)其属性一部分可能来自于 param、query,另一部分则可能来自于 Cookie 甚至是第三方服务。
例如下面UserDTO中,uname来自于 param,role则来自于 query,而operator则是根据 Cookie 从 sso service 中获取:
针对这种情况我们可以使用自定义参数装饰器来封装这些繁琐的、从不同地方获取字段值的操作:
// decorator/UserDTO.ts
export const GetUser = createArgDecorator(
(ctx: IContext) => {
const user = new UserDTO();
user.uname = ctx.param.uname;
user.role = ctx.query.role || 'user';
user.operator = ctx.uid || 10269;
return user;
},
);
在 Controller 方法里直接通过@GetUser获取并使用相应的 DTO 实例:
// controller/index.controller.ts
@Path('/user/:uname')
async addUser(@GetUser() dto: UserDTO) {
const data = await this.userService.addUser(dto);
return Result.json(data);
}
针对复杂场景我们可以通过自定义一个强大的参数装饰器以实现获取、校验、聚合一体,从而分离业务逻辑与其它逻辑,实现代码灵活复用。
5、 参数装饰器小结
通过以上几个示例为大家展示了 Umajs 参数装饰器是如何实现对参数的快速获取、校验、转换及聚合。针对常用场景 Umajs 提供了一系列的内置参数装饰器。
返回处理结果
1、拦截并替换返回值
我们先看一段示例代码:
// decorator/AgeCheck.ts
export const AgeCheck = createArgDecorator(
(ctx: IContext, ageKey: string) => {
let age = ctx.query[ageKey];
if (age === undefined) {
return Result.json({
code: 0,
msg: '请加上 age 参数',
});
}
age = +age;
if (Number.isNaN(age) || age < 0 || age > 120) {
return Result.json({
code: 0,
msg: '请传入正确的 age 参数',
});
}
return age;
}
);
想必朋友们也发现了,又是这个AgeCheck的代码。放心,代码没有粘错😁 。在这个章节里我们的关注点和上一章有所不同:请大家注意,当参数校验未通过的时候,我们通过return Result.json(data)这段代码把对应的错误信息抛给了接口。
这就是 Umajs 的统一返回机制: 在 Controller 的方法里返回Result而不是直接操作context。统一返回本质上仍是对如下传统方式的包装,并且 Umajs 仍然支持传统的方式。
ctx.body = 'happy hacking';
ctx.status = 200;
但是传统方式如果想对返回结果进行修改是比较麻烦的,而使用了统一返机制则相当简单。譬如上述AgeCheck装饰器,在校验未通过后可以直接返回Result,这个返回值代替了被修饰的 Controller 方法的返回值;
在没有统一返回的情况下,如果想要拦截返回值,我们在AgeCheck装饰器中直接修改了ctx;而在执行 Controller 方法的时候仍然会对ctx进行修改。而装饰器的执行机制决定了AgeCheck装饰器所修改的ctx会被目标方法的修改覆盖掉。如果在AgeCheck装饰器中主动抛出异常固然阻止了目标方法修改ctx,但是又需要进行额外的异常处理;
对比之下,统一返回机制是不是方便了很多?
2、修改当前返回值
AgeCheck
装饰器演示了对返回值的替换,接下来我们探讨一下如何修改返回值:
export default class implements IAspect {
@Inject(Timestamp)
timestamp: Timestamp;
async around(proceedPoint: IProceedJoinPoint<any>) {
const { proceed, args } = proceedPoint;
const result = await proceed(...args);
result.stamp = this.timestamp.getTimestamp();
return result;
}
}
上述代码是一个切面方法,通过切面的 around 方法获取到了目标方法执行后的返回值,并且为这个返回值增加了一个时间戳字段。是不是很轻松就实现了对返回值的修改?
关于切面会在稍后讨论。
3、统一返回内置类型及扩展
为了便于使用,Umajs 的统一返回机制封装了常用的返回类型,如:
json
view
redirect
stream
jsonp
download
send
假如上述这些返回类型仍不足以应对某些场景,那么统一返回也支持通过插件的方式自定义扩展返回类型:
4、统一返回机制小结
以上几个示例介绍了 Umajs 的统一返回机制, 以及如何在校验未通过等场景下使用参数装饰器拦截返回值、通过切面方法修改返回值;统一返回机制内置了常用的返回类型,并且支持通过插件进行扩展。统一返回机制的意义不仅在于它封装了常见的返回类型,更重要的是,通过它我们能够对返回值进行便捷的干涉以应对不同的业务场景。
切面
请问大家现在外出后回到家中第一件事情是什么?我想大部分人回家第一时间是洗手。那出门前的最后一件事呢?没错,是戴口罩。
戴口罩这个动作相对于出门这件事而言,就是切面。出门则是切点,在这个点的前后我们可以插入一些其他的动作,比如关灯、锁门等等。
这就是 AOP 面向切面编程,是对 OOP 的补充。利用 AOP 可以对业务逻辑的各个部分进行隔离,也可以隔离业务无关的功能,从而使得业务逻辑各部分之间的耦合度降低,提高业务无关的功能的复用性,也就提高了开发的效率。
在 Umajs 中使用了 Aspect 装饰器来实现 AOP。
1、切面的执行
在上一章中我们演示了如何使用切面,Aspect,修改了返回值。在 Umajs 中 Aspect 的执行顺序如下:
可以看到,切面有如下几个方法:
around 环绕通知,包裹目标方法;
before 前置通知,在目标方法之前执行;
after 后置通知,在目标方法之后执行;
afterReturing 最终通知,方法执行成功后执行该切面;
afterThrowing 异常通知,处理未捕获的异常。
首先执行 around 的 before 部分,接下来执行 before ,然后是目标方法的执行;
如果目标方法的执行抛出了未捕获异常,则执行 after,然后执行 afterThrowing;
如果目标方法执行成功,则执行 around 的 after 部分,然后执行 after,最后在目标方法成功返回后执行 afterReturning;
多个 Aspect 的执行顺序为包裹型。
结合示意图来看,相信大家对Aspect.around这个切面方法有一种莫名的亲切感对不对?没错,它与 Koa 大名鼎鼎的洋葱模型基本一致。
2、切面的使用
// aspect/test.aspect.ts
export default class implements IAspect {
before() {
console.log('test: this is before');
}
// 其它通知略
}
// controller/index.controller.ts
@Aspect.before('test')
export default class Index extends BaseController {
@Aspect('auth')
@Path('/users/:id')
getUser(@Param('id') uid: string, @Query('role') role: string) {
// 其它代码略
return Result.json({ role });
}
}
切面的实现很简单,继承IAspect后实现基类的around、before等通知方法即可。
通过上述示例代码,可以看得出:
@Aspect装饰器既可以修饰类,也可以修饰类的方法;
@Aspect修饰类的时候,对类的所有方法都生效;
@Aspect既可以默认使用所有通知,也可以通过@Aspect.before这种方式指定特定的通知;
3、Aspect.around
在 Aspect 的五种通知中,,从它们的函数签名就可以看得出,Aspect.around是比较特别的一个。在 Umajs 中,它也是唯一一个能够修改返回值的通知类型:
export interface IAspect {
before?(point: IJoinPoint): void;
after?(point: IJoinPoint): void;
around?(proceedPoint: IProceedJoinPoint): Promise<Result>;
afterReturning?(point: IJoinPoint, val: any): void;
afterThrowing?(err: Error): void;
}
export interface IProceedJoinPoint<T = any> extends IJoinPoint<T> {
proceed(...props: any[]): Promise<any>;
}
export interface IJoinPoint<T = any> {
target: T;
args: Array<any>;
}
为什么只有Aspect.around能够修改返回值呢 ?通过函数签名我们看得出Aspect.around的切点类型相比其他通知多了一个proceed。这个函数就是被环绕通知所修饰的目标方法,执行这个函数自然会返回目标方法的返回值,那么我们在Aspect.around这里修改目标方法的返回值是不是也显得和合理呢?
Aspect.around
的proceed和 Koa 中间件的next有异曲同工之处。
另一方面,它的特别之处还在于它和 Koa 中间件一样都属于洋葱模型。
共同点:
两者都是洋葱模型,包裹现有方法;
他们都能够拦截现有方法、进行错误处理等等;
差异点:
Aspect.around针对目标方法生效,而中间件针对请求生效;
Aspect.around能够对目标方法的参数和返回结果进行修改,而中间件无法处理这些;
而为了利用 Koa 社区丰富的中间件资源,Umajs 提供了middlewareToAround方法,通过这个方法我们能够以Aspect.around的方式来使用中间件:
import { IAspect, middlewareToAround } from '@umajs/core';
import mw from 'demo-middleware';
// aspect/middleware.aspect.ts
export default class implements IAspect {
around = middlewareToAround(mw())
}
这种转换方式适用于有局部加载需求的中间件,转换后不但代码结构更加清晰,其性能也有一定的提升。
而对于有全局加载需求的中间件,可以通过 Umajs 的插件形式来使用中间件。
4、切面应用场景
Aspect 的应用场景可以说是非常广泛,除了我们之前提到的对于参数、返回值的处理,还有例如埋点\日志、性能监控、事务性操作等等。
5、切面小结
以上几个示例介绍了:
Umajs 的 Aspect 执行机制;
Aspect 的多种使用方式;
Aspect.around这个通知的强大之处以及它与 Koa 中间件的异同;
middlewareToAround方法能够将中间件快速转换为Aspect.around;
对于有全局加载需求的中间件,可以通过 Umajs 的插件形式来使用中间件。
参数装饰器、统一返回、切面小结
在以上三个小节中,我们为大家分别演示讲解了 Umajs 的参数装饰器、统一返回和切面以及他们的应用。
一起来回顾一下:
参数装饰器 Umajs 内置丰富的参数装饰器,同时提供强大的自定义参数装饰器功能,用于处理输入;
统一返回机制 Umajs 的统一返回机制使得我们可以更便捷、更灵活的处理 Controller 方法返回值;
Aspect 切面和参数装饰器、统一返回机制的有机结合足以面对绝大多数的复杂业务场景;
实际开发中,我们通过 Umajs 所提供的丰富的参数装饰器能够快速处理输入 + 统一返回机制提供对输出的拦截修改等操作 + Aspect 按需修改参数、返回值的有机结合,能够轻松应对绝大多数的复杂业务场景、实现关注点分离。
关于关注点分离,有个小故事可以和大家分享一下:我们都知道瓦特发明蒸汽机的故事。但实际上瓦特是对原纽可门式蒸汽机做了很多改良从而使得蒸汽机更流行了起来。他所做的非常重要的一项改良就是蒸汽机的冷却系统。纽可门蒸汽机在冷却的时候会将冷水注入气缸,使水蒸气凝结。而瓦特发现,这种方式固然会凝结水蒸气,但是也将气缸降温了,造成了能量的浪费。于是他改良了冷凝器,只降温水蒸气而不降温气缸,从而大大提升了蒸汽机的效率。
这在开发中相对应的就是关注点分离、单一职责原则:把核心的功能剥离出来,降低单个方法的复杂度,不同的方法承担不同的职责。这样除了能够复用代码外,另一个很重要的优势就是其中一个职责的变化不会影响其他职责继续履行。比如在之前的示例中,我们修改参数检查机制不会对 Aspect.around 的功能造成任何影响,两者井水不犯河水,哪怕是经过转译、压缩和混淆,我们也可以放心的使用这些方法。
而且无论是参数装饰器还是统一返回机制都提供了强大的自定义方法,在框架本身不满足业务需求的情况下,能够灵活的进行自定义扩展。这也是为什么我们称 Umajs 为扩展灵活的框架。
写在最后
优秀的框架能够辅助开发者进行设计、降低重构的成本。
Umajs 就是我们针对业务项目里所存在的这些问题而进行的一系列探索的产出:它来自于具体业务、最终也服务于具体业务;无论是参数装饰器还是切面,都是围绕着关注点分离这一主旨而展开,通过提供这些简洁的、行之有效的方式来方便使用者对业务逻辑进行拆分、进而促进使用者对代码的思考:在这套范式下我们可以把校验逻辑、通信逻辑、计算逻辑、转换逻辑轻松的与主业务逻辑做切割;而通过上述这些手段对代码职责进一步的细化,显著提高可复用性之余,也更加便于重构和优化:修改或者优化某处切割出去的逻辑,完全不会影响其它的逻辑。
它不仅仅是一系列常用工具的集合与封装,它更像是我们对代码的一种期许。
Umajs 还有很多其它的特性没有在本文介绍,例如 IOC、插件机制等等,感兴趣的小伙伴可以在 https://github.com/wuba/Umajs 进一步了解。现在它可能仍有不完善的地方,如果您有任何意见或者建议,欢迎联系我们,欢迎提 issue 和 PR。
Taro 3.2 版本正式发布:React Native 支持,王者归来