对于Cordis附加在Context的四大核心服务,我们已经介绍其中三个(EventsService、ReflectService、RegistryService),我们今天来补充最后一个,用来输出日志的LoggerService。日志的重要性无需多做介绍,如果你需要将DeepSeek Harness来构造正在在产线应用的Agent,相信你会编写很多日志相关的代码,所以我们有必要了解基于LoggerService的日志编程模式和背后的实现原理。

1. LoggerService接口

为了先搞清楚如何使用LoggerService,我们先来看看LoggerService接口的定义。如下面的代码所示,LoggerService接口是对Record<LoggerType, LoggerMethod>的扩展。而LoggerType是对表示四种日志类型的字符串字面量类型的联合,LoggerMethod则是一个用来输出的日志的函数,format和param参数分别表示包含占位符的模板和填充的对象。

export interface LoggerService extends Record<LoggerType, LoggerMethod> {
  (name?: string): Logger
}

export type LoggerType = 'error' | 'info' | 'warn' | 'debug'
export type LoggerMethod = (format: any, ...param: any[]) => void
export interface Logger extends Record<LoggerType, LoggerMethod> {}

2. 日志的输出渠道

上面涉及的几个接口已经体现了实施输出日志的基本用法,但是我们都知道,基本的日志框架都可以设置相应的输出渠道,LoggerService自然也不例外。日志的输出由Exeporter接口表示的对象来完成,这里涉及一个重要的表示原始的日志消息的Message接口,相关定义如下:

export interface Message {
  sn: number
  ts: number
  name: string
  type: LoggerType
  level: number
  args: any[]
  fiber?: WeakRef<Fiber>
}

export interface Exporter {
  colors?: number | false
  maxLength?: number
  levels?: Record<string, number>
  formatters?: Record<string, Formatter>
  export(message: Message): void
}


Message接口各成员说明如下:

  • sn:日志序号。这是一个全局自增,用来唯一标识每条日志,方便追踪日志顺序,尤其在并发 Fiber 中区分先后;
  • ts:时间戳。通常是毫秒级 UNIX 时间,标记日志产生的精确时间,用于排序和性能分析;
  • name:日志来源名字。一般取自 Fiber 或 Context 的名称,用来区分不同插件或模块的日志来源;
  • type:日志类型。类型为 LoggerType 枚举,常见值有 error、warn、info、debug,表示日志的语义类别;
  • level:日志级别。数值化的等级,通常用于过滤日志输出,例如 error=0,warn=1,info=2,debug=3;
  • args:日志参数数组。实际的日志内容,可以是字符串、对象或其他数据,支持结构化日志;
  • fiber:弱引用(WeakRef<Fiber>),指向产生日志的 Fiber,不会阻止 Fiber 被 GC 回收,用于提供上下文信息,便于调试和定位日志来源。

Exporter接口各成员说明如下:

  • colors:颜色模式(可选)。用来控制日志在控制台或终端中的彩色输出效果。选项包括:
    • false 或 0 → 不输出颜色;
    • 1 → 使用 ANSI 16 色调色板;
    • 2 → 使用 ANSI 256 色调色板,支持更丰富的颜色和装饰。
  • maxLength:最大长度(可选)。限制单条日志的最大字符数,避免日志过长导致控制台或存储输出混乱;
  • levels:日志等级映射(可选)。类型为 Record<string, number>,用于定义不同日志类型对应的数值等级,方便自定义过滤规则,例如将 info 映射为 2,debug 映射为 3;
  • formatters:日志格式化器集合(可选)。类型为 Record<string, Formatter>,为不同日志类型或来源定义专用的格式化函数,实现灵活的日志输出样式,比如 JSON 格式或带时间戳的字符串;
  • export(message: Message): void:日志导出方法(必选)。核心函数,接收一条 Message 日志并执行导出逻辑,决定日志最终的输出位置和形式,例如打印到控制台、写入文件或发送到远程服务。

我们提供的Exporter对象可以直接调用LoggerService的exporter方法进行注册,它会将指定的Exporter对象添加到exporters字段中,另一个自增的_snExporter字段则用来生成Message的sn字段表示的序列号。由于整个注入实在调用Conext的effect方法中进行的,所以当前Fiber被释放的时候,注册会解除。

export class LoggerService {
  _snExporter = 0
  exporters = new Map<number, Exporter>()
  exporter(exporter: Exporter) {
    return this.ctx.effect(() => {
      this.exporters.set(++this._snExporter, exporter)
      return () => this.exporters.delete(this._snExporter)
    }, 'ctx.logger.exporter()')
  }
}

