浅聊一下AOP

前言

这周,在做一个项目的时候,遇到了一个业务上的动态性问题,大概是这样。

我们有很多报名类的活动,后台负责管理这些活动,比如控制日期,控制报名策略,控制人数等,可以实时的动态修改;而前台项目,也就是业务接口要实时的捕获到这些策略的变化,当某个阶段的报名策略变更了,要及时的响应给前端,并在界面上友好的提示用户,比如,"当前阶段不允许修改报名信息"之类。

那问题是,我的Api接口已经基本成型了,如果要增加这些动态策略,最直接的方法是进入到每个接口里,看它的业务逻辑怎么写的,然后挨个加上这样一个判定策略。

当然,策略是可以统一编写,只需要在调用处增加简单的代码即可,但从工程角度看,这仍然是一次有侵入性的变动,不是最佳实践,万一后续策略再次调整,或者又有新的策略加入进来,又该如何呢?再次从头到尾的改一遍吗?我遇到这个问题的时候,最先想到的就是AOP切面编程的思路,然后积极的实现了一下。

当一起看起来都挺美好的时候,突然想到,这会不会出现死锁?因为我实现AOP的过程,依赖了Autofac的动态代理插件,而为了解决异步调用的问题,又引入了Castle.Core.AsyncInterceptor这个包。

而我在引入的时候其实就发现了,Autofac的动态代理插件(Autofac.Extras.DynamicProxy)早就停止更新了,并没有和主包保持版本一致,截止到发文当天,Autofac的主包版本是10.0,而动态代理是7.1.0,且更新日期停留在了2023,同样的AsyncInterceptor包的更新日期更早,停留在了2022年。我突然意识到,我是不是走错方向了~

接下来,我简单介绍下本例里AOP的实现,并聊一下我实际遇到的问题。

动态代理实现AOP

有时候,我们分不清,头脑发热和头脑清醒,因为都是相对亢奋的状态,还是得冷静一下。

我这里,虽然我觉得是走错路了,但真正动手实现一次AOP的拦截,对了解切面编程还是很有帮助的。

定义标记接口

首先,我定义了一个拦截专用的标记接口,我这里判定的是是否包含一个指定的参数

csharp 复制代码
 public interface IHasDecProjectId
 {
     long DecProjectId { get; }
 }

然后所有的Dto,或者传递对象,只要用到指定参数的,都继承这个借口,比如

csharp 复制代码
public class DecProjectDetailFriendlyRequestdto : IHasDecProjectId
{
    [Required(ErrorMessage = "项目Id不能为空")]
    public long DecProjectId { get; set; }

    public DecProjectDetailFieldRequestDto? Fields { get; set; }
}

实际上这里就已经开始侵入原有的业务代码了,而且深层侵入,虽然看起来改动不大

定义标记特性

定义一个特性,目的是,给所有需要切面拦截的接口打上标记,缩小拦截范围

csharp 复制代码
[AttributeUsage(AttributeTargets.Method,Inherited = false)]
public class DecProjectDetailEditOperationAttribute : Attribute 
{ }

然后,在需要拦截的接口方法上,标记特性

csharp 复制代码
 [DecProjectDetailEditOperation]
 Task<Result<long>> UpdateFriendlyAsync(long projectId, DecProjectDetailRequestDto dto);

定义拦截器

csharp 复制代码
public class DecProjectDetailModifyInterceptor : IInterceptor
{
    private readonly IDecMainProcessRepo _decMainProcessRepo;
    private ILogger<DecProjectDetailModifyInterceptor> _logger;
    public DecProjectDetailModifyInterceptor(
        IDecMainProcessRepo decMainProcessRepo,
        ILogger<DecProjectDetailModifyInterceptor> logger)
    {
        _decMainProcessRepo = decMainProcessRepo;
        _logger = logger;
    }

    public void Intercept(IInvocation invocation)
    {
        _logger.LogWarning($"[AOP] 拦截: {invocation.Method.Name}"); 
       
        // 判断是否是标记的写操作
        if (!IsWriteOperation(invocation.Method))
        {
            invocation.Proceed(); // 不是标记的写操作,放行
            return;
        }
        var decProjectValue = ExtractDecProjectId(invocation.Arguments);
        if (!decProjectValue.HasValue)
        {
            var failResult = Result<long>.Fail("缺少项目ID,无法执行操作");
            invocation.ReturnValue = Task.FromResult(failResult);
            return;
        }
        long decProjectId = decProjectValue.Value;

        // 这里改成同步(不太好,实际是掩盖问题,牺牲性能)
        var mainProcessResult = _decMainProcessRepo
            .GetCurrProjectMainProcess(decProjectId)
            .GetAwaiter()
            .GetResult();

        if (mainProcessResult.IsSuccess &&
            mainProcessResult.Value.AllowEditApply == Domain.StatusEnum.Disabled)
        {
            var failResult = Result<long>.Fail("当前项目所处的活动阶段,不允许修改");
            invocation.ReturnValue = Task.FromResult(failResult);
            return;
        }

        //检索失败的话,也直接放行,业务方法会有自己的校验,不在拦截器里处理
        invocation.Proceed();
    }

