Перейти к основному содержимому

Сборка и развёртывание .NET-приложений

Разработчику Архитектору

Сборка в .NET

Сборка как основополагающая единица в .NET

Сборка (assembly) — это фундаментальная логическая и физическая единица в экосистеме .NET. Она представляет собой инкапсулированный, самодостаточный компонент, который объединяет исполняемый код, метаданные и ресурсы в единое целое, готовое к развертыванию, загрузке и выполнению в среде Common Language Runtime (CLR). В отличие от традиционных исполняемых файлов и библиотек, которые в первую очередь ориентированы на операционную систему, сборка служит прежде всего потребностям среды выполнения: она несёт в себе полную декларативную информацию о своём содержании, зависимостях, версии и безопасности — достаточно для того, чтобы CLR могла корректно загрузить её, разрешить зависимости, проверить соответствие политикам и начать исполнение без внешних подсказок из реестра, конфигурационных файлов или соглашений на уровне файловой системы.

С одной стороны, сборка — это файл или набор файлов: чаще всего это один переносимый исполняемый файл (Portable Executable, PE), расширенный метаданными в формате, понятном CLR; реже — набор из главного PE-файла и вспомогательных модулей (как это допускалось в .NET Framework, но не в современных версиях .NET 5+). С другой стороны, сборка — это логический контекст: все типы, объявленные в ней, существуют только в её границах; идентичность типа строится не только по имени и пространству имён, но и по имени сборки. Это означает, что два типа с одинаковым полным именем Namespace.Type, расположенные в разных сборках, рассматриваются CLR как принципиально различные сущности: они не совместимы по присваиванию, не могут быть неявно приведены друг к другу и требуют явного сопоставления при взаимодействии — даже если их сигнатуры абсолютно идентичны. Такая изоляция лежит в основе предсказуемости, безопасности и версионирования в .NET.

Важно подчеркнуть: сборка — это не просто контейнер для кода. Это единица разграничения ответственности в нескольких ключевых измерениях:

  • Развертывания: приложение может быть доставлено как набор сборок, каждая из которых может обновляться независимо (при соблюдении контрактов);
  • Активации: сборки загружаются по требованию, и CLR гарантирует, что они будут присутствовать в памяти только тогда, когда действительно востребованы;
  • Безопасности: политики разрешений (permissions) назначаются на уровне сборки — в частности, с помощью атрибутов, таких как AllowPartiallyTrustedCallersAttribute или через файлы политики (policy files) в устаревших сценариях .NET Framework;
  • Версионирования: сборка — минимальный атом, которому присваивается версия; система зависимостей строится на сравнении версий сборок, а не отдельных типов;
  • Глобального размещения и разделяемого использования: сборки могут быть установлены в центральные хранилища (например, в GAC — Global Assembly Cache в .NET Framework), откуда они становятся доступны всем приложениям на машине, при условии строгого именования.

С физической точки зрения, у сборки почти всегда есть один файл — головной (primary) модуль, имеющий расширение .exe или .dll. Файл .exe обозначает, что сборка содержит точку входа: метод Main, вызываемый при запуске приложения. Файл .dll — это библиотека, не предназначенная для прямого запуска, но пригодная к подключению как зависимость. Наличие точки входа — единственное отличие .exe от .dll на уровне содержимого: технически, оба файла имеют одинаковую структуру PE, и оба содержат метаданные, включая манифест. Можно скомпилировать приложение как .dll, а запускать его через dotnet MyLib.dll — и это будет работать корректно.

Модуль (module), который исторически упоминается в документации .NET Framework, — это более мелкая единица: файл, содержащий IL-код и метаданные, но не содержащий манифеста сборки. Несколько модулей могут быть объединены в одну сборку с помощью утилиты al.exe (Assembly Linker), при этом один из модулей становится главным и получает манифест. Однако эта практика почти не используется в современных проектах: средства сборки (MSBuild, Roslyn) генерируют одну сборку из одного или нескольких исходных файлов, но всегда упаковывают результат в единственный PE-файл. Многофайловые сборки остались в прошлом как нишевый механизм, и сегодня в .NET 5 и выше поддержка нескольких модулей в одной сборке отсутствует. Поэтому в дальнейшем, говоря о сборке, мы будем подразумевать один PE-файл — с возможным исключением редких сценариев, требующих явного упоминания устаревшей функциональности.

Стоит отдельно остановиться на том, что сборка — это не «упакованный DLL». Это исполняемая единица, оснащённая полной самоидентификацией. В .NET устранена проблема DLL hell (конфликты версий разделяемых библиотек), характерная для классических Windows-приложений, потому что каждая сборка содержит точную информацию о своей версии, и каждая зависимость указывает не просто на имя сборки, но и на требуемый диапазон версий — вплоть до конкретного патча. При этом сборка может быть размещена рядом с приложением (private assembly), и тогда она будет загружаться из локального каталога, игнорируя системные версии. Это обеспечивает изоляцию приложений и предсказуемость среды выполнения.

Даже в условиях, когда несколько приложений используют одну и ту же сборку, например, через глобальное размещение (GAC), CLR гарантирует, что каждое приложение загрузит ту версию, на которую оно ссылается — при условии, что сборка имеет строгое имя (strong name). Мы подробно рассмотрим этот механизм ниже, но уже сейчас важно понимать: сборка не «плавает» в системе, как DLL-файл старых времён; она — идентифицируемый, контролируемый, версионированный артефакт, чья идентичность неотделима от её содержимого и метаданных.

Манифест сборки

Манифест сборки — это встроенная, обязательная часть самой сборки, представленная в виде структурированных метаданных, записанных в PE-файл в специальном формате, понятном CLR. Его можно сравнить с заголовком книги, но гораздо более содержательным: он не только указывает название и автора, но и перечисляет все главы, ссылается на цитируемые источники, фиксирует издание и гарантирует подлинность через цифровую подпись.

Манифест содержит четыре группы ключевой информации, без которой сборка не может быть загружена и выполнена:

1. Идентификация сборки

