Сборка и развёртывание .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 — и важно понимать как исторический контекст, так и текущее положение дел.
Как это работает: криптографическая основа
Процесс строгого именования состоит из трёх шагов:
- Генерация пары ключей (RSA): с помощью
sn.exe -k MyKey.snkсоздаётся файл с приватным и публичным ключами. - Подпись сборки: при компиляции (через атрибут
[assembly: AssemblyKeyFile("MyKey.snk")]или параметр/keyfile) компилятор вычисляет хэш от содержимого сборки (без учёта PE-заголовка и самой подписи) и шифрует его приватным ключом. Полученная подпись (signature blob) записывается в PE-файл — в раздел.snили в специальное поле заголовка. - Формирование полного имени: в манифест вносятся имя сборки, версия, культура и токен публичного ключа (8-байтовый хеш от полного публичного ключа — для компактности).
При загрузке сборки CLR:
- извлекает публичный ключ (или токен, по которому находит полный ключ в GAC или в подписи самой сборки);
- вычисляет хэш от текущего содержимого сборки;
- расшифровывает подпись с помощью публичного ключа и сравнивает полученный хэш с вычисленным.
Если хэши не совпадают — сборка отклоняется с исключением FileLoadException («Подпись сборки недействительна»). Это защищает от случайной или злонамеренной модификации содержимого.
Зачем это нужно? Четыре цели
-
Уникальность имени в глобальном масштабе
Без строгого имени конфликт имён неизбежен: два разработчика могут создатьUtils.dll. Строгое имя включает публичный ключ — а вероятность совпадения ключей криптографически пренебрежима. Это критично для глобального кэша сборок (GAC): в GAC можно установить несколько версийUtils.dll, но каждая — с разнымPublicKeyToken, и CLR будет точно знать, какую из них загружать. -
Целостность содержимого
Подпись гарантирует, что сборка не была изменена после компиляции — ни повреждена при передаче, ни модифицирована вирусом, ни патчена «на лету». Это важно в сценариях, где сборки поставляются от ненадёжных источников (например, через интернет в плагинных системах). -
Side-by-side execution
Приложения могут ссылаться на разные версии одной сборки, и CLR загрузит обе — при условии, что они имеют разные строгие имена. Например,App1требуетLib, Version=1.0.0.0,App2—Lib, Version=2.0.0.0; если обе сборки подписаны и установлены в GAC, они сосуществуют без конфликтов. -
Доверие и политики безопасности
В .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-библиотеки, плагины для устаревших систем).
- Authenticode-подпись (через
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), используется стандартный алгоритм привязки:- Если сборка имеет строгое имя и установлена в GAC (.NET Framework) — загружается оттуда;
- Иначе — поиск в локальном каталоге приложения (рядом с EXE);
- Затем — в подкаталогах, соответствующих культуре (
ru-RU/,fr-FR/и т.д.) — для satellite-сборок; - При наличии — в путях, заданных через
<probing privatePath="...">вapp.config; - В .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.json— dependency 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.json— runtime 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. При создании экземпляра (обычно один на модуль или компонент) он:
- Определяет базовое имя ресурса — по умолчанию это пространство имён и имя файла
.resx(например,MyApp.Resources.Strings); - Извлекает текущую
CultureInfo.CurrentUICulture; - Строит цепочку fallback по правилам иерархии культур:
ru-RU→ru→neutral
es-MX→es→neutral
zh-Hans-CN→zh-Hans→zh→neutral
На каждом шаге 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
генерирует один исполняемый файл, содержащий:
- хост (
dotnetruntime 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).