自定义Egg Logger(二):自定义加密功能的logger

简介

我们遇到一个场景,需要对日志中的敏感字段进行脱敏操作。为了实现该需求,我们定义一个插件(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
// config/plugins.js

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 = {
/**
* @description 默认使用config.logger.dir
*/
dir: '',
/**
* @description 默认使用config.logger.appLogName
*/
appLogName: '',
/**
* @description 默认使用config.logger.coreLogName
*/
coreLogName: '',
/**
* @description 默认使用config.logger.agentLogName
*/
agentLogName: '',
/**
* @description 默认使用config.logger.errorLogName
*/
errorLogName: '',
/**
* @description 日志文件路径,参考CustomLogger file配置。
* eg: path.join(config.logger.dir, config.logger.appLogName),
*/
file: '',
/**
* @description 日志输出格式化函数,参考CustomLogger formatter。
* 如果为空使用默认的egg-logger/utils下defaultFormatter
* defaultFormatter(meta) {
* return meta.date + ' ' + meta.level + ' ' + meta.pid + ' ' + meta.message;
* },
*/
formatter: null,
/**
* @description 日志输出格式化,参考CustomLogger formatter。
*/
/**
* @description Context logger日志输出格式化行数,参考CustomLogger contextFormatter。
* 如有有contextFormatter则使用contextFormatter,contextFormatter
*/
contextFormatter: null,
/**
* 需要加密的日志字段,通过正则表达式使用星号(*)替换需要加密的值,
* 默认规则是取头4位,尾4位,中间替换星号(*)
* 字符串格式,eg: : ["loginId", "passWord" ],
* 对象格式,regx表示需要替换加密字段的正则表达式,
* eg: [{ value: 'loginId', regx: /^(\w{3})\w{4}(\w+)/ }, { value: 'passWord', regx: /^(\w{4})\w{24}(\w+)/ }]
* 混合 eg:["loginId", { value: 'passWord', regx: /^(\w{4})\w{24}(\w+)/ }]
*/
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
// lib/XxxConsoleTransport.js
'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);
}

/**
* @description 重写ConsoleTransport类的log方法,对encryptField中的字段进行加密
* output log, see {@link Transport#log}
* if stderrLevel presents, will output log to stderr
* @param {String} level - log level, in upper case
* @param {Array} args - all arguments
* @param {Object} meta - meta infomations
*/
log(level, args, meta) {
// const encryptString = utils.formatObject(args, this.encryptField);
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;

XxxFileTransportXxxConsoleTransport类似,重写 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();

/**
* @description 格式化日志参数对象
* @param { Array } args 需要输出的日志参数对象
* @param { Object } encryptField 需要加密(脱敏)的字段集合
* @return {string} 返回结果
*/
function formatObject(args, encryptField) {
let newArgs = lodashCloneDeep(args); // JSON.parse(JSON.stringify(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('');
}

/**
* @description 将需要加密的字段进行标准化,格式如下
* eg: [{ value: 'loginId', regx: /^(\w{3})\w{4}(\w+)/ }, { value: 'passWord', regx: /^(\w{4})\w{24}(\w+)/ }]
* @param { Array } keys 所有需要加密(脱敏)的字段
* @return {string} 返回结果
*/
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;
}

/**
* @description 加密(脱敏)字段
* @param {Object} obj 需要加密的对象
* @param {String} key 需要加密的对象的key
* @param encryptField 需要加密(脱敏)的字段集合
* @return {*} 返回结果
*/
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;
}

/**
* @description 找到所有的对象,并按encryptField匹配加密
* @param obj 需要加密的对象
* @param encryptField 需要加密(脱敏)的字段集合
* @return {*} 返回结果
*/
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,
/**
* 重写egg-logger/lib/utils.js的format方法,使用formatObject代替util.format.apply(util, args);
*
* Invoke link: {@Link Logger#log} -> {@link Transport#log} -> LoggerUtils.format
* @method LoggerUtils#format
* @param {String} level - log level
* @param {Array} args - format arguments
* @param {Object} meta - loging behaviour meta infomation
* - {String} level
* - {Boolean} raw
* - {Function} formatter
* - {Error} error
* - {String} message
* - {Number} pid
* - {String} hostname
* - {String} date
* @param {Object} options - {@link Transport}'s options
* - {String} encoding
* - {Boolean} json
* - {Function} formatter
* @return {Buffer} formatted log string buffer
*/
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;

// convert string to buffer when encoding is not utf8
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
// lib/XxxLogger.js

'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');

/**
* @description 自定义的logger,使用自定义的XxxFileTransport, XxxFileBufferTransport,XxxConsoleTransport
* 代替原来的FileTransport、FileBufferTransport、ConsoleTransport.
* @extends Logger
* Support three transports: Console, File and JSON File
*/

class XxxLogger extends Logger {
/**
* @constructor
* @param {Object} options
* - {String} dir - log base dir
* - {String} file - log file, support relavie path
* - {String} [encoding = utf8] - log string encoding
* - {String} [level = INFO] - file log level
* - {String} [consoleLevel = NONE] - console log level
* - {Function} [formatter] - log format function
* - {String} [jsonFile] - JSON log file
* - {Boolean} [outputJSON = false] - send JSON log or not
* - {Boolean} [buffer] - use {@link FileBufferTransport} or not
* - {String} [eol] - end of line char
* - {String} encryptField - encrypt field of log
* - {String} [concentrateError] - whether write error logger to common-error.log, `duplicate` / `redirect` / `ignore`
*/
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
// app.js
'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
// app/extend/content.js
'use strict';

const XXX_CONTEXT_LOGGERS = Symbol('Context#xxxLogger');
const EggContextLogger = require('egg-logger').EggContextLogger;

/**
* Context扩展对象
*/
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

1
ctx.xxxLogger.error(e);

总结

自此我们完成了自定义一个 Egg-logger。虽然会有点复杂,但在实现的过程中对 Egg-logger 加深了理解,而且以后的扩展其他功能也很方便。