Это основа всего: имя, версия, культура и публичный ключ (или его токен). В совокупности они формируют полное (полностью квалифицированное) имя сборки, которое CLR использует для однозначной идентификации.

  • Имя (Name) — это короткое, человекочитаемое обозначение, обычно совпадающее с именем выходного файла без расширения (например, System.Text.Json). Оно задаётся в проекте через свойство AssemblyName и используется в директивах using, ссылках на проекты и в манифестах зависимостей.
  • Версия (Version) — четырёхкомпонентное число вида Major.Minor.Build.Revision. Это строгий критерий разрешения зависимостей. При компиляции ссылка на сборку фиксирует её имя и точную версию или диапазон допустимых версий. При загрузке CLR проверяет соответствие — и если требуемая версия отсутствует, возникает исключение FileNotFoundException или FileLoadException. Версия сборки — это метаданные. Файл может называться MyLib.dll, но в манифесте содержать версию 2.1.0.0; CLR будет ориентироваться именно на метаданные.
  • Культура (Culture) — указывает, предназначена ли сборка для конкретной локали (например, ru-RU, fr-FR). Обычно это используется только для сборок ресурсов (.resources.dll), содержащих локализованные строки, изображения или другие данные. Основная сборка имеет нейтральную культуру (neutral).
  • Публичный ключ / токен (Public Key / Public Key Token) — часть механизма строгого именования. Если сборка подписана, в манифест записывается полный публичный ключ или его 8-байтовый хеш — токен. Это необходимо для обеспечения уникальности имени и проверки целостности.

Вместе эти четыре элемента образуют строгое имя (strong name), если подпись присутствует. Оно имеет форму:
Имя, Version=..., Culture=..., PublicKeyToken=...
— именно в таком виде сборка идентифицируется в GAC, в логах, в диагностике загрузчика.

2. Состав сборки

Если сборка состоит из нескольких файлов (многофайловая сборка — редкость в современном .NET, но возможна в рамках .NET Framework), манифест содержит таблицу файлов. В ней перечисляются все вспомогательные модули и ресурсы, входящие в сборку: их имена, хэши (обычно SHA1), атрибуты (например, ContainsMetadata, ContainsNoMetadata). Хэш критически важен: при загрузке CLR проверяет, что содержимое файла совпадает с зафиксированным в манифесте — это защита от подмены. В большинстве случаев, однако, сборка однофайловая, и таблица файлов минимальна (содержит только главный модуль).

3. Типы и экспорт

Манифест ссылается на таблицы типов, но не содержит их напрямую. Вместо этого он указывает, какие типы объявлены в этой сборке и какие из них экспортируются — то есть доступны для использования извне. Это не означает, что все публичные (public) типы автоматически экспортируются: экспортируемость — более тонкий признак, связанный с тем, что тип объявлен в сборке как часть её публичного API. Технически, при компиляции компилятор генерирует записи в таблице ExportedTypes, и именно по ней среда выполнения определяет, какие типы «видны» другим сборкам. Это позволяет, например, иметь public-класс внутри сборки, но не экспортировать его — тогда он будет доступен только из других модулей внутри той же сборки (если бы они существовали), но не извне.

4. Зависимости

Это, пожалуй, самая важная часть манифеста с точки зрения стабильности приложений. В разделе зависимостей перечисляются все сборки, от которых данная сборка непосредственно зависит — не транзитивно, а только прямые ссылки. Для каждой зависимости указывается:

  • полное имя (имя, версия, культура, публичный ключ/токен);
  • флаг retargetable (используется в сценариях перенацеливания, например, между .NET Framework и .NET Core);
  • уровень требуемой совместимости (в устаревших системах — через политики привязки в app.config, но сама ссылка в манифесте остаётся фиксированной).

Ключевой принцип: манифест фиксирует зависимости на момент компиляции. Это означает, что если вы скомпилировали App.exe, ссылающийся на Lib.dll версии 1.0.0.0, то при запуске CLR будет искать именно эту версию — даже если в системе есть Lib.dll версии 2.0.0.0. Так обеспечивается воспроизводимость: приложение ведёт себя одинаково на машине разработчика, в тестовой среде и в продакшене.

Однако реальность сложнее, и .NET предоставляет механизмы перенаправления сборок (binding redirects) — через файлы конфигурации или атрибуты в коде ([assembly: AssemblyVersion("2.0.0.0")] с политиками совместимости). Но важно понимать: это — исключения из правила, а не его основа. По умолчанию действует строгое соответствие.


Метаданные

Манифест — лишь верхушка айсберга. Под ним лежит богатый слой метаданных, который делает .NET по-настоящему самоописываемой платформой. Метаданные — это структурированная информация о всех элементах сборки: типах, методах, свойствах, полях, параметрах, атрибутах, интерфейсах, наследовании, обобщениях и даже исключениях, которые метод может выбросить.

Они хранятся в таблицах, организованных по категориям (например, TypeDef, MethodDef, Property, CustomAttribute). Каждая запись в таблице имеет уникальный токен (token) — 32-битное число, где старшие 8 бит указывают на тип таблицы, а младшие 24 — на индекс записи. Например, токен 0x06000001 относится к первой записи в таблице MethodDef. Именно эти токены используются в IL-коде для ссылок: когда метод вызывает другой метод, в инструкции call указывается токен — что обеспечивает компактность и устойчивость к переименованию.

Какие сведения содержат метаданные? Примеры:

  • Для типа: его имя, пространство имён, базовый тип, реализуемые интерфейсы, модификаторы доступа (public, internal), флаги (sealed, abstract), обобщённые параметры.
  • Для метода: имя, сигнатура (возвращаемый тип, типы параметров), модификаторы (static, virtual), кастомные атрибуты.
  • Для свойства: имена методов-аксессоров (get_, set_), тип значения.
  • Для атрибута: тип атрибута и сериализованное значение его параметров.

Эта информация позволяет:

  • Компилятору проверять корректность кода при сборке: доступны ли типы, совпадают ли сигнатуры, соблюдены ли ограничения обобщений.
  • CLR выполнять JIT-компиляцию: генерировать нативный код, опираясь на точные сигнатуры и расположение в памяти.
  • Системе безопасности применять проверки: например, LinkDemand или InheritanceDemand анализируют метаданные вызывающей сборки.
  • Рефлексии (System.Reflection) программно исследовать структуру сборки во время выполнения — без исходного кода, без документации, без внешних источников.
  • Инструментам разработки (IDE, анализаторам, сериализаторам) строить интеллектуальные функции: IntelliSense, навигацию по коду, генерацию прокси-классов (например, в WCF или Entity Framework).

