简介
我们遇到一个场景,需要对日志中的敏感字段进行脱敏操作。为了实现该需求,我们定义一个插件(egg-logger-xxx),该插件在 egg-logger 的基础上扩展了自己的 logger、transport,使用方式和 Egg 框架自带的 logger 一致。目前我们的脱敏规则是:
- 取头尾四位,中间使用星号(
*
)代替,例如:440c*\*****0245。
- 日志字段最大长度 255,超出部分使用省略号(
...
)
定义一个插件
有两种方式新建插件,可以使用 Egg 框架提供的脚手架开发,或者项目中新建插件目录文件。这里我们的使用第二种方式,将插件存放在/lib/plugins/egg-logger-xxx。
插件目录结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| custom-logger-demo ├── app ├── config ├── lib │ └── plugins │ └── egg-logger-xxx │ ├── config │ │ └── config.default.js │ ├── app │ │ └── extend │ │ └── context.js │ ├── app.js │ └── lib │ ├── XxxConsoleTransport.js │ ├── XxxFileBufferTransport.js │ ├── XxxFileTransport.js │ ├── XxxLogger.js │ └── utils.js ├── README.md
|
加载本地插件
1 2 3 4 5 6 7
|
module.exports = { loggerXxx: { enable: true, path: path.join(__dirname, '../lib/plugins/egg-logger-xxx'), }
|
添加依赖参数
在插件的config/config.default.js
中添加插件依赖的参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| exports.loggerXxx = {
dir: '',
appLogName: '',
coreLogName: '',
agentLogName: '',
errorLogName: '',
file: '',
formatter: null,
contextFormatter: null,
encryptField: [], };
|
在custom-logger-demo/config/config.default.js
中可以添加修改的配置,eg:
1 2 3 4 5 6 7
| config.xxxLogger = { file: path.join(config.logger.dir, config.logger.appLogName), encryptField: [ { value: 'loginId', regx: /^(\w{3})\w{4}(\w+)/ }, { value: 'passWord', regx: /^(\w{4})\w{24}(\w+)/ }, ], };
|
自定义 Transport
依据需求自定义修改的 transport, XxxConsoleTransport.js 输出日志到控制台,XxxFileTransport.js 输出日志到文件,XxxFileBufferTransport.js 输出日志到内存和文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| 'use strict';
const XxxConsoleTransport = require('egg-logger').ConsoleTransport; const levels = require('egg-logger/lib/level.js'); const utils = require('./utils.js');
class GxjlConsoleTransport extends ConsoleTransport { constructor(options) { super(options); this.encryptField = utils.normalizeEncryptField(options.encryptField); }
log(level, args, meta) { const msg = utils.format(level, args, meta, this.options, this.encryptField);
if (levels[level] >= this.options.stderrLevel && levels[level] < levels.NONE) { process.stderr.write(msg); } else { process.stdout.write(msg); } } }
module.exports = XxxConsoleTransport;
|
XxxFileTransport
和XxxConsoleTransport
类似,重写 log 函数新增encryptField
传参,XxxFileBufferTransport
只修改从XxxFileTransport
继承即可
transport 的 log 函数会调用utils.format()
函数对日志进行格式化,并返回进行输出操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
| 'use strict'; const { cloneDeep: lodashCloneDeep, isObject: lodashIsObject, isArray: lodashIsArray, isString: lodashIsString, } = require('lodash'); const os = require('os'); const utils = require('egg-logger/lib/utils.js'); const utility = require('utility'); const iconv = require('iconv-lite'); const LIMIT_MAX_SIZE = 255;
const hostname = os.hostname();
function formatObject(args, encryptField) { let newArgs = lodashCloneDeep(args); newArgs = nestedEncryptObject(newArgs, encryptField); const formatString = Object.values(newArgs).map(elem => { if (!elem) return elem; if (lodashIsObject(elem) || lodashIsArray(elem)) { return JSON.stringify(elem); } return elem; }); return formatString.join(''); }
function normalizeEncryptField(keys) { const obj = {}; keys.forEach(elem => { if (lodashIsObject(elem) && elem.value) { obj[elem.value] = { value: elem.value, regx: elem.regx, }; } else { obj[elem] = { value: elem, regx: null, }; } }); return obj; }
function encrypted(obj, key, encryptField) { const encryptKeys = Object.keys(encryptField); if (encryptKeys.indexOf(key) >= 0) { const middle = obj[key].length - 8; let regx = RegExp('^(\\w{4})\\w{' + middle + '}(\\w+)'); const replacement = `$1${'*'.repeat(middle)}$2`; if (encryptField[key].regx) { regx = encryptField[key].regx; } obj[key] = obj[key].replace(regx, replacement); } else if (lodashIsString(obj[key]) && obj[key].length > LIMIT_MAX_SIZE) { obj[key] = obj[key].substring(0, LIMIT_MAX_SIZE - 3) + '...'; } return obj; }
function nestedEncryptObject(obj, encryptField) { for (const key in obj) { if (obj.hasOwnProperty(key)) { if (lodashIsObject(obj[key]) || lodashIsArray(obj[key])) { nestedEncryptObject(obj[key], encryptField); } else { obj = encrypted(obj, key, encryptField); } } } return obj; }
const formatError = utils.formatError;
module.exports = { ...utils, normalizeEncryptField, formatError,
format(level, args, meta, options, encryptField) { meta = meta || {}; let message; let output; let formatter = meta.formatter || options.formatter; if (meta.ctx && options.contextFormatter) formatter = options.contextFormatter;
if (args[0] instanceof Error) { message = formatError(args[0]); } else { message = formatObject(args, encryptField).trim(); } if (meta.raw === true) { output = message; } else if (options.json === true || formatter) { meta.level = level; meta.date = utility.logDate(','); meta.pid = process.pid; meta.hostname = hostname; meta.message = message; output = options.json === true ? JSON.stringify(meta) : formatter(meta); } else { output = message; }
if (!output) return new Buffer('');
output += options.eol;
return options.encoding === 'utf8' ? output : iconv.encode(output, options.encoding); }, };
|
自定义 logger
将自定义的 Transport 绑定到自定义的 logger 上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
|
'use strict';
const path = require('path'); const Logger = require('egg-logger').Logger; const utils = require('egg-logger/lib/utils.js'); const XxxFileTransport = require('./XxxFileTransport'); const XxxFileBufferTransport = require('./XxxFileBufferTransport'); const XxxConsoleTransport = require('./XxxConsoleTransport');
class XxxLogger extends Logger {
constructor(options) { super(options); if (!path.isAbsolute(this.options.file)) this.options.file = path.join(this.options.dir, this.options.file);
if (this.options.outputJSON === true && this.options.file) { this.options.jsonFile = this.options.file.replace(/\.log$/, '.json.log'); }
const EggFileTransport = this.options.buffer === true ? XxxFileBufferTransport : XxxFileTransport;
const fileTransport = new EggFileTransport({ file: this.options.file, level: this.options.level || 'INFO', encoding: this.options.encoding, formatter: this.options.formatter, contextFormatter: this.options.contextFormatter, flushInterval: this.options.flushInterval, eol: this.options.eol, encryptField: this.options.encryptField, }); this.set('file', fileTransport);
if (this.options.jsonFile) { const jsonFileTransport = new EggFileTransport({ file: this.options.jsonFile, level: this.options.level, encoding: this.options.encoding, flushInterval: this.options.flushInterval, json: true, eol: this.options.eol, encryptField: this.options.encryptField, }); this.set('jsonFile', jsonFileTransport); }
const consoleTransport = new GxjlConsoleTransport({ level: this.options.consoleLevel, formatter: utils.consoleFormatter, contextFormatter: this.options.contextFormatter, eol: this.options.eol, encryptField: this.options.encryptField, }); this.set('console', consoleTransport); } ... }
module.exports = XxxLogger;
|
添加 app.xxxLogger
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| 'use strict';
const XxxLogger = require('./lib/XxxLogger'); const utils = require('egg-logger/lib/utils.js');
const defaults = { env: 'default', type: '', dir: '', encoding: 'utf8', level: 'INFO', consoleLevel: 'NONE', outputJSON: false, buffer: true, appLogName: '', coreLogName: '', agentLogName: '', errorLogName: '', concentrateError: 'duplicate', };
module.exports = app => { const config = app.config; const loggerConfig = utils.assign({}, defaults, config.logger); loggerConfig.type = app.type;
if (app.config.env === 'prod' && loggerConfig.level === 'DEBUG' && !loggerConfig.allowDebugAtProd) { loggerConfig.level = 'INFO'; }
app.xxxLogger = new XxxLogger(utils.assign({}, loggerConfig, config.xxxLogger));
app.messenger.on('log-reload', () => { app.xxxLogger.reload('[egg-logger-xxx] got log-reload message'); app.xxxLogger.info('[egg-logger-xxx] app logger reload: got log-reload message'); });
app.ready(() => { if (loggerConfig.disableConsoleAfterReady) { app.xxxLogger.disable('console'); } }); };
|
添加 ctx.xxxLogger
访问 Context 下的 Logger 可以输出userId、traceId、method、url
等信息,例如:[-/127.0.0.1/bd5ff220-3ab7-11eb-9110-85a4f7631238/19ms POST /app/v1/login]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 'use strict';
const XXX_CONTEXT_LOGGERS = Symbol('Context#xxxLogger'); const EggContextLogger = require('egg-logger').EggContextLogger;
module.exports = { get xxxLogger() { if (!this[XXX_CONTEXT_LOGGERS]) { this[XXX_CONTEXT_LOGGERS] = new EggContextLogger(this, this.app.xxxLogger); } return this[XXX_CONTEXT_LOGGERS]; }, };
|
使用方式
日志级别:INFO
1
| ctx.xxxLogger.info('入参:', { header: ctx.request.header, body: ctx.request.body });
|
日志级别:ERROR
总结
自此我们完成了自定义一个 Egg-logger。虽然会有点复杂,但在实现的过程中对 Egg-logger 加深了理解,而且以后的扩展其他功能也很方便。