simpleinjector / SimpleInjector

An easy, flexible, and fast Dependency Injection library that promotes best practice to steer developers towards the pit of success.
https://simpleinjector.org
MIT License
1.21k stars 154 forks source link

Exception thrown when attempting to inject COM interface #986

Closed myth384 closed 10 months ago

myth384 commented 10 months ago

Describe the bug

I am integrating Simple Injector into an AddIn-type application for Autodesk Inventor. Inventor exposes its API through COM interop. The primary entry point is a COM interface named Inventor.Application. Contrary to its name, this is indeed an interface, not a class. The Application instance gets successfully registered in the container using: container.RegisterInstance(inventor). Subsequently, the instance is retrieved successfully with: var inventor = container.GetInstance();. However, when the COM interface is utilized in the constructors of services, an exception is thrown upon calling container.Verify().

Expected behavior

I expect the Application instance to be injected into services.

Actual behavior

Instead the following exception is thrown:

System.InvalidOperationException: The configuration is invalid. Creating the instance for type IUserInterfaceManager failed. Expression of type 'System.ComObject' cannot be used for constructor parameter of type 'Inventor.Application' ---> SimpleInjector.ActivationException: Expression of type 'System.ComObject' cannot be used for constructor parameter of type 'Inventor.Application' ---> System.ArgumentException: Expression of type 'System.__ComObject' cannot be used for constructor parameter of type 'Inventor.Application' at System.Linq.Expressions.Expression.ValidateOneArgument(MethodBase method, ExpressionType nodeKind, Expression arg, ParameterInfo pi) at System.Linq.Expressions.Expression.ValidateArgumentTypes(MethodBase method, ExpressionType nodeKind, ReadOnlyCollection1& arguments) at System.Linq.Expressions.Expression.New(ConstructorInfo constructor, IEnumerable1 arguments) at SimpleInjector.Registration.BuildNewExpression() at SimpleInjector.Registration.BuildTransientExpression() at SimpleInjector.Lifestyles.TransientLifestyle.TransientRegistration.BuildExpression() at SimpleInjector.InstanceProducer.BuildExpressionInternal() at SimpleInjector.Internals.LazyEx1.InitializeAndReturn() at SimpleInjector.Internals.LazyEx1.get_Value() at SimpleInjector.InstanceProducer.BuildExpression() --- End of inner exception stack trace --- at SimpleInjector.InstanceProducer.BuildExpression() at SimpleInjector.Registration.BuildConstructorParameters(ConstructorInfo constructor) at SimpleInjector.Registration.BuildNewExpression() at SimpleInjector.Registration.BuildTransientExpression() at SimpleInjector.Lifestyles.TransientLifestyle.TransientRegistration.BuildExpression() at SimpleInjector.InstanceProducer.BuildExpressionInternal() at SimpleInjector.Internals.LazyEx1.InitializeAndReturn() at SimpleInjector.Internals.LazyEx1.get_Value() at SimpleInjector.InstanceProducer.BuildExpression() at SimpleInjector.InstanceProducer.VerifyExpressionBuilding() --- End of inner exception stack trace --- at SimpleInjector.InstanceProducer.VerifyExpressionBuilding() at SimpleInjector.Container.VerifyThatAllExpressionsCanBeBuilt(InstanceProducer[] producersToVerify) at SimpleInjector.Container.VerifyThatAllExpressionsCanBeBuilt() at SimpleInjector.Container.VerifyInternal(Boolean suppressLifestyleMismatchVerification) at SimpleInjector.Container.Verify(VerificationOption option) at SimpleInjector.Container.Verify()

To Reproduce

As I assume you may not have access to an Autodesk Inventor installation, the steps below can help simulate my situation: 1) Create a Console app. 2) Instantiate or obtain a COM object in this app. (Although I am not sure how...) 3) Create a class with a method that has a COM interface parameter. 4) Within this method, create a new Container instance. 5) Register the COM object using: container.RegisterInstance(comInstance) 6) Add a line to verify the container using: container.Verify() 7) Create a class implementing an interface and add the COM interface to its constructor. 8) Register the interface and class using: container.Register<IService, Service>() 9) After execution, the .Verify() call should throw an exception.

Additional context

If you require any assistance, I am more that glad to help you out!

dotnetjunkie commented 10 months ago