Важно: метаданные — это не комментарии и не отладочная информация. Они обязательны и генерируются всегда, даже в релизных сборках без отладочных символов (.pdb). Отладочная информация дополняет метаданные, добавляя соответствие IL-инструкций строкам исходного кода, но не заменяет их.

Благодаря метаданным, сборка становится самоконтролируемой: она содержит всё, что нужно для её понимания, загрузки и выполнения. Это устраняет необходимость во внешних реестрах, описаниях IDL или схемах. Достаточно иметь сам файл — и среда выполнения, и инструменты, и другие сборки смогут с ним корректно взаимодействовать.


EXE и DLL в .NET

На первый взгляд, различие между .exe и .dll в .NET ничтожно: оба файла имеют одинаковую структуру PE, содержат IL-код, метаданные, манифест и ресурсы. Разница сводится к одному признаку — наличию точки входа. Однако последствия этого признака глубоки и затрагивают архитектуру загрузки, управление жизненным циклом и взаимодействие с хост-процессом.

Точка входа: не просто Main

В PE-файле существует поле AddressOfEntryPoint, указывающее на стартовый RVA (Relative Virtual Address). В сборке .NET это поле не указывает напрямую на IL-код — вместо этого оно указывает на заглушку (stub), встроенную в заголовок PE. Эта заглушка — небольшой фрагмент машинного кода, задача которого — передать управление среде выполнения.

  • В случае EXE-файла заглушка вызывает mscoree.dll!_CorExeMain (в .NET Framework) или, в современных версиях .NET (начиная с .NET Core 3.0), передаёт управление хосту dotnet.exe, который загружает среду выполнения, инициализирует домен по умолчанию и ищет метод с атрибутом [STAThread], [MTAThread] или просто static void Main(...), соответствующий сигнатуре точки входа. Этот метод становится корневым вызовом всего приложения.

  • В случае DLL-файла поле AddressOfEntryPoint равно нулю или указывает на заглушку, вызывающую mscoree.dll!_CorDllMain. Однако _CorDllMain никогда не вызывается автоматически при подключении DLL через LoadLibrary, как это происходит с нативными библиотеками. Вместо этого инициализация управляемой сборки происходит только тогда, когда CLR фактически загружает её как сборку — например, при первом обращении к её типу через new, typeof, Assembly.Load и т.п.

Это принципиально: загрузка DLL как нативного модуля ≠ загрузка сборки как управляемой единицы. Можно вызвать LoadLibrary("MyLib.dll") из нативного кода — и DLL будет отображена в память, но её IL-код не будет JIT-скомпилирован, статические конструкторы не выполнятся, манифест не проанализируется — пока не сработает обращение из управляемого контекста.

Жизненный цикл и изоляция

EXE-файл определяет границу приложения в терминах процесса ОС. Каждый запущенный EXE — отдельный процесс, со своей памятью, потоками и, в .NET Framework, своим доменом приложения по умолчанию (AppDomain). В .NET 5+ концепция AppDomain упразднена, и изоляция достигается на уровне процесса и сборок с помощью механизма AssemblyLoadContext.

DLL-файлы, напротив, могут быть загружены в несколько контекстов одновременно. Например, один и тот же Lib.dll может быть загружен в разные AssemblyLoadContext внутри одного процесса — и каждый экземпляр будет иметь собственные статические поля, отдельное состояние и даже разные версии зависимостей. Это позволяет реализовывать плагинные архитектуры, горячую перезагрузку модулей или изоляцию ненадёжного кода — без создания новых процессов.

Важно: даже если DLL лежит в том же каталоге, что и EXE, она не загружается автоматически. Загрузка происходит только по первому обращению к её типу или явному вызову Assembly.LoadFrom. Это свойство — отложенная загрузка (lazy loading) — лежит в основе эффективного использования памяти: редко используемые функции (например, экспорт в PDF или интеграция с CRM) могут быть вынесены в отдельные сборки и подгружаться только при необходимости.

Взаимодействие с нативным миром

EXE-файл может быть настроен как self-contained deployment (самодостаточная развёртка), включающая среду выполнения .NET целиком. В этом случае он становится полностью независимым от установленных в системе компонентов — и может запускаться как обычное нативное приложение.

DLL-файл, в свою очередь, может быть скомпилирован в нативную библиотеку с помощью NativeAOT или использоваться как COM-компонент (через ComVisibleAttribute и regasm.exe в .NET Framework). В таких сценариях .dll действительно ведёт себя как классическая нативная библиотека: её DllMain (если определён) вызывается при загрузке, и она может экспортировать функции через exports. Но это — уже гибридный режим, требующий явной настройки; по умолчанию, DLL в .NET — это управляемая сборка, а не нативный модуль.


Строгое именование (strong naming)

Строгое именование — это механизм, призванный обеспечить глобальную уникальность, защиту от подмены и возможность side-by-side execution (одновременного сосуществования нескольких версий одной сборки). Однако его роль и ценность изменились с переходом от .NET Framework к современным версиям .NET — и важно понимать как исторический контекст, так и текущее положение дел.

Как это работает: криптографическая основа

Процесс строгого именования состоит из трёх шагов:

  1. Генерация пары ключей (RSA): с помощью sn.exe -k MyKey.snk создаётся файл с приватным и публичным ключами.
  2. Подпись сборки: при компиляции (через атрибут [assembly: AssemblyKeyFile("MyKey.snk")] или параметр /keyfile) компилятор вычисляет хэш от содержимого сборки (без учёта PE-заголовка и самой подписи) и шифрует его приватным ключом. Полученная подпись (signature blob) записывается в PE-файл — в раздел .sn или в специальное поле заголовка.
  3. Формирование полного имени: в манифест вносятся имя сборки, версия, культура и токен публичного ключа (8-байтовый хеш от полного публичного ключа — для компактности).

