Sisk

日志记录

本页由英文自动翻译。 阅读原文

您可以配置 Sisk 自动写入访问日志和错误日志。可以定义日志轮转、扩展名和轮转频率。

The LogStream 类提供了一种异步写入日志并将其保存在可等待写入队列中的方式。LogStream 类实现了 IAsyncDisposable,确保在流关闭前写入所有未完成的日志。

本文将向您展示如何为应用程序配置日志记录。

基于文件的访问日志 #

写入文件的日志会在每写入一行时打开文件、写入文本,然后关闭文件。采用此方式是为了保持日志写入的响应性。

Program.csC#
class Program
{
    static async Task Main(string[] args)
    {
        using var app = HttpServer.CreateBuilder()
            .UseConfiguration(config => {
                config.AccessLogsStream = new LogStream("logs/access.log");
            })
            .Build();
        
        ...
        
        await app.StartAsync();
    }
}

上述代码会将所有传入的请求写入 logs/access.log 文件。请注意,如果文件不存在会自动创建,但其上级文件夹不会自动创建。无需手动创建 logs/ 目录,因为 LogStream 类会自动创建它。

基于流的日志记录 #

您可以通过在构造函数中传入 TextWriter 对象(例如 Console.Out),将日志写入到 TextWriter 实例中:

Program.csC#
using var app = HttpServer.CreateBuilder()
    .UseConfiguration(config => {
        config.AccessLogsStream = new LogStream(Console.Out);
    })
    .Build();

对于流式日志写入的每条消息,都会调用 TextWriter.Flush() 方法。

访问日志格式化 #

您可以通过预定义变量自定义访问日志的格式。请参考以下示例行:

C#
config.AccessLogsFormat = "%dd/%dmm/%dy %tH:%ti:%ts %tz %ls %ri %rs://%ra%rz%rq [%sc %sd] %lin -> %lou in %lmsms [%{user-agent}]";

它将生成类似如下的日志信息:

29/mar./2023 15:21:47 -0300 Executed ::1 http://localhost:5555/ [200 OK] 689B -> 707B in 84ms [Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/111.0.0.0 Safari/537.36]

您可以按照下表所述的格式来定义日志文件:

