在 ASP.NET Core 项目中,过滤器(Filter)是实现横切关注点(Cross-Cutting Concerns)的核心机制。不同于中间件(Middleware)在请求管道全局生效,过滤器工作在 Action 执行的前后,能够精细地控制和捕获方法级别的执行上下文。

本文将基于 SwitchData.Api 项目中的 ApiDbLoggingActionFilter 实现,带你深入理解如何封装一个功能强大的数据库操作日志过滤器,并掌握其中涉及的高级技术点:异步日志写入、匿名对象处理、上下文状态捕获等。

1. 过滤器基础:为什么选择 IAsyncActionFilter?

在 ASP.NET Core 中,过滤器有多种类型,其中 IAsyncActionFilter 是最常用的 Action 级别过滤器。它的核心方法 OnActionExecutionAsync 接收两个关键参数: - ActionExecutingContext:Action 执行的上下文,可获取入参、Controller 信息、请求头。 - ActionExecutionDelegate:一个委托,调用它会执行后续的 Action 及其他过滤器。

我们的策略是在 next() 捕获入参,在 next() 捕获出参和异常,实现完整的请求链路日志记录。

2. 日志过滤器核心实现

以下是从项目中提取的核心代码结构,展示了一个完整的异步日志过滤器设计思路。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

namespace SwitchData.Api.Filters
{
    /// <summary>
    /// API 数据库操作日志过滤器
    /// </summary>
    public class ApiDbLoggingActionFilter : IAsyncActionFilter
    {
        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            // 1. 前置检查:判断是否需要跳过日志记录
            if (!ShouldLog(context))
            {
                await next();
                return;
            }

            // 2. 捕获入参
            var inputParameters = CaptureInputParameters(context.ActionArguments);

            // 3. 执行 Action(进入管道)
            var executedContext = await next();

            // 4. 捕获出参和异常
            var outputResult = await CaptureOutputResultAsync(executedContext);

            // 5. 异步写入日志(fire-and-forget,不阻塞响应)
            _ = WriteLogAsync(context, inputParameters, outputResult, executedContext);
        }

        private bool ShouldLog(ActionExecutingContext context)
        {
            // 检查是否存在跳过日志的权限标识
            var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
            if (controllerActionDescriptor != null)
            {
                var methodInfo = controllerActionDescriptor.MethodInfo;
                var attrs = methodInfo.GetCustomAttributes(typeof(PermissionAttribute), false);
                if (attrs.Length > 0)
                {
                    var perm = attrs[0] as PermissionAttribute;
                    // 如果方法标注了白名单权限,则跳过日志
                    if (perm?.Permission?.Contains("White.Default") ?? false)
                        return false;
                }
            }
            return true;
        }
    }
}

3. 关键技术点解析

3.1 敏感参数脱敏:入参捕获与过滤

在捕获入参时,密码、密钥、Token 等敏感信息绝不能原样入库。我们通过反射遍历 Action 参数,识别敏感字段并进行掩码处理:

private string CaptureInputParameters(IDictionary<string, object> actionArguments)
{
    if (actionArguments == null || actionArguments.Count == 0)
        return "{}";

    try
    {
        var safeArguments = new Dictionary<string, object>();
        // 定义敏感关键字列表
        var sensitiveKeys = new[] { "password", "pwd", "token", "secret", "key" };

        foreach (var arg in actionArguments)
        {
            // 包含敏感关键字的参数直接替换为 ***
            if (sensitiveKeys.Any(s => arg.Key.Contains(s, System.StringComparison.OrdinalIgnoreCase)))
            {
                safeArguments[arg.Key] = "***";
            }
            else
            {
                safeArguments[arg.Key] = arg.Value;
            }
        }

        return JsonHelper.Serialize(safeArguments);
    }
    catch (System.Exception ex)
    {
        return $"入参序列化失败: {ex.Message}";
    }
}

设计亮点:使用 StringComparison.OrdinalIgnoreCase 忽略大小写匹配,确保 PasswordpasswordPWD 等变体都能被正确识别。

3.2 智能出参处理:多类型结果捕获