При загрузке сборки CLR:

  • извлекает публичный ключ (или токен, по которому находит полный ключ в GAC или в подписи самой сборки);
  • вычисляет хэш от текущего содержимого сборки;
  • расшифровывает подпись с помощью публичного ключа и сравнивает полученный хэш с вычисленным.

Если хэши не совпадают — сборка отклоняется с исключением FileLoadException («Подпись сборки недействительна»). Это защищает от случайной или злонамеренной модификации содержимого.

Зачем это нужно? Четыре цели

  1. Уникальность имени в глобальном масштабе
    Без строгого имени конфликт имён неизбежен: два разработчика могут создать Utils.dll. Строгое имя включает публичный ключ — а вероятность совпадения ключей криптографически пренебрежима. Это критично для глобального кэша сборок (GAC): в GAC можно установить несколько версий Utils.dll, но каждая — с разным PublicKeyToken, и CLR будет точно знать, какую из них загружать.

  2. Целостность содержимого
    Подпись гарантирует, что сборка не была изменена после компиляции — ни повреждена при передаче, ни модифицирована вирусом, ни патчена «на лету». Это важно в сценариях, где сборки поставляются от ненадёжных источников (например, через интернет в плагинных системах).

  3. Side-by-side execution
    Приложения могут ссылаться на разные версии одной сборки, и CLR загрузит обе — при условии, что они имеют разные строгие имена. Например, App1 требует Lib, Version=1.0.0.0, App2Lib, Version=2.0.0.0; если обе сборки подписаны и установлены в GAC, они сосуществуют без конфликтов.

  4. Доверие и политики безопасности
    В .NET Framework существовали политики, позволяющие выдавать разрешения на основе PublicKeyToken (например, «разрешить полный доступ только сборкам, подписанным этим ключом»). Это использовалось в сценариях частичного доверия (sandboxing), например, для надстроек в Office или веб-частей SharePoint.

Уход от GAC и строгого именования

В .NET 5+ и выше ситуация изменилась радикально:

  • GAC отсутствует. Все сборки развёртываются локально — рядом с приложением или в общих папках через dotnet store. Side-by-side execution достигается через изоляцию зависимостей в AssemblyLoadContext или через framework-dependent deployments с явным указанием версий в runtimeconfig.json.
  • Политики частичного доверия упразднены. Безопасность теперь строится на уровне процесса, контейнеров и ОС — не на проверке подписей сборок.
  • NuGet заменил GAC как основной механизм распространения. Версионирование и разрешение зависимостей берёт на себя клиент NuGet и MSBuild — не загрузчик сборок.
  • Подпись сборок по-прежнему важна, но для других целей:
    • Authenticode-подпись (через signtool.exe) — для доверия со стороны ОС (SmartScreen, UAC);
    • Подпись контента (content signing) в NuGet-пакетах — для проверки подлинности издателя в репозиториях;
    • Строгая подпись (strong name signing) сохраняется для обратной совместимости и в сценариях, где сборка может быть использована в .NET Framework (например, COM-библиотеки, плагины для устаревших систем).

Microsoft рекомендует всегда подписывать сборки строгим именем, даже если они не идут в GAC — просто потому, что многие инструменты (например, ILDasm, dotnet-trace, анализаторы безопасности) ожидают наличие PublicKeyToken, и его отсутствие может вызывать предупреждения или ограничения. Но при этом подчёркивается: strong name ≠ security. Строгая подпись не защищает от атак, если приватный ключ украден — и не заменяет Authenticode для доверия пользователей.

Подпись без раскрытия приватного ключа

В командах, где приватный ключ хранится в защищённом хранилище (HSM), используется отложенная подпись (delay signing):

  • Во время разработки сборка компилируется с указанием только публичного ключа ([assembly: AssemblyDelaySign(true)], [assembly: AssemblyKeyFile("public.snk")]);
  • В PE-файле резервируется место под подпись, но она не генерируется;
  • На этапе релиза специализированный инструмент (например, sn.exe -R) подставляет реальную подпись, используя приватный ключ в безопасной среде.

Это позволяет разработчикам тестировать side-by-side, работать с GAC-совместимыми зависимостями — без доступа к секретному ключу.


Жизненный цикл загрузки сборки

Загрузка сборки в .NET — это не мгновенное отображение файла в память. Это многоступенчатый, управляемый средой выполнения процесс, включающий проверку подлинности, разрешение зависимостей, валидацию метаданных и подготовку к выполнению. Каждый этап может быть наблюдаем, перехвачен или переопределён — что делает его ключевым для понимания производительности, изоляции и отладки.

Этап 1. Инициация загрузки

Загрузка может быть явной или неявной:

  • Неявная — происходит при первом обращении к типу из сборки:

    var obj = new SomeTypeFromLib(); // → загрузка сборки, содержащей SomeTypeFromLib
    var t = typeof(SomeTypeFromLib); // → загрузка без создания экземпляра

    В этом случае компилятор генерирует IL-код, содержащий ссылку на тип через токен. При выполнении JIT-компилятор обнаруживает, что тип не загружен, и инициирует загрузку его сборки.

  • Явная — через программный вызов:

    Assembly.Load("LibName, Version=1.0.0.0, ...");
    Assembly.LoadFrom("path/to/Lib.dll");
    Assembly.LoadFile("absolute/path/to/Lib.dll");
    Assembly.Load(byte[] rawAssembly);

    Эти методы дают разработчику контроль над когда и как загружается сборка — что критично для плагинных систем, динамической загрузки модулей или изоляции.

В .NET 5+ и выше явная загрузка почти всегда осуществляется через контексты загрузки (AssemblyLoadContext) — класс, наследуемый от System.Runtime.Loader.AssemblyLoadContext. Он заменяет устаревшие домены приложений (AppDomain) и предоставляет гибкий механизм изоляции:

  • Каждый AssemblyLoadContext имеет собственное пространство имён сборок: две сборки с одинаковым строгим именем могут существовать в разных контекстах одновременно;
  • Он переопределяет метод LoadFromAssemblyPath, позволяя кастомизировать логику поиска и загрузки;
  • Он поддерживает сборку мусора сборок — через WeakReference на сам контекст и явный вызов Unload() (с ограничениями: сборка выгружается только если нет активных ссылок на её типы).

