Merging assemblies others depend on
Published 17 October 2019
Given three assemblies: A, B and C, where A and C depend on B, how to merge B into A, such that C can still reference B?
This document outlines our progress and proposed solution of this problem. To describe the problem we will base on a real-world scenario using three assemblies. Later we'll discuss how the problem can be worked-around manually by creating an additional "proxy" assembly. Based on the idea explained by the manual workaround, we'll describe our proposed implementation within SmartAssembly which automates the process of solving the problem introduced in the question above.
Sample scenario
A perfect example of such scenario would be an application whose functionality can be extended using plugins. Each plugin is based on the API exposed by the developer, and loaded into its own app domain at runtime. Both the main application and each of the plugins must reference the API to communicate correctly.
In such scenario there would be three separate assemblies:
- MainAssembly — depends on the ApiAssembly and loads the AddInAssembly at runtime.
- AddInAssembly — depends on ApiAssembly and implements its interface.
- ApiAssembly — exposes an interface implemented by the AddInAssembly; used by the MainAssembly to initialise the plugin.
Because ApiAssembly is used by both the main application and the plugin, it needs to be accessible by both of them. Normally, merging, embedding and obfuscating the ApiAssembly would be impossible without breaking the link between the ApiAssembly andAddInAssembly. SmartAssembly 7.1 solves this problem.
The problem
The diagram above visualises a scenario in which the ApiAssembly is merged into MainAssembly. Executing the MainAssembly will cause an exception to be thrown, because ApiAssembly no longer exists, preventing the AddInAssembly to be loaded correctly:
Could not load file or assembly 'ApiAssembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
The system cannot find the file specified.
The solution
We are working on a solution to help overcome these obstacles. We hope to achieve a functionality which allows ApiAssembly to be merged and partially obfuscated into MainAssembly without breaking the reference between the ApiAssembly and AddInAssembly.
Manual implementation
The issue can be solved using type forwarding. When applied to a referenced assembly, TypeForwardedToAttribute
instructs the application to look for selected type definitions in another assembly. More details about the attribute can be found at https://www.red-gate.com/simple-talk/blogs/anatomy-of-a-net-assembly-type-forwards/.
With type forwarding, one additional assembly is added to our initial list:
- MainAssembly — depends on ApiAssembly; loads AddInAssembly by reflection.
- AddInAssembly — depends on ApiAssembly.
- ApiAssembly — defines
IExample
interface; vanishes after merging into MainAssembly. - ApiAssembly — empty helper assembly; stays in place of the merged ApiAssembly; uses type forwarding to forward
IExample
interface to the one now defined in MainAssembly (because the original ApiAssembly was merged into it).
After merging ApiAssembly into MainAssembly, a new copy with type forwarding has to be created. The steps are as follows:Each forwarded type has to have the same name and namespace. If obfuscation or pruning is enabled when merging ApiAssembly into MainAssembly, each forwarded type exposed by the API has to be excluded using DoNotObfuscateType
and DoNotPruneType
attributes.
- Create a new empty assembly named ApiAssembly.
- Add a reference to the MainAssembly already processed by SmartAssembly (with original ApiAssembly merged in).
- Add a
TypeForwardedTo
attribute for each exposed type, for example:[assembly: TypeForwardedTo(IExample)]
(whereIExample
points to the interface contained inside MainAssembly after merging ApiAssembly into it). - If the original ApiAssembly was signed, make sure to use the same key.
- Place a new ApiAssembly with type forwards next to the MainAssembly processed by SmartAssembly.
After following the steps above, each loaded plugin (e.g. AddInAssembly) will load a newly created ApiAssembly. When resolving types (e.g. IExample
), the CLR will follow the type forwards and resolve the merged types from MainAssembly instead.
Unfortunately, as a result of this process the application still has to use two assemblies (MainAssembly and a new ApiAssembly). Additionally, a copy of ApiAssembly has to be created manually. This is prone to human error and would greatly benefit from proper automation.
Automatic implementation using SmartAssembly
All the steps presented before can be automated with SmartAssembly. As an additional benefit, a copy of ApiAssembly containing type forwards, will be automatically embedded within MainAssembly and properly resolved at runtime during plugin load.
This means that the MainAssembly will contain 2 copies of ApiAssembly inside:
- the original ApiAssembly will be merged and can be partially obfuscated and pruned (except for the exposed types).
- a "proxy" to ApiAssembly will be automatically created and embedded, forwarding all required types to the original (merged) assembly.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface | AttributeTargets.Enum)] public sealed class ForwardWhenMergedAttribute : Attribute { }
To chose which types should be exposed by the API (and automatically forwarded), a new ForwardWhenMerged
attribute has to be applied to each of them. The attribute can be applied by referencing SmartAssembly.Attributes.dll or simply by manually declaring it inside the ApiAssembly:
Each type inside ApiAssembly used by the plugins has to be marked with the attribute. For example:
[ForwardWhenMerged] public interface IExample { }
Types marked with ForwardWhenMerged
attribute will be automatically excluded from obfuscation and pruning, which allows them to be properly forwarded during merge.
If the assembly to be forwarded (e.g. ApiAssembly) was signed, the key path has to be provided first. In the GUI, Browse for a key can be used. For the command line, we added keyfilename
, keypassword
and keypasswordenv
arguments (described later in this document). If the selected key doesn't match the key used in the assembly being merged, a warning will be shown.
Remember to always make direct calls to the ApiAssembly. As it will be merged, you should never load it from a separate file using reflection methods such as Assembly.LoadFrom(...) to avoid any unexpected behaviour.
After building with SmartAssembly, the output directory will only contain MainAssembly. The original ApiAssembly was merged, and a dynamically created proxy with type forwards was embedded.
Problems with a new AppDomain? Not anymore.
When assemblies are embedded into SmartAssembly, they're automatically resolved at runtime, because SmartAssembly listens to AssemblyResolve
event for the main app domain.
If the plugins depend on assemblies that have been embedded into the main assembly, and the plugins are loaded into a separate app domain, it may be required to properly resolved them.
This version also of SmartAssembly introduces a new SmartAssembly.AssemblyResolverCore.dll
assembly. All you need is add a reference to this assembly, and attach a resolver for each app domain that requires access to assemblies that were embedded into the main assembly:
var domain = AppDomain.CreateDomain("SeparateAppDomain"); SmartAssembly.AssemblyResolverCore.Resolver.AttachResolver(domain);
Command line parameters
Command line arguments for referenced assemblies are defined in a similar manner as strong naming arguments for the main assembly (see Using the command line mode).
Signing the assembly with the specified strong name key file:
keyfilename=[path\to\file.snk | path\to\file.pfx | false]
Setting the password for the provided PFX key.
keypassword=[keypassword | false]
To avoid passing the password directly, you can use the /keypasswordenv
argument to read the password from the environment variable instead.
Set the environment variable name from which the PFX key password will be read:
keypasswordenv=[keypasswordenv | false]
Describing ApiAssembly using the command line:
/assembly="ApiAssembly";prune:true,merge:true,embed:true,keyfilename:PathToFile,keypassword:"KeyPassword"