浏览本文前,请先浏览 Eggjs 官网例子和 理解 Koajs

本文作者:东东章

开始

官网给了这样一个例子,手动搭建 Hacker News。

当咱们看到这个页面的时候,不要焦急往下看教程。 先自我思考下如何实现这个页面,要用到哪些技术:

  1. 路由解决。咱们须要一个角色解决承受 /news 申请,除此之外,个别还有 / 默认首页,也就是说至多 2 个 URL。
  2. 页面展现。这里能够用模板,也能够间接本人拼接 HTML 元素。nodejs 模板有Pug,EJS,Handlebarsjs等多个模板。
  3. 取数问题。有一个角色解决申请并拿到返回的数据。
  4. 合并数据。将模板和取到的数据联合起来,显示最终的后果。

MVC

在服务端有个很经典的 MVC 设计模式来解决这类问题。

  1. Modal: 治理数据和业务逻辑。通常细分为 service (业务逻辑) 和 dao (数据库治理) 两层。
  2. View: 布局和页面展现。
  3. Controller:将相干申请路由到对应的 Modal 和 View。

上面以Java Spring MVC为例

@Controllerpublic class GreetingController {    @GetMapping("/greeting")    public String greeting(@RequestParam(name="ownerId", required=false, defaultValue="World") String ownerId, Model model) {    String name = ownerService.findOwner(ownerId);        model.addAttribute("name", name);        return "greeting";    }}

模板greeting.html

<body>    <p th:text="'Hello, ' + ${name} + '!'" /></body>
  1. 首先用注解 @Controller 定义了一个 GreetingController 类。
  2. @GetMapping("/greeting") 承受了 /greeting,并交给 public String greeting 解决,这块属于 Controller 层。
  3. String name = ownerService.findOwner(ownerId);model.addAttribute("name", name); 获取数据,属于 Modal 层。
  4. return "greeting"; 返回对应模板 (View 层),而后与获得数据联合造成最终后果。

有了下面的教训之后,接下来 咱们将眼光转向 Eggjs。咱们能够依据下面的 MVC 架构,实现给出的例子。

因为实际上是有两个页面,一个是/news, 另外一个是/, 咱们首先从首页/的开始。

先定义一个 Controller.

// app/controller/home.jsconst Controller = require('egg').Controller;class HomeController extends Controller {  async index() {    this.ctx.body = 'Hello world';  }}module.exports = HomeController;

用 CJS 的规范先引入框架的 Controller,定义一个了HomeController类,并有办法index

类曾经定义好,接下来就是实例化阶段。

如果相熟 Koajs 的开发,个别会用 new 关键字

const Koa = require('koa');const app = new Koa();

如果相熟 Java 开发,个别会用注解来实例化,比方上面的 person 用了@Autowired 这个注解来实现主动实例化 。

public class Customer {    @Autowired                                   private Person person;                       private int type;}

从下面的例子看,发现注解岂但能解决申请,同时也能实例对象,十分不便。

ES7 外面有个也有相似的概念装璜器 Decorators,而后配合 reflect-metadata实现相似成果,这也是以后 Node 框架的标配做法。

然而,因为种种原因,Eggjs 即没有让你本人间接 new 一个实例,也没有用装璜器办法,而是本人实现了一套实例初始化规定:

它会读取以后的文件,而后依据文件名初始化一个实例,最初绑定到内置根底对象上。

比方下面的app/controller/home.js, 会产生一个 home 实例。因为是 Controller 角色,所以会绑定到 contoller 这个内置对象上。同时 contoller 对象也是内置 app 对象的一部分,更多的内置对象能够看这里。

总的来说,基本上所有的实例化对象都被绑定到 app 和 ctx 两个内置对象上了,拜访规定为this.(app|ctx).类型(controller|service...).本人定义的文件名.办法名

申请方面,Eggjs 用一个 router 对象来解决