3. 日志的格式化

由于Message是对原始日志消息的描述,但是输出的内容都需要经过格式化,Exporter通过formatters字段提供的字典为指定的对象定义了不同的格式化器。格式化器通过如下所示的Formatter类型表示,本质上是一个包含是三个参数,输出任意类型的函数。第一个参数value来自Message.args 数组,按顺序取出。日志调用时传入的实际值,由 Formatter 决定如何把这个值转成最终的输出字符串或其他形式。defultFormatters针对不同的数据类型提供了对应的格式化实现:

export type Formatter = (value: any, exporter: Exporter, message: Message) => any

export const defaultFormatters: Record<string, Formatter> = {
  s: (value) => String(value),
  d: (value) => Math.trunc(Number(value)),
  i: (value) => Math.trunc(Number(value)),
  f: (value) => Number(value),
  o: (value) => JSON.stringify(value),
  O: (value) => JSON.stringify(value),
  c: () => '',
  C: (value, exporter, message) => {
    return Logger.color(exporter, Logger.code(message.name, exporter.colors), value)
  },
}

defultFormatters对象的成员对应数据类型,以及具体格式化行为说明如下:

  • s:字符串格式化。调用 String(value),将任意值转为字符串输出;
  • d:整数格式化。调用 Math.trunc(Number(value)),先转为数字再截断为整数(去掉小数部分);
  • i:整数格式化。与 d 相同,也是 Math.trunc(Number(value)),通常 %d 和 %i 在日志里等价;
  • f:浮点数格式化。调用 Number(value),直接转为数值(保留小数部分);
  • o:对象格式化。调用 JSON.stringify(value),将对象序列化为 JSON 字符串;
  • O:对象格式化。与 o 相同,也是 JSON.stringify(value),区别在于日志系统可能用大小写区分不同语义,但这里实现一致;
  • c:空字符串。始终返回 '',常用于占位符但不输出内容;
  • C:彩色输出。调用 Logger.color(exporter, Logger.code(message.name, exporter.colors), value),
    • 根据 exporter.colors 配置选择颜色模式;
    • 使用 message.name 生成颜色代码;
    • 将 value 渲染为带颜色的字符串;
    • 作用:让不同模块的日志在控制台中显示不同颜色,便于区分。

Logger类型的静态方法format提供了默认的格式化实现,我们可以直接拿来使用,从实现可以可以看出,在针对具体占位符参数进行格式化的时候,它会首先使用Exporter的formatters字段提供的格式化器,并使用defultFormatters作为兜底。defultFormatters用于彩色输出的color和code方法也以静态方法的形式定义在这里。

export class Logger {
  static color(exporter: Exporter, code: number, value: any, decoration = '') ;
  static code(name: string, level?: false | number);
  static format(exporter: Exporter, message: Message): string' 
}

4. 将LoggerServce作为字典来使用

按照LoggerService的定义,由于它派生于Record<LoggerType, LoggerMethod>,多以可以将它作为一个字典,利用指定的LoggerType从中提取对应的LoggerMethod函数来输出日志。如下就是一个典型的例子。我们通过调用LoggerService的exporter方法注册了面向控制台的Exporter对象,具体调用console的哪个方法取决于日志的等级。我们参看原始的日志消息,我们在输出格式化日志之前,会将整个Message对象输出来。

import { Context, Exporter, Message,Logger} from '@deepseek-ai/cordis'
const formatters: {(msg:any, exporter: Exporter) : void}[] = [
            (m,e)=>console.error(Logger.format(e, m)),
            (m,e)=>console.info(Logger.format(e,m)),
            (m,e)=>console.warn(Logger.format(e,m)),
            (m,e)=>console.debug(Logger.format(e,m)),
        ] 
const context = new Context();
context.logger.exporter({
    export(message:Message){      
        console.log(message);
        formatters[message.level]!(message, this);
        console.log();
    }
})

context.plugin({
    name: "foobar",
    apply(ctx:Context){
        ctx.logger["error"]("this is a %s message!","error"); 
        ctx.logger["info"]("this is a %s message!","information"); 
        ctx.logger["warn"]("this is a %s message!","warning"); 
        ctx.logger["debug"]("this is a %s message!","debug"); 
    }
});

输出:

{
  sn: 1,
  ts: 1788648934592,
  type: 'error',
  level: 0,
  name: 'foobar',
  fiber: WeakRef {},
  args: [ 'this is a %s message!', 'error' ]
}
this is a error message!

