iKcamp|基於Koa2搭建Node.js實戰(含視頻)☞ 記錄日誌

滬江CCtalk視頻地址:https://www.cctalk.com/v/15114923883523html

log 日誌中間件

最困難的事情就是認識本身。node

在一個真實的項目中,開發只是整個投入的一小部分,版本迭代和後期維護佔了極其重要的部分。項目上線運轉起來以後,咱們如何知道項目運轉的狀態呢?如何發現線上存在的問題,如何及時進行補救呢?記錄日誌就是解決困擾的關鍵方案。正如咱們天天寫日記同樣,不只可以記錄項目天天都作了什麼,便於往後回顧,也能夠將作錯的事情記錄下來,進行自我檢討。完善的日誌記錄不只可以還原問題場景,還有助於統計訪問數據,分析用戶行爲。git

日誌的做用

  • 顯示程序運行狀態
  • 幫助開發者排除問題故障
  • 結合專業的日誌分析工具(如 ELK )給出預警

關於編寫 log 中間件的預備知識

log4js

本項目中的 log 中間件是基於 log4js 2.x 的封裝,Log4jsNode.js 中一個成熟的記錄日誌的第三方模塊,下文也會根據中間件的使用介紹一些 log4js 的使用方法。github

日誌分類

日誌能夠大致上分爲訪問日誌和應用日誌。訪問日誌通常記錄客戶端對項目的訪問,主要是 http 請求。這些數據屬於運營數據,也能夠反過來幫助改進和提高網站的性能和用戶體驗;應用日誌是項目中須要特殊標記和記錄的位置打印的日誌,包括出現異常的狀況,方便開發人員查詢項目的運行狀態和定位 bug 。應用日誌包含了debuginfowarnerror等級別的日誌。apache

日誌等級

log4js 中的日誌輸出可分爲以下7個等級:npm

LOG_LEVEL.957353bf.png

在應用中按照級別記錄了日誌以後,能夠按照指定級別輸出高於指定級別的日誌。json

日誌切割

當咱們的項目在線上環境穩定運行後,訪問量會愈來愈大,日誌文件也會愈來愈大。日益增大的文件對查看和跟蹤問題帶來了諸多不便,同時增大了服務器的壓力。雖然能夠按照類型將日誌分爲兩個文件,但並不會有太大的改善。因此咱們按照日期將日誌文件進行分割。好比:今天將日誌輸出到 task-2017-10-16.log 文件,明天會輸出到 task-2017-10-17.log 文件。減少單個文件的大小不只方便開發人員按照日期排查問題,還方便對日誌文件進行遷移。小程序

代碼實現

安裝 log4js 模塊

npm i log4js -S

log4js 官方簡單示例

middleware/ 目錄下建立 mi-log/demo.js,並貼入官方示例代碼:微信小程序

var log4js = require('log4js');
var logger = log4js.getLogger();
logger.level = 'debug';
logger.debug("Some debug messages");

而後在 /middleware/mi-log/ 目錄下運行:瀏覽器

cd ./middleware/mi-log/ && node demo.js

能夠在終端看到以下輸出:

[2017-10-24 15:45:30.770] [DEBUG] default - Some debug messages

一段帶有日期、時間、日誌級別和調用 debug 方法時傳入的字符串的文本日誌。實現了簡單的終端日誌輸出。

log4js 官方複雜示例

替換 mi-log/demo.js 中的代碼爲以下:

const log4js = require('log4js');
log4js.configure({
  appenders: { cheese: { type: 'file', filename: 'cheese.log' } },
  categories: { default: { appenders: ['cheese'], level: 'error' } }
});

const logger = log4js.getLogger('cheese');
logger.trace('Entering cheese testing');
logger.debug('Got cheese.');
logger.info('Cheese is Gouda.');
logger.warn('Cheese is quite smelly.');
logger.error('Cheese is too ripe!');
logger.fatal('Cheese was breeding ground for listeria.');

