Eggjs 入門解構

雲音樂大前端團隊發表於2021-12-02

閱讀本文前,請先瀏覽 Eggjs 官方例子和 瞭解 Koajs

本文作者:東東章

開始

例子

官方給了這樣一個例子,手動搭建 Hacker News。

當我們看到這個頁面的時候,不要著急往下看教程。 先自我思考下如何實現這個頁面,要用到哪些技術:

  1. 路由處理。我們需要一個角色處理接受 /news 請求,除此之外,一般還有 / 預設首頁,也就是說至少 2 個 URL。
  2. 頁面展示。這裡可以用模板,也可以直接自己拼接 HTML 元素。nodejs 模板有PugEJSHandlebarsjs等多個模板。
  3. 取數問題。有一個角色處理請求並拿到返回的資料。
  4. 合併資料。將模板和取到的資料結合起來,顯示最終的結果。

MVC

在服務端有個很經典的 MVC 設計模式來解決這類問題。

mvc

  1. Modal: 管理資料和業務邏輯。通常細分為 service (業務邏輯) 和 dao (資料庫管理) 兩層。
  2. View: 佈局和頁面展示。
  3. Controller:將相關請求路由到對應的 Modal 和 View。

下面以Java Spring MVC為例

@Controller
public 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.js

const 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.js
module.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.js
const 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,Ejshandlebarsjs,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.js
const 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 程式
egg_progress

那 schedule 又是什麼呢?這裡是 worker 程式執行定時任務。

// app/schedule/force_refresh.js
exports.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 support
last 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!

相關文章