{
  sn: 2,
  ts: 1788648934595,
  type: 'info',
  level: 1,
  name: 'foobar',
  fiber: WeakRef {},
  args: [ 'this is a %s message!', 'information' ]
}
this is a information message!

日条日志是在注册的一个名为foobar的插件中输出的,可以看出Message的name字段会默认设置为插件的名称。

5. 将LoggerServce作为函数

除了将LoggerService视为一个通过Record<LoggerType, LoggerMethod>类型表示的字典外,我们还可以直接将它作为一个函数,这源于它接口如下的成员定义。具体实现则体现在LoggerService类型通过symbols.invoke表示的方法上。这里体现了创建Logger对象时针对名称、等级和元数据的设置。

export interface LoggerService extends Record<LoggerType, LoggerMethod> {
  (name?: string): Logger
}

export class LoggerService {
  [symbols.invoke](name?: string): Logger {
    const config = this._resolveConfig()
    const fiber = ((this.ctx as any)[symbols.shadow] ?? this.ctx).fiber
    name ??= config.name
    name ??= hyphenate(fiber.name)
    return new Logger({
      name,
      level: config.level,
      meta: { fiber: new WeakRef(fiber) },
    }, this)
  }
}

当我们将LoggerService作为函数调用时,它的name参数表示日志来源,对应于Message的name字段。函数的返回值是一个Logger对象,由于它派生于Record<LoggerType, LoggerMethod>,所以我们可以调用它对应于日志等级的四个方法来输出日志,所以上述的演示程序也可以写成如下的形式,我们会得到完全一致的输出:

context.plugin(ctx=>{
        const logger = ctx.logger("foobar")
        logger.error("this is a %s message!","error"); 
        logger.info("this is a %s message!","information"); 
        logger.warn("this is a %s message!","warning"); 
        logger.debug("this is a %s message!","debug"); 
    });

输出:

{
  sn: 1,
  ts: 1788648934592,
  type: 'error',
  level: 0,
  name: 'foobar',
  fiber: WeakRef {},
  args: [ 'this is a %s message!', 'error' ]
}
this is a error message!

{
  sn: 2,
  ts: 1788648934595,
  type: 'info',
  level: 1,
  name: 'foobar',
  fiber: WeakRef {},
  args: [ 'this is a %s message!', 'information' ]
}
this is a information message!

6. 设置日志过滤条件

从上面两个演示的实例可以看出,虽然我们的代码输出的四条不同等级的日志,但是控制台上只显示了两条等级分别为error和info的日志。很明显,日志在输入之前根据等级进行了过滤,这也是所有日志框架都有基本功能。对于LoggerLevel定义的四个日志等级,等级(Severity)越高,对应的数字越小,可以看出默认的只会输出等级小于或者等于1(info)的日志。

export const enum LoggerLevel {
  ERROR = 0,
  INFO = 1,
  WARN = 2,
  DEBUG = 3,
}

6.1 利用Exporter设置日志过滤等级

作为过滤的最低日志等级可以通过Exporter的levels字段针对具体的日志来演作针对性设置。

export const enum LoggerLevel {
  ERROR = 0,
  INFO = 1,
  WARN = 2,
  DEBUG = 3,
}

export interface Exporter {
  levels?: Record<string, number>
  ...
}

在如下的演示程序中,我们为设置设置的Exporter的levels字段添加了针对bar的最低日志等级3(debug),意味着会将所有等级的日志都输出来。在注册的插件中,我们分别针对日志名称foo和bar写入四条具有不同等级的日志,可以看出名称为bar的四条日志都被输出来了。

const context = new Context();
context.logger.exporter({
    export(message:Message){  formatters[message.level]!(message, this)},
    levels: {bar: 3},
})

context.plugin(ctx=>{
    for (const name of ["foo","bar"]) {
        const logger = ctx.logger(name);
        console.log(`name = ${name}`);
        logger.error("this is a %s message!","error"); 
        logger.info("this is a %s message!","information"); 
        logger.warn("this is a %s message!","warning"); 
        logger.debug("this is a %s message!","debug"); 
        console.log()
    }        
});

输出:

name = foo
this is a error message!
this is a information message!

name = bar
this is a error message!
this is a information message!
this is a warning message!
this is a debug message!

6.2 利用Logger设置过滤等级

