当前位置:   article > 正文

阿里的nodejs框架Eggjs上手指南_阿里的node框架

阿里的node框架

前言

几年前用过一阵子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';
};
  • 1
  • 2
  • 3
  • 4

然后通过 app/router.js 来配置路由映射,相关 API 可以参考 koa-router 模块。

// app/router.js
module.exports = app => {
  app.get('/', app.controller.home);
};
  • 1
  • 2
  • 3
  • 4

好,现在可以启动应用来体验下

$ 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

模板渲染

绝大多数情况,我们都需要读取数据后渲染模板,然后呈现给用户。故我们需要引入对应的模板引擎。

egg 并不强制你使用某种模板引擎,只是约定了 view 插件的定义,开发者可以引入不同的插件来实现差异化定制。

在本例中,我们使用 nunjucks 来渲染,先安装对应的插件 egg-view-nunjucks :

$ npm ii egg-view-nunjucks --save
开启插件:

// config/plugin.js
// 指定 view 插件
exports.view = {
  enable: true,
  package: 'egg-view-nunjucks'
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

为列表页编写模板文件,一般放置在 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/>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

添加 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);
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

// app/router.js

module.exports = app => {
  app.get('/', app.controller.home);
  app.get('/news', app.controller.news.list);
};
  • 1
  • 2
  • 3
  • 4

启动浏览器,访问 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;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

然后稍微修改下之前的 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 });
};
  • 1
  • 2
  • 3
  • 4
  • 5

再补个单元测试:

// 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']);
  });
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

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

概念 描述

  • 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();
  • 1
  • 2
  • 3

在模板里面使用:

{{ 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;
    }
  }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

// config/config.default.js
// 挂载 middleware

exports.middleware = [
  'robot'
];
exports.robot = {
  ua: [
    /Baiduspider/i,
  ]
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

现在可以使用 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,
  ],
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

// config/config.local.js
// 仅在本地开发生效, 覆盖 default

exports.robot = {
  ua: [
    /Baiduspider/i,
  ],
};
  • 1
  • 2
  • 3
  • 4
  • 5

// app/controller/news.js

exports.list = function* newsListController() {
  const config = this.app.config.news;
};
  • 1
  • 2
  • 3

编写单元测试

没有单元测试,你们也敢上线?
在 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"
} 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

编写测试用例:

// 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);
  });
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

然后一键测试,爽不爽啊~

$ 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"
}
  • 1
  • 2
  • 3

编写插件

对于应用开发者来说,看到这里相信你对 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

可以发现,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')
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

插件需要在自己的 package 里面声明 eggPlugin,譬如 egg-view-nunjucks , egg-view-ejs 的 name 都是 view 来实现差异化。

// plugins/combo/package.json

{
  "name": "combo",
  "description": "提供静态资源的合并请求服务",
  "eggPlugin": {
    "name": "combo",
    "dep": ["static"]
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

插件有自己的 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

可以发现,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);
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

再附上完善的测试,发布到 npm,再写个脚手架,就可以愉快的去推广了。

最佳实践

一般来说,当应用中有可能会复用到的代码时,直接放到 plugin 目录去,如例子中的 combo 。
当该 plugin 功能稳定后,即可独立出来作为一个 egg plugin 的 node module 。
如此以往,应用中相对复用性较强的代码都会逐渐独立为单独的 plugin 模块。
当你的应用逐渐进化到针对某类业务场景的解决方案时,将其抽象为独立的 framework 进行发布。
多看看多进程模型 master/worker/agent,egg 内置的 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]

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/从前慢现在也慢/article/detail/486143
推荐阅读
相关标签
  

闽ICP备14008679号