По умолчанию все сборки загружаются в AssemblyLoadContext.Default — глобальный, не выгружаемый контекст, соответствующий корневому домену в .NET Framework.

Этап 2. Поиск сборки (probing)

Как только запрос на загрузку поступил, CLR должна найти физический файл сборки. Порядок поиска зависит от типа запроса и конфигурации:

  • Для сборок, ссылка на которые зашита в манифесте (через AssemblyRef), используется стандартный алгоритм привязки:

    1. Если сборка имеет строгое имя и установлена в GAC (.NET Framework) — загружается оттуда;
    2. Иначе — поиск в локальном каталоге приложения (рядом с EXE);
    3. Затем — в подкаталогах, соответствующих культуре (ru-RU/, fr-FR/ и т.д.) — для satellite-сборок;
    4. При наличии — в путях, заданных через <probing privatePath="..."> в app.config;
    5. В .NET Core/.NET 5+ — используется deps.json и runtimeconfig.json, которые явно перечисляют расположение всех зависимостей.
  • Для сборок, загружаемых через LoadFrom или LoadFile, применяются другие правила:

    • LoadFrom(path) загружает сборку из указанного пути, но также инициирует привязку её зависимостей из того же каталога (т.н. load context of the path). Это помогает избежать «размазывания» зависимостей по разным папкам.
    • LoadFile(path), напротив, загружает только указанный файл — без разрешения зависимостей. Это может привести к FileNotFoundException, если зависимости не загружены явно. Используется редко, в диагностических или sandbox-сценариях.

Поиск не рекурсивен. Если сборка A ссылается на B, а B — на C, CLR не будет искать C в подкаталогах B; она ожидает, что все зависимости находятся в одном каталоге или описаны в deps.json.

Этап 3. Валидация и проверка

После нахождения файла начинается его анализ и проверка:

  • Проверяется, является ли файл корректным PE-файлом с управляемым кодом (наличие .text, .rsrc, .reloc, и особенно — .metadata);
  • Извлекается манифест и проверяется его целостность (включая хэш в многофайловых сборках);
  • Если сборка имеет строгое имя — проверяется подпись: вычисляется хэш, сравнивается с расшифрованной подписью;
  • Проверяется совместимость с текущей средой выполнения: целевая версия .NET, архитектура (x64/x86/arm64), наличие требуемых feature flags (например, System.Runtime.CompilerServices.IsSupportedOSPlatformAttribute).

На этом этапе могут возникнуть исключения:

  • BadImageFormatException — повреждённый PE, неуправляемая DLL, несовпадение разрядности;
  • FileLoadException — неверная подпись, конфликт версий, блокировка антивирусом;
  • FileNotFoundException — зависимость не найдена (даже если сама сборка — на месте).

Этап 4. Построение представления в памяти

Если проверки пройдены, сборка не копируется целиком в память. Вместо этого:

  • PE-файл отображается в адресное пространство процесса через memory-mapped I/O (CreateFileMapping/MapViewOfFile на Windows);
  • Метаданные парсятся и строится внутреннее представление — таблицы типов, методов, зависимостей — в виде структур CLR;
  • IL-код остаётся на диске: он будет JIT-скомпилирован по мере вызова методов;
  • Статические поля инициализируются только при первом обращении к типу (lazy static initialization).

Такой подход минимизирует потребление памяти: сборка «занимает место» только своими метаданными и загруженными JIT-методами — не всем IL-кодом сразу.

Этап 5. JIT-компиляция и готовность к выполнению

Когда вызывается метод из только что загруженной сборки, JIT-компилятор:

  • считывает IL-код метода из отображённого PE;
  • проверяет его на соответствие правилам проверки (verifiability), если включена безопасность;
  • генерирует нативный код для текущей архитектуры;
  • кэширует его в куче кода (code heap);
  • заменяет stub-вызова на прямой переход к нативному коду.

После этого метод выполняется как обычный машинный код — с полной производительностью, но под управлением GC и runtime-сервисов (стековая раскрутка, исключения, профилирование).


Привязка зависимостей

Механизм привязки (binding) — это способ, которым CLR определяет, какую именно версию зависимой сборки загрузить, когда их несколько доступно. Эта система эволюционировала от жёстко закодированных правил в .NET Framework к декларативной, инструментально управляемой модели в современном .NET.

В .NET Framework: политики в app.config

Файл MyApp.exe.config мог содержать секцию <runtime><assemblyBinding>, где задавались:

  • Перенаправления версий (<bindingRedirect oldVersion="1.0.0.0-2.0.0.0" newVersion="2.0.0.0"/>) — позволяли заменить запрос на старую версию на новую, совместимую;
  • Пробинг-пути (<probing privatePath="libs;plugins"/>) — расширение поиска в подкаталогах;
  • Пути к GAC-альтернативам (<codeBase version="1.0.0.0" href="file:///C:/Custom/Lib.dll"/>) — для локальных замен глобальных сборок.

Этот подход был гибким, но хрупким: конфигурация — вне контроля сборки, легко нарушалась при развёртке, и требовала ручного сопровождения.

В .NET Core / .NET 5+: deps.json и runtimeconfig.json

Современный .NET использует два автогенерируемых файла:

  • MyApp.deps.jsondependency file. Содержит полное дерево зависимостей: для каждой сборки — её имя, версия, хэш, путь относительно publish-каталога, и флаги (runtime, compile, resource). Пример:

    {
    "runtimeTarget": { "name": ".NETCoreApp,Version=v8.0" },
    "targets": {
    ".NETCoreApp,Version=v8.0": {
    "MyApp/1.0.0": {
    "dependencies": { "Newtonsoft.Json": "13.0.1" },
    "runtime": { "MyApp.dll": {} }
    },
    "Newtonsoft.Json/13.0.1": {
    "runtime": { "lib/netstandard2.0/Newtonsoft.Json.dll": { "assemblyVersion": "13.0.0.0" } }
    }
    }
    },
    "libraries": { ... }
    }

    Этот файл генерируется MSBuild на этапе публикации (dotnet publish) и используется хостом (dotnet.exe или self-contained EXE) для построения точной карты загрузки.

  • MyApp.runtimeconfig.jsonruntime configuration. Указывает требуемую версию среды выполнения, включает флаги (gcServer, tieredCompilation, readyToRun), и может содержать политики привязки:

    {
    "runtimeOptions": {
    "tfm": "net8.0",
    "framework": { "name": "Microsoft.NETCore.App", "version": "8.0.0" },
    "configProperties": {
    "System.Reflection.Metadata.MetadataUpdater.IsSupported": false
    }
    }
    }