Exporter中设置的过滤规则仅仅针对当前的输出渠道,意味着我们可以根据不同的输出方法(控制台、文件和远程调用等)采用不同的日志过滤策略。由于Exporter的日志是由Logger对象提交给它们的,如果过滤策略定义Logger上,这意味着这将是全局的规则。不过设置在Exporter上的规律等级具有更高的优先级。

从如下的代码可以看出,Logger还派生于另一个名为LoggerOptions的接口,后者定义的name和level分别指的就是日志的名称(对应Message的name字段)和最低日志等级。至于meta字段返回的Partial<Message>对象,从类型定义可知它表示Message 接口的一部分字段。在该 Logger 输出的 每一条日志记录 中,都会自动合并这些字段。相当于给日志器设置一个默认上下文,让所有日志都带上统一的附加信息。

export interface Logger extends LoggerOptions {}
export interface LoggerOptions {
  name: string
  meta?: Partial<Message>
  level?: number
}
type Partial<T> = {
    [P in keyof T]?: T[P];
};

从Logger的构造函数可以看出,它接受LoggerOptions对象作为其参数,并且只调用调用Object.assign方法将定义在LoggerOptions中的成员赋值给自己。

export class Logger {
  constructor(options: LoggerOptions, private service: LoggerService) {
    Object.assign(this, options)
    this.error = this._method('error', LoggerLevel.ERROR)
    this.info = this._method('info', LoggerLevel.INFO)
    this.warn = this._method('warn', LoggerLevel.WARN)
    this.debug = this._method('debug', LoggerLevel.DEBUG)
  }
}

在如下的代码片段所示我们在设置的Exporter中为日志名称foo设置了最低等级3,意味着不做任何过滤。在注册的插件中,针对日志名称foo和bar创建的Logger同时将level设置成2,意味着只屏蔽debug等级的日志。由于前者具有更高的优先级,所以日志名称为foo的四条日志均被输出,但是名称为bar的debug日志被屏蔽。

const context = new Context();
context.logger.exporter({
    export(message:Message){  formatters[message.level]!(message, this)},
    levels: {foo: 3},
})

context.plugin(ctx=>{
    for (const name of ["foo","bar"]) {
        const logger = new Logger({name:name, level:2}, context.logger)
        console.log(`name = ${name}`);
        logger.error("this is a %s message!","error"); 
        logger.info("this is a %s message!","information"); 
        logger.warn("this is a %s message!","warning"); 
        logger.debug("this is a %s message!","debug"); 
        console.log()
    }        
});

输出:

name = foo
this is a error message!
this is a information message!
this is a warning message!
this is a debug message!

name = bar
this is a error message!
this is a information message!
this is a warning message!

7. 利用LoggerOptions控制输出的Message

在默认情况下生成的Message的每个字段都有其固定规则,比如作为序列号的sn来源于Logger自增长字段,作为时间戳的ts来源于系统时间,表示日志类型的type取决于调用的方法,它们还会决定日志的等级。如果创建的Logger来利用meta字段设置了相应的字段,意味着将这些字段完全固定下来。

以如下的演示程序为例,我们重写设置的Exporter这次不再仅仅输出默认的格式化字符串,而是会一并输出Message名称、类型和日志等级。在注册的插件中,用来输出日志的Logger在创建的时候设置了日志等级和类型,从输出可以看出,四条生成的Message具有完全一致的类型和等级。

import { Context, Exporter, Message,Logger} from '@deepseek-ai/cordis'

const levels = ["error","info","warn","debug"];
const context = new Context();
context.logger.exporter({
    export(message:Message){
        var formatted = Logger.format(this, message);
        console.log(`
name: ${message.name}
type: ${message.type}
level: ${levels[message.level]}
message: ${formatted}
`);
    },
    levels: {foo: 3},
})

context.plugin(ctx=>{
    const logger = new Logger({name:"foobar", level:2, meta:{"level": 0, "type":"error"}}, context.logger)
        logger.error("this is a %s message!","error"); 
        logger.info("this is a %s message!","information"); 
        logger.warn("this is a %s message!","warning"); 
        logger.debug("this is a %s message!","debug");     
});
name: foobar
type: error
level: error
message: this is a error message!

name: foobar
type: error
level: error
message: this is a information message!

name: foobar
type: error
level: error
message: this is a warning message!

Logger针对日志的输出实现在它的私有函数_method中,上面介绍的基于日志等级的过滤和最终Message的创建都实现在这里。

export class Logger {

