閱讀本文前,請先瀏覽 Eggjs 官方例子和 瞭解 Koajs
本文作者:東東章
開始
官方給了這樣一個例子,手動搭建 Hacker News。
當我們看到這個頁面的時候,不要著急往下看教程。 先自我思考下如何實現這個頁面,要用到哪些技術:
- 路由處理。我們需要一個角色處理接受
/news
請求,除此之外,一般還有/
預設首頁,也就是說至少 2 個 URL。 - 頁面展示。這裡可以用模板,也可以直接自己拼接 HTML 元素。nodejs 模板有Pug,EJS,Handlebarsjs等多個模板。
- 取數問題。有一個角色處理請求並拿到返回的資料。
- 合併資料。將模板和取到的資料結合起來,顯示最終的結果。
MVC
在服務端有個很經典的 MVC 設計模式來解決這類問題。
- Modal: 管理資料和業務邏輯。通常細分為 service (業務邏輯) 和 dao (資料庫管理) 兩層。
- View: 佈局和頁面展示。
- 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>
- 首先用註解
@Controller
定義了一個GreetingController
類。 @GetMapping("/greeting")
接受了/greeting
,並交給public String greeting
處理,這塊屬於 Controller 層。String name = ownerService.findOwner(ownerId);model.addAttribute("name", name);
獲取資料,屬於 Modal 層。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,Ejs,handlebarsjs,Nunjucks等多種。
有時候在專案中要根據情況來從多個模板選擇具體某個,因此需要框架做到:
- 宣告多個模板型別。
- 配置具體使用某個模板。
為了更好的管理,宣告和使用要分開,配置一般放在 config 目錄下,所以有了config/plugin.js
和config/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 程式
那 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
- 去掉了 router 和 controller。這部分之前說過主要處理請求,進行轉發,而外掛的定義是增強的中介軟體,所以沒必要。
- 去掉了 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!