Преимущество этой модели — детерминированность: развёртка всегда воспроизводима, потому что все пути и версии зафиксированы в JSON. Нет неожиданных подмен из GAC или реестра. При этом deps.json можно модифицировать вручную (например, для hot-swap), но это требует осторожности.

Неявные зависимости

  • В framework-dependent deployment (по умолчанию) приложение содержит только свои сборки, а зависимости среды выполнения (BCL — Base Class Library) берутся из глобально установленного shared framework (C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.0\). Привязка BCL-сборок управляется runtimeconfig.json.
  • В self-contained deployment (dotnet publish -r win-x64 --self-contained) все зависимости, включая BCL, копируются в папку publish. Привязка становится полностью локальной — и deps.json включает даже System.Runtime.dll.

Это позволяет достичь максимальной изоляции: приложение не зависит от того, какая версия .NET установлена на целевой машине.


Сборки ресурсов

В .NET локализация реализована на уровне сборок и среды выполнения — как встроенная, самодостаточная подсистема. Её основа — спутниковые сборки (satellite assemblies): отдельные DLL-файлы, содержащие только ресурсы (строки, изображения, аудио), предназначенные для конкретной культуры. Они организованы иерархически, и CLR автоматически выбирает наиболее подходящую, следуя правилам fallback (возврата к нейтральной культуре), если точное совпадение отсутствует.

Устройство спутниковых сборок

Спутниковая сборка — это обычная сборка .dll, но с рядом особенностей:

  • Её имя имеет вид ИмяОсновнойСборки.resources.dll. Например, если основное приложение — MyApp.exe, то ресурсы для русского языка будут в MyApp.resources.dll.
  • Она не содержит IL-кода, только ресурсы и минимальный манифест.
  • Её культура указана в манифесте как ru-RU, fr-FR, es-MX и т.д. — и только для неё эта культура не neutral.
  • Она размещается в подкаталоге, название которого совпадает с именем культуры:
    MyApp.exe  
    ├── ru-RU/
    │ └── MyApp.resources.dll
    ├── fr-FR/
    │ └── MyApp.resources.dll
    └── es/
    └── MyApp.resources.dll

Ключевой момент: спутниковая сборка не ссылается на основную — наоборот, основная сборка декларирует, что может использовать ресурсы из спутниковых, но не зависит от них. Это позволяет добавлять поддержку новых языков без перекомпиляции основного приложения: достаточно положить новую xx-XX/MyApp.resources.dll в каталог — и приложение сразу начнёт её использовать при смене CurrentUICulture.

ResourceManager и загрузка по требованию

В основе локализации лежит класс ResourceManager. При создании экземпляра (обычно один на модуль или компонент) он:

  1. Определяет базовое имя ресурса — по умолчанию это пространство имён и имя файла .resx (например, MyApp.Resources.Strings);
  2. Извлекает текущую CultureInfo.CurrentUICulture;
  3. Строит цепочку fallback по правилам иерархии культур:
    ru-RUruneutral
    es-MXesneutral
    zh-Hans-CNzh-Hanszhneutral

На каждом шаге ResourceManager пытается загрузить сборку ресурсов с соответствующей культурой:

  • Сначала ищет MyApp.resources.dll в подкаталоге ru-RU/;
  • Если не найдена — ищет в ru/;
  • Если и там нет — использует встроенные ресурсы в основной сборке (нейтральная культура), которые компилируются из Strings.resx (без суффикса) прямо в MyApp.exe.

Загрузка происходит отложенно и асинхронно по мере обращения к ключу:

var rm = new ResourceManager("MyApp.Resources.Strings", Assembly.GetExecutingAssembly());
string greeting = rm.GetString("Hello"); // → загружает ru-RU/MyApp.resources.dll, если CurrentUICulture == ru-RU

Если сборка ресурсов загружена, её содержимое кэшируется на уровне ResourceManager — повторные обращения не вызывают повторной загрузки.

Встроенные vs внешние ресурсы

Ресурсы могут храниться двумя способами:

  • Встроенные (embedded) — компилируются в основную сборку из .resx-файлов с помощью ResXResourceReader и ResourceWriter. Это — нейтральная культура, «запасной» вариант. Они всегда доступны, но увеличивают размер основной сборки.
  • Внешние (satellite) — компилируются в отдельные .resources.dll из .resx, помещённых в папки вида ru-RU/. Это позволяет:
    • уменьшить размер основного EXE/DLL;
    • обновлять переводы без пересборки приложения;
    • поставлять языковые пакеты отдельно (например, через MSI-патчи или AppX bundles).

Для генерации спутниковых сборок используется AL.exe (Assembly Linker) или, в современных проектах, MSBuild-задача GenerateSatelliteAssemblies, которая автоматически вызывается при наличии .resx-файлов в культура-специфичных папках.

Особенности в .NET 5+

В современных версиях .NET несколько изменилась реализация, но семантика осталась:

  • Поддержка спутниковых сборок сохранена полностью — для совместимости и гибкости;
  • Однако по умолчанию dotnet build не генерирует .resources.dll, если явно не указано <SatelliteResourceLanguages>ru;fr</SatelliteResourceLanguages> в .csproj;
  • Вместо этого часто используются ресурсы в виде .resx, встроенных в основную сборку, с переключением через ResourceManager и кастомные провайдеры;
  • Для веб-приложений (ASP.NET Core) предпочтение отдаётся IStringLocalizer<T>, который может брать строки из .resx, JSON, базы данных — но внутренне всё равно опирается на те же механизмы ResourceManager и fallback.

