框架设计:如何基于 Egg 设计 Node 的服务框架

2019-09-30 15:19:41 浏览数 (1)

快乐活在当下,尽心就是完美。——林清玄

Node 的工具化价值自不多言,而服务化价值需要长期探索,小菜前端在服务化路上依然是小学生,目前的尝试是是 Cross 框架,尝到了一些甜头。

我想,几乎没有前端工程师会对 Node 不感兴趣,但用它适合干哪些事情,每个人的答案都不同了,比如小菜前端,我们对于 Node 的深度尝试,可以在这里找到答案:《技术栈:为什么 Node 是前端团队的核心技术栈》[1],但关于让 Node 做服务端的事情,却只有少数团队有这样的勇气。

之所以缺乏自信和勇气,本质的原因在于 Node 还没有一个足够顺手的框架来让你快速证明驱动业务的价值,也在于对 Node 缺乏足够的了解和信心,以及相对于服务端的强势,往往前端在侵蚀服务端领域的时候,会受到这般那样的挑战甚至刁难,这也成为了在团队推广 Node 常遇到的阻力,希望大家从小菜团队身上可以找到一些答案,其中答案的一部分就是要对 Node 要有足够的了解和认知,才可以为通用问题抽象出通用的方案去实施,在小菜,就是对于 Node 框架的封装,这个框架尚未开源,名叫 Cross,寓意没有迈不过的技术门槛。

分清楚 Node 的边界

前后端的团队本身是相爱相杀的关系,是左右手的双十合十,既有接口联调上的上下游数据立场,也有必须与对方精诚合作才能一次次拿下项目的战役,而在服务这件事情,前端就直接介入到了服务端的领域,而且从整个行业来看,这种介入在大中型公司已成为不可阻挡的趋势,无论是淘宝、天猫、支付宝、腾讯、网易、百度,包括创业独角兽大搜车、贝贝网、Rokid,海内外不分国籍不分领域的众多公司都有一个个团队在深度耕耘,所以这里的第一个边界是前后端的边界。

一千家公司可能就有一千种商业模式,一千种用户画像,一千种业务特征,既有高依赖算法的高实时计算的井喷式访问场景,也有日均几十 UV 几百 PV 的 toB 大客户产品,什么场景用 Node 合适,什么不合适,这第二个边界就是 Node 在业务领域里的服务边界。

只有弄清楚这两个边界,才有 Node 的生存土壤,脱离了这两个边界,就难免处处碰壁无法落地,针对前后端边界,我从前写过这样一段话:

数据的控制权和与视图所依赖的 API,这里就是目前前后端的边界,数据控制权属于后端,API 属于后端,把前后端简单看做是一个完整的系统,这个系统中自 API 向下自然是后端的,API 向上则属于前端。

在 API 下面,对于数据的业务流转流转逻辑,在上面对于数据的调用和组装,这就是数据层面的天然分界点,而 Node 植入进去,也必须在 API 这一层与 Java 保持规范的统一和兼容,通过 RPC 无缝的调用才能来谈边界,而这个边界我的理解它可以是非强业务耦合的,比如独立的内部协同系统,也可以是非高计算型的,可以是相对独立的异步的高并发的模块,比如消息堆栈的频繁拉取推送,比如日志的收集整理等等,总结起来就是非复杂业务流程的,非高计算型的这个地方可以作为 Node 进入的边界。

而对于业务的服务边界,只要的小而美的相对独立的系统,只要不是核心业务,都可以用 Node 快速开发,比如小菜这里就有报表系统、打包系统、发布系统、市调系统、日志系统、可视化平台、招聘面试系统、Bug 跟踪系统等等。

以上的两个边界,大家在仔细评估的时候,一定不要忘了自己团队人员的能力配置,能不能 Hold 住 Node,有没有 Node 技术专家坐镇,不然仓促使用可能还适得其反。

为什么要封装 Cross

在弄清楚上述的边界后,小菜前端在 1 年多的时间里,对 Node 进行深度的使用,从基建系统到相对独立的业务系统,整个走下来,团队更多同学掌握了 Node 的使用,同时每个系统之间的差异性也越来越大,有的用的是 Koa 有的是 Koa2,有的是 Thinkjs 有的是 Express,还有的是原生 NodeJS。

显然每个人的偏好都不同,代码质量也不同,工程架构方式也不同,这为后期的维护带来巨大的麻烦,尤其是做 Node 监控时候,发现没法用一套方案做批量的部署,也同样不能做水平的快速扩展,需要挑选一个框架基于它做统一的封装,从而把前端参与的所有服务端建设可以统一起来,而且现实是我们的前端和 Node 应用由于整个工程的构建与服务部署方式的不同,已经散落到各个服务器上,导致维护成为了瓶颈,也必须到做出改变的时候了,这是当时的部分零散的应用图:

为什么选择 Eggjs

小菜前端在使用 Eggjs 作为 Nodejs 的基础服务框架之前使用过诸如 Koa、Express、Koa2、Thinkjs 等框架,其中与 Eggjs 最接近的当属奇舞团开源的 Thinkjs[2] , 同样的约定大于配置,同样的基于 Koa2 进行包装完善,同样的采用多级分层的设计方式(Controller, Service 等等),让应用开发变得更加清晰明了,然而有趣的是, Thinkjs 的开源时间(2013)早于 Eggjs 的开源时间,其在 github 上的 star 的增长速度却是远远落后于 Eggjs,NPM 下载数亦然,虽然 thinkjs 开发体验也不错,小菜最后会选定 Eggjs 作为 Nodejs 服务框架的原因,除了上述提到的优点之外,还有如下几点 :

  • 高度可扩展的插件机制
  • 方便定制上层框架
  • 丰富且活跃的社区生态
  • 渐进式开发
  • 多进程管理

