.NET CLI — Зачем загружать все родительские сборки при загрузке сборки
В 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; }
}
}
Если скопировать код выше в 2 файла и разместить их в одной папке с названием CommonLib.bat и ConsoleApp.bat, и запустить файл ConsoleApp.bat, можно увидеть в консоле значение атрибута метода MethodWithEnumAttribute.
Следовательно, выполнив файл ConsoleApp.bat, в консоле мы увидим строку: Third. Теперь удалим значение Third из файла CommonLib.bat. Следует произвести запуск файла CommonLib.bat для обновления сборки и повторно запустить файл ConsoleApp.bat. Как и следовало ожидать, значение сохраняется в консольном приложении, поэтому вместо Third выведится цифра 3. Из чего следует, что константа осталась неизменной в сборке ConsoleApp, несмотря на то, что в реальности её больше нет в CommonLib.
Далее следует изменить тип enum с 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, которая хранит ссылки и типы значений пользовательских атрибутов. Кроме системых атрибутов, системные атрибуты встраиваются в разные места CLI, а для DllImportAttribute предусмотрена даже отдельная таблица в метаданных. (Для визуализации я использую приложение PE Image Info Plugin):
Детализация сигнатуры Value в таблице CustomAttribute
Разберём колонки таблицы для понимания дальнейшего процесса:
Колонка Parent содержит в себе ссылку на объект к которому применяется атрибут, как видно из примера выше, атрибуты могут применяться к сборке, методу или полю класса
Type — содержит ссылку на конструктор атрибута. Атрибут может быть объявлен внутри нашей сборки и ссылка будет на таблицу MethodDef или атрибут может быть объявлен в родительской сборке, тогда будет ссылка будет на таблицу MemberRef.
В колонке Value содержится сигнатура создания атрибута с константами, описанная в разделе II.23.3 стандарта ECMA-335. В данном случае — конструктор (FixedArgs) без параметров и с одним свойством Value (NamedArgs).
[Enum (Value = SharedEnum.Third)]
0×0001 — Первые 2 байта представляют из себя константу Prolog.
Затем следует массив аргументов для создания экземпляра объекта. Кол-во аргументов описано в таблицах MethodDef или MemberRef. (В нашем случае это MethodDef, ибо атрибут объявлен внутри нашей сборки, у нашего атрибута нет конструктора с аргументами и, поэтому, в массиве его нет и его размерность равняется нулю).
0×0001 — Следующие 2 байта информируют о количестве именованных свойств и их значений. В нашем случае это будет одно свойство Value со значением SharedEnum.Third.
0×54 — Значение идентифицирует тип свойства — Property (Тут может быть только 0×54 или 0×53 — Field).
0×55 — Это тип значения — Enum.
0×56 — Представляет из себя упакованное int (II.23.2) значение длины типа нашего свойства. (Упаковка работает по значению первого байта и может занимать от 1 до 4 байт)
0×43…0×6C — Строковое представление типа значения: «CommonLib.SharedEnum, CommonLib, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null»
0×05 — Представляет из себя упакованное int значение длинны названия свойства.
0×56–0×65 — Представляет из себя название свойства Value.
И последние 4 байта — представляют из себя само значение enum, которое было передано в качестве значения в свойство Value атрибута: SharedEnum.Third = 3.
В данной сигнатуре можно заметить, что размер всего свойства никак не определён.
Обычно для размера объекта используется одна из следующих техник:
Размер элемента. После Count, каждый Prop или Field начинается с размера всего поля. Для примера, как это сделано со строками выше, где каждая строка начинается с Length.
Символ остановки. Ключевой байт, который обозначает что элемент прочитан полностью и дальше следует следующий элемент. Для примера, строка в C++ заканчивается символом 0×00.
В отсутствии разделителя и заключается проблема (получается исключение описанное выше):
У нас нет возможности понять сколько занимает значение enum, а без этого мы не можем прочитать значение: Int64, Int32, Int16 или Byte? То есть, для чтения всей этой сигнатуры, CLR загружает сборку CommonLib.dll, находит в метаданных описание этого enum, получает его размерность и только после этого он имеет возможность корректно прочитать всю сигнатуру целиком.
Данная проблема касается именно значений типа enum, так как для всех остальных данных можно понять тип или прочитать их размерность без запроса родительской сборки.
Мои предположения почему сделано именно так
В качестве практической части, следует задать вопрос:
Почему же не указана размерность значения и почему сделано именно так?
Данная сигнатура мапится напрямую в память?
Это маловероятно, так как в сигнатуре хранится полный путь к типу enum’а, а не ссылка на таблицу TypeRef → AssemblyRef (В этой таблице хранятся ссылки на все родительские сборки). И так-же хранится название свойства, а не ссылка вида TypeDefOrRef coded index (II.24.2.6). При этом, длина константных строк хранится в упакованном виде, т.е. может занимать от одного до 4х байт. Логика маппинга заключается в том, что массив байт должен быть фиксированной длины, чтобы его можно было быстро положить в память, накрыв его «трафаретом» → структурой.
Возможно размерность исключена для экономии места?
Положительный ответ на этот вопрос является маловероятным, т.к. во первых, как описано выше, — строки дублируются вместо ссылок в другие таблицы, во вторых значение enum (0×55) можно было заменить на значение базового типа. В качестве примера можно рассмотреть CorElementType.ELEMENT_TYPE_I4 потому что, мы видели на примере выше, удаление значение Enum — не ломает приложение и вместо определённого значения Third было получено просто 3. В этом случае существует проблема — нет возможности через рефлексию понять, что это значение enum из внешней сборки, и поэтому теряется оригинальное приведение типов. А если заменить тип свойства Value в атрибуте с SharedEnum на Object, то сигнатура будет включать в себя не только тип переменной Enum (0×55), но ещё и Boxed (0×51).
internal class EnumAttribute : Attribute
{
public Object Value { get; set; }
}
[Enum (Value = SharedEnum.Third)]
Вот так-бы выглядела сигнатура нашего атрибута, если в свойство Value передать значение 3 (Int32 — CorElementType.ELEMENT_TYPE_I4 — 0×08):
[Enum (Value = 3)]
Итого
Моё мнение, архитекторы бинарного формата пожалев один байт для хранения типа enum — усложнили работу не только загрузчику CLR, но и добавив неопределённости при работе с константами.
А как Вы считаете, какой был вложен смысл в том, чтобы не добавлять размерность значения enum в CLI?