日志记录
用
ILoggerFactory创建分类日志器,把 Chat 调用过程的记录与输出目标、过滤规则解耦。
适用场景
- Chat 客户端里还在散落
println,模型请求、Token 统计、异常信息混在一起。 - 开发环境想看
chat.transport的 Debug 日志,生产环境只想保留 Warn 以上。 - 输出目标需要从控制台切到日志采集系统,但业务代码不应该跟着修改。
- 应用已经接入 DI,希望服务只声明需要日志工厂,不自己创建全局对象。
class ChatClient {
public func complete(prompt: String): String {
println("准备请求模型")
println("prompt = ${prompt}")
return "ok"
}
}这类写法的问题不在于 println 不能输出,而是它把“记录了什么”和“写到哪里、按什么级别过滤”绑死在一起。日志记录要解决的就是这层耦合。
架构概览
日志记录把一次 Chat 调用拆成三件事:业务代码按级别记录事件,分类名说明日志来自哪个模块,provider 和过滤规则决定是否输出、输出到哪里。
业务代码
│ logger.info() / logger.debug()
▼
ILogger ──────────── 按分类名写入事件
│
▼
ILoggerFactory ───── 管理 provider、创建分类日志器
│
▼
ILoggerProvider ──── 创建输出器,对接具体目标
│
▼
LoggerFilterOptions ─ 按级别 + 分类名过滤,决定是否放行
│
▼
输出目标 ─────────── Console / 文件 / 自定义这张图只表达一条主线:业务代码写入 ILogger,日志工厂把记录分发给 provider,过滤规则在输出前决定是否放行。调级别、换格式、换输出目标,都留在启动配置或 provider 扩展里处理,ChatClient 无需感知。
相关依赖
日志系统由多个包分层组成,按需引入即可。
| 包名 | 作用 | 什么时候需要 |
|---|---|---|
soulsoft_extensions_logging | 提供 ILogger、ILoggerFactory、ILoggerProvider、LogLevel、EventId、LoggingBuilder 等核心抽象 | 始终需要 |
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 | 提供 ConfigurationManager、addJsonFile()、addJsonString() 等配置读取能力 | 配合 addConfiguration() 构建配置源时 |
快速开始
先引入 soulsoft_extensions_logging 和 soulsoft_extensions_logging_console,按“创建工厂 → 注册 provider → 创建分类日志器 → 记录日志”这条最小路径走一遍。
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) | 字符串变量 |
大多数业务类直接用泛型入口就够了:
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) 的含义是:只放行 Info、Warn、Error、Fatal。级别越低,信息越细;生产环境不要默认打开过细级别,否则会增加 I/O 和排障噪声。
provider
provider 决定日志的输出目标。当前内置的常用 provider 是控制台 provider,通过 addConsole() 注册。
let factory = LoggerFactory.create { builder =>
builder.addConsole()
builder.setMinimumLevel(LogLevel.Info)
}同一个 ILogger 可以分发到多个 provider。当前页只展开控制台和自定义 provider,不把文件、ES、Kafka 写成内置能力。
常见用法
记录 Chat 调用过程
业务类里推荐注入 ILoggerFactory,日志器由工厂创建,业务类本身就是分类来源。
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 共享同一基类。
let factory = LoggerFactory.create { builder =>
builder.addConsole()
builder.setMinimumLevel(LogLevel.Info)
}let factory = LoggerFactory.create { builder =>
builder.addSimpleConsole { options =>
options.timestampFormat = "yyyy-MM-dd HH:mm:ss"
options.singleLine = true
}
builder.setMinimumLevel(LogLevel.Info)
}let factory = LoggerFactory.create { builder =>
builder.addJsonConsole { options =>
options.timestampFormat = "yyyy-MM-dd HH:mm:ss"
options.useUtcTimestamp = true
}
builder.setMinimumLevel(LogLevel.Info)
}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(控制 depth、indent 等),需要定制 JSON 输出结构时可通过该选项调整。
常用控制台选项如下:
| 选项 | 所属回调 | 默认值 | 说明 |
|---|---|---|---|
timestampFormat | 所有格式器回调 | None | 不设置时不显示时间戳 |
useUtcTimestamp | 所有格式器回调 | false | 是否使用 UTC 时间 |
singleLine | addSimpleConsole / addSystemdConsole | false | 是否压成单行输出 |
colorBehavior | addSimpleConsole / addSystemdConsole | Default | 颜色策略 |
formatterName | addConsole | None | 不指定时走 simple 路径 |
logToStandardErrorThreshold | addConsole / addSimpleConsole / addJsonConsole / addSystemdConsole | Off | 达到该级别后写 stderr |
queueFullMode | addConsole / addSimpleConsole / addJsonConsole / addSystemdConsole | Wait | 队列满时 Wait 等待或 DropWrite 丢弃 |
maxQueueLength | addConsole / addSimpleConsole / addJsonConsole / addSystemdConsole | 2500 | 队列最大长度 |
需要调整队列和 stderr 行为时,在 options 回调中设置即可:
builder.addConsole { options =>
options.queueFullMode = ConsoleLoggerQueueFullMode.DropWrite
options.maxQueueLength = 5000
options.logToStandardErrorThreshold = LogLevel.Error
options.colorBehavior = LoggerColorBehavior.Enabled
}按分类过滤
全局只看 Info,但临时打开传输层 Debug,是 Chat 应用里最常见的排障方式。
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。
分类名支持一个 * 通配符:
builder.addFilter("chat.*", LogLevel.Warn)
builder.addFilter("*.transport", LogLevel.Debug)规则匹配时会忽略大小写;同一条分类规则里最多只能出现一个 *。通配符按前缀—后缀模式匹配:"chat.*" 匹配所有以 chat. 开头的分类,"*.transport" 匹配所有以 .transport 结尾的分类。
自定义过滤函数
如果级别规则不够,可以直接写过滤函数。下面这段只让 chat.token 输出 Warn 及以上,其他分类继续放行。
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 实例。
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。
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"))
}配置里的级别字符串支持 Trace、Debug、Info、Warn、Error、Fatal、Off,解析时大小写不敏感。枚举型格式选项仍建议按成员名书写。
自定义文件日志提供器
自定义输出目标时,实现 ILogger + ILoggerProvider,扩展 LoggingBuilder:
实现 ILogger——负责把单条日志格式化成文本并写入文件:
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 按分类创建日志器,扩展方法封装注册细节:
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起步,只对正在排障的分类临时打开Debug或Trace。 - 采集链路优先使用 JSON 控制台格式,便于日志平台按字段解析。
- 自定义 provider 的
log()不要做长时间阻塞操作;需要写文件或网络上报时,应考虑队列和后台刷出。 - 日志配置建议接入配置系统,让环境变量或配置文件控制级别,不要靠改代码开关 Debug。
常见问题
LoggerFactory.create 和 services.addLogging 怎么选?
小工具或独立示例用 LoggerFactory.create()。应用已经有 DI 时,用 services.addLogging(),让 ILoggerFactory 由容器统一管理。
为什么 debug() 没有输出?
先检查最低级别。通过 addLogging() 或 LoggerFactory.create() 接入时,默认最低级别是 Info;Debug 低于 Info,不会输出。需要临时打开时,用 setMinimumLevel(LogLevel.Debug) 或对指定分类加 addFilter()。
addConsole() 和 addSimpleConsole() 有什么区别?
addConsole() 注册控制台 provider 和默认格式器;addSimpleConsole() 明确指定 simple 格式。只需要默认可读文本时,二者效果接近;如果要明确格式选择,推荐显式写 addSimpleConsole()、addJsonConsole() 或 addSystemdConsole()。
日志写 stdout 还是 stderr?
默认写 stdout。设置 ConsoleLoggerOptions.logToStandardErrorThreshold 后,达到该级别及以上的日志会写 stderr,例如设置为 Error 时,Error 和 Fatal 写 stderr。