Статьи

CLI - Зачем загружать все родительские сборки при загрузке сборки

18 декабря 2023 г.

В CLR есть особенность, что при загрузки сборки через Assembly.Load или через Assembly.ReflectionOnlyLoad, загружаются все сборки по мере запроса. В отличии от констант и их типов, они заранее копируются в дочернюю сборку и больше не зависят от родительской сборки. Но в определённых случаях типы констант не копируются в дочернюю сборку и их изменение может сломать работу дочерней сборки, несмотря на то, что тип константы не должен этого делать. Эта статья Вам поможет разобраться в каких случаях это может произойти.

При компиляции любых сборок в .NET все значения констант копируются в дочернюю сборку и при попытке, без компиляции, подменить родительскую сборку вручную, приведёт к тому что константы в дочерней сборке - не изменятся и CLR будет использовать значения, которые были перенесены в дочернюю сборку.

Однако, изменение типа константы в родительской сборки и подмена её без компиляции в дочернюю сборку - может привести к CLR ошибке при определённых условиях:

Unhandled Exception: System.Reflection.CustomAttributeFormatException: Binary format of the specified custom attribute was invalid.
   at System.Reflection.CustomAttribute.GetCustomAttributes(RuntimeModule decoratedModule, Int32 decoratedMetadataToken, Int32 pcaCount, RuntimeType attributeFilterType, Boolean mustBeInheritable, IList derivedAttributes, Boolean isDecoratedTargetSecurityTransparent)
   at System.Reflection.CustomAttribute.GetCustomAttributes(RuntimeMethodInfo method, RuntimeType caType, Boolean inherit)
   at EnumTestApp.Program.Main(String[] args)

Следует разобраться, что происходит в CLI и почему смена типа константы может привести к таким последствиям: Для эксперимента использовано решение из 2х проектов, с применением .NET Framework (но можно собрать аналогичное приложение на последних версиях .NET). Родительская (Common) сборка состоит из одного файла с enum размерностью в int:

/*
@echo off && cls
set WinDirNet=%WinDir%\Microsoft.NET\Framework
IF EXIST "%WinDirNet%\v2.0.50727\csc.exe" set csc="%WinDirNet%\v2.0.50727\csc.exe"
IF EXIST "%WinDirNet%\v3.5\csc.exe" set csc="%WinDirNet%\v3.5\csc.exe"
IF EXIST "%WinDirNet%\v4.0.30319\csc.exe" set csc="%WinDirNet%\v4.0.30319\csc.exe"
%csc% /nologo /target:library  /out:"CommonLib.dll" %0
goto :eof
*/
using System;


namespace CommonLib
{
    public enum SharedEnum : int
    {
   	 Undefined = 0,
   	 First = 1,
   	 Second = 2,
   	 Third = 3,
    }
}

Дочернее консольное приложение будет содержать в себе атрибут и тестовый код:

/*
@echo off && cls
IF EXIST "%~0.exe" (
"%~0.exe"
exit
)
call CommonLib.bat
set WinDirNet=%WinDir%\Microsoft.NET\Framework
IF EXIST "%WinDirNet%\v2.0.50727\csc.exe" set csc="%WinDirNet%\v2.0.50727\csc.exe"
IF EXIST "%WinDirNet%\v3.5\csc.exe" set csc="%WinDirNet%\v3.5\csc.exe"
IF EXIST "%WinDirNet%\v4.0.30319\csc.exe" set csc="%WinDirNet%\v4.0.30319\csc.exe"
%csc% /nologo /reference:CommonLib.dll  /out:"%~0.exe" %0
"%~0.exe"
PAUSE
exit
*/
using System;
using System.Reflection;
using CommonLib;

namespace EnumTestApp
{
    internal class Program
    {
   	 static void Main(string[] args)
   	 {
   		 EnumAttribute attr = (EnumAttribute)typeof(Program)
   			 .GetMethod("MethodWithEnumAttribute", BindingFlags.Static | BindingFlags.NonPublic)
   			 .GetCustomAttributes(typeof(EnumAttribute), false)[0];

   		 Console.WriteLine(attr.Value.ToString());
   	 }

   	 [Enum(Value = SharedEnum.Third)]
   	 static void MethodWithEnumAttribute() { }
    }

    internal class EnumAttribute : Attribute
    {
   	 public SharedEnum Value { get; set; }
    }
}

Консольное приложение при запуске читает значение атрибута метода MethodWithEnumAttribute и выводит его в консоль.

При выполнении файла ConsoleApp.bat, то в командной строке мы увидим строку: Third. Теперь удалим значение Third из файла CommonLib.bat. Следует произвести запуск файла CommonLib.bat для обновления сборки и повторный запуск файла ConsoleApp.bat. Как и следовало ожидать, значение сохраняется в консольном приложении, поэтому вместо Third выводится цифра 3. Из чего следует, что константа осталась неизменной в сборке ConsoleApp.bat, несмотря на то, что в реальности её больше нет в CommonLib.

Далее следует изменить тип emum с int на byte в CommonLib.bat:

public enum SharedEnum : byte //int

