赞
踩
几年前用过一阵子eggjs,当时阿里内容很多小型web项目都采用这套框架,和Antd搭配起来,前后端通吃。
eggjs目前在Github上有18.3k个star,还是非常受开发者欢迎的。注意:本文并非完全针对最新版本的eggjs介绍的。
简单的说 controller 负责解析用户的输入,处理后返回相应的结果,例如
在 RESTful 接口中,controller 接受用户的参数,从数据库中查找内容返回给用户或者将用户的请求更新到数据库中。
在 html 页面请求中,controller 根据用户访问不同的 URL,渲染不同的模板得到 html 返回给用户。
在代理服务器中,controller 将用户的请求转发到其他服务器上,并将其他服务器的处理结果返回给用户。
框架推荐 controller 层主要对用户的请求参数进行处理(校验、转换),然后调用对应的 service 方法处理业务,得到业务结果后封装并返回:
获取用户通过 HTTP 传递过来的请求参数。
校验、组装参数。
调用 service 进行业务处理,必要时处理转换 service 的返回结果,让它适应用户的需求。
通过 HTTP 将结果响应给用户。
简单来说,service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层,提供这个抽象有以下几个好处:
保持 controller 中的逻辑更加简洁。
保持业务逻辑的独立性,抽象出来的 service 可以被多个 controller 重复调用。
将逻辑和展现分离,更容易编写测试用例,测试用例的编写具体可以查看 这里。
Helper 函数用来提供一些实用的 utility 函数。
它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数,这样可以用 JavaScript 来写复杂的逻辑,避免逻辑分散各处。另外还有一个好处是 Helper 这样一个简单的函数,可以让我们更容易编写测试用例。框架内置了一些常用的 Helper 函数。
框架内置了几种日志,分别在不同的场景下使用:
appLogger ${appInfo.name}-web.log,例如 example-app-web.log,应用相关日志,供应用开发者使用的日志。我们在绝大数情况下都在使用它。
coreLogger egg-web.log 框架内核、插件日志。
errorLogger common-error.log 实际一般不会直接使用它,任何 logger 的 .error() 调用输出的日志都会重定向到这里,重点通过查看此日志定位异常。
agentLogger egg-agent.log agent 进程日志,框架和使用到 agent 进程执行任务的插件会打印一些日志到这里。
egg v0.11.0 匹配 egg-view-nunjucks v1.x, 不能使用2.x
内置无用中间件可配置关闭
建议用 context.get(name) 而不是 context.headers[‘name’],因为前者会自动处理大小写。
一般来说属性的计算只需要进行一次,那么一定要实现缓存,否则在多次访问属性时会计算多次,这样会降低应用性能。推荐的方式是使用 Symbol + getter 的模式。
生产环境关闭egg服务器: ps找到master进程,然后kill
使用 logger.debug() 输出调试信息,推荐在应用代码中使用它。
当网站需要直接输出用户输入的结果时,请务必使用 helper.escape() 包裹起来
网站输出的内容会提供给 JavaScript 来使用。这个时候需要使用helper.sjs()来进行过滤。
需要在 JavaScript 中输出 json ,若未做转义,易被利用为 XSS 漏洞。框架提供了 helper.sjson() 宏做 json encode
注意1: 本文基于 egg 2.x 版本
注意2: 如果是你用 egg-bin 启动的话,则无需编写入口文件。
如果你熟悉 Web 开发或 MVC,肯定猜到我们第一步需要编写的是 Controller 和路由映射。
Controller 用于控制页面的展现逻辑,渲染页面或控制页面跳转等等。
每个 Controller 类都是一个文件,包含一个或多个符合 koa middleware 约定的 Generator 函数。
需放置在 app/controller 目录下。
每个 app/controller/.js 文件,都会被自动加载到 app.controller. 上。
注意:下划线会转换为驼峰命名,如 foo_bar => fooBar。
一个简单的欢迎页:
// app/controller/home.js
module.exports = function* homeController() {
this.body = 'hello world, egg';
};
然后通过 app/router.js 来配置路由映射,相关 API 可以参考 koa-router 模块。
// app/router.js
module.exports = app => {
app.get('/', app.controller.home);
};
好,现在可以启动应用来体验下
$ node index.js
$ open localhost:7001
egg 内置了 static 插件,但默认未开启,线上环境建议部署到 CDN,无需该插件。
先开启插件:
// config/plugin.js
// 开启 static 插件, 默认映射 /public -> app/public 目录
exports.static = true;
这里我们偷个懒,直接把 vue-hacker-news 示例抄过来,静态资源都放到 app/public 目录
app/public
├── css
│ └── news.css
└── js
├── lib.js
└── test.js
绝大多数情况,我们都需要读取数据后渲染模板,然后呈现给用户。故我们需要引入对应的模板引擎。
egg 并不强制你使用某种模板引擎,只是约定了 view 插件的定义,开发者可以引入不同的插件来实现差异化定制。
在本例中,我们使用 nunjucks 来渲染,先安装对应的插件 egg-view-nunjucks :
$ npm ii egg-view-nunjucks --save
开启插件:
// config/plugin.js
// 指定 view 插件
exports.view = {
enable: true,
package: 'egg-view-nunjucks'
};
为列表页编写模板文件,一般放置在 app/views 目录下
<!-- app/views/news/list.tpl -->
<html>
<head>
<title>Egg HackerNews Clone</title>
<link rel="stylesheet" href="/public/css/news.css" />
</head>
<body>
<div class="news-view view">
{% for item in list %}
<div class="item">
<a href="{{ item.url }}">{{ item.title }}</a>
</div>
{% endfor %}
</div>
</body>
<html/>
// app/controller/news.js
exports.list = function* newsListController() {
const dataList = {
list: [
{ id: 1, title: 'this is news 1', url: '/news/1' },
{ id: 2, title: 'this is news 2', url: '/news/2' }
]
};
yield this.render('news/list.tpl', dataList);
};
// app/router.js
module.exports = app => {
app.get('/', app.controller.home);
app.get('/news', app.controller.news.list);
};
启动浏览器,访问 localhost:7001/news 即可看到渲染后的页面。
提示:egg 在开发期默认启动 development 插件,修改服务端代码后,会自动重启 worker 进程。
在实际应用中, Controller 一般不会自己生成数据,也不会包含复杂的逻辑,你应该将那些复杂的过程放到业务逻辑层 Service 里面,然后暴露出一个简单的函数给 Controller 调用,这样也便于测试。
同样,每一个 Service 类都是一个文件,需放置在 app/service 目录下。
每个 Service 都会像 Context 一样,在每个请求生成的时候,被自动实例化到 ctx.service.* 下。
注意:下划线会转换为驼峰命名,如 foo_bar => fooBar。
注意:Service 不是单例。
我们来添加一个 service 抓取 hacker-news 的数据 ,如下:
// app/service/news.js
module.exports = app => {
class NewsService extends app.Service {
constructor(ctx) {
super(ctx);
}
* list(page) {
// 读取配置文件
const serverUrl = this.app.config.news.serverUrl;
const pageSize = this.app.config.news.pageSize;
page = page || 1;
// 读取 hacker-news api 数据
// 先请求列表
const idList = yield this.app.urllib.request(`${serverUrl}/topstories.json`, {
data: {
orderBy: '"$key"',
startAt: `"${pageSize * (page - 1)}"`,
endAt: `"${pageSize * page - 1}"`,
},
dataType: 'json',
}).then(res => res.data);
// 并行获取详细信息, 参见 co 文档的 yield {}
const newsList = yield Object.keys(idList).map(key => {
const url = `${serverUrl}/item/${idList[key]}.json`;
return this.app.urllib.request(url, { dataType: 'json' }).then(res => res.data);
});
return newsList;
}
}
return NewsService;
};
然后稍微修改下之前的 Controller:
// app/controller/news.js
exports.list = function* newsListController() {
const page = this.query.page || 1;
const newsList = yield this.service.news.list(page);
yield this.render('news/list.tpl', { list: newsList });
};
再补个单元测试:
// test/app/service/news.test.js
const expect = require('expect.js');
const mm = require('mm');
describe('test/service/news.test.js', function() {
before(function(done) {
// 会自动获取当前应用代码目录,来创建 App
this.app = mm.app();
this.ctx = this.app.mockContext();
this.app.ready(done);
});
// 确保每个测试用例运行完之后自动还原到 mock 之前的状态
afterEach(mm.restore);
it('should list news', function* () {
const list = yield this.ctx.service.news.list(3);
expect(list.length).to.be(this.app.config.news.pageSize);
expect(list[0]).to.have.keys(['id', 'title', 'url']);
});
});
概念 描述
遇到一个小问题,我们的资讯时间的数据是 UnixTime 格式的,我们希望显示为便于阅读的格式。
egg 提供了一种快速扩展的方法,在 app/extend 目录下提供扩展脚本即可,具体参见 Web规范。
在 egg-view-nunjucks 里面,我们可以通过扩展 helper 的方式来实现:
// app/extend/helper.js
const moment = require('moment');
exports.relativeTime = time => moment(new Date(time * 1000)).fromNow();
在模板里面使用:
{{ helper.relativeTime(item.time) }}
假设有个需求:我们的新闻站点,禁止百度爬虫访问。
聪明的同学们一定很快能想到可以通过 Middleware 判断 UA,如下:
// app/middleware/robot.js
// options 为同名的 config, 即 app.config.robot
module.exports = (options, app) => {
return function* robotMiddleware(next) {
const source = this.get('user-agent') || '';
const match = options.ua.some(ua => ua.test(source));
if (match) {
this.status = 403;
this.message = '禁止爬虫访问';
} else {
yield next;
}
}
};
// config/config.default.js
// 挂载 middleware
exports.middleware = [
'robot'
];
exports.robot = {
ua: [
/Baiduspider/i,
]
};
现在可以使用 curl localhost:7001/news -A “Baiduspider” 看看效果。
写业务的时候,不可避免的需要有配置文件,egg 提供了强大的配置合并管理功能:
支持按环境变量加载不同的配置文件,如 config.local.js , config.prod.js …
配置文件可以在应用/插件/框架等地方就近配置,egg 将合并加载。
具体合并逻辑可参见 Web规范
// config/config.default.js
exports.robot = {
ua: [
/curl/i,
/Baiduspider/i,
],
};
// config/config.local.js
// 仅在本地开发生效, 覆盖 default
exports.robot = {
ua: [
/Baiduspider/i,
],
};
// app/controller/news.js
exports.list = function* newsListController() {
const config = this.app.config.news;
};
没有单元测试,你们也敢上线?
在 egg 里面,写单元测试其实很简单的事,接下来让我们开干吧:
首先安装开发期依赖,使用的是 egg-bin (内置 mocha ) 和 supertest ,并且使用了 eslint 做代码检查。
$ npm ii --save-dev mm expect.js mocha thunk-mocha supertest
$ npm ii --save-dev eslint eslint-config-egg
添加 npm scripts 到 package.json
"scripts": {
"lint": "eslint lib test",
"test": "npm run lint && npm run test-local",
"test-local": "egg-bin test",
"cov": "egg-bin cov",
"ci": "npm run lint && npm run cov"
}
编写测试用例:
// test/app/controller/home.test.js
const request = require('supertest');
const mm = require('mm');
describe('test/controllers/home.test.js', function() {
before(function(done) {
// 会自动获取当前应用代码目录,来创建 App
this.app = mm.app();
this.app.ready(done);
});
// 确保每个测试用例运行完之后自动还原到 mock 之前的状态
afterEach(mm.restore);
it('should get 200 status', function(done) {
request(this.app.callback())
.get('/')
.expect(/egg/)
.expect(200, done);
});
});
然后一键测试,爽不爽啊~
$ npm test
细心的同学会发现,在我们的单元测试前面会自动跑 eslint 代码风格检查,这是很有必要的,能保证团队成员写出一致的风格。
另外,还可以检查测试的覆盖率:
$ npm run cov
$ open coverage/lcov-report/index.html
有兴趣可以查看下生成的 html 文件,我们甚至可以看到每一行代码和每一条分支的执行次数。
quickstart_coverage
运行环境,通过 config/serverEnv 文件或process.env.EGG_SERVER_ENV指定,具体参见 egg-loader 源码。
$ EGG_SERVER_ENV=prod node index.js
为解决版本问题,node 可以打包在应用中,具体参见 Node.js 阿里手册。
// package.json
"engines": {
"install-node": "4.3.1"
}
对于应用开发者来说,看到这里相信你对 egg 的使用有了直观的感受。
接下来的几节,我们来了解下一些高级用法。
插件是 egg 的精髓之一,它其实就是一个 mini 应用,用于逻辑解耦,便于生态复用和差异化定制。
优点 描述
共建生态 譬如 egg-security ,egg-hsfclient 这些插件,沉淀了很多企业级开发的经验,可以在应用中自由选择,一键引入,极大的方便了开发者。
差异化定制 譬如 view 插件,在 egg 里面只是定义了 view 的规范和接口,上层应用可以使用不同的插件, 如 egg-view-nunjucks , egg-view-ejs 来实现差异化定制。
一般来说,在应用开发过程中,觉得有可能复用的代码,可以抽离到 plugin/PLUGIN_NAME 下。
譬如在我们此次的旅途中,需要用到静态资源的 combo 服务:
先看下最终的目录结构
./nut-example
├── app
├── config
│ └── plugin.js
├── plugins
│ └── combo
│ ├── README.md
│ ├── app
│ │ └── middleware
│ │ └── combo.js
│ ├── app.js
│ ├── config
│ │ ├── config.default.js
│ │ └── config.local.js
│ └── package.json
└── test
└── plugins
└── combo.test.js
可以发现,plugins 的目录结构跟应用差不多,除了没有 controller 和 router 。
首先需要开启插件,通过指定路径的方式:
// config/plugin.js
const path = require('path');
const pluginsDir = path.join(__dirname, '../plugins');
exports.static = true;
exports.view = {
enable: true,
package: 'egg-view-nunjucks',
};
exports.combo = {
enable: true,
path: path.join(pluginsDir, 'combo')
};
插件需要在自己的 package 里面声明 eggPlugin,譬如 egg-view-nunjucks , egg-view-ejs 的 name 都是 view 来实现差异化。
// plugins/combo/package.json
{
"name": "combo",
"description": "提供静态资源的合并请求服务",
"eggPlugin": {
"name": "combo",
"dep": ["static"]
}
}
插件有自己的 config 文件,将被合并到一起,framework -> plugin -> app,具体参见 Web规范 。
当然,单元测试什么的可不能少,放置在 test/plugins/combo.test.js 。
当你的插件稳定后,可以考虑独立为一个模块,发布到 npm,共建 egg 生态。
PS: egg 还提供了 app/extend 的方式去简单扩展,具体参见规范。
一般来说,我们不推荐直接基于 egg 开发应用,而是基于对应的解决方案之上。
很多框架一般都会提供了项目骨架,如本文顶部的 egg-init
如果你想 hard-mode 的话:
npm ii 安装对应的框架
修改 index.js 中 require(‘egg’) 改为对应的框架名
按照框架的规范去使用
那如何封装解决方案呢?
假设我们的新闻站点需要快速复制,故很有必要封装一个框架出来。
一个框架的目录结构一般如下:
./egg-example-framework
├── lib
│ ├── core
│ │ ├── app
│ │ └── config
│ └── plugins
│ ├── combo
│ └── view
├── test
│ ├── fixtures
│ ├── lib
│ └── index.test.js
├── index.js
├── docs
├── README.md
└── package.json
可以发现,framework 的目录结构也差不多,对应的 app/config 需放到 lib/core 下 。
然后提供个入口文件:
// index.js
// 继承 egg 框架并自定义
const egg = require('egg');
const originStartCluster = egg.startCluster;
module.exports = exports = egg;
class CustomApplication extends egg.Application {
constructor(options) {
super(options);
}
get [egg.symbol.eggPath]() {
return __dirname;
}
}
exports.Application = CustomApplication;
// 覆盖 startCluster 提供 customEgg 路径
exports.startCluster = (opts, callback) => {
const options = Object.assign({
customEgg: __dirname
}, opts);
// start app
originStartCluster(options, callback);
};
再附上完善的测试,发布到 npm,再写个脚手架,就可以愉快的去推广了。
一般来说,当应用中有可能会复用到的代码时,直接放到 plugin 目录去,如例子中的 combo 。
当该 plugin 功能稳定后,即可独立出来作为一个 egg plugin 的 node module 。
如此以往,应用中相对复用性较强的代码都会逐渐独立为单独的 plugin 模块。
当你的应用逐渐进化到针对某类业务场景的解决方案时,将其抽象为独立的 framework 进行发布。
多看看多进程模型 master/worker/agent,egg 内置的 egg-security ,egg-userservice 等等沉淀
内置的 extend 扩展里面也有很多好东西,如 curl 等。建议:
egg-security 是默认开启的插件,新手经常遇到的一个问题是 POST 被拒绝,就是这个插件的 crsf 功能限制,防止伪造 POST,带上 ctoken 即可,可以查看下对应的文档。
egg 2.x 后不再内置 view 插件,你需要自行安装对应的插件。
https://github.com/expressjs/express
meteor/meteorgithub.com
https://github.com/nestjs/nestgithub.com
https://github.com/koajs/koagithub.com
https://github.com/balderdashy/sailsgithub.com
https://github.com/eggjs/egggithub.com
https://github.com/fastify/fastifygithub.com
https://github.com/strongloop/loopbackgithub.com
https://github.com/hapijs/hapigithub.com
https://github.com/NetEase/pomelogithub.com
https://github.com/restify/node-restifygithub.com
[https://github.com/adonisjs/adonis-frameworkgithub.com]
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。