Action 的返回值类型是多样的——ObjectResultJsonResultContentResultStatusCodeResult 等都可能出现。我们需要根据具体类型进行差异化处理:

private async Task<string> CaptureOutputResultAsync(ActionExecutedContext executedContext)
{
    return await Task.Run(() =>
    {
        // 1. 异常情况:优先记录异常信息
        if (executedContext.Exception != null)
            return $"异常信息: {executedContext.Exception.Message}";

        if (executedContext.Result == null)
            return "{}";

        try
        {
            string resultJson;

            // 2. 根据 Result 类型分别处理
            switch (executedContext.Result)
            {
                case ObjectResult objectResult:
                    resultJson = objectResult.Value != null
                        ? JsonHelper.Serialize(TruncateAnonymousType(objectResult.Value))
                        : "null";
                    break;

                case JsonResult jsonResult:
                    resultJson = jsonResult.Value != null
                        ? JsonHelper.Serialize(TruncateAnonymousType(jsonResult.Value))
                        : "null";
                    break;

                case ContentResult contentResult:
                    resultJson = $"内容: {contentResult.Content}";
                    break;

                case StatusCodeResult statusCodeResult:
                    resultJson = $"状态码: {statusCodeResult.StatusCode}";
                    break;

                case EmptyResult:
                    resultJson = "空结果";
                    break;

                default:
                    resultJson = $"结果类型: {executedContext.Result.GetType().Name}";
                    break;
            }

            return resultJson;
        }
        catch (System.Exception ex)
        {
            return $"出参序列化失败: {ex.Message}";
        }
    });
}

设计亮点:使用 Task.Run 将出参序列化工作放到线程池执行,避免阻塞主请求线程。对于大数据量的序列化场景,这一优化尤为重要。 ### 3.3 匿名对象截断:ExpandoObject 动态处理

最具挑战性的部分是:Controller 的出参可能是匿名类型(匿名类),我们无法预先知道其结构,且序列化后的数据可能非常庞大。这里使用了 ExpandoObject + 反射进行动态截断:

/// <summary>
/// 截取匿名类型字符串属性长度(防止日志过长)
/// </summary>
private object TruncateAnonymousType(object obj, int maxLength = 1000, string suffix = "...(已截断)")
{
    if (obj == null) return null;

    System.Type type = obj.GetType();

    // 创建动态对象(ExpandoObject 可以在运行时动态添加属性)
    dynamic result = new ExpandoObject();
    var resultDict = (IDictionary<string, object>)result;

    // 通过反射遍历所有属性
    foreach (System.Reflection.PropertyInfo prop in type.GetProperties())
    {
        if (prop.CanRead)
        {
            object value = prop.GetValue(obj);
            resultDict[prop.Name] = TruncateValue(value, maxLength, suffix);
        }
    }

    return result;
}

private object TruncateValue(object value, int maxLength, string suffix)
{
    if (value == null) return null;
    System.Type valueType = value.GetType();

    // 处理字符串:超长截断
    if (valueType == typeof(string))
    {
        string str = (string)value;
        if (str.Length <= maxLength)
            return str;

        int truncateLength = maxLength - suffix.Length;
        return truncateLength > 0 ? str.Substring(0, truncateLength) + suffix : suffix;
    }

    // 处理嵌套匿名类型:递归截断
    if (valueType.Name.Contains("AnonymousType"))
    {
        return TruncateAnonymousType(value, maxLength, suffix);
    }

    // 处理列表/数组
    if (value is System.Collections.IEnumerable enumerable && !(value is string))
    {
        var resultList = new List<object>();
        foreach (var item in enumerable)
        {
            resultList.Add(TruncateValue(item, maxLength, suffix));
        }
        return resultList.ToArray();
    }

    return value;
}

设计亮点: 1. ExpandoObject:这是 .NET 中处理动态对象的利器,无需定义实体类即可构建键值对结构,非常适合日志场景的灵活需求。 2. 递归处理:嵌套类型和集合都能被正确截断,确保日志的可控体积。 3. 智能截断:保留前 1000 字符 + 截断标记,既保留了关键信息,又防止日志膨胀。