I'm afraid this is a bug I can't fix. Fixing this quite radically changes the way Simple Injector works. Basically, the problem can be demonstrated with the following code:

var comObject = new Inventor.Application();
Type consumer = typeof(Foo<Inventor.Application>);
var ctor = consumer.GetConstructors().Single();
var param = ctor.GetParameters().Single();
var comExpr = Expression.Constant(comObject);
Expression.New(ctor, comExpr);

public record Foo<T>(T instance) { }

When Inventor.Application would be any 'normal' .NET type, this code would complete successfully. With Inventor.Application being a COM object, you'll experience the following exception when Expression.New is called:

System.ArgumentException: Expression of type 'System.__ComObject' cannot be used for constructor parameter of type 'Inventor.Application'

Notice, BTW, how this code is pure .NET code. It runs without Simple Injector.

This exception is caused by a limitation in System.Linq.Expressions, which was never designed to properly handle COM objects. The problem occurs because both Expression.New and Expression.Constant do simple type checks that don't work with COM objects, and they don't check whether the supplied type is a COM object. This makes System.Linq.Expressions believe that the object doesn't match, which -in fact- it does. This is because proxy classes are generated by (I believe) the JIT in order to let .NET code communicate with the COM object.

Although it's not impossible for Simple Injector to work around this limitation of the .NET framework, this is not an investment I'm willing to make. Not only is this an enormous undertaking for a corner case scenario, more importantly, it forces me to make breaking changes to the API of Simple Injector—something I never take lightly.

You can expect other DI Containers to have similar limitations, especially the one's that are more optimized. Chances are slim, for instance, that this would work with MS.DI.

Not all is lost, though. Simple workarounds exist.

I'd like to propose a workaround, which is to make the COM object accessible through a 'provider' class or abstraction and to inject that provider instead of injecting the COM object directly. For instance:

// Application:
public sealed record AutodeskProvider(Inventor.Application Application);

public class UserInterfaceManager : IUserInterfaceManager
{
    // Inject IAutodeskProvider instead of Inventor.Application
    public UserInterfaceManager(AutodeskProvider provider) ...
}
// Composition Root
// Register AutodeskProvider instead of Inventor.Application.
container.RegisterInstance(new AutodeskProvider(new Inventor.Application()));

This works, because the COM object is no longer part of the constructor signature that is being constructed by Simple Injector and compiled by .NET.

myth384 commented 10 months ago

Hi Steven,

First of all, I want to thank you for taking the time to write such an extensive response! I agree it is not worth the large effort, as you describe it, to make Simple Injector work fully with the relic that COM Interop is. I sure hope Autodesk will move to a .NET API one day; the current API feels so outdated...

Now that you are mentioning Microsoft.Extensions.DependencyInjection... I use that in my current implementation. It actually works directly with the COM interface. However, I decided to switch to Simple Injector because Autodesk ships Inventor with version 1.0.0 of Microsoft.Extensions.DependencyInjection. The .config files of add-ins are completely ignored, so I have to hack the .config file of Inventor to redirect to newer versions of the library. This was unacceptable for me.

Also, thank you for suggesting a workaround. I had already had something comparable in mind. I'm glad I don't have to discard Simple Injector either.

I wish you happy hollidays!

dotnetjunkie commented 10 months ago

Interesting to hear that this actually works with MS.DI. That means they abandoned using System.Linq.Expression altogether.

It took me a few hours to investigate this and write the response. After reproducing the issue in a unit test my intention was on fixing this issue. That code was added in v5 which was based on #598. That's because there already is some code inside Simple Injector that detects whether a registration is for a COM object and tries to work around the limitation within the Expression subsystem of .NET.

But during the process I slowly started to realize that the code in place actually did very little. The code allowed to directly resolve a COM object from the container, but that's hardly ever useful. If you're requesting the COM object directly from the container, you could as well pass it manually without the container.

Because of this, I'm considering to remove the current COM object checks from the container and instead prevent the registration of COM objects altogether and throw an exception that points at a section in the documentation that explains how to work with COM objects (that section still has to be written).

myth384 commented 10 months ago

I did some quick searching and from what I've read Microsoft.Extensions.DependencyInjection uses a mix of reflection, expression trees and dynamically generated code. I think removing support for COM objects is a good idea. The ability to register COM objects creates a misleading impression that the container supports constructor injection for those objects. Failing fast would make it much clearer to future developers attempting the same.