Skip to content

日志记录

日志系统用于把运行时发生的事件,按统一格式输出到控制台、文件或其他目标中。

soulsoft_extensions_logging 中,日志能力有 4 个最重要的特性:

  • LoggingBuilder 统一配置日志系统
  • ILoggerFactory 创建按分类命名的日志器
  • 支持 TraceFatal 的分级日志
  • 支持全局级别、分类规则、提供者规则和自定义过滤函数

最小示例

如果你只是想“先把日志打出来”,可以从这个例子开始:

cangjie
import soulsoft_extensions_logging.*

main(): Int64 {
    // 创建日志工厂,注册控制台提供者并设置最低级别。
    let factory = LoggingBuilder()
        .addConsole()
        .setMinimumLevel(LogLevel.Info)
        .build()

    // 按分类名创建 logger。
    let logger = factory.createLogger("app.hosting.lifetime")

    logger.debug("这条日志不会输出")
    logger.info("应用已启动")
    logger.warn("缓存命中率偏低")
    logger.error("数据库连接失败")

    // 使用完成后关闭日志工厂。
    factory.close()
    return 0
}

这个例子里有三个关键点:

  • addConsole() 注册控制台日志提供者
  • setMinimumLevel(LogLevel.Info) 指定最低输出级别
  • createLogger("app.hosting.lifetime") 创建一个带分类名的日志器

核心类型

类型作用
LoggingBuilder配置日志系统,注册提供者和过滤规则
ILoggerFactory创建或缓存 ILogger
ILogger记录日志
ILoggerProvider定义日志输出目标,例如控制台、文件
LogLevel表示日志级别

最常见的使用流程只有 4 步:

  1. 创建 LoggingBuilder
  2. 注册提供者和过滤规则
  3. 调用 build() 得到 ILoggerFactory
  4. 通过 ILoggerFactory 创建 ILogger

日志级别

日志级别定义了日志的重要程度。

cangjie
public enum LogLevel <: Comparable<LogLevel> & ToString {
    Trace | Debug | Info | Warn | Error | Fatal | Off
}
级别使用场景
Trace非常细的跟踪信息,例如函数进入/退出
Debug开发调试信息,例如中间变量、关键分支
Info正常运行信息,例如服务启动、任务完成
Warn非致命问题,例如重试、降级、潜在风险
Error当前操作失败,但进程未必终止
Fatal严重故障,通常意味着系统无法继续工作
Off完全关闭日志输出

级别从低到高依次递增,因此:

  • setMinimumLevel(LogLevel.Info) 会放行 InfoWarnErrorFatal
  • setMinimumLevel(LogLevel.Error) 只会放行 ErrorFatal

记录日志

按分类创建日志器

分类名通常用来表示日志属于哪个模块。

cangjie
// 创建日志工厂。
let factory = LoggingBuilder()
    .addConsole()
    .build()

// 为不同分类创建不同 logger。
let appLogger = factory.createLogger("app")
let dbLogger = factory.createLogger("app.database")

appLogger.info("应用启动")
dbLogger.warn("数据库连接池接近上限")

分类名本身不会自动产生层级对象,它只是后续过滤规则匹配时使用的字符串。

在命名上,建议统一使用全小写的点分层级形式,例如:

  • app
  • app.service
  • app.database.connection

常用日志方法

ILogger 提供了一组按级别命名的方法:

cangjie
// 按级别分别输出日志。
logger.trace("trace")
logger.debug("debug")
logger.info("info")
logger.warn("warn")
logger.error("error")
logger.fatal("fatal")

如果你需要同时记录异常,也可以使用异常重载:

cangjie
try {
    throw Exception("连接超时")
} catch (ex: Exception) {
    // 记录异常对象和日志消息。
    logger.error(ex, "请求处理失败")
}

控制台提供者

最常用的内置提供者是控制台提供者:

cangjie
// 注册控制台日志提供者并构建工厂。
let factory = LoggingBuilder()
    .addConsole()
    .build()

它有两个值得知道的行为:

  • 提供者名称是 console,后面写过滤规则时会用到
  • ErrorFatal 会写入标准错误流,其他级别写入标准输出流

如果你没有注册任何提供者,即使创建了 logger,也不会真正产生输出。

日志过滤

日志过滤用于决定“哪些日志应该被放行”。

设置全局最低级别

最简单的过滤方式是设置全局最低级别:

cangjie
let factory = LoggingBuilder()
    .addConsole()
    // 全局最低级别设置为 Warn。
    .setMinimumLevel(LogLevel.Warn)
    .build()

let logger = factory.createLogger("app")
logger.info("不会输出")
logger.warn("会输出")
logger.error("会输出")

按分类和提供者添加规则