Важно: даже в ASP.NET Core, если вы кладёте Strings.ru-RU.resx в папку Resources, при публикации будет сгенерирована MyWebApp.resources.dll в ru-RU/, и IStringLocalizer будет использовать её автоматически — без дополнительной настройки.

Практические рекомендации

  • Используйте нейтральную культуру как fallback по умолчанию — например, английский. Это гарантирует, что приложение всегда покажет что-то осмысленное;
  • Не дублируйте ключи в .resx и в коде: строковые литералы — анти-паттерн в локализуемых приложениях;
  • Именуйте .resx-файлы строго по правилу: ИмяБезРасширения.язык-регион.resx (например, Messages.ru-RU.resx);
  • Проверяйте fallback вручную: временно удалите ru-RU/, оставив только ru/ — и убедитесь, что приложение корректно откатывается.

Однофайловые развёртки и trimming

Завершим главу рассмотрением современных тенденций — упаковки множества сборок в один исполняемый файл и отсечения неиспользуемого кода (trimming). Это не замена концепции сборки, а её эволюция в условиях контейнеризации, микросервисов и edge-устройств.

Self-contained и single-file deployment

Команда:

dotnet publish -r linux-x64 --self-contained true -p:PublishSingleFile=true

генерирует один исполняемый файл, содержащий:

  • хост (dotnet runtime core);
  • все управляемые сборки (включая BCL);
  • неуправляемые зависимости (например, libuv, icu);
  • ресурсы (включая спутниковые, если не исключены).

Внутри этот файл — не архив, а самораспаковывающийся образ:

  • При первом запуске он распаковывает содержимое во временный каталог (обычно %TEMP%\.net\MyApp\hash\);
  • Затем загружает среду выполнения из этого каталога — как обычное framework-dependent приложение;
  • Все сборки загружаются из памяти (через AssemblyLoadContext.LoadFromStream), а не с диска.

Манифесты, метаданные, подписи — всё сохраняется. CLR «не замечает», что сборки взяты не из файлов, а из потоков. Это позволяет:

  • упростить развёртку («скопируй один EXE — и работает»);
  • снизить риски подмены отдельных DLL;
  • избежать проблем с правами на запись в каталог приложения.

Trimming

При включении PublishTrimmed=true запускается статический анализ графа вызовов:

  • Стартовая точка — точка входа (Main);
  • Анализатор рекурсивно обходит все вызовы, атрибуты, рефлексивные обращения (если разрешено);
  • Всё, что не достижимо, помечается как лишнее;
  • На этапе линковки (ILLink) соответствующие типы, методы, поля удаляются из сборок до упаковки.

Результат: размер итогового файла может сократиться на 30–70%, особенно в консольных утилитах и микросервисах.

Однако trimming требует осторожности:

  • Рефлексия (Type.GetType("SomeType"), Activator.CreateInstance) «ломает» анализ — такие типы нужно явно защищать через [DynamicDependency] или <TrimmerRootAssembly>;
  • Сериализаторы (Json.NET, System.Text.Json) часто используют динамическое создание типов — требуется настройка;
  • COM-interop, P/Invoke, plugin-архитектуры почти несовместимы с trimming.

Microsoft рекомендует:

Используйте trimming в self-contained single-file развёртках только после тщательного тестирования. Включайте постепенно: сначала для приложения, потом — для зависимостей.


Динамические сборки

Динамическая сборка — это сборка, которая создаётся, компилируется и загружается во время выполнения, без участия дисковой файловой системы (если только её не сохраняют явно). Это мощный механизм, лежащий в основе:

  • генераторов прокси (например, Castle DynamicProxy, используемый в Moq и Entity Framework);
  • сериализаторов с динамической генерацией методов чтения/записи (System.Text.Json, protobuf-net);
  • компиляторов выражений (Expression.Compile);
  • плагинных систем с JIT-скриптингом (Roslyn Scripting API).

История API

В .NET Framework динамические сборки создавались через AppDomain.DefineDynamicAssembly, что жёстко привязывало их к домену приложения. В .NET Core, где AppDomain отсутствует, появился универсальный API на основе AssemblyBuilder и ModuleBuilder:

var assemblyName = new AssemblyName("DynamicLib");
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");

Здесь AssemblyBuilderAccess.Run означает: сборка будет существовать только в памяти и готова к немедленному выполнению. Другие варианты:

  • RunAndSave — разрешает последующее сохранение на диск через assemblyBuilder.Save("DynamicLib.dll");
  • Save — только для сохранения, выполнение невозможно.

Генерация типов и методов

После создания модуля можно определять типы, методы, поля — пошагово, с помощью TypeBuilder, MethodBuilder, ILGenerator:

var typeBuilder = moduleBuilder.DefineType("Calculator", TypeAttributes.Public);
var methodBuilder = typeBuilder.DefineMethod(
"Add",
MethodAttributes.Public | MethodAttributes.Static,
typeof(int),
new[] { typeof(int), typeof(int) }
);

var il = methodBuilder.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // загрузить первый аргумент
il.Emit(OpCodes.Ldarg_1); // загрузить второй аргумент
il.Emit(OpCodes.Add); // сложить
il.Emit(OpCodes.Ret); // вернуть результат

var calcType = typeBuilder.CreateType(); // → System.Type
var addMethod = calcType.GetMethod("Add");
int result = (int)addMethod.Invoke(null, new object[] { 2, 3 }); // → 5

Этот код — прямая работа с IL-инструкциями. ILGenerator обеспечивает типобезопасную, проверяемую генерацию, недоступную при ручной работе с байткодом.

Контекст выполнения и ограничения

Динамические сборки:

  • Загружаются в текущий AssemblyLoadContext — обычно Default;
  • Не могут быть выгружены отдельно от контекста (в отличие от статических сборок в кастомном AssemblyLoadContext);
  • Не имеют манифеста в классическом понимании — но содержат минимальный, достаточный для CLR (имя, версия по умолчанию 0.0.0.0);
  • Не подписываются строгим именем (нет доступа к приватному ключу во время выполнения);
  • Не участвуют в привязке зависимостей — все ссылки на типы должны быть разрешены явно через Type или сборки, уже загруженные в процесс.