3.4 Fire-and-Forget:异步日志写入

日志写入是典型的 I/O 密集操作(数据库写入),绝不能阻塞主请求的响应。我们使用以下模式实现真正的异步解耦:

private async Task WriteLogAsync(
    ActionExecutingContext context,
    string inputParams,
    string outputParams,
    ActionExecutedContext executedContext)
{
    try
    {
        await Task.Run(() =>
        {
            var messageBuilder = new StringBuilder();

            // 根据执行结果构建日志消息
            if (executedContext.Exception != null)
            {
                messageBuilder.Append($"执行失败 - 异常: {executedContext.Exception.Message}");
            }
            else
            {
                // 从统一响应中提取业务消息
                if (executedContext.Result is ObjectResult { Value: JsonResultExtension obj })
                    messageBuilder.Append(obj.message ?? "执行完成");
                else if (executedContext.Result is JsonResult { Value: JsonResultExtension json })
                    messageBuilder.Append(json.message ?? "执行完成");
                else
                    messageBuilder.Append("执行完成");
            }

            // 调用底层日志服务入库
            DbLogHelper.AddApiLog(
                url: context.HttpContext.Request.Path,
                controller: context.Controller.GetType().Name,
                action: context.ActionDescriptor.DisplayName,
                userid: context.HttpContext.Request.Headers["user-id"].ToString(),
                ip: context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "",
                request: inputParams,
                response: outputParams,
                message: messageBuilder.ToString(),
                remark: string.Empty
            );
        }).ConfigureAwait(false);
    }
    catch (System.Exception ex)
    {
        // 日志失败不影响业务,仅输出到控制台
        Console.WriteLine($"记录业务操作日志失败: {ex.Message}");
    }
}

设计亮点: 1. ConfigureAwait(false):避免在异步日志写入完成后回到原始同步上下文,减少线程池压力。 2. 异常兜底:日志服务的异常被捕获后仅输出到控制台,绝不会影响业务接口的正常返回。 3. 统一响应解析:巧妙地利用模式匹配提取业务响应中的消息字段,实现了日志内容与业务语义的关联。

4. 过滤器注册与全局应用

要让过滤器全局生效,需要在 Program.cs 中进行注册:

// Program.cs
builder.Services.AddControllers(options =>
{
    // 注册全局 Action Filter
    options.Filters.Add<ApiDbLoggingActionFilter>();
});

如果只需要在特定 Controller 或 Action 上应用,可以直接标注特性:

// Controller 级别(整个控制器的所有方法都记录)
[ApiDbLoggingActionFilter]
public class WorkController : ApiBaseController
{
    // ...
}

// Action 级别(仅单个方法记录)
[ApiDbLoggingActionFilter]
public IActionResult AddWork([FromBody] AddRequest request)
{
    // ...
}

5. 设计模式总结

通过这个日志过滤器的实现,我们可以总结出 ASP.NET Core 过滤器设计的几条最佳实践:

5.1 职责单一原则

过滤器只做日志相关的事,不混入业务逻辑。入参捕获、出参处理、日志写入各司其职。

5.2 性能优先原则

日志写入使用异步 fire-and-forget 模式,序列化使用线程池,避免任何可能阻塞主请求的同步操作。

5.3 安全优先原则

敏感参数脱敏、异常信息安全输出,确保日志系统不会成为信息泄露的源头。

5.4 可扩展原则

通过 ShouldLog 方法预留了灵活的跳过机制,可以根据权限标识、请求路径、用户角色等条件动态控制日志记录范围。

6. 总结

ASP.NET Core 的过滤器机制为实现横切关注点提供了优雅的解决方案。通过 IAsyncActionFilter,我们能够: - 在 Action 执行前后无侵入地注入日志逻辑 - 通过反射和动态类型处理未知的数据结构 - 利用异步编程确保日志操作不影响业务性能

这种设计模式不仅适用于操作日志场景,同样可以推广到审计追踪、性能监控、异常捕获等其他需要在方法级别介入的需求中。掌握了这个模式,你就能在 ASP.NET Core 项目中灵活应对各种横切关注点的开发挑战。