Midway 后端代码的设计建议

3,072 阅读13分钟

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 工具代码存放目录,存放业务通用工具方法

当请求进入时,各目录对应的代码发挥了如下的功能作用:

  1. Middleware 作为起始逻辑进行通用性逻辑执行。
  2. 接着 DTO 层对参数进行校验。
  3. 参数校验无异常进入 Controller 执行整体业务逻辑。
  4. 数据库的调用,或者整体性比较强的通用业务逻辑会被封装到 Service 层方便复用。
  5. 工具方法、常量、配置和数据库实体则作为工程的底层支撑,向 Service 或 Controller 返回数据。
  6. Controller 吐出响应结果,如果响应异常,Middleware 进行逻辑兜底。
  7. 最终吐出响应数据返回给用户。

整理一下,你可以这么分类,在 MVC 中,C 层对应为 Middleware + DTO + ControllerM 层对应为 Service;V 层由于一般后端只提供对外的接口,不会有太多静态页面透出,所以暂时可以忽略。当然,Service 层有一定的边界混淆,它不仅仅只包含 Model 模型层,否则我们就直接起名成 Model 层好了,在 Service 中,我也会把一些可抽象、可复用的逻辑放入其中,来缓解一下复杂业务中 Controller 逻辑过于繁琐的问题。

image.png

了解上述 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<any>) => {
      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<boolean> {
  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,简历请发送至 caijun.hcj@alibaba-inc.com