再次在 /middleware/mi-log/ 目錄下運行:

node demo.js

運行以後,在當前的目錄下會生成一個日誌文件 cheese.log文件,文件中有兩條日誌並記錄了 error 及以上級別的信息,也就是以下內容:

[2017-10-24 15:51:30.770] [ERROR] cheese - Cheese is too ripe!
[2017-10-24 15:51:30.774] [FATAL] cheese - Cheese was breeding ground for listeria.

注意: 日誌文件產生的位置就是當前啓動環境的位置。

分析以上代碼就會發現,configure 函數配置了日誌的基本信息

{
  /**
   * 指定要記錄的日誌分類 cheese
   * 展現方式爲文件類型 file
   * 日誌輸出的文件名 cheese.log
   */
  appenders: { cheese: { type: 'file', filename: 'cheese.log' } },

  /**
   * 指定日誌的默認配置項
   * 若是 log4js.getLogger 中沒有指定,默認爲 cheese 日誌的配置項
   * 指定 cheese 日誌的記錄內容爲 error 及 error 以上級別的信息
   */
  categories: { default: { appenders: ['cheese'], level: 'error' } }
}

改寫爲log中間件

建立 /mi-log/logger.js 文件,並增長以下代碼:

const log4js = require('log4js');
module.exports = ( options ) => {
  return async (ctx, next) => {
    const start = Date.now()
    log4js.configure({
      appenders: { cheese: { type: 'file', filename: 'cheese.log' } },
      categories: { default: { appenders: ['cheese'], level: 'info' } }
    }); 
    const logger = log4js.getLogger('cheese');
    await next()
    const end = Date.now()
    const responseTime = end - start;
    logger.info(`響應時間爲${responseTime/1000}s`);
  }
}

建立 /mi-log/index.js 文件,並增長以下代碼:

const logger = require("./logger")
module.exports = () => {
   return logger()
}

修改 middleware/index.js 文件,並增長對 log 中間件的註冊, 以下代碼:

const path = require('path')
const bodyParser = require('koa-bodyparser')
const nunjucks = require('koa-nunjucks-2')
const staticFiles = require('koa-static')

const miSend = require('./mi-send')
// 引入日誌中間件
const miLog = require('./mi-log')
module.exports = (app) => {
  // 註冊中間件
  app.use(miLog())

  app.use(staticFiles(path.resolve(__dirname, "../public")))
  app.use(nunjucks({
    ext: 'html',
    path: path.join(__dirname, '../views'),
    nunjucksConfig: {
      trimBlocks: true
    }
  }));
  app.use(bodyParser())
  app.use(miSend())
}

打開瀏覽器並訪問 http://localhost:3000, 來發送一個http 請求。

如上,按照前幾節課程中講解的中間件的寫法,將以上代碼改寫爲中間件。 基於 koa 的洋蔥模型,當 http 請求通過此中間件時便會在 cheese.log 文件中打印一條日誌級別爲 info 的日誌並記錄了請求的響應時間。如此,便實現了訪問日誌的記錄。

實現應用日誌,將其掛載到 ctx

若要在其餘中間件或代碼中經過 ctx 上的方法打印日誌,首先須要在上下文中掛載 log 函數。打開 /mi-log/logger.js 文件:

const log4js = require('log4js');
const methods = ["trace", "debug", "info", "warn", "error", "fatal", "mark"]

module.exports = () => {
  const contextLogger = {}
  log4js.configure({
    appenders: { cheese: { type: 'file', filename: 'cheese.log' } },
    categories: { default: { appenders: ['cheese'], level: 'info' } }
  }); 
 
  const logger = log4js.getLogger('cheese');
  
  return async (ctx, next) => {
     // 記錄請求開始的時間
    const start = Date.now()
     // 循環methods將全部方法掛載到ctx 上
    methods.forEach((method, i) => {
       contextLogger[method] = (message) => {
         logger[method](message)
       }
    })
    ctx.log = contextLogger;

    await next()
    // 記錄完成的時間 做差 計算響應時間
    const responseTime = Date.now() - start;
    logger.info(`響應時間爲${responseTime/1000}s`);
  }
}