如果你希望不同模块用不同级别,可以用 addFilter(providerName, categoryName, logLevel)

cangjie
let factory = LoggingBuilder()
    .addConsole()
    .setMinimumLevel(LogLevel.Info)
    // 对 console 提供者下的 app.hosting.* 分类单独提高门槛。
    .addFilter("console", "app.hosting.*", LogLevel.Warn)
    .build()

let logger1 = factory.createLogger("app.hosting.lifetime")
logger1.info("不会输出")
logger1.error("会输出")

let logger2 = factory.createLogger("app.web.router")
logger2.info("会输出")

这里三个参数分别表示:

  • providerName:提供者名称,例如 console
  • categoryName:分类名匹配规则
  • logLevel:该规则对应的最低级别

分类规则支持单个 *

分类规则支持一个 * 通配符,用来表示前缀/后缀匹配。

cangjie
// 匹配 app 下的全部子分类。
.addFilter(None, "app.*", LogLevel.Warn)
// 匹配以 .controller 结尾的分类。
.addFilter(None, "*.controller", LogLevel.Error)
// 匹配 app 下某一层级中的 controller 分类。
.addFilter(None, "app.*.controller", LogLevel.Error)

需要注意两点:

  • 匹配时不区分大小写
  • 一个规则里最多只能出现一个 *

使用自定义过滤函数

如果内置规则不够用,可以直接传入函数:

cangjie
let factory = LoggingBuilder()
    .addConsole()
    // 用自定义函数决定哪些日志允许输出。
    .addFilter { providerName, categoryName, logLevel =>
        providerName == "console"
            && categoryName.startsWith("app.admin")
            && logLevel >= LogLevel.Error
    }
    .build()

这个函数签名是:

cangjie
public type LoggerFilter =
    (providerName: String, categoryName: String, logLevel: LogLevel) -> Bool

返回 true 表示允许输出,返回 false 表示过滤掉。

规则优先级

当多条规则同时命中时,可以按下面的顺序理解:

  • 提供者特定规则优先于全局规则
  • 分类更具体的规则优先于更宽泛的规则
  • 如果没有命中显式规则,则回退到 setMinimumLevel() 设置的全局最低级别

例如:

cangjie
let factory = LoggingBuilder()
    .addConsole()
    .setMinimumLevel(LogLevel.Trace)
    // 给 app.* 设置 Info 级别门槛。
    .addFilter(None, "app.*", LogLevel.Info)
    // 给更具体的 controllers 分类设置更高门槛。
    .addFilter(None, "app.controllers.*", LogLevel.Error)
    .build()

那么:

  • app.services.user-service 会使用 Info
  • app.controllers.home-controller 会使用更具体的 Error

自定义输出

一个日志系统可以同时注册多个提供者,同一条日志会分发到所有提供者。 如果控制台输出不够用,可以自己实现 ILoggerProviderILogger

先看一个最简单的文件日志例子,日志按天写入 ./logs/yyyyMMdd.log

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

class FileLogger <: ILogger {
    // 记录当前 logger 对应的分类名。
    public FileLogger(let categoryName: String) {}

    public func log(logLevel: LogLevel, message: ?String, exception: ?Exception): Unit {
        // 先检查当前级别是否启用。
        if (!isEnabled(logLevel)) {
            return
        }

        // 计算当天日志文件路径并追加写入。
        let path = getFileName()
        try (sw = StringWriter(getFileStream(path))) {
            sw.write("${DateTime.now()}|${logLevel}|${categoryName}: ")
            if (let Some(ex) <- exception) {
                // 异常存在时优先写异常文本。
                sw.writeln(ex.toString())
            } else {
                // 否则写普通日志消息。
                sw.writeln(message ?? "")
            }
        }
    }

    private func getFileName() {
        // 目录不存在时先创建 logs 目录。
        if (!exists("logs")) {
            Directory.create("logs", recursive: true)
        }
        let name = DateTime.now().format("yyyyMMdd")
        return Path("./logs/${name}.log")
    }

    private func getFileStream(path: Path) {
        // 文件不存在时新建,存在时追加。
        if (!exists(path)) {
            return File(path, OpenMode.Write)
        }
        return File(path, OpenMode.Append)
    }
}

然后补一个提供者:

cangjie
class FileLoggerProvider <: ILoggerProvider {
    public prop name: String {
        get() {
            // 声明提供者名称,供过滤规则匹配。
            "file"
        }
    }

    public func createLogger(categoryName: String): ILogger {
        // 为每个分类创建对应的文件 logger。
        return FileLogger(categoryName)
    }
}

再扩展 LoggingBuilder

cangjie
extend LoggingBuilder {
    public func addFile() {
        // 把自定义文件提供者加入日志系统。
        addProvider(FileLoggerProvider())
        return this
    }
}