// app/router.jsmodule.exports = app => {  const { router, controller } = app;  router.get('/', controller.home.index);};

下面的代码指 router 将 / 申请交由 home 实例的 index 办法解决。

文件目录规定也是依照约定搁置

egg-example├── app│   ├── controller│   │   └── home.js│   └── router.js├── config│   └── config.default.js└── package.json

app 目录搁置了所有与其相干的子元素目录。

至此,咱们实现了首页的工作,接下来思考 /news 列表页。

列表页

同理,咱们先定义 MVC 外面的 C,而后解决剩下两个角色。

有了下面的教训,咱们先创立一个 NewsController 类的 list 办法,而后在 router.js 增加对 /news 的解决,指定到对应的办法,如下。

// app/controller/news.jsconst Controller = require('egg').Controller;class NewsController extends Controller {  async list() {    const dataList = {      list: [        { id: 1, title: 'this is news 1', url: '/news/1' },        { id: 2, title: 'this is news 2', url: '/news/2' }      ]    };    await this.ctx.render('news/list.tpl', dataList);  }}module.exports = NewsController;

数据 dataList 先写死,后续用 service 替换。
this.ctx.render('news/list.tpl', dataList)这里是模板与数据的联合。

news/list.tpl属于 view,依据下面咱们所知的命名标准,残缺目录门路应该是app/view/news/list.tpl

// app/router.js 增加了/news申请门路,指定news对象的list对象解决module.exports = app => {  const { router, controller } = app;  router.get('/', controller.home.index);  router.get('/news', controller.news.list);};

模板渲染。

依据 MVC 模型,当初咱们曾经有了 C,剩下就是 M 和 V,M 数据曾经写死,先解决 View。

之前说过,nodejs 模板有Pug,Ejs,handlebarsjs,Nunjucks等多种。

有时候在我的项目中要依据状况来从多个模板抉择具体某个,因而须要框架做到:

  1. 申明多个模板类型。
  2. 配置具体应用某个模板。

为了更好的治理,申明和应用要离开,配置个别放在 config 目录下,所以有了config/plugin.jsconfig/config.default.js。前者做定义,后者具体配置。

// config/plugin.js 申明了2个view模板exports.nunjucks = {  enable: true,  package: 'egg-view-nunjucks'};exports.ejs = {  enable: true,  package: 'egg-view-ejs',};
// config/config.default.js 具体配置应用某个模板。exports.view = {  defaultViewEngine: 'nunjucks',  mapping: {    '.tpl': 'nunjucks',  },};

而后写一个nunjucks的具体模板的具体内容如下

// app/view/news/list.tpl<html>  <head>    <title>Hacker News</title>    <link rel="stylesheet" href="/public/css/news.css" />  </head>  <body>    <ul class="news-view view">      {% for item in list %}        <li class="item">          <a href="{{ item.url }}">{{ item.title }}</a>        </li>      {% endfor %}    </ul>  </body></html>

上面解决 service,取名为 news.js 文件门路参照下面,放在 app 目录的子目录 service 上面。

// app/service/news.js const Service = require('egg').Service;class NewsService extends Service {  async list(page = 1) {    // read config    const { serverUrl, pageSize } = this.config.news;    // use build-in http client to GET hacker-news api    const { data: idList } = await this.ctx.curl(`${serverUrl}/topstories.json`, {      data: {        orderBy: '"$key"',        startAt: `"${pageSize * (page - 1)}"`,        endAt: `"${pageSize * page - 1}"`,      },      dataType: 'json',    });    // parallel GET detail    const newsList = await Promise.all(      Object.keys(idList).map(key => {        const url = `${serverUrl}/item/${idList[key]}.json`;        return this.ctx.curl(url, { dataType: 'json' });      })    );    return newsList.map(res => res.data);  }}module.exports = NewsService;

const { serverUrl, pageSize } = this.config.news; 这行有 2 个分页参数,具体应该配置在哪里?