建立 contextLogger 對象,將全部的日誌級別方法賦給對應的 contextLogger 對象方法。在將循環後的包含全部方法的 contextLogger 對象賦給 ctx 上的 log 方法。

打開 /mi-send/index.js 文件, 並調用 ctx 上的 log 方法:

module.exports = () => {
  function render(json) {
      this.set("Content-Type", "application/json")
      this.body = JSON.stringify(json)
  }
  return async (ctx, next) => {
      ctx.send = render.bind(ctx)
      // 調用ctx上的log方法下的error方法打印日誌
      ctx.log.error('ikcamp');
      await next()
  }
}

在其餘中間件中經過調用 ctx 上的 log 方法,從而實現打印應用日誌。

const log4js = require('log4js');
const methods = ["trace", "debug", "info", "warn", "error", "fatal", "mark"]

module.exports = () => {
  const contextLogger = {}
  const config = {
    appenders: {
        cheese: {
         type: 'dateFile', // 日誌類型 
         filename: `logs/task`,  // 輸出的文件名
         pattern: '-yyyy-MM-dd.log',  // 文件名增長後綴
         alwaysIncludePattern: true   // 是否老是有後綴名
       }
    },
    categories: {
      default: {
        appenders: ['cheese'],
        level:'info'
      }
    }
  }

  const logger = log4js.getLogger('cheese');

  return async (ctx, next) => {
    const start = Date.now()

    log4js.configure(config)
    methods.forEach((method, i) => {
      contextLogger[method] = (message) => {
        logger[method](message)
      }
    })
    ctx.log = contextLogger;

    await next()
    const responseTime = Date.now() - start;
    logger.info(`響應時間爲${responseTime/1000}s`);
  }
}

修改日誌類型爲日期文件,按照日期切割日誌輸出,以減少單個日誌文件的大小。這時候打開瀏覽器並訪問 http://localhost:3000,這時會自動生成一個 logs 目錄,並生成一個 cheese-2017-10-24.log 文件, 中間件執行便會在其中中記錄下訪問日誌。

├── node_modules/
├── logs/ 
│     ├── cheese-2017-10-24.log 
├── ……
├── app.js

抽出可配置量

const log4js = require('log4js');
const methods = ["trace", "debug", "info", "warn", "error", "fatal", "mark"]

// 提取默認公用參數對象
const baseInfo = {
  appLogLevel: 'debug',  // 指定記錄的日誌級別
  dir: 'logs',      // 指定日誌存放的目錄名
  env: 'dev',   // 指定當前環境,當爲開發環境時,在控制檯也輸出,方便調試
  projectName: 'koa2-tutorial',  // 項目名,記錄在日誌中的項目信息
  serverIp: '0.0.0.0'       // 默認狀況下服務器 ip 地址
}

const { env, appLogLevel, dir } = baseInfo
module.exports = () => {
  const contextLogger = {}
  const appenders = {}
  
  appenders.cheese = {
    type: 'dateFile',
    filename: `${dir}/task`,
    pattern: '-yyyy-MM-dd.log',
    alwaysIncludePattern: true
  }
  // 環境變量爲dev local development 認爲是開發環境
  if (env === "dev" || env === "local" || env === "development") {
    appenders.out = {
      type: "console"
    }
  }
  let config = {
    appenders,
    categories: {
      default: {
        appenders: Object.keys(appenders),
        level: appLogLevel
      }
    }
  }

  const logger = log4js.getLogger('cheese');

  return async (ctx, next) => {
    const start = Date.now()

    log4js.configure(config)
    methods.forEach((method, i) => {
      contextLogger[method] = (message) => {
        logger[method](message)
      }
    })
    ctx.log = contextLogger;

    await next()
    const responseTime = Date.now() - start;
    logger.info(`響應時間爲${responseTime/1000}s`);
  }
}