值含义示例
%dd月份中的日期(两位数字)05
%dmmm月份的全称July
%dmm月份的缩写(三个字母)Jul
%dm月份数字(两位)07
%dy年份(四位数字)2023
%th12 小时制的小时03
%tH24 小时制的小时(HH)15
%ti分钟(两位数字)30
%ts秒数(两位数字)45
%tm毫秒(三位数字)123
%tz时区偏移(UTC 总小时)+03:00
%ri客户端的远程 IP 地址(在请求开始时解析)192.168.1.100
%rmHTTP 方法(大写)GET
%rsURI 协议(http/https)https
%raURI 权限部分(域名)example.com
%rh请求的主机名www.example.com
%rp请求的端口号443
%rz请求的路径/path/to/resource
%rq查询字符串?key=value&another=123
%scHTTP 响应状态码200
%sdHTTP 响应状态描述OK
%lin请求的可读大小1.2 KB
%linr请求的原始大小(字节)1234
%lou响应的可读大小2.5 KB
%lour响应的原始大小(字节)2560
%lms耗时(毫秒)120
%ls执行状态Executed
%{header-name}表示请求中的 header-name 头部。Mozilla/5.0 (platform; rv:gecko [...]
%{:header-name}表示响应中的 header-name 头部。application/json
%{$key}表示存放在 HttpContext.RequestBag 中 key 键的值,使用 ToString() 转换。如果键未设置则为空。42

您也可以使用 HttpServerConfiguration.DefaultAccessLogFormat 来使用默认的访问日志格式。

%{$key} 语法允许请求处理程序和路由向访问日志中添加自定义值:

C#
config.AccessLogsFormat = "%dd/%dmm/%dy %tH:%ti:%ts %rm %rz [%sc] user=%{$UserId}";

router.MapGet("/profile", request =>
{
    request.Bag["UserId"] = "42";
    return new HttpResponse("ok");
});

日志轮转 #

您可以配置 HTTP 服务器在日志文件达到一定大小时将其轮转为压缩的 .gz 文件。大小会按照您定义的间隔定期检查。

C#
LogStream errorLog = new LogStream("logs/error.log")
    .ConfigureRotatingPolicy(
        maximumSize: 64 * SizeHelper.UnitMb,
        dueTime: TimeSpan.FromHours(6));

上述代码会每六小时检查一次 LogStream 的文件是否已达到 64 MB 限制。如果达到,则将文件压缩为 .gz 并随后清理 access.log。

在此过程中,文件写入会被锁定,直至文件压缩并清理完成。此期间产生的所有写入行都会进入队列,等待压缩结束后再写入。

此功能仅适用于基于文件的 LogStream。

错误日志记录 #

当服务器不将错误抛给调试器时,会在出现错误时将其转发到日志写入。您可以通过以下方式配置错误日志写入:

C#
config.ThrowExceptions = false;
config.ErrorsLogsStream = new LogStream("error.log");

仅当错误未被回调或 Router.CallbackErrorHandler 属性捕获时,此属性才会向日志写入内容。

服务器写入的错误日志始终包含日期时间、请求头(不包括请求体)、错误堆栈以及内部异常堆栈(如果存在)。

其他日志实例 #

您的应用程序可以拥有零个或多个 LogStream,日志通道数量没有限制。因此,您可以将应用程序的日志写入除默认 AccessLog 或 ErrorLog 之外的其他文件。

C#
LogStream appMessages = new LogStream("messages.log");
appMessages.WriteLine("Application started at {0}", DateTime.Now);

安全写入文件 #

对于非日志流的文件(如报告或状态转储),LogStream 提供了静态辅助方法,可创建缺失的父目录、在进程内序列化对同一文件的并发写入,并在操作失败时返回 false 而不是抛异常:

每个方法接受字符串或字符串数组,并提供接受 Encoding 的重载。

C#
bool written = await LogStream.SafeAppendToFileAsync("logs/audit.log", $"{DateTime.Now:s} user signed in\n");

if (!written)
{
    Console.WriteLine("Could not write the audit entry.");
}

锁定是进程级的:来自其他进程对同一文件的写入不会被协调。如果仅需确保目录存在,可使用 PathHelper.EnsureDirectoryExistance 或 PathHelper.EnsureFileDirectoryExistance,它们在目录已存在时返回 true,在创建时返回 false。

扩展 LogStream #

您可以扩展 LogStream 类以写入自定义格式,兼容当前的 Sisk 日志引擎。下面的示例演示如何通过 Spectre.Console 库向控制台写入彩色消息:

CustomLogStream.csC#
public class CustomLogStream : LogStream
{
    protected override void WriteLineInternal(string line)
    {
        base.WriteLineInternal($"[{DateTime.Now:g}] {line}");
    }
}

另一种为每个请求/响应自动写入自定义日志的方式是创建一个 HttpServerHandler。下面的示例更为完整。它将请求和响应的 JSON 正文写入控制台,可用于一般的请求调试。该示例使用了 ContextBag 和 HttpServerHandler。

Program.csC#
class Program
{
    static async Task Main(string[] args)
    {
        var app = HttpServer.CreateBuilder(host =>
        {
            host.UseListeningPort(5555);
            host.UseHandler<JsonMessageHandler>();
        });

        app.Router.MapAny("/json", request =>
        {
            return new HttpResponse()
                .WithContent(JsonContent.Create(new
                {
                    method = request.Method.Method,
                    path = request.Path,
                    specialMessage = "Hello, world!!"
                }));
        });

        await app.StartAsync();
    }
}
JsonMessageHandler.csC#
class JsonMessageHandler : HttpServerHandler
{
    protected override void OnHttpRequestOpen(HttpRequest request)
    {
        if (request.Method != HttpMethod.Get && request.Headers["Content-Type"]?.Contains("json", StringComparison.InvariantCultureIgnoreCase) == true)
        {
            // 此时连接已打开,客户端已发送指定内容为 JSON 的头部
            // 内容为 JSON。下面的代码读取内容并将其保存在请求中。
            //
            // 如果在请求处理阶段未读取内容,GC 可能会回收该内容
            // 在向客户端发送响应后,内容可能已不可用。
            //
            _ = request.RawBody;

            // 在上下文中添加提示,标记此请求包含 JSON 正文
            request.Bag.Add("IsJsonRequest", true);
        }
    }

    protected override async void OnHttpRequestClose(HttpServerExecutionResult result)
    {
        string? requestJson = null,
                responseJson = null,
                responseMessage;

        if (result.Request.Bag.ContainsKey("IsJsonRequest"))
        {
            // 使用 CypherPotato.LightJson 库重新格式化 JSON
            var content = result.Request.Body;
            requestJson = JsonValue.Deserialize(content, new JsonOptions() { WriteIndented = true }).ToString();
        }
        
        if (result.Response is { } response)
        {
            var content = response.Content;
            responseMessage = $"{(int)response.Status} {HttpStatusInformation.GetStatusCodeDescription(response.Status)}";
            
            if (content is HttpContent httpContent &&
                // 检查响应是否为 JSON
                httpContent.Headers.ContentType?.MediaType?.Contains("json", StringComparison.InvariantCultureIgnoreCase) == true)
            {
                string json = await httpContent.ReadAsStringAsync();
                responseJson = JsonValue.Deserialize(json, new JsonOptions() { WriteIndented = true }).ToString();
            }
        }
        else
        {
            // 获取内部服务器处理状态
            responseMessage = result.Status.ToString();
        }
        
        StringBuilder outputMessage = new StringBuilder();

        if (requestJson != null)
        {
            outputMessage.AppendLine("-----");
            outputMessage.AppendLine($">>> {result.Request.Method} {result.Request.Path}");

            if (requestJson is not null)
                outputMessage.AppendLine(requestJson);
        }

        outputMessage.AppendLine($"<<< {responseMessage}");

        if (responseJson != null)
            outputMessage.AppendLine(responseJson);

        outputMessage.AppendLine("-----");

        await Console.Out.WriteLineAsync(outputMessage.ToString());
    }
}

输入关键词以搜索文档和 API 参考。