I will show you ways to read string data from arbitrary .NET assembly in case when you don’t have:

  • Reflection, i.e when you operate from a trimmed or AOTed application
  • Full set of app dependencies. This is conceptionally wrong - why do we need to load everything, when we only need raw data from a library, also, I wanted a way to read data from damaged distributions.

Let’s imagine we have an assembly, where we have this code:

public static class UpdateServerInfo
{
    public static readonly Uri Uri = new("http://localhost:123");
}

The task is to read the http://localhost:123 from this field.

1. What’s wrong with Assembly.Load

Using Assembly.Load we can even read Uri object directly:

Assembly appAssembly = Assembly.Load(File.ReadAllBytes(dllPath));

// Assembly.GetType works this way https://stackoverflow.com/a/7889272
Type[] types = AssemblyUtils.GetTypes(appAssembly);

Type infoType = types.Single(t => t.Name == typeName);

return (Uri)infoType.GetField(fieldName)!.GetValue(null)!

It’s a simple and straightforward way, but that will not work for two reasons:

  1. Obviously, reflection and dynamic loading is not available, see AOT Limitations
  2. Loading also requires all dependent assemblies

I needed to make a AOTed updater utility, that can update or repair damaged distribution, so that was not an option at all.

2. MetadataLoadContext

When I was stuck with this problem, Michal Strehovský was very kind to help me. He offered two options: the first one is using MetadataLoadContext, which is included in base class library.

Unfortunately, the MetadataLoadContext way does not support reading arbitrary data from the assembly, only from attributes. That was not ideal, since I’d need to recompile my apps first to include the URL in the attributes, but it was worth trying anyway since it’s already included, so why not? It can be used like this:

// Get the array of runtime assemblies
// This will allow us to at least inspect types depending only on BCL
string[] runtimeAssemblies = Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll");

// Create the list of assembly paths consisting of runtime assemblies and the input file
List<string> paths = [..runtimeAssemblies, dllPath];

// Create MetadataLoadContext that can resolve assemblies using the created list
PathAssemblyResolver resolver = new(paths);

MetadataLoadContext mlc = new(resolver);

using (mlc)
{
    Assembly assembly = mlc.LoadFromAssemblyPath(dllPath);

    Console.WriteLine($"{assembly.GetName().Name} has following attributes: ");

    foreach (CustomAttributeData attr in assembly.GetCustomAttributesData())
        Console.WriteLine(attr.AttributeType);
}

In the beginning of the code you can see another caveat - we need to load runtime assemblies from somewhere, and these assemblies must be compatible with the assembly we try to read. That worked well for debugging, but for trimmed AOTed app the RuntimeEnvironment.GetRuntimeDirectory() returns a directory with only that .exe file and no libraries.

My target app is not trimmed, but MetadataLoadContext did not work there too - net8.0-windows self-contained package did not contain libraries it was looking for.

I think it’s a good way to go if you don’t deal with trimming and my other constraints, but I had to explore further.

3. Cecil

The second option from Michal worked like a charm. The simplicity and flexibility are just unmatched! The only downside is that you need to add the NuGet package Mono.Cecil to the dependencies.

This is the code that allowed me to read URL without any modifications to the main apps:

ModuleDefinition module = ModuleDefinition.ReadModule(dllPath);

// Type in the format Namespace.TypeName, you can also use GetTypes
TypeDefinition type = module.GetType("MyApp.UpdateServerInfo");
MethodBody body = type.GetConstructors().Single().Body;
string url = (string)body.Instructions[0].Operand;

It worked like a charm! And it does not need any other assemblies.

Probably, it looks like magic to you. There was a field with URI in the main app, but here I retrieved constructors and got some operand of the first instruction. That’s fine, follow me.

  1. We get the type, containing this string, using module.GetType, which is similar to how it’s done in .NET’s reflection, but Cecil’s TypeDefinition allows us to do more.

  2. Behind the syntactic sugar of in-place initialization, static fields are initialized in static constructor, which is created automatically for us:

    public static class UpdateServerInfo
    {
        public static readonly Uri Uri;
    
        static UpdateServerInfo()
        {
            Uri = new("http://localhost:123");
        }
    }
    

    That is why we need constructors.

  3. We know there’s only one constructor. Otherwise, we’d need to select the proper one.

  4. If we open the constructor’s body via ILSpy, we can see this underlying Intermediate Language instructions (shortened for brevity):

    .method static void .cctor () cil managed 
    {
        // Uri = new Uri("http://localhost:123");
        IL_0000: ldstr "http://localhost:123"
        IL_0005: newobj instance void System.Uri::.ctor(string)
        IL_000a: stsfld class System.Uri MyApp.UpdateServerInfo::Uri
        // }
        IL_000f: ret
    } // end of method UpdateServerInfo::.cctor
    
  5. This body consists of instructions like ldstr, newobj, etc.

  6. We are particularly interested in the first one, ldstr, that loads provided string value onto the stack.

  7. Next, we check, what Operand is provided to this command, and there’s our http://localhost:123! Perfect.

Bonus - Dealing with Trim Warnings

Unfortunately, Cecil itself is not trimmable, and if you try to dotnet publish -r win-x64 your AOT project, that uses Cecil, you’ll get this warning:

Mono.Cecil.dll : warning IL2104: Assembly 'Mono.Cecil' produced trim warnings.

That’s unfortunate, but not a deal-breaker - Cecil itself is quite compact, and we can instruct the compiler to don’t trim it at all. To do this, you need to add this:

<TrimmerRootAssembly Include="Mono.Cecil" />

to any ItemGroup, for example, near the reference itself:

<ItemGroup>
    <PackageReference Include="Mono.Cecil" Version="0.11.5" />
    <TrimmerRootAssembly Include="Mono.Cecil" />
</ItemGroup>

This will instruct the compiler to keep this assembly untrimmed. There are official docs about TrimmerRootAssembly, but I found this reply on topic more human-friendly. Also, here’s a video explanation of that property in the Deep .NET - Ahead Of Time episode.

If you run the dotnet publish -r win-x64 now, you will see, that the warning… is still there! 😄

The best way I found to get rid of this warning is by adding this into <PropertyGroup> section:

<NoWarn>$(NoWarn);IL2104</NoWarn> <!-- Suppress "Assembly ... produced trim warnings" -->

If you know the better way to deal with trim warnings, please share in the comments!