How it works(1) winston3源码阅读(A)

winston 是我在 nodejs 下最常用的日志框架,那么他到底是如何工作的呢?

winston 的运行核心

winston 中有两个关键词: 记录器(logger)和传输器(transport).

记录器负责收集/修饰分配进入的每一条日志,传输器则负责最终把日志记录到的哪,

整个 winston 其实就是多个流(stream)的链式调用.记录器继承了交换流(transform),而传输器则继承自可写流(writeable).

为什么使用流式架构呢?

面对大量的日志,流式架构有类似于消息队列的消费模式,可以自行调节流动速度.日志来的太快,传输器消费不动,就在缓冲区先存一下,等待压力小了再消费.不会在不必要的地方浪费内存,保证了稳定性和速度.

况且,最终的传输器如日志文件,接口调用,控制台输出,这些本质上都是流的实现,winston 也使用流的话编写起来也更方便.

winston 的组织结构

结构图

How it works(1) winston3源码阅读(A)_第1张图片

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 传输器的运行逻辑如图所示:
How it works(1) winston3源码阅读(A)_第2张图片

file 传输器中大多数方法都是为轮转文件服务,同时还有实现查询等功能,因为不是核心功能,在此就不赘述了.

总结

综上,winston 的核心逻辑应该就清楚了.
其主干逻辑很清晰,就是将通过流将整个流程连接起来.

复杂的地方在于可用性保障与灵活性保障:

可用性保障

  • 用 symbol 命名属性,保证不会被修改
  • 用 Object.defineProperty 定义只读方法,同样保证不被修改.
  •  传输器增加有限缓冲,不会爆内存,也尽可能少丢失数据.

模块的健壮是灵活性的保证,不健壮的代码也一旦灵活起来处处都是漏洞.

灵活性保障

  • 可以自定义配置,也可以采用默认配置,甚至可以开箱即用.
  • 可修改模块配置,也可从已有模块派生兼容的模块

作为通用型的模块,灵活是可以让更多的人来使用的前提.

你可能感兴趣的:(源码阅读)