    private bool IsWriteOperation(MethodInfo method)
    {
        return method.GetCustomAttribute<DecProjectDetailEditOperationAttribute>() != null;
    }

    private long? ExtractDecProjectId(object[] args)
    {
        return args.OfType<IHasDecProjectId>().FirstOrDefault()?.DecProjectId;
    }
}

这一段实际上没什么可说的,就是固定写法,Intercept里写上拦截的逻辑就好,而两个私有函数分别是定位拦截的参数和拦截的接口方法。需要说明的是,拦截器里的业务逻辑,本身是一个异步方法,我这里改成了同步,问题也就是出现在这里,下面再说,先把流程说完

注入容器

因为我这里使用的是Autofac,所以拦截器的注入也要在符合Autofac的写法

csharp 复制代码
// 注册拦截器
builder.RegisterType<DecProjectDetailModifyInterceptor>()
       .AsSelf()
       .SingleInstance(); // 拦截器通常单例
// 注册 Services 并启用拦截
builder.RegisterAssemblyTypes(assemblyServices)
        .Where(t => t.Name.EndsWith("Service") && !t.IsInterface)
        .AsImplementedInterfaces()
        .InstancePerLifetimeScope()
        .EnableInterfaceInterceptors() // 启用接口代理
        .InterceptedBy(typeof(DecProjectDetailModifyInterceptor));//必须在同一链中

需要说明的是,项目的架构分层要相对明确,各层次的职能清晰,避免出现循环依赖的问题,这个注意下就行,遇到了应该也能根据报错信息针对性解决,不在赘述。

执行效果

当访问被标记的接口时,如期进行了拦截

bash 复制代码
curl -X 'PUT' \
  'https://localhost:7100/api/DecProjectDetail/xxx' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "decProjectId": xxx,
  "fields": {
      ...业务参数不再赘述
  }
}'

*问题分析

前面的操作完成后,一个拦截器基本就做好了,拦截器里的方法,应该是异步方法,这里改成了同步,也就是使用了GetAwaiter之类的方法,在引入AsyncInterceptor之前,这里如果不影响异步的调用,需要手动处理,我开始是写了一个私有方法,如下

csharp 复制代码
private async Task HandleAsyncMethod(IInvocation invocation, long decProjectId)
{
    // 先执行异步校验
    var mainProcessResult = await _decMainProcessRepo
        .GetCurrProjectMainProcess(decProjectId)
        .ConfigureAwait(false);

    if (mainProcessResult.IsSuccess &&
        mainProcessResult.Value.AllowEditApply == Domain.StatusEnum.Disabled)
    {
        var failResult = Result<long>.Fail("当前项目所处的活动阶段,不允许修改");
        invocation.ReturnValue = Task.FromResult(failResult);
        return;
    }

    // 校验通过 → 执行原方法
    invocation.Proceed();
}

写完之后,改动拦截器里的方法,修改为这样

csharp 复制代码
if (IsAsyncMethod(invocation.Method))
{    
    invocation.ReturnValue = HandleAsyncInvocation(invocation, decProjectId.Value);
    return; 
}

这样改完之后,执行之后,是会进行拦截,但返回值会异常,也就是影响了主函数的执行

plain 复制代码
Unable to cast object of type 'AsyncStateMachineBox`1[System.Threading.Tasks.VoidTaskResult,Magic.Declaration.WebAPI.AopInterceptors.DecProjectDetailModifyInterceptor+<HandleAsyncMethod>d__4]' to type 'System.Threading.Tasks.Task`1[Magic.Declaration.Application.Result`1[System.Int64]]'.

但改为同步调用后,一切就都回复正常,毕竟这是客户端接口,访问量可能会有激增的情况,很容易出现死锁,即便配置了ConfigureAwait(false),也不能完全避免。

虽然有Castle.Core.AsyncInterceptor插件作为修补,但这终归不是实现拦截的最佳实践,因为你可能需要修改调用方法的关键字,将其改为虚方法virtual,如果你一开始就考虑到了这部分,那这样做没什么毛病,而如果是像我一样中途修改,那改动代价相对还是挺大的。

这部分的代码我就不贴了,大家感兴趣可以看一下它的仓库(github.com/JSkimming/C...),实际上Autofac的动态代理底层也依赖了Castle,但版本没跟上,不支持异步拦截,到这里,我也就突然明白,是不是走错方向了。

