最近想写一套自用的前后端分离的对外 api,之前已有的一个 Node Web 工程是基于 Express 的,后面想为何不正好使用一下更潮的 Koa 呢。参加好几次技术讲座,阿里主推的 egg 便吸引了我的注意,为何不尝试一下企业级框架 egg 呢。打开 doc,研读了一下新手指南和基础功能部分,在这里写一点感受。
总览
两章节 doc 阅读下来最直观的感受就是,egg 并不是什么新的技术选型,也并没有带来太多的新特性,而是一种针对 Node Web 工程实践下的总结。
我自己的YY的背景就是,Koa 在实际的使用中太过于灵活,对于一个小型项目而言,问题不大,但是如果项目一大就会暴露出很多问题,比如
- 灵活带来的问题就是开发人员的适应成本的变高
- 标准的MVC开发模型不一定会遵循,或者写法千奇百怪
- Koa没有加入安全性的考虑,而这一点对于企业来说是十分重要的
- 企业级日常开发的必备库直接内置
- 统一的异常处理
- ……
egg 推崇“约定优于配置”,直接从目录层面对 MVC 相关的目录,比如view,router,controller,service进行了强规定,同时提供如下扩展
- extend对内置对象进行统一扩展
- middleware中间件
- schedule定义定时任务
egg 专注于提供 Web 开发的核心功能和一套灵活可扩展的插件机制。
社区活跃的方案有 Express 和 Koa,为何 egg 选择了 Koa 呢?我觉得其中有个重要原因就是中间件机制的不同,Koa 中间件选择了洋葱圈模型。所有的请求经过一个中间件的时候都会执行两次,对比 Express 形式的中间件,Koa 的模型可以非常方便的实现后置处理逻辑。
开始
我们需要知道egg-init
脚手架及创建项目的目的
egg-init egg-example --type=simple
关于静态资源,你会发现脚手架自动生成有app/public/目录,egg 内置了 static 插件,默认映射 /public/ -> app/public/* 目录。备注:线上你很可能会部署到CDN,无需该插件。
关于模版渲染,egg 并不强制你是用莫衷模版,但是约定了 View 插件的开发规范,开发者可以引入不同的插件来实现差异化定制
关于编写service,有MVC编程思想的话,这里就不用多说了,在 egg 中约定,会将service目录下文件暴露的对象在ctx.service
对象下根据文件名称自动实例化(延迟)。
关于编写扩展,直接在extend目录下创建需要扩展的内置对象的同名扩展脚本即可
关于编写中间件,相比之前的要特别一点,具体如下
- 考虑到需要一些配置参数,因此入参是两个,一个是options(框架会将 app.config[${middlewareName}] 传递进来),用来访问针对该中间件具体配置,一个就是app,用来访问Application,因此语法是一个高阶函数
- 需要在配置中心的middleware数组中注册
接下来重点聊聊配置文件,这里集合了项目中所有的配置,特性如下
- 支持按照环境变量加载不同的配置文件
应用/插件/框架
都可以配置自己的配置文件,将按顺序合并加载
内置对象
egg 不仅继承了 Koa 的四个常用对象(Application, Context, Request, Response) ,还扩展了一些对象(Controller, Service, Helper, Config, Logger),熟悉他们可以说是框架的基石了,
Application
关键词:全局应用对象,一个应用中,只会实例化一个
作用:用来挂在全局的方法和对象,可以自行扩展
事件:server,error,request,response
获取方式
- 几乎所有被框架 Loader 加载的文件(Controller,Service,Schedule 等),都可以 export 一个函数,这个函数会被 Loader 调用,并使用 app 作为参数
- 在 Context 对象上,可以通过 ctx.app 访问到 Application 对象。
- 在继承于 Controller, Service 基类的实例中,可以通过 this.app 访问到 Application 对象。
Context
关键词:请求级别对象,框架会将所有的 Service 挂载到 Context 实例上,一些插件也会将一些其他的方法和对象挂载到它上面
获取方式
- Middleware, Controller 以及 Service 中获取,通过入参或者 this.ctx 即可
- 在有些非用户请求的场景下我们需要访问 service / model 等 Context 实例上的对象,我们可以通过 Application.createAnonymousContext() 方法创建一个匿名 Context 实例
- 在定时任务中的每一个 task 都接受一个 Context 实例作为参数,以便我们更方便的执行一些定时的业务逻辑
Request & Response
请求级别对象,直接通过Context实际获取
主要注意的是:Koa 会在 Context 上代理一部分 Request 和 Response 上的方法和属性
Controller & Service
egg 提供基类,并推荐所有的 Controller & Service 都继承该基类实现,属性有
- ctx - 当前请求的 Context 实例。
- app - 应用的 Application 实例。
- config - 应用的配置。
- service - 应用所有的 service。
- logger - 为当前 controller 封装的 logger 对象。
Helper
关键词
- 提供一些实用的 utility 函数
- Helper 自身是一个类,有和 Controller 基类一样的属性,它也会在每次请求时进行实例化,因此 Helper 上的所有函数也能获取到当前请求相关的上下文信息。
- 支持框架扩展的形式来自定义 helper 方法。
获取方式:可以在 Context 的实例上获取到当前请求的 Helper(ctx.helper) 实例。除此之外,Helper 的实例还可以在模板中获取到
Config
推荐应用开发遵循配置和代码分离的原则,将一些需要硬编码的业务配置都放到配置文件中,同时配置文件支持各个不同的运行环境使用不同的配置
获取方式:我们可以通过 app.config 从 Application 实例上获取到 config 对象,也可以在 Controller, Service, Helper 的实例上通过 this.config 获取到 config 对象。
配置文件返回的是一个 object 对象,可以覆盖框架的一些配置,应用也可以将自己业务的配置放到这里方便管理。配置文件也可以返回一个 function,可以接受 appInfo 参数,appInfo具体内容请查看文档。
配置加载顺序:应用、插件、框架都可以定义这些配置,而且目录结构都是一致的,但存在优先级(应用 > 框架 > 插件),相对于此运行环境的优先级会更高。
Logger
框架内置了功能强大的日志功能,可以非常方便的打印各种级别的日志到对应的日志文件中,每一个 logger 对象都提供了 4 个级别的方法:
- logger.debug()
- logger.info()
- logger.warn()
- logger.error()
同时框架中提供了多个Logger对象,具体场景有所差异,建议遵循使用规则
Subscription
订阅模型是一种比较常见的开发模式,譬如消息中间件的消费者或调度任务。
中间件
和 Koa 一毛一样啦,但是 egg 有一个重要优化
大家都知道中间件的先后顺序十分重要,在 Koa 中只能通过控制代码书写的先后顺序来决定中间件顺序,在 egg 中,我们可以完全通过配置来加载自定义的中间件,并决定它们的顺序。
值得注意的是在框架和插件中,不支持在 config.default.js 中匹配 middleware
// app.js
module.exports = app => {
// 在中间件最前面统计请求时间
app.config.coreMiddleware.unshift('report');
};
应用层定义的中间件(app.config.appMiddleware)和框架默认中间件(app.config.coreMiddleware)都会被加载器加载,并挂载到 app.middleware 上。
以上两种方式配置的中间件是全局的,会处理每一次请求,如果你只想针对单个路由生效,可以直接在 app/router.js 中实例化和挂载,具体使用其实和 koa 很像
module.exports = app => {
const gzip = app.middleware.gzip({ threshold: 1024 });
app.router.get('/needgzip', gzip, app.controller.handler);
};
除了应用层加载中间件之外,框架自身和其他的插件也会加载许多中间件。所有的这些自带中间件的配置项都通过在配置中修改中间件同名配置项进行修改
框架和插件加载的中间件会在应用层配置的中间件之前,框架默认中间件不能被应用层中间件覆盖,如果应用层有自定义同名中间件,在启动时会报错。
通用配置
无论是应用层加载的中间件还是框架自带中间件,都支持几个通用的配置项:
- enable:控制中间件是否开启。
- match:设置只有符合某些规则的请求才会经过这个中间件。
- ignore:设置符合某些规则的请求不经过这个中间件。
atch 和 ignore 支持多种类型的配置方式,字符串、正则和函数
路由
路由完整定义如下
router.verb([router-name], path-match, [...middleware], app.controller.action)
其实verb主要是HTTP协议常见动词:head options get put post patch delete,再加上 redirect(可以对 URL 进行重定向处理,比如我们最经常使用的可以把用户访问的根目录路由到某个主页。)
重定向
- 内部重定向
app.router.redirect('/', '/home/index', 302);
- 外部重定向
ctx.redirect(`http://cn.bing.com/search?q=${q}`);
控制层 & 服务层
这一块官方文档写了很多,其实有了后端的编程思维,知道在什么样的场景需要什么样的功能,知道如何实现,直接通过文档寻找对应api即可。并没有多少技术含量。这里主要记录点特别的。
自定义基类:按照类的方式编写 Controller,不仅可以让我们更好的对 Controller 层代码进行抽象(例如将一些统一的处理抽象成一些私有方法),还可以通过自定义 Controller 基类的方式封装应用中常用的方法。这个实践还是很不错的
// app/core/base_controller.js
const { Controller } = require('egg');
class BaseController extends Controller {
get user() {
return this.ctx.session.user;
}
success(data) {
this.ctx.body = {
success: true,
data,
};
}
notFound(msg) {
msg = msg || 'not found';
this.ctx.throw(404, msg);
}
}
module.exports = BaseController;
在 egg 中,ctx.query 只取 key 第一次出现时的值,后面再出现的都会被忽略,框架保证了从 ctx.query 上获取的参数一旦存在,一定是字符串类型。
如果我们系统需要让用户传递相同的 key,框架提供了 ctx.queries 对象,这个对象也解析了 Query String,但是它不会丢弃任何一个重复的数据,而是将他们都放到一个数组中。
一般请求中有 body 的时候,客户端(浏览器)会同时发送 Content-Type 告诉服务端这次请求的 body 是什么格式的。Web 开发中数据传递最常用的两类格式分别是 JSON 和 Form。
HTTP 协议中并不建议在通过 GET、HEAD 方法访问时传递 body,所以我们无法在 GET、HEAD 方法中按照此方法获取到内容。
Service 不是单例,是 请求级别 的对象,框架在每次请求中首次访问 ctx.service.xx 时延迟实例化,所以 Service 中可以通过 this.ctx 获取到当前请求的上下文。
扩展
在基于 Egg 的框架或者应用中,我们可以通过定义 app/extend/{application,context,request,response}.js
来扩展 Koa 中对应的四个对象的原型,通过这个功能,我们可以快速的增加更多的辅助方法
更加智能的是,我们可以通过object.[env].js的方式,在特定环境下增加特定的扩展
原理:把扩展目录下文件定义的对象与原有对应对象的prototype对象进行合并,在应用启动时会基于扩展后的prototype生成对象。
关于属性扩展:一般来说属性的计算只需要进行一次,那么一定要实现缓存,否则在多次访问属性时会计算多次,这样会降低应用性能。
推荐的方式是使用 Symbol + Getter 的模式。
const BAR = Symbol('Application#bar');
module.exports = {
get bar() {
// this 就是 app 对象,在其中可以调用 app 上的其他方法,或访问属性
if (!this[BAR]) {
// 实际情况肯定更复杂
this[BAR] = this.config.xx + this.config.yy;
}
return this[BAR];
},
};
插件
在学习插件之间,我们首选需要知道插件和中间件的区别:中间件定位是拦截用户请求,而实际情况,很多功能和请求无关,比如定时任务、消息订阅、后台逻辑,此时就需要中间件
一个插件可以包含
- extend:扩展基础对象的上下文,提供各种工具类、属性。
- middleware:增加一个或多个中间件,提供请求的前置、后置处理逻辑。
- config:配置各个环境下插件自身的默认配置项。
plugin.js 中的每个配置项支持:
- {Boolean} enable - 是否开启此插件,默认为 true
- {String} package - npm 模块名称,通过 npm 模块形式引入插件
- {String} path - 插件绝对路径,跟 package 配置互斥
- {Array} env - 只有在指定运行环境才能开启,会覆盖插件自身 package.json 中的配置
定时任务
定时任务可以指定 interval 或者 cron 两种不同的定时方式
- interval:通过 schedule.interval 参数来配置定时任务的执行时机,定时任务将会每间隔指定的时间执行一次。
- cron:通过 schedule.cron 参数来配置定时任务的执行时机,定时任务将会按照 cron 表达式在特定的时间点执行。
具体cron语法查看文档
定义定时任务有三种方式
- class方式
- module.exports = {} 对象方式
- module.exports = (app) => {} 函数方式
可以采用函数定义的方式,动态配置定时任务。
通过app.runSchedule(schedulePath)
可以手动执行任务
异常处理
通过同步方式编写异步代码带来的另外一个非常大的好处就是异常处理非常自然,使用 try catch 就可以将按照规范编写的代码中的所有错误都捕获到。这样我们可以很便捷的编写一个自定义的错误处理中间件。
async function onerror(ctx, next) {
try {
await next();
} catch (err) {
ctx.app.emit('error', err);
ctx.body = 'server error';
ctx.status = err.status || 500;
}
}
值得注意的是,egg 加入了一个新特性,可以直接使用app.resources
,快速在一个路径上生成CRUD路由结构,具体查看文档,个人还是蛮喜欢这个约定的,这样一来接口会规范很多!
app.resources('routerName', 'pathMatch', controller)
渐进式开发
看到这部分内容觉得挺有意思的,爱思考的人才会思考如何在项目中沉淀处一些共用的东西,日后加速新项目的推进呢,这个可以说是一个亮点,会让初级开发者形成一种意识
- 初级:共用代码,我们会放在extend文件夹作为扩展
- 稳定以后,我们会思考将这部分从项目中独立出来,作为一个插件,在 config/plugin.js 中通过 path 来挂载插件
- 不同团队间可能会共享这部分代码,此时我们可以独立成一个node_modules,需要将 config/plugin.js 中修改依赖声明为 package 方式
- 时间一长,会发现在团队的大部分项目中,都会用到这些插件。抽象出一个适合团队业务场景的框架。
- 把依赖从原有应用中移除,配置到该框架的 package.json 和 config/plugin.js 中
- 原有项目中的 package.json 中声明对该 framework 的依赖,并配置 egg.framework
在插件还没发布前,可以通过 npm link 的方式进行本地测试
更多
运行环境
egg-validate
jsonp