阿里的nodejs框架Eggjs上手指南

前言

几年前用过一阵子eggjs当时阿里内容很多小型web项目都采用这套框架和Antd搭配起来前后端通吃。
eggjs目前在Github上有18.3k个star还是非常受开发者欢迎的。注意本文并非完全针对最新版本的eggjs介绍的。

Egg特点

  • 约定大于配置
    架构分层较清晰router、controller、service、extend
  • 原生支持配置路由映射
  • 插件机制
  • 方便扩展: helper、filter(使用分隔符: |)
  • 单元测试: egg-mock
  • 支持定时任务
  • 支持启动定义
  • 本地开发工具egg-bin
  • 集成应用部署
  • 内置进程管理egg-cluster 经过双11的考验
  • 内置日志模块controller: this.logger、service: this.ctx.logger、app: app.logger
  • 友好的异常处理
  • 支持国际化
  • 支持多粒度的扩展Application、Context、Request、Response、Helper
  • 内置安全插件egg-security
  • 渐进式开发progressive: extend -> plugin -> application -> framework

Egg重要概念

Controller

简单的说 controller 负责解析用户的输入处理后返回相应的结果例如

在 RESTful 接口中controller 接受用户的参数从数据库中查找内容返回给用户或者将用户的请求更新到数据库中。
在 html 页面请求中controller 根据用户访问不同的 URL渲染不同的模板得到 html 返回给用户。
在代理服务器中controller 将用户的请求转发到其他服务器上并将其他服务器的处理结果返回给用户。
框架推荐 controller 层主要对用户的请求参数进行处理校验、转换然后调用对应的 service 方法处理业务得到业务结果后封装并返回

获取用户通过 HTTP 传递过来的请求参数。
校验、组装参数。
调用 service 进行业务处理必要时处理转换 service 的返回结果让它适应用户的需求。
通过 HTTP 将结果响应给用户。

Service

简单来说service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层提供这个抽象有以下几个好处

保持 controller 中的逻辑更加简洁。
保持业务逻辑的独立性抽象出来的 service 可以被多个 controller 重复调用。
将逻辑和展现分离更容易编写测试用例测试用例的编写具体可以查看 这里。

Helper

Helper 函数用来提供一些实用的 utility 函数。
它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数这样可以用 JavaScript 来写复杂的逻辑避免逻辑分散各处。另外还有一个好处是 Helper 这样一个简单的函数可以让我们更容易编写测试用例。框架内置了一些常用的 Helper 函数。

Logger

框架内置了几种日志分别在不同的场景下使用

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注意点

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

eggjs实例上手

注意1: 本文基于 egg 2.x 版本
注意2: 如果是你用 egg-bin 启动的话则无需编写入口文件。

编写 Controller

如果你熟悉 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/>

添加 Controller 和 Router

// 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 进程。

编写 Service

在实际应用中 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']);
  });
});

简单小结下几个概念的区别

概念 描述

  • Controller 逻辑更加简洁专注 Web 页面的渲染
  • Service 负责组装和格式化 Proxy 接口提供的数据并封装业务逻辑被多个 Controller 使用
  • Proxy 从 Service 中细分出的数据层专门负责跟后端获取数据。一般通过 jar2proxy 动态生成。

编写扩展

遇到一个小问题我们的资讯时间的数据是 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

假设有个需求我们的新闻站点禁止百度爬虫访问。
聪明的同学们一定很快能想到可以通过 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/agentegg 内置的 egg-security egg-userservice 等等沉淀
内置的 extend 扩展里面也有很多好东西如 curl 等。建议

  • 多仔细阅读几遍 Web规范
  • 多看看插件的源码和README
  • 多看看 koa 和 egg 的源码

常见问题

egg-security 是默认开启的插件新手经常遇到的一个问题是 POST 被拒绝就是这个插件的 crsf 功能限制防止伪造 POST带上 ctoken 即可可以查看下对应的文档。
egg 2.x 后不再内置 view 插件你需要自行安装对应的插件。

附录nodejs框架排名(2022)

  • 第一名 express 50.4k 2010年1月发布 目前star 和下载量最高的老牌框架。

https://github.com/expressjs/express

  • 第二名meteor 42k 2012年发布构建现代 Web 应用程序的超简单框架。

meteor/meteorgithub.com

  • 第三名 nest.js 30.8k 2017年11月发布 目前上榜框架中发布最晚也是star 最高且增长最快的 typescript 后端框架。

https://github.com/nestjs/nestgithub.com

  • 第四名 koa2 30k 2013年11月发布 express 的继任者。

https://github.com/koajs/koagithub.com

  • 第五名 sails 21.6k 2012年7月 最早的 node.js 类 ror 框架。

https://github.com/balderdashy/sailsgithub.com

  • 第六名egg 16.2k 2016年7月 阿里开源的 node.js 框架国内使用较为普及。

https://github.com/eggjs/egggithub.com

  • 第七名 fastify 16k 2016年10月 目前性能最好的 node.js 框架。

https://github.com/fastify/fastifygithub.com

  • 第八名 loopback 13.2k 2013年6月 可以自动生成增删改查的 node.js 框架。

https://github.com/strongloop/loopbackgithub.com

  • 第九名 hapi 12.8k 2012年8月 老牌的 node.js 框架。 不知道为什么这个月 star 反而降了。

https://github.com/hapijs/hapigithub.com

  • 第十名 polemo 11k 2012年12月 网易开源的游戏后端框架。

https://github.com/NetEase/pomelogithub.com

  • 第十一名node-restify 10k 2011年5月 构建 restful API 的框架。

https://github.com/restify/node-restifygithub.com

  • 第十二名 adonis 8.8k 2015年10月 类似 laravel 的 node.js 框架。

[https://github.com/adonisjs/adonis-frameworkgithub.com]

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: NodeJS