0

Midway 后端代码的设计建议

 2 years ago
source link: https://segmentfault.com/a/1190000041583285
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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,简历请发送至 [email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK