Introduction
COM/.NET interop is a well-known, well-documented, and well-loved technology. It serves to help us bridge existing COM-based systems with .NET components, allowing us to leverage the wealth of functionality available through the use of the .NET class libraries. On the flip side, COM/.NET interop also allows .NET applications to use existing COM objects and controls, thereby achieving backward-compatibility with legacy COM components.
Many articles have been written on the techniques of COM/.NET interop. Many "how-to"s and even "why"s have been addressed through the years since .NET first inaugurated, earlier this decade.
In this article, I aim to concentrate on the techniques behind exposing .NET objects to COM. I will attempt to explain, in as much depth as possible, how the .NET system achieves interop through language constructs and various tools. To this end, I will present several example COM server source codes mostly written in C# (as an example .NET language) and one written in C++ (for cross-comparison purposes). It also gives me great pleasure to demonstrate how we can expose objects inside .NET EXE assemblies (both static and running instances) to COM via a specially written .NET class factory implemented in C#.
I will assume that the reader already has prior knowledge on the following:
- COM development, in general.
- C++ and ATL.
- C# and various .NET tools like tlbimp.exe and gacutil.exe.
Throughout my article, emphasis will be given to the C# implementation codes, tools usage examples, and various configuration settings that together demonstrate how to build C# components that can be transformed into COM components usable by unmanaged clients. The C++ implementation codes will be given some mention, but I will assume that the reader has ample development experience in this. I will also give special focus on the COM component discovery and loading mechanism. This is explored in order to cross-compare, understand, and appreciate the way that a .NET component (disguising as a COM object) gets discovered and loaded for an unmanaged client application.
General Outline
The following is a general outline of how the main bulk of this article is organised:
- The COM Platform
I will present a summary appraisal of COM, a technology near and dear to many programmers' hearts. I want very much to share with all my views of how COM achieves Object-Oriented Development principles. A short refresher on IDL will also be presented in which its significance in terms of language-independence is highlighted.
- A Simple COM Interface
We will define a Simple COM Interface which will be used to implement concrete code. This COM interface is maintained by an "empty" ATL project. By "empty", we mean that this ATL project does not contain any meaningful implementation code. Its purpose is to help us maintain a central IDL which contains the definitions of the above-mentioned simple COM interface as well as other abstract interfaces. The ATL project can also be used to help us compile and produce a Type Library File (.TLB) which will be useful for producing something known as a primary interop assembly.
- In-Proc Server (DLL) Implementations
We will then begin our hands-on study of the implementation of COM servers, starting with in-proc (DLL) servers. We will walk through the development process of two separate concrete implementations of the Simple COM Interface: one in C++ and the other in C#. Various aspects of the C++ implementation are examined in order to show how these are equitably achieved by the C# implementation. Two client applications will be included (one in VB and the other in VC++). These will illustrate the instantiation of the C++ and C# concrete implementations and the calling of their methods.
- Out-of-Proc (EXE) Implementations
After building a strong foundation on the internals of standard .NET/COM interop, we will move on to explore ways to use .NET EXE assemblies as COM servers. We will explore the possibility of their use as in-proc servers, showing the versatility of the .NET engine in treating assemblies in a uniform fashion (regardless of whether they be executables or class libraries). A sample COM EXE server fully implemented in managed code (C#) will be explored. I will also present a special IDotNetClassFactory
interface and implementation by which we research into techniques for transforming .NET EXE assemblies into pseudo out-of-proc (or local) COM servers.
The COM Platform
COM is a truly excellent programming model for the development of integrating components based on interfaces. Some of the fundamental principles of COM have their roots in Object-Oriented Philosophies. It is a great platform for the realization of Object-Oriented Development and Deployment.
One of COM's major contributions to the world of Windows development is the awareness of the concept of separation of interface from implementation. This awareness has, no doubt, profoundly influenced the way programmers build systems today. An extension of this fundamental concept is the notion of: one interface, multiple implementations. By this, we mean that at runtime, a COM client can choose to instantiate an interface from one of many different concrete implementations. Each concrete implementation can be written in any programming language that supports COM component development, e.g., C++, Visual Basic, Delphi, PowerBuilder, etc.
And now, with .NET/COM-interop, a .NET component can also be deployed as a COM component. This implies that a COM interface implementation can also be developed in a .NET language like C#.
A Short Refresher on IDL
The common practice in COM development is to start the definition of an interface using IDL (Interface Definition Language). On this note, it is important to grasp the intended purpose of IDL and understand how it realizes OO principles. An IDL file is not just another one of Microsoft's proprietary file types. It deserves deeper understanding. In this section, I will explain some of the more important concepts behind the IDL and the coclass
(an IDL keyword), in particular.
An IDL file is what COM provides that allows developers to define language independent object-oriented classes. An IDL file is compiled by the MIDL compiler into a Type Library (.TLB file) which is a binary form of an IDL file meant to be processed by various language compilers (e.g., VB, VC++, Delphi, etc.). The end result of such .TLB processing is that the specific language compiler produces the language-specific constructs (VB classes for VB, C++ classes, various structs, macros, and typedefs for VC++) that represent the coclass defined in the .TLB (and ultimately that which was defined in the originating IDL file).
A coclass is COM's (language independent) way of defining a class (class in the object-oriented sense). Let's take a look at an example coclass definition in an IDL:
coclass MyObject
{
[default] interface IMyObject;
[default, source] dispinterface _IMyObjectEvents;
};
The above code fragment declares a COM class named MyObject
which must implement an interface named IMyObject
and which supports (not implements) the event interface _IMyObjectEvents
.
Ignoring the event interface bit, this is conceptually equivalent to defining a C++ class like this:
class CSomeObject : public ISomeInterface
{
...
...
...
};
where ISomeInterface
is a C++ virtual class.
Referring once again to the MyObject
COM class: once a coclass definition for it has been formalized in an IDL, and a Type Library compiled from it, the onus is on the individual language compiler to read and appropriately interpret this Type Library and then produce whatever code (in the specific compiler's language) necessary for a developer to implement and ultimately produce the binary executable code which can be deemed by COM to be of the coclass MyObject
.
Once an implementation of a COM coclass is built and is available in the system, next comes the question of how to instantiate it. Now, in languages like C++, we can use the CoCreateInstance()
API in which we specify the CLSID
of the coclass as well as the interface from that coclass that we want to use to interact with that coclass. Calling CoCreateInstance()
like this:
CoCreateInstance
(
CLSID_MyObject,
NULL,
CLSCTX_INPROC_SERVER,
IID_IMyObject,
(void**)&m_pIMyObject
);
is conceptually equivalent to the following C++ code:
ISomeInterface* pISomeInterface = NULL;
pISomeInterface = new CSomeObject();
In the first case, we are saying to the COM sub-system that we want to obtain a pointer to an object that implements the IMyObject
interface and we want the coclass CLSID_MyObject
's particular implementation of this interface. In the second case, we are saying that we want to create an instance of a C++ class that implements the interface ISomeInterface
and we are using CSomeObject
as that C++ class.
Do you see the equivalence? A coclass, then, is an object-oriented class in the COM world. The main feature of the coclass is that it is:
- binary in nature, and consequently
- programming language-independent.
A Simple COM Interface
We will now begin the actual code study by walking through the definition of a simple COM interface named ISimpleCOMObject
. Using Visual Studio .NET, we create an ATL project named SimpleCOMObject.sln. The complete source codes for this project is included in the downloadable sample codes. Once unzipped, it is contained in the following directory:
<main folder>\SimpleCOMObject\interfaces\SimpleCOMObject
where <main folder> is wherever you have copied the source code zip file to.
This SimpleCOMObject.sln project will not contain any useful implementation code. Its purpose is simply to allow us to automate the creation and maintenance of the ISimpleCOMObject
interface. This is accomplished via the use of the ATL Wizards. The project file folder also serves as the central repository for the storage of various .NET related resources (e.g., Primary Interop Assembly, Strong Name Key Files, etc.). More on these later.
Listed below is a fragment of code taken from SimpleCOMObject.idl showing the ISimpleCOMObject
interface:
[
object,
uuid(9EB07DC7-6807-4104-95FE-AD7672A87BD7),
dual,
nonextensible,
helpstring("ISimpleCOMObject Interface"),
pointer_default(unique)
]
interface ISimpleCOMObject : IDispatch
{
[propget, id(1), helpstring("property LongProperty")]
HRESULT LongProperty([out, retval] LONG* pVal);
[propput, id(1), helpstring("property LongProperty")]
HRESULT LongProperty([in] LONG newVal);
[id(2), helpstring("method Method01")]
HRESULT Method01([in] BSTR strMessage);
};
ISimpleCOMObject
contains a property (LongProperty
) and a method (Method01()
). We stipulate that ISimpleCOMObject
is meant to be implemented by an object that takes a long
value (LongProperty
) and then displays this value when we call Method01()
. The BSTR
parameter "strMessage
" is meant to be a short message which is to be displayed together with the LongProperty
value when Method01()
is called.
Note that we defined ISimpleCOMObject
as a dual interface. Hence, a client application can call its methods by using an IUnknown
(vtable) interface pointer or by using an IDispatch
interface pointer. Although it is possible to define ISimpleCOMObject
as deriving directly from IUnknown
, I have chosen to derive it from IDispatch
in order to ensure that the parameters and return values of its methods be strictly automation-compatible (i.e., the types that can be stored in a VARIANT
structure). This is done to keep things simple.
I will assume that the reader is already well-versed in the implementation of a COM interface using an unmanaged language like C++. It is the implementation in a .NET language like C# that I intend to explain in detail in this article. The use of automation-compatible types will certainly help to keep this process as simple as possible.
Now, although SimpleCOMObject.sln contains no useful implementation of ISimpleCOMObject
, we will nevertheless need to compile it in order to produce two important files:
- A Type Library (SimpleCOMObject.tlb).
- A DLL (SimpleCOMObject.dll).
These will be created in the Debug directory of the project's containing folder. If you have downloaded my sample code, and have not modified anything so far, these files will be stored in the following path:
<main folder>\SimpleCOMObject\interfaces\SimpleCOMObject\Debug
where <main folder> is wherever you have copied the source code zip file to.
The SimpleCOMObject.tlb will be used to produce something known as a Primary Interop Assembly. More on this in the next sub-section. Note that the SimpleCOMObject.dll itself will internally contain the SimpleCOMObject.tlb (embedded as a binary resource). SimpleCOMObject.dll will be registered (by the Visual Studio .NET IDE) as the Type Library containing the binary definitions of the coclasses and interfaces which are defined in SimpleCOMObject.idl.
This Type Library registration is done so that COM will know where to look for type information when it needs to perform marshalling for the interfaces defined in SimpleCOMObject.idl. This form of marshaling is better known as Type Library Marshaling.
The data stored for the SimpleCOMObject.tlb type library in the registry lies in the "HKEY_CLASSES_ROOT\TypeLib\{5830FDB2-10E1-427E-B967-515F4DC05F58}" subkey with "{5830FDB2-10E1-427E-B967-515F4DC05F58}" being the LIBID of the type library:
The default string value for the "\1.0\0\win32" subkey is the full path to SimpleCOMObject.dll. We shall revisit this type library key entry later when we talk about the registration of something known as a Primary Interop Assembly.
The Primary Interop Assembly Creation and Registration
As mentioned previously, the COM Type Library file is COM's language independent way of exposing types which are defined in the originating IDL file. This works great for working in unmanaged language compilers like Visual C++ and Visual Basic, but not for .NET compilers. This may come as a surprise to the reader, but a .NET compiler like Visual C# really does not have any innate capability to process COM Type Libraries.
A .NET compiler only understands .NET metadata as a source of type information. The key to exposing COM type information to the .NET world is by taking a COM Type Library and producing the equivalent .NET metadata for it. This process is accomplished by using the Type Library Importer (TLBIMP.exe) tool.
Now, in a normal Visual Studio .NET project, when we add a reference to a COM component, the Type Library Importer is invoked under the covers to produce a new assembly that contains the COM Type Information in the form of .NET metadata. It is this new assembly, not the type library contained in the COM component, that the project is really referencing.
The assembly that is produced by a Type Library Importer is known as an Interop Assembly. It contains the .NET equivalent definitions of COM types that can be referenced from a .NET language code. Unlike a typical assembly, which contains both metadata and IL (Intermediate Language) code, an interop assembly contains only metadata.
An interop assembly has two general purposes:
- It enables the resolution of method calls and type definitions during compile time.
- It enables the Common Language Runtime to generate a Runtime-Callable Wrapper for the COM component at runtime.
The first purpose is important to us, whereas the second is only relevant if we are instantiating a COM component in managed code. For proper distribution purposes, something known as a Primary Interop Assembly (PIA) is required. A PIA is mostly no different from a non-primary interop assembly, except for the following:
- It is digitally signed by the COM component's author.
- It is marked with a special PIA-specific custom attribute.
Its intended use, however, is significant, and should matter to developers. A PIA is meant to be designated as the single metadata identity for a COM component's type definitions. Let me explain this in greater detail.
In the COM world, a type is identified by a GUID (Globally Unique Identifier). It does not matter if more than one type library contains a definition of a single published type. As far as COM is concerned, they are all referring to the same thing as long as the numbers of the GUID match. A COM type is inseparable from its GUID, nothing more, nothing less.
This is not so for .NET types. A .NET type's identity is associated with its containing assembly. Put another way, a containing assembly forms part of a .NET type's identity. Hence, if more than one assembly contains the definition of a type (even one which originated from the same COM type library), these are all considered separate and unrelated types to .NET. Hence, it matters which assembly a project references when it needs a type definition.
Now, if every client project adds a reference to a COM type library via Visual Studio .NET (thereby causing a new interop assembly to be generated), or uses TLBIMP to manually create an interop assembly, we will end up with multiple and separate definitions of COM types.
As such, the common practice is for a COM Type Library publisher to create and digitally sign a single Primary Interop Assembly which is to be used commonly by all clients.
To create a PIA, the TLBIMP utility is used on a COM Type Library with a /primary flag to produce a Primary Interop Assembly. The primary flag will cause TLBIMP to mark the PIA with a special PIA-specific custom attribute: System.Runtime.InteropServices.PrimaryInteropAssemblyAttribute
.
Thereafter, REGASM is called on the newly created PIA to register more information on it to the registry. A PIA should also be registered into the Global Assembly Cache (GAC) for global sharing among all clients.
Technically, nothing can force a developer to use a PIA. It is a matter of common convention. The presence of PIAs (if they have been properly registered in the system) can be detected by .NET development tools such as Visual Studio .NET and TLBIMP.EXE, and they do react appropriately, e.g.:
- TLBIMP.EXE will print a warning if a user tries to create an Interop assembly for a type library if a PIA for that type library has already been registered in the current computer.
- When a project tries to add a reference to a type library on the "COM" tab of the "Add Reference" dialog box, Visual Studio .NET will use a registered PIA instead of generating a new Interop assembly if such a PIA exists on the current system.
My sample source code includes a batch file CreateAndRegisterPrimaryInteropAssembly.bat that contains the commands that will generate a PIA for SimpleCOMObject.tlb and then register the output interop assembly (i.e., Interop.SimpleCOMObject.dll) into the Registry and to the GAC. In order that the PIA is digitally signed, I have also pre-created a strong name key file SimpleCOMObject.snk to be used by TLBIMP. Note also that in order to register any assembly into the GAC, the assembly must be digitally signed as well.
The contents of CreateAndRegisterPrimaryInteropAssembly.bat is listed below:
echo off
echo Generating Primary Interop Assembly for Debug\SimpleCOMObject.tlb ...
tlbimp Debug\SimpleCOMObject.tlb /out:Interop.SimpleCOMObject.dll
/keyfile:SimpleCOMObject.snk /primary
echo Registering PIA Interop.SimpleCOMObject.dll to the Registry
regasm Interop.SimpleCOMObject.dll
echo Registering Primary Interop Assembly Interop.SimpleCOMObject.dll
into the Global Assembly Cache...
gacutil -i Interop.SimpleCOMObject.dll
After compiling SimpleCOMObject.sln, please call this batch file in a Visual Studio .NET Command Prompt window. This batch file needs to be called at least once after compiling SimpleCOMObject.sln. However, it can be called as many times as necessary in the event that new interfaces are added to the project or existing interfaces are modified.
Note that in the event that CreateAndRegisterPrimaryInteropAssembly.bat is called a second (or more) time, a warning will be issued by TLBIMP informing us that a PIA for the SimpleCOMObject.tlb type library has already been registered. Nevertheless, TLBIMP will continue to produce a new PIA. This is illustrated below:
Note that once a primary interop assembly has been registered into the Registry, a special string entry named "PrimaryInteropAssemblyName" will be created under the registry key of the original type library (for which we have invoked TLBIMP). The string value contains basic information on the registered PIA:
The generation and GAC registration of a PIA for SimpleCOMObject.tlb will allow us to later develop a C# implementation of ISimpleCOMObject
. I mentioned in the Introduction section that we will also provide an implementation in C++. We will, hence, briefly walk through our C++ implementation of ISimpleCOMObject
before exploring our C# implementation. This is described in the next section.
In-Proc Server (DLL) Implementations
The C++ Implementation
The complete source code for the C++ implementation is included in the zip file. Once unzipped, it will be stored in the following folder:
<main folder>\SimpleCOMObject\implementations\CPP\SimpleCOMObject_CPPImpl
where <main folder> is wherever you have copied the zip file to. The sub-sections that follow provide brief summaries on the important aspects of this project.
SimpleCOMObject_CPPImpl.sln is an ATL Project
ATL (Active Template Library) is a great tool for developing COM components in C++. We saw earlier that SimpleCOMObject.sln was also developed using ATL. Note that our SimpleCOMObject_CPPImpl project is a non-attributed ATL project.
Coclass SimpleCOMObject_CPPImpl
The SimpleCOMObject_CPPImpl.idl file contains the following coclass definition:
[
uuid(5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B),
helpstring("SimpleCOMObject_CPPImpl Class")
]
coclass SimpleCOMObject_CPPImpl
{
[default] interface ISimpleCOMObject;
interface ISimpleCOMObject_CPPImpl;
[default, source] dispinterface _ISimpleCOMObject_CPPImplEvents;
};
This is a declaration of a COM class (with CLSID {5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}) that will contain an implementation of the ISimpleCOMObject
interface. This coclass (with its unique CLSID) is what COM recognizes. The specific programming language used to develop such a COM class is not important as far as COM is concerned.
At runtime, something known as a Class Factory (for a particular coclass) performs the act of dynamically creating the required object by instantiating it from the specific class construct relevant to the implementation programming language. More on this in a later sub-section below.
ISimpleCOMObject is implemented by the CSimpleCOMObject_CPPImpl C++ class
CSimpleCOMObject_CPPImpl
is the name of a C++ class (defined in SimpleCOMObject_CPPImpl_.h) that provides the implementation code for the interface ISimpleCOMObject
.
Take note that it is more accurate to say that the CSimpleCOMObject_CPPImpl
C++ class is an implementation for the coclass SimpleCOMObject_CPPImpl
. And, since the coclass SimpleCOMObject_CPPImpl
has been declared (in the IDL) to implement the interface ISimpleCOMObject
, the CSimpleCOMObject_CPPImpl
C++ class will provide an implementation for the interface ISimpleCOMObject
.
Now, since COM is oblivious to the fact that the C++ class CSimpleCOMObject_CPPImpl
is the implementation for the coclass SimpleCOMObject_CPPImpl
, how does an instance of the C++ class CSimpleCOMObject_CPPImpl
wind up at runtime being used as the implementation codes for the coclass SimpleCOMObject_CPPImpl
?
The answer: the Class Factory for the coclass SimpleCOMObject_CPPImpl
. At runtime, in response to an API call like the following:
CoCreateInstance
(
<SOME_CLSID>,
NULL,
CLSCTX_INPROC_SERVER,
<SOME_IID>,
(void**)&m_pIMyObject
);
The COM sub-system will look up the Registry to discover the DLL (or EXE) module that houses the implementation code of the coclass whose CLSID matches SOME_CLSID
. This is found in one of the following Registry keys:
HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\InprocServer32 (for DLLs)
HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\LocalServer32 (for EXEs)
Either way, once the path to the executable module is discovered, COM loads it, and then seeks to invoke the Class Factory for the coclass whose CLSID is SOME_CLSID
. It is this Class Factory that has specific knowledge on what class construct (class in the context of a specific programming language) to instantiate.
The ProgID for coclass SimpleCOMObject_CPPImpl is listed in SimpleCOMObject_CPPImpl1.rgs
In our client application code, we shall be using ProgIDs to identify specific COM coclass implementations. A ProgID is a human-readable equivalent of a component's CLSID. They can be looked upon as "class names". They are not as universally unique as GUIDs but are sufficient for most purposes. The SimpleCOMObject_CPPImpl1.rgs file contains the definition for the ProgID of the coclass SimpleCOMObject_CPPImpl
(in bold, below):
HKCR
{
SimpleCOMObject_CPPImpl.SimpleCOMObje.1
= s 'SimpleCOMObject_CPPImpl Class'
{
CLSID
= s '{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}'
}
SimpleCOMObject_CPPImpl.SimpleCOMObject
= s 'SimpleCOMObject_CPPImpl Class'
{
CLSID = s '{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}'
CurVer = s 'SimpleCOMObject_CPPImpl.SimpleCOMObject.1'
}
NoRemove CLSID
{
ForceRemove {5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}
= s 'SimpleCOMObject_CPPImpl Class'
{
ProgID = s 'SimpleCOMObject_CPPImpl.SimpleCOMObject.1'
VersionIndependentProgID
= s 'SimpleCOMObject_CPPImpl.SimpleCOMObject'
ForceRemove 'Programmable'
InprocServer32 = s '%MODULE%'
{
val ThreadingModel = s 'Apartment'
}
val AppID = s '%APPID%'
'TypeLib' = s '{881D7F1E-698B-40B9-9347-860B5E00A9AD}'
}
}
}
The ProgID for the coclass SimpleCOMObject_CPPImpl
is "SimpleCOMObject_CPPImpl.SimpleCOMObject
". The COM system, however, only deals with GUIDs, and will load COM coclasses identified by the actual CLSID. Hence, at runtime, a ProgID must be converted to its actual CLSID equivalent. This information is also discoverable in the Registry under the HKEY_CLASSES_ROOT key. Hence, for our coclass SimpleCOMObject_CPPImpl
COM object, the following entry will be found in the Registry:
HKEY_CLASSES_ROOT\SimpleCOMObject_CPPImpl.SimpleCOMObject
and within this Registry key, there will be a CLSID subkey in which the GUID equivalent of the "SimpleCOMObject_CPPImpl.SimpleCOMObject
" ProgID will be recorded:
HKEY_CLASSES_ROOT\SimpleCOMObject_CPPImpl.SimpleCOMObject\CLSID =
{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}
This is illustrated with a screenshot of the Registry setting for the above key:
Now, once the CLSID is discovered, the COM sub-system will proceed to discover the binary executable for the COM component by looking up the following Registry key:
HKEY_CLASSES_ROOT\CLSID\{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}
From here, COM will know that the binary executable is a DLL because the following sub-key will be found:
HKEY_CLASSES_ROOT\CLSID\{5E5F1A4A-3F76-4F07-946D-DF40E7B29C8B}\InprocServer32
The string value for this sub-key will be the path to the DLL:
From this Registry key, we will note that the DLL is SimpleCOMObject_CPPImpl.dll. The full path to this DLL is recorded in the Registry key string value.
The C# Implementation
The complete source code for the C# implementation is included in the zip file. Once unzipped, it will be stored in the following folder:
<main folder>\SimpleCOMObject\implementations\C#\SimpleCOMObject_CSharpImpl
where <main folder> is wherever you have copied the zip file to.
Our aim is to create a typical C# class library that exposes a .NET class that implements the COM-defined ISimpleCOMObject
interface. The sub-sections that follow provide a step-by-step guide on how this project was built.
SimpleCOMObject_CSharpImpl.sln is a C# Class Library Project
SimpleCOMObject_CSharpImpl.sln starts life by being defined as a Class Library Project. To produce a .NET module that can be loaded as a COM in-proc server, the .NET module may be created as a Class Library or as an EXE.
We shall be studying .NET EXEs serving as COM in-proc servers later on in this article. However, most .NET modules which are used for COM/.NET interop are typically Class Libraries, and this is how we will implement SimpleCOMObject_CSharpImpl.sln.
The main source file in the SimpleCOMObject_CSharpImpl.sln project is SimpleCOMObject.cs. This is where the namespace SimpleCOMObject_CSharpImpl
and the C# class SimpleCOMObject
are defined:
namespace SimpleCOMObject_CSharpImpl
{
public class SimpleCOMObject : ISimpleCOMObject
{
...
...
...
}
}
The C# class SimpleCOMObject_CSharpImpl.SimpleCOMObject
provides the implementation of the ISimpleCOMObject
interface. Note that there is a twist here: this ISimpleCOMObject
interface is not the same as the ISimpleCOMObject
interface exposed by the SimpleCOMObject.tlb type library. Its full name is actually Interop.SimpleCOMObject.ISimpleCOMObject
. This is because it belongs to the Interop.SimpleCOMObject
namespace which is referenced from the Interop.SimpleCOMObject.dll Primary Interop Assembly. We shall explore this further in the next sub-section below.
SimpleCOMObject_CSharpImpl.sln references the Interop.SimpleCOMObject.dll PIA
The SimpleCOMObject_CSharpImpl.sln project references the Interop.SimpleCOMObject.dll PIA. This can be seen in the Solution Explorer:
To reference the PIA, we first invoke the "Add Reference" dialog box, and then select the "COM" tab. Thereafter, we can search for the appropriate Type Library whose description is listed under the "Component Name" column:
An alternative is to use the "Browse" button to help us directly navigate to SimpleCOMObject.tlb or SimpleCOMObject.dll. Either way, note that it is the PIA (registered in the GAC) that is referenced (see the property value for Path).
By referencing the Interop.SimpleCOMObject.dll Primary Interop Assembly, SimpleCOMObject_CSharpImpl
is able to access and resolve type definitions which originate from the SimpleCOMObject.tlb type library. This way, the C# class SimpleCOMObject_CSharpImpl.SimpleCOMObject
is able to inherit the Interop.SimpleCOMObject.ISimpleCOMObject
interface and implement its required properties and methods. These implementations are listed below:
public int LongProperty
{
get
{
return m_iLongProperty;
}
set
{
m_iLongProperty = value;
}
}
public void Method01 (String strMessage)
{
StringBuilder sb = new StringBuilder(strMessage);
sb.Append(LongProperty.ToString());
MessageBox.Show(sb.ToString(),
"SimpleCOMObject_CSharpImpl.SimpleCOMObject");
}
Although the interface Interop.SimpleCOMObject.ISimpleCOMObject
is a full-fledged .NET type, it is indelibly linked to the original COM interface ISimpleCOMObject
. This is because the Interop.SimpleCOMObject.dll interop assembly contains the GUID of the original COM interface ISimpleCOMObject
.
In fact, at runtime, the metadata contained inside the Interop.SimpleCOMObject.dll interop assembly is required by a process known as Interop Marshaling. Let me explain this in greater detail below.
Although the types defined inside the SimpleCOMObject.tlb COM type library and the Interop.SimpleCOMObject.dll interop assembly are logically equivalent, they are not directly interchangeable. When a method call is made from unmanaged code to managed code, data passed in and out via parameters will need to be converted from one representation to another. This describes the general process of marshaling. The process of marshaling data across unmanaged and managed boundaries is specifically known as Interop Marshaling.
Hence, at runtime, the Interop.SimpleCOMObject.dll module will be required and must be available. One way to ensure this is to register it into the GAC and we have done this already. Another step to take for any project that will reference Interop.SimpleCOMObject.dll is to ensure that the "Copy Local" property of the reference to the Interop.SimpleCOMObject.dll is set to "False" (in the diagram above, the setting is underlined in red). This will ensure that at runtime, the output executable (e.g., SimpleCOMObject_CSharpImpl.dll) will load the shared resource Interop.SimpleCOMObject.dll instead of loading its own copy.
The SimpleCOMObject_CSharpImpl.dll is registered to the Registry and to the GAC
The SimpleCOMObject_CSharpImpl.sln project will produce a SimpleCOMObject_CSharpImpl.dll module. This is fundamentally a .NET assembly and not a COM component DLL. Again, it cannot be directly loaded and used by a COM client application. To bridge the gap, the Microsoft .NET Runtime Execution Engine (mscoree.dll) plays the role of go-between.
We shall discuss this in greater detail below. For now, it suffices to say that mscoree.dll provides, to COM client applications, an adequate front-end interface or a proxy to .NET components that implement COM interfaces. In the following sub-sections, we will discuss .NET's runtime relations with COM in order to understand the extra steps that we must take in order to completely transform a .NET component into a usable COM object.
.NET supports the common COM protocols
In order for .NET components to be used transparently by COM client applications, all the known COM protocols must remain in force. Microsoft has achieved this excellently. Whatever information COM requires in order to create a .NET object, which COM thinks is a plain COM object, must be available. The method(s) with which COM acquires these information must remain unchanged.
Now, recall from our discussion in the section "The C++ Implementation" that when a COM client application wants to create an instance of a COM coclass, it must supply the following:
- The CLSID or the ProgID of the coclass.
- The IID of the interface of the coclass from which to invoke properties and methods.
To accommodate this protocol, .NET components must possess their own CLSIDs. The IID of any interface defined in the originating COM Type Library will remain the same, of course.
The C# class SimpleCOMObject_CSharpImpl.SimpleCOMObject has its own CLSID
As mentioned previously in the section "The C++ Implementation", when COM needs to load a coclass of CLSID SOME_CLSID
, it looks up the Registry to discover the DLL (or EXE) module that houses the implementation code of the coclass. The full path to the DLL or EXE is recorded in one of the following Registry keys:
HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\InprocServer32 (for DLLs)
HKEY_CLASSES_ROOT\CLSID\<SOME_CLSID>\LocalServer32 (for EXEs)
This same basic mechanism of discovery extends to .NET components as well. The .NET Framework blends in seamlessly into this protocol, and does not invent any new lookup methods.
Now, to generate a CLSID for your .NET class, there are two methods:
- Have Visual Studio .NET automatically generate one for you.
- Via the
GuidAttribute
.
Automatic Generation
By default, Visual Studio .NET can automatically generate all required GUIDs (e.g., LIBIDs, CLSIDs, and IIDs) for a component. With the exception of IIDs, these GUIDs are generated mostly based on an assembly's identity (i.e., its name, public key, and version).
IIDs (Interface IDs) originating from a COM Type Library and imported into an Interop assembly need not be re-generated. They remain the same and will be re-used. This is logical because COM clients will expect the same IID numbers when they ask for a specific interface from a COM object.
The CLSID of a COM class is rightfully associated only with the class itself. Every coclass will have a unique one. This applies also to .NET classes which are exposed as COM classes. The CLSID for a .NET class, when generated automatically, is based on a hash of the fully qualified class name and the identity of the assembly containing the class. The identity of an assembly comprises the assembly's name, public key, version, and culture, but culture is not used in the generation of the CLSID.
GuidAttribute Generation
Instead of having Visual Studio .NET generate a CLSID for your .NET class, you can manually set this value via the GuidAttribute
. You would need to include the System.Runtime.InteropServices
namespace. The following is an example of how we can generate this for our C# class SimpleCOMObject_CSharpImpl.SimpleCOMObject
:
using System.Runtime.InteropServices;
namespace SimpleCOMObject_CSharpImpl
{
[Guid("62D910F8-1BBA-4c16-8C86-445F9A7AD007")]
public class SimpleCOMObject : ISimpleCOMObject
{
...
...
...
}
}
I have included the (commented out) use of this attribute in the attached sample code for this article, for the reader to experiment with.
The ProgID of the SimpleCOMObject_CSharpImpl.SimpleCOMObject class
The ProgID of a .NET class which is to be wrapped into a COM-callable coclass is, by default, its fully qualified name. This will be evident when we explore how the .NET system registers its classes into the Registry, in the next section. The C# class SimpleCOMObject_CSharpImpl.SimpleCOMObject
's ProgID is simply "SimpleCOMObject_CSharpImpl.SimpleCOMObject
".
The SimpleCOMObject_CSharpImpl module needs to be registered
Just like a normal COM DLL or EXE, a .NET module which has been wrapped as a COM module needs to have its information written into the Registry in order for the COM sub-system to locate it and load it into memory for a client.
This is achieved via the REGASM.EXE utility. REGASM performs similarly to the well-known REGSVR32.EXE utility which is used to register COM modules. REGASM uses the metadata contained inside a .NET assembly to generate COM-equivalent information which is then used to insert entries into the Registry. We will call regasm.exe for our C# component SimpleCOMObject_CSharpImpl.dll, as follows:
regasm .\bin\Debug\SimpleCOMObject_CSharpImpl.dll /tlb
This is assuming that we are calling from the project directory (hence the ".\bin\Debug" subdirectory specification). The /tlb flag will indicate to regasm.exe to produce a Type Library for SimpleCOMObject_CSharpImpl.dll. Although this type library is not used in our sample code, it remains useful should an unmanaged client application wish to import it.
The entries written into the Registry include the CLSIDs and ProgIDs of .NET classes which are exposed as COM classes. This registration process is important for COM clients in the discovery and loading process.
Let us now examine some of the important entries that REGASM writes into the registry for our C# class SimpleCOMObject_CSharpImpl.SimpleCOMObject
:
[HKEY_CLASSES_ROOT\CLSID\{8E8F26E5-D9FA-3ADC-9F0D-2A70C831E547}]
@="SimpleCOMObject_CSharpImpl.SimpleCOMObject"
The CLSID generated for SimpleCOMObject_CSharpImpl.SimpleCOMObject
has its own entry under the HKEY_CLASSES_ROOT\CLSID key. From here, we can tell that the CLSID generated is "8E8F26E5-D9FA-3ADC-9F0D-2A70C831E547". The default string value of "SimpleCOMObject_CSharpImpl.SimpleCOMObject
" is the C# class' COM ProgID.
[HKEY_CLASSES_ROOT\CLSID\{8E8F26E5-D9FA-3ADC-9F0D-2A70C831E547}\
InprocServer32]
@="mscoree.dll"
"ThreadingModel"="Both"
"Class"="SimpleCOMObject_CSharpImpl.SimpleCOMObject"
"Assembly"="SimpleCOMObject_CSharpImpl, Version=1.0.0.0,
Culture=neutral, PublicKeyToken=8aacfb3a580209ca"
"RuntimeVersion"="v1.1.4322"
The InprocServer32 key contains by far the most important and interesting set of values. Let's examine this in detail:
- The default string value
This is set to the unintuitive value of "mscoree.dll". This is the Microsoft .NET Runtime Execution Engine. Why is this DLL set as the in-proc server for SimpleCOMObject_CSharpImpl.SimpleCOMObject
? As mentioned earlier in this section, it acts as the "go-between" between a COM client and the .NET CLR which performs the loading of the SimpleCOMObject_CSharpImpl.SimpleCOMObject
.NET class object. Recall that our C# class cannot be used directly by an unmanaged client application.
The Runtime Engine mscoree.dll will use the other string values of the InprocServer32 key (especially the "Assembly" and "Class" string values) to internally load the appropriate assembly and thereafter the appropriate class to instantiate.
- The "Assembly" string value
The corresponding string value for "Assembly" contains the complete identity of the assembly which contains the C# class "SimpleCOMObject_CSharpImpl.SimpleCOMObject
". Note that no path information is written, only the name of the assembly. Hence, at runtime, the usual rules for locating assemblies will apply.
For convenience of access by multiple client applications, I have opted to register the output SimpleCOMObject_CSharpImpl.dll module into the Global Assembly Cache (GAC). In order that a module be registered to the GAC, it needs to be digitally signed. This requires a Strong Name Key (SNK) file. I have pre-created this SNK file KeyFile.snk.
Another step to take is to set the path of the SNK file in the AssemblyKeyFile
attribute in the AssemblyInfo.cs file:
[assembly: AssemblyKeyFile("..\\..\\KeyFile.snk")]
To register an assembly into the GAC, we use the GACUTIL.EXE utility.
- The "Class" string value
The string value for "Class" indicates the name of the C# class associated with the CLSID. This is unsurprisingly set to "SimpleCOMObject_CSharpImpl.SimpleCOMObject".
- The "ThreadingModel" value
The string value for "ThreadingModel" is "Both". This means that our component is designated as being able to survive as an STA (single-threaded apartment) or MTA (multi-threaded apartment) object. My anecdotal observations led me to believe that the .NET runtime has probably, by default, put in internal locking mechanisms that ensured that each .NET object is always accessed one method or property at a time, even when it is living inside an MTA. Once this is achieved, the object can just as well live inside an STA.
I have combined the two steps of COM Registration and GAC Registration into a single batch file RegisterAssemblyToRegistryAndGAC.bat:
echo off
echo Registering Assembly SimpleCOMObject_CSharpImpl.dll to the Registry...
regasm .\bin\Debug\SimpleCOMObject_CSharpImpl.dll /tlb
echo Install Assembly SimpleCOMObject_CSharpImpl.dll into
echo the Global Assembly Cache...
gacutil -i .\bin\Debug\SimpleCOMObject_CSharpImpl.dll
Always run this batch file after compiling SimpleCOMObject_CSharpImpl.sln.
The Client Applications
I have written two sample client applications which will put to test the concepts discussed so far. One client app is written in Visual Basic 6.0, and the other in Visual C++ 7.0. The sub-sections that follow give brief outlines of these applications.
The Visual Basic 6.0 Client Application
The VB6 code is located in the folder <main folder>\SimpleCOMObject\clients\VB\VBClient01, where <main folder> is wherever you have copied the source code zip file to. It contains a simple Form named FormMain
in which two objects are dimmed:
Dim SimpleCOMObject_CPPImpl As SimpleCOMObject
Dim SimpleCOMObject_CSharpImpl As SimpleCOMObject
The type SimpleCOMObject
is referenced from the Type Library contained in SimpleCOMObject.dll which is compiled from the interface ATL project SimpleCOMObject.sln. We included such a reference via the References dialog box which can be invoked from the Project|References... menu. Note well that we only referred to the Type Library contained in SimpleCOMObject.dll and no other.
There was no need for us to refer to the type libraries of the implementation DLLs that we know we will be loading. At runtime, we will bind to the actual implementation DLLs by way of the CreateObject()
VB API. Using this VB API, we can choose the actual implementation to instantiate by specifying the appropriate ProgID. More on this shortly.
FormMain
also defines a simple edit box (Text_LongPropertyValue
) and a button (Command_Invoke
):
The Text_LongPropertyValue
edit box is for users to enter a long numerical value. The Command_Invoke
button is to invoke the methods and properties of the actual SimpleCOMObject
implementations that we will bind to (i.e., SimpleCOMObject_CPPImpl
and SimpleCOMObject_CSharpImpl
).
When FormMain
is loaded, I initialize the Text_LongPropertyValue
edit box and then instantiate the two SimpleCOMObject
objects:
Private Sub Form_Load()
Text_LongPropertyValue = "0"
Set SimpleCOMObject_CPPImpl _
= CreateObject("SimpleCOMObject_CPPImpl.SimpleCOMObject")
Set SimpleCOMObject_CSharpImpl _
= CreateObject("SimpleCOMObject_CSharpImpl.SimpleCOMObject")
End Sub
Note the power and elegance of the CreateObject()
VB API. It instantiates a COM coclass whose ProgID is the parameter, and then returns an interface pointer whose IID matches that of the Left-Hand-Side object.
Note also that when we instantiate the coclass which is associated with the SimpleCOMObject_CSharpImpl.SimpleCOMObject
ProgID, the .NET runtime engine is loaded together with the SimpleCOMObject_CSharpImpl.dll module which resides in the GAC. All these are done transparently via COM/.NET interoperation.
The real taste of the pudding is in the eating, and we must prove the functionality of this COM/.NET interoperation by invoking the properties and methods of our two objects, SimpleCOMObject_CSharpImpl
in particular. This is done in Sub Command_Invoke_Click()
when the Command_Invoke
button is clicked:
Private Sub Command_Invoke_Click()
SimpleCOMObject_CPPImpl.LongProperty = Val(Text_LongPropertyValue)
SimpleCOMObject_CPPImpl.Method01 _
"From CPP Impl. The Long Property Value is : "
SimpleCOMObject_CSharpImpl.LongProperty = _
Val(Text_LongPropertyValue)
SimpleCOMObject_CSharpImpl.Method01 _
"From CSharp Impl. The Long Property Value is : "
End Sub
Each SimpleCOMObject
instance (SimpleCOMObject_CPPImpl
and SimpleCOMObject_CSharpImpl
) is assigned a value for its LongProperty
. This value is taken from the Text_LongPropertyValue
edit box. Let's say this value is "1001". Each instance's Method1()
is then invoked. At runtime, two message boxes should appear one after the other:
The second one is actually invoked from the C# code.
The Visual C++ 7.0 client application
The Visual C++ 7.0 client application is located in the folder <main folder>\SimpleCOMObject\clients\CPP\CPPClient01, where <main folder> is wherever you have copied the source code zip file to. It is a simple console application. It imports the SimpleCOMObject.tlb type library. In the CPPClient01.cpp source file, I included the following statement to import the type library:
#import "SimpleCOMObject.tlb"
This type library is stored in the relative path "..\..\..\interfaces\SimpleCOMObject\Debug". I have set this relative path in the "Additional Include Directories" setting in the project properties. Note that, just like in the VB client app, we do not need to import any other Type Library. The definitions contained in SimpleCOMObject.tlb is sufficient for our purposes. At runtime, we choose the actual implementation to instantiate by specifying the appropriate ProgIDs.
I have written a templated function CreateInstance()
as a helper function to ease the process of instantiating a COM coclass by way of a ProgID:
template <class SmartPtrClass>
bool CreateInstance
(
LPCTSTR lpszProgID,
SmartPtrClass& spSmartPtrReceiver,
DWORD dwClsContext = CLSCTX_ALL
);
A full documentation of this function will be provided later on below. For now, just be aware that the third parameter dwClsContext
has a default value of CLSCTX_ALL
. We shall be using this default value most of the time, except in a later section where we will experiment with using CLSCTX_LOCAL_SERVER
. Using CLSCTX_ALL
will ensure that the appropriate server type is used.
The _tmain()
function is where all the action starts. Similar to the Visual Basic client application, I have defined two smart pointer objects to represent two pointers to the ISimpleCOMObject
interface:
ISimpleCOMObjectPtr spISimpleCOMObject_CPPImpl = NULL;
ISimpleCOMObjectPtr spISimpleCOMObject_CSharpImpl = NULL;
Our aim is analogous with the one in the VB client app: i.e., to instantiate two objects which expose the ISimpleCOMObject
interface, one implemented in C++, and the other in C#.
The ISimpleCOMObject
interface pointers contained inside the two smart pointer objects are now instantiated via our template function CreateInstance()
:
CreateInstance<ISimpleCOMObjectPtr>
(
"SimpleCOMObject_CPPImpl.SimpleCOMObject",
spISimpleCOMObject_CPPImpl
);
CreateInstance<ISimpleCOMObjectPtr>
(
"SimpleCOMObject_CSharpImpl.SimpleCOMObject",
spISimpleCOMObject_CSharpImpl
);
Note that we have opted to use the default CLSCTX_ALL
values for the third parameter to the two calls to CreateInstance()
. This will cause the COM system to use whatever server types are deemed appropriate for the intended coclasses (the CLSIDs of which are associated with the ProgIDs SimpleCOMObject_CPPImpl.SimpleCOMObject
and SimpleCOMObject_CSharpImpl.SimpleCOMObject
, respectively).
Now, we know from discussions in a previous section that the server for the first ProgID has been designated as an in-proc-server and is SimpleCOMObject_CPPImpl.dll. The full path to this DLL is recorded in the Registry setting for the appropriate CLSID. Hence, SimpleCOMObject_CPPImpl.dll will be loaded into memory.
We also know that the server for the second ProgID is also an in-proc-server, and it is actually mscoree.dll which is the .NET Runtime Engine. It will load the .NET Class Library SimpleCOMObject_CSharpImpl.dll and proceed to instantiate the SimpleCOMObject_CSharpImpl.SimpleCOMObject
class.
Thereafter, spISimpleCOMObject_CPPImpl
and spISimpleCOMObject_CSharpImpl
can be used to invoke the properties and methods of the ISimpleCOMObject
interface:
spISimpleCOMObject_CPPImpl -> put_LongProperty(1000);
spISimpleCOMObject_CPPImpl -> Method01
(
_bstr_t("CPP Implementation. The Long Property Value Is : ")
);
spISimpleCOMObject_CSharpImpl -> put_LongProperty(1000);
spISimpleCOMObject_CSharpImpl -> Method01
(
_bstr_t("C# Implementation. The Long Property Value Is : ")
);
We have set the value 1000 as the LongProperty
value of both implementations of the ISimpleCOMObject
interface. At runtime, two dialog boxes similar to those we have seen in the VB client app will be displayed:
The idea of single interface, multiple implementations advocates the possibility that each object behind the interface pointers may differ according to how each was actually implemented. In the case of our sample app, the most significant difference lies in the fact that the object behind spISimpleCOMObject_CSharpImpl
is actually a .NET component written in C#.
The CreateInstance() function
The full source code for this function is listed below:
template <class SmartPtrClass>
bool CreateInstance
(
LPCTSTR lpszProgID,
SmartPtrClass& spSmartPtrReceiver,
DWORD dwClsContext = CLSCTX_ALL
)
{
HRESULT hrRetTemp = S_OK;
_bstr_t bstProgID(lpszProgID);
CLSID clsid;
bool bRet = false;
hrRetTemp = CLSIDFromProgID
(
(LPCOLESTR)bstProgID,
(LPCLSID)&clsid
);
if (hrRetTemp == S_OK)
{
if
(
SUCCEEDED
(
spSmartPtrReceiver.CreateInstance(clsid, NULL, dwClsContext)
)
)
{
bRet = true;
}
else
{
bRet = false;
}
}
return bRet;
}
The parameter for the template (class SmartPtrClass
) is meant to indicate the specific smart pointer class which is to be initialized with an instantiation of a coclass. The class of the smart pointer is important because it indicates the specific interface type that its internal interface pointer will point to. The parameters to the function are:
- The intended ProgID string (first parameter
lpszProgID
).
The ProgID indicates the CLSID of the coclass to instantiate.
- A
_com_ptr_t
smart pointer object reference (second parameter spSmartPtrReceiver
).
After instantiating the coclass, the resultant interface pointer of the coclass will be stored in this smart pointer object.
- A
DWORD
indicating the type of COM server to use (third parameter dwClsContext
).
This value can be used to specify the type of server to be used to supply an instance of and manage the target COM object. The most common constant values for this are:
CLSCTX_INPROC_SERVER
This means that the object's server is a DLL that runs in the same process as the client application.
CLSCTX_INPROC_HANDLER
This means that the object's server is an in-process handler, i.e., a DLL that runs in the client process and implements client-side structures for the object while the actual code for the object resides in a remote machine.
CLSCTX_LOCAL_SERVER
This means that the object's server is an EXE which runs on the same machine and is loaded in a separate process space.
The actual value used can be any combination of the above values (and others not listed above) which indicates to COM that we want it to decide for us the best server type to use. The constant CLSCTX_ALL
(the default value) is defined as the combination of all three.
The CreateInstance() function first uses the CLSIDFromProgID()
API to translate the input ProgID to a CLSID. If this is successful, we call the CreateInstance()
method of the smart pointer class (which is actually an instantiation of the _com_ptr_t
templated class), passing the newly discovered CLSID value and the input CLSCTX value. This method will internally call the CoCreateInstance()
API to perform the actual creation process. If the creation process is successful, we return a value of true
. For all other cases, we return false
.
Out-of-Proc (EXE) Implementation
It now gives me great pleasure to present in this section the techniques of instantiating a COM interface from a C# class defined in a .NET EXE. This is the result of research work done by myself and by collaboration with a fellow CodeProject member mav.northwind. The focus of this research work centers around determining whether we can actually create out-of-proc COM servers in .NET as well as instantiate objects from static (non-running) .NET EXE assemblies.
In this section, we shall explore the various methods I know of instantiating a .NET class (contained inside a .NET EXE assembly) and then delivering it to an unmanaged client application. Let us now establish what this exactly means. It could mean one of three things:
- Using standard COM/.NET interop techniques to instantiate a COM "coclass" which is housed within a .NET EXE assembly.
- Loading a type defined in a static .NET EXE file, instantiating it within managed code, and then marshaling it to an unmanaged client as a COM object.
- Instantiating a .NET object from a running .NET EXE, marshaling a proxy to it to managed code, and then marshaling this proxy to an unmanaged client as a COM object.
Each of the above concepts is useful, and each require a specific technique to accomplish. In the sub-sections that follow, we will study each concept carefully with the help of sample code.
Standard COM/.NET Interop
It may come as a surprise to some readers, but it is indeed possible to use standard COM/.NET interop techniques to instantiate a .NET class which is defined inside a .NET EXE assembly. However, there is a catch: the EXE assembly is neither executed, nor will a running instance of it be used to instantiate the required .NET class.
Instead, the EXE assembly is loaded just like a .NET Class Library DLL into the address space of the client application, and the target class is then instantiated by the .NET Runtime. This is reminiscent of the way that we previously exposed an interface implementation by a .NET class as an in-proc (DLL) server.
I have included an example of this concept with a set of Visual Studio solutions. These projects are included in this article's source code zip file. Once unzipped, it can be found in the directory, <main folder>\CSharpExeCOMServers\UsingStandardCOMInterop, where <main folder> is wherever you have copied the zip file to. There are two separate projects contained inside this folder:
- .\implementations\SimpleCOMObject_CSharpExeImpl\SimpleCOMObject_CSharpExeImpl.sln
- .\clients\CPP\CPPClient01\CPPClient01.sln
The SimpleCOMObject_CSharpExeImpl.sln solution
This is a .NET console EXE project that contains an implementation of the ISimpleCOMObject
interface (class SimpleCOMObject
) plus a Main()
function that serves as the startup point of the application:
namespace SimpleCOMObject_CSharpExeImpl
{
public class SimpleCOMObject : ISimpleCOMObject
{
...
...
...
}
class SimpleCOMObject_CSharpExeImpl
{
[STAThread]
static void Main(string[] args)
{
int i;
int iCount = args.Length;
for (i = 0; i < iCount; i++)
{
Console.Write ("Argument : ");
Console.WriteLine (args[i]);
}
Console.WriteLine("Press [ENTER] to exit.");
Console.ReadLine();
}
}
}
Note well the following points regarding this solution:
- It references the Interop.SimpleCOMObject interop assembly
Just like the earlier SimpleCOMObject_CSharpImpl project, this solution also references the Interop.SimpleCOMObject interop assembly. This is so that the methods and properties of ISimpleCOMObject
are visible to this project. Therefore, please ensure that the earlier SimpleCOMObject project contained in <main folder>\SimpleCOMObject\interfaces\SimpleCOMObject is first compiled successfully and the CreateAndRegisterPrimaryInteropAssembly.bat batch file invoked.
- It is strong named
This project is strong named. This is so that we can register the output EXE SimpleCOMObject_CSharpExeImpl.exe to the GAC. This is done for convenience of reference by client applications.
- Class SimpleCOMObject must be public
The ISimpleCOMObject
implementation class SimpleCOMObject
must be a public
class. Otherwise, it will not be exposed as a COM object by regasm.exe.
- The Main() function displays the command line arguments
The Main()
function is very simple. It only displays the command line arguments to the program as well as a line prompting the user to press the Enter key in order to terminate the program. The display of the program's command line arguments will show some usefulness later on.
The implementation code of SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
is simple. The only special thing about it is the message box title that it uses in its Method01()
implementation that indicates code executed from the SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
class:
public void Method01 (String strMessage)
{
StringBuilder sb = new StringBuilder(strMessage);
sb.Append(LongProperty.ToString());
MessageBox.Show
(
sb.ToString(),
"SimpleCOMObject_CSharpExeImpl.SimpleCOMObject"
);
}
Once SimpleCOMObject_CSharpExeImpl.sln is compiled successfully, we need to register COM information for it to the Registry as well as register it as a .NET shared resource in the GAC. I have prepared a RegisterAssemblyToRegistryAndGAC.bat batch file that will perform these two tasks. Please invoke this batch file after successfully compiling SimpleCOMObject_CSharpExeImpl.sln.
The following screenshot shows what will be recorded in the Registry:
The CLSID generated for SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
has its own entry under the HKEY_CLASSES_ROOT\CLSID key. The CLSID generated is 3ED697FF-F9EA-3910-AFE6-BCF305572FFB. The default string value of SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
is the C# class' COM ProgID. Note that there is an InprocServer32 subkey as well as a ProdId subkey, but there is no LocalServer32 subkey. This observation will be given some mention again later on.
The COM information recorded in the Registry for SimpleCOMObject_CSharpExeImpl.exe will be used to instantiate the C# class SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
at runtime by a client application. The ProgID is also SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
.
The CPPClient01.sln Solution
This is a C++ console client application that behaves in a very similar way to the C++ client application we saw earlier. The _tmain()
function is very short and simple. It is listed below:
int _tmain(int argc, _TCHAR* argv[])
{
::CoInitialize(NULL);
if (1)
{
ISimpleCOMObjectPtr spISimpleCOMObject_CSharpExeImpl = NULL;
CreateInstance<ISimpleCOMObjectPtr>
(
"SimpleCOMObject_CSharpExeImpl.SimpleCOMObject",
spISimpleCOMObject_CSharpExeImpl,
CLSCTX_INPROC_SERVER
);
if (spISimpleCOMObject_CSharpExeImpl)
{
spISimpleCOMObject_CSharpExeImpl -> put_LongProperty(1000);
spISimpleCOMObject_CSharpExeImpl -> Method01
(_bstr_t("C# Exe Implementation. The Long Property Value Is : "));
}
}
::CoUninitialize();
return 0;
}
The _tmain()
function instantiates a ISimpleCOMObject
interface pointer which is contained inside a ISimpleCOMObjectPtr
smart pointer object spISimpleCOMObject_CSharpExeImpl
. The specific implementation to instantiate is that provided by the COM coclass whose CLSID is synonymous with the ProgID SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
. We know that this COM coclass is implemented by SimpleCOMObject_CSharpExeImpl.exe.
Notice now that the third parameter to CreateInstance()
has been set to a specific value instead of being left to the default of CLSCTX_ALL
. Using the default of CLSCTX_ALL
for COM/.NET interop will result in the CLSCTX value CLSCTX_INPROC_SERVER
being used due to the fact that there is an InprocServer32 subkey contained inside the CLSID key of the coclass to instantiate. Recall that for .NET classes to be transformed into COM objects, this InprocServer32 string value will be mscoree.dll.
Now, as will be seen later on: regardless of whether the implementation .NET assembly is a Class Library DLL or an executable, this assembly is always loaded in-process into the address space of the client application. And, in the case of an EXE assembly, recall our assertion at the beginning of this section that it is neither executed, nor will a running instance of it be used to instantiate the required .NET class.
In our current client application, the parameter to CreateInstance()
is specifically set to CLSCTX_INPROC_SERVER
. Using this constant will ensure success of the CreateInstance()
function. As the program continues, the following message box will be displayed:
Now, using the Process Explorer tool from SysInternals (a great utility that allows us to list out the loaded modules of a running application), we make the following remarkable observations (watch for the items underlined in red):
- The Interop.SimpleCOMObject interop assembly is loaded into the address space of the client.
- The SimpleCOMObject_CSharpExeImpl.exe executable image is loaded into the address space of the client.
The second point is an incredible phenomenon. An EXE file is loaded into the address space of another EXE, which shows the incredible flexibility of .NET.
Now, let us perform an experiment to see how things will turn out if we were to use the CLSCTX_LOCAL_SERVER
constant for the third parameter to CreateInstance()
. Please comment out the use of the CLSCTX_INPROC_SERVER
constant. Then, insert the use of CLSCTX_LOCAL_SERVER
:
CreateInstance<ISimpleCOMObjectPtr>
(
"SimpleCOMObject_CSharpExeImpl.SimpleCOMObject",
spISimpleCOMObject_CSharpExeImpl,
CLSCTX_LOCAL_SERVER
);
When the internal call to the CoCreateInstance()
API is made, a value of 0x80040154 (Class Not Registered) is returned, which results in the failure of the CreateInstance()
function. This error code suggests that our (supposedly) out-of-proc server SimpleCOMObject_CSharpExeImpl.exe has not registered a class factory for our "coclass" SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
.
Note that a proper out-of-proc COM EXE Server will seek to create a class factory object (which implements the IClassFactory
interface) for every coclass that it serves, and then register the class factory object with a call to CoRegisterClassObject()
. It is through class factory objects that a client application eventually obtains object instantiations.
However, without going into wondering how we are going to make a meaningful call to CoRegisterClassObject()
, we must realize that we do not even have a class factory for SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
. Simply put, our SimpleCOMObject_CSharpExeImpl.exe is not a proper COM EXE Server. It does not call CoRegisterClassObject()
on any of its C# classes.
We must also remember that the Registry entry for the CLSID of SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
does not contain any LocalServer32 subkey, which means that even though we requested for an out-of-proc server, COM cannot even determine what the EXE server is to launch for this purpose. Hence, to this day, it remains unclear to me the significance of this return value 0x80040154 (Class Not Registered).
Let us run a little experiment to see what happens if we manually add a LocalServer32 subkey to the Registry key of the CLSID of SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
, as illustrated below:
The string value for this subkey should be the full path to SimpleCOMObject_CSharpExeImpl.exe.
Now, try running CPPClient01.exe again. This time, when we execute the CreateInstance()
function with the third argument being CLSCTX_LOCAL_SERVER
, the SimpleCOMObject_CSharpExeImpl.exe program is actually started up by COM:
Notice that an argument of "-Embedding" is actually passed to the program. We know that this is COM's way of indicating to an out-of-proc EXE server to start life as a COM server (viz. an application program) and as such, its GUI main window should not be visible. The SimpleCOMObject_CSharpExeImpl.exe program, not being a COM out-of-proc EXE server, is not aware of this protocol. It simply starts up as commanded by COM, and remains running.
Deep within the call stack initiated by the CreateInstance()
function, the SimpleCOMObject_CSharpExeImpl.exe program is invoked at the point when the CoCreateInstance()
API is called. CoCreateInstance()
then seems to hang on for a very long time without returning. When it does return, 0x80080005 (Server execution failed) is returned, and SimpleCOMObject_CSharpExeImpl.exe terminates by itself. This return value is in-line with COM standards, since it is logical to assume that after starting up a local EXE server, COM would wait for the class factory of the required coclass to be registered via CoRegisterClassObject()
and thereafter to instantiate the required object from the IClassFactory
interface of this class factory. It is likely that a timeout occurred while COM waited for the availability of the class factory and hence the error return code.
[At this time, please do not forget to delete the "LocalServer32" subkey that we have manually added to the Registry key of the CLSID of SimpleCOMObject_CSharpExeImpl.SimpleCOMObject
.]
It is of little surprise to note the futility of our attempt to use standard COM/.NET interop to exploit a .NET EXE assembly as an out-of-proc COM Server. In fact, any COM .NET assembly (be it an EXE or Class Library DLL) cannot even function as an in-proc COM server if not for the help of mscoree.dll and all the COM-related Registry information.
Some other unorthodox means are required in order for us to simulate the use of a .NET EXE assembly as an out-of-proc COM Server. We shall begin to explore this in the next section.
Activation and Reflection
Besides using typical .NET/COM interop, another way to instantiate a .NET class contained inside a .NET EXE assembly and then delivering it to an unmanaged client is by loading the class type via Activation or Reflection, instantiating it within managed code, and then marshaling it to the unmanaged client as a COM object.
This managed code serves the role of a Class Factory for creating objects from .NET classes and then delivering them to the unmanaged world via standard COM/.NET interop. As usual, I have defined an interface named IDotNetClassFactory
(in C#) for this purpose. We will also study an implementation of such an interface which must be developed in a .NET language in order that it run as managed code.
However, just like the technique using standard COM/.NET interop, the use of Activation and/or Reflection will not cause the target EXE assembly to be executed, nor will a running instance of it be used to instantiate the required .NET class.
The basic idea is simple and is illustrated below:
In the previous discussions on creating .NET objects from Class Library Assemblies and on using standard COM/.NET interop to instantiate objects from .NET EXE assemblies, we used the COM API CoCreateInstance()
as a one-stop function call to object creation. We did not have to care whether the implementation code behind the target object is a true COM object or is a .NET object wrapped inside a COM-Callable Wrapper.
This time, we must note that a new layer is added to our object creation process: a .NET Class Factory must be used to perform the creation of a .NET object. The newly created object is then returned to an unmanaged client application. The .NET Class Factory now goes between the client app and the .NET Runtime Engine (MSCOREE.DLL) which remains the primary COM/.NET interop provider.
The sequence of actions is listed below:
- An unmanaged client application instantiates an
IDotNetClassFactory
interface implementation.
The IDotNetClassFactory
interface will be originally defined in .NET. It will be registered to the Registry and be transformed into a COM interface via regasm.exe. This interface serves as a gateway for unmanaged clients to instantiate .NET objects in general, whether they be from an EXE or a Class Library DLL. There will be at least two methods defined in this interface, one for creating objects by Activation and the other by Reflection.
- The
IDotNetClassFactory
interface implementation is a .NET object.
We will be using managed code to create other managed code, and this will be performed by the IDotNetClassFactory
interface implementation. The IDotNetClassFactory
interface implementation will be a .NET Class Library which will be used in an unmanaged client. Hence, COM/.NET interop will be required. This is where MSCOREE.DLL gets involved.
- The unmanaged client uses the
IDotNetClassFactory
object to instantiate a .NET class in an EXE assembly.
The IDotNetClassFactory
object, being primarily a managed object living in the .NET world, can easily use .NET class library objects to load an EXE assembly and create an instance of a class defined in that EXE assembly. The two creation methods of the IDotNetClassFactory
interface must each take in a path to a target .NET EXE assembly. Internally, each method uses the Activator and Reflection classes, respectively, to perform the creation process.
- The
IDotNetClassFactory
object returns the newly created .NET assembly class object.
The creation methods of the IDotNetClassFactory
object will each return a newly created .NET assembly class object to the unmanaged client inside a COM-callable wrapper. Each creation method's return type must be object
. The created objects are marshaled across to the unmanaged client as COM interface pointers wrapped inside VARIANT
structs.
I have included an example of the above concepts with a set of Visual Studio solutions. These projects are included in this article's source code zip file. However, before we study the source code, we need to cover the next method of instantiating a .NET class contained inside a .NET EXE assembly. This is because the source code for this next technique is part and parcel of the source code for the one we have just discussed above.
.NET Remoting
This time, we will study a solution that provides a close resemblance to COM in terms of activating and connecting with external EXE assemblies. We are talking about .NET Remoting. The basic idea is similar to the one for Activation and Reflection, except that we will connect with a running instance of an assembly.
The sequence of actions is listed below:
- An unmanaged client application instantiates an
IDotNetClassFactory
interface implementation.
An IDotNetClassFactory
interface implementation is to be instantiated by the unmanaged client code. The IDotNetClassFactory
interface will include a separate method to create an instance of a .NET class, specifically by Remoting.
- The creation method includes parameters for Remoting and parameters for EXE invocation.
This "Creation by Remoting" method will include parameters to facilitate .NET Remoting as well as parameters for starting up a new running instance of the target assembly.
- The
IDotNetClassFactory
object may be requested to start the assembly EXE.
The assembly invocation parameters are important in simulating COM's REGCLS_SINGLEUSE
and REGCLS_MULTIPLEUSE
flags. If the EXE assembly is started up once and subsequently not started up anymore by other clients, we simulate REGCLS_MULTIPLEUSE
. If a new EXE running instance is always started up every time the creation method is called, REGCLS_SINGLEUSE
is simulated. Whichever choice is made, the Remoting object's server must be in a running state in order to establish connection with it. More on this later when we study the source code.
- The
IDotNetClassFactory
object registers TCP and HTTP channels to connect with the Remoting object.
The IDotNetClassFactory
object will register TCP and HTTP client channels (without any port numbers). The Remoting object's server must also register TCP and/or HTTP server channels with specific port numbers. The client application must also supply the URL of the Remoting object.
- The
IDotNetClassFactory
object returns the newly created Remoting object.
Once the creation method establishes connection with the Remoting object, it will return a proxy to the object to the unmanaged client inside a COM-callable wrapper. This creation method's return type is also object
. The Remoting proxy is marshaled across to the unmanaged client as a COM interface pointer wrapped inside a VARIANT
struct.
We shall go deeper into studying the relevant source code, beginning from the next section.
The IDotNetClassFactory System
The source code pertaining to the IDotNetClassFactory System is included in the source code zip file accompanying this article. Once unzipped, it can be found in the directory, <main folder>\CSharpExeCOMServers\IDotNetClassFactory, where <main folder> is wherever you have copied the zip file to. The sub-sections that follow will discuss each of the separate projects contained inside this folder:
IDotNetClassFactory
This project is contained inside the directory, <main folder>\CSharpExeCOMServers\ IDotNetClassFactory\interfaces\IDotNetClassFactory.
It is another one of those "empty" projects meant to maintain an interface, this time: the IDotNetClassFactory
interface. This interface is listed below:
public interface IDotNetClassFactory
{
object CreateInstance_ByActivation
(
string strAssemblyName,
string strTypeName
);
object CreateInstance_ByReflection
(
string strAssemblyName,
string strTypeName
);
object CreateInstance_ByRemoting
(
string strAssemblyName,
string strTypeName,
string strURL,
bool bExecute,
string strAssemblyFullPath,
string strArgumentString
);
}
There are three methods defined in this interface. Their functionalities should be self-explanatory. We have discussed these earlier. The following are some comments I have concerning these methods:
- Equivalence with COM APIs
There are equivalents between these methods and with COM's CoCreateInstance()
API and with our templated CreateInstance()
function. There is the persistence of the concept of selecting the specific COM class to instantiate (recall ProgID and CLSID). This is fulfilled by the use of the assembly name (strAssemblyName
) and the type name (strTypeName
) parameters.
Recall from earlier discussions that a .NET type is identified by its name as well as its containing assembly. Hence, we can consider the use of these two parameters as the key to selecting the appropriate .NET class to instantiate.
- Shortcoming
The shortcoming of these methods is the fact that the returned interface pointer is either an IUnknown
or IDispatch
interface pointer (depending on the definitions and attributes of the .NET class). We have to specifically QueryInterface()
it to the required interface. This has to be done manually by the client application, whereas CoCreateInstance()
will perform this on behalf of its caller.
- The common parameter
strAssemblyName
of all three methods
The common parameters of all three methods are strAssemblyName
and strTypeName
. Note that the strAssemblyName
parameter for the CreateInstance_ByActivation()
and CreateInstance_ByReflection()
methods must be paths to the EXE assembly to load. However, the strAssemblyName
parameter for the CreateInstance_ByRemoting()
method is simply an assembly name (without the .exe extension). We shall explore this difference later on.
- The special parameters of the
CreateInstance_ByRemoting()
method
The CreateInstance_ByRemoting()
method takes an additional four parameters. The strURL
parameter is used to hold the URL string that uniquely identifies a .NET class type which is exposed by a Remoting Server. The bExecute
parameter is specified to indicate to an IDotNetClassFactory
interface implementation whether to launch a target EXE assembly. The strAssemblyFullPath
parameter indicates the full path to the .NET EXE assembly to launch (if bExecute
is true
), and the strArgumentString
parameter is the argument to pass to the assembly to be launched.
Note well our previous point about the first parameter strAssemblyName
, that unlike the other two creation methods, it must contain just the pure name (without any extension) of the assembly which contains the class type to be instantiated. This is why we have to supply the strAssemblyFullPath
parameter because we cannot use strAssemblyName
to help us launch the target assembly.
The IDotNetClassFactory project is also strong named, and should be registered to the Registry (with COM-related information) as well as inserted into the GAC. I have prepared a batch file RegisterAssemblyToRegistryAndGAC.bat (contained in the project folder) that will perform this. Please run this batch file after successfully compiling the project.
The Registry registration process is done by the regasm.exe tool which will also help to produce a .TLB (type library) file that can be imported by a client application for definition of the COM interpretation of the IDotNetClassFactory
interface. The GAC registration is necessary because the output IDotNetClassFactory class library will become a dependency assembly at runtime, which makes it important that we ensure that it is a shared resource.
IDotNetClassFactory_Impl01
This project is contained inside the directory, <main folder>\CSharpExeCOMServers\ IDotNetClassFactory\implementations\IDotNetClassFactory_Impl01.
This project is a Class Library project. It provides a class named IDotNetClassFactory_Impl01
which is an implementation of the IDotNetClassFactory
interface. Let's have a look at it:
public object CreateInstance_ByActivation
(
string strAssemblyName,
string strTypeName
)
{
ObjectHandle object_handle = null;
object objRet = null;
object_handle = Activator.CreateInstanceFrom
(
strAssemblyName,
strTypeName
);
objRet = (object)(object_handle.Unwrap());
return objRet;
}
The above lists the complete source code of the CreateInstance_ByActivation()
method. As you can see, it is very simple. We simply use the static method CreateInstanceFrom()
of the Activator
class to create an instance of the required type from the target assembly file. The returned ObjectHandle
value is then unwrapped to transform it into an object
type entity which is then returned.
public object CreateInstance_ByReflection
(
string strAssemblyName,
string strTypeName
)
{
Assembly assembly;
object objRet = null;
assembly = Assembly.LoadFrom(strAssemblyName);
objRet = assembly.CreateInstance
(
strTypeName
);
return objRet;
}
The above lists the complete source code of the CreateInstance_ByReflection()
method. It is also very simple. The Assembly
class is used to load the target assembly (via the static LoadFrom()
method). The loaded assembly is then represented in an assembly
object (type Assembly
). Thereafter, we create an instance of the target type (strTypeName
) and return it.
public object CreateInstance_ByRemoting
(
string strAssemblyName,
string strTypeName,
string strURL,
bool bExecute,
string strAssemblyFullPath,
string strArgumentString
)
{
UrlAttribute[] attr = { new UrlAttribute(strURL) };
ObjectHandle object_handle = null;
object objRet = null;
if (bExecute)
{
Process.Start
(strAssemblyFullPath, strArgumentString);
}
TcpClientChannel tcp_channel = new TcpClientChannel();
ChannelServices.RegisterChannel(tcp_channel);
HttpClientChannel http_channel = new HttpClientChannel();
ChannelServices.RegisterChannel(http_channel);
object_handle = Activator.CreateInstance
(
strAssemblyName,
strTypeName,
attr
);
objRet = (object)(object_handle.Unwrap());
return objRet;
}
The above lists the complete source code of the CreateInstance_ByRemoting()
method. This method is a little more complicated as compared with the previous two creation methods. Note that IDotNetClassFactory_Impl01
's implementation of CreateInstance_ByRemoting()
specifically deals with Client-Activated Remoting Objects.
A single element UrlAttribute
array object attr
is defined using the passed in strURL
. This attr
object will be used later in the Activator.CreateInstance()
static method.
Some Notes on Client-Activated Remoting Objects
Client-Activated Remoting Objects are genuinely Remote Objects. Remote in the sense that they are instantiated on the external server process and not on the client side. Once created, they are private discrete objects which survive normally until no further reference to them is required by the client, after which the object is destroyed as per normal by the Garbage Collector (through a process known as Leasing Distributed Garbage Collection). Each created object is not shared among clients.
To create a Client-Activated Remote Object, we use a version of the Activator.CreateInstance() method where Activation Attributes can be passed in. Such Activation Attributes are best described by the MSDN documentation as: "an array of one or more attributes that can participate in activation".
This remote object instantiated via Activator.CreateInstance() will remain alive until something known as a lease time is expired (thereby causing garbage collection to occur and hence destruction of the remote object).
Another thing to note about Client-Activated Remote Object creation is the fact that an ObjectHandle is returned from the call to CreateInstance() . An ObjectHandle is best described by the MSDN documentation as ".. a remoted MarshalByRefObject that is tracked by the Remoting lifetime service..."
Finally, the ObjectHandle object returned from the CreateInstance() method must be unwrapped in order to retrieve the contained actual remote object. Actually, the contained remote object is a still proxy of the real remote object created on the external server process.
|
Now, if bExecute
is true
, we use the Process.Start()
static method to launch the target assembly which is to serve as the Remoting Server.
We then instantiate and register the TcpClientChannel
and HttpClientChannel
channels in order to be able to connect to a running Remoting Server which uses either TcpServerChannel
or HttpServerChannel
, or both.
We then use the Activator.CreateInstance()
static method to connect with a Remoting Object Server which exposes an object whose identifier is specified in the strURL
parameter. The strURL
URL string must also specify the channel protocol (TCP or HTTP) and the port number used by the target Remoting Server's TCP or HTTP channel.
If the call to CreateInstance()
is successful, an ObjectHandle
object is returned from which we extract an object
type entity via the ObjectHandle
's Unwrap()
method. The extracted "object
" entity is then returned.
IDotNetClassFactory_Impl01
is strong named, and should be registered into the Registry (with COM information) and inserted into the GAC. I have prepared a batch file RegisterAssemblyToRegistryAndGAC.bat (contained in the project folder) that will perform this. Please run this batch file after successfully compiling the project.
The Registry registration process is not strictly required. This is because our unmanaged client will not be instantiating a IDotNetClassFactory_Impl01
interface implementation. Instead, the client app will be instantiating a IDotNetClassFactory
interface implementation which makes it necessary for the client source to import the IDotNetClassFactory.tlb type library instead.
The GAC registration is necessary because the output IDotNetClassFactory class library will become a dependency assembly at runtime, which makes it important that we ensure that it is a shared resource.
Testing the IDotNetClassFactory System
We shall now put our IDotNetClassFactory System to the test. For this purpose, I have prepared a client application that will make use of the methods of an IDotNetClassFactory
interface implementation. I have also written a test .NET EXE application. This application will be used as a test case.
The Test Case Application
To expedite understanding of the client application, we first study the source code of the test case .NET EXE application. The test case is a collection of Visual Studio .NET projects written in C#. The sub-sections that follow will provide more details:
ITestCSharpObjectInterfaces
This is contained in the directory, <main folder>\CSharpExeCOMServers\ IDotNetClassFactory\testobjects\interface\ITestCSharpObjectInterfaces.
It is an "empty" Class Library project which is used to define the ITestCSharpObjectInterfaces
interface. I have defined an interface so as to consistently follow our principle of binding pure interface references to actual implementations at runtime. That is, our client code must use the methods of the ITestCSharpObjectInterfaces
interface without being explicitly aware of which actual .NET assembly it will be loading at runtime.
This interface is listed below:
public interface ITestCSharpObjectInterfaces
{
string stringProperty
{
get;
set;
}
bool DisplayMessage();
}
I have kept it very simple in order to illustrate principles. This interface defines a stringProperty
string property which can be set/get by a client. The DisplayMessage()
method is used to display the stringProperty
string in a message box.
The ITestCSharpObjectInterfaces
project is also strong named, and will need to be COM registered into the Registry and inserted into the GAC. Our client application will need to import the type library generated for this assembly (i.e., ITestCSharpObjectInterfaces.tlb). The output ITestCSharpObjectInterfaces.dll Class Library Assembly will also need to be registered into the GAC to make it a shared resource, because it may be loaded at runtime by more than one dependent assembly.
ITestCSharpObjectInterfacesImpl01
The ITestCSharpObjectInterfacesImpl01 project is contained in the directory, <main folder>\CSharpExeCOMServers\IDotNetClassFactory\testobjects\ implementations\ITestCSharpObjectInterfacesImpl01.
It is an EXE assembly project which provides an implementation of the ITestCSharpObjectInterfaces
interface. This is served by the TestCSharpObject01
class. A fragment of this class is listed below:
public class TestCSharpObject01 : MarshalByRefObject,
ITestCSharpObjectInterfaces.ITestCSharpObjectInterfaces
{
private string m_stringProperty;
public TestCSharpObject01()
{
m_stringProperty = "";
Console.WriteLine("TestCSharpObject01 constructor.");
}
public string stringProperty
{
get
{
return m_stringProperty;
}
set
{
m_stringProperty = value;
}
}
public bool DisplayMessage ( )
{
if (m_stringProperty != "")
{
MessageBox.Show(m_stringProperty, "TestCSharpObject01");
return true;
}
else
{
return false;
}
}
...
...
...
The TestCSharpObject01
class is designated as a public
class which is derived from MarshalByRefObject
. MarshalByRefObject
is the base class for objects that can be created by a remote client and then transported across application domain boundaries to the remote client. In other words, we have designated the TestCSharpObject01
class as being available for .NET Remoting.
Communication between the remote client and the MarshalByRefObject
is achieved by exchanging messages using a proxy. It is the proxy that makes the object "marshaled by reference". By being "marshaled by reference", the remote server object becomes "stateful" to the client application. The first time a (remote) client application accesses the MarshalByRefObject
, the proxy is passed to the remote client application.
The client uses this proxy to make method calls on the remote object. These method calls and their parameters are actually marshaled across application domain boundaries to the remote server object itself. Much of it is reminiscent of COM's way of invoking remote server function calls.
The TestCSharpObject01
is also derived from the ITestCSharpObjectInterfaces
which makes it an implementor of the ITestCSharpObjectInterfaces
interface.
The implementations are simple and self-explanatory. But, one thing worth mentioning is that the string "TestCSharpObject01 constructor." will be printed on a console output whenever an instance of the TestCSharpObject01
class is created. This feature will help in illustrating some important points later on.
The Main()
function of ITestCSharpObjectInterfacesImpl01
is where the action for .NET Remoting takes place. It is listed below:
static void Main(string[] args)
{
string strChannelType = "TCP";
int iPort = 9000;
if (args.Length > 0)
{
for (int i = 0; i < args.Length; i++)
{
Console.Write("Argument : ");
Console.WriteLine(args[i]);
}
strChannelType = args[0];
iPort = Convert.ToInt32(args[1]);
}
if (strChannelType == "TCP")
{
TcpServerChannel tcp_channel = new TcpServerChannel(iPort);
ChannelServices.RegisterChannel(tcp_channel);
}
else
{
HttpServerChannel http_channel = new HttpServerChannel(iPort);
ChannelServices.RegisterChannel(http_channel);
}
ActivatedServiceTypeEntry remObj =
new ActivatedServiceTypeEntry(typeof(TestCSharpObject01));
string strApplicationName = "TestCSharpObject01";
RemotingConfiguration.ApplicationName = strApplicationName;
RemotingConfiguration.RegisterActivatedServiceType(remObj);
Console.WriteLine("Press [ENTER] to exit.");
Console.ReadLine();
}
The ITestCSharpObjectInterfacesImpl01 application takes either zero or two arguments. The first argument is to indicate the type of server channel to register. This can be either "TCP" or "HTTP" for TCP and HTTP channels, respectively. The second argument indicates the port number to use for the server channel. If no arguments are used, a TCP server channel using port number 9000 will be used, by default.
The designated server channel will then be created and registered with the designated port number. We then create a ActivatedServiceTypeEntry
object, specifying in the constructor the type of the remote object to expose, and then register it via the RemotingConfiguration.RegisterActivatedServiceType()
method call.
We also specify the URI
of the Remote Object via the RemotingConfiguration.ApplicationName
property. This URI is set to "TestCSharpObject01" in our server. From here on, the name "TestCSharpObject01" will be associated with the class type TestCSharpObject01
.
The WriteLine()
and ReadLine()
API calls are there to ensure that the ITestCSharpObjectInterfacesImpl01 application, which will serve as the Remoting Server, will remain alive until someone presses the Enter key on it.
Note well the significance of the two parameters of the application. Specification of the channel server type and port number can help to ensure that no repeated channel type plus port number is registered. This will cause an exception to be thrown. We cannot, for example, register a TCP channel with port 9000 twice. This is a useful feature of the ITestCSharpObjectInterfacesImpl01 Remoting Server. In fact, any Remoting Server participating in the IDotNetClassFactory system should be aware of the possibility of being launched more than once. Since each time it is launched it must register a TCP or HTTP channel, it is important that no duplicate channel type and port number is registered.
This ITestCSharpObjectInterfacesImpl01 application, once compiled, need not be registered to the Registry nor the GAC. What is required is that it be available to client applications for runtime type loading and launching. Please take note this important point: that the nature of .NET Remoting is such that a copy of the assembly file containing the definition of the type that is exposed by Remoting must be available on the client-side at runtime.
Hence a copy of ITestCSharpObjectInterfacesImpl01.exe must be on the same path as any client application. More on this in the next section, when we study our client app.
Test Client Application (CPPClient01)
This project is contained inside the directory, <main folder>\CSharpExeCOMServers\IDotNetClassFactory\clients\CPPClient01.
This project is a C++ console application. In this application, I have sought to demonstrate the three techniques of .NET object creation. This is summarised in the _tmain()
function:
int _tmain(int argc, _TCHAR* argv[])
{
::CoInitialize(NULL);
if (1)
{
IDotNetClassFactoryPtr spIDotNetClassFactory;
CreateInstance
(
"IDotNetClassFactory_Impl01.IDotNetClassFactory_Impl01",
spIDotNetClassFactory
);
Demonstrate_CreationInstance_ByActivation
(spIDotNetClassFactory);
Demonstrate_CreationInstance_ByReflection
(spIDotNetClassFactory);
Demonstrate_CreationInstance_ByRemoting
(spIDotNetClassFactory);
}
::CoUninitialize();
return 0;
}
We first define a smart pointer object of type IDotNetClassFactoryPtr
which will be defined once we #import the IDotNetClassFactory.tlb type library at the beginning of the CPPClient01.cpp source file:
#import "IDotNetClassFactory.tlb" raw_interfaces_only
We then use our templated CreateInstance()
function to instantiate an IDotNetClassFactory
interface as implemented by the coclass whose CLSID is represented by the ProgID IDotNetClassFactory_Impl01.IDotNetClassFactory_Impl01
.
We then call on three demonstration functions, each of which demonstrates a separate call to the three creation methods of the IDotNetClassFactory
interface. Let us examine each in turn:
void Demonstrate_CreationInstance_ByActivation
(
IDotNetClassFactoryPtr& spIDotNetClassFactory
)
{
_bstr_t _bstrAssemblyName
(
"... bin\\Debug\\ITestCSharpObjectInterfacesImpl01.exe"
);
_bstr_t _bstrTypeName
(
"ITestCSharpObjectInterfacesImpl01.TestCSharpObject01"
);
VARIANT varRet;
ITestCSharpObjectInterfacesPtr spITestCSharpObjectInterfaces = NULL;
VariantInit(&varRet);
VariantClear(&varRet);
spIDotNetClassFactory -> CreateInstance_ByActivation
(
(BSTR)_bstrAssemblyName,
(BSTR)_bstrTypeName,
&varRet
);
if ((V_VT(&varRet) != VT_EMPTY) && (V_UNKNOWN(&varRet) != NULL))
{
V_UNKNOWN(&varRet) -> QueryInterface
(
__uuidof(ITestCSharpObjectInterfacesPtr),
(void**)&spITestCSharpObjectInterfaces
);
}
if (spITestCSharpObjectInterfaces)
{
spITestCSharpObjectInterfaces -> put_stringProperty
(
_bstr_t
(
"...Created using IDotNetClassFactory.CreateInstance_ByActivation()"
)
);
spITestCSharpObjectInterfaces -> DisplayMessage();
}
return;
}
The first demonstration function is Demonstrate_CreationInstance_ByActivation()
, which uses the passed in spIDotNetClassFactory
smart pointer object to create an instance of an ITestCSharpObjectInterfaces
interface implementation and then call its property and method.
Before all else, please note that the ITestCSharpObjectInterfaces
interface is defined within our client project because we have #import
ed the "TestCSharpObjectInterfaces.tlb" type library file early in the CPPClient01.cpp source file:
#import "TestCSharpObjectInterfaces.tlb"
The smart pointer class ITestCSharpObjectInterfacesPtr
is also defined as a result of our #import
.
Note now how we call the CreateInstance_ByActivation()
method. We pass in a path to the ITestCSharpObjectInterfacesImpl01.exe file relative to the current client application project folder (please refer to the path set in the actual source file - the path shown in the code snippet has been truncated due to its length). Please take note that my use of the relative path will only work if you have not modified any of the source file folders contained in my source code, and that you are performing debugging. If the relative path is not good for you, please modify it to a full absolute path.
The second parameter contains the type name "ITestCSharpObjectInterfacesImpl01.TestCSharpObject01
" which is defined inside the above-mentioned EXE assembly. Note that we are not using any of the Remoting facilities programmed into the ITestCSharpObjectInterfacesImpl01.exe application. We are simply instantiating a .NET class defined inside this assembly.
Once the CreateInstance_ByActivation()
method call succeeds, an IUnknown
(or IDispatch
) interface pointer will be returned in the "out
" parameter "varRet
". We then perform a QueryInterface()
call on the returned IUnknown
interface pointer to obtain an ITestCSharpObjectInterfaces
interface pointer.
Thereafter, setting the string property and invoking the DisplayMessage()
method of this interface is straightforward. The following message box should appear:
We shall next study the Demonstrate_CreationInstance_ByReflection()
function:
void Demonstrate_CreationInstance_ByReflection
(IDotNetClassFactoryPtr& spIDotNetClassFactory)
{
_bstr_t _bstrAssemblyName
("... ITestCSharpObjectInterfacesImpl01.exe");
_bstr_t _bstrTypeName
("ITestCSharpObjectInterfacesImpl01.TestCSharpObject01");
VARIANT varRet;
ITestCSharpObjectInterfacesPtr spITestCSharpObjectInterfaces = NULL;
VariantInit(&varRet);
VariantClear(&varRet);
spIDotNetClassFactory -> CreateInstance_ByReflection
(
(BSTR)_bstrAssemblyName,
(BSTR)_bstrTypeName,
&varRet
);
if ((V_VT(&varRet) != VT_EMPTY) && (V_UNKNOWN(&varRet) != NULL))
{
V_UNKNOWN(&varRet) -> QueryInterface
(
__uuidof(ITestCSharpObjectInterfacesPtr),
(void**)&spITestCSharpObjectInterfaces
);
}
if (spITestCSharpObjectInterfaces)
{
spITestCSharpObjectInterfaces -> put_stringProperty
(
_bstr_t
(
"... Created using IDotNetClassFactory.CreateInstance_ByReflection()"
)
);
spITestCSharpObjectInterfaces -> DisplayMessage();
}
return;
}
This function is almost identical to the Demonstrate_CreationInstance_ByActivation()
function, except for a few minor changes, which includes a call to the CreateInstance_ByReflection()
method (instead of to CreateInstance_ByActivation()
), and the string property is set such that a call to CreateInstance_ByReflection()
is indicated.
The following message box will be displayed:
In both the Demonstrate_CreationInstance_ByActivation()
and Demonstrate_CreationInstance_ByReflection()
calls, the target assembly will be loaded into the memory space of the client application as if they are Class Libraries, even though they are actually .NET EXE assemblies.
In both cases, we did not use any of the Remoting facilities programmed into the ITestCSharpObjectInterfacesImpl01.exe application. We simply instantiated a public .NET class defined inside this assembly.
When we next study the function call to Demonstrate_CreationInstance_ByRemoting()
, we shall either see the target EXE assembly launched, or see a running instance of it being used.
void Demonstrate_CreationInstance_ByRemoting
(
IDotNetClassFactoryPtr& spIDotNetClassFactory
)
{
_bstr_t _bstrAssemblyName("ITestCSharpObjectInterfacesImpl01");
_bstr_t _bstrTypeName("ITestCSharpObjectInterfacesImpl01.TestCSharpObject01");
_bstr_t _bstrURL("tcp://localhost:7000/TestCSharpObject01");
_bstr_t _bstrAssemblyFullPath("... ITestCSharpObjectInterfacesImpl01.exe");
_bstr_t _bstrArgumentString("TCP 7000");
VARIANT varRet;
ITestCSharpObjectInterfacesPtr spITestCSharpObjectInterfaces = NULL;
VariantInit(&varRet);
VariantClear(&varRet);
spIDotNetClassFactory -> CreateInstance_ByRemoting
(
(BSTR)_bstrAssemblyName,
(BSTR)_bstrTypeName,
(BSTR)_bstrURL,
VARIANT_TRUE,
(BSTR)_bstrAssemblyFullPath,
(BSTR)_bstrArgumentString,
&varRet
);
if ((V_VT(&varRet) != VT_EMPTY) && (V_UNKNOWN(&varRet) != NULL))
{
V_UNKNOWN(&varRet) -> QueryInterface
(
__uuidof(ITestCSharpObjectInterfacesPtr),
(void**)&spITestCSharpObjectInterfaces
);
}
if (spITestCSharpObjectInterfaces)
{
spITestCSharpObjectInterfaces -> put_stringProperty
(_bstr_t("... Created using IDotNetClassFactory.CreateInstance_ByRemoting()"));
spITestCSharpObjectInterfaces -> DisplayMessage();
}
return;
}
The Demonstrate_CreationInstance_ByRemoting()
call is, as usual, a little more complicated. The following is a summary of the steps involved:
- The assembly ITestCSharpObjectInterfacesImpl01.exe must be copied to the same path as the client app.
This is a requirement of Remoting clients. The assembly file must be included in the same path as the client application. The .NET runtime will load it temporarily (presumably to examine the type that is exposed by the Remoting Server). In order to ensure its availability, I have deliberately retained the "Debug" folder of the client application and stored a copy of ITestCSharpObjectInterfacesImpl01.exe there. If you later compile a "Release" version of the client, or if you make any changes to the ITestCSharpObjectInterfacesImpl01 project, please remember to copy ITestCSharpObjectInterfacesImpl01.exe to the appropriate output directory of CPPClient01.
- The _bstrAssemblyName variable is set to "
ITestCSharpObjectInterfacesImpl01
".
This is not to be set to a path to the EXE assembly. Instead, the name of the assembly file without extension (which contains a definition of the class type to which we would like to access by Remoting) is to be specified.
- Decision to launch the Remoting server or not will affect the
_bstrURL
and _bstrArgumentString
parameters.
Note that the decision whether to set the fourth parameter (bExecute
) to VARIANT_TRUE
or VARIANT_FALSE
will have a significant impact on the _bstrURL
and _bstrArgumentString
parameters.
If we will not be launching a new running instance of the Remoting Server ITestCSharpObjectInterfacesImpl01.exe, then at least one instance of the Remoting Server must already be running. Also, _bstrURL
must be set such that the port number is one which is used by the target Remoting Server. Furthermore, the _bstrAssemblyFullPath
and _bstrArgumentString
parameters are irrelevant, and may be set to empty strings.
If we will be launching a new running instance of the Remoting Server, then not only must _bstrAssemblyFullPath
be set to a proper path to ITestCSharpObjectInterfacesImpl01.exe, but the _bstrArgumentString
must be set to indicate a required channel type ("TCP or HTTP") and, more importantly, a unique port number. The _bstrURL
must be set to reflect the selected channel type and unique port number.
Let's take the case where we want to start a new running instance of the Remoting Server ITestCSharpObjectInterfacesImpl01.exe; we would then need to set bExecute
to VARIANT_TRUE
, select a channel type, and give a unique port number.
We will use the TCP channel type (no particular reason why) and, for a start, we will use the unique port number 7000. This is accomplished by setting the _bstrArgumentString
variable to a string value of "TCP 7000".
This configuration is exactly as you will find in the source code of the Demonstrate_CreationInstance_ByRemoting()
function.
When run, the Remoting Server ITestCSharpObjectInterfacesImpl01.exe will be launched:
The first argument "TCP" (channel type) will be printed on its console together with its second argument "7000" (port number). The special string "TestCSharpObject01 constructor." is printed by the TestCSharpObject01
class during its construction phase.
The following message box will be displayed:
Now that we have a running instance of ITestCSharpObjectInterfacesImpl01.exe, let us try to connect with this running instance instead of launching a new one. We will need to set bExecute
to VARIANT_FALSE
, and use the existing channel type of TCP and existing port number of 7000. Give the test client application another go.
When CreateInstance_ByRemoting()
is called, another line "TestCSharpObject01 constructor." is printed:
Now that we have successfully tested re-using a running instance of ITestCSharpObjectInterfacesImpl01.exe with port 7000, let us now try running a new instance of the Remoting Server.
To do this, we must set bExecute
to VARIANT_TRUE
, use the existing channel type of TCP, and change the port number to 6000 (or any other number other than 7000) by setting the _bstrArgumentString
variable to a string value of "TCP 6000".
The _bstrURL
string must also be changed to something like: "tcp://localhost:6000/TestCSharpObject01".
When the test client is now run, a new running instance of ITestCSharpObjectInterfacesImpl01.exe will be launched:
Note that it is this new instance's TestCSharpObject01
class that will be instantiated. How do we know? The "TestCSharpObject01 constructor." line is printed in the new running instance's console window.
Some Final Words About the IDotNetClassFactory System
The CreateInstance_ByRemoting()
method presented by the IDotNetClassFactory
system is really a simple mechanism for activating a .NET EXE assembly as a pseudo COM EXE Server. It is by no means perfect. Although Remote Object Lifetime Management is handled by the inherent .NET Remoting system, there is no system in place to initiate automatic server termination once all the server's objects have been garbage collected. This remains the responsibility of the specific client and the specific Remoting Server.
Note also that the complex arrangement of Remoting Channel Type and Port Numbers is strictly a matter between the Test Client and the Test EXE Remoting Server. The IDotNetClassFactory
system acts as a broker, and has no knowledge of the protocol going on in between. This necessary design point keeps the IDotNetClassFactory
system clean, simple, and robust.
COM EXE Server Fully Implemented in Managed Code
This section is the result of the research and development work by a fellow CodeProject member mav.northwind and myself, which was first inspired by Aaron Queenan (please see his comments in the FAQ section below, dated January 4, 2005, entitled "You CAN build a COM local server entirely in .NET managed code").
Aaron basically suggests completely dressing up a .NET EXE assembly as a COM Local Server by fulfilling all the requirements of such a server as stipulated by the COM specifications.
This includes, among other requirements:
- Providing a class factory class (which implements the COM
IClassFactory
interface) for every .NET class that we want to export to COM.
- Correctly inserting Registry entries for the EXE assembly, including a LocalServer32 key.
- Appropriately calling
CoRegisterClassObject()
and CoRevokeClassObject()
on these factory classes.
I must admit that this did seem like colossal work at first sight, until mav.northwind (a great buddy!) sent me some startup code he had previously done. After several days of collaborative work, we emerged a final solution. I will present in this section the fruitful results of our experimentation work.
Important Note
Please note that the information presented here is by no means authoritative. mav.northwind and I are expounding an idea which has proven to work but only in the test cases that we have subjected it to. Its validity is still based largely on anecdotal evidence.
There are known issues about this solution, including the control of the apartment model of objects exposed by the local server which appear to be different from their unmanaged counterparts. Other unforeseen problems could also surface as we use it more.
We call upon all readers and experienced developers to rigorously test our work and revert to us with any comments. Thanks!
The Source Code
I have provided a sample implementation of a .NET EXE server which runs in pure managed code. The source code for this is contained inside <main folder>\CSharpExeCOMServers\ManagedCOMLocalServer.
Inside this directory, we have two projects housed inside the following sub-folders:
- .\implementations\ManagedCOMLocalServer_Impl01
- .\clients\CPPClient01
Please note that both projects refer to the types originally defined inside the SimpleCOMObject project. ManagedCOMLocalServer_Impl01 will require the Interop.SimpleCOMObject.dll primary interop assembly and CPPClient01 will require SimpleCOMObject.tlb. Therefore, please ensure that SimpleCOMObject.sln has been compiled successfully and that CreateAndRegisterPrimaryInteropAssembly.bat has been invoked.
Let us now begin to study the ManagedCOMLocalServer_Impl01 solution.
ManagedCOMLocalServer_Impl01
The ManagedCOMLocalServer_Impl01 solution is a C# project which provides an implementation of the ISimpleCOMObject
interface. In addition to this, ManagedCOMLocalServer_Impl01 is also a C# implementation of a COM out-of-proc or local server. This is achieved by attempting to fulfill all the requirements of such a server as stipulated by the COM specifications.
The sub-sections that follow will provide explanations on the relevant parts of the source code as well as the required steps taken to accomplish this.
Multiple use of the DllImportAttribute
In order to develop a C# application that can serve as a COM Local Server by COM rules, we have to use the tried and tested COM/Win32 APIs as well as code constructs that are the mainstay of COM development.
These include CoRegisterClassObject()
, CoResumeClassObjects()
, CoRevokeClassObject()
, as well as message loops.
In order to use these APIs, we need the DllImportAttribute
. Furthermore, some of these APIs require Win32 structs as parameters; hence, these structs have been defined within our solution.
These structs are also marked with the ComVisibleAttribute
(with the bool Visibility
constructor parameter set to false
). This will ensure that when we call regasm (with the /tlb flag) on the output ManagedCOMLocalServer_Impl01.exe, these structs will not be included in the output type library file ManagedCOMLocalServer_Impl01.tlb. The presence of these structs within a type library may cause redefinition problems when #import
ed into a VC++ project.
The Class Factory
The concept of the Class Factory is particularly important in a COM Local Server. A class factory object is one which must implement the IClassFactory
interface. Each COM class (coclass) requires its own class factory object for instantiation purposes.
The IClassFactory::CreateInstance()
method is the entry point for the creation of COM objects of a particular CLSID. The other method of this interface, IClassFactory::LockServer()
is for keeping a local server running while clients continue to use its class factories for object creation. Keeping a local server "locked" avoids the possibly undesirable situation where a server application is constantly started up and terminated. We shall study local server lifetime management in a later section.
In our ManagedCOMLocalServer_Impl01 example code, we have defined a base class named ClassFactoryBase
to encapsulate the functionalities required of a COM class factory. This will be expounded in its own sub-section later on.
The Apartment Model of the Class Factory
Unlike COM objects exported from an in-proc (DLL) server, the apartment model of a COM object exported from a local server is not based on any Registry setting. Rather, it is based on the apartment model of its class factory object. And, the apartment model of the class factory object, in turn, depends on the apartment model used by the thread in which it is created. This is the common protocol used by unmanaged COM local servers.
For managed COM local servers, the situation seems to be different. Our class factory object will be created in the main thread of the server application. Hence, at first sight, it seems apparent that we should actively set the apartment model used by this main thread.
There are several .NET constructs that can help us affect the threading model of the main thread: the STAThreadAttribute
, the MTAThreadAttribute
, and the ApartmentState
property of the Thread
class. Please refer to MSDN for reference on these techniques. In addition to these, we may also want to use the trusty CoInitializeEx()
API.
However, it appears that these constructs did not have any influence on the apartment model of the main thread. Neither did it have any impact on the apartment model of the class factory object nor the eventual objects that we later create.
What I have observed is that the main thread, the class factory, and the objects created by the factory all seem to be living in the MTA. This seem to be so despite the fact that our ManagedCOMLocalServer_Impl01 code includes the use of the STAThreadAttribute
for the Main()
function. A call to CoInitializeEx()
(with parameter COINIT_APARTMENTTHREADED
) also did not do the trick.
My current theory is that the STAThreadAttribute
, the MTAThreadAttribute
, and the CoInitializeEx()
API are useful only for the benefit of COM objects used within a managed application. That is, since COM objects by nature need to live inside an apartment, the CLR creates such environmental constructs for them to live and breathe normally inside managed code.
Another important fact to remember is that the main thread of our server application is fundamentally a managed thread. The class factory object and the objects that the factory instantiates are still, after all, managed objects. They are all born in and living in managed code in an application process separate from the client. As such, the default COM settings of these objects as set by the CLR will take place. My gut feel is that when put in the context of COM interop, the main thread, the class factory, and the created objects live in the MTA.
I am currently researching with another CodeProject member Kyoung-Sang Yu on ways to possibly control the apartment model of a managed thread, a managed factory object, and the objects that it manufactures. We will certainly keep all informed of progress.
Class Factory Registration, Suspension, Resumption, and Revocation
When an unmanaged client application calls CoCreateInstance()
, or CoGetClassObject()
with a class context value of CLSCTX_LOCAL_SERVER
, COM searches the Registry for the appropriate local server code, either to launch or to refer to. If the appropriate class factory object is found in its internal table of class factories, and this class factory has been registered for multiple use (via the REGCLS_MULTIPLEUSE
flag), COM will temporarily lock this class factory (via IClassFactory::LockServer()
) and use it to create the required object.
If the class factory object is not found in the table, or it is found but has been registered for single use (via the REGCLS_SINGLEUSE
flag), COM will launch the server application with a special command line parameter "-Embedding". COM will then wait for the appropriate class factory object to be registered. The "-Embedding" command line parameter is meant to indicate to the server application to perform class factories registration and not to start up as a normal program. This flag should also indicate to the server to startup without showing any application windows.
Each class factory must be created by the COM local server and then registered into COM's class factories table. This registration is achieved via CoRegisterClassFactory()
.
Note that once a class factory object has been registered into COM's class factory table, a client application is able to use it to create objects. This raises possible problems with coordination because objects can be created before a local server has completed its initializations. In the case of an STA object, this issue may not be too serious due to the fact that calls to its methods will only be serviced during the containing STA thread's message loop, which is usually started when all initializations have completed for that thread. MTA objects, however, do not have this luxury. Furthermore, the local server app may start up other threads, each of which could register other class factories.
To overcome potential coordination issues, COM allows us to register class factories with the REGCLS_SUSPENDED
flag, which indicates to COM to suspend the registration process and hold back any activation requests for objects of a specified CLSID until there is a call to CoResumeClassObjects()
.
When a local server is marked for termination, all registered class factory objects exposed by the server must be revoked. This is accomplished by the use of the CoRevokeClassObject()
API. Note that once a class factory object has been revoked, it will be removed from the global class object table. This means that subsequent client calls to create objects supported by the class factory will cause COM to startup another instance of the local server.
In our ManagedCOMLocalServer_Impl01 example code, our ClassFactoryBase
class exposes methods that perform the jobs of class factory registration, suspension, resumption, and revocation.
Local Server Lifetime Management
A COM local server must control its own lifetime. This is normally accomplished by detecting the right conditions for self-termination. The right conditions, typically, involve two entities: global objects count and global server lock count.
The global objects count indicates the total number of objects the local server has created (through its registered class factories) and is still alive at any given moment.
The global server lock count refers to the collective lock counts of all the registered class factory objects of a server application. A non-zero value indicates that a client application is still holding onto at least one class factory, thereby obliging the server to remain running.
A typical server application will also contain a Windows message loop inside its main thread, which continues to pump in order to stay alive. This will be so until a WM_QUIT
message is received, at which time the loop will break and the application clean-up will take place (e.g., calling CoRevokeClassObject()
for all registered class factories) before official termination.
The common arrangement for managing a server's lifetime involves checking the two above-mentioned global values to see if they have both dropped to zero. This check is usually contained inside a function (let's call this the AttemptToTerminateServer()
function), and it is invoked when an object's reference count has been Release()
'ed to zero and when a class factory's LockServer()
method is called with a parameter value of FALSE
.
When both values are zero, it indicates that all objects exported by the server have been destroyed and that there are no locks enforced on any of the server's class factory objects. Under these circumstances, the server application is no longer required to stay alive. The AttemptToTerminateServer()
function will then post a WM_QUIT
message to the main message loop to begin the process of application termination.
Overall Design of ManagedCOMLocalServer_Impl01
Our ManagedCOMLocalServer_Impl01 local server example follows the typical pattern of class factory registration, message loop activation, class factory revocation, and final termination, as discussed above. It also uses the concepts of global objects count and global lock count to control its lifetime. Note that a .NET application can also contain a message loop which seamlessly blends into the .NET application workflow. There is no problem with this.
The problem with .NET COM local servers is that while it is not difficult to keep track of the global lock count, it is not easy to determine exactly when the reference count of a .NET object (disguising as a COM object) has dropped to zero. We can try to use an object's destructor as a sure bet that its reference count has dropped to zero, but we know that calls to an object's destructor is indeterminate due to the nature of the .NET Garbage Collector. Simply put, we do not know when the Garbage Collector is going to activate its process and destroy unreferenced objects.
To resolve this, in addition to the logic flow of the main application thread, we introduce the concept of a Garbage Collection Thread. This thread is very simple, and it works by periodically calling the GC.Collect()
static method.
This GC.Collect()
method forces immediate garbage collection, which is very useful to us because it causes all unreferenced objects to be destroyed. When this happens, the destructor function of the objects get fired. Our AttemptToTerminateServer()
function can then be invoked. This is good for us because we can have a reliable way to perform server shutdown.
We shall study the Garbage Collection Thread in greater detail in its own section later on.
The IClassFactory Interface
We provide a definition of the IClassFactory
interface for use by the ManagedCOMLocalServer_Impl01 solution. This interface is listed below:
[
ComImport,
ComVisible(false),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("00000001-0000-0000-C000-000000000046")
]
public interface IClassFactory
{
void CreateInstance
(
IntPtr pUnkOuter,
ref Guid riid,
out IntPtr ppvObject
);
void LockServer(bool fLock);
}
This IClassFactory
interface definition is meant to provide a .NET definition of the COM IClassFactory
interface. It is required for successful compilation of the class factory classes defined in this solution. The following are important points of this definition:
- By applying the
ComImportAttribute
, we indicate to the C# compiler that this interface originated from COM.
- The
ComVisibleAttribute
(constructed with a value of false
) indicates to the C# compiler, and later to tools like regasm.exe and tlbexp.exe, that this interface must not be included in any Type Library File generated by those tools. This is certainly not hard to imagine. Ignoring this would cause a re-definition problem.
- By way of the
InterfaceTypeAttribute
(constructed with ComInterfaceType.InterfaceIsIUnknown
), we indicate to the C# compiler that this interface is not IDispatch
-based. This is indeed the case for the COM IClassFactory
interface, which is derived directly from IUnknown
.
- The
GuidAttribute
(constructed with "00000001-0000-0000-C000-000000000046") indicates the specific GUID to be applied to this interface. Note that this is indeed the GUID of the COM IClassFactory
interface.
The methods of this interface are exactly those of its COM counterpart.
The SimpleCOMObject Class
The SimpleCOMObject C# class implements the ISimpleCOMObject
interface. This implementation is very simple, and is very similar to other implementations we have seen earlier. We will not go into details. We shall instead briefly touch on the pertinent parts of this class:
[
Guid("E1FE1223-45C2-4872-9B1E-634FB850E753"),
ProgId("ManagedCOMLocalServer_Impl01.SimpleCOMObject"),
ClassInterface(ClassInterfaceType.None)
]
public class SimpleCOMObject :
ReferenceCountedObjectBase,
ISimpleCOMObject
{
...
...
...
}
- By applying the
GuidAttribute
, we have indicated a specific CLSID for this class.
- The
ProgIdAttribute
indicates that the ProgId of this class is "ManagedCOMLocalServer_Impl01.SimpleCOMObject". Note that this attribute is not exactly required as it is also the default ProgId generated by regasm.exe if no ProgIdAttribute
was used.
- By way of the
ClassInterfaceAttribute
, we have indicated to regasm.exe not to generate a class interface for this class. Please lookup the MSDN documentation for Class Interfaces, for more details. Practically speaking, with this attribute set this way, we will not have any interface with a name like _SimpleCOMObject
registered into the Registry.
- This class inherits from the base class
ReferenceCountedObjectBase
. We shall go into the details of the base class in a later section. For now, it suffices to say that the base class provides automatic and thread-safe incrementing and decrementing of the global objects count. The global objects count is crucial in the COM Local Server's self-termination mechanism.
The ReferenceCountedObjectBase Class
This is a helper base class that is very useful for helping to automate the tracking of objects alive in a COM Local Server. It is listed below:
[ComVisible(false)]
public class ReferenceCountedObjectBase
{
public ReferenceCountedObjectBase()
{
Console.WriteLine("ReferenceCountedObjectBase contructor.");
ManagedCOMLocalServer_Impl01.InterlockedIncrementObjectsCount();
}
~ReferenceCountedObjectBase()
{
Console.WriteLine("ReferenceCountedObjectBase destructor.");
ManagedCOMLocalServer_Impl01.InterlockedDecrementObjectsCount();
ManagedCOMLocalServer_Impl01.AttemptToTerminateServer();
}
}
By deriving from this base class, we ensure that when an object is instantiated, we call the ManagedCOMLocalServer_Impl01
class' static method InterlockedIncrementObjectsCount()
. We also ensure that when an object is destroyed, ManagedCOMLocalServer_Impl01
's InterlockedDecrementObjectsCount()
and AttemptToTerminateServer()
are called.
We shall discuss the ManagedCOMLocalServer_Impl01
class later on. For now, just note the following:
- The class
ManagedCOMLocalServer_Impl01
is a central class for controlling the activities of a COM Local Server. These activities include the tracking of the total number of objects which have been created and exported to COM.
ManagedCOMLocalServer_Impl01
will also determine whether it is time to shutdown the local server application itself.
ReferenceCountedObjectBase
is a good base class for use by other classes which are to be exported to COM.
The SimpleCOMObjectClassFactory Class
Every COM class, whether they be housed within an in-proc DLL Server or an out-of-proc EXE Server, must provide a class factory. The SimpleCOMObjectClassFactory
class is designated the COM class factory for the SimpleCOMObject
class. This class is listed below:
class SimpleCOMObjectClassFactory : ClassFactoryBase
{
public override void virtual_CreateInstance
(
IntPtr pUnkOuter,
ref Guid riid,
out IntPtr ppvObject
)
{
Console.WriteLine("SimpleCOMObjectClassFactory.CreateInstance().");
Console.WriteLine("Requesting Interface : " + riid.ToString());
if (riid == Marshal.GenerateGuidForType(typeof(ISimpleCOMObject)) ||
riid == ManagedCOMLocalServer_Impl01.IID_IDispatch ||
riid == ManagedCOMLocalServer_Impl01.IID_IUnknown)
{
SimpleCOMObject SimpleCOMObject_New = new SimpleCOMObject();
ppvObject =
Marshal.GetComInterfaceForObject
(SimpleCOMObject_New, typeof(ISimpleCOMObject));
}
else
{
throw new COMException("No interface",
unchecked((int) 0x80004002));
}
}
}
SimpleCOMObjectClassFactory
derives from the ClassFactoryBase
base class, which provides many helper functionalities. SimpleCOMObjectClassFactory
only implements the virtual_CreateInstance()
method, which is a virtual method of the base class.
We shall discuss ClassFactoryBase
very shortly. For now, just note that SimpleCOMObjectClassFactory.virtual_CreateInstance()
is called by this base class when COM requires the class factory of the ManagedCOMLocalServer_Impl01.SimpleCOMObject
coclass to instantiate an object.
As you can probably guess, virtual_CreateInstance()
has the same parameters as the IClassFactory.CreateInstance()
method. This is because ClassFactoryBase
will require derived classes to perform the actual class instantiation process.
The parameter riid
is used to determine the interface required of a new instance of SimpleCOMObject
. If this is one of the standard interfaces IUnknown
or IDispatch
, or if it is ISimpleCOMObject
, a new instance of SimpleCOMObject
is created and its IUnknown
interface is passed to the ppvObject
IntPtr
parameter.
What we are witnessing is actually the C# implementation of a typical COM IClassFactory::CreateInstance()
method.
The ClassFactoryBase Class
This class is a helper base class which provides very useful properties and methods to derived classes. It serves to provide base implementations of the required functionalities of a Class Factory object to be used by COM. These include:
- Standard implementations of our .NET version of the
IClassFactory
interface.
- Encapsulation of class factory registration (
RegisterClassObject()
method), resumption (ResumeClassObjects()
method), and revocation (RevokeClassObject()
method) operations.
It exposes several properties, e.g.: ClassContext
, ClassId
, Flags
. These properties are used for filling the parameters to an internal call to the COM API CoRegisterClassObject()
. It also maintains the cookie returned by the internal call to CoRegisterClassObject()
.
Note that once the RegisterClassObject()
method is called for a ClassFactoryBase
-derived object (with possibly a need to follow up with a call to ResumeClassObjects()
), the class factory is available for use by COM. This will remain so until RevokeClassObject()
is called on this ClassFactoryBase
-derived object.
Its IClassFactory
implementation is pretty interesting:
public void CreateInstance
(
IntPtr pUnkOuter,
ref Guid riid,
out IntPtr ppvObject
)
{
virtual_CreateInstance
(
pUnkOuter,
ref riid,
out ppvObject
);
}
Its CreateInstance()
method (as listed above) simply calls the virtual virtual_CreateInstance()
method. This method is meant to be overridden by a derived class. Its own default implementation is simple:
public virtual void virtual_CreateInstance
(
IntPtr pUnkOuter,
ref Guid riid,
out IntPtr ppvObject
)
{
IntPtr nullPtr = new IntPtr(0);
ppvObject = nullPtr;
}
We have already seen a meaningful implementation by SimpleCOMObjectClassFactory
.
public void LockServer(bool bLock)
{
if (bLock)
{
ManagedCOMLocalServer_Impl01.InterlockedIncrementServerLockCount();
}
else
{
ManagedCOMLocalServer_Impl01.InterlockedDecrementServerLockCount();
}
ManagedCOMLocalServer_Impl01.AttemptToTerminateServer();
}
Its LockServer()
method (as listed above) will perform a thread-safe incrementing or decrementing of this COM EXE Server's lock count depending on whether the parameter bLock
is true
or false
.
Next, it will call ManagedCOMLocalServer_Impl01
's AttemptToTerminateServer()
method. This method is very useful as it will check to see if the conditions are right for this COM EXE Server to self-terminate. We will study this method in detail in the next section.
The ManagedCOMLocalServer_Impl01 Class
The ManagedCOMLocalServer_Impl01
class is the controller of the entire COM EXE Server application. I have coded it for re-use by developers. It imports many Win32 APIs and structs. We will study only the more significant methods. Most of the properties and methods of this class are self-explanatory.
The Main() Method
This function is the starting point of the server. This method is pretty long, and will not be listed here. I will instead go through it step by step:
- It first calls
ProcessArguments()
to process the command line arguments. The important command line arguments include: "-register", "-unregister", and "-embedding".
- When "-register" is supplied, ManagedCOMLocalServer_Impl01.exe will append the "LocalServer32" key to the COM-Registry key of the
SimpleCOMObject
class.
- When "-unregister" is supplied, the reverse (deletion of the "LocalServer32" key) will be performed. The use of either argument will cause the application to immediately exit.
- If the "-embedding" argument is passed, the server app is started for real. In this case, we first initialize the objects and the server lock counts.
- We also determine the thread ID of the current thread (i.e., the main thread) and keep this value in
m_uiMainThreadId
.
- We then instantiate a
SimpleCOMObjectClassFactory
object, set its ClassContext
, ClassId
, and Flags
properties to appropriate values and call its RegisterClassObject()
method.
- Note that we have supplied a value of
REGCLS.REGCLS_MULTIPLEUSE | REGCLS.REGCLS_SUSPENDED
as the value for the property Flags
.
- The use of the
REGCLS.REGCLS_MULTIPLEUSE
flag will cause the class factory as contained in the current EXE Server app to be used multiple times by multiple client applications. Hence, as long as RevokeClassObject()
is not called, the current server will continue to supply instances of the SimpleCOMObject
class. Try experimenting with the REGCLS.REGCLS_SINGLEUSE
flag to see other results.
- The use of the
REGCLS.REGCLS_SUSPENDED
flag will cause the class factory registration process (as invoked by CoRegisterClassObject()
) to be paused until CoResumeClassObjects()
is called. This is useful in certain circumstances.
- Internally, when
RegisterClassObject()
is invoked, the SimpleCOMObjectClassFactory
object will call, through its base class, the CoRegisterClassObject()
API to register itself as the class to be exposed to COM as a class factory object. This is possible because a value of "this
" is passed as the second parameter to CoRegisterClassObjec
t().
- Then, the static
ClassFactoryBase.ResumeClassObjects()
method is called to officially render the registered class factory as being available for connection and instantiation.
- This will remain so until the
RevokeClassObject()
method is called by the SimpleCOMObjectClassFactory
object.
- Next, a "Garbage Collection" thread is started up, the purpose of which is to periodically call the static
GC.Collect()
method. This forces immediate garbage collection, which is important for us to ensure timely destruction of fully released and no longer referenced objects. This, in turn, ensures timely shutdown of the COM EXE Server.
- A Windows message loop is put in place to process Windows messages, especially the
WM_QUIT
message which is posted to this message loop inside the ManagedCOMLocalServer_Impl01.AttemptToTerminateServer()
method (when the conditions are right to shutdown the server).
- When the
WM_QUIT
message is indeed received by the message loop, we will immediately call RevokeClassObject()
so that the SimpleCOMObjectClassFactory
object is no longer available for use by COM. In this case, when a SimpleCOMObjectClassFactory
object is next required, a new instance of ManagedCOMLocalServer_Impl01.exe is started up.
- We will then stop the Garbage Collection thread.
- The server app is now on its way to termination.
The AttemptToTerminateServer() Method
This method is listed below:
public static void AttemptToTerminateServer()
{
lock(typeof(ManagedCOMLocalServer_Impl01))
{
Console.WriteLine("AttemptToTerminateServer()");
int iObjsInUse = ObjectsCount;
int iServerLocks = ServerLockCount;
StringBuilder sb = new StringBuilder("");
sb.AppendFormat
("m_iObjsInUse : {0}. m_iServerLocks : {1}",
iObjsInUse, iServerLocks);
Console.WriteLine(sb.ToString());
if ((iObjsInUse > 0) || (iServerLocks > 0))
{
Console.WriteLine
("There are still referenced objects or the server
lock count is non-zero.");
}
else
{
UIntPtr wParam = new UIntPtr(0);
IntPtr lParam = new IntPtr(0);
Console.WriteLine("PostThreadMessage(WM_QUIT)");
PostThreadMessage(MainThreadId, 0x0012, wParam, lParam);
}
}
}
This method is thread-safe, and ensures that only one thread accesses it at any one time. The global objects count and the server lock count is then accessed and printed out (for diagnostic purposes). We then check to see if either the total number of objects served by this app or the total server lock count is still non-zero. If so, we deem that this server app is required to stay alive. We simply exit this method.
If these numbers have fallen to zero at the same time, we deem that this server app is no longer needed. We post a WM_QUIT
message to the main thread, and the message loop contained inside Main()
will break out.
The AttemptToTerminateServer()
method is called in two places:
- The destructor of
ReferenceCountedObjectBase()
.
- The
ClassFactoryBase.LockServer()
method.
The destructor of ReferenceCountedObjectBase()
signals the end of life of an object. This (destroyed) object will decrement the global objects count, and it is now a good opportunity to determine if the global objects count has fallen to zero together with the server lock count.
The ClassFactoryBase.LockServer()
method is called by a client application, and also called by COM when it accesses a Class Factory object. In case a value of FALSE
is passed as the parameter, we need to call on AttemptToTerminateServer()
to once again check whether conditions are right for server termination.
The GarbageCollection Class
The GarbageCollection
class provides a simple example of a thread management class. Its main purpose is to periodically invoke the static GC.Collect()
method. The interval period between calls to GC.Collect()
is configurable, and is set via a GarbageCollection
class constructor parameter (iInterval
):
public GarbageCollection(int iInterval)
{
m_bContinueThread = true;
m_GCWatchStopped = false;
m_iInterval = iInterval;
m_EventThreadEnded = new ManualResetEvent(false);
}
The GCWatch()
method serves as the thread entry point of the "Garbage Collection" thread of the Main()
method:
public void GCWatch()
{
Console.WriteLine("GarbageCollection.GCWatch() is now running ...");
while (ContinueThread())
{
GC.Collect();
Thread.Sleep(m_iInterval);
}
Console.WriteLine("Goind to call m_EventThreadEnded.Set().");
m_EventThreadEnded.Set();
}
It basically contains a while
loop which will only break when m_bContinueThread
is set to false
. At the start of every loop, we call GC.Collect()
. Thereafter, we block the thread for a set interval period. When the loop is eventually broken via the StopThread()
method, GCWatch()
will signal the ManualResetEvent
object m_EventThreadEnded
and then exit the thread.
The signaling of the m_EventThreadEnded
object will cause the WaitForThreadToStop()
method, assuming it is called on another thread, to unblock and exit.
COM-Relevant Entries are Added to the Registry
The standard .NET tool regasm.exe is used to register all relevant COM entries into the Registry. This includes the following entries:
[HKEY_CLASSES_ROOT\ManagedCOMLocalServer_Impl01.SimpleCOMObject]
@="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
[HKEY_CLASSES_ROOT\ManagedCOMLocalServer_Impl01.SimpleCOMObject\CLSID]
@="{E1FE1223-45C2-4872-9B1E-634FB850E753}"
[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}]
@="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}
\InprocServer32]
@="mscoree.dll"
"ThreadingModel"="Both"
"Class"="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
"Assembly"="ManagedCOMLocalServer_Impl01, Version=1.0.0.0, Culture=neutral,
PublicKeyToken=94ff3289282b08f3"
"RuntimeVersion"="v1.1.4322"
[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}
\InprocServer32\1.0.0.0]
"Class"="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
"Assembly"="ManagedCOMLocalServer_Impl01, Version=1.0.0.0, Culture=neutral,
PublicKeyToken=94ff3289282b08f3"
"RuntimeVersion"="v1.1.4322"
[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}\ProgId]
@="ManagedCOMLocalServer_Impl01.SimpleCOMObject"
[HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}
\Implemented Categories\{62C8FE65-4EBB-45E7-B440-6E39B2CDBF29}]
These Registry entries, basically, prepare the output ManagedCOMLocalServer_Impl01.exe for being loaded as a COM in-proc-server. However, we also want ManagedCOMLocalServer_Impl01.exe to be activated as a COM local server that runs on its own while being connected to an unmanaged client running in a separate process.
This requires an additional "LocalServer32" key to be present in the "HKEY_CLASSES_ROOT\CLSID\{E1FE1223-45C2-4872-9B1E-634FB850E753}" key. The value for the LocalServer32 key must be the full path to ManagedCOMLocalServer_Impl01.exe. We have catered to this need by making ManagedCOMLocalServer_Impl01.exe respond to a "-register" command line argument.
When ManagedCOMLocalServer_Impl01.exe is run with this command line argument, it will add the additional LocalServer32 key with the following section of code in ManagedCOMLocalServer_Impl01::ProcessArguments()
:
try
{
key = Registry.ClassesRoot.CreateSubKey
("CLSID\\" +
Marshal.GenerateGuidForType(typeof(SimpleCOMObject)).ToString("B")
);
key2 = key.CreateSubKey("LocalServer32");
key2.SetValue(null, Application.ExecutablePath);
}
catch (Exception ex)
{
MessageBox.Show("Error while registering the server:\n"+ex.ToString());
}
finally
{
if (key != null)
key.Close();
if (key2 != null)
key2.Close();
}
This will result in the following Registry situation to be observed:
The fact that the full path is used is alright, but is not ideal if we intend to GAC register the executable. I have not found an API or a formula to determine the GAC path to a GAC-registered assembly file, hence I have not attempted to GAC-register ManagedCOMLocalServer_Impl01.exe.
I have included a batch file RegisterAssemblyToRegistry.bat that will automate the process of Registry update. This batch file is included in the solution folder. Its contents are listed below:
echo off
echo Registering Assembly ManagedCOMLocalServer_Impl01.exe
to the Registry...
regasm .\bin\Debug\ManagedCOMLocalServer_Impl01.exe /tlb
echo Registering Additional Entries into the Registry
for ManagedCOMLocalServer_Impl01.exe
.\bin\Debug\ManagedCOMLocalServer_Impl01.exe -register
We use regasm.exe to update the Registry with standard COM information. We then invoke the executable with the "-register" flag so that the LocalServer32 key is created/updated.
Testing ManagedCOMLocalServer_Impl01.exe
We shall now put ManagedCOMLocalServer_Impl01.exe to the test. For this purpose, I have prepared a client unmanaged application contained in <main folder>\CSharpExeCOMServers\ManagedCOMLocalServer\clients\CPPClient01.
In this test application, we create two ISimpleCOMObjectPtr
smart pointer objects plus a IClassFactoryPtr
smart pointer object.
ISimpleCOMObjectPtr spISimpleCOMObject1 = NULL;
ISimpleCOMObjectPtr spISimpleCOMObject2 = NULL;
IClassFactoryPtr spIClassFactory = NULL;
The two ISimpleCOMObjectPtr
smart pointer objects are instantiated at different points in the application's life. The IClassFactoryPtr
object is used to lock the Class Factory of the ISimpleCOMObject
object at midpoint.
I have coded the client app such that the full functionality of the various objects contained in ManagedCOMLocalServer_Impl01.exe (including the Garbage Collector, not just the COM objects) can be clearly seen. Console printouts are also put in place in order to show action sequences as they take place.
Let's analyze the _tmain()
function. Here, using a specially constructed global template function CreateInstanceByClassFactory()
, we create an instance of the class factory of the coclass whose CLSID is synonymous with the ProgID "ManagedCOMLocalServer_Impl01.SimpleCOMObject
". We will then create an instance of the above-mentioned coclass (via the just-obtained class factory) and get hold of its ISimpleCOMObject
interface:
CreateInstanceByClassFactory<ISimpleCOMObjectPtr>
(
"ManagedCOMLocalServer_Impl01.SimpleCOMObject",
spISimpleCOMObject1,
spIClassFactory,
CLSCTX_LOCAL_SERVER
);
Let's study this function in greater depth, and note what happens within the ManagedCOMLocalServer_Impl01 local server as CreateInstanceByClassFactory()
executes.
CreateInstanceByClassFactory()
first uses the CLSIDFromProgID()
API to translate the input ProgID string to its binary CLSID equivalent:
hrRetTemp = CLSIDFromProgID
(
(LPCOLESTR)bstProgID,
(LPCLSID)&clsid
);
Having obtained the CLSID of the intended coclass to instantiate, CreateInstanceByClassFactory()
then uses it as a parameter value to a call to CoGetClassObject()
to get hold of the class factory of this coclass (whose ProgId is "ManagedCOMLocalServer_Impl01.SimpleCOMObject
"):
CoGetClassObject
(
(REFCLSID)clsid,
(DWORD)dwClsContext,
(COSERVERINFO*)NULL,
(REFIID)IID_IClassFactory,
(LPVOID *)&spIClassFactoryPtrReceiver
);
Now, upon executing this API, the ManagedCOMLocalServer_Impl01 local server is launched by the COM sub-system. The console window of the server application will display the following (or equivalent) output:
The line "Request to start as out-of-process COM server." is displayed by the ProcessArguments()
function. Notice that immediately after this line, "InterlockedIncrementServerLockCount()" is printed which shows that the ManagedCOMLocalServer_Impl01.InterlockedIncrementServerLockCount()
function was called. This function is only called within the ClassFactoryBase.LockServer()
function. The next three line printouts are from the ManagedCOMLocalServer_Impl01.AttemptToTerminateServer()
function.
What we can postulate from these line printouts is that upon request to get the class factory object of a coclass, the class factory object (by then already created and registered by into COM's internal class factory table) is locked via its LockServer()
function. This is done by COM.
We next get the class factory object to perform the coclass instance creation:
hrRetTemp = spIClassFactoryPtrReceiver -> CreateInstance
(
NULL,
__uuidof(SmartPtrClass),
(LPVOID *)&spSmartPtrReceiver
);
Notice that more lines on the local server's console window appears:
The first two lines: "SimpleCOMObjectClassFactory.CreateInstance()" and "Requesting Interface : ..." are printed from the virtual_CreateInstance()
method.
The next three lines: "ReferenceCountedObjectBase constructor", "InterlockedIncrementObjectsCount()", and "SimpleCOMObject constructor" are printed as a result of a new instance of the SimpleCOMObject
class (deriving from ReferenceCountedObjectBase
) being created.
Next, when we call on the LockServer() method of the class factory interface pointer we have just filled, an interesting thing happens:
if (spIClassFactory)
{
spIClassFactory -> LockServer(TRUE);
}
We note that the SimpleCOMObjectClassFactory.LockServer()
method was not invoked. We can determine this because the InterlockedIncrementServerLockCount()
method was not called, and neither was AttemptToTerminateServer()
executed. This appears to be the case for genuine COM Local Server client applications.
Jumping ahead a little, we will note that when we call the following code:
if (spIClassFactory)
{
spIClassFactory -> LockServer(FALSE);
spIClassFactory = NULL;
}
the SimpleCOMObjectClassFactory.LockServer()
method (with a parameter value of FALSE
) was still not called. However, when we set "spIClassFactory
" to NULL
(effectively calling Release()
on the class factory object), SimpleCOMObjectClassFactory.LockServer()
was indeed called with a parameter value of FALSE
.
This seems to suggest that the IClassFactory::LockServer()
method is actually controlled by the COM sub-system. LockServer(TRUE)
seems to be called (by COM) when a class factory is instantiated via a call to the CoGetClassObject()
API. Conversely, LockServer(FALSE)
seems to be invoked when the class factory is destroyed (as a result of its reference count dropping to zero). As mentioned, this same behaviour is observed in genuine COM Local Servers, which is comforting for us as it suggests that our managed local server has worked according to specs.
The second spISimpleCOMObject2
object was instantiated normally via our templated CreateInstance()
function. Nothing unusual about this object. Throughout the test application, I hope the reader appreciates the significance of the GarbageCollection()
thread which ensures that destructors are called regularly. To see this in action, I suggest that the reader temporarily set the constructor parameter of the GarbageCollection
instance to a relatively large value like 20000 (signifying 20 seconds) per cycle of garbage collection:
while (ContinueThread())
{
GC.Collect();
Thread.Sleep(m_iInterval);
}
Now, set a debug breakpoint in the CPPClient01 source code where spISimpleCOMObject1
is set to NULL
:
if (spISimpleCOMObject1)
{
spISimpleCOMObject1 -> put_LongProperty(1002);
spISimpleCOMObject1 -> Method01
(
_bstr_t("C# EXE Local Server. The Long Property Value Is : ")
);
spISimpleCOMObject1 = NULL;
}
After executing this set to NULL
statement, hold on for a while (and do not execute the next client source statement), and observe the console output of the ManagedCOMLocalServer_Impl01 local server. After some time, you will notice that the sequence of actions unleashed by the destructor code of SimpleCOMObject
will be put to action:
In Conclusion
I certainly hope that you have enjoyed our long exploration into the world of COM/.NET interoperability. I have done my level best to be as thorough as I can in presenting an in-depth look at the mechanics of interoperation, albeit from one side of the story only.
I do hope that readers will benefit from the sample COM Local Server ManagedCOMLocalServer_Impl01 as well as the IDotNetClassFactory system. In view of the need for simplicity, I have avoided putting in too much error trapping and exception handling in order to focus on presenting the right ideas across.
Please do not hesitate to contact me should you find any errors in the explanatory text or any bugs in the source code, or if you simply need to clarify any issues with me regarding this article.
Already I am indebted to some very kind CodeProject members including Aaron Queenan, mav.northwind, and Kyoung-Sang Yu for their valuable feedback.
Acknowledgements and References
- .NET and COM - The Complete Interoperability Guide by Adam Nathan. Published by SAMS Publishing.
Useful Resources
Update History
January 5th 2005
Updated source code zip file BuildCOMServersInDotNet_src.ZIP. The original batch file CreateAndRegisterPrimaryInteropAssembly.bat for the SimpleCOMObject.sln solution has been updated with a new line:
regasm Interop.SimpleCOMObject.dll
This will register the Interop.SimpleCOMObject.dll primary interop assembly to the Registry.
January 11th 2005
Updated source code zip file BuildCOMServersInDotNet_src.ZIP with a new set of source code contained in a folder named ManagedCOMLocalServer. This folder contains a VS.NET solution for a COM EXE Local Server project fully coded in C#.
January 16th 2005
Updated the "COM EXE Server Fully Implemented in Managed Code" section with more explanatory text.
January 22nd 2005
Updated the "Testing ManagedCOMLocalServer_Impl01.exe" section with more explanatory text. An in-depth look at the server code in action is provided. There is also a brief demonstration of how the GarbageCollection thread plays its part in influencing server lifetime management.
February 2nd 2005
Minor updates to the "Testing ManagedCOMLocalServer_Impl01.exe" section.