代碼中,咱們指定了幾個常量以方便後面提取,好比 appLogLeveldirenv 等。 。並判斷當前環境爲開發環境則將日誌同時輸出到終端, 以便開發人員在開發是查看運行狀態和查詢異常。

豐富日誌信息

ctx 對象中,有一些客戶端信息是咱們數據統計及排查問題所須要的,因此徹底能夠利用這些信息來豐富日誌內容。在這裏,咱們只須要修改掛載 ctx 對象的 log 函數的傳入參數:

logger[method](message)

參數 message 是一個字符串,因此咱們封裝一個函數,用來把信息與上下文 ctx 中的客戶端信息相結合,並返回字符串。

增長日誌信息的封裝文件 mi-log/access.js

module.exports = (ctx, message, commonInfo) => {
  const {
    method,  // 請求方法 get post或其餘
    url,          // 請求連接
    host,     // 發送請求的客戶端的host
    headers   // 請求中的headers
  } = ctx.request;
  const client = {
    method,
    url,
    host,
    message,
    referer: headers['referer'],  // 請求的源地址
    userAgent: headers['user-agent']  // 客戶端信息 設備及瀏覽器信息
  }
  return JSON.stringify(Object.assign(commonInfo, client));
}

注意: 最終返回的是字符串。

取出 ctx 對象中請求相關信息及客戶端 userAgent 等信息並轉爲字符串。

mi-log/logger.js 文件中調用:

const log4js = require('log4js');
// 引入日誌輸出信息的封裝文件
const access = require("./access.js");
const methods = ["trace", "debug", "info", "warn", "error", "fatal", "mark"]

const baseInfo = {
  appLogLevel: 'debug',
  dir: 'logs',
  env: 'dev',
  projectName: 'koa2-tutorial',
  serverIp: '0.0.0.0'
}
const { env, appLogLevel, dir, serverIp, projectName } = baseInfo
// 增長常量,用來存儲公用的日誌信息
const commonInfo = { projectName, serverIp }
module.exports = () => {
  const contextLogger = {}
  const appenders = {}

  appenders.cheese = {
    type: 'dateFile',
    filename: `${dir}/task`,
    pattern: '-yyyy-MM-dd.log',
    alwaysIncludePattern: true
  }
  
  if (env === "dev" || env === "local" || env === "development") {
    appenders.out = {
      type: "console"
    }
  }
  let config = {
    appenders,
    categories: {
      default: {
        appenders: Object.keys(appenders),
        level: appLogLevel
      }
    }
  }

  const logger = log4js.getLogger('cheese');

  return async (ctx, next) => {
    const start = Date.now()

    log4js.configure(config)
    methods.forEach((method, i) => {
      contextLogger[method] = (message) => {
       // 將入參換爲函數返回的字符串
        logger[method](access(ctx, message, commonInfo))
      }
    })
    ctx.log = contextLogger;

    await next()
    const responseTime = Date.now() - start;
    logger.info(access(ctx, {
      responseTime: `響應時間爲${responseTime/1000}s`
    }, commonInfo))
  }
}

重啓服務器並訪問 http://localhost:3000 就會發現,日誌文件的記錄內容已經變化。代碼到這裏,已經完成了大部分的日誌功能。下面咱們完善下其餘功能:自定義配置參數和捕捉錯誤。

項目自定義內容

安裝依賴文件 ip:

npm i ip -S

修改 middleware/index.js 中的調用方法

const path = require('path')
const ip = require('ip')
const bodyParser = require('koa-bodyparser')
const nunjucks = require('koa-nunjucks-2')
const staticFiles = require('koa-static')