小菜前端从 18 年年初就开始使用 Eggjs 了,我们的很多项目都是基于 Eggjs 搭建的,其中包括我们的报表系统、GraphQL 网关、小程序后台服务等。在使用 Eggjs 开发这些项目的过程中我们逐渐形成了自己的一套适用于宋小菜的基于 Eggjs 的上层框架,基于小菜特定业务场景长出来的 Framework,它的定制程度很高,大家可以参考我们实现这套框架时用到的技巧与方法,这些套路应该是通用的。

秉承怎样的设计理念

考虑授人以鱼不如授人以渔嘛,我们先分享下我们的设计理念,这是最简单却也最重要的开始部分,我们的目标是风格统一、上手容易、维护方便:

然后就是整体需求的整理和开发集成,在开发集成个过程中不断调优:

image.png

定完目标,设计好流程,就要准备具体的实施了,我们实施涉及到过程,主要从下面四个方面着手:

  • 框架关系
  • 通用 API
  • 插件定制
  • 工程管理

如何设计 Framework

框架关系

我们将所有通用的 API 和常用工具函数以及常用的插件(redis、gateway)等统一集成在基础框架 baseFramework 中,由于 Egg 支持多级框架继承,所以我们可以根据基础框架 baseFramework 衍生出其他框架如 GraphQL 相关的框架、微服务相关的框架,它相当于是一颗框架种子,可以往不同的方向定制:

image.png

通用 API

1. 请求参数统一获取

假定某个 HomeController 有成员函数 testAction 既要处理 post 请求又要处理 get 请求,就有可能出现以下情况:

代码语言:javascript复制
const { Controller } = require('egg');

module.exports = class HomeController extends Controller {
	testAction(){
    const { ctx } = this;
    const { method } = ctx.request;
    const id = method === 'GET'? ctx.request.query.id : ctx.request.body.id;
    ...
	}
}

我们可以将其优化为:

代码语言:javascript复制
/* yourapp/app/controller/home.js */
const { BaseController } = require('egg');
// 或者
const { BaseController } = require('your-egg-framework');

module.exports = class HomeController extends BaseController {
	testAction(){
    const id = this.getParam('id');//
    ...
	}
}
  
/* egg-baseframework/core/base_controller.js */
const { Controller } = require('egg')

module.exports = class BaseController extends Controller {
	getParam(key) {
  	const { ctx } = this;
    const { method } = ctx.request;
    if (method === 'GET') {
    	if(key) {
      	...
      } else {
      	...
      }
    } else {
			...
    }
  }
}
/* your-egg-baseframework/lib/index.js */
const { BaseController } = require('../core/base_controller');

module.exports = {
  BaseController,
  ...
}

/* your-egg-framework/app.js */
module.exports = (app) => {
	require('egg').BaseController = BaseController
}
2. 返回数据格式化

方法同上,我们可以在 BaseController 中定义统一的调用成功和调用失败返回函数,并在函数中处理返回数据从而避免返回数据不规范的问题

3. 通用工具函数

我们可以将平时业务开发中可能会用到的工具函数统一通过框架扩展的额形式定义到内置对象 helper 上,这些都可以以框架扩展(extend)的方式集成进来,比如参数转化啊,错误信息格式化等等。

4. 增加参数校验层

我们可以将参数校验这一步抽离出来成为 logic 层。有两种方式可以做到:

  • 在框架加载时调用 app.loader.loadToContext 将所有 controller 对应的参数校验函数挂载到 context 上,在 controller 执行相应的处理函数时调用
  • 在你的框架继承的 appWorkerLoader 中覆写 eggjs 的 loadController , 对每一个 controller 的处理函数都使用对应的 logic 进行代理

插件定制

Egg 的拥有着丰富的插件生态,然而总有些我们需要用到的插件不太符合我们的要求,比如:

  • egg-redis 长久不支持哨兵模式
  • egg-graphql 不支持连接其他 graphql 服务
  • egg-kafka 长久没有维护

这个时候就需要我们自己动手编写或修改相应的插件了,而有些在公司层面上通用的功能如:Java 服务端网关请求(egg-gateway)、用户鉴权(egg-auth)等我们也将其封装为插件集成到基础框架中,讲实话,整个框架开发中,让人最开心最后成就感的部分就是写插件的时候:

image.png

工程管理

由于插件和插件之间,插件和框架之间,框架和框架之间存在相互依赖的关系,代码管理就成为了比较头疼的问题,推荐使用目前比较火的 monorepo 来进行管理。规范版本发布流程,避免出现不兼容问题。

总结

关于 Cross 的建设我们差不多投入了一个多月的周期,从投入产出比来看还是很划算的一次尝试,但是在落地时候也会遇到不少问题,从人和团队的角度来看,这样的一套 Framework 需要有一定的 Node 编程能力的同学才能较好的用起来,对于所有人依然有一定的心智成本,有没有可能把这个成本继续降低呢,走向 Pass 跟高阶的只关心业务逻辑不关心背后实现的阶段呢,这是一个很值得研究的课题,另外就是从事情的角度,如果业务中没有那么多的场景来承载这个框架,事实上它是很难继续进阶的,因为没有足够的应用和测试场景来暴露问题,这也是我们当下遇到的一个实际困难,缺少 Node 好手掣肘了我们前进的步子,不过好消息是接下来的业务场景已经铺开了,团队也刚刚进了一个 Node 选手,接下来看看应用后发力效果如何。

参考资料

[1]

《技术栈:为什么 Node 是前端团队的核心技术栈》: https://www.yuque.com/sxc/front/vh1kg5

[2]

Thinkjs: https://thinkjs.org/

[3]

关注 Scott 跟进我的动态: https://www.yuque.com/iscott

0 人点赞