Midway 是阿里巴巴内部开发的基于 TypeScript 的 Node.js 研发框架,在集团内部,由于其集成了内部的各类基础服务与稳定性监控,同时支持 FaaS 函数部署,所以是内部 Node.js 应用研发的首选框架。
虽然 Midway 结合了面向对象(OOP + Class + IoC)与函数式(FP + Function + Hooks)两种编程范式,但考虑到一般项目大多采用面向对象的开发方式,所以本文也重点阐述针对面向对象这种范式,在工程开发中可以参考的代码设计。
基于 MVC 的工程目录设计
在 Midway 工程开发中,一般建议采用如下工作目录组织业务代码。config 目录中的代码内容,根据自身需要,结合官方的配置文档即可正确标准的完成配置,在本文中不做过多讲解。
- config 配置文件目录,存放不同环境的差异配置信息
- constant 常量存放目录,存放业务常量及国际化业务文案
- controller 控制器存放目录,存放核心业务逻辑代码
- dto 数据传输对象目录,存放外部数据的校验规则
- entity 数据实体目录,存放数据库集合对应的数据实体
- middleware 中间件目录,存放项目中间件代码
- service 服务逻辑存放目录,存放数据存储、局部通用逻辑代码
- util 工具代码存放目录,存放业务通用工具方法
当请求进入时,各目录对应的代码发挥了如下的功能作用:
- Middleware 作为起始逻辑进行通用性逻辑执行。
- 接着 DTO 层对参数进行校验。
- 参数校验无异常进入 Controller 执行整体业务逻辑。
- 数据库的调用,或者整体性比较强的通用业务逻辑会被封装到 Service 层方便复用。
- 工具方法、常量、配置和数据库实体则作为工程的底层支撑,向 Service 或 Controller 返回数据。
- Controller 吐出响应结果,如果响应异常,Middleware 进行逻辑兜底。
- 最终吐出响应数据返回给用户。
整理一下,你可以这么分类,在 MVC 中,C 层对应为 Middleware + DTO + Controller;M 层对应为 Service;V 层由于一般后端只提供对外的接口,不会有太多静态页面透出,所以暂时可以忽略。当然,Service 层有一定的边界混淆,它不仅仅只包含 Model 模型层,否则我们就直接起名成 Model 层好了,在 Service 中,我也会把一些可抽象、可复用的逻辑放入其中,来缓解一下复杂业务中 Controller 逻辑过于繁琐的问题。
了解上述 Midway 代码目录的设计思考后,就分别对每一个部分展开代码设计上的一些经验分享。
Middleware 层的代码建议
在开发中,业务中间件可以自行设计开发,这依赖于你的业务诉求。但是,代码执行异常,可以通过下述方案比较优雅的完成处理。
异常兜底容错中间件
代码执行异常,是指在执行业务代码过程中,可能产生的执行错误。一般来说,为了解决这种潜在的风险,我们可以在逻辑外层增加 try catch 语句进行处理。在很多工程中,由于为了做异常处理,增加了大量的 try catch 语句;还有很多工程中,没有考虑异常处理的问题,根本就没有做 try catch 的兜底容错。
// 以下代码缺少异常兜底冗错
@Get('/findOne')@Validate()
async findOne(@Query(ALL) appParams: AppFindDTO) {
const { id } = appParams;
const app = await this.appService.findOneAppById(id);
return getReturnValue(true, app);
}
// 以下代码每个函数都要有一个 try catch 包裹
@Get('/findOne')@Validate()
async findOne(@Query(ALL) appParams: AppFindDTO) {
try {
const { id } = appParams;
const app = await this.appService.findOneAppById(id);
return getReturnValue(true, app);
} catch(e) {
return getReturnValue(false, e.message);
}
}
使用中间件,就可以解决上面的两个问题,你可以编写如下中间件:
@Provide('errorResponse')
@Scope(ScopeEnum.Singleton)
export class ErrorResponseMiddleware {
resolve() {
return async (ctx: FaaSContext, next: () => Promise) => {
try {
await next();
} catch (error) {
ctx.body = getReturnValue(
false,
null,
error.message || '系统发生错误,请联系管理员'
);
}
};
}
}
将这段中间件代码加入到程序的执行逻辑中,编写代码时,你就无需再关注代码执行异常的问题,中间件会帮你捕获程序执行异常并标准化返回。同时,你也可以在这里统一做异常的日志收集或实时预警,扩展更多的功能。所以这个中间件设计,强烈推荐在工程中统一使用。
DTO 层的代码建议
DTO 层,也就是数据传输对象层,在 Midway 中,主要是通过它来对 POST、GET 等请求的请求参数进行校验。在实践的过程中,有两方面的问题需要在设计中着重关注:合理的代码复用、明确的代码职责划分。
合理的代码复用
首先我们看一下不合理的 DTO 层的代码设计:
// 第一种问题:
// 分页的校验,看起来很难懂,未来很多地方都要用,这么写无法复用
export class AppsPageFindDTO {
@Rule(RuleType.string().required())
siteId: number;
@Rule(RuleType.number().integer().empty('').default(1).greater(0))
pageNum: number;
@Rule(RuleType.number().integer().empty('').default(20).greater(0))
pageSize: number;
}
// 第二种问题
// 对参数的校验,本身应该是 DTO 层面校验的,放到业务中不合理
// 同时,对逗号间隔的 id 进行校验,这是常见功能,放在这难以复用
@Get('/findMany')@Validate()
async findMany(@Query(ALL) appParams: AppsFindDTO) {
const { ids } = appParams;
const newIds = ids.split(',');
if(!Array.isArray(newIds)) {
return getReturnValue(false, null, 'ids 参数不符合要求');
}
const app = await this.appService.findOneAppByIds(newIds);
return getReturnValue(true, app);
}
建议使用如下的方式进行 DTO 层的代码编写,首先对可复用的常见规则进行封装:
// 必填字符串规则
export const requiredStringRule = RuleType.string().required();
// 页码校验规则
export const pageNoRule = RuleType.number().integer().default(1).greater(0);
// 单页显示内容数量校验规则
export const pageSizeRule = RuleType.number().integer().default(20).greater(0);
// 逗号间隔的 id 进行校验的规则扩展,起名为 stringArray
RuleType.extend(joi => ({
base: joi.array(),
type: 'stringArray',
coerce: value => ({
value: value.split ? value.split(',') : value,
}),
}));
接着在你的 DTO 定义文件中,代码就可以精简为:
// 分页的校验的逻辑可以精简为这种写法
export class AppsPageFindDTO {
@Rule(requiredStringRule)
siteId: number;
@Rule(pageNoRule)
pageNum: number;
@Rule(pageSizeRule)
pageSize: number;
}
// 逗号间隔的 id 字符串校验,可以改为如下写法
export class AppsFindDTO {
@Rule(RuleType.stringArray())
ids: number;
}
@Get('/findMany')@Validate()
async findMany(@Query(ALL) appParams: AppsFindDTO) {
const { ids } = appParams;
const app = await this.appService.findOneAppByIds(ids);
return getReturnValue(true, app);
}
比起初始的代码,要精简非常多,而且所有的校验规则,都可以未来复用,这是比较推荐的 DTO 层代码设计。
明确的职责划分
DTO 的核心职责是对入参进行校验,它的职责仅限于此,但是很多时候,我们能看到这样的代码:
// Controller 层代码逻辑
@Post('/createRelatedService')
@Validate()
async createRelatedService(@Body(ALL) requestBody: AppRelationSaveDTO) {
// 判断当前站点和应用的关联是否存在
const appRelation = new AppRelation();
const saveResult = await this.appServiceService.saveAppRelation(
appRelation,
requestBody,
);
return getReturnValue(true, saveResult);
}
// Service 层的代码逻辑
async saveAppRelation(
relation: AppRelation,
params: AppRelationSaveDTO,
) {
const { appId, serviceId } = params;
appId && (relation.appId = appId);
serviceId && (relation.serviceId = serviceId);
const result = await this.appServiceRelation.save(relation);
return result;
}
在 Service 层中的方法中,使用了 AppRelationSaveDTO 这个 DTO 作为 Typescript 的类型来帮助做代码类型校验。这段代码问题在于,让 DTO 层承担了数据校验外的额外职责,本身 Service 层关注数据怎么存,现在 Service 层还要关注外部数据怎么传,很显然代码职责就比较混乱。
优化的方式也很简单,我们可以改进一下代码:
// Controller 层代码逻辑
@Post('/createRelatedService')
@Validate()
async createRelatedService(@Body(ALL) requestBody: AppRelationSaveDTO) {
// 判断当前站点和应用的关联是否存在
const { appId, serviceId } = requestBody;
const appRelation = new AppRelation();
const saveResult = await this.appServiceService.saveAppRelation(
appRelation,
appId,
serviceId
);
return getReturnValue(true, saveResult);
}
// Service 层的代码逻辑
async saveAppRelation(
relation: AppRelation,
appId: string,
serviceId: stirng
) {
const { appId, serviceId } = params;
appId && (relation.appId = appId);
serviceId && (relation.serviceId = serviceId);
const result = await this.appServiceRelation.save(relation);
return result;
}
Service 层的参数类型,不再使用 DTO 进行描述,代码逻辑很清晰:Controller 层负责摘取必要数据;Service 层,负责拿到必要的数据进行增删改查即可;而 DTO 层,也只承担数据校验的职责。
控制层和服务层的代码建议
Controller 和 Service 层的设计建议可能会有比较大的争议,这里仅表达一下个人的观点:Controller 是控制器,所以业务逻辑都应该放在 Controller 中进行编写,Service 层作为服务层,应该把抽象沉淀的逻辑放在其中(比如说数据库操作,或者复用性代码)。也就是说,Controller 层应该存放业务定制的一次性逻辑,而 Service 层则存放可复用性的业务逻辑。
控制层和服务层的职责明确
围绕这个思路,给一个优化代码的设计例子供参考:
// 控制器层代码
@Post('/create')
@Validate()
async update(@Body(ALL) appBody: AppSaveDTO) {
const { code, name, description } = appBody;
const saveResult = await this.appService.saveApp(
code, name, description
);
return getReturnValue(true, saveResult);
}
// 服务层代码
async saveApp(code: sting, name: string, description: string) {
const app = await this.findOneAppByCode(code);
app.code = code;
app.name = name;
app.description = description;
const result = await this.appModel.save(app);
return result;
}
这段代码,其实是要更新一条信息,而且一下子必须更新 code,name 和 description,这样做 Service 层其实是和 Controller 有耦合的,到底怎么存实际上是业务逻辑,应该由 Controller 来决定,所以建议修改成如下代码:
// 控制器层代码
@Post('/create')
@Validate()
async update(@Body(ALL) appBody: AppSaveDTO) {
const { code, name, description } = appBody;
const app = await this.appService.findOneAppByCode(code);
app.code = code;
app.name = name;
app.description = description;
const saveResult = await this.appService.saveApp(app);
return getReturnValue(true, saveResult);
}
// 服务层代码
async saveApp(app: App) {
const result = await this.appModel.save(app);
return result;
}
这样写,相对于之前的代码,Controller 更聚焦业务;Service 更聚焦服务,而且能够得到更好的复用。这是在控制器和服务层写代码时可以参考的设计思路。
控制器层和服务层一对一匹配
在编写 Midway 代码的时候,存在这样的一种灵活性:控制器可以调用多个服务,而服务之间也可以互相调用。也就是说,服务层的一段代码,可能在任何的控制器中被调用,也可能在任何的服务层被调用。这种比较强的灵活度,最终一定会导致代码的层次结构不清晰,编码方式不统一,最终导致系统可维护性减弱。
为了规避过度灵活可能带来的问题,我们可以从规范上进行一定的约束。目前我的想法是,控制器只调用自己的服务层,如果需要其他服务层的能力,在自己的服务层进行转发。这样做后,一个服务层的代码,只能被自己的控制器调用,或者被其他的服务层调用,调用的灵活度从 N2 降低到 N,代码也就相对更可控。
依然通过代码举例来说:
// 控制器中的函数方法
@Post('/create')
@Validate()
async create(@Body(ALL) siteBody: SiteCreateDTO) {
// 用到一个 ACL 的服务层
const hasPermission = await this.aclService.checkManagePermission('site');
if (!hasPermission) {
return getReturnValue(false, null, '您无权限,无法创建');
}
const { name, code } = siteBody;
const site = new Site();
site.name = name;
site.code = code;
// 用到自身的服务层
const result = await this.siteService.createSite(site);
return getReturnValue(true, result);
}
如果代码这样设计,业务代码中,用到 ACL 的服务,校验权限,那么随着业务的发展,aclService 层可能会耦合越来越多的定制逻辑,因为所有的权限校验都由着一个方法提供,如果调用场景多,肯定会存在定制化需求。
所以更合理、更可扩展的代码可以改变成下面的样子:
// 控制器中的函数方法
@Post('/create')
@Validate()
async create(@Body(ALL) siteBody: SiteCreateDTO) {
// 用到自身的服务层
const hasPermission = await this.siteService.checkManagePermission();
if (!hasPermission) {
return getReturnValue(false, null, '您无权限,无法创建');
}
const { name, code } = siteBody;
const site = new Site();
site.name = name;
site.code = code;
// 用到自身的服务层
const result = await this.siteService.createSite(site);
return getReturnValue(true, result);
}
// 自身服务层的代码
async checkManagePermission(): Promise {
const hasPermission = await this.aclService.checkUserPermission('site');
return hasPermission;
}
在自身的服务层,增加一层转发代码,不仅可以约束代码的灵活度,当定制性逻辑增加的时候,也可以直接在这里扩展,所以是一种更合理的代码设计。
数据库查询的代码设计
使用逻辑表关联
在 Midway 中,集成的 TypeORM 的数据库框架,里面提供了 OneToOne ,OneToMany 这样的数据库操作语法,帮助你自动生成 Join 语句,管理表之间的关联。
但在业务系统中,我不建议使用这种直接的表连接语句,因为这很容易产生慢 SQL,影响系统的性能,所以建议在数据库操作中,统一采用逻辑表关联的方式进行关联数据查询,这里直接给出代码例子:
@Get('/findRelatedServices')
@Validate()
async findRelatedServices(@Query(ALL) appParams: AppServicesFindDTO) {
const { id } = appParams;
// 寻找关联关系内容
const relations = await this.appService.findAppRelatedServices(id);
// 从关联关系中找到另一张表关联的 id 合集
const serviceIds = relations.map(item => item.serviceId);
// 去另一张表取数据拼装
const services = await this.appService.findServicesByIds(serviceIds);
// 返回最终数据
return getReturnValue(true, {services});
}
虽然这种查询,相对于 Join,代码更多,但是逻辑全部在代码中体现,而且性能很好,所以在开发中,推荐使用这种数据库操作的设计。
常量的用法
常量在服务端开发中非常常用,通过常量语义化的表述一些枚举,这种基础内容不再累述,主要讲一下使用常量管理业务提示的想法。
业务提示文案抽离
复杂的项目,最终有可能走向国际化的路线,如果在代码中,写死的文字提示太多,最后做国际化,还是要投入精力修改,所以不如在开发开始,就对项目做一个提前准备,很简单,你只要把所有的文字提示抽离到常量文件里管理就可以了。
// 不推荐这种写法,文字和业务耦合
@Post('/create')
@Validate()
async create(@Body(ALL) appBody: AppSaveDTO) {
const { code, name } = appBody;
const appWithSameCode = await this.appService.findOneAppByCode(code);
if (appWithSameCode) {
// 文字和业务耦合在一起
return getReturnValue(false, null, 'code 已存在,无法重复创建!');
}
const app = new App();
const saveResult = await this.appService.saveApp(app, name);
return getReturnValue(true, saveResult);
}
// 推荐这种写法,文字和业务解耦
@Post('/create')
@Validate()
async create(@Body(ALL) appBody: AppSaveDTO) {
const { code, name } = appBody;
const appWithSameCode = await this.appService.findOneAppByCode(code);
if (appWithSameCode) {
// 文字拆离到常量中管理,实现解耦
return getReturnValue(false, null, APP_MESSAGES.CODE_EXIST);
}
const app = new App();
const saveResult = await this.appService.saveApp(app, name);
return getReturnValue(true, saveResult);
}
很小的一个改动,就可以让你的代码看起来有很大的变化,非常建议使用这个技巧。
总结
在复杂的项目开发中,选择好开发框架只是第一步,真正把代码写好才是最困难的事情,本篇文章总结了过去一年在使用 Midway 框架开发过程中,我对如何写好服务端代码自己的一些思考和编码技巧,希望能够对你有一定的启发,如果有挑战获疑问,欢迎留言讨论。
作者:ES2049 | Dell
文章可随意转载,但请保留原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 [email protected] 。