Skip to content

日志记录

ILoggerFactory 创建分类日志器,把 Chat 调用过程的记录与输出目标、过滤规则解耦。

适用场景

  • Chat 客户端里还在散落 println,模型请求、Token 统计、异常信息混在一起。
  • 开发环境想看 chat.transport 的 Debug 日志,生产环境只想保留 Warn 以上。
  • 输出目标需要从控制台切到日志采集系统,但业务代码不应该跟着修改。
  • 应用已经接入 DI,希望服务只声明需要日志工厂,不自己创建全局对象。
cangjie
class ChatClient {
    public func complete(prompt: String): String {
        println("准备请求模型")
        println("prompt = ${prompt}")
        return "ok"
    }
}

这类写法的问题不在于 println 不能输出,而是它把“记录了什么”和“写到哪里、按什么级别过滤”绑死在一起。日志记录要解决的就是这层耦合。

架构概览

日志记录把一次 Chat 调用拆成三件事:业务代码按级别记录事件,分类名说明日志来自哪个模块,provider 和过滤规则决定是否输出、输出到哪里。

text
业务代码
  │  logger.info() / logger.debug()

ILogger  ──────────── 按分类名写入事件


ILoggerFactory ───── 管理 provider、创建分类日志器


ILoggerProvider ──── 创建输出器,对接具体目标


LoggerFilterOptions ─ 按级别 + 分类名过滤,决定是否放行


输出目标 ─────────── Console / 文件 / 自定义

这张图只表达一条主线:业务代码写入 ILogger,日志工厂把记录分发给 provider,过滤规则在输出前决定是否放行。调级别、换格式、换输出目标,都留在启动配置或 provider 扩展里处理,ChatClient 无需感知。

相关依赖

日志系统由多个包分层组成,按需引入即可。

包名作用什么时候需要
soulsoft_extensions_logging提供 ILoggerILoggerFactoryILoggerProviderLogLevelEventIdLoggingBuilder 等核心抽象始终需要
soulsoft_extensions_logging_console提供 addConsole()addSimpleConsole()addJsonConsole()addSystemdConsole() 等控制台输出能力需要控制台输出时
soulsoft_extensions_logging_configuration提供 addConfiguration(),从 IConfiguration 读取日志级别与 provider 配置日志配置放在 JSON 文件或环境变量中时
soulsoft_extensions_injection提供 addLogging(),将 ILoggerFactory 接入 DI 容器使用 ServiceCollection / DI 时
soulsoft_extensions_configuration提供 ConfigurationManageraddJsonFile()addJsonString() 等配置读取能力配合 addConfiguration() 构建配置源时

快速开始

先引入 soulsoft_extensions_loggingsoulsoft_extensions_logging_console,按“创建工厂 → 注册 provider → 创建分类日志器 → 记录日志”这条最小路径走一遍。

cangjie
import soulsoft_extensions_logging.*
import soulsoft_extensions_logging_console.*

class ChatClient {}

main(): Int64 {
    let factory = LoggerFactory.create { builder =>
        builder.addConsole()
        builder.setMinimumLevel(LogLevel.Info)
    }

    let logger = factory.createLogger<ChatClient>()
    logger.info("Chat client started")
    logger.debug("This debug log is filtered")
    logger.error("Chat request failed")

    factory.close()
    return 0
}

addConsole() 注册控制台输出,setMinimumLevel(LogLevel.Info)Info 及以上级别放行。

整个流程只有三个角色:

角色职责常用入口
ILoggerFactory创建分类日志器,持有 provider 和过滤规则LoggerFactory.create() / DI 中解析
ILogger在业务代码里记录日志info() / debug() / error()
ILoggerProvider决定日志写到哪里addConsole() / 自定义 provider

信息

本页示例已通过 cjpm build 验证。

核心概念

分类名

创建日志器时有两个入口,选哪个取决于场景:

场景用法说明
普通业务类factory.createLogger<T>()分类名自动跟随类型全限定名
需要跨类归到同一层级factory.createLogger("chat.transport")手写分层名,便于过滤规则统一命中
分类名要在运行时决定factory.createLogger(name)字符串变量

大多数业务类直接用泛型入口就够了:

cangjie
class ChatClient {
    private let logger: ILogger

    public init(factory: ILoggerFactory) {
        logger = factory.createLogger<ChatClient>()
    }
}

泛型入口会使用类型全限定名作为分类名。只有需要把多个类型归到同一层级名下,或者要匹配类似 chat.transport 这种手工规划的过滤规则时,再退回字符串分类名。