依据咱们下面的教训,config.default.js配置了具体模板应用参数,因而这里就是一个比拟适合的中央。

// config/config.default.js// 增加 news 的配置项exports.news = {  pageSize: 5,  serverUrl: 'https://hacker-news.firebaseio.com/v0',};

service 有了,当初是把固定写死的数据改为动静取数的模式,批改对应的如下

// app/controller/news.jsconst Controller = require('egg').Controller;class NewsController extends Controller {  async list() {    const ctx = this.ctx;    const page = ctx.query.page || 1;    const newsList = await ctx.service.news.list(page);    await ctx.render('news/list.tpl', { list: newsList });  }}module.exports = NewsController;

这行ctx.service.news.list(page), 能够发现 service 不是像 controller 一样绑定在 app 上,而是 ctx 上,这是无意为之,具体看探讨

至此,基本上实现了咱们的整个页面。

目录构造

当咱们实现下面的工作之后,看一下残缺的目录标准

egg-project├── package.json├── app.js (可选)├── agent.js (可选)├── app|   ├── router.js│   ├── controller│   |   └── home.js│   ├── service (可选)│   |   └── user.js│   ├── middleware (可选)│   |   └── response_time.js│   ├── schedule (可选)│   |   └── my_task.js│   ├── public (可选)│   |   └── reset.css│   ├── view (可选)│   |   └── home.tpl│   └── extend (可选)│       ├── helper.js (可选)│       ├── request.js (可选)│       ├── response.js (可选)│       ├── context.js (可选)│       ├── application.js (可选)│       └── agent.js (可选)├── config|   ├── plugin.js|   ├── config.default.js│   ├── config.prod.js|   ├── config.test.js (可选)|   ├── config.local.js (可选)|   └── config.unittest.js (可选)└── test    ├── middleware    |   └── response_time.test.js    └── controller        └── home.test.js

第一次看到这个的时候,会有一些困扰,为什么有了 app 目录,还有 agent.js 和 app.js, schedule 目录又是什么, config 目录上面一大堆货色是什么。

先说 config 目录,
plugin.js 之前说过是定义插件的。

上面一堆 config.xxx.js 到底是个什么东东?

咱们先看下一般 webpack 的配置,个别有三个文件。

scripts├── webpack.common.js├── webpack.dev.js└── webpack.prod.js

在 webpack.dev.js 和 webpack.prod.js 外面,咱们通过 webpack-merge 手动合并 webpack.common.js 。

而在 Eggjs 外面会主动合并 config.default.js, 这在开始的时候的确让人困扰,比方当你环境是 prod 时候,config.prod.js 会主动合并 config.default.js。

环境通过EGG_SERVER_ENV=prod npm start指定,更多阐明参见配置

app 目录上面 router.js, controller,service,view 等目录曾经分明,middleware 目录搁置的是 Koajs 的中间件,extend 目录是对原生对象的扩大,咱们一些罕用的办法个别会放在 util.js 文件中,这里对应的是 helper.js。

接下来说下 app.js , agent.js 和 app/schedule ,这三者的关系。

当咱们在本地开发阶,个别只会起一个实例,通常用node app.js 启动。
然而咱们在部署的时候,个别会有多个,通常用 pm2 来治理,如pm2 start app.js。一个实例对应一个过程。

而 Eggjs 本人实现了一套多过程治理形式,别离有 Master、Agent、Worker 三个角色。

Master: 数量 1,性能稳固,不做具体工作,负责其余两者的管理工作,相似 pm2 。

Agent: 数量 1, 性能稳固,一些后端工作,比方长连贯监听后端配置,而后做一些告诉。

Worker: 性能不稳固,数量多个 (默认核数),业务代码跑这个下面。

那下面 app.js (包含 app 目录) 等就是跑在 worker 过程下,会有多个。
agent.js 跑在 Agent 过程下。

以自己电脑MacBook Pro (13-inch, M1, 2020)为例,这电脑有 8 核,所以基本上会有 8 个 worker 过程,一个 agent 和一个 master 过程。