过滤器实现AOP

.net core的设计有自己的风格,它没有盲目跟随Java Spring的AOP风格,而是有自己的最佳实践方案。

本身.net core的mvc架构里,对动态代理的支持度就不好,只能通过Autofac之类的插件来整合,但真正操作下来,还是会反思,这可能真的不是一个好方法,AOP的初衷是对使用者屏蔽复杂的内部处理,通过抽取切面进行编程,而这个问题恰恰需要我们对使用者进行修改,违背了SOLID原则,简单来说就是让复杂的问题变得更复杂了~

回到最初的需求,我只是想在执行某个方法或者接口的时候,通过请求参数,在执行主操作之前,验证一下它的执行环境,比如用户要提交一个报名表,我想在他提交之前先验证这个报名的活动是否还接收新的报名或者是否允许修改,我第一个想到的方法就是AOP,但忽略了其他方案,既然.net core webapi里用AOP有这么多限制,还容易造成死锁,那有没有其他的方案可以满足我的需求呢。

实际上,.net core里已经实现了AOP,而且非常好用,就是中间件和过滤器,微软的目的实际上不是让我们通过动态代理来实现AOP,而是通过middleware或者Filter。

我的项目里,已经用到Filter了,但是是全局注入的,主要是解决异常,授权和日志跟踪,也正是因为对架构设计了解不够深入,遇到问题时,我只想到了AOP,却忽略了实现AOP的方法。

这里,我再次通过Filter的方法实现一下上述的拦截

定义过滤器

csharp 复制代码
public class ValidateEnrollmentActivityFilter : IAsyncActionFilter
{
    private ILogger<DecProjectDetailModifyInterceptor> _logger;
    public ValidateEnrollmentActivityFilter(ILogger<DecProjectDetailModifyInterceptor> logger)
    {
        _logger = logger;
    }

    public async Task OnActionExecutionAsync(
    ActionExecutingContext context,
    ActionExecutionDelegate next)
    {
        var decprojectid = context.ActionArguments["projectId"];

        _logger.LogWarning("拦住了:" + decprojectid);
        //业务代码
        await next();
    }
}

注入容器

csharp 复制代码
services.AddScoped<ValidateEnrollmentActivityFilter>();

如果你需要全局拦截,比如每个接口都需要验证某个参数,可以像这样

csharp 复制代码
services.AddControllers(option =>
{
    option.Filters.Add(typeof(GlobalActionFilter));
})

需要说明的是,过滤器本身不会影响性能,不论是全局还是局部,影响性能的是过滤器里做的什么事儿,所以如果你的过滤器里要做一些相对复杂的操作,比如检索数据库等,还是尽量不要做全局拦截,这个视情况而定。

标记特性

csharp 复制代码
[HttpGet("{projectId}")]
[ServiceFilter(typeof(ValidateEnrollmentActivityFilter))]
public async Task<IActionResult> Get(long projectId)
{
     _logger.LogWarning("执行接口: /decprojectdetail/"+ projectId);
    var detail = await _decProjectDetailService.GetByProjectIdAsync(projectId);
    if (detail == null)
        return NotFound(ApiResult.Warning("无记录"));
    _logger.
    return Ok(ApiResult.Success(detail));
}

拦截效果

这就结束了,不需要考虑死锁之类的问题,不需要标记特性,不需要引入动态代理,一切都是熟悉的样子,世界都安静了。

结束语

好了,虽然折腾了一番,但收获也蛮多,以上只是我个人对AOP的一点见解,有错误和不足之处请见谅。

相关推荐
悟空码字13 小时前
腾讯开源啦,源码地址+部署脚本
后端·腾讯
Xxtaoaooo13 小时前
Spring Boot 启动卡死:循环依赖与Bean初始化的深度分析
java·后端·依赖注入·三级缓存机制·spring boot循环依赖
洛小豆13 小时前
Ubuntu 网络配置演进:从 20.04 到 24.04 的静态 IP 设置指南
linux·后端·ubuntu
JavaGuide13 小时前
2025 程序员时薪排行榜,PDD 太顶了!
java·后端
咖啡Beans14 小时前
Maven的POM常用标签详解
后端
MrSYJ14 小时前
别告诉我你还不会OAuth 2.0授权过滤器:OAuth2AuthorizationEndpointFilter第三篇
java·spring boot·后端
天天摸鱼的java工程师14 小时前
如何快速判断几十亿个数中是否存在某个数?—— 八年 Java 开发的实战避坑指南
java·后端
老邓计算机毕设14 小时前
Springboot乐家流浪猫管理系统16lxw(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·后端
武子康14 小时前
大数据-88 Spark Super Word Count 全流程实现(Scala + MySQL)
大数据·后端·spark