使用时可以同时输出到控制台和文件:

cangjie
let factory = LoggingBuilder()
    .addConsole()
    .addFile()
    .build()

let logger = factory.createLogger("app")
// 同一条日志会同时分发给两个提供者。
logger.error("这条日志会同时输出到控制台和文件")

如果你只想让文件日志记录更高等级的内容,可以再加一条规则:

cangjie
let factory = LoggingBuilder()
    .addConsole()
    .addFile()
    // 只让 file 提供者记录 Error 及以上级别。
    .addFilter("file", None, LogLevel.Error)
    .build()

与依赖注入集成

如果你的应用已经使用 soulsoft_extensions_injection,通常做法是把 ILoggerFactory 注册到容器里,再在服务构造函数中注入它。

典型写法如下:

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_logging.*

class AppService {
    private let _logger: ILogger

    public AppService(loggerFactory: ILoggerFactory) {
        // 在服务构造函数中创建自己的分类 logger。
        _logger = loggerFactory.createLogger("app.service")
    }

    public func run(): Unit {
        _logger.info("service running")
    }
}

main(): Int64 {
    // 构建日志工厂。
    let loggerFactory = LoggingBuilder()
        .addConsole()
        .setMinimumLevel(LogLevel.Info)
        .build()

    let services = ServiceCollection()
    // 把 ILoggerFactory 注册进容器,并注册业务服务。
    services.addSingleton<ILoggerFactory>(loggerFactory)
    services.addSingleton<AppService, AppService>()

    // 解析服务并执行业务逻辑。
    let root = services.build()
    let app = root.getOrThrow<AppService>()
    app.run()

    // 释放容器持有的资源。
    root.close()
    return 0
}

这里有两个实践点:

  • 容器里注册的是 ILoggerFactory,不是某个固定分类名的 ILogger
  • 业务服务里自行创建 logger,并显式使用小写分类名,例如 app.service

如果你使用的是 HostBuilderWebHostBuilderILoggerFactory 会在构建时自动注册到容器中,这时你的服务可以直接依赖注入 ILoggerFactory,不需要手动再注册一次。

与配置系统集成

上面这些例子都只依赖 soulsoft_extensions_logging。 如果你希望把日志规则放到 JSON、内存字典或配置文件中统一管理,再引入扩展包 soulsoft_extensions_logging_configuration

要点只有两个:

  • 核心日志能力来自 soulsoft_extensions_logging
  • 读取配置规则时,再额外引入 soulsoft_extensions_logging_configurationsoulsoft_extensions_configuration

典型写法如下:

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

main(): Int64 {
    // 构建一份包含 logging 节的配置对象。
    let configuration = ConfigurationManager()
        .addJsonString(###"
        {
            "logging": {
                "logLevel": {
                    "default": "Info",
                    "app.hosting.*": "Warn"
                },
                "console": {
                    "logLevel": {
                        "default": "Error"
                    }
                }
            }
        }
        "###)
        .build()

    // 取出 logging 配置节。
    let loggingSection = configuration.getSection("logging")

    // 根据配置节构建日志工厂。
    let factory = LoggingBuilder()
        .addConsole()
        .addConfiguration(loggingSection)
        .build()

    let logger1 = factory.createLogger("app.hosting.lifetime")
    logger1.info("不会输出")
    logger1.error("会输出")

    let logger2 = factory.createLogger("app.web.router")
    logger2.info("不会输出")
    logger2.error("会输出")

    // 使用完成后关闭工厂。
    factory.close()
    return 0
}

这个例子里,logLevel.default = Info 是全局默认级别,console.logLevel.default = Errorconsole 提供者自己的规则,优先级更高。

配置结构保持下面这种形式即可:

当你把某个配置节传给 addConfiguration() 后,它会识别两类结构:

json
{
    "logLevel": {
        "default": "Info",
        "app.service": "Warn"
    },
    "console": {
        "logLevel": {
            "default": "Error"
        }
    }
}

其中 logLevel:<分类名> 表示全局规则,<提供者名>:logLevel:<分类名> 表示提供者特定规则。 default 表示默认规则。日志级别必须是 TraceDebugInfoWarnErrorFatalOff,并且大小写敏感。

使用建议

  • 先从 addConsole() + setMinimumLevel() 开始,不要一开始就把规则设计得太复杂
  • 分类名建议按模块命名,并统一使用小写,例如 app.web.routerapp.data.sql
  • 扩展包应当后置引入:先把核心日志能力跑通,再接入配置系统
  • 自定义文件提供者时,先采用简单目录,例如 ./logs/yyyyMMdd.log,后续再演进到更复杂的落盘策略