Egg進階

Egg進階與實戰(zhàn)

Debug

添加 npm scriptspackage.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.jsconfig.local.js 合并的結果叭披。

chrome瀏覽器調試窗口

image-20190704133631408.png

調式步驟

  1. 打開 chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9999/__ws_proxy__
  2. 選擇sources
  3. 找到你需要調試的地方,打上斷點
  4. 進行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桩匪,INFOWARNERROR 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 及以上(WARNERROR)的日志到文件中。

可通過如下方式配置輸出到文件日志的級別:

打印所有級別日志到文件中:

// 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)雅退出

  1. 關閉異常 Worker 進程所有的 TCP Server(將已有的連接快速斷開,且不再接收新的連接)头朱,斷開和 Master 的 IPC 通道运悲,不再接受新的用戶請求。
  2. Master 立刻 fork 一個新的 Worker 進程项钮,保證在線的『工人』總數不變班眯。
  3. 異常 Worker 等待一段時間,處理完已經接受的請求后退出烁巫。
image-20190704185701491.png
OOM,系統(tǒng)異常

而當一個進程出現異常導致 crash 或者 OOM 被系統(tǒng)殺死時署隘,不像未捕獲異常發(fā)生時我們還有機會讓進程繼續(xù)執(zhí)行,只能夠讓當前進程直接退出亚隙,Master 立刻 fork 一個新的 Worker定踱。

  • OOM
    • 內存用盡

在框架里,我們采用 gracefulegg-cluster 兩個模塊配合實現上面的邏輯恃鞋。這套方案已在阿里巴巴和螞蟻金服的生產環(huán)境廣泛部署,且經受過『雙11』大促的考驗亦歉,所以是相對穩(wěn)定和靠譜的恤浪。

Agent機制

egg和nodejs原生多進程不同的地方

說到這里,Node.js 多進程方案貌似已經成型肴楷,這也是我們早期線上使用的方案水由。但后來我們發(fā)現有些工作其實不需要每個 Worker 都去做,如果都做赛蔫,一來是浪費資源,更重要的是可能會導致多進程間資源訪問沖突。舉個例子:生產環(huán)境的日志文件我們一般會按照日期進行歸檔贸毕,在單進程模型下這再簡單不過了:

  1. 每天凌晨 0 點贪薪,將當前日志文件按照日期進行重命名
  2. 銷毀以前的文件句柄,并創(chuàng)建新的日志文件繼續(xù)寫入

試想如果現在是 4 個進程來做同樣的事情昧互,是不是就亂套了。所以,對于這一類后臺運行的邏輯钞钙,我們希望將它們放到一個單獨的進程上去執(zhí)行,這個進程就叫 Agent Worker声离,簡稱 Agent芒炼。Agent 好比是 Master 給其他 Worker 請的一個『秘書』,它不對外提供服務术徊,只給 App Worker 打工本刽,專門處理一些公共事務。現在我們的多進程模型就變成下面這個樣子了

image-20190704190149301.png

那我們框架的啟動時序如下:

image-20190704190421232.png
  1. Master 啟動后先 fork Agent 進程
  2. Agent 初始化成功后赠涮,通過 IPC 通道通知 Master
  3. Master 再 fork 多個 App Worker
  4. App Worker 初始化成功子寓,通知 Master
  5. 所有的進程初始化成功后,Master 通知 Agent 和 Worker 應用啟動成功

另外世囊,關于 Agent Worker 還有幾點需要注意的是:

  1. 由于 App Worker 依賴于 Agent别瞭,所以必須等 Agent 初始化完成后才能 fork App Worker
  2. Agent 雖然是 App Worker 的『小秘』,但是業(yè)務相關的工作不應該放到 Agent 上去做株憾,不然把她累垮了就不好了
  3. 由于 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

當一個應用啟動時,會同時啟動這三類進程掉瞳。

image-20190704192617114.png
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ā)台夺。

image-20190704192933130.png

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饭耳,便于有些特殊的插件需要異步化獲取一些配置文件。

單實例

  1. 在配置文件中聲明 MySQL 的配置执解。
// config/config.default.js
module.exports = {
  mysql: {
    client: {
      host: 'mysql.com',
      port: '3306',
      user: 'test_user',
      password: 'test_password',
      database: 'test',
    },
  },
};
  1. 直接通過 app.mysql 訪問數據庫寞肖。
// app/controller/post.js
class PostController extends Controller {
  async list() {
    const posts = await this.app.mysql.query(sql, values);
  },
}

多實例

  1. 同樣需要在配置文件中聲明 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',
  },
};
  1. 通過 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)建卿拴。

image-20190705175043007.png

