Egg進階與實戰(zhàn)
Debug
添加 npm scripts
到 package.json
:
{
"scripts": {
"debug": "egg-bin debug"
}
}
egg-bin
會智能選擇調試協(xié)議不撑,在 8.x 之后版本使用 Inspector Protocol 協(xié)議,低版本使用 Legacy Protocol宣渗。
同時也支持自定義調試參數:
egg-bin debug --inpsect=9229
執(zhí)行 debug
命令時,應用也是以 env: local
啟動的,讀取的配置是 config.default.js
和 config.local.js
合并的結果叭披。
chrome瀏覽器調試窗口
調式步驟
- 打開
chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9999/__ws_proxy__
- 選擇sources
- 找到你需要調試的地方,打上斷點
- 進行http訪問
日志
日志對于 Web 開發(fā)的重要性毋庸置疑玩讳,它對于監(jiān)控應用的運行狀態(tài)涩蜘、問題排查等都有非常重要的意義。
框架內置了強大的企業(yè)級日志支持熏纯,由 egg-logger 模塊提供同诫。
日志路徑
所有日志文件默認都放在 ${appInfo.root}/logs/${appInfo.name}
路徑下。
如果想自定義日志路徑:
一般生產環(huán)境下才需要自定義日志
// config/config.${env}.js
exports.logger = {
dir: '/path/to/your/custom/log/dir',
};
日志分類
框架內置了幾種日志豆巨,分別在不同的場景下使用:
- appLogger
${appInfo.name}-web.log
剩辟,例如example-app-web.log
,應用相關日志往扔,供應用開發(fā)者使用的日志。我們在絕大數情況下都在使用它熊户。 - coreLogger
egg-web.log
框架內核萍膛、插件日志。 - errorLogger
common-error.log
實際一般不會直接使用它嚷堡,任何 logger 的.error()
調用輸出的日志都會重定向到這里蝗罗,重點通過查看此日志定位異常。 - agentLogger
egg-agent.log
agent 進程日志蝌戒,框架和使用到 agent 進程執(zhí)行任務的插件會打印一些日志到這里串塑。
如果想自定義以上日志文件名稱,可以在 config 文件中覆蓋默認值:
// config/config.${env}.js
module.exports = appInfo => {
return {
logger: {
appLogName: `${appInfo.name}-web.log`,
coreLogName: 'egg-web.log',
agentLogName: 'egg-agent.log',
errorLogName: 'common-error.log',
},
};
};
日志級別
日志分為 NONE
北苟,DEBUG
桩匪,INFO
,WARN
和 ERROR
5 個級別友鼻。
日志打印到文件中的同時傻昙,為了方便開發(fā)闺骚,也會同時打印到終端中。
- Error 錯誤
- Warn 警告妆档,比如vue沒有加key
- Info 性能分析僻爽,這里一定是有用的信息
- Debug 打印一些隨意的信息
- None 不要去打印
如何打印日志
如果我們在處理請求時需要打印日志,這時候使用 Context Logger贾惦,用于記錄 Web 行為相關的日志胸梆。
ctx.logger.debug('debug info');
ctx.logger.info('info');
ctx.logger.warn('WARNNING!!!!');
如果我們想做一些應用級別的日志記錄,如記錄啟動階段的一些數據信息须板,可以通過 App Logger 來完成碰镜。
// app.js
module.exports = app => {
app.logger.debug('debug info');
app.logger.info('啟動耗時 %d ms', Date.now() - start);
app.logger.warn('warning!');
app.logger.error(someErrorObj);
};
對于框架和插件開發(fā)者會使用到的 App Logger 還有 app.coreLogger
。
// app.js
module.exports = app => {
app.coreLogger.info('啟動耗時 %d ms', Date.now() - start);
};
在開發(fā)框架和插件時有時會需要在 Agent 進程運行代碼逼纸,這時使用 agent.coreLogger
洋措。
// agent.js
module.exports = agent => {
agent.logger.debug('debug info');
agent.logger.info('啟動耗時 %d ms', Date.now() - start);
agent.logger.warn('warning!');
agent.logger.error(someErrorObj);
};
文件日志級別
默認只會輸出 INFO
及以上(WARN
和 ERROR
)的日志到文件中。
可通過如下方式配置輸出到文件日志的級別:
打印所有級別日志到文件中:
// config/config.${env}.js
exports.logger = {
level: 'DEBUG',
};
關閉所有打印到文件的日志:
// config/config.${env}.js
exports.logger = {
level: 'NONE',
};
日志切割
默認日志切割方式杰刽,在每日 00:00
按照 .log.YYYY-MM-DD
文件名進行切割菠发。
我們也可以按照文件大小進行切割。例如贺嫂,當文件超過 2G 時進行切割滓鸠。
例如,我們需要把 egg-web.log
按照大小進行切割:
// config/config.${env}.js
const path = require('path');
module.exports = appInfo => {
return {
logrotator: {
filesRotateBySize: [
path.join(appInfo.root, 'logs', appInfo.name, 'egg-web.log'),
],
maxFileSize: 2 * 1024 * 1024 * 1024,
},
};
};
我們也可以選擇按照小時進行切割第喳,這和默認的按天切割非常類似糜俗,只是時間縮短到每小時。
// config/config.${env}.js
const path = require('path');
module.exports = appInfo => {
return {
logrotator: {
filesRotateByHour: [
path.join(appInfo.root, 'logs', appInfo.name, 'common-error.log'),
],
},
};
};
性能
通常 Web 訪問是高頻訪問曲饱,每次打印日志都寫磁盤會造成頻繁磁盤 IO悠抹,為了提高性能,我們采用的文件日志寫入策略是:日志同步寫入內存扩淀,異步每隔一段時間(默認 1 秒)刷盤
多進程模型和進程間通信
我們知道 JavaScript 代碼是運行在單線程上的楔敌,換句話說一個 Node.js 進程只能運行在一個 CPU 上。那么如果用 Node.js 來做 Web Server驻谆,就無法享受到多核運算的好處卵凑。作為企業(yè)級的解決方案,我們要解決的一個問題就是:
如何榨干服務器資源胜臊,利用上多核 CPU 的并發(fā)優(yōu)勢勺卢?
而 Node.js 官方提供的解決方案是 Cluster 模塊
- 負責啟動其他進程的叫做 Master 進程,他好比是個『包工頭』象对,不做具體的工作黑忱,只負責啟動其他進程。
- 其他被啟動的叫 Worker 進程,顧名思義就是干活的『工人』杨何。它們接收請求酱塔,對外提供服務。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
// Fork workers.
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', function(worker, code, signal) {
console.log('worker ' + worker.process.pid + ' died');
});
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
http.createServer(function(req, res) {
res.writeHead(200);
res.end("hello world\n");
}).listen(8000);
}
egg多進程模型
作為企業(yè)級的解決方案危虱,要考慮的東西還有很多羊娃。
- Worker 進程異常退出以后該如何處理?
- 多個 Worker 進程之間如何共享資源埃跷?
- 多個 Worker 進程之間如何調度蕊玷?
進程守護
健壯性(又叫魯棒性)是企業(yè)級應用必須考慮的問題,除了程序本身代碼質量要保證弥雹,框架層面也需要提供相應的『兜底』機制保證極端情況下應用的可用性垃帅。
一般來說,Node.js 進程退出可以分為兩類:
未捕獲異常
當代碼拋出了異常沒有被捕獲到時剪勿,進程將會退出贸诚,此時 Node.js 提供了 process.on('uncaughtException', handler)
接口來捕獲它,但是當一個 Worker 進程遇到 未捕獲的異常 時厕吉,它已經處于一個不確定狀態(tài)酱固,此時我們應該讓這個進程優(yōu)雅退出:
- 關閉異常 Worker 進程所有的 TCP Server(將已有的連接快速斷開,且不再接收新的連接)头朱,斷開和 Master 的 IPC 通道运悲,不再接受新的用戶請求。
- Master 立刻 fork 一個新的 Worker 進程项钮,保證在線的『工人』總數不變班眯。
- 異常 Worker 等待一段時間,處理完已經接受的請求后退出烁巫。
OOM,系統(tǒng)異常
而當一個進程出現異常導致 crash 或者 OOM 被系統(tǒng)殺死時署隘,不像未捕獲異常發(fā)生時我們還有機會讓進程繼續(xù)執(zhí)行,只能夠讓當前進程直接退出亚隙,Master 立刻 fork 一個新的 Worker定踱。
- OOM
- 內存用盡
在框架里,我們采用 graceful 和 egg-cluster 兩個模塊配合實現上面的邏輯恃鞋。這套方案已在阿里巴巴和螞蟻金服的生產環(huán)境廣泛部署,且經受過『雙11』大促的考驗亦歉,所以是相對穩(wěn)定和靠譜的恤浪。
Agent機制
egg和nodejs原生多進程不同的地方
說到這里,Node.js 多進程方案貌似已經成型肴楷,這也是我們早期線上使用的方案水由。但后來我們發(fā)現有些工作其實不需要每個 Worker 都去做,如果都做赛蔫,一來是浪費資源,更重要的是可能會導致多進程間資源訪問沖突。舉個例子:生產環(huán)境的日志文件我們一般會按照日期進行歸檔贸毕,在單進程模型下這再簡單不過了:
- 每天凌晨 0 點贪薪,將當前日志文件按照日期進行重命名
- 銷毀以前的文件句柄,并創(chuàng)建新的日志文件繼續(xù)寫入
試想如果現在是 4 個進程來做同樣的事情昧互,是不是就亂套了。所以,對于這一類后臺運行的邏輯钞钙,我們希望將它們放到一個單獨的進程上去執(zhí)行,這個進程就叫 Agent Worker声离,簡稱 Agent芒炼。Agent 好比是 Master 給其他 Worker 請的一個『秘書』,它不對外提供服務术徊,只給 App Worker 打工本刽,專門處理一些公共事務。現在我們的多進程模型就變成下面這個樣子了
那我們框架的啟動時序如下:
- Master 啟動后先 fork Agent 進程
- Agent 初始化成功后赠涮,通過 IPC 通道通知 Master
- Master 再 fork 多個 App Worker
- App Worker 初始化成功子寓,通知 Master
- 所有的進程初始化成功后,Master 通知 Agent 和 Worker 應用啟動成功
另外世囊,關于 Agent Worker 還有幾點需要注意的是:
- 由于 App Worker 依賴于 Agent别瞭,所以必須等 Agent 初始化完成后才能 fork App Worker
- Agent 雖然是 App Worker 的『小秘』,但是業(yè)務相關的工作不應該放到 Agent 上去做株憾,不然把她累垮了就不好了
- 由于 Agent 的特殊定位蝙寨,我們應該保證它相對穩(wěn)定。當它發(fā)生未捕獲異常嗤瞎,框架不會像 App Worker 一樣讓他退出重啟墙歪,而是記錄異常日志、報警等待人工處理
Agent用法
你可以在應用或插件根目錄下的 agent.js
中實現你自己的邏輯(和啟動自定義 用法類似贝奇,只是入口參數是 agent 對象)
// agent.js
module.exports = agent => {
// 在這里寫你的初始化邏輯
// 也可以通過 messenger 對象發(fā)送消息給 App Worker
// 但需要等待 App Worker 啟動成功后才能發(fā)送虹菲,不然很可能丟失
agent.messenger.on('egg-ready', () => {
const data = { ... };
agent.messenger.sendToApp('xxx_action', data);
});
};
// app.js
module.exports = app => {
app.messenger.on('xxx_action', data => {
// ...
});
};
master VS agent VS worker
當一個應用啟動時,會同時啟動這三類進程掉瞳。
master
在這個模型下毕源,Master 進程承擔了進程管理的工作(類似 pm2),不運行任何業(yè)務代碼陕习,我們只需要運行起一個 Master 進程它就會幫我們搞定所有的 Worker霎褐、Agent 進程的初始化以及重啟等工作了。
Master 進程的穩(wěn)定性是極高的该镣,線上運行時我們只需要通過 egg-scripts 后臺運行通過 egg.startCluster
啟動的 Master 進程就可以了冻璃,不再需要使用 pm2 等進程守護模塊。
agent
在大部分情況下,我們在寫業(yè)務代碼的時候完全不用考慮 Agent 進程的存在省艳,但是當我們遇到一些場景娘纷,只想讓代碼運行在一個進程上的時候,Agent 進程就到了發(fā)揮作用的時候了跋炕。
由于 Agent 只有一個赖晶,而且會負責許多維持連接的臟活累活,因此它不能輕易掛掉和重啟枣购,所以 Agent 進程在監(jiān)聽到未捕獲異常時不會退出嬉探,但是會打印出錯誤日志,我們需要對日志中的未捕獲異常提高警惕棉圈。
- 應用場景
- 長連接
worker
Worker 進程負責處理真正的用戶請求和定時任務的處理涩堤。而 Egg 的定時任務也提供了只讓一個 Worker 進程運行的能力,所以能夠通過定時任務解決的問題就不要放到 Agent 上執(zhí)行分瘾。
Worker 運行的是業(yè)務代碼胎围,相對會比 Agent 和 Master 進程上運行的代碼復雜度更高,穩(wěn)定性也低一點德召,當 Worker 進程異常退出時白魂,Master 進程會重啟一個 Worker 進程。
進程間通信
雖然每個 Worker 進程是相對獨立的上岗,但是它們之間始終還是需要通訊的福荸,叫進程間通訊(IPC)。下面是 Node.js 官方提供的一段示例代碼
'use strict';
const cluster = require('cluster');
if (cluster.isMaster) {
const worker = cluster.fork();
worker.send('hi there');
worker.on('message', msg => {
console.log(`msg: ${msg} from worker#${worker.id}`);
});
} else if (cluster.isWorker) {
process.on('message', (msg) => {
process.send(msg);
});
}
細心的你可能已經發(fā)現 cluster 的 IPC 通道只存在于 Master 和 Worker/Agent 之間肴掷,Worker 與 Agent 進程互相間是沒有的敬锐。那么 Worker 之間想通訊該怎么辦呢?是的呆瞻,通過 Master 來轉發(fā)台夺。
messenger 對象
app.messenger.broadcast(action, data)
:發(fā)送給所有的 agent / app 進程(包括自己)-
app.messenger.sendToApp(action, data)
: 發(fā)送給所有的 app 進程- 在 app 上調用該方法會發(fā)送給自己和其他的 app 進程
- 在 agent 上調用該方法會發(fā)送給所有的 app 進程
-
app.messenger.sendToAgent(action, data)
: 發(fā)送給 agent 進程- 在 app 上調用該方法會發(fā)送給 agent 進程
- 在 agent 上調用該方法會發(fā)送給 agent 自己
-
agent.messenger.sendRandom(action, data)
:- app 上沒有該方法(現在 Egg 的實現是等同于 sentToAgent)
- agent 會隨機發(fā)送消息給一個 app 進程(由 master 來控制發(fā)送給誰)
app.messenger.sendTo(pid, action, data)
: 發(fā)送給指定進程
// app.js
module.exports = app => {
// 注意,只有在 egg-ready 事件拿到之后才能發(fā)送消息
app.messenger.once('egg-ready', () => {
app.messenger.sendToAgent('agent-event', { foo: 'bar' });
app.messenger.sendToApp('app-event', { foo: 'bar' });
});
}
上面所有 app.messenger 上的方法都可以在 agent.messenger 上使用痴脾。
上面的示例中提到颤介,需要等
egg-ready
消息之后才能發(fā)送消息。只有在 Master 確認所有的 Agent 進程和 Worker 進程都已經成功啟動(并 ready)之后赞赖,才會通過 messenger 發(fā)送egg-ready
消息給所有的 Agent 和 Worker滚朵,告知一切準備就緒,IPC 通道可以開始使用了前域。
在 messenger 上監(jiān)聽對應的 action 事件始绍,就可以收到其他進程發(fā)送來的信息了。
app.messenger.on(action, data => {
// process data
});
app.messenger.once(action, data => {
// process data
});
異常捕獲( 錯誤處理 )
得益于框架支持的異步編程模型话侄,錯誤完全可以用 try catch
來捕獲。在編寫應用代碼時,所有地方都可以直接用 try catch
來捕獲異常年堆。
// app/service/test.js
try {
const res = await this.ctx.curl('http://eggjs.com/api/echo', { dataType: 'json' });
if (res.status !== 200) throw new Error('response status is not 200');
return res.data;
} catch (err) {
this.logger.error(err);
return {};
}
按照正常代碼寫法吞杭,所有的異常都可以用這個方式進行捕獲并處理,但是一定要注意一些特殊的寫法可能帶來的問題变丧。打一個不太正式的比方芽狗,我們的代碼全部都在一個異步調用鏈上,所有的異步操作都通過 await 串接起來了痒蓬,但是只要有一個地方跳出了異步調用鏈童擎,異常就捕獲不到了。
// app/controller/home.js
class HomeController extends Controller {
async buy () {
const request = {};
const config = await ctx.service.trade.buy(request);
// 下單后需要進行一次核對攻晒,且不阻塞當前請求
setImmediate(() => {
ctx.service.trade.check(request);
});
}
}
在這個場景中顾复,如果 service.trade.check
方法中代碼有問題,導致執(zhí)行時拋出了異常鲁捏,盡管框架會在最外層通過 try catch
統(tǒng)一捕獲錯誤芯砸,但是由于 setImmediate
中的代碼『跳出』了異步鏈,它里面的錯誤就無法被捕捉到了给梅。因此在編寫類似代碼的時候一定要注意假丧。
當然,框架也考慮到了這類場景动羽,提供了 ctx.runInBackground(scope)
輔助方法包帚,通過它又包裝了一個異步鏈,所有在這個 scope 里面的錯誤都會統(tǒng)一捕獲运吓。
class HomeController extends Controller {
async buy () {
const request = {};
const config = await ctx.service.trade.buy(request);
// 下單后需要進行一次核對渴邦,且不阻塞當前請求
ctx.runInBackground(async () => {
// 這里面的異常都會統(tǒng)統(tǒng)被 Backgroud 捕獲掉,并打印錯誤日志
await ctx.service.trade.check(request);
});
}
}
為了保證異秤鸬拢可追蹤几莽,必須保證所有拋出的異常都是 Error 類型,因為只有 Error 類型才會帶上堆棧信息宅静,定位到問題章蚣。
框架層統(tǒng)一異常處里
盡管框架提供了默認的統(tǒng)一異常處理機制(dev 會顯示堆棧信息),但是應用開發(fā)中經常需要對異常時的響應做自定義姨夹,特別是在做一些接口開發(fā)的時候纤垂。框架自帶的 onerror 插件支持自定義配置錯誤處理方法磷账,可以覆蓋默認的錯誤處理方法峭沦。
// config/config.default.js
module.exports = {
onerror: {
all(err, ctx) {
// 在此處定義針對所有響應類型的錯誤處理方法
// 注意,定義了 config.all 之后逃糟,其他錯誤處理方法不會再生效
ctx.body = 'error';
ctx.status = 500;
},
},
};
404
框架并不會將服務端返回的 404 狀態(tài)當做異常來處理吼鱼,但是框架提供了當響應為 404 且沒有返回 body 時的默認響應蓬豁。
- 當請求被框架判定為需要 JSON 格式的響應時,會返回一段 JSON:
{ "message": "Not Found" }
- 當請求被框架判定為需要 HTML 格式的響應時菇肃,會返回一段 HTML:
<h1>404 Not Found</h1>
框架支持通過配置地粪,將默認的 HTML 請求的 404 響應重定向到指定的頁面。
// config/config.default.js
module.exports = {
notfound: {
pageUrl: '/404.html',
},
};
自定義404響應
在一些場景下琐谤,我們需要自定義服務器 404 時的響應蟆技,和自定義異常處理一樣,我們也只需要加入一個中間件即可對 404 做統(tǒng)一處理:
// app/middleware/notfound_handler.js
module.exports = () => {
return async function notFoundHandler(ctx, next) {
await next();
if (ctx.status === 404 && !ctx.body) {
if (ctx.acceptJSON) {
ctx.body = { error: 'Not Found' };
} else {
ctx.body = '<h1>Page Not Found</h1>';
}
}
};
};
在配置中引入中間件:
// config/config.default.js
module.exports = {
middleware: [ 'notfoundHandler' ],
};
多實例插件
許多插件的目的都是將一些已有的服務引入到框架中斗忌,如 egg-mysql, egg-oss质礼。他們都需要在 app 上創(chuàng)建對應的實例。而在開發(fā)這一類的插件時织阳,我們發(fā)現存在一些普遍性的問題:
- 在一個應用中同時使用同一個服務的不同實例(連接到兩個不同的 MySQL 數據庫)眶蕉。
- 從其他服務獲取配置后動態(tài)初始化連接(從配置中心獲取到 MySQL 服務地址后再建立連接)。
如果讓插件各自實現陈哑,可能會出現各種奇怪的配置方式和初始化方式妻坝,所以框架提供了 app.addSingleton(name, creator)
方法來統(tǒng)一這一類服務的創(chuàng)建。需要注意的是在使用 app.addSingleton(name, creator)
方法時惊窖,配置文件中一定要有 client
或者 clients
為 key 的配置作為傳入 creator
函數 的 config
刽宪。
插件寫法
我們將 egg-mysql 的實現簡化之后來看看如何編寫此類插件:
// egg-mysql/app.js
module.exports = app => {
// 第一個參數 mysql 指定了掛載到 app 上的字段,我們可以通過 `app.mysql` 訪問到 MySQL singleton 實例
// 第二個參數 createMysql 接受兩個參數(config, app)界酒,并返回一個 MySQL 的實例
app.addSingleton('mysql', createMysql);
}
/**
* @param {Object} config 框架處理之后的配置項圣拄,如果應用配置了多個 MySQL 實例,會將每一個配置項分別傳入并調用多次 createMysql
* @param {Application} app 當前的應用
* @return {Object} 返回創(chuàng)建的 MySQL 實例
*/
function createMysql(config, app) {
// 省略毁欣。庇谆。。通過config凭疮,創(chuàng)建一個mysql實例
return client;
}
初始化方法也支持 Async function
饭耳,便于有些特殊的插件需要異步化獲取一些配置文件。
單實例
- 在配置文件中聲明 MySQL 的配置执解。
// config/config.default.js
module.exports = {
mysql: {
client: {
host: 'mysql.com',
port: '3306',
user: 'test_user',
password: 'test_password',
database: 'test',
},
},
};
- 直接通過
app.mysql
訪問數據庫寞肖。
// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.mysql.query(sql, values);
},
}
多實例
- 同樣需要在配置文件中聲明 MySQL 的配置,不過和單實例時不同衰腌,配置項中需要有一個
clients
字段新蟆,分別申明不同實例的配置,同時可以通過default
字段來配置多個實例中共享的配置(如 host 和 port)右蕊。需要注意的是在這種情況下要用get
方法指定相應的實例琼稻。(例如:使用app.mysql.get('db1').query()
,而不是直接使用app.mysql.query()
得到一個undefined
)饶囚。
// config/config.default.js
exports.mysql = {
clients: {
// clientId, access the client instance by app.mysql.get('clientId')
db1: {
user: 'user1',
password: 'upassword1',
database: 'db1',
},
db2: {
user: 'user2',
password: 'upassword2',
database: 'db2',
},
},
// default configuration for all databases
default: {
host: 'mysql.com',
port: '3306',
},
};
- 通過
app.mysql.get('db1')
來獲取對應的實例并使用帕翻。
// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.mysql.get('db1').query(sql, values);
},
}
動態(tài)創(chuàng)建實例
// app.js
module.exports = app => {
app.beforeStart(async () => {
// 從配置中心獲取 MySQL 的配置 { host, post, password, ... }
const mysqlConfig = await app.configCenter.fetch('mysql');
// 動態(tài)創(chuàng)建 MySQL 實例
app.database = await app.mysql.createInstanceAsync(mysqlConfig);
});
};
通過 app.database
來使用這個實例鸠补。
// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.database.query(sql, values);
},
}
注意,在動態(tài)創(chuàng)建實例的時候熊咽,框架也會讀取配置中 default 字段內的配置項作為默認配置莫鸭。
多進程增強版
在前面講解 的多進程模型中, 其中適合使用 Agent 進程的有一類常見的場景:一些中間件客戶端需要和服務器建立長連接横殴,理論上一臺服務器最好只建立一個長連接,但多進程模型會導致 n 倍(n = Worker 進程數)連接被創(chuàng)建卿拴。
為了盡可能的復用長連接(因為它們對于服務端來說是非常寶貴的資源)衫仑,我們會把它放到 Agent 進程里維護,然后通過 messenger 將數據傳遞給各個 Worker堕花。這種做法是可行的文狱,但是往往需要寫大量代碼去封裝接口和實現數據的傳遞,非常麻煩缘挽。
另外瞄崇,通過 messenger 傳遞數據效率是比較低的,因為它會通過 Master 來做中轉壕曼;萬一 IPC 通道出現問題還可能將 Master 進程搞掛苏研。
我們提供一種新的模式來降低這類客戶端封裝的復雜度。通過建立 Agent 和 Worker 的 socket 直連跳過 Master 的中轉腮郊。Agent 作為對外的門面維持多個 Worker 進程的共享連接摹蘑。
核心思想
受到 Leader/Follower 模式的啟發(fā)。
-
客戶端會被區(qū)分為兩種角色:
- Leader: 負責和遠程服務端維持連接轧飞,對于同一類的客戶端只有一個 Leader衅鹿。
- Follower: 會將具體的操作委托給 Leader,常見的是訂閱模型(讓 Leader 和遠程服務端交互过咬,并等待其返回)大渤。
-
如何確定誰是 Leader,誰是 Follower 呢掸绞?有兩種模式:
- 自由競爭模式:客戶端啟動的時候通過本地端口的爭奪來確定 Leader泵三。例如:大家都嘗試監(jiān)聽 7777 端口,最后只會有一個實例搶占到集漾,那它就變成 Leader切黔,其余的都是 Follower。
- 強制指定模式:框架指定某一個 Leader具篇,其余的就是 Follower纬霞。
框架里面我們采用的是強制指定模式,Leader 只能在 Agent 里面創(chuàng)建驱显,這也符合我們對 Agent 的定位
框架啟動的時候 Master 會隨機選擇一個可用的端口作為 Cluster Client 監(jiān)聽的通訊端口诗芜,并將它通過參數傳遞給 Agent 和 App Worker瞳抓。
Leader 和 Follower 之間通過 socket 直連(通過通訊端口),不再需要 Master 中轉伏恐。
新的模式下孩哑,客戶端的通信方式如下:
客戶端接口類型抽象
抽象是類的描述
我們將客戶端接口抽象為下面兩大類,這也是對客戶端接口的一個規(guī)范翠桦,對于符合規(guī)范的客戶端横蜒,我們可以自動將其包裝為 Leader/Follower 模式。
-
訂閱销凑、發(fā)布類(subscribe / publish):
-
subscribe(info, listener)
接口包含兩個參數丛晌,第一個是訂閱的信息,第二個是訂閱的回調函數斗幼。 -
publish(info)
接口包含一個參數澎蛛,就是訂閱的信息。
-
調用類 (invoke)蜕窿,支持 callback, promise 和 generator function 三種風格的接口谋逻,但是推薦使用 generator function。
客戶端示例
const Base = require('sdk-base');
class Client extends Base {
constructor(options) {
super(options);
// 在初始化成功以后記得 ready
this.ready(true);
}
/**
* 訂閱
*
* @param {Object} info - 訂閱的信息(一個 JSON 對象桐经,注意盡量不要包含 Function, Buffer, Date 這類屬性)
* @param {Function} listener - 監(jiān)聽的回調函數毁兆,接收一個參數就是監(jiān)聽到的結果對象
*/
subscribe(info, listener) {
// ...
}
/**
* 發(fā)布
*
* @param {Object} info - 發(fā)布的信息,和上面 subscribe 的 info 類似
*/
publish(info) {
// ...
}
/**
* 獲取數據 (invoke)
*
* @param {String} id - id
* @return {Object} result
*/
async getData(id) {
// ...
}
}
異常處理
- Leader 如果“死掉”會觸發(fā)新一輪的端口爭奪次询,爭奪到端口的那個實例被推選為新的 Leader荧恍。
- 為保證 Leader 和 Follower 之間的通道健康,需要引入定時心跳檢查機制屯吊,如果 Follower 在固定時間內沒有發(fā)送心跳包送巡,那么 Leader 會將 Follower 主動斷開,從而觸發(fā) Follower 的重新初始化盒卸。
具體使用方法
下面我用一個簡單的例子骗爆,介紹在框架里面如何讓一個客戶端支持 Leader/Follower 模式:
- 第一步,我們的客戶端最好是符合上面提到過的接口約定蔽介,例如:
// registry_client.js 就是進行socket的基礎類
const URL = require('url');
const Base = require('sdk-base');
class RegistryClient extends Base {
constructor(options) {
super({
// 指定異步啟動的方法
initMethod: 'init',
});
this._options = options;
this._registered = new Map();
}
/**
* 啟動邏輯
*/
async init() {
this.ready(true);
}
/**
* 獲取配置
* @param {String} dataId - the dataId
* @return {Object} 配置
*/
async getConfig(dataId) {
return this._registered.get(dataId);
}
/**
* 訂閱
* @param {Object} reg
* - {String} dataId - the dataId
* @param {Function} listener - the listener
*/
subscribe(reg, listener) {
const key = reg.dataId; // 時間名稱
this.on(key, listener);
const data = this._registered.get(key);
if (data) {
process.nextTick(() => listener(data));
}
}
/**
* 發(fā)布
* @param {Object} reg
* - {String} dataId - the dataId
* - {String} publishData - the publish data
*/
publish(reg) {
const key = reg.dataId;
let changed = false;
if (this._registered.has(key)) {
const arr = this._registered.get(key);
if (arr.indexOf(reg.publishData) === -1) {
changed = true;
arr.push(reg.publishData);
}
} else {
changed = true;
this._registered.set(key, [reg.publishData]);
}
if (changed) {
this.emit(key, this._registered.get(key).map(url => URL.parse(url, true)));
}
}
}
module.exports = RegistryClient;
- 第二步摘投,使用
agent.cluster
接口對RegistryClient
進行封裝:
// agent.js
const RegistryClient = require('registry_client');
module.exports = agent => {
// 對 RegistryClient 進行封裝和實例化
agent.registryClient = agent.cluster(RegistryClient)
// create 方法的參數就是 RegistryClient 構造函數的參數
.create({});
agent.beforeStart(async () => {
await agent.registryClient.ready();
agent.coreLogger.info('registry client is ready');
});
};
- 第三步,使用
app.cluster
接口對RegistryClient
進行封裝:
const RegistryClient = require('registry_client');
module.exports = app => {
app.registryClient = app.cluster(RegistryClient).create({});
app.beforeStart(async () => {
await app.registryClient.ready();
app.coreLogger.info('registry client is ready');
// 調用 subscribe 進行訂閱
app.registryClient.subscribe({
dataId: 'demo.DemoService',
}, val => {
// ...
});
// 調用 publish 發(fā)布數據
app.registryClient.publish({
dataId: 'demo.DemoService',
publishData: 'xxx',
});
// 調用 getConfig 接口
const res = await app.registryClient.getConfig('demo.DemoService');
console.log(res);
});
};
實戰(zhàn)-簡易博客
學習目標
使用egg + mongoDb實現一個簡易博客的增刪改查虹蓄。
框架選型
egg + mongoose
準備工作
- mongoDb可視化工具
- postman
- 安裝mongoDb
mongoose簡介
簡介: mongoos對mongoDb的一層封裝和抽象犀呼,方便在nodejs中操作mongoDb數據庫 。
依賴: mongoDb 和 nodejs
安裝
npm install mongoose
連接
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');
快速開始
Mongoose 里薇组,一切都始于Schema外臂。 現在我們來看一個例子:
-
schema是對數據模型的描述
對應數據庫存儲的字段
var postsSchema = mongoose.Schema({ // 對文章的描述
title: String,
content: String
});
很好,我們得到了一個帶有 String
類型 name
屬性的 schema 律胀。 接著我們需要把這個 schema 編譯成一個 Model:
var Posts = mongoose.model('posts', postsSchema);
model 是我們構造 document 的 Class宋光。 在例子中貌矿,每個 document 都是一篇文章,它的屬性和行為都會被聲明在 schema罪佳。 現在我們來“創(chuàng)造”一篇文章:
var a_news = new Posts({ title: 'xxx', content: 'xxx' });
emmmmm雖然我們還沒吧它存到數據庫里逛漫。 每個 document 會在調用他的 save 方法后保存到數據庫。 注意回調函數的第一個參數永遠是 error 赘艳。
a_news.save(function (err, fluffy) {
if (err) return console.error(err);
});
后來我們收集了好多喵酌毡,就可以通過以下方法獲取喵星人 model 里的所有數據:
Posts.find(function (err, kittens) {
if (err) return console.error(err);
console.log(kittens);
})
如果我們想獲取特定的數據, 可以了解一下 query蕾管。
// 這么寫可以獲取所有 name 為 "book" 開頭的數據
Posts.find({ name: /^book/ }, callback);
正式開始
安裝依賴
npm init egg type-simple
npm install egg-mongoose --save
開發(fā)順序
- 安裝環(huán)境阔馋,配置數據庫插件
- 編寫schema,設計存儲的字段
- 進行路由設計, 通過控制器添加數據
- 錯誤處理
- 返回值
- 校驗參數
- 數據庫的查詢
- 查詢全部數據
- 查詢單個數據
- 數據庫的刪除
- 數據庫的更新
- service提取
配置
// config/plugin.js
mongoose: {
enable: true,
package: 'egg-mongoose',
},
// config/config.default.js
config.mongoose = {
url: 'mongodb://127.0.0.1/example',
options: {},
}
路由配置
- 解析用戶請求,給相應的controller
- 通過resources 直接創(chuàng)建restFul api
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.resources('posts', '/api/posts',controller.posts);
};
控制器
- 解析request參數
- 校驗參數
- 調用service
- 返回結果
'use strict';
// const Controller = require('egg').Controller;
const httpController = require('./base/http');
// GET /posts -> app.controller.posts.index
// GET /posts/new -> app.controller.posts.new
// GET /posts/:id -> app.controller.posts.show
// GET /posts/:id/edit -> app.controller.posts.edit
// POST /posts -> app.controller.posts.create
// PUT /posts/:id -> app.controller.posts.update
// DELETE /posts/:id -> app.controller.posts.destroy
const createRule = { // 參數校驗
title: { type: 'string' },
content: { type: 'string' },
}
const updateRule = {
title: { type: 'string', required: false },
content: { type: 'string', required: false },
}
class PostsController extends httpController {
async create() { // 創(chuàng)建文章
const { ctx } = this;
const requestBody = ctx.request.body;
try {
this.ctx.validate(createRule);
const res = await ctx.service.posts.create(requestBody);
this.success(res);
} catch (err) {
// console.log('=======', err)
this.fail(err)
}
}
async index() {// 讀取所有文章
const { ctx } = this;
ctx.body = await ctx.service.posts.find({});
}
async show() {
const { ctx } = this;
try {
const params_id = ctx.params.id;
const res = await ctx.service.posts.find({
_id: params_id
});
this.success(res);
} catch (err) {
this.fail(err);
}
}
async update() { // 更新文章
const { ctx } = this;
// console.log('================', ctx.request.body)
try {
const params_id = ctx.params.id;
const requestBody = ctx.request.body;
ctx.validate(updateRule);
const res = await ctx.service.posts.update({
_id: params_id,
}, {
$set: {
...requestBody
}
});
this.success(res);
} catch (err) {
this.fail(err);
}
}
async destroy() { // 刪除文章
const { ctx } = this;
// console.log('------------', ctx.model);
try {
const params_id = ctx.params.id;
const res = await ctx.service.posts.remove({
_id: params_id
});
this.success(res);
} catch (err) {
this.fail(err);
}
}
}
module.exports = PostsController;
base controller
- 對controller的一種抽象
- 抽取通用邏輯
// controller/base/http.js
'use strict';
const Controller = require('egg').Controller;
class HttpController extends Controller {
success(data) {
// msg 和 code這樣的2個字段 在所有的請求里面都需要返回娇掏。
// 好好理解oop
this.ctx.body = {
msg: data && data.msg || 'success',
code: 0,
data
}
}
fail(data) {
this.logger.error(data)
this.ctx.body = {
msg: data && data.msg || 'fail',
code: data && data.code || 1,
data
}
}
}
module.exports = HttpController;
model
- 對數據模型的描述和創(chuàng)建
// {app_root}/app/model/user.js
module.exports = app => {
const mongoose = app.mongoose;
const Schema = mongoose.Schema;
const postsSchema = new Schema({
title: { type: String, unique: true },
content: { type: String },
});
return mongoose.model('Posts', postsSchema);
}
service
- 具體操作數據庫的邏輯
- 進行增刪改查,并暴露方法給控制器調用
const Service = require('egg').Service;
class PostsService extends Service {
async find(data) { // 查
const res = await this.ctx.model.Posts.find(data);
return res
}
async update(findData, updateData) { // 改
const res = await this.ctx.model.Posts.update(findData, updateData);
return res;
}
async remove(data) { // 刪除
const res = await this.ctx.model.Posts.remove(data);
return res;
}
async create(data) { // 增
// console.log(this.ctx.model)
const postsInstance = new this.ctx.model.Posts({
title: data.title,
content: data.content
});
const res = await postsInstance.save();
return res;
}
}
module.exports = PostsService;