const miSend = require('./mi-send')
const miLog = require('./mi-log/logger')
module.exports = (app) => {
  // 將配置中間件的參數在註冊中間件時做爲參數傳入
  app.use(miLog({
    env: app.env,  // koa 提供的環境變量
    projectName: 'koa2-tutorial',
    appLogLevel: 'debug',
    dir: 'logs',
    serverIp: ip.address()
  }))

  app.use(staticFiles(path.resolve(__dirname, "../public")))

  app.use(nunjucks({
    ext: 'html',
    path: path.join(__dirname, '../views'),
    nunjucksConfig: {
      trimBlocks: true
    }
  }));

  app.use(bodyParser())
  app.use(miSend())
}

再次修改 mi-log/logger.js 文件:

const log4js = require('log4js');
const access = require("./access.js");
const methods = ["trace", "debug", "info", "warn", "error", "fatal", "mark"]

const baseInfo = {
  appLogLevel: 'debug',
  dir: 'logs',
  env: 'dev',
  projectName: 'koa2-tutorial',
  serverIp: '0.0.0.0'
}

module.exports = (options) => {
  const contextLogger = {}
  const appenders = {}
  
  // 繼承自 baseInfo 默認參數
  const opts = Object.assign({}, baseInfo, options || {})
  // 須要的變量解構 方便使用
  const { env, appLogLevel, dir, serverIp, projectName } = opts
  const commonInfo = { projectName, serverIp }
    
  appenders.cheese = {
    type: 'dateFile',
    filename: `${dir}/task`,
    pattern: '-yyyy-MM-dd.log',
    alwaysIncludePattern: true
  }
  
  if (env === "dev" || env === "local" || env === "development") {
    appenders.out = {
      type: "console"
    }
  }
  let config = {
    appenders,
    categories: {
      default: {
        appenders: Object.keys(appenders),
        level: appLogLevel
      }
    }
  }

  const logger = log4js.getLogger('cheese');

  return async (ctx, next) => {
    const start = Date.now()

    log4js.configure(config)
    methods.forEach((method, i) => {
      contextLogger[method] = (message) => {
        logger[method](access(ctx, message, commonInfo))
      }
    })
    ctx.log = contextLogger;

    await next()
    const responseTime = Date.now() - start;
    logger.info(access(ctx, {
      responseTime: `響應時間爲${responseTime/1000}s`
    }, commonInfo))
  }
}

將項目中自定義的量覆蓋默認值,解構使用。以達到項目自定義的目的。

對日誌中間件進行錯誤處理

對於日誌中間件裏面的錯誤,咱們也須要捕獲並處理。在這裏,咱們提取一層進行封裝。

打開 mi-log/index.js 文件,修改代碼以下:

const logger = require("./logger")
module.exports = (options) => {
  const loggerMiddleware = logger(options)

  return (ctx, next) => {
    return loggerMiddleware(ctx, next)
    .catch((e) => {
        if (ctx.status < 500) {
            ctx.status = 500;
        }
        ctx.log.error(e.stack);
        ctx.state.logged = true;
        ctx.throw(e);
    })
  }
}

若是中間件裏面有拋出錯誤,這裏將經過 catch 函數捕捉到並處理,將狀態碼小於 500 的錯誤統一按照 500 錯誤碼處理,以方便後面的 http-error 中間件顯示錯誤頁面。 調用 log 中間件打印堆棧信息並將錯誤拋出到最外層的全局錯誤監聽進行處理。

到這裏咱們的日誌中間件已經制做完成。固然,還有不少的狀況咱們須要根據項目狀況來繼續擴展,好比結合『監控系統』、『日誌分析預警』和『自動排查跟蹤機制』等。能夠參考一下官方文檔

下一節中,咱們將學習下如何處理請求錯誤。

上一篇:iKcamp新課程推出啦~~~~~iKcamp|基於Koa2搭建Node.js實戰(含視頻)☞ 處理靜態資源

推薦: 翻譯項目Master的自述:

1. 乾貨|人人都是翻譯項目的Master

2. iKcamp出品微信小程序教學共5章16小節彙總(含視頻)

相關文章
相關標籤/搜索