為了盡可能的復用長連接(因為它們對于服務端來說是非常寶貴的資源)衫仑,我們會把它放到 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 中轉伏恐。

新的模式下孩哑,客戶端的通信方式如下:

image-20190707004732019.png

客戶端接口類型抽象

抽象是類的描述

我們將客戶端接口抽象為下面兩大類,這也是對客戶端接口的一個規(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簡介

安裝
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ā)順序

  1. 安裝環(huán)境阔馋,配置數據庫插件
  2. 編寫schema,設計存儲的字段
  3. 進行路由設計, 通過控制器添加數據
    1. 錯誤處理
    2. 返回值
    3. 校驗參數
  4. 數據庫的查詢
    1. 查詢全部數據
    2. 查詢單個數據
  5. 數據庫的刪除
  6. 數據庫的更新
  7. service提取
配置
// config/plugin.js
mongoose: {
    enable: true,
    package: 'egg-mongoose',
},
// config/config.default.js
config.mongoose = {
  url: 'mongodb://127.0.0.1/example',
  options: {},
}
路由配置
  1. 解析用戶請求,給相應的controller
  2. 通過resources 直接創(chuàng)建restFul api
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.resources('posts', '/api/posts',controller.posts);
};
控制器
  1. 解析request參數
  2. 校驗參數
  3. 調用service
  4. 返回結果
'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
  1. 對controller的一種抽象
  2. 抽取通用邏輯
// 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
  1. 對數據模型的描述和創(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
  1. 具體操作數據庫的邏輯
  2. 進行增刪改查,并暴露方法給控制器調用
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;
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末勋眯,一起剝皮案震驚了整個濱河市婴梧,隨后出現的幾起案子,更是在濱河造成了極大的恐慌客蹋,老刑警劉巖塞蹭,帶你破解...
    沈念sama閱讀 219,039評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現場離奇詭異讶坯,居然都是意外死亡番电,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 93,426評論 3 395
  • 文/潘曉璐 我一進店門辆琅,熙熙樓的掌柜王于貴愁眉苦臉地迎上來漱办,“玉大人,你說我怎么就攤上這事婉烟∶渚” “怎么了?”我有些...
    開封第一講書人閱讀 165,417評論 0 356
  • 文/不壞的土叔 我叫張陵似袁,是天一觀的道長洞辣。 經常有香客問我,道長昙衅,這世上最難降的妖魔是什么扬霜? 我笑而不...
    開封第一講書人閱讀 58,868評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮而涉,結果婚禮上著瓶,老公的妹妹穿的比我還像新娘。我一直安慰自己婴谱,他們只是感情好蟹但,可當我...
    茶點故事閱讀 67,892評論 6 392
  • 文/花漫 我一把揭開白布躯泰。 她就那樣靜靜地躺著,像睡著了一般华糖。 火紅的嫁衣襯著肌膚如雪麦向。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,692評論 1 305
  • 那天客叉,我揣著相機與錄音诵竭,去河邊找鬼。 笑死兼搏,一個胖子當著我的面吹牛卵慰,可吹牛的內容都是我干的。 我是一名探鬼主播佛呻,決...
    沈念sama閱讀 40,416評論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼裳朋,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了吓著?” 一聲冷哼從身側響起鲤嫡,我...
    開封第一講書人閱讀 39,326評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎绑莺,沒想到半個月后暖眼,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 45,782評論 1 316
  • 正文 獨居荒郊野嶺守林人離奇死亡纺裁,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,957評論 3 337
  • 正文 我和宋清朗相戀三年诫肠,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片欺缘。...
    茶點故事閱讀 40,102評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡栋豫,死狀恐怖,靈堂內的尸體忽然破棺而出浪南,到底是詐尸還是另有隱情笼才,我是刑警寧澤,帶...
    沈念sama閱讀 35,790評論 5 346
  • 正文 年R本政府宣布络凿,位于F島的核電站骡送,受9級特大地震影響,放射性物質發(fā)生泄漏絮记。R本人自食惡果不足惜摔踱,卻給世界環(huán)境...
    茶點故事閱讀 41,442評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望怨愤。 院中可真熱鬧派敷,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,996評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至试躏,卻和暖如春猪勇,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背颠蕴。 一陣腳步聲響...
    開封第一講書人閱讀 33,113評論 1 272
  • 我被黑心中介騙來泰國打工泣刹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人犀被。 一個月前我還...
    沈念sama閱讀 48,332評論 3 373
  • 正文 我出身青樓椅您,卻偏偏與公主長得像,于是被迫代替她去往敵國和親寡键。 傳聞我的和親對象是個殘疾皇子掀泳,可洞房花燭夜當晚...
    茶點故事閱讀 45,044評論 2 355

推薦閱讀更多精彩內容