下图能够看得更清晰,能够看到起了 8 个app_worker.js, 一个agent_work.js, 还有一个 master 过程

那 schedule 又是什么呢?这里是 worker 过程执行定时工作。

// app/schedule/force_refresh.jsexports.schedule = {  interval: '10m',  type: 'all', // 所有worker过程,8个都会执行};exports.schedule = {  interval: '10s',  type: 'worker', // 每台机器上只有一个 worker 会执行定时工作,每次执行定时工作的 worker 随机。};

schedule 和 agent.js 依据本人须要来判断 具体应用哪种。

下面是Eggjs多过程的简略剖析,具体能够看这里

插件

如果当初让你设计一个插件零碎,要求插件之间有依赖关系,要有环境判断,要有开关管制插件启动,该如何设计?

咱们首先想到的是依赖解决,这块前端曾经十分成熟,能够借助 npm,来进行依赖治理。

另外像环境判断等一些参数,能够参考第三方库例如 browserslist,在 package.json 增加一个字段配置,也能够专门新建一个.xxxxrc 配置。

//package.json 写法{  "private": true,  "dependencies": {    "autoprefixer": "^6.5.4"  },  "browserslist": [    "last 1 version",    "> 1%",    "IE 10"  ]}
//.browserslistrc# Browsers that we supportlast 1 version> 1%IE 10 # sorry

由此,咱们能够定义本人的配置如下

//package.json{    myplugin:{        env:"dev",        others:"xxx"    }}

Eggjs 的插件也是这样设计的

{  "eggPlugin": {    "env": [ "local", "test", "unittest", "prod" ]  }}

然而 Eggjs 对于依赖治理,名字都本人做了解决,导致看上去比拟冗余。

//package.json{  "eggPlugin": {    "name": "rpc",    "dependencies": [ "registry" ],    "optionalDependencies": [ "vip" ],    "env": [ "local", "test", "unittest", "prod" ]  }}

所有的一些都写在 eggPlugin 的配置外面,包含插件名字,依赖等,而不是利用 package.json 已有的字段和能力。这也是开始的时候比拟困惑的中央。

官网给出的解释是:

首先 Egg 插件不仅仅反对 npm 包,还反对通过目录来找插件

当初能够通过 yarn 的 workspace 和 lerna 这种 monorepo 的形式,更好的治理插件。

看一下插件的目录和内容,其实是简化版利用。

. egg-hello├── package.json├── app.js (可选)├── agent.js (可选)├── app│   ├── extend (可选)│   |   ├── helper.js (可选)│   |   ├── request.js (可选)│   |   ├── response.js (可选)│   |   ├── context.js (可选)│   |   ├── application.js (可选)│   |   └── agent.js (可选)│   ├── service (可选)│   └── middleware (可选)│       └── mw.js├── config|   ├── config.default.js│   ├── config.prod.js|   ├── config.test.js (可选)|   ├── config.local.js (可选)|   └── config.unittest.js (可选)└── test    └── middleware        └── mw.test.js
  1. 去掉了 router 和 controller。这部分之前说过次要解决申请,进行转发,而插件的定义是加强的中间件,所以没必要。
  2. 去掉了 plugin.js。 这个文件的次要作用就是引入或开启其余插件。框架曾经做了这部分工作,这里就没必要。

因为插件是一个小型利用,因为会存在插件中和框架反复的状况,因而 Eggjs 的加载程序是 插件 < 框架 < 利用

比方 插件有个 config.default.js,框架也有 config.default.js,利用也有 config.default.js。

最初会合并成一个 config.default.js, 执行程序为

let finalConfig= Objeact.assign(插件的config,框架的config,利用的config)

总结

Eggjs 的呈现和框架设计带有本身的特点和时代的因素,
本文作为入门的一个解读,心愿能帮忙大家可能更好的把握这个框架。

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!