Egg上層框架CabloyJS是如何輸出SQL語句日誌的?

背景

在Egg開發實踐中,經常會遇到一個問題:如何查看剛剛執行過的Egg組裝的原生SQL語句呢?

1. 現有方案

可以直接在項目的config配置文件中添加MySQL配置debug: true。這會啓用底層模塊mysql的調試標誌,然後輸出有關SQL語句的詳盡信息,效果如下:

在這裏插入圖片描述

2. 弊端

debug: true方案有如下弊端:

  1. 輸出信息過於詳細,在實際開發中反而會干擾我們快速查看其他日誌信息

  2. 沒有輸出SQL語句的執行時間

3. 理想方案

對於一個理想的SQL語句輸出方案,我們其實只關心兩個問題:

  1. Egg組裝的原生SQL語句到底是怎樣的?便於我們快速排查問題
  2. SQL語句的執行時間是多少,便於我們儘早鎖定性能問題,從而得到及時解決

4. CabloyJS的方案效果

CabloyJS是基於Egg的上層開發框架,針對前面提到的兩個核心問題,實現瞭如下效果

在這裏插入圖片描述

這種SQL語句日誌的輸出效果:不僅一目瞭然可以看到剛剛執行了多少SQL語句,而且每一條SQL語句的執行時間也是歷歷在目。當然,順便我們還能看到SQL語句是由哪個連接對象執行的(通過threadId

實現方案

下面我們看一下CabloyJS是如何實現的。這種實現機制也適用於其他Egg系的上層框架

假設你已經創建了一個CabloyJS的項目,下面的源碼均位於CabloyJS項目內

如何創建CabloyJS項目,請參見:快速開始

1. config定義

爲了讓方案更靈活,我們先擴展一下MySQL參數的定義

node_modules/egg-born-backend/config/config.default.js

  // mysql
  config.mysql = {
    clients: {
      __ebdb: {
        // debug: true,
        hook: {
          meta: {
            color: 'orange',
            long_query_time: 0,
          },
          callback: {
            onQuery,
          },
        },
      },
    },
  };
名稱 說明
debug 如果爲true,就是啓用內置的調試標誌。在這裏沒有啓用
hook.meta 包含hook的配置參數
hook.meta.color 日誌輸出的顏色
hook.meta.long_query_time 如果SQL語句的執行時間超過了long_query_time(ms),就會被輸出到控制檯。特別的,如果long_query_time設爲0,則輸出所有SQL語句
hook.callback.onQuery 爲了提升靈活性,我們可以通過onQuery提供一個自定義的回調函數,當SQL語句的執行信息準備就緒時會被自動調用

2. 改寫模塊ali-rds

Egg執行MySQL語句的技術棧如下:模塊egg -> 模塊egg-mysql -> 模塊ali-rds -> 模塊mysql

在這裏,我們只需要改寫模塊ali-rds即可

node_modules/@zhennann/ali-rds/lib/client.js

function RDSClient(options) {
  if (!(this instanceof RDSClient)) {
    return new RDSClient(options);
  }
  Operator.call(this);

  this.pool = mysql.createPool(options);

  const _hook = options.hook;
  const _getConnection = this.pool.getConnection.bind(this.pool);
  this.pool.getConnection = function(cb) {
    _getConnection(function(err, conn) {
      if (err) return cb(err, null);
      onQuery(conn, function(err) {
        if (err) return cb(err, null);
        onConnection(conn, function(err) {
          if (err) return cb(err, null);
          cb(null, conn);
        });
      });
    });
    function onConnection(conn, cb) {
      if (!_hook || !_hook.callback || !_hook.callback.onConnection) return cb(null);
      if (conn.__hook_onConnection) return cb(null);
      conn.__hook_onConnection = true;
      co.wrap(_hook.callback.onConnection)(new RDSConnection(conn)).then(function() {
        cb(null);
      }).catch(function(err) {
        cb(err);
      });
    }
    function onQuery(conn, cb) {
      if (!_hook || !_hook.callback || !_hook.callback.onQuery) return cb(null);
      if (conn.__hook_onQuery) return cb(null);
      conn.__hook_onQuery = true;
      const _query = conn.query;
      conn.query = function query(sql, values, cb) {
        const prevTime = Number(new Date());
        const sequence = _query.call(conn, sql, values, cb);
        const _callback = sequence._callback;
        sequence._callback = function(...args) {
          const ms = Number(new Date()) - prevTime;
          _hook.callback.onQuery(_hook, ms, sequence, args);
          _callback && _callback(...args);
        };
        return sequence;
      };
      cb(null);
    }
  };

  [
    'query',
    'getConnection',
  ].forEach(method => {
    this.pool[method] = promisify(this.pool[method]);
  });
}
  1. 首先,攔截pool.getConnection方法

  2. 當系統從數據庫連接池中獲取到connection對象時,執行兩個回調onConnectiononQuery

  3. onConnection是在第一次創建connection對象時,執行一些初始化SQL語句,比如設置一些會話級別的變量,不是這裏討論的重點

  4. onQuery的作用就是攔截connection.query方法,在query執行前和執行後分別記錄時間,從而得到SQL語句的執行時間,然後執行config配置中指定的回調函數hook.callback.onQuery

3. 回調hook.callback.onQuery

我們再回頭看一下config配置文件中的回調函數是如何實現的

node_modules/egg-born-backend/config/config.default.js

function onQuery(hook, ms, sequence, args) {
  if (!hook.meta.long_query_time || hook.meta.long_query_time < ms) {
    const message = `threadId: ${sequence._connection.threadId}, ${ms}ms ==> ${sequence.sql}`;
    console.log(chalk.keyword(hook.meta.color)(message));
  }
}
  1. 首先判斷hook.meta.long_query_time,如果爲0或者小於執行時間,就會執行輸出

  2. 使用模塊chalk,並使用指定的顏色值hook.meta.color輸出SQL執行日誌

4. 模塊module-alias

由於我們改寫了模塊ali-rds的源代碼,所以我們需要啓用一個新的模塊名稱,在這裏就是@zhennann/ali-rds,發佈到npm倉庫即可

那麼,如何使新模塊@zhennann/ali-rds生效呢?由於模塊ali-rds是被模塊egg-mysql所引用的。我們如果還要改寫模塊egg-mysql的源碼,代價就未免太大了

在這裏,我們引入模塊module-alias,從而達到這樣的效果:模塊egg-mysql源碼不變,仍然是const rds = require('ali-rds');,但實際上引用的卻是@zhennann/ali-rds

模塊module-alias的機理,請參見:https://github.com/ilearnio/module-alias

這裏,我們只需看一下如何使用模塊module-alias

node_modules/egg-born-backend/index.js

const moduleAlias = require('module-alias');
moduleAlias.addAlias('ali-rds', '@zhennann/ali-rds');

結語

這樣,我們就實現了一個輕巧的方案,不僅可以直接在Egg上層框架中提供缺省的SQL語句輸出方案,而且還可以通過覆蓋config參數hook.callback.onQuery提供自定義的輸出方案

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章