После чего сделаем перекомпиляцию CommonLib.bat и снова запустим консольное приложение: ConsoleApp.bat
В результате и происходит вышеописанная ошибка:

System.Reflection.CustomAttributeFormatException: Binary format of the specified custom attribute was invalid

Как устроено хранение атрибутов в CLI

Чтобы понять что случилось, потребуется углубиться в дебри стандарта ECMA-335 на котором и основан CLI. Метаданные хранятся в 44 таблицах в числе которых есть таблица CustomAttribute (Для визуализации я использую приложение PE Image Info Plugin):

В поле Value представляет из себя сигнатуру (Аргументы и значения экземпляра атрибута) описанную в разделе II.23.3 стандарта ECMA-335

0x0001 — Первые 2 байта представляют из себя константу Prolog.
Затем следует массив аргументов для создания экземпляра объекта. Кол-во аргументов описано в таблицах MethodDef или MemberRef. (В нашем случае это MethodDef, ибо атрибут объявлен внутри нашей сборки, у нашего атрибута нет конструктора с аргументами и, поэтому, в массиве его нет и размерность его равняется нулю). 0x0001 — Следующие 2 байта информируют о количестве именованных свойств и их значений. В нашем случае это будет одно свойство Value со значением SharedEnum.Third.
0x54 — Значение идентифицирует тип объекта — Property (Тут может быть только 0x54 или 0x53 - Field).
0x55 — Это тип значения - Enum.
0x56 — Представляет из себя упакованное int (II.23.2) значение длины типа нашего свойства.
0x43…0x6C — Строковое представление типа нашего свойства: “CommonLib.SharedEnum, CommonLib, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null”
0x05 — Представляет из себя упакованное int значение длинны названия нашего свойства.
0x56-0x65 — Представляет из себя название свойства Value.
И последние 4 байта представляют из себя само значение enum, которое мы передали в качестве значения в свойство Value атрибута: SharedEnum.Third = 3.

В данной сигнатуре можно заметить, что размер всего свойства никак не определён. Обычно для размера объекта используется одна из следующих техник:

  1. Размер элемента. После Count, каждый Prop или Field начинается с размера всего поля. Для примера, как это сделано со строками в примере, где каждая строка начинается с Length.
  2. Символ остановки. Ключевой байт, который обозначает что элемент прочитан полностью и дальше следует следующий элемент. Для примера, строка в C++ заканчивается символом 0x00.

В отсутствии разделителя и заключается проблема (получается исключение описанное выше):
У нас нет возможности понять сколько занимает значение Enum, а без этого мы не можем прочитать значение: Int64, Int32, Int16 или Byte?. То есть, для чтения всей этой сигнатуры, CLR загружает сборку CommonLib.dll, находит в метаданных описание этого enum, получает его размерность и только после этого он имеет возможность корректно прочитать всю сигнатуру целиком.
Данная проблема касается именно значений типа enum, так как для всех остальных данных можно понять тип или прочитать их размерность.

Мои предположения почему сделано именно так

В качестве практической части,следует задать вопрос почему же не указана размерность значения и какие варианты являются ошибочными и почему сделано именно так:

Данная сигнатура мапится напрямую в память?

Это маловероятно, так как мы имеем полный путь к типу, а не ссылку на таблицу AssemblyRef (В этой таблице хранятся ссылки на все зависимые сборки). И так-же хранится название свойства, а не ссылка вида TypeDefOrRef coded index (II.24.2.6). При этом, длина константных строк хранится в упакованном виде, т.е. может занимать от одного до 4х байт.
Логика маппинга заключается в том, что массив байт должен быть фиксированной длины, чтобы его можно было быстро положить в память, накрыв его “трафаретом” → структурой.

Возможно размерность исключена для экономии места?

Положительный ответ на этот вопрос является маловероятным, т.к. во первых, как описано выше, — строки дублируются вместо ссылок в другие таблицы, во вторых значение Enum (0x55) можно было заменить на значение базового типа,в качестве примера можно рассмотреть CorElementType.ELEMENT_TYPE_I4 потому что, мы видели на примере выше, удаление значение Enum — не ломает приложение и вместо определённого значения Third было получено просто 3.
В этом случае существует проблема — нет возможности через рефлексию понять, что это значение enum из внешней сборки, и поэтому теряется оригинальное приведение типов.
А если заменить тип свойства Value в атрибуте с SharedEnum на Object, то сигнатура будет включать в себя не только тип переменной Enum (0x55), но ещё и Boxed (0x51).

internal class EnumAttribute : Attribute
{
    public Object Value { get; set; }
}

Вот так-бы выглядела сигнатура нашего атрибута, если в свойство Value передать значение 3 (Int32 - CorElementType.ELEMENT_TYPE_I4 - 0x08):

[Enum(Value = 3)]

Итого

Моё мнение, архитекторы бинарного формата пожалев один байт для хранения типа enum — усложнили работу не только загрузчику CLR, но и добавив неопределённости при работе с константами.

А как Вы считаете, какой был вложен смысл в том, чтобы не добавлять размерность значения enum в CLI?