  private _method(type: LoggerType, level: number): LoggerMethod {
    return (...args: any[]) => {
      if (args.length === 1 && args[0] instanceof Error) {
        if (args[0].cause) {
          this[type](args[0].cause)
        } else if (isAggregateError(args[0])) {
          args[0].errors.forEach(error => this[type](error))
          return
        }
      }

      const sn = ++this.service._snMessage
      const ts = Date.now()
      for (const exporter of this.service.exporters.values()) {
        const targetLevel = exporter.levels?.[this.name] ?? exporter.levels?.default ?? this.level ?? LoggerLevel.INFO
        if (targetLevel < level) continue
        const message: Message = { sn, ts, type, level, name: this.name, ...this.meta, args }
        exporter.export(message)
      }
    }
  }
}

8. 动态修改日志名称和过滤等级

作为一个基础服务,LoggerService具有自己的配置,具体通过如下这个LoggerService.Intercept接口表示。name和level字典分别用来设置作为日志来源的名称和作为过滤条件的最低日志等级。

export namespace LoggerService {
  export interface Intercept {
    name?: string
    level?: number
  }
}

既然LoggerService关联了配置,我们自然就可以调用Context的intercept方法动态修改其配置。在如下的演示程序中,我们在根Context上注册了一个LogService服务,为了能够使用函数的形式使用它,我们定义以symbols.invoke作为标识的方法。在这个方法中,我们调用以函数形式调用当前Context的logger服务,但作为name参数的不是一个具体的名称,而是null。我们利用得到的Logger对象输出了四条具有不同等级的日志。

import { Context, Service, Message,Logger, symbols} from '@deepseek-ai/cordis'
const levels = ["error","info","warn","debug"];
const context = new Context();
context.logger.exporter({
    export(message:Message){
        var formatted = Logger.format(this, message);
        console.log(`
name: ${message.name}
type: ${message.type}
level: ${levels[message.level]}
message: ${formatted}
`);
    },
})

class LogService extends Service{
    constructor(ctx: Context){
        super(ctx,"log");
    }
    [symbols.invoke](){
       const logger = this.ctx.logger(null!)
        logger.error("this is a %s message!","error"); 
        logger.info("this is a %s message!","information"); 
        logger.warn("this is a %s message!","warning"); 
        logger.debug("this is a %s message!","debug"); 
    }
}

declare module '@deepseek-ai/cordis'{
    interface Context{
        log:LogService & (()=> void);
    }
}

new LogService(context);

let subContext = context.intercept("logger",{name: "PluginA", level: 3});
subContext.inject(["log"],ctx=>ctx.log());

subContext = context.intercept("logger",{name: "PluginB", level: 1});
subContext.inject(["log"],ctx=>ctx.log());

输出:

name: PluginA
type: error
level: error
message: this is a error message!


name: PluginA
type: info
level: info
message: this is a information message!


name: PluginA
type: warn
level: warn
message: this is a warning message!


name: PluginA
type: debug
level: debug
message: this is a debug message!


name: PluginB
type: error
level: error
message: this is a error message!


name: PluginB
type: info
level: info
message: this is a information message!

我们调用根Context的intercept设置了不同的配置:name设置为对应的插件名称,level分别设置为3(不进行过滤)和1(只输出info和error等级的日志),并在生成的子Context中调用inject方法注册了两个插件并将LogService服务注入其中。两个插件函数的操作相同,都是以函数的形式直接调用LogService。从输出的结果可以看出,前面四条来源于PluginA,后者两条来源于PluginB,与我们动态设置的配置是契合的。

LoggerService针对配置的解析实现在它的_resolveConfig方法中,symbols.invoke方法会调用此方法得到的LoggerService.Intercept对象来创建Logger。

export namespace LoggerService {
  private _resolveConfig(): LoggerService.Intercept {
    let intercept = this.ctx[symbols.intercept]
    const configs: LoggerService.Intercept[] = []
    while ('logger' in intercept) {
      if (Object.hasOwn(intercept, 'logger')) {
        configs.unshift(intercept['logger'])
      }
      intercept = Object.getPrototypeOf(intercept)
    }
    return Object.assign({}, ...configs)
  }
    [symbols.invoke](name?: string): Logger {
    const config = this._resolveConfig()
    const fiber = ((this.ctx as any)[symbols.shadow] ?? this.ctx).fiber
    name ??= config.name
    name ??= hyphenate(fiber.name)
    return new Logger({
      name,
      level: config.level,
      meta: { fiber: new WeakRef(fiber) },
    }, this)
  }
}
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