winston 是我在 nodejs 下最常用的日志框架,那么他到底是如何工作的呢?
winston 的运行核心
winston 中有两个关键词: 记录器(logger)和传输器(transport).
记录器负责收集/修饰分配进入的每一条日志,传输器则负责最终把日志记录到的哪,
整个 winston 其实就是多个流(stream)的链式调用.记录器继承了交换流(transform),而传输器则继承自可写流(writeable).
为什么使用流式架构呢?
面对大量的日志,流式架构有类似于消息队列的消费模式,可以自行调节流动速度.日志来的太快,传输器消费不动,就在缓冲区先存一下,等待压力小了再消费.不会在不必要的地方浪费内存,保证了稳定性和速度.
况且,最终的传输器如日志文件,接口调用,控制台输出,这些本质上都是流的实现,winston 也使用流的话编写起来也更方便.
winston 的组织结构
结构图
madge生成的 winston 组织结构图.
winston.js.
整个框架对外暴露的对象:
- 引用 package.json,获取当前的版本信息.
- 引用 container.js,用于管理所有创建的记录器
- 引用 common.js,用于获取通用工具
- 引用 config/index.js,加载了默认的配置项
- 引用 create-logger.js,用于创建默认的日志方法.
- 引用 exception-handle.js,默认添加了拦截意外错误的处理器
- 引用 transports/index.js,加载了默认内置的所有传输器(transports)
container.js
winston 可以直接使用默认生成的一个记录器,也可以自由添加命名的记录器.
命名的记录器就通过 container 管理.
- 引用 create-logger,用于生成记录器
create-logger.js
生成记录器的类.
- 引用记录器基类(logger.js),create-logger 继承了这个基类
logger.js
winston 的核心之一,记录器基类.接收具体的日志消息,对接传输器.
- 引用 common.js,获取工具函数,用于废弃方法警告(3.x 版本,还兼容一些 2.x 的 API,将会在 4.x 移除)
- 引用 config/index.js,加载默认的配置
- 引用 profiler.js,可以测试日志生成时间
- 引用 exception-handle.js,可以拦截意外错误
exception-handle.js
拦截并处理未被捕获的错误
- 引用 exception-stream.js,对捕获的错误进行处理.
transport/index.js
传输器作为 winston 的另一核心,对从记录器收到的日志进行最终的存储/处理/展示
- 引用 console.js,用于打印在控制台.
- 引用 file.js,用于存储到文件
- 引用 http.js,用于将日志以 json 形式发送到对应地址
- 引用 stream.js,将日志输送到指定可写流中
transport/file.js
最常用的传输器,将日志打印到日志文件中
- 引用 tail-file.js,用于对日志进行查询
各模块源码阅读
winston 的源码其实阅读起来并不方便,因为里面又引用了作者封装的一些 winston 专用的库,阅读 winston 还需要把这些相关库的功能搞清.
winston.js
const logform = require("logform");
const { warn } = require("./winston/common");
在一开始引用了两个需要多次调用的库.
- logform 是作者将对日志进行格式化的一些方法(比如 json 处理,添加时间等),又封成一个独立的库,这里不展开了,以后有机会可能会拿出来详细的看一下.
- warn 是定义的公共函数,用于警告用户.一些历史遗留的方法,函数(winston 1.x,2.x 版本)已经不可用了
整个文件基本上为了实现其两个职能:
创建自定义记录器
可以利用 winston 对象创建自定义的记录器
const winston = exports;
winston.version = require('../package.json').version;
winston.transports = require('./winston/transports');
...
winston.Transport = require('winston-transport');
//初始化了一个默认的记录器容器.
winston.loggers = new winston.Container();
代码中,winston 对象直接指向了 module.exports.每一行挂载一个模块到对应的功能上,避免在文件头部占据大部分空间.当然,像某些强类型语言是有引用全部在头部写的规则的.
挂载的模块都是创建自定义记录器可能用到的.
使用默认的记录器
winston 对象本身也是一个有默认参数的记录器,对于最一般需求,可以直接拿来就用.
//创建了一个默认的记录器,将一个记录器应有的方法和属性都挂到winston对象上.
const defaultLogger = winston.createLogger();
Object.keys(winston.config.npm.levels).concat(['log','query','stream','add','remove','clear','profile','startTimer','handleExceptions','unhandleExceptions','configure']).forEach(method =>
(winston[method] = (...args) => defaultLogger[method](...args))
);
Object.defineProperty(winston, 'level', {
get() {
return defaultLogger.level;
},
set(val) {
defaultLogger.level = val;
}
});
Object.defineProperty(winston, 'exceptions', {
get() {
return defaultLogger.exceptions;
}
});
//这里可能是以前数组里有若干个属性,后来就剩这一个了,所以还保留这种数组形式
['exitOnError'].forEach(prop => {
Object.defineProperty(winston, prop, {
get() {
return defaultLogger[prop];
},
set(val) {
defaultLogger[prop] = val;
}
});
});
...
这里定义的几个属性是通过 Object.defineProperty 定义的.定义的几个属性都有保护,不会被删除或者修改指向.
猜测是因为要保护默认定义的这个记录器,防止其他也引用了 winston 的第三方包修改这些引用,造成难以预估的问题.而自定义的记录器因为相互之间是独立的,所以一般不会和第三方包冲突.
container.js
如果需要在同一个项目的若干个模块里都采用同一个记录器,那么 container 就很方便了.定义一个拥有名称的记录器,在任何模块都可以使用记录器名称从 container 中取出来.
如果只在一个模块内使用,或者有其他方法能在模块间传递某个记录器的引用,那么可以不使用 container.
container 本质上是维持了一个字典.不过并没有用普通的对象作为字典,而是使用了 ES6 的 Map.
在这里使用 Map 的原因,我觉得是 Map 的语义更能表达一个字典的功能(如 has,get,set,delete),同时相比普通的对象效率更高.
create-logger.js
一开始先引用了一个作者自己写的包 triple-beam 中的 LEVEL 变量,其实相当于使用了 es6 的 symbol
const { LEVEL } = require("triple-beam");
const LEVEL = Symbol.for("level");
create-logger 本身是对 logger 类的扩展.相比 logger 本身,他多做了一件事:
将所有的日志级别绑定在记录器上.
传输器是绑定级别的,所以记录器将收集所有级别的日志,再派发给各个传输器.
_setupLevels() {
/*
this.levels默认是npm日志的7个级别,即:
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
silly: 6
*/
Object.keys(this.levels).forEach(level => {
this[level] = function (...args) {
//新式API,使用时直接调用log.info(msg)
if (args.length === 1) {
const [msg] = args;
const info = msg && msg.message && msg || {
message: msg
};
info.level = info[LEVEL] = level;
this.write(info);
//这里的this因为没有使用箭头函数,所以指向的是具体的实例,否则就会挂载到原型链上
return this;
}
//旧式API,log('info',msg)
return this.log(level, ...args);
};
//挂载了一个判断该级别下本方法是否可用的函数,后期没有用到
this[isLevelEnabledFunctionName(level)] = () => this.isLevelEnabled(level);
});
}
其中的特别点,是添加了一个 Symbol 变量 LEVEL 作为属性名的属性,防止该 msg 本身也有一个名为 level 的属性在某些情况下覆盖掉.
关于 symbol 类型,可以看阮老师的讲解
logger.js
logger 本质上是一个 transform 流的实现,不过他实现自'readable-stream'模块,而不是 node 自带的 stream 模块
const { Stream, Transform } = require("readable-stream");
文档是这样描述这个模块的:
This package is a mirror of the streams implementations in Node.js.
Full documentation may be found on the Node.js website.
If you want to guarantee a stable streams base, regardless of what version of Node you, or the users of your libraries are using, use readable-stream only and avoid the "stream" module in Node-core, for background see this blogpost.
大意是,这个模块不会因为切换不同的 nodejs 版本而产生兼容问题,如果你的产品需要兼容多个 nodejs 版本,尽量选择这个模块而不是原生的 stream 模块.
记录器在初始化方法里,通过调用 add 方法,将用户自定义的传输器都通过管道连到了自身上,因为每种传输器都是相互独立的,所以并非是链式调用,本质上更接近于用户定义的若干个传输器都监听了记录器的 data 事件,一旦来了数据,就会向各个传输器写入.
add(transport) {
//确保输入的传输器是规定的对象(LegacyTransportStream也来自于作者自定义的模块:winston-transport/legacy)
//如果是自定义的传输器,就用LegacyTransportStream包装,如果是自带的传输器,如file,console就直接传输
const target = !isStream(transport) || transport.log.length > 2 ?
new LegacyTransportStream({transport}) :transport;
if (!target._writableState || !target._writableState.objectMode) {
throw new Error('Transports must WritableStreams in objectMode. Set { objectMode: true }.');
}
//将传输器的error和warn事件传递到记录器上
this._onEvent('error', target);
this._onEvent('warn', target);
//pipe到传输器上
this.pipe(target);
if (transport.handleExceptions) {
//拦截意外错误
this.exceptions.handle();
}
return this;
}
_onEvent(event, transport) {
function transportEvent(err) {
this.emit(event, err, transport);
}
if (!transport['__winston' + event]) {
transport['__winston' + event] = transportEvent.bind(this);
transport.on(event, transport['__winston' + event]);
}
}
既然继承了 transform,本模块就需要实现_transform 方法:
记录器的_transform 方法本质很简单,仅是把接到的已被各种格式化器(format)处理好的对象调用自身的 push 方法,发给了自己的 readable.
同时,还实现了_final 方法.
_final 方法会在写完所有数据时调用,调用完成后会发送'finish'事件.在这里利用_final 会等待所有传输器都处理完毕后才触发事件
_transform(info, enc, callback) {
if (this.silent) {
return callback();
}
//确保info是合法的info
if (!info[LEVEL]) {
info[LEVEL] = info.level;
}
if (!this.levels[info[LEVEL]] && this.levels[info[LEVEL]] !== 0) {
console.error('[winston] Unknown logger level: %s', info[LEVEL]);
}
if (!this._readableState.pipes) {
console.error('[winston] Attempt to write logs with no transports %j', info);
}
try {
this.push(this.format.transform(info, this.format.options));
} catch (ex) {
throw ex;
} finally {
callback();
}
}
_final(callback) {
const transports = this.transports.slice();
asyncForEach(transports, (transport, next) => {
//这里用setImmediate而不是直接调用next,是等待前面未完成的transport完成才调用next
if (!transport || transport.finished) return setImmediate(next);
//否则就等待他结束再处理下一个
transport.once('finish', next);
transport.end();
}, callback);
}
file.js
以最常用的文件日志传输器为例.
要想搞清楚这个 file 传输器是如何工作,需要先搞清楚所有传输器都继承的类'winston-transport'是如何工作的.
winston-transport 主要实现的功能是日志按级别分发,是对可写流的继承:
记录器有日志级别,传输器也可以指定级别,未指定级别的传输器默认级别就是其记录器的级别.
同时,依据日志级别协议,低级别的日志会包含高级别日志,比如定义了输出级别为 info,则日志文件中会包含 info 以及比 info 高级别的 warn,error 等信息.
constructor(){
this.once('pipe', logger => {
//记录连接到的记录器,得到记录器的日志级别(默认是info级别)
this.parent = logger;
});
}
_write(info, enc, callback) {
//如果指定了级别就按指定的来,否则,就继承自连接的记录器的级别
const level = this.level || (this.parent && this.parent.level);
//记录所有与本级别相等或高于本级别的日志(数字越小,级别越高)
if (!level || this.levels[level] >= this.levels[info[LEVEL]]) {
//不需要格式化就直接打印
if (info && !this.format) {
//这里的log函数实现于本类的继承者,在本例子里就是File传输器.
return this.log(info, callback);
}
let errState;
let transformed;
//格式化传入的日志
try {
transformed = this.format.transform(Object.assign({}, info), this.format.options);
} catch (err) {
errState = err;
}
if (errState || !transformed) {
callback();
if (errState) throw errState;
return;
}
return this.log(transformed, callback);
}
return callback(null);
};
Ok,了解完其基类,再来看看 file 传输器本身,这几乎也是 winston 最复杂的地方了.
file 传输器的初始化:
constructor(options = {}) {
super(options);
//基础的双工流,主要用于写入本地文件,也可以传给用户自定义的流
this._stream = new PassThrough();
/*
传输器在暂时无法写入本地文件的特殊情况(写入太快,正在轮转文件等),
可以缓冲30条日志,在此期间,如果日志多于30条.数据就丢失了
*/
this._stream.setMaxListeners(30);
//创建文件流并通过管道和基础双工流连接
this.open();
}
log 函数是 file 传输器的运行核心:
log(info, callback = () => {}) {
//如果正等待硬盘处理完毕
if (this._drain) {
//通过事件进行延迟调用
this._stream.once('drain', () => {
this._drain = false;
this.log(info, callback);
});
return;
}
//如果正处在文件轮转间隙
if (this._rotate) {
//通过事件进行延迟调用
this._stream.once('rotate', () => {
this._rotate = false;
this.log(info, callback);
});
return;
}
//拼装一条完整的包含结尾符的日志
const output = `${info[MESSAGE]}${this.eol}`;
const bytes = Buffer.byteLength(output);
//每次写入后都检测是不是下一次应该轮转文件
function logged() {
this._size += bytes;
this._pendingSize -= bytes;
this.emit('logged', info);
//如果正在第一次执行初始化(轮转后的再初始化过程不会调用此处)
if (this._opening) {
return;
}
//检测是否需要轮转文件
if (!this._needsNewFile()) {
return;
}
this._rotate = true;
//关闭当前流,开始轮转文件
this._endStream(() => this._rotateFile());
}
this._pendingSize += bytes;
//检测是否需要轮转
if (this._opening &&
!this.rotatedWhileOpening &&
this._needsNewFile(this._size + this._pendingSize)) {
this.rotatedWhileOpening = true;
}
//硬盘能否继续写入
const written = this._stream.write(output, logged.bind(this));
//硬盘暂时写入不了,就打开等待硬盘IO处理完毕的开关,直到可以继续消费再关闭开关.
if (!written) {
this._drain = true;
this._stream.once('drain', () => {
this._drain = false;
callback();
});
} else {
callback();
}
return written;
}
file 传输器的运行逻辑如图所示:
file 传输器中大多数方法都是为轮转文件服务,同时还有实现查询等功能,因为不是核心功能,在此就不赘述了.
总结
综上,winston 的核心逻辑应该就清楚了.
其主干逻辑很清晰,就是将通过流将整个流程连接起来.
复杂的地方在于可用性保障与灵活性保障:
可用性保障
- 用 symbol 命名属性,保证不会被修改
- 用 Object.defineProperty 定义只读方法,同样保证不被修改.
- 传输器增加有限缓冲,不会爆内存,也尽可能少丢失数据.
模块的健壮是灵活性的保证,不健壮的代码也一旦灵活起来处处都是漏洞.
灵活性保障
- 可以自定义配置,也可以采用默认配置,甚至可以开箱即用.
- 可修改模块配置,也可从已有模块派生兼容的模块
作为通用型的模块,灵活是可以让更多的人来使用的前提.
网友评论