В .NET 7+ появился RuntimeFeature.IsDynamicCodeSupported, позволяющий проверить, разрешена ли динамическая генерация кода (например, в средах с жёсткими ограничениями, вроде iOS или некоторых конфигураций AOT).

Рекомендации и антипаттерны

  • Не используйте динамические сборки для «быстрой» замены рефлексииActivator.CreateInstance или Delegate.CreateDelegate часто быстрее и безопаснее.
  • Кэшируйте сгенерированные типы — повторная генерация одного и того же типа приводит к утечке памяти (каждый вызов CreateType() даёт новый, несборирируемый тип).
  • Избегайте генерации в горячем пути — JIT-компиляция динамического метода происходит при первом вызове, и это может вызвать паузу.
  • Предпочтите Source Generators, если код может быть сгенерирован на этапе сборки — это даёт лучшую производительность, отладку и совместимость с trimming/AOT.

Анализ метаданных без загрузки: MetadataLoadContext

Иногда требуется прочитать структуру сборки, не выполняя её кода и не затрагивая состояние приложения. Например:

  • статический анализатор кода;
  • инструмент миграции с .NET Framework на .NET 5+;
  • генератор документации (как в вашем проекте «Вселенная IT»);
  • проверка совместимости API перед развёрткой.

Раньше для этого использовался Assembly.ReflectionOnlyLoad — но он:

  • загружал сборку в отдельное «рефлексивное» пространство;
  • всё равно требовал наличия всех зависимостей;
  • был удалён в .NET Core.

Его замена — MetadataLoadContext, появившийся в .NET Core 3.0 и доступный в .NET Standard 2.1.

Как это работает

MetadataLoadContext — это изолированный, read-only загрузчик метаданных. Он:

  • читает PE-файл напрямую (без загрузки в CLR);
  • парсит только метаданные — IL-код игнорируется;
  • не выполняет статические конструкторы, не JIT-компилирует, не проверяет подписи;
  • разрешает зависимости через пользовательский резолвер — вы сами задаёте, откуда брать System.Runtime.dll, mscorlib.dll и т.д.

Пример:

var paths = new[] { "MyLib.dll", "System.Runtime.dll", "System.Collections.dll" };
using var mlc = new MetadataLoadContext(new PathAssemblyResolver(paths));

Assembly lib = mlc.LoadFromAssemblyPath("MyLib.dll");
foreach (Type t in lib.GetTypes())
{
Console.WriteLine($"Type: {t.FullName}");
foreach (MethodInfo m in t.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
Console.WriteLine($" Method: {m.Name} -> {m.ReturnType}");
}
}
// сборка не загружена в Default-контекст — приложение не изменилось

Преимущества

  • Полная изоляция: анализ одной сборки не влияет на выполнение основного приложения;
  • Поддержка перекрёстных платформ: можно анализировать сборки .NET Framework на Linux, так как метаданные — платформонезависимы;
  • Безопасность: даже вредоносная сборка не может выполнить код при анализе;
  • Совместимость с trimming/AOT: не требует наличия JIT или полной среды выполнения.

Практическое применение

  • Генерация API-документации: извлечение сигнатур, комментариев XML (если включены), атрибутов;
  • Проверка breaking changes: сравнение MetadataReference двух версий сборки через System.Reflection.Metadata;
  • Миграция на новую версию .NET: поиск устаревших API ([Obsolete]), отсутствующих типов;
  • Интеграция с Docusaurus: автоматическое извлечение структуры из сборок для построения навигации по разделу «Языки → C# → Библиотеки».

Для работы с низкоуровневыми метаданными (без System.Reflection) рекомендуется использовать System.Reflection.Metadata — библиотеку, предоставляющую прямой, эффективный доступ к таблицам метаданных (в том числе к custom attributes, generic instantiations, security declarations).


Сборки в контексте AOT

Завершаем главу рассмотрением радикального сценария — когда сборка перестаёт быть управляемым артефактом и превращается в нативный исполняемый файл.

Что такое NativeAOT

NativeAOT — это компилятор, входящий в состав .NET 7+ (ранее — отдельный проект corert), который:

  • берёт управляемые сборки (IL + метаданные);
  • выполняет статическую компиляцию всего reachable-кода в машинный код (x64, ARM64);
  • генерирует standalone-исполняемый файл без зависимости от dotnet-хоста;
  • встраивает GC (на основе CoreRT), стековую раскрутку, исключения как нативные SEH-обработчики.

Что происходит с манифестом и метаданными

  • Метаданные частично сохраняются — только то, что необходимо для рефлексии, сериализации, атрибутов. Если trimming включён, неиспользуемые метаданные удаляются.
  • Манифест как отдельная структура исчезает — но его содержимое (имя, версия, зависимости) используется на этапе компиляции для:
    • разрешения ссылок;
    • генерации Assembly-объектов через typeof(object).Assembly;
    • работы Assembly.GetExecutingAssembly().
  • Строгая подпись не поддерживается — вместо неё используется Authenticode (подпись сертификатом через signtool), так как проверка strong name требует динамической загрузки и хэширования, чего нет в AOT.

Ограничения и компромиссы

  • Рефлексия ограничена: Type.GetType("SomeType") работает только если тип «корневой» (помечен [DynamicDependency] или используется в коде);
  • Плагины невозможны через Assembly.LoadFrom — но можно использовать предопределённые интерфейсы и NativeCallable для экспорта функций;
  • Размер увеличивается — за счёт встраивания GC, JIT-подобных таблиц исключений, но уменьшается за счёт отсутствия IL и метаданных;
  • Запуск мгновенный — нет JIT-пауз, нет загрузки сборок: всё — готовый нативный код.

Где это применяется

  • CLI-утилиты с требованиями к скорости старта (менее 10 мс);
  • Системы с ограниченной памятью (IoT, embedded);
  • Конфиденциальные приложения, где запрещено JIT-выполнение (финансы, госсектор);
  • Кроссплатформенные бинарники без зависимостей (например, утилиты для CI/CD).