abpframework / abp

Open Source Web Application Framework for ASP.NET Core. Offers an opinionated architecture to build enterprise software solutions with best practices on top of the .NET and the ASP.NET Core platforms. Provides the fundamental infrastructure, production-ready startup templates, application modules, UI themes, tooling, guides and documentation.
https://abp.io
GNU Lesser General Public License v3.0
12.31k stars 3.32k forks source link

Custom interceptor not being executed for an app service method #12028

Closed dejancg closed 2 years ago

dejancg commented 2 years ago

ABP version: 4.4

--

Even though there is no official documentation about this, I tried to add an interceptor to one of my app service methods. Although I can confirm the interceptor has been added to the list of interceptors via IOnServiceRegistredContext.Interceptors property, it is not being used.

(BTW: I want to try to get around the problem described here: https://github.com/aspnetboilerplate/aspnetboilerplate/issues/3221 so I'm going to share the code exactly as is at the moment. If you think there is a better way other than trying with interceptor, please be free to point me in another direction..)

To reproduce

  1. Define custom attribute to add to the method which should be intercepted
    [AttributeUsage(AttributeTargets.Method)]
    public class RetryOnTransientErrorAttribute : Attribute
    {
    }
  1. Define the interceptor
    public class RetryOnTransientErrorInterceptor : AbpInterceptor, ITransientDependency
    {
        private readonly ResilientDbActionHandler resilientDbActionHandler;

        public RetryOnTransientErrorInterceptor(ResilientDbActionHandler resilientDbActionHandler)
        {
            this.resilientDbActionHandler = resilientDbActionHandler;
        }

        public override async Task InterceptAsync(IAbpMethodInvocation invocation)
        {
            if (!invocation.Method.ShouldRetryAction())
            {
                await invocation.ProceedAsync();
                return;
            }

            await resilientDbActionHandler.ExecuteUsingRetryAsync(invocation.ProceedAsync);
        }
    }
  1. Define the interceptor registration helper
    public static class RetryOnTransientErrorInterceptorRegistrar
    {
        public static void RegisterIfNeeded(IOnServiceRegistredContext context)
        {
            if (ShouldIntercept(context.ImplementationType))
            {
                context.Interceptors.TryAdd<RetryOnTransientErrorInterceptor>();
            }
        }

        private static bool ShouldIntercept(Type type)
        {
            return !DynamicProxyIgnoreTypes.Contains(type) && AnyMethodHasAttribute(type);
        }

        private static bool AnyMethodHasAttribute(Type implementationType)
        {
            return implementationType
                .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
                .Any(HasAttribute);
        }

        private static bool HasAttribute(MemberInfo methodInfo)
        {
            return methodInfo.IsDefined(typeof(RetryOnTransientErrorAttribute), true);
        }
    }
  1. Call RegisterIfNeeded in PreConfigureServices in my application module
    public override void PreConfigureServices(ServiceConfigurationContext context)
    {
        context.Services.OnRegistred(RetryOnTransientErrorInterceptorRegistrar.RegisterIfNeeded);
    }
  1. Decorate the app service action with the above defined attribute [RetryOnTransientError]

You will observe that the interceptor would not be used.

Additional information

If I manually resolve my app service using the service interface, the interceptor is used.

Workaround

As a workaround which can be used for app service actions, I made an action filter like this:

    public class ResilientActionFilter : IAsyncActionFilter, ITransientDependency
    {
        private readonly ResilientDbActionHandler actionHandler;

        public ResilientActionFilter(ResilientDbActionHandler actionHandler)
        {
            this.actionHandler = actionHandler;
        }

        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            if (context.ActionDescriptor is not ControllerActionDescriptor descriptor
                || !descriptor.MethodInfo.ShouldRetryAction())
            {
                await next();
                return;
            }

            await actionHandler.ExecuteUsingRetryAsync(() => next());
        }
    }

and registered it in my host module:

    Configure<MvcOptions>(options =>
    {
        options.Filters.AddService<ResilientActionFilter>();
    });

Unrelated: If, by any chance, someone might want to know how I implemented the ResilientDbActionHandler, here it is:

    public class ResilientDbActionHandler : ITransientDependency
    {
        private readonly ITransientDbExceptionDetector transientDbExceptionDetector;

        public ResilientDbActionHandler(ITransientDbExceptionDetector transientDbExceptionDetector)
        {
            this.transientDbExceptionDetector = transientDbExceptionDetector;
        }

        public async Task ExecuteUsingRetryAsync(Func<Task> action)
        {
            var executionCount = 0;
            while (true)
            {
                try
                {
                    await action();
                    return;
                }
                catch (Exception ex) when (transientDbExceptionDetector.IsDetected(ex))
                {
                    // TODO: Configure delay via IOptions
                    await Task.Delay(1000 * (executionCount + 1));
                    executionCount++;

                    // TODO: Configure count via IOptions
                    if (executionCount >= 3)
                    {
                        throw;
                    }
                }
            }
        }
    }

The referenced ShouldRetryAction would be written like:

    public static bool ShouldRetryAction(this MethodInfo methodInfo)
    {
        Check.NotNull(methodInfo, nameof(methodInfo));
        var attrs = methodInfo.GetCustomAttributes(true).OfType<RetryOnTransientErrorAttribute>().ToArray();
        return attrs.Any();
    }

where ITransientDbExceptionDetector is simply:

    public interface ITransientDbExceptionDetector
    {
        bool IsDetected(Exception ex);
    }

with implementation suitable for Npgsql.EntityFrameworkCore users:

    public class TransientDbExceptionDetector : ITransientDbExceptionDetector, ITransientDependency
    {
        public bool IsDetected(Exception ex)
        {
            return ExecutionStrategy.CallOnWrappedException(ex, NpgsqlTransientExceptionDetector.ShouldRetryOn);
        }
    }
dejancg commented 2 years ago

Silly me. app services are not constructed via interfaces, but directly via implementations. If I mark the method I want to intercept virtual, then it works fine.