分类名不是展示文案,而是过滤规则的匹配依据。稳定、分层的分类名能让你只打开某个模块的详细日志,而不影响其他模块。

日志级别

日志级别从低到高依次是:

级别Chat 场景Info 门槛下是否输出
Trace非常细的内部步骤,如逐段 token 拼接不输出
Debug调试信息,如模型路由、重试次数不输出
Info正常业务事件,如请求完成输出
Warn可恢复问题,如降级到备用模型输出
Error当前请求失败,如提供方超时输出
Fatal应用无法继续运行输出
Off关闭日志不输出

setMinimumLevel(LogLevel.Info) 的含义是:只放行 InfoWarnErrorFatal。级别越低,信息越细;生产环境不要默认打开过细级别,否则会增加 I/O 和排障噪声。

provider

provider 决定日志的输出目标。当前内置的常用 provider 是控制台 provider,通过 addConsole() 注册。

cangjie
let factory = LoggerFactory.create { builder =>
    builder.addConsole()
    builder.setMinimumLevel(LogLevel.Info)
}

同一个 ILogger 可以分发到多个 provider。当前页只展开控制台和自定义 provider,不把文件、ES、Kafka 写成内置能力。

常见用法

记录 Chat 调用过程

业务类里推荐注入 ILoggerFactory,日志器由工厂创建,业务类本身就是分类来源。

cangjie
import soulsoft_extensions_logging.*

class ChatClient {
    private let logger: ILogger

    public init(factory: ILoggerFactory) {
        logger = factory.createLogger<ChatClient>()
    }

    public func complete(prompt: String): String {
        logger.info("开始处理 Chat 请求")

        try {
            logger.debug("准备调用模型")
            return "ok"
        } catch (ex: Exception) {
            logger.error(ex, "Chat 请求失败")
            throw ex
        }
    }
}

后续改成 JSON 控制台或自定义 provider,ChatClient 不需要修改。

控制台格式

控制台 provider 支持 simple、json、systemd 等格式。addConsole() 是最通用的注册入口,不额外指定时走默认控制台格式;addSimpleConsole()addJsonConsole()addSystemdConsole() 则是在注册 provider 的同时显式指定格式。最常见的是 simple;如果日志要被采集系统按行解析,优先用 json;跑在 systemd / journald 环境时,可以直接使用 systemd 格式。systemd 选项与 simple 共享同一基类。

cangjie
let factory = LoggerFactory.create { builder =>
    builder.addConsole()
    builder.setMinimumLevel(LogLevel.Info)
}
cangjie
let factory = LoggerFactory.create { builder =>
    builder.addSimpleConsole { options =>
        options.timestampFormat = "yyyy-MM-dd HH:mm:ss"
        options.singleLine = true
    }
    builder.setMinimumLevel(LogLevel.Info)
}
cangjie
let factory = LoggerFactory.create { builder =>
    builder.addJsonConsole { options =>
        options.timestampFormat = "yyyy-MM-dd HH:mm:ss"
        options.useUtcTimestamp = true
    }
    builder.setMinimumLevel(LogLevel.Info)
}
cangjie
let factory = LoggerFactory.create { builder =>
    builder.addSystemdConsole { options =>
        options.timestampFormat = "yyyy-MM-dd HH:mm:ss"
        options.useUtcTimestamp = true
    }
    builder.setMinimumLevel(LogLevel.Info)
}

四段代码目标相同,都是注册控制台输出;差异在于是否显式指定格式。addConsole() 适合先把控制台日志接起来,addSimpleConsole() 适合需要明确 simple 选项时使用,addJsonConsole() 适合采集链路,addSystemdConsole() 适合 systemd / journald 环境。JSON 格式还支持 jsonWriterOptions(控制 depthindent 等),需要定制 JSON 输出结构时可通过该选项调整。

常用控制台选项如下:

选项所属回调默认值说明
timestampFormat所有格式器回调None不设置时不显示时间戳
useUtcTimestamp所有格式器回调false是否使用 UTC 时间
singleLineaddSimpleConsole / addSystemdConsolefalse是否压成单行输出
colorBehavioraddSimpleConsole / addSystemdConsoleDefault颜色策略
formatterNameaddConsoleNone不指定时走 simple 路径
logToStandardErrorThresholdaddConsole / addSimpleConsole / addJsonConsole / addSystemdConsoleOff达到该级别后写 stderr
queueFullModeaddConsole / addSimpleConsole / addJsonConsole / addSystemdConsoleWait队列满时 Wait 等待或 DropWrite 丢弃
maxQueueLengthaddConsole / addSimpleConsole / addJsonConsole / addSystemdConsole2500队列最大长度

需要调整队列和 stderr 行为时,在 options 回调中设置即可:

cangjie
builder.addConsole { options =>
    options.queueFullMode = ConsoleLoggerQueueFullMode.DropWrite
    options.maxQueueLength = 5000
    options.logToStandardErrorThreshold = LogLevel.Error
    options.colorBehavior = LoggerColorBehavior.Enabled
}

按分类过滤

全局只看 Info,但临时打开传输层 Debug,是 Chat 应用里最常见的排障方式。

cangjie
import soulsoft_extensions_logging.*

let factory = LoggerFactory.create { builder =>
    builder.addConsole()
    builder.setMinimumLevel(LogLevel.Info)
    builder.addFilter("chat.transport", LogLevel.Debug)
}

// 这里故意使用字符串分类名,让过滤规则能精确命中 `chat.transport`
let clientLogger = factory.createLogger("chat.client")
let transportLogger = factory.createLogger("chat.transport")

clientLogger.debug("不会输出")
transportLogger.debug("会输出")

这里故意不用泛型入口,因为过滤规则写死的是 chat.transport。重点是“更具体的规则覆盖全局门槛”:全局还是 Info,但 chat.transport 被单独放行到 Debug

分类名支持一个 * 通配符:

cangjie
builder.addFilter("chat.*", LogLevel.Warn)
builder.addFilter("*.transport", LogLevel.Debug)

规则匹配时会忽略大小写;同一条分类规则里最多只能出现一个 *。通配符按前缀—后缀模式匹配:"chat.*" 匹配所有以 chat. 开头的分类,"*.transport" 匹配所有以 .transport 结尾的分类。

自定义过滤函数

如果级别规则不够,可以直接写过滤函数。下面这段只让 chat.token 输出 Warn 及以上,其他分类继续放行。

cangjie
import soulsoft_extensions_logging.*

builder.addFilter { _, category, level =>
    if (let Some(name) <- category) {
        if (name == "chat.token") {
            return level >= LogLevel.Warn
        }
    }
    true
}

自定义过滤函数返回 true 表示放行,返回 false 表示拦截。只处理自己关心的分类,其他分类返回 true,可以避免误伤无关日志。

与 DI 集成

services.addLogging() 把日志工厂交给容器管理。ChatClient 依赖 ILoggerFactory,不依赖某个固定 ILogger 实例。

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_logging.*
import soulsoft_extensions_logging_console.*

// ChatClient 构造函数同上(见"记录 Chat 调用过程")

main(): Int64 {
    let services = ServiceCollection()
    services.addLogging { builder =>
        builder.addConsole()
        builder.setMinimumLevel(LogLevel.Info)
    }
    services.addSingleton<ChatClient, ChatClient>()

    let provider = services.build()
    let client = provider.getOrThrow<ChatClient>()
    _ = client.complete("hello")
    return 0
}

这种写法把日志基础设施收敛在启动阶段,业务服务只声明依赖。关闭和释放也交给容器生命周期统一处理。

警告

不要在每个业务类里各自调用 LoggerFactory.create()。每个 LoggerFactory.create() 都会创建独立的过滤规则集和 provider 实例——同一个分类名可能在不同工厂里被不同的规则命中,导致同一条日志有的输出、有的不输出,看起来像是随机行为。始终让整个应用共享同一套日志工厂,排障时才有一条确定的规则链路可追溯。

与配置系统集成

addConfiguration() 可以从 IConfiguration 读取日志级别和 provider 配置。下面的配置让默认级别保持 Info,同时打开 chat.transport 的 Debug。

cangjie
import soulsoft_extensions_configuration.*
import soulsoft_extensions_logging.*
import soulsoft_extensions_logging_configuration.*
import soulsoft_extensions_logging_console.*

let config = ConfigurationManager()
    .addJsonString(###"
    {
        "logging": {
            "logLevel": {
                "default": "Info",
                "chat.transport": "Debug"
            },
            "console": {
                "formatterName": "simple",
                "formatterOptions": {
                    "timestampFormat": "yyyy-MM-dd HH:mm:ss",
                    "singleLine": true
                }
            }
        }
    }
    "###)
    .build()

let factory = LoggerFactory.create { builder =>
    builder.addConsole()
    builder.addConfiguration(config.getSection("logging"))
}

配置里的级别字符串支持 TraceDebugInfoWarnErrorFatalOff,解析时大小写不敏感。枚举型格式选项仍建议按成员名书写。

自定义文件日志提供器

自定义输出目标时,实现 ILogger + ILoggerProvider,扩展 LoggingBuilder

实现 ILogger——负责把单条日志格式化成文本并写入文件:

cangjie
import soulsoft_extensions_logging.*
import std.fs.*
import std.time.*

class FileLogger <: ILogger {
    private let categoryName: String
    private let logFile: Path

    public init(categoryName: String, logFile: Path) {
        this.categoryName = categoryName
        this.logFile = logFile
    }

    public func log<TState>(logLevel: LogLevel, eventId: EventId, state: TState,
        exception: ?Exception, formatter: (TState, ?Exception) -> String): Unit where TState <: ToString {
        let message = formatter(state, exception)
        let timestamp = DateTime.now().format('yyyy-MM-dd HH:mm:ss')
        let eventName = eventId.name ?? "-"
        let exceptionText = exception?.toString() ?? ""
        let line = if (exceptionText.isEmpty()) {
            "${timestamp}|${logLevel}|${categoryName}|${eventId.id}|${eventName}|${message}\n"
        } else {
            "${timestamp}|${logLevel}|${categoryName}|${eventId.id}|${eventName}|${message}|${exceptionText}\n"
        }
        File.appendTo(logFile, line.toArray())
    }

    public func isEnabled(logLevel: LogLevel): Bool {
        logLevel != LogLevel.Off
    }
}

实现 ILoggerProvider 并扩展 LoggingBuilder——provider 按分类创建日志器,扩展方法封装注册细节:

cangjie
import soulsoft_extensions_logging.annotations.*

@ProviderAlias("file")
class FileLoggerProvider <: ILoggerProvider {
    private let logFile: Path

    public init(directoryPath: String, fileName: String) {
        let directory = Path(directoryPath)
        if (!exists(directory)) {
            Directory.create(directory, recursive: true)
        }
        logFile = directory.join(fileName)
    }

    public func createLogger(categoryName: String): ILogger {
        FileLogger(categoryName, logFile)
    }

    public func close(): Unit {}
    public func isClosed(): Bool { false }
}

extend LoggingBuilder {
    public func addFileLogger(directoryPath: String, fileName: String): LoggingBuilder {
        addProvider(FileLoggerProvider(directoryPath, fileName))
        this
    }
}

调用方只需 builder.addFileLogger("./logs", "chat.log") 一行注册。同步 File.appendTo() 仅做示例——生产化时需补队列、批量刷盘、文件滚动和失败重试。

生产建议

  • 分类名先按模块设计,不要直接使用随手写的中文句子;过滤规则依赖分类名稳定性。
  • 默认级别建议从 Info 起步,只对正在排障的分类临时打开 DebugTrace
  • 采集链路优先使用 JSON 控制台格式,便于日志平台按字段解析。
  • 自定义 provider 的 log() 不要做长时间阻塞操作;需要写文件或网络上报时,应考虑队列和后台刷出。
  • 日志配置建议接入配置系统,让环境变量或配置文件控制级别,不要靠改代码开关 Debug。

常见问题

LoggerFactory.createservices.addLogging 怎么选?

小工具或独立示例用 LoggerFactory.create()。应用已经有 DI 时,用 services.addLogging(),让 ILoggerFactory 由容器统一管理。

为什么 debug() 没有输出?

先检查最低级别。通过 addLogging()LoggerFactory.create() 接入时,默认最低级别是 InfoDebug 低于 Info,不会输出。需要临时打开时,用 setMinimumLevel(LogLevel.Debug) 或对指定分类加 addFilter()

addConsole()addSimpleConsole() 有什么区别?

addConsole() 注册控制台 provider 和默认格式器;addSimpleConsole() 明确指定 simple 格式。只需要默认可读文本时,二者效果接近;如果要明确格式选择,推荐显式写 addSimpleConsole()addJsonConsole()addSystemdConsole()

日志写 stdout 还是 stderr?

默认写 stdout。设置 ConsoleLoggerOptions.logToStandardErrorThreshold 后,达到该级别及以上的日志会写 stderr,例如设置为 Error 时,ErrorFatal 写 stderr。

相关专题

  • 配置管理addConfiguration()IConfiguration 读取日志级别和 provider 配置
  • 依赖注入addLogging()ILoggerFactory 接入 DI 容器
  • 选项模式 — 日志级别、输出目标等配置通过选项模式注入
  • 宿主 — 启动阶段自动配置默认日志 provider