美文网首页
How it works(1) winston3源码阅读(A

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

作者: 默而识之者 | 来源:发表于2018-12-26 15:05 被阅读0次

    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 定义只读方法,同样保证不被修改.
    • 传输器增加有限缓冲,不会爆内存,也尽可能少丢失数据.

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

    灵活性保障

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

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

    相关文章

      网友评论

          本文标题:How it works(1) winston3源码阅读(A

          本文链接:https://www.haomeiwen